diff --git a/AI_GUIDE.md b/AI_GUIDE.md
index a98ca18..765d69c 100644
--- a/AI_GUIDE.md
+++ b/AI_GUIDE.md
@@ -164,7 +164,7 @@ Add this comment at the top of every new file:
* Created by: Chandika Nurdiansyah (chandika@skatsa.com)
* Date: [current date]
* 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)
* Date: [current date]
* 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
- **Error Handling**: Comprehensive error messages and recovery
- **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
- **Mandatory Component Usage**:
- ALL edit pages MUST use AppForm component
@@ -431,7 +431,6 @@ export { default as ActionButton } from "./index";
- User management, roles, authorization, app management
- Requires admin-level permissions
- **Business Applications**: `/app/(app)` - Business operations
-
- Finance & Accounting, future business modules
- Requires role-based permissions per module
diff --git a/README.md b/README.md
index e215bc4..8a57d61 100644
--- a/README.md
+++ b/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
-npm run dev
-# or
-yarn dev
-# or
-pnpm dev
-# or
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.
-- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+## 🔐 Hak Akses (Role)
-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.
-
-Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+[MIT](LICENSE)
\ No newline at end of file
diff --git a/TODO-kreatiVortex-platform.md b/TODO-kreatiVortex-platform.md
deleted file mode 100644
index 4162238..0000000
--- a/TODO-kreatiVortex-platform.md
+++ /dev/null
@@ -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
\ No newline at end of file
diff --git a/app/(app)/dashboard/layout.tsx b/app/(app)/dashboard/layout.tsx
deleted file mode 100644
index 5885308..0000000
--- a/app/(app)/dashboard/layout.tsx
+++ /dev/null
@@ -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 (
-
- {/* Background overlay */}
-
-
-
- {/* Sidebar */}
-
-
- {/* Logo */}
-
-
- kV
-
-
- kreatiVortex
-
-
-
-
- {/* Navigation */}
-
-
-
-
-
-
-
- Beranda
-
-
-
-
-
-
-
- Teori
-
-
-
-
-
-
-
- Praktik
-
-
-
-
-
-
-
- Template Makalah
-
-
-
-
- {/* Additional sections */}
-
-
- Komunitas
-
-
-
-
-
-
-
- Video Saya
-
-
-
-
-
-
-
- Forum
-
-
-
-
-
-
-
- Tugas
-
-
-
-
-
-
-
- {/* Main content */}
-
- {/* Top bar */}
-
-
-
- Dashboard
-
-
-
- {/* Notifications */}
-
-
-
-
-
-
-
- {/* Profile */}
-
-
-
John Doe
-
Calon Pendidik
-
-
- JD
-
-
-
-
-
-
- {/* Page content */}
-
- {children}
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx
deleted file mode 100644
index ce86dfb..0000000
--- a/app/(app)/dashboard/page.tsx
+++ /dev/null
@@ -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 (
-
-
-
- Selamat Datang di kreatiVortex!
-
-
- Platform pembelajaran tari tradisional Indonesia
-
-
-
- {/* Stats Cards */}
-
-
- {/* Recent Activity */}
-
- {/* Recent Videos */}
-
-
Video Terbaru
-
-
-
-
-
Tari Piring - Dasar
-
2 jam yang lalu
-
-
-
-
-
-
Tari Saman - Gerakan Tangan
-
5 jam yang lalu
-
-
-
-
-
- {/* Recent Forum Posts */}
-
-
Diskusi Terbaru
-
-
-
Tips untuk pemula Tari Piring
-
Saya ingin berbagi beberapa tips untuk yang baru mulai belajar...
-
Oleh Sarah Pendidik • 1 jam yang lalu
-
-
-
Costum untuk pertunjukan
-
Apakah ada saran untuk costum yang tepat untuk pertunjukan tari...
-
Oleh Budi Student • 3 jam yang lalu
-
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/app/[locale]/(app)/dashboard/assignments/[id]/page.tsx b/app/[locale]/(app)/dashboard/assignments/[id]/page.tsx
new file mode 100644
index 0000000..1ec06dd
--- /dev/null
+++ b/app/[locale]/(app)/dashboard/assignments/[id]/page.tsx
@@ -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 (
+
+
+
+
+
+
+ Kembali ke Daftar Tugas
+
+
+
+
+ {/* Assignment Details */}
+
+
+
+
+ {assignment.class}
+
+
+ Oleh {assignment.educator}
+
+
+
+
{assignment.title}
+
+
+
+ {assignment.description}
+
+
+
+
+
+
Tenggat Waktu
+
+ {new Date(assignment.dueDate).toLocaleString()}
+
+
+
+
Nilai Maksimal
+
{assignment.maxScore}
+
+
+
+
+
+ {/* Submission Sidebar */}
+
+
+
Pengumpulan Tugas
+
+
+
Status:
+
+ {assignment.status}
+
+
+
+
+
+
+
+
+
Upload File
+
Word (DOCX) atau PDF
+
+
+
+ Serahkan Tugas
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/[locale]/(app)/dashboard/assignments/new/page.tsx b/app/[locale]/(app)/dashboard/assignments/new/page.tsx
new file mode 100644
index 0000000..ad43ef4
--- /dev/null
+++ b/app/[locale]/(app)/dashboard/assignments/new/page.tsx
@@ -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 (
+
+
+
Buat Tugas Baru
+
Berikan tugas kepada siswa untuk evaluasi pembelajaran
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/[locale]/(app)/dashboard/assignments/page.tsx b/app/[locale]/(app)/dashboard/assignments/page.tsx
new file mode 100644
index 0000000..fedeaab
--- /dev/null
+++ b/app/[locale]/(app)/dashboard/assignments/page.tsx
@@ -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('/api/assignments');
+
+ return (
+
+
+
+
Tugas Saya
+
Kelola dan kumpulkan tugas-tugas Anda
+
+ {/* Only educators should see this button in real app */}
+
+
+ Buat Tugas Baru
+
+
+
+
+ {loading ? (
+
Loading...
+ ) : (
+
+ {assignments?.map((assignment) => (
+
+
+
+ {assignment.class.name}
+
+ {/* Status placeholder - needs logic */}
+
+ Pending
+
+
+
+
+ {assignment.title}
+
+
+
+
Tenggat Waktu:
+
{new Date(assignment.dueDate).toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
+
+
+
+
+ Nilai Maks:
+ {assignment.maxScore}
+
+
+
+ Detail
+
+
+
+
+
+
+
+ ))}
+ {(!assignments || assignments.length === 0) && (
+
+ Belum ada tugas yang tersedia.
+
+ )}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/app/[locale]/(app)/dashboard/classes/[id]/page.tsx b/app/[locale]/(app)/dashboard/classes/[id]/page.tsx
new file mode 100644
index 0000000..ccaa407
--- /dev/null
+++ b/app/[locale]/(app)/dashboard/classes/[id]/page.tsx
@@ -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(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(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 (
+
+ );
+ }
+
+ if (error || !classData) {
+ return (
+
+
+
Error: {error || 'Kelas tidak ditemukan'}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ Kembali ke Daftar Kelas
+
+
+ Pengaturan Kelas
+
+
+
+ {/* Class Header */}
+
+
+
+ {classData.isActive ? 'Aktif' : 'Tidak Aktif'}
+
+
+
+
{classData.name}
+
{classData.code}
+
+
+
+
+
+
+ Pengajar: {classData.educator.user.name}
+
+
+
+
+
+ {classData.members.length} / {classData.maxStudents || '∞'} Siswa
+
+
+
+
+
+ {/* Main Content */}
+
+ {/* About */}
+
+
Tentang Kelas
+
+ {classData.description || 'Tidak ada deskripsi untuk kelas ini.'}
+
+
+
+ {/* Quick Actions */}
+
+
+ {/* Statistics */}
+
+
+
{classData.videos.length}
+
Video
+
+
+
{classData.assignments.length}
+
Tugas
+
+
+
{classData.members.length}
+
Siswa
+
+
+
+
+ {/* Sidebar */}
+
+ {/* Class Members */}
+
+
+
Anggota Kelas
+ {classData.members.length} siswa
+
+
+ {classData.members.slice(0, 5).map((member) => (
+
+
+ {member.student.user.image ? (
+
+ ) : (
+
+ {member.student.user.name.charAt(0).toUpperCase()}
+
+ )}
+
+
+
+ {member.student.user.name}
+
+
+
+ ))}
+ {classData.members.length > 5 && (
+
+
+ +{classData.members.length - 5} siswa lainnya
+
+
+ )}
+
+
+
+ {/* Class Info */}
+
+
Informasi Kelas
+
+
+ Kode Kelas
+ {classData.code}
+
+
+ Status
+
+ {classData.isActive ? 'Aktif' : 'Tidak Aktif'}
+
+
+
+ Kapasitas
+
+ {classData.members.length} / {classData.maxStudents || '∞'}
+
+
+
+ Dibuat
+
+ {new Date(classData.createdAt).toLocaleDateString('id-ID')}
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/[locale]/(app)/dashboard/classes/new/page.tsx b/app/[locale]/(app)/dashboard/classes/new/page.tsx
new file mode 100644
index 0000000..a0018a5
--- /dev/null
+++ b/app/[locale]/(app)/dashboard/classes/new/page.tsx
@@ -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 (
+
+
+
Buat Kelas Baru
+
Buat kelas baru untuk mulai mengajar
+
+
+
+
+
+ Nama Kelas
+
+ 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"
+ />
+
+
+
+
+ Kode Kelas
+
+ setFormData({ ...formData, code: e.target.value })}
+ required
+ placeholder="Contoh: TTI101"
+ className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
+ />
+
+
+
+
+
+
+ Maksimal Siswa
+
+ setFormData({ ...formData, maxStudents: parseInt(e.target.value) })}
+ className="bg-white/10 border-white/20 text-white"
+ />
+
+
+
+
+ Deskripsi Kelas
+
+ 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"
+ />
+
+
+
+ router.back()}
+ disabled={loading}
+ className="text-gray-300 hover:text-white"
+ >
+ Batal
+
+
+ {loading ? 'Menyimpan...' : 'Buat Kelas'}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/[locale]/(app)/dashboard/classes/page.tsx b/app/[locale]/(app)/dashboard/classes/page.tsx
new file mode 100644
index 0000000..cce6ebd
--- /dev/null
+++ b/app/[locale]/(app)/dashboard/classes/page.tsx
@@ -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('/api/classes');
+ const [userProfile, setUserProfile] = useState(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 (
+
+
+
+
{t('title')}
+
{t('subtitle')}
+
+ {canCreateClass ? (
+
+
+ {t('createButton')}
+
+
+ ) : userProfile?.role?.name === 'UMUM' ? (
+
+
Upgrade ke Pendidik untuk membuat kelas
+
+ Daftar sebagai Pendidik →
+
+
+ ) : (
+
+ Hanya Pendidik yang dapat membuat kelas
+
+ )}
+
+
+ {loading ? (
+
{t('loading')}
+ ) : (
+
+ {classes?.map((cls) => (
+
+
+
+ {t('active')}
+
+
+
+
+
+ {cls._count.members} {t('students')}
+
+
+
+
+ {cls.name}
+
+
+ {cls.description}
+
+
+
+
+
+
+
+ {cls.educator?.user.name || t('unknownEducator')}
+
+ {/* Schedule not in model yet, placeholder */}
+
+
+
+
+ {t('scheduleNotSet')}
+
+
+
+
+
+ {t('enterClass')}
+
+
+
+
+
+
+ ))}
+ {(!classes || classes.length === 0) && (
+
+ {t('noClasses')}
+
+ )}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/app/[locale]/(app)/dashboard/forum/[classId]/page.tsx b/app/[locale]/(app)/dashboard/forum/[classId]/page.tsx
new file mode 100644
index 0000000..33b815c
--- /dev/null
+++ b/app/[locale]/(app)/dashboard/forum/[classId]/page.tsx
@@ -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(`/api/forums?classId=${classId}`);
+ const { data: classData } = useFetch(`/api/classes/${classId}`);
+
+ return (
+
+
+
+
+ {classData ? `${t('classForum')}: ${classData.name}` : t('classForum')}
+
+
+ {classData ? classData.description : t('classForumSubtitle')}
+
+
+
+
+ {t('createButton')}
+
+
+
+
+
+ {loading ? (
+
Loading...
+ ) : (
+
+ {forums?.map((forum) => (
+
+
+
+
+
+ {forum.type}
+
+
+
+
+ {forum.title}
+
+
+
+
+
+ {forum.creator.user.name.charAt(0)}
+
+ {forum.creator.user.name}
+
+ •
+ {new Date(forum.updatedAt).toLocaleDateString()}
+
+
+
+
+
+
{forum._count.posts}
+
{t('posts')}
+
+
+
+
+ ))}
+ {(!forums || forums.length === 0) && (
+
+ {t('noForums')}
+
+ )}
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/[locale]/(app)/dashboard/forum/detail/[id]/page.tsx b/app/[locale]/(app)/dashboard/forum/detail/[id]/page.tsx
new file mode 100644
index 0000000..39670fd
--- /dev/null
+++ b/app/[locale]/(app)/dashboard/forum/detail/[id]/page.tsx
@@ -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(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 Loading...
;
+ }
+
+ if (!forum) {
+ return Forum not found
;
+ }
+
+ return (
+
+
+
+
+
+
+ Kembali ke Forum
+
+
+
+ {/* Main Post */}
+
+
+
+
+
+ {new Date(forum.createdAt).toLocaleString()}
+
+
+
{forum.title}
+
+
+
+ {forum.description && (
+
+
+ {forum.creator.user.name.charAt(0)}
+
+
+
+ {forum.creator.user.name}
+
+ Pembuat Forum
+
+
+
+ {forum.description}
+
+
+
+ )}
+
+
+
+ {/* Posts */}
+
+
{forum.posts.length} Diskusi
+
+ {forum.posts.map((post) => (
+
+
+
+ {post.author.user.name.charAt(0)}
+
+
+
+
+ {post.author.user.name}
+
+
+ {new Date(post.createdAt).toLocaleString()}
+
+
+
+ {post.content}
+
+
+
+
+ ))}
+
+
+ {/* Reply Form */}
+
+
Tulis Balasan
+
+
+
+
+ Isi Balasan
+
+ setReplyContent(e.target.value)}
+ placeholder="Tulis balasan Anda di sini..."
+ className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
+ required
+ />
+
+
+
+ {submittingReply ? 'Mengirim...' : 'Kirim Balasan'}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/[locale]/(app)/dashboard/forum/new/page.tsx b/app/[locale]/(app)/dashboard/forum/new/page.tsx
new file mode 100644
index 0000000..126b818
--- /dev/null
+++ b/app/[locale]/(app)/dashboard/forum/new/page.tsx
@@ -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([]);
+ const { data: classes } = useFetch('/api/classes');
+
+ useEffect(() => {
+ if (classId) {
+ setFormData(prev => ({ ...prev, classId }));
+ }
+ }, [classId, classes ]);
+
+ const handleFileUpload = async (e: React.ChangeEvent) => {
+ 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 (
+
+
+
Buat Diskusi Baru
+
Mulai percakapan dengan komunitas
+
+
+
+
+
+ Judul Diskusi
+
+ 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"
+ />
+
+
+
+
+ Pilih Kelas
+
+ setFormData({ ...formData, classId: value })}
+ >
+
+
+
+
+ Forum Umum
+ {classes?.map((classItem) => (
+
+ {classItem.name}
+
+ ))}
+
+
+
+
+
+
+ Isi Diskusi
+
+ 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"
+ />
+
+
+
+
+ Lampiran (PDF, Word, Gambar)
+
+
+
+ Maksimal 10MB per file. Format yang diizinkan: PDF, Word, JPG, PNG, GIF
+
+
+ {attachments.length > 0 && (
+
+
File yang diupload:
+ {attachments.map((file, index) => (
+
+
+
+
+
{file.originalName}
+
{formatFileSize(file.size)}
+
+
+
removeAttachment(index)}
+ className="text-red-400 hover:text-red-300 transition-colors"
+ >
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ router.back()}
+ disabled={loading}
+ className="text-gray-300 hover:text-white"
+ >
+ Batal
+
+
+ {loading ? 'Menyimpan...' : 'Buat Diskusi'}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/[locale]/(app)/dashboard/forum/umum/page.tsx b/app/[locale]/(app)/dashboard/forum/umum/page.tsx
new file mode 100644
index 0000000..26d3e5b
--- /dev/null
+++ b/app/[locale]/(app)/dashboard/forum/umum/page.tsx
@@ -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('/api/forums?type=GENERAL');
+
+ return (
+
+
+
+
{t('generalForum')}
+
{t('generalForumSubtitle')}
+
+
+
+ {t('createButton')}
+
+
+
+
+
+ {loading ? (
+
Loading...
+ ) : (
+
+ {forums?.map((forum) => (
+
+
+
+
+
+ {forum.type}
+
+
+
+
+ {forum.title}
+
+
+
+
+
+ {forum.creator.user.name.charAt(0)}
+
+ {forum.creator.user.name}
+
+ •
+ {new Date(forum.updatedAt).toLocaleDateString()}
+
+
+
+
+
+
{forum._count.posts}
+
{t('posts')}
+
+
+
+
+ ))}
+ {(!forums || forums.length === 0) && (
+
+ {t('noForums')}
+
+ )}
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/[locale]/(app)/dashboard/layout.tsx b/app/[locale]/(app)/dashboard/layout.tsx
new file mode 100644
index 0000000..c339629
--- /dev/null
+++ b/app/[locale]/(app)/dashboard/layout.tsx
@@ -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 (
+
+
+
+
+
+
+
+ KreatiVortex
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/[locale]/(app)/dashboard/menu/_components/MenuClient.tsx b/app/[locale]/(app)/dashboard/menu/_components/MenuClient.tsx
new file mode 100644
index 0000000..77586da
--- /dev/null
+++ b/app/[locale]/(app)/dashboard/menu/_components/MenuClient.tsx
@@ -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) => 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 }) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [currentMenu, setCurrentMenu] = useState(null);
+ const [isFormOpen, setIsFormOpen] = useState(false);
+
+ const formSchema = createFormSchema(translations);
+
+ const {
+ register,
+ handleSubmit,
+ reset,
+ setValue,
+ formState: { errors, isSubmitting },
+ } = useForm({
+ 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 (
+
+
+
{translations.title}
+
{ resetForm(); setIsFormOpen(true); }} className="bg-gold-500 hover:bg-gold-600 text-black">
+
+ {translations.addButton}
+
+
+
+ {isFormOpen && (
+
+
+
+ {isEditing ? translations.editTitle : translations.addTitle}
+
+
+
+
+
+
+
+
+
+
{translations.nameIdLabel}
+
+ {errors.nameId &&
{errors.nameId.message}
}
+
+
+ {translations.nameEnLabel}
+
+
+
+
+
+
+
{translations.descriptionIdLabel}
+
+ {errors.descriptionId &&
{errors.descriptionId.message}
}
+
+
+ {translations.descriptionEnLabel}
+
+
+
+
+
+
+
{translations.slugLabel}
+
+ {errors.slug &&
{errors.slug.message}
}
+
+
+ {translations.parentLabel}
+
+ {translations.parentNone}
+ {initialMenus.map((menu) => (
+
+ {getLocalizedName(menu.name)}
+
+ ))}
+
+
+
+
+
+
+ {translations.activeLabel}
+
+
+
+
+ {translations.cancelButton}
+
+
+ {isSubmitting ? translations.saving : (isEditing ? translations.saveButton : translations.createButton)}
+
+
+
+
+ )}
+
+
+
+
+
+
+ {translations.tableName}
+ {translations.tableSlug}
+ {translations.tableParent}
+ {translations.tableStatus}
+ {translations.tableActions}
+
+
+
+ {initialMenus.map((menu) => (
+
+
+ {getLocalizedName(menu.name)}
+
+ {getLocalizedName(menu.description)}
+
+
+ {menu.slug}
+
+ {menu.parent ? getLocalizedName(menu.parent.name) : '-'}
+
+
+
+ {menu.isActive ? translations.statusActive : translations.statusInactive}
+
+
+
+ handleEdit(menu)} className="hover:bg-white/10 hover:text-gold-400">
+
+
+ handleDelete(menu.id)} className="hover:bg-white/10 hover:text-red-400">
+
+
+
+
+ ))}
+ {initialMenus.length === 0 && (
+
+
+ {translations.noMenus}
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/app/[locale]/(app)/dashboard/menu/actions.ts b/app/[locale]/(app)/dashboard/menu/actions.ts
new file mode 100644
index 0000000..84c8ffa
--- /dev/null
+++ b/app/[locale]/(app)/dashboard/menu/actions.ts
@@ -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" };
+ }
+}
diff --git a/app/[locale]/(app)/dashboard/menu/page.tsx b/app/[locale]/(app)/dashboard/menu/page.tsx
new file mode 100644
index 0000000..51d0234
--- /dev/null
+++ b/app/[locale]/(app)/dashboard/menu/page.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/app/[locale]/(app)/dashboard/page.tsx b/app/[locale]/(app)/dashboard/page.tsx
new file mode 100644
index 0000000..24a9914
--- /dev/null
+++ b/app/[locale]/(app)/dashboard/page.tsx
@@ -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 (
+
+
+
+ {t('welcomeTitle')}
+
+
+ {t('welcomeSubtitle')}
+
+
+ {/* Show role registration options for general users */}
+ {userProfile?.role?.name === 'UMUM' || userProfile === null && (
+
+
Upgrade Akun Anda
+
+ Pilih role untuk mengakses fitur lengkap kreatiVortex
+
+
+
+ )}
+
+
+ {/* Recent Activity */}
+
+ {/* Recent Videos */}
+
+
{t('recentVideosTitle')}
+
+
+
+
+
{t('sampleVideo1')}
+
{t('timeAgo', {hours: 2})}
+
+
+
+
+
+
{t('sampleVideo2')}
+
{t('timeAgo', {hours: 5})}
+
+
+
+
+
+ {/* Recent Forum Posts */}
+
+
{t('recentDiscussionsTitle')}
+
+
+
{t('sampleDiscussion1Title')}
+
{t('sampleDiscussion1Content')}
+
Oleh Sarah Pendidik • {t('timeAgo', {hours: 1})}
+
+
+
{t('sampleDiscussion2Title')}
+
{t('sampleDiscussion2Content')}
+
Oleh Budi Student • {t('timeAgo', {hours: 3})}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/[locale]/(app)/dashboard/pages/[slug]/page.tsx b/app/[locale]/(app)/dashboard/pages/[slug]/page.tsx
new file mode 100644
index 0000000..d95ee5d
--- /dev/null
+++ b/app/[locale]/(app)/dashboard/pages/[slug]/page.tsx
@@ -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 (
+
+
+
+ {getLocalizedText(menu.name)}
+
+
') }}>
+
+
+
+ {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 (
+
+ {/* Video Thumbnail/Embed Placeholder */}
+
+
+ {youtubeId ? (
+
+ ) : (
+
+ Video format not supported or invalid URL
+
+ )}
+
+
+
+
+
+ {video.title}
+
+ {video.description && (
+
+ {video.description}
+
+ )}
+
+
+ )
+ })
+ ) : (
+
+
Belum ada video di menu ini.
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/[locale]/(app)/dashboard/register-role/page.tsx b/app/[locale]/(app)/dashboard/register-role/page.tsx
new file mode 100644
index 0000000..dc33827
--- /dev/null
+++ b/app/[locale]/(app)/dashboard/register-role/page.tsx
@@ -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 (
+
+
+
Pilih Role Anda
+
Upgrade akun Anda untuk mengakses fitur lengkap
+
+
+ {!selectedRole ? (
+
+
handleRoleSelect('CALON_PENDIDIK')}>
+
+
+
+
Calon Pendidik
+
Bergabung sebagai peserta didik untuk mengakses materi pembelajaran, mengerjakan tugas, dan berpartisipasi dalam forum diskusi kelas.
+
+ • Akses video pembelajaran
+ • Bergabung dengan kelas
+ • Mengerjakan tugas
+ • Forum diskusi kelas
+
+
+
+
+
+
handleRoleSelect('PENDIDIK')}>
+
+
+
+
Pendidik
+
Daftar sebagai instruktur untuk membuat kelas, mengelola konten pembelajaran, dan membimbing calon pendidik.
+
+ • Buat dan kelola kelas
+ • Upload video pembelajaran
+ • Berikan tugas dan revisi
+ • Moderasi forum kelas
+
+
+
+
+
+ ) : (
+
+
+
+ Anda memilih role:
+ {selectedRole === 'CALON_PENDIDIK' ? 'Calon Pendidik' : 'Pendidik'}
+
+
+
setSelectedRole('')}
+ className="text-sm text-gold-400 hover:text-gold-300 mt-2"
+ >
+ ← Pilih role lain
+
+
+
+
+
+ Instansi
+
+ 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"
+ />
+
+
+ {selectedRole === 'CALON_PENDIDIK' ? (
+ <>
+
+
+ Kode Kelas
+
+
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"
+ />
+
+ Hubungi pendidik Anda untuk mendapatkan kode kelas
+
+
+
+
+
+ NIM (Nomor Induk Mahasiswa)
+
+ setFormData({ ...formData, nim: e.target.value })}
+ required
+ placeholder="Masukkan NIM Anda"
+ className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
+ />
+
+ >
+ ) : (
+ <>
+
+
+ Nama Kelas
+
+ 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"
+ />
+
+
+
+
+ Jenjang Mengajar
+
+ 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"
+ />
+
+
+
+
+ Tujuan Bergabung
+
+ 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"
+ />
+
+ >
+ )}
+
+
+ setSelectedRole('')}
+ disabled={loading}
+ className="text-gray-300 hover:text-white"
+ >
+ Batal
+
+
+ {loading ? 'Mendaftar...' : 'Daftar Sekarang'}
+
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/app/[locale]/(app)/dashboard/videos/[id]/edit/page.tsx b/app/[locale]/(app)/dashboard/videos/[id]/edit/page.tsx
new file mode 100644
index 0000000..3326ea3
--- /dev/null
+++ b/app/[locale]/(app)/dashboard/videos/[id]/edit/page.tsx
@@ -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(`/api/videos/${id}`);
+ const { data: menusData } = useFetch('/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 Loading...
;
+ }
+
+ const initialData = {
+ title: video.title,
+ description: video.description || '',
+ videoUrl: video.videoUrl,
+ videoType: video.videoType,
+ isPublic: video.isPublic,
+ menuId: video.menuId || '',
+ };
+
+ return (
+
+
+
Edit Video
+
Perbarui informasi video pembelajaran
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/[locale]/(app)/dashboard/videos/[id]/page.tsx b/app/[locale]/(app)/dashboard/videos/[id]/page.tsx
new file mode 100644
index 0000000..15e6734
--- /dev/null
+++ b/app/[locale]/(app)/dashboard/videos/[id]/page.tsx
@@ -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(`/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 Loading...
;
+ }
+
+ if (!video) {
+ return Video not found
;
+ }
+
+ return (
+
+
+
+
+
+
+ Kembali ke Daftar Video
+
+
+
+ Edit Video
+
+
+
+
+ {/* Video Player */}
+
+ {youtubeId ? (
+
+ ) : (
+
+ Video format not supported or invalid URL
+
+ )}
+
+
+ {/* Video Info */}
+
+
+
+
{video.title}
+
+ Diunggah oleh {video.uploader.user.name}
+ •
+ {new Date(video.createdAt).toLocaleDateString()}
+ •
+ {video.viewCount} kali ditonton
+
+
+
+
+
+
+
+
+ {/* Comments Section */}
+
+
Komentar
+
+ {video.comments?.map((comment) => (
+
+
+ {comment.author.user.name.charAt(0)}
+
+
+
+
+ {comment.author.user.name}
+ {new Date(comment.createdAt).toLocaleDateString()}
+
+
{comment.content}
+
+
+
+ ))}
+ {(!video.comments || video.comments.length === 0) && (
+
Belum ada komentar.
+ )}
+
+ {/* Comment Form */}
+
+
+ ME
+
+
+
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..."
+ >
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/[locale]/(app)/dashboard/videos/new/page.tsx b/app/[locale]/(app)/dashboard/videos/new/page.tsx
new file mode 100644
index 0000000..10054e1
--- /dev/null
+++ b/app/[locale]/(app)/dashboard/videos/new/page.tsx
@@ -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 (
+
+
+
Upload Video Baru
+
Tambahkan video pembelajaran baru ke koleksi Anda
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/[locale]/(app)/dashboard/videos/page.tsx b/app/[locale]/(app)/dashboard/videos/page.tsx
new file mode 100644
index 0000000..6597973
--- /dev/null
+++ b/app/[locale]/(app)/dashboard/videos/page.tsx
@@ -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 {
+ 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('/api/videos');
+ const [userProfile, setUserProfile] = useState(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 (
+
+
+
+
+
+ {t('actionView')}
+
+
+ {canEdit && (
+
+
+
+
+ {t('actionEdit')}
+
+ )}
+
+ {canDelete && (
+
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')}
+ >
+
+
+
+ {t('actionDelete')}
+
+ )}
+
+ );
+ };
+
+ return (
+
+
+
+
{t('title')}
+
{t('subtitle')}
+
+ {canUploadVideos(userProfile) && (
+
+
+ {t('uploadButton')}
+
+
+ )}
+
+
+ {loading ? (
+
+ ) : videos && videos.length > 0 ? (
+
+ {videos.map((video) => (
+
+ {/* Video Thumbnail */}
+
+ {video.videoType === 'YOUTUBE' ? (
+
+ ) : (
+
+ )}
+
+ {/* Play Button Overlay */}
+
+
+ {/* Status Badge */}
+
+
+ {video.isPublic ? 'Public' : 'Private'}
+
+
+
+ {/* Play Button Overlay */}
+
+
+ {/* Status Badge */}
+
+
+ {video.isPublic ? 'Public' : 'Private'}
+
+
+
+
+ {/* Video Info */}
+
+
+
+ {video.title}
+
+
+
+ {video.videoType}
+
+
+
+
+
+ {video.viewCount || 0} views
+
+
+
+
+
+ {video.description}
+
+
+ {/* Video Actions */}
+
+
+
+
+
+ {new Date(video.createdAt).toLocaleDateString('id-ID', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ })}
+
+
+
+
+
+
+
+ {t('actionView')}
+
+
+ {(() => {
+ 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 (
+
+
+
+
+ {t('actionEdit')}
+
+ );
+ }
+
+ if (canDelete) {
+ return (
+
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')}
+ >
+
+
+
+ {t('actionDelete')}
+
+ );
+ }
+
+ return null;
+ })()}
+
+
+
+
+ ))}
+
+ ) : (
+
+
+
+
+
+
{t('noVideos')}
+
{t('noVideosDesc')}
+ {canUploadVideos(userProfile) && (
+
+
+ {t('uploadFirst')}
+
+
+ )}
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/app/[locale]/auth/layout.tsx b/app/[locale]/auth/layout.tsx
new file mode 100644
index 0000000..10d6c46
--- /dev/null
+++ b/app/[locale]/auth/layout.tsx
@@ -0,0 +1,10 @@
+export default function AuthLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return ;
+}
\ No newline at end of file
diff --git a/app/auth/signin/page.tsx b/app/[locale]/auth/signin/page.tsx
similarity index 61%
rename from app/auth/signin/page.tsx
rename to app/[locale]/auth/signin/page.tsx
index dbfa64d..3abd691 100644
--- a/app/auth/signin/page.tsx
+++ b/app/[locale]/auth/signin/page.tsx
@@ -9,10 +9,14 @@
'use client';
import { useState } from 'react';
-import Link from 'next/link';
-import { useRouter } from 'next/navigation';
+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";
export default function SignIn() {
+ const t = useTranslations('Auth');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
@@ -21,18 +25,23 @@ export default function SignIn() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
-
- // TODO: Implement actual sign in logic
- setTimeout(() => {
- setIsLoading(false);
- router.push('/dashboard');
- }, 1000);
+
+ await authClient.signIn.email({
+ email,
+ password,
+ }, {
+ onSuccess: () => {
+ router.push('/dashboard');
+ },
+ onError: (ctx) => {
+ alert(ctx.error.message);
+ setIsLoading(false);
+ }
+ });
};
return (
-
-
-
+
{/* Logo */}
@@ -40,54 +49,54 @@ export default function SignIn() {
kreatiVortex
-
Masuk ke akun Anda
+
{t('signInTitle')}
{/* Form */}
-
-
- Email
-
-
+
+ {t('emailLabel')}
+
+ 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"
required
/>
-
-
- Password
-
-
+
+ {t('passwordLabel')}
+
+ 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="••••••••"
required
/>
-
+
-
- Ingat saya
-
+
+ {t('rememberMe')}
+
- Lupa password?
+ {t('forgotPassword')}
@@ -96,16 +105,16 @@ export default function SignIn() {
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 ? 'Memproses...' : 'Masuk'}
+ {isLoading ? t('processing') : t('signInButton')}
{/* Sign up link */}
- Belum punya akun?{' '}
+ {t('noAccount')}{' '}
- Daftar sekarang
+ {t('registerNow')}
@@ -113,4 +122,4 @@ export default function SignIn() {
);
-}
\ No newline at end of file
+}
diff --git a/app/[locale]/auth/signup/page.tsx b/app/[locale]/auth/signup/page.tsx
new file mode 100644
index 0000000..abd477f
--- /dev/null
+++ b/app/[locale]/auth/signup/page.tsx
@@ -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
) => {
+ setFormData({
+ ...formData,
+ [e.target.name]: e.target.value,
+ });
+ };
+
+ return (
+
+
+
+ {/* Logo */}
+
+
+ kreatiVortex
+
+
{t('signUpTitle')}
+
+
+ {/* Form */}
+
+
+
+ {t('nameLabel')}
+
+
+
+
+
+
+ {t('emailLabel')}
+
+
+
+
+
+
+ {t('roleLabel')}
+
+ setFormData({ ...formData, role: value as any })}
+ >
+
+
+
+
+ {t('roleEducator')}
+ {t('roleGeneral')}
+
+
+
+
+
+
+ {t('passwordLabel')}
+
+
+
+
+
+
+ {t('confirmPasswordLabel')}
+
+
+
+
+
+ {isLoading ? t('registering') : t('signUpButton')}
+
+
+
+ {/* Sign in link */}
+
+
+ {t('haveAccount')}{' '}
+
+ {t('signInNow')}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx
new file mode 100644
index 0000000..a629df3
--- /dev/null
+++ b/app/[locale]/layout.tsx
@@ -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 (
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx
new file mode 100644
index 0000000..a569cac
--- /dev/null
+++ b/app/[locale]/page.tsx
@@ -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 (
+
+
+
+
+
+ EN
+
+ |
+
+ ID
+
+
+
+
+
+ {t('login')}
+
+
+
+
+ {t('registration')}
+
+
+
+
+
+ {/* Main content */}
+
+
+ {/* Logo/Title */}
+
+
+ {t('title')}
+
+
+ {t('subtitle')}
+
+
+
+ {/* Action Buttons */}
+
+
+
+ {t('startNow')}
+
+
+
+
+
+ Login
+
+
+ atau
+
+
+ Daftar sebagai Pendidik
+
+
+
+
+ Daftar sebagai Calon Pendidik
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/actions/video.ts b/app/actions/video.ts
new file mode 100644
index 0000000..35fc56a
--- /dev/null
+++ b/app/actions/video.ts
@@ -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;
+}
diff --git a/app/api/assignments/[id]/route.ts b/app/api/assignments/[id]/route.ts
new file mode 100644
index 0000000..2dc4021
--- /dev/null
+++ b/app/api/assignments/[id]/route.ts
@@ -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 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/api/assignments/route.ts b/app/api/assignments/route.ts
new file mode 100644
index 0000000..67ee408
--- /dev/null
+++ b/app/api/assignments/route.ts
@@ -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 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts
index a6c4ada..f5dec16 100644
--- a/app/api/auth/[...all]/route.ts
+++ b/app/api/auth/[...all]/route.ts
@@ -6,12 +6,7 @@
* 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() {
- return NextResponse.json({ message: 'Auth API - GET' });
-}
-
-export async function POST() {
- return NextResponse.json({ message: 'Auth API - POST' });
-}
\ No newline at end of file
+export const { GET, POST } = toNextJsHandler(auth.handler);
\ No newline at end of file
diff --git a/app/api/auth/register-role/route.ts b/app/api/auth/register-role/route.ts
new file mode 100644
index 0000000..f2aa401
--- /dev/null
+++ b/app/api/auth/register-role/route.ts
@@ -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 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/api/classes/[id]/route.ts b/app/api/classes/[id]/route.ts
new file mode 100644
index 0000000..29e02d0
--- /dev/null
+++ b/app/api/classes/[id]/route.ts
@@ -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 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/api/classes/route.ts b/app/api/classes/route.ts
new file mode 100644
index 0000000..b7dfba4
--- /dev/null
+++ b/app/api/classes/route.ts
@@ -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 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/api/comments/route.ts b/app/api/comments/route.ts
new file mode 100644
index 0000000..dbdf9a4
--- /dev/null
+++ b/app/api/comments/route.ts
@@ -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 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/api/forums/[id]/posts/route.ts b/app/api/forums/[id]/posts/route.ts
new file mode 100644
index 0000000..28e7422
--- /dev/null
+++ b/app/api/forums/[id]/posts/route.ts
@@ -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 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/api/forums/[id]/route.ts b/app/api/forums/[id]/route.ts
new file mode 100644
index 0000000..43dc2d3
--- /dev/null
+++ b/app/api/forums/[id]/route.ts
@@ -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 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/api/forums/route.ts b/app/api/forums/route.ts
new file mode 100644
index 0000000..11aa9a8
--- /dev/null
+++ b/app/api/forums/route.ts
@@ -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 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/api/menus/route.ts b/app/api/menus/route.ts
new file mode 100644
index 0000000..ea06924
--- /dev/null
+++ b/app/api/menus/route.ts
@@ -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 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts
new file mode 100644
index 0000000..3d53b20
--- /dev/null
+++ b/app/api/upload/route.ts
@@ -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 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/api/user/profile/route.ts b/app/api/user/profile/route.ts
new file mode 100644
index 0000000..ccc7a54
--- /dev/null
+++ b/app/api/user/profile/route.ts
@@ -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 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/api/videos/[id]/route.ts b/app/api/videos/[id]/route.ts
new file mode 100644
index 0000000..a9c4d7e
--- /dev/null
+++ b/app/api/videos/[id]/route.ts
@@ -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 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/api/videos/route.ts b/app/api/videos/route.ts
new file mode 100644
index 0000000..cb2dc24
--- /dev/null
+++ b/app/api/videos/route.ts
@@ -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 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/auth/signup/page.tsx b/app/auth/signup/page.tsx
deleted file mode 100644
index aea55ec..0000000
--- a/app/auth/signup/page.tsx
+++ /dev/null
@@ -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) => {
- setFormData({
- ...formData,
- [e.target.name]: e.target.value,
- });
- };
-
- return (
-
-
-
-
-
- {/* Logo */}
-
-
- kreatiVortex
-
-
Buat akun baru
-
-
- {/* Form */}
-
-
-
- Nama Lengkap
-
-
-
-
-
-
- Email
-
-
-
-
-
-
- Daftar sebagai
-
-
- Calon Pendidik
- Umum
-
-
-
-
-
- Password
-
-
-
-
-
-
- Konfirmasi Password
-
-
-
-
-
- {isLoading ? 'Mendaftar...' : 'Daftar'}
-
-
-
- {/* Sign in link */}
-
-
- Sudah punya akun?{' '}
-
- Masuk
-
-
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/app/globals.css b/app/globals.css
index e120972..e68a634 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -4,8 +4,10 @@
@custom-variant dark (&:is(.dark *));
@theme inline {
+ --radius: 0.65rem;
--color-background: var(--background);
--color-foreground: var(--foreground);
+ --font-serif: var(--font-kaisei-decol);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
@@ -41,7 +43,7 @@
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
-
+
/* kreatiVortex custom colors */
--color-navy-50: #f0f4ff;
--color-navy-100: #dae9ff;
@@ -66,47 +68,12 @@
}
:root {
- --radius: 0.625rem;
- --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);
+ --background: oklch(12.856% 0.00001 271.152);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
- --popover: oklch(0.205 0 0);
- --popover-foreground: oklch(0.985 0 0);
+ --popover: oklch(98.511% 0.00011 271.152 / 0.3);
+ --popover-foreground: oklch(16.376% 0.00002 271.152);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
@@ -138,7 +105,25 @@
* {
@apply border-border outline-ring/50;
}
+
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ @apply font-serif;
+ }
+
body {
@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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/layout.tsx b/app/layout.tsx
deleted file mode 100644
index f7fa87e..0000000
--- a/app/layout.tsx
+++ /dev/null
@@ -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 (
-
-
- {children}
-
-
- );
-}
diff --git a/app/page.tsx b/app/page.tsx
deleted file mode 100644
index b79fe5e..0000000
--- a/app/page.tsx
+++ /dev/null
@@ -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 (
-
- {/* Background overlay effect */}
-
-
- {/* Main content */}
-
-
- {/* Logo/Title */}
-
-
- kreatiVortex
-
-
- Platform Pembelajaran Tari Online
-
-
-
- {/* Description */}
-
-
- Bergabunglah dengan komunitas pembelajaran tari tradisional Indonesia.
- Pelajari berbagai tarian dari seluruh nusantara dengan panduan dari para ahli.
-
-
-
- {/* Action Buttons */}
-
-
- Masuk
-
-
- Daftar
-
-
- Mulai Sekarang
-
-
-
- {/* Features */}
-
-
-
-
Video Pembelajaran
-
Akses video tutorial tari dari berbagai daerah di Indonesia
-
-
-
-
-
Forum Diskusi
-
Berinteraksi dengan sesama pecinta tari dan para ahli
-
-
-
-
-
Tugas & Evaluasi
-
Kumpulkan tugas dan dapatkan feedback dari para pendidik
-
-
-
-
-
- );
-}
diff --git a/bun.lock b/bun.lock
index 6f6e503..c957bd1 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,21 +5,34 @@
"": {
"name": "kreativortex",
"dependencies": {
+ "@hookform/resolvers": "^5.2.2",
"@prisma/adapter-pg": "^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/pg": "^8.15.6",
+ "@types/uuid": "^11.0.0",
"bcryptjs": "^3.0.3",
"better-auth": "^1.4.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.555.0",
"next": "16.0.5",
+ "next-intl": "^4.5.6",
+ "pg": "^8.16.3",
"prisma": "^7.0.1",
"react": "19.2.0",
"react-dom": "19.2.0",
+ "react-hook-form": "^7.67.0",
+ "scrypt": "^6.0.3",
"tailwind-merge": "^3.4.0",
"tsx": "^4.20.6",
+ "uuid": "^13.0.0",
+ "zod": "^4.1.13",
},
"devDependencies": {
"@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=="],
+ "@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=="],
+ "@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/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=="],
+ "@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=="],
+ "@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/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/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/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/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/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=="],
+ "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=="],
"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=="],
+ "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=="],
"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-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=="],
"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-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
+
"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=="],
@@ -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=="],
+ "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-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=="],
+ "nan": ["nan@2.23.1", "", {}, "sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw=="],
+
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="],
@@ -865,8 +1006,14 @@
"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-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-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=="],
+ "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=="],
"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-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-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=="],
"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=="],
+ "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=="],
"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=="],
+ "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=="],
"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=="],
+ "@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/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=="],
+ "@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/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
diff --git a/components/ActionButton/ActionButton.tsx b/components/ActionButton/ActionButton.tsx
new file mode 100644
index 0000000..30762fa
--- /dev/null
+++ b/components/ActionButton/ActionButton.tsx
@@ -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 {
+ variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive';
+ size?: 'sm' | 'md' | 'lg';
+ loading?: boolean;
+ icon?: React.ReactNode;
+ children: React.ReactNode;
+}
+
+const ActionButton = React.forwardRef(
+ ({ 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 (
+
+ {loading && (
+
+
+
+
+ )}
+ {!loading && icon && {icon} }
+ {!loading && icon && {icon} }
+ {children}
+
+ );
+ }
+);
+
+ActionButton.displayName = 'ActionButton';
+
+export default ActionButton;
\ No newline at end of file
diff --git a/components/ActionButton/index.ts b/components/ActionButton/index.ts
index 665211d..ed7e89f 100644
--- a/components/ActionButton/index.ts
+++ b/components/ActionButton/index.ts
@@ -6,4 +6,4 @@
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
-// ActionButton component will be created here
\ No newline at end of file
+export { default } from "./ActionButton";
\ No newline at end of file
diff --git a/components/AttachmentDisplay.tsx b/components/AttachmentDisplay.tsx
new file mode 100644
index 0000000..b959699
--- /dev/null
+++ b/components/AttachmentDisplay.tsx
@@ -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 (
+
+
+
+ );
+ }
+ if (mimeType.includes('word') || mimeType.includes('document')) {
+ return (
+
+
+
+ );
+ }
+ if (mimeType.includes('image')) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+
+
+ );
+ };
+
+ 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 (
+
+ );
+}
\ No newline at end of file
diff --git a/components/CommentForm.tsx b/components/CommentForm.tsx
new file mode 100644
index 0000000..f5ae6f2
--- /dev/null
+++ b/components/CommentForm.tsx
@@ -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([]);
+
+ 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) => {
+ 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 (
+
+ setContent(e.target.value)}
+ placeholder={placeholder}
+ rows={3}
+ className="bg-white/10 border-white/20 text-white placeholder:text-gray-400 resize-none"
+ />
+
+
+
+
+
+
+
+
+ Lampirkan File
+
+
+ PDF, Word, Gambar (Maks 10MB)
+
+
+
+ {attachments.length > 0 && (
+
+ {attachments.map((file, index) => (
+
+
+
+
+
{file.originalName}
+
{formatFileSize(file.size)}
+
+
+
removeAttachment(index)}
+ className="text-red-400 hover:text-red-300 transition-colors"
+ >
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ {loading ? 'Mengirim...' : buttonText}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/Common/AppDataView.tsx b/components/Common/AppDataView.tsx
new file mode 100644
index 0000000..507af27
--- /dev/null
+++ b/components/Common/AppDataView.tsx
@@ -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 {
+ key: keyof T;
+ label: string;
+ render?: (value: T[keyof T], row: T) => React.ReactNode;
+ className?: string;
+}
+
+interface AppDataViewProps {
+ data: T[];
+ columns: Column[];
+ loading?: boolean;
+ className?: string;
+ emptyMessage?: string;
+}
+
+function AppDataView>({
+ data,
+ columns,
+ loading = false,
+ className,
+ emptyMessage = 'Tidak ada data tersedia'
+}: AppDataViewProps) {
+ if (loading) {
+ return (
+
+ {/* Skeleton rows */}
+ {Array.from({ length: 5 }).map((_, index) => (
+
+ ))}
+
+ );
+ }
+
+ if (data.length === 0) {
+ return (
+
+
+
{emptyMessage}
+
Coba ubah filter atau tambah data baru
+
+ );
+ }
+
+ return (
+
+ {/* Table for desktop */}
+
+
+
+
+ {columns.map((column) => (
+
+ {column.label}
+
+ ))}
+
+
+
+ {data.map((row, index) => (
+
+ {columns.map((column) => (
+
+ {column.render ? column.render(row[column.key], row) : String(row[column.key] || '')}
+
+ ))}
+
+ ))}
+
+
+
+
+ {/* Cards for mobile */}
+
+ {data.map((row, index) => (
+
+ {columns.map((column) => (
+
+
+ {column.label}
+
+
+ {column.render ? column.render(row[column.key], row) : String(row[column.key] || '')}
+
+
+ ))}
+
+ ))}
+
+
+ );
+}
+
+export default AppDataView;
\ No newline at end of file
diff --git a/components/Common/AppForm.tsx b/components/Common/AppForm.tsx
new file mode 100644
index 0000000..8844bf7
--- /dev/null
+++ b/components/Common/AppForm.tsx
@@ -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(
+ ({ children, className, onSubmit, ...props }, ref) => {
+ return (
+
+ {children}
+
+ );
+ }
+);
+
+AppForm.displayName = 'AppForm';
+
+export default AppForm;
\ No newline at end of file
diff --git a/components/Forms/VideoForm.tsx b/components/Forms/VideoForm.tsx
new file mode 100644
index 0000000..de83000
--- /dev/null
+++ b/components/Forms/VideoForm.tsx
@@ -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;
+ 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(initialData || {
+ title: '',
+ description: '',
+ videoUrl: '',
+ videoType: 'YOUTUBE',
+ isPublic: true,
+ menuId: '',
+ });
+
+ const handleChange = (e: React.ChangeEvent) => {
+ 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 (
+
+
+
+ Judul Video
+
+
+
+
+
+
+ Deskripsi
+
+
+
+
+
+
+ Menu (Opsional)
+
+ handleSelectChange('menuId', value)}
+ >
+
+
+
+
+ Tidak ada menu
+ {menus.map((menu) => (
+
+ {menu.title}
+
+ ))}
+
+
+
+
+
+
+ Tipe Video
+
+ handleSelectChange('videoType', value)}
+ >
+
+
+
+
+ YouTube
+ Upload File (Local)
+
+
+
+
+
+
+ {formData.videoType === 'YOUTUBE' ? 'URL YouTube' : 'File Video'}
+
+ {formData.videoType === 'YOUTUBE' ? (
+
+ ) : (
+
+
Upload file video belum tersedia dalam demo ini
+
+
+ )}
+
+
+
+
+
+ Publik (Dapat dilihat oleh semua orang)
+
+
+
+
+ router.back()}
+ disabled={loading}
+ className="text-gray-300 hover:text-white"
+ >
+ Batal
+
+
+ {loading ? 'Menyimpan...' : (isEditing ? 'Simpan Perubahan' : 'Upload Video')}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/Layouts/DashboardMenu.tsx b/components/Layouts/DashboardMenu.tsx
new file mode 100644
index 0000000..fa3a2a1
--- /dev/null
+++ b/components/Layouts/DashboardMenu.tsx
@@ -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('/api/classes');
+ const { data: userProfile } = useFetch('/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 (
+
+
+ {/* Navigation */}
+
+
+
+
+
+
+ {t('home')}
+
+
+
+ {userIsAdmin && (
+
+
+
+
+ {t('videos')}
+
+
+
+ )}
+
+
+
+
+
+ {t('menu')}
+
+
+
+
+ {menus.map((menu) => (
+
+ {menu.children && menu.children.length > 0 ? (
+
+
+ {getLocalizedName(menu.name)}
+
+
+
+ {menu.children.map((child) => (
+
+
+ {getLocalizedName(child.name)}
+
+
+ ))}
+
+
+
+ ) : (
+
+
+ {getLocalizedName(menu.name)}
+
+
+ )}
+
+ ))}
+
+
+
+
+
+
+
+
+ {t('classes')}
+
+
+
+
+
+
+
+
+ {t('forum')}
+
+
+
+
+
+
+ {t('general')}
+
+
+
+
+ {t('group')}
+
+
+
+ {classes?.map((classItem) => (
+
+
+ {classItem.name}
+
+
+ ))}
+ {(!classes || classes.length === 0) && (
+
+ {t('noClasses')}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {t('contact')}
+
+
+
+
+
+
+
+ {t('faq')}
+
+
+
+
+
+
+ );
+}
diff --git a/components/Layouts/DashboardProfile.tsx b/components/Layouts/DashboardProfile.tsx
new file mode 100644
index 0000000..4188faa
--- /dev/null
+++ b/components/Layouts/DashboardProfile.tsx
@@ -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 (
+
+ )
+ }
+
+ // 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 (
+
+
setIsOpen(!isOpen)}
+ className="flex items-center space-x-3 group focus:outline-none"
+ >
+
+
{user.name}
+
{role.toLowerCase().replace('_', ' ')}
+
+
+ {user.image ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
{initials}
+ )}
+
+
+
+ {/* Dropdown Menu */}
+ {isOpen && (
+ <>
+
setIsOpen(false)}
+ >
+
+
+
{user.name}
+
{user.email}
+
+
setIsOpen(false)}
+ >
+ {t('profileSettings')}
+
+
+ {t('signOut')}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/components/Layouts/index.ts b/components/Layouts/index.ts
index acbf97f..b8a7426 100644
--- a/components/Layouts/index.ts
+++ b/components/Layouts/index.ts
@@ -6,4 +6,5 @@
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
-// Layout components will be exported here as they are created
\ No newline at end of file
+export { default as DashboardProfile } from './DashboardProfile';
+export { default as DashboardMenu } from './DashboardMenu';
diff --git a/components/index.ts b/components/index.ts
index 9df75ce..a511b93 100644
--- a/components/index.ts
+++ b/components/index.ts
@@ -6,4 +6,9 @@
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
-// Components will be exported here as they are created
\ No newline at end of file
+// 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";
\ No newline at end of file
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
new file mode 100644
index 0000000..db050cc
--- /dev/null
+++ b/components/ui/button.tsx
@@ -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 & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot : "button"
+
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..3f85f62
--- /dev/null
+++ b/components/ui/dropdown-menu.tsx
@@ -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) {
+ return
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuContent({
+ className,
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function DropdownMenuGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/components/ui/field.tsx b/components/ui/field.tsx
new file mode 100644
index 0000000..235d00e
--- /dev/null
+++ b/components/ui/field.tsx
@@ -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 (
+ [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 (
+
+ )
+}
+
+function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ [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
) {
+ return (
+
+ )
+}
+
+function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function FieldLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+ [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 (
+
+ )
+}
+
+function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ children?: React.ReactNode
+}) {
+ return (
+
+
+ {children && (
+
+ {children}
+
+ )}
+
+ )
+}
+
+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 (
+
+ {uniqueErrors.map(
+ (error, index) =>
+ error?.message && {error.message}
+ )}
+
+ )
+ }, [children, errors])
+
+ if (!content) {
+ return null
+ }
+
+ return (
+
+ {content}
+
+ )
+}
+
+export {
+ Field,
+ FieldLabel,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLegend,
+ FieldSeparator,
+ FieldSet,
+ FieldContent,
+ FieldTitle,
+}
diff --git a/components/ui/form.tsx b/components/ui/form.tsx
new file mode 100644
index 0000000..524b986
--- /dev/null
+++ b/components/ui/form.tsx
@@ -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 = FieldPath,
+> = {
+ name: TName
+}
+
+const FormFieldContext = React.createContext(
+ {} as FormFieldContextValue
+)
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+>({
+ ...props
+}: ControllerProps) => {
+ return (
+
+
+
+ )
+}
+
+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 ")
+ }
+
+ 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(
+ {} as FormItemContextValue
+)
+
+function FormItem({ className, ...props }: React.ComponentProps<"div">) {
+ const id = React.useId()
+
+ return (
+
+
+
+ )
+}
+
+function FormLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ const { error, formItemId } = useFormField()
+
+ return (
+
+ )
+}
+
+function FormControl({ ...props }: React.ComponentProps) {
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
+
+ return (
+
+ )
+}
+
+function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
+ const { formDescriptionId } = useFormField()
+
+ return (
+
+ )
+}
+
+function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
+ const { error, formMessageId } = useFormField()
+ const body = error ? String(error?.message ?? "") : props.children
+
+ if (!body) {
+ return null
+ }
+
+ return (
+
+ {body}
+
+ )
+}
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+}
diff --git a/components/ui/input.tsx b/components/ui/input.tsx
new file mode 100644
index 0000000..8916905
--- /dev/null
+++ b/components/ui/input.tsx
@@ -0,0 +1,21 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/components/ui/label.tsx b/components/ui/label.tsx
new file mode 100644
index 0000000..fb5fbc3
--- /dev/null
+++ b/components/ui/label.tsx
@@ -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) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/components/ui/select.tsx b/components/ui/select.tsx
new file mode 100644
index 0000000..8fdb85e
--- /dev/null
+++ b/components/ui/select.tsx
@@ -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) {
+ return
+}
+
+function SelectGroup({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "popper",
+ align = "center",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx
new file mode 100644
index 0000000..275381c
--- /dev/null
+++ b/components/ui/separator.tsx
@@ -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) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx
new file mode 100644
index 0000000..7f21b5e
--- /dev/null
+++ b/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/hooks/useFetch.ts b/hooks/useFetch.ts
new file mode 100644
index 0000000..065af48
--- /dev/null
+++ b/hooks/useFetch.ts
@@ -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;
+ body?: any;
+}
+
+interface FetchResult {
+ data: T | null;
+ loading: boolean;
+ error: Error | null;
+ refetch: () => Promise;
+}
+
+export function useFetch(url: string, options?: FetchOptions): FetchResult {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(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 };
+}
\ No newline at end of file
diff --git a/i18n/request.ts b/i18n/request.ts
new file mode 100644
index 0000000..52b18ea
--- /dev/null
+++ b/i18n/request.ts
@@ -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
+ };
+});
diff --git a/i18n/routing.ts b/i18n/routing.ts
new file mode 100644
index 0000000..0f57759
--- /dev/null
+++ b/i18n/routing.ts
@@ -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);
diff --git a/lib/admin.ts b/lib/admin.ts
new file mode 100644
index 0000000..d50f95e
--- /dev/null
+++ b/lib/admin.ts
@@ -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;
+}
\ No newline at end of file
diff --git a/lib/profile.ts b/lib/profile.ts
new file mode 100644
index 0000000..929edfd
--- /dev/null
+++ b/lib/profile.ts
@@ -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;
+}
\ No newline at end of file
diff --git a/lib/upload.ts b/lib/upload.ts
new file mode 100644
index 0000000..c5da240
--- /dev/null
+++ b/lib/upload.ts
@@ -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 {
+ 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];
+}
\ No newline at end of file
diff --git a/lib/utils.ts b/lib/utils.ts
index bd0c391..d1326bd 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -4,3 +4,10 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
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;
+};
\ No newline at end of file
diff --git a/lib/youtube.ts b/lib/youtube.ts
new file mode 100644
index 0000000..c8c7d52
--- /dev/null
+++ b/lib/youtube.ts
@@ -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 '';
+}
\ No newline at end of file
diff --git a/messages/en.json b/messages/en.json
new file mode 100644
index 0000000..80faf2b
--- /dev/null
+++ b/messages/en.json
@@ -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"
+ }
+}
\ No newline at end of file
diff --git a/messages/id.json b/messages/id.json
new file mode 100644
index 0000000..7e0030b
--- /dev/null
+++ b/messages/id.json
@@ -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"
+ }
+}
diff --git a/next.config.ts b/next.config.ts
index e9ffa30..d015379 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,7 +1,10 @@
+import createNextIntlPlugin from 'next-intl/plugin';
import type { NextConfig } from "next";
+const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
+
const nextConfig: NextConfig = {
/* config options here */
};
-export default nextConfig;
+export default withNextIntl(nextConfig);
diff --git a/opencode.json b/opencode.json
index 38ba24f..8ca1535 100644
--- a/opencode.json
+++ b/opencode.json
@@ -5,6 +5,61 @@
"type": "remote",
"url": "https://mcp.chonkie.ai/better-auth/better-auth-builder/mcp",
"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"
+ ]
+ },
}
}
\ No newline at end of file
diff --git a/package.json b/package.json
index e56b6ed..1cea391 100644
--- a/package.json
+++ b/package.json
@@ -12,21 +12,34 @@
"db:studio": "prisma studio"
},
"dependencies": {
+ "@hookform/resolvers": "^5.2.2",
"@prisma/adapter-pg": "^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/pg": "^8.15.6",
+ "@types/uuid": "^11.0.0",
"bcryptjs": "^3.0.3",
"better-auth": "^1.4.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.555.0",
"next": "16.0.5",
+ "next-intl": "^4.5.6",
+ "pg": "^8.16.3",
"prisma": "^7.0.1",
"react": "19.2.0",
"react-dom": "19.2.0",
+ "react-hook-form": "^7.67.0",
+ "scrypt": "^6.0.3",
"tailwind-merge": "^3.4.0",
- "tsx": "^4.20.6"
+ "tsx": "^4.20.6",
+ "uuid": "^13.0.0",
+ "zod": "^4.1.13"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -38,8 +51,5 @@
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
- },
- "prisma": {
- "schema": "./prisma"
}
}
\ No newline at end of file
diff --git a/prisma.config.ts b/prisma.config.ts
index 8318e32..8ad7a67 100644
--- a/prisma.config.ts
+++ b/prisma.config.ts
@@ -5,7 +5,7 @@ export default defineConfig({
schema: 'prisma',
migrations: {
path: 'prisma/migrations',
- seed: 'node prisma/seed.js',
+ seed: 'bun prisma/seed.ts',
},
datasource: {
url: env('DATABASE_URL'),
diff --git a/prisma/migrations/20251129112746_init/migration.sql b/prisma/migrations/20251129112746_init/migration.sql
new file mode 100644
index 0000000..4a0aaa8
--- /dev/null
+++ b/prisma/migrations/20251129112746_init/migration.sql
@@ -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;
diff --git a/prisma/migrations/20251129143316_add_menu_model/migration.sql b/prisma/migrations/20251129143316_add_menu_model/migration.sql
new file mode 100644
index 0000000..13a58b3
--- /dev/null
+++ b/prisma/migrations/20251129143316_add_menu_model/migration.sql
@@ -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;
diff --git a/prisma/migrations/20251129143447_menu_name_description_to_json/migration.sql b/prisma/migrations/20251129143447_menu_name_description_to_json/migration.sql
new file mode 100644
index 0000000..ec406a5
--- /dev/null
+++ b/prisma/migrations/20251129143447_menu_name_description_to_json/migration.sql
@@ -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;
diff --git a/prisma/migrations/20251201120000_add_forum_attachments/migration.sql b/prisma/migrations/20251201120000_add_forum_attachments/migration.sql
new file mode 100644
index 0000000..392e50c
--- /dev/null
+++ b/prisma/migrations/20251201120000_add_forum_attachments/migration.sql
@@ -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;
\ No newline at end of file
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 87fa1b2..d2cf9b1 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -22,67 +22,67 @@ datasource db {
// We need to extend the User model here to add the profile relation
model User {
- id String @id
- name String
- email String
- emailVerified Boolean @default(false)
- image String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- sessions Session[]
- accounts Account[]
- profile UserProfile?
+ id String @id
+ name String
+ email String
+ emailVerified Boolean @default(false)
+ image String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ sessions Session[]
+ accounts Account[]
+ profile UserProfile?
- @@unique([email])
- @@map("user")
+ @@unique([email])
+ @@map("user")
}
model Session {
- id String @id
- expiresAt DateTime
- token String
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- ipAddress String?
- userAgent String?
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ id String @id
+ expiresAt DateTime
+ token String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ ipAddress String?
+ userAgent String?
+ userId String
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- @@unique([token])
- @@index([userId])
- @@map("session")
+ @@unique([token])
+ @@index([userId])
+ @@map("session")
}
model Account {
- id String @id
- accountId String
- providerId String
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- accessToken String?
- refreshToken String?
- idToken String?
- accessTokenExpiresAt DateTime?
- refreshTokenExpiresAt DateTime?
- scope String?
- password String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ id String @id
+ accountId String
+ providerId String
+ userId String
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ accessToken String?
+ refreshToken String?
+ idToken String?
+ accessTokenExpiresAt DateTime?
+ refreshTokenExpiresAt DateTime?
+ scope String?
+ password String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
- @@index([userId])
- @@map("account")
+ @@index([userId])
+ @@map("account")
}
model Verification {
- id String @id
- identifier String
- value String
- expiresAt DateTime
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ id String @id
+ identifier String
+ value String
+ expiresAt DateTime
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
- @@index([identifier])
- @@map("verification")
+ @@index([identifier])
+ @@map("verification")
}
// ========================================
@@ -106,7 +106,7 @@ model UserProfile {
id String @id @default(cuid())
userId String @unique
roleId String
- nim String? // Nomor Induk Mahasiswa
+ nim String? // Nomor Induk Mahasiswa
phone String?
address String?
bio String?
@@ -114,21 +114,21 @@ model UserProfile {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- role UserRole @relation(fields: [roleId], references: [id])
-
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ role UserRole @relation(fields: [roleId], references: [id])
+
// Relations as educator
classesTeaching Class[]
videos Video[]
forums Forum[]
assignments Assignment[]
-
+
// Relations as student
- classesEnrolled ClassMember[]
- forumPosts ForumPost[] @relation("ForumPosts")
+ classesEnrolled ClassMember[]
+ forumPosts ForumPost[] @relation("ForumPosts")
assignmentSubmissions AssignmentSubmission[]
- comments Comment[] @relation("CommentAuthor")
-
+ comments Comment[] @relation("CommentAuthor")
+
// Additional relations
reviewedSubmissions AssignmentSubmission[] @relation("SubmissionReviewer")
@@ -148,10 +148,10 @@ model Class {
createdBy String
updatedBy String
- educator UserProfile @relation(fields: [educatorId], references: [id], onDelete: Cascade)
- members ClassMember[]
- videos Video[]
- forums Forum[]
+ educator UserProfile @relation(fields: [educatorId], references: [id], onDelete: Cascade)
+ members ClassMember[]
+ videos Video[]
+ forums Forum[]
assignments Assignment[]
@@map("classes")
@@ -178,7 +178,7 @@ model Video {
videoUrl String
videoType VideoType @default(YOUTUBE)
thumbnailUrl String?
- duration Int? // in seconds
+ duration Int? // in seconds
uploaderId String
classId String?
isPublic Boolean @default(true)
@@ -187,50 +187,53 @@ model Video {
updatedAt DateTime @updatedAt
createdBy String
updatedBy String
+ menuId String?
uploader UserProfile @relation(fields: [uploaderId], references: [id], onDelete: Cascade)
class Class? @relation(fields: [classId], references: [id], onDelete: SetNull)
+ menu Menu? @relation(fields: [menuId], references: [id], onDelete: SetNull)
comments Comment[]
@@map("videos")
}
model Forum {
- id String @id @default(cuid())
+ id String @id @default(cuid())
title String
description String?
- type ForumType @default(GENERAL)
+ type ForumType @default(GENERAL)
classId String?
createdBy String
- isActive Boolean @default(true)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ isActive Boolean @default(true)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
updatedBy String
- creator UserProfile @relation(fields: [createdBy], references: [id], onDelete: Cascade)
- class Class? @relation(fields: [classId], references: [id], onDelete: SetNull)
+ creator UserProfile @relation(fields: [createdBy], references: [id], onDelete: Cascade)
+ class Class? @relation(fields: [classId], references: [id], onDelete: SetNull)
posts ForumPost[]
@@map("forums")
}
model ForumPost {
- id String @id @default(cuid())
- title String
- content String
- forumId String
- authorId String
- parentId String? // for replies
- isPinned Boolean @default(false)
- isActive Boolean @default(true)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- updatedBy String
+ id String @id @default(cuid())
+ title String
+ content String
+ forumId String
+ authorId String
+ parentId String? // for replies
+ isPinned Boolean @default(false)
+ isActive Boolean @default(true)
+ attachments Json @default("[]")
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ updatedBy String
- forum Forum @relation(fields: [forumId], references: [id], onDelete: Cascade)
- author UserProfile @relation("ForumPosts", fields: [authorId], references: [id], onDelete: Cascade)
- parent ForumPost? @relation("PostReplies", fields: [parentId], references: [id], onDelete: Cascade)
- replies ForumPost[] @relation("PostReplies")
+ forum Forum @relation(fields: [forumId], references: [id], onDelete: Cascade)
+ author UserProfile @relation("ForumPosts", fields: [authorId], references: [id], onDelete: Cascade)
+ parent ForumPost? @relation("PostReplies", fields: [parentId], references: [id], onDelete: Cascade)
+ replies ForumPost[] @relation("PostReplies")
comments Comment[]
@@map("forum_posts")
@@ -250,57 +253,76 @@ model Assignment {
createdBy String
updatedBy String
- class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
- educator UserProfile @relation(fields: [educatorId], references: [id], onDelete: Cascade)
+ class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
+ educator UserProfile @relation(fields: [educatorId], references: [id], onDelete: Cascade)
submissions AssignmentSubmission[]
@@map("assignments")
}
model AssignmentSubmission {
- id String @id @default(cuid())
+ id String @id @default(cuid())
assignmentId String
studentId String
documentUrl String
- documentType String // MIME type
+ documentType String // MIME type
score Int?
feedback String?
- status SubmissionStatus @default(SUBMITTED)
- submittedAt DateTime @default(now())
+ status SubmissionStatus @default(SUBMITTED)
+ submittedAt DateTime @default(now())
reviewedAt DateTime?
reviewedBy String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
updatedBy String
- assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
- student UserProfile @relation(fields: [studentId], references: [id], onDelete: Cascade)
+ assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
+ student UserProfile @relation(fields: [studentId], references: [id], onDelete: Cascade)
reviewer UserProfile? @relation("SubmissionReviewer", fields: [reviewedBy], references: [id], onDelete: SetNull)
@@map("assignment_submissions")
}
model Comment {
- id String @id @default(cuid())
- content String
- authorId String
- videoId String?
+ id String @id @default(cuid())
+ content String
+ authorId String
+ videoId String?
forumPostId String?
- parentId String? // for replies
- isActive Boolean @default(true)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- updatedBy String
+ parentId String? // for replies
+ attachments Json @default("[]")
+ isActive Boolean @default(true)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ updatedBy String
- author UserProfile @relation("CommentAuthor", fields: [authorId], references: [id], onDelete: Cascade)
- video Video? @relation(fields: [videoId], references: [id], onDelete: Cascade)
- forumPost ForumPost? @relation(fields: [forumPostId], references: [id], onDelete: Cascade)
- parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
- replies Comment[] @relation("CommentReplies")
+ author UserProfile @relation("CommentAuthor", fields: [authorId], references: [id], onDelete: Cascade)
+ video Video? @relation(fields: [videoId], references: [id], onDelete: Cascade)
+ forumPost ForumPost? @relation(fields: [forumPostId], references: [id], onDelete: Cascade)
+ parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
+ replies Comment[] @relation("CommentReplies")
@@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
// ========================================
diff --git a/prisma/seed.ts b/prisma/seed.ts
new file mode 100644
index 0000000..bc695e7
--- /dev/null
+++ b/prisma/seed.ts
@@ -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();
+ });
diff --git a/proxy.ts b/proxy.ts
new file mode 100644
index 0000000..57ee059
--- /dev/null
+++ b/proxy.ts
@@ -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|.*\\..*).*)']
+};
diff --git a/public/bg-2.jpeg b/public/bg-2.jpeg
new file mode 100644
index 0000000..a316471
Binary files /dev/null and b/public/bg-2.jpeg differ
diff --git a/public/bg.jpeg b/public/bg.jpeg
new file mode 100644
index 0000000..77c2ec7
Binary files /dev/null and b/public/bg.jpeg differ
diff --git a/public/bg.jpg b/public/bg.jpg
new file mode 100644
index 0000000..4465991
Binary files /dev/null and b/public/bg.jpg differ
diff --git a/public/bg.png b/public/bg.png
new file mode 100644
index 0000000..53323e5
Binary files /dev/null and b/public/bg.png differ
diff --git a/public/forum-attachments/40243320b4f0af8e968abdd53e37bd29.pdf b/public/forum-attachments/40243320b4f0af8e968abdd53e37bd29.pdf
new file mode 100644
index 0000000..c8d72b1
Binary files /dev/null and b/public/forum-attachments/40243320b4f0af8e968abdd53e37bd29.pdf differ