This commit is contained in:
Jessica Rekcah 2025-12-02 00:22:34 +07:00
parent 0d339a35e2
commit 4253483f44
98 changed files with 9040 additions and 985 deletions

View File

@ -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

110
README.md
View File

@ -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)

View File

@ -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

View File

@ -1,170 +0,0 @@
/**
* File: layout.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Dashboard layout for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
import Link from 'next/link';
import { ReactNode } from 'react';
export default function DashboardLayout({
children,
}: {
children: ReactNode;
}) {
return (
<div className="min-h-screen bg-gradient-to-br from-navy-900 via-navy-800 to-gray-900">
{/* Background overlay */}
<div className="absolute inset-0 bg-black/20"></div>
<div className="relative z-10 flex min-h-screen">
{/* Sidebar */}
<div className="w-64 bg-navy-900/50 backdrop-blur-sm border-r border-white/10">
<div className="p-6">
{/* Logo */}
<Link href="/dashboard" className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gold-400 rounded-lg flex items-center justify-center">
<span className="text-navy-900 font-bold text-sm">kV</span>
</div>
<span className="text-white font-bold text-xl">
kreati<span className="text-gold-400">Vortex</span>
</span>
</Link>
</div>
{/* Navigation */}
<nav className="px-4 pb-6">
<ul className="space-y-2">
<li>
<Link
href="/dashboard"
className="flex items-center space-x-3 px-4 py-3 text-gray-300 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 00-1-1v-4a1 1 0 011-1h2m4 0a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<span>Beranda</span>
</Link>
</li>
<li>
<Link
href="/dashboard/teori"
className="flex items-center space-x-3 px-4 py-3 text-gray-300 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<span>Teori</span>
</Link>
</li>
<li>
<Link
href="/dashboard/praktik"
className="flex items-center space-x-3 px-4 py-3 text-gray-300 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span>Praktik</span>
</Link>
</li>
<li>
<Link
href="/dashboard/template-makalah"
className="flex items-center space-x-3 px-4 py-3 text-gray-300 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span>Template Makalah</span>
</Link>
</li>
</ul>
{/* Additional sections */}
<div className="mt-8">
<h3 className="px-4 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-4">
Komunitas
</h3>
<ul className="space-y-2">
<li>
<Link
href="/dashboard/videos"
className="flex items-center space-x-3 px-4 py-3 text-gray-300 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span>Video Saya</span>
</Link>
</li>
<li>
<Link
href="/dashboard/forum"
className="flex items-center space-x-3 px-4 py-3 text-gray-300 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z" />
</svg>
<span>Forum</span>
</Link>
</li>
<li>
<Link
href="/dashboard/assignments"
className="flex items-center space-x-3 px-4 py-3 text-gray-300 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2v2a2 2 0 01-2 2M9 5a2 2 0 012-2v2a2 2 0 01-2 2m-3 7h8m-8 4h8m-8 4h8" />
</svg>
<span>Tugas</span>
</Link>
</li>
</ul>
</div>
</nav>
</div>
{/* Main content */}
<div className="flex-1 overflow-auto">
{/* Top bar */}
<header className="bg-navy-800/50 backdrop-blur-sm border-b border-white/10 px-6 py-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-white">
Dashboard
</h1>
<div className="flex items-center space-x-4">
{/* Notifications */}
<button className="relative p-2 text-gray-300 hover:text-white transition-colors">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 10v6.159c0 .538.214 1.055.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<span className="absolute top-1 right-1 w-2 h-2 bg-gold-400 rounded-full"></span>
</button>
{/* Profile */}
<div className="flex items-center space-x-3">
<div className="text-right">
<p className="text-sm font-medium text-white">John Doe</p>
<p className="text-xs text-gray-400">Calon Pendidik</p>
</div>
<div className="w-10 h-10 bg-gold-400 rounded-full flex items-center justify-center">
<span className="text-navy-900 font-semibold">JD</span>
</div>
</div>
</div>
</div>
</header>
{/* Page content */}
<main className="p-6">
{children}
</main>
</div>
</div>
</div>
);
}

View File

@ -1,130 +0,0 @@
/**
* File: page.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Dashboard page for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
export default function Dashboard() {
return (
<div>
<div className="mb-8">
<h2 className="text-2xl font-bold text-white mb-2">
Selamat Datang di kreatiVortex!
</h2>
<p className="text-gray-300">
Platform pembelajaran tari tradisional Indonesia
</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-300">Total Video</p>
<p className="text-2xl font-bold text-white">24</p>
</div>
<div className="w-12 h-12 bg-gold-400/20 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</div>
</div>
</div>
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-300">Kelas Aktif</p>
<p className="text-2xl font-bold text-white">3</p>
</div>
<div className="w-12 h-12 bg-gold-400/20 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
</div>
</div>
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-300">Tugas Selesai</p>
<p className="text-2xl font-bold text-white">12</p>
</div>
<div className="w-12 h-12 bg-gold-400/20 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm7-2a2 2 0 11-4 0v4a2 2 0 014 0V9z" />
</svg>
</div>
</div>
</div>
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-300">Forum Post</p>
<p className="text-2xl font-bold text-white">48</p>
</div>
<div className="w-12 h-12 bg-gold-400/20 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z" />
</svg>
</div>
</div>
</div>
</div>
{/* Recent Activity */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Recent Videos */}
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
<h3 className="text-lg font-semibold text-white mb-4">Video Terbaru</h3>
<div className="space-y-4">
<div className="flex items-center space-x-4">
<div className="w-16 h-12 bg-gold-400/20 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</div>
<div className="flex-1">
<h4 className="text-white font-medium">Tari Piring - Dasar</h4>
<p className="text-sm text-gray-400">2 jam yang lalu</p>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="w-16 h-12 bg-gold-400/20 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</div>
<div className="flex-1">
<h4 className="text-white font-medium">Tari Saman - Gerakan Tangan</h4>
<p className="text-sm text-gray-400">5 jam yang lalu</p>
</div>
</div>
</div>
</div>
{/* Recent Forum Posts */}
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
<h3 className="text-lg font-semibold text-white mb-4">Diskusi Terbaru</h3>
<div className="space-y-4">
<div className="border-l border-gold-400 pl-4">
<h4 className="text-white font-medium mb-1">Tips untuk pemula Tari Piring</h4>
<p className="text-sm text-gray-400 mb-2">Saya ingin berbagi beberapa tips untuk yang baru mulai belajar...</p>
<p className="text-xs text-gray-500">Oleh Sarah Pendidik 1 jam yang lalu</p>
</div>
<div className="border-l border-gold-400 pl-4">
<h4 className="text-white font-medium mb-1">Costum untuk pertunjukan</h4>
<p className="text-sm text-gray-400 mb-2">Apakah ada saran untuk costum yang tepat untuk pertunjukan tari...</p>
<p className="text-xs text-gray-500">Oleh Budi Student 3 jam yang lalu</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,104 @@
/**
* File: page.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Assignment detail page for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
'use client';
import Link from 'next/link';
import ActionButton from '@/components/ActionButton';
export default function AssignmentDetailPage() {
const assignment = {
id: 1,
title: 'Analisis Gerakan Tari Piring',
description: 'Buatlah makalah analisis mengenai teknik dasar gerakan Tari Piring. Fokuskan analisis pada: 1. Teknik memegang piring, 2. Koordinasi gerakan tangan dan kaki, 3. Pola lantai dasar. Panjang makalah minimal 500 kata.',
class: 'Tari Tradisional Indonesia 101',
educator: 'Sarah Pendidik',
dueDate: '2025-12-05T23:59:00Z',
maxScore: 100,
status: 'Belum Diserahkan',
mySubmission: null,
};
return (
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex justify-between items-center">
<Link href="/dashboard/assignments" className="text-gray-400 hover:text-white flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Kembali ke Daftar Tugas
</Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Assignment Details */}
<div className="lg:col-span-2 space-y-6">
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
<div className="flex justify-between items-start mb-4">
<span className="px-3 py-1 text-blue-200 text-xs rounded-full border border-blue-500/30">
{assignment.class}
</span>
<span className="text-sm text-gray-400">
Oleh {assignment.educator}
</span>
</div>
<h1 className="text-2xl font-bold text-white mb-4">{assignment.title}</h1>
<div className="prose prose-invert max-w-none mb-6">
<p className="text-gray-300 leading-relaxed">
{assignment.description}
</p>
</div>
<div className="border-t border-white/10 pt-4 grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-400 mb-1">Tenggat Waktu</p>
<p className="text-white font-medium">
{new Date(assignment.dueDate).toLocaleString()}
</p>
</div>
<div>
<p className="text-sm text-gray-400 mb-1">Nilai Maksimal</p>
<p className="text-white font-medium">{assignment.maxScore}</p>
</div>
</div>
</div>
</div>
{/* Submission Sidebar */}
<div className="lg:col-span-1">
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20 sticky top-6">
<h3 className="text-lg font-semibold text-white mb-4">Pengumpulan Tugas</h3>
<div className="mb-6">
<p className="text-sm text-gray-400 mb-2">Status:</p>
<span className="px-3 py-1 bg-yellow-900/50 text-yellow-200 text-sm rounded border border-yellow-500/30 inline-block w-full text-center">
{assignment.status}
</span>
</div>
<div className="space-y-4">
<div className="border-2 border-dashed rounded-lg p-6 text-center hover:bg-white/5 transition-colors cursor-pointer">
<svg className="w-8 h-8 text-gray-400 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="text-sm text-gray-300 font-medium">Upload File</p>
<p className="text-xs text-gray-500 mt-1">Word (DOCX) atau PDF</p>
</div>
<ActionButton className="w-full">
Serahkan Tugas
</ActionButton>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,141 @@
/**
* File: page.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Create assignment page for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
export default function NewAssignmentPage() {
const router = useRouter();
const searchParams = useSearchParams();
const classId = searchParams.get('classId');
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
title: '',
description: '',
classId: '',
dueDate: '',
maxScore: 100,
});
useEffect(() => {
if (classId) {
setFormData(prev => ({ ...prev, classId }));
}
}, [classId]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
// Simulate API call
setTimeout(() => {
setLoading(false);
router.push('/dashboard/assignments');
}, 1000);
};
return (
<div className="max-w-2xl mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Buat Tugas Baru</h1>
<p className="text-gray-400">Berikan tugas kepada siswa untuk evaluasi pembelajaran</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="title" className="text-gray-300">
Judul Tugas
</Label>
<Input
type="text"
id="title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
placeholder="Contoh: Analisis Gerakan Tari Piring"
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
/>
</div>
<div className="space-y-2">
<Label htmlFor="classId" className="text-gray-300">
Kelas
</Label>
<Select
value={formData.classId}
onValueChange={(value) => setFormData({ ...formData, classId: value })}
>
<SelectTrigger className="bg-white/10 border-white/20 text-white">
<SelectValue placeholder="Pilih Kelas" />
</SelectTrigger>
<SelectContent>
<SelectItem value="asdf">Pilih Kelas</SelectItem>
<SelectItem value="1">Tari Tradisional Indonesia 101</SelectItem>
<SelectItem value="2">Sejarah Tari</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="dueDate" className="text-gray-300">
Tenggat Waktu
</Label>
<Input
type="datetime-local"
id="dueDate"
value={formData.dueDate}
onChange={(e) => setFormData({ ...formData, dueDate: e.target.value })}
required
className="bg-white/10 border-white/20 text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description" className="text-gray-300">
Deskripsi Tugas
</Label>
<Textarea
id="description"
rows={6}
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
required
placeholder="Jelaskan detail tugas yang harus dikerjakan..."
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
/>
</div>
<div className="pt-4 flex justify-end space-x-3">
<Button
type="button"
variant="ghost"
onClick={() => router.back()}
disabled={loading}
className="text-gray-300 hover:text-white"
>
Batal
</Button>
<Button
type="submit"
disabled={loading}
className="bg-gold-500 hover:bg-gold-600 text-navy-900"
>
{loading ? 'Menyimpan...' : 'Buat Tugas'}
</Button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,94 @@
/**
* File: page.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Assignment list page for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
'use client';
import Link from 'next/link';
import ActionButton from '@/components/ActionButton';
import { useFetch } from '@/hooks/useFetch';
interface AssignmentData {
id: string;
title: string;
class: {
name: string;
};
dueDate: string;
status?: string; // Add logic to determine status
maxScore: number;
}
export default function AssignmentPage() {
const { data: assignments, loading } = useFetch<AssignmentData[]>('/api/assignments');
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-white">Tugas Saya</h1>
<p className="text-gray-400">Kelola dan kumpulkan tugas-tugas Anda</p>
</div>
{/* Only educators should see this button in real app */}
<Link href="/dashboard/assignments/new">
<ActionButton>
Buat Tugas Baru
</ActionButton>
</Link>
</div>
{loading ? (
<div className="text-center text-gray-400 py-12">Loading...</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{assignments?.map((assignment) => (
<div key={assignment.id} className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20 hover:bg-white/15 transition-all cursor-pointer group">
<div className="flex justify-between items-start mb-4">
<span className="text-xs text-gray-400 font-medium bg-white/10 px-2 py-1 rounded">
{assignment.class.name}
</span>
{/* Status placeholder - needs logic */}
<span className={`px-2 py-1 text-xs rounded border bg-yellow-900/50 text-yellow-200 border-yellow-500/30`}>
Pending
</span>
</div>
<h3 className="text-lg font-semibold text-white mb-2 group-hover:text-gold-400 transition-colors">
{assignment.title}
</h3>
<div className="mb-4">
<p className="text-sm text-gray-400">Tenggat Waktu:</p>
<p className="text-white font-medium">{new Date(assignment.dueDate).toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}</p>
</div>
<div className="flex justify-between items-center pt-4 border-t border-white/10">
<div className="text-sm">
<span className="text-gray-400">Nilai Maks: </span>
<span className="text-white font-bold">{assignment.maxScore}</span>
</div>
<Link href={`/dashboard/assignments/${assignment.id}`}>
<span className="text-gold-400 hover:text-gold-300 text-sm font-medium flex items-center">
Detail
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</span>
</Link>
</div>
</div>
))}
{(!assignments || assignments.length === 0) && (
<div className="col-span-full text-center py-12 text-gray-400">
Belum ada tugas yang tersedia.
</div>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,269 @@
/**
* File: page.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Class detail page for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import ActionButton from '@/components/ActionButton';
interface ClassMember {
id: string;
student: {
user: {
name: string;
image?: string;
};
};
}
interface ClassData {
id: string;
name: string;
description: string;
code: string;
educatorId: string;
educator: {
user: {
name: string;
image?: string;
};
};
isActive: boolean;
maxStudents?: number;
members: ClassMember[];
videos: any[];
assignments: any[];
createdAt: string;
updatedAt: string;
}
export default function ClassDetailPage() {
const params = useParams();
const [classData, setClassData] = useState<ClassData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchClassData = async () => {
try {
const response = await fetch(`/api/classes/${params.id}`);
const result = await response.json();
if (result.success) {
setClassData(result.data);
} else {
setError(result.message || 'Failed to fetch class data');
}
} catch (err) {
setError('Error fetching class data');
console.error('Error:', err);
} finally {
setLoading(false);
}
};
if (params.id) {
fetchClassData();
}
}, [params.id]);
if (loading) {
return (
<div className="max-w-6xl mx-auto">
<div className="flex justify-center items-center h-64">
<div className="text-white">Memuat data kelas...</div>
</div>
</div>
);
}
if (error || !classData) {
return (
<div className="max-w-6xl mx-auto">
<div className="flex justify-center items-center h-64">
<div className="text-red-400">Error: {error || 'Kelas tidak ditemukan'}</div>
</div>
</div>
);
}
return (
<div className="max-w-6xl mx-auto space-y-6">
<div className="flex justify-between items-center">
<Link href="/dashboard/classes" className="text-gray-400 hover:text-white flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Kembali ke Daftar Kelas
</Link>
<ActionButton variant="outline" size="sm">
Pengaturan Kelas
</ActionButton>
</div>
{/* Class Header */}
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-8 border border-white/20 relative overflow-hidden">
<div className="absolute top-0 right-0 p-4">
<span className={`px-3 py-1 text-sm rounded-full border ${
classData.isActive
? 'bg-green-900/50 text-green-200 border-green-500/30'
: 'bg-red-900/50 text-red-200 border-red-500/30'
}`}>
{classData.isActive ? 'Aktif' : 'Tidak Aktif'}
</span>
</div>
<h1 className="text-3xl font-bold text-white mb-2">{classData.name}</h1>
<p className="text-xl text-gold-400 mb-6">{classData.code}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm text-gray-300">
<div className="flex items-center">
<svg className="w-5 h-5 mr-2 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
Pengajar: {classData.educator.user.name}
</div>
<div className="flex items-center">
<svg className="w-5 h-5 mr-2 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
{classData.members.length} / {classData.maxStudents || '∞'} Siswa
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* About */}
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
<h2 className="text-xl font-bold text-white mb-4">Tentang Kelas</h2>
<p className="text-gray-300 leading-relaxed">
{classData.description || 'Tidak ada deskripsi untuk kelas ini.'}
</p>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-2 gap-4">
<Link href={`/dashboard/forum/${classData.id}`} className="block">
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20 hover:bg-white/15 transition-colors text-center group">
<div className="w-12 h-12 bg-gold-400/20 rounded-full flex items-center justify-center mx-auto mb-3 group-hover:scale-110 transition-transform">
<svg className="w-6 h-6 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z" />
</svg>
</div>
<h3 className="font-semibold text-white">Forum Kelas</h3>
</div>
</Link>
<Link href={`/dashboard/assignments?classId=${classData.id}`} className="block">
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20 hover:bg-white/15 transition-colors text-center group">
<div className="w-12 h-12 bg-gold-400/20 rounded-full flex items-center justify-center mx-auto mb-3 group-hover:scale-110 transition-transform">
<svg className="w-6 h-6 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2v2a2 2 0 01-2 2M9 5a2 2 0 012-2v2a2 2 0 01-2 2m-3 7h8m-8 4h8m-8 4h8" />
</svg>
</div>
<h3 className="font-semibold text-white">Tugas Kelas</h3>
</div>
</Link>
</div>
{/* Statistics */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-4 border border-white/20 text-center">
<div className="text-2xl font-bold text-gold-400">{classData.videos.length}</div>
<div className="text-sm text-gray-300">Video</div>
</div>
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-4 border border-white/20 text-center">
<div className="text-2xl font-bold text-gold-400">{classData.assignments.length}</div>
<div className="text-sm text-gray-300">Tugas</div>
</div>
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-4 border border-white/20 text-center">
<div className="text-2xl font-bold text-gold-400">{classData.members.length}</div>
<div className="text-sm text-gray-300">Siswa</div>
</div>
</div>
</div>
{/* Sidebar */}
<div className="lg:col-span-1 space-y-6">
{/* Class Members */}
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-bold text-white">Anggota Kelas</h2>
<span className="text-gold-400 text-sm">{classData.members.length} siswa</span>
</div>
<div className="space-y-3">
{classData.members.slice(0, 5).map((member) => (
<div key={member.id} className="flex items-center space-x-3">
<div className="w-8 h-8 rounded-full bg-gold-400/20 flex items-center justify-center">
{member.student.user.image ? (
<img
src={member.student.user.image}
alt={member.student.user.name}
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<span className="text-gold-400 text-xs font-bold">
{member.student.user.name.charAt(0).toUpperCase()}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">
{member.student.user.name}
</p>
</div>
</div>
))}
{classData.members.length > 5 && (
<div className="text-center pt-2">
<button className="text-gold-400 text-sm hover:underline">
+{classData.members.length - 5} siswa lainnya
</button>
</div>
)}
</div>
</div>
{/* Class Info */}
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
<h2 className="text-lg font-bold text-white mb-4">Informasi Kelas</h2>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Kode Kelas</span>
<span className="text-white font-medium">{classData.code}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Status</span>
<span className={`font-medium ${
classData.isActive ? 'text-green-400' : 'text-red-400'
}`}>
{classData.isActive ? 'Aktif' : 'Tidak Aktif'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Kapasitas</span>
<span className="text-white font-medium">
{classData.members.length} / {classData.maxStudents || '∞'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Dibuat</span>
<span className="text-white font-medium">
{new Date(classData.createdAt).toLocaleDateString('id-ID')}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,145 @@
/**
* File: page.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Create class page for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
export default function NewClassPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
name: '',
description: '',
code: '',
maxStudents: 30,
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const response = await fetch('/api/classes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});
const result = await response.json();
if (result.success) {
router.push('/dashboard/classes');
} else {
alert('Gagal membuat kelas: ' + result.message);
}
} catch (error) {
console.error('Error creating class:', error);
alert('Terjadi kesalahan saat membuat kelas');
} finally {
setLoading(false);
}
};
return (
<div className="max-w-2xl mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Buat Kelas Baru</h1>
<p className="text-gray-400">Buat kelas baru untuk mulai mengajar</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name" className="text-gray-300">
Nama Kelas
</Label>
<Input
type="text"
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
placeholder="Contoh: Tari Tradisional Indonesia 101"
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
/>
</div>
<div className="space-y-2">
<Label htmlFor="code" className="text-gray-300">
Kode Kelas
</Label>
<Input
type="text"
id="code"
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
required
placeholder="Contoh: TTI101"
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxStudents" className="text-gray-300">
Maksimal Siswa
</Label>
<Input
type="number"
id="maxStudents"
value={formData.maxStudents}
onChange={(e) => setFormData({ ...formData, maxStudents: parseInt(e.target.value) })}
className="bg-white/10 border-white/20 text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description" className="text-gray-300">
Deskripsi Kelas
</Label>
<Textarea
id="description"
rows={4}
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
required
placeholder="Deskripsikan materi yang akan diajarkan..."
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
/>
</div>
<div className="pt-4 flex justify-end space-x-3">
<Button
type="button"
variant="ghost"
onClick={() => router.back()}
disabled={loading}
className="text-gray-300 hover:text-white"
>
Batal
</Button>
<Button
type="submit"
disabled={loading}
className="bg-gold-500 hover:bg-gold-600 text-navy-900"
>
{loading ? 'Menyimpan...' : 'Buat Kelas'}
</Button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,155 @@
/**
* File: page.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Class list page for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
'use client';
import { Link } from '@/i18n/routing';
import ActionButton from '@/components/ActionButton';
import { useFetch } from '@/hooks/useFetch';
import { useTranslations } from 'next-intl';
import { useState, useEffect } from 'react';
interface ClassData {
id: string;
name: string;
description: string;
educator: {
user: {
name: string;
};
};
schedule: string;
_count: {
members: number;
};
status: string; // Assuming we add status to API or calculate it
}
interface UserProfile {
role: {
name: string;
};
}
export default function ClassPage() {
const t = useTranslations('Classes');
const { data: classes, loading } = useFetch<ClassData[]>('/api/classes');
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
useEffect(() => {
// Fetch user profile to check role
const fetchProfile = async () => {
try {
const response = await fetch('/api/user/profile', {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
setUserProfile(data.data);
}
} catch (error) {
console.error('Error fetching user profile:', error);
}
};
fetchProfile();
}, []);
const canCreateClass = userProfile?.role?.name === 'PENDIDIK' || userProfile?.role?.name === 'ADMIN';
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-white">{t('title')}</h1>
<p className="text-gray-400">{t('subtitle')}</p>
</div>
{canCreateClass ? (
<Link href="/dashboard/classes/new">
<ActionButton>
{t('createButton')}
</ActionButton>
</Link>
) : userProfile?.role?.name === 'UMUM' ? (
<div className="text-sm text-gray-400">
<p>Upgrade ke Pendidik untuk membuat kelas</p>
<Link
href="/dashboard/register-role"
className="text-gold-400 hover:text-gold-300 underline"
>
Daftar sebagai Pendidik
</Link>
</div>
) : (
<div className="text-sm text-gray-400">
Hanya Pendidik yang dapat membuat kelas
</div>
)}
</div>
{loading ? (
<div className="text-center text-gray-400 py-12">{t('loading')}</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{classes?.map((cls) => (
<div key={cls.id} className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20 hover:bg-white/15 transition-all cursor-pointer group">
<div className="flex justify-between items-start mb-4">
<span className={`px-2 py-1 text-xs rounded border bg-green-900/50 text-green-200 border-green-500/30`}>
{t('active')}
</span>
<span className="text-sm text-gray-400 flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
{cls._count.members} {t('students')}
</span>
</div>
<h3 className="text-xl font-semibold text-white mb-2 group-hover:text-gold-400 transition-colors">
{cls.name}
</h3>
<p className="text-gray-400 text-sm mb-4 line-clamp-2">
{cls.description}
</p>
<div className="space-y-2 text-sm text-gray-300">
<div className="flex items-center">
<svg className="w-4 h-4 mr-2 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
{cls.educator?.user.name || t('unknownEducator')}
</div>
{/* Schedule not in model yet, placeholder */}
<div className="flex items-center">
<svg className="w-4 h-4 mr-2 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{t('scheduleNotSet')}
</div>
</div>
<div className="mt-6 pt-4 border-t border-white/10 flex justify-end">
<Link href={`/dashboard/classes/${cls.id}`} className="text-gold-400 hover:text-gold-300 text-sm font-medium flex items-center">
{t('enterClass')}
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</Link>
</div>
</div>
))}
{(!classes || classes.length === 0) && (
<div className="col-span-full text-center py-12 text-gray-400 bg-white/5 rounded-xl border border-white/10">
{t('noClasses')}
</div>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,114 @@
/**
* File: page.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Forum list page for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
'use client';
import { useParams } from 'next/navigation';
import { Link } from '@/i18n/routing';
import ActionButton from '@/components/ActionButton';
import { useFetch } from '@/hooks/useFetch';
import { useTranslations } from 'next-intl';
interface ForumData {
id: string;
title: string;
creator: {
user: {
name: string;
};
};
_count: {
posts: number;
};
updatedAt: string;
type: string;
}
interface ClassData {
id: string;
name: string;
code: string;
description: string;
}
export default function ForumPage() {
const params = useParams();
const classId = params.classId as string;
const t = useTranslations('Forum');
const { data: forums, loading } = useFetch<ForumData[]>(`/api/forums?classId=${classId}`);
const { data: classData } = useFetch<ClassData>(`/api/classes/${classId}`);
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-white">
{classData ? `${t('classForum')}: ${classData.name}` : t('classForum')}
</h1>
<p className="text-gray-400">
{classData ? classData.description : t('classForumSubtitle')}
</p>
</div>
<Link href={`/dashboard/forum/new?classId=${classId}`}>
<ActionButton>
{t('createButton')}
</ActionButton>
</Link>
</div>
<div className="bg-white/10 backdrop-blur-sm rounded-xl border border-white/20 overflow-hidden">
{loading ? (
<div className="text-center text-gray-400 py-12">Loading...</div>
) : (
<div className="divide-y divide-white/10">
{forums?.map((forum) => (
<div key={forum.id} className="p-6 hover:bg-white/5 transition-colors">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center space-x-2 mb-1">
<span className="px-2 py-0.5 bg-white/10 text-gray-400 text-xs rounded border border-white/10">
{forum.type}
</span>
</div>
<Link href={`/dashboard/forum/detail/${forum.id}`}>
<h3 className="text-lg font-semibold text-white mb-2 hover:text-gold-400 transition-colors">
{forum.title}
</h3>
</Link>
<div className="flex items-center text-sm text-gray-400 space-x-4">
<span className="flex items-center">
<span className="w-5 h-5 bg-gray-600 rounded-full flex items-center justify-center text-xs text-white font-bold mr-2">
{forum.creator.user.name.charAt(0)}
</span>
{forum.creator.user.name}
</span>
<span></span>
<span>{new Date(forum.updatedAt).toLocaleDateString()}</span>
</div>
</div>
<div className="flex items-center space-x-6 text-gray-400">
<div className="text-center">
<div className="text-lg font-semibold text-white">{forum._count.posts}</div>
<div className="text-xs">{t('posts')}</div>
</div>
</div>
</div>
</div>
))}
{(!forums || forums.length === 0) && (
<div className="text-center py-12 text-gray-400">
{t('noForums')}
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,229 @@
/**
* File: page.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Forum detail page for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
interface ForumPost {
id: string;
title: string;
content: string;
createdAt: string;
author: {
user: {
name: string;
image?: string;
};
};
}
interface Forum {
id: string;
title: string;
description?: string;
createdAt: string;
classId: string;
creator: {
user: {
name: string;
image?: string;
};
};
posts: ForumPost[];
}
export default function ForumDetailPage() {
const params = useParams();
const [forum, setForum] = useState<Forum | null>(null);
const [loading, setLoading] = useState(true);
const [replyContent, setReplyContent] = useState('');
const [submittingReply, setSubmittingReply] = useState(false);
useEffect(() => {
const fetchForum = async () => {
try {
const response = await fetch(`/api/forums/${params.id}`);
const result = await response.json();
if (result.success) {
setForum(result.data);
} else {
console.error('Failed to fetch forum:', result.message);
}
} catch (error) {
console.error('Error fetching forum:', error);
} finally {
setLoading(false);
}
};
if (params.id) {
fetchForum();
}
}, [params.id]);
const handleReplySubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!replyContent.trim()) return;
setSubmittingReply(true);
try {
const response = await fetch(`/api/forums/${params.id}/posts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: replyContent,
}),
});
const result = await response.json();
if (result.success) {
// Add the new post to the forum posts
setForum(prev => prev ? {
...prev,
posts: [result.data, ...prev.posts],
} : null);
// Clear the reply form
setReplyContent('');
} else {
console.error('Failed to create reply:', result.message);
}
} catch (error) {
console.error('Error creating reply:', error);
} finally {
setSubmittingReply(false);
}
};
if (loading) {
return <div className="text-white text-center">Loading...</div>;
}
if (!forum) {
return <div className="text-white text-center">Forum not found</div>;
}
return (
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex justify-between items-center">
<Link href={`/dashboard/forum/${forum.classId ? forum.classId : 'umum'}`} className="text-gray-400 hover:text-white flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Kembali ke Forum
</Link>
</div>
{/* Main Post */}
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
<div className="flex justify-between items-start mb-4">
<div>
<div className="flex items-center space-x-2 mb-2">
<span className="text-xs text-gray-500">
{new Date(forum.createdAt).toLocaleString()}
</span>
</div>
<h1 className="text-2xl font-bold text-white mb-2">{forum.title}</h1>
</div>
</div>
{forum.description && (
<div className="flex items-start space-x-4 mb-6">
<div className="w-10 h-10 bg-gold-400 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-navy-900 font-bold">{forum.creator.user.name.charAt(0)}</span>
</div>
<div>
<div className="flex items-center space-x-2">
<span className="font-medium text-white">{forum.creator.user.name}</span>
<span className="text-xs bg-gold-500/20 text-gold-400 px-2 py-0.5 rounded-full">
Pembuat Forum
</span>
</div>
<div className="mt-2 text-gray-300 leading-relaxed">
{forum.description}
</div>
</div>
</div>
)}
</div>
{/* Posts */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-white ml-2">{forum.posts.length} Diskusi</h3>
{forum.posts.map((post) => (
<div key={post.id} className="bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-white/10 ml-8">
<div className="flex items-start space-x-4">
<div className="w-8 h-8 bg-gray-600 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-white font-bold text-xs">{post.author.user.name.charAt(0)}</span>
</div>
<div className="flex-1">
<div className="flex justify-between items-start mb-2">
<div className="flex items-center space-x-2">
<span className="font-medium text-white">{post.author.user.name}</span>
</div>
<span className="text-xs text-gray-500">
{new Date(post.createdAt).toLocaleString()}
</span>
</div>
<div className="text-gray-300 text-sm leading-relaxed">
{post.content}
</div>
</div>
</div>
</div>
))}
</div>
{/* Reply Form */}
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20 mt-8">
<h3 className="text-lg font-semibold text-white mb-4">Tulis Balasan</h3>
<form onSubmit={handleReplySubmit}>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="reply" className="text-gray-300">
Isi Balasan
</Label>
<Textarea
id="reply"
rows={4}
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
placeholder="Tulis balasan Anda di sini..."
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
required
/>
</div>
<div className="flex justify-end">
<Button
type="submit"
disabled={submittingReply || !replyContent.trim()}
className="bg-gold-500 hover:bg-gold-600 text-navy-900"
>
{submittingReply ? 'Mengirim...' : 'Kirim Balasan'}
</Button>
</div>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,243 @@
/**
* File: page.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Create forum discussion page for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useFetch } from '@/hooks/useFetch';
import { uploadFile, isAllowedFileType, formatFileSize, UploadedFile } from '@/lib/upload';
interface ClassData {
id: string;
name: string;
code: string;
}
export default function NewForumPage() {
const router = useRouter();
const searchParams = useSearchParams();
const classId = searchParams.get('classId');
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
title: '',
content: '',
classId: '',
});
const [attachments, setAttachments] = useState<UploadedFile[]>([]);
const { data: classes } = useFetch<ClassData[]>('/api/classes');
useEffect(() => {
if (classId) {
setFormData(prev => ({ ...prev, classId }));
}
}, [classId, classes ]);
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
const newAttachments: UploadedFile[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!isAllowedFileType(file)) {
alert(`File ${file.name} tidak diizinkan. Hanya PDF, Word, dan file gambar yang diperbolehkan.`);
continue;
}
if (file.size > 10 * 1024 * 1024) { // 10MB limit
alert(`File ${file.name} terlalu besar. Maksimal ukuran file adalah 10MB.`);
continue;
}
try {
const uploadedFile = await uploadFile(file, 'forum-attachments');
newAttachments.push(uploadedFile);
} catch (error) {
console.error('Error uploading file:', error);
alert(`Gagal mengupload file ${file.name}`);
}
}
setAttachments(prev => [...prev, ...newAttachments]);
};
const removeAttachment = (index: number) => {
setAttachments(prev => prev.filter((_, i) => i !== index));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const response = await fetch('/api/forums', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: formData.title,
description: formData.content,
type: formData.classId && formData.classId !== 'general' ? 'CLASS' : 'GENERAL',
classId: formData.classId && formData.classId !== 'general' ? formData.classId : undefined,
attachments: attachments,
}),
});
const result = await response.json();
if (result.success) {
router.push(formData.classId && formData.classId !== 'general' ? `/dashboard/forum/${formData.classId}` : '/dashboard/forum/umum');
} else {
console.error('Failed to create forum:', result.message);
// You could show an error message to the user here
}
} catch (error) {
console.error('Error creating forum:', error);
// You could show an error message to the user here
} finally {
setLoading(false);
}
};
return (
<div className="max-w-2xl mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Buat Diskusi Baru</h1>
<p className="text-gray-400">Mulai percakapan dengan komunitas</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="title" className="text-gray-300">
Judul Diskusi
</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
placeholder="Apa yang ingin Anda diskusikan?"
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
/>
</div>
<div className="space-y-2">
<Label htmlFor="class" className="text-gray-300">
Pilih Kelas
</Label>
<Select
value={formData.classId}
onValueChange={(value) => setFormData({ ...formData, classId: value })}
>
<SelectTrigger className="bg-white/10 border-white/20 text-white">
<SelectValue placeholder="Pilih kelas atau forum umum" />
</SelectTrigger>
<SelectContent>
<SelectItem value="general">Forum Umum</SelectItem>
{classes?.map((classItem) => (
<SelectItem key={classItem.id} value={classItem.id}>
{classItem.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="content" className="text-gray-300">
Isi Diskusi
</Label>
<Textarea
id="content"
rows={6}
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
required
placeholder="Tuliskan detail diskusi Anda di sini..."
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
/>
</div>
<div className="space-y-2">
<Label htmlFor="attachments" className="text-gray-300">
Lampiran (PDF, Word, Gambar)
</Label>
<Input
id="attachments"
type="file"
multiple
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png,.gif"
onChange={handleFileUpload}
className="bg-white/10 border-white/20 text-white file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-gold-500 file:text-white hover:file:bg-gold-600"
/>
<p className="text-xs text-gray-400">
Maksimal 10MB per file. Format yang diizinkan: PDF, Word, JPG, PNG, GIF
</p>
{attachments.length > 0 && (
<div className="space-y-2 mt-3">
<p className="text-sm text-gray-300">File yang diupload:</p>
{attachments.map((file, index) => (
<div key={file.id} className="flex items-center justify-between bg-white/5 rounded-lg p-3 border border-white/10">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gold-500/20 rounded flex items-center justify-center">
<svg className="w-4 h-4 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div>
<p className="text-sm text-white font-medium">{file.originalName}</p>
<p className="text-xs text-gray-400">{formatFileSize(file.size)}</p>
</div>
</div>
<button
type="button"
onClick={() => removeAttachment(index)}
className="text-red-400 hover:text-red-300 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
)}
</div>
<div className="pt-4 flex justify-end space-x-3">
<Button
type="button"
variant="ghost"
onClick={() => router.back()}
disabled={loading}
className="text-gray-300 hover:text-white"
>
Batal
</Button>
<Button
type="submit"
disabled={loading}
className="bg-gold-500 hover:bg-gold-600 text-navy-900"
>
{loading ? 'Menyimpan...' : 'Buat Diskusi'}
</Button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,99 @@
/**
* File: page.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Forum list page for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
'use client';
import { Link } from '@/i18n/routing';
import ActionButton from '@/components/ActionButton';
import { useFetch } from '@/hooks/useFetch';
import { useTranslations } from 'next-intl';
interface ForumData {
id: string;
title: string;
creator: {
user: {
name: string;
};
};
_count: {
posts: number;
};
updatedAt: string;
type: string;
}
export default function ForumPage() {
const t = useTranslations('Forum');
const { data: forums, loading } = useFetch<ForumData[]>('/api/forums?type=GENERAL');
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-white">{t('generalForum')}</h1>
<p className="text-gray-400">{t('generalForumSubtitle')}</p>
</div>
<Link href="/dashboard/forum/new">
<ActionButton>
{t('createButton')}
</ActionButton>
</Link>
</div>
<div className="bg-white/10 backdrop-blur-sm rounded-xl border border-white/20 overflow-hidden">
{loading ? (
<div className="text-center text-gray-400 py-12">Loading...</div>
) : (
<div className="divide-y divide-white/10">
{forums?.map((forum) => (
<div key={forum.id} className="p-6 hover:bg-white/5 transition-colors">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center space-x-2 mb-1">
<span className="px-2 py-0.5 bg-white/10 text-gray-400 text-xs rounded border border-white/10">
{forum.type}
</span>
</div>
<Link href={`/dashboard/forum/detail/${forum.id}`}>
<h3 className="text-lg font-semibold text-white mb-2 hover:text-gold-400 transition-colors">
{forum.title}
</h3>
</Link>
<div className="flex items-center text-sm text-gray-400 space-x-4">
<span className="flex items-center">
<span className="w-5 h-5 bg-gray-600 rounded-full flex items-center justify-center text-xs text-white font-bold mr-2">
{forum.creator.user.name.charAt(0)}
</span>
{forum.creator.user.name}
</span>
<span></span>
<span>{new Date(forum.updatedAt).toLocaleDateString()}</span>
</div>
</div>
<div className="flex items-center space-x-6 text-gray-400">
<div className="text-center">
<div className="text-lg font-semibold text-white">{forum._count.posts}</div>
<div className="text-xs">{t('posts')}</div>
</div>
</div>
</div>
</div>
))}
{(!forums || forums.length === 0) && (
<div className="text-center py-12 text-gray-400">
{t('noForums')}
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,82 @@
/**
* File: layout.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Dashboard layout for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
import { ReactNode } from 'react';
import { DashboardProfile, DashboardMenu } from '@/components/Layouts';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
export default async function DashboardLayout({
children,
}: {
children: ReactNode;
}) {
const session = await auth.api.getSession({
headers: await headers()
});
if (!session) {
redirect('/auth/signin');
}
const menus = await prisma.menu.findMany({
where: {
parentId: null,
isActive: true,
},
select: {
id: true,
name: true,
slug: true,
children: {
where: {
isActive: true,
},
select: {
id: true,
name: true,
slug: true,
},
},
},
orderBy: {
createdAt: 'asc',
},
});
return (
<div className="min-h-screen">
<div className="floating-background dark"></div>
<div className="container mx-auto relative z-10 flex min-h-screen">
<div className="flex-1 overflow-auto">
<header className="px-6 py-4">
<div className="flex items-center justify-between">
<p className="text-2xl font-semibold text-white">
KreatiVortex
</p>
<div className='flex'>
<DashboardMenu menus={menus as any} />
</div>
<div className="flex items-center space-x-4">
<DashboardProfile />
</div>
</div>
</header>
<main className="p-6">
{children}
</main>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,271 @@
'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { createMenu, updateMenu, deleteMenu } from '../actions';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Pencil, Trash2, Plus, X } from 'lucide-react';
// Schema for form - will be created dynamically with translations
const createFormSchema = (translations: Record<string, string>) => z.object({
nameId: z.string().min(1, translations.nameIdRequired),
nameEn: z.string().optional(),
descriptionId: z.string().min(1, translations.descriptionIdRequired),
descriptionEn: z.string().optional(),
slug: z.string().min(1, translations.slugRequired),
parentId: z.string().optional().nullable(),
isActive: z.boolean().default(true),
});
type Menu = {
id: string;
name: any;
slug: string;
description: any;
parentId: string | null;
isActive: boolean;
children?: Menu[];
parent?: { name: any } | null;
};
type FormData = {
nameId: string;
nameEn?: string;
descriptionId: string;
descriptionEn?: string;
slug: string;
parentId?: string | null;
isActive?: boolean;
};
export default function MenuClient({ initialMenus, translations }: { initialMenus: Menu[]; translations: Record<string, string> }) {
const [isEditing, setIsEditing] = useState(false);
const [currentMenu, setCurrentMenu] = useState<Menu | null>(null);
const [isFormOpen, setIsFormOpen] = useState(false);
const formSchema = createFormSchema(translations);
const {
register,
handleSubmit,
reset,
setValue,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
isActive: true,
},
});
const onSubmit = async (data: FormData) => {
const formData = new FormData();
formData.append('nameId', data.nameId);
if (data.nameEn) formData.append('nameEn', data.nameEn);
formData.append('descriptionId', data.descriptionId);
if (data.descriptionEn) formData.append('descriptionEn', data.descriptionEn);
formData.append('slug', data.slug);
if (data.parentId) formData.append('parentId', data.parentId);
else formData.append('parentId', 'null');
if (data.isActive) formData.append('isActive', 'on');
let result;
if (isEditing && currentMenu) {
result = await updateMenu(currentMenu.id, null, formData);
} else {
result = await createMenu(null, formData);
}
if (result.success) {
resetForm();
} else {
alert(result.error || translations.errorOccurred);
}
};
const handleEdit = (menu: Menu) => {
setCurrentMenu(menu);
setIsEditing(true);
setIsFormOpen(true);
setValue('nameId', menu.name.id || menu.name);
setValue('nameEn', menu.name.en || '');
setValue('descriptionId', menu.description.id || menu.description);
setValue('descriptionEn', menu.description.en || '');
setValue('slug', menu.slug);
setValue('parentId', menu.parentId || 'null');
setValue('isActive', menu.isActive);
};
const handleDelete = async (id: string) => {
if (confirm(translations.confirmDelete)) {
await deleteMenu(id);
}
};
const resetForm = () => {
reset({
isActive: true,
parentId: 'null'
});
setIsEditing(false);
setCurrentMenu(null);
setIsFormOpen(false);
};
const getLocalizedName = (name: any) => {
if (typeof name === 'string') return name;
return name.id || name.en || JSON.stringify(name);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold text-white">{translations.title}</h2>
<Button onClick={() => { resetForm(); setIsFormOpen(true); }} className="bg-gold-500 hover:bg-gold-600 text-black">
<Plus className="w-4 h-4 mr-2" />
{translations.addButton}
</Button>
</div>
{isFormOpen && (
<div className="bg-white/5 border border-white/10 p-6 rounded-xl animate-in fade-in slide-in-from-top-4">
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-semibold text-white">
{isEditing ? translations.editTitle : translations.addTitle}
</h3>
<Button variant="ghost" size="icon" onClick={resetForm}>
<X className="w-4 h-4 text-gray-400" />
</Button>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="nameId" className="text-gray-200">{translations.nameIdLabel}</Label>
<Input id="nameId" {...register('nameId')} className="bg-white/5 border-white/10 text-white" />
{errors.nameId && <p className="text-red-400 text-sm">{errors.nameId.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="nameEn" className="text-gray-200">{translations.nameEnLabel}</Label>
<Input id="nameEn" {...register('nameEn')} className="bg-white/5 border-white/10 text-white" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="descriptionId" className="text-gray-200">{translations.descriptionIdLabel}</Label>
<Textarea id="descriptionId" {...register('descriptionId')} className="bg-white/5 border-white/10 text-white" />
{errors.descriptionId && <p className="text-red-400 text-sm">{errors.descriptionId.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="descriptionEn" className="text-gray-200">{translations.descriptionEnLabel}</Label>
<Textarea id="descriptionEn" {...register('descriptionEn')} className="bg-white/5 border-white/10 text-white" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="slug" className="text-gray-200">{translations.slugLabel}</Label>
<Input id="slug" {...register('slug')} className="bg-white/5 border-white/10 text-white" />
{errors.slug && <p className="text-red-400 text-sm">{errors.slug.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="parentId" className="text-gray-200">{translations.parentLabel}</Label>
<select
id="parentId"
{...register('parentId')}
className="w-full h-10 px-3 py-2 rounded-md border border-white/10 bg-white/5 text-white focus:outline-none focus:ring-2 focus:ring-gold-500"
>
<option value="null" className="bg-gray-900">{translations.parentNone}</option>
{initialMenus.map((menu) => (
<option key={menu.id} value={menu.id} className="bg-gray-900">
{getLocalizedName(menu.name)}
</option>
))}
</select>
</div>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="isActive"
{...register('isActive')}
className="w-4 h-4 rounded border-gray-300 text-gold-600 focus:ring-gold-500"
/>
<Label htmlFor="isActive" className="text-gray-200">{translations.activeLabel}</Label>
</div>
<div className="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" onClick={resetForm} className="border-white/10 text-gray-300 hover:bg-white/10 hover:text-white">
{translations.cancelButton}
</Button>
<Button type="submit" disabled={isSubmitting} className="bg-gold-500 hover:bg-gold-600 text-black">
{isSubmitting ? translations.saving : (isEditing ? translations.saveButton : translations.createButton)}
</Button>
</div>
</form>
</div>
)}
<div className="bg-white/5 border border-white/10 rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-white/10 bg-white/5">
<th className="p-4 text-gray-300 font-medium">{translations.tableName}</th>
<th className="p-4 text-gray-300 font-medium">{translations.tableSlug}</th>
<th className="p-4 text-gray-300 font-medium">{translations.tableParent}</th>
<th className="p-4 text-gray-300 font-medium">{translations.tableStatus}</th>
<th className="p-4 text-gray-300 font-medium text-right">{translations.tableActions}</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{initialMenus.map((menu) => (
<tr key={menu.id} className="hover:bg-white/5 transition-colors">
<td className="p-4 text-white font-medium">
{getLocalizedName(menu.name)}
<div className="text-xs text-gray-500 mt-1 truncate max-w-[200px]">
{getLocalizedName(menu.description)}
</div>
</td>
<td className="p-4 text-gray-300">{menu.slug}</td>
<td className="p-4 text-gray-300">
{menu.parent ? getLocalizedName(menu.parent.name) : '-'}
</td>
<td className="p-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${menu.isActive ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'
}`}>
{menu.isActive ? translations.statusActive : translations.statusInactive}
</span>
</td>
<td className="p-4 text-right space-x-2">
<Button variant="ghost" size="icon" onClick={() => handleEdit(menu)} className="hover:bg-white/10 hover:text-gold-400">
<Pencil className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDelete(menu.id)} className="hover:bg-white/10 hover:text-red-400">
<Trash2 className="w-4 h-4" />
</Button>
</td>
</tr>
))}
{initialMenus.length === 0 && (
<tr>
<td colSpan={5} className="p-8 text-center text-gray-500">
{translations.noMenus}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,119 @@
'use server'
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const MenuSchema = z.object({
nameId: z.string().min(1, "Nama (ID) wajib diisi"),
nameEn: z.string().optional(),
descriptionId: z.string().min(1, "Deskripsi (ID) wajib diisi"),
descriptionEn: z.string().optional(),
slug: z.string().min(1, "Slug wajib diisi"),
parentId: z.string().optional().nullable(),
isActive: z.boolean().default(true),
});
export async function createMenu(prevState: any, formData: FormData) {
const validatedFields = MenuSchema.safeParse({
nameId: formData.get("nameId"),
nameEn: formData.get("nameEn"),
descriptionId: formData.get("descriptionId"),
descriptionEn: formData.get("descriptionEn"),
slug: formData.get("slug"),
parentId: formData.get("parentId") === "null" ? null : formData.get("parentId"),
isActive: formData.get("isActive") === "on",
});
if (!validatedFields.success) {
return {
error: validatedFields.error.flatten().fieldErrors,
};
}
const { nameId, nameEn, descriptionId, descriptionEn, slug, parentId, isActive } = validatedFields.data;
try {
await prisma.menu.create({
data: {
name: {
id: nameId,
en: nameEn || nameId,
},
description: {
id: descriptionId,
en: descriptionEn || descriptionId,
},
slug,
parentId,
isActive,
updatedBy: "system", // TODO: Get actual user
},
});
revalidatePath("/dashboard/menu");
return { success: true, message: "Menu berhasil dibuat" };
} catch (error) {
console.error("Failed to create menu:", error);
return { error: "Gagal membuat menu. Pastikan slug unik." };
}
}
export async function updateMenu(id: string, prevState: any, formData: FormData) {
const validatedFields = MenuSchema.safeParse({
nameId: formData.get("nameId"),
nameEn: formData.get("nameEn"),
descriptionId: formData.get("descriptionId"),
descriptionEn: formData.get("descriptionEn"),
slug: formData.get("slug"),
parentId: formData.get("parentId") === "null" ? null : formData.get("parentId"),
isActive: formData.get("isActive") === "on",
});
if (!validatedFields.success) {
return {
error: validatedFields.error.flatten().fieldErrors,
};
}
const { nameId, nameEn, descriptionId, descriptionEn, slug, parentId, isActive } = validatedFields.data;
try {
await prisma.menu.update({
where: { id },
data: {
name: {
id: nameId,
en: nameEn || nameId,
},
description: {
id: descriptionId,
en: descriptionEn || descriptionId,
},
slug,
parentId,
isActive,
updatedBy: "system", // TODO: Get actual user
},
});
revalidatePath("/dashboard/menu");
return { success: true, message: "Menu berhasil diperbarui" };
} catch (error) {
console.error("Failed to update menu:", error);
return { error: "Gagal memperbarui menu." };
}
}
export async function deleteMenu(id: string) {
try {
await prisma.menu.delete({
where: { id },
});
revalidatePath("/dashboard/menu");
return { success: true, message: "Menu berhasil dihapus" };
} catch (error) {
console.error("Failed to delete menu:", error);
return { error: "Gagal menghapus menu" };
}
}

View File

@ -0,0 +1,58 @@
import { prisma } from "@/lib/prisma";
import MenuClient from "./_components/MenuClient";
import { getTranslations } from "next-intl/server";
export default async function MenuPage() {
const t = await getTranslations('Menu');
const menus = await prisma.menu.findMany({
include: {
parent: {
select: {
name: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
});
// Extract all needed translations as a plain object
const translations = {
title: t('title'),
addButton: t('addButton'),
editTitle: t('editTitle'),
addTitle: t('addTitle'),
nameIdLabel: t('nameIdLabel'),
nameEnLabel: t('nameEnLabel'),
descriptionIdLabel: t('descriptionIdLabel'),
descriptionEnLabel: t('descriptionEnLabel'),
slugLabel: t('slugLabel'),
parentLabel: t('parentLabel'),
parentNone: t('parentNone'),
activeLabel: t('activeLabel'),
cancelButton: t('cancelButton'),
saveButton: t('saveButton'),
createButton: t('createButton'),
saving: t('saving'),
tableName: t('tableName'),
tableSlug: t('tableSlug'),
tableParent: t('tableParent'),
tableStatus: t('tableStatus'),
tableActions: t('tableActions'),
statusActive: t('statusActive'),
statusInactive: t('statusInactive'),
noMenus: t('noMenus'),
confirmDelete: t('confirmDelete'),
errorOccurred: t('errorOccurred'),
nameIdRequired: t('nameIdRequired'),
descriptionIdRequired: t('descriptionIdRequired'),
slugRequired: t('slugRequired'),
};
return (
<div className="container mx-auto py-8">
<MenuClient initialMenus={menus} translations={translations} />
</div>
);
}

View File

@ -0,0 +1,115 @@
import { getTranslations } from "next-intl/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
export default async function Dashboard() {
const t = await getTranslations('Dashboard');
// Get user session and profile
const session = await auth.api.getSession({
headers: await headers()
});
let userProfile = null;
if (session?.user) {
userProfile = await prisma.userProfile.findUnique({
where: { userId: session.user.id },
include: { role: true }
});
}
console.log("User Profile:", userProfile);
return (
<div>
<div className="mb-8">
<h2 className="text-2xl font-bold text-white mb-2">
{t('welcomeTitle')}
</h2>
<p className="text-gray-300">
{t('welcomeSubtitle')}
</p>
{/* Show role registration options for general users */}
{userProfile?.role?.name === 'UMUM' || userProfile === null && (
<div className="mt-6 bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
<h3 className="text-lg font-semibold text-white mb-3">Upgrade Akun Anda</h3>
<p className="text-gray-400 mb-4">
Pilih role untuk mengakses fitur lengkap kreatiVortex
</p>
<div className="flex flex-col sm:flex-row gap-3">
<a
href="/dashboard/register-role"
className="inline-flex items-center justify-center px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
Daftar sebagai Calon Pendidik
</a>
<a
href="/dashboard/register-role"
className="inline-flex items-center justify-center px-4 py-2 bg-green-500 hover:bg-green-600 text-white rounded-lg transition-colors"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a2 2 0 012-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm7-2a2 2 0 11-4 0v4a2 2 0 014 0V9z" />
</svg>
Daftar sebagai Pendidik
</a>
</div>
</div>
)}
</div>
{/* Recent Activity */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Recent Videos */}
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
<h3 className="text-lg font-semibold text-white mb-4">{t('recentVideosTitle')}</h3>
<div className="space-y-4">
<div className="flex items-center space-x-4">
<div className="w-16 h-12 bg-gold-400/20 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</div>
<div className="flex-1">
<h4 className="text-white font-medium">{t('sampleVideo1')}</h4>
<p className="text-sm text-gray-400">{t('timeAgo', {hours: 2})}</p>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="w-16 h-12 bg-gold-400/20 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</div>
<div className="flex-1">
<h4 className="text-white font-medium">{t('sampleVideo2')}</h4>
<p className="text-sm text-gray-400">{t('timeAgo', {hours: 5})}</p>
</div>
</div>
</div>
</div>
{/* Recent Forum Posts */}
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
<h3 className="text-lg font-semibold text-white mb-4">{t('recentDiscussionsTitle')}</h3>
<div className="space-y-4">
<div className="border-l border-gold-400 pl-4">
<h4 className="text-white font-medium mb-1">{t('sampleDiscussion1Title')}</h4>
<p className="text-sm text-gray-400 mb-2">{t('sampleDiscussion1Content')}</p>
<p className="text-xs text-gray-500">Oleh Sarah Pendidik {t('timeAgo', {hours: 1})}</p>
</div>
<div className="border-l border-gold-400 pl-4">
<h4 className="text-white font-medium mb-1">{t('sampleDiscussion2Title')}</h4>
<p className="text-sm text-gray-400 mb-2">{t('sampleDiscussion2Content')}</p>
<p className="text-xs text-gray-500">Oleh Budi Student {t('timeAgo', {hours: 3})}</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,93 @@
import { prisma } from "@/lib/prisma";
import { getYouTubeId } from "@/lib/utils";
import { getLocale } from "next-intl/server";
import { notFound } from "next/navigation";
import { use } from "react";
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const locale = await getLocale();
const menu = await prisma.menu.findUnique({
where: {
slug: slug,
},
include: {
videos: {
where: {
isPublic: true,
},
orderBy: {
createdAt: 'desc',
},
},
},
});
if (!menu) {
notFound();
}
const getLocalizedText = (json: any) => {
if (!json) return '';
return json[locale] || json['id'] || json['en'] || '';
};
return (
<div className="space-y-8 max-w-4xl mx-auto">
<div className="space-y-4">
<h1 className="text-3xl font-bold text-white">
{getLocalizedText(menu.name)}
</h1>
<div className="text-gray-300 text-lg" dangerouslySetInnerHTML={{ __html: getLocalizedText(menu.description).replace(/\n/g, '<br>') }}></div>
</div>
<div className="flex flex-col gap-6">
{menu.videos.length > 0 ? (
menu.videos.map((video) => {
const youtubeId = video?.videoUrl ? getYouTubeId(video.videoUrl) : null;
const embedUrl = youtubeId ? `https://www.youtube.com/embed/${youtubeId}` : video?.videoUrl;
return (
<div key={video.id} className="bg-white/5 border border-white/10 rounded-xl overflow-hidden hover:border-white/20 transition-colors">
{/* Video Thumbnail/Embed Placeholder */}
<div className="aspect-video bg-black/50 relative">
<div className="aspect-video bg-black rounded-xl overflow-hidden border border-white/10 shadow-2xl">
{youtubeId ? (
<iframe
src={embedUrl}
title={video.title}
className="w-full h-full"
allowFullScreen
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
></iframe>
) : (
<div className="flex items-center justify-center h-full text-gray-500">
Video format not supported or invalid URL
</div>
)}
</div>
</div>
<div className="p-4 space-y-2">
<h3 className="text-xl font-semibold text-white line-clamp-1">
{video.title}
</h3>
{video.description && (
<p className="text-gray-400 text-sm line-clamp-2">
{video.description}
</p>
)}
</div>
</div>
)
})
) : (
<div className="col-span-full text-center py-12 text-gray-400 bg-white/5 rounded-xl border border-white/10 border-dashed">
<p>Belum ada video di menu ini.</p>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,257 @@
/**
* File: page.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Role registration page for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
export default function RegisterRolePage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [selectedRole, setSelectedRole] = useState('');
const [formData, setFormData] = useState({
institution: '',
className: '', // This will store class code for students
teachingLevel: '',
purpose: '',
nim: '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const response = await fetch('/api/auth/register-role', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
role: selectedRole,
...formData,
}),
credentials: 'include',
});
const result = await response.json();
if (result.success) {
router.push('/dashboard');
router.refresh();
} else {
console.error('Failed to register role:', result.message);
// You could show an error message to the user here
}
} catch (error) {
console.error('Error registering role:', error);
} finally {
setLoading(false);
}
};
const handleRoleSelect = (role: string) => {
setSelectedRole(role);
setFormData({
institution: '',
className: '',
teachingLevel: '',
purpose: '',
nim: '',
});
};
return (
<div className="max-w-2xl mx-auto">
<div className="mb-8">
<h1 className="text-2xl font-bold text-white mb-2">Pilih Role Anda</h1>
<p className="text-gray-400">Upgrade akun Anda untuk mengakses fitur lengkap</p>
</div>
{!selectedRole ? (
<div className="space-y-4">
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20 hover:bg-white/15 transition-all cursor-pointer" onClick={() => handleRoleSelect('CALON_PENDIDIK')}>
<div className="flex items-start space-x-4">
<div className="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-white mb-2">Calon Pendidik</h3>
<p className="text-gray-400 text-sm mb-3">Bergabung sebagai peserta didik untuk mengakses materi pembelajaran, mengerjakan tugas, dan berpartisipasi dalam forum diskusi kelas.</p>
<ul className="text-gray-300 text-sm space-y-1">
<li> Akses video pembelajaran</li>
<li> Bergabung dengan kelas</li>
<li> Mengerjakan tugas</li>
<li> Forum diskusi kelas</li>
</ul>
</div>
</div>
</div>
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20 hover:bg-white/15 transition-all cursor-pointer" onClick={() => handleRoleSelect('PENDIDIK')}>
<div className="flex items-start space-x-4">
<div className="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a2 2 0 012-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm7-2a2 2 0 11-4 0v4a2 2 0 014 0V9z" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-white mb-2">Pendidik</h3>
<p className="text-gray-400 text-sm mb-3">Daftar sebagai instruktur untuk membuat kelas, mengelola konten pembelajaran, dan membimbing calon pendidik.</p>
<ul className="text-gray-300 text-sm space-y-1">
<li> Buat dan kelola kelas</li>
<li> Upload video pembelajaran</li>
<li> Berikan tugas dan revisi</li>
<li> Moderasi forum kelas</li>
</ul>
</div>
</div>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="bg-white/5 rounded-lg p-4 mb-6">
<p className="text-sm text-gray-300">
Anda memilih role: <span className="font-semibold text-white">
{selectedRole === 'CALON_PENDIDIK' ? 'Calon Pendidik' : 'Pendidik'}
</span>
</p>
<button
type="button"
onClick={() => setSelectedRole('')}
className="text-sm text-gold-400 hover:text-gold-300 mt-2"
>
Pilih role lain
</button>
</div>
<div className="space-y-2">
<Label htmlFor="institution" className="text-gray-300">
Instansi
</Label>
<Input
id="institution"
value={formData.institution}
onChange={(e) => setFormData({ ...formData, institution: e.target.value })}
required
placeholder="Nama instansi pendidikan Anda"
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
/>
</div>
{selectedRole === 'CALON_PENDIDIK' ? (
<>
<div className="space-y-2">
<Label htmlFor="classCode" className="text-gray-300">
Kode Kelas
</Label>
<Input
id="classCode"
value={formData.className}
onChange={(e) => setFormData({ ...formData, className: e.target.value })}
required
placeholder="Masukkan kode kelas yang diberikan oleh pendidik"
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
/>
<p className="text-xs text-gray-400">
Hubungi pendidik Anda untuk mendapatkan kode kelas
</p>
</div>
<div className="space-y-2">
<Label htmlFor="nim" className="text-gray-300">
NIM (Nomor Induk Mahasiswa)
</Label>
<Input
id="nim"
value={formData.nim}
onChange={(e) => setFormData({ ...formData, nim: e.target.value })}
required
placeholder="Masukkan NIM Anda"
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
/>
</div>
</>
) : (
<>
<div className="space-y-2">
<Label htmlFor="className" className="text-gray-300">
Nama Kelas
</Label>
<Input
id="className"
value={formData.className}
onChange={(e) => setFormData({ ...formData, className: e.target.value })}
required
placeholder="Nama kelas yang akan Anda buat"
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
/>
</div>
<div className="space-y-2">
<Label htmlFor="teachingLevel" className="text-gray-300">
Jenjang Mengajar
</Label>
<Textarea
id="teachingLevel"
rows={3}
value={formData.teachingLevel}
onChange={(e) => setFormData({ ...formData, teachingLevel: e.target.value })}
required
placeholder="Jelaskan jenjang mengajar Anda (contoh: Tari Tradisional, Tari Modern, dll)"
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
/>
</div>
<div className="space-y-2">
<Label htmlFor="purpose" className="text-gray-300">
Tujuan Bergabung
</Label>
<Textarea
id="purpose"
rows={3}
value={formData.purpose}
onChange={(e) => setFormData({ ...formData, purpose: e.target.value })}
required
placeholder="Jelaskan tujuan Anda bergabung sebagai pendidik"
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
/>
</div>
</>
)}
<div className="pt-4 flex justify-end space-x-3">
<Button
type="button"
variant="ghost"
onClick={() => setSelectedRole('')}
disabled={loading}
className="text-gray-300 hover:text-white"
>
Batal
</Button>
<Button
type="submit"
disabled={loading}
className="bg-gold-500 hover:bg-gold-600 text-navy-900"
>
{loading ? 'Mendaftar...' : 'Daftar Sekarang'}
</Button>
</div>
</form>
)}
</div>
);
}

View File

@ -0,0 +1,67 @@
'use client';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import VideoForm from '@/components/Forms/VideoForm';
import { updateVideo } from '@/app/actions/video';
import { useFetch } from '@/hooks/useFetch';
interface Video {
id: string;
title: string;
description: string;
videoUrl: string;
videoType: 'YOUTUBE' | 'LOCAL';
isPublic: boolean;
menuId?: string;
}
interface Menu {
id: string;
title: string;
}
export default function EditVideoPage() {
const params = useParams();
const id = params?.id as string;
const { data: video, loading } = useFetch<Video>(`/api/videos/${id}`);
const { data: menusData } = useFetch<Menu[]>('/api/menus');
const handleSubmit = async (data: any) => {
try {
await updateVideo(id, data);
} catch (error) {
console.error('Error updating video:', error);
throw error;
}
};
if (loading || !video || !menusData) {
return <div className="text-center text-gray-400 py-12">Loading...</div>;
}
const initialData = {
title: video.title,
description: video.description || '',
videoUrl: video.videoUrl,
videoType: video.videoType,
isPublic: video.isPublic,
menuId: video.menuId || '',
};
return (
<div className="max-w-2xl mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Edit Video</h1>
<p className="text-gray-400">Perbarui informasi video pembelajaran</p>
</div>
<VideoForm
initialData={initialData}
onSubmit={handleSubmit}
isEditing={true}
menus={menusData}
/>
</div>
);
}

View File

@ -0,0 +1,193 @@
/**
* File: page.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Video player page for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import ActionButton from '@/components/ActionButton';
import { useFetch } from '@/hooks/useFetch';
import { getYouTubeId } from '@/lib/utils';
interface VideoDetail {
id: string;
title: string;
description: string;
videoUrl: string;
viewCount: number;
createdAt: string;
uploader: {
user: {
name: string;
};
};
comments: Comment[];
}
interface Comment {
id: string;
content: string;
createdAt: string;
author: {
user: {
name: string;
};
};
}
export default function VideoDetailPage() {
const params = useParams();
const id = params?.id as string;
const { data: video, loading, refetch } = useFetch<VideoDetail>(`/api/videos/${id}`);
const [commentText, setCommentText] = useState('');
const [submittingComment, setSubmittingComment] = useState(false);
const handleSubmitComment = async () => {
if (!commentText.trim()) return;
setSubmittingComment(true);
try {
const response = await fetch('/api/comments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: commentText,
videoId: id,
}),
});
if (response.ok) {
setCommentText('');
refetch(); // Refresh comments
} else {
console.error('Failed to post comment');
}
} catch (error) {
console.error('Error posting comment:', error);
} finally {
setSubmittingComment(false);
}
};
const youtubeId = video?.videoUrl ? getYouTubeId(video.videoUrl) : null;
const embedUrl = youtubeId ? `https://www.youtube.com/embed/${youtubeId}` : video?.videoUrl;
if (loading) {
return <div className="text-center text-gray-400 py-12">Loading...</div>;
}
if (!video) {
return <div className="text-center text-gray-400 py-12">Video not found</div>;
}
return (
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex justify-between items-center">
<Link href="/dashboard/videos" className="text-gray-400 hover:text-white flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Kembali ke Daftar Video
</Link>
<Link href={`/dashboard/videos/${id}/edit`}>
<ActionButton variant="outline" size="sm">
Edit Video
</ActionButton>
</Link>
</div>
{/* Video Player */}
<div className="aspect-video bg-black rounded-xl overflow-hidden border border-white/10 shadow-2xl">
{youtubeId ? (
<iframe
src={embedUrl}
title={video.title}
className="w-full h-full"
allowFullScreen
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
></iframe>
) : (
<div className="flex items-center justify-center h-full text-gray-500">
Video format not supported or invalid URL
</div>
)}
</div>
{/* Video Info */}
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
<div className="flex justify-between items-start mb-4">
<div>
<h1 className="text-2xl font-bold text-white mb-2">{video.title}</h1>
<div className="flex items-center text-sm text-gray-400 space-x-4">
<span>Diunggah oleh <span className="text-gold-400">{video.uploader.user.name}</span></span>
<span></span>
<span>{new Date(video.createdAt).toLocaleDateString()}</span>
<span></span>
<span>{video.viewCount} kali ditonton</span>
</div>
</div>
</div>
<div className="prose prose-invert max-w-none">
<div className="text-gray-300 leading-relaxed" dangerouslySetInnerHTML={{ __html: video.description.replace(/\n/g, '<br/>') }}>
</div>
</div>
</div>
{/* Comments Section */}
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
<h3 className="text-lg font-semibold text-white mb-4">Komentar</h3>
<div className="space-y-4">
{video.comments?.map((comment) => (
<div key={comment.id} className="flex space-x-4">
<div className="w-10 h-10 bg-gold-400 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-navy-900 font-bold">{comment.author.user.name.charAt(0)}</span>
</div>
<div className="flex-1">
<div className="bg-white/5 rounded-lg p-3">
<div className="flex justify-between items-start mb-1">
<span className="font-medium text-white">{comment.author.user.name}</span>
<span className="text-xs text-gray-500">{new Date(comment.createdAt).toLocaleDateString()}</span>
</div>
<p className="text-gray-300 text-sm">{comment.content}</p>
</div>
</div>
</div>
))}
{(!video.comments || video.comments.length === 0) && (
<p className="text-gray-400 text-sm">Belum ada komentar.</p>
)}
{/* Comment Form */}
<div className="mt-6 flex space-x-4">
<div className="w-10 h-10 bg-gray-600 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-white font-bold">ME</span>
</div>
<div className="flex-1">
<textarea
rows={2}
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
className="w-full px-4 py-2 border rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-gold-400 placeholder-gray-500"
placeholder="Tulis komentar..."
></textarea>
<div className="mt-2 flex justify-end">
<ActionButton size="sm" onClick={handleSubmitComment} loading={submittingComment} disabled={!commentText.trim()}>
Kirim
</ActionButton>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,44 @@
import VideoForm from '@/components/Forms/VideoForm';
import { prisma } from '@/lib/prisma';
import { getLocale } from 'next-intl/server';
import { createVideo } from '@/app/actions/video';
export default async function NewVideoPage() {
const locale = await getLocale();
const menus = await prisma.menu.findMany({
where: { isActive: true },
include: {
parent: true,
},
orderBy: { createdAt: 'asc' },
});
const getLocalizedName = (json: any) => {
if (!json) return '';
return json[locale] || json['id'] || json['en'] || '';
};
const formattedMenus = menus.map((menu) => {
const name = getLocalizedName(menu.name);
const parentName = menu.parent ? getLocalizedName(menu.parent.name) : '';
return {
id: menu.id,
title: parentName ? `${parentName} > ${name}` : name,
};
});
// Sort by title for better UX
formattedMenus.sort((a, b) => a.title.localeCompare(b.title));
return (
<div className="max-w-2xl mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Upload Video Baru</h1>
<p className="text-gray-400">Tambahkan video pembelajaran baru ke koleksi Anda</p>
</div>
<VideoForm menus={formattedMenus} onSubmit={createVideo} />
</div>
);
}

View File

@ -0,0 +1,335 @@
/**
* File: page.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Video list page for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
'use client';
import { Link } from '@/i18n/routing';
import ActionButton from '@/components/ActionButton';
import { useFetch } from '@/hooks/useFetch';
import { useTranslations } from 'next-intl';
import { useState, useEffect } from 'react';
import { generateYoutubeEmbedUrl } from '@/lib/youtube';
import { PlayIcon } from 'lucide-react';
import { canUploadVideos } from '@/lib/admin';
interface VideoData extends Record<string, unknown> {
id: string;
title: string;
description: string;
videoType: 'YOUTUBE' | 'LOCAL';
viewCount: number;
createdAt: string;
isPublic: boolean;
uploaderId: string;
uploader: {
user: {
name: string;
};
};
}
export default function VideosPage() {
const t = useTranslations('Videos');
const { data: videos, loading } = useFetch<VideoData[]>('/api/videos');
const [userProfile, setUserProfile] = useState<any>(null);
useEffect(() => {
// Fetch user profile to check permissions
const fetchProfile = async () => {
try {
const response = await fetch('/api/user/profile', {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
setUserProfile(data.data);
}
} catch (error) {
console.error('Error fetching user profile:', error);
}
};
fetchProfile();
}, []);
const handleDelete = async (videoId: string) => {
if (!confirm('Apakah Anda yakin ingin menghapus video ini?')) {
return;
}
try {
const response = await fetch(`/api/videos/${videoId}`, {
method: 'DELETE',
credentials: 'include'
});
if (response.ok) {
// Refresh the videos list
window.location.reload();
} else {
alert('Gagal menghapus video');
}
} catch (error) {
console.error('Error deleting video:', error);
alert('Gagal menghapus video');
}
};
const renderVideoActions = (video: any, userProfile: any, handleDelete: Function, t: Function) => {
const canEdit = userProfile && (
userProfile.role?.name === 'ADMIN' ||
userProfile.role?.name === 'PENDIDIK' ||
video.uploaderId === userProfile.id
);
const canDelete = userProfile && (
userProfile.role?.name === 'ADMIN' ||
video.uploaderId === userProfile.id
);
return (
<div className="flex space-x-2">
<Link
href={`/dashboard/videos/${video.id}`}
className="inline-flex items-center px-3 py-2 bg-gold-500 hover:bg-gold-600 text-navy-900 rounded-lg text-sm font-medium transition-colors"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002 2z" />
</svg>
{t('actionView')}
</Link>
{canEdit && (
<Link
href={`/dashboard/videos/${video.id}/edit`}
className="inline-flex items-center px-3 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg text-sm font-medium transition-colors"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002 2v-4a1 1 0 011-1h7a1 1 0 011-1v-4a2 2 0 002-2H7a2 2 0 00-2 2v11a2 2 0 002-2z" />
</svg>
{t('actionEdit')}
</Link>
)}
{canDelete && (
<button
onClick={() => handleDelete(video.id)}
className="inline-flex items-center px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg text-sm font-medium transition-colors"
title={t('actionDelete')}
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0015-1.414l-7-7A2 2 0 003 7v10a2 2 0 002 2h8a2 2 0 002 2z" />
</svg>
{t('actionDelete')}
</button>
)}
</div>
);
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-white">{t('title')}</h1>
<p className="text-gray-400">{t('subtitle')}</p>
</div>
{canUploadVideos(userProfile) && (
<Link href="/dashboard/videos/new">
<ActionButton>
{t('uploadButton')}
</ActionButton>
</Link>
)}
</div>
{loading ? (
<div className="text-center text-gray-400 py-12">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gold-400"></div>
<p className="mt-4">{t('loading')}</p>
</div>
) : videos && videos.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{videos.map((video) => (
<div key={video.id} className="bg-white/10 backdrop-blur-sm rounded-xl border border-white/20 overflow-hidden hover:bg-white/15 transition-all duration-300 group">
{/* Video Thumbnail */}
<div className="aspect-video bg-gray-900 relative overflow-hidden">
{video.videoType === 'YOUTUBE' ? (
<iframe
src={generateYoutubeEmbedUrl(video.videoUrl)}
className="w-full h-full"
allowFullScreen
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-800">
<svg className="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002 2z" />
</svg>
<p className="text-xs text-gray-500 mt-2">Video Preview</p>
</div>
)}
{/* Play Button Overlay */}
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Link href={`/dashboard/videos/${video.id}`} className="text-white hover:text-gold-400 transition-colors">
<PlayIcon className="w-12 h-12" />
</Link>
</div>
{/* Status Badge */}
<div className="absolute top-4 right-4">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
video.isPublic
? 'bg-green-500 text-white'
: 'bg-yellow-500 text-white'
}`}>
{video.isPublic ? 'Public' : 'Private'}
</span>
</div>
{/* Play Button Overlay */}
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Link href={`/dashboard/videos/${video.id}`} className="text-white hover:text-gold-400 transition-colors">
<PlayIcon className="w-12 h-12" />
</Link>
</div>
{/* Status Badge */}
<div className="absolute top-4 right-4">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
video.isPublic
? 'bg-green-500 text-white'
: 'bg-yellow-500 text-white'
}`}>
{video.isPublic ? 'Public' : 'Private'}
</span>
</div>
</div>
{/* Video Info */}
<div className="p-6">
<div className="flex justify-between items-start mb-3">
<h3 className="text-lg font-semibold text-white group-hover:text-gold-400 transition-colors line-clamp-2">
{video.title}
</h3>
<div className="flex items-center space-x-2 text-sm text-gray-400">
<span className={`px-2 py-1 rounded text-xs font-medium ${
video.videoType === 'YOUTUBE'
? 'bg-red-900/50 text-red-200'
: 'bg-blue-900/50 text-blue-200'
}`}>
{video.videoType}
</span>
<span className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0l-3 3m0 0a3 3 0 016 0l3-3m6 0a3 3 0 016 0l-3 3" />
</svg>
{video.viewCount || 0} views
</span>
</div>
</div>
<p className="text-gray-300 text-sm line-clamp-3 mb-4">
{video.description}
</p>
{/* Video Actions */}
<div className="flex items-center justify-between pt-4 border-t border-gray-700">
<div className="flex items-center space-x-2 text-sm text-gray-400">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0v4a4 4 0 014 0H6a4 4 0 00-4 4v4a4 4 0 014 0h8a4 4 0 014 0z" />
</svg>
{new Date(video.createdAt).toLocaleDateString('id-ID', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</div>
<div className="flex space-x-2">
<Link
href={`/dashboard/videos/${video.id}`}
className="inline-flex items-center px-3 py-2 bg-gold-500 hover:bg-gold-600 text-navy-900 rounded-lg text-sm font-medium transition-colors"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0l-3 3m0 0a3 3 0 016 0l3-3m6 0a3 3 0 016 0l-3 3" />
</svg>
{t('actionView')}
</Link>
{(() => {
const canEdit = userProfile && (
userProfile.role?.name === 'ADMIN' ||
userProfile.role?.name === 'PENDIDIK' ||
video.uploaderId === userProfile.id
);
const canDelete = userProfile && (
userProfile.role?.name === 'ADMIN' ||
video.uploaderId === userProfile.id
);
if (canEdit) {
return (
<Link
href={`/dashboard/videos/${video.id}/edit`}
className="inline-flex items-center px-3 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg text-sm font-medium transition-colors"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-4a1 1 0 011-1h7a1 1 0 011 1v4a1 1 0 011-1h7a1 1 0 011-1v-4a2 2 0 00-2 2H7a2 2 0 00-2 2v11a2 2 0 002 2z" />
</svg>
{t('actionEdit')}
</Link>
);
}
if (canDelete) {
return (
<button
onClick={() => handleDelete(video.id)}
className="inline-flex items-center px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg text-sm font-medium transition-colors"
title={t('actionDelete')}
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0015-1.414l-7-7A2 2 0 003 7v10a2 2 0 002 2h8a2 2 0 002 2v10a2 2 0 002-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{t('actionDelete')}
</button>
);
}
return null;
})()}
</div>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<div className="text-gray-400 mb-4">
<svg className="w-16 h-16 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002 2z" />
</svg>
<h3 className="text-xl font-semibold text-white mb-2">{t('noVideos')}</h3>
<p className="text-gray-400 mb-6">{t('noVideosDesc')}</p>
{canUploadVideos(userProfile) && (
<Link href="/dashboard/videos/new">
<ActionButton>
{t('uploadFirst')}
</ActionButton>
</Link>
)}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,10 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return <div className="min-h-screen">
<div className="floating-background"></div>
{children}
</div>;
}

View File

@ -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);
@ -22,17 +26,22 @@ export default function SignIn() {
e.preventDefault();
setIsLoading(true);
// TODO: Implement actual sign in logic
setTimeout(() => {
setIsLoading(false);
await authClient.signIn.email({
email,
password,
}, {
onSuccess: () => {
router.push('/dashboard');
}, 1000);
},
onError: (ctx) => {
alert(ctx.error.message);
setIsLoading(false);
}
});
};
return (
<div className="min-h-screen bg-gradient-to-br from-navy-900 via-navy-800 to-gray-900 flex items-center justify-center px-4">
<div className="absolute inset-0 bg-black/20"></div>
<div className="min-h-screen flex items-center justify-center px-4">
<div className="relative z-10 w-full max-w-md">
<div className="bg-white/10 backdrop-blur-lg rounded-2xl p-8 border border-white/20">
{/* Logo */}
@ -40,54 +49,54 @@ export default function SignIn() {
<h1 className="text-3xl font-bold text-white mb-2">
kreati<span className="text-gold-400">Vortex</span>
</h1>
<p className="text-gray-300">Masuk ke akun Anda</p>
<p className="text-gray-300">{t('signInTitle')}</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
Email
</label>
<input
<div className="grid gap-2">
<Label htmlFor="email" className="text-gray-300">
{t('emailLabel')}
</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gold-400 focus:border-transparent"
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400 focus-visible:ring-gold-400"
placeholder="nama@email.com"
required
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-2">
Password
</label>
<input
<div className="grid gap-2">
<Label htmlFor="password" className="text-gray-300">
{t('passwordLabel')}
</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gold-400 focus:border-transparent"
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400 focus-visible:ring-gold-400"
placeholder="••••••••"
required
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="flex items-center space-x-2">
<input
id="remember"
type="checkbox"
className="h-4 w-4 bg-white/10 border-white/20 rounded focus:ring-gold-400"
/>
<label htmlFor="remember" className="ml-2 block text-sm text-gray-300">
Ingat saya
</label>
<Label htmlFor="remember" className="text-gray-300 font-normal">
{t('rememberMe')}
</Label>
</div>
<Link href="/auth/forgot-password" className="text-sm text-gold-400 hover:text-gold-300">
Lupa password?
{t('forgotPassword')}
</Link>
</div>
@ -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')}
</button>
</form>
{/* Sign up link */}
<div className="mt-8 text-center">
<p className="text-gray-300">
Belum punya akun?{' '}
{t('noAccount')}{' '}
<Link href="/auth/signup" className="text-gold-400 hover:text-gold-300 font-medium">
Daftar sekarang
{t('registerNow')}
</Link>
</p>
</div>

View File

@ -0,0 +1,180 @@
'use client';
import { useState } from 'react';
import { Link, useRouter } from '@/i18n/routing';
import { authClient } from '@/lib/auth-client';
import { useTranslations } from 'next-intl';
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export default function SignUp() {
const t = useTranslations('Auth');
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: '',
role: 'CALON_PENDIDIK' as 'CALON_PENDIDIK' | 'UMUM',
});
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (formData.password !== formData.confirmPassword) {
alert(t('passwordMismatch'));
return;
}
setIsLoading(true);
await authClient.signUp.email({
email: formData.email,
password: formData.password,
name: formData.name,
// role: formData.role, // TODO: Handle role in metadata or post-signup
}, {
onSuccess: () => {
router.push('/dashboard');
},
onError: (ctx) => {
alert(ctx.error.message);
setIsLoading(false);
}
});
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
return (
<div className="min-h-screen flex items-center justify-center px-4 py-12">
<div className="relative z-10 w-full max-w-md">
<div className="bg-white/10 backdrop-blur-lg rounded-2xl p-8 border border-white/20">
{/* Logo */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-white mb-2">
kreati<span className="text-gold-400">Vortex</span>
</h1>
<p className="text-gray-300">{t('signUpTitle')}</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="name" className="text-gray-300">
{t('nameLabel')}
</Label>
<Input
id="name"
name="name"
type="text"
value={formData.name}
onChange={handleChange}
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400 focus-visible:ring-gold-400"
placeholder="John Doe"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="email" className="text-gray-300">
{t('emailLabel')}
</Label>
<Input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400 focus-visible:ring-gold-400"
placeholder="nama@email.com"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="role" className="text-gray-300">
{t('roleLabel')}
</Label>
<Select
value={formData.role}
onValueChange={(value) => setFormData({ ...formData, role: value as any })}
>
<SelectTrigger className="bg-white/10 border-white/20 text-white focus:ring-gold-400">
<SelectValue placeholder={t('roleLabel')} />
</SelectTrigger>
<SelectContent className="bg-navy-800 border-white/20 text-white">
<SelectItem value="CALON_PENDIDIK">{t('roleEducator')}</SelectItem>
<SelectItem value="UMUM">{t('roleGeneral')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="password" className="text-gray-300">
{t('passwordLabel')}
</Label>
<Input
id="password"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400 focus-visible:ring-gold-400"
placeholder="••••••••"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="confirmPassword" className="text-gray-300">
{t('confirmPasswordLabel')}
</Label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={handleChange}
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400 focus-visible:ring-gold-400"
placeholder="••••••••"
required
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-3 px-4 bg-gold-500 hover:bg-gold-400 text-navy-900 font-semibold rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? t('registering') : t('signUpButton')}
</button>
</form>
{/* Sign in link */}
<div className="mt-8 text-center">
<p className="text-gray-300">
{t('haveAccount')}{' '}
<Link href="/auth/signin" className="text-gold-400 hover:text-gold-300 font-medium">
{t('signInNow')}
</Link>
</p>
</div>
</div>
</div>
</div>
);
}

61
app/[locale]/layout.tsx Normal file
View File

@ -0,0 +1,61 @@
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing } from '@/i18n/routing';
import type { Metadata } from "next";
import { Geist, Geist_Mono, Kaisei_Decol } from "next/font/google";
import "../globals.css";
const kaiseiDecol = Kaisei_Decol({
variable: "--font-kaisei-decol",
subsets: ["latin"],
weight: ["400", "500", "700"],
});
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "KreatiVortex",
description: "Indonesian traditional dance learning platform",
};
export default async function RootLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
// Ensure that the incoming `locale` is valid
if (!routing.locales.includes(locale as any)) {
notFound();
}
// Providing all messages to the client
// side is the easiest way to get started
const messages = await getMessages();
return (
<html lang={locale}>
<body
className={`${geistSans.variable} ${geistMono.variable} ${kaiseiDecol.variable} antialiased`}
>
<NextIntlClientProvider messages={messages}>
<div className="min-h-screen">
{children}
</div>
</NextIntlClientProvider>
</body>
</html>
);
}

90
app/[locale]/page.tsx Normal file
View File

@ -0,0 +1,90 @@
/**
* File: page.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Landing page for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
import { Button } from "@/components/ui/button";
import { Link } from "@/i18n/routing";
import { getTranslations } from 'next-intl/server';
export default async function Home() {
const t = await getTranslations('HomePage');
return (
<div className="min-h-screen text-white">
<div className="floating-background"></div>
<div className="container mx-auto fixed z-10 top-0 left-0 right-0 flex justify-between items-center py-6">
<div className="flex items-center">
<Link href="/" locale="en" className="hover:text-gray-300 transition-colors">
EN
</Link>
<span className="mx-2">|</span>
<Link href="/" locale="id" className="hover:text-gray-300 transition-colors">
ID
</Link>
</div>
<div className="flex items-center gap-4">
<Link href="/auth/signin">
<Button variant="ghost" size="lg" className="uppercase">
{t('login')}
</Button>
</Link>
<Link href="/auth/signup">
<Button variant="outline" size="lg" className="uppercase">
{t('registration')}
</Button>
</Link>
</div>
</div>
{/* Main content */}
<div className="relative flex min-h-screen flex-col items-start justify-center px-4">
<div className="w-3/4 text-center">
{/* Logo/Title */}
<div className="mb-12">
<h1 className="text-5xl md:text-8xl text-gray-300 mb-4">
{t('title')}
</h1>
<p className="uppercase text-xl md:text-2xl text-gray-300">
{t('subtitle')}
</p>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<Link
href="/auth/signup">
<Button variant="glass" size="xl" className="uppercase">
{t('startNow')}
</Button>
</Link>
<div className="flex flex-col sm:flex-row gap-2 justify-center items-center mt-4">
<Link
href="/auth/signin">
<Button variant="outline" size="lg" className="uppercase text-sm">
Login
</Button>
</Link>
<span className="text-gray-400 text-sm">atau</span>
<Link
href="/auth/signup">
<Button variant="outline" size="lg" className="uppercase text-sm">
Daftar sebagai Pendidik
</Button>
</Link>
<Link
href="/auth/signup">
<Button variant="outline" size="lg" className="uppercase text-sm">
Daftar sebagai Calon Pendidik
</Button>
</Link>
</div>
</div>
</div>
</div>
</div>
);
}

153
app/actions/video.ts Normal file
View File

@ -0,0 +1,153 @@
'use server';
import { prisma } from '@/lib/prisma';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
const VideoSchema = z.object({
title: z.string().min(1),
description: z.string().optional(),
videoUrl: z.string().min(1),
videoType: z.enum(['YOUTUBE', 'LOCAL']),
isPublic: z.boolean(),
menuId: z.string().optional(),
});
export async function createVideo(data: {
title: string;
description: string;
videoUrl: string;
videoType: 'YOUTUBE' | 'LOCAL';
isPublic: boolean;
menuId?: string;
}) {
const session = await auth.api.getSession({
headers: await headers()
});
if (!session?.user) {
throw new Error('Unauthorized');
}
// Get user profile
const userProfile = await prisma.userProfile.findUnique({
where: { userId: session.user.id },
});
if (!userProfile) {
throw new Error('User profile not found. Please complete your profile first.');
}
const validatedData = VideoSchema.parse(data);
let thumbnailUrl = null;
if (validatedData.videoType === 'YOUTUBE') {
const videoId = extractYoutubeId(validatedData.videoUrl);
if (videoId) {
thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
}
}
await prisma.video.create({
data: {
...validatedData,
uploaderId: userProfile.id,
thumbnailUrl,
createdBy: session.user.id,
updatedBy: session.user.id,
},
});
revalidatePath('/dashboard/videos');
if (validatedData.menuId) {
// We need to find the slug to revalidate the page, but revalidating the layout might be enough or we can just revalidate the specific page if we fetch the menu.
// For now, let's revalidate the dashboard layout to be safe or just the videos page.
// Actually, we can fetch the menu to get the slug.
const menu = await prisma.menu.findUnique({ where: { id: validatedData.menuId } });
if (menu) {
revalidatePath(`/dashboard/pages/${menu.slug}`);
}
}
redirect('/dashboard/videos');
}
export async function updateVideo(id: string, data: {
title: string;
description: string;
videoUrl: string;
videoType: 'YOUTUBE' | 'LOCAL';
isPublic: boolean;
menuId?: string;
}) {
const session = await auth.api.getSession({
headers: await headers()
});
if (!session?.user) {
throw new Error('Unauthorized');
}
// Get user profile
const userProfile = await prisma.userProfile.findUnique({
where: { userId: session.user.id },
});
if (!userProfile) {
throw new Error('User profile not found. Please complete your profile first.');
}
// Check if user owns the video
const existingVideo = await prisma.video.findUnique({
where: { id },
});
if (!existingVideo || existingVideo.uploaderId !== userProfile.id) {
throw new Error('Video not found or you do not have permission to edit it');
}
const validatedData = VideoSchema.parse(data);
let thumbnailUrl = existingVideo.thumbnailUrl;
if (validatedData.videoType === 'YOUTUBE') {
const videoId = extractYoutubeId(validatedData.videoUrl);
if (videoId) {
thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
}
}
await prisma.video.update({
where: { id },
data: {
...validatedData,
thumbnailUrl,
updatedBy: session.user.id,
},
});
revalidatePath('/dashboard/videos');
revalidatePath(`/dashboard/videos/${id}`);
if (validatedData.menuId) {
const menu = await prisma.menu.findUnique({ where: { id: validatedData.menuId } });
if (menu) {
revalidatePath(`/dashboard/pages/${menu.slug}`);
}
}
if (existingVideo.menuId && existingVideo.menuId !== validatedData.menuId) {
const oldMenu = await prisma.menu.findUnique({ where: { id: existingVideo.menuId } });
if (oldMenu) {
revalidatePath(`/dashboard/pages/${oldMenu.slug}`);
}
}
redirect(`/dashboard/videos/${id}`);
}
function extractYoutubeId(url: string) {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
const match = url.match(regExp);
return (match && match[2].length === 11) ? match[2] : null;
}

View File

@ -0,0 +1,88 @@
/**
* File: route.ts
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Assignment API endpoints for single assignment operations
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(
_: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const assignment = await prisma.assignment.findUnique({
where: { id },
include: {
educator: {
include: {
user: {
select: {
name: true,
image: true,
},
},
},
},
class: {
select: {
name: true,
},
},
submissions: {
include: {
student: {
include: {
user: {
select: {
name: true,
image: true,
},
},
},
},
},
},
},
});
if (!assignment) {
return NextResponse.json(
{ success: false, message: 'Assignment not found' },
{ status: 404 }
);
}
return NextResponse.json({ success: true, data: assignment });
} catch (error) {
console.error('Error fetching assignment:', error);
return NextResponse.json(
{ success: false, message: 'Failed to fetch assignment' },
{ status: 500 }
);
}
}
export async function DELETE(
_: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
await prisma.assignment.delete({
where: { id },
});
return NextResponse.json({ success: true, message: 'Assignment deleted' });
} catch (error) {
console.error('Error deleting assignment:', error);
return NextResponse.json(
{ success: false, message: 'Failed to delete assignment' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,101 @@
/**
* File: route.ts
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Assignment API endpoints for listing and creating assignments
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const classId = searchParams.get('classId');
const whereClause: any = { isActive: true };
if (classId) {
whereClause.classId = classId;
}
const assignments = await prisma.assignment.findMany({
where: whereClause,
include: {
educator: {
include: {
user: {
select: {
name: true,
image: true,
},
},
},
},
class: {
select: {
name: true,
},
},
},
orderBy: {
dueDate: 'asc',
},
});
return NextResponse.json({ success: true, data: assignments });
} catch (error) {
console.error('Error fetching assignments:', error);
return NextResponse.json(
{ success: false, message: 'Failed to fetch assignments' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { title, description, classId, dueDate, maxScore } = body;
// Validation
if (!title || !description || !classId || !dueDate) {
return NextResponse.json(
{ success: false, message: 'All fields are required' },
{ status: 400 }
);
}
// Mock educator ID for now
const educator = await prisma.userProfile.findFirst();
if (!educator) {
return NextResponse.json(
{ success: false, message: 'No user profile found' },
{ status: 400 }
);
}
const assignment = await prisma.assignment.create({
data: {
title,
description,
classId,
dueDate: new Date(dueDate),
maxScore: Number(maxScore),
educatorId: educator.id,
createdBy: educator.id,
updatedBy: educator.id,
},
});
return NextResponse.json({ success: true, data: assignment });
} catch (error) {
console.error('Error creating assignment:', error);
return NextResponse.json(
{ success: false, message: 'Failed to create assignment' },
{ status: 500 }
);
}
}

View File

@ -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' });
}
export const { GET, POST } = toNextJsHandler(auth.handler);

View File

@ -0,0 +1,141 @@
/**
* File: route.ts
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Role registration API for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
import { getOrCreateUserProfile } from '@/lib/profile';
export async function POST(request: Request) {
try {
const session = await auth.api.getSession({
headers: await headers()
});
if (!session?.user) {
return NextResponse.json(
{ success: false, message: 'Unauthorized' },
{ status: 401 }
);
}
// Get or create user profile
const userProfile = await getOrCreateUserProfile(session.user.id);
// Check if user already has a specific role (not 'UMUM')
if (userProfile.role.name !== 'UMUM') {
return NextResponse.json(
{ success: false, message: 'User already has a role' },
{ status: 400 }
);
}
const body = await request.json();
const { role, institution, className, teachingLevel, purpose, nim } = body;
// Get role from database
const targetRole = await prisma.userRole.findUnique({
where: { name: role }
});
if (!targetRole) {
return NextResponse.json(
{ success: false, message: 'Invalid role' },
{ status: 400 }
);
}
// Validate required fields based on role
if (role === 'PENDIDIK') {
if (!institution || !className || !teachingLevel || !purpose) {
return NextResponse.json(
{ success: false, message: 'All fields are required for educator registration' },
{ status: 400 }
);
}
} else if (role === 'CALON_PENDIDIK') {
if (!institution || !className || !nim) {
return NextResponse.json(
{ success: false, message: 'All fields are required for student registration' },
{ status: 400 }
);
}
// Check if class exists by code (not ID)
const classExists = await prisma.class.findUnique({
where: { code: className }
});
if (!classExists) {
return NextResponse.json(
{ success: false, message: 'Class code not found. Please check the code with your educator.' },
{ status: 404 }
);
}
}
// Update user role and additional information
const updatedProfile = await prisma.userProfile.update({
where: { id: userProfile.id },
data: {
roleId: targetRole.id,
nim: nim || null,
bio: role === 'PENDIDIK'
? `Institution: ${institution}\nTeaching Level: ${teachingLevel}\nPurpose: ${purpose}`
: `Institution: ${institution}\nNIM: ${nim}`
}
});
// If registering as student, add to class
if (role === 'CALON_PENDIDIK') {
// Find the class by code to get the ID
const targetClass = await prisma.class.findUnique({
where: { code: className }
});
if (targetClass) {
await prisma.classMember.create({
data: {
classId: targetClass.id,
studentId: updatedProfile.id,
joinedAt: new Date()
}
});
}
}
// If registering as educator, create the class
if (role === 'PENDIDIK') {
await prisma.class.create({
data: {
name: className,
description: `Class created by ${updatedProfile.id}`,
code: `CLASS-${Date.now()}`,
educatorId: updatedProfile.id,
createdBy: updatedProfile.id,
updatedBy: updatedProfile.id,
isActive: true
}
});
}
return NextResponse.json({
success: true,
message: `Successfully registered as ${role}`,
data: updatedProfile
});
} catch (error) {
console.error('Error registering role:', error);
return NextResponse.json(
{ success: false, message: 'Failed to register role' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,136 @@
/**
* File: route.ts
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Class API endpoints for single class operations
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
import { getOrCreateUserProfile } from '@/lib/profile';
export async function GET(
_: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth.api.getSession({
headers: await headers()
});
if (!session?.user) {
return NextResponse.json(
{ success: false, message: 'Unauthorized' },
{ status: 401 }
);
}
// Get or create user profile
const userProfile = await getOrCreateUserProfile(session.user.id);
const { id } = await params;
// Build where clause based on user role
let whereClause: any = {
id,
isActive: true,
};
if (userProfile.role.name === 'ADMIN' || userProfile.role.name === 'PENDIDIK') {
// Admins and Educators can see any active class
whereClause.OR = [
{ educatorId: userProfile.id },
{
members: {
some: {
studentId: userProfile.id
}
}
}
];
} else if (userProfile.role.name === 'CALON_PENDIDIK') {
// Students can only see classes they're enrolled in
whereClause.members = {
some: {
studentId: userProfile.id
}
};
} else {
// UMUM role cannot access any class
return NextResponse.json(
{ success: false, message: 'Access denied' },
{ status: 403 }
);
}
const classData = await prisma.class.findUnique({
where: whereClause,
include: {
educator: {
include: {
user: {
select: {
name: true,
image: true,
},
},
},
},
members: {
include: {
student: {
include: {
user: {
select: {
name: true,
image: true,
},
},
},
},
},
},
videos: true,
assignments: true,
},
});
if (!classData) {
return NextResponse.json(
{ success: false, message: 'Class not found or access denied' },
{ status: 404 }
);
}
return NextResponse.json({ success: true, data: classData });
} catch (error) {
console.error('Error fetching class:', error);
return NextResponse.json(
{ success: false, message: 'Failed to fetch class' },
{ status: 500 }
);
}
}
export async function DELETE(
_: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
await prisma.class.delete({
where: { id },
});
return NextResponse.json({ success: true, message: 'Class deleted' });
} catch (error) {
console.error('Error deleting class:', error);
return NextResponse.json(
{ success: false, message: 'Failed to delete class' },
{ status: 500 }
);
}
}

177
app/api/classes/route.ts Normal file
View File

@ -0,0 +1,177 @@
/**
* File: route.ts
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Class API endpoints for listing and creating classes
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
import { getOrCreateUserProfile } from '@/lib/profile';
export async function GET(request: Request) {
try {
const session = await auth.api.getSession({
headers: await headers()
});
if (!session?.user) {
return NextResponse.json(
{ success: false, message: 'Unauthorized' },
{ status: 401 }
);
}
// Get or create user profile
const userProfile = await getOrCreateUserProfile(session.user.id);
const { searchParams } = new URL(request.url);
const educatorId = searchParams.get('educatorId');
// If educatorId is provided and matches current user, show classes they created
if (educatorId && educatorId === userProfile.id) {
const classes = await prisma.class.findMany({
where: { educatorId: userProfile.id, isActive: true },
include: {
educator: {
include: {
user: {
select: {
name: true,
image: true,
},
},
},
},
_count: {
select: { members: true },
},
},
orderBy: {
createdAt: 'desc',
},
});
return NextResponse.json({ success: true, data: classes });
}
// Otherwise, show classes based on user role
let classes: any[] = [];
if (userProfile.role.name === 'UMUM') {
// General users can't see any classes
classes = [];
} else if (userProfile.role.name === 'CALON_PENDIDIK') {
// Students can only see classes they've joined
classes = await prisma.class.findMany({
where: {
isActive: true,
members: {
some: {
studentId: userProfile.id
}
}
},
include: {
educator: {
include: {
user: {
select: {
name: true,
image: true,
},
},
},
},
_count: {
select: { members: true },
},
},
orderBy: {
createdAt: 'desc',
},
});
} else {
// Educators and Admins can see all classes
classes = await prisma.class.findMany({
where: {
isActive: true,
},
include: {
educator: {
include: {
user: {
select: {
name: true,
image: true,
},
},
},
},
_count: {
select: { members: true },
},
},
orderBy: {
createdAt: 'desc',
},
});
}
return NextResponse.json({ success: true, data: classes });
} catch (error) {
console.error('Error fetching classes:', error);
return NextResponse.json(
{ success: false, message: 'Failed to fetch classes' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { name, description, code, maxStudents } = body;
// Validation
if (!name || !code) {
return NextResponse.json(
{ success: false, message: 'Name and Code are required' },
{ status: 400 }
);
}
// Mock educator ID for now
const educator = await prisma.userProfile.findFirst();
if (!educator) {
return NextResponse.json(
{ success: false, message: 'No user profile found' },
{ status: 400 }
);
}
const newClass = await prisma.class.create({
data: {
name,
description,
code,
maxStudents: maxStudents ? Number(maxStudents) : null,
educatorId: educator.id,
createdBy: educator.id,
updatedBy: educator.id,
},
});
return NextResponse.json({ success: true, data: newClass });
} catch (error) {
console.error('Error creating class:', error);
return NextResponse.json(
{ success: false, message: 'Failed to create class' },
{ status: 500 }
);
}
}

143
app/api/comments/route.ts Normal file
View File

@ -0,0 +1,143 @@
/**
* File: route.ts
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Comments API with file attachment support
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
import { getOrCreateUserProfile } from '@/lib/profile';
export async function POST(request: Request) {
try {
const session = await auth.api.getSession({
headers: await headers()
});
if (!session?.user) {
return NextResponse.json(
{ success: false, message: 'Unauthorized' },
{ status: 401 }
);
}
// Get or create user profile
const userProfile = await getOrCreateUserProfile(session.user.id);
const body = await request.json();
const { content, videoId, forumPostId, parentId, attachments } = body;
// Validation
if (!content && (!attachments || attachments.length === 0)) {
return NextResponse.json(
{ success: false, message: 'Content or attachment is required' },
{ status: 400 }
);
}
if (!videoId && !forumPostId) {
return NextResponse.json(
{ success: false, message: 'Video ID or Forum Post ID is required' },
{ status: 400 }
);
}
const comment = await prisma.comment.create({
data: {
content: content || '',
videoId: videoId || null,
forumPostId: forumPostId || null,
parentId: parentId || null,
authorId: userProfile.id,
updatedBy: userProfile.id,
attachments: attachments || [],
},
include: {
author: {
include: {
user: {
select: {
name: true,
image: true,
},
},
},
},
},
});
return NextResponse.json({ success: true, data: comment });
} catch (error) {
console.error('Error creating comment:', error);
return NextResponse.json(
{ success: false, message: 'Failed to create comment' },
{ status: 500 }
);
}
}
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const videoId = searchParams.get('videoId');
const forumPostId = searchParams.get('forumPostId');
const whereClause: any = { isActive: true };
if (videoId) {
whereClause.videoId = videoId;
}
if (forumPostId) {
whereClause.forumPostId = forumPostId;
}
const comments = await prisma.comment.findMany({
where: whereClause,
include: {
author: {
include: {
user: {
select: {
name: true,
image: true,
},
},
},
},
replies: {
include: {
author: {
include: {
user: {
select: {
name: true,
image: true,
},
},
},
},
},
orderBy: {
createdAt: 'asc',
},
},
},
orderBy: {
createdAt: 'asc',
},
});
return NextResponse.json({ success: true, data: comments });
} catch (error) {
console.error('Error fetching comments:', error);
return NextResponse.json(
{ success: false, message: 'Failed to fetch comments' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,81 @@
/**
* File: route.ts
* Created by: AI Assistant
* Date: 2025-12-01
* Purpose: Forum posts API endpoints for creating posts within forums
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const { title, content } = body;
// Validation
if (!content) {
return NextResponse.json(
{ success: false, message: 'Content is required' },
{ status: 400 }
);
}
// Mock author for now - in real app, get from session
const author = await prisma.userProfile.findFirst();
if (!author) {
return NextResponse.json(
{ success: false, message: 'No user profile found' },
{ status: 400 }
);
}
// Check if forum exists
const forum = await prisma.forum.findUnique({
where: { id },
});
if (!forum) {
return NextResponse.json(
{ success: false, message: 'Forum not found' },
{ status: 404 }
);
}
const post = await prisma.forumPost.create({
data: {
title: title || `Re: ${forum.title}`,
content,
forumId: id,
authorId: author.id,
updatedBy: author.id,
},
include: {
author: {
include: {
user: {
select: {
name: true,
image: true,
},
},
},
},
},
});
return NextResponse.json({ success: true, data: post });
} catch (error) {
console.error('Error creating forum post:', error);
return NextResponse.json(
{ success: false, message: 'Failed to create forum post' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,89 @@
/**
* File: route.ts
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Forum API endpoints for single forum operations
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(
_: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const forum = await prisma.forum.findUnique({
where: { id },
include: {
creator: {
include: {
user: {
select: {
name: true,
image: true,
},
},
},
},
posts: {
include: {
author: {
include: {
user: {
select: {
name: true,
image: true,
},
},
},
},
_count: {
select: { comments: true },
},
},
orderBy: {
createdAt: 'desc',
},
},
},
});
if (!forum) {
return NextResponse.json(
{ success: false, message: 'Forum not found' },
{ status: 404 }
);
}
return NextResponse.json({ success: true, data: forum });
} catch (error) {
console.error('Error fetching forum:', error);
return NextResponse.json(
{ success: false, message: 'Failed to fetch forum' },
{ status: 500 }
);
}
}
export async function DELETE(
_: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
await prisma.forum.delete({
where: { id },
});
return NextResponse.json({ success: true, message: 'Forum deleted' });
} catch (error) {
console.error('Error deleting forum:', error);
return NextResponse.json(
{ success: false, message: 'Failed to delete forum' },
{ status: 500 }
);
}
}

123
app/api/forums/route.ts Normal file
View File

@ -0,0 +1,123 @@
/**
* File: route.ts
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Forum API endpoints for listing and creating forums
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
import { getOrCreateUserProfile } from '@/lib/profile';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const type = searchParams.get('type');
const classId = searchParams.get('classId');
const whereClause: any = { isActive: true };
if (type) {
whereClause.type = type;
}
if (classId) {
whereClause.classId = classId;
}
const forums = await prisma.forum.findMany({
where: whereClause,
include: {
creator: {
include: {
user: {
select: {
name: true,
image: true,
},
},
},
},
_count: {
select: { posts: true },
},
},
orderBy: {
updatedAt: 'desc',
},
});
return NextResponse.json({ success: true, data: forums });
} catch (error) {
console.error('Error fetching forums:', error);
return NextResponse.json(
{ success: false, message: 'Failed to fetch forums' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await auth.api.getSession({
headers: await headers()
});
if (!session?.user) {
return NextResponse.json(
{ success: false, message: 'Unauthorized' },
{ status: 401 }
);
}
// Get or create user profile
const userProfile = await getOrCreateUserProfile(session.user.id);
const body = await request.json();
const { title, description, type, classId, attachments } = body;
// Validation
if (!title) {
return NextResponse.json(
{ success: false, message: 'Title is required' },
{ status: 400 }
);
}
const forum = await prisma.forum.create({
data: {
title,
description,
type: type || 'GENERAL',
classId,
createdBy: userProfile.id,
updatedBy: userProfile.id,
},
});
// Create the first post in the forum
if (description || attachments) {
await prisma.forumPost.create({
data: {
title,
content: description || '',
forumId: forum.id,
authorId: userProfile.id,
updatedBy: userProfile.id,
attachments: attachments || [],
},
});
}
return NextResponse.json({ success: true, data: forum });
} catch (error) {
console.error('Error creating forum:', error);
return NextResponse.json(
{ success: false, message: 'Failed to create forum' },
{ status: 500 }
);
}
}

42
app/api/menus/route.ts Normal file
View File

@ -0,0 +1,42 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getLocale } from 'next-intl/server';
export async function GET() {
try {
const locale = await getLocale();
const menus = await prisma.menu.findMany({
where: { isActive: true },
include: {
parent: true,
},
orderBy: { createdAt: 'asc' },
});
const getLocalizedName = (json: any) => {
if (!json) return '';
return json[locale] || json['id'] || json['en'] || '';
};
const formattedMenus = menus.map((menu) => {
const name = getLocalizedName(menu.name);
const parentName = menu.parent ? getLocalizedName(menu.parent.name) : '';
return {
id: menu.id,
title: parentName ? `${parentName} > ${name}` : name,
};
});
// Sort by title for better UX
formattedMenus.sort((a, b) => a.title.localeCompare(b.title));
return NextResponse.json({ success: true, data: formattedMenus });
} catch (error) {
console.error('Error fetching menus:', error);
return NextResponse.json(
{ success: false, message: 'Failed to fetch menus' },
{ status: 500 }
);
}
}

112
app/api/upload/route.ts Normal file
View File

@ -0,0 +1,112 @@
/**
* File: route.ts
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: File upload API endpoint
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
import { NextResponse } from 'next/server';
import { promises as fs } from 'fs';
import { join } from 'path';
import { randomBytes } from 'crypto';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
function generateId(): string {
return randomBytes(16).toString('hex');
}
function isAllowedFileType(file: File): boolean {
const allowedTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'image/jpeg',
'image/png',
'image/gif'
];
return allowedTypes.includes(file.type);
}
export async function POST(request: Request) {
try {
const session = await auth.api.getSession({
headers: await headers()
});
if (!session?.user) {
return NextResponse.json(
{ success: false, message: 'Unauthorized' },
{ status: 401 }
);
}
const formData = await request.formData();
const file = formData.get('file') as File;
const folder = (formData.get('folder') as string) || 'uploads';
if (!file) {
return NextResponse.json(
{ success: false, message: 'No file provided' },
{ status: 400 }
);
}
// Validation
if (!isAllowedFileType(file)) {
return NextResponse.json(
{ success: false, message: 'File type not allowed' },
{ status: 400 }
);
}
if (file.size > 10 * 1024 * 1024) { // 10MB limit
return NextResponse.json(
{ success: false, message: 'File too large' },
{ status: 400 }
);
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const fileId = generateId();
const fileExtension = file.name.split('.').pop();
const fileName = `${fileId}.${fileExtension}`;
// Create uploads directory if it doesn't exist
const uploadsDir = join(process.cwd(), 'public', folder);
try {
await fs.mkdir(uploadsDir, { recursive: true });
} catch (error) {
// Directory already exists
}
// Write file to public/uploads directory
const filePath = join(uploadsDir, fileName);
await fs.writeFile(filePath, buffer);
const uploadedFile = {
id: fileId,
name: fileName,
originalName: file.name,
mimeType: file.type,
size: file.size,
url: `/${folder}/${fileName}`
};
return NextResponse.json({
success: true,
data: uploadedFile
});
} catch (error) {
console.error('Error uploading file:', error);
return NextResponse.json(
{ success: false, message: 'Failed to upload file' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,57 @@
/**
* File: route.ts
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: User profile API for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
import { getOrCreateUserProfile } from '@/lib/profile';
export async function GET() {
try {
const session = await auth.api.getSession({
headers: await headers()
});
if (!session?.user) {
return NextResponse.json(
{ success: false, message: 'Unauthorized' },
{ status: 401 }
);
}
const userProfile = await getOrCreateUserProfile(session.user.id);
// Get full profile with user data
const fullProfile = await prisma.userProfile.findUnique({
where: { id: userProfile.id },
include: {
role: true,
user: {
select: {
name: true,
email: true,
image: true,
}
}
}
});
return NextResponse.json({
success: true,
data: fullProfile
});
} catch (error) {
console.error('Error fetching user profile:', error);
return NextResponse.json(
{ success: false, message: 'Failed to fetch user profile' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,167 @@
/**
* File: route.ts
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Video API endpoints for single video operations
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
import { getOrCreateUserProfile } from '@/lib/profile';
export async function GET(
_: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const video = await prisma.video.findUnique({
where: { id },
include: {
uploader: {
include: {
user: {
select: {
name: true,
image: true,
},
},
},
},
comments: {
include: {
author: {
include: {
user: {
select: {
name: true,
image: true,
},
},
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
});
if (!video) {
return NextResponse.json(
{ success: false, message: 'Video not found' },
{ status: 404 }
);
}
// Increment view count
await prisma.video.update({
where: { id },
data: { viewCount: { increment: 1 } },
});
return NextResponse.json({ success: true, data: video });
} catch (error) {
console.error('Error fetching video:', error);
return NextResponse.json(
{ success: false, message: 'Failed to fetch video' },
{ status: 500 }
);
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const { title, description, videoUrl, videoType, isPublic } = body;
const video = await prisma.video.update({
where: { id },
data: {
title,
description,
videoUrl,
videoType,
isPublic,
},
});
return NextResponse.json({ success: true, data: video });
} catch (error) {
console.error('Error updating video:', error);
return NextResponse.json(
{ success: false, message: 'Failed to update video' },
{ status: 500 }
);
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth.api.getSession({
headers: await headers()
});
if (!session?.user) {
return NextResponse.json(
{ success: false, message: 'Unauthorized' },
{ status: 401 }
);
}
// Get or create user profile
const userProfile = await getOrCreateUserProfile(session.user.id);
const { id } = await params;
// Check if video exists
const video = await prisma.video.findUnique({
where: { id },
});
if (!video) {
return NextResponse.json(
{ success: false, message: 'Video not found' },
{ status: 404 }
);
}
// Check if user can delete this video
const canDelete = userProfile.role.name === 'ADMIN' || video.uploaderId === userProfile.id;
if (!canDelete) {
return NextResponse.json(
{ success: false, message: 'Access denied' },
{ status: 403 }
);
}
// Delete the video
await prisma.video.delete({
where: { id },
});
return NextResponse.json({
success: true,
message: 'Video deleted successfully'
});
} catch (error) {
console.error('Error deleting video:', error);
return NextResponse.json(
{ success: false, message: 'Failed to delete video' },
{ status: 500 }
);
}
}

160
app/api/videos/route.ts Normal file
View File

@ -0,0 +1,160 @@
/**
* File: route.ts
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Video API endpoints with authentication and role-based access
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
import { getOrCreateUserProfile } from '@/lib/profile';
export async function GET(request: Request) {
try {
const session = await auth.api.getSession({
headers: await headers()
});
if (!session?.user) {
return NextResponse.json(
{ success: false, message: 'Unauthorized' },
{ status: 401 }
);
}
// Get or create user profile
const userProfile = await getOrCreateUserProfile(session.user.id);
let whereClause: any = {
OR: [
{ isPublic: true },
],
};
// Add user's own videos and class videos
if (userProfile.role.name !== 'UMUM') {
whereClause.OR.push(
{ uploaderId: userProfile.id }
);
}
// If user is educator or admin, show videos from their classes
if (userProfile.role.name === 'PENDIDIK' || userProfile.role.name === 'ADMIN') {
const userClasses = await prisma.class.findMany({
where: {
educatorId: userProfile.id,
isActive: true,
},
include: {
videos: true,
},
});
const classVideoIds = userClasses.flatMap(cls => cls.videos.map(v => v.id));
if (classVideoIds.length > 0) {
whereClause.OR.push({ id: { in: classVideoIds } });
}
}
// If user is student, show videos from their enrolled classes
if (userProfile.role.name === 'CALON_PENDIDIK') {
const enrolledClasses = await prisma.classMember.findMany({
where: {
studentId: userProfile.id,
},
include: {
class: {
include: {
videos: true,
},
},
},
});
const classVideoIds = enrolledClasses.flatMap(cm => cm.class.videos.map(v => v.id));
if (classVideoIds.length > 0) {
whereClause.OR.push({ id: { in: classVideoIds } });
}
}
const videos = await prisma.video.findMany({
where: whereClause,
include: {
uploader: {
include: {
user: {
select: {
name: true,
image: true,
},
},
},
},
},
orderBy: {
createdAt: 'desc',
},
});
return NextResponse.json({ success: true, data: videos });
} catch (error) {
console.error('Error fetching videos:', error);
return NextResponse.json(
{ success: false, message: 'Failed to fetch videos' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await auth.api.getSession({
headers: await headers()
});
if (!session?.user) {
return NextResponse.json(
{ success: false, message: 'Unauthorized' },
{ status: 401 }
);
}
// Get or create user profile
const userProfile = await getOrCreateUserProfile(session.user.id);
const body = await request.json();
const { title, description, videoUrl, videoType, isPublic } = body;
// Validation
if (!title || !videoUrl) {
return NextResponse.json(
{ success: false, message: 'Title and Video URL are required' },
{ status: 400 }
);
}
const video = await prisma.video.create({
data: {
title: title,
description: description,
videoUrl: videoUrl,
videoType: videoType,
isPublic: isPublic,
uploaderId: userProfile.id,
createdBy: userProfile.id,
updatedBy: userProfile.id,
},
});
return NextResponse.json({ success: true, data: video });
} catch (error) {
console.error('Error creating video:', error);
return NextResponse.json(
{ success: false, message: 'Failed to create video' },
{ status: 500 }
);
}
}

View File

@ -1,168 +0,0 @@
/**
* File: page.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Sign up page for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
export default function SignUp() {
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: '',
role: 'CALON_PENDIDIK' as 'CALON_PENDIDIK' | 'UMUM',
});
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (formData.password !== formData.confirmPassword) {
alert('Password tidak cocok!');
return;
}
setIsLoading(true);
// TODO: Implement actual sign up logic
setTimeout(() => {
setIsLoading(false);
router.push('/auth/signin');
}, 1000);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
return (
<div className="min-h-screen bg-gradient-to-br from-navy-900 via-navy-800 to-gray-900 flex items-center justify-center px-4 py-12">
<div className="absolute inset-0 bg-black/20"></div>
<div className="relative z-10 w-full max-w-md">
<div className="bg-white/10 backdrop-blur-lg rounded-2xl p-8 border border-white/20">
{/* Logo */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-white mb-2">
kreati<span className="text-gold-400">Vortex</span>
</h1>
<p className="text-gray-300">Buat akun baru</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
Nama Lengkap
</label>
<input
id="name"
name="name"
type="text"
value={formData.name}
onChange={handleChange}
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gold-400 focus:border-transparent"
placeholder="John Doe"
required
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
Email
</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gold-400 focus:border-transparent"
placeholder="nama@email.com"
required
/>
</div>
<div>
<label htmlFor="role" className="block text-sm font-medium text-gray-300 mb-2">
Daftar sebagai
</label>
<select
id="role"
name="role"
value={formData.role}
onChange={handleChange}
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-gold-400 focus:border-transparent"
>
<option value="CALON_PENDIDIK" className="bg-navy-800">Calon Pendidik</option>
<option value="UMUM" className="bg-navy-800">Umum</option>
</select>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-2">
Password
</label>
<input
id="password"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gold-400 focus:border-transparent"
placeholder="••••••••"
required
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-300 mb-2">
Konfirmasi Password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={handleChange}
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gold-400 focus:border-transparent"
placeholder="••••••••"
required
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-3 px-4 bg-gold-500 hover:bg-gold-400 text-navy-900 font-semibold rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Mendaftar...' : 'Daftar'}
</button>
</form>
{/* Sign in link */}
<div className="mt-8 text-center">
<p className="text-gray-300">
Sudah punya akun?{' '}
<Link href="/auth/signin" className="text-gold-400 hover:text-gold-300 font-medium">
Masuk
</Link>
</p>
</div>
</div>
</div>
</div>
);
}

View File

@ -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);
@ -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;
}
}
}

View File

@ -1,34 +0,0 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

View File

@ -1,96 +0,0 @@
/**
* File: page.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Landing page for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
import Link from "next/link";
export default function Home() {
return (
<div className="min-h-screen bg-gradient-to-br from-navy-900 via-navy-800 to-gray-900 text-white">
{/* Background overlay effect */}
<div className="absolute inset-0 bg-black/20"></div>
{/* Main content */}
<div className="relative z-10 flex min-h-screen flex-col items-center justify-center px-4">
<div className="max-w-4xl w-full text-center">
{/* Logo/Title */}
<div className="mb-12">
<h1 className="text-5xl md:text-7xl font-bold text-white mb-4">
kreati<span className="text-gold-400">Vortex</span>
</h1>
<p className="text-xl md:text-2xl text-gray-300">
Platform Pembelajaran Tari Online
</p>
</div>
{/* Description */}
<div className="mb-16 max-w-2xl mx-auto">
<p className="text-lg text-gray-300 leading-relaxed">
Bergabunglah dengan komunitas pembelajaran tari tradisional Indonesia.
Pelajari berbagai tarian dari seluruh nusantara dengan panduan dari para ahli.
</p>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<Link
href="/auth/signin"
className="px-8 py-4 bg-navy-700 hover:bg-navy-600 text-white font-semibold rounded-lg transition-colors duration-200 min-w-[160px] text-center"
>
Masuk
</Link>
<Link
href="/auth/signup"
className="px-8 py-4 bg-gold-500 hover:bg-gold-400 text-navy-900 font-semibold rounded-lg transition-colors duration-200 min-w-[160px] text-center"
>
Daftar
</Link>
<Link
href="/auth/signup"
className="px-8 py-4 border-2 border-gold-400 text-gold-400 hover:bg-gold-400 hover:text-navy-900 font-semibold rounded-lg transition-colors duration-200 min-w-[160px] text-center"
>
Mulai Sekarang
</Link>
</div>
{/* Features */}
<div className="mt-24 grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="text-center">
<div className="w-16 h-16 bg-gold-400/20 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<h3 className="text-xl font-semibold text-white mb-2">Video Pembelajaran</h3>
<p className="text-gray-400">Akses video tutorial tari dari berbagai daerah di Indonesia</p>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-gold-400/20 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z" />
</svg>
</div>
<h3 className="text-xl font-semibold text-white mb-2">Forum Diskusi</h3>
<p className="text-gray-400">Berinteraksi dengan sesama pecinta tari dan para ahli</p>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-gold-400/20 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 className="text-xl font-semibold text-white mb-2">Tugas & Evaluasi</h3>
<p className="text-gray-400">Kumpulkan tugas dan dapatkan feedback dari para pendidik</p>
</div>
</div>
</div>
</div>
</div>
);
}

181
bun.lock
View File

@ -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=="],

View File

@ -0,0 +1,84 @@
/**
* File: ActionButton.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: ActionButton component for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
'use client';
import React from 'react';
import { cn } from '@/lib/utils';
interface ActionButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
icon?: React.ReactNode;
children: React.ReactNode;
}
const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
({ className, variant = 'primary', size = 'md', loading = false, icon, children, disabled, ...props }, ref) => {
const baseClasses = 'inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
const variants = {
primary: 'bg-gold-500 text-navy-900 hover:bg-gold-400 focus:ring-gold-400 shadow-lg hover:shadow-gold-400/25',
secondary: ' text-white hover:bg-navy-600 focus:ring-navy-500',
outline: 'border-2 border-gold-400 text-gold-400 hover:bg-gold-400 hover:text-navy-900 focus:ring-gold-400',
ghost: 'text-gray-300 hover:text-white hover:bg-white/10 focus:ring-white/20',
destructive: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
};
return (
<button
className={cn(
baseClasses,
variants[variant],
sizes[size],
className
)}
disabled={disabled || loading}
ref={ref}
{...props}
>
{loading && (
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0112 20c5.514 0 10.241-2.636 11.317-7.319l.702-.707a1 1 0 00-1.414 1.414l-.707.707c-.878.879-2.262 1.414-3.817z"
/>
</svg>
)}
{!loading && icon && <span className="ml-2">{icon}</span>}
{!loading && icon && <span className="mr-2">{icon}</span>}
{children}
</button>
);
}
);
ActionButton.displayName = 'ActionButton';
export default ActionButton;

View File

@ -6,4 +6,4 @@
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
// ActionButton component will be created here
export { default } from "./ActionButton";

View File

@ -0,0 +1,101 @@
/**
* File: AttachmentDisplay.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Component to display file attachments
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
'use client';
import React from 'react';
interface Attachment {
id: string;
name: string;
originalName: string;
mimeType: string;
size: number;
url: string;
}
interface AttachmentDisplayProps {
attachments: Attachment[];
}
export default function AttachmentDisplay({ attachments }: AttachmentDisplayProps) {
if (!attachments || attachments.length === 0) {
return null;
}
const getFileIcon = (mimeType: string) => {
if (mimeType.includes('pdf')) {
return (
<svg className="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0015.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
);
}
if (mimeType.includes('word') || mimeType.includes('document')) {
return (
<svg className="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
);
}
if (mimeType.includes('image')) {
return (
<svg className="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
);
}
return (
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
);
};
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
return (
<div className="mt-3 space-y-2">
<p className="text-sm font-medium text-gray-300 mb-2">Lampiran:</p>
<div className="space-y-2">
{attachments.map((attachment) => (
<a
key={attachment.id}
href={attachment.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-3 p-3 bg-white/5 rounded-lg border border-white/10 hover:bg-white/10 transition-colors group"
>
<div className="flex-shrink-0">
{getFileIcon(attachment.mimeType)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-white font-medium truncate group-hover:text-gold-400 transition-colors">
{attachment.originalName}
</p>
<p className="text-xs text-gray-400">{formatFileSize(attachment.size)}</p>
</div>
<div className="flex-shrink-0">
<svg className="w-4 h-4 text-gray-400 group-hover:text-gold-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</div>
</a>
))}
</div>
</div>
);
}

151
components/CommentForm.tsx Normal file
View File

@ -0,0 +1,151 @@
/**
* File: CommentForm.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Comment form with file upload for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
'use client';
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { uploadFile, isAllowedFileType, formatFileSize, UploadedFile } from '@/lib/upload';
interface CommentFormProps {
onSubmit: (content: string, attachments: UploadedFile[]) => void;
placeholder?: string;
buttonText?: string;
loading?: boolean;
}
export default function CommentForm({
onSubmit,
placeholder = "Tulis komentar...",
buttonText = "Kirim Komentar",
loading = false
}: CommentFormProps) {
const [content, setContent] = useState('');
const [attachments, setAttachments] = useState<UploadedFile[]>([]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (content.trim() || attachments.length > 0) {
onSubmit(content.trim(), attachments);
setContent('');
setAttachments([]);
}
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
const newAttachments: UploadedFile[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!isAllowedFileType(file)) {
alert(`File ${file.name} tidak diizinkan. Hanya PDF, Word, dan file gambar yang diperbolehkan.`);
continue;
}
if (file.size > 10 * 1024 * 1024) { // 10MB limit
alert(`File ${file.name} terlalu besar. Maksimal ukuran file adalah 10MB.`);
continue;
}
try {
const uploadedFile = await uploadFile(file, 'comment-attachments');
newAttachments.push(uploadedFile);
} catch (error) {
console.error('Error uploading file:', error);
alert(`Gagal mengupload file ${file.name}`);
}
}
setAttachments(prev => [...prev, ...newAttachments]);
};
const removeAttachment = (index: number) => {
setAttachments(prev => prev.filter((_, i) => i !== index));
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={placeholder}
rows={3}
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400 resize-none"
/>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<input
type="file"
multiple
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png,.gif"
onChange={handleFileUpload}
className="hidden"
id="file-upload"
/>
<label
htmlFor="file-upload"
className="cursor-pointer inline-flex items-center px-3 py-1.5 text-sm bg-gold-500/20 hover:bg-gold-500/30 text-gold-400 rounded-lg transition-colors"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
Lampirkan File
</label>
<span className="text-xs text-gray-400">
PDF, Word, Gambar (Maks 10MB)
</span>
</div>
{attachments.length > 0 && (
<div className="space-y-2">
{attachments.map((file, index) => (
<div key={file.id} className="flex items-center justify-between bg-white/5 rounded-lg p-2 border border-white/10">
<div className="flex items-center space-x-2">
<div className="w-6 h-6 bg-gold-500/20 rounded flex items-center justify-center">
<svg className="w-3 h-3 text-gold-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div>
<p className="text-xs text-white font-medium truncate max-w-[150px]">{file.originalName}</p>
<p className="text-xs text-gray-400">{formatFileSize(file.size)}</p>
</div>
</div>
<button
type="button"
onClick={() => removeAttachment(index)}
className="text-red-400 hover:text-red-300 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
)}
</div>
<div className="flex justify-end">
<Button
type="submit"
disabled={loading || (!content.trim() && attachments.length === 0)}
className="bg-gold-500 hover:bg-gold-600 text-navy-900"
>
{loading ? 'Mengirim...' : buttonText}
</Button>
</div>
</form>
);
}

View File

@ -0,0 +1,131 @@
/**
* File: AppDataView.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Data view component for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
'use client';
import React from 'react';
import { cn } from '@/lib/utils';
interface Column<T> {
key: keyof T;
label: string;
render?: (value: T[keyof T], row: T) => React.ReactNode;
className?: string;
}
interface AppDataViewProps<T> {
data: T[];
columns: Column<T>[];
loading?: boolean;
className?: string;
emptyMessage?: string;
}
function AppDataView<T extends Record<string, unknown>>({
data,
columns,
loading = false,
className,
emptyMessage = 'Tidak ada data tersedia'
}: AppDataViewProps<T>) {
if (loading) {
return (
<div className="space-y-4">
{/* Skeleton rows */}
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20">
<div className="space-y-4">
<div className="h-4 bg-gray-600/20 rounded animate-pulse"></div>
<div className="h-4 bg-gray-600/20 rounded animate-pulse"></div>
<div className="h-4 bg-gray-600/20 rounded animate-pulse w-3/4"></div>
</div>
</div>
))}
</div>
);
}
if (data.length === 0) {
return (
<div className={cn(
'bg-white/10 backdrop-blur-sm rounded-xl p-12 border border-white/20 text-center',
className
)}>
<div className="w-16 h-16 bg-gray-600/20 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 className="text-lg font-medium text-gray-300 mb-2">{emptyMessage}</h3>
<p className="text-gray-400">Coba ubah filter atau tambah data baru</p>
</div>
);
}
return (
<div className={cn(
'bg-white/10 backdrop-blur-sm rounded-xl border border-white/20 overflow-hidden',
className
)}>
{/* Table for desktop */}
<div className="hidden lg:block overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-white/10">
{columns.map((column) => (
<th
key={column.key as string}
className={cn(
'px-6 py-4 text-left text-xs font-medium text-gray-300 uppercase tracking-wider',
column.className
)}
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{data.map((row, index) => (
<tr key={index} className="hover:bg-white/5 transition-colors">
{columns.map((column) => (
<td
key={column.key as string}
className="px-6 py-4 whitespace-nowrap text-sm text-gray-300"
>
{column.render ? column.render(row[column.key], row) : String(row[column.key] || '')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Cards for mobile */}
<div className="lg:hidden space-y-4 p-4">
{data.map((row, index) => (
<div key={index} className="bg-white/5 rounded-lg p-4 border border-white/10">
{columns.map((column) => (
<div key={column.key as string} className="mb-3">
<div className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-1">
{column.label}
</div>
<div className="text-sm text-gray-300">
{column.render ? column.render(row[column.key], row) : String(row[column.key] || '')}
</div>
</div>
))}
</div>
))}
</div>
</div>
);
}
export default AppDataView;

View File

@ -0,0 +1,40 @@
/**
* File: AppForm.tsx
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Form component for kreatiVortex platform
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
'use client';
import React from 'react';
import { cn } from '@/lib/utils';
interface AppFormProps {
children: React.ReactNode;
className?: string;
onSubmit?: (e: React.FormEvent) => void;
}
const AppForm = React.forwardRef<HTMLFormElement, AppFormProps>(
({ children, className, onSubmit, ...props }, ref) => {
return (
<form
ref={ref}
onSubmit={onSubmit}
className={cn(
'space-y-6 bg-white/10 backdrop-blur-sm rounded-xl p-6 border border-white/20',
className
)}
{...props}
>
{children}
</form>
);
}
);
AppForm.displayName = 'AppForm';
export default AppForm;

View File

@ -0,0 +1,216 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface VideoFormData {
title: string;
description: string;
videoUrl: string;
videoType: 'YOUTUBE' | 'LOCAL';
isPublic: boolean;
menuId?: string;
}
interface VideoFormProps {
initialData?: VideoFormData;
onSubmit?: (data: VideoFormData) => Promise<void>;
isEditing?: boolean;
menus?: { id: string; title: string }[];
}
export default function VideoForm({ initialData, onSubmit, isEditing = false, menus = [] }: VideoFormProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState<VideoFormData>(initialData || {
title: '',
description: '',
videoUrl: '',
videoType: 'YOUTUBE',
isPublic: true,
menuId: '',
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value, type } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked : value,
}));
};
const handleSelectChange = (name: string, value: string) => {
setFormData(prev => ({
...prev,
[name]: value === 'no-selection' ? '' : value,
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
if (onSubmit) {
await onSubmit(formData);
} else {
// Default submission logic if no prop provided
console.log('Submitting video data:', formData);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
router.push('/dashboard/videos');
}
} catch (error) {
console.error('Error submitting form:', error);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="title" className="text-gray-300">
Judul Video
</Label>
<Input
type="text"
id="title"
name="title"
required
value={formData.title}
onChange={handleChange}
placeholder="Masukkan judul video"
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description" className="text-gray-300">
Deskripsi
</Label>
<Textarea
id="description"
name="description"
rows={4}
value={formData.description}
onChange={handleChange}
placeholder="Deskripsi singkat tentang video"
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="menuId" className="text-gray-300">
Menu (Opsional)
</Label>
<Select
value={formData.menuId || 'no-selection'}
onValueChange={(value) => handleSelectChange('menuId', value)}
>
<SelectTrigger className="bg-white/10 border-white/20 text-white">
<SelectValue placeholder="Pilih menu" />
</SelectTrigger>
<SelectContent>
<SelectItem value="no-selection">Tidak ada menu</SelectItem>
{menus.map((menu) => (
<SelectItem key={menu.id} value={menu.id}>
{menu.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="videoType" className="text-gray-300">
Tipe Video
</Label>
<Select
value={formData.videoType}
onValueChange={(value) => handleSelectChange('videoType', value)}
>
<SelectTrigger className="bg-white/10 border-white/20 text-white">
<SelectValue placeholder="Pilih tipe video" />
</SelectTrigger>
<SelectContent>
<SelectItem value="YOUTUBE">YouTube</SelectItem>
<SelectItem value="LOCAL">Upload File (Local)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="videoUrl" className="text-gray-300">
{formData.videoType === 'YOUTUBE' ? 'URL YouTube' : 'File Video'}
</Label>
{formData.videoType === 'YOUTUBE' ? (
<Input
type="url"
id="videoUrl"
name="videoUrl"
required
value={formData.videoUrl}
onChange={handleChange}
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400"
placeholder="https://youtube.com/watch?v=..."
/>
) : (
<div className="border-2 border-dashed border-white/20 rounded-lg p-6 text-center">
<p className="text-gray-400 mb-2">Upload file video belum tersedia dalam demo ini</p>
<Input
type="text"
name="videoUrl"
value={formData.videoUrl}
onChange={handleChange}
className="hidden"
/>
</div>
)}
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="isPublic"
name="isPublic"
checked={formData.isPublic}
onChange={handleChange}
className="w-4 h-4 text-gold-400 bg-white/10 border-white/20 rounded focus:ring-gold-400 focus:ring-offset-white/20"
/>
<Label htmlFor="isPublic" className="text-gray-300 font-medium">
Publik (Dapat dilihat oleh semua orang)
</Label>
</div>
<div className="pt-4 flex justify-end space-x-3">
<Button
type="button"
variant="ghost"
onClick={() => router.back()}
disabled={loading}
className="text-gray-300 hover:text-white"
>
Batal
</Button>
<Button
type="submit"
disabled={loading}
className="bg-gold-500 hover:bg-gold-600 text-navy-900"
>
{loading ? 'Menyimpan...' : (isEditing ? 'Simpan Perubahan' : 'Upload Video')}
</Button>
</div>
</form>
);
}

View File

@ -0,0 +1,191 @@
"use client";
import { Link, usePathname } from '@/i18n/routing';
import { useTranslations, useLocale } from 'next-intl';
import { useFetch } from '@/hooks/useFetch';
import { isAdmin } from '@/lib/admin';
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuPortal, DropdownMenuSubContent
} from '../ui/dropdown-menu';
import { Button } from '../ui/button';
import { BookUserIcon, HouseIcon, InfoIcon, MenuIcon, MessagesSquareIcon, UsersIcon, VideoIcon } from 'lucide-react';
interface MenuWithChildren {
id: string;
name: any; // Json type
slug: string;
children: MenuWithChildren[];
}
interface ClassData {
id: string;
name: string;
code: string;
}
interface DashboardMenuProps {
menus: MenuWithChildren[];
}
export default function DashboardMenu({ menus = [] }: DashboardMenuProps) {
const t = useTranslations('Sidebar');
const locale = useLocale();
const pathname = usePathname();
const { data: classes } = useFetch<ClassData[]>('/api/classes');
const { data: userProfile } = useFetch<any>('/api/user/profile');
const isActive = (path: string) => pathname === path;
// Check if user is admin or educator
const userIsAdmin = isAdmin(userProfile?.data);
const getLocalizedName = (name: any) => {
if (!name) return '';
return name[locale] || name['id'] || name['en'] || '';
};
return (
<div className="w-64 md:w-auto md:bg-transparent md:backdrop-blur-none md:border-0 backdrop-blur-sm border-r border-white/10 h-full">
{/* Navigation */}
<nav className="px-4 flex-1 overflow-y-auto">
<ul className="flex flex-col md:flex-row space-y-2 space-x-2 justify-center items-center">
<li className='mb-0'>
<Link
href="/dashboard"
>
<Button variant={isActive('/dashboard') ? "glass" : "ghost"}>
<HouseIcon className="w-5 h-5" />
{t('home')}
</Button>
</Link>
</li>
{userIsAdmin && (
<li className='mb-0'>
<Link
href="/dashboard/videos"
>
<Button variant={isActive('/dashboard/videos') ? "glass" : "ghost"}>
<VideoIcon className="w-5 h-5" />
{t('videos')}
</Button>
</Link>
</li>
)}
<li className='mb-0'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="lg" variant="ghost">
<MenuIcon className="w-5 h-5" />
{t('menu')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">
<DropdownMenuGroup>
{menus.map((menu) => (
<div key={menu.id}>
{menu.children && menu.children.length > 0 ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{getLocalizedName(menu.name)}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{menu.children.map((child) => (
<DropdownMenuItem key={child.id}>
<Link href={`/dashboard/pages/${child.slug}`} className="w-full">
{getLocalizedName(child.name)}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
) : (
<DropdownMenuItem>
<Link href={`/dashboard/pages/${menu.slug}`} className="w-full">
{getLocalizedName(menu.name)}
</Link>
</DropdownMenuItem>
)}
</div>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</li>
<li className='mb-0'>
<Link
href="/dashboard/classes"
>
<Button variant={isActive('/dashboard/classes') ? "glass" : "ghost"}>
<UsersIcon className="w-5 h-5" />
{t('classes')}
</Button>
</Link>
</li>
<li className='mb-0'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="lg" variant="ghost">
<MessagesSquareIcon className="w-5 h-5" />
{t('forum')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">
<DropdownMenuGroup>
<DropdownMenuItem>
<Link href={`/dashboard/forum/umum`} className="w-full">
{t('general')}
</Link>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{t('group')}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{classes?.map((classItem) => (
<DropdownMenuItem key={classItem.id}>
<Link href={`/dashboard/forum/${classItem.id}`} className="w-full">
{classItem.name}
</Link>
</DropdownMenuItem>
))}
{(!classes || classes.length === 0) && (
<DropdownMenuItem disabled>
{t('noClasses')}
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</li>
<li className='mb-0'>
<Link
href="/dashboard/contact"
>
<Button variant={isActive('/dashboard/contact') ? "glass" : "ghost"}>
<BookUserIcon className="w-5 h-5" />
{t('contact')}
</Button>
</Link>
</li>
<li className='mb-0'>
<Link
href="/dashboard/faq"
>
<Button variant={isActive('/dashboard/faq') ? "glass" : "ghost"}>
<InfoIcon className="w-5 h-5" />
{t('faq')}
</Button>
</Link>
</li>
</ul>
</nav>
</div>
);
}

View File

@ -0,0 +1,102 @@
"use client";
import { authClient } from "@/lib/auth-client";
import { useRouter } from "@/i18n/routing";
import { Link } from "@/i18n/routing";
import { useState } from "react";
import { useTranslations } from "next-intl";
export default function DashboardProfile() {
const t = useTranslations('Profile');
const { data: session, isPending } = authClient.useSession();
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
// If loading, show skeleton
if (isPending) {
return (
<div className="flex items-center space-x-3 animate-pulse">
<div className="text-right hidden md:block">
<div className="h-4 w-24 bg-gray-600 rounded"></div>
<div className="h-3 w-16 bg-gray-600 rounded mt-1"></div>
</div>
<div className="w-10 h-10 bg-gray-600 rounded-full"></div>
</div>
)
}
// If no session, show nothing (or could show login button, but dashboard is protected)
if (!session) {
return null;
}
const user = session.user;
const initials = user.name
? user.name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)
: "??";
// Attempt to get role from user object if it exists (custom field), or default
// @ts-ignore - role might not be in the default type definition yet
const role = user.role || "Member";
const handleSignOut = async () => {
await authClient.signOut();
router.push("/auth/signin");
};
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center space-x-3 group focus:outline-none"
>
<div className="text-right hidden md:block">
<p className="text-sm font-medium text-white">{user.name}</p>
<p className="text-xs text-gray-400 capitalize">{role.toLowerCase().replace('_', ' ')}</p>
</div>
<div className="w-10 h-10 bg-gold-400 rounded-full flex items-center justify-center overflow-hidden border-2 border-transparent group-hover:border-white/20 transition-all">
{user.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={user.image} alt={user.name} className="w-full h-full object-cover" />
) : (
<span className="text-navy-900 font-bold">{initials}</span>
)}
</div>
</button>
{/* Dropdown Menu */}
{isOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
></div>
<div className="absolute right-0 top-full mt-2 w-48 bg-popover text-popover-foreground backdrop-blur-md rounded-lg shadow-xl z-50 py-1">
<div className="px-4 py-2 border-b border-white/5 md:hidden">
<p className="text-sm font-medium text-white">{user.name}</p>
<p className="text-xs text-gray-400">{user.email}</p>
</div>
<Link
href="/dashboard/profile"
className="block px-4 py-2 text-sm text-popover-foreground hover:bg-white/10 hover:text-white transition-colors"
onClick={() => setIsOpen(false)}
>
{t('profileSettings')}
</Link>
<button
onClick={handleSignOut}
className="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-white/10 hover:text-red-300 transition-colors"
>
{t('signOut')}
</button>
</div>
</>
)}
</div>
);
}

View File

@ -6,4 +6,5 @@
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
// Layout components will be exported here as they are created
export { default as DashboardProfile } from './DashboardProfile';
export { default as DashboardMenu } from './DashboardMenu';

View File

@ -6,4 +6,9 @@
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
// Components will be exported here as they are created
// ActionButton export
export { default as ActionButton } from "./ActionButton";
// Common components exports
export { default as AppForm } from "./Common/AppForm";
export { default as AppDataView } from "./Common/AppDataView";

64
components/ui/button.tsx Normal file
View File

@ -0,0 +1,64 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
glass:
"border bg-white/5 shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-white/5 hover:text-accent-foreground dark:hover:bg-white/5",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-lg gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-lg px-6 has-[>svg]:px-4",
xl: "h-12 rounded-lg px-8 has-[>svg]:px-6 text-base",
"2xl": "h-14 rounded-lg px-10 has-[>svg]:px-8 text-lg",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"backdrop-blur-xs bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"backdrop-blur-xs bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

248
components/ui/field.tsx Normal file
View File

@ -0,0 +1,248 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

167
components/ui/form.tsx Normal file
View File

@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

21
components/ui/input.tsx Normal file
View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

24
components/ui/label.tsx Normal file
View File

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

187
components/ui/select.tsx Normal file
View File

@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

61
hooks/useFetch.ts Normal file
View File

@ -0,0 +1,61 @@
/**
* File: useFetch.ts
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Custom hook for data fetching
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
import { useState, useEffect } from 'react';
interface FetchOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
headers?: Record<string, string>;
body?: any;
}
interface FetchResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
export function useFetch<T>(url: string, options?: FetchOptions): FetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | null>(null);
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url, {
method: options?.method || 'GET',
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
body: options?.body ? JSON.stringify(options.body) : undefined,
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Error: ${response.status} ${response.statusText}`);
}
const result = await response.json();
setData(result.data || result);
} catch (err) {
setError(err instanceof Error ? err : new Error('An unknown error occurred'));
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [url]);
return { data, loading, error, refetch: fetchData };
}

17
i18n/request.ts Normal file
View File

@ -0,0 +1,17 @@
import {getRequestConfig} from 'next-intl/server';
import {routing} from './routing';
export default getRequestConfig(async ({requestLocale}) => {
// This typically corresponds to the `[locale]` segment
let locale = await requestLocale;
// Ensure that a valid locale is used
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default
};
});

15
i18n/routing.ts Normal file
View File

@ -0,0 +1,15 @@
import {defineRouting} from 'next-intl/routing';
import {createNavigation} from 'next-intl/navigation';
export const routing = defineRouting({
// A list of all locales that are supported
locales: ['en', 'id'],
// Used when no locale matches
defaultLocale: 'en'
});
// Lightweight wrappers around Next.js' navigation APIs
// that will consider the routing configuration
export const {Link, redirect, usePathname, useRouter, getPathname} =
createNavigation(routing);

71
lib/admin.ts Normal file
View File

@ -0,0 +1,71 @@
/**
* File: admin.ts
* Created by: AI Assistant
* Date: 2025-12-01
* Purpose: Utility functions for admin role checking
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
/**
* Check if user has admin privileges
* @param userProfile - User profile object with role information
* @returns boolean - true if user is ADMIN or PENDIDIK, false otherwise
*/
export function isAdmin(userProfile: any): boolean {
if (!userProfile || !userProfile.role) {
return false;
}
const adminRoles = ['ADMIN', 'PENDIDIK'];
return adminRoles.includes(userProfile.role.name);
}
/**
* Check if user can upload videos
* @param userProfile - User profile object with role information
* @returns boolean - true if user can upload videos, false otherwise
*/
export function canUploadVideos(userProfile: any): boolean {
return !!userProfile;
}
/**
* Check if user can delete any video (not just their own)
* @param userProfile - User profile object with role information
* @returns boolean - true if user is ADMIN, false otherwise
*/
export function canDeleteAnyVideo(userProfile: any): boolean {
if (!userProfile || !userProfile.role) {
return false;
}
return userProfile.role.name === 'ADMIN';
}
/**
* Check if user can edit a specific video
* @param userProfile - User profile object with role information
* @param videoUploaderId - ID of the user who uploaded the video
* @returns boolean - true if user can edit the video, false otherwise
*/
export function canEditVideo(userProfile: any, videoUploaderId: string): boolean {
if (!userProfile || !userProfile.id || !userProfile.role) {
return false;
}
return isAdmin(userProfile) || userProfile.id === videoUploaderId;
}
/**
* Check if user can delete a specific video
* @param userProfile - User profile object with role information
* @param videoUploaderId - ID of the user who uploaded the video
* @returns boolean - true if user can delete the video, false otherwise
*/
export function canDeleteVideo(userProfile: any, videoUploaderId: string): boolean {
if (!userProfile || !userProfile.id || !userProfile.role) {
return false;
}
return canDeleteAnyVideo(userProfile) || userProfile.id === videoUploaderId;
}

38
lib/profile.ts Normal file
View File

@ -0,0 +1,38 @@
/**
* File: profile.ts
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Utility functions for user profile management
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
import { prisma } from '@/lib/prisma';
export async function getOrCreateUserProfile(userId: string) {
let userProfile = await prisma.userProfile.findUnique({
where: { userId },
include: { role: true }
});
if (!userProfile) {
// Create new user profile with UMUM role
const umumRole = await prisma.userRole.findUnique({
where: { name: 'UMUM' }
});
if (!umumRole) {
throw new Error('Default role UMUM not found');
}
userProfile = await prisma.userProfile.create({
data: {
userId,
roleId: umumRole.id,
bio: 'New user profile'
},
include: { role: true }
});
}
return userProfile;
}

63
lib/upload.ts Normal file
View File

@ -0,0 +1,63 @@
/**
* File: upload.ts
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: Client-side file upload utility functions
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
export interface UploadedFile {
id: string;
name: string;
originalName: string;
mimeType: string;
size: number;
url: string;
}
export async function uploadFile(file: File, folder: string = 'uploads'): Promise<UploadedFile> {
const formData = new FormData();
formData.append('file', file);
formData.append('folder', folder);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
credentials: 'include',
});
if (!response.ok) {
throw new Error('Failed to upload file');
}
const result = await response.json();
if (!result.success) {
throw new Error(result.message || 'Upload failed');
}
return result.data;
}
export function isAllowedFileType(file: File): boolean {
const allowedTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'image/jpeg',
'image/png',
'image/gif'
];
return allowedTypes.includes(file.type);
}
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}

View File

@ -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;
};

36
lib/youtube.ts Normal file
View File

@ -0,0 +1,36 @@
/**
* File: youtube.ts
* Created by: AI Assistant
* Date: 2025-11-29
* Purpose: YouTube utility functions
* Part of: kreatiVortex - Platform Pembelajaran Tari Online
*/
export function extractYoutubeId(url: string): string | null {
const regex = /(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/)|youtu\.be\/(?:watch\?v=|embed\/|v\/|shorts\/))([a-zA-Z0-9_-]{11})/;
const match = url.match(regex);
if (match && match[1]) {
return match[1];
}
return null;
}
export function generateYoutubeEmbedUrl(url: string): string {
const videoId = extractYoutubeId(url);
if (videoId) {
return `https://www.youtube.com/embed/${videoId}`;
}
return url;
}
export function generateYoutubeThumbnailUrl(url: string): string {
const videoId = extractYoutubeId(url);
if (videoId) {
return `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
}
return '';
}

151
messages/en.json Normal file
View File

@ -0,0 +1,151 @@
{
"HomePage": {
"title": "KreatiVortex",
"subtitle": "Dance Theory and Creation",
"login": "Login",
"registration": "Registration",
"startNow": "Start Now"
},
"Auth": {
"signInTitle": "Sign in to your account",
"signUpTitle": "Create a new account",
"emailLabel": "Email",
"passwordLabel": "Password",
"confirmPasswordLabel": "Confirm Password",
"nameLabel": "Full Name",
"roleLabel": "Register as",
"roleEducator": "Educator Candidate",
"roleGeneral": "General",
"rememberMe": "Remember me",
"forgotPassword": "Forgot password?",
"signInButton": "Sign In",
"signUpButton": "Sign Up",
"processing": "Processing...",
"registering": "Registering...",
"noAccount": "Don't have an account?",
"haveAccount": "Already have an account?",
"registerNow": "Register now",
"signInNow": "Sign In",
"passwordMismatch": "Passwords do not match!"
},
"Sidebar": {
"home": "Home",
"menu": "Menu",
"classes": "Classes",
"theory": "Theory",
"practice": "Practice",
"paperTemplate": "Paper Template",
"community": "Community",
"videos": "Videos",
"forum": "Forum",
"general": "General",
"group": "Group",
"tempo": "Tempo",
"assignments": "Assignments",
"contact": "Contact",
"faq": "FAQ",
"noClasses": "No classes available."
},
"Profile": {
"profileSettings": "Profile Settings",
"signOut": "Sign Out"
},
"Dashboard": {
"headerTitle": "Dashboard",
"welcomeTitle": "Welcome to kreatiVortex!",
"welcomeSubtitle": "Indonesian traditional dance learning platform",
"statTotalVideos": "Total Videos",
"statActiveClasses": "Active Classes",
"statCompletedAssignments": "Completed Assignments",
"statForumPosts": "Forum Posts",
"recentVideosTitle": "Recent Videos",
"recentDiscussionsTitle": "Recent Discussions",
"sampleVideo1": "Plate Dance - Basic",
"sampleVideo2": "Saman Dance - Hand Movements",
"sampleDiscussion1Title": "Tips for Plate Dance beginners",
"sampleDiscussion1Content": "I want to share some tips for those just starting to learn...",
"sampleDiscussion2Title": "Costume for performance",
"sampleDiscussion2Content": "Any suggestions for the right costume for a dance performance...",
"timeAgo": "{hours} hours ago"
},
"Classes": {
"title": "My Classes",
"subtitle": "List of classes you are enrolled in or teaching",
"createButton": "Create New Class",
"active": "Active",
"students": "Students",
"scheduleNotSet": "Schedule not set",
"unknownEducator": "Unknown",
"enterClass": "Enter Class",
"noClasses": "No classes available.",
"loading": "Loading..."
},
"Forum": {
"title": "Discussion Forum",
"subtitle": "Discuss with the kreatiVortex community",
"createButton": "Create New Discussion",
"filterAll": "All Discussions",
"filterAnnouncements": "Announcements",
"filterTechnique": "Dance Technique",
"filterCostume": "Costume & Makeup",
"filterMyClasses": "My Classes",
"posts": "Posts",
"noForums": "No discussions created.",
"generalForum": "General Forum",
"generalForumSubtitle": "Discuss with the kreatiVortex community",
"classForum": "Class Forum",
"classForumSubtitle": "Discuss with your class members"
},
"Videos": {
"title": "Learning Videos",
"subtitle": "Manage your dance learning video collection",
"uploadButton": "Upload New Video",
"colTitle": "Video Title",
"colType": "Type",
"colViews": "Views",
"viewsSuffix": "times",
"colStatus": "Status",
"statusPublic": "Public",
"statusPrivate": "Private",
"colAction": "Action",
"actionView": "View",
"actionEdit": "Edit",
"noVideos": "No videos uploaded",
"actionDelete": "Delete",
"confirmDelete": "Are you sure you want to delete this video?",
"errorOccurred": "An error occurred",
"loading": "Loading..."
},
"Menu": {
"title": "Menu Management",
"subtitle": "Manage application menu",
"addButton": "Add Menu",
"editTitle": "Edit Menu",
"addTitle": "Add New Menu",
"nameIdLabel": "Name (Indonesian)",
"nameEnLabel": "Name (English) - Optional",
"descriptionIdLabel": "Description (Indonesian)",
"descriptionEnLabel": "Description (English) - Optional",
"slugLabel": "Slug (URL)",
"parentLabel": "Parent Menu",
"parentNone": "None (Top Level)",
"activeLabel": "Active",
"cancelButton": "Cancel",
"saveButton": "Save Changes",
"createButton": "Create Menu",
"saving": "Saving...",
"tableName": "Name",
"tableSlug": "Slug",
"tableParent": "Parent",
"tableStatus": "Status",
"tableActions": "Actions",
"statusActive": "Active",
"statusInactive": "Inactive",
"noMenus": "No menus available. Please add a new menu.",
"confirmDelete": "Are you sure you want to delete this menu?",
"errorOccurred": "An error occurred",
"nameIdRequired": "Name (ID) is required",
"descriptionIdRequired": "Description (ID) is required",
"slugRequired": "Slug is required"
}
}

136
messages/id.json Normal file
View File

@ -0,0 +1,136 @@
{
"HomePage": {
"title": "KreatiVortex",
"subtitle": "Teori dan Kreasi Tari",
"login": "Masuk",
"registration": "Pendaftaran",
"startNow": "Mulai Sekarang"
},
"Auth": {
"signInTitle": "Masuk ke akun Anda",
"signUpTitle": "Buat akun baru",
"emailLabel": "Email",
"passwordLabel": "Password",
"confirmPasswordLabel": "Konfirmasi Password",
"nameLabel": "Nama Lengkap",
"roleLabel": "Daftar sebagai",
"roleEducator": "Calon Pendidik",
"roleGeneral": "Umum",
"rememberMe": "Ingat saya",
"forgotPassword": "Lupa password?",
"signInButton": "Masuk",
"signUpButton": "Daftar",
"processing": "Memproses...",
"registering": "Mendaftar...",
"noAccount": "Belum punya akun?",
"haveAccount": "Sudah punya akun?",
"registerNow": "Daftar sekarang",
"signInNow": "Masuk",
"passwordMismatch": "Password tidak cocok!"
},
"Sidebar": {
"home": "Beranda",
"classes": "Kelas",
"theory": "Teori",
"practice": "Praktik",
"paperTemplate": "Template Makalah",
"community": "Komunitas",
"myVideos": "Video Saya",
"forum": "Forum",
"assignments": "Tugas"
},
"Profile": {
"profileSettings": "Pengaturan Profil",
"signOut": "Keluar"
},
"Dashboard": {
"headerTitle": "Dashboard",
"welcomeTitle": "Selamat Datang di kreatiVortex!",
"welcomeSubtitle": "Platform pembelajaran tari tradisional Indonesia",
"statTotalVideos": "Total Video",
"statActiveClasses": "Kelas Aktif",
"statCompletedAssignments": "Tugas Selesai",
"statForumPosts": "Forum Post",
"recentVideosTitle": "Video Terbaru",
"recentDiscussionsTitle": "Diskusi Terbaru",
"sampleVideo1": "Tari Piring - Dasar",
"sampleVideo2": "Tari Saman - Gerakan Tangan",
"sampleDiscussion1Title": "Tips untuk pemula Tari Piring",
"sampleDiscussion1Content": "Saya ingin berbagi beberapa tips untuk yang baru mulai belajar...",
"sampleDiscussion2Title": "Costum untuk pertunjukan",
"sampleDiscussion2Content": "Apakah ada saran untuk costum yang tepat untuk pertunjukan tari...",
"timeAgo": "{hours} jam yang lalu"
},
"Classes": {
"title": "Kelas Saya",
"subtitle": "Daftar kelas yang Anda ikuti atau ajar",
"createButton": "Buat Kelas Baru",
"active": "Aktif",
"students": "Siswa",
"scheduleNotSet": "Jadwal belum diatur",
"unknownEducator": "Tidak Diketahui",
"enterClass": "Masuk Kelas",
"noClasses": "Belum ada kelas yang tersedia.",
"loading": "Memuat..."
},
"Forum": {
"title": "Forum Diskusi",
"subtitle": "Berdiskusi dengan komunitas kreatiVortex",
"createButton": "Buat Diskusi Baru",
"filterAll": "Semua Diskusi",
"filterAnnouncements": "Pengumuman",
"filterTechnique": "Teknik Tari",
"filterCostume": "Kostum & Rias",
"filterMyClasses": "Kelas Saya",
"posts": "Postingan",
"noForums": "Belum ada diskusi yang dibuat."
},
"Videos": {
"title": "Video Pembelajaran",
"subtitle": "Kelola koleksi video pembelajaran tari Anda",
"uploadButton": "Upload Video Baru",
"colTitle": "Judul Video",
"colType": "Tipe",
"colViews": "Dilihat",
"viewsSuffix": "kali",
"colStatus": "Status",
"statusPublic": "Publik",
"statusPrivate": "Privat",
"colAction": "Aksi",
"actionView": "Lihat",
"actionEdit": "Edit",
"noVideos": "Belum ada video yang diunggah"
},
"Menu": {
"title": "Manajemen Menu",
"subtitle": "Kelola menu aplikasi",
"addButton": "Tambah Menu",
"editTitle": "Edit Menu",
"addTitle": "Tambah Menu Baru",
"nameIdLabel": "Nama (Indonesia)",
"nameEnLabel": "Nama (English) - Opsional",
"descriptionIdLabel": "Deskripsi (Indonesia)",
"descriptionEnLabel": "Deskripsi (English) - Opsional",
"slugLabel": "Slug (URL)",
"parentLabel": "Parent Menu",
"parentNone": "None (Top Level)",
"activeLabel": "Aktif",
"cancelButton": "Batal",
"saveButton": "Simpan Perubahan",
"createButton": "Buat Menu",
"saving": "Menyimpan...",
"tableName": "Nama",
"tableSlug": "Slug",
"tableParent": "Parent",
"tableStatus": "Status",
"tableActions": "Aksi",
"statusActive": "Aktif",
"statusInactive": "Nonaktif",
"noMenus": "Belum ada menu. Silakan tambah menu baru.",
"confirmDelete": "Apakah Anda yakin ingin menghapus menu ini?",
"errorOccurred": "Terjadi kesalahan",
"nameIdRequired": "Nama (ID) wajib diisi",
"descriptionIdRequired": "Deskripsi (ID) wajib diisi",
"slugRequired": "Slug wajib diisi"
}
}

View File

@ -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);

View File

@ -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"
]
},
}
}

View File

@ -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"
}
}

View File

@ -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'),

View File

@ -0,0 +1,247 @@
-- CreateEnum
CREATE TYPE "VideoType" AS ENUM ('YOUTUBE', 'LOCAL');
-- CreateEnum
CREATE TYPE "ForumType" AS ENUM ('GENERAL', 'CLASS');
-- CreateEnum
CREATE TYPE "SubmissionStatus" AS ENUM ('SUBMITTED', 'REVIEWED', 'REVISED', 'APPROVED');
-- CreateTable
CREATE TABLE "user_roles" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"permissions" TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_profiles" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"nim" TEXT,
"phone" TEXT,
"address" TEXT,
"bio" TEXT,
"avatar" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_profiles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "classes" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"code" TEXT NOT NULL,
"educatorId" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"maxStudents" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdBy" TEXT NOT NULL,
"updatedBy" TEXT NOT NULL,
CONSTRAINT "classes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "class_members" (
"id" TEXT NOT NULL,
"classId" TEXT NOT NULL,
"studentId" TEXT NOT NULL,
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "class_members_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "videos" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"videoUrl" TEXT NOT NULL,
"videoType" "VideoType" NOT NULL DEFAULT 'YOUTUBE',
"thumbnailUrl" TEXT,
"duration" INTEGER,
"uploaderId" TEXT NOT NULL,
"classId" TEXT,
"isPublic" BOOLEAN NOT NULL DEFAULT true,
"viewCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdBy" TEXT NOT NULL,
"updatedBy" TEXT NOT NULL,
CONSTRAINT "videos_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "forums" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"type" "ForumType" NOT NULL DEFAULT 'GENERAL',
"classId" TEXT,
"createdBy" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"updatedBy" TEXT NOT NULL,
CONSTRAINT "forums_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "forum_posts" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"forumId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"parentId" TEXT,
"isPinned" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"updatedBy" TEXT NOT NULL,
CONSTRAINT "forum_posts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "assignments" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"dueDate" TIMESTAMP(3) NOT NULL,
"classId" TEXT NOT NULL,
"educatorId" TEXT NOT NULL,
"maxScore" INTEGER NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdBy" TEXT NOT NULL,
"updatedBy" TEXT NOT NULL,
CONSTRAINT "assignments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "assignment_submissions" (
"id" TEXT NOT NULL,
"assignmentId" TEXT NOT NULL,
"studentId" TEXT NOT NULL,
"documentUrl" TEXT NOT NULL,
"documentType" TEXT NOT NULL,
"score" INTEGER,
"feedback" TEXT,
"status" "SubmissionStatus" NOT NULL DEFAULT 'SUBMITTED',
"submittedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"reviewedAt" TIMESTAMP(3),
"reviewedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"updatedBy" TEXT NOT NULL,
CONSTRAINT "assignment_submissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "comments" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"videoId" TEXT,
"forumPostId" TEXT,
"parentId" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"updatedBy" TEXT NOT NULL,
CONSTRAINT "comments_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "user_roles_name_key" ON "user_roles"("name");
-- CreateIndex
CREATE UNIQUE INDEX "user_profiles_userId_key" ON "user_profiles"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "classes_code_key" ON "classes"("code");
-- CreateIndex
CREATE UNIQUE INDEX "class_members_classId_studentId_key" ON "class_members"("classId", "studentId");
-- AddForeignKey
ALTER TABLE "user_profiles" ADD CONSTRAINT "user_profiles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_profiles" ADD CONSTRAINT "user_profiles_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "user_roles"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "classes" ADD CONSTRAINT "classes_educatorId_fkey" FOREIGN KEY ("educatorId") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "class_members" ADD CONSTRAINT "class_members_classId_fkey" FOREIGN KEY ("classId") REFERENCES "classes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "class_members" ADD CONSTRAINT "class_members_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "videos" ADD CONSTRAINT "videos_uploaderId_fkey" FOREIGN KEY ("uploaderId") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "videos" ADD CONSTRAINT "videos_classId_fkey" FOREIGN KEY ("classId") REFERENCES "classes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "forums" ADD CONSTRAINT "forums_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "forums" ADD CONSTRAINT "forums_classId_fkey" FOREIGN KEY ("classId") REFERENCES "classes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "forum_posts" ADD CONSTRAINT "forum_posts_forumId_fkey" FOREIGN KEY ("forumId") REFERENCES "forums"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "forum_posts" ADD CONSTRAINT "forum_posts_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "forum_posts" ADD CONSTRAINT "forum_posts_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "forum_posts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "assignments" ADD CONSTRAINT "assignments_classId_fkey" FOREIGN KEY ("classId") REFERENCES "classes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "assignments" ADD CONSTRAINT "assignments_educatorId_fkey" FOREIGN KEY ("educatorId") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "assignment_submissions" ADD CONSTRAINT "assignment_submissions_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "assignments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "assignment_submissions" ADD CONSTRAINT "assignment_submissions_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "assignment_submissions" ADD CONSTRAINT "assignment_submissions_reviewedBy_fkey" FOREIGN KEY ("reviewedBy") REFERENCES "user_profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "user_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "videos"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_forumPostId_fkey" FOREIGN KEY ("forumPostId") REFERENCES "forum_posts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "comments"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -187,9 +187,11 @@ 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")
@ -223,6 +225,7 @@ model ForumPost {
parentId String? // for replies
isPinned Boolean @default(false)
isActive Boolean @default(true)
attachments Json @default("[]")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
updatedBy String
@ -287,6 +290,7 @@ model Comment {
videoId String?
forumPostId String?
parentId String? // for replies
attachments Json @default("[]")
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -301,6 +305,24 @@ model Comment {
@@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
// ========================================

188
prisma/seed.ts Normal file
View File

@ -0,0 +1,188 @@
import { prisma } from '../lib/prisma';
import { auth } from '../lib/auth';
async function main() {
console.log('Start seeding...');
// 1. Create Roles
const adminRole = await prisma.userRole.upsert({
where: { name: 'ADMIN' },
update: {},
create: {
name: 'ADMIN',
description: 'Administrator with full access',
permissions: ['*'],
},
});
await prisma.userRole.upsert({
where: { name: 'PENDIDIK' },
update: {},
create: {
name: 'PENDIDIK',
description: 'Educator who can create classes and content',
permissions: ['class:create', 'class:manage', 'content:create', 'assignment:create', 'forum:moderate'],
},
});
await prisma.userRole.upsert({
where: { name: 'CALON_PENDIDIK' },
update: {},
create: {
name: 'CALON_PENDIDIK',
description: 'Prospective educator/student',
permissions: ['class:join', 'content:view', 'assignment:submit', 'forum:participate'],
},
});
await prisma.userRole.upsert({
where: { name: 'UMUM' },
update: {},
create: {
name: 'UMUM',
description: 'General user with basic access',
permissions: ['content:view', 'forum:general'],
},
});
console.log('Roles created/verified.');
// 2. Create Admin User
const adminEmail = 'admin@kreativortex.com';
const adminPassword = 'adminpassword123'; // Change this in production!
let adminUserId: string;
const existingUser = await prisma.user.findUnique({
where: { email: adminEmail },
});
if (existingUser) {
adminUserId = existingUser.id;
} else {
console.log('Creating admin user...');
const res = await auth.api.signUpEmail({
body: {
email: adminEmail,
password: adminPassword,
name: 'Admin KreatiVortex',
image: 'https://ui-avatars.com/api/?name=Admin+KV&background=ffd700&color=000',
},
});
adminUserId = res.user.id;
}
// 4. Create Admin Profile
await prisma.userProfile.upsert({
where: { userId: adminUserId },
update: {
roleId: adminRole.id,
},
create: {
userId: adminUserId,
roleId: adminRole.id,
bio: 'Administrator of KreatiVortex Platform',
},
});
console.log(`Admin user created with email: ${adminEmail}`);
// 5. Seed Menus
const menus = [
{
name: { id: 'Teori', en: 'Theory' },
description: { id: 'Menu pembelajaran teori tari', en: 'Dance theory learning menu' },
children: [
'Pengetahuan dasar tari',
'Unsur utama gerak',
'Unsur utama pertunjukan tari',
'Penciptaan tari dan pengambangan diri',
],
},
{
name: { id: 'Praktik', en: 'Practice' },
description: { id: 'Menu pembelajaran praktik tari', en: 'Dance practice learning menu' },
children: ['Olah Tubuh', 'Imitasi Gerak', 'Gerak Dasar'],
},
{
name: { id: 'Template Makalah', en: 'Paper Template' },
description: { id: 'Template penyusunan makalah', en: 'Paper composition template' },
children: [],
},
{
name: { id: 'Tempo', en: 'Tempo' },
description: { id: 'Latihan tempo gerak', en: 'Movement tempo practice' },
children: [],
},
];
// Helper to create slugs
const slugify = (text: string) =>
text
.toLowerCase()
.replace(/ /g, '-')
.replace(/[^\w-]+/g, '');
for (const menuData of menus) {
const menuSlug = slugify(menuData.name.id);
// Check if parent menu exists
let parentMenu = await prisma.menu.findFirst({
where: {
slug: menuSlug,
parentId: null,
},
});
if (!parentMenu) {
console.log(`Creating menu: ${menuData.name.id} (${menuSlug})`);
parentMenu = await prisma.menu.create({
data: {
name: menuData.name,
slug: menuSlug,
description: menuData.description,
updatedBy: adminUserId,
},
});
} else {
console.log(`Menu already exists: ${menuData.name.id}`);
}
// Create children
for (const childName of menuData.children) {
const childNameJson = { id: childName, en: childName };
const childSlug = slugify(childName);
const childMenu = await prisma.menu.findFirst({
where: {
slug: childSlug,
parentId: parentMenu.id,
},
});
if (!childMenu) {
console.log(`Creating sub-menu: ${childName} (${childSlug})`);
await prisma.menu.create({
data: {
name: childNameJson,
slug: childSlug,
description: { id: childName, en: childName },
parentId: parentMenu.id,
updatedBy: adminUserId,
},
});
}
}
}
console.log('Seeding finished.');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

44
proxy.ts Normal file
View File

@ -0,0 +1,44 @@
import createMiddleware from 'next-intl/middleware';
import {routing} from './i18n/routing';
import { NextRequest, NextResponse } from 'next/server';
const handleI18n = createMiddleware(routing);
export function proxy(request: NextRequest) {
const response = handleI18n(request);
// If next-intl redirects (e.g. for locale prefix), let it pass
if (response.status >= 300 && response.status < 400) {
return response;
}
// Auth check logic
const sessionCookie = request.cookies.get("better-auth.session_token");
const { pathname } = request.nextUrl;
// Check if accessing dashboard
// Matches /dashboard, /en/dashboard, /id/dashboard, etc.
const isDashboardRoute = pathname === '/dashboard' ||
pathname.startsWith('/dashboard/') ||
routing.locales.some(locale => pathname.startsWith(`/${locale}/dashboard`));
if (isDashboardRoute && !sessionCookie) {
// Extract locale from path to maintain consistency, fallback to default
const segments = pathname.split('/');
const potentialLocale = segments[1];
const locale = routing.locales.includes(potentialLocale as any)
? potentialLocale
: routing.defaultLocale;
return NextResponse.redirect(new URL(`/${locale}/auth/signin`, request.url));
}
return response;
}
export const config = {
// Match all pathnames except for
// - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel`
// - … the ones containing a dot (e.g. `favicon.ico`)
matcher: ['/((?!api|trpc|_next|_vercel|.*\\..*).*)']
};

BIN
public/bg-2.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

BIN
public/bg.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

BIN
public/bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
public/bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 953 KiB