jalan
This commit is contained in:
parent
0d339a35e2
commit
4253483f44
@ -164,7 +164,7 @@ Add this comment at the top of every new file:
|
|||||||
* Created by: Chandika Nurdiansyah (chandika@skatsa.com)
|
* Created by: Chandika Nurdiansyah (chandika@skatsa.com)
|
||||||
* Date: [current date]
|
* Date: [current date]
|
||||||
* Purpose: [brief description of file purpose]
|
* Purpose: [brief description of file purpose]
|
||||||
* Part of: SDI Super App for PT Skatsa Data Integra
|
* Part of: PT Skatsa Data Integra
|
||||||
*/
|
*/
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -175,7 +175,7 @@ For modified files, add this comment above your changes:
|
|||||||
* Modified by: Chandika Nurdiansyah (chandika@skatsa.com)
|
* Modified by: Chandika Nurdiansyah (chandika@skatsa.com)
|
||||||
* Date: [current date]
|
* Date: [current date]
|
||||||
* Changes: [brief description of changes]
|
* Changes: [brief description of changes]
|
||||||
* Part of: SDI Super App for PT Skatsa Data Integra
|
* Part of: PT Skatsa Data Integra
|
||||||
*/
|
*/
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -209,7 +209,7 @@ For modified files, add this comment above your changes:
|
|||||||
- **Responsive Design**: Mobile-first approach
|
- **Responsive Design**: Mobile-first approach
|
||||||
- **Error Handling**: Comprehensive error messages and recovery
|
- **Error Handling**: Comprehensive error messages and recovery
|
||||||
- **Loading States**: Smooth transitions and visual feedback
|
- **Loading States**: Smooth transitions and visual feedback
|
||||||
- **Component Reuse**: Maximize use of existing Common components (AppTable, AppForm, etc.) before creating custom components
|
- **Component Reuse**: Maximize use of existing Common components before creating custom components
|
||||||
- **Component Index Files**: ALWAYS include `index.ts` file in `/components/` and all subdirectories to export components for clean imports
|
- **Component Index Files**: ALWAYS include `index.ts` file in `/components/` and all subdirectories to export components for clean imports
|
||||||
- **Mandatory Component Usage**:
|
- **Mandatory Component Usage**:
|
||||||
- ALL edit pages MUST use AppForm component
|
- ALL edit pages MUST use AppForm component
|
||||||
@ -431,7 +431,6 @@ export { default as ActionButton } from "./index";
|
|||||||
- User management, roles, authorization, app management
|
- User management, roles, authorization, app management
|
||||||
- Requires admin-level permissions
|
- Requires admin-level permissions
|
||||||
- **Business Applications**: `/app/(app)` - Business operations
|
- **Business Applications**: `/app/(app)` - Business operations
|
||||||
|
|
||||||
- Finance & Accounting, future business modules
|
- Finance & Accounting, future business modules
|
||||||
- Requires role-based permissions per module
|
- Requires role-based permissions per module
|
||||||
|
|
||||||
|
|||||||
110
README.md
110
README.md
@ -1,36 +1,104 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
|
||||||
## Getting Started
|
Platform pembelajaran tari online yang menghubungkan pendidik, calon pendidik, dan masyarakat umum untuk melestarikan dan mempelajari tari tradisional Indonesia.
|
||||||
|
|
||||||
First, run the development server:
|
## 🌟 Fitur Utama
|
||||||
|
|
||||||
|
### 🎭 Manajemen Konten Pembelajaran
|
||||||
|
- **Video Pembelajaran**: Upload dan tonton video tutorial tari (mendukung YouTube dan file lokal)
|
||||||
|
- **Materi Teori**: Akses materi sejarah, filosofi, dan kostum tari
|
||||||
|
- **Materi Praktik**: Panduan langkah demi langkah gerakan tari
|
||||||
|
- **Template Makalah**: Download template tugas dan panduan observasi
|
||||||
|
|
||||||
|
### 🏫 Manajemen Kelas
|
||||||
|
- **Sistem Kelas**: Pendidik dapat membuat kelas dan mengelola siswa
|
||||||
|
- **Jadwal & Pengumuman**: Informasi terupdate mengenai jadwal latihan
|
||||||
|
- **Penugasan**: Sistem pemberian dan pengumpulan tugas terintegrasi
|
||||||
|
|
||||||
|
### 💬 Kolaborasi & Komunitas
|
||||||
|
- **Forum Diskusi**: Diskusi umum dan spesifik per kelas
|
||||||
|
- **Komentar**: Interaksi pada video dan postingan forum
|
||||||
|
- **Peran Pengguna**: Sistem 4 peran (Administrator, Pendidik, Calon Pendidik, Umum)
|
||||||
|
|
||||||
|
## 🚀 Teknologi
|
||||||
|
|
||||||
|
- **Framework**: Next.js 16 (App Router)
|
||||||
|
- **Bahasa**: TypeScript
|
||||||
|
- **Database**: PostgreSQL dengan Prisma ORM
|
||||||
|
- **Auth**: Better Auth
|
||||||
|
- **Styling**: Tailwind CSS
|
||||||
|
- **UI Components**: Custom components (Glassmorphism design)
|
||||||
|
|
||||||
|
## 🛠️ Instalasi & Menjalankan Project
|
||||||
|
|
||||||
|
1. **Clone repository**
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/kreati-vortex.git
|
||||||
|
cd kreati-vortex
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install dependencies**
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Setup Database**
|
||||||
|
Pastikan PostgreSQL sudah berjalan, lalu konfigurasi `.env`:
|
||||||
|
```env
|
||||||
|
DATABASE_URL="postgresql://user:password@localhost:5432/kreativortex?schema=public"
|
||||||
|
```
|
||||||
|
|
||||||
|
Jalankan migrasi database:
|
||||||
|
```bash
|
||||||
|
bun prisma db push
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Jalankan Development Server**
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
bun dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Buka [http://localhost:3000](http://localhost:3000) di browser Anda.
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
## 📂 Struktur Project
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
```
|
||||||
|
app/
|
||||||
|
├── (app)/ # Halaman aplikasi (protected)
|
||||||
|
│ └── dashboard/ # Dashboard utama
|
||||||
|
│ ├── assignments/ # Manajemen tugas
|
||||||
|
│ ├── classes/ # Manajemen kelas
|
||||||
|
│ ├── forum/ # Forum diskusi
|
||||||
|
│ ├── videos/ # Manajemen video
|
||||||
|
│ ├── teori/ # Materi teori
|
||||||
|
│ ├── praktik/ # Materi praktik
|
||||||
|
│ └── template-makalah/ # Download template
|
||||||
|
├── api/ # API Endpoints
|
||||||
|
│ ├── auth/ # Autentikasi
|
||||||
|
│ ├── videos/ # CRUD Video
|
||||||
|
│ ├── forums/ # CRUD Forum
|
||||||
|
│ ├── classes/ # CRUD Kelas
|
||||||
|
│ └── assignments/ # CRUD Tugas
|
||||||
|
└── auth/ # Halaman autentikasi (public)
|
||||||
|
├── signin/ # Halaman login
|
||||||
|
└── signup/ # Halaman registrasi
|
||||||
|
|
||||||
## Learn More
|
components/
|
||||||
|
├── ActionButton/ # Komponen tombol
|
||||||
|
├── Common/ # Komponen umum (Layout, Table, Form)
|
||||||
|
└── Forms/ # Komponen form spesifik
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
prisma/
|
||||||
|
└── schema.prisma # Skema database
|
||||||
|
```
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
## 🔐 Hak Akses (Role)
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
1. **Administrator**: Akses penuh ke seluruh sistem
|
||||||
|
2. **Pendidik**: Manajemen kelas, video, tugas, dan forum
|
||||||
|
3. **Calon Pendidik**: Mengikuti kelas, akses materi, upload tugas
|
||||||
|
4. **Umum**: Akses materi publik dan forum umum
|
||||||
|
|
||||||
## Deploy on Vercel
|
## 📝 Lisensi
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
[MIT](LICENSE)
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
|
||||||
@ -1,163 +0,0 @@
|
|||||||
# TODO: kreatiVortex - Platform Pembelajaran Tari Online
|
|
||||||
|
|
||||||
**Created by:** AI Assistant
|
|
||||||
**Date:** 2025-11-28
|
|
||||||
**Purpose:** Complete development of kreatiVortex online dance learning platform with user management, video content, forums, and assignment system
|
|
||||||
|
|
||||||
## 📋 Task Breakdown
|
|
||||||
|
|
||||||
### Phase 1: Project Setup & Foundation
|
|
||||||
|
|
||||||
- [x] Initialize Next.js 16+ project with TypeScript
|
|
||||||
- [x] Install and configure Better Auth framework
|
|
||||||
- [x] Set up Prisma ORM with database connection
|
|
||||||
- [ ] Configure project structure following AI_GUIDE.md patterns
|
|
||||||
- [ ] Set up ESLint, TypeScript configuration
|
|
||||||
- [ ] Create basic layout and routing structure
|
|
||||||
|
|
||||||
### Phase 2: Database Schema & Models
|
|
||||||
|
|
||||||
- [ ] Create User model with role-based access (Administrator, Pendidik, Calon Pendidik, Umum)
|
|
||||||
- [ ] Create Class/Instansi model for class management
|
|
||||||
- [ ] Create Video model with YouTube/local file support
|
|
||||||
- [ ] Create Forum model (General and Class-based)
|
|
||||||
- [ ] Create Assignment model with Word document support
|
|
||||||
- [ ] Create Comment model for video and forum interactions
|
|
||||||
- [ ] Add audit fields (createdBy, createdAt, updatedAt, updatedBy)
|
|
||||||
- [ ] Generate Prisma client and push schema to database
|
|
||||||
- [ ] Create seed data for initial users and roles
|
|
||||||
|
|
||||||
### Phase 3: Authentication & User Management
|
|
||||||
|
|
||||||
- [ ] Implement Better Auth configuration with role-based access
|
|
||||||
- [ ] Create landing page with Login, Register, Mulai Sekarang buttons
|
|
||||||
- [ ] Implement basic registration (Name, Email, Password, Password Confirmation)
|
|
||||||
- [ ] Create role upgrade forms (Pendidik and Calon Pendidik registration)
|
|
||||||
- [ ] Implement login/logout functionality
|
|
||||||
- [ ] Create user dashboard with role-specific options
|
|
||||||
- [ ] Add authorization middleware for protected routes
|
|
||||||
|
|
||||||
### Phase 4: Core UI Components & Layout
|
|
||||||
|
|
||||||
- [ ] Create main layout with navigation (Beranda, Teori, Praktik, Template Makalah)
|
|
||||||
- [ ] Implement Navy (#000080), Gray (#A9A9A9), White (#FFFFFF) color scheme
|
|
||||||
- [ ] Add background.jpg with overlay effect
|
|
||||||
- [ ] Create AppDataView component for data tables
|
|
||||||
- [ ] Create AppForm component for forms
|
|
||||||
- [ ] Create ActionButton component for buttons
|
|
||||||
- [ ] Ensure responsive design and mobile-first approach
|
|
||||||
- [ ] Add skeleton loading states
|
|
||||||
|
|
||||||
### Phase 5: Video Management System
|
|
||||||
|
|
||||||
- [ ] Create video upload page with file/YouTube options
|
|
||||||
- [ ] Implement YouTube URL validation and embedding
|
|
||||||
- [ ] Create video player page with comments
|
|
||||||
- [ ] Implement Beranda page showing community videos
|
|
||||||
- [ ] Create video filtering (by uploader, latest, popular)
|
|
||||||
- [ ] Add administrator-specific video placement options
|
|
||||||
- [ ] Implement video storage and compression for local files
|
|
||||||
|
|
||||||
### Phase 6: Learning Content Management
|
|
||||||
|
|
||||||
- [ ] Create Teori page for theoretical content
|
|
||||||
- [ ] Create Praktik page for practical content
|
|
||||||
- [ ] Implement administrator-only content placement for Teori/Praktik
|
|
||||||
- [ ] Create Template Makalah page for document downloads
|
|
||||||
- [ ] Add content categorization and organization
|
|
||||||
- [ ] Implement content suggestion system for Pendidik
|
|
||||||
|
|
||||||
### Phase 7: Forum & Collaboration System
|
|
||||||
|
|
||||||
- [ ] Create Forum Umum for all users
|
|
||||||
- [ ] Create Forum Group/Kelas for Pendidik and Calon Pendidik
|
|
||||||
- [ ] Implement thread creation and reply functionality
|
|
||||||
- [ ] Add forum moderation features
|
|
||||||
- [ ] Create class-based discussion organization
|
|
||||||
- [ ] Implement rich text editor for forum posts
|
|
||||||
|
|
||||||
### Phase 8: Assignment & Document Management
|
|
||||||
|
|
||||||
- [ ] Create assignment creation system for Pendidik
|
|
||||||
- [ ] Implement Word document upload for Calon Pendidik
|
|
||||||
- [ ] Create revision system with document comparison
|
|
||||||
- [ ] Add assignment tracking and status management
|
|
||||||
- [ ] Implement notification system for assignments
|
|
||||||
- [ ] Create document download and management features
|
|
||||||
|
|
||||||
### Phase 9: Class Management System
|
|
||||||
|
|
||||||
- [ ] Create class creation and management for Pendidik
|
|
||||||
- [ ] Implement class joining system for Calon Pendidik
|
|
||||||
- [ ] Add class member management
|
|
||||||
- [ ] Create class-specific content organization
|
|
||||||
- [ ] Implement class analytics and reporting
|
|
||||||
- [ ] Add class approval workflows
|
|
||||||
|
|
||||||
### Phase 10: API Development
|
|
||||||
|
|
||||||
- [ ] Create user management API endpoints
|
|
||||||
- [ ] Create video CRUD API endpoints
|
|
||||||
- [ ] Create forum API endpoints
|
|
||||||
- [ ] Create assignment API endpoints
|
|
||||||
- [ ] Create class management API endpoints
|
|
||||||
- [ ] Implement proper error handling and validation
|
|
||||||
- [ ] Add authentication and authorization checks
|
|
||||||
- [ ] Create TypeScript interfaces for API responses
|
|
||||||
|
|
||||||
### Phase 11: Testing & Quality Assurance
|
|
||||||
|
|
||||||
- [ ] Run ESLint (0 errors, 0 warnings)
|
|
||||||
- [ ] Run TypeScript type checking (0 errors)
|
|
||||||
- [ ] Perform build test (successful compilation)
|
|
||||||
- [ ] Test authentication flows end-to-end
|
|
||||||
- [ ] Test CRUD operations functionality
|
|
||||||
- [ ] Test permission matrix and role-based access
|
|
||||||
- [ ] Test video upload and playback functionality
|
|
||||||
- [ ] Test forum and assignment workflows
|
|
||||||
- [ ] Perform responsive design testing
|
|
||||||
- [ ] Test with MCP Server for browser automation
|
|
||||||
|
|
||||||
### Phase 12: Documentation & Deployment
|
|
||||||
|
|
||||||
- [ ] Update README.md with project structure and features
|
|
||||||
- [ ] Document API endpoints and usage
|
|
||||||
- [ ] Create component documentation
|
|
||||||
- [ ] Add deployment configuration
|
|
||||||
- [ ] Perform final security review
|
|
||||||
- [ ] Optimize performance and loading times
|
|
||||||
|
|
||||||
## 🎯 Expected Outcomes
|
|
||||||
|
|
||||||
- [ ] Complete kreatiVortex platform with all required features
|
|
||||||
- [ ] Four-tier user role system with proper access control
|
|
||||||
- [ ] Video management with local and YouTube support
|
|
||||||
- [ ] Forum system for community and class discussions
|
|
||||||
- [ ] Assignment system with Word document support
|
|
||||||
- [ ] Class management and organization
|
|
||||||
- [ ] Responsive design with Navy/Gray/White color scheme
|
|
||||||
- [ ] Better Auth integration for secure authentication
|
|
||||||
- [ ] Comprehensive API with proper error handling
|
|
||||||
- [ ] Mobile-responsive and accessible design
|
|
||||||
|
|
||||||
## ⚠️ Risks & Considerations
|
|
||||||
|
|
||||||
- **Video Storage**: Local file storage may require significant server space and compression
|
|
||||||
- **YouTube Integration**: Direct URL embedding without API may have limitations
|
|
||||||
- **Document Processing**: Word document handling requires proper file type validation
|
|
||||||
- **Role Complexity**: Four-tier role system requires careful permission management
|
|
||||||
- **Performance**: Video streaming and large file uploads may impact performance
|
|
||||||
- **Security**: User data (especially NIM and documents) requires proper encryption
|
|
||||||
- **Scalability**: System must handle growing number of users, videos, and documents
|
|
||||||
|
|
||||||
## 🔄 Dependencies
|
|
||||||
|
|
||||||
- **Next.js 16+**: Core framework for the application
|
|
||||||
- **Better Auth**: Authentication and authorization framework
|
|
||||||
- **Prisma ORM**: Database management and migrations
|
|
||||||
- **TypeScript**: Type safety and development experience
|
|
||||||
- **shadcn/ui**: UI component library
|
|
||||||
- **Database**: PostgreSQL or MySQL for data storage
|
|
||||||
- **File Storage**: Local storage or cloud storage for videos and documents
|
|
||||||
- **YouTube API**: Optional for enhanced YouTube integration
|
|
||||||
- **Rich Text Editor**: For forum posts and content creation
|
|
||||||
@ -1,170 +0,0 @@
|
|||||||
/**
|
|
||||||
* File: layout.tsx
|
|
||||||
* Created by: AI Assistant
|
|
||||||
* Date: 2025-11-29
|
|
||||||
* Purpose: Dashboard layout for kreatiVortex platform
|
|
||||||
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { ReactNode } from 'react';
|
|
||||||
|
|
||||||
export default function DashboardLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gradient-to-br from-navy-900 via-navy-800 to-gray-900">
|
|
||||||
{/* Background overlay */}
|
|
||||||
<div className="absolute inset-0 bg-black/20"></div>
|
|
||||||
|
|
||||||
<div className="relative z-10 flex min-h-screen">
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="w-64 bg-navy-900/50 backdrop-blur-sm border-r border-white/10">
|
|
||||||
<div className="p-6">
|
|
||||||
{/* Logo */}
|
|
||||||
<Link href="/dashboard" className="flex items-center space-x-3">
|
|
||||||
<div className="w-8 h-8 bg-gold-400 rounded-lg flex items-center justify-center">
|
|
||||||
<span className="text-navy-900 font-bold text-sm">kV</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-white font-bold text-xl">
|
|
||||||
kreati<span className="text-gold-400">Vortex</span>
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<nav className="px-4 pb-6">
|
|
||||||
<ul className="space-y-2">
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
href="/dashboard"
|
|
||||||
className="flex items-center space-x-3 px-4 py-3 text-gray-300 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 00-1-1v-4a1 1 0 011-1h2m4 0a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
|
||||||
</svg>
|
|
||||||
<span>Beranda</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
href="/dashboard/teori"
|
|
||||||
className="flex items-center space-x-3 px-4 py-3 text-gray-300 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
|
||||||
</svg>
|
|
||||||
<span>Teori</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
href="/dashboard/praktik"
|
|
||||||
className="flex items-center space-x-3 px-4 py-3 text-gray-300 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
<span>Praktik</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
href="/dashboard/template-makalah"
|
|
||||||
className="flex items-center space-x-3 px-4 py-3 text-gray-300 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
<span>Template Makalah</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{/* Additional sections */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<h3 className="px-4 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-4">
|
|
||||||
Komunitas
|
|
||||||
</h3>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
href="/dashboard/videos"
|
|
||||||
className="flex items-center space-x-3 px-4 py-3 text-gray-300 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
<span>Video Saya</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
href="/dashboard/forum"
|
|
||||||
className="flex items-center space-x-3 px-4 py-3 text-gray-300 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z" />
|
|
||||||
</svg>
|
|
||||||
<span>Forum</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
href="/dashboard/assignments"
|
|
||||||
className="flex items-center space-x-3 px-4 py-3 text-gray-300 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2v2a2 2 0 01-2 2M9 5a2 2 0 012-2v2a2 2 0 01-2 2m-3 7h8m-8 4h8m-8 4h8" />
|
|
||||||
</svg>
|
|
||||||
<span>Tugas</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<div className="flex-1 overflow-auto">
|
|
||||||
{/* Top bar */}
|
|
||||||
<header className="bg-navy-800/50 backdrop-blur-sm border-b border-white/10 px-6 py-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className="text-xl font-semibold text-white">
|
|
||||||
Dashboard
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
{/* Notifications */}
|
|
||||||
<button className="relative p-2 text-gray-300 hover:text-white transition-colors">
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 10v6.159c0 .538.214 1.055.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
|
||||||
</svg>
|
|
||||||
<span className="absolute top-1 right-1 w-2 h-2 bg-gold-400 rounded-full"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Profile */}
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-sm font-medium text-white">John Doe</p>
|
|
||||||
<p className="text-xs text-gray-400">Calon Pendidik</p>
|
|
||||||
</div>
|
|
||||||
<div className="w-10 h-10 bg-gold-400 rounded-full flex items-center justify-center">
|
|
||||||
<span className="text-navy-900 font-semibold">JD</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Page content */}
|
|
||||||
<main className="p-6">
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
/**
|
|
||||||
* File: page.tsx
|
|
||||||
* Created by: AI Assistant
|
|
||||||
* Date: 2025-11-29
|
|
||||||
* Purpose: Dashboard page for kreatiVortex platform
|
|
||||||
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
|
||||||
*/
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="mb-8">
|
|
||||||
<h2 className="text-2xl font-bold text-white mb-2">
|
|
||||||
Selamat Datang di kreatiVortex!
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-300">
|
|
||||||
Platform pembelajaran tari tradisional Indonesia
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Cards */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
|
||||||
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-300">Total Video</p>
|
|
||||||
<p className="text-2xl font-bold text-white">24</p>
|
|
||||||
</div>
|
|
||||||
<div className="w-12 h-12 bg-gold-400/20 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-300">Kelas Aktif</p>
|
|
||||||
<p className="text-2xl font-bold text-white">3</p>
|
|
||||||
</div>
|
|
||||||
<div className="w-12 h-12 bg-gold-400/20 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-300">Tugas Selesai</p>
|
|
||||||
<p className="text-2xl font-bold text-white">12</p>
|
|
||||||
</div>
|
|
||||||
<div className="w-12 h-12 bg-gold-400/20 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm7-2a2 2 0 11-4 0v4a2 2 0 014 0V9z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-300">Forum Post</p>
|
|
||||||
<p className="text-2xl font-bold text-white">48</p>
|
|
||||||
</div>
|
|
||||||
<div className="w-12 h-12 bg-gold-400/20 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recent Activity */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
{/* Recent Videos */}
|
|
||||||
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-4">Video Terbaru</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div className="w-16 h-12 bg-gold-400/20 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h4 className="text-white font-medium">Tari Piring - Dasar</h4>
|
|
||||||
<p className="text-sm text-gray-400">2 jam yang lalu</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div className="w-16 h-12 bg-gold-400/20 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h4 className="text-white font-medium">Tari Saman - Gerakan Tangan</h4>
|
|
||||||
<p className="text-sm text-gray-400">5 jam yang lalu</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recent Forum Posts */}
|
|
||||||
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-4">Diskusi Terbaru</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="border-l border-gold-400 pl-4">
|
|
||||||
<h4 className="text-white font-medium mb-1">Tips untuk pemula Tari Piring</h4>
|
|
||||||
<p className="text-sm text-gray-400 mb-2">Saya ingin berbagi beberapa tips untuk yang baru mulai belajar...</p>
|
|
||||||
<p className="text-xs text-gray-500">Oleh Sarah Pendidik • 1 jam yang lalu</p>
|
|
||||||
</div>
|
|
||||||
<div className="border-l border-gold-400 pl-4">
|
|
||||||
<h4 className="text-white font-medium mb-1">Costum untuk pertunjukan</h4>
|
|
||||||
<p className="text-sm text-gray-400 mb-2">Apakah ada saran untuk costum yang tepat untuk pertunjukan tari...</p>
|
|
||||||
<p className="text-xs text-gray-500">Oleh Budi Student • 3 jam yang lalu</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
104
app/[locale]/(app)/dashboard/assignments/[id]/page.tsx
Normal file
104
app/[locale]/(app)/dashboard/assignments/[id]/page.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* File: page.tsx
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Assignment detail page for kreatiVortex platform
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import ActionButton from '@/components/ActionButton';
|
||||||
|
|
||||||
|
export default function AssignmentDetailPage() {
|
||||||
|
const assignment = {
|
||||||
|
id: 1,
|
||||||
|
title: 'Analisis Gerakan Tari Piring',
|
||||||
|
description: 'Buatlah makalah analisis mengenai teknik dasar gerakan Tari Piring. Fokuskan analisis pada: 1. Teknik memegang piring, 2. Koordinasi gerakan tangan dan kaki, 3. Pola lantai dasar. Panjang makalah minimal 500 kata.',
|
||||||
|
class: 'Tari Tradisional Indonesia 101',
|
||||||
|
educator: 'Sarah Pendidik',
|
||||||
|
dueDate: '2025-12-05T23:59:00Z',
|
||||||
|
maxScore: 100,
|
||||||
|
status: 'Belum Diserahkan',
|
||||||
|
mySubmission: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Link href="/dashboard/assignments" className="text-gray-400 hover:text-white flex items-center">
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
Kembali ke Daftar Tugas
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Assignment Details */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<span className="px-3 py-1 text-blue-200 text-xs rounded-full border border-blue-500/30">
|
||||||
|
{assignment.class}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
Oleh {assignment.educator}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-4">{assignment.title}</h1>
|
||||||
|
|
||||||
|
<div className="prose prose-invert max-w-none mb-6">
|
||||||
|
<p className="text-gray-300 leading-relaxed">
|
||||||
|
{assignment.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-white/10 pt-4 grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Tenggat Waktu</p>
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{new Date(assignment.dueDate).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Nilai Maksimal</p>
|
||||||
|
<p className="text-white font-medium">{assignment.maxScore}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submission Sidebar */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20 sticky top-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">Pengumpulan Tugas</h3>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<p className="text-sm text-gray-400 mb-2">Status:</p>
|
||||||
|
<span className="px-3 py-1 bg-yellow-900/50 text-yellow-200 text-sm rounded border border-yellow-500/30 inline-block w-full text-center">
|
||||||
|
{assignment.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border-2 border-dashed rounded-lg p-6 text-center hover:bg-white/5 transition-colors cursor-pointer">
|
||||||
|
<svg className="w-8 h-8 text-gray-400 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm text-gray-300 font-medium">Upload File</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Word (DOCX) atau PDF</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ActionButton className="w-full">
|
||||||
|
Serahkan Tugas
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
app/[locale]/(app)/dashboard/assignments/new/page.tsx
Normal file
141
app/[locale]/(app)/dashboard/assignments/new/page.tsx
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* File: page.tsx
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Create assignment page for kreatiVortex platform
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
|
||||||
|
export default function NewAssignmentPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const classId = searchParams.get('classId');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
classId: '',
|
||||||
|
dueDate: '',
|
||||||
|
maxScore: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (classId) {
|
||||||
|
setFormData(prev => ({ ...prev, classId }));
|
||||||
|
}
|
||||||
|
}, [classId]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Simulate API call
|
||||||
|
setTimeout(() => {
|
||||||
|
setLoading(false);
|
||||||
|
router.push('/dashboard/assignments');
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Buat Tugas Baru</h1>
|
||||||
|
<p className="text-gray-400">Berikan tugas kepada siswa untuk evaluasi pembelajaran</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title" className="text-gray-300">
|
||||||
|
Judul Tugas
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||||
|
required
|
||||||
|
placeholder="Contoh: Analisis Gerakan Tari Piring"
|
||||||
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="classId" className="text-gray-300">
|
||||||
|
Kelas
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.classId}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, classId: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-white/10 border-white/20 text-white">
|
||||||
|
<SelectValue placeholder="Pilih Kelas" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="asdf">Pilih Kelas</SelectItem>
|
||||||
|
<SelectItem value="1">Tari Tradisional Indonesia 101</SelectItem>
|
||||||
|
<SelectItem value="2">Sejarah Tari</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dueDate" className="text-gray-300">
|
||||||
|
Tenggat Waktu
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
id="dueDate"
|
||||||
|
value={formData.dueDate}
|
||||||
|
onChange={(e) => setFormData({ ...formData, dueDate: e.target.value })}
|
||||||
|
required
|
||||||
|
className="bg-white/10 border-white/20 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description" className="text-gray-300">
|
||||||
|
Deskripsi Tugas
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
rows={6}
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
required
|
||||||
|
placeholder="Jelaskan detail tugas yang harus dikerjakan..."
|
||||||
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 flex justify-end space-x-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-gray-300 hover:text-white"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-gold-500 hover:bg-gold-600 text-navy-900"
|
||||||
|
>
|
||||||
|
{loading ? 'Menyimpan...' : 'Buat Tugas'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
app/[locale]/(app)/dashboard/assignments/page.tsx
Normal file
94
app/[locale]/(app)/dashboard/assignments/page.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* File: page.tsx
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Assignment list page for kreatiVortex platform
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import ActionButton from '@/components/ActionButton';
|
||||||
|
import { useFetch } from '@/hooks/useFetch';
|
||||||
|
|
||||||
|
interface AssignmentData {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
class: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
dueDate: string;
|
||||||
|
status?: string; // Add logic to determine status
|
||||||
|
maxScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssignmentPage() {
|
||||||
|
const { data: assignments, loading } = useFetch<AssignmentData[]>('/api/assignments');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Tugas Saya</h1>
|
||||||
|
<p className="text-gray-400">Kelola dan kumpulkan tugas-tugas Anda</p>
|
||||||
|
</div>
|
||||||
|
{/* Only educators should see this button in real app */}
|
||||||
|
<Link href="/dashboard/assignments/new">
|
||||||
|
<ActionButton>
|
||||||
|
Buat Tugas Baru
|
||||||
|
</ActionButton>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center text-gray-400 py-12">Loading...</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{assignments?.map((assignment) => (
|
||||||
|
<div key={assignment.id} className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20 hover:bg-white/15 transition-all cursor-pointer group">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<span className="text-xs text-gray-400 font-medium bg-white/10 px-2 py-1 rounded">
|
||||||
|
{assignment.class.name}
|
||||||
|
</span>
|
||||||
|
{/* Status placeholder - needs logic */}
|
||||||
|
<span className={`px-2 py-1 text-xs rounded border bg-yellow-900/50 text-yellow-200 border-yellow-500/30`}>
|
||||||
|
Pending
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2 group-hover:text-gold-400 transition-colors">
|
||||||
|
{assignment.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-sm text-gray-400">Tenggat Waktu:</p>
|
||||||
|
<p className="text-white font-medium">{new Date(assignment.dueDate).toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center pt-4 border-t border-white/10">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-gray-400">Nilai Maks: </span>
|
||||||
|
<span className="text-white font-bold">{assignment.maxScore}</span>
|
||||||
|
</div>
|
||||||
|
<Link href={`/dashboard/assignments/${assignment.id}`}>
|
||||||
|
<span className="text-gold-400 hover:text-gold-300 text-sm font-medium flex items-center">
|
||||||
|
Detail
|
||||||
|
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(!assignments || assignments.length === 0) && (
|
||||||
|
<div className="col-span-full text-center py-12 text-gray-400">
|
||||||
|
Belum ada tugas yang tersedia.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
269
app/[locale]/(app)/dashboard/classes/[id]/page.tsx
Normal file
269
app/[locale]/(app)/dashboard/classes/[id]/page.tsx
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* File: page.tsx
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Class detail page for kreatiVortex platform
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import ActionButton from '@/components/ActionButton';
|
||||||
|
|
||||||
|
interface ClassMember {
|
||||||
|
id: string;
|
||||||
|
student: {
|
||||||
|
user: {
|
||||||
|
name: string;
|
||||||
|
image?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClassData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
code: string;
|
||||||
|
educatorId: string;
|
||||||
|
educator: {
|
||||||
|
user: {
|
||||||
|
name: string;
|
||||||
|
image?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
isActive: boolean;
|
||||||
|
maxStudents?: number;
|
||||||
|
members: ClassMember[];
|
||||||
|
videos: any[];
|
||||||
|
assignments: any[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClassDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const [classData, setClassData] = useState<ClassData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchClassData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/classes/${params.id}`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setClassData(result.data);
|
||||||
|
} else {
|
||||||
|
setError(result.message || 'Failed to fetch class data');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Error fetching class data');
|
||||||
|
console.error('Error:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (params.id) {
|
||||||
|
fetchClassData();
|
||||||
|
}
|
||||||
|
}, [params.id]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<div className="text-white">Memuat data kelas...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !classData) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<div className="text-red-400">Error: {error || 'Kelas tidak ditemukan'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Link href="/dashboard/classes" className="text-gray-400 hover:text-white flex items-center">
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
Kembali ke Daftar Kelas
|
||||||
|
</Link>
|
||||||
|
<ActionButton variant="outline" size="sm">
|
||||||
|
Pengaturan Kelas
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Class Header */}
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-8 border border-white/20 relative overflow-hidden">
|
||||||
|
<div className="absolute top-0 right-0 p-4">
|
||||||
|
<span className={`px-3 py-1 text-sm rounded-full border ${
|
||||||
|
classData.isActive
|
||||||
|
? 'bg-green-900/50 text-green-200 border-green-500/30'
|
||||||
|
: 'bg-red-900/50 text-red-200 border-red-500/30'
|
||||||
|
}`}>
|
||||||
|
{classData.isActive ? 'Aktif' : 'Tidak Aktif'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">{classData.name}</h1>
|
||||||
|
<p className="text-xl text-gold-400 mb-6">{classData.code}</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm text-gray-300">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="w-5 h-5 mr-2 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
Pengajar: {classData.educator.user.name}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="w-5 h-5 mr-2 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
{classData.members.length} / {classData.maxStudents || '∞'} Siswa
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* About */}
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4">Tentang Kelas</h2>
|
||||||
|
<p className="text-gray-300 leading-relaxed">
|
||||||
|
{classData.description || 'Tidak ada deskripsi untuk kelas ini.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Link href={`/dashboard/forum/${classData.id}`} className="block">
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20 hover:bg-white/15 transition-colors text-center group">
|
||||||
|
<div className="w-12 h-12 bg-gold-400/20 rounded-full flex items-center justify-center mx-auto mb-3 group-hover:scale-110 transition-transform">
|
||||||
|
<svg className="w-6 h-6 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-white">Forum Kelas</h3>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Link href={`/dashboard/assignments?classId=${classData.id}`} className="block">
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20 hover:bg-white/15 transition-colors text-center group">
|
||||||
|
<div className="w-12 h-12 bg-gold-400/20 rounded-full flex items-center justify-center mx-auto mb-3 group-hover:scale-110 transition-transform">
|
||||||
|
<svg className="w-6 h-6 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2v2a2 2 0 01-2 2M9 5a2 2 0 012-2v2a2 2 0 01-2 2m-3 7h8m-8 4h8m-8 4h8" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-white">Tugas Kelas</h3>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistics */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-4 border border-white/20 text-center">
|
||||||
|
<div className="text-2xl font-bold text-gold-400">{classData.videos.length}</div>
|
||||||
|
<div className="text-sm text-gray-300">Video</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-4 border border-white/20 text-center">
|
||||||
|
<div className="text-2xl font-bold text-gold-400">{classData.assignments.length}</div>
|
||||||
|
<div className="text-sm text-gray-300">Tugas</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-4 border border-white/20 text-center">
|
||||||
|
<div className="text-2xl font-bold text-gold-400">{classData.members.length}</div>
|
||||||
|
<div className="text-sm text-gray-300">Siswa</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="lg:col-span-1 space-y-6">
|
||||||
|
{/* Class Members */}
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-lg font-bold text-white">Anggota Kelas</h2>
|
||||||
|
<span className="text-gold-400 text-sm">{classData.members.length} siswa</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{classData.members.slice(0, 5).map((member) => (
|
||||||
|
<div key={member.id} className="flex items-center space-x-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-gold-400/20 flex items-center justify-center">
|
||||||
|
{member.student.user.image ? (
|
||||||
|
<img
|
||||||
|
src={member.student.user.image}
|
||||||
|
alt={member.student.user.name}
|
||||||
|
className="w-8 h-8 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-gold-400 text-xs font-bold">
|
||||||
|
{member.student.user.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-white truncate">
|
||||||
|
{member.student.user.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{classData.members.length > 5 && (
|
||||||
|
<div className="text-center pt-2">
|
||||||
|
<button className="text-gold-400 text-sm hover:underline">
|
||||||
|
+{classData.members.length - 5} siswa lainnya
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Class Info */}
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
|
||||||
|
<h2 className="text-lg font-bold text-white mb-4">Informasi Kelas</h2>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Kode Kelas</span>
|
||||||
|
<span className="text-white font-medium">{classData.code}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Status</span>
|
||||||
|
<span className={`font-medium ${
|
||||||
|
classData.isActive ? 'text-green-400' : 'text-red-400'
|
||||||
|
}`}>
|
||||||
|
{classData.isActive ? 'Aktif' : 'Tidak Aktif'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Kapasitas</span>
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{classData.members.length} / {classData.maxStudents || '∞'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Dibuat</span>
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{new Date(classData.createdAt).toLocaleDateString('id-ID')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
app/[locale]/(app)/dashboard/classes/new/page.tsx
Normal file
145
app/[locale]/(app)/dashboard/classes/new/page.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* File: page.tsx
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Create class page for kreatiVortex platform
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|
||||||
|
export default function NewClassPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
code: '',
|
||||||
|
maxStudents: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/classes', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
router.push('/dashboard/classes');
|
||||||
|
} else {
|
||||||
|
alert('Gagal membuat kelas: ' + result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating class:', error);
|
||||||
|
alert('Terjadi kesalahan saat membuat kelas');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Buat Kelas Baru</h1>
|
||||||
|
<p className="text-gray-400">Buat kelas baru untuk mulai mengajar</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name" className="text-gray-300">
|
||||||
|
Nama Kelas
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
placeholder="Contoh: Tari Tradisional Indonesia 101"
|
||||||
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="code" className="text-gray-300">
|
||||||
|
Kode Kelas
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="code"
|
||||||
|
value={formData.code}
|
||||||
|
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
||||||
|
required
|
||||||
|
placeholder="Contoh: TTI101"
|
||||||
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="maxStudents" className="text-gray-300">
|
||||||
|
Maksimal Siswa
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
id="maxStudents"
|
||||||
|
value={formData.maxStudents}
|
||||||
|
onChange={(e) => setFormData({ ...formData, maxStudents: parseInt(e.target.value) })}
|
||||||
|
className="bg-white/10 border-white/20 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description" className="text-gray-300">
|
||||||
|
Deskripsi Kelas
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
rows={4}
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
required
|
||||||
|
placeholder="Deskripsikan materi yang akan diajarkan..."
|
||||||
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 flex justify-end space-x-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-gray-300 hover:text-white"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-gold-500 hover:bg-gold-600 text-navy-900"
|
||||||
|
>
|
||||||
|
{loading ? 'Menyimpan...' : 'Buat Kelas'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
app/[locale]/(app)/dashboard/classes/page.tsx
Normal file
155
app/[locale]/(app)/dashboard/classes/page.tsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* File: page.tsx
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Class list page for kreatiVortex platform
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Link } from '@/i18n/routing';
|
||||||
|
import ActionButton from '@/components/ActionButton';
|
||||||
|
import { useFetch } from '@/hooks/useFetch';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface ClassData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
educator: {
|
||||||
|
user: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
schedule: string;
|
||||||
|
_count: {
|
||||||
|
members: number;
|
||||||
|
};
|
||||||
|
status: string; // Assuming we add status to API or calculate it
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserProfile {
|
||||||
|
role: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClassPage() {
|
||||||
|
const t = useTranslations('Classes');
|
||||||
|
const { data: classes, loading } = useFetch<ClassData[]>('/api/classes');
|
||||||
|
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch user profile to check role
|
||||||
|
const fetchProfile = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/profile', {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setUserProfile(data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user profile:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchProfile();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const canCreateClass = userProfile?.role?.name === 'PENDIDIK' || userProfile?.role?.name === 'ADMIN';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">{t('title')}</h1>
|
||||||
|
<p className="text-gray-400">{t('subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
{canCreateClass ? (
|
||||||
|
<Link href="/dashboard/classes/new">
|
||||||
|
<ActionButton>
|
||||||
|
{t('createButton')}
|
||||||
|
</ActionButton>
|
||||||
|
</Link>
|
||||||
|
) : userProfile?.role?.name === 'UMUM' ? (
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
<p>Upgrade ke Pendidik untuk membuat kelas</p>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/register-role"
|
||||||
|
className="text-gold-400 hover:text-gold-300 underline"
|
||||||
|
>
|
||||||
|
Daftar sebagai Pendidik →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Hanya Pendidik yang dapat membuat kelas
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center text-gray-400 py-12">{t('loading')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{classes?.map((cls) => (
|
||||||
|
<div key={cls.id} className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20 hover:bg-white/15 transition-all cursor-pointer group">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<span className={`px-2 py-1 text-xs rounded border bg-green-900/50 text-green-200 border-green-500/30`}>
|
||||||
|
{t('active')}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-400 flex items-center">
|
||||||
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||||
|
</svg>
|
||||||
|
{cls._count.members} {t('students')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-white mb-2 group-hover:text-gold-400 transition-colors">
|
||||||
|
{cls.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-400 text-sm mb-4 line-clamp-2">
|
||||||
|
{cls.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-sm text-gray-300">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="w-4 h-4 mr-2 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
{cls.educator?.user.name || t('unknownEducator')}
|
||||||
|
</div>
|
||||||
|
{/* Schedule not in model yet, placeholder */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="w-4 h-4 mr-2 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
{t('scheduleNotSet')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 pt-4 border-t border-white/10 flex justify-end">
|
||||||
|
<Link href={`/dashboard/classes/${cls.id}`} className="text-gold-400 hover:text-gold-300 text-sm font-medium flex items-center">
|
||||||
|
{t('enterClass')}
|
||||||
|
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(!classes || classes.length === 0) && (
|
||||||
|
<div className="col-span-full text-center py-12 text-gray-400 bg-white/5 rounded-xl border border-white/10">
|
||||||
|
{t('noClasses')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
app/[locale]/(app)/dashboard/forum/[classId]/page.tsx
Normal file
114
app/[locale]/(app)/dashboard/forum/[classId]/page.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* File: page.tsx
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Forum list page for kreatiVortex platform
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { Link } from '@/i18n/routing';
|
||||||
|
import ActionButton from '@/components/ActionButton';
|
||||||
|
import { useFetch } from '@/hooks/useFetch';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
interface ForumData {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
creator: {
|
||||||
|
user: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
_count: {
|
||||||
|
posts: number;
|
||||||
|
};
|
||||||
|
updatedAt: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClassData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ForumPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const classId = params.classId as string;
|
||||||
|
const t = useTranslations('Forum');
|
||||||
|
const { data: forums, loading } = useFetch<ForumData[]>(`/api/forums?classId=${classId}`);
|
||||||
|
const { data: classData } = useFetch<ClassData>(`/api/classes/${classId}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">
|
||||||
|
{classData ? `${t('classForum')}: ${classData.name}` : t('classForum')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
{classData ? classData.description : t('classForumSubtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href={`/dashboard/forum/new?classId=${classId}`}>
|
||||||
|
<ActionButton>
|
||||||
|
{t('createButton')}
|
||||||
|
</ActionButton>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-xl border border-white/20 overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center text-gray-400 py-12">Loading...</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-white/10">
|
||||||
|
{forums?.map((forum) => (
|
||||||
|
<div key={forum.id} className="p-6 hover:bg-white/5 transition-colors">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center space-x-2 mb-1">
|
||||||
|
<span className="px-2 py-0.5 bg-white/10 text-gray-400 text-xs rounded border border-white/10">
|
||||||
|
{forum.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Link href={`/dashboard/forum/detail/${forum.id}`}>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2 hover:text-gold-400 transition-colors">
|
||||||
|
{forum.title}
|
||||||
|
</h3>
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center text-sm text-gray-400 space-x-4">
|
||||||
|
<span className="flex items-center">
|
||||||
|
<span className="w-5 h-5 bg-gray-600 rounded-full flex items-center justify-center text-xs text-white font-bold mr-2">
|
||||||
|
{forum.creator.user.name.charAt(0)}
|
||||||
|
</span>
|
||||||
|
{forum.creator.user.name}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{new Date(forum.updatedAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-6 text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-semibold text-white">{forum._count.posts}</div>
|
||||||
|
<div className="text-xs">{t('posts')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(!forums || forums.length === 0) && (
|
||||||
|
<div className="text-center py-12 text-gray-400">
|
||||||
|
{t('noForums')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
229
app/[locale]/(app)/dashboard/forum/detail/[id]/page.tsx
Normal file
229
app/[locale]/(app)/dashboard/forum/detail/[id]/page.tsx
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
/**
|
||||||
|
* File: page.tsx
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Forum detail page for kreatiVortex platform
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
interface ForumPost {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
author: {
|
||||||
|
user: {
|
||||||
|
name: string;
|
||||||
|
image?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Forum {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
createdAt: string;
|
||||||
|
classId: string;
|
||||||
|
creator: {
|
||||||
|
user: {
|
||||||
|
name: string;
|
||||||
|
image?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
posts: ForumPost[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ForumDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const [forum, setForum] = useState<Forum | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [replyContent, setReplyContent] = useState('');
|
||||||
|
const [submittingReply, setSubmittingReply] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchForum = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/forums/${params.id}`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setForum(result.data);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch forum:', result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching forum:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (params.id) {
|
||||||
|
fetchForum();
|
||||||
|
}
|
||||||
|
}, [params.id]);
|
||||||
|
|
||||||
|
const handleReplySubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!replyContent.trim()) return;
|
||||||
|
|
||||||
|
setSubmittingReply(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/forums/${params.id}/posts`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: replyContent,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Add the new post to the forum posts
|
||||||
|
setForum(prev => prev ? {
|
||||||
|
...prev,
|
||||||
|
posts: [result.data, ...prev.posts],
|
||||||
|
} : null);
|
||||||
|
|
||||||
|
// Clear the reply form
|
||||||
|
setReplyContent('');
|
||||||
|
} else {
|
||||||
|
console.error('Failed to create reply:', result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating reply:', error);
|
||||||
|
} finally {
|
||||||
|
setSubmittingReply(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-white text-center">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!forum) {
|
||||||
|
return <div className="text-white text-center">Forum not found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Link href={`/dashboard/forum/${forum.classId ? forum.classId : 'umum'}`} className="text-gray-400 hover:text-white flex items-center">
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
Kembali ke Forum
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Post */}
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center space-x-2 mb-2">
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{new Date(forum.createdAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-2">{forum.title}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{forum.description && (
|
||||||
|
<div className="flex items-start space-x-4 mb-6">
|
||||||
|
<div className="w-10 h-10 bg-gold-400 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-navy-900 font-bold">{forum.creator.user.name.charAt(0)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="font-medium text-white">{forum.creator.user.name}</span>
|
||||||
|
<span className="text-xs bg-gold-500/20 text-gold-400 px-2 py-0.5 rounded-full">
|
||||||
|
Pembuat Forum
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-gray-300 leading-relaxed">
|
||||||
|
{forum.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Posts */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-white ml-2">{forum.posts.length} Diskusi</h3>
|
||||||
|
|
||||||
|
{forum.posts.map((post) => (
|
||||||
|
<div key={post.id} className="bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-white/10 ml-8">
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="w-8 h-8 bg-gray-600 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-white font-bold text-xs">{post.author.user.name.charAt(0)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="font-medium text-white">{post.author.user.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{new Date(post.createdAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-300 text-sm leading-relaxed">
|
||||||
|
{post.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reply Form */}
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20 mt-8">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">Tulis Balasan</h3>
|
||||||
|
<form onSubmit={handleReplySubmit}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="reply" className="text-gray-300">
|
||||||
|
Isi Balasan
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="reply"
|
||||||
|
rows={4}
|
||||||
|
value={replyContent}
|
||||||
|
onChange={(e) => setReplyContent(e.target.value)}
|
||||||
|
placeholder="Tulis balasan Anda di sini..."
|
||||||
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={submittingReply || !replyContent.trim()}
|
||||||
|
className="bg-gold-500 hover:bg-gold-600 text-navy-900"
|
||||||
|
>
|
||||||
|
{submittingReply ? 'Mengirim...' : 'Kirim Balasan'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
243
app/[locale]/(app)/dashboard/forum/new/page.tsx
Normal file
243
app/[locale]/(app)/dashboard/forum/new/page.tsx
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* File: page.tsx
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Create forum discussion page for kreatiVortex platform
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { useFetch } from '@/hooks/useFetch';
|
||||||
|
import { uploadFile, isAllowedFileType, formatFileSize, UploadedFile } from '@/lib/upload';
|
||||||
|
|
||||||
|
interface ClassData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewForumPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const classId = searchParams.get('classId');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
classId: '',
|
||||||
|
});
|
||||||
|
const [attachments, setAttachments] = useState<UploadedFile[]>([]);
|
||||||
|
const { data: classes } = useFetch<ClassData[]>('/api/classes');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (classId) {
|
||||||
|
setFormData(prev => ({ ...prev, classId }));
|
||||||
|
}
|
||||||
|
}, [classId, classes ]);
|
||||||
|
|
||||||
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files) return;
|
||||||
|
|
||||||
|
const newAttachments: UploadedFile[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
|
||||||
|
if (!isAllowedFileType(file)) {
|
||||||
|
alert(`File ${file.name} tidak diizinkan. Hanya PDF, Word, dan file gambar yang diperbolehkan.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 10 * 1024 * 1024) { // 10MB limit
|
||||||
|
alert(`File ${file.name} terlalu besar. Maksimal ukuran file adalah 10MB.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uploadedFile = await uploadFile(file, 'forum-attachments');
|
||||||
|
newAttachments.push(uploadedFile);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading file:', error);
|
||||||
|
alert(`Gagal mengupload file ${file.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttachments(prev => [...prev, ...newAttachments]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAttachment = (index: number) => {
|
||||||
|
setAttachments(prev => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/forums', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: formData.title,
|
||||||
|
description: formData.content,
|
||||||
|
type: formData.classId && formData.classId !== 'general' ? 'CLASS' : 'GENERAL',
|
||||||
|
classId: formData.classId && formData.classId !== 'general' ? formData.classId : undefined,
|
||||||
|
attachments: attachments,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
router.push(formData.classId && formData.classId !== 'general' ? `/dashboard/forum/${formData.classId}` : '/dashboard/forum/umum');
|
||||||
|
} else {
|
||||||
|
console.error('Failed to create forum:', result.message);
|
||||||
|
// You could show an error message to the user here
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating forum:', error);
|
||||||
|
// You could show an error message to the user here
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Buat Diskusi Baru</h1>
|
||||||
|
<p className="text-gray-400">Mulai percakapan dengan komunitas</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title" className="text-gray-300">
|
||||||
|
Judul Diskusi
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||||
|
required
|
||||||
|
placeholder="Apa yang ingin Anda diskusikan?"
|
||||||
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="class" className="text-gray-300">
|
||||||
|
Pilih Kelas
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.classId}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, classId: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-white/10 border-white/20 text-white">
|
||||||
|
<SelectValue placeholder="Pilih kelas atau forum umum" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="general">Forum Umum</SelectItem>
|
||||||
|
{classes?.map((classItem) => (
|
||||||
|
<SelectItem key={classItem.id} value={classItem.id}>
|
||||||
|
{classItem.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="content" className="text-gray-300">
|
||||||
|
Isi Diskusi
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="content"
|
||||||
|
rows={6}
|
||||||
|
value={formData.content}
|
||||||
|
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||||
|
required
|
||||||
|
placeholder="Tuliskan detail diskusi Anda di sini..."
|
||||||
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="attachments" className="text-gray-300">
|
||||||
|
Lampiran (PDF, Word, Gambar)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="attachments"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png,.gif"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="bg-white/10 border-white/20 text-white file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-gold-500 file:text-white hover:file:bg-gold-600"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Maksimal 10MB per file. Format yang diizinkan: PDF, Word, JPG, PNG, GIF
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="space-y-2 mt-3">
|
||||||
|
<p className="text-sm text-gray-300">File yang diupload:</p>
|
||||||
|
{attachments.map((file, index) => (
|
||||||
|
<div key={file.id} className="flex items-center justify-between bg-white/5 rounded-lg p-3 border border-white/10">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-8 h-8 bg-gold-500/20 rounded flex items-center justify-center">
|
||||||
|
<svg className="w-4 h-4 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-white font-medium">{file.originalName}</p>
|
||||||
|
<p className="text-xs text-gray-400">{formatFileSize(file.size)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeAttachment(index)}
|
||||||
|
className="text-red-400 hover:text-red-300 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 flex justify-end space-x-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-gray-300 hover:text-white"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-gold-500 hover:bg-gold-600 text-navy-900"
|
||||||
|
>
|
||||||
|
{loading ? 'Menyimpan...' : 'Buat Diskusi'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
app/[locale]/(app)/dashboard/forum/umum/page.tsx
Normal file
99
app/[locale]/(app)/dashboard/forum/umum/page.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* File: page.tsx
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Forum list page for kreatiVortex platform
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Link } from '@/i18n/routing';
|
||||||
|
import ActionButton from '@/components/ActionButton';
|
||||||
|
import { useFetch } from '@/hooks/useFetch';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
interface ForumData {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
creator: {
|
||||||
|
user: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
_count: {
|
||||||
|
posts: number;
|
||||||
|
};
|
||||||
|
updatedAt: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ForumPage() {
|
||||||
|
const t = useTranslations('Forum');
|
||||||
|
const { data: forums, loading } = useFetch<ForumData[]>('/api/forums?type=GENERAL');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">{t('generalForum')}</h1>
|
||||||
|
<p className="text-gray-400">{t('generalForumSubtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/dashboard/forum/new">
|
||||||
|
<ActionButton>
|
||||||
|
{t('createButton')}
|
||||||
|
</ActionButton>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-xl border border-white/20 overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center text-gray-400 py-12">Loading...</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-white/10">
|
||||||
|
{forums?.map((forum) => (
|
||||||
|
<div key={forum.id} className="p-6 hover:bg-white/5 transition-colors">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center space-x-2 mb-1">
|
||||||
|
<span className="px-2 py-0.5 bg-white/10 text-gray-400 text-xs rounded border border-white/10">
|
||||||
|
{forum.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Link href={`/dashboard/forum/detail/${forum.id}`}>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2 hover:text-gold-400 transition-colors">
|
||||||
|
{forum.title}
|
||||||
|
</h3>
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center text-sm text-gray-400 space-x-4">
|
||||||
|
<span className="flex items-center">
|
||||||
|
<span className="w-5 h-5 bg-gray-600 rounded-full flex items-center justify-center text-xs text-white font-bold mr-2">
|
||||||
|
{forum.creator.user.name.charAt(0)}
|
||||||
|
</span>
|
||||||
|
{forum.creator.user.name}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{new Date(forum.updatedAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-6 text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-semibold text-white">{forum._count.posts}</div>
|
||||||
|
<div className="text-xs">{t('posts')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(!forums || forums.length === 0) && (
|
||||||
|
<div className="text-center py-12 text-gray-400">
|
||||||
|
{t('noForums')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
app/[locale]/(app)/dashboard/layout.tsx
Normal file
82
app/[locale]/(app)/dashboard/layout.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* File: layout.tsx
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Dashboard layout for kreatiVortex platform
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { DashboardProfile, DashboardMenu } from '@/components/Layouts';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export default async function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect('/auth/signin');
|
||||||
|
}
|
||||||
|
|
||||||
|
const menus = await prisma.menu.findMany({
|
||||||
|
where: {
|
||||||
|
parentId: null,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
children: {
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<div className="floating-background dark"></div>
|
||||||
|
<div className="container mx-auto relative z-10 flex min-h-screen">
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<header className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-2xl font-semibold text-white">
|
||||||
|
KreatiVortex
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className='flex'>
|
||||||
|
<DashboardMenu menus={menus as any} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<DashboardProfile />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="p-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
271
app/[locale]/(app)/dashboard/menu/_components/MenuClient.tsx
Normal file
271
app/[locale]/(app)/dashboard/menu/_components/MenuClient.tsx
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { createMenu, updateMenu, deleteMenu } from '../actions';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Pencil, Trash2, Plus, X } from 'lucide-react';
|
||||||
|
|
||||||
|
// Schema for form - will be created dynamically with translations
|
||||||
|
const createFormSchema = (translations: Record<string, string>) => z.object({
|
||||||
|
nameId: z.string().min(1, translations.nameIdRequired),
|
||||||
|
nameEn: z.string().optional(),
|
||||||
|
descriptionId: z.string().min(1, translations.descriptionIdRequired),
|
||||||
|
descriptionEn: z.string().optional(),
|
||||||
|
slug: z.string().min(1, translations.slugRequired),
|
||||||
|
parentId: z.string().optional().nullable(),
|
||||||
|
isActive: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Menu = {
|
||||||
|
id: string;
|
||||||
|
name: any;
|
||||||
|
slug: string;
|
||||||
|
description: any;
|
||||||
|
parentId: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
children?: Menu[];
|
||||||
|
parent?: { name: any } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormData = {
|
||||||
|
nameId: string;
|
||||||
|
nameEn?: string;
|
||||||
|
descriptionId: string;
|
||||||
|
descriptionEn?: string;
|
||||||
|
slug: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MenuClient({ initialMenus, translations }: { initialMenus: Menu[]; translations: Record<string, string> }) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [currentMenu, setCurrentMenu] = useState<Menu | null>(null);
|
||||||
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
|
|
||||||
|
const formSchema = createFormSchema(translations);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
setValue,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<FormData>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: FormData) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('nameId', data.nameId);
|
||||||
|
if (data.nameEn) formData.append('nameEn', data.nameEn);
|
||||||
|
formData.append('descriptionId', data.descriptionId);
|
||||||
|
if (data.descriptionEn) formData.append('descriptionEn', data.descriptionEn);
|
||||||
|
formData.append('slug', data.slug);
|
||||||
|
if (data.parentId) formData.append('parentId', data.parentId);
|
||||||
|
else formData.append('parentId', 'null');
|
||||||
|
if (data.isActive) formData.append('isActive', 'on');
|
||||||
|
|
||||||
|
let result;
|
||||||
|
if (isEditing && currentMenu) {
|
||||||
|
result = await updateMenu(currentMenu.id, null, formData);
|
||||||
|
} else {
|
||||||
|
result = await createMenu(null, formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
resetForm();
|
||||||
|
} else {
|
||||||
|
alert(result.error || translations.errorOccurred);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (menu: Menu) => {
|
||||||
|
setCurrentMenu(menu);
|
||||||
|
setIsEditing(true);
|
||||||
|
setIsFormOpen(true);
|
||||||
|
|
||||||
|
setValue('nameId', menu.name.id || menu.name);
|
||||||
|
setValue('nameEn', menu.name.en || '');
|
||||||
|
setValue('descriptionId', menu.description.id || menu.description);
|
||||||
|
setValue('descriptionEn', menu.description.en || '');
|
||||||
|
setValue('slug', menu.slug);
|
||||||
|
setValue('parentId', menu.parentId || 'null');
|
||||||
|
setValue('isActive', menu.isActive);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (confirm(translations.confirmDelete)) {
|
||||||
|
await deleteMenu(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
reset({
|
||||||
|
isActive: true,
|
||||||
|
parentId: 'null'
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
setCurrentMenu(null);
|
||||||
|
setIsFormOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLocalizedName = (name: any) => {
|
||||||
|
if (typeof name === 'string') return name;
|
||||||
|
return name.id || name.en || JSON.stringify(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-2xl font-bold text-white">{translations.title}</h2>
|
||||||
|
<Button onClick={() => { resetForm(); setIsFormOpen(true); }} className="bg-gold-500 hover:bg-gold-600 text-black">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
{translations.addButton}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isFormOpen && (
|
||||||
|
<div className="bg-white/5 border border-white/10 p-6 rounded-xl animate-in fade-in slide-in-from-top-4">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-xl font-semibold text-white">
|
||||||
|
{isEditing ? translations.editTitle : translations.addTitle}
|
||||||
|
</h3>
|
||||||
|
<Button variant="ghost" size="icon" onClick={resetForm}>
|
||||||
|
<X className="w-4 h-4 text-gray-400" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nameId" className="text-gray-200">{translations.nameIdLabel}</Label>
|
||||||
|
<Input id="nameId" {...register('nameId')} className="bg-white/5 border-white/10 text-white" />
|
||||||
|
{errors.nameId && <p className="text-red-400 text-sm">{errors.nameId.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nameEn" className="text-gray-200">{translations.nameEnLabel}</Label>
|
||||||
|
<Input id="nameEn" {...register('nameEn')} className="bg-white/5 border-white/10 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="descriptionId" className="text-gray-200">{translations.descriptionIdLabel}</Label>
|
||||||
|
<Textarea id="descriptionId" {...register('descriptionId')} className="bg-white/5 border-white/10 text-white" />
|
||||||
|
{errors.descriptionId && <p className="text-red-400 text-sm">{errors.descriptionId.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="descriptionEn" className="text-gray-200">{translations.descriptionEnLabel}</Label>
|
||||||
|
<Textarea id="descriptionEn" {...register('descriptionEn')} className="bg-white/5 border-white/10 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="slug" className="text-gray-200">{translations.slugLabel}</Label>
|
||||||
|
<Input id="slug" {...register('slug')} className="bg-white/5 border-white/10 text-white" />
|
||||||
|
{errors.slug && <p className="text-red-400 text-sm">{errors.slug.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="parentId" className="text-gray-200">{translations.parentLabel}</Label>
|
||||||
|
<select
|
||||||
|
id="parentId"
|
||||||
|
{...register('parentId')}
|
||||||
|
className="w-full h-10 px-3 py-2 rounded-md border border-white/10 bg-white/5 text-white focus:outline-none focus:ring-2 focus:ring-gold-500"
|
||||||
|
>
|
||||||
|
<option value="null" className="bg-gray-900">{translations.parentNone}</option>
|
||||||
|
{initialMenus.map((menu) => (
|
||||||
|
<option key={menu.id} value={menu.id} className="bg-gray-900">
|
||||||
|
{getLocalizedName(menu.name)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="isActive"
|
||||||
|
{...register('isActive')}
|
||||||
|
className="w-4 h-4 rounded border-gray-300 text-gold-600 focus:ring-gold-500"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="isActive" className="text-gray-200">{translations.activeLabel}</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 pt-4">
|
||||||
|
<Button type="button" variant="outline" onClick={resetForm} className="border-white/10 text-gray-300 hover:bg-white/10 hover:text-white">
|
||||||
|
{translations.cancelButton}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting} className="bg-gold-500 hover:bg-gold-600 text-black">
|
||||||
|
{isSubmitting ? translations.saving : (isEditing ? translations.saveButton : translations.createButton)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-white/10 bg-white/5">
|
||||||
|
<th className="p-4 text-gray-300 font-medium">{translations.tableName}</th>
|
||||||
|
<th className="p-4 text-gray-300 font-medium">{translations.tableSlug}</th>
|
||||||
|
<th className="p-4 text-gray-300 font-medium">{translations.tableParent}</th>
|
||||||
|
<th className="p-4 text-gray-300 font-medium">{translations.tableStatus}</th>
|
||||||
|
<th className="p-4 text-gray-300 font-medium text-right">{translations.tableActions}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{initialMenus.map((menu) => (
|
||||||
|
<tr key={menu.id} className="hover:bg-white/5 transition-colors">
|
||||||
|
<td className="p-4 text-white font-medium">
|
||||||
|
{getLocalizedName(menu.name)}
|
||||||
|
<div className="text-xs text-gray-500 mt-1 truncate max-w-[200px]">
|
||||||
|
{getLocalizedName(menu.description)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-gray-300">{menu.slug}</td>
|
||||||
|
<td className="p-4 text-gray-300">
|
||||||
|
{menu.parent ? getLocalizedName(menu.parent.name) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${menu.isActive ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'
|
||||||
|
}`}>
|
||||||
|
{menu.isActive ? translations.statusActive : translations.statusInactive}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-right space-x-2">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleEdit(menu)} className="hover:bg-white/10 hover:text-gold-400">
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleDelete(menu.id)} className="hover:bg-white/10 hover:text-red-400">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{initialMenus.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="p-8 text-center text-gray-500">
|
||||||
|
{translations.noMenus}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
app/[locale]/(app)/dashboard/menu/actions.ts
Normal file
119
app/[locale]/(app)/dashboard/menu/actions.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const MenuSchema = z.object({
|
||||||
|
nameId: z.string().min(1, "Nama (ID) wajib diisi"),
|
||||||
|
nameEn: z.string().optional(),
|
||||||
|
descriptionId: z.string().min(1, "Deskripsi (ID) wajib diisi"),
|
||||||
|
descriptionEn: z.string().optional(),
|
||||||
|
slug: z.string().min(1, "Slug wajib diisi"),
|
||||||
|
parentId: z.string().optional().nullable(),
|
||||||
|
isActive: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createMenu(prevState: any, formData: FormData) {
|
||||||
|
const validatedFields = MenuSchema.safeParse({
|
||||||
|
nameId: formData.get("nameId"),
|
||||||
|
nameEn: formData.get("nameEn"),
|
||||||
|
descriptionId: formData.get("descriptionId"),
|
||||||
|
descriptionEn: formData.get("descriptionEn"),
|
||||||
|
slug: formData.get("slug"),
|
||||||
|
parentId: formData.get("parentId") === "null" ? null : formData.get("parentId"),
|
||||||
|
isActive: formData.get("isActive") === "on",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!validatedFields.success) {
|
||||||
|
return {
|
||||||
|
error: validatedFields.error.flatten().fieldErrors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { nameId, nameEn, descriptionId, descriptionEn, slug, parentId, isActive } = validatedFields.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.menu.create({
|
||||||
|
data: {
|
||||||
|
name: {
|
||||||
|
id: nameId,
|
||||||
|
en: nameEn || nameId,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
id: descriptionId,
|
||||||
|
en: descriptionEn || descriptionId,
|
||||||
|
},
|
||||||
|
slug,
|
||||||
|
parentId,
|
||||||
|
isActive,
|
||||||
|
updatedBy: "system", // TODO: Get actual user
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/dashboard/menu");
|
||||||
|
return { success: true, message: "Menu berhasil dibuat" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create menu:", error);
|
||||||
|
return { error: "Gagal membuat menu. Pastikan slug unik." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMenu(id: string, prevState: any, formData: FormData) {
|
||||||
|
const validatedFields = MenuSchema.safeParse({
|
||||||
|
nameId: formData.get("nameId"),
|
||||||
|
nameEn: formData.get("nameEn"),
|
||||||
|
descriptionId: formData.get("descriptionId"),
|
||||||
|
descriptionEn: formData.get("descriptionEn"),
|
||||||
|
slug: formData.get("slug"),
|
||||||
|
parentId: formData.get("parentId") === "null" ? null : formData.get("parentId"),
|
||||||
|
isActive: formData.get("isActive") === "on",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!validatedFields.success) {
|
||||||
|
return {
|
||||||
|
error: validatedFields.error.flatten().fieldErrors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { nameId, nameEn, descriptionId, descriptionEn, slug, parentId, isActive } = validatedFields.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.menu.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: {
|
||||||
|
id: nameId,
|
||||||
|
en: nameEn || nameId,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
id: descriptionId,
|
||||||
|
en: descriptionEn || descriptionId,
|
||||||
|
},
|
||||||
|
slug,
|
||||||
|
parentId,
|
||||||
|
isActive,
|
||||||
|
updatedBy: "system", // TODO: Get actual user
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/dashboard/menu");
|
||||||
|
return { success: true, message: "Menu berhasil diperbarui" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update menu:", error);
|
||||||
|
return { error: "Gagal memperbarui menu." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMenu(id: string) {
|
||||||
|
try {
|
||||||
|
await prisma.menu.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
revalidatePath("/dashboard/menu");
|
||||||
|
return { success: true, message: "Menu berhasil dihapus" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete menu:", error);
|
||||||
|
return { error: "Gagal menghapus menu" };
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/[locale]/(app)/dashboard/menu/page.tsx
Normal file
58
app/[locale]/(app)/dashboard/menu/page.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import MenuClient from "./_components/MenuClient";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
export default async function MenuPage() {
|
||||||
|
const t = await getTranslations('Menu');
|
||||||
|
const menus = await prisma.menu.findMany({
|
||||||
|
include: {
|
||||||
|
parent: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract all needed translations as a plain object
|
||||||
|
const translations = {
|
||||||
|
title: t('title'),
|
||||||
|
addButton: t('addButton'),
|
||||||
|
editTitle: t('editTitle'),
|
||||||
|
addTitle: t('addTitle'),
|
||||||
|
nameIdLabel: t('nameIdLabel'),
|
||||||
|
nameEnLabel: t('nameEnLabel'),
|
||||||
|
descriptionIdLabel: t('descriptionIdLabel'),
|
||||||
|
descriptionEnLabel: t('descriptionEnLabel'),
|
||||||
|
slugLabel: t('slugLabel'),
|
||||||
|
parentLabel: t('parentLabel'),
|
||||||
|
parentNone: t('parentNone'),
|
||||||
|
activeLabel: t('activeLabel'),
|
||||||
|
cancelButton: t('cancelButton'),
|
||||||
|
saveButton: t('saveButton'),
|
||||||
|
createButton: t('createButton'),
|
||||||
|
saving: t('saving'),
|
||||||
|
tableName: t('tableName'),
|
||||||
|
tableSlug: t('tableSlug'),
|
||||||
|
tableParent: t('tableParent'),
|
||||||
|
tableStatus: t('tableStatus'),
|
||||||
|
tableActions: t('tableActions'),
|
||||||
|
statusActive: t('statusActive'),
|
||||||
|
statusInactive: t('statusInactive'),
|
||||||
|
noMenus: t('noMenus'),
|
||||||
|
confirmDelete: t('confirmDelete'),
|
||||||
|
errorOccurred: t('errorOccurred'),
|
||||||
|
nameIdRequired: t('nameIdRequired'),
|
||||||
|
descriptionIdRequired: t('descriptionIdRequired'),
|
||||||
|
slugRequired: t('slugRequired'),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8">
|
||||||
|
<MenuClient initialMenus={menus} translations={translations} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
app/[locale]/(app)/dashboard/page.tsx
Normal file
115
app/[locale]/(app)/dashboard/page.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
export default async function Dashboard() {
|
||||||
|
const t = await getTranslations('Dashboard');
|
||||||
|
|
||||||
|
// Get user session and profile
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers()
|
||||||
|
});
|
||||||
|
|
||||||
|
let userProfile = null;
|
||||||
|
if (session?.user) {
|
||||||
|
userProfile = await prisma.userProfile.findUnique({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
include: { role: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("User Profile:", userProfile);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-2">
|
||||||
|
{t('welcomeTitle')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-300">
|
||||||
|
{t('welcomeSubtitle')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Show role registration options for general users */}
|
||||||
|
{userProfile?.role?.name === 'UMUM' || userProfile === null && (
|
||||||
|
<div className="mt-6 bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-3">Upgrade Akun Anda</h3>
|
||||||
|
<p className="text-gray-400 mb-4">
|
||||||
|
Pilih role untuk mengakses fitur lengkap kreatiVortex
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<a
|
||||||
|
href="/dashboard/register-role"
|
||||||
|
className="inline-flex items-center justify-center px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
|
</svg>
|
||||||
|
Daftar sebagai Calon Pendidik
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/dashboard/register-role"
|
||||||
|
className="inline-flex items-center justify-center px-4 py-2 bg-green-500 hover:bg-green-600 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a2 2 0 012-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm7-2a2 2 0 11-4 0v4a2 2 0 014 0V9z" />
|
||||||
|
</svg>
|
||||||
|
Daftar sebagai Pendidik
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Activity */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{/* Recent Videos */}
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">{t('recentVideosTitle')}</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="w-16 h-12 bg-gold-400/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg className="w-6 h-6 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-white font-medium">{t('sampleVideo1')}</h4>
|
||||||
|
<p className="text-sm text-gray-400">{t('timeAgo', {hours: 2})}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="w-16 h-12 bg-gold-400/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg className="w-6 h-6 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-white font-medium">{t('sampleVideo2')}</h4>
|
||||||
|
<p className="text-sm text-gray-400">{t('timeAgo', {hours: 5})}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Forum Posts */}
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">{t('recentDiscussionsTitle')}</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border-l border-gold-400 pl-4">
|
||||||
|
<h4 className="text-white font-medium mb-1">{t('sampleDiscussion1Title')}</h4>
|
||||||
|
<p className="text-sm text-gray-400 mb-2">{t('sampleDiscussion1Content')}</p>
|
||||||
|
<p className="text-xs text-gray-500">Oleh Sarah Pendidik • {t('timeAgo', {hours: 1})}</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-l border-gold-400 pl-4">
|
||||||
|
<h4 className="text-white font-medium mb-1">{t('sampleDiscussion2Title')}</h4>
|
||||||
|
<p className="text-sm text-gray-400 mb-2">{t('sampleDiscussion2Content')}</p>
|
||||||
|
<p className="text-xs text-gray-500">Oleh Budi Student • {t('timeAgo', {hours: 3})}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
app/[locale]/(app)/dashboard/pages/[slug]/page.tsx
Normal file
93
app/[locale]/(app)/dashboard/pages/[slug]/page.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getYouTubeId } from "@/lib/utils";
|
||||||
|
import { getLocale } from "next-intl/server";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { use } from "react";
|
||||||
|
|
||||||
|
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const locale = await getLocale();
|
||||||
|
|
||||||
|
const menu = await prisma.menu.findUnique({
|
||||||
|
where: {
|
||||||
|
slug: slug,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
videos: {
|
||||||
|
where: {
|
||||||
|
isPublic: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!menu) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLocalizedText = (json: any) => {
|
||||||
|
if (!json) return '';
|
||||||
|
return json[locale] || json['id'] || json['en'] || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 max-w-4xl mx-auto">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-3xl font-bold text-white">
|
||||||
|
{getLocalizedText(menu.name)}
|
||||||
|
</h1>
|
||||||
|
<div className="text-gray-300 text-lg" dangerouslySetInnerHTML={{ __html: getLocalizedText(menu.description).replace(/\n/g, '<br>') }}></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{menu.videos.length > 0 ? (
|
||||||
|
menu.videos.map((video) => {
|
||||||
|
const youtubeId = video?.videoUrl ? getYouTubeId(video.videoUrl) : null;
|
||||||
|
const embedUrl = youtubeId ? `https://www.youtube.com/embed/${youtubeId}` : video?.videoUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={video.id} className="bg-white/5 border border-white/10 rounded-xl overflow-hidden hover:border-white/20 transition-colors">
|
||||||
|
{/* Video Thumbnail/Embed Placeholder */}
|
||||||
|
<div className="aspect-video bg-black/50 relative">
|
||||||
|
<div className="aspect-video bg-black rounded-xl overflow-hidden border border-white/10 shadow-2xl">
|
||||||
|
{youtubeId ? (
|
||||||
|
<iframe
|
||||||
|
src={embedUrl}
|
||||||
|
title={video.title}
|
||||||
|
className="w-full h-full"
|
||||||
|
allowFullScreen
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
></iframe>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-500">
|
||||||
|
Video format not supported or invalid URL
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-2">
|
||||||
|
<h3 className="text-xl font-semibold text-white line-clamp-1">
|
||||||
|
{video.title}
|
||||||
|
</h3>
|
||||||
|
{video.description && (
|
||||||
|
<p className="text-gray-400 text-sm line-clamp-2">
|
||||||
|
{video.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="col-span-full text-center py-12 text-gray-400 bg-white/5 rounded-xl border border-white/10 border-dashed">
|
||||||
|
<p>Belum ada video di menu ini.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
257
app/[locale]/(app)/dashboard/register-role/page.tsx
Normal file
257
app/[locale]/(app)/dashboard/register-role/page.tsx
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
/**
|
||||||
|
* File: page.tsx
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Role registration page for kreatiVortex platform
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
export default function RegisterRolePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedRole, setSelectedRole] = useState('');
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
institution: '',
|
||||||
|
className: '', // This will store class code for students
|
||||||
|
teachingLevel: '',
|
||||||
|
purpose: '',
|
||||||
|
nim: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/register-role', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
role: selectedRole,
|
||||||
|
...formData,
|
||||||
|
}),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
router.push('/dashboard');
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
console.error('Failed to register role:', result.message);
|
||||||
|
// You could show an error message to the user here
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error registering role:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoleSelect = (role: string) => {
|
||||||
|
setSelectedRole(role);
|
||||||
|
setFormData({
|
||||||
|
institution: '',
|
||||||
|
className: '',
|
||||||
|
teachingLevel: '',
|
||||||
|
purpose: '',
|
||||||
|
nim: '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-2">Pilih Role Anda</h1>
|
||||||
|
<p className="text-gray-400">Upgrade akun Anda untuk mengakses fitur lengkap</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!selectedRole ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20 hover:bg-white/15 transition-all cursor-pointer" onClick={() => handleRoleSelect('CALON_PENDIDIK')}>
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg className="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">Calon Pendidik</h3>
|
||||||
|
<p className="text-gray-400 text-sm mb-3">Bergabung sebagai peserta didik untuk mengakses materi pembelajaran, mengerjakan tugas, dan berpartisipasi dalam forum diskusi kelas.</p>
|
||||||
|
<ul className="text-gray-300 text-sm space-y-1">
|
||||||
|
<li>• Akses video pembelajaran</li>
|
||||||
|
<li>• Bergabung dengan kelas</li>
|
||||||
|
<li>• Mengerjakan tugas</li>
|
||||||
|
<li>• Forum diskusi kelas</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20 hover:bg-white/15 transition-all cursor-pointer" onClick={() => handleRoleSelect('PENDIDIK')}>
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a2 2 0 012-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm7-2a2 2 0 11-4 0v4a2 2 0 014 0V9z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">Pendidik</h3>
|
||||||
|
<p className="text-gray-400 text-sm mb-3">Daftar sebagai instruktur untuk membuat kelas, mengelola konten pembelajaran, dan membimbing calon pendidik.</p>
|
||||||
|
<ul className="text-gray-300 text-sm space-y-1">
|
||||||
|
<li>• Buat dan kelola kelas</li>
|
||||||
|
<li>• Upload video pembelajaran</li>
|
||||||
|
<li>• Berikan tugas dan revisi</li>
|
||||||
|
<li>• Moderasi forum kelas</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="bg-white/5 rounded-lg p-4 mb-6">
|
||||||
|
<p className="text-sm text-gray-300">
|
||||||
|
Anda memilih role: <span className="font-semibold text-white">
|
||||||
|
{selectedRole === 'CALON_PENDIDIK' ? 'Calon Pendidik' : 'Pendidik'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedRole('')}
|
||||||
|
className="text-sm text-gold-400 hover:text-gold-300 mt-2"
|
||||||
|
>
|
||||||
|
← Pilih role lain
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="institution" className="text-gray-300">
|
||||||
|
Instansi
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="institution"
|
||||||
|
value={formData.institution}
|
||||||
|
onChange={(e) => setFormData({ ...formData, institution: e.target.value })}
|
||||||
|
required
|
||||||
|
placeholder="Nama instansi pendidikan Anda"
|
||||||
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedRole === 'CALON_PENDIDIK' ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="classCode" className="text-gray-300">
|
||||||
|
Kode Kelas
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="classCode"
|
||||||
|
value={formData.className}
|
||||||
|
onChange={(e) => setFormData({ ...formData, className: e.target.value })}
|
||||||
|
required
|
||||||
|
placeholder="Masukkan kode kelas yang diberikan oleh pendidik"
|
||||||
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Hubungi pendidik Anda untuk mendapatkan kode kelas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nim" className="text-gray-300">
|
||||||
|
NIM (Nomor Induk Mahasiswa)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="nim"
|
||||||
|
value={formData.nim}
|
||||||
|
onChange={(e) => setFormData({ ...formData, nim: e.target.value })}
|
||||||
|
required
|
||||||
|
placeholder="Masukkan NIM Anda"
|
||||||
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="className" className="text-gray-300">
|
||||||
|
Nama Kelas
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="className"
|
||||||
|
value={formData.className}
|
||||||
|
onChange={(e) => setFormData({ ...formData, className: e.target.value })}
|
||||||
|
required
|
||||||
|
placeholder="Nama kelas yang akan Anda buat"
|
||||||
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="teachingLevel" className="text-gray-300">
|
||||||
|
Jenjang Mengajar
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="teachingLevel"
|
||||||
|
rows={3}
|
||||||
|
value={formData.teachingLevel}
|
||||||
|
onChange={(e) => setFormData({ ...formData, teachingLevel: e.target.value })}
|
||||||
|
required
|
||||||
|
placeholder="Jelaskan jenjang mengajar Anda (contoh: Tari Tradisional, Tari Modern, dll)"
|
||||||
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="purpose" className="text-gray-300">
|
||||||
|
Tujuan Bergabung
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="purpose"
|
||||||
|
rows={3}
|
||||||
|
value={formData.purpose}
|
||||||
|
onChange={(e) => setFormData({ ...formData, purpose: e.target.value })}
|
||||||
|
required
|
||||||
|
placeholder="Jelaskan tujuan Anda bergabung sebagai pendidik"
|
||||||
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-4 flex justify-end space-x-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setSelectedRole('')}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-gray-300 hover:text-white"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-gold-500 hover:bg-gold-600 text-navy-900"
|
||||||
|
>
|
||||||
|
{loading ? 'Mendaftar...' : 'Daftar Sekarang'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
app/[locale]/(app)/dashboard/videos/[id]/edit/page.tsx
Normal file
67
app/[locale]/(app)/dashboard/videos/[id]/edit/page.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import VideoForm from '@/components/Forms/VideoForm';
|
||||||
|
import { updateVideo } from '@/app/actions/video';
|
||||||
|
import { useFetch } from '@/hooks/useFetch';
|
||||||
|
|
||||||
|
interface Video {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
videoUrl: string;
|
||||||
|
videoType: 'YOUTUBE' | 'LOCAL';
|
||||||
|
isPublic: boolean;
|
||||||
|
menuId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Menu {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditVideoPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const id = params?.id as string;
|
||||||
|
|
||||||
|
const { data: video, loading } = useFetch<Video>(`/api/videos/${id}`);
|
||||||
|
const { data: menusData } = useFetch<Menu[]>('/api/menus');
|
||||||
|
const handleSubmit = async (data: any) => {
|
||||||
|
try {
|
||||||
|
await updateVideo(id, data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating video:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading || !video || !menusData) {
|
||||||
|
return <div className="text-center text-gray-400 py-12">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialData = {
|
||||||
|
title: video.title,
|
||||||
|
description: video.description || '',
|
||||||
|
videoUrl: video.videoUrl,
|
||||||
|
videoType: video.videoType,
|
||||||
|
isPublic: video.isPublic,
|
||||||
|
menuId: video.menuId || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Edit Video</h1>
|
||||||
|
<p className="text-gray-400">Perbarui informasi video pembelajaran</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VideoForm
|
||||||
|
initialData={initialData}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isEditing={true}
|
||||||
|
menus={menusData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
app/[locale]/(app)/dashboard/videos/[id]/page.tsx
Normal file
193
app/[locale]/(app)/dashboard/videos/[id]/page.tsx
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* File: page.tsx
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Video player page for kreatiVortex platform
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import ActionButton from '@/components/ActionButton';
|
||||||
|
import { useFetch } from '@/hooks/useFetch';
|
||||||
|
import { getYouTubeId } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface VideoDetail {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
videoUrl: string;
|
||||||
|
viewCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
uploader: {
|
||||||
|
user: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
comments: Comment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Comment {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
author: {
|
||||||
|
user: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VideoDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const id = params?.id as string;
|
||||||
|
const { data: video, loading, refetch } = useFetch<VideoDetail>(`/api/videos/${id}`);
|
||||||
|
const [commentText, setCommentText] = useState('');
|
||||||
|
const [submittingComment, setSubmittingComment] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmitComment = async () => {
|
||||||
|
if (!commentText.trim()) return;
|
||||||
|
|
||||||
|
setSubmittingComment(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/comments', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: commentText,
|
||||||
|
videoId: id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setCommentText('');
|
||||||
|
refetch(); // Refresh comments
|
||||||
|
} else {
|
||||||
|
console.error('Failed to post comment');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error posting comment:', error);
|
||||||
|
} finally {
|
||||||
|
setSubmittingComment(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const youtubeId = video?.videoUrl ? getYouTubeId(video.videoUrl) : null;
|
||||||
|
const embedUrl = youtubeId ? `https://www.youtube.com/embed/${youtubeId}` : video?.videoUrl;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-center text-gray-400 py-12">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return <div className="text-center text-gray-400 py-12">Video not found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Link href="/dashboard/videos" className="text-gray-400 hover:text-white flex items-center">
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
Kembali ke Daftar Video
|
||||||
|
</Link>
|
||||||
|
<Link href={`/dashboard/videos/${id}/edit`}>
|
||||||
|
<ActionButton variant="outline" size="sm">
|
||||||
|
Edit Video
|
||||||
|
</ActionButton>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video Player */}
|
||||||
|
<div className="aspect-video bg-black rounded-xl overflow-hidden border border-white/10 shadow-2xl">
|
||||||
|
{youtubeId ? (
|
||||||
|
<iframe
|
||||||
|
src={embedUrl}
|
||||||
|
title={video.title}
|
||||||
|
className="w-full h-full"
|
||||||
|
allowFullScreen
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
></iframe>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-500">
|
||||||
|
Video format not supported or invalid URL
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video Info */}
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-2">{video.title}</h1>
|
||||||
|
<div className="flex items-center text-sm text-gray-400 space-x-4">
|
||||||
|
<span>Diunggah oleh <span className="text-gold-400">{video.uploader.user.name}</span></span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{new Date(video.createdAt).toLocaleDateString()}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{video.viewCount} kali ditonton</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="prose prose-invert max-w-none">
|
||||||
|
<div className="text-gray-300 leading-relaxed" dangerouslySetInnerHTML={{ __html: video.description.replace(/\n/g, '<br/>') }}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments Section */}
|
||||||
|
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">Komentar</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{video.comments?.map((comment) => (
|
||||||
|
<div key={comment.id} className="flex space-x-4">
|
||||||
|
<div className="w-10 h-10 bg-gold-400 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-navy-900 font-bold">{comment.author.user.name.charAt(0)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="bg-white/5 rounded-lg p-3">
|
||||||
|
<div className="flex justify-between items-start mb-1">
|
||||||
|
<span className="font-medium text-white">{comment.author.user.name}</span>
|
||||||
|
<span className="text-xs text-gray-500">{new Date(comment.createdAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-300 text-sm">{comment.content}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(!video.comments || video.comments.length === 0) && (
|
||||||
|
<p className="text-gray-400 text-sm">Belum ada komentar.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comment Form */}
|
||||||
|
<div className="mt-6 flex space-x-4">
|
||||||
|
<div className="w-10 h-10 bg-gray-600 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-white font-bold">ME</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={commentText}
|
||||||
|
onChange={(e) => setCommentText(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 border rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-gold-400 placeholder-gray-500"
|
||||||
|
placeholder="Tulis komentar..."
|
||||||
|
></textarea>
|
||||||
|
<div className="mt-2 flex justify-end">
|
||||||
|
<ActionButton size="sm" onClick={handleSubmitComment} loading={submittingComment} disabled={!commentText.trim()}>
|
||||||
|
Kirim
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
app/[locale]/(app)/dashboard/videos/new/page.tsx
Normal file
44
app/[locale]/(app)/dashboard/videos/new/page.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import VideoForm from '@/components/Forms/VideoForm';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { getLocale } from 'next-intl/server';
|
||||||
|
import { createVideo } from '@/app/actions/video';
|
||||||
|
|
||||||
|
export default async function NewVideoPage() {
|
||||||
|
const locale = await getLocale();
|
||||||
|
|
||||||
|
const menus = await prisma.menu.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
include: {
|
||||||
|
parent: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const getLocalizedName = (json: any) => {
|
||||||
|
if (!json) return '';
|
||||||
|
return json[locale] || json['id'] || json['en'] || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formattedMenus = menus.map((menu) => {
|
||||||
|
const name = getLocalizedName(menu.name);
|
||||||
|
const parentName = menu.parent ? getLocalizedName(menu.parent.name) : '';
|
||||||
|
return {
|
||||||
|
id: menu.id,
|
||||||
|
title: parentName ? `${parentName} > ${name}` : name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by title for better UX
|
||||||
|
formattedMenus.sort((a, b) => a.title.localeCompare(b.title));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Upload Video Baru</h1>
|
||||||
|
<p className="text-gray-400">Tambahkan video pembelajaran baru ke koleksi Anda</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VideoForm menus={formattedMenus} onSubmit={createVideo} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
335
app/[locale]/(app)/dashboard/videos/page.tsx
Normal file
335
app/[locale]/(app)/dashboard/videos/page.tsx
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
/**
|
||||||
|
* File: page.tsx
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Video list page for kreatiVortex platform
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Link } from '@/i18n/routing';
|
||||||
|
import ActionButton from '@/components/ActionButton';
|
||||||
|
import { useFetch } from '@/hooks/useFetch';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { generateYoutubeEmbedUrl } from '@/lib/youtube';
|
||||||
|
import { PlayIcon } from 'lucide-react';
|
||||||
|
import { canUploadVideos } from '@/lib/admin';
|
||||||
|
|
||||||
|
interface VideoData extends Record<string, unknown> {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
videoType: 'YOUTUBE' | 'LOCAL';
|
||||||
|
viewCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
isPublic: boolean;
|
||||||
|
uploaderId: string;
|
||||||
|
uploader: {
|
||||||
|
user: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VideosPage() {
|
||||||
|
const t = useTranslations('Videos');
|
||||||
|
const { data: videos, loading } = useFetch<VideoData[]>('/api/videos');
|
||||||
|
const [userProfile, setUserProfile] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch user profile to check permissions
|
||||||
|
const fetchProfile = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/profile', {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setUserProfile(data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user profile:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchProfile();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDelete = async (videoId: string) => {
|
||||||
|
if (!confirm('Apakah Anda yakin ingin menghapus video ini?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/videos/${videoId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Refresh the videos list
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Gagal menghapus video');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting video:', error);
|
||||||
|
alert('Gagal menghapus video');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderVideoActions = (video: any, userProfile: any, handleDelete: Function, t: Function) => {
|
||||||
|
const canEdit = userProfile && (
|
||||||
|
userProfile.role?.name === 'ADMIN' ||
|
||||||
|
userProfile.role?.name === 'PENDIDIK' ||
|
||||||
|
video.uploaderId === userProfile.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const canDelete = userProfile && (
|
||||||
|
userProfile.role?.name === 'ADMIN' ||
|
||||||
|
video.uploaderId === userProfile.id
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/videos/${video.id}`}
|
||||||
|
className="inline-flex items-center px-3 py-2 bg-gold-500 hover:bg-gold-600 text-navy-900 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
{t('actionView')}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/videos/${video.id}/edit`}
|
||||||
|
className="inline-flex items-center px-3 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002 2v-4a1 1 0 011-1h7a1 1 0 011-1v-4a2 2 0 002-2H7a2 2 0 00-2 2v11a2 2 0 002-2z" />
|
||||||
|
</svg>
|
||||||
|
{t('actionEdit')}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canDelete && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(video.id)}
|
||||||
|
className="inline-flex items-center px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg text-sm font-medium transition-colors"
|
||||||
|
title={t('actionDelete')}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0015-1.414l-7-7A2 2 0 003 7v10a2 2 0 002 2h8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
{t('actionDelete')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">{t('title')}</h1>
|
||||||
|
<p className="text-gray-400">{t('subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
{canUploadVideos(userProfile) && (
|
||||||
|
<Link href="/dashboard/videos/new">
|
||||||
|
<ActionButton>
|
||||||
|
{t('uploadButton')}
|
||||||
|
</ActionButton>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center text-gray-400 py-12">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gold-400"></div>
|
||||||
|
<p className="mt-4">{t('loading')}</p>
|
||||||
|
</div>
|
||||||
|
) : videos && videos.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
{videos.map((video) => (
|
||||||
|
<div key={video.id} className="bg-white/10 backdrop-blur-sm rounded-xl border border-white/20 overflow-hidden hover:bg-white/15 transition-all duration-300 group">
|
||||||
|
{/* Video Thumbnail */}
|
||||||
|
<div className="aspect-video bg-gray-900 relative overflow-hidden">
|
||||||
|
{video.videoType === 'YOUTUBE' ? (
|
||||||
|
<iframe
|
||||||
|
src={generateYoutubeEmbedUrl(video.videoUrl)}
|
||||||
|
className="w-full h-full"
|
||||||
|
allowFullScreen
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-gray-800">
|
||||||
|
<svg className="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-xs text-gray-500 mt-2">Video Preview</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Play Button Overlay */}
|
||||||
|
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Link href={`/dashboard/videos/${video.id}`} className="text-white hover:text-gold-400 transition-colors">
|
||||||
|
<PlayIcon className="w-12 h-12" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Badge */}
|
||||||
|
<div className="absolute top-4 right-4">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
|
video.isPublic
|
||||||
|
? 'bg-green-500 text-white'
|
||||||
|
: 'bg-yellow-500 text-white'
|
||||||
|
}`}>
|
||||||
|
{video.isPublic ? 'Public' : 'Private'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Play Button Overlay */}
|
||||||
|
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Link href={`/dashboard/videos/${video.id}`} className="text-white hover:text-gold-400 transition-colors">
|
||||||
|
<PlayIcon className="w-12 h-12" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Badge */}
|
||||||
|
<div className="absolute top-4 right-4">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
|
video.isPublic
|
||||||
|
? 'bg-green-500 text-white'
|
||||||
|
: 'bg-yellow-500 text-white'
|
||||||
|
}`}>
|
||||||
|
{video.isPublic ? 'Public' : 'Private'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video Info */}
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-start mb-3">
|
||||||
|
<h3 className="text-lg font-semibold text-white group-hover:text-gold-400 transition-colors line-clamp-2">
|
||||||
|
{video.title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center space-x-2 text-sm text-gray-400">
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
|
video.videoType === 'YOUTUBE'
|
||||||
|
? 'bg-red-900/50 text-red-200'
|
||||||
|
: 'bg-blue-900/50 text-blue-200'
|
||||||
|
}`}>
|
||||||
|
{video.videoType}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center">
|
||||||
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0l-3 3m0 0a3 3 0 016 0l3-3m6 0a3 3 0 016 0l-3 3" />
|
||||||
|
</svg>
|
||||||
|
{video.viewCount || 0} views
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-300 text-sm line-clamp-3 mb-4">
|
||||||
|
{video.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Video Actions */}
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t border-gray-700">
|
||||||
|
<div className="flex items-center space-x-2 text-sm text-gray-400">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0v4a4 4 0 014 0H6a4 4 0 00-4 4v4a4 4 0 014 0h8a4 4 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
{new Date(video.createdAt).toLocaleDateString('id-ID', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/videos/${video.id}`}
|
||||||
|
className="inline-flex items-center px-3 py-2 bg-gold-500 hover:bg-gold-600 text-navy-900 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0l-3 3m0 0a3 3 0 016 0l3-3m6 0a3 3 0 016 0l-3 3" />
|
||||||
|
</svg>
|
||||||
|
{t('actionView')}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const canEdit = userProfile && (
|
||||||
|
userProfile.role?.name === 'ADMIN' ||
|
||||||
|
userProfile.role?.name === 'PENDIDIK' ||
|
||||||
|
video.uploaderId === userProfile.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const canDelete = userProfile && (
|
||||||
|
userProfile.role?.name === 'ADMIN' ||
|
||||||
|
video.uploaderId === userProfile.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (canEdit) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/videos/${video.id}/edit`}
|
||||||
|
className="inline-flex items-center px-3 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-4a1 1 0 011-1h7a1 1 0 011 1v4a1 1 0 011-1h7a1 1 0 011-1v-4a2 2 0 00-2 2H7a2 2 0 00-2 2v11a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
{t('actionEdit')}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canDelete) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(video.id)}
|
||||||
|
className="inline-flex items-center px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg text-sm font-medium transition-colors"
|
||||||
|
title={t('actionDelete')}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0015-1.414l-7-7A2 2 0 003 7v10a2 2 0 002 2h8a2 2 0 002 2v10a2 2 0 002-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
{t('actionDelete')}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-gray-400 mb-4">
|
||||||
|
<svg className="w-16 h-16 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<h3 className="text-xl font-semibold text-white mb-2">{t('noVideos')}</h3>
|
||||||
|
<p className="text-gray-400 mb-6">{t('noVideosDesc')}</p>
|
||||||
|
{canUploadVideos(userProfile) && (
|
||||||
|
<Link href="/dashboard/videos/new">
|
||||||
|
<ActionButton>
|
||||||
|
{t('uploadFirst')}
|
||||||
|
</ActionButton>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
app/[locale]/auth/layout.tsx
Normal file
10
app/[locale]/auth/layout.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export default function AuthLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <div className="min-h-screen">
|
||||||
|
<div className="floating-background"></div>
|
||||||
|
{children}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@ -9,10 +9,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import { Link, useRouter } from '@/i18n/routing';
|
||||||
import { useRouter } from 'next/navigation';
|
import { authClient } from '@/lib/auth-client';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
export default function SignIn() {
|
export default function SignIn() {
|
||||||
|
const t = useTranslations('Auth');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@ -21,18 +25,23 @@ export default function SignIn() {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
// TODO: Implement actual sign in logic
|
await authClient.signIn.email({
|
||||||
setTimeout(() => {
|
email,
|
||||||
setIsLoading(false);
|
password,
|
||||||
router.push('/dashboard');
|
}, {
|
||||||
}, 1000);
|
onSuccess: () => {
|
||||||
|
router.push('/dashboard');
|
||||||
|
},
|
||||||
|
onError: (ctx) => {
|
||||||
|
alert(ctx.error.message);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-navy-900 via-navy-800 to-gray-900 flex items-center justify-center px-4">
|
<div className="min-h-screen flex items-center justify-center px-4">
|
||||||
<div className="absolute inset-0 bg-black/20"></div>
|
|
||||||
|
|
||||||
<div className="relative z-10 w-full max-w-md">
|
<div className="relative z-10 w-full max-w-md">
|
||||||
<div className="bg-white/10 backdrop-blur-lg rounded-2xl p-8 border border-white/20">
|
<div className="bg-white/10 backdrop-blur-lg rounded-2xl p-8 border border-white/20">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
@ -40,54 +49,54 @@ export default function SignIn() {
|
|||||||
<h1 className="text-3xl font-bold text-white mb-2">
|
<h1 className="text-3xl font-bold text-white mb-2">
|
||||||
kreati<span className="text-gold-400">Vortex</span>
|
kreati<span className="text-gold-400">Vortex</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-300">Masuk ke akun Anda</p>
|
<p className="text-gray-300">{t('signInTitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div>
|
<div className="grid gap-2">
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
|
<Label htmlFor="email" className="text-gray-300">
|
||||||
Email
|
{t('emailLabel')}
|
||||||
</label>
|
</Label>
|
||||||
<input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gold-400 focus:border-transparent"
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400 focus-visible:ring-gold-400"
|
||||||
placeholder="nama@email.com"
|
placeholder="nama@email.com"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="grid gap-2">
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-2">
|
<Label htmlFor="password" className="text-gray-300">
|
||||||
Password
|
{t('passwordLabel')}
|
||||||
</label>
|
</Label>
|
||||||
<input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gold-400 focus:border-transparent"
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400 focus-visible:ring-gold-400"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center space-x-2">
|
||||||
<input
|
<input
|
||||||
id="remember"
|
id="remember"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="h-4 w-4 bg-white/10 border-white/20 rounded focus:ring-gold-400"
|
className="h-4 w-4 bg-white/10 border-white/20 rounded focus:ring-gold-400"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="remember" className="ml-2 block text-sm text-gray-300">
|
<Label htmlFor="remember" className="text-gray-300 font-normal">
|
||||||
Ingat saya
|
{t('rememberMe')}
|
||||||
</label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/auth/forgot-password" className="text-sm text-gold-400 hover:text-gold-300">
|
<Link href="/auth/forgot-password" className="text-sm text-gold-400 hover:text-gold-300">
|
||||||
Lupa password?
|
{t('forgotPassword')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -96,16 +105,16 @@ export default function SignIn() {
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="w-full py-3 px-4 bg-gold-500 hover:bg-gold-400 text-navy-900 font-semibold rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full py-3 px-4 bg-gold-500 hover:bg-gold-400 text-navy-900 font-semibold rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Memproses...' : 'Masuk'}
|
{isLoading ? t('processing') : t('signInButton')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Sign up link */}
|
{/* Sign up link */}
|
||||||
<div className="mt-8 text-center">
|
<div className="mt-8 text-center">
|
||||||
<p className="text-gray-300">
|
<p className="text-gray-300">
|
||||||
Belum punya akun?{' '}
|
{t('noAccount')}{' '}
|
||||||
<Link href="/auth/signup" className="text-gold-400 hover:text-gold-300 font-medium">
|
<Link href="/auth/signup" className="text-gold-400 hover:text-gold-300 font-medium">
|
||||||
Daftar sekarang
|
{t('registerNow')}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -113,4 +122,4 @@ export default function SignIn() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
180
app/[locale]/auth/signup/page.tsx
Normal file
180
app/[locale]/auth/signup/page.tsx
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useRouter } from '@/i18n/routing';
|
||||||
|
import { authClient } from '@/lib/auth-client';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
export default function SignUp() {
|
||||||
|
const t = useTranslations('Auth');
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
role: 'CALON_PENDIDIK' as 'CALON_PENDIDIK' | 'UMUM',
|
||||||
|
});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (formData.password !== formData.confirmPassword) {
|
||||||
|
alert(t('passwordMismatch'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
await authClient.signUp.email({
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
name: formData.name,
|
||||||
|
// role: formData.role, // TODO: Handle role in metadata or post-signup
|
||||||
|
}, {
|
||||||
|
onSuccess: () => {
|
||||||
|
router.push('/dashboard');
|
||||||
|
},
|
||||||
|
onError: (ctx) => {
|
||||||
|
alert(ctx.error.message);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[e.target.name]: e.target.value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center px-4 py-12">
|
||||||
|
<div className="relative z-10 w-full max-w-md">
|
||||||
|
<div className="bg-white/10 backdrop-blur-lg rounded-2xl p-8 border border-white/20">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">
|
||||||
|
kreati<span className="text-gold-400">Vortex</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-300">{t('signUpTitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="name" className="text-gray-300">
|
||||||
|
{t('nameLabel')}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400 focus-visible:ring-gold-400"
|
||||||
|
placeholder="John Doe"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="email" className="text-gray-300">
|
||||||
|
{t('emailLabel')}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400 focus-visible:ring-gold-400"
|
||||||
|
placeholder="nama@email.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="role" className="text-gray-300">
|
||||||
|
{t('roleLabel')}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.role}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, role: value as any })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-white/10 border-white/20 text-white focus:ring-gold-400">
|
||||||
|
<SelectValue placeholder={t('roleLabel')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-navy-800 border-white/20 text-white">
|
||||||
|
<SelectItem value="CALON_PENDIDIK">{t('roleEducator')}</SelectItem>
|
||||||
|
<SelectItem value="UMUM">{t('roleGeneral')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="password" className="text-gray-300">
|
||||||
|
{t('passwordLabel')}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400 focus-visible:ring-gold-400"
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="confirmPassword" className="text-gray-300">
|
||||||
|
{t('confirmPasswordLabel')}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400 focus-visible:ring-gold-400"
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full py-3 px-4 bg-gold-500 hover:bg-gold-400 text-navy-900 font-semibold rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? t('registering') : t('signUpButton')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Sign in link */}
|
||||||
|
<div className="mt-8 text-center">
|
||||||
|
<p className="text-gray-300">
|
||||||
|
{t('haveAccount')}{' '}
|
||||||
|
<Link href="/auth/signin" className="text-gold-400 hover:text-gold-300 font-medium">
|
||||||
|
{t('signInNow')}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
app/[locale]/layout.tsx
Normal file
61
app/[locale]/layout.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
|
import { getMessages } from 'next-intl/server';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { routing } from '@/i18n/routing';
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono, Kaisei_Decol } from "next/font/google";
|
||||||
|
import "../globals.css";
|
||||||
|
|
||||||
|
const kaiseiDecol = Kaisei_Decol({
|
||||||
|
variable: "--font-kaisei-decol",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500", "700"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "KreatiVortex",
|
||||||
|
description: "Indonesian traditional dance learning platform",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
|
||||||
|
// Ensure that the incoming `locale` is valid
|
||||||
|
if (!routing.locales.includes(locale as any)) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Providing all messages to the client
|
||||||
|
// side is the easiest way to get started
|
||||||
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang={locale}>
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} ${kaiseiDecol.variable} antialiased`}
|
||||||
|
>
|
||||||
|
<NextIntlClientProvider messages={messages}>
|
||||||
|
<div className="min-h-screen">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
app/[locale]/page.tsx
Normal file
90
app/[locale]/page.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* File: page.tsx
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Landing page for kreatiVortex platform
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Link } from "@/i18n/routing";
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
const t = await getTranslations('HomePage');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen text-white">
|
||||||
|
<div className="floating-background"></div>
|
||||||
|
<div className="container mx-auto fixed z-10 top-0 left-0 right-0 flex justify-between items-center py-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Link href="/" locale="en" className="hover:text-gray-300 transition-colors">
|
||||||
|
EN
|
||||||
|
</Link>
|
||||||
|
<span className="mx-2">|</span>
|
||||||
|
<Link href="/" locale="id" className="hover:text-gray-300 transition-colors">
|
||||||
|
ID
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/auth/signin">
|
||||||
|
<Button variant="ghost" size="lg" className="uppercase">
|
||||||
|
{t('login')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/auth/signup">
|
||||||
|
<Button variant="outline" size="lg" className="uppercase">
|
||||||
|
{t('registration')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="relative flex min-h-screen flex-col items-start justify-center px-4">
|
||||||
|
<div className="w-3/4 text-center">
|
||||||
|
{/* Logo/Title */}
|
||||||
|
<div className="mb-12">
|
||||||
|
<h1 className="text-5xl md:text-8xl text-gray-300 mb-4">
|
||||||
|
{t('title')}
|
||||||
|
</h1>
|
||||||
|
<p className="uppercase text-xl md:text-2xl text-gray-300">
|
||||||
|
{t('subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||||
|
<Link
|
||||||
|
href="/auth/signup">
|
||||||
|
<Button variant="glass" size="xl" className="uppercase">
|
||||||
|
{t('startNow')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 justify-center items-center mt-4">
|
||||||
|
<Link
|
||||||
|
href="/auth/signin">
|
||||||
|
<Button variant="outline" size="lg" className="uppercase text-sm">
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<span className="text-gray-400 text-sm">atau</span>
|
||||||
|
<Link
|
||||||
|
href="/auth/signup">
|
||||||
|
<Button variant="outline" size="lg" className="uppercase text-sm">
|
||||||
|
Daftar sebagai Pendidik
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/auth/signup">
|
||||||
|
<Button variant="outline" size="lg" className="uppercase text-sm">
|
||||||
|
Daftar sebagai Calon Pendidik
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
app/actions/video.ts
Normal file
153
app/actions/video.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const VideoSchema = z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
description: z.string().optional(),
|
||||||
|
videoUrl: z.string().min(1),
|
||||||
|
videoType: z.enum(['YOUTUBE', 'LOCAL']),
|
||||||
|
isPublic: z.boolean(),
|
||||||
|
menuId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createVideo(data: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
videoUrl: string;
|
||||||
|
videoType: 'YOUTUBE' | 'LOCAL';
|
||||||
|
isPublic: boolean;
|
||||||
|
menuId?: string;
|
||||||
|
}) {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user profile
|
||||||
|
const userProfile = await prisma.userProfile.findUnique({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userProfile) {
|
||||||
|
throw new Error('User profile not found. Please complete your profile first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const validatedData = VideoSchema.parse(data);
|
||||||
|
|
||||||
|
let thumbnailUrl = null;
|
||||||
|
if (validatedData.videoType === 'YOUTUBE') {
|
||||||
|
const videoId = extractYoutubeId(validatedData.videoUrl);
|
||||||
|
if (videoId) {
|
||||||
|
thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.video.create({
|
||||||
|
data: {
|
||||||
|
...validatedData,
|
||||||
|
uploaderId: userProfile.id,
|
||||||
|
thumbnailUrl,
|
||||||
|
createdBy: session.user.id,
|
||||||
|
updatedBy: session.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath('/dashboard/videos');
|
||||||
|
if (validatedData.menuId) {
|
||||||
|
// We need to find the slug to revalidate the page, but revalidating the layout might be enough or we can just revalidate the specific page if we fetch the menu.
|
||||||
|
// For now, let's revalidate the dashboard layout to be safe or just the videos page.
|
||||||
|
// Actually, we can fetch the menu to get the slug.
|
||||||
|
const menu = await prisma.menu.findUnique({ where: { id: validatedData.menuId } });
|
||||||
|
if (menu) {
|
||||||
|
revalidatePath(`/dashboard/pages/${menu.slug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect('/dashboard/videos');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateVideo(id: string, data: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
videoUrl: string;
|
||||||
|
videoType: 'YOUTUBE' | 'LOCAL';
|
||||||
|
isPublic: boolean;
|
||||||
|
menuId?: string;
|
||||||
|
}) {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user profile
|
||||||
|
const userProfile = await prisma.userProfile.findUnique({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userProfile) {
|
||||||
|
throw new Error('User profile not found. Please complete your profile first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user owns the video
|
||||||
|
const existingVideo = await prisma.video.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingVideo || existingVideo.uploaderId !== userProfile.id) {
|
||||||
|
throw new Error('Video not found or you do not have permission to edit it');
|
||||||
|
}
|
||||||
|
|
||||||
|
const validatedData = VideoSchema.parse(data);
|
||||||
|
|
||||||
|
let thumbnailUrl = existingVideo.thumbnailUrl;
|
||||||
|
if (validatedData.videoType === 'YOUTUBE') {
|
||||||
|
const videoId = extractYoutubeId(validatedData.videoUrl);
|
||||||
|
if (videoId) {
|
||||||
|
thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.video.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...validatedData,
|
||||||
|
thumbnailUrl,
|
||||||
|
updatedBy: session.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath('/dashboard/videos');
|
||||||
|
revalidatePath(`/dashboard/videos/${id}`);
|
||||||
|
if (validatedData.menuId) {
|
||||||
|
const menu = await prisma.menu.findUnique({ where: { id: validatedData.menuId } });
|
||||||
|
if (menu) {
|
||||||
|
revalidatePath(`/dashboard/pages/${menu.slug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (existingVideo.menuId && existingVideo.menuId !== validatedData.menuId) {
|
||||||
|
const oldMenu = await prisma.menu.findUnique({ where: { id: existingVideo.menuId } });
|
||||||
|
if (oldMenu) {
|
||||||
|
revalidatePath(`/dashboard/pages/${oldMenu.slug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(`/dashboard/videos/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractYoutubeId(url: string) {
|
||||||
|
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
|
||||||
|
const match = url.match(regExp);
|
||||||
|
return (match && match[2].length === 11) ? match[2] : null;
|
||||||
|
}
|
||||||
88
app/api/assignments/[id]/route.ts
Normal file
88
app/api/assignments/[id]/route.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* File: route.ts
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Assignment API endpoints for single assignment operations
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const assignment = await prisma.assignment.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
educator: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
class: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
submissions: {
|
||||||
|
include: {
|
||||||
|
student: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!assignment) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Assignment not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: assignment });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching assignment:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to fetch assignment' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
await prisma.assignment.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Assignment deleted' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting assignment:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to delete assignment' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
app/api/assignments/route.ts
Normal file
101
app/api/assignments/route.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* File: route.ts
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Assignment API endpoints for listing and creating assignments
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const classId = searchParams.get('classId');
|
||||||
|
|
||||||
|
const whereClause: any = { isActive: true };
|
||||||
|
|
||||||
|
if (classId) {
|
||||||
|
whereClause.classId = classId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignments = await prisma.assignment.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
include: {
|
||||||
|
educator: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
class: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
dueDate: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: assignments });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching assignments:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to fetch assignments' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { title, description, classId, dueDate, maxScore } = body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!title || !description || !classId || !dueDate) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'All fields are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock educator ID for now
|
||||||
|
const educator = await prisma.userProfile.findFirst();
|
||||||
|
|
||||||
|
if (!educator) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'No user profile found' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignment = await prisma.assignment.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
classId,
|
||||||
|
dueDate: new Date(dueDate),
|
||||||
|
maxScore: Number(maxScore),
|
||||||
|
educatorId: educator.id,
|
||||||
|
createdBy: educator.id,
|
||||||
|
updatedBy: educator.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: assignment });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating assignment:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to create assignment' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,12 +6,7 @@
|
|||||||
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextResponse } from 'next/server';
|
import { auth } from "@/lib/auth";
|
||||||
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
|
|
||||||
export async function GET() {
|
export const { GET, POST } = toNextJsHandler(auth.handler);
|
||||||
return NextResponse.json({ message: 'Auth API - GET' });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
return NextResponse.json({ message: 'Auth API - POST' });
|
|
||||||
}
|
|
||||||
141
app/api/auth/register-role/route.ts
Normal file
141
app/api/auth/register-role/route.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* File: route.ts
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Role registration API for kreatiVortex platform
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { getOrCreateUserProfile } from '@/lib/profile';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create user profile
|
||||||
|
const userProfile = await getOrCreateUserProfile(session.user.id);
|
||||||
|
|
||||||
|
// Check if user already has a specific role (not 'UMUM')
|
||||||
|
if (userProfile.role.name !== 'UMUM') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'User already has a role' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { role, institution, className, teachingLevel, purpose, nim } = body;
|
||||||
|
|
||||||
|
// Get role from database
|
||||||
|
const targetRole = await prisma.userRole.findUnique({
|
||||||
|
where: { name: role }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetRole) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Invalid role' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields based on role
|
||||||
|
if (role === 'PENDIDIK') {
|
||||||
|
if (!institution || !className || !teachingLevel || !purpose) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'All fields are required for educator registration' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (role === 'CALON_PENDIDIK') {
|
||||||
|
if (!institution || !className || !nim) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'All fields are required for student registration' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if class exists by code (not ID)
|
||||||
|
const classExists = await prisma.class.findUnique({
|
||||||
|
where: { code: className }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!classExists) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Class code not found. Please check the code with your educator.' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user role and additional information
|
||||||
|
const updatedProfile = await prisma.userProfile.update({
|
||||||
|
where: { id: userProfile.id },
|
||||||
|
data: {
|
||||||
|
roleId: targetRole.id,
|
||||||
|
nim: nim || null,
|
||||||
|
bio: role === 'PENDIDIK'
|
||||||
|
? `Institution: ${institution}\nTeaching Level: ${teachingLevel}\nPurpose: ${purpose}`
|
||||||
|
: `Institution: ${institution}\nNIM: ${nim}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If registering as student, add to class
|
||||||
|
if (role === 'CALON_PENDIDIK') {
|
||||||
|
// Find the class by code to get the ID
|
||||||
|
const targetClass = await prisma.class.findUnique({
|
||||||
|
where: { code: className }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (targetClass) {
|
||||||
|
await prisma.classMember.create({
|
||||||
|
data: {
|
||||||
|
classId: targetClass.id,
|
||||||
|
studentId: updatedProfile.id,
|
||||||
|
joinedAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If registering as educator, create the class
|
||||||
|
if (role === 'PENDIDIK') {
|
||||||
|
await prisma.class.create({
|
||||||
|
data: {
|
||||||
|
name: className,
|
||||||
|
description: `Class created by ${updatedProfile.id}`,
|
||||||
|
code: `CLASS-${Date.now()}`,
|
||||||
|
educatorId: updatedProfile.id,
|
||||||
|
createdBy: updatedProfile.id,
|
||||||
|
updatedBy: updatedProfile.id,
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `Successfully registered as ${role}`,
|
||||||
|
data: updatedProfile
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error registering role:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to register role' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
136
app/api/classes/[id]/route.ts
Normal file
136
app/api/classes/[id]/route.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* File: route.ts
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Class API endpoints for single class operations
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { getOrCreateUserProfile } from '@/lib/profile';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create user profile
|
||||||
|
const userProfile = await getOrCreateUserProfile(session.user.id);
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Build where clause based on user role
|
||||||
|
let whereClause: any = {
|
||||||
|
id,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (userProfile.role.name === 'ADMIN' || userProfile.role.name === 'PENDIDIK') {
|
||||||
|
// Admins and Educators can see any active class
|
||||||
|
whereClause.OR = [
|
||||||
|
{ educatorId: userProfile.id },
|
||||||
|
{
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
studentId: userProfile.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
} else if (userProfile.role.name === 'CALON_PENDIDIK') {
|
||||||
|
// Students can only see classes they're enrolled in
|
||||||
|
whereClause.members = {
|
||||||
|
some: {
|
||||||
|
studentId: userProfile.id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// UMUM role cannot access any class
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Access denied' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const classData = await prisma.class.findUnique({
|
||||||
|
where: whereClause,
|
||||||
|
include: {
|
||||||
|
educator: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
include: {
|
||||||
|
student: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
videos: true,
|
||||||
|
assignments: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!classData) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Class not found or access denied' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: classData });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching class:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to fetch class' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
await prisma.class.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Class deleted' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting class:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to delete class' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
177
app/api/classes/route.ts
Normal file
177
app/api/classes/route.ts
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* File: route.ts
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Class API endpoints for listing and creating classes
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { getOrCreateUserProfile } from '@/lib/profile';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create user profile
|
||||||
|
const userProfile = await getOrCreateUserProfile(session.user.id);
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const educatorId = searchParams.get('educatorId');
|
||||||
|
|
||||||
|
// If educatorId is provided and matches current user, show classes they created
|
||||||
|
if (educatorId && educatorId === userProfile.id) {
|
||||||
|
const classes = await prisma.class.findMany({
|
||||||
|
where: { educatorId: userProfile.id, isActive: true },
|
||||||
|
include: {
|
||||||
|
educator: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: { members: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: classes });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, show classes based on user role
|
||||||
|
let classes: any[] = [];
|
||||||
|
|
||||||
|
if (userProfile.role.name === 'UMUM') {
|
||||||
|
// General users can't see any classes
|
||||||
|
classes = [];
|
||||||
|
} else if (userProfile.role.name === 'CALON_PENDIDIK') {
|
||||||
|
// Students can only see classes they've joined
|
||||||
|
classes = await prisma.class.findMany({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
studentId: userProfile.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
educator: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: { members: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Educators and Admins can see all classes
|
||||||
|
classes = await prisma.class.findMany({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
educator: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: { members: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: classes });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching classes:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to fetch classes' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, description, code, maxStudents } = body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!name || !code) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Name and Code are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock educator ID for now
|
||||||
|
const educator = await prisma.userProfile.findFirst();
|
||||||
|
|
||||||
|
if (!educator) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'No user profile found' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newClass = await prisma.class.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
code,
|
||||||
|
maxStudents: maxStudents ? Number(maxStudents) : null,
|
||||||
|
educatorId: educator.id,
|
||||||
|
createdBy: educator.id,
|
||||||
|
updatedBy: educator.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: newClass });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating class:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to create class' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
143
app/api/comments/route.ts
Normal file
143
app/api/comments/route.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* File: route.ts
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Comments API with file attachment support
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { getOrCreateUserProfile } from '@/lib/profile';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create user profile
|
||||||
|
const userProfile = await getOrCreateUserProfile(session.user.id);
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { content, videoId, forumPostId, parentId, attachments } = body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!content && (!attachments || attachments.length === 0)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Content or attachment is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!videoId && !forumPostId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Video ID or Forum Post ID is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = await prisma.comment.create({
|
||||||
|
data: {
|
||||||
|
content: content || '',
|
||||||
|
videoId: videoId || null,
|
||||||
|
forumPostId: forumPostId || null,
|
||||||
|
parentId: parentId || null,
|
||||||
|
authorId: userProfile.id,
|
||||||
|
updatedBy: userProfile.id,
|
||||||
|
attachments: attachments || [],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
author: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: comment });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating comment:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to create comment' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const videoId = searchParams.get('videoId');
|
||||||
|
const forumPostId = searchParams.get('forumPostId');
|
||||||
|
|
||||||
|
const whereClause: any = { isActive: true };
|
||||||
|
|
||||||
|
if (videoId) {
|
||||||
|
whereClause.videoId = videoId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forumPostId) {
|
||||||
|
whereClause.forumPostId = forumPostId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments = await prisma.comment.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
include: {
|
||||||
|
author: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
replies: {
|
||||||
|
include: {
|
||||||
|
author: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'asc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: comments });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching comments:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to fetch comments' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/api/forums/[id]/posts/route.ts
Normal file
81
app/api/forums/[id]/posts/route.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* File: route.ts
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-12-01
|
||||||
|
* Purpose: Forum posts API endpoints for creating posts within forums
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const { title, content } = body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!content) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Content is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock author for now - in real app, get from session
|
||||||
|
const author = await prisma.userProfile.findFirst();
|
||||||
|
|
||||||
|
if (!author) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'No user profile found' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if forum exists
|
||||||
|
const forum = await prisma.forum.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!forum) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Forum not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = await prisma.forumPost.create({
|
||||||
|
data: {
|
||||||
|
title: title || `Re: ${forum.title}`,
|
||||||
|
content,
|
||||||
|
forumId: id,
|
||||||
|
authorId: author.id,
|
||||||
|
updatedBy: author.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
author: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: post });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating forum post:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to create forum post' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/api/forums/[id]/route.ts
Normal file
89
app/api/forums/[id]/route.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* File: route.ts
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Forum API endpoints for single forum operations
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const forum = await prisma.forum.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
posts: {
|
||||||
|
include: {
|
||||||
|
author: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: { comments: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!forum) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Forum not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: forum });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching forum:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to fetch forum' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
await prisma.forum.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Forum deleted' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting forum:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to delete forum' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
123
app/api/forums/route.ts
Normal file
123
app/api/forums/route.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* File: route.ts
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Forum API endpoints for listing and creating forums
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { getOrCreateUserProfile } from '@/lib/profile';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const type = searchParams.get('type');
|
||||||
|
const classId = searchParams.get('classId');
|
||||||
|
|
||||||
|
const whereClause: any = { isActive: true };
|
||||||
|
|
||||||
|
if (type) {
|
||||||
|
whereClause.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classId) {
|
||||||
|
whereClause.classId = classId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forums = await prisma.forum.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: { posts: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
updatedAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: forums });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching forums:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to fetch forums' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create user profile
|
||||||
|
const userProfile = await getOrCreateUserProfile(session.user.id);
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { title, description, type, classId, attachments } = body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!title) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Title is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const forum = await prisma.forum.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
type: type || 'GENERAL',
|
||||||
|
classId,
|
||||||
|
createdBy: userProfile.id,
|
||||||
|
updatedBy: userProfile.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the first post in the forum
|
||||||
|
if (description || attachments) {
|
||||||
|
await prisma.forumPost.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
content: description || '',
|
||||||
|
forumId: forum.id,
|
||||||
|
authorId: userProfile.id,
|
||||||
|
updatedBy: userProfile.id,
|
||||||
|
attachments: attachments || [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: forum });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating forum:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to create forum' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/api/menus/route.ts
Normal file
42
app/api/menus/route.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { getLocale } from 'next-intl/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const locale = await getLocale();
|
||||||
|
|
||||||
|
const menus = await prisma.menu.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
include: {
|
||||||
|
parent: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const getLocalizedName = (json: any) => {
|
||||||
|
if (!json) return '';
|
||||||
|
return json[locale] || json['id'] || json['en'] || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formattedMenus = menus.map((menu) => {
|
||||||
|
const name = getLocalizedName(menu.name);
|
||||||
|
const parentName = menu.parent ? getLocalizedName(menu.parent.name) : '';
|
||||||
|
return {
|
||||||
|
id: menu.id,
|
||||||
|
title: parentName ? `${parentName} > ${name}` : name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by title for better UX
|
||||||
|
formattedMenus.sort((a, b) => a.title.localeCompare(b.title));
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: formattedMenus });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching menus:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to fetch menus' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
112
app/api/upload/route.ts
Normal file
112
app/api/upload/route.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* File: route.ts
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: File upload API endpoint
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
return randomBytes(16).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedFileType(file: File): boolean {
|
||||||
|
const allowedTypes = [
|
||||||
|
'application/pdf',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/gif'
|
||||||
|
];
|
||||||
|
|
||||||
|
return allowedTypes.includes(file.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file') as File;
|
||||||
|
const folder = (formData.get('folder') as string) || 'uploads';
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'No file provided' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!isAllowedFileType(file)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'File type not allowed' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 10 * 1024 * 1024) { // 10MB limit
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'File too large' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(bytes);
|
||||||
|
|
||||||
|
const fileId = generateId();
|
||||||
|
const fileExtension = file.name.split('.').pop();
|
||||||
|
const fileName = `${fileId}.${fileExtension}`;
|
||||||
|
|
||||||
|
// Create uploads directory if it doesn't exist
|
||||||
|
const uploadsDir = join(process.cwd(), 'public', folder);
|
||||||
|
try {
|
||||||
|
await fs.mkdir(uploadsDir, { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
// Directory already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write file to public/uploads directory
|
||||||
|
const filePath = join(uploadsDir, fileName);
|
||||||
|
await fs.writeFile(filePath, buffer);
|
||||||
|
|
||||||
|
const uploadedFile = {
|
||||||
|
id: fileId,
|
||||||
|
name: fileName,
|
||||||
|
originalName: file.name,
|
||||||
|
mimeType: file.type,
|
||||||
|
size: file.size,
|
||||||
|
url: `/${folder}/${fileName}`
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: uploadedFile
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading file:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to upload file' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/api/user/profile/route.ts
Normal file
57
app/api/user/profile/route.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* File: route.ts
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: User profile API for kreatiVortex platform
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { getOrCreateUserProfile } from '@/lib/profile';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userProfile = await getOrCreateUserProfile(session.user.id);
|
||||||
|
|
||||||
|
// Get full profile with user data
|
||||||
|
const fullProfile = await prisma.userProfile.findUnique({
|
||||||
|
where: { id: userProfile.id },
|
||||||
|
include: {
|
||||||
|
role: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
image: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: fullProfile
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user profile:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to fetch user profile' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
167
app/api/videos/[id]/route.ts
Normal file
167
app/api/videos/[id]/route.ts
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* File: route.ts
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Video API endpoints for single video operations
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { getOrCreateUserProfile } from '@/lib/profile';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const video = await prisma.video.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
uploader: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
comments: {
|
||||||
|
include: {
|
||||||
|
author: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Video not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment view count
|
||||||
|
await prisma.video.update({
|
||||||
|
where: { id },
|
||||||
|
data: { viewCount: { increment: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: video });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching video:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to fetch video' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const { title, description, videoUrl, videoType, isPublic } = body;
|
||||||
|
|
||||||
|
const video = await prisma.video.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
videoUrl,
|
||||||
|
videoType,
|
||||||
|
isPublic,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: video });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating video:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to update video' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create user profile
|
||||||
|
const userProfile = await getOrCreateUserProfile(session.user.id);
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Check if video exists
|
||||||
|
const video = await prisma.video.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Video not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user can delete this video
|
||||||
|
const canDelete = userProfile.role.name === 'ADMIN' || video.uploaderId === userProfile.id;
|
||||||
|
|
||||||
|
if (!canDelete) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Access denied' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the video
|
||||||
|
await prisma.video.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Video deleted successfully'
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting video:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to delete video' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
160
app/api/videos/route.ts
Normal file
160
app/api/videos/route.ts
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* File: route.ts
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Video API endpoints with authentication and role-based access
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { getOrCreateUserProfile } from '@/lib/profile';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create user profile
|
||||||
|
const userProfile = await getOrCreateUserProfile(session.user.id);
|
||||||
|
|
||||||
|
let whereClause: any = {
|
||||||
|
OR: [
|
||||||
|
{ isPublic: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add user's own videos and class videos
|
||||||
|
if (userProfile.role.name !== 'UMUM') {
|
||||||
|
whereClause.OR.push(
|
||||||
|
{ uploaderId: userProfile.id }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user is educator or admin, show videos from their classes
|
||||||
|
if (userProfile.role.name === 'PENDIDIK' || userProfile.role.name === 'ADMIN') {
|
||||||
|
const userClasses = await prisma.class.findMany({
|
||||||
|
where: {
|
||||||
|
educatorId: userProfile.id,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
videos: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const classVideoIds = userClasses.flatMap(cls => cls.videos.map(v => v.id));
|
||||||
|
if (classVideoIds.length > 0) {
|
||||||
|
whereClause.OR.push({ id: { in: classVideoIds } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user is student, show videos from their enrolled classes
|
||||||
|
if (userProfile.role.name === 'CALON_PENDIDIK') {
|
||||||
|
const enrolledClasses = await prisma.classMember.findMany({
|
||||||
|
where: {
|
||||||
|
studentId: userProfile.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
class: {
|
||||||
|
include: {
|
||||||
|
videos: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const classVideoIds = enrolledClasses.flatMap(cm => cm.class.videos.map(v => v.id));
|
||||||
|
if (classVideoIds.length > 0) {
|
||||||
|
whereClause.OR.push({ id: { in: classVideoIds } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const videos = await prisma.video.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
include: {
|
||||||
|
uploader: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: videos });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching videos:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to fetch videos' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create user profile
|
||||||
|
const userProfile = await getOrCreateUserProfile(session.user.id);
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { title, description, videoUrl, videoType, isPublic } = body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!title || !videoUrl) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Title and Video URL are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const video = await prisma.video.create({
|
||||||
|
data: {
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
videoUrl: videoUrl,
|
||||||
|
videoType: videoType,
|
||||||
|
isPublic: isPublic,
|
||||||
|
uploaderId: userProfile.id,
|
||||||
|
createdBy: userProfile.id,
|
||||||
|
updatedBy: userProfile.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: video });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating video:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: 'Failed to create video' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,168 +0,0 @@
|
|||||||
/**
|
|
||||||
* File: page.tsx
|
|
||||||
* Created by: AI Assistant
|
|
||||||
* Date: 2025-11-29
|
|
||||||
* Purpose: Sign up page for kreatiVortex platform
|
|
||||||
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
export default function SignUp() {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
password: '',
|
|
||||||
confirmPassword: '',
|
|
||||||
role: 'CALON_PENDIDIK' as 'CALON_PENDIDIK' | 'UMUM',
|
|
||||||
});
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (formData.password !== formData.confirmPassword) {
|
|
||||||
alert('Password tidak cocok!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
// TODO: Implement actual sign up logic
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsLoading(false);
|
|
||||||
router.push('/auth/signin');
|
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
[e.target.name]: e.target.value,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gradient-to-br from-navy-900 via-navy-800 to-gray-900 flex items-center justify-center px-4 py-12">
|
|
||||||
<div className="absolute inset-0 bg-black/20"></div>
|
|
||||||
|
|
||||||
<div className="relative z-10 w-full max-w-md">
|
|
||||||
<div className="bg-white/10 backdrop-blur-lg rounded-2xl p-8 border border-white/20">
|
|
||||||
{/* Logo */}
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">
|
|
||||||
kreati<span className="text-gold-400">Vortex</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-300">Buat akun baru</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
|
|
||||||
Nama Lengkap
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
type="text"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={handleChange}
|
|
||||||
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gold-400 focus:border-transparent"
|
|
||||||
placeholder="John Doe"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
value={formData.email}
|
|
||||||
onChange={handleChange}
|
|
||||||
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gold-400 focus:border-transparent"
|
|
||||||
placeholder="nama@email.com"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="role" className="block text-sm font-medium text-gray-300 mb-2">
|
|
||||||
Daftar sebagai
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="role"
|
|
||||||
name="role"
|
|
||||||
value={formData.role}
|
|
||||||
onChange={handleChange}
|
|
||||||
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-gold-400 focus:border-transparent"
|
|
||||||
>
|
|
||||||
<option value="CALON_PENDIDIK" className="bg-navy-800">Calon Pendidik</option>
|
|
||||||
<option value="UMUM" className="bg-navy-800">Umum</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-2">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
value={formData.password}
|
|
||||||
onChange={handleChange}
|
|
||||||
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gold-400 focus:border-transparent"
|
|
||||||
placeholder="••••••••"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-300 mb-2">
|
|
||||||
Konfirmasi Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="confirmPassword"
|
|
||||||
name="confirmPassword"
|
|
||||||
type="password"
|
|
||||||
value={formData.confirmPassword}
|
|
||||||
onChange={handleChange}
|
|
||||||
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gold-400 focus:border-transparent"
|
|
||||||
placeholder="••••••••"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full py-3 px-4 bg-gold-500 hover:bg-gold-400 text-navy-900 font-semibold rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{isLoading ? 'Mendaftar...' : 'Daftar'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Sign in link */}
|
|
||||||
<div className="mt-8 text-center">
|
|
||||||
<p className="text-gray-300">
|
|
||||||
Sudah punya akun?{' '}
|
|
||||||
<Link href="/auth/signin" className="text-gold-400 hover:text-gold-300 font-medium">
|
|
||||||
Masuk
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -4,8 +4,10 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
--radius: 0.65rem;
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
|
--font-serif: var(--font-kaisei-decol);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
@ -41,7 +43,7 @@
|
|||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
|
||||||
/* kreatiVortex custom colors */
|
/* kreatiVortex custom colors */
|
||||||
--color-navy-50: #f0f4ff;
|
--color-navy-50: #f0f4ff;
|
||||||
--color-navy-100: #dae9ff;
|
--color-navy-100: #dae9ff;
|
||||||
@ -66,47 +68,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--background: oklch(12.856% 0.00001 271.152);
|
||||||
--background: oklch(1 0 0);
|
|
||||||
--foreground: oklch(0.145 0 0);
|
|
||||||
--card: oklch(1 0 0);
|
|
||||||
--card-foreground: oklch(0.145 0 0);
|
|
||||||
--popover: oklch(1 0 0);
|
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
|
||||||
--primary: oklch(0.205 0 0);
|
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
|
||||||
--secondary: oklch(0.97 0 0);
|
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
|
||||||
--muted: oklch(0.97 0 0);
|
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
|
||||||
--accent: oklch(0.97 0 0);
|
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
|
||||||
--border: oklch(0.922 0 0);
|
|
||||||
--input: oklch(0.922 0 0);
|
|
||||||
--ring: oklch(0.708 0 0);
|
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
|
||||||
--sidebar: oklch(0.985 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: oklch(0.145 0 0);
|
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.205 0 0);
|
--card: oklch(0.205 0 0);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(98.511% 0.00011 271.152 / 0.3);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(16.376% 0.00002 271.152);
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: oklch(0.922 0 0);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.269 0 0);
|
||||||
@ -138,7 +105,25 @@
|
|||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
@apply font-serif;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
.floating-background {
|
||||||
|
@apply fixed inset-0 bg-[url('/bg.jpeg')] bg-cover bg-center opacity-70;
|
||||||
|
|
||||||
|
&.dark {
|
||||||
|
@apply opacity-30 blur-md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,34 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import "./globals.css";
|
|
||||||
|
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Create Next App",
|
|
||||||
description: "Generated by create next app",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
}>) {
|
|
||||||
return (
|
|
||||||
<html lang="en">
|
|
||||||
<body
|
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
96
app/page.tsx
96
app/page.tsx
@ -1,96 +0,0 @@
|
|||||||
/**
|
|
||||||
* File: page.tsx
|
|
||||||
* Created by: AI Assistant
|
|
||||||
* Date: 2025-11-29
|
|
||||||
* Purpose: Landing page for kreatiVortex platform
|
|
||||||
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gradient-to-br from-navy-900 via-navy-800 to-gray-900 text-white">
|
|
||||||
{/* Background overlay effect */}
|
|
||||||
<div className="absolute inset-0 bg-black/20"></div>
|
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<div className="relative z-10 flex min-h-screen flex-col items-center justify-center px-4">
|
|
||||||
<div className="max-w-4xl w-full text-center">
|
|
||||||
{/* Logo/Title */}
|
|
||||||
<div className="mb-12">
|
|
||||||
<h1 className="text-5xl md:text-7xl font-bold text-white mb-4">
|
|
||||||
kreati<span className="text-gold-400">Vortex</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl md:text-2xl text-gray-300">
|
|
||||||
Platform Pembelajaran Tari Online
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div className="mb-16 max-w-2xl mx-auto">
|
|
||||||
<p className="text-lg text-gray-300 leading-relaxed">
|
|
||||||
Bergabunglah dengan komunitas pembelajaran tari tradisional Indonesia.
|
|
||||||
Pelajari berbagai tarian dari seluruh nusantara dengan panduan dari para ahli.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
|
||||||
<Link
|
|
||||||
href="/auth/signin"
|
|
||||||
className="px-8 py-4 bg-navy-700 hover:bg-navy-600 text-white font-semibold rounded-lg transition-colors duration-200 min-w-[160px] text-center"
|
|
||||||
>
|
|
||||||
Masuk
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/auth/signup"
|
|
||||||
className="px-8 py-4 bg-gold-500 hover:bg-gold-400 text-navy-900 font-semibold rounded-lg transition-colors duration-200 min-w-[160px] text-center"
|
|
||||||
>
|
|
||||||
Daftar
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/auth/signup"
|
|
||||||
className="px-8 py-4 border-2 border-gold-400 text-gold-400 hover:bg-gold-400 hover:text-navy-900 font-semibold rounded-lg transition-colors duration-200 min-w-[160px] text-center"
|
|
||||||
>
|
|
||||||
Mulai Sekarang
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Features */}
|
|
||||||
<div className="mt-24 grid grid-cols-1 md:grid-cols-3 gap-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-16 h-16 bg-gold-400/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<svg className="w-8 h-8 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-semibold text-white mb-2">Video Pembelajaran</h3>
|
|
||||||
<p className="text-gray-400">Akses video tutorial tari dari berbagai daerah di Indonesia</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-16 h-16 bg-gold-400/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<svg className="w-8 h-8 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-semibold text-white mb-2">Forum Diskusi</h3>
|
|
||||||
<p className="text-gray-400">Berinteraksi dengan sesama pecinta tari dan para ahli</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-16 h-16 bg-gold-400/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<svg className="w-8 h-8 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-semibold text-white mb-2">Tugas & Evaluasi</h3>
|
|
||||||
<p className="text-gray-400">Kumpulkan tugas dan dapatkan feedback dari para pendidik</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
181
bun.lock
181
bun.lock
@ -5,21 +5,34 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "kreativortex",
|
"name": "kreativortex",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@prisma/adapter-pg": "^7.0.1",
|
"@prisma/adapter-pg": "^7.0.1",
|
||||||
"@prisma/client": "^7.0.1",
|
"@prisma/client": "^7.0.1",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/pg": "^8.15.6",
|
"@types/pg": "^8.15.6",
|
||||||
|
"@types/uuid": "^11.0.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-auth": "^1.4.3",
|
"better-auth": "^1.4.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"next": "16.0.5",
|
"next": "16.0.5",
|
||||||
|
"next-intl": "^4.5.6",
|
||||||
|
"pg": "^8.16.3",
|
||||||
"prisma": "^7.0.1",
|
"prisma": "^7.0.1",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
|
"react-hook-form": "^7.67.0",
|
||||||
|
"scrypt": "^6.0.3",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
|
"zod": "^4.1.13",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@ -167,8 +180,28 @@
|
|||||||
|
|
||||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
||||||
|
|
||||||
|
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
|
||||||
|
|
||||||
|
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
|
||||||
|
|
||||||
|
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="],
|
||||||
|
|
||||||
|
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||||
|
|
||||||
|
"@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="],
|
||||||
|
|
||||||
|
"@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="],
|
||||||
|
|
||||||
|
"@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@2.11.4", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/icu-skeleton-parser": "1.8.16", "tslib": "^2.8.0" } }, "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw=="],
|
||||||
|
|
||||||
|
"@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@1.8.16", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "tslib": "^2.8.0" } }, "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ=="],
|
||||||
|
|
||||||
|
"@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.5.10", "", { "dependencies": { "tslib": "2" } }, "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q=="],
|
||||||
|
|
||||||
"@hono/node-server": ["@hono/node-server@1.14.2", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GHjpOeHYbr9d1vkID2sNUYkl5IxumyhDrUJB7wBp7jvqYwPFt+oNKsAPBRcdSbV7kIrXhouLE199ks1QcK4r7A=="],
|
"@hono/node-server": ["@hono/node-server@1.14.2", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GHjpOeHYbr9d1vkID2sNUYkl5IxumyhDrUJB7wBp7jvqYwPFt+oNKsAPBRcdSbV7kIrXhouLE199ks1QcK4r7A=="],
|
||||||
|
|
||||||
|
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
|
||||||
|
|
||||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||||
|
|
||||||
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||||
@ -299,12 +332,106 @@
|
|||||||
|
|
||||||
"@prisma/studio-core": ["@prisma/studio-core@0.8.2", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-/iAEWEUpTja+7gVMu1LtR2pPlvDmveAwMHdTWbDeGlT7yiv0ZTCPpmeAGdq/Y9aJ9Zj1cEGBXGRbmmNPj022PQ=="],
|
"@prisma/studio-core": ["@prisma/studio-core@0.8.2", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-/iAEWEUpTja+7gVMu1LtR2pPlvDmveAwMHdTWbDeGlT7yiv0ZTCPpmeAGdq/Y9aJ9Zj1cEGBXGRbmmNPj022PQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||||
|
|
||||||
|
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
|
||||||
|
|
||||||
|
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||||
|
|
||||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||||
|
|
||||||
|
"@schummar/icu-type-parser": ["@schummar/icu-type-parser@1.21.5", "", {}, "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw=="],
|
||||||
|
|
||||||
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
|
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
|
||||||
|
|
||||||
|
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||||
|
|
||||||
|
"@swc/core": ["@swc/core@1.15.3", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.3", "@swc/core-darwin-x64": "1.15.3", "@swc/core-linux-arm-gnueabihf": "1.15.3", "@swc/core-linux-arm64-gnu": "1.15.3", "@swc/core-linux-arm64-musl": "1.15.3", "@swc/core-linux-x64-gnu": "1.15.3", "@swc/core-linux-x64-musl": "1.15.3", "@swc/core-win32-arm64-msvc": "1.15.3", "@swc/core-win32-ia32-msvc": "1.15.3", "@swc/core-win32-x64-msvc": "1.15.3" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-Qd8eBPkUFL4eAONgGjycZXj1jFCBW8Fd+xF0PzdTlBCWQIV1xnUT7B93wUANtW3KGjl3TRcOyxwSx/u/jyKw/Q=="],
|
||||||
|
|
||||||
|
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-AXfeQn0CvcQ4cndlIshETx6jrAM45oeUrK8YeEY6oUZU/qzz0Id0CyvlEywxkWVC81Ajpd8TQQ1fW5yx6zQWkQ=="],
|
||||||
|
|
||||||
|
"@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-p68OeCz1ui+MZYG4wmfJGvcsAcFYb6Sl25H9TxWl+GkBgmNimIiRdnypK9nBGlqMZAcxngNPtnG3kEMNnvoJ2A=="],
|
||||||
|
|
||||||
|
"@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.3", "", { "os": "linux", "cpu": "arm" }, "sha512-Nuj5iF4JteFgwrai97mUX+xUOl+rQRHqTvnvHMATL/l9xE6/TJfPBpd3hk/PVpClMXG3Uvk1MxUFOEzM1JrMYg=="],
|
||||||
|
|
||||||
|
"@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-2Nc/s8jE6mW2EjXWxO/lyQuLKShcmTrym2LRf5Ayp3ICEMX6HwFqB1EzDhwoMa2DcUgmnZIalesq2lG3krrUNw=="],
|
||||||
|
|
||||||
|
"@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-j4SJniZ/qaZ5g8op+p1G9K1z22s/EYGg1UXIb3+Cg4nsxEpF5uSIGEE4mHUfA70L0BR9wKT2QF/zv3vkhfpX4g=="],
|
||||||
|
|
||||||
|
"@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.3", "", { "os": "linux", "cpu": "x64" }, "sha512-aKttAZnz8YB1VJwPQZtyU8Uk0BfMP63iDMkvjhJzRZVgySmqt/apWSdnoIcZlUoGheBrcqbMC17GGUmur7OT5A=="],
|
||||||
|
|
||||||
|
"@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.3", "", { "os": "linux", "cpu": "x64" }, "sha512-oe8FctPu1gnUsdtGJRO2rvOUIkkIIaHqsO9xxN0bTR7dFTlPTGi2Fhk1tnvXeyAvCPxLIcwD8phzKg6wLv9yug=="],
|
||||||
|
|
||||||
|
"@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-L9AjzP2ZQ/Xh58e0lTRMLvEDrcJpR7GwZqAtIeNLcTK7JVE+QineSyHp0kLkO1rttCHyCy0U74kDTj0dRz6raA=="],
|
||||||
|
|
||||||
|
"@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-B8UtogMzErUPDWUoKONSVBdsgKYd58rRyv2sHJWKOIMCHfZ22FVXICR4O/VwIYtlnZ7ahERcjayBHDlBZpR0aw=="],
|
||||||
|
|
||||||
|
"@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.3", "", { "os": "win32", "cpu": "x64" }, "sha512-SpZKMR9QBTecHeqpzJdYEfgw30Oo8b/Xl6rjSzBt1g0ZsXyy60KLXrp6IagQyfTYqNYE/caDvwtF2FPn7pomog=="],
|
||||||
|
|
||||||
|
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
|
||||||
|
|
||||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||||
|
|
||||||
|
"@swc/types": ["@swc/types@0.1.25", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g=="],
|
||||||
|
|
||||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.17", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.17" } }, "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg=="],
|
"@tailwindcss/node": ["@tailwindcss/node@4.1.17", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.17" } }, "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.17", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.17", "@tailwindcss/oxide-darwin-arm64": "4.1.17", "@tailwindcss/oxide-darwin-x64": "4.1.17", "@tailwindcss/oxide-freebsd-x64": "4.1.17", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", "@tailwindcss/oxide-linux-x64-musl": "4.1.17", "@tailwindcss/oxide-wasm32-wasi": "4.1.17", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" } }, "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA=="],
|
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.17", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.17", "@tailwindcss/oxide-darwin-arm64": "4.1.17", "@tailwindcss/oxide-darwin-x64": "4.1.17", "@tailwindcss/oxide-freebsd-x64": "4.1.17", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", "@tailwindcss/oxide-linux-x64-musl": "4.1.17", "@tailwindcss/oxide-wasm32-wasi": "4.1.17", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" } }, "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA=="],
|
||||||
@ -353,6 +480,8 @@
|
|||||||
|
|
||||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||||
|
|
||||||
|
"@types/uuid": ["@types/uuid@11.0.0", "", { "dependencies": { "uuid": "*" } }, "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA=="],
|
||||||
|
|
||||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.48.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/type-utils": "8.48.0", "@typescript-eslint/utils": "8.48.0", "@typescript-eslint/visitor-keys": "8.48.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.48.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ=="],
|
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.48.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/type-utils": "8.48.0", "@typescript-eslint/utils": "8.48.0", "@typescript-eslint/visitor-keys": "8.48.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.48.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ=="],
|
||||||
|
|
||||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.48.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", "@typescript-eslint/typescript-estree": "8.48.0", "@typescript-eslint/visitor-keys": "8.48.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ=="],
|
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.48.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", "@typescript-eslint/typescript-estree": "8.48.0", "@typescript-eslint/visitor-keys": "8.48.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ=="],
|
||||||
@ -421,6 +550,8 @@
|
|||||||
|
|
||||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
|
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||||
|
|
||||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||||
|
|
||||||
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
|
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
|
||||||
@ -519,6 +650,8 @@
|
|||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||||
|
|
||||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||||
|
|
||||||
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
|
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
|
||||||
@ -535,6 +668,8 @@
|
|||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
|
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||||
|
|
||||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||||
|
|
||||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||||
@ -651,6 +786,8 @@
|
|||||||
|
|
||||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||||
|
|
||||||
|
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||||
|
|
||||||
"get-port-please": ["get-port-please@3.1.2", "", {}, "sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ=="],
|
"get-port-please": ["get-port-please@3.1.2", "", {}, "sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ=="],
|
||||||
|
|
||||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||||
@ -707,6 +844,8 @@
|
|||||||
|
|
||||||
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
||||||
|
|
||||||
|
"intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="],
|
||||||
|
|
||||||
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
|
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
|
||||||
|
|
||||||
"is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
|
"is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
|
||||||
@ -857,6 +996,8 @@
|
|||||||
|
|
||||||
"named-placeholders": ["named-placeholders@1.1.3", "", { "dependencies": { "lru-cache": "^7.14.1" } }, "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w=="],
|
"named-placeholders": ["named-placeholders@1.1.3", "", { "dependencies": { "lru-cache": "^7.14.1" } }, "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w=="],
|
||||||
|
|
||||||
|
"nan": ["nan@2.23.1", "", {}, "sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw=="],
|
||||||
|
|
||||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
"nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="],
|
"nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="],
|
||||||
@ -865,8 +1006,14 @@
|
|||||||
|
|
||||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||||
|
|
||||||
|
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||||
|
|
||||||
"next": ["next@16.0.5", "", { "dependencies": { "@next/env": "16.0.5", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.5", "@next/swc-darwin-x64": "16.0.5", "@next/swc-linux-arm64-gnu": "16.0.5", "@next/swc-linux-arm64-musl": "16.0.5", "@next/swc-linux-x64-gnu": "16.0.5", "@next/swc-linux-x64-musl": "16.0.5", "@next/swc-win32-arm64-msvc": "16.0.5", "@next/swc-win32-x64-msvc": "16.0.5", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-XUPsFqSqu/NDdPfn/cju9yfIedkDI7ytDoALD9todaSMxk1Z5e3WcbUjfI9xsanFTys7xz62lnRWNFqJordzkQ=="],
|
"next": ["next@16.0.5", "", { "dependencies": { "@next/env": "16.0.5", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.5", "@next/swc-darwin-x64": "16.0.5", "@next/swc-linux-arm64-gnu": "16.0.5", "@next/swc-linux-arm64-musl": "16.0.5", "@next/swc-linux-x64-gnu": "16.0.5", "@next/swc-linux-x64-musl": "16.0.5", "@next/swc-win32-arm64-msvc": "16.0.5", "@next/swc-win32-x64-msvc": "16.0.5", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-XUPsFqSqu/NDdPfn/cju9yfIedkDI7ytDoALD9todaSMxk1Z5e3WcbUjfI9xsanFTys7xz62lnRWNFqJordzkQ=="],
|
||||||
|
|
||||||
|
"next-intl": ["next-intl@4.5.6", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.5.4", "@swc/core": "^1.15.2", "negotiator": "^1.0.0", "next-intl-swc-plugin-extractor": "^4.5.6", "po-parser": "^1.0.2", "use-intl": "^4.5.6" }, "peerDependencies": { "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0", "typescript": "^5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-LD1mM9HL44NGqDus3cpIE8wqRU87GWf7rdy1g7UHceT9KJvvjER/jlmIRt3GHaoOiln16K4IbHpO2ZI6jiqiDQ=="],
|
||||||
|
|
||||||
|
"next-intl-swc-plugin-extractor": ["next-intl-swc-plugin-extractor@4.5.6", "", {}, "sha512-ApB3wGYqni8lks90UuaslnCK4a+q8I6ajEafSpknN6RDrs2hUwNuWVrjKhOuhLqNLn4kBKl+Zi5c0WKpL968ag=="],
|
||||||
|
|
||||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||||
|
|
||||||
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||||
@ -933,6 +1080,8 @@
|
|||||||
|
|
||||||
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
|
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
|
||||||
|
|
||||||
|
"po-parser": ["po-parser@1.0.2", "", {}, "sha512-yTIQL8PZy7V8c0psPoJUx7fayez+Mo/53MZgX9MPuPHx+Dt+sRPNuRbI+6Oqxnddhkd68x4Nlgon/zizL1Xg+w=="],
|
||||||
|
|
||||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||||
@ -967,8 +1116,16 @@
|
|||||||
|
|
||||||
"react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="],
|
"react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="],
|
||||||
|
|
||||||
|
"react-hook-form": ["react-hook-form@7.67.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-E55EOwKJHHIT/I6J9DmQbCWToAYSw9nN5R57MZw9rMtjh+YQreMDxRLfdjfxQbiJ3/qbg3Z02wGzBX4M+5fMtQ=="],
|
||||||
|
|
||||||
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||||
|
|
||||||
|
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||||
|
|
||||||
|
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||||
|
|
||||||
|
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||||
|
|
||||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||||
|
|
||||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||||
@ -1003,6 +1160,8 @@
|
|||||||
|
|
||||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
|
"scrypt": ["scrypt@6.0.3", "", { "dependencies": { "nan": "^2.0.8" } }, "sha512-NDrWb9hCm6Ev170XYVl7TSgu4R44Rjc8EVw1ce0TMN8EkfLvkhlwcfp61OVNc8EJDiHaQwVErn1fIU0RO3kSZw=="],
|
||||||
|
|
||||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="],
|
"seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="],
|
||||||
@ -1113,6 +1272,14 @@
|
|||||||
|
|
||||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||||
|
|
||||||
|
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||||
|
|
||||||
|
"use-intl": ["use-intl@4.5.6", "", { "dependencies": { "@formatjs/fast-memoize": "^2.2.0", "@schummar/icu-type-parser": "1.21.5", "intl-messageformat": "^10.5.14" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, "sha512-SzxrUH/X3LatVcgWVqz8ifoBK01LC3fzc8Y29Vj0QfrjLIXfGwxvJ3aapyWumBIIHsZmCR0Rx5FpKDWCc9JiOg=="],
|
||||||
|
|
||||||
|
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||||
|
|
||||||
|
"uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="],
|
||||||
|
|
||||||
"valibot": ["valibot@1.1.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw=="],
|
"valibot": ["valibot@1.1.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw=="],
|
||||||
|
|
||||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
@ -1147,12 +1314,26 @@
|
|||||||
|
|
||||||
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||||
|
|
||||||
|
"@formatjs/ecma402-abstract/@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="],
|
||||||
|
|
||||||
"@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.0.1", "", { "dependencies": { "@prisma/debug": "7.0.1" } }, "sha512-DrsGnZOsF7PlAE7UtqmJenWti87RQtg7v9qW9alS71Pj0P6ZQV0RuzRQaql9dCWoo6qKAaF5U/L4kI826MmiZg=="],
|
"@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.0.1", "", { "dependencies": { "@prisma/debug": "7.0.1" } }, "sha512-DrsGnZOsF7PlAE7UtqmJenWti87RQtg7v9qW9alS71Pj0P6ZQV0RuzRQaql9dCWoo6qKAaF5U/L4kI826MmiZg=="],
|
||||||
|
|
||||||
"@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.0.1", "", { "dependencies": { "@prisma/debug": "7.0.1" } }, "sha512-DrsGnZOsF7PlAE7UtqmJenWti87RQtg7v9qW9alS71Pj0P6ZQV0RuzRQaql9dCWoo6qKAaF5U/L4kI826MmiZg=="],
|
"@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.0.1", "", { "dependencies": { "@prisma/debug": "7.0.1" } }, "sha512-DrsGnZOsF7PlAE7UtqmJenWti87RQtg7v9qW9alS71Pj0P6ZQV0RuzRQaql9dCWoo6qKAaF5U/L4kI826MmiZg=="],
|
||||||
|
|
||||||
"@prisma/get-platform/@prisma/debug": ["@prisma/debug@6.8.2", "", {}, "sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg=="],
|
"@prisma/get-platform/@prisma/debug": ["@prisma/debug@6.8.2", "", {}, "sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
|
||||||
|
|||||||
84
components/ActionButton/ActionButton.tsx
Normal file
84
components/ActionButton/ActionButton.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* File: ActionButton.tsx
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: ActionButton component for kreatiVortex platform
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ActionButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
loading?: boolean;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
|
||||||
|
({ className, variant = 'primary', size = 'md', loading = false, icon, children, disabled, ...props }, ref) => {
|
||||||
|
const baseClasses = 'inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary: 'bg-gold-500 text-navy-900 hover:bg-gold-400 focus:ring-gold-400 shadow-lg hover:shadow-gold-400/25',
|
||||||
|
secondary: ' text-white hover:bg-navy-600 focus:ring-navy-500',
|
||||||
|
outline: 'border-2 border-gold-400 text-gold-400 hover:bg-gold-400 hover:text-navy-900 focus:ring-gold-400',
|
||||||
|
ghost: 'text-gray-300 hover:text-white hover:bg-white/10 focus:ring-white/20',
|
||||||
|
destructive: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'px-3 py-1.5 text-sm',
|
||||||
|
md: 'px-4 py-2 text-sm',
|
||||||
|
lg: 'px-6 py-3 text-base',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
baseClasses,
|
||||||
|
variants[variant],
|
||||||
|
sizes[size],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<svg
|
||||||
|
className="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0112 20c5.514 0 10.241-2.636 11.317-7.319l.702-.707a1 1 0 00-1.414 1.414l-.707.707c-.878.879-2.262 1.414-3.817z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{!loading && icon && <span className="ml-2">{icon}</span>}
|
||||||
|
{!loading && icon && <span className="mr-2">{icon}</span>}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ActionButton.displayName = 'ActionButton';
|
||||||
|
|
||||||
|
export default ActionButton;
|
||||||
@ -6,4 +6,4 @@
|
|||||||
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ActionButton component will be created here
|
export { default } from "./ActionButton";
|
||||||
101
components/AttachmentDisplay.tsx
Normal file
101
components/AttachmentDisplay.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* File: AttachmentDisplay.tsx
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Component to display file attachments
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface Attachment {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
originalName: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttachmentDisplayProps {
|
||||||
|
attachments: Attachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AttachmentDisplay({ attachments }: AttachmentDisplayProps) {
|
||||||
|
if (!attachments || attachments.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFileIcon = (mimeType: string) => {
|
||||||
|
if (mimeType.includes('pdf')) {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0015.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mimeType.includes('word') || mimeType.includes('document')) {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mimeType.includes('image')) {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<p className="text-sm font-medium text-gray-300 mb-2">Lampiran:</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{attachments.map((attachment) => (
|
||||||
|
<a
|
||||||
|
key={attachment.id}
|
||||||
|
href={attachment.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center space-x-3 p-3 bg-white/5 rounded-lg border border-white/10 hover:bg-white/10 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{getFileIcon(attachment.mimeType)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-white font-medium truncate group-hover:text-gold-400 transition-colors">
|
||||||
|
{attachment.originalName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400">{formatFileSize(attachment.size)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="w-4 h-4 text-gray-400 group-hover:text-gold-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
components/CommentForm.tsx
Normal file
151
components/CommentForm.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* File: CommentForm.tsx
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Comment form with file upload for kreatiVortex platform
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { uploadFile, isAllowedFileType, formatFileSize, UploadedFile } from '@/lib/upload';
|
||||||
|
|
||||||
|
interface CommentFormProps {
|
||||||
|
onSubmit: (content: string, attachments: UploadedFile[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
buttonText?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommentForm({
|
||||||
|
onSubmit,
|
||||||
|
placeholder = "Tulis komentar...",
|
||||||
|
buttonText = "Kirim Komentar",
|
||||||
|
loading = false
|
||||||
|
}: CommentFormProps) {
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [attachments, setAttachments] = useState<UploadedFile[]>([]);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (content.trim() || attachments.length > 0) {
|
||||||
|
onSubmit(content.trim(), attachments);
|
||||||
|
setContent('');
|
||||||
|
setAttachments([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files) return;
|
||||||
|
|
||||||
|
const newAttachments: UploadedFile[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
|
||||||
|
if (!isAllowedFileType(file)) {
|
||||||
|
alert(`File ${file.name} tidak diizinkan. Hanya PDF, Word, dan file gambar yang diperbolehkan.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 10 * 1024 * 1024) { // 10MB limit
|
||||||
|
alert(`File ${file.name} terlalu besar. Maksimal ukuran file adalah 10MB.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uploadedFile = await uploadFile(file, 'comment-attachments');
|
||||||
|
newAttachments.push(uploadedFile);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading file:', error);
|
||||||
|
alert(`Gagal mengupload file ${file.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttachments(prev => [...prev, ...newAttachments]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAttachment = (index: number) => {
|
||||||
|
setAttachments(prev => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={3}
|
||||||
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400 resize-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png,.gif"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="hidden"
|
||||||
|
id="file-upload"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="file-upload"
|
||||||
|
className="cursor-pointer inline-flex items-center px-3 py-1.5 text-sm bg-gold-500/20 hover:bg-gold-500/30 text-gold-400 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||||
|
</svg>
|
||||||
|
Lampirkan File
|
||||||
|
</label>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
PDF, Word, Gambar (Maks 10MB)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{attachments.map((file, index) => (
|
||||||
|
<div key={file.id} className="flex items-center justify-between bg-white/5 rounded-lg p-2 border border-white/10">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="w-6 h-6 bg-gold-500/20 rounded flex items-center justify-center">
|
||||||
|
<svg className="w-3 h-3 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white font-medium truncate max-w-[150px]">{file.originalName}</p>
|
||||||
|
<p className="text-xs text-gray-400">{formatFileSize(file.size)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeAttachment(index)}
|
||||||
|
className="text-red-400 hover:text-red-300 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || (!content.trim() && attachments.length === 0)}
|
||||||
|
className="bg-gold-500 hover:bg-gold-600 text-navy-900"
|
||||||
|
>
|
||||||
|
{loading ? 'Mengirim...' : buttonText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
components/Common/AppDataView.tsx
Normal file
131
components/Common/AppDataView.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* File: AppDataView.tsx
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Data view component for kreatiVortex platform
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface Column<T> {
|
||||||
|
key: keyof T;
|
||||||
|
label: string;
|
||||||
|
render?: (value: T[keyof T], row: T) => React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppDataViewProps<T> {
|
||||||
|
data: T[];
|
||||||
|
columns: Column<T>[];
|
||||||
|
loading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppDataView<T extends Record<string, unknown>>({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
loading = false,
|
||||||
|
className,
|
||||||
|
emptyMessage = 'Tidak ada data tersedia'
|
||||||
|
}: AppDataViewProps<T>) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Skeleton rows */}
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<div key={index} className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="h-4 bg-gray-600/20 rounded animate-pulse"></div>
|
||||||
|
<div className="h-4 bg-gray-600/20 rounded animate-pulse"></div>
|
||||||
|
<div className="h-4 bg-gray-600/20 rounded animate-pulse w-3/4"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
'bg-white/10 backdrop-blur-sm rounded-xl p-12 border border-white/20 text-center',
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
<div className="w-16 h-16 bg-gray-600/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-300 mb-2">{emptyMessage}</h3>
|
||||||
|
<p className="text-gray-400">Coba ubah filter atau tambah data baru</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
'bg-white/10 backdrop-blur-sm rounded-xl border border-white/20 overflow-hidden',
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
{/* Table for desktop */}
|
||||||
|
<div className="hidden lg:block overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-white/10">
|
||||||
|
{columns.map((column) => (
|
||||||
|
<th
|
||||||
|
key={column.key as string}
|
||||||
|
className={cn(
|
||||||
|
'px-6 py-4 text-left text-xs font-medium text-gray-300 uppercase tracking-wider',
|
||||||
|
column.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{column.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/10">
|
||||||
|
{data.map((row, index) => (
|
||||||
|
<tr key={index} className="hover:bg-white/5 transition-colors">
|
||||||
|
{columns.map((column) => (
|
||||||
|
<td
|
||||||
|
key={column.key as string}
|
||||||
|
className="px-6 py-4 whitespace-nowrap text-sm text-gray-300"
|
||||||
|
>
|
||||||
|
{column.render ? column.render(row[column.key], row) : String(row[column.key] || '')}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cards for mobile */}
|
||||||
|
<div className="lg:hidden space-y-4 p-4">
|
||||||
|
{data.map((row, index) => (
|
||||||
|
<div key={index} className="bg-white/5 rounded-lg p-4 border border-white/10">
|
||||||
|
{columns.map((column) => (
|
||||||
|
<div key={column.key as string} className="mb-3">
|
||||||
|
<div className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-1">
|
||||||
|
{column.label}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-300">
|
||||||
|
{column.render ? column.render(row[column.key], row) : String(row[column.key] || '')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppDataView;
|
||||||
40
components/Common/AppForm.tsx
Normal file
40
components/Common/AppForm.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* File: AppForm.tsx
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Form component for kreatiVortex platform
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface AppFormProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
onSubmit?: (e: React.FormEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppForm = React.forwardRef<HTMLFormElement, AppFormProps>(
|
||||||
|
({ children, className, onSubmit, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
ref={ref}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className={cn(
|
||||||
|
'space-y-6 bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
AppForm.displayName = 'AppForm';
|
||||||
|
|
||||||
|
export default AppForm;
|
||||||
216
components/Forms/VideoForm.tsx
Normal file
216
components/Forms/VideoForm.tsx
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
interface VideoFormData {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
videoUrl: string;
|
||||||
|
videoType: 'YOUTUBE' | 'LOCAL';
|
||||||
|
isPublic: boolean;
|
||||||
|
menuId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoFormProps {
|
||||||
|
initialData?: VideoFormData;
|
||||||
|
onSubmit?: (data: VideoFormData) => Promise<void>;
|
||||||
|
isEditing?: boolean;
|
||||||
|
menus?: { id: string; title: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VideoForm({ initialData, onSubmit, isEditing = false, menus = [] }: VideoFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<VideoFormData>(initialData || {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
videoUrl: '',
|
||||||
|
videoType: 'YOUTUBE',
|
||||||
|
isPublic: true,
|
||||||
|
menuId: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
const { name, value, type } = e.target;
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked : value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectChange = (name: string, value: string) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value === 'no-selection' ? '' : value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (onSubmit) {
|
||||||
|
await onSubmit(formData);
|
||||||
|
} else {
|
||||||
|
// Default submission logic if no prop provided
|
||||||
|
console.log('Submitting video data:', formData);
|
||||||
|
// Simulate API call
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
router.push('/dashboard/videos');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting form:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="title" className="text-gray-300">
|
||||||
|
Judul Video
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
value={formData.title}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Masukkan judul video"
|
||||||
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="description" className="text-gray-300">
|
||||||
|
Deskripsi
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
rows={4}
|
||||||
|
value={formData.description}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Deskripsi singkat tentang video"
|
||||||
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="menuId" className="text-gray-300">
|
||||||
|
Menu (Opsional)
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.menuId || 'no-selection'}
|
||||||
|
onValueChange={(value) => handleSelectChange('menuId', value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-white/10 border-white/20 text-white">
|
||||||
|
<SelectValue placeholder="Pilih menu" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="no-selection">Tidak ada menu</SelectItem>
|
||||||
|
{menus.map((menu) => (
|
||||||
|
<SelectItem key={menu.id} value={menu.id}>
|
||||||
|
{menu.title}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="videoType" className="text-gray-300">
|
||||||
|
Tipe Video
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.videoType}
|
||||||
|
onValueChange={(value) => handleSelectChange('videoType', value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-white/10 border-white/20 text-white">
|
||||||
|
<SelectValue placeholder="Pilih tipe video" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="YOUTUBE">YouTube</SelectItem>
|
||||||
|
<SelectItem value="LOCAL">Upload File (Local)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="videoUrl" className="text-gray-300">
|
||||||
|
{formData.videoType === 'YOUTUBE' ? 'URL YouTube' : 'File Video'}
|
||||||
|
</Label>
|
||||||
|
{formData.videoType === 'YOUTUBE' ? (
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
id="videoUrl"
|
||||||
|
name="videoUrl"
|
||||||
|
required
|
||||||
|
value={formData.videoUrl}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
|
||||||
|
placeholder="https://youtube.com/watch?v=..."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="border-2 border-dashed border-white/20 rounded-lg p-6 text-center">
|
||||||
|
<p className="text-gray-400 mb-2">Upload file video belum tersedia dalam demo ini</p>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="videoUrl"
|
||||||
|
value={formData.videoUrl}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="isPublic"
|
||||||
|
name="isPublic"
|
||||||
|
checked={formData.isPublic}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-4 h-4 text-gold-400 bg-white/10 border-white/20 rounded focus:ring-gold-400 focus:ring-offset-white/20"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="isPublic" className="text-gray-300 font-medium">
|
||||||
|
Publik (Dapat dilihat oleh semua orang)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 flex justify-end space-x-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-gray-300 hover:text-white"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-gold-500 hover:bg-gold-600 text-navy-900"
|
||||||
|
>
|
||||||
|
{loading ? 'Menyimpan...' : (isEditing ? 'Simpan Perubahan' : 'Upload Video')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
components/Layouts/DashboardMenu.tsx
Normal file
191
components/Layouts/DashboardMenu.tsx
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Link, usePathname } from '@/i18n/routing';
|
||||||
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
|
import { useFetch } from '@/hooks/useFetch';
|
||||||
|
import { isAdmin } from '@/lib/admin';
|
||||||
|
import {
|
||||||
|
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuPortal, DropdownMenuSubContent
|
||||||
|
} from '../ui/dropdown-menu';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { BookUserIcon, HouseIcon, InfoIcon, MenuIcon, MessagesSquareIcon, UsersIcon, VideoIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
interface MenuWithChildren {
|
||||||
|
id: string;
|
||||||
|
name: any; // Json type
|
||||||
|
slug: string;
|
||||||
|
children: MenuWithChildren[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClassData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardMenuProps {
|
||||||
|
menus: MenuWithChildren[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardMenu({ menus = [] }: DashboardMenuProps) {
|
||||||
|
const t = useTranslations('Sidebar');
|
||||||
|
const locale = useLocale();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { data: classes } = useFetch<ClassData[]>('/api/classes');
|
||||||
|
const { data: userProfile } = useFetch<any>('/api/user/profile');
|
||||||
|
|
||||||
|
const isActive = (path: string) => pathname === path;
|
||||||
|
|
||||||
|
// Check if user is admin or educator
|
||||||
|
const userIsAdmin = isAdmin(userProfile?.data);
|
||||||
|
|
||||||
|
const getLocalizedName = (name: any) => {
|
||||||
|
if (!name) return '';
|
||||||
|
return name[locale] || name['id'] || name['en'] || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-64 md:w-auto md:bg-transparent md:backdrop-blur-none md:border-0 backdrop-blur-sm border-r border-white/10 h-full">
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="px-4 flex-1 overflow-y-auto">
|
||||||
|
<ul className="flex flex-col md:flex-row space-y-2 space-x-2 justify-center items-center">
|
||||||
|
<li className='mb-0'>
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
>
|
||||||
|
<Button variant={isActive('/dashboard') ? "glass" : "ghost"}>
|
||||||
|
<HouseIcon className="w-5 h-5" />
|
||||||
|
{t('home')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
{userIsAdmin && (
|
||||||
|
<li className='mb-0'>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/videos"
|
||||||
|
>
|
||||||
|
<Button variant={isActive('/dashboard/videos') ? "glass" : "ghost"}>
|
||||||
|
<VideoIcon className="w-5 h-5" />
|
||||||
|
{t('videos')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
<li className='mb-0'>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button size="lg" variant="ghost">
|
||||||
|
<MenuIcon className="w-5 h-5" />
|
||||||
|
{t('menu')}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-56" align="start">
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
{menus.map((menu) => (
|
||||||
|
<div key={menu.id}>
|
||||||
|
{menu.children && menu.children.length > 0 ? (
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>
|
||||||
|
{getLocalizedName(menu.name)}
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
{menu.children.map((child) => (
|
||||||
|
<DropdownMenuItem key={child.id}>
|
||||||
|
<Link href={`/dashboard/pages/${child.slug}`} className="w-full">
|
||||||
|
{getLocalizedName(child.name)}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Link href={`/dashboard/pages/${menu.slug}`} className="w-full">
|
||||||
|
{getLocalizedName(menu.name)}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</li>
|
||||||
|
<li className='mb-0'>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/classes"
|
||||||
|
>
|
||||||
|
<Button variant={isActive('/dashboard/classes') ? "glass" : "ghost"}>
|
||||||
|
<UsersIcon className="w-5 h-5" />
|
||||||
|
{t('classes')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className='mb-0'>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button size="lg" variant="ghost">
|
||||||
|
<MessagesSquareIcon className="w-5 h-5" />
|
||||||
|
{t('forum')}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-56" align="start">
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Link href={`/dashboard/forum/umum`} className="w-full">
|
||||||
|
{t('general')}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>
|
||||||
|
{t('group')}
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
{classes?.map((classItem) => (
|
||||||
|
<DropdownMenuItem key={classItem.id}>
|
||||||
|
<Link href={`/dashboard/forum/${classItem.id}`} className="w-full">
|
||||||
|
{classItem.name}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
{(!classes || classes.length === 0) && (
|
||||||
|
<DropdownMenuItem disabled>
|
||||||
|
{t('noClasses')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</li>
|
||||||
|
<li className='mb-0'>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/contact"
|
||||||
|
>
|
||||||
|
<Button variant={isActive('/dashboard/contact') ? "glass" : "ghost"}>
|
||||||
|
<BookUserIcon className="w-5 h-5" />
|
||||||
|
{t('contact')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className='mb-0'>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/faq"
|
||||||
|
>
|
||||||
|
<Button variant={isActive('/dashboard/faq') ? "glass" : "ghost"}>
|
||||||
|
<InfoIcon className="w-5 h-5" />
|
||||||
|
{t('faq')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
components/Layouts/DashboardProfile.tsx
Normal file
102
components/Layouts/DashboardProfile.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { useRouter } from "@/i18n/routing";
|
||||||
|
import { Link } from "@/i18n/routing";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export default function DashboardProfile() {
|
||||||
|
const t = useTranslations('Profile');
|
||||||
|
const { data: session, isPending } = authClient.useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
// If loading, show skeleton
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-3 animate-pulse">
|
||||||
|
<div className="text-right hidden md:block">
|
||||||
|
<div className="h-4 w-24 bg-gray-600 rounded"></div>
|
||||||
|
<div className="h-3 w-16 bg-gray-600 rounded mt-1"></div>
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 bg-gray-600 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no session, show nothing (or could show login button, but dashboard is protected)
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = session.user;
|
||||||
|
const initials = user.name
|
||||||
|
? user.name
|
||||||
|
.split(" ")
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2)
|
||||||
|
: "??";
|
||||||
|
|
||||||
|
// Attempt to get role from user object if it exists (custom field), or default
|
||||||
|
// @ts-ignore - role might not be in the default type definition yet
|
||||||
|
const role = user.role || "Member";
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
await authClient.signOut();
|
||||||
|
router.push("/auth/signin");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="flex items-center space-x-3 group focus:outline-none"
|
||||||
|
>
|
||||||
|
<div className="text-right hidden md:block">
|
||||||
|
<p className="text-sm font-medium text-white">{user.name}</p>
|
||||||
|
<p className="text-xs text-gray-400 capitalize">{role.toLowerCase().replace('_', ' ')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 bg-gold-400 rounded-full flex items-center justify-center overflow-hidden border-2 border-transparent group-hover:border-white/20 transition-all">
|
||||||
|
{user.image ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={user.image} alt={user.name} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-navy-900 font-bold">{initials}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Menu */}
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
></div>
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-48 bg-popover text-popover-foreground backdrop-blur-md rounded-lg shadow-xl z-50 py-1">
|
||||||
|
<div className="px-4 py-2 border-b border-white/5 md:hidden">
|
||||||
|
<p className="text-sm font-medium text-white">{user.name}</p>
|
||||||
|
<p className="text-xs text-gray-400">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/profile"
|
||||||
|
className="block px-4 py-2 text-sm text-popover-foreground hover:bg-white/10 hover:text-white transition-colors"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
{t('profileSettings')}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleSignOut}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-white/10 hover:text-red-300 transition-colors"
|
||||||
|
>
|
||||||
|
{t('signOut')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,4 +6,5 @@
|
|||||||
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Layout components will be exported here as they are created
|
export { default as DashboardProfile } from './DashboardProfile';
|
||||||
|
export { default as DashboardMenu } from './DashboardMenu';
|
||||||
|
|||||||
@ -6,4 +6,9 @@
|
|||||||
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Components will be exported here as they are created
|
// ActionButton export
|
||||||
|
export { default as ActionButton } from "./ActionButton";
|
||||||
|
|
||||||
|
// Common components exports
|
||||||
|
export { default as AppForm } from "./Common/AppForm";
|
||||||
|
export { default as AppDataView } from "./Common/AppDataView";
|
||||||
64
components/ui/button.tsx
Normal file
64
components/ui/button.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
glass:
|
||||||
|
"border bg-white/5 shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-white/5 hover:text-accent-foreground dark:hover:bg-white/5",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-lg gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-lg px-6 has-[>svg]:px-4",
|
||||||
|
xl: "h-12 rounded-lg px-8 has-[>svg]:px-6 text-base",
|
||||||
|
"2xl": "h-14 rounded-lg px-10 has-[>svg]:px-8 text-lg",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
257
components/ui/dropdown-menu.tsx
Normal file
257
components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"backdrop-blur-xs bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"backdrop-blur-xs bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
248
components/ui/field.tsx
Normal file
248
components/ui/field.tsx
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
data-slot="field-set"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-6",
|
||||||
|
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLegend({
|
||||||
|
className,
|
||||||
|
variant = "legend",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||||
|
return (
|
||||||
|
<legend
|
||||||
|
data-slot="field-legend"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"mb-3 font-medium",
|
||||||
|
"data-[variant=legend]:text-base",
|
||||||
|
"data-[variant=label]:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-group"
|
||||||
|
className={cn(
|
||||||
|
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldVariants = cva(
|
||||||
|
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||||
|
horizontal: [
|
||||||
|
"flex-row items-center",
|
||||||
|
"[&>[data-slot=field-label]]:flex-auto",
|
||||||
|
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
],
|
||||||
|
responsive: [
|
||||||
|
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
|
||||||
|
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||||
|
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="field"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(fieldVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-content"
|
||||||
|
className={cn(
|
||||||
|
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Label>) {
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||||
|
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
|
||||||
|
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="field-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||||
|
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
|
||||||
|
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-separator"
|
||||||
|
data-content={!!children}
|
||||||
|
className={cn(
|
||||||
|
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Separator className="absolute inset-0 top-1/2" />
|
||||||
|
{children && (
|
||||||
|
<span
|
||||||
|
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||||
|
data-slot="field-separator-content"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldError({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
errors,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
errors?: Array<{ message?: string } | undefined>
|
||||||
|
}) {
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if (children) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errors?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueErrors = [
|
||||||
|
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (uniqueErrors?.length == 1) {
|
||||||
|
return uniqueErrors[0]?.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||||
|
{uniqueErrors.map(
|
||||||
|
(error, index) =>
|
||||||
|
error?.message && <li key={index}>{error.message}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}, [children, errors])
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-slot="field-error"
|
||||||
|
className={cn("text-destructive text-sm font-normal", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldSet,
|
||||||
|
FieldContent,
|
||||||
|
FieldTitle,
|
||||||
|
}
|
||||||
167
components/ui/form.tsx
Normal file
167
components/ui/form.tsx
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
useFormState,
|
||||||
|
type ControllerProps,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
const Form = FormProvider
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext)
|
||||||
|
const itemContext = React.useContext(FormItemContext)
|
||||||
|
const { getFieldState } = useFormContext()
|
||||||
|
const formState = useFormState({ name: fieldContext.name })
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState)
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error("useFormField should be used within <FormField>")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = itemContext
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormItemContextValue = {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const id = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div
|
||||||
|
data-slot="form-item"
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="form-label"
|
||||||
|
data-error={!!error}
|
||||||
|
className={cn("data-[error=true]:text-destructive", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
data-slot="form-control"
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-description"
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { error, formMessageId } = useFormField()
|
||||||
|
const body = error ? String(error?.message ?? "") : props.children
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-message"
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-destructive text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
}
|
||||||
21
components/ui/input.tsx
Normal file
21
components/ui/input.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
24
components/ui/label.tsx
Normal file
24
components/ui/label.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
187
components/ui/select.tsx
Normal file
187
components/ui/select.tsx
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "popper",
|
||||||
|
align = "center",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
28
components/ui/separator.tsx
Normal file
28
components/ui/separator.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
18
components/ui/textarea.tsx
Normal file
18
components/ui/textarea.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
61
hooks/useFetch.ts
Normal file
61
hooks/useFetch.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* File: useFetch.ts
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Custom hook for data fetching
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface FetchOptions {
|
||||||
|
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FetchResult<T> {
|
||||||
|
data: T | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFetch<T>(url: string, options?: FetchOptions): FetchResult<T> {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: options?.method || 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
body: options?.body ? JSON.stringify(options.body) : undefined,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
setData(result.data || result);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('An unknown error occurred'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
return { data, loading, error, refetch: fetchData };
|
||||||
|
}
|
||||||
17
i18n/request.ts
Normal file
17
i18n/request.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import {getRequestConfig} from 'next-intl/server';
|
||||||
|
import {routing} from './routing';
|
||||||
|
|
||||||
|
export default getRequestConfig(async ({requestLocale}) => {
|
||||||
|
// This typically corresponds to the `[locale]` segment
|
||||||
|
let locale = await requestLocale;
|
||||||
|
|
||||||
|
// Ensure that a valid locale is used
|
||||||
|
if (!locale || !routing.locales.includes(locale as any)) {
|
||||||
|
locale = routing.defaultLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
messages: (await import(`../messages/${locale}.json`)).default
|
||||||
|
};
|
||||||
|
});
|
||||||
15
i18n/routing.ts
Normal file
15
i18n/routing.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import {defineRouting} from 'next-intl/routing';
|
||||||
|
import {createNavigation} from 'next-intl/navigation';
|
||||||
|
|
||||||
|
export const routing = defineRouting({
|
||||||
|
// A list of all locales that are supported
|
||||||
|
locales: ['en', 'id'],
|
||||||
|
|
||||||
|
// Used when no locale matches
|
||||||
|
defaultLocale: 'en'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lightweight wrappers around Next.js' navigation APIs
|
||||||
|
// that will consider the routing configuration
|
||||||
|
export const {Link, redirect, usePathname, useRouter, getPathname} =
|
||||||
|
createNavigation(routing);
|
||||||
71
lib/admin.ts
Normal file
71
lib/admin.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* File: admin.ts
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-12-01
|
||||||
|
* Purpose: Utility functions for admin role checking
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has admin privileges
|
||||||
|
* @param userProfile - User profile object with role information
|
||||||
|
* @returns boolean - true if user is ADMIN or PENDIDIK, false otherwise
|
||||||
|
*/
|
||||||
|
export function isAdmin(userProfile: any): boolean {
|
||||||
|
if (!userProfile || !userProfile.role) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminRoles = ['ADMIN', 'PENDIDIK'];
|
||||||
|
return adminRoles.includes(userProfile.role.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user can upload videos
|
||||||
|
* @param userProfile - User profile object with role information
|
||||||
|
* @returns boolean - true if user can upload videos, false otherwise
|
||||||
|
*/
|
||||||
|
export function canUploadVideos(userProfile: any): boolean {
|
||||||
|
return !!userProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user can delete any video (not just their own)
|
||||||
|
* @param userProfile - User profile object with role information
|
||||||
|
* @returns boolean - true if user is ADMIN, false otherwise
|
||||||
|
*/
|
||||||
|
export function canDeleteAnyVideo(userProfile: any): boolean {
|
||||||
|
if (!userProfile || !userProfile.role) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return userProfile.role.name === 'ADMIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user can edit a specific video
|
||||||
|
* @param userProfile - User profile object with role information
|
||||||
|
* @param videoUploaderId - ID of the user who uploaded the video
|
||||||
|
* @returns boolean - true if user can edit the video, false otherwise
|
||||||
|
*/
|
||||||
|
export function canEditVideo(userProfile: any, videoUploaderId: string): boolean {
|
||||||
|
if (!userProfile || !userProfile.id || !userProfile.role) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAdmin(userProfile) || userProfile.id === videoUploaderId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user can delete a specific video
|
||||||
|
* @param userProfile - User profile object with role information
|
||||||
|
* @param videoUploaderId - ID of the user who uploaded the video
|
||||||
|
* @returns boolean - true if user can delete the video, false otherwise
|
||||||
|
*/
|
||||||
|
export function canDeleteVideo(userProfile: any, videoUploaderId: string): boolean {
|
||||||
|
if (!userProfile || !userProfile.id || !userProfile.role) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return canDeleteAnyVideo(userProfile) || userProfile.id === videoUploaderId;
|
||||||
|
}
|
||||||
38
lib/profile.ts
Normal file
38
lib/profile.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* File: profile.ts
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Utility functions for user profile management
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function getOrCreateUserProfile(userId: string) {
|
||||||
|
let userProfile = await prisma.userProfile.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
include: { role: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userProfile) {
|
||||||
|
// Create new user profile with UMUM role
|
||||||
|
const umumRole = await prisma.userRole.findUnique({
|
||||||
|
where: { name: 'UMUM' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!umumRole) {
|
||||||
|
throw new Error('Default role UMUM not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
userProfile = await prisma.userProfile.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
roleId: umumRole.id,
|
||||||
|
bio: 'New user profile'
|
||||||
|
},
|
||||||
|
include: { role: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return userProfile;
|
||||||
|
}
|
||||||
63
lib/upload.ts
Normal file
63
lib/upload.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* File: upload.ts
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: Client-side file upload utility functions
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface UploadedFile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
originalName: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFile(file: File, folder: string = 'uploads'): Promise<UploadedFile> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('folder', folder);
|
||||||
|
|
||||||
|
const response = await fetch('/api/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to upload file');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || 'Upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAllowedFileType(file: File): boolean {
|
||||||
|
const allowedTypes = [
|
||||||
|
'application/pdf',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/gif'
|
||||||
|
];
|
||||||
|
|
||||||
|
return allowedTypes.includes(file.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
@ -4,3 +4,10 @@ import { twMerge } from "tailwind-merge"
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getYouTubeId = (url: string) => {
|
||||||
|
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
|
||||||
|
const match = url.match(regExp);
|
||||||
|
return (match && match[2].length === 11) ? match[2] : null;
|
||||||
|
};
|
||||||
36
lib/youtube.ts
Normal file
36
lib/youtube.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* File: youtube.ts
|
||||||
|
* Created by: AI Assistant
|
||||||
|
* Date: 2025-11-29
|
||||||
|
* Purpose: YouTube utility functions
|
||||||
|
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function extractYoutubeId(url: string): string | null {
|
||||||
|
const regex = /(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/)|youtu\.be\/(?:watch\?v=|embed\/|v\/|shorts\/))([a-zA-Z0-9_-]{11})/;
|
||||||
|
const match = url.match(regex);
|
||||||
|
|
||||||
|
if (match && match[1]) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateYoutubeEmbedUrl(url: string): string {
|
||||||
|
const videoId = extractYoutubeId(url);
|
||||||
|
if (videoId) {
|
||||||
|
return `https://www.youtube.com/embed/${videoId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateYoutubeThumbnailUrl(url: string): string {
|
||||||
|
const videoId = extractYoutubeId(url);
|
||||||
|
if (videoId) {
|
||||||
|
return `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
151
messages/en.json
Normal file
151
messages/en.json
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
{
|
||||||
|
"HomePage": {
|
||||||
|
"title": "KreatiVortex",
|
||||||
|
"subtitle": "Dance Theory and Creation",
|
||||||
|
"login": "Login",
|
||||||
|
"registration": "Registration",
|
||||||
|
"startNow": "Start Now"
|
||||||
|
},
|
||||||
|
"Auth": {
|
||||||
|
"signInTitle": "Sign in to your account",
|
||||||
|
"signUpTitle": "Create a new account",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"passwordLabel": "Password",
|
||||||
|
"confirmPasswordLabel": "Confirm Password",
|
||||||
|
"nameLabel": "Full Name",
|
||||||
|
"roleLabel": "Register as",
|
||||||
|
"roleEducator": "Educator Candidate",
|
||||||
|
"roleGeneral": "General",
|
||||||
|
"rememberMe": "Remember me",
|
||||||
|
"forgotPassword": "Forgot password?",
|
||||||
|
"signInButton": "Sign In",
|
||||||
|
"signUpButton": "Sign Up",
|
||||||
|
"processing": "Processing...",
|
||||||
|
"registering": "Registering...",
|
||||||
|
"noAccount": "Don't have an account?",
|
||||||
|
"haveAccount": "Already have an account?",
|
||||||
|
"registerNow": "Register now",
|
||||||
|
"signInNow": "Sign In",
|
||||||
|
"passwordMismatch": "Passwords do not match!"
|
||||||
|
},
|
||||||
|
"Sidebar": {
|
||||||
|
"home": "Home",
|
||||||
|
"menu": "Menu",
|
||||||
|
"classes": "Classes",
|
||||||
|
"theory": "Theory",
|
||||||
|
"practice": "Practice",
|
||||||
|
"paperTemplate": "Paper Template",
|
||||||
|
"community": "Community",
|
||||||
|
"videos": "Videos",
|
||||||
|
"forum": "Forum",
|
||||||
|
"general": "General",
|
||||||
|
"group": "Group",
|
||||||
|
"tempo": "Tempo",
|
||||||
|
"assignments": "Assignments",
|
||||||
|
"contact": "Contact",
|
||||||
|
"faq": "FAQ",
|
||||||
|
"noClasses": "No classes available."
|
||||||
|
},
|
||||||
|
"Profile": {
|
||||||
|
"profileSettings": "Profile Settings",
|
||||||
|
"signOut": "Sign Out"
|
||||||
|
},
|
||||||
|
"Dashboard": {
|
||||||
|
"headerTitle": "Dashboard",
|
||||||
|
"welcomeTitle": "Welcome to kreatiVortex!",
|
||||||
|
"welcomeSubtitle": "Indonesian traditional dance learning platform",
|
||||||
|
"statTotalVideos": "Total Videos",
|
||||||
|
"statActiveClasses": "Active Classes",
|
||||||
|
"statCompletedAssignments": "Completed Assignments",
|
||||||
|
"statForumPosts": "Forum Posts",
|
||||||
|
"recentVideosTitle": "Recent Videos",
|
||||||
|
"recentDiscussionsTitle": "Recent Discussions",
|
||||||
|
"sampleVideo1": "Plate Dance - Basic",
|
||||||
|
"sampleVideo2": "Saman Dance - Hand Movements",
|
||||||
|
"sampleDiscussion1Title": "Tips for Plate Dance beginners",
|
||||||
|
"sampleDiscussion1Content": "I want to share some tips for those just starting to learn...",
|
||||||
|
"sampleDiscussion2Title": "Costume for performance",
|
||||||
|
"sampleDiscussion2Content": "Any suggestions for the right costume for a dance performance...",
|
||||||
|
"timeAgo": "{hours} hours ago"
|
||||||
|
},
|
||||||
|
"Classes": {
|
||||||
|
"title": "My Classes",
|
||||||
|
"subtitle": "List of classes you are enrolled in or teaching",
|
||||||
|
"createButton": "Create New Class",
|
||||||
|
"active": "Active",
|
||||||
|
"students": "Students",
|
||||||
|
"scheduleNotSet": "Schedule not set",
|
||||||
|
"unknownEducator": "Unknown",
|
||||||
|
"enterClass": "Enter Class",
|
||||||
|
"noClasses": "No classes available.",
|
||||||
|
"loading": "Loading..."
|
||||||
|
},
|
||||||
|
"Forum": {
|
||||||
|
"title": "Discussion Forum",
|
||||||
|
"subtitle": "Discuss with the kreatiVortex community",
|
||||||
|
"createButton": "Create New Discussion",
|
||||||
|
"filterAll": "All Discussions",
|
||||||
|
"filterAnnouncements": "Announcements",
|
||||||
|
"filterTechnique": "Dance Technique",
|
||||||
|
"filterCostume": "Costume & Makeup",
|
||||||
|
"filterMyClasses": "My Classes",
|
||||||
|
"posts": "Posts",
|
||||||
|
"noForums": "No discussions created.",
|
||||||
|
"generalForum": "General Forum",
|
||||||
|
"generalForumSubtitle": "Discuss with the kreatiVortex community",
|
||||||
|
"classForum": "Class Forum",
|
||||||
|
"classForumSubtitle": "Discuss with your class members"
|
||||||
|
},
|
||||||
|
"Videos": {
|
||||||
|
"title": "Learning Videos",
|
||||||
|
"subtitle": "Manage your dance learning video collection",
|
||||||
|
"uploadButton": "Upload New Video",
|
||||||
|
"colTitle": "Video Title",
|
||||||
|
"colType": "Type",
|
||||||
|
"colViews": "Views",
|
||||||
|
"viewsSuffix": "times",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"statusPublic": "Public",
|
||||||
|
"statusPrivate": "Private",
|
||||||
|
"colAction": "Action",
|
||||||
|
"actionView": "View",
|
||||||
|
"actionEdit": "Edit",
|
||||||
|
"noVideos": "No videos uploaded",
|
||||||
|
"actionDelete": "Delete",
|
||||||
|
"confirmDelete": "Are you sure you want to delete this video?",
|
||||||
|
"errorOccurred": "An error occurred",
|
||||||
|
"loading": "Loading..."
|
||||||
|
},
|
||||||
|
"Menu": {
|
||||||
|
"title": "Menu Management",
|
||||||
|
"subtitle": "Manage application menu",
|
||||||
|
"addButton": "Add Menu",
|
||||||
|
"editTitle": "Edit Menu",
|
||||||
|
"addTitle": "Add New Menu",
|
||||||
|
"nameIdLabel": "Name (Indonesian)",
|
||||||
|
"nameEnLabel": "Name (English) - Optional",
|
||||||
|
"descriptionIdLabel": "Description (Indonesian)",
|
||||||
|
"descriptionEnLabel": "Description (English) - Optional",
|
||||||
|
"slugLabel": "Slug (URL)",
|
||||||
|
"parentLabel": "Parent Menu",
|
||||||
|
"parentNone": "None (Top Level)",
|
||||||
|
"activeLabel": "Active",
|
||||||
|
"cancelButton": "Cancel",
|
||||||
|
"saveButton": "Save Changes",
|
||||||
|
"createButton": "Create Menu",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"tableName": "Name",
|
||||||
|
"tableSlug": "Slug",
|
||||||
|
"tableParent": "Parent",
|
||||||
|
"tableStatus": "Status",
|
||||||
|
"tableActions": "Actions",
|
||||||
|
"statusActive": "Active",
|
||||||
|
"statusInactive": "Inactive",
|
||||||
|
"noMenus": "No menus available. Please add a new menu.",
|
||||||
|
"confirmDelete": "Are you sure you want to delete this menu?",
|
||||||
|
"errorOccurred": "An error occurred",
|
||||||
|
"nameIdRequired": "Name (ID) is required",
|
||||||
|
"descriptionIdRequired": "Description (ID) is required",
|
||||||
|
"slugRequired": "Slug is required"
|
||||||
|
}
|
||||||
|
}
|
||||||
136
messages/id.json
Normal file
136
messages/id.json
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
{
|
||||||
|
"HomePage": {
|
||||||
|
"title": "KreatiVortex",
|
||||||
|
"subtitle": "Teori dan Kreasi Tari",
|
||||||
|
"login": "Masuk",
|
||||||
|
"registration": "Pendaftaran",
|
||||||
|
"startNow": "Mulai Sekarang"
|
||||||
|
},
|
||||||
|
"Auth": {
|
||||||
|
"signInTitle": "Masuk ke akun Anda",
|
||||||
|
"signUpTitle": "Buat akun baru",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"passwordLabel": "Password",
|
||||||
|
"confirmPasswordLabel": "Konfirmasi Password",
|
||||||
|
"nameLabel": "Nama Lengkap",
|
||||||
|
"roleLabel": "Daftar sebagai",
|
||||||
|
"roleEducator": "Calon Pendidik",
|
||||||
|
"roleGeneral": "Umum",
|
||||||
|
"rememberMe": "Ingat saya",
|
||||||
|
"forgotPassword": "Lupa password?",
|
||||||
|
"signInButton": "Masuk",
|
||||||
|
"signUpButton": "Daftar",
|
||||||
|
"processing": "Memproses...",
|
||||||
|
"registering": "Mendaftar...",
|
||||||
|
"noAccount": "Belum punya akun?",
|
||||||
|
"haveAccount": "Sudah punya akun?",
|
||||||
|
"registerNow": "Daftar sekarang",
|
||||||
|
"signInNow": "Masuk",
|
||||||
|
"passwordMismatch": "Password tidak cocok!"
|
||||||
|
},
|
||||||
|
"Sidebar": {
|
||||||
|
"home": "Beranda",
|
||||||
|
"classes": "Kelas",
|
||||||
|
"theory": "Teori",
|
||||||
|
"practice": "Praktik",
|
||||||
|
"paperTemplate": "Template Makalah",
|
||||||
|
"community": "Komunitas",
|
||||||
|
"myVideos": "Video Saya",
|
||||||
|
"forum": "Forum",
|
||||||
|
"assignments": "Tugas"
|
||||||
|
},
|
||||||
|
"Profile": {
|
||||||
|
"profileSettings": "Pengaturan Profil",
|
||||||
|
"signOut": "Keluar"
|
||||||
|
},
|
||||||
|
"Dashboard": {
|
||||||
|
"headerTitle": "Dashboard",
|
||||||
|
"welcomeTitle": "Selamat Datang di kreatiVortex!",
|
||||||
|
"welcomeSubtitle": "Platform pembelajaran tari tradisional Indonesia",
|
||||||
|
"statTotalVideos": "Total Video",
|
||||||
|
"statActiveClasses": "Kelas Aktif",
|
||||||
|
"statCompletedAssignments": "Tugas Selesai",
|
||||||
|
"statForumPosts": "Forum Post",
|
||||||
|
"recentVideosTitle": "Video Terbaru",
|
||||||
|
"recentDiscussionsTitle": "Diskusi Terbaru",
|
||||||
|
"sampleVideo1": "Tari Piring - Dasar",
|
||||||
|
"sampleVideo2": "Tari Saman - Gerakan Tangan",
|
||||||
|
"sampleDiscussion1Title": "Tips untuk pemula Tari Piring",
|
||||||
|
"sampleDiscussion1Content": "Saya ingin berbagi beberapa tips untuk yang baru mulai belajar...",
|
||||||
|
"sampleDiscussion2Title": "Costum untuk pertunjukan",
|
||||||
|
"sampleDiscussion2Content": "Apakah ada saran untuk costum yang tepat untuk pertunjukan tari...",
|
||||||
|
"timeAgo": "{hours} jam yang lalu"
|
||||||
|
},
|
||||||
|
"Classes": {
|
||||||
|
"title": "Kelas Saya",
|
||||||
|
"subtitle": "Daftar kelas yang Anda ikuti atau ajar",
|
||||||
|
"createButton": "Buat Kelas Baru",
|
||||||
|
"active": "Aktif",
|
||||||
|
"students": "Siswa",
|
||||||
|
"scheduleNotSet": "Jadwal belum diatur",
|
||||||
|
"unknownEducator": "Tidak Diketahui",
|
||||||
|
"enterClass": "Masuk Kelas",
|
||||||
|
"noClasses": "Belum ada kelas yang tersedia.",
|
||||||
|
"loading": "Memuat..."
|
||||||
|
},
|
||||||
|
"Forum": {
|
||||||
|
"title": "Forum Diskusi",
|
||||||
|
"subtitle": "Berdiskusi dengan komunitas kreatiVortex",
|
||||||
|
"createButton": "Buat Diskusi Baru",
|
||||||
|
"filterAll": "Semua Diskusi",
|
||||||
|
"filterAnnouncements": "Pengumuman",
|
||||||
|
"filterTechnique": "Teknik Tari",
|
||||||
|
"filterCostume": "Kostum & Rias",
|
||||||
|
"filterMyClasses": "Kelas Saya",
|
||||||
|
"posts": "Postingan",
|
||||||
|
"noForums": "Belum ada diskusi yang dibuat."
|
||||||
|
},
|
||||||
|
"Videos": {
|
||||||
|
"title": "Video Pembelajaran",
|
||||||
|
"subtitle": "Kelola koleksi video pembelajaran tari Anda",
|
||||||
|
"uploadButton": "Upload Video Baru",
|
||||||
|
"colTitle": "Judul Video",
|
||||||
|
"colType": "Tipe",
|
||||||
|
"colViews": "Dilihat",
|
||||||
|
"viewsSuffix": "kali",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"statusPublic": "Publik",
|
||||||
|
"statusPrivate": "Privat",
|
||||||
|
"colAction": "Aksi",
|
||||||
|
"actionView": "Lihat",
|
||||||
|
"actionEdit": "Edit",
|
||||||
|
"noVideos": "Belum ada video yang diunggah"
|
||||||
|
},
|
||||||
|
"Menu": {
|
||||||
|
"title": "Manajemen Menu",
|
||||||
|
"subtitle": "Kelola menu aplikasi",
|
||||||
|
"addButton": "Tambah Menu",
|
||||||
|
"editTitle": "Edit Menu",
|
||||||
|
"addTitle": "Tambah Menu Baru",
|
||||||
|
"nameIdLabel": "Nama (Indonesia)",
|
||||||
|
"nameEnLabel": "Nama (English) - Opsional",
|
||||||
|
"descriptionIdLabel": "Deskripsi (Indonesia)",
|
||||||
|
"descriptionEnLabel": "Deskripsi (English) - Opsional",
|
||||||
|
"slugLabel": "Slug (URL)",
|
||||||
|
"parentLabel": "Parent Menu",
|
||||||
|
"parentNone": "None (Top Level)",
|
||||||
|
"activeLabel": "Aktif",
|
||||||
|
"cancelButton": "Batal",
|
||||||
|
"saveButton": "Simpan Perubahan",
|
||||||
|
"createButton": "Buat Menu",
|
||||||
|
"saving": "Menyimpan...",
|
||||||
|
"tableName": "Nama",
|
||||||
|
"tableSlug": "Slug",
|
||||||
|
"tableParent": "Parent",
|
||||||
|
"tableStatus": "Status",
|
||||||
|
"tableActions": "Aksi",
|
||||||
|
"statusActive": "Aktif",
|
||||||
|
"statusInactive": "Nonaktif",
|
||||||
|
"noMenus": "Belum ada menu. Silakan tambah menu baru.",
|
||||||
|
"confirmDelete": "Apakah Anda yakin ingin menghapus menu ini?",
|
||||||
|
"errorOccurred": "Terjadi kesalahan",
|
||||||
|
"nameIdRequired": "Nama (ID) wajib diisi",
|
||||||
|
"descriptionIdRequired": "Deskripsi (ID) wajib diisi",
|
||||||
|
"slugRequired": "Slug wajib diisi"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,10 @@
|
|||||||
|
import createNextIntlPlugin from 'next-intl/plugin';
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
@ -5,6 +5,61 @@
|
|||||||
"type": "remote",
|
"type": "remote",
|
||||||
"url": "https://mcp.chonkie.ai/better-auth/better-auth-builder/mcp",
|
"url": "https://mcp.chonkie.ai/better-auth/better-auth-builder/mcp",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
}
|
},
|
||||||
|
"web-search-prime": {
|
||||||
|
"type": "remote",
|
||||||
|
"url": "https://api.z.ai/api/mcp/web_search_prime/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer 064ec6407a70440cb598101a00fbc088.cZRDhjW8LynnZCqY"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zai-mcp-server": {
|
||||||
|
"enabled": true,
|
||||||
|
"type": "local",
|
||||||
|
"command": [
|
||||||
|
"npx",
|
||||||
|
"-y",
|
||||||
|
"@z_ai/mcp-server"
|
||||||
|
],
|
||||||
|
"environment": {
|
||||||
|
"Z_AI_API_KEY": "064ec6407a70440cb598101a00fbc088.cZRDhjW8LynnZCqY",
|
||||||
|
"Z_AI_MODE": "ZAI"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"web-reader": {
|
||||||
|
"enabled": true,
|
||||||
|
"type": "remote",
|
||||||
|
"url": "https://api.z.ai/api/mcp/web_reader/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer 064ec6407a70440cb598101a00fbc088.cZRDhjW8LynnZCqY"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"chrome-devtools": {
|
||||||
|
"enabled": true,
|
||||||
|
"type": "local",
|
||||||
|
"command": [
|
||||||
|
"npx",
|
||||||
|
"chrome-devtools-mcp@latest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"context7": {
|
||||||
|
"enabled": true,
|
||||||
|
"type": "local",
|
||||||
|
"command": [
|
||||||
|
"npx",
|
||||||
|
"-y",
|
||||||
|
"@upstash/context7-mcp",
|
||||||
|
"--api-key",
|
||||||
|
"ctx7sk-e703c14f-19c9-44dc-b377-25c54089c1a0"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"next-devtools": {
|
||||||
|
"enabled": true,
|
||||||
|
"type": "local",
|
||||||
|
"command": [
|
||||||
|
"npx",
|
||||||
|
"next-devtools-mcp@latest"
|
||||||
|
]
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
18
package.json
18
package.json
@ -12,21 +12,34 @@
|
|||||||
"db:studio": "prisma studio"
|
"db:studio": "prisma studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@prisma/adapter-pg": "^7.0.1",
|
"@prisma/adapter-pg": "^7.0.1",
|
||||||
"@prisma/client": "^7.0.1",
|
"@prisma/client": "^7.0.1",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/pg": "^8.15.6",
|
"@types/pg": "^8.15.6",
|
||||||
|
"@types/uuid": "^11.0.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-auth": "^1.4.3",
|
"better-auth": "^1.4.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"next": "16.0.5",
|
"next": "16.0.5",
|
||||||
|
"next-intl": "^4.5.6",
|
||||||
|
"pg": "^8.16.3",
|
||||||
"prisma": "^7.0.1",
|
"prisma": "^7.0.1",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
|
"react-hook-form": "^7.67.0",
|
||||||
|
"scrypt": "^6.0.3",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tsx": "^4.20.6"
|
"tsx": "^4.20.6",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
|
"zod": "^4.1.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@ -38,8 +51,5 @@
|
|||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
|
||||||
"prisma": {
|
|
||||||
"schema": "./prisma"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -5,7 +5,7 @@ export default defineConfig({
|
|||||||
schema: 'prisma',
|
schema: 'prisma',
|
||||||
migrations: {
|
migrations: {
|
||||||
path: 'prisma/migrations',
|
path: 'prisma/migrations',
|
||||||
seed: 'node prisma/seed.js',
|
seed: 'bun prisma/seed.ts',
|
||||||
},
|
},
|
||||||
datasource: {
|
datasource: {
|
||||||
url: env('DATABASE_URL'),
|
url: env('DATABASE_URL'),
|
||||||
|
|||||||
247
prisma/migrations/20251129112746_init/migration.sql
Normal file
247
prisma/migrations/20251129112746_init/migration.sql
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "VideoType" AS ENUM ('YOUTUBE', 'LOCAL');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ForumType" AS ENUM ('GENERAL', 'CLASS');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SubmissionStatus" AS ENUM ('SUBMITTED', 'REVIEWED', 'REVISED', 'APPROVED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "user_roles" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"permissions" TEXT[],
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "user_profiles" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"roleId" TEXT NOT NULL,
|
||||||
|
"nim" TEXT,
|
||||||
|
"phone" TEXT,
|
||||||
|
"address" TEXT,
|
||||||
|
"bio" TEXT,
|
||||||
|
"avatar" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "user_profiles_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "classes" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"educatorId" TEXT NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"maxStudents" INTEGER,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdBy" TEXT NOT NULL,
|
||||||
|
"updatedBy" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "classes_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "class_members" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"classId" TEXT NOT NULL,
|
||||||
|
"studentId" TEXT NOT NULL,
|
||||||
|
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
CONSTRAINT "class_members_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "videos" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"videoUrl" TEXT NOT NULL,
|
||||||
|
"videoType" "VideoType" NOT NULL DEFAULT 'YOUTUBE',
|
||||||
|
"thumbnailUrl" TEXT,
|
||||||
|
"duration" INTEGER,
|
||||||
|
"uploaderId" TEXT NOT NULL,
|
||||||
|
"classId" TEXT,
|
||||||
|
"isPublic" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"viewCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdBy" TEXT NOT NULL,
|
||||||
|
"updatedBy" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "videos_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "forums" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"type" "ForumType" NOT NULL DEFAULT 'GENERAL',
|
||||||
|
"classId" TEXT,
|
||||||
|
"createdBy" TEXT NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"updatedBy" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "forums_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "forum_posts" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"forumId" TEXT NOT NULL,
|
||||||
|
"authorId" TEXT NOT NULL,
|
||||||
|
"parentId" TEXT,
|
||||||
|
"isPinned" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"updatedBy" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "forum_posts_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "assignments" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"dueDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"classId" TEXT NOT NULL,
|
||||||
|
"educatorId" TEXT NOT NULL,
|
||||||
|
"maxScore" INTEGER NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdBy" TEXT NOT NULL,
|
||||||
|
"updatedBy" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "assignments_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "assignment_submissions" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"assignmentId" TEXT NOT NULL,
|
||||||
|
"studentId" TEXT NOT NULL,
|
||||||
|
"documentUrl" TEXT NOT NULL,
|
||||||
|
"documentType" TEXT NOT NULL,
|
||||||
|
"score" INTEGER,
|
||||||
|
"feedback" TEXT,
|
||||||
|
"status" "SubmissionStatus" NOT NULL DEFAULT 'SUBMITTED',
|
||||||
|
"submittedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"reviewedAt" TIMESTAMP(3),
|
||||||
|
"reviewedBy" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"updatedBy" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "assignment_submissions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "comments" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"authorId" TEXT NOT NULL,
|
||||||
|
"videoId" TEXT,
|
||||||
|
"forumPostId" TEXT,
|
||||||
|
"parentId" TEXT,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"updatedBy" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "comments_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "user_roles_name_key" ON "user_roles"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "user_profiles_userId_key" ON "user_profiles"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "classes_code_key" ON "classes"("code");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "class_members_classId_studentId_key" ON "class_members"("classId", "studentId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "user_profiles" ADD CONSTRAINT "user_profiles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "user_profiles" ADD CONSTRAINT "user_profiles_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "user_roles"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "classes" ADD CONSTRAINT "classes_educatorId_fkey" FOREIGN KEY ("educatorId") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "class_members" ADD CONSTRAINT "class_members_classId_fkey" FOREIGN KEY ("classId") REFERENCES "classes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "class_members" ADD CONSTRAINT "class_members_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "videos" ADD CONSTRAINT "videos_uploaderId_fkey" FOREIGN KEY ("uploaderId") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "videos" ADD CONSTRAINT "videos_classId_fkey" FOREIGN KEY ("classId") REFERENCES "classes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "forums" ADD CONSTRAINT "forums_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "forums" ADD CONSTRAINT "forums_classId_fkey" FOREIGN KEY ("classId") REFERENCES "classes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "forum_posts" ADD CONSTRAINT "forum_posts_forumId_fkey" FOREIGN KEY ("forumId") REFERENCES "forums"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "forum_posts" ADD CONSTRAINT "forum_posts_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "forum_posts" ADD CONSTRAINT "forum_posts_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "forum_posts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "assignments" ADD CONSTRAINT "assignments_classId_fkey" FOREIGN KEY ("classId") REFERENCES "classes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "assignments" ADD CONSTRAINT "assignments_educatorId_fkey" FOREIGN KEY ("educatorId") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "assignment_submissions" ADD CONSTRAINT "assignment_submissions_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "assignments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "assignment_submissions" ADD CONSTRAINT "assignment_submissions_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "assignment_submissions" ADD CONSTRAINT "assignment_submissions_reviewedBy_fkey" FOREIGN KEY ("reviewedBy") REFERENCES "user_profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "comments" ADD CONSTRAINT "comments_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "comments" ADD CONSTRAINT "comments_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "videos"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "comments" ADD CONSTRAINT "comments_forumPostId_fkey" FOREIGN KEY ("forumPostId") REFERENCES "forum_posts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "comments" ADD CONSTRAINT "comments_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "comments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "videos" ADD COLUMN "menuId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "menus" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"parentId" TEXT,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"updatedBy" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "menus_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "videos" ADD CONSTRAINT "videos_menuId_fkey" FOREIGN KEY ("menuId") REFERENCES "menus"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "menus" ADD CONSTRAINT "menus_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "menus"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Changed the type of `name` on the `menus` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
|
||||||
|
- Changed the type of `description` on the `menus` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "menus" DROP COLUMN "name",
|
||||||
|
ADD COLUMN "name" JSONB NOT NULL,
|
||||||
|
DROP COLUMN "description",
|
||||||
|
ADD COLUMN "description" JSONB NOT NULL;
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "forum_posts"
|
||||||
|
ADD COLUMN "attachments" JSONB DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "comments"
|
||||||
|
ADD COLUMN "attachments" JSONB DEFAULT '[]'::jsonb;
|
||||||
@ -22,67 +22,67 @@ datasource db {
|
|||||||
// We need to extend the User model here to add the profile relation
|
// We need to extend the User model here to add the profile relation
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id
|
id String @id
|
||||||
name String
|
name String
|
||||||
email String
|
email String
|
||||||
emailVerified Boolean @default(false)
|
emailVerified Boolean @default(false)
|
||||||
image String?
|
image String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
profile UserProfile?
|
profile UserProfile?
|
||||||
|
|
||||||
@@unique([email])
|
@@unique([email])
|
||||||
@@map("user")
|
@@map("user")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
id String @id
|
id String @id
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
token String
|
token String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
ipAddress String?
|
ipAddress String?
|
||||||
userAgent String?
|
userAgent String?
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([token])
|
@@unique([token])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@map("session")
|
@@map("session")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
id String @id
|
id String @id
|
||||||
accountId String
|
accountId String
|
||||||
providerId String
|
providerId String
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
accessToken String?
|
accessToken String?
|
||||||
refreshToken String?
|
refreshToken String?
|
||||||
idToken String?
|
idToken String?
|
||||||
accessTokenExpiresAt DateTime?
|
accessTokenExpiresAt DateTime?
|
||||||
refreshTokenExpiresAt DateTime?
|
refreshTokenExpiresAt DateTime?
|
||||||
scope String?
|
scope String?
|
||||||
password String?
|
password String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@map("account")
|
@@map("account")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Verification {
|
model Verification {
|
||||||
id String @id
|
id String @id
|
||||||
identifier String
|
identifier String
|
||||||
value String
|
value String
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([identifier])
|
@@index([identifier])
|
||||||
@@map("verification")
|
@@map("verification")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
@ -106,7 +106,7 @@ model UserProfile {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String @unique
|
userId String @unique
|
||||||
roleId String
|
roleId String
|
||||||
nim String? // Nomor Induk Mahasiswa
|
nim String? // Nomor Induk Mahasiswa
|
||||||
phone String?
|
phone String?
|
||||||
address String?
|
address String?
|
||||||
bio String?
|
bio String?
|
||||||
@ -114,21 +114,21 @@ model UserProfile {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
role UserRole @relation(fields: [roleId], references: [id])
|
role UserRole @relation(fields: [roleId], references: [id])
|
||||||
|
|
||||||
// Relations as educator
|
// Relations as educator
|
||||||
classesTeaching Class[]
|
classesTeaching Class[]
|
||||||
videos Video[]
|
videos Video[]
|
||||||
forums Forum[]
|
forums Forum[]
|
||||||
assignments Assignment[]
|
assignments Assignment[]
|
||||||
|
|
||||||
// Relations as student
|
// Relations as student
|
||||||
classesEnrolled ClassMember[]
|
classesEnrolled ClassMember[]
|
||||||
forumPosts ForumPost[] @relation("ForumPosts")
|
forumPosts ForumPost[] @relation("ForumPosts")
|
||||||
assignmentSubmissions AssignmentSubmission[]
|
assignmentSubmissions AssignmentSubmission[]
|
||||||
comments Comment[] @relation("CommentAuthor")
|
comments Comment[] @relation("CommentAuthor")
|
||||||
|
|
||||||
// Additional relations
|
// Additional relations
|
||||||
reviewedSubmissions AssignmentSubmission[] @relation("SubmissionReviewer")
|
reviewedSubmissions AssignmentSubmission[] @relation("SubmissionReviewer")
|
||||||
|
|
||||||
@ -148,10 +148,10 @@ model Class {
|
|||||||
createdBy String
|
createdBy String
|
||||||
updatedBy String
|
updatedBy String
|
||||||
|
|
||||||
educator UserProfile @relation(fields: [educatorId], references: [id], onDelete: Cascade)
|
educator UserProfile @relation(fields: [educatorId], references: [id], onDelete: Cascade)
|
||||||
members ClassMember[]
|
members ClassMember[]
|
||||||
videos Video[]
|
videos Video[]
|
||||||
forums Forum[]
|
forums Forum[]
|
||||||
assignments Assignment[]
|
assignments Assignment[]
|
||||||
|
|
||||||
@@map("classes")
|
@@map("classes")
|
||||||
@ -178,7 +178,7 @@ model Video {
|
|||||||
videoUrl String
|
videoUrl String
|
||||||
videoType VideoType @default(YOUTUBE)
|
videoType VideoType @default(YOUTUBE)
|
||||||
thumbnailUrl String?
|
thumbnailUrl String?
|
||||||
duration Int? // in seconds
|
duration Int? // in seconds
|
||||||
uploaderId String
|
uploaderId String
|
||||||
classId String?
|
classId String?
|
||||||
isPublic Boolean @default(true)
|
isPublic Boolean @default(true)
|
||||||
@ -187,50 +187,53 @@ model Video {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
createdBy String
|
createdBy String
|
||||||
updatedBy String
|
updatedBy String
|
||||||
|
menuId String?
|
||||||
|
|
||||||
uploader UserProfile @relation(fields: [uploaderId], references: [id], onDelete: Cascade)
|
uploader UserProfile @relation(fields: [uploaderId], references: [id], onDelete: Cascade)
|
||||||
class Class? @relation(fields: [classId], references: [id], onDelete: SetNull)
|
class Class? @relation(fields: [classId], references: [id], onDelete: SetNull)
|
||||||
|
menu Menu? @relation(fields: [menuId], references: [id], onDelete: SetNull)
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
|
|
||||||
@@map("videos")
|
@@map("videos")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Forum {
|
model Forum {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String
|
title String
|
||||||
description String?
|
description String?
|
||||||
type ForumType @default(GENERAL)
|
type ForumType @default(GENERAL)
|
||||||
classId String?
|
classId String?
|
||||||
createdBy String
|
createdBy String
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
updatedBy String
|
updatedBy String
|
||||||
|
|
||||||
creator UserProfile @relation(fields: [createdBy], references: [id], onDelete: Cascade)
|
creator UserProfile @relation(fields: [createdBy], references: [id], onDelete: Cascade)
|
||||||
class Class? @relation(fields: [classId], references: [id], onDelete: SetNull)
|
class Class? @relation(fields: [classId], references: [id], onDelete: SetNull)
|
||||||
posts ForumPost[]
|
posts ForumPost[]
|
||||||
|
|
||||||
@@map("forums")
|
@@map("forums")
|
||||||
}
|
}
|
||||||
|
|
||||||
model ForumPost {
|
model ForumPost {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String
|
title String
|
||||||
content String
|
content String
|
||||||
forumId String
|
forumId String
|
||||||
authorId String
|
authorId String
|
||||||
parentId String? // for replies
|
parentId String? // for replies
|
||||||
isPinned Boolean @default(false)
|
isPinned Boolean @default(false)
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
attachments Json @default("[]")
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
updatedBy String
|
updatedAt DateTime @updatedAt
|
||||||
|
updatedBy String
|
||||||
|
|
||||||
forum Forum @relation(fields: [forumId], references: [id], onDelete: Cascade)
|
forum Forum @relation(fields: [forumId], references: [id], onDelete: Cascade)
|
||||||
author UserProfile @relation("ForumPosts", fields: [authorId], references: [id], onDelete: Cascade)
|
author UserProfile @relation("ForumPosts", fields: [authorId], references: [id], onDelete: Cascade)
|
||||||
parent ForumPost? @relation("PostReplies", fields: [parentId], references: [id], onDelete: Cascade)
|
parent ForumPost? @relation("PostReplies", fields: [parentId], references: [id], onDelete: Cascade)
|
||||||
replies ForumPost[] @relation("PostReplies")
|
replies ForumPost[] @relation("PostReplies")
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
|
|
||||||
@@map("forum_posts")
|
@@map("forum_posts")
|
||||||
@ -250,57 +253,76 @@ model Assignment {
|
|||||||
createdBy String
|
createdBy String
|
||||||
updatedBy String
|
updatedBy String
|
||||||
|
|
||||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||||
educator UserProfile @relation(fields: [educatorId], references: [id], onDelete: Cascade)
|
educator UserProfile @relation(fields: [educatorId], references: [id], onDelete: Cascade)
|
||||||
submissions AssignmentSubmission[]
|
submissions AssignmentSubmission[]
|
||||||
|
|
||||||
@@map("assignments")
|
@@map("assignments")
|
||||||
}
|
}
|
||||||
|
|
||||||
model AssignmentSubmission {
|
model AssignmentSubmission {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
assignmentId String
|
assignmentId String
|
||||||
studentId String
|
studentId String
|
||||||
documentUrl String
|
documentUrl String
|
||||||
documentType String // MIME type
|
documentType String // MIME type
|
||||||
score Int?
|
score Int?
|
||||||
feedback String?
|
feedback String?
|
||||||
status SubmissionStatus @default(SUBMITTED)
|
status SubmissionStatus @default(SUBMITTED)
|
||||||
submittedAt DateTime @default(now())
|
submittedAt DateTime @default(now())
|
||||||
reviewedAt DateTime?
|
reviewedAt DateTime?
|
||||||
reviewedBy String?
|
reviewedBy String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
updatedBy String
|
updatedBy String
|
||||||
|
|
||||||
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
||||||
student UserProfile @relation(fields: [studentId], references: [id], onDelete: Cascade)
|
student UserProfile @relation(fields: [studentId], references: [id], onDelete: Cascade)
|
||||||
reviewer UserProfile? @relation("SubmissionReviewer", fields: [reviewedBy], references: [id], onDelete: SetNull)
|
reviewer UserProfile? @relation("SubmissionReviewer", fields: [reviewedBy], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@map("assignment_submissions")
|
@@map("assignment_submissions")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Comment {
|
model Comment {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
content String
|
content String
|
||||||
authorId String
|
authorId String
|
||||||
videoId String?
|
videoId String?
|
||||||
forumPostId String?
|
forumPostId String?
|
||||||
parentId String? // for replies
|
parentId String? // for replies
|
||||||
isActive Boolean @default(true)
|
attachments Json @default("[]")
|
||||||
createdAt DateTime @default(now())
|
isActive Boolean @default(true)
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
updatedBy String
|
updatedAt DateTime @updatedAt
|
||||||
|
updatedBy String
|
||||||
|
|
||||||
author UserProfile @relation("CommentAuthor", fields: [authorId], references: [id], onDelete: Cascade)
|
author UserProfile @relation("CommentAuthor", fields: [authorId], references: [id], onDelete: Cascade)
|
||||||
video Video? @relation(fields: [videoId], references: [id], onDelete: Cascade)
|
video Video? @relation(fields: [videoId], references: [id], onDelete: Cascade)
|
||||||
forumPost ForumPost? @relation(fields: [forumPostId], references: [id], onDelete: Cascade)
|
forumPost ForumPost? @relation(fields: [forumPostId], references: [id], onDelete: Cascade)
|
||||||
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
|
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
|
||||||
replies Comment[] @relation("CommentReplies")
|
replies Comment[] @relation("CommentReplies")
|
||||||
|
|
||||||
@@map("comments")
|
@@map("comments")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Menu {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name Json
|
||||||
|
slug String @unique
|
||||||
|
description Json
|
||||||
|
parentId String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
updatedBy String
|
||||||
|
|
||||||
|
parent Menu? @relation("MenuHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
|
||||||
|
children Menu[] @relation("MenuHierarchy")
|
||||||
|
videos Video[]
|
||||||
|
|
||||||
|
@@map("menus")
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// ENUMS
|
// ENUMS
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|||||||
188
prisma/seed.ts
Normal file
188
prisma/seed.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import { prisma } from '../lib/prisma';
|
||||||
|
import { auth } from '../lib/auth';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Start seeding...');
|
||||||
|
|
||||||
|
// 1. Create Roles
|
||||||
|
const adminRole = await prisma.userRole.upsert({
|
||||||
|
where: { name: 'ADMIN' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: 'ADMIN',
|
||||||
|
description: 'Administrator with full access',
|
||||||
|
permissions: ['*'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.userRole.upsert({
|
||||||
|
where: { name: 'PENDIDIK' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: 'PENDIDIK',
|
||||||
|
description: 'Educator who can create classes and content',
|
||||||
|
permissions: ['class:create', 'class:manage', 'content:create', 'assignment:create', 'forum:moderate'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.userRole.upsert({
|
||||||
|
where: { name: 'CALON_PENDIDIK' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: 'CALON_PENDIDIK',
|
||||||
|
description: 'Prospective educator/student',
|
||||||
|
permissions: ['class:join', 'content:view', 'assignment:submit', 'forum:participate'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.userRole.upsert({
|
||||||
|
where: { name: 'UMUM' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: 'UMUM',
|
||||||
|
description: 'General user with basic access',
|
||||||
|
permissions: ['content:view', 'forum:general'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Roles created/verified.');
|
||||||
|
|
||||||
|
// 2. Create Admin User
|
||||||
|
const adminEmail = 'admin@kreativortex.com';
|
||||||
|
const adminPassword = 'adminpassword123'; // Change this in production!
|
||||||
|
|
||||||
|
let adminUserId: string;
|
||||||
|
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { email: adminEmail },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
adminUserId = existingUser.id;
|
||||||
|
} else {
|
||||||
|
console.log('Creating admin user...');
|
||||||
|
const res = await auth.api.signUpEmail({
|
||||||
|
body: {
|
||||||
|
email: adminEmail,
|
||||||
|
password: adminPassword,
|
||||||
|
name: 'Admin KreatiVortex',
|
||||||
|
image: 'https://ui-avatars.com/api/?name=Admin+KV&background=ffd700&color=000',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
adminUserId = res.user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create Admin Profile
|
||||||
|
await prisma.userProfile.upsert({
|
||||||
|
where: { userId: adminUserId },
|
||||||
|
update: {
|
||||||
|
roleId: adminRole.id,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: adminUserId,
|
||||||
|
roleId: adminRole.id,
|
||||||
|
bio: 'Administrator of KreatiVortex Platform',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Admin user created with email: ${adminEmail}`);
|
||||||
|
|
||||||
|
// 5. Seed Menus
|
||||||
|
const menus = [
|
||||||
|
{
|
||||||
|
name: { id: 'Teori', en: 'Theory' },
|
||||||
|
description: { id: 'Menu pembelajaran teori tari', en: 'Dance theory learning menu' },
|
||||||
|
children: [
|
||||||
|
'Pengetahuan dasar tari',
|
||||||
|
'Unsur utama gerak',
|
||||||
|
'Unsur utama pertunjukan tari',
|
||||||
|
'Penciptaan tari dan pengambangan diri',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: { id: 'Praktik', en: 'Practice' },
|
||||||
|
description: { id: 'Menu pembelajaran praktik tari', en: 'Dance practice learning menu' },
|
||||||
|
children: ['Olah Tubuh', 'Imitasi Gerak', 'Gerak Dasar'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: { id: 'Template Makalah', en: 'Paper Template' },
|
||||||
|
description: { id: 'Template penyusunan makalah', en: 'Paper composition template' },
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: { id: 'Tempo', en: 'Tempo' },
|
||||||
|
description: { id: 'Latihan tempo gerak', en: 'Movement tempo practice' },
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Helper to create slugs
|
||||||
|
const slugify = (text: string) =>
|
||||||
|
text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ /g, '-')
|
||||||
|
.replace(/[^\w-]+/g, '');
|
||||||
|
|
||||||
|
for (const menuData of menus) {
|
||||||
|
const menuSlug = slugify(menuData.name.id);
|
||||||
|
|
||||||
|
// Check if parent menu exists
|
||||||
|
let parentMenu = await prisma.menu.findFirst({
|
||||||
|
where: {
|
||||||
|
slug: menuSlug,
|
||||||
|
parentId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parentMenu) {
|
||||||
|
console.log(`Creating menu: ${menuData.name.id} (${menuSlug})`);
|
||||||
|
parentMenu = await prisma.menu.create({
|
||||||
|
data: {
|
||||||
|
name: menuData.name,
|
||||||
|
slug: menuSlug,
|
||||||
|
description: menuData.description,
|
||||||
|
updatedBy: adminUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(`Menu already exists: ${menuData.name.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create children
|
||||||
|
for (const childName of menuData.children) {
|
||||||
|
const childNameJson = { id: childName, en: childName };
|
||||||
|
const childSlug = slugify(childName);
|
||||||
|
|
||||||
|
const childMenu = await prisma.menu.findFirst({
|
||||||
|
where: {
|
||||||
|
slug: childSlug,
|
||||||
|
parentId: parentMenu.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!childMenu) {
|
||||||
|
console.log(`Creating sub-menu: ${childName} (${childSlug})`);
|
||||||
|
await prisma.menu.create({
|
||||||
|
data: {
|
||||||
|
name: childNameJson,
|
||||||
|
slug: childSlug,
|
||||||
|
description: { id: childName, en: childName },
|
||||||
|
parentId: parentMenu.id,
|
||||||
|
updatedBy: adminUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Seeding finished.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
44
proxy.ts
Normal file
44
proxy.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import createMiddleware from 'next-intl/middleware';
|
||||||
|
import {routing} from './i18n/routing';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const handleI18n = createMiddleware(routing);
|
||||||
|
|
||||||
|
export function proxy(request: NextRequest) {
|
||||||
|
const response = handleI18n(request);
|
||||||
|
|
||||||
|
// If next-intl redirects (e.g. for locale prefix), let it pass
|
||||||
|
if (response.status >= 300 && response.status < 400) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth check logic
|
||||||
|
const sessionCookie = request.cookies.get("better-auth.session_token");
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
// Check if accessing dashboard
|
||||||
|
// Matches /dashboard, /en/dashboard, /id/dashboard, etc.
|
||||||
|
const isDashboardRoute = pathname === '/dashboard' ||
|
||||||
|
pathname.startsWith('/dashboard/') ||
|
||||||
|
routing.locales.some(locale => pathname.startsWith(`/${locale}/dashboard`));
|
||||||
|
|
||||||
|
if (isDashboardRoute && !sessionCookie) {
|
||||||
|
// Extract locale from path to maintain consistency, fallback to default
|
||||||
|
const segments = pathname.split('/');
|
||||||
|
const potentialLocale = segments[1];
|
||||||
|
const locale = routing.locales.includes(potentialLocale as any)
|
||||||
|
? potentialLocale
|
||||||
|
: routing.defaultLocale;
|
||||||
|
|
||||||
|
return NextResponse.redirect(new URL(`/${locale}/auth/signin`, request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// Match all pathnames except for
|
||||||
|
// - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel`
|
||||||
|
// - … the ones containing a dot (e.g. `favicon.ico`)
|
||||||
|
matcher: ['/((?!api|trpc|_next|_vercel|.*\\..*).*)']
|
||||||
|
};
|
||||||
BIN
public/bg-2.jpeg
Normal file
BIN
public/bg-2.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
BIN
public/bg.jpeg
Normal file
BIN
public/bg.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/bg.jpg
Normal file
BIN
public/bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
BIN
public/bg.png
Normal file
BIN
public/bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 953 KiB |
BIN
public/forum-attachments/40243320b4f0af8e968abdd53e37bd29.pdf
Normal file
BIN
public/forum-attachments/40243320b4f0af8e968abdd53e37bd29.pdf
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user