commit 81ad7e4ea9096ddb1a5747de9690ccf17322ca2e
Author: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com>
Date: Thu Jun 11 10:46:09 2026 +0200
Initial commit
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..0c4a88d
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,37 @@
+.git
+.gitignore
+node_modules
+.next
+.env.local
+.env.*.local
+.DS_Store
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+.turbo
+.swc
+.vscode
+.idea
+*.swp
+*.swo
+*~
+.cache
+coverage
+dist
+build
+out
+.env.example
+README.md
+LICENSE
+.editorconfig
+.prettierrc
+.eslintrc*
+.gitattributes
+public/videos
+public/thumbnails
+# Note: public/uploads is no longer used; uploads are stored in UPLOADS_DIR (/uploads)
+uploads
+.turbopack
+.turbopack-cache
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7478d14
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,47 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+
+/lib/generated/prisma
+
+# uploaded media (large binary files - store outside git)
+/public/videos/
+/public/thumbnails/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..2b94fdb
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,46 @@
+# Multi-stage Dockerfile for Next.js CMS
+# Stage 1: Build
+FROM node:20-alpine AS builder
+
+WORKDIR /app
+
+# Copy package files
+COPY package.json pnpm-lock.yaml ./
+
+# Install dependencies
+RUN npm install -g pnpm && pnpm install --frozen-lockfile
+
+# Copy source code
+COPY . .
+
+# Generate Prisma client
+RUN pnpm exec prisma generate
+
+# Build Next.js application
+RUN npm run build
+
+# Stage 2: Production Runtime
+FROM node:20-alpine
+
+WORKDIR /app
+
+# Install dumb-init for proper signal handling
+RUN apk add --no-cache dumb-init
+
+# Copy from builder: node_modules and .next build output
+COPY --from=builder /app/node_modules ./node_modules
+COPY --from=builder /app/.next ./.next
+COPY --from=builder /app/public ./public
+COPY --from=builder /app/package.json ./package.json
+
+# Set production environment
+ENV NODE_ENV=production
+
+# Expose port 3000
+EXPOSE 3000
+
+# Use dumb-init to handle signals properly
+ENTRYPOINT ["dumb-init", "--"]
+
+# Start Next.js production server
+CMD ["npm", "run", "start"]
diff --git a/Dockerfile.transcoder b/Dockerfile.transcoder
new file mode 100644
index 0000000..e56c3d4
--- /dev/null
+++ b/Dockerfile.transcoder
@@ -0,0 +1,52 @@
+# ==============================
+# Build stage
+# ==============================
+FROM node:20-alpine AS builder
+
+WORKDIR /app
+
+# Copy package files and Prisma schema FIRST
+COPY transcoder/package.json transcoder/tsconfig.json ./
+COPY prisma ./prisma
+
+# Set a temporary DATABASE_URL for Prisma schema generation during build
+ENV DATABASE_URL="postgresql://user:password@localhost:5432/dummy"
+
+# Install all deps (including dev)
+RUN npm install
+
+# Copy source
+COPY transcoder ./
+
+# Build TypeScript
+RUN npm run build
+
+
+# ==============================
+# Runtime stage
+# ==============================
+FROM node:20-alpine
+
+# Install FFmpeg
+RUN apk add --no-cache ffmpeg
+
+WORKDIR /app
+
+# Copy package files
+COPY transcoder/package.json ./
+
+# Copy node_modules from builder (includes all dependencies)
+COPY --from=builder /app/node_modules ./node_modules
+
+# Copy compiled output
+COPY --from=builder /app/dist ./dist
+
+# Copy Prisma schema
+COPY prisma ./prisma
+
+# Environment defaults (can be overridden)
+ENV NODE_ENV=production
+ENV UPLOADS_DIR=/uploads
+
+# The container runs once and exits
+CMD ["node", "dist/index.js"]
diff --git a/TRANSCODING.md b/TRANSCODING.md
new file mode 100644
index 0000000..2363af1
--- /dev/null
+++ b/TRANSCODING.md
@@ -0,0 +1,277 @@
+# HLS Transcoding Setup Documentation
+
+## Overview
+
+This system implements HLS (HTTP Live Streaming) encoding and adaptive bitrate streaming for video content. Videos are stored as MP4s initially and are automatically transcoded to HLS format using FFmpeg.
+
+## Architecture
+
+### Components
+
+1. **CMS Application** (Next.js)
+ - Allows admin users to upload MP4 videos
+ - Videos are stored in `/uploads/videos/{video_id}.mp4`
+ - Videos are marked with `transcodingStatus = 'uploaded'` initially
+ - Video player supports HLS with MP4 fallback
+
+2. **Transcoder Service** (Node.js + FFmpeg)
+ - Runs as a batch job, processing all videos with `transcodingStatus = 'uploaded'`
+ - Reads MP4 from storage and converts to HLS format
+ - Creates multiple quality variants (based on source resolution):
+ - **1080p**: 1920x1080 @ 3500 kbps (if source supports)
+ - **720p**: 1280x720 @ 1800 kbps (if source supports)
+ - **480p**: 854x480 @ 900 kbps (if source supports)
+ - Stores files in `/uploads/hls/{video_id}.tmp/` during transcoding
+ - Creates a master playlist (`master.m3u8`) combining all variants
+ - Atomically renames folder from `.tmp` to final location
+ - Updates database to `transcodingStatus = 'transcoded'` on success
+ - Marks as `'failed'` if transcoding fails
+ - Runs in a loop with configurable delay between batches
+
+3. **Database Schema**
+ - `Video` model includes `transcodingStatus` field
+ - Valid values: `'uploaded'` | `'processing'` | `'transcoded'` | `'failed'`
+
+4. **Video Player**
+ - Uses `HlsPlayer` component with HLS.js
+ - Automatically selects source based on availability:
+ - If `transcodingStatus = 'transcoded'`: Uses `/api/videos/hls/{video_id}/master.m3u8`
+ - Otherwise: Falls back to original MP4 file
+
+## Directory Structure
+
+```
+/uploads/
+├── videos/
+│ ├── {video_id_1}.mp4
+│ ├── {video_id_2}.mp4
+│ └── ...
+└── hls/
+ ├── {video_id_1}/
+ │ ├── master.m3u8 (Master playlist)
+ │ ├── 1080p.m3u8
+ │ ├── 1080p_000.ts
+ │ ├── 1080p_001.ts
+ │ ├── 720p.m3u8
+ │ ├── 720p_000.ts
+ │ ├── 480p.m3u8
+ │ └── ...
+ └── {video_id_2}/
+ └── ...
+```
+
+## Database Migration
+
+The migration adds `transcodingStatus` field to the `Video` model:
+
+```sql
+ALTER TABLE "Video" ADD COLUMN "transcodingStatus" TEXT NOT NULL DEFAULT 'uploaded';
+CREATE INDEX "Video_transcodingStatus_idx" ON "Video"("transcodingStatus");
+```
+
+## Docker Setup
+
+### Building the Transcoder
+
+The transcoder is built as a multi-stage Docker image:
+
+```bash
+docker build -f Dockerfile.transcoder -t vault-transcoder:latest .
+```
+
+### Running with Docker Compose
+
+```bash
+docker-compose up -d
+```
+
+Services started:
+- **cms** (port 3000): Next.js CMS application
+- **postgres**: PostgreSQL database
+- **transcoder**: HLS transcoding service
+
+### Environment Variables
+
+Create a `.env` file in the project root:
+
+```
+# Transcoder (set automatically via docker-compose)
+DATABASE_URL=postgresql://cms_user:changeme@postgres:5432/cms_db
+UPLOADS_DIR=/uploads
+POLL_INTERVAL=5000
+CONCURRENT_JOBS=2
+
+# CMS specific (as before)
+NEXTAUTH_SECRET=your-secret-key
+NEXTAUTH_URL=http://localhost:3000
+GOOGLE_CLIENT_ID=...
+GOOGLE_CLIENT_SECRET=...
+ALLOWED_ADMINS=admin@university.edu
+
+# Paths
+UPLOADS_PATH=/mnt/tank/apps/college-platform/uploads
+POSTGRES_DATA_PATH=/mnt/tank/apps/college-platform/postgres_data
+```
+
+## API Endpoints
+
+### Get Video Details
+
+```
+GET /api/videos/{videoId}
+```
+
+Response includes:
+
+```json
+{
+ "video": {
+ "id": "...",
+ "title": "...",
+ "url": "/videos/{video_id}.mp4",
+ "transcodingStatus": "transcoded",
+ "videoUrls": {
+ "hlsUrl": "/api/videos/hls/{video_id}/master.m3u8",
+ "mp4Url": "/videos/{video_id}.mp4"
+ }
+ }
+}
+```
+
+## File Format: HLS Playlist Examples
+
+### Master Playlist (`master.m3u8`)
+
+```m3u8
+#EXTM3U
+#EXT-X-VERSION:3
+#EXT-X-STREAM-INF:BANDWIDTH=3500000,RESOLUTION=1920x1080
+1080p.m3u8
+#EXT-X-STREAM-INF:BANDWIDTH=1800000,RESOLUTION=1280x720
+720p.m3u8
+#EXT-X-STREAM-INF:BANDWIDTH=900000,RESOLUTION=854x480
+480p.m3u8
+```
+
+### Variant Playlist (`1080p.m3u8`)
+
+```m3u8
+#EXTM3U
+#EXT-X-VERSION:3
+#EXT-X-TARGETDURATION:6
+#EXT-X-PLAYLIST-TYPE:VOD
+#EXTINF:6.0,
+1080p_000.ts
+#EXTINF:6.0,
+1080p_001.ts
+...
+#EXT-X-ENDLIST
+```
+
+## Development Mode
+
+For local development without Docker:
+
+1. Ensure FFmpeg is installed on your system
+2. Set environment variables:
+ ```bash
+ export DATABASE_URL=postgresql://cms_user:changeme@localhost:5432/cms_db
+ export UPLOADS_DIR=/path/to/uploads
+ ```
+3. Run the transcoder:
+ ```bash
+ cd transcoder
+ npm install
+ npm run dev
+ ```
+
+## Monitoring & Logs
+
+### CMS Logs
+
+```bash
+docker logs college-cms
+```
+
+### Transcoder Logs
+
+```bash
+docker logs college-transcoder
+```
+
+Look for lines starting with:
+- `[Transcoding]` - Transcoding progress
+- `[HLS]` - HLS specific operations
+- `[Database]` - Database updates
+- `[Error]` - Any errors encountered
+
+### Checking Transcoding Status
+
+```bash
+# Check video transcoding status
+psql -U cms_user -d cms_db -c "SELECT id, title, transcodingStatus FROM \"Video\";"
+
+# Monitor real-time transcoding
+watch -n 1 'docker exec college-transcoder head -20 /app/transcoder.log'
+```
+
+## Troubleshooting
+
+### Transcoder Not Finding Videos
+
+**Issue**: Transcoder logs show "MP4 file not found"
+
+**Solution**:
+1. Verify MP4 files are in `/uploads/videos/` directory
+2. Check file permissions: `chmod 644 /uploads/videos/*.mp4`
+3. Ensure the path matches the `UPLOADS_DIR` environment variable
+
+### HLS Files Not Generated
+
+**Issue**: Videos stay in `'uploaded'` state
+
+**Solution**:
+1. Check FFmpeg is installed in container: `docker exec college-transcoder ffmpeg -version`
+2. Check database connectivity: `docker logs college-transcoder | grep "DATABASE_URL"`
+3. Verify disk space: `df -h /uploads`
+
+### Video Player Shows MP4 Instead of HLS
+
+**Issue**: HLS not playing even after transcoding
+
+**Solution**:
+1. Verify `transcodingStatus = 'transcoded'` in database
+2. Check HLS files exist: `ls /uploads/hls/{video_id}/`
+3. Verify master playlist is valid: `cat /uploads/hls/{video_id}/master.m3u8`
+
+### Performance Issues
+
+**Recommendation**: Adjust `CONCURRENT_JOBS` based on available system resources:
+- 1-2 jobs for systems with <4 CPU cores
+- 2-4 jobs for systems with 4-8 CPU cores
+- Increase `POLL_INTERVAL` if CPU usage is high
+
+## Client-Side Configuration
+
+The video player is configured to:
+
+1. Try HLS playlist first if available
+2. Fall back to MP4 on HLS failure
+3. Use adaptive bitrate selection when HLS is available
+4. Support all modern browsers (Chrome, Firefox, Safari, Edge)
+
+## Security Considerations
+
+1. **Storage Access**: Only the transcoder container has write access to `/uploads/hls/`
+2. **Database**: Use strong PostgreSQL password in production
+3. **Network**: Both containers connect through Docker network, no external ports exposed
+4. **File Validation**: Consider adding video file validation before transcoding
+
+## Future Enhancements
+
+1. Add thumbnail extraction during transcoding
+2. Implement retry logic for failed transcoding jobs
+3. Add progress tracking API for long-running transcodes
+4. Support multiple audio tracks/subtitles
+5. Implement cache invalidation for CDN
+6. Add metrics and monitoring dashboard
diff --git a/UI_DESIGN_SYSTEM.md b/UI_DESIGN_SYSTEM.md
new file mode 100644
index 0000000..a58b0be
--- /dev/null
+++ b/UI_DESIGN_SYSTEM.md
@@ -0,0 +1,1138 @@
+# OW Animation Arts Vault - UI Design System & Layout Guide
+
+This document outlines the complete UI/style and layout architecture used in this codebase. Use this guide to create additional applications for the same client with consistent design and user experience.
+
+---
+
+## Table of Contents
+
+1. [Technology Stack](#technology-stack)
+2. [Project Architecture](#project-architecture)
+3. [Color System & Theming](#color-system--theming)
+4. [Layout System](#layout-system)
+5. [Navigation & Sidebar](#navigation--sidebar)
+6. [Page Layouts](#page-layouts)
+7. [Component Library](#component-library)
+8. [Typography & Spacing](#typography--spacing)
+9. [Form Patterns](#form-patterns)
+10. [Responsive Design](#responsive-design)
+11. [Dark Mode Implementation](#dark-mode-implementation)
+12. [Reusable Patterns](#reusable-patterns)
+13. [Asset Guidelines](#asset-guidelines)
+
+---
+
+## 1. Technology Stack
+
+### Core Framework
+- **Next.js 16.0.3** - React framework with App Router
+- **React 18+** - UI library
+- **TypeScript** - Type safety
+- **Tailwind CSS 3.4+** - Utility-first CSS framework
+
+### UI Component Library
+- **shadcn/ui** - Pre-built, composable React components
+- **@radix-ui/** - Unstyled, accessible component primitives
+- **Lucide React** - Beautiful, consistent icon library
+- **Tabler Icons** - Alternative icon set
+
+### State Management & Data
+- **NextAuth.js** - Authentication
+- **Next-Themes** - Theme management (dark/light mode)
+- **Prisma ORM** - Database abstraction
+- **TanStack React Table** - Advanced table data management
+- **Sonner** - Toast notifications
+
+### Additional Libraries
+- **@dnd-kit** - Drag and drop functionality
+- **Embla Carousel** - Carousel/slider component
+- **Recharts** - Data visualization charts
+- **HLS.js** - Video streaming (HLS protocol)
+- **date-fns** - Date utilities
+- **class-variance-authority (CVA)** - Component variant management
+- **clsx** - Conditional className merging
+
+---
+
+## 2. Project Architecture
+
+### Directory Structure
+
+```
+app/
+├── layout.tsx # Root layout (server component)
+├── page.tsx # Landing/home page
+├── providers.tsx # Client-side providers (SessionProvider, ThemeProvider)
+├── globals.css # Global styles & CSS variables
+├── login/
+│ ├── page.tsx # Login page
+│ └── unauthorized/
+├── dashboard/
+│ ├── page.tsx # Protected dashboard
+│ ├── dashboard-client.tsx # Client component with content
+│ ├── liked-videos/
+│ └── watch-history/
+├── admin/
+│ ├── page.tsx # Admin gate (server component)
+│ ├── admin-client.tsx # Admin UI (client component)
+│ ├── users/
+│ ├── courses/
+│ ├── playlists/
+│ ├── videos/
+│ ├── enrollments/
+│ └── stats/
+├── api/ # API routes for server operations
+└── videoplayer/ # Video player page
+
+components/
+├── app-sidebar.tsx # Main navigation sidebar
+├── site-header.tsx # Top header with breadcrumbs
+├── nav-main.tsx # Primary navigation menu
+├── nav-secondary.tsx # Secondary navigation items
+├── nav-user.tsx # User profile dropdown
+├── login-form.tsx # Google OAuth login form
+├── data-table.tsx # Advanced data table component
+├── hls.tsx # Video player (HLS streaming)
+├── chart-area-interactive.tsx # Interactive chart component
+├── comments-section.tsx # Comments/discussion area
+├── admin-notifications.tsx # Admin notifications
+├── ui/ # shadcn/ui components
+│ ├── button.tsx
+│ ├── card.tsx
+│ ├── sidebar.tsx
+│ ├── input.tsx
+│ ├── label.tsx
+│ ├── badge.tsx
+│ ├── dropdown-menu.tsx
+│ ├── dialog.tsx
+│ ├── drawer.tsx
+│ ├── table.tsx
+│ ├── tabs.tsx
+│ ├── carousel.tsx
+│ ├── chart.tsx
+│ ├── select.tsx
+│ ├── checkbox.tsx
+│ ├── field.tsx
+│ ├── collapsible.tsx
+│ └── ... (27+ components)
+
+lib/
+├── utils.ts # Utility functions (cn for className merging)
+├── auth-options.ts # NextAuth configuration
+├── auth-check.ts # Authentication helpers
+├── prisma.ts # Prisma client
+└── video-urls.ts # Video URL generation
+
+hooks/
+├── use-mobile.ts # Mobile breakpoint hook
+├── useVideo.ts # Video data hooks
+├── usePlaylists.ts # Playlist data hooks
+└── usePlaylist.ts # Single playlist hook
+
+public/
+├── icon.svg # App icon
+├── vault.png # Vault image (login page)
+└── vault.exr # Vault image (high-res)
+```
+
+### Component Structure Pattern
+
+**Server Component (pages):**
+```tsx
+// app/dashboard/page.tsx
+import { requireUser } from '@/lib/auth-check';
+import DashboardClient from './dashboard-client';
+
+export default async function DashboardPage() {
+ const session = await requireUser('/login');
+ return ;
+}
+```
+
+**Client Component (content):**
+```tsx
+// app/dashboard/dashboard-client.tsx
+'use client';
+import React from 'react';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
+
+export default function DashboardClient() {
+ return (
+
+
+
+
+
+ {/* Page content */}
+
+
+
+ );
+}
+```
+
+---
+
+## 3. Color System & Theming
+
+### CSS Variables (OKLch Color Space)
+
+The design system uses **OKLch color space** for perceptually uniform colors across light and dark modes.
+
+**Light Mode (`:root`):**
+```css
+--background: oklch(1 0 0); /* White */
+--foreground: oklch(0.141 0.005 285.823); /* Near Black */
+--card: oklch(1 0 0); /* White */
+--card-foreground: oklch(0.141 0.005 285.823); /* Near Black */
+--primary: oklch(0.646 0.222 41.116); /* Warm Yellow/Orange */
+--primary-foreground: oklch(0.98 0.016 73.684); /* Light cream */
+--secondary: oklch(0.967 0.001 286.375); /* Light gray */
+--secondary-foreground: oklch(0.21 0.006 285.885);
+--muted: oklch(0.967 0.001 286.375); /* Light gray (buttons, etc) */
+--muted-foreground: oklch(0.552 0.016 285.938); /* Medium gray (text) */
+--accent: oklch(0.967 0.001 286.375); /* Light gray */
+--accent-foreground: oklch(0.21 0.006 285.885);
+--destructive: oklch(0.577 0.245 27.325); /* Red */
+--border: oklch(0.92 0.004 286.32); /* Light gray */
+--input: oklch(0.92 0.004 286.32); /* Light gray */
+--ring: oklch(0.75 0.183 55.934); /* Orange/Gold for focus */
+```
+
+**Dark Mode (`.dark`):**
+```css
+.dark {
+ --background: oklch(0.141 0.005 285.823); /* Near Black */
+ --foreground: oklch(0.985 0 0); /* White */
+ --card: oklch(0.21 0.006 285.885); /* Dark gray */
+ --card-foreground: oklch(0.985 0 0); /* White */
+ --primary: oklch(0.705 0.213 47.604); /* Lighter Orange */
+ --secondary: oklch(0.274 0.006 286.033); /* Dark gray */
+ --muted: oklch(0.274 0.006 286.033); /* Dark gray */
+ --muted-foreground: oklch(0.705 0.015 286.067);
+ --border: oklch(1 0 0 / 10%); /* White with 10% opacity */
+ --input: oklch(1 0 0 / 15%); /* White with 15% opacity */
+ --ring: oklch(0.408 0.123 38.172); /* Red-orange for focus */
+}
+```
+
+### Chart Colors
+```css
+--chart-1: oklch(0.837 0.128 66.29); /* Warm yellow */
+--chart-2: oklch(0.705 0.213 47.604); /* Orange */
+--chart-3: oklch(0.646 0.222 41.116); /* Dark orange */
+--chart-4: oklch(0.553 0.195 38.402); /* Red-orange */
+--chart-5: oklch(0.47 0.157 37.304); /* Deep red-orange */
+```
+
+### Sidebar Colors
+```css
+--sidebar: oklch(0.985 0 0);
+--sidebar-foreground: oklch(0.141 0.005 285.823);
+--sidebar-primary: oklch(0.646 0.222 41.116);
+--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
+--sidebar-accent: oklch(0.967 0.001 286.375);
+--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
+--sidebar-border: oklch(0.92 0.004 286.32);
+--sidebar-ring: oklch(0.75 0.183 55.934);
+```
+
+### Color Usage
+
+| Color | Usage |
+|-------|-------|
+| **Primary** | Main CTA buttons, active links, selected items |
+| **Secondary** | Secondary buttons, badges |
+| **Muted** | Disabled states, placeholder text, subtle backgrounds |
+| **Accent** | Hover states, subtle highlights |
+| **Destructive** | Delete buttons, error states |
+| **Ring** | Focus indicators, form input focus |
+
+---
+
+## 4. Layout System
+
+### Main Layout Structure
+
+The application uses a **sidebar + content** layout pattern:
+
+```
+┌─────────────────────────────┐
+│ SidebarProvider │
+├──────────┬──────────────────┤
+│ │ │
+│AppSidebar│ SidebarInset │
+│ ├──────────────────┤
+│ │ SiteHeader │
+│ Logo │ (breadcrumbs) │
+│ Nav ├──────────────────┤
+│ Items │ │
+│ │ Main Content │
+│ Footer │ │
+│ (User) │ │
+│ │ │
+└──────────┴──────────────────┘
+```
+
+### Layout Implementation
+
+```tsx
+// Standard page layout
+export default function DashboardClient() {
+ return (
+
+
+
+
+
+ {/* Page content */}
+
+
+
+ );
+}
+```
+
+### Content Container Sizing
+
+```tsx
+// Wide content area with centered max-width
+
+ {/* Content */}
+
+
+// Grid layout patterns
+
+ {/* Columns adjust: 1 on mobile, 3 on desktop */}
+
+
+
+ {/* Responsive grid: 1 → 2 → 3 columns */}
+
+```
+
+---
+
+## 5. Navigation & Sidebar
+
+### AppSidebar Component
+
+Located in `components/app-sidebar.tsx`
+
+**Features:**
+- Collapsible sidebar (offcanvas mode on mobile)
+- Uses `next-auth` for user session and roles
+- Dynamic navigation based on user role (admin/superadmin/user)
+- Logo and brand in header
+- User profile footer with avatar
+
+**Navigation Items:**
+
+**For Regular Users:**
+- Library → `/dashboard`
+- Liked Videos → `/dashboard/liked-videos`
+- History → `/dashboard/watch-history`
+- Search → `#` (secondary item)
+
+**For Admins/Superadmins (Additional):**
+- Admin (collapsible parent with submenu):
+ - Manage Users → `/admin/users`
+ - Manage Enrollments → `/admin/enrollments`
+ - Manage Courses → `/admin/courses`
+ - Manage Playlists → `/admin/playlists`
+ - Manage Videos → `/admin/videos`
+
+### SiteHeader Component
+
+Located in `components/site-header.tsx`
+
+**Features:**
+- Responsive breadcrumb navigation
+- Sidebar toggle trigger button
+- Dynamic breadcrumbs based on current route
+- Breadcrumb mapping for all main routes
+
+**Breadcrumb Pattern:**
+```
+Dashboard
+Dashboard > Liked Videos
+Dashboard > Watch History
+Admin Panel
+Admin Panel > Users > User Details
+Admin Panel > Videos > Edit Video
+```
+
+### NavMain Component
+
+Located in `components/nav-main.tsx`
+
+**Features:**
+- Collapsible menu items with expandable submenus
+- Icon support (Tabler icons)
+- Smooth transitions with chevron rotation
+- Accessible keyboard navigation
+
+```tsx
+// Usage
+
+```
+
+### NavSecondary Component
+
+Located in `components/nav-secondary.tsx`
+
+For secondary navigation items (Search, Help, etc.)
+
+### NavUser Component
+
+Located in `components/nav-user.tsx`
+
+**Features:**
+- User avatar display
+- User name and email
+- Dropdown menu with user actions
+- Sign out button
+- Accessible user menu
+
+---
+
+## 6. Page Layouts
+
+### Login Page
+
+**Location:** `app/login/page.tsx`
+
+**Layout Structure:**
+```
+Centered Container (min-h-screen, flex-col)
+├── Brand Logo + Text (top)
+├── Vault Image
+└── Login Form (Card with Google OAuth button)
+```
+
+**Styling:**
+```tsx
+
+```
+
+**Key Features:**
+- Centered, max-width container (sm = 448px)
+- Muted background color
+- Responsive padding (6 on mobile, 10 on desktop)
+- Logo + app name at top
+- Vault imagery
+- Google OAuth login form in card
+
+### Dashboard Page
+
+**Location:** `app/dashboard/page.tsx` (server) → `dashboard-client.tsx` (client)
+
+**Layout Structure:**
+```
+Sidebar + Content Layout
+├── AppSidebar
+└── SidebarInset
+ ├── SiteHeader (breadcrumbs)
+ └── Main Content
+ ├── Hero Section (Welcome)
+ ├── Video Carousels (by course/playlist)
+ ├── Segmented Progress Bars
+ └── Lock indicators for restricted content
+```
+
+**Content Structure:**
+```tsx
+
+ {/* Section: Welcome/Title */}
+
+
Welcome back, {userName}
+
Continue your learning
+
+
+ {/* Section: Courses/Playlists with Video Carousels */}
+ {playlists.map(playlist => (
+
+
{playlist.title}
+
+ {/* Video cards with progress bars */}
+
+
+ ))}
+
+```
+
+### Admin Page
+
+**Location:** `app/admin/page.tsx` (server) → `admin-client.tsx` (client)
+
+**Layout Structure:**
+```
+Sidebar + Content Layout
+├── AppSidebar (with Admin menu)
+└── SidebarInset
+ ├── SiteHeader (breadcrumbs)
+ └── Main Content
+ ├── Page Title & Description
+ ├── Grid of Management Cards (2-3 columns)
+ │ ├── Video Statistics Card
+ │ ├── Manage Users Card
+ │ ├── Manage Courses Card
+ │ ├── Manage Playlists Card
+ │ ├── Manage Videos Card
+ │ └── Manage Enrollments Card
+ └── Admin Notifications (right sidebar)
+```
+
+**Admin Grid Layout:**
+```tsx
+
+
+
+ {/* Admin cards */}
+
+
+
+
+```
+
+---
+
+## 7. Component Library
+
+### shadcn/ui Components
+
+The following pre-built components are used throughout the application:
+
+| Component | Used For |
+|-----------|----------|
+| **Button** | CTAs, form submissions, interactive elements |
+| **Card** | Container for content sections |
+| **Input** | Text input fields |
+| **Label** | Form labels |
+| **Badge** | Tags, status indicators, trending indicators |
+| **Sidebar** | Main navigation (with collapsible support) |
+| **Dropdown Menu** | User menus, action menus |
+| **Dialog** | Modal dialogs |
+| **Drawer** | Mobile-friendly off-canvas menus |
+| **Table** | Data tables with sorting, filtering, pagination |
+| **Tabs** | Tab navigation |
+| **Carousel** | Image/video sliders |
+| **Chart** | Data visualization (AreaChart, BarChart, etc.) |
+| **Select** | Dropdown select fields |
+| **Checkbox** | Multi-select checkboxes |
+| **Form/Field** | Form field containers and validation |
+| **Collapsible** | Expandable/collapsible sections |
+| **Progress** | Progress bars |
+| **Avatar** | User profile pictures |
+| **Separator** | Visual dividers |
+| **Toast/Sonner** | Toast notifications |
+| **Tooltip** | Info tooltips |
+
+### Custom Components
+
+**HlsPlayer** (`components/hls.tsx`)
+- HLS video streaming with fallback to MP4
+- Subtitle/caption support
+- Controls with no-download protection
+- Used in: Video player pages
+
+**DataTable** (`components/data-table.tsx`)
+- Advanced table with sorting, filtering, pagination
+- Column visibility toggle
+- Drag-and-drop row reordering (via dnd-kit)
+- Export functionality
+- Used in: Admin management pages (Users, Videos, Courses, etc.)
+
+**SegmentedProgressBar** (`components/segmented-progress-bar.tsx`)
+- Shows watched segments of video
+- Visual indication of progress across duration
+- Used in: Dashboard video carousels
+
+**VideoCarousel** (`components/VideoCarousel.tsx`)
+- Embla carousel-based video slider
+- Responsive grid layout
+- Lock indicators for restricted content
+- Used in: Dashboard, video listings
+
+**CommentsSection** (`components/comments-section.tsx`)
+- Comments display and threading
+- Used in: Video player pages
+
+**LoginForm** (`components/login-form.tsx`)
+- Google OAuth integration
+- Card-based layout
+- Terms and privacy links
+- Used in: Login page
+
+---
+
+## 8. Typography & Spacing
+
+### Font Families
+
+The project uses **Geist** font family (Next.js default):
+- `--font-sans: var(--font-geist-sans)` - Primary (body text, UI)
+- `--font-mono: var(--font-geist-mono)` - Monospace (code, technical text)
+
+Imported in `layout.tsx`:
+```tsx
+import { Geist, Geist_Mono } from "next/font/google";
+```
+
+### Typography Scale
+
+| Element | Class | Size | Weight | Usage |
+|---------|-------|------|--------|-------|
+| H1 | `.text-3xl` | 30px | bold (700) | Page titles |
+| H2 | `.text-2xl` | 24px | bold (700) | Section titles |
+| H3 | `.text-xl` | 20px | semibold (600) | Subsection titles |
+| H4 | `.text-lg` | 18px | semibold (600) | Card titles |
+| Body | `.text-base` | 16px | normal (400) | Body text |
+| Small | `.text-sm` | 14px | normal (400) | Labels, captions, secondary text |
+| Extra Small | `.text-xs` | 12px | normal (400) | Tiny labels |
+
+### Spacing Scale
+
+Tailwind default spacing (4px base):
+
+| Class | Size |
+|-------|------|
+| `gap-1` | 4px |
+| `gap-2` | 8px |
+| `gap-3` | 12px |
+| `gap-4` | 16px |
+| `gap-6` | 24px |
+| `gap-8` | 32px |
+| `p-4` | 16px padding |
+| `p-6` | 24px padding |
+| `py-4` | 16px vertical padding |
+| `px-4` | 16px horizontal padding |
+
+### Text Opacity/Color Hierarchy
+
+```tsx
+// Primary text
+Main content
+
+// Secondary text
+Secondary info
+
+// Subtle text
+Caption
+```
+
+---
+
+## 9. Form Patterns
+
+### Login Form Pattern
+
+Located in `components/login-form.tsx`
+
+```tsx
+
+```
+
+### Admin Form Patterns
+
+Located in `app/admin/*/` directories
+
+**Data Table Integration:**
+```tsx
+
+```
+
+**Modal Form Pattern:**
+```tsx
+
+
+
+ Create New Item
+
+
+
+
+```
+
+---
+
+## 10. Responsive Design
+
+### Tailwind Breakpoints
+
+```
+sm: 640px
+md: 768px
+lg: 1024px
+xl: 1280px
+2xl: 1536px
+```
+
+### Mobile-First Approach
+
+All responsive classes use mobile-first convention:
+
+```tsx
+// Mobile: 1 column, Tablet: 2 columns, Desktop: 3 columns
+
+ {/* Content */}
+
+
+// Mobile: padding-4, Desktop: padding-6
+
+ {/* Content */}
+
+
+// Mobile: hidden, Desktop: visible
+
+ {/* Content only on desktop */}
+
+```
+
+### Mobile-Specific Components
+
+- **Drawer** - Off-canvas menus on mobile (instead of Dialog)
+- **Sidebar Collapsible** - Sidebar collapses to mobile menu on small screens
+- **useMobile Hook** - `hooks/use-mobile.ts` for React-based breakpoint logic
+
+```tsx
+const isMobile = useIsMobile();
+
+return isMobile ? : ;
+```
+
+### Container Queries
+
+Used for responsive component sizing:
+
+```tsx
+
+
+ Responsive heading
+
+
+```
+
+---
+
+## 11. Dark Mode Implementation
+
+### Theme Provider Setup
+
+Located in `app/providers.tsx`:
+
+```tsx
+
+ defaultTheme="dark" // Dark by default
+ enableSystem={false} // Don't auto-detect system preference
+>
+ {children}
+
+```
+
+### Dark Mode Styling
+
+CSS variables automatically switch in `.dark` class:
+
+```tsx
+// Light mode (automatic)
+:root {
+ --background: oklch(1 0 0); /* White */
+ --foreground: oklch(0.141 0.005 285.823); /* Dark gray */
+}
+
+// Dark mode (when .dark is on )
+.dark {
+ --background: oklch(0.141 0.005 285.823); /* Dark gray */
+ --foreground: oklch(0.985 0 0); /* White */
+}
+```
+
+### Toggle Theme Button Pattern
+
+```tsx
+// In component
+const { theme, setTheme } = useTheme();
+
+ setTheme(theme === 'dark' ? 'light' : 'dark')}
+>
+ {theme === 'dark' ? : }
+
+```
+
+---
+
+## 12. Reusable Patterns
+
+### Protected Page Pattern
+
+**Server Component (checks auth):**
+```tsx
+// app/dashboard/page.tsx
+import { requireUser } from '@/lib/auth-check';
+import DashboardClient from './dashboard-client';
+
+export default async function DashboardPage() {
+ const session = await requireUser('/login');
+ return ;
+}
+```
+
+**Client Component (renders content):**
+```tsx
+// app/dashboard/dashboard-client.tsx
+'use client';
+import React from 'react';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+
+export default function DashboardClient() {
+ return (
+
+
+
+
+
+
+
+ );
+}
+```
+
+### Role-Based Navigation
+
+Located in `components/app-sidebar.tsx`:
+
+```tsx
+const { data: session } = useSession();
+const role = session?.user?.role ?? 'user';
+
+const navMain = React.useMemo(() => {
+ const base = [...navMainBase];
+
+ if (role === 'admin' || role === 'superadmin') {
+ base.unshift({
+ title: 'Admin',
+ url: '/admin',
+ icon: IconShieldCheck,
+ items: [/*submenu items*/]
+ });
+ }
+
+ return base;
+}, [role]);
+```
+
+### Session Initialization
+
+Located in `app/layout.tsx`:
+
+```tsx
+export default async function RootLayout({ children }) {
+ // Get server session once, pass to client providers
+ const session = await getServerSession(authOptions);
+
+ return (
+
+
+
+ {children}
+
+
+
+
+ );
+}
+```
+
+### Data Fetching Pattern
+
+Using `useFetch`/SWR for client-side data:
+
+```tsx
+// hooks/usePlaylists.ts
+export function usePlaylists() {
+ const [subjects, setSubjects] = React.useState([]);
+ const [isLoading, setIsLoading] = React.useState(true);
+
+ React.useEffect(() => {
+ fetchPlaylists().then(data => {
+ setSubjects(data);
+ setIsLoading(false);
+ });
+ }, []);
+
+ return { subjects, isLoading };
+}
+
+// Usage in component
+export default function DashboardClient() {
+ const { subjects, isLoading } = usePlaylists();
+
+ if (isLoading) return ;
+
+ return <>{/* Content */}>;
+}
+```
+
+### Carousel Pattern
+
+```tsx
+// Embla-based carousel
+
+
+ {videos.map(video => (
+
+
+
+ ))}
+
+
+
+
+```
+
+### Badge & Status Pattern
+
+```tsx
+// Icon + Badge combination
+
+
+
+ +12.5%
+
+
+
+// Status badge
+
+ {status}
+
+```
+
+---
+
+## 13. Asset Guidelines
+
+### Logo & Branding
+
+- **Icon**: `/public/icon.svg` - Small 5x5 size in sidebar header
+- **Vault Image**: `/public/vault.png` - 1:1 aspect ratio for login page
+- **High-Res Alternative**: `/public/vault.exr` - For promotional use
+
+### Image Optimization
+
+```tsx
+// Use Next.js Image for optimization
+import Image from 'next/image';
+
+
+
+// Regular images
+
+```
+
+### Approved Icon Sets
+
+1. **Tabler Icons** (`@tabler/icons-react`) - Primary
+ - Large collection (4000+)
+ - Consistent stroke weight
+ - Import: `import { IconHome, IconUsers } from '@tabler/icons-react';`
+
+2. **Lucide React** (`lucide-react`) - Secondary
+ - Modern, clean icons
+ - Import: `import { Home, Users } from 'lucide-react';`
+
+3. **SVG Files** - Custom branding
+ - Small icons in public/
+ - Optimized for performance
+
+### Sizing Conventions
+
+```tsx
+// Icon sizes
+ /* 12px */
+ /* 16px */
+ /* 20px */
+ /* 24px */
+ /* 32px */
+
+// Image sizes
+ /* Full width */
+ /* Max 448px */
+ /* Max 512px */
+```
+
+---
+
+## Quick Reference - Common Patterns
+
+### Button Variants
+```tsx
+Default
+Secondary
+Outline
+Ghost
+Delete
+Disabled
+Small
+Large
+```
+
+### Card Layout
+```tsx
+
+
+ Title
+ Subtitle
+
+
+ {/* Content */}
+
+
+ {/* Footer */}
+
+
+```
+
+### Container Classes
+```tsx
+// Full width
+className="w-full"
+
+// Max width containers
+className="w-full max-w-sm" /* 448px */
+className="w-full max-w-md" /* 512px */
+className="w-full max-w-lg" /* 576px */
+className="w-full max-w-2xl" /* 672px */
+
+// Flex containers
+className="flex flex-col gap-4"
+className="flex items-center justify-between"
+
+// Grid containers
+className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
+```
+
+### Responsive Padding
+```tsx
+className="p-4 md:p-6 lg:p-8" /* All sides */
+className="px-4 py-6" /* Horizontal & vertical */
+className="p-4 md:py-6 md:px-8" /* Different per breakpoint */
+```
+
+### Text & Styling
+```tsx
+className="text-foreground" /* Main text */
+className="text-muted-foreground" /* Secondary text */
+className="text-sm text-muted-foreground" /* Label text */
+className="font-semibold" /* Bold text */
+className="line-clamp-1" /* Truncate to 1 line */
+className="text-center" /* Centered text */
+```
+
+---
+
+## Migration Checklist for New Projects
+
+When creating a new app with this design system:
+
+- [ ] Copy `globals.css` (CSS variables)
+- [ ] Copy `tailwind.config.cjs`
+- [ ] Copy `components/ui/` directory (all base components)
+- [ ] Copy `components/app-sidebar.tsx`, `site-header.tsx`, `nav-*.tsx`
+- [ ] Copy `app/providers.tsx` (SessionProvider, ThemeProvider)
+- [ ] Copy `lib/utils.ts` (utility functions)
+- [ ] Copy `lib/auth-options.ts` and auth-related files
+- [ ] Install dependencies from `package.json`
+- [ ] Update branding assets (/public icons, logos)
+- [ ] Customize navigation items in `app-sidebar.tsx`
+- [ ] Update theme colors in `globals.css` if needed
+- [ ] Test responsive design on mobile/tablet/desktop
+
+---
+
+## Key Takeaways
+
+1. **Framework**: Next.js 16 with shadcn/ui component library
+2. **Styling**: Tailwind CSS with OKLch color variables
+3. **Layout**: Sidebar + content pattern with collapsible nav
+4. **Dark Mode**: CSS class-based switching with automatic variable updates
+5. **Components**: Pre-built shadcn/ui components + custom components (HLS, DataTable, etc.)
+6. **Responsiveness**: Mobile-first, Tailwind breakpoints (sm/md/lg/xl)
+7. **Navigation**: Role-based dynamic nav, breadcrumb system
+8. **Auth**: NextAuth.js with Google OAuth integration
+9. **Icons**: Tabler Icons (primary), Lucide (secondary)
+10. **Consistency**: Use className patterns, spacing scale, and color variables throughout
+
+---
+
+**Last Updated**: February 2026
+**Version**: 1.0
+
+For questions or updates to this design system guide, refer to the component files and config files referenced throughout.
diff --git a/VPS_UPLOAD_CONFIG.md b/VPS_UPLOAD_CONFIG.md
new file mode 100644
index 0000000..a732a07
--- /dev/null
+++ b/VPS_UPLOAD_CONFIG.md
@@ -0,0 +1,91 @@
+# VPS Upload Timeout Fix
+
+## Problem
+Uploads timing out after moving to VPS + npm environment.
+
+## Root Causes
+1. Prisma connection pool timeout too short for network latency
+2. FFprobe duration extraction timing out on slow disk I/O
+3. Missing socket/request timeout configuration
+4. Database connection pool exhaustion
+
+## Solutions Applied
+
+### 1. Prisma Client Configuration
+Updated `lib/prisma.ts` with:
+- Connection timeout handling for slow VPS networks
+- Proper pool configuration support
+- Removed 'info' logging (reduces noise in production)
+
+### 2. FFprobe Timeout Protection
+Updated `app/api/admin/upload/route.ts`:
+- Added 15-second timeout for FFprobe duration extraction
+- Falls back gracefully if FFprobe takes too long
+- Prevents blocking the entire upload handler
+
+### 3. Next.js Server Configuration
+Updated `next.config.ts`:
+- Added socket timeout configuration (45 seconds default)
+- VPS-aware settings for slow network environments
+
+## Required Environment Variables
+
+Add these to your `.env.local` (or VPS environment):
+
+```
+# Database connection with proper pool settings for VPS
+# Adjust pool_max based on your VPS CPU cores
+DATABASE_URL="postgresql://user:password@host:5432/dbname?schema=public&pool_max=5&socket_timeout=45000&connect_timeout=10000"
+
+# Optional: Override default socket timeout (in milliseconds)
+DATABASE_SOCKET_TIMEOUT=45000
+
+# Optional: For testing, you can increase the API timeout
+# This is already set to 300s in the route, but ensure your nginx/proxy respects it
+```
+
+## PostgreSQL Connection String Parameters
+If using PostgreSQL, ensure your `DATABASE_URL` includes:
+- `pool_max=5` — Limit connections (adjust to CPU cores)
+- `socket_timeout=45000` — Socket timeout in ms
+- `connect_timeout=10000` — Connection establishment timeout in ms
+
+Example PostgreSQL URL:
+```
+postgresql://user:password@vps-host:5432/dbname?schema=public&pool_max=5&socket_timeout=45000&connect_timeout=10000
+```
+
+## Next.js Server Configuration
+If running with a proxy (nginx/Apache), ensure:
+
+```nginx
+# nginx example
+location /api/admin/upload {
+ proxy_pass http://next-server;
+ proxy_connect_timeout 60s;
+ proxy_send_timeout 300s; # 5 minutes for large uploads
+ proxy_read_timeout 300s; # 5 minutes for large uploads
+ client_max_body_size 1024M; # Adjust based on your max video size
+}
+```
+
+## Testing
+After applying these changes:
+
+1. Restart your Node.js server
+2. Test with a medium-sized video (100MB+)
+3. Check logs: `pm2 logs` or your logging system
+4. Monitor database connections: `SELECT count(*) FROM pg_stat_activity;` in psql
+
+## Additional Tuning
+
+### If still timing out:
+1. **Check disk I/O**: `iostat -x 1` on VPS
+2. **Monitor database**: Check if queries are slow
+3. **Increase FFprobe timeout**: Edit line in `app/api/admin/upload/route.ts` (currently 15000ms)
+4. **Vertical scaling**: Increase VPS CPU for FFprobe processing
+
+### For very large files (>500MB):
+- Consider streaming directly to cloud storage (S3, etc.)
+- Implement chunked uploads
+- Use a separate transcoding queue service
diff --git a/app/admin/admin-client.tsx b/app/admin/admin-client.tsx
new file mode 100644
index 0000000..6a93c5e
--- /dev/null
+++ b/app/admin/admin-client.tsx
@@ -0,0 +1,208 @@
+// app/admin/admin-client.tsx
+'use client';
+import React from 'react';
+import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { AdminNotifications } from '@/components/admin-notifications';
+
+import { Button } from '@/components/ui/button';
+import Link from 'next/link';
+import {
+ IconFileUpload,
+ IconBooks,
+ IconList,
+ IconUsers,
+ IconUsers as IconUsersManage,
+ IconChartBar,
+} from '@tabler/icons-react';
+
+function AdminUI() {
+ return (
+
+
+
Admin Panel
+
+ Manage your users, courses, playlists, videos, and enrollments.
+
+
+
+
+
+
+
+
+
+
+ Video Statistics
+
+
+
+
+ View video performance metrics and analytics.
+
+
+
+ Go to Stats
+
+
+
+
+
+
+
+
+
+ Manage Users
+
+
+
+
+ View users and their activity.
+
+
+
+ Go to Users
+
+
+
+
+
+
+
+
+
+ Manage Courses
+
+
+
+
+ Create and manage courses.
+
+
+
+ Go to Courses
+
+
+
+
+
+
+
+
+
+ Manage Playlists
+
+
+
+
+ Create and manage playlists.
+
+
+
+ Go to Playlists
+
+
+
+
+
+
+
+
+
+ Manage Videos
+
+
+
+
+ Upload and manage videos.
+
+
+
+ Go to Videos
+
+
+
+
+
+
+
+
+
+ Manage Enrollments
+
+
+
+
+ Manage user enrollments.
+
+
+
+ Go to Enrollments
+
+
+
+
+
+
+
+
+
+ Allowed Students
+
+
+
+
+ Manage whitelisted student access.
+
+
+
+ Go to Students
+
+
+
+
+
+
+
+
+
+
+ Activity Feed
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function AdminClient() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/admin/allowed-students/admin-client.tsx b/app/admin/allowed-students/admin-client.tsx
new file mode 100644
index 0000000..69da327
--- /dev/null
+++ b/app/admin/allowed-students/admin-client.tsx
@@ -0,0 +1,393 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetFooter,
+} from '@/components/ui/sheet';
+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogTitle } from '@/components/ui/alert-dialog';
+import { toast } from 'sonner';
+import { Trash2, Edit, Upload, Plus } from 'lucide-react';
+import { formatDistanceToNow } from 'date-fns';
+
+type AllowedStudent = {
+ id: string;
+ email: string;
+ levels: string;
+ active: boolean;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export default function AllowedStudentsClient() {
+ const [students, setStudents] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
+ const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
+ const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false);
+ const [isImportLoading, setIsImportLoading] = useState(false);
+ const [editingStudent, setEditingStudent] = useState(null);
+ const [deleteId, setDeleteId] = useState(null);
+ const [newEmail, setNewEmail] = useState('');
+ const [newLevels, setNewLevels] = useState('');
+
+ // Fetch students on mount
+ useEffect(() => {
+ fetchStudents();
+ }, []);
+
+ const fetchStudents = async () => {
+ setIsLoading(true);
+ try {
+ const res = await fetch('/api/admin/allowed-students');
+ if (res.ok) {
+ const data = await res.json();
+ setStudents(data);
+ } else {
+ toast.error('Failed to fetch students');
+ }
+ } catch (err) {
+ console.error('Failed to fetch students:', err);
+ toast.error('Error fetching students');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleAddStudent = async () => {
+ if (!newEmail.trim() || !newLevels.trim()) {
+ toast.error('Email and course codes required');
+ return;
+ }
+
+ try {
+ const res = await fetch('/api/admin/allowed-students', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ email: newEmail.trim(),
+ levels: newLevels.trim(),
+ }),
+ });
+
+ if (res.status === 201) {
+ toast.success('Student added');
+ setNewEmail('');
+ setNewLevels('');
+ setIsAddDialogOpen(false);
+ fetchStudents();
+ } else if (res.status === 409) {
+ toast.error('Email already registered');
+ } else {
+ const data = await res.json();
+ toast.error(data.error || 'Failed to add student');
+ }
+ } catch (err) {
+ console.error('Failed to add student:', err);
+ toast.error('Error adding student');
+ }
+ };
+
+ const handleEditStudent = async () => {
+ if (!editingStudent) return;
+
+ try {
+ const res = await fetch('/api/admin/allowed-students', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ id: editingStudent.id,
+ email: editingStudent.email,
+ levels: editingStudent.levels,
+ active: editingStudent.active,
+ }),
+ });
+
+ if (res.ok) {
+ toast.success('Student updated');
+ setIsEditDialogOpen(false);
+ setEditingStudent(null);
+ fetchStudents();
+ } else {
+ const data = await res.json();
+ toast.error(data.error || 'Failed to update student');
+ }
+ } catch (err) {
+ console.error('Failed to update student:', err);
+ toast.error('Error updating student');
+ }
+ };
+
+ const handleDeleteStudent = async () => {
+ if (!deleteId) return;
+
+ try {
+ const res = await fetch(`/api/admin/allowed-students?id=${deleteId}`, {
+ method: 'DELETE',
+ });
+
+ if (res.ok) {
+ toast.success('Student removed');
+ setIsDeleteAlertOpen(false);
+ setDeleteId(null);
+ fetchStudents();
+ } else {
+ toast.error('Failed to delete student');
+ }
+ } catch (err) {
+ console.error('Failed to delete student:', err);
+ toast.error('Error deleting student');
+ }
+ };
+
+ const handleImportCSV = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ setIsImportLoading(true);
+ try {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const res = await fetch('/api/admin/allowed-students/import', {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (res.ok) {
+ const result = await res.json();
+ toast.success(`Imported: ${result.imported}, Updated: ${result.updated}`);
+ if (result.errors.length > 0) {
+ toast.warning(`${result.errors.length} errors during import`);
+ }
+ fetchStudents();
+ } else {
+ const data = await res.json();
+ toast.error(data.error || 'Failed to import students');
+ }
+ } catch (err) {
+ console.error('Failed to import CSV:', err);
+ toast.error('Error importing students');
+ } finally {
+ setIsImportLoading(false);
+ }
+ };
+
+ if (isLoading) {
+ return Loading allowed students...
;
+ }
+
+ return (
+
+
+
+ Allowed Students ({students.length})
+
+
setIsAddDialogOpen(true)}
+ className="gap-2"
+ >
+
+ Add Student
+
+
+
+ document.getElementById('csv-upload')?.click()}
+ disabled={isImportLoading}
+ className="gap-2"
+ >
+
+ Import CSV
+
+
+
+
+
+
+
+
+ Email
+ Course Codes
+ Status
+ Added
+ Actions
+
+
+
+ {students.length === 0 ? (
+
+
+ No students added yet
+
+
+ ) : (
+ students.map((student) => (
+
+ {student.email}
+
+
+ {student.levels.split(',').map((code) => (
+
+ {code.trim()}
+
+ ))}
+
+
+
+
+ {student.active ? 'Active' : 'Inactive'}
+
+
+
+ {formatDistanceToNow(new Date(student.createdAt), { addSuffix: true })}
+
+
+
+ {
+ setEditingStudent(student);
+ setIsEditDialogOpen(true);
+ }}
+ >
+
+
+ {
+ setDeleteId(student.id);
+ setIsDeleteAlertOpen(true);
+ }}
+ >
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+ {/* Add Student Sheet */}
+
+
+
+ Add Student
+
+
+
+ Email
+ setNewEmail(e.target.value)}
+ />
+
+
+
Course Codes
+
setNewLevels(e.target.value)}
+ />
+
+ Comma-separated course codes
+
+
+
+
+ setIsAddDialogOpen(false)}>
+ Cancel
+
+ Add Student
+
+
+
+
+ {/* Edit Student Sheet */}
+
+
+
+ Edit Student
+
+ {editingStudent && (
+
+ )}
+
+ setIsEditDialogOpen(false)}>
+ Cancel
+
+ Save Changes
+
+
+
+
+ {/* Delete Confirmation */}
+
+
+ Remove Student
+
+ Are you sure? The student will be prevented from logging in, but their existing enrollments will remain.
+
+
+
+
+
+ );
+}
diff --git a/app/admin/allowed-students/page.tsx b/app/admin/allowed-students/page.tsx
new file mode 100644
index 0000000..d560a03
--- /dev/null
+++ b/app/admin/allowed-students/page.tsx
@@ -0,0 +1,34 @@
+// app/admin/allowed-students/page.tsx
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { redirect } from 'next/navigation';
+import { SidebarProvider } from '@/components/ui/sidebar';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import { SidebarInset } from '@/components/ui/sidebar';
+import AllowedStudentsClient from './admin-client';
+
+export const dynamic = 'force-dynamic';
+
+export default async function AllowedStudentsPage() {
+ const session = await getServerSession(authOptions);
+ const role = (session as any)?.user?.role ?? null;
+
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ if (!(role === 'admin' || role === 'superadmin')) {
+ redirect('/dashboard');
+ }
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/admin/courses/admin-client.tsx b/app/admin/courses/admin-client.tsx
new file mode 100644
index 0000000..1e16167
--- /dev/null
+++ b/app/admin/courses/admin-client.tsx
@@ -0,0 +1,247 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import { useRouter } from 'next/navigation';
+import { toast } from 'sonner';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ Field,
+ FieldGroup,
+ FieldLabel,
+} from '@/components/ui/field';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog';
+import { Trash2Icon } from 'lucide-react';
+
+type Course = { id: string; title: string; code?: string };
+
+function CoursesUI() {
+ const router = useRouter();
+ const [courses, setCourses] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [courseTitle, setCourseTitle] = useState('');
+ const [courseCode, setCourseCode] = useState('');
+ const [deletingId, setDeletingId] = useState(null);
+
+ useEffect(() => {
+ fetchCourses();
+ }, []);
+
+ async function fetchCourses() {
+ try {
+ const res = await fetch('/api/admin/meta');
+ if (res.ok) {
+ const json = await res.json();
+ setCourses(json.courses || []);
+ }
+ } catch (err) {
+ console.error('Failed to fetch courses', err);
+ toast.error('Failed to fetch courses');
+ }
+ }
+
+ async function createCourse(e: React.FormEvent) {
+ e.preventDefault();
+ setLoading(true);
+ try {
+ const res = await fetch('/api/admin/create-course', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ title: courseTitle, code: courseCode }),
+ });
+ if (res.ok) {
+ toast.success('Course created');
+ setCourseTitle('');
+ setCourseCode('');
+ await fetchCourses();
+ router.refresh();
+ } else {
+ const txt = await res.text();
+ toast.error('Failed to create course: ' + txt);
+ }
+ } catch (err: any) {
+ toast.error('Error: ' + String(err.message ?? err));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function deleteCourse(courseId: string) {
+ setDeletingId(courseId);
+ try {
+ const res = await fetch(`/api/admin/delete-course`, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ courseId }),
+ });
+ if (res.ok) {
+ toast.success('Course deleted');
+ await fetchCourses();
+ router.refresh();
+ } else {
+ const txt = await res.text();
+ toast.error('Failed to delete course: ' + txt);
+ }
+ } catch (err: any) {
+ toast.error('Error: ' + String(err.message ?? err));
+ } finally {
+ setDeletingId(null);
+ }
+ }
+
+ return (
+
+
+
+ Create Course
+
+
+
+
+
+
+
+
+ Courses
+
+
+ {courses.length === 0 ? (
+
+ No courses yet
+
+ ) : (
+
+
+
+
+ Title
+ Code
+ Actions
+
+
+
+ {courses.map((course) => (
+
+
+ {course.title}
+
+ {course.code || '—'}
+
+
+
+
+
+
+
+
+
+ Delete Course
+
+ Are you sure? This will delete the course and all
+ associated playlists and videos.
+
+
+
+
Cancel
+
deleteCourse(course.id)}
+ >
+ Delete
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ );
+}
+
+export default function CoursesAdminClient() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/admin/courses/page.tsx b/app/admin/courses/page.tsx
new file mode 100644
index 0000000..0aa655b
--- /dev/null
+++ b/app/admin/courses/page.tsx
@@ -0,0 +1,22 @@
+// app/admin/courses/page.tsx
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { redirect } from 'next/navigation';
+import CoursesAdminClient from './admin-client';
+
+export const dynamic = 'force-dynamic';
+
+export default async function CoursesAdminPage() {
+ const session = await getServerSession(authOptions);
+ const role = (session as any)?.user?.role ?? null;
+
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ if (!(role === 'admin' || role === 'superadmin')) {
+ redirect('/dashboard');
+ }
+
+ return ;
+}
diff --git a/app/admin/enrollments/admin-client.tsx b/app/admin/enrollments/admin-client.tsx
new file mode 100644
index 0000000..03de60b
--- /dev/null
+++ b/app/admin/enrollments/admin-client.tsx
@@ -0,0 +1,349 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import { useRouter } from 'next/navigation';
+import { toast } from 'sonner';
+import { Progress } from '@/components/ui/progress';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ CardFooter,
+} from '@/components/ui/card';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import {
+ Field,
+ FieldGroup,
+ FieldLabel,
+ FieldDescription,
+} from '@/components/ui/field';
+
+import {
+ Table,
+ TableBody,
+ TableCaption,
+ TableCell,
+ TableFooter,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+
+type User = { id: number | string; name?: string | null; email: string };
+type Course = { id: number | string; title: string; code: string };
+type Enrollment = {
+ id: number;
+ role?: string | null;
+ createdAt?: string;
+ user: User;
+ course: Course;
+};
+
+function AdminUI() {
+ const [users, setUsers] = useState([]);
+ const [courses, setCourses] = useState([]);
+ const [enrollments, setEnrollments] = useState([]);
+
+ // placeholder sentinel
+ const [selectedUser, setSelectedUser] = useState('none');
+ const [selectedCourse, setSelectedCourse] = useState('none');
+ const [role, setRole] = useState('student');
+
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ setLoading(true);
+ setError(null);
+
+ Promise.all([
+ fetch('/api/users')
+ .then((r) => (r.ok ? r.json() : Promise.reject(r)))
+ .catch(() => []),
+ fetch('/api/courses')
+ .then((r) => (r.ok ? r.json() : Promise.reject(r)))
+ .catch(() => []),
+ fetch('/api/enrollments')
+ .then((r) => (r.ok ? r.json() : Promise.reject(r)))
+ .catch(() => []),
+ ])
+ .then(([u, c, e]) => {
+ setUsers(u ?? []);
+ setCourses(c ?? []);
+ setEnrollments(e ?? []);
+ })
+ .catch((err) => {
+ console.error('Failed to load admin data', err);
+ setError('Failed to load admin data');
+ })
+ .finally(() => setLoading(false));
+ }, []);
+
+ function extractIdString(val: unknown): string | null {
+ const s = val === null || val === undefined ? '' : String(val).trim();
+ console.debug('extractIdString raw value:', s);
+
+ if (!s) return null;
+
+ if (s.length > 0) return s;
+
+ return null;
+ }
+
+ async function handleCreate(e?: React.FormEvent) {
+ e?.preventDefault();
+ setError(null);
+
+ const userIdStr = extractIdString(selectedUser);
+ const courseIdStr = extractIdString(selectedCourse);
+
+ if (!userIdStr || !courseIdStr) {
+ setError(
+ `Invalid selection. user="${String(selectedUser)}" course="${String(
+ selectedCourse
+ )}". Expected values containing numeric ids or direct ids.`
+ );
+ return;
+ }
+
+ // send strings to the server (your Prisma schema expects String ids)
+ const payload = {
+ userId: String(userIdStr),
+ courseId: String(courseIdStr),
+ };
+ console.debug('Creating enrollment with payload:', payload);
+
+ setSaving(true);
+ try {
+ const res = await fetch('/api/enrollments', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ const data = await res.json();
+ if (!res.ok) {
+ setError(data?.error || 'Failed to create enrollment');
+ } else {
+ setEnrollments((prev) => [data, ...prev]);
+ setSelectedUser('none');
+ setSelectedCourse('none');
+ setRole('student');
+ }
+ } catch (err) {
+ console.error('Network error while creating enrollment', err);
+ setError('Network error while creating enrollment');
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function handleDelete(id: number) {
+ if (!confirm('Remove this enrollment?')) return;
+ setSaving(true);
+ setError(null);
+ try {
+ const res = await fetch('/api/enrollments', {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id }),
+ });
+ const data = await res.json();
+ if (!res.ok && !data?.success) {
+ setError(data?.error || 'Failed to delete enrollment');
+ } else {
+ setEnrollments((prev) => prev.filter((en) => en.id !== id));
+ }
+ } catch (err) {
+ console.error('Network error while deleting enrollment', err);
+ setError('Network error while deleting enrollment');
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+
+
+ Assign enrollment
+
+
+
+
+
+ {error && (
+ {error}
+ )}
+
+
+
+
+
+ Current enrollments
+
+
+
+ {loading && enrollments.length === 0 ? (
+ Loading…
+ ) : enrollments.length === 0 ? (
+
+ No enrollments yet.
+
+ ) : (
+
+
+
+ Student
+ Email
+ Course
+ Role
+ Enrolled
+ Actions
+
+
+
+ {enrollments.map((en) => (
+
+
+ {en.user.name ?? en.user.email}
+
+
+ {en.user.email}
+
+
+ {en.course.title}{' '}
+
+ | {en.course.code}
+
+
+ {en.role ?? 'student'}
+
+ {en.createdAt
+ ? new Date(en.createdAt).toLocaleString()
+ : '—'}
+
+
+
+ handleDelete(en.id)}
+ disabled={saving}
+ >
+ Remove
+
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+ );
+}
+
+export default function AdminClient() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/admin/enrollments/page.tsx b/app/admin/enrollments/page.tsx
new file mode 100644
index 0000000..9544e84
--- /dev/null
+++ b/app/admin/enrollments/page.tsx
@@ -0,0 +1,22 @@
+// app/admin/enrollments/page.tsx
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { redirect } from 'next/navigation';
+import AdminClient from './admin-client';
+
+export const dynamic = 'force-dynamic';
+
+export default async function EnrollmentsAdminPage() {
+ const session = await getServerSession(authOptions);
+ const role = (session as any)?.user?.role ?? null;
+
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ if (!(role === 'admin' || role === 'superadmin')) {
+ redirect('/dashboard');
+ }
+
+ return ;
+}
diff --git a/app/admin/page.tsx b/app/admin/page.tsx
new file mode 100644
index 0000000..896d319
--- /dev/null
+++ b/app/admin/page.tsx
@@ -0,0 +1,23 @@
+// app/admin/page.tsx (server component)
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { redirect } from 'next/navigation';
+import AdminClient from './admin-client'; // the client UI component (see below)
+
+export const dynamic = 'force-dynamic';
+
+export default async function AdminPage() {
+ const session = await getServerSession(authOptions);
+ const role = (session as any)?.user?.role ?? null;
+
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ if (!(role === 'admin' || role === 'superadmin')) {
+ redirect('/dashboard');
+ }
+
+ // authorised — render the client admin UI
+ return ;
+}
diff --git a/app/admin/playlists/admin-client.tsx b/app/admin/playlists/admin-client.tsx
new file mode 100644
index 0000000..d85a335
--- /dev/null
+++ b/app/admin/playlists/admin-client.tsx
@@ -0,0 +1,539 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import { useRouter } from 'next/navigation';
+import { toast } from 'sonner';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ Field,
+ FieldGroup,
+ FieldLabel,
+ FieldDescription,
+} from '@/components/ui/field';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog';
+import { Trash2Icon, Edit2, Check, X } from 'lucide-react';
+import {
+ DndContext,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ closestCenter,
+} from '@dnd-kit/core';
+import {
+ arrayMove,
+ SortableContext,
+ verticalListSortingStrategy,
+ useSortable,
+} from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+
+type Course = { id: string; title: string; code?: string };
+type Playlist = { id: string; title: string; courseId: string };
+
+function PlaylistsUI() {
+ const router = useRouter();
+ const [courses, setCourses] = useState([]);
+ const [playlists, setPlaylists] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [playlistTitle, setPlaylistTitle] = useState('');
+ const [playlistCourseId, setPlaylistCourseId] = useState('');
+ const [additionalCourseIds, setAdditionalCourseIds] = useState([]);
+ const [deletingId, setDeletingId] = useState(null);
+ const [editingId, setEditingId] = useState(null);
+ const [editingTitle, setEditingTitle] = useState('');
+ const [savingEditId, setSavingEditId] = useState(null);
+
+ useEffect(() => {
+ fetchData();
+ }, []);
+
+ async function fetchData() {
+ try {
+ const res = await fetch('/api/admin/meta');
+ if (res.ok) {
+ const json = await res.json();
+ setCourses(json.courses || []);
+ setPlaylists(json.playlists || []);
+ if (!playlistCourseId && json.courses?.[0]) {
+ setPlaylistCourseId(json.courses[0].id);
+ }
+ }
+ } catch (err) {
+ console.error('Failed to fetch data', err);
+ toast.error('Failed to fetch data');
+ }
+ }
+
+ async function createPlaylist(e: React.FormEvent) {
+ e.preventDefault();
+ if (!playlistCourseId) {
+ toast.error('Pick a primary course first');
+ return;
+ }
+ setLoading(true);
+ try {
+ const res = await fetch('/api/admin/create-playlist', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title: playlistTitle,
+ courseId: playlistCourseId,
+ additionalCourseIds,
+ }),
+ });
+ if (res.ok) {
+ toast.success('Playlist created');
+ setPlaylistTitle('');
+ setAdditionalCourseIds([]);
+ await fetchData();
+ router.refresh();
+ } else {
+ const txt = await res.text();
+ toast.error('Failed to create playlist: ' + txt);
+ }
+ } catch (err: any) {
+ toast.error('Error: ' + String(err.message ?? err));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function deletePlaylist(playlistId: string) {
+ setDeletingId(playlistId);
+ try {
+ const res = await fetch(`/api/admin/delete-playlist`, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ playlistId }),
+ });
+ if (res.ok) {
+ toast.success('Playlist deleted');
+ await fetchData();
+ router.refresh();
+ } else {
+ const txt = await res.text();
+ toast.error('Failed to delete playlist: ' + txt);
+ }
+ } catch (err: any) {
+ toast.error('Error: ' + String(err.message ?? err));
+ } finally {
+ setDeletingId(null);
+ }
+ }
+
+ async function updatePlaylistTitle(playlistId: string, newTitle: string) {
+ if (!newTitle.trim()) {
+ toast.error('Title cannot be empty');
+ return;
+ }
+
+ setSavingEditId(playlistId);
+ try {
+ const res = await fetch(`/api/admin/update-playlist`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ playlistId, title: newTitle }),
+ });
+ if (res.ok) {
+ toast.success('Playlist updated');
+ setEditingId(null);
+ setEditingTitle('');
+ await fetchData();
+ router.refresh();
+ } else {
+ const txt = await res.text();
+ toast.error('Failed to update playlist: ' + txt);
+ }
+ } catch (err: any) {
+ toast.error('Error: ' + String(err.message ?? err));
+ } finally {
+ setSavingEditId(null);
+ }
+ }
+
+ // Organizer state & helpers
+ const [selectedPlaylistId, setSelectedPlaylistId] = useState('');
+ const [videos, setVideos] = useState>([]);
+ const sensors = useSensors(useSensor(PointerSensor));
+
+ function SortableItem({ id, title, thumbnail }: { id: string; title: string; thumbnail?: string }) {
+ const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ } as React.CSSProperties;
+
+ return (
+
+
+ {thumbnail ? (
+
+ ) : (
+
No image
+ )}
+
{title}
+
+
+ );
+ }
+
+ async function fetchPlaylistVideos(playlistId: string) {
+ if (!playlistId) return;
+ try {
+ const res = await fetch(`/api/admin/playlist-videos?playlistId=${playlistId}`);
+ if (res.ok) {
+ const data = await res.json();
+ setVideos(data || []);
+ } else {
+ toast.error('Failed to load playlist videos');
+ }
+ } catch (err) {
+ console.error('Failed to fetch playlist videos', err);
+ toast.error('Failed to load playlist videos');
+ }
+ }
+
+ async function handleDragEnd(e: any) {
+ const { active, over } = e;
+ if (!over || active.id === over.id) return;
+ const oldIndex = videos.findIndex((v) => v.id === active.id);
+ const newIndex = videos.findIndex((v) => v.id === over.id);
+ if (oldIndex === -1 || newIndex === -1) return;
+ const newOrder = arrayMove(videos, oldIndex, newIndex);
+ setVideos(newOrder.map((v, idx) => ({ ...v, index: idx })));
+
+ // Persist order
+ try {
+ const orderedIds = newOrder.map((v) => v.id);
+ const res = await fetch('/api/admin/reorder-videos', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ playlistId: selectedPlaylistId, orderedIds }),
+ });
+ if (!res.ok) {
+ toast.error('Failed to save new order');
+ // refetch to revert
+ await fetchPlaylistVideos(selectedPlaylistId);
+ } else {
+ toast.success('Order saved');
+ }
+ } catch (err) {
+ console.error('Failed to save order', err);
+ toast.error('Failed to save new order');
+ await fetchPlaylistVideos(selectedPlaylistId);
+ }
+ }
+
+ const getCourseTitle = (courseId: string) => {
+ return courses.find((c) => c.id === courseId)?.title || 'Unknown Course';
+ };
+
+ return (
+
+
+
+ Create Playlist
+
+
+
+
+
+
+
+
+ Organize Playlist
+
+
+
+
+
+ Select playlist
+ {
+ setSelectedPlaylistId(val);
+ fetchPlaylistVideos(val);
+ }}
+ >
+
+
+
+
+ {playlists.map((p) => (
+
+ {p.title}
+
+ ))}
+
+
+
+
+
+
+
+ {videos.length === 0 ? (
+
No videos loaded
+ ) : (
+
+ v.id)} strategy={verticalListSortingStrategy}>
+
+ {videos.map((v) => (
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+
+ Playlists
+
+
+ {playlists.length === 0 ? (
+
+ No playlists yet
+
+ ) : (
+
+
+
+
+ Title
+ Course
+ Actions
+
+
+
+ {playlists.map((playlist) => (
+
+
+ {editingId === playlist.id ? (
+
+ setEditingTitle(e.target.value)}
+ className="h-8"
+ autoFocus
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ updatePlaylistTitle(playlist.id, editingTitle);
+ }
+ if (e.key === 'Escape') {
+ setEditingId(null);
+ setEditingTitle('');
+ }
+ }}
+ />
+ updatePlaylistTitle(playlist.id, editingTitle)}
+ disabled={savingEditId === playlist.id}
+ >
+
+
+ {
+ setEditingId(null);
+ setEditingTitle('');
+ }}
+ disabled={savingEditId === playlist.id}
+ >
+
+
+
+ ) : (
+
+ {playlist.title}
+ {
+ setEditingId(playlist.id);
+ setEditingTitle(playlist.title);
+ }}
+ >
+
+
+
+ )}
+
+ {getCourseTitle(playlist.courseId)}
+
+
+
+
+
+
+
+
+
+ Delete Playlist
+
+ Are you sure? This will delete the playlist and all
+ associated videos.
+
+
+
+
Cancel
+
deletePlaylist(playlist.id)}
+ >
+ Delete
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ );
+}
+
+export default function PlaylistsAdminClient() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/admin/playlists/page.tsx b/app/admin/playlists/page.tsx
new file mode 100644
index 0000000..efb60d0
--- /dev/null
+++ b/app/admin/playlists/page.tsx
@@ -0,0 +1,22 @@
+// app/admin/playlists/page.tsx
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { redirect } from 'next/navigation';
+import PlaylistsAdminClient from './admin-client';
+
+export const dynamic = 'force-dynamic';
+
+export default async function PlaylistsAdminPage() {
+ const session = await getServerSession(authOptions);
+ const role = (session as any)?.user?.role ?? null;
+
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ if (!(role === 'admin' || role === 'superadmin')) {
+ redirect('/dashboard');
+ }
+
+ return ;
+}
diff --git a/app/admin/stats-client.tsx b/app/admin/stats-client.tsx
new file mode 100644
index 0000000..acf5626
--- /dev/null
+++ b/app/admin/stats-client.tsx
@@ -0,0 +1,298 @@
+// app/admin/stats-client.tsx
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { Skeleton } from '@/components/ui/skeleton';
+import { AlertCircle } from 'lucide-react';
+
+interface VideoStats {
+ id: string;
+ title: string;
+ playlistTitle: string;
+ uploaderName: string;
+ views: number;
+ totalViewers: number;
+ completions: number;
+ completionRate: string;
+ likes: number;
+ comments: number;
+ engagement: number;
+ avgPercentWatched: number;
+ totalSecondsWatched: number;
+ totalSegments: number;
+ durationSec: number | null;
+ createdAt: string;
+}
+
+export default function StatsClient() {
+ const [stats, setStats] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [sortBy, setSortBy] = useState('views');
+ const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
+
+ useEffect(() => {
+ const fetchStats = async () => {
+ try {
+ setLoading(true);
+ const response = await fetch('/api/admin/stats');
+ if (!response.ok) throw new Error('Failed to fetch stats');
+ const data = await response.json();
+ setStats(data);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'An error occurred');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchStats();
+ }, []);
+
+ const handleSort = (column: keyof VideoStats) => {
+ if (sortBy === column) {
+ setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
+ } else {
+ setSortBy(column);
+ setSortOrder('desc');
+ }
+ };
+
+ const sortedStats = [...stats].sort((a, b) => {
+ const aVal = a[sortBy];
+ const bVal = b[sortBy];
+
+ if (typeof aVal === 'string' && typeof bVal === 'string') {
+ return sortOrder === 'asc'
+ ? aVal.localeCompare(bVal)
+ : bVal.localeCompare(aVal);
+ }
+
+ const aNum = typeof aVal === 'number' ? aVal : 0;
+ const bNum = typeof bVal === 'number' ? bVal : 0;
+
+ return sortOrder === 'asc' ? aNum - bNum : bNum - aNum;
+ });
+
+ const formatDuration = (seconds: number | null) => {
+ if (!seconds) return 'N/A';
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+ return `${hours}h ${minutes}m ${secs}s`;
+ };
+
+ const SortHeader = ({ column, label }: { column: keyof VideoStats; label: string }) => (
+ handleSort(column)}
+ >
+
+ {label}
+ {sortBy === column && (
+
+ {sortOrder === 'asc' ? '↑' : '↓'}
+
+ )}
+
+
+ );
+
+ if (error) {
+ return (
+
+
+
+
Error loading stats
+
{error}
+
+
+ );
+ }
+
+ return (
+
+
+
+
Video Statistics
+
+ Performance metrics for all videos in the system
+
+
+
+ {stats.length} Videos
+
+
+
+ {/* Summary Cards */}
+
+
+
+
+ Total Views
+
+
+
+
+ {stats.reduce((sum, s) => sum + s.views, 0).toLocaleString()}
+
+
+
+
+
+
+
+ Unique Viewers
+
+
+
+
+ {stats.reduce((sum, s) => sum + s.totalViewers, 0).toLocaleString()}
+
+
+
+
+
+
+
+ Total Engagements
+
+
+
+
+ {stats.reduce((sum, s) => sum + s.engagement, 0).toLocaleString()}
+
+
+ Likes + Comments
+
+
+
+
+
+
+
+ Avg. Completion Rate
+
+
+
+
+ {(
+ stats.reduce((sum, s) => sum + parseFloat(s.completionRate), 0) /
+ (stats.length || 1)
+ ).toFixed(1)}
+ %
+
+
+
+
+
+ {/* Data Table */}
+
+
+ Video Performance Details
+
+
+
+ {loading ? (
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+ ) : stats.length === 0 ? (
+
+ No videos found
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {sortedStats.map((video) => (
+
+
+
+ {video.title}
+
+ {video.playlistTitle}
+
+
+
+
+ {video.uploaderName}
+
+
+ {video.views}
+
+
+ {video.totalViewers}
+
+
+ {video.completions}
+
+
+ = 50
+ ? 'default'
+ : parseFloat(video.completionRate) >= 25
+ ? 'secondary'
+ : 'destructive'
+ }
+ >
+ {video.completionRate}%
+
+
+
+ {video.avgPercentWatched.toFixed(1)}%
+
+
+ {video.engagement}
+
+
+ {video.likes}
+
+
+ {video.comments}
+
+
+ {video.totalSecondsWatched.toLocaleString()}s
+
+
+ {formatDuration(video.durationSec)}
+
+
+ ))}
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/app/admin/stats/page.tsx b/app/admin/stats/page.tsx
new file mode 100644
index 0000000..993bb26
--- /dev/null
+++ b/app/admin/stats/page.tsx
@@ -0,0 +1,26 @@
+// app/admin/stats/page.tsx
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { redirect } from 'next/navigation';
+import StatsClient from '../stats-client';
+
+export const dynamic = 'force-dynamic';
+
+export default async function StatsPage() {
+ const session = await getServerSession(authOptions);
+ const role = (session as any)?.user?.role ?? null;
+
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ if (!(role === 'admin' || role === 'superadmin')) {
+ redirect('/dashboard');
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/app/admin/users/[userId]/page.tsx b/app/admin/users/[userId]/page.tsx
new file mode 100644
index 0000000..bebeb07
--- /dev/null
+++ b/app/admin/users/[userId]/page.tsx
@@ -0,0 +1,26 @@
+// app/admin/users/[userId]/page.tsx
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { redirect } from 'next/navigation';
+import UserDetailClient from './user-detail-client';
+
+export default async function UserDetailPage({
+ params,
+}: {
+ params: Promise<{ userId: string }>;
+}) {
+ const session = await getServerSession(authOptions);
+ const role = (session as any)?.user?.role ?? null;
+
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ if (!(role === 'admin' || role === 'superadmin')) {
+ redirect('/dashboard');
+ }
+
+ const { userId } = await params;
+
+ return ;
+}
diff --git a/app/admin/users/[userId]/user-detail-client.tsx b/app/admin/users/[userId]/user-detail-client.tsx
new file mode 100644
index 0000000..6b13cc0
--- /dev/null
+++ b/app/admin/users/[userId]/user-detail-client.tsx
@@ -0,0 +1,564 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import { useSession } from 'next-auth/react';
+import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import { SegmentedProgressBar, WatchSegment } from '@/components/segmented-progress-bar';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import { formatDistanceToNow } from 'date-fns';
+import { ArrowLeft, Trash2, Save } from 'lucide-react';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+import { toast } from 'sonner';
+
+type Video = {
+ id: string;
+ title: string;
+};
+
+type UserProgress = {
+ id: string;
+ videoId: string;
+ video: Video;
+ watchedSec: number;
+ lastPos: number | null;
+ percent: number;
+ completed: boolean;
+ durationSec: number | null;
+ updatedAt: string;
+ createdAt: string;
+};
+
+type UserComment = {
+ id: string;
+ content: string;
+ createdAt: string;
+ video: Video;
+ replies: Array<{ id: string }>;
+};
+
+type UserDetail = {
+ id: string;
+ email: string;
+ name: string | null;
+ image: string | null;
+ role: string;
+ createdAt: string;
+ enrollments: Array<{
+ course: {
+ id: string;
+ title: string;
+ };
+ }>;
+ progress: UserProgress[];
+ comments: UserComment[];
+};
+
+interface UserDetailClientProps {
+ userId: string;
+}
+
+function UserDetailClientUI({ userId }: UserDetailClientProps) {
+ const { data: session } = useSession();
+ const [user, setUser] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [progressSegments, setProgressSegments] = useState>({});
+ const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [selectedRole, setSelectedRole] = useState('');
+ const [isRoleChangeAlertOpen, setIsRoleChangeAlertOpen] = useState(false);
+ const [isUpdatingRole, setIsUpdatingRole] = useState(false);
+ const router = useRouter();
+
+ const isSuperadmin = (session?.user as any)?.role === 'superadmin';
+
+ useEffect(() => {
+ const fetchUserDetail = async () => {
+ setIsLoading(true);
+ try {
+ const res = await fetch(`/api/admin/users/${userId}`);
+ if (res.ok) {
+ const data = await res.json();
+ setUser(data);
+ setSelectedRole(data.role);
+
+ // Fetch segments for all progress entries
+ const segments: Record = {};
+ for (const prog of data.progress) {
+ try {
+ const segRes = await fetch(`/api/admin/users/${userId}/progress/${prog.videoId}/segments`);
+ if (segRes.ok) {
+ const segData = await segRes.json();
+ segments[prog.videoId] = segData.segments ?? [];
+ }
+ } catch (err) {
+ console.error(`Failed to fetch segments for video ${prog.videoId}:`, err);
+ }
+ }
+ setProgressSegments(segments);
+ }
+ } catch (err) {
+ console.error('Failed to fetch user detail:', err);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchUserDetail();
+ }, [userId]);
+
+ const handleDeleteUser = async () => {
+ setIsDeleting(true);
+ try {
+ const res = await fetch(`/api/admin/users/${userId}`, {
+ method: 'DELETE',
+ });
+
+ if (res.ok) {
+ toast.success('User deleted successfully');
+ router.push('/admin/users');
+ } else {
+ const data = await res.json();
+ toast.error(data.error || 'Failed to delete user');
+ }
+ } catch (err) {
+ console.error('Failed to delete user:', err);
+ toast.error('Error deleting user');
+ } finally {
+ setIsDeleting(false);
+ setIsDeleteAlertOpen(false);
+ }
+ };
+
+ const handleUpdateRole = async () => {
+ if (!user || selectedRole === user.role) {
+ setIsRoleChangeAlertOpen(false);
+ return;
+ }
+
+ setIsUpdatingRole(true);
+ try {
+ const res = await fetch('/api/admin/update-user-role', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ userId,
+ newRole: selectedRole,
+ }),
+ });
+
+ if (res.ok) {
+ const data = await res.json();
+ setUser({ ...user, role: selectedRole });
+ toast.success(`User role updated to ${selectedRole}`);
+ setIsRoleChangeAlertOpen(false);
+ } else {
+ const data = await res.json();
+ toast.error(data.error || 'Failed to update user role');
+ setSelectedRole(user.role);
+ }
+ } catch (err) {
+ console.error('Failed to update user role:', err);
+ toast.error('Error updating user role');
+ setSelectedRole(user.role);
+ } finally {
+ setIsUpdatingRole(false);
+ }
+ };
+
+ if (isLoading) {
+ return (
+ Loading user details...
+ );
+ }
+
+ if (!user) {
+ return (
+
+
+
User not found
+
router.back()} className="mt-4">
+ Go back
+
+
+ );
+ }
+
+ const getInitials = (name: string | null, email: string) => {
+ if (!name) {
+ return email.substring(0, 2).toUpperCase();
+ }
+ return name
+ .split(' ')
+ .map((n) => n[0])
+ .join('')
+ .toUpperCase();
+ };
+
+ const formatSeconds = (seconds: number) => {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+ if (hours > 0) {
+ return `${hours}h ${minutes}m ${secs}s`;
+ }
+ return `${minutes}m ${secs}s`;
+ };
+
+ return (
+
+
+
router.back()}
+ >
+
+ Back
+
+
setIsDeleteAlertOpen(true)}
+ disabled={isDeleting}
+ className="gap-2"
+ >
+
+ Delete User
+
+
+
+ {/* User Header */}
+
+
+
+
+
+
+ {getInitials(user.name, user.email)}
+
+
+
+
+
+ {user.name || user.email}
+
+
+ {user.role}
+
+
+
{user.email}
+
+ Joined {formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
+
+
+
+
+
+
+ {/* Role Assignment Card (Superadmin Only) */}
+ {isSuperadmin && (
+
+
+ Assign Role
+
+
+
+
+ User Role
+
+
+
+
+
+ User (Student)
+ Admin
+ Superadmin
+
+
+
+
setIsRoleChangeAlertOpen(true)}
+ disabled={selectedRole === user.role || isUpdatingRole}
+ className="gap-2"
+ >
+
+ {isUpdatingRole ? 'Updating...' : 'Update Role'}
+
+
+
+ • User: Regular student with access to enrolled courses
+
+ • Admin: Can manage courses, playlists, videos, and users
+
+ • Superadmin: Full access + can assign roles to other admins
+
+
+
+ )}
+
+ {/* Enrollments */}
+ {user.enrollments.length > 0 && (
+
+
+ Enrollments ({user.enrollments.length})
+
+
+
+ {user.enrollments.map((enrollment, idx) => (
+
+ {enrollment.course.title}
+
+ ))}
+
+
+
+ )}
+
+ {/* Tabs for Watch History and Comments */}
+
+
+
+ Watch History ({user.progress.length})
+
+
+ Comments ({user.comments.length})
+
+
+
+ {/* Watch History Tab */}
+
+
+
+ {user.progress.length === 0 ? (
+
+ No watch history
+
+ ) : (
+
+
+
+
+ Video
+ Watched
+ Duration
+ Progress
+ Status
+ Last Updated
+
+
+
+ {user.progress.map((progress) => (
+
+
+ {progress.video.title}
+
+
+ {formatSeconds(progress.watchedSec)}
+
+
+ {progress.durationSec
+ ? formatSeconds(progress.durationSec)
+ : '-'}
+
+
+
+
+
+ {progress.completed ? (
+ Completed
+ ) : (
+ In Progress
+ )}
+
+
+ {formatDistanceToNow(new Date(progress.updatedAt), {
+ addSuffix: true,
+ })}
+
+
+ ))}
+
+
+
+ )}
+
+
+
+
+ {/* Comments Tab */}
+
+
+
+ {user.comments.length === 0 ? (
+
+ No comments
+
+ ) : (
+
+ {user.comments.map((comment) => (
+
+
+
+
+ Video: {comment.video.title}
+
+
+ {formatDistanceToNow(new Date(comment.createdAt), {
+ addSuffix: true,
+ })}
+
+
+ {comment.replies.length > 0 && (
+
+ {comment.replies.length} replies
+
+ )}
+
+
{comment.content}
+
+ ))}
+
+ )}
+
+
+
+
+
+ {/* Delete User Alert Dialog */}
+
+
+ Delete User
+
+
+
+ Are you sure you want to delete {user?.email} ?
+
+
+ ⚠️ This will permanently delete:
+
+
+ User account and profile
+ All enrollments
+ All progress and watch history
+ All comments and replies
+ All video likes
+ All video unlocks
+
+
+ This action cannot be undone.
+
+
+
+
+
+ Cancel
+
+
+ {isDeleting ? 'Deleting...' : 'Delete User'}
+
+
+
+
+
+ {/* Role Change Confirmation Dialog */}
+
+
+ Update User Role
+
+
+
+ Change {user?.email} 's role from{' '}
+ {user?.role} to{' '}
+ {selectedRole} ?
+
+ {selectedRole === 'superadmin' && (
+
+ ⚠️ This user will have full access including the ability to assign roles to other users.
+
+ )}
+ {selectedRole === 'user' && user?.role !== 'user' && (
+
+ ℹ️ This user will lose admin access but will still have access to enrolled courses.
+
+ )}
+
+
+
+
+ Cancel
+
+
+ {isUpdatingRole ? 'Updating...' : 'Update Role'}
+
+
+
+
+
+ );
+}
+
+
+export default function UserDetailClient({ userId }: UserDetailClientProps) {
+ return (
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/admin/users/admin-client.tsx b/app/admin/users/admin-client.tsx
new file mode 100644
index 0000000..d290fad
--- /dev/null
+++ b/app/admin/users/admin-client.tsx
@@ -0,0 +1,185 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { formatDistanceToNow } from 'date-fns';
+import { Eye } from 'lucide-react';
+
+type User = {
+ id: string;
+ email: string;
+ name: string | null;
+ image: string | null;
+ role: string;
+ createdAt: Date;
+ enrollments: Array<{
+ course: {
+ id: string;
+ title: string;
+ };
+ }>;
+ lastActivity: Date | null;
+};
+
+function UsersAdminClientUI() {
+ const [users, setUsers] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const router = useRouter();
+
+ useEffect(() => {
+ const fetchUsers = async () => {
+ setIsLoading(true);
+ try {
+ const res = await fetch('/api/admin/users');
+ if (res.ok) {
+ const data = await res.json();
+ setUsers(data);
+ }
+ } catch (err) {
+ console.error('Failed to fetch users:', err);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchUsers();
+ }, []);
+
+ const getRoleBadgeVariant = (role: string) => {
+ switch (role) {
+ case 'superadmin':
+ return 'default';
+ case 'admin':
+ return 'secondary';
+ default:
+ return 'outline';
+ }
+ };
+
+ if (isLoading) {
+ return (
+ Loading users...
+ );
+ }
+
+ return (
+
+
+
+ Total: {users.length} users
+
+
+
+
+
+ Email
+ Name
+ Role
+ Courses
+ Last Activity
+ Joined
+ Actions
+
+
+
+ {users.length === 0 ? (
+
+
+ No users found
+
+
+ ) : (
+ users.map((user) => (
+
+ {user.email}
+ {user.name || '-'}
+
+
+ {user.role}
+
+
+
+ {user.enrollments.length > 0 ? (
+
+ {user.enrollments.map((enrollment, idx) => (
+
+ {enrollment.course.title}
+
+ ))}
+
+ ) : (
+ -
+ )}
+
+
+ {user.lastActivity ? (
+ formatDistanceToNow(new Date(user.lastActivity), { addSuffix: true })
+ ) : (
+ No activity
+ )}
+
+
+ {formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
+
+
+ router.push(`/admin/users/${user.id}`)}
+ >
+
+ View
+
+
+
+ ))
+ )}
+
+
+
+
+
+ );
+
+}
+export default function sersAdminClient() {
+ return (
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx
new file mode 100644
index 0000000..2ad6bc2
--- /dev/null
+++ b/app/admin/users/page.tsx
@@ -0,0 +1,22 @@
+// app/admin/users/page.tsx
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { redirect } from 'next/navigation';
+import UsersAdminClient from './admin-client';
+
+export const dynamic = 'force-dynamic';
+
+export default async function UsersAdminPage() {
+ const session = await getServerSession(authOptions);
+ const role = (session as any)?.user?.role ?? null;
+
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ if (!(role === 'admin' || role === 'superadmin')) {
+ redirect('/dashboard');
+ }
+
+ return ;
+}
diff --git a/app/admin/videos/[videoId]/edit/edit-video-client.tsx b/app/admin/videos/[videoId]/edit/edit-video-client.tsx
new file mode 100644
index 0000000..792fc27
--- /dev/null
+++ b/app/admin/videos/[videoId]/edit/edit-video-client.tsx
@@ -0,0 +1,268 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { toast } from 'sonner';
+import Image from 'next/image';
+import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ Field,
+ FieldGroup,
+ FieldLabel,
+ FieldDescription,
+} from '@/components/ui/field';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import { Textarea } from '@/components/ui/textarea';
+
+interface VideoData {
+ id: string;
+ title: string;
+ description: string;
+ thumbnail?: string;
+ playlistTitle: string;
+ courseTitle: string;
+ restrictedCourseIds?: string[];
+}
+
+interface CourseData {
+ id: string;
+ title: string;
+}
+
+export default function EditVideoClient({ video }: { video: VideoData }) {
+ const router = useRouter();
+ const [title, setTitle] = useState(video.title);
+ const [description, setDescription] = useState(video.description);
+ const [thumbFile, setThumbFile] = useState(null);
+ const [thumbPreview, setThumbPreview] = useState(video.thumbnail);
+ const [loading, setLoading] = useState(false);
+ const [courses, setCourses] = useState([]);
+ const [coursesLoading, setCoursesLoading] = useState(true);
+ const [restrictedCourseIds, setRestrictedCourseIds] = useState(
+ video.restrictedCourseIds ?? []
+ );
+
+ useEffect(() => {
+ let cancelled = false;
+ setCoursesLoading(true);
+ fetch('/api/admin/meta')
+ .then(async (res) => {
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const json = await res.json();
+ if (!cancelled) {
+ setCourses(json.courses ?? []);
+ }
+ })
+ .catch((err) => {
+ console.error('Failed to load courses', err);
+ if (!cancelled) {
+ setCourses([]);
+ }
+ })
+ .finally(() => {
+ if (!cancelled) setCoursesLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const handleThumbnailChange = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (file) {
+ setThumbFile(file);
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ setThumbPreview(event.target?.result as string);
+ };
+ reader.readAsDataURL(file);
+ }
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLoading(true);
+
+ try {
+ const formData = new FormData();
+ formData.append('videoId', video.id);
+ formData.append('title', title);
+ formData.append('description', description);
+ if (thumbFile) {
+ formData.append('thumbnail', thumbFile);
+ }
+ formData.append('restrictedCourseIds', JSON.stringify(restrictedCourseIds));
+
+ const res = await fetch('/api/admin/update-video', {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (res.ok) {
+ toast.success('Video updated successfully');
+ router.push('/admin/videos');
+ } else {
+ const err = await res.text();
+ toast.error('Failed to update video: ' + err);
+ }
+ } catch (err: any) {
+ toast.error('Error: ' + String(err.message ?? err));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ Edit Video
+
+
+ {video.courseTitle} • {video.playlistTitle}
+
+
+
+
+
+ {video.title}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/admin/videos/[videoId]/edit/page.tsx b/app/admin/videos/[videoId]/edit/page.tsx
new file mode 100644
index 0000000..5bffbf3
--- /dev/null
+++ b/app/admin/videos/[videoId]/edit/page.tsx
@@ -0,0 +1,57 @@
+// app/admin/videos/[videoId]/edit/page.tsx
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+import { redirect } from 'next/navigation';
+import EditVideoClient from './edit-video-client';
+
+export default async function EditVideoPage({
+ params,
+}: {
+ params: Promise<{ videoId: string }>;
+}) {
+ const { videoId } = await params;
+
+ const session = await getServerSession(authOptions);
+ const role = (session as any)?.user?.role ?? null;
+
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ if (!(role === 'admin' || role === 'superadmin')) {
+ redirect('/dashboard');
+ }
+
+ const video = await prisma.video.findUnique({
+ where: { id: videoId },
+ include: {
+ playlist: {
+ include: {
+ course: true,
+ },
+ },
+ videoCourses: true,
+ },
+ });
+
+ if (!video) {
+ redirect('/admin/videos');
+ }
+
+ return (
+ vc.exclusive)
+ .map((vc) => vc.courseId),
+ }}
+ />
+ );
+}
diff --git a/app/admin/videos/admin-client.tsx b/app/admin/videos/admin-client.tsx
new file mode 100644
index 0000000..7bb48c6
--- /dev/null
+++ b/app/admin/videos/admin-client.tsx
@@ -0,0 +1,815 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
+import { AppSidebar } from '@/components/app-sidebar';
+import { Badge } from '@/components/ui/badge';
+import { SiteHeader } from '@/components/site-header';
+import { useRouter } from 'next/navigation';
+import { toast } from 'sonner';
+import { Progress } from '@/components/ui/progress';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ Field,
+ FieldGroup,
+ FieldLabel,
+ FieldDescription,
+} from '@/components/ui/field';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog';
+import { Trash2Icon, Edit, Check, X } from 'lucide-react';
+import { Switch } from '@/components/ui/switch';
+import { getVideoDuration } from '@/utils/getVideoDuration';
+
+type Course = { id: string; title: string; code?: string };
+type Playlist = { id: string; title: string; courseId: string };
+type Video = {
+ id: string;
+ title: string;
+ durationSec?: number;
+ thumbnail?: string;
+ url: string;
+ index: number;
+ locked: boolean;
+ instantAccess: boolean;
+ playlistId: string;
+ playlist: {
+ id: string;
+ title: string;
+ course: {
+ id: string;
+ title: string;
+ };
+ };
+ restrictedCourseIds?: string[];
+};
+
+function VideosUI() {
+ const router = useRouter();
+ const [courses, setCourses] = useState([]);
+ const [playlists, setPlaylists] = useState([]);
+ const [videos, setVideos] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [uploadProgress, setUploadProgress] = useState(null);
+
+ // form state
+ const [videoTitle, setVideoTitle] = useState('');
+ const [videoFile, setVideoFile] = useState(null);
+ const [videoPlaylistId, setVideoPlaylistId] = useState('');
+ const [thumbFile, setThumbFile] = useState(null);
+ const [videoDurationSec, setVideoDurationSec] = useState(null);
+ const [deletingId, setDeletingId] = useState(null);
+ const [togglingId, setTogglingId] = useState(null);
+ const [editingId, setEditingId] = useState(null);
+ const [editingTitle, setEditingTitle] = useState('');
+ const [savingEditId, setSavingEditId] = useState(null);
+
+ // Manual file copy mode
+ const [useManualFileCopy, setUseManualFileCopy] = useState(false);
+ const [pendingVideoId, setPendingVideoId] = useState(null);
+ const [finalizingVideoId, setFinalizingVideoId] = useState(null);
+
+ useEffect(() => {
+ fetchData();
+ }, []);
+
+ async function toggleInstantAccess(videoId: string, currentValue: boolean) {
+ setTogglingId(videoId);
+ try {
+ const res = await fetch(`/api/admin/videos/${videoId}/instant-access`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ instantAccess: !currentValue }),
+ });
+
+ if (res.ok) {
+ const data = await res.json();
+ // Update the video in state
+ setVideos((prev) =>
+ prev.map((v) =>
+ v.id === videoId ? { ...v, instantAccess: data.video.instantAccess } : v
+ )
+ );
+ toast.success(`Video ${!currentValue ? 'set to' : 'removed from'} instant access`);
+ } else {
+ toast.error('Failed to toggle instant access');
+ }
+ } catch (err) {
+ console.error('Error toggling instant access:', err);
+ toast.error('Error toggling instant access');
+ } finally {
+ setTogglingId(null);
+ }
+ }
+
+ async function toggleLocked(videoId: string, currentValue: boolean) {
+ setTogglingId(videoId);
+ try {
+ const res = await fetch(`/api/admin/videos/${videoId}/locked`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ locked: !currentValue }),
+ });
+
+ if (res.ok) {
+ const data = await res.json();
+ // Update the video in state
+ setVideos((prev) =>
+ prev.map((v) =>
+ v.id === videoId ? { ...v, locked: data.video.locked } : v
+ )
+ );
+ toast.success(`Video ${!currentValue ? 'locked' : 'unlocked'}`);
+ } else {
+ toast.error('Failed to toggle lock status');
+ }
+ } catch (err) {
+ console.error('Error toggling locked:', err);
+ toast.error('Error toggling lock status');
+ } finally {
+ setTogglingId(null);
+ }
+ }
+
+ async function fetchData() {
+ try {
+ const [metaRes, videosRes] = await Promise.all([
+ fetch('/api/admin/meta'),
+ fetch('/api/admin/videos'),
+ ]);
+
+ if (metaRes.ok) {
+ const json = await metaRes.json();
+ setCourses(json.courses || []);
+ setPlaylists(json.playlists || []);
+ if (!videoPlaylistId && json.playlists?.[0]) {
+ setVideoPlaylistId(json.playlists[0].id);
+ }
+ }
+
+ if (videosRes.ok) {
+ const json = await videosRes.json();
+ // Ensure data is serialized properly
+ setVideos(json.map((v: any) => ({
+ id: v.id,
+ title: v.title,
+ durationSec: v.durationSec,
+ thumbnail: v.thumbnail,
+ url: v.url,
+ index: v.index,
+ locked: v.locked,
+ instantAccess: v.instantAccess,
+ playlistId: v.playlistId,
+ playlist: {
+ id: v.playlist.id,
+ title: v.playlist.title,
+ course: {
+ id: v.playlist.course.id,
+ title: v.playlist.course.title,
+ },
+ },
+ restrictedCourseIds: (v.videoCourses ?? [])
+ .filter((assignment: any) => assignment.exclusive)
+ .map((assignment: any) => assignment.courseId),
+ })));
+ }
+ } catch (err) {
+ console.error('Failed to fetch data', err);
+ toast.error('Failed to fetch data');
+ }
+ }
+
+ async function handleFileChange(e: React.ChangeEvent) {
+ const f = e.target.files?.[0] ?? null;
+ setVideoFile(f);
+ setVideoDurationSec(null);
+ if (!f) return;
+ try {
+ const secs = await getVideoDuration(f);
+ setVideoDurationSec(secs);
+ toast.success(`Duration: ${secs}s`);
+ } catch (err) {
+ console.warn('duration read failed', err);
+ toast.error('Could not read duration (server will measure)');
+ }
+ }
+
+ async function uploadVideo(e: React.FormEvent) {
+ e.preventDefault();
+ if (!videoPlaylistId) {
+ toast.error('Select a playlist');
+ return;
+ }
+
+ // Manual file copy mode
+ if (useManualFileCopy) {
+ if (!videoFile) {
+ toast.error('Select a file to get the filename');
+ return;
+ }
+ // Create video entry without uploading the actual file
+ setLoading(true);
+ try {
+ const form = new FormData();
+ form.append('title', videoTitle);
+ form.append('playlistId', videoPlaylistId);
+ form.append('manualFileCopy', 'true'); // Flag to skip file upload
+ if (videoDurationSec) form.append('durationSec', String(videoDurationSec));
+ if (thumbFile) form.append('thumbnail', thumbFile);
+
+ const res = await fetch('/api/admin/upload', {
+ method: 'POST',
+ body: form,
+ });
+
+ const data = await res.json();
+
+ if (!res.ok) {
+ toast.error(data.error ?? 'Upload failed');
+ return;
+ }
+
+ // Show modal with the video ID
+ setPendingVideoId(data.video.id);
+ toast.success(`Video created! Copy file as: ${data.video.id}.mp4`);
+ } catch (err: any) {
+ console.error('upload error', err);
+ toast.error(String(err?.message ?? 'Upload failed'));
+ } finally {
+ setLoading(false);
+ }
+ return;
+ }
+
+ // Normal upload mode
+ if (!videoFile) {
+ toast.error('Pick a file first');
+ return;
+ }
+
+ setLoading(true);
+ setUploadProgress(0);
+
+ try {
+ const form = new FormData();
+ form.append('file', videoFile);
+ form.append('title', videoTitle);
+ form.append('playlistId', videoPlaylistId);
+ if (videoDurationSec) form.append('durationSec', String(videoDurationSec));
+ if (thumbFile) form.append('thumbnail', thumbFile);
+
+ await new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.open('POST', '/api/admin/upload');
+
+ xhr.upload.onprogress = (ev) => {
+ if (ev.lengthComputable) {
+ const pct = Math.round((ev.loaded / ev.total) * 100);
+ setUploadProgress(pct);
+ }
+ };
+
+ xhr.onload = async () => {
+ let body: any = null;
+ try {
+ body = xhr.responseText ? JSON.parse(xhr.responseText) : null;
+ } catch (err) {
+ body = { error: xhr.responseText };
+ }
+
+ if (xhr.status >= 200 && xhr.status < 300) {
+ toast.success('Upload complete');
+ setUploadProgress(null);
+ setVideoFile(null);
+ setVideoTitle('');
+ setVideoDurationSec(null);
+ setThumbFile(null);
+ await fetchData();
+ router.refresh();
+ resolve();
+ } else {
+ const errMsg = body?.error ?? `Upload failed (${xhr.status})`;
+ toast.error(errMsg);
+ setUploadProgress(null);
+ reject(new Error(errMsg));
+ }
+ };
+
+ xhr.onerror = () => {
+ toast.error('Upload failed (network)');
+ setUploadProgress(null);
+ reject(new Error('network error'));
+ };
+
+ // Timeout for 2GB uploads over Tailscale: 50 minutes
+ xhr.timeout = 50 * 60 * 1000;
+ xhr.ontimeout = () => {
+ toast.error('Upload timed out');
+ setUploadProgress(null);
+ reject(new Error('timeout'));
+ };
+
+ xhr.send(form);
+ });
+ } catch (err: any) {
+ console.error('upload error', err);
+ if (!uploadProgress) setUploadProgress(null);
+ toast.error(String(err?.message ?? 'Upload failed'));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function finalizeManualUpload(videoId: string) {
+ setFinalizingVideoId(videoId);
+ try {
+ const res = await fetch('/api/admin/upload/finalize', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ videoId }),
+ });
+
+ const data = await res.json();
+
+ if (!res.ok) {
+ toast.error(data.error ?? 'Finalization failed');
+ return;
+ }
+
+ toast.success(`Video finalized! ${data.message}`);
+ setPendingVideoId(null);
+ setVideoFile(null);
+ setVideoTitle('');
+ setVideoDurationSec(null);
+ setThumbFile(null);
+ setUseManualFileCopy(false);
+ await fetchData();
+ router.refresh();
+ } catch (err: any) {
+ console.error('finalize error', err);
+ toast.error(String(err?.message ?? 'Finalization failed'));
+ } finally {
+ setFinalizingVideoId(null);
+ }
+ }
+
+ async function deleteVideo(videoId: string) {
+ setDeletingId(videoId);
+ try {
+ const res = await fetch(`/api/admin/delete-video`, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ videoId }),
+ });
+ if (res.ok) {
+ toast.success('Video deleted');
+ await fetchData();
+ router.refresh();
+ } else {
+ const txt = await res.text();
+ toast.error('Failed to delete video: ' + txt);
+ }
+ } catch (err: any) {
+ toast.error('Error: ' + String(err.message ?? err));
+ } finally {
+ setDeletingId(null);
+ }
+ }
+
+ async function updateVideoTitle(videoId: string, newTitle: string) {
+ if (!newTitle.trim()) {
+ toast.error('Title cannot be empty');
+ return;
+ }
+
+ setSavingEditId(videoId);
+ try {
+ const res = await fetch(`/api/admin/update-video`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ videoId, title: newTitle }),
+ });
+ if (res.ok) {
+ toast.success('Video updated');
+ setEditingId(null);
+ setEditingTitle('');
+ await fetchData();
+ router.refresh();
+ } else {
+ const txt = await res.text();
+ toast.error('Failed to update video: ' + txt);
+ }
+ } catch (err: any) {
+ toast.error('Error: ' + String(err.message ?? err));
+ } finally {
+ setSavingEditId(null);
+ }
+ }
+
+ return (
+
+
+
+ Upload Video
+
+
+
+
+
+ Video title
+ Title shown in the playlist.
+ setVideoTitle(e.target.value)}
+ placeholder="e.g. Lesson 1 — Intro to the Rig"
+ />
+
+
+
+
+
+
+ Manual file copy mode
+
+
+
+ {useManualFileCopy
+ ? 'Creates DB entry and thumbnail. You will copy the file manually to the server.'
+ : 'Upload file directly from browser.'}
+
+
+
+
+ Playlist
+
+ Select which playlist this video belongs to.
+
+
+ setVideoPlaylistId(val)}
+ >
+
+
+
+
+ {playlists.map((p) => (
+
+ {p.title}
+
+ ))}
+
+
+
+
+
+ Video file
+
+ {useManualFileCopy
+ ? 'Select the file to get its name, then copy manually to: /uploads/videos/{videoId}.mp4'
+ : 'Large files will be uploaded to the server.'}
+
+
+
+ {videoDurationSec ? (
+
+ Duration: {videoDurationSec}s
+
+ ) : null}
+
+ {uploadProgress !== null && !useManualFileCopy ? (
+
+
+
Uploading
+
+ {uploadProgress}%
+
+
+
+
+ ) : null}
+
+
+
+ Thumbnail
+
+
setThumbFile(e.target.files?.[0] ?? null)}
+ className="block w-full text-sm"
+ />
+
+ {thumbFile ? (
+
+ ) : null}
+
+
+
+
+
+ {loading
+ ? useManualFileCopy
+ ? 'Creating…'
+ : 'Uploading…'
+ : useManualFileCopy
+ ? 'Create & Setup Manual Copy'
+ : 'Upload video'}
+
+
+
+
+
+
+
+ {/* Manual file copy modal */}
+ {pendingVideoId && (
+
+
+ File Ready for Manual Copy
+
+
+
+
+ Copy your video file to the following location on your server:
+
+
+ /uploads/videos/{pendingVideoId}.mp4
+
+
+ The file must be named exactly as shown above (using the video ID).
+
+
+
+ finalizeManualUpload(pendingVideoId)}
+ disabled={finalizingVideoId !== null}
+ className="w-full"
+ >
+ {finalizingVideoId ? 'Checking file…' : 'I have placed the file'}
+
+ setPendingVideoId(null)}
+ disabled={finalizingVideoId !== null}
+ className="w-full"
+ >
+ Cancel
+
+
+
+ )}
+
+
+
+ Videos
+
+
+ {videos.length === 0 ? (
+
+ No videos yet
+
+ ) : (
+
+
+
+
+ Thumbnail
+ Title
+ Playlist
+ Course
+ Locked
+ Instant Access
+ Actions
+
+
+
+ {videos.map((video) => (
+
+
+ {video.thumbnail ? (
+
+ ) : (
+
+
+ No image
+
+
+ )}
+
+
+ {editingId === video.id ? (
+
+ setEditingTitle(e.target.value)}
+ className="h-8"
+ autoFocus
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ updateVideoTitle(video.id, editingTitle);
+ }
+ if (e.key === 'Escape') {
+ setEditingId(null);
+ setEditingTitle('');
+ }
+ }}
+ />
+ updateVideoTitle(video.id, editingTitle)}
+ disabled={savingEditId === video.id}
+ >
+
+
+ {
+ setEditingId(null);
+ setEditingTitle('');
+ }}
+ disabled={savingEditId === video.id}
+ >
+
+
+
+ ) : (
+
+
+ {video.title}
+ {
+ setEditingId(video.id);
+ setEditingTitle(video.title);
+ }}
+ >
+
+
+
+ {video.restrictedCourseIds?.length ? (
+
+ {video.restrictedCourseIds.map((courseId) => {
+ const course = courses.find((c) => c.id === courseId);
+ const label = course?.code ?? course?.title ?? 'Course';
+ return (
+
+ {label}
+
+ );
+ })}
+
+ ) : null}
+
+ )}
+
+ {video.playlist.title}
+ {video.playlist.course.title}
+
+ toggleLocked(video.id, video.locked)}
+ disabled={togglingId === video.id}
+ aria-label="Toggle lock status"
+ />
+
+
+ toggleInstantAccess(video.id, video.instantAccess)}
+ disabled={togglingId === video.id}
+ aria-label="Toggle instant access"
+ />
+
+
+
+
router.push(`/admin/videos/${video.id}/edit`)}
+ >
+
+
+
+
+
+
+
+
+
+
+ Delete Video
+
+ Are you sure you want to delete "{video.title}"?
+
+
+
+
Cancel
+
deleteVideo(video.id)}
+ >
+ Delete
+
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ );
+}
+
+export default function VideosAdminClient() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/admin/videos/page.tsx b/app/admin/videos/page.tsx
new file mode 100644
index 0000000..b47a17e
--- /dev/null
+++ b/app/admin/videos/page.tsx
@@ -0,0 +1,22 @@
+// app/admin/videos/page.tsx
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { redirect } from 'next/navigation';
+import VideosAdminClient from './admin-client';
+
+export const dynamic = 'force-dynamic';
+
+export default async function VideosAdminPage() {
+ const session = await getServerSession(authOptions);
+ const role = (session as any)?.user?.role ?? null;
+
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ if (!(role === 'admin' || role === 'superadmin')) {
+ redirect('/dashboard');
+ }
+
+ return ;
+}
diff --git a/app/api/admin/allowed-students/import/route.ts b/app/api/admin/allowed-students/import/route.ts
new file mode 100644
index 0000000..786c107
--- /dev/null
+++ b/app/api/admin/allowed-students/import/route.ts
@@ -0,0 +1,156 @@
+// app/api/admin/allowed-students/import/route.ts
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+import { normalizeEmail } from '@/lib/normalize-email';
+
+// Check if user is admin
+async function checkAdmin(req: NextRequest) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return null;
+ }
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return null;
+ }
+
+ return session;
+}
+
+// POST import students from CSV
+export async function POST(req: NextRequest) {
+ try {
+ const session = await checkAdmin(req);
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const formData = await req.formData();
+ const file = formData.get('file') as File;
+
+ if (!file) {
+ return NextResponse.json({ error: 'No file provided' }, { status: 400 });
+ }
+
+ // Read file
+ const text = await file.text();
+ const lines = text.split('\n').filter((line) => line.trim().length > 0);
+
+ if (lines.length === 0) {
+ return NextResponse.json({ error: 'CSV file is empty' }, { status: 400 });
+ }
+
+ // Skip header row if it exists (assume first line is header if email doesn't look like email)
+ let startIndex = 0;
+ if (lines[0].toLowerCase().includes('email')) {
+ startIndex = 1;
+ }
+
+ const results = {
+ imported: 0,
+ updated: 0,
+ errors: [] as Array<{ row: number; email: string; error: string }>,
+ };
+
+ for (let i = startIndex; i < lines.length; i++) {
+ const line = lines[i].trim();
+ if (!line) continue;
+
+ const [email, levels] = line.split(',').map((v) => v.trim());
+ const normalizedEmail = normalizeEmail(email);
+
+ if (!normalizedEmail || !levels) {
+ results.errors.push({
+ row: i + 1,
+ email: email || 'N/A',
+ error: 'Invalid format: expected "email,courseCodes"',
+ });
+ continue;
+ }
+
+ // Validate email format
+ if (!email.includes('@')) {
+ results.errors.push({
+ row: i + 1,
+ email,
+ error: 'Invalid email format',
+ });
+ continue;
+ }
+
+ // Validate levels are valid course codes
+ const levelArray = levels.split('|').map((l) => l.trim());
+ let invalidLevel = null;
+
+ for (const level of levelArray) {
+ const course = await prisma.course.findUnique({ where: { code: level } });
+ if (!course) {
+ invalidLevel = level;
+ break;
+ }
+ }
+
+ if (invalidLevel) {
+ results.errors.push({
+ row: i + 1,
+ email,
+ error: `Course code '${invalidLevel}' not found`,
+ });
+ continue;
+ }
+
+ try {
+ // Check if student already exists
+ const existing = await prisma.allowedStudent.findFirst({
+ where: {
+ email: {
+ equals: normalizedEmail,
+ mode: 'insensitive',
+ },
+ },
+ });
+
+ if (existing) {
+ // Update existing
+ await prisma.allowedStudent.update({
+ where: { id: existing.id },
+ data: {
+ email: normalizedEmail,
+ levels: levelArray.join(','),
+ active: true,
+ },
+ });
+ results.updated++;
+ } else {
+ // Create new
+ await prisma.allowedStudent.create({
+ data: {
+ email: normalizedEmail,
+ levels: levelArray.join(','),
+ active: true,
+ },
+ });
+ results.imported++;
+ }
+ } catch (err) {
+ results.errors.push({
+ row: i + 1,
+ email,
+ error: `Database error: ${(err as any)?.message || 'Unknown error'}`,
+ });
+ }
+ }
+
+ return NextResponse.json(results);
+ } catch (err) {
+ console.error('POST /api/admin/allowed-students/import error', err);
+ return NextResponse.json(
+ { error: 'Failed to import students' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/allowed-students/route.ts b/app/api/admin/allowed-students/route.ts
new file mode 100644
index 0000000..e58273d
--- /dev/null
+++ b/app/api/admin/allowed-students/route.ts
@@ -0,0 +1,192 @@
+// app/api/admin/allowed-students/route.ts
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+import { normalizeEmail } from '@/lib/normalize-email';
+
+// Check if user is admin
+async function checkAdmin(req: NextRequest) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return null;
+ }
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return null;
+ }
+
+ return session;
+}
+
+// GET all allowed students
+export async function GET(req: NextRequest) {
+ try {
+ const session = await checkAdmin(req);
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const students = await prisma.allowedStudent.findMany({
+ orderBy: { createdAt: 'desc' },
+ });
+
+ return NextResponse.json(students);
+ } catch (err) {
+ console.error('GET /api/admin/allowed-students error', err);
+ return NextResponse.json(
+ { error: 'Failed to fetch allowed students' },
+ { status: 500 }
+ );
+ }
+}
+
+// POST create new allowed student
+export async function POST(req: NextRequest) {
+ try {
+ const session = await checkAdmin(req);
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const body = await req.json();
+ const { email, levels } = body;
+ const normalizedEmail = normalizeEmail(email);
+
+ if (!normalizedEmail || !levels) {
+ return NextResponse.json(
+ { error: 'Email and levels required' },
+ { status: 400 }
+ );
+ }
+
+ // Validate levels are valid course codes
+ const levelArray = levels.split(',').map((l: string) => l.trim());
+ for (const level of levelArray) {
+ const course = await prisma.course.findUnique({ where: { code: level } });
+ if (!course) {
+ return NextResponse.json(
+ { error: `Course code '${level}' not found` },
+ { status: 400 }
+ );
+ }
+ }
+
+ // Check if student already exists
+ const existing = await prisma.allowedStudent.findFirst({
+ where: {
+ email: {
+ equals: normalizedEmail,
+ mode: 'insensitive',
+ },
+ },
+ });
+
+ if (existing) {
+ return NextResponse.json(
+ { error: 'Email already registered' },
+ { status: 409 }
+ );
+ }
+
+ const student = await prisma.allowedStudent.create({
+ data: {
+ email: normalizedEmail,
+ levels: levelArray.join(','),
+ active: true,
+ },
+ });
+
+ return NextResponse.json(student, { status: 201 });
+ } catch (err) {
+ console.error('POST /api/admin/allowed-students error', err);
+ return NextResponse.json(
+ { error: 'Failed to create allowed student' },
+ { status: 500 }
+ );
+ }
+}
+
+// PUT update allowed student
+export async function PUT(req: NextRequest) {
+ try {
+ const session = await checkAdmin(req);
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const body = await req.json();
+ const { id, email, levels, active } = body;
+ const normalizedEmail = email ? normalizeEmail(email) : null;
+
+ if (!id) {
+ return NextResponse.json({ error: 'Student ID required' }, { status: 400 });
+ }
+
+ if (email && !normalizedEmail) {
+ return NextResponse.json({ error: 'Valid email required' }, { status: 400 });
+ }
+
+ // Validate levels if provided
+ if (levels) {
+ const levelArray = levels.split(',').map((l: string) => l.trim());
+ for (const level of levelArray) {
+ const course = await prisma.course.findUnique({ where: { code: level } });
+ if (!course) {
+ return NextResponse.json(
+ { error: `Course code '${level}' not found` },
+ { status: 400 }
+ );
+ }
+ }
+ }
+
+ const student = await prisma.allowedStudent.update({
+ where: { id },
+ data: {
+ ...(normalizedEmail && { email: normalizedEmail }),
+ ...(levels && { levels }),
+ ...(active !== undefined && { active }),
+ },
+ });
+
+ return NextResponse.json(student);
+ } catch (err) {
+ console.error('PUT /api/admin/allowed-students error', err);
+ return NextResponse.json(
+ { error: 'Failed to update allowed student' },
+ { status: 500 }
+ );
+ }
+}
+
+// DELETE allowed student
+export async function DELETE(req: NextRequest) {
+ try {
+ const session = await checkAdmin(req);
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(req.url);
+ const id = searchParams.get('id');
+
+ if (!id) {
+ return NextResponse.json({ error: 'Student ID required' }, { status: 400 });
+ }
+
+ await prisma.allowedStudent.delete({
+ where: { id },
+ });
+
+ return NextResponse.json({ message: 'Student removed' });
+ } catch (err) {
+ console.error('DELETE /api/admin/allowed-students error', err);
+ return NextResponse.json(
+ { error: 'Failed to delete allowed student' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/create-course/route.ts b/app/api/admin/create-course/route.ts
new file mode 100644
index 0000000..f3f7f7e
--- /dev/null
+++ b/app/api/admin/create-course/route.ts
@@ -0,0 +1,27 @@
+// app/api/admin/create-course/route.ts
+import { NextResponse } from 'next/server';
+import { prisma } from '@/lib/prisma';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+
+async function checkAdmin() {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) throw { status: 401, message: 'Unauthorized' };
+ const allowed = (process.env.ALLOWED_ADMINS || '').split(',').map(s=>s.trim()).filter(Boolean);
+ if (allowed.length && !allowed.includes(session.user.email)) throw { status: 403, message: 'Forbidden' };
+}
+
+export async function POST(req: Request) {
+ try {
+ await checkAdmin();
+ const body = await req.json();
+ const { title, code } = body;
+ if (!title) return NextResponse.json({ error: 'missing title' }, { status: 400 });
+
+ const course = await prisma.course.create({ data: { title, code } });
+ return NextResponse.json({ course });
+ } catch (err: any) {
+ console.error(err);
+ return NextResponse.json({ error: err?.message ?? 'server' }, { status: err?.status ?? 500 });
+ }
+}
diff --git a/app/api/admin/create-playlist/route.ts b/app/api/admin/create-playlist/route.ts
new file mode 100644
index 0000000..6d64e78
--- /dev/null
+++ b/app/api/admin/create-playlist/route.ts
@@ -0,0 +1,42 @@
+// app/api/admin/create-playlist/route.ts
+import { NextResponse } from 'next/server';
+import { prisma } from '@/lib/prisma';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+
+async function checkAdmin() {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) throw { status: 401, message: 'Unauthorized' };
+ const allowed = (process.env.ALLOWED_ADMINS || '').split(',').map(s=>s.trim()).filter(Boolean);
+ if (allowed.length && !allowed.includes(session.user.email)) throw { status: 403, message: 'Forbidden' };
+}
+
+export async function POST(req: Request) {
+ try {
+ await checkAdmin();
+ const { title, courseId, additionalCourseIds } = await req.json();
+ if (!title || !courseId) return NextResponse.json({ error: 'missing fields' }, { status: 400 });
+
+ const playlist = await prisma.playlist.create({
+ data: {
+ title,
+ courseId,
+ // Create CoursePlaylist mappings for additional courses
+ courses: {
+ create: (additionalCourseIds || []).map((cid: string) => ({
+ courseId: cid,
+ })),
+ },
+ },
+ include: {
+ courses: {
+ include: { course: true },
+ },
+ },
+ });
+ return NextResponse.json({ playlist });
+ } catch (err: any) {
+ console.error(err);
+ return NextResponse.json({ error: err?.message ?? 'server' }, { status: err?.status ?? 500 });
+ }
+}
diff --git a/app/api/admin/delete-course/route.ts b/app/api/admin/delete-course/route.ts
new file mode 100644
index 0000000..80675d5
--- /dev/null
+++ b/app/api/admin/delete-course/route.ts
@@ -0,0 +1,40 @@
+// app/api/admin/delete-course/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function DELETE(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ const body = await req.json().catch(() => ({}));
+ const { courseId } = body ?? {};
+
+ if (!courseId)
+ return NextResponse.json(
+ { error: 'courseId required' },
+ { status: 400 }
+ );
+
+ // Delete course (cascade will handle playlists and videos via DB constraints)
+ await prisma.course.delete({
+ where: { id: courseId },
+ });
+
+ return NextResponse.json({ success: true });
+ } catch (err: any) {
+ console.error('delete course error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/delete-playlist/route.ts b/app/api/admin/delete-playlist/route.ts
new file mode 100644
index 0000000..e7aaa64
--- /dev/null
+++ b/app/api/admin/delete-playlist/route.ts
@@ -0,0 +1,40 @@
+// app/api/admin/delete-playlist/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function DELETE(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ const body = await req.json().catch(() => ({}));
+ const { playlistId } = body ?? {};
+
+ if (!playlistId)
+ return NextResponse.json(
+ { error: 'playlistId required' },
+ { status: 400 }
+ );
+
+ // Delete playlist (cascade will handle videos via DB constraints)
+ await prisma.playlist.delete({
+ where: { id: playlistId },
+ });
+
+ return NextResponse.json({ success: true });
+ } catch (err: any) {
+ console.error('delete playlist error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/delete-video/route.ts b/app/api/admin/delete-video/route.ts
new file mode 100644
index 0000000..877d74e
--- /dev/null
+++ b/app/api/admin/delete-video/route.ts
@@ -0,0 +1,37 @@
+// app/api/admin/delete-video/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function DELETE(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ const body = await req.json().catch(() => ({}));
+ const { videoId } = body ?? {};
+
+ if (!videoId)
+ return NextResponse.json({ error: 'videoId required' }, { status: 400 });
+
+ // Delete video (cascade will handle progress records)
+ await prisma.video.delete({
+ where: { id: videoId },
+ });
+
+ return NextResponse.json({ success: true });
+ } catch (err: any) {
+ console.error('delete video error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/manage-playlist-courses/route.ts b/app/api/admin/manage-playlist-courses/route.ts
new file mode 100644
index 0000000..902b8f7
--- /dev/null
+++ b/app/api/admin/manage-playlist-courses/route.ts
@@ -0,0 +1,108 @@
+// app/api/admin/manage-playlist-courses/route.ts
+// Assign or remove a playlist from additional courses
+import { NextResponse } from 'next/server';
+import { prisma } from '@/lib/prisma';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+
+async function checkAdmin() {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) throw { status: 401, message: 'Unauthorized' };
+ const allowed = (process.env.ALLOWED_ADMINS || '').split(',').map(s=>s.trim()).filter(Boolean);
+ if (allowed.length && !allowed.includes(session.user.email)) throw { status: 403, message: 'Forbidden' };
+}
+
+// GET: Get all courses a playlist is assigned to
+export async function GET(req: Request) {
+ try {
+ await checkAdmin();
+ const { searchParams } = new URL(req.url);
+ const playlistId = searchParams.get('playlistId');
+
+ if (!playlistId) return NextResponse.json({ error: 'playlistId required' }, { status: 400 });
+
+ const playlist = await prisma.playlist.findUnique({
+ where: { id: playlistId },
+ include: {
+ course: true,
+ courses: {
+ include: { course: true },
+ },
+ },
+ });
+
+ if (!playlist) return NextResponse.json({ error: 'Playlist not found' }, { status: 404 });
+
+ // Return primary course + all additional courses
+ const allCourses = [
+ playlist.course,
+ ...playlist.courses.map(cp => cp.course),
+ ];
+
+ return NextResponse.json({ playlist, allCourses });
+ } catch (err: any) {
+ console.error(err);
+ return NextResponse.json({ error: err?.message ?? 'server' }, { status: err?.status ?? 500 });
+ }
+}
+
+// POST: Assign playlist to an additional course
+export async function POST(req: Request) {
+ try {
+ await checkAdmin();
+ const { playlistId, courseId } = await req.json();
+
+ if (!playlistId || !courseId) return NextResponse.json({ error: 'missing fields' }, { status: 400 });
+
+ // Check if already assigned
+ const existing = await prisma.coursePlaylist.findFirst({
+ where: { playlistId, courseId },
+ });
+
+ if (existing) return NextResponse.json({ error: 'Already assigned' }, { status: 400 });
+
+ const assignment = await prisma.coursePlaylist.create({
+ data: { playlistId, courseId },
+ include: { course: true },
+ });
+
+ return NextResponse.json({ assignment });
+ } catch (err: any) {
+ console.error(err);
+ return NextResponse.json({ error: err?.message ?? 'server' }, { status: err?.status ?? 500 });
+ }
+}
+
+// DELETE: Remove playlist from a course
+export async function DELETE(req: Request) {
+ try {
+ await checkAdmin();
+ const { searchParams } = new URL(req.url);
+ const playlistId = searchParams.get('playlistId');
+ const courseId = searchParams.get('courseId');
+
+ if (!playlistId || !courseId) return NextResponse.json({ error: 'missing fields' }, { status: 400 });
+
+ // Don't allow deleting the primary course assignment
+ const playlist = await prisma.playlist.findUnique({
+ where: { id: playlistId },
+ });
+
+ if (!playlist) return NextResponse.json({ error: 'Playlist not found' }, { status: 404 });
+
+ if (playlist.courseId === courseId) {
+ return NextResponse.json({ error: 'Cannot remove primary course' }, { status: 400 });
+ }
+
+ await prisma.coursePlaylist.delete({
+ where: {
+ courseId_playlistId: { playlistId, courseId },
+ },
+ });
+
+ return NextResponse.json({ success: true });
+ } catch (err: any) {
+ console.error(err);
+ return NextResponse.json({ error: err?.message ?? 'server' }, { status: err?.status ?? 500 });
+ }
+}
diff --git a/app/api/admin/meta/route.ts b/app/api/admin/meta/route.ts
new file mode 100644
index 0000000..73ed1aa
--- /dev/null
+++ b/app/api/admin/meta/route.ts
@@ -0,0 +1,19 @@
+// app/api/admin/meta/route.ts
+import { NextResponse } from 'next/server';
+import { prisma } from '@/lib/prisma';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+
+export async function GET() {
+ // Basic protection: ensure signed-in user is admin. Modify as needed.
+ // If you want stricter, get session from context or check roles.
+ // Here we skip session check to let dev access; but production should check.
+ try {
+ const courses = await prisma.course.findMany({ orderBy: { title: 'asc' } });
+ const playlists = await prisma.playlist.findMany({ orderBy: { title: 'asc' } });
+ return NextResponse.json({ courses, playlists });
+ } catch (err) {
+ console.error(err);
+ return NextResponse.json({ error: 'server' }, { status: 500 });
+ }
+}
diff --git a/app/api/admin/notifications/route.ts b/app/api/admin/notifications/route.ts
new file mode 100644
index 0000000..541bdcd
--- /dev/null
+++ b/app/api/admin/notifications/route.ts
@@ -0,0 +1,168 @@
+// app/api/admin/notifications/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ // Get current user
+ const user = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ select: { id: true },
+ });
+
+ if (!user) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ // Get all videos uploaded by this admin or superadmin
+ const uploadedVideos = await prisma.video.findMany({
+ where: { userId: user.id },
+ select: { id: true, title: true },
+ });
+
+ const videoIds = uploadedVideos.map((v) => v.id);
+
+ if (videoIds.length === 0) {
+ return NextResponse.json({
+ likes: [],
+ comments: [],
+ total: 0,
+ });
+ }
+
+ // Fetch recent likes on uploaded videos
+ const likes = await prisma.videoLike.findMany({
+ where: { videoId: { in: videoIds } },
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ image: true,
+ email: true,
+ },
+ },
+ video: {
+ select: {
+ id: true,
+ title: true,
+ },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ take: 50,
+ });
+
+ // Fetch recent comments on uploaded videos
+ const comments = await prisma.comment.findMany({
+ where: { videoId: { in: videoIds } },
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ image: true,
+ email: true,
+ },
+ },
+ video: {
+ select: {
+ id: true,
+ title: true,
+ },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ take: 50,
+ });
+
+ // Fetch recently created courses by this user
+ const createdCourses = await prisma.course.findMany({
+ where: { userId: user.id },
+ select: {
+ id: true,
+ title: true,
+ code: true,
+ createdAt: true,
+ },
+ orderBy: { createdAt: 'desc' },
+ take: 50,
+ });
+
+ // Fetch recently created playlists by this user
+ const createdPlaylists = await prisma.playlist.findMany({
+ where: { userId: user.id },
+ include: {
+ course: {
+ select: {
+ id: true,
+ title: true,
+ },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ take: 50,
+ });
+
+ // Combine and sort by date
+ const allNotifications = [
+ ...likes.map((like) => ({
+ id: like.id,
+ type: 'like' as const,
+ user: like.user,
+ video: like.video,
+ content: null,
+ createdAt: like.createdAt,
+ })),
+ ...comments.map((comment) => ({
+ id: comment.id,
+ type: 'comment' as const,
+ user: comment.user,
+ video: comment.video,
+ content: comment.content,
+ createdAt: comment.createdAt,
+ })),
+ ...createdCourses.map((course) => ({
+ id: course.id,
+ type: 'course_created' as const,
+ user: null,
+ course: { id: course.id, title: course.title, code: course.code },
+ content: null,
+ createdAt: course.createdAt,
+ })),
+ ...createdPlaylists.map((playlist) => ({
+ id: playlist.id,
+ type: 'playlist_created' as const,
+ user: null,
+ playlist: { id: playlist.id, title: playlist.title, courseTitle: playlist.course.title },
+ content: null,
+ createdAt: playlist.createdAt,
+ })),
+ ].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
+
+ return NextResponse.json({
+ likes,
+ comments,
+ notifications: allNotifications,
+ total: allNotifications.length,
+ });
+ } catch (err: any) {
+ console.error('get notifications error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/playlist-videos/route.ts b/app/api/admin/playlist-videos/route.ts
new file mode 100644
index 0000000..c34d9cb
--- /dev/null
+++ b/app/api/admin/playlist-videos/route.ts
@@ -0,0 +1,34 @@
+// app/api/admin/playlist-videos/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const { searchParams } = new URL(req.url);
+ const playlistId = searchParams.get('playlistId');
+ if (!playlistId)
+ return NextResponse.json({ error: 'playlistId required' }, { status: 400 });
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ const videos = await prisma.video.findMany({
+ where: { playlistId },
+ select: { id: true, title: true, thumbnail: true, index: true, durationSec: true },
+ orderBy: { index: 'asc' },
+ });
+
+ return NextResponse.json(videos);
+ } catch (err: any) {
+ console.error('playlist videos GET error', err);
+ return NextResponse.json({ error: err?.message ?? 'server error' }, { status: 500 });
+ }
+}
diff --git a/app/api/admin/reorder-videos/route.ts b/app/api/admin/reorder-videos/route.ts
new file mode 100644
index 0000000..b13240d
--- /dev/null
+++ b/app/api/admin/reorder-videos/route.ts
@@ -0,0 +1,55 @@
+// app/api/admin/reorder-videos/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function POST(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ const body = await req.json().catch(() => ({}));
+ const { playlistId, orderedIds } = body ?? {};
+ if (!playlistId || !Array.isArray(orderedIds))
+ return NextResponse.json({ error: 'playlistId and orderedIds required' }, { status: 400 });
+
+ // Validate that all video ids belong to the playlist (optional)
+ const vids = await prisma.video.findMany({ where: { playlistId }, select: { id: true } });
+ const validIds = new Set(vids.map((v) => v.id));
+ const invalid = orderedIds.find((id: string) => !validIds.has(id));
+ if (invalid)
+ return NextResponse.json({ error: 'Invalid video id in orderedIds' }, { status: 400 });
+
+ // Use two-phase update to avoid unique constraint violation:
+ // Phase 1: Set all indices to negative temporary values
+ // Phase 2: Set to final positive values
+ await prisma.$transaction([
+ // Phase 1: Set temporary negative indices to avoid conflicts
+ ...orderedIds.map((id: string, idx: number) =>
+ prisma.video.update({
+ where: { id },
+ data: { index: -(idx + 1) }, // Use negative values: -1, -2, -3, etc.
+ })
+ ),
+ // Phase 2: Set to final indices
+ ...orderedIds.map((id: string, idx: number) =>
+ prisma.video.update({
+ where: { id },
+ data: { index: idx },
+ })
+ ),
+ ]);
+
+ return NextResponse.json({ success: true });
+ } catch (err: any) {
+ console.error('reorder videos error', err);
+ return NextResponse.json({ error: err?.message ?? 'server error' }, { status: 500 });
+ }
+}
diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts
new file mode 100644
index 0000000..345fab8
--- /dev/null
+++ b/app/api/admin/stats/route.ts
@@ -0,0 +1,106 @@
+// app/api/admin/stats/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+import { Prisma } from '@prisma/client';
+
+export async function GET(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ // Fetch all videos with aggregated stats using raw queries for better performance
+ const videos = await prisma.video.findMany({
+ include: {
+ playlist: {
+ select: {
+ title: true,
+ },
+ },
+ uploader: {
+ select: {
+ id: true,
+ name: true,
+ },
+ },
+ _count: {
+ select: {
+ likes: true,
+ comments: true,
+ unlocks: true,
+ },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ });
+
+ // Fetch detailed stats for each video using raw queries for better performance
+ const statsData = await prisma.$queryRaw<
+ Array<{
+ videoId: string;
+ totalViewers: number;
+ totalSecondsWatched: bigint;
+ avgPercentWatched: number | null;
+ totalSegments: number;
+ completionCount: number;
+ }>
+ >`
+ SELECT
+ p."videoId",
+ COUNT(DISTINCT p."userId") as "totalViewers",
+ CAST(COALESCE(SUM(p."watchedSec"), 0) AS BIGINT) as "totalSecondsWatched",
+ ROUND(CAST(AVG(p."percent") AS NUMERIC), 2) as "avgPercentWatched",
+ (SELECT COUNT(*) FROM "VideoWatchSegment" ws WHERE ws."videoId" = p."videoId") as "totalSegments",
+ COUNT(CASE WHEN p."completed" = true THEN 1 END) as "completionCount"
+ FROM "VideoProgress" p
+ GROUP BY p."videoId"
+ `;
+
+ // Create a map for quick lookup
+ const statsMap = new Map(statsData.map(item => [item.videoId, item]));
+
+ // Combine video data with stats
+ const videosWithStats = videos.map(video => {
+ const stats = statsMap.get(video.id);
+ const views = video._count.unlocks || 0;
+ const totalViewers = stats ? Number(stats.totalViewers) : 0;
+ const completions = stats ? Number(stats.completionCount) : 0;
+ const engagement = (video._count.likes || 0) + (video._count.comments || 0);
+ const avgWatched = stats?.avgPercentWatched ? Number(stats.avgPercentWatched) : 0;
+
+ return {
+ id: video.id,
+ title: video.title,
+ playlistTitle: video.playlist.title,
+ uploaderName: video.uploader?.name || 'Unknown',
+ views,
+ totalViewers,
+ completions,
+ completionRate: totalViewers > 0 ? ((completions / totalViewers) * 100).toFixed(2) : '0.00',
+ likes: video._count.likes,
+ comments: video._count.comments,
+ engagement,
+ avgPercentWatched: avgWatched,
+ totalSecondsWatched: stats ? Number(stats.totalSecondsWatched) : 0,
+ totalSegments: stats ? Number(stats.totalSegments) : 0,
+ durationSec: video.durationSec,
+ createdAt: video.createdAt,
+ };
+ });
+
+ return NextResponse.json(videosWithStats);
+ } catch (err: any) {
+ console.error('get stats error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/update-playlist/route.ts b/app/api/admin/update-playlist/route.ts
new file mode 100644
index 0000000..aacde0d
--- /dev/null
+++ b/app/api/admin/update-playlist/route.ts
@@ -0,0 +1,48 @@
+// app/api/admin/update-playlist/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function PATCH(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ const { playlistId, title } = await req.json();
+
+ if (!playlistId) {
+ return NextResponse.json(
+ { error: 'playlistId required' },
+ { status: 400 }
+ );
+ }
+
+ if (!title || typeof title !== 'string' || title.trim().length === 0) {
+ return NextResponse.json(
+ { error: 'title required and must be non-empty' },
+ { status: 400 }
+ );
+ }
+
+ const updatedPlaylist = await prisma.playlist.update({
+ where: { id: playlistId },
+ data: { title: title.trim() },
+ });
+
+ return NextResponse.json(updatedPlaylist, { status: 200 });
+ } catch (err: any) {
+ console.error('update playlist error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/update-user-role/route.ts b/app/api/admin/update-user-role/route.ts
new file mode 100644
index 0000000..f9a7127
--- /dev/null
+++ b/app/api/admin/update-user-role/route.ts
@@ -0,0 +1,77 @@
+// app/api/admin/update-user-role/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function PATCH(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ // Only superadmins can assign roles
+ const role = (session as any)?.user?.role ?? null;
+ if (role !== 'superadmin') {
+ return NextResponse.json(
+ { error: 'Only superadmins can assign roles' },
+ { status: 403 }
+ );
+ }
+
+ const body = await req.json();
+ const { userId, newRole } = body;
+
+ if (!userId || !newRole) {
+ return NextResponse.json(
+ { error: 'userId and newRole required' },
+ { status: 400 }
+ );
+ }
+
+ // Validate newRole
+ const validRoles = ['user', 'admin', 'superadmin'];
+ if (!validRoles.includes(newRole)) {
+ return NextResponse.json(
+ { error: `Invalid role. Must be one of: ${validRoles.join(', ')}` },
+ { status: 400 }
+ );
+ }
+
+ // Prevent self-demotion from superadmin
+ const currentUser = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ });
+
+ if (currentUser?.id === userId && newRole !== 'superadmin') {
+ return NextResponse.json(
+ { error: 'Cannot demote yourself from superadmin' },
+ { status: 400 }
+ );
+ }
+
+ // Update user role
+ const updatedUser = await prisma.user.update({
+ where: { id: userId },
+ data: { role: newRole },
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ role: true,
+ },
+ });
+
+ return NextResponse.json({
+ message: 'User role updated successfully',
+ user: updatedUser,
+ });
+ } catch (err: any) {
+ console.error('update user role error:', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/update-video/route.ts b/app/api/admin/update-video/route.ts
new file mode 100644
index 0000000..11ad84a
--- /dev/null
+++ b/app/api/admin/update-video/route.ts
@@ -0,0 +1,141 @@
+// app/api/admin/update-video/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+import { writeFile, mkdir } from 'fs/promises';
+import { join } from 'path';
+
+export async function PATCH(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ const { videoId, title } = await req.json();
+
+ if (!videoId) {
+ return NextResponse.json(
+ { error: 'videoId required' },
+ { status: 400 }
+ );
+ }
+
+ if (!title || typeof title !== 'string' || title.trim().length === 0) {
+ return NextResponse.json(
+ { error: 'title required and must be non-empty' },
+ { status: 400 }
+ );
+ }
+
+ const updatedVideo = await prisma.video.update({
+ where: { id: videoId },
+ data: { title: title.trim() },
+ });
+
+ return NextResponse.json(updatedVideo, { status: 200 });
+ } catch (err: any) {
+ console.error('update video error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
+
+export async function POST(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ const formData = await req.formData().catch(() => null);
+ if (!formData) {
+ return NextResponse.json({ error: 'Invalid form data' }, { status: 400 });
+ }
+
+ const videoId = formData.get('videoId') as string;
+ const title = formData.get('title') as string;
+ const description = formData.get('description') as string | null;
+ const thumbnail = formData.get('thumbnail') as File | null;
+ const restricted = formData.get('restrictedCourseIds') as string | null;
+
+ if (!videoId)
+ return NextResponse.json({ error: 'videoId required' }, { status: 400 });
+
+ // Build update data
+ const updateData: any = {};
+ if (title) updateData.title = title;
+ if (description !== null) updateData.description = description;
+
+ let restrictedCourseIds: string[] | null = null;
+ if (restricted) {
+ try {
+ const parsed = JSON.parse(restricted);
+ if (Array.isArray(parsed)) {
+ restrictedCourseIds = parsed.filter((id) => typeof id === 'string' && id);
+ }
+ } catch (err) {
+ console.warn('Invalid restrictedCourseIds payload', err);
+ }
+ }
+
+ // Handle thumbnail upload if provided
+ if (thumbnail && thumbnail.size > 0) {
+ const bytes = await thumbnail.arrayBuffer();
+ const buffer = Buffer.from(bytes);
+
+ // Save to public/thumbnails
+ const uploadDir = join(process.cwd(), 'public', 'thumbnails');
+ await mkdir(uploadDir, { recursive: true });
+
+ const filename = `${videoId}-${Date.now()}.${thumbnail.type.split('/')[1] || 'jpg'}`;
+ const filepath = join(uploadDir, filename);
+ await writeFile(filepath, buffer);
+
+ updateData.thumbnail = `/thumbnails/${filename}`;
+ }
+
+ if (Object.keys(updateData).length === 0 && !thumbnail && restrictedCourseIds === null) {
+ return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
+ }
+
+ const updated = await prisma.video.update({
+ where: { id: videoId },
+ data: updateData,
+ });
+
+ if (restrictedCourseIds !== null) {
+ await prisma.videoCourse.deleteMany({ where: { videoId } });
+ if (restrictedCourseIds.length > 0) {
+ await prisma.videoCourse.createMany({
+ data: restrictedCourseIds.map((courseId) => ({
+ videoId,
+ courseId,
+ exclusive: true,
+ })),
+ skipDuplicates: true,
+ });
+ }
+ }
+
+ return NextResponse.json({ success: true, video: updated });
+ } catch (err: any) {
+ console.error('update video error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/upload/finalize/route.ts b/app/api/admin/upload/finalize/route.ts
new file mode 100644
index 0000000..da6d925
--- /dev/null
+++ b/app/api/admin/upload/finalize/route.ts
@@ -0,0 +1,150 @@
+// app/api/admin/upload/finalize/route.ts
+import { NextResponse } from "next/server";
+import fs from "fs";
+import path from "path";
+import { spawn } from "child_process";
+import { prisma } from "@/lib/prisma";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth-options";
+
+const UPLOADS_DIR = process.env.UPLOADS_DIR || "/uploads";
+
+async function getVideoDurationFromFile(filePath: string): Promise {
+ return new Promise((resolve) => {
+ try {
+ const ffprobe = spawn("ffprobe", [
+ "-v", "error",
+ "-show_entries", "format=duration",
+ "-of", "default=noprint_wrappers=1:nokey=1:nokey=1",
+ filePath,
+ ]);
+
+ let output = "";
+ let timedOut = false;
+
+ const timeoutId = setTimeout(() => {
+ timedOut = true;
+ ffprobe.kill();
+ resolve(null);
+ }, 30000);
+
+ ffprobe.stdout.on("data", (data) => {
+ output += data.toString();
+ });
+
+ ffprobe.on("close", (code) => {
+ clearTimeout(timeoutId);
+ if (!timedOut && code === 0) {
+ const duration = parseFloat(output.trim());
+ if (!isNaN(duration) && isFinite(duration) && duration > 0) {
+ resolve(Math.round(duration));
+ } else {
+ resolve(null);
+ }
+ } else {
+ resolve(null);
+ }
+ });
+
+ ffprobe.on("error", () => {
+ clearTimeout(timeoutId);
+ resolve(null);
+ });
+ } catch (err) {
+ resolve(null);
+ }
+ });
+}
+
+async function checkAdmin() {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) throw { status: 401, message: "Unauthorized" };
+ const allowed = (process.env.ALLOWED_ADMINS || "")
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean);
+ if (allowed.length && !allowed.includes(session.user.email))
+ throw { status: 403, message: "Forbidden" };
+ return session;
+}
+
+export async function POST(req: Request) {
+ try {
+ console.log('[Upload/Finalize] Request received');
+
+ await checkAdmin();
+
+ const body = await req.json();
+ const { videoId } = body;
+
+ if (!videoId) {
+ return NextResponse.json({ error: "videoId required" }, { status: 400 });
+ }
+
+ console.log('[Upload/Finalize] Checking for video:', videoId);
+
+ // Check if video exists in DB
+ const video = await prisma.video.findUnique({
+ where: { id: videoId },
+ });
+
+ if (!video) {
+ return NextResponse.json({ error: "video not found" }, { status: 404 });
+ }
+
+ // Check if file exists at the expected path
+ const videoPath = path.join(UPLOADS_DIR, "videos", `${videoId}.mp4`);
+ console.log('[Upload/Finalize] Checking file at:', videoPath);
+
+ try {
+ await fs.promises.access(videoPath, fs.constants.F_OK);
+ } catch {
+ return NextResponse.json(
+ { error: `File not found at ${videoPath}. Please copy the file there first.` },
+ { status: 404 }
+ );
+ }
+
+ console.log('[Upload/Finalize] File found, extracting duration');
+
+ // Extract duration if not already set
+ let durationSec = video.durationSec;
+ if (!durationSec) {
+ try {
+ const extractedDuration = await getVideoDurationFromFile(videoPath);
+ if (extractedDuration !== null) {
+ durationSec = extractedDuration;
+ console.log('[Upload/Finalize] Extracted duration:', durationSec);
+ } else {
+ console.warn('[Upload/Finalize] Could not extract duration');
+ }
+ } catch (err) {
+ console.warn('[Upload/Finalize] Duration extraction error:', err);
+ }
+ }
+
+ // Update video: set URL, duration, and transcoding status to trigger processing
+ const videoUrl = `/uploads/videos/${videoId}.mp4`;
+ const updatedVideo = await prisma.video.update({
+ where: { id: videoId },
+ data: {
+ url: videoUrl,
+ transcodingStatus: 'uploaded', // Mark as ready for transcoding
+ ...(durationSec !== null && { durationSec }),
+ },
+ });
+
+ console.log('[Upload/Finalize] Video updated successfully with URL:', videoUrl);
+
+ return NextResponse.json({
+ success: true,
+ video: updatedVideo,
+ message: `Video finalized${durationSec ? ` (${durationSec}s)` : ''}. HLS transcoding will start shortly.`,
+ }, { status: 200 });
+ } catch (err: any) {
+ console.error("[Upload/Finalize] Error:", err);
+ const status = err?.status ?? 500;
+ const message = err?.message ?? "server error";
+ return NextResponse.json({ error: message }, { status });
+ }
+}
diff --git a/app/api/admin/upload/route.ts b/app/api/admin/upload/route.ts
new file mode 100644
index 0000000..7e73a5a
--- /dev/null
+++ b/app/api/admin/upload/route.ts
@@ -0,0 +1,394 @@
+// app/api/admin/upload/route.ts
+import { NextResponse } from "next/server";
+import fs from "fs";
+import path from "path";
+import { IncomingMessage } from "http";
+import { spawn } from "child_process";
+import { pipeline } from "stream/promises";
+import formidable, { File as FormidableFile } from "formidable";
+import { prisma } from "@/lib/prisma";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth-options";
+
+// For 2GB uploads over Tailscale (~10-50 Mbps), need 30-45 minutes
+export const maxDuration = 2700; // 45 minutes for very large uploads over slow connections
+
+// Use ConfigurableUPLOADS_DIR from environment or default to /uploads
+const UPLOADS_DIR = process.env.UPLOADS_DIR || "/uploads";
+
+// Helper to extract video duration using FFprobe
+async function getVideoDurationFromFile(filePath: string): Promise {
+ return new Promise((resolve) => {
+ try {
+ const ffprobe = spawn("ffprobe", [
+ "-v", "error",
+ "-show_entries", "format=duration",
+ "-of", "default=noprint_wrappers=1:nokey=1:nokey=1",
+ filePath,
+ ]);
+
+ let output = "";
+ let timedOut = false;
+
+ // Large 2GB files may take longer to scan; timeout after 30s
+ const timeoutId = setTimeout(() => {
+ timedOut = true;
+ ffprobe.kill();
+ resolve(null); // Return null instead of blocking
+ }, 30000);
+
+ ffprobe.stdout.on("data", (data) => {
+ output += data.toString();
+ });
+
+ ffprobe.on("close", (code) => {
+ clearTimeout(timeoutId);
+ if (!timedOut && code === 0) {
+ const duration = parseFloat(output.trim());
+ if (!isNaN(duration) && isFinite(duration) && duration > 0) {
+ resolve(Math.round(duration));
+ } else {
+ resolve(null);
+ }
+ } else {
+ resolve(null);
+ }
+ });
+
+ ffprobe.on("error", () => {
+ clearTimeout(timeoutId);
+ resolve(null);
+ });
+ } catch (err) {
+ resolve(null);
+ }
+ });
+}
+
+function parseForm(req: IncomingMessage): Promise<{ fields: any; files: any }> {
+ const form = formidable({
+ multiples: false,
+ maxFileSize: 2.5 * 1024 * 1024 * 1024, // 2.5GB max file size
+ maxFieldsSize: 10 * 1024 * 1024, // 10MB for all fields combined
+ maxFields: 50,
+ keepExtensions: true,
+ });
+ return new Promise((resolve, reject) => {
+ form.parse(req, (err, fields, files) => {
+ if (err) {
+ console.error('[Upload] Formidable parse error:', err.code, err.message);
+ reject(err);
+ }
+ else resolve({ fields, files });
+ });
+ });
+}
+
+async function checkAdmin() {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) throw { status: 401, message: "Unauthorized" };
+ const allowed = (process.env.ALLOWED_ADMINS || "")
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean);
+ if (allowed.length && !allowed.includes(session.user.email))
+ throw { status: 403, message: "Forbidden" };
+ return session;
+}
+
+export async function POST(req: Request) {
+ // track saved paths so we can cleanup on error
+ let savedVideoDest: string | undefined;
+ let savedThumbDest: string | undefined;
+
+ try {
+ console.log('[Upload] Request received, content-type:', req.headers.get("content-type"));
+
+ const nodeReq = (req as any).req ?? (globalThis as any).__NEXT_INIT?.req ?? null;
+ const session = await checkAdmin();
+
+ console.log('[Upload] Admin check passed for:', session.user?.email);
+
+ // helper to stream a File directly to disk (handles large files efficiently)
+ async function saveVideoStream(file: File, origName?: string) {
+ const filename = `${Date.now()}-${String(origName ?? "upload.mp4")}`;
+ const dest = path.join(UPLOADS_DIR, "videos", filename);
+
+ // Ensure directory exists
+ await fs.promises.mkdir(path.dirname(dest), { recursive: true });
+
+ // Create write stream to destination
+ const writeStream = fs.createWriteStream(dest);
+
+ try {
+ // Stream the file directly to disk without buffering
+ // file.stream() returns a Web ReadableStream, convert to Node.js stream
+ const nodeStream = file.stream() as any;
+ await pipeline(nodeStream, writeStream);
+
+ // Ensure the file is readable by all processes (mode 644)
+ await fs.promises.chmod(dest, 0o644);
+
+ return { filename, dest };
+ } catch (err) {
+ // Clean up the partially written file if stream fails
+ try {
+ await fs.promises.unlink(dest);
+ } catch (_) {}
+ throw err;
+ }
+ }
+
+ // helper to save a thumbnail buffer to UPLOADS_DIR/thumbnails
+ async function saveThumbBuffer(buffer: Buffer, ext = "jpg") {
+ const filename = `${Date.now()}-thumb.${ext.replace(/^\./, "")}`;
+ const dest = path.join(UPLOADS_DIR, "thumbnails", filename);
+ await fs.promises.mkdir(path.dirname(dest), { recursive: true });
+ await fs.promises.writeFile(dest, buffer);
+ // Ensure the file is readable by all processes (mode 644)
+ await fs.promises.chmod(dest, 0o644);
+ return { filename, dest };
+ }
+
+ let title: string | undefined;
+ let playlistId: string | undefined;
+ let durationSec: number | null = null;
+ let savedFilename: string | undefined;
+
+ // **IMPORTANT**: single thumbnailUrl used in both branches
+ let thumbnailUrl: string | null = null;
+
+ if (!nodeReq) {
+ // Request.formData() flow (some Next.js environments)
+ let formData: FormData | null = null;
+ try {
+ formData = await req.formData();
+ } catch (e: any) {
+ console.error("Failed to parse body as FormData.", e, "content-type=", req.headers.get("content-type"));
+ return NextResponse.json(
+ { error: "Failed to parse body as FormData. Ensure request is sent with multipart/form-data." },
+ { status: 400 }
+ );
+ }
+
+ const isManualCopy = formData.get("manualFileCopy") === "true";
+ const file = isManualCopy ? null : (formData.get("file") as Blob | null);
+ title = (formData.get("title") as string) || undefined;
+ playlistId = (formData.get("playlistId") as string) || undefined;
+ const durationField = formData.get("durationSec") as string | null;
+ if (durationField) {
+ const n = Number(durationField);
+ if (!isNaN(n)) durationSec = Math.round(n);
+ }
+
+ // thumbnail (optional)
+ const thumb = formData.get("thumbnail") as Blob | null;
+ if (thumb) {
+ try {
+ const thumbArrayBuffer = await thumb.arrayBuffer();
+ const thumbBuffer = Buffer.from(thumbArrayBuffer);
+ // try to infer extension from name, fallback to jpg
+ const fName = (thumb as any).name ?? "";
+ const extMatch = fName.match(/\.([a-z0-9]+)$/i);
+ const ext = extMatch ? extMatch[1] : "jpg";
+ const saved = await saveThumbBuffer(thumbBuffer, ext);
+ thumbnailUrl = `/api/thumbnails/${saved.filename}`;
+ savedThumbDest = saved.dest;
+ } catch (err) {
+ console.warn("thumbnail save failed (formData)", err);
+ // not fatal — we simply leave thumbnailUrl null
+ }
+ }
+
+ if (!isManualCopy) {
+ if (!file) return NextResponse.json({ error: "no file" }, { status: 400 });
+
+ // Stream the large file directly to disk without buffering
+ const origName = (file as any).name ?? `upload-${Date.now()}.mp4`;
+ const saved = await saveVideoStream(file as File, origName);
+ savedFilename = saved.filename;
+ savedVideoDest = saved.dest;
+ } else {
+ // Manual copy mode: don't save file, just mark for manual copy
+ console.log('[Upload] Manual file copy mode enabled');
+ savedFilename = undefined;
+ savedVideoDest = undefined;
+ }
+ } else {
+ // formidable flow (Node IncomingMessage available)
+ console.log('[Upload] Using formidable flow for file upload');
+ const { fields, files } = await parseForm(nodeReq as IncomingMessage);
+ console.log('[Upload] Formidable parsing complete, fields:', Object.keys(fields), 'files:', Object.keys(files));
+
+ const f: FormidableFile | undefined =
+ (files && (files.file as FormidableFile)) || (files && Object.values(files)[0]);
+ if (!f) return NextResponse.json({ error: "no file found" }, { status: 400 });
+
+ // handle thumbnail file if present in formidable files
+ const thumbFile = (files && (files.thumbnail as FormidableFile)) || undefined;
+ if (thumbFile) {
+ try {
+ const tPath = (thumbFile as any).filepath || (thumbFile as any).path;
+ const originalThumbName = (thumbFile as any).originalFilename || path.basename(tPath);
+ const tExt = path.extname(originalThumbName) || ".jpg";
+ const thumbFilename = `${Date.now()}-thumb${tExt}`;
+ const thumbDest = path.join(UPLOADS_DIR, "thumbnails", thumbFilename);
+ await fs.promises.mkdir(path.dirname(thumbDest), { recursive: true });
+ await fs.promises.copyFile(tPath, thumbDest);
+ // Ensure the file is readable by all processes (mode 644)
+ await fs.promises.chmod(thumbDest, 0o644);
+ thumbnailUrl = `/api/thumbnails/${thumbFilename}`;
+ savedThumbDest = thumbDest;
+ } catch (err) {
+ console.warn("thumbnail save failed (formidable)", err);
+ }
+ }
+
+ // copy video file to UPLOADS_DIR/videos
+ const filePath = (f as any).filepath || (f as any).path;
+ const originalFilename = (f as any).originalFilename || (f as any).name || path.basename(filePath);
+ const filename = `${Date.now()}-${originalFilename}`;
+ const dest = path.join(UPLOADS_DIR, "videos", filename);
+
+ console.log('[Upload] Copying video file', { size: (f as any).size, path: filePath, dest });
+
+ await fs.promises.mkdir(path.dirname(dest), { recursive: true });
+ await fs.promises.copyFile(filePath, dest);
+ // Ensure the file is readable by all processes (mode 644)
+ await fs.promises.chmod(dest, 0o644);
+
+ console.log('[Upload] Video file copied successfully');
+
+ title = fields.title ?? originalFilename;
+ playlistId = fields.playlistId;
+ const durationField = fields.durationSec ?? fields.duration ?? null;
+ if (durationField) {
+ const n = Number(durationField);
+ if (!isNaN(n)) durationSec = Math.round(n);
+ }
+
+ savedFilename = filename;
+ savedVideoDest = dest;
+ }
+
+ // validate playlist: avoid FK errors
+ if (!playlistId) {
+ // cleanup if needed
+ if (savedVideoDest) await fs.promises.unlink(savedVideoDest).catch(() => {});
+ if (savedThumbDest) await fs.promises.unlink(savedThumbDest).catch(() => {});
+ return NextResponse.json({ error: "playlistId required" }, { status: 400 });
+ }
+
+ const playlist = await prisma.playlist.findUnique({ where: { id: playlistId } });
+ if (!playlist) {
+ if (savedVideoDest) await fs.promises.unlink(savedVideoDest).catch(() => {});
+ if (savedThumbDest) await fs.promises.unlink(savedThumbDest).catch(() => {});
+ return NextResponse.json({ error: "playlist not found" }, { status: 400 });
+ }
+
+ // Get current user
+ const user = await prisma.user.findUnique({
+ where: { email: session.user?.email ?? "" },
+ select: { id: true },
+ });
+ if (!user) {
+ if (savedVideoDest) await fs.promises.unlink(savedVideoDest).catch(() => {});
+ if (savedThumbDest) await fs.promises.unlink(savedThumbDest).catch(() => {});
+ return NextResponse.json({ error: "user not found" }, { status: 400 });
+ }
+
+ // If duration not provided by client, try to extract it from the saved video file
+ if (durationSec === null && savedVideoDest) {
+ try {
+ const extractedDuration = await getVideoDurationFromFile(savedVideoDest);
+ if (extractedDuration !== null) {
+ durationSec = extractedDuration;
+ console.log(`[Upload] Extracted duration: ${durationSec}s from ${savedVideoDest}`);
+ } else {
+ console.warn(`[Upload] Could not extract duration from ${savedVideoDest}`);
+ }
+ } catch (err) {
+ console.warn(`[Upload] Duration extraction error:`, err);
+ }
+ }
+
+ // For manual copy mode, check if we have a file
+ const isManualMode = !savedVideoDest;
+
+ // Calculate index: find max index in playlist and add 1
+ const maxIndexVideo = await prisma.video.findFirst({
+ where: { playlistId },
+ orderBy: { index: 'desc' },
+ select: { index: true },
+ });
+ const desiredIndex = (maxIndexVideo?.index ?? -1) + 1;
+
+ // First, create the video record to get its ID
+ const video = await prisma.video.create({
+ data: {
+ title: title ?? savedFilename ?? 'Untitled',
+ url: '', // Will be set below or after rename
+ thumbnail: thumbnailUrl,
+ index: desiredIndex,
+ playlistId,
+ userId: user.id,
+ transcodingStatus: isManualMode ? 'pending_manual_file' : 'uploaded',
+ ...(durationSec !== null && { durationSec }),
+ },
+ });
+
+ // If manual copy mode, set the URL and return
+ if (isManualMode) {
+ const videoUrl = `/uploads/videos/${video.id}.mp4`;
+ await prisma.video.update({
+ where: { id: video.id },
+ data: { url: videoUrl },
+ });
+ console.log('[Upload] Manual copy mode: video created with ID', video.id, 'URL:', videoUrl);
+ return NextResponse.json({ video: { ...video, url: videoUrl } }, { status: 201 });
+ }
+
+ // Now rename the uploaded file to use the video ID
+ const finalFilename = `${video.id}.mp4`;
+ const finalDest = path.join(UPLOADS_DIR, "videos", finalFilename);
+ try {
+ if (savedVideoDest) {
+ await fs.promises.rename(savedVideoDest, finalDest);
+ }
+ // Update the URL in the database to reflect the final filename
+ await prisma.video.update({
+ where: { id: video.id },
+ data: { url: `/uploads/videos/${finalFilename}` },
+ });
+ } catch (err) {
+ console.error("Error renaming video file:", err);
+ // If rename fails, cleanup and delete the record
+ await prisma.video.delete({ where: { id: video.id } });
+ throw err;
+ }
+
+ return NextResponse.json({ video: { ...video, url: `/uploads/videos/${finalFilename}` } }, { status: 201 });
+ } catch (err: any) {
+ console.error("upload error", {
+ code: err?.code,
+ message: err?.message,
+ errno: err?.errno,
+ }, err);
+
+ // cleanup saved files if something failed
+ if (savedVideoDest) {
+ try {
+ await fs.promises.unlink(savedVideoDest);
+ } catch (_) {}
+ }
+ if (savedThumbDest) {
+ try {
+ await fs.promises.unlink(savedThumbDest);
+ } catch (_) {}
+ }
+
+ const status = err?.status ?? 500;
+ const message = err?.message ?? (err?.toString ? err.toString() : "server error");
+ return NextResponse.json({ error: message }, { status });
+ }
+}
diff --git a/app/api/admin/users/[userId]/progress/[videoId]/segments/route.ts b/app/api/admin/users/[userId]/progress/[videoId]/segments/route.ts
new file mode 100644
index 0000000..879b102
--- /dev/null
+++ b/app/api/admin/users/[userId]/progress/[videoId]/segments/route.ts
@@ -0,0 +1,37 @@
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET(
+ req: Request,
+ { params }: { params: Promise<{ userId: string; videoId: string }> }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ // Check if user is admin
+ const admin = await prisma.user.findUnique({ where: { email: session.user.email } });
+ if (!admin || (admin.role !== 'admin' && admin.role !== 'superadmin'))
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+
+ const { userId, videoId } = await params;
+
+ // Fetch all watch segments for the specified user+video
+ const segments = await prisma.videoWatchSegment.findMany({
+ where: { userId, videoId },
+ select: { startSec: true, endSec: true, watchedAt: true },
+ orderBy: { createdAt: 'asc' },
+ });
+
+ return NextResponse.json({ segments });
+ } catch (err: any) {
+ console.error('admin segments GET error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/users/[userId]/route.ts b/app/api/admin/users/[userId]/route.ts
new file mode 100644
index 0000000..13b3289
--- /dev/null
+++ b/app/api/admin/users/[userId]/route.ts
@@ -0,0 +1,150 @@
+// app/api/admin/users/[userId]/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET(
+ req: Request,
+ { params }: { params: Promise<{ userId: string }> }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ const { userId } = await params;
+
+ // Fetch user with all related data
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ image: true,
+ role: true,
+ createdAt: true,
+ enrollments: {
+ select: {
+ course: {
+ select: {
+ id: true,
+ title: true,
+ },
+ },
+ },
+ },
+ progress: {
+ select: {
+ id: true,
+ videoId: true,
+ watchedSec: true,
+ lastPos: true,
+ percent: true,
+ completed: true,
+ durationSec: true,
+ updatedAt: true,
+ createdAt: true,
+ video: {
+ select: {
+ id: true,
+ title: true,
+ },
+ },
+ },
+ orderBy: {
+ updatedAt: 'desc',
+ },
+ },
+ comments: {
+ select: {
+ id: true,
+ content: true,
+ createdAt: true,
+ video: {
+ select: {
+ id: true,
+ title: true,
+ },
+ },
+ replies: {
+ select: {
+ id: true,
+ },
+ },
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ },
+ },
+ });
+
+ if (!user) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ return NextResponse.json(user);
+ } catch (err: any) {
+ console.error('fetch user detail error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
+
+export async function DELETE(
+ req: Request,
+ { params }: { params: Promise<{ userId: string }> }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ const { userId } = await params;
+
+ // Prevent deleting yourself
+ const currentUser = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ });
+
+ if (currentUser?.id === userId) {
+ return NextResponse.json(
+ { error: 'Cannot delete your own account' },
+ { status: 400 }
+ );
+ }
+
+ // Delete user with cascading deletes handled by Prisma schema
+ // The schema has onDelete: Cascade for most relations
+ const deletedUser = await prisma.user.delete({
+ where: { id: userId },
+ });
+
+ return NextResponse.json({
+ message: 'User deleted successfully',
+ deleted: deletedUser.email,
+ });
+ } catch (err: any) {
+ console.error('delete user error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts
new file mode 100644
index 0000000..8092e40
--- /dev/null
+++ b/app/api/admin/users/route.ts
@@ -0,0 +1,68 @@
+// app/api/admin/users/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ // Fetch all users with enrollments and last activity
+ const users = await prisma.user.findMany({
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ image: true,
+ role: true,
+ createdAt: true,
+ enrollments: {
+ select: {
+ course: {
+ select: {
+ id: true,
+ title: true,
+ },
+ },
+ },
+ },
+ progress: {
+ select: {
+ updatedAt: true,
+ },
+ orderBy: {
+ updatedAt: 'desc',
+ },
+ take: 1,
+ },
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ });
+
+ // Map to include last activity
+ const usersWithActivity = users.map((user) => ({
+ ...user,
+ lastActivity: user.progress[0]?.updatedAt ?? null,
+ progress: undefined, // Remove the progress array
+ }));
+
+ return NextResponse.json(usersWithActivity);
+ } catch (err: any) {
+ console.error('fetch users error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/videos/[videoId]/instant-access/route.ts b/app/api/admin/videos/[videoId]/instant-access/route.ts
new file mode 100644
index 0000000..8f5e4bc
--- /dev/null
+++ b/app/api/admin/videos/[videoId]/instant-access/route.ts
@@ -0,0 +1,96 @@
+// app/api/admin/videos/[videoId]/instant-access/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function PATCH(req: Request, context: any) {
+ try {
+ // Unwrap params
+ let params = context?.params;
+ if (typeof params?.then === 'function') params = await params;
+
+ const videoId = params?.videoId;
+ if (!videoId)
+ return NextResponse.json({ error: 'videoId required' }, { status: 400 });
+
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ // Check admin role
+ const user = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ select: { id: true, role: true },
+ });
+
+ if (!user || (user.role !== 'admin' && user.role !== 'superadmin'))
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+
+ const body = await req.json().catch(() => ({}));
+ const { instantAccess } = body ?? {};
+
+ if (typeof instantAccess !== 'boolean')
+ return NextResponse.json(
+ { error: 'instantAccess boolean value required' },
+ { status: 400 }
+ );
+
+ // Update the video
+ const video = await prisma.video.update({
+ where: { id: videoId },
+ data: { instantAccess },
+ select: { id: true, title: true, instantAccess: true, locked: true },
+ });
+
+ return NextResponse.json({ ok: true, video });
+ } catch (err: any) {
+ console.error('admin instant-access PATCH error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
+
+export async function GET(req: Request, context: any) {
+ try {
+ // Unwrap params
+ let params = context?.params;
+ if (typeof params?.then === 'function') params = await params;
+
+ const videoId = params?.videoId;
+ if (!videoId)
+ return NextResponse.json({ error: 'videoId required' }, { status: 400 });
+
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ // Check admin role
+ const user = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ select: { id: true, role: true },
+ });
+
+ if (!user || (user.role !== 'admin' && user.role !== 'superadmin'))
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+
+ // Get the video
+ const video = await prisma.video.findUnique({
+ where: { id: videoId },
+ select: { id: true, title: true, instantAccess: true, locked: true },
+ });
+
+ if (!video)
+ return NextResponse.json({ error: 'video not found' }, { status: 404 });
+
+ return NextResponse.json({ video });
+ } catch (err: any) {
+ console.error('admin instant-access GET error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/videos/[videoId]/locked/route.ts b/app/api/admin/videos/[videoId]/locked/route.ts
new file mode 100644
index 0000000..bab58f8
--- /dev/null
+++ b/app/api/admin/videos/[videoId]/locked/route.ts
@@ -0,0 +1,96 @@
+// app/api/admin/videos/[videoId]/locked/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function PATCH(req: Request, context: any) {
+ try {
+ // Unwrap params
+ let params = context?.params;
+ if (typeof params?.then === 'function') params = await params;
+
+ const videoId = params?.videoId;
+ if (!videoId)
+ return NextResponse.json({ error: 'videoId required' }, { status: 400 });
+
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ // Check admin role
+ const user = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ select: { id: true, role: true },
+ });
+
+ if (!user || (user.role !== 'admin' && user.role !== 'superadmin'))
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+
+ const body = await req.json().catch(() => ({}));
+ const { locked } = body ?? {};
+
+ if (typeof locked !== 'boolean')
+ return NextResponse.json(
+ { error: 'locked boolean value required' },
+ { status: 400 }
+ );
+
+ // Update the video
+ const video = await prisma.video.update({
+ where: { id: videoId },
+ data: { locked },
+ select: { id: true, title: true, locked: true, instantAccess: true },
+ });
+
+ return NextResponse.json({ ok: true, video });
+ } catch (err: any) {
+ console.error('admin locked PATCH error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
+
+export async function GET(req: Request, context: any) {
+ try {
+ // Unwrap params
+ let params = context?.params;
+ if (typeof params?.then === 'function') params = await params;
+
+ const videoId = params?.videoId;
+ if (!videoId)
+ return NextResponse.json({ error: 'videoId required' }, { status: 400 });
+
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ // Check admin role
+ const user = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ select: { id: true, role: true },
+ });
+
+ if (!user || (user.role !== 'admin' && user.role !== 'superadmin'))
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+
+ // Get the video
+ const video = await prisma.video.findUnique({
+ where: { id: videoId },
+ select: { id: true, title: true, locked: true, instantAccess: true },
+ });
+
+ if (!video)
+ return NextResponse.json({ error: 'video not found' }, { status: 404 });
+
+ return NextResponse.json({ video });
+ } catch (err: any) {
+ console.error('admin locked GET error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/videos/route.ts b/app/api/admin/videos/route.ts
new file mode 100644
index 0000000..de18db9
--- /dev/null
+++ b/app/api/admin/videos/route.ts
@@ -0,0 +1,53 @@
+// app/api/admin/videos/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ // Fetch all videos with their playlist and course info
+ const videos = await prisma.video.findMany({
+ include: {
+ playlist: {
+ include: {
+ course: {
+ select: {
+ id: true,
+ title: true,
+ },
+ },
+ },
+ },
+ uploader: {
+ select: {
+ id: true,
+ name: true,
+ image: true,
+ },
+ },
+ videoCourses: {
+ include: { course: true },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ });
+
+ return NextResponse.json(videos);
+ } catch (err: any) {
+ console.error('get videos error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts
new file mode 100644
index 0000000..2425311
--- /dev/null
+++ b/app/api/auth/[...nextauth]/route.ts
@@ -0,0 +1,7 @@
+// app/api/auth/[...nextauth]/route.ts
+import NextAuth from "next-auth";
+import { authOptions } from "@/lib/auth-options";
+
+const handler = NextAuth(authOptions);
+
+export { handler as GET, handler as POST };
diff --git a/app/api/auth/check-student.ts b/app/api/auth/check-student.ts
new file mode 100644
index 0000000..1e68ac7
--- /dev/null
+++ b/app/api/auth/check-student.ts
@@ -0,0 +1,57 @@
+// app/api/auth/check-student.ts
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+import { prisma } from '@/lib/prisma';
+import { normalizeEmail } from '@/lib/normalize-email';
+
+export async function POST(req: NextRequest) {
+ try {
+ const body = await req.json();
+ const email = body?.email;
+ const normalizedEmail = normalizeEmail(email);
+
+ if (!normalizedEmail) {
+ return NextResponse.json({ error: 'Email required' }, { status: 400 });
+ }
+
+ const allowedStudent = await prisma.allowedStudent.findFirst({
+ where: {
+ email: {
+ equals: normalizedEmail,
+ mode: 'insensitive',
+ },
+ },
+ });
+
+ if (!allowedStudent || !allowedStudent.active) {
+ return NextResponse.json(
+ {
+ allowed: false,
+ message: 'Email not registered for access'
+ },
+ { status: 200 }
+ );
+ }
+
+ // Parse course levels
+ const levels = allowedStudent.levels
+ .split(',')
+ .map((level) => level.trim())
+ .filter((level) => level.length > 0);
+
+ return NextResponse.json(
+ {
+ allowed: true,
+ levels,
+ studentId: allowedStudent.id,
+ },
+ { status: 200 }
+ );
+ } catch (err) {
+ console.error('POST /api/auth/check-student error', err);
+ return NextResponse.json(
+ { error: 'Failed to check student status' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/comments/[commentId]/reply/route.ts b/app/api/comments/[commentId]/reply/route.ts
new file mode 100644
index 0000000..012fb30
--- /dev/null
+++ b/app/api/comments/[commentId]/reply/route.ts
@@ -0,0 +1,62 @@
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ commentId: string }> }
+) {
+ const { commentId } = await params;
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const { content } = await request.json();
+
+ if (!content || typeof content !== 'string' || content.trim().length === 0) {
+ return NextResponse.json(
+ { error: 'Non-empty content is required' },
+ { status: 400 }
+ );
+ }
+
+ // Verify comment exists
+ const comment = await prisma.comment.findUnique({
+ where: { id: commentId },
+ });
+
+ if (!comment) {
+ return NextResponse.json({ error: 'Comment not found' }, { status: 404 });
+ }
+
+ const reply = await prisma.commentReply.create({
+ data: {
+ commentId,
+ userId: (session.user as any).id,
+ content: content.trim(),
+ },
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ image: true,
+ },
+ },
+ },
+ });
+
+ return NextResponse.json(reply, { status: 201 });
+ } catch (error) {
+ console.error('Failed to create reply:', error);
+ return NextResponse.json(
+ { error: 'Failed to create reply' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/comments/[commentId]/route.ts b/app/api/comments/[commentId]/route.ts
new file mode 100644
index 0000000..1c4c820
--- /dev/null
+++ b/app/api/comments/[commentId]/route.ts
@@ -0,0 +1,36 @@
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ commentId: string }> }
+) {
+ const { commentId } = await params;
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const role = (session.user as any).role;
+ if (role !== 'admin' && role !== 'superadmin') {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ try {
+ // Delete the comment and its replies (cascade via Prisma)
+ const comment = await prisma.comment.delete({
+ where: { id: commentId },
+ });
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error('Failed to delete comment:', error);
+ return NextResponse.json(
+ { error: 'Failed to delete comment' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/comments/reply/[replyId]/route.ts b/app/api/comments/reply/[replyId]/route.ts
new file mode 100644
index 0000000..70feaf5
--- /dev/null
+++ b/app/api/comments/reply/[replyId]/route.ts
@@ -0,0 +1,35 @@
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ replyId: string }> }
+) {
+ const { replyId } = await params;
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const role = (session.user as any).role;
+ if (role !== 'admin' && role !== 'superadmin') {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ try {
+ const reply = await prisma.commentReply.delete({
+ where: { id: replyId },
+ });
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error('Failed to delete reply:', error);
+ return NextResponse.json(
+ { error: 'Failed to delete reply' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/comments/route.ts b/app/api/comments/route.ts
new file mode 100644
index 0000000..3dd77b3
--- /dev/null
+++ b/app/api/comments/route.ts
@@ -0,0 +1,123 @@
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function GET(request: NextRequest) {
+ const videoId = request.nextUrl.searchParams.get('videoId');
+
+ if (!videoId) {
+ return NextResponse.json(
+ { error: 'videoId is required' },
+ { status: 400 }
+ );
+ }
+
+ try {
+ const comments = await prisma.comment.findMany({
+ where: { videoId },
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ image: true,
+ },
+ },
+ replies: {
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ image: true,
+ },
+ },
+ },
+ orderBy: {
+ createdAt: 'asc',
+ },
+ },
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ });
+
+ return NextResponse.json(comments);
+ } catch (error) {
+ console.error('Failed to fetch comments:', error);
+ return NextResponse.json(
+ { error: 'Failed to fetch comments' },
+ { status: 500 }
+ );
+ }
+}
+
+export async function POST(request: NextRequest) {
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const { videoId, content } = await request.json();
+
+ if (!videoId || !content || typeof content !== 'string' || content.trim().length === 0) {
+ return NextResponse.json(
+ { error: 'videoId and non-empty content are required' },
+ { status: 400 }
+ );
+ }
+
+ // Verify video exists
+ const video = await prisma.video.findUnique({
+ where: { id: videoId },
+ });
+
+ if (!video) {
+ return NextResponse.json({ error: 'Video not found' }, { status: 404 });
+ }
+
+ const comment = await prisma.comment.create({
+ data: {
+ videoId,
+ userId: (session.user as any).id,
+ content: content.trim(),
+ },
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ image: true,
+ },
+ },
+ replies: {
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ image: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ return NextResponse.json(comment, { status: 201 });
+ } catch (error) {
+ console.error('Failed to create comment:', error);
+ return NextResponse.json(
+ { error: 'Failed to create comment' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/courses/route.ts b/app/api/courses/route.ts
new file mode 100644
index 0000000..132e8a2
--- /dev/null
+++ b/app/api/courses/route.ts
@@ -0,0 +1,8 @@
+// app/api/users/route.ts
+import { NextResponse } from "next/server";
+import { prisma } from "../../../lib/prisma";
+
+export async function GET() {
+ const courses = await prisma.course.findMany({ select: { id: true, title: true, code: true } });
+ return NextResponse.json(courses);
+}
diff --git a/app/api/enrollments/route.ts b/app/api/enrollments/route.ts
new file mode 100644
index 0000000..a02e131
--- /dev/null
+++ b/app/api/enrollments/route.ts
@@ -0,0 +1,94 @@
+// /app/api/enrollments/route.ts
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+import { PrismaClient } from '@prisma/client';
+
+const prisma = new PrismaClient();
+
+// --- GET --------------------------------------------------------------------
+
+export async function GET() {
+ try {
+ const enrollments = await prisma.enrollment.findMany({
+ include: {
+ user: { select: { id: true, name: true, email: true } },
+ course: { select: { id: true, title: true, code: true, } },
+ },
+ orderBy: { createdAt: 'asc' },
+ });
+ return NextResponse.json(enrollments);
+ } catch (err) {
+ console.error('GET /api/enrollments error', err);
+ return NextResponse.json({ error: 'Failed to fetch enrollments' }, { status: 500 });
+ }
+}
+
+// --- POST --------------------------------------------------------------------
+
+export async function POST(req: NextRequest) {
+ try {
+ const body = await req.json();
+
+ // incoming payload example:
+ // { userId: "cmicsu2030000uaec0sp56gso", courseId: "cmicrx1b70000uahwilu0gh9u", role: "student" }
+ const userId: string | undefined = body.userId;
+ const courseId: string | undefined = body.courseId;
+ // const role: string = body.role ?? 'student';
+
+ if (!userId || !courseId) {
+ return NextResponse.json({ error: 'Missing userId or courseId' }, { status: 400 });
+ }
+
+ // Optional: Prevent duplicate enrolments
+ const existing = await prisma.enrollment.findFirst({
+ where: { userId: userId, courseId: courseId },
+ });
+
+ if (existing) {
+ return NextResponse.json(
+ { error: 'Enrollment already exists' },
+ { status: 409 }
+ );
+ }
+
+ // Create enrollment using STRING IDs
+ const enrollment = await prisma.enrollment.create({
+ data: {
+ user: { connect: { id: userId } },
+ course: { connect: { id: courseId } },
+ // role: role,
+ },
+ include: {
+ user: { select: { id: true, name: true, email: true } },
+ course: { select: { id: true, title: true } },
+ },
+ });
+
+ return NextResponse.json(enrollment, { status: 201 });
+ } catch (err) {
+ console.error('POST /api/enrollments error', err);
+ return NextResponse.json({ error: 'Failed to create enrollment' }, { status: 500 });
+ }
+}
+
+// --- DELETE --------------------------------------------------------------------
+
+export async function DELETE(req: NextRequest) {
+ try {
+ const body = await req.json();
+ const id = body?.id;
+
+ if (!id) {
+ return NextResponse.json({ error: 'Missing id' }, { status: 400 });
+ }
+
+ await prisma.enrollment.delete({
+ where: { id: id }, // STRING — not Number(id)
+ });
+
+ return NextResponse.json({ success: true });
+ } catch (err) {
+ console.error('DELETE /api/enrollments error', err);
+ return NextResponse.json({ error: 'Failed to delete enrollment' }, { status: 500 });
+ }
+}
diff --git a/app/api/enrollments/sync.ts b/app/api/enrollments/sync.ts
new file mode 100644
index 0000000..2d60dfa
--- /dev/null
+++ b/app/api/enrollments/sync.ts
@@ -0,0 +1,122 @@
+// app/api/enrollments/sync.ts
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function POST(req: NextRequest) {
+ console.log('🔥 [SYNC-ENROLLMENTS] POST endpoint called');
+ console.log('🔥 [SYNC-ENROLLMENTS] Request method:', req.method);
+ console.log('🔥 [SYNC-ENROLLMENTS] Request URL:', req.url);
+
+ try {
+ const session = await getServerSession(authOptions);
+ console.log('🔥 [SYNC-ENROLLMENTS] Session check:', !!session?.user?.email);
+ console.log('🔥 [SYNC-ENROLLMENTS] Session user:', session?.user);
+
+ if (!session?.user?.email) {
+ console.log('🔥 [SYNC-ENROLLMENTS] No session or email, returning 401');
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const userEmail = session.user.email;
+ console.log('🔥 [SYNC-ENROLLMENTS] About to parse request body...');
+
+ const body = await req.json();
+ console.log('🔥 [SYNC-ENROLLMENTS] Raw body received:', JSON.stringify(body, null, 2));
+
+ const levels: string[] = body?.levels || [];
+ console.log('🔥 [SYNC-ENROLLMENTS] Extracted levels:', levels);
+ console.log('🔥 [SYNC-ENROLLMENTS] Levels type check - isArray:', Array.isArray(levels));
+ console.log('🔥 [SYNC-ENROLLMENTS] Levels length:', levels.length);
+
+ console.log('🔥 [SYNC-ENROLLMENTS] User:', userEmail);
+ console.log('🔥 [SYNC-ENROLLMENTS] Received levels:', levels);
+
+ if (!Array.isArray(levels) || levels.length === 0) {
+ console.log('🔥 [SYNC-ENROLLMENTS] VALIDATION FAILED!');
+ console.log('🔥 [SYNC-ENROLLMENTS] levels isArray:', Array.isArray(levels));
+ console.log('🔥 [SYNC-ENROLLMENTS] levels length:', levels?.length);
+ console.log('🔥 [SYNC-ENROLLMENTS] levels value:', levels);
+ console.log('🔥 [SYNC-ENROLLMENTS] Returning 400 - Levels array required');
+ return NextResponse.json(
+ { error: 'Levels array required' },
+ { status: 400 }
+ );
+ }
+
+ // Get user
+ const user = await prisma.user.findUnique({
+ where: { email: userEmail },
+ });
+ if (!user) {
+ console.log('[SYNC-ENROLLMENTS] User not found in database:', userEmail);
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ console.log('[SYNC-ENROLLMENTS] Found user:', user.id, user.email);
+
+ const createdEnrollments = [];
+
+ // For each course level, find the course and create enrollment
+ for (const level of levels) {
+ try {
+ console.log('[SYNC-ENROLLMENTS] Looking for course with code:', level);
+
+ const course = await prisma.course.findUnique({
+ where: { code: level },
+ });
+
+ if (!course) {
+ console.warn(`[SYNC-ENROLLMENTS] Course code not found: ${level}`);
+ continue;
+ }
+
+ console.log('[SYNC-ENROLLMENTS] Found course:', course.id, course.code, course.title);
+
+ // Check if enrollment already exists
+ const existing = await prisma.enrollment.findFirst({
+ where: { userId: user.id, courseId: course.id },
+ });
+
+ if (existing) {
+ console.log('[SYNC-ENROLLMENTS] Enrollment already exists:', existing.id);
+ } else {
+ const enrollment = await prisma.enrollment.create({
+ data: {
+ userId: user.id,
+ courseId: course.id,
+ },
+ });
+ console.log('[SYNC-ENROLLMENTS] Created new enrollment:', enrollment.id);
+ createdEnrollments.push({
+ courseCode: level,
+ courseId: course.id,
+ enrollmentId: enrollment.id,
+ });
+ }
+ } catch (err) {
+ console.error(`[SYNC-ENROLLMENTS] Failed to create enrollment for level ${level}:`, err);
+ }
+ }
+
+ console.log('[SYNC-ENROLLMENTS] Summary - Created enrollments:', createdEnrollments.length);
+ console.log('[SYNC-ENROLLMENTS] Details:', createdEnrollments);
+
+ return NextResponse.json(
+ {
+ message: 'Enrollments synced',
+ created: createdEnrollments,
+ },
+ { status: 200 }
+ );
+ } catch (err) {
+ console.error('🔥 [SYNC-ENROLLMENTS] FATAL ERROR:', err);
+ console.error('🔥 [SYNC-ENROLLMENTS] Error stack:', err instanceof Error ? err.stack : 'No stack');
+ return NextResponse.json(
+ { error: 'Failed to sync enrollments' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/enrollments/sync/route.ts b/app/api/enrollments/sync/route.ts
new file mode 100644
index 0000000..767657a
--- /dev/null
+++ b/app/api/enrollments/sync/route.ts
@@ -0,0 +1,122 @@
+// app/api/enrollments/sync/route.ts
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function POST(req: NextRequest) {
+ console.log('🔥 [SYNC-ENROLLMENTS] POST endpoint called');
+ console.log('🔥 [SYNC-ENROLLMENTS] Request method:', req.method);
+ console.log('🔥 [SYNC-ENROLLMENTS] Request URL:', req.url);
+
+ try {
+ const session = await getServerSession(authOptions);
+ console.log('🔥 [SYNC-ENROLLMENTS] Session check:', !!session?.user?.email);
+ console.log('🔥 [SYNC-ENROLLMENTS] Session user:', session?.user);
+
+ if (!session?.user?.email) {
+ console.log('🔥 [SYNC-ENROLLMENTS] No session or email, returning 401');
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const userEmail = session.user.email;
+ console.log('🔥 [SYNC-ENROLLMENTS] About to parse request body...');
+
+ const body = await req.json();
+ console.log('🔥 [SYNC-ENROLLMENTS] Raw body received:', JSON.stringify(body, null, 2));
+
+ const levels: string[] = body?.levels || [];
+ console.log('🔥 [SYNC-ENROLLMENTS] Extracted levels:', levels);
+ console.log('🔥 [SYNC-ENROLLMENTS] Levels type check - isArray:', Array.isArray(levels));
+ console.log('🔥 [SYNC-ENROLLMENTS] Levels length:', levels.length);
+
+ console.log('🔥 [SYNC-ENROLLMENTS] User:', userEmail);
+ console.log('🔥 [SYNC-ENROLLMENTS] Received levels:', levels);
+
+ if (!Array.isArray(levels) || levels.length === 0) {
+ console.log('🔥 [SYNC-ENROLLMENTS] VALIDATION FAILED!');
+ console.log('🔥 [SYNC-ENROLLMENTS] levels isArray:', Array.isArray(levels));
+ console.log('🔥 [SYNC-ENROLLMENTS] levels length:', levels?.length);
+ console.log('🔥 [SYNC-ENROLLMENTS] levels value:', levels);
+ console.log('🔥 [SYNC-ENROLLMENTS] Returning 400 - Levels array required');
+ return NextResponse.json(
+ { error: 'Levels array required' },
+ { status: 400 }
+ );
+ }
+
+ // Get user
+ const user = await prisma.user.findUnique({
+ where: { email: userEmail },
+ });
+ if (!user) {
+ console.log('[SYNC-ENROLLMENTS] User not found in database:', userEmail);
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ console.log('[SYNC-ENROLLMENTS] Found user:', user.id, user.email);
+
+ const createdEnrollments = [];
+
+ // For each course level, find the course and create enrollment
+ for (const level of levels) {
+ try {
+ console.log('[SYNC-ENROLLMENTS] Looking for course with code:', level);
+
+ const course = await prisma.course.findUnique({
+ where: { code: level },
+ });
+
+ if (!course) {
+ console.warn(`[SYNC-ENROLLMENTS] Course code not found: ${level}`);
+ continue;
+ }
+
+ console.log('[SYNC-ENROLLMENTS] Found course:', course.id, course.code, course.title);
+
+ // Check if enrollment already exists
+ const existing = await prisma.enrollment.findFirst({
+ where: { userId: user.id, courseId: course.id },
+ });
+
+ if (existing) {
+ console.log('[SYNC-ENROLLMENTS] Enrollment already exists:', existing.id);
+ } else {
+ const enrollment = await prisma.enrollment.create({
+ data: {
+ userId: user.id,
+ courseId: course.id,
+ },
+ });
+ console.log('[SYNC-ENROLLMENTS] Created new enrollment:', enrollment.id);
+ createdEnrollments.push({
+ courseCode: level,
+ courseId: course.id,
+ enrollmentId: enrollment.id,
+ });
+ }
+ } catch (err) {
+ console.error(`[SYNC-ENROLLMENTS] Failed to create enrollment for level ${level}:`, err);
+ }
+ }
+
+ console.log('[SYNC-ENROLLMENTS] Summary - Created enrollments:', createdEnrollments.length);
+ console.log('[SYNC-ENROLLMENTS] Details:', createdEnrollments);
+
+ return NextResponse.json(
+ {
+ message: 'Enrollments synced',
+ created: createdEnrollments,
+ },
+ { status: 200 }
+ );
+ } catch (err) {
+ console.error('🔥 [SYNC-ENROLLMENTS] FATAL ERROR:', err);
+ console.error('🔥 [SYNC-ENROLLMENTS] Error stack:', err instanceof Error ? err.stack : 'No stack');
+ return NextResponse.json(
+ { error: 'Failed to sync enrollments' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/likes/all/route.ts b/app/api/likes/all/route.ts
new file mode 100644
index 0000000..c4c8116
--- /dev/null
+++ b/app/api/likes/all/route.ts
@@ -0,0 +1,53 @@
+// app/api/likes/all/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const user = await prisma.user.findUnique({ where: { email: session.user.email } });
+ if (!user)
+ return NextResponse.json({ error: 'user not found' }, { status: 404 });
+
+ // Fetch all videos liked by user, sorted by most recent
+ const likes = await prisma.videoLike.findMany({
+ where: { userId: user.id },
+ include: {
+ video: {
+ select: {
+ id: true,
+ title: true,
+ thumbnail: true,
+ durationSec: true,
+ url: true,
+ playlist: {
+ select: {
+ title: true,
+ course: {
+ select: {
+ id: true,
+ title: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ });
+
+ return NextResponse.json(likes);
+ } catch (err: any) {
+ console.error('get liked videos error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/likes/route.ts b/app/api/likes/route.ts
new file mode 100644
index 0000000..67d5e48
--- /dev/null
+++ b/app/api/likes/route.ts
@@ -0,0 +1,88 @@
+// app/api/likes/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function POST(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const body = await req.json().catch(() => ({}));
+ const { videoId, isLiked } = body ?? {};
+
+ if (!videoId)
+ return NextResponse.json({ error: 'videoId required' }, { status: 400 });
+
+ const user = await prisma.user.findUnique({ where: { email: session.user.email } });
+ if (!user)
+ return NextResponse.json({ error: 'user not found' }, { status: 404 });
+
+ if (isLiked) {
+ // Add like
+ const existingLike = await prisma.videoLike.findUnique({
+ where: { userId_videoId: { userId: user.id, videoId } },
+ });
+
+ if (existingLike) {
+ return NextResponse.json({ success: true, message: 'Already liked' });
+ }
+
+ await prisma.videoLike.create({
+ data: {
+ userId: user.id,
+ videoId,
+ },
+ });
+
+ return NextResponse.json({ success: true, message: 'Video liked' });
+ } else {
+ // Remove like
+ await prisma.videoLike.deleteMany({
+ where: { userId: user.id, videoId },
+ });
+
+ return NextResponse.json({ success: true, message: 'Video unliked' });
+ }
+ } catch (err: any) {
+ console.error('like toggle error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
+
+export async function GET(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const { searchParams } = new URL(req.url);
+ const videoId = searchParams.get('videoId');
+
+ const user = await prisma.user.findUnique({ where: { email: session.user.email } });
+ if (!user)
+ return NextResponse.json({ error: 'user not found' }, { status: 404 });
+
+ if (videoId) {
+ // Check if a specific video is liked
+ const like = await prisma.videoLike.findUnique({
+ where: { userId_videoId: { userId: user.id, videoId } },
+ });
+ return NextResponse.json({ isLiked: !!like });
+ } else {
+ // Get all liked videos for user
+ return NextResponse.json({ error: 'videoId required for check' }, { status: 400 });
+ }
+ } catch (err: any) {
+ console.error('like check error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/playlists/[id]/route.ts b/app/api/playlists/[id]/route.ts
new file mode 100644
index 0000000..5b7acb8
--- /dev/null
+++ b/app/api/playlists/[id]/route.ts
@@ -0,0 +1,59 @@
+// app/api/playlists/[id]/route.ts
+import { NextResponse } from "next/server";
+import { prisma } from "../../../../lib/prisma";
+import { getServerSession } from "next-auth";
+import { authOptions } from "../../../../lib/auth-options";
+
+export async function GET(req: Request, context: any) {
+ try {
+ // Unwrap params (Next may provide a Promise)
+ let params = context?.params;
+ if (typeof params?.then === "function") params = await params;
+
+ const id = params?.id;
+ if (!id) return NextResponse.json({ error: "Missing playlist id" }, { status: 400 });
+
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const user = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ });
+ if (!user) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const playlist = await prisma.playlist.findUnique({
+ where: { id },
+ include: {
+ videos: {
+ include: {
+ uploader: {
+ select: {
+ id: true,
+ name: true,
+ image: true,
+ },
+ },
+ },
+ orderBy: { index: "asc" },
+ },
+ course: true,
+ },
+ });
+
+ if (!playlist) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ const enrolled = await prisma.enrollment.findUnique({
+ where: { userId_courseId: { userId: user.id, courseId: playlist.courseId } },
+ });
+ if (!enrolled) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+
+ return NextResponse.json({ playlist });
+ } catch (err: any) {
+ console.error("GET /api/playlists/[id] error:", err);
+ return NextResponse.json({ error: "Server error" }, { status: 500 });
+ }
+}
diff --git a/app/api/playlists/route.ts b/app/api/playlists/route.ts
new file mode 100644
index 0000000..08e7598
--- /dev/null
+++ b/app/api/playlists/route.ts
@@ -0,0 +1,133 @@
+// app/api/playlists/route.ts
+import { NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth-options";
+import { prisma } from "@/lib/prisma";
+
+type VideoWithCourses = {
+ videoCourses?: Array<{ courseId: string; exclusive: boolean }>
+};
+
+function mapRestrictedCourseIds(video: VideoWithCourses) {
+ if (!video?.videoCourses?.length) return [];
+ return video.videoCourses
+ .filter((assignment) => assignment.exclusive)
+ .map((assignment) => assignment.courseId);
+}
+
+function filterRestrictedVideos(videos: any[], userCourseIds: string[]) {
+ return videos
+ .filter((video) => {
+ const restrictedCourseIds = mapRestrictedCourseIds(video);
+ if (restrictedCourseIds.length === 0) return true;
+ return restrictedCourseIds.some((courseId: string) =>
+ userCourseIds.includes(courseId)
+ );
+ })
+ .map((video) => ({
+ ...video,
+ restrictedCourseIds: mapRestrictedCourseIds(video),
+ }));
+}
+
+export async function GET(req: Request) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const user = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ include: {
+ enrollments: {
+ include: { course: true },
+ },
+ },
+ });
+
+ const courseIds = user?.enrollments.map((e) => e.courseId) ?? [];
+
+ // Get playlists directly assigned to enrolled courses
+ const coursesWithPlaylists = await prisma.course.findMany({
+ where: { id: { in: courseIds } },
+ include: {
+ playlists: {
+ include: {
+ videos: {
+ include: {
+ uploader: {
+ select: {
+ id: true,
+ name: true,
+ image: true,
+ },
+ },
+ videoCourses: true,
+ },
+ orderBy: { index: "asc" },
+ },
+ courses: {
+ include: { course: true },
+ },
+ },
+ orderBy: { sortOrder: "asc" },
+ },
+ },
+ orderBy: { title: "asc" },
+ });
+
+ // Also get playlists assigned to courses via CoursePlaylist mapping
+ const additionalPlaylists = await prisma.coursePlaylist.findMany({
+ where: { courseId: { in: courseIds } },
+ include: {
+ playlist: {
+ include: {
+ videos: {
+ include: {
+ uploader: {
+ select: {
+ id: true,
+ name: true,
+ image: true,
+ },
+ },
+ videoCourses: true,
+ },
+ orderBy: { index: "asc" },
+ },
+ courses: {
+ include: { course: true },
+ },
+ },
+ },
+ course: true,
+ },
+ });
+
+ // Merge results: add additional playlists to their respective courses
+ const playlistMap = new Map();
+ additionalPlaylists.forEach(({ course, playlist }) => {
+ if (!playlistMap.has(course.id)) {
+ playlistMap.set(course.id, []);
+ }
+ playlist.videos = filterRestrictedVideos(playlist.videos, courseIds);
+ playlistMap.get(course.id).push(playlist);
+ });
+
+ coursesWithPlaylists.forEach((course) => {
+ course.playlists.forEach((playlist) => {
+ playlist.videos = filterRestrictedVideos(playlist.videos, courseIds);
+ });
+
+ const additional = playlistMap.get(course.id) || [];
+ const existingIds = new Set(course.playlists.map((p) => p.id));
+ const newPlaylists = additional.filter((p: any) => !existingIds.has(p.id));
+ newPlaylists.forEach((playlist: any) => {
+ playlist.videos = filterRestrictedVideos(playlist.videos, courseIds);
+ });
+ course.playlists.push(...newPlaylists);
+ course.playlists.sort((a, b) => a.sortOrder - b.sortOrder);
+ });
+
+ return NextResponse.json({ subjects: coursesWithPlaylists });
+}
diff --git a/app/api/progress/route.ts b/app/api/progress/route.ts
new file mode 100644
index 0000000..6690037
--- /dev/null
+++ b/app/api/progress/route.ts
@@ -0,0 +1,225 @@
+// app/api/progress/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const userEmail = session.user.email;
+ const { searchParams } = new URL(req.url);
+ const videoId = searchParams.get('videoId');
+
+ if (!videoId)
+ return NextResponse.json({ error: 'videoId required' }, { status: 400 });
+
+ const user = await prisma.user.findUnique({ where: { email: userEmail } });
+ if (!user)
+ return NextResponse.json({ error: 'user not found' }, { status: 404 });
+
+ // Validate that the video exists
+ const video = await prisma.video.findUnique({ where: { id: videoId } });
+ if (!video) {
+ console.debug(`[PROGRESS] Video not found: ${videoId} (likely deleted video with cached browser reference)`);
+ return NextResponse.json({ error: 'video not found' }, { status: 404 });
+ }
+
+ const progress = await prisma.videoProgress.findUnique({
+ where: { userId_videoId: { userId: user.id, videoId } },
+ select: { percent: true, lastPos: true, watchedSec: true },
+ });
+
+ return NextResponse.json({
+ percent: progress?.percent ?? 0,
+ lastPos: progress?.lastPos ?? 0,
+ watchedSec: progress?.watchedSec ?? 0,
+ });
+ } catch (err: any) {
+ console.error('progress GET error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
+
+// Helper function to calculate unique watched seconds from segments
+function calculateWatchedSeconds(segments: Array<{ startSec: number; endSec: number }>): number {
+ if (segments.length === 0) return 0;
+
+ // Sort and merge overlapping ranges
+ const sorted = segments.sort((a, b) => a.startSec - b.startSec);
+ const merged: Array<[number, number]> = [];
+
+ for (const seg of sorted) {
+ if (merged.length === 0) {
+ merged.push([seg.startSec, seg.endSec]);
+ } else {
+ const last = merged[merged.length - 1];
+ if (seg.startSec <= last[1] + 0.5) {
+ // Overlapping or adjacent, merge
+ last[1] = Math.max(last[1], seg.endSec);
+ } else {
+ // Gap, new range
+ merged.push([seg.startSec, seg.endSec]);
+ }
+ }
+ }
+
+ let total = 0;
+ for (const [start, end] of merged) {
+ total += Math.max(0, end - start);
+ }
+ return Math.round(total);
+}
+
+export async function POST(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const userEmail = session.user.email;
+
+ const body = await req.json().catch(() => ({}));
+ const { videoId, playlistId, watchedSec, lastPos, duration } = body ?? {};
+
+ if (!videoId)
+ return NextResponse.json({ error: 'videoId required' }, { status: 400 });
+ if (!duration || Number(duration) <= 0)
+ return NextResponse.json({ error: 'duration required' }, { status: 400 });
+
+ const watched = Number(watchedSec ?? 0);
+ const lastPosition = Number(lastPos ?? 0);
+ const dur = Number(duration);
+
+ // look up user id
+ const user = await prisma.user.findUnique({ where: { email: userEmail } });
+ if (!user)
+ return NextResponse.json({ error: 'user not found' }, { status: 404 });
+
+ // Validate that the video exists
+ const video = await prisma.video.findUnique({ where: { id: videoId } });
+ if (!video) {
+ // Log at debug level since this is expected when users have old cached references
+ console.debug(`[PROGRESS] Video not found: ${videoId} (likely deleted video with cached browser reference)`);
+ return NextResponse.json({ error: 'video not found' }, { status: 404 });
+ }
+
+ // Ensure VideoProgress exists or create it
+ let videoProgress = await prisma.videoProgress.findUnique({
+ where: { userId_videoId: { userId: user.id, videoId } },
+ });
+
+ if (!videoProgress) {
+ videoProgress = await prisma.videoProgress.create({
+ data: {
+ userId: user.id,
+ videoId,
+ watchedSec: 0,
+ lastPos: 0,
+ percent: 0,
+ durationSec: dur,
+ },
+ });
+ }
+
+ // Record the watch segment if watched seconds > 0
+ let newSegment = null;
+ if (watched > 0) {
+ newSegment = await prisma.videoWatchSegment.create({
+ data: {
+ userId: user.id,
+ videoId,
+ startSec: Math.max(0, lastPosition - watched),
+ endSec: lastPosition,
+ },
+ });
+ }
+
+ // Fetch all segments for this user+video to recalculate totals
+ const allSegments = await prisma.videoWatchSegment.findMany({
+ where: { userId: user.id, videoId },
+ select: { startSec: true, endSec: true },
+ orderBy: { createdAt: 'asc' },
+ });
+
+ // Calculate total unique watched seconds
+ const totalWatchedSec = calculateWatchedSeconds(allSegments);
+
+ // Calculate completion percentage
+ const ratio = dur > 0 ? totalWatchedSec / dur : 0;
+ const percentInt = Math.min(100, Math.round(ratio * 100));
+ const completed = percentInt >= 80; // Changed from 90 to 80 for unlock threshold
+
+ // Update VideoProgress with recalculated values
+ const upserted = await prisma.videoProgress.update({
+ where: { userId_videoId: { userId: user.id, videoId } },
+ data: {
+ watchedSec: totalWatchedSec,
+ lastPos: Math.max(videoProgress.lastPos ?? 0, lastPosition),
+ percent: percentInt,
+ durationSec: dur,
+ completed,
+ updatedAt: new Date(),
+ },
+ });
+
+ // If 80% watched, unlock next video in playlist for this user
+ let unlockedNext = null;
+ if (completed && playlistId) {
+ // find current video
+ const current = await prisma.video.findUnique({ where: { id: videoId } });
+ if (current && current.playlistId === playlistId) {
+ // Find next video that is NOT instant access and NOT globally locked
+ // Skip any instant access videos in the sequence
+ const nextVideos = await prisma.video.findMany({
+ where: {
+ playlistId,
+ index: { gt: current.index },
+ locked: false,
+ instantAccess: false,
+ },
+ orderBy: { index: 'asc' },
+ take: 1,
+ });
+
+ if (nextVideos.length > 0) {
+ const next = nextVideos[0];
+ // Create a VideoUnlock record for this user (per-user unlock tracking)
+ const unlock = await prisma.videoUnlock.upsert({
+ where: { userId_videoId: { userId: user.id, videoId: next.id } },
+ update: {}, // if already exists, do nothing
+ create: { userId: user.id, videoId: next.id },
+ });
+ unlockedNext = { id: next.id, title: next.title };
+ }
+ }
+ }
+
+ return NextResponse.json({ ok: true, progress: upserted, unlockedNext, segment: newSegment });
+ } catch (err: any) {
+ console.error('progress error', err);
+
+ // Handle foreign key constraint violations
+ if (err.code === 'P2003') {
+ const constraint = err.meta?.constraint_name;
+ if (constraint?.includes('videoId')) {
+ console.error(`[PROGRESS] Foreign key violation - invalid videoId: ${err.meta}`);
+ return NextResponse.json(
+ { error: 'Invalid video reference' },
+ { status: 400 }
+ );
+ }
+ }
+
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/progress/segments/route.ts b/app/api/progress/segments/route.ts
new file mode 100644
index 0000000..c4a84e4
--- /dev/null
+++ b/app/api/progress/segments/route.ts
@@ -0,0 +1,45 @@
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const userEmail = session.user.email;
+ const { searchParams } = new URL(req.url);
+ const videoId = searchParams.get('videoId');
+
+ if (!videoId)
+ return NextResponse.json({ error: 'videoId required' }, { status: 400 });
+
+ const user = await prisma.user.findUnique({ where: { email: userEmail } });
+ if (!user)
+ return NextResponse.json({ error: 'user not found' }, { status: 404 });
+
+ // Validate that the video exists
+ const video = await prisma.video.findUnique({ where: { id: videoId } });
+ if (!video) {
+ console.debug(`[PROGRESS] Video not found for segments: ${videoId} (likely deleted video with cached browser reference)`);
+ return NextResponse.json({ error: 'video not found' }, { status: 404 });
+ }
+
+ // Fetch all watch segments for this user+video
+ const segments = await prisma.videoWatchSegment.findMany({
+ where: { userId: user.id, videoId },
+ select: { startSec: true, endSec: true, watchedAt: true },
+ orderBy: { createdAt: 'asc' },
+ });
+
+ return NextResponse.json({ segments });
+ } catch (err: any) {
+ console.error('segments GET error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/thumbnails/[...path]/route.ts b/app/api/thumbnails/[...path]/route.ts
new file mode 100644
index 0000000..91e48d8
--- /dev/null
+++ b/app/api/thumbnails/[...path]/route.ts
@@ -0,0 +1,117 @@
+// /api/thumbnails/[...path]/route.ts
+// This endpoint serves thumbnails from the thumbnails directory
+
+import { NextResponse } from 'next/server';
+import * as fs from 'fs';
+import * as path from 'path';
+
+// Path to thumbnails directory (should match UPLOADS_DIR/thumbnails)
+const THUMBNAILS_DIR = path.join(process.env.UPLOADS_DIR || '/uploads', 'thumbnails');
+
+export async function GET(
+ request: Request,
+ context: { params: Promise<{ path?: string[] }> }
+) {
+ try {
+ const params = await context.params;
+ const pathSegments = params?.path || [];
+
+ if (pathSegments.length === 0) {
+ return NextResponse.json(
+ { error: 'Invalid thumbnail request' },
+ { status: 400 }
+ );
+ }
+
+ // Construct the file path and validate it
+ const requestedPath = path.join(THUMBNAILS_DIR, ...pathSegments);
+
+ // Security: prevent directory traversal attacks
+ if (!requestedPath.startsWith(THUMBNAILS_DIR)) {
+ return NextResponse.json(
+ { error: 'Invalid request' },
+ { status: 403 }
+ );
+ }
+
+ // Check if file exists
+ if (!fs.existsSync(requestedPath)) {
+ console.log(`[Thumbnails] File not found: ${requestedPath}`);
+ return NextResponse.json(
+ { error: 'Not found' },
+ { status: 404 }
+ );
+ }
+
+ // Read the file
+ const fileContent = fs.readFileSync(requestedPath);
+
+ // Determine content type based on file extension
+ let contentType = 'application/octet-stream';
+
+ if (requestedPath.endsWith('.jpg') || requestedPath.endsWith('.jpeg')) {
+ contentType = 'image/jpeg';
+ } else if (requestedPath.endsWith('.png')) {
+ contentType = 'image/png';
+ } else if (requestedPath.endsWith('.webp')) {
+ contentType = 'image/webp';
+ } else if (requestedPath.endsWith('.gif')) {
+ contentType = 'image/gif';
+ }
+
+ // Return the file with appropriate headers
+ return new NextResponse(fileContent, {
+ status: 200,
+ headers: {
+ 'Content-Type': contentType,
+ 'Cache-Control': 'public, max-age=31536000, immutable', // Cache for 1 year (thumbnails don't change)
+ 'Access-Control-Allow-Origin': '*', // Allow CORS if needed
+ },
+ });
+ } catch (error) {
+ console.error('[Thumbnails] Error serving thumbnail file:', error);
+ return NextResponse.json(
+ { error: 'Internal server error' },
+ { status: 500 }
+ );
+ }
+}
+
+// HEAD request support for thumbnail validation
+export async function HEAD(
+ request: Request,
+ context: { params: Promise<{ path?: string[] }> }
+) {
+ try {
+ const params = await context.params;
+ const pathSegments = params?.path || [];
+
+ if (pathSegments.length === 0) {
+ return new NextResponse(null, { status: 400 });
+ }
+
+ const requestedPath = path.join(THUMBNAILS_DIR, ...pathSegments);
+
+ // Security: prevent directory traversal attacks
+ if (!requestedPath.startsWith(THUMBNAILS_DIR)) {
+ return new NextResponse(null, { status: 403 });
+ }
+
+ // Check if file exists
+ if (!fs.existsSync(requestedPath)) {
+ return new NextResponse(null, { status: 404 });
+ }
+
+ // Return headers only
+ return new NextResponse(null, {
+ status: 200,
+ headers: {
+ 'Content-Type': 'image/jpeg', // Default for HEAD requests
+ 'Cache-Control': 'public, max-age=31536000, immutable',
+ },
+ });
+ } catch (error) {
+ console.error('[Thumbnails] Error in HEAD request:', error);
+ return new NextResponse(null, { status: 500 });
+ }
+}
diff --git a/app/api/transcoder/claim/route.ts b/app/api/transcoder/claim/route.ts
new file mode 100644
index 0000000..a0a074e
--- /dev/null
+++ b/app/api/transcoder/claim/route.ts
@@ -0,0 +1,60 @@
+// app/api/transcoder/claim/route.ts
+// Atomically claims the next available transcoding job.
+// Uses SELECT … FOR UPDATE SKIP LOCKED so multiple workers never race on the
+// same video.
+
+import { NextResponse } from "next/server";
+import { prisma } from "@/lib/prisma";
+import { Prisma } from "@prisma/client";
+import { verifyTranscoderToken } from "@/lib/transcoder-auth";
+
+export const dynamic = "force-dynamic";
+
+export async function POST(request: Request) {
+ if (!verifyTranscoderToken(request)) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ try {
+ const video = await prisma.$transaction(async (tx) => {
+ // Atomically lock the oldest uploaded video, skipping rows already
+ // locked by concurrent workers.
+ const rows = await tx.$queryRaw<{ id: string }[]>(
+ Prisma.sql`
+ SELECT id
+ FROM "Video"
+ WHERE "transcodingStatus" = 'uploaded'
+ ORDER BY "createdAt" ASC
+ LIMIT 1
+ FOR UPDATE SKIP LOCKED
+ `
+ );
+
+ if (rows.length === 0) return null;
+
+ return tx.video.update({
+ where: { id: rows[0].id },
+ data: { transcodingStatus: "processing" },
+ });
+ });
+
+ if (!video) {
+ // No work available – return null body so the worker knows to stop.
+ return NextResponse.json(null, { status: 200 });
+ }
+
+ // Build the download URL from the public CMS base URL.
+ const cmsBase =
+ process.env.NEXTAUTH_URL?.replace(/\/$/, "") ??
+ process.env.CMS_PUBLIC_URL?.replace(/\/$/, "") ??
+ "";
+
+ return NextResponse.json({
+ videoId: video.id,
+ downloadUrl: `${cmsBase}/api/transcoder/download/${video.id}`,
+ });
+ } catch (err) {
+ console.error("[Transcoder Claim] Error:", err);
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+}
diff --git a/app/api/transcoder/download/[videoId]/route.ts b/app/api/transcoder/download/[videoId]/route.ts
new file mode 100644
index 0000000..e1b14b6
--- /dev/null
+++ b/app/api/transcoder/download/[videoId]/route.ts
@@ -0,0 +1,72 @@
+// app/api/transcoder/download/[videoId]/route.ts
+// Streams the original MP4 directly to the remote transcoder worker.
+// Never buffers the file in memory.
+
+import { NextResponse } from "next/server";
+import * as fssync from "fs";
+import * as fs from "fs/promises";
+import * as path from "path";
+import { Readable } from "stream";
+import { prisma } from "@/lib/prisma";
+import { verifyTranscoderToken } from "@/lib/transcoder-auth";
+
+// Allow up to 45 minutes for large file transfers.
+export const maxDuration = 2700;
+
+const UPLOADS_DIR = process.env.UPLOADS_DIR ?? "/uploads";
+const ORIGINALS_DIR = path.join(UPLOADS_DIR, "videos");
+
+// Narrow character set – CUIDs are alphanumeric plus underscore/dash.
+const SAFE_ID = /^[a-zA-Z0-9_-]{1,64}$/;
+
+export async function GET(
+ request: Request,
+ context: { params: Promise<{ videoId: string }> }
+) {
+ if (!verifyTranscoderToken(request)) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { videoId } = await context.params;
+
+ if (!SAFE_ID.test(videoId)) {
+ return NextResponse.json({ error: "Invalid video ID" }, { status: 400 });
+ }
+
+ const video = await prisma.video.findUnique({ where: { id: videoId } });
+ if (!video) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
+ // Only allow download while the job is actively claimed.
+ if (video.transcodingStatus !== "processing") {
+ return NextResponse.json(
+ { error: "Video is not in processing state" },
+ { status: 409 }
+ );
+ }
+
+ const filePath = path.join(ORIGINALS_DIR, `${videoId}.mp4`);
+
+ // Security: ensure the resolved path stays within ORIGINALS_DIR.
+ const resolved = path.resolve(filePath);
+ if (!resolved.startsWith(path.resolve(ORIGINALS_DIR))) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ try {
+ const stat = await fs.stat(resolved);
+ const nodeStream = fssync.createReadStream(resolved);
+ const webStream = Readable.toWeb(nodeStream) as ReadableStream;
+
+ return new Response(webStream, {
+ headers: {
+ "Content-Type": "video/mp4",
+ "Content-Length": stat.size.toString(),
+ "Content-Disposition": `attachment; filename="${videoId}.mp4"`,
+ },
+ });
+ } catch {
+ return NextResponse.json({ error: "File not found on disk" }, { status: 404 });
+ }
+}
diff --git a/app/api/transcoder/fail/[videoId]/route.ts b/app/api/transcoder/fail/[videoId]/route.ts
new file mode 100644
index 0000000..e610a17
--- /dev/null
+++ b/app/api/transcoder/fail/[videoId]/route.ts
@@ -0,0 +1,54 @@
+// app/api/transcoder/fail/[videoId]/route.ts
+// Marks a video job as failed. Called by the remote worker when transcoding
+// or upload encounters an unrecoverable error.
+
+import { NextResponse } from "next/server";
+import { prisma } from "@/lib/prisma";
+import { verifyTranscoderToken } from "@/lib/transcoder-auth";
+
+const SAFE_ID = /^[a-zA-Z0-9_-]{1,64}$/;
+
+export async function POST(
+ request: Request,
+ context: { params: Promise<{ videoId: string }> }
+) {
+ if (!verifyTranscoderToken(request)) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { videoId } = await context.params;
+
+ if (!SAFE_ID.test(videoId)) {
+ return NextResponse.json({ error: "Invalid video ID" }, { status: 400 });
+ }
+
+ const video = await prisma.video.findUnique({ where: { id: videoId } });
+ if (!video) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
+ try {
+ await prisma.video.update({
+ where: { id: videoId },
+ data: { transcodingStatus: "failed" },
+ });
+
+ // Log the error message from the worker if provided.
+ let workerError = "";
+ try {
+ const body = await request.json();
+ workerError = typeof body?.error === "string" ? body.error : "";
+ } catch {
+ // Body may be empty – that's fine.
+ }
+
+ console.error(
+ `[Transcoder Fail] ${videoId}${workerError ? ` – ${workerError}` : ""}`
+ );
+
+ return NextResponse.json({ success: true });
+ } catch (err) {
+ console.error(`[Transcoder Fail] DB error for ${videoId}:`, err);
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+}
diff --git a/app/api/transcoder/upload/[videoId]/route.ts b/app/api/transcoder/upload/[videoId]/route.ts
new file mode 100644
index 0000000..52ee620
--- /dev/null
+++ b/app/api/transcoder/upload/[videoId]/route.ts
@@ -0,0 +1,140 @@
+// app/api/transcoder/upload/[videoId]/route.ts
+// Receives a ZIP archive of the completed HLS package from the remote worker,
+// extracts it to disk, validates it, renames the temp dir to its final name,
+// and marks the video as transcoded.
+//
+// The request body must be raw application/zip (no multipart wrapper).
+// The file is streamed to disk before extraction – never fully buffered in RAM.
+
+import { NextResponse } from "next/server";
+import * as fssync from "fs";
+import * as fs from "fs/promises";
+import * as path from "path";
+import { Readable } from "stream";
+import { pipeline } from "stream/promises";
+import * as unzipper from "unzipper";
+import { prisma } from "@/lib/prisma";
+import { verifyTranscoderToken } from "@/lib/transcoder-auth";
+
+// Allow up to 45 minutes for very large uploads.
+export const maxDuration = 2700;
+
+const UPLOADS_DIR = process.env.UPLOADS_DIR ?? "/uploads";
+const HLS_ROOT = path.join(UPLOADS_DIR, "hls");
+
+const SAFE_ID = /^[a-zA-Z0-9_-]{1,64}$/;
+
+export async function POST(
+ request: Request,
+ context: { params: Promise<{ videoId: string }> }
+) {
+ if (!verifyTranscoderToken(request)) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { videoId } = await context.params;
+
+ if (!SAFE_ID.test(videoId)) {
+ return NextResponse.json({ error: "Invalid video ID" }, { status: 400 });
+ }
+
+ const video = await prisma.video.findUnique({ where: { id: videoId } });
+ if (!video) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+ if (video.transcodingStatus !== "processing") {
+ return NextResponse.json(
+ { error: "Video is not in processing state" },
+ { status: 409 }
+ );
+ }
+
+ await fs.mkdir(HLS_ROOT, { recursive: true });
+
+ const tempZipPath = path.join(HLS_ROOT, `${videoId}.incoming.zip`);
+ const tempDir = path.join(HLS_ROOT, `${videoId}.tmp`);
+ const finalDir = path.join(HLS_ROOT, videoId);
+
+ // Security: path traversal guard.
+ for (const p of [tempZipPath, tempDir, finalDir]) {
+ if (!path.resolve(p).startsWith(path.resolve(HLS_ROOT))) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+ }
+
+ try {
+ if (!request.body) {
+ return NextResponse.json({ error: "Empty request body" }, { status: 400 });
+ }
+
+ // 1. Stream upload body to a temp zip file on disk.
+ const nodeReadable = Readable.fromWeb(
+ request.body as ReadableStream
+ );
+ const writeStream = fssync.createWriteStream(tempZipPath);
+ await pipeline(nodeReadable, writeStream);
+
+ // 2. Prepare extraction directory.
+ if (fssync.existsSync(tempDir)) {
+ await fs.rm(tempDir, { recursive: true, force: true });
+ }
+ await fs.mkdir(tempDir, { recursive: true });
+
+ // 3. Stream-extract the zip.
+ await fssync
+ .createReadStream(tempZipPath)
+ .pipe(unzipper.Extract({ path: tempDir }))
+ .promise();
+
+ // 4. Remove temp zip.
+ await fs.unlink(tempZipPath).catch(() => {});
+
+ // 5. Validate contents.
+ const masterPath = path.join(tempDir, "master.m3u8");
+ if (!fssync.existsSync(masterPath)) {
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
+ return NextResponse.json(
+ { error: "master.m3u8 not found in archive" },
+ { status: 422 }
+ );
+ }
+
+ const files = await fs.readdir(tempDir);
+ const hasVariant = files.some(
+ (f) => f.endsWith(".m3u8") && f !== "master.m3u8"
+ );
+ if (!hasVariant) {
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
+ return NextResponse.json(
+ { error: "No variant playlist found in archive" },
+ { status: 422 }
+ );
+ }
+
+ // 6. Remove any stale final directory and atomically rename.
+ if (fssync.existsSync(finalDir)) {
+ await fs.rm(finalDir, { recursive: true, force: true });
+ }
+ await fs.rename(tempDir, finalDir);
+
+ // 7. Mark as transcoded in the database.
+ await prisma.video.update({
+ where: { id: videoId },
+ data: { transcodingStatus: "transcoded" },
+ });
+
+ console.log(`[Transcoder Upload] ${videoId} – success (${files.length} files)`);
+ return NextResponse.json({ success: true });
+ } catch (err) {
+ console.error(`[Transcoder Upload] Error for ${videoId}:`, err);
+
+ // Best-effort cleanup.
+ await fs.unlink(tempZipPath).catch(() => {});
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
+
+ return NextResponse.json(
+ { error: "Upload processing failed" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/user/unlocks/route.ts b/app/api/user/unlocks/route.ts
new file mode 100644
index 0000000..490b5cf
--- /dev/null
+++ b/app/api/user/unlocks/route.ts
@@ -0,0 +1,104 @@
+// app/api/user/unlocks/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const userEmail = session.user.email;
+
+ const user = await prisma.user.findUnique({
+ where: { email: userEmail },
+ select: { id: true },
+ });
+
+ if (!user)
+ return NextResponse.json({ error: 'user not found' }, { status: 404 });
+
+ // Fetch all VideoUnlock records for this user
+ const unlocks = await prisma.videoUnlock.findMany({
+ where: { userId: user.id },
+ select: { id: true, videoId: true, unlockedAt: true, createdAt: true },
+ orderBy: { unlockedAt: 'desc' },
+ });
+
+ return NextResponse.json({ unlocks });
+ } catch (err: any) {
+ console.error('user unlocks GET error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
+
+export async function POST(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const userEmail = session.user.email;
+ const body = await req.json();
+ const { videoId } = body;
+
+ if (!videoId) {
+ return NextResponse.json({ error: 'videoId required' }, { status: 400 });
+ }
+
+ const user = await prisma.user.findUnique({
+ where: { email: userEmail },
+ select: { id: true },
+ });
+
+ if (!user)
+ return NextResponse.json({ error: 'user not found' }, { status: 404 });
+
+ // Verify video exists
+ const video = await prisma.video.findUnique({
+ where: { id: videoId },
+ select: { id: true, locked: true, instantAccess: true },
+ });
+
+ if (!video) {
+ return NextResponse.json({ error: 'video not found' }, { status: 404 });
+ }
+
+ // Check if already unlocked
+ const existingUnlock = await prisma.videoUnlock.findUnique({
+ where: { userId_videoId: { userId: user.id, videoId } },
+ });
+
+ if (existingUnlock) {
+ return NextResponse.json({
+ message: 'Video already unlocked',
+ unlock: existingUnlock
+ });
+ }
+
+ // Create the unlock record
+ const unlock = await prisma.videoUnlock.create({
+ data: {
+ userId: user.id,
+ videoId: videoId,
+ },
+ });
+
+ return NextResponse.json({
+ message: 'Video unlocked successfully',
+ unlock
+ });
+
+ } catch (err: any) {
+ console.error('user unlocks POST error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/users/route.ts b/app/api/users/route.ts
new file mode 100644
index 0000000..485b6f2
--- /dev/null
+++ b/app/api/users/route.ts
@@ -0,0 +1,9 @@
+// app/api/users/route.ts
+import { NextResponse } from "next/server";
+import { prisma } from "../../../lib/prisma";
+
+
+export async function GET() {
+ const users = await prisma.user.findMany({ select: { id: true, name: true, email: true } });
+ return NextResponse.json(users);
+}
diff --git a/app/api/videos/[id]/route.ts b/app/api/videos/[id]/route.ts
new file mode 100644
index 0000000..a807688
--- /dev/null
+++ b/app/api/videos/[id]/route.ts
@@ -0,0 +1,109 @@
+// app/api/videos/[id]/route.ts
+import { NextResponse } from "next/server";
+import { prisma } from "../../../../lib/prisma";
+import { getServerSession } from "next-auth";
+import { authOptions } from "../../../../lib/auth-options";
+import { getVideoUrls } from "../../../../lib/video-urls";
+
+export async function GET(req: Request, context: any) {
+ try {
+ // unwrap params
+ let params = context?.params;
+ if (typeof params?.then === "function") params = await params;
+
+ const id = params?.id;
+ if (!id) return NextResponse.json({ error: "Missing video id" }, { status: 400 });
+
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const user = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ include: {
+ enrollments: true,
+ },
+ });
+ if (!user) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const video = await prisma.video.findUnique({
+ where: { id },
+ include: {
+ playlist: {
+ include: {
+ course: true,
+ courses: true,
+ },
+ },
+ uploader: {
+ select: {
+ id: true,
+ name: true,
+ image: true,
+ },
+ },
+ videoCourses: true,
+ },
+ });
+ if (!video) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ const userCourseIds = user.enrollments.map((enrollment) => enrollment.courseId);
+
+ const playlistCourseIds = new Set([video.playlist.courseId]);
+ (video.playlist.courses || []).forEach((mapping) => {
+ playlistCourseIds.add(mapping.courseId);
+ });
+
+ const hasPlaylistAccess = [...playlistCourseIds].some((courseId) =>
+ userCourseIds.includes(courseId)
+ );
+ if (!hasPlaylistAccess) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const restrictedCourseIds = (video.videoCourses || [])
+ .filter((assignment) => assignment.exclusive)
+ .map((assignment) => assignment.courseId);
+ if (
+ restrictedCourseIds.length > 0 &&
+ !restrictedCourseIds.some((courseId) => userCourseIds.includes(courseId))
+ ) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ // Find the next video (index = current.index + 1)
+ const next = await prisma.video.findFirst({
+ where: {
+ playlistId: video.playlistId,
+ index: video.index + 1,
+ },
+ include: {
+ uploader: {
+ select: {
+ id: true,
+ name: true,
+ image: true,
+ },
+ },
+ },
+ });
+
+ // Add video URLs with HLS support
+ const videoUrls = getVideoUrls(video.id, video.url, video.transcodingStatus as any);
+ const responseVideo = {
+ ...video,
+ videoUrls,
+ transcodingStatus: video.transcodingStatus,
+ restrictedCourseIds,
+ };
+
+ // Ensure we return the URL field explicitly (player expects `video.url`).
+ return NextResponse.json({ video: responseVideo, next });
+ } catch (err: any) {
+ console.error("GET /api/videos/[id] error:", err);
+ return NextResponse.json({ error: "Server error" }, { status: 500 });
+ }
+}
diff --git a/app/api/videos/hls/[...path]/route.ts b/app/api/videos/hls/[...path]/route.ts
new file mode 100644
index 0000000..8fb8b5f
--- /dev/null
+++ b/app/api/videos/hls/[...path]/route.ts
@@ -0,0 +1,109 @@
+// /api/videos/hls/[...path]/route.ts
+// This endpoint serves HLS playlists and segments from the HLS directory
+
+import { NextResponse } from 'next/server';
+import * as fs from 'fs';
+import * as path from 'path';
+
+// Path to HLS directory (should match UPLOADS_DIR/hls)
+const HLS_DIR = path.join(process.env.UPLOADS_DIR || '/uploads', 'hls');
+
+export async function GET(
+ request: Request,
+ context: { params: Promise<{ path?: string[] }> }
+) {
+ try {
+ const params = await context.params;
+ const pathSegments = params?.path || [];
+
+ if (pathSegments.length === 0) {
+ return NextResponse.json(
+ { error: 'Invalid HLS request' },
+ { status: 400 }
+ );
+ }
+
+ // Construct the file path and validate it
+ const requestedPath = path.join(HLS_DIR, ...pathSegments);
+
+ // Security: prevent directory traversal attacks
+ if (!requestedPath.startsWith(HLS_DIR)) {
+ return NextResponse.json(
+ { error: 'Invalid request' },
+ { status: 403 }
+ );
+ }
+
+ // Check if file exists
+ if (!fs.existsSync(requestedPath)) {
+ console.log(`[HLS] File not found: ${requestedPath}`);
+ return NextResponse.json(
+ { error: 'Not found' },
+ { status: 404 }
+ );
+ }
+
+ // Read the file
+ const fileContent = fs.readFileSync(requestedPath);
+
+ // Determine content type based on file extension
+ let contentType = 'application/octet-stream';
+
+ if (requestedPath.endsWith('.m3u8')) {
+ contentType = 'application/vnd.apple.mpegurl';
+ } else if (requestedPath.endsWith('.ts')) {
+ contentType = 'video/mp2t';
+ } else if (requestedPath.endsWith('.mp4')) {
+ contentType = 'video/mp4';
+ }
+
+ // Return the file with appropriate headers
+ return new NextResponse(fileContent, {
+ status: 200,
+ headers: {
+ 'Content-Type': contentType,
+ 'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
+ 'Access-Control-Allow-Origin': '*', // Allow CORS if needed
+ 'Accept-Ranges': 'bytes',
+ },
+ });
+ } catch (error) {
+ console.error('[HLS] Error serving HLS file:', error);
+ return NextResponse.json(
+ { error: 'Internal server error' },
+ { status: 500 }
+ );
+ }
+}
+
+// HEAD request support for playlist validation
+export async function HEAD(
+ request: Request,
+ context: { params: Promise<{ path?: string[] }> }
+) {
+ try {
+ const params = await context.params;
+ const pathSegments = params?.path || [];
+
+ if (pathSegments.length === 0) {
+ return new NextResponse(null, { status: 400 });
+ }
+
+ const requestedPath = path.join(HLS_DIR, ...pathSegments);
+
+ // Security: prevent directory traversal attacks
+ if (!requestedPath.startsWith(HLS_DIR)) {
+ return new NextResponse(null, { status: 403 });
+ }
+
+ // Check if file exists
+ if (!fs.existsSync(requestedPath)) {
+ return new NextResponse(null, { status: 404 });
+ }
+
+ return new NextResponse(null, { status: 200 });
+ } catch (error) {
+ console.error('[HLS] Error in HEAD request:', error);
+ return new NextResponse(null, { status: 500 });
+ }
+}
diff --git a/app/api/videos/latest/route.ts b/app/api/videos/latest/route.ts
new file mode 100644
index 0000000..2404f1a
--- /dev/null
+++ b/app/api/videos/latest/route.ts
@@ -0,0 +1,81 @@
+// app/api/videos/latest/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET() {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const user = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ });
+
+ if (!user) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ // Get all courses the user is enrolled in
+ const enrolledCourses = await prisma.enrollment.findMany({
+ where: { userId: user.id },
+ select: { courseId: true },
+ });
+
+ const enrolledCourseIds = enrolledCourses.map((e) => e.courseId);
+
+ if (enrolledCourseIds.length === 0) {
+ return NextResponse.json({ videos: [] });
+ }
+
+ // Get the latest 10 videos from those courses
+ // Videos can be in playlists that belong to enrolled courses
+ const videos = await prisma.video.findMany({
+ where: {
+ playlist: {
+ OR: [
+ { courseId: { in: enrolledCourseIds } },
+ {
+ courses: {
+ some: { courseId: { in: enrolledCourseIds } },
+ },
+ },
+ ],
+ },
+ },
+ select: {
+ id: true,
+ title: true,
+ durationSec: true,
+ thumbnail: true,
+ createdAt: true,
+ uploader: {
+ select: {
+ id: true,
+ name: true,
+ image: true,
+ },
+ },
+ playlist: {
+ select: {
+ id: true,
+ title: true,
+ },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ take: 10,
+ });
+
+ return NextResponse.json({ videos });
+ } catch (err: any) {
+ console.error('GET /api/videos/latest error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'Server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/watch-history/route.ts b/app/api/watch-history/route.ts
new file mode 100644
index 0000000..56b66c1
--- /dev/null
+++ b/app/api/watch-history/route.ts
@@ -0,0 +1,44 @@
+// app/api/watch-history/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const userEmail = session.user.email;
+
+ const user = await prisma.user.findUnique({ where: { email: userEmail } });
+ if (!user)
+ return NextResponse.json({ error: 'user not found' }, { status: 404 });
+
+ // Fetch all videos watched by user, sorted by most recently updated
+ const watchHistory = await prisma.videoProgress.findMany({
+ where: { userId: user.id },
+ include: {
+ video: {
+ select: {
+ id: true,
+ title: true,
+ thumbnail: true,
+ durationSec: true,
+ url: true,
+ },
+ },
+ },
+ orderBy: { updatedAt: 'desc' },
+ });
+
+ return NextResponse.json(watchHistory);
+ } catch (err: any) {
+ console.error('watch history GET error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/dashboard/dashboard-client.tsx b/app/dashboard/dashboard-client.tsx
new file mode 100644
index 0000000..1965b75
--- /dev/null
+++ b/app/dashboard/dashboard-client.tsx
@@ -0,0 +1,484 @@
+'use client';
+import * as React from 'react';
+import { useRouter } from 'next/navigation';
+import { useSession } from 'next-auth/react';
+import { usePlaylists } from '@/hooks/usePlaylists';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselNext,
+ CarouselPrevious,
+} from '@/components/ui/carousel';
+import { Badge } from '@/components/ui/badge';
+import { SegmentedProgressBar, WatchSegment } from '@/components/segmented-progress-bar';
+
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+
+import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
+import { Lock } from 'lucide-react';
+import Image from 'next/image';
+
+type VideoItem = {
+ id: string;
+ title: string;
+ durationSec?: number;
+ thumbnail?: string;
+ locked?: boolean;
+ instantAccess?: boolean;
+ uploader?: {
+ id: string;
+ name?: string;
+ image?: string;
+ };
+};
+
+type VideoProgressData = {
+ percent: number;
+ watchedSec: number;
+ segments: WatchSegment[];
+};
+
+export default function DashboardClient() {
+ const router = useRouter();
+ const { data: session } = useSession();
+ const { subjects, isLoading } = usePlaylists();
+ const [videoProgress, setVideoProgress] = React.useState>({});
+ const [userUnlocks, setUserUnlocks] = React.useState>(new Set());
+ const [latestVideos, setLatestVideos] = React.useState([]);
+ const [latestVideosLoading, setLatestVideosLoading] = React.useState(true);
+ const playlistsToShow = React.useMemo(() => {
+ const playlistMap = new Map<
+ string,
+ {
+ playlist: any;
+ courses: Map;
+ }
+ >();
+
+ subjects.forEach((subject: any) => {
+ (subject.playlists || []).forEach((playlist: any) => {
+ const entry = playlistMap.get(playlist.id);
+ const courseInfo = {
+ id: subject.id,
+ title: subject.title,
+ code: subject.code,
+ };
+
+ if (entry) {
+ entry.courses.set(courseInfo.id, courseInfo);
+ } else {
+ const coursesMap = new Map();
+ coursesMap.set(courseInfo.id, courseInfo);
+ playlistMap.set(playlist.id, { playlist, courses: coursesMap });
+ }
+ });
+ });
+
+ return Array.from(playlistMap.values()).map(({ playlist, courses }) => ({
+ ...playlist,
+ coursesForDisplay: Array.from(courses.values()),
+ }));
+ }, [subjects]);
+
+ // Fetch user's unlocks
+ React.useEffect(() => {
+ const fetchUserUnlocks = async () => {
+ try {
+ const res = await fetch('/api/user/unlocks');
+ if (res.ok) {
+ const data = await res.json();
+ const unlockedVideoIds = new Set((data.unlocks?.map((u: any) => u.videoId) ?? []) as string[]);
+ setUserUnlocks(unlockedVideoIds);
+ }
+ } catch (err) {
+ console.error('Failed to fetch user unlocks:', err);
+ }
+ };
+
+ fetchUserUnlocks();
+ }, []);
+
+ // Fetch latest videos from enrolled courses
+ React.useEffect(() => {
+ const fetchLatestVideos = async () => {
+ try {
+ setLatestVideosLoading(true);
+ const res = await fetch('/api/videos/latest');
+ if (res.ok) {
+ const data = await res.json();
+ setLatestVideos(data.videos ?? []);
+ }
+ } catch (err) {
+ console.error('Failed to fetch latest videos:', err);
+ } finally {
+ setLatestVideosLoading(false);
+ }
+ };
+
+ fetchLatestVideos();
+ }, []);
+
+ // Auto-sync enrollments for allowed students on first login
+ React.useEffect(() => {
+ const syncEnrollments = async () => {
+ if (!session?.user) return;
+
+ const userLevels = (session.user as any)?.levels;
+ console.log('[DASHBOARD] User levels from session:', userLevels);
+
+ if (!userLevels) {
+ console.log('[DASHBOARD] No levels found in session');
+ return;
+ }
+
+ const levels = userLevels
+ .split(',')
+ .map((level: string) => level.trim())
+ .filter((level: string) => level.length > 0);
+
+ console.log('[DASHBOARD] Parsed levels:', levels);
+
+ if (levels.length === 0) {
+ console.log('[DASHBOARD] No valid levels after parsing');
+ return;
+ }
+
+ try {
+ console.log('[DASHBOARD] Calling sync-enrollments with:', { levels });
+ const res = await fetch('/api/enrollments/sync', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ levels }),
+ });
+
+ if (!res.ok) {
+ console.error('[DASHBOARD] Failed to sync enrollments, status:', res.status);
+ } else {
+ const result = await res.json();
+ console.log('[DASHBOARD] Sync enrollments result:', result);
+ }
+ } catch (err) {
+ console.error('[DASHBOARD] Error syncing enrollments:', err);
+ }
+ };
+
+ syncEnrollments();
+ }, [session]);
+
+ React.useEffect(() => {
+ const fetchProgress = async () => {
+ const progressMap: Record = {};
+
+ // Fetch progress for playlist videos
+ for (const playlist of playlistsToShow) {
+ for (const video of playlist.videos || []) {
+ try {
+ const [progressRes, segmentsRes] = await Promise.all([
+ fetch(`/api/progress?videoId=${video.id}`),
+ fetch(`/api/progress/segments?videoId=${video.id}`),
+ ]);
+
+ if (progressRes.ok) {
+ const progressData = await progressRes.json();
+ const segmentsData = segmentsRes.ok ? await segmentsRes.json() : { segments: [] };
+ progressMap[video.id] = {
+ percent: progressData.percent ?? 0,
+ watchedSec: progressData.watchedSec ?? 0,
+ segments: segmentsData.segments ?? [],
+ };
+ }
+ } catch (err) {
+ console.error(`Failed to fetch progress for video ${video.id}`, err);
+ progressMap[video.id] = { percent: 0, watchedSec: 0, segments: [] };
+ }
+ }
+ }
+
+ // Fetch progress for latest videos
+ for (const video of latestVideos) {
+ try {
+ const [progressRes, segmentsRes] = await Promise.all([
+ fetch(`/api/progress?videoId=${video.id}`),
+ fetch(`/api/progress/segments?videoId=${video.id}`),
+ ]);
+
+ if (progressRes.ok) {
+ const progressData = await progressRes.json();
+ const segmentsData = segmentsRes.ok ? await segmentsRes.json() : { segments: [] };
+ progressMap[video.id] = {
+ percent: progressData.percent ?? 0,
+ watchedSec: progressData.watchedSec ?? 0,
+ segments: segmentsData.segments ?? [],
+ };
+ }
+ } catch (err) {
+ console.error(`Failed to fetch progress for video ${video.id}`, err);
+ progressMap[video.id] = { percent: 0, watchedSec: 0, segments: [] };
+ }
+ }
+
+ setVideoProgress(progressMap);
+ };
+
+ if (playlistsToShow.length > 0 || latestVideos.length > 0) {
+ fetchProgress();
+ }
+ }, [playlistsToShow, latestVideos]);
+
+ const handleOpenVideo = (playlistId: string, videoId: string) => {
+ // navigate to videoplayer page using query params (keeps your current structure)
+ router.push(`/videoplayer?playlistId=${playlistId}&videoId=${videoId}`);
+ };
+
+ const formatDuration = (s?: number) => {
+ if (!s && s !== 0) return '';
+ const mins = Math.floor(s! / 60);
+ const secs = Math.floor(s! % 60)
+ .toString()
+ .padStart(2, '0');
+ return `${mins}:${secs}`;
+ };
+
+ return (
+
+
+
+
+
+
+
+ {isLoading && latestVideosLoading ? (
+
Loading…
+ ) : (
+
+ {/* Latest Videos Section */}
+ {!latestVideosLoading && latestVideos.length > 0 && (
+
+
+
Latest Videos
+
+ Recently uploaded from your enrolled courses
+
+
+
+
+
+ {latestVideos.map((v: any) => (
+
+ {
+ if (v.playlist?.id) {
+ handleOpenVideo(v.playlist.id, v.id);
+ }
+ }}
+ >
+
+
+
+
+
+ {v.uploader?.image && (
+
+
+
+ )}
+
+
+
+ {v.title}
+
+
+ {formatDuration(v.durationSec)}
+
+
+ {v.uploader?.name && (
+
+ {v.uploader.name}
+
+ )}
+ {v.playlist?.title && (
+
+ {v.playlist.title}
+
+ )}
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+ )}
+
+ {/* Playlists Section */}
+ {playlistsToShow.length === 0 && latestVideos.length === 0 ? (
+
No content available.
+ ) : (
+ <>
+ {playlistsToShow.length > 0 && (
+ <>
+
+
Playlists
+
+ Showing {playlistsToShow.length} playlist{playlistsToShow.length === 1 ? '' : 's'}
+
+
+ {playlistsToShow.map((pl: any) => (
+
+
+
+
{pl.title}
+
+ {pl.description}
+
+ {pl.coursesForDisplay?.length ? (
+
+ {pl.coursesForDisplay.map((course: any) => (
+
+ {course.code ?? course.title}
+
+ ))}
+
+ ) : null}
+
+
+
+
+
+ {pl.videos?.map((v: VideoItem) => (
+
+ {
+ if ((v as any).locked) return;
+ if ((v as any).instantAccess) {
+ handleOpenVideo(pl.id, v.id);
+ return;
+ }
+ if ((v as any).index === 0) {
+ handleOpenVideo(pl.id, v.id);
+ return;
+ }
+ if (userUnlocks.has(v.id)) {
+ handleOpenVideo(pl.id, v.id);
+ }
+ }}
+ >
+
+
+
+ {(v as any).locked || (!(v as any).instantAccess && (v as any).index !== 0 && !userUnlocks.has(v.id)) ? (
+
+
+
+ ) : null}
+
+
+ {(v as any).uploader?.image && (
+
+
+
+ )}
+
+
+
+ {v.title}
+
+
+ {formatDuration(v.durationSec)}
+
+
+ {(v as any).uploader?.name && (
+
+ {(v as any).uploader.name}
+
+ )}
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+ ))}
+ >
+ )}
+ >
+ )}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/app/dashboard/liked-videos-client.tsx b/app/dashboard/liked-videos-client.tsx
new file mode 100644
index 0000000..4c82b5f
--- /dev/null
+++ b/app/dashboard/liked-videos-client.tsx
@@ -0,0 +1,225 @@
+'use client';
+
+import * as React from 'react';
+import { useRouter } from 'next/navigation';
+import Image from 'next/image';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
+import { Button } from '@/components/ui/button';
+
+interface LikedVideoItem {
+ id: string;
+ videoId: string;
+ createdAt: string;
+ video: {
+ id: string;
+ title: string;
+ thumbnail?: string;
+ durationSec?: number;
+ url: string;
+ playlist: {
+ title: string;
+ course: {
+ id: string;
+ title: string;
+ };
+ };
+ };
+}
+
+export default function LikedVideosClient() {
+ const router = useRouter();
+ const [likes, setLikes] = React.useState([]);
+ const [isLoading, setIsLoading] = React.useState(true);
+
+ React.useEffect(() => {
+ const fetchLikes = async () => {
+ try {
+ const res = await fetch('/api/likes/all');
+ if (res.ok) {
+ const data = await res.json();
+ setLikes(data);
+ }
+ } catch (err) {
+ console.error('Failed to fetch liked videos', err);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchLikes();
+ }, []);
+
+ const handleVideoClick = (videoId: string) => {
+ router.push(`/videoplayer?videoId=${videoId}`);
+ };
+
+ const formatTime = (seconds?: number) => {
+ if (!seconds) return '0:00';
+ const mins = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
+ };
+
+ const formatDate = (dateString: string) => {
+ const date = new Date(dateString);
+ return date.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+ Liked Videos
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ Liked Videos
+
+
+ {likes.length === 0 ? (
+
+ ) : (
+
+
+
+
+ Thumbnail
+ Title
+ Playlist
+ Course
+ Duration
+ Liked On
+ Action
+
+
+
+ {likes.map((item) => (
+
+
+ handleVideoClick(item.video.id)}
+ >
+ {item.video.thumbnail ? (
+
+
+
+ ) : (
+
+
+ No image
+
+
+ )}
+
+
+
+ handleVideoClick(item.video.id)}
+ >
+ {item.video.title}
+
+
+
+
+ {item.video.playlist.title}
+
+
+
+
+ {item.video.playlist.course.title}
+
+
+
+
+ {formatTime(item.video.durationSec)}
+
+
+
+
+ {formatDate(item.createdAt)}
+
+
+
+ handleVideoClick(item.video.id)}
+ >
+ Watch
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/app/dashboard/liked-videos/page.tsx b/app/dashboard/liked-videos/page.tsx
new file mode 100644
index 0000000..6e8f8b3
--- /dev/null
+++ b/app/dashboard/liked-videos/page.tsx
@@ -0,0 +1,20 @@
+// app/dashboard/liked-videos/page.tsx
+import { Metadata } from 'next';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { redirect } from 'next/navigation';
+import LikedVideosClient from '../liked-videos-client';
+
+export const metadata: Metadata = {
+ title: 'Liked Videos | CMS',
+ description: 'View your liked videos',
+};
+
+export default async function LikedVideosPage() {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ return ;
+}
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
new file mode 100644
index 0000000..c3e9e77
--- /dev/null
+++ b/app/dashboard/page.tsx
@@ -0,0 +1,11 @@
+// app/dashboard/page.tsx (server component)
+import { requireUser } from '@/lib/auth-check';
+import DashboardClient from './dashboard-client'; // will be your existing client UI
+
+export default async function DashboardPage() {
+ // will redirect to /login if not authenticated
+ const session = await requireUser('/login');
+
+ // you can optionally pass session to client via props if desired:
+ return ;
+}
diff --git a/app/dashboard/watch-history-client.tsx b/app/dashboard/watch-history-client.tsx
new file mode 100644
index 0000000..57c8463
--- /dev/null
+++ b/app/dashboard/watch-history-client.tsx
@@ -0,0 +1,210 @@
+'use client';
+
+import * as React from 'react';
+import { useRouter } from 'next/navigation';
+import Image from 'next/image';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
+import { Button } from '@/components/ui/button';
+
+interface WatchHistoryItem {
+ id: string;
+ videoId: string;
+ lastPos?: number;
+ updatedAt: string;
+ video: {
+ id: string;
+ title: string;
+ thumbnail?: string;
+ durationSec?: number;
+ url: string;
+ };
+}
+
+export default function WatchHistoryClient() {
+ const router = useRouter();
+ const [history, setHistory] = React.useState([]);
+ const [isLoading, setIsLoading] = React.useState(true);
+
+ React.useEffect(() => {
+ const fetchHistory = async () => {
+ try {
+ const res = await fetch('/api/watch-history');
+ if (res.ok) {
+ const data = await res.json();
+ setHistory(data);
+ }
+ } catch (err) {
+ console.error('Failed to fetch watch history', err);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchHistory();
+ }, []);
+
+ const handleVideoClick = (videoId: string) => {
+ router.push(`/videoplayer?videoId=${videoId}`);
+ };
+
+ const formatTime = (seconds?: number) => {
+ if (!seconds) return '0:00';
+ const mins = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
+ };
+
+ const formatDate = (dateString: string) => {
+ const date = new Date(dateString);
+ return date.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+ Watch History
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ Watch History
+
+
+ {history.length === 0 ? (
+
+
No videos watched yet
+
+ ) : (
+
+
+
+
+ Thumbnail
+ Title
+ Last Position
+ Last Watched
+ Action
+
+
+
+ {history.map((item) => (
+
+
+ handleVideoClick(item.video.id)}
+ >
+ {item.video.thumbnail ? (
+
+
+
+ ) : (
+
+
+ No image
+
+
+ )}
+
+
+
+ handleVideoClick(item.video.id)}
+ >
+ {item.video.title}
+
+
+
+
+ {formatTime(item.lastPos)} /{' '}
+ {formatTime(item.video.durationSec)}
+
+
+
+
+ {formatDate(item.updatedAt)}
+
+
+
+ handleVideoClick(item.video.id)}
+ >
+ Resume
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/app/dashboard/watch-history/page.tsx b/app/dashboard/watch-history/page.tsx
new file mode 100644
index 0000000..eb1b47a
--- /dev/null
+++ b/app/dashboard/watch-history/page.tsx
@@ -0,0 +1,20 @@
+// app/dashboard/watch-history/page.tsx
+import { Metadata } from 'next';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { redirect } from 'next/navigation';
+import WatchHistoryClient from '../watch-history-client';
+
+export const metadata: Metadata = {
+ title: 'Watch History | CMS',
+ description: 'View your video watch history',
+};
+
+export default async function WatchHistoryPage() {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ return ;
+}
diff --git a/app/favicon.ico b/app/favicon.ico
new file mode 100644
index 0000000..4977ba9
Binary files /dev/null and b/app/favicon.ico differ
diff --git a/app/globals.css b/app/globals.css
new file mode 100644
index 0000000..ab298ef
--- /dev/null
+++ b/app/globals.css
@@ -0,0 +1,122 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --font-sans: var(--font-geist-sans);
+ --font-mono: var(--font-geist-mono);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-chart-5: var(--chart-5);
+ --color-chart-4: var(--chart-4);
+ --color-chart-3: var(--chart-3);
+ --color-chart-2: var(--chart-2);
+ --color-chart-1: var(--chart-1);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-destructive: var(--destructive);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+}
+
+:root {
+ --radius: 0.65rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.141 0.005 285.823);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.141 0.005 285.823);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.141 0.005 285.823);
+ --primary: oklch(0.646 0.222 41.116);
+ --primary-foreground: oklch(0.98 0.016 73.684);
+ --secondary: oklch(0.967 0.001 286.375);
+ --secondary-foreground: oklch(0.21 0.006 285.885);
+ --muted: oklch(0.967 0.001 286.375);
+ --muted-foreground: oklch(0.552 0.016 285.938);
+ --accent: oklch(0.967 0.001 286.375);
+ --accent-foreground: oklch(0.21 0.006 285.885);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.92 0.004 286.32);
+ --input: oklch(0.92 0.004 286.32);
+ --ring: oklch(0.75 0.183 55.934);
+ --chart-1: oklch(0.837 0.128 66.29);
+ --chart-2: oklch(0.705 0.213 47.604);
+ --chart-3: oklch(0.646 0.222 41.116);
+ --chart-4: oklch(0.553 0.195 38.402);
+ --chart-5: oklch(0.47 0.157 37.304);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.141 0.005 285.823);
+ --sidebar-primary: oklch(0.646 0.222 41.116);
+ --sidebar-primary-foreground: oklch(0.98 0.016 73.684);
+ --sidebar-accent: oklch(0.967 0.001 286.375);
+ --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
+ --sidebar-border: oklch(0.92 0.004 286.32);
+ --sidebar-ring: oklch(0.75 0.183 55.934);
+}
+
+.dark {
+ --background: oklch(0.141 0.005 285.823);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.21 0.006 285.885);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.21 0.006 285.885);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.705 0.213 47.604);
+ --primary-foreground: oklch(0.98 0.016 73.684);
+ --secondary: oklch(0.274 0.006 286.033);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.274 0.006 286.033);
+ --muted-foreground: oklch(0.705 0.015 286.067);
+ --accent: oklch(0.274 0.006 286.033);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.408 0.123 38.172);
+ --chart-1: oklch(0.837 0.128 66.29);
+ --chart-2: oklch(0.705 0.213 47.604);
+ --chart-3: oklch(0.646 0.222 41.116);
+ --chart-4: oklch(0.553 0.195 38.402);
+ --chart-5: oklch(0.47 0.157 37.304);
+ --sidebar: oklch(0.21 0.006 285.885);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.705 0.213 47.604);
+ --sidebar-primary-foreground: oklch(0.98 0.016 73.684);
+ --sidebar-accent: oklch(0.274 0.006 286.033);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.408 0.123 38.172);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/app/layout.tsx b/app/layout.tsx
new file mode 100644
index 0000000..c6c91e4
--- /dev/null
+++ b/app/layout.tsx
@@ -0,0 +1,27 @@
+// app/layout.tsx (server)
+import './globals.css';
+import { Providers } from './providers';
+import type { ReactNode } from 'react';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { Toaster } from "@/components/ui/sonner"
+
+export const metadata = {
+ title: 'OW ANIMATION ARTS VAULT',
+ description: '...',
+};
+
+export default async function RootLayout({ children }: { children: ReactNode }) {
+ // get server session and pass into client SessionProvider for identical initial markup
+ const session = await getServerSession(authOptions);
+
+ return (
+
+
+ {/* pass session to Providers to avoid client/server mismatch */}
+ {children}
+
+
+
+ );
+}
diff --git a/app/login/page.tsx b/app/login/page.tsx
new file mode 100644
index 0000000..2bc0649
--- /dev/null
+++ b/app/login/page.tsx
@@ -0,0 +1,26 @@
+import { GalleryVerticalEnd } from "lucide-react"
+
+import { LoginForm } from "@/components/login-form"
+
+export default function LoginPage() {
+ return (
+
+ )
+}
diff --git a/app/login/unauthorized/page.tsx b/app/login/unauthorized/page.tsx
new file mode 100644
index 0000000..c4abdd0
--- /dev/null
+++ b/app/login/unauthorized/page.tsx
@@ -0,0 +1,53 @@
+// app/login/unauthorized/page.tsx
+'use client';
+import { AlertCircle } from 'lucide-react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import Link from 'next/link';
+import { signOut } from 'next-auth/react';
+import { useEffect } from 'react';
+
+export default function UnauthorizedPage() {
+ // Clear any partial session data when landing on this page
+ useEffect(() => {
+ signOut({ redirect: false });
+ }, []);
+ return (
+
+
+
+
+
+ Not Registered
+
+ Your email is not registered to access this platform.
+
+
+
+
+
What does this mean?
+
+ This is a university-only platform. If you believe you should have access, please contact your administrator or department coordinator.
+
+
+
+
+
+ Try Another Email
+
+
+
+
+ Go Home
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
new file mode 100644
index 0000000..2bc0649
--- /dev/null
+++ b/app/page.tsx
@@ -0,0 +1,26 @@
+import { GalleryVerticalEnd } from "lucide-react"
+
+import { LoginForm } from "@/components/login-form"
+
+export default function LoginPage() {
+ return (
+
+ )
+}
diff --git a/app/providers.tsx b/app/providers.tsx
new file mode 100644
index 0000000..0908ca5
--- /dev/null
+++ b/app/providers.tsx
@@ -0,0 +1,22 @@
+// app/providers.tsx (client)
+'use client';
+
+import { SessionProvider } from 'next-auth/react';
+import { ThemeProvider } from 'next-themes';
+import type { PropsWithChildren } from 'react';
+
+type Props = PropsWithChildren<{ session?: any }>;
+
+export function Providers({ children, session }: Props) {
+ return (
+
+
+ defaultTheme="dark" // default to dark to match server if you want dark by default
+ enableSystem={false}
+ >
+ {children}
+
+
+ );
+}
diff --git a/app/videoplayer/page.tsx b/app/videoplayer/page.tsx
new file mode 100644
index 0000000..aa9e700
--- /dev/null
+++ b/app/videoplayer/page.tsx
@@ -0,0 +1,785 @@
+'use client';
+
+import React from 'react';
+import { useSearchParams, useRouter } from 'next/navigation';
+import { usePlaylist } from '@/hooks/usePlaylist';
+import { useVideo } from '@/hooks/useVideo';
+
+import {
+ Card,
+ CardContent,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { SegmentedProgressBar, WatchSegment } from '@/components/segmented-progress-bar';
+
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import { CommentsSection } from '@/components/comments-section';
+import { formatDistanceToNow } from 'date-fns';
+
+import { Lock, Heart, Edit } from 'lucide-react';
+
+import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
+import { Button } from '@/components/ui/button';
+import { HlsPlayer } from '@/components/hls';
+import { useSession } from 'next-auth/react';
+import Image from 'next/image';
+
+type VideoFromApi = {
+ id: string;
+ title: string;
+ description?: string | null;
+ durationSec?: number | null;
+ duration?: string | null;
+ thumbnail?: string | null;
+ url?: string | null;
+ locked?: boolean;
+ createdAt?: string | null;
+ transcodingStatus?: string;
+ videoUrls?: {
+ hlsUrl: string | null;
+ mp4Url: string;
+ };
+ uploader?: {
+ id: string;
+ name?: string;
+ image?: string;
+ } | null;
+};
+
+export function useWatchTracker(
+ videoRef: React.RefObject,
+ opts: { sendIntervalMs?: number; commitIntervalSec?: number; debug?: boolean } = {}
+) {
+ const sendIntervalMs = opts.sendIntervalMs ?? 5000;
+ const commitIntervalSec = opts.commitIntervalSec ?? 3;
+ const debug = !!opts.debug;
+
+ const rangesRef = React.useRef>([]);
+ const lastTimeRef = React.useRef(null);
+ const currentRangeStartRef = React.useRef(null);
+ const sendTimerRef = React.useRef(null);
+ const commitTimerRef = React.useRef(null);
+
+ // merging helper
+ function mergeRanges(ranges: Array<[number, number]>) {
+ if (!ranges.length) return [];
+ ranges.sort((a, b) => a[0] - b[0]);
+ const merged: Array<[number, number]> = [];
+ for (const [s, e] of ranges) {
+ if (!merged.length) merged.push([s, e]);
+ else {
+ const last = merged[merged.length - 1];
+ if (s <= last[1] + 0.5) last[1] = Math.max(last[1], e);
+ else merged.push([s, e]);
+ }
+ }
+ return merged;
+ }
+
+ function addRange(s: number, e: number) {
+ if (e <= s) return;
+ if (debug) console.debug("[tracker] addRange", s, e);
+ rangesRef.current.push([s, e]);
+ rangesRef.current = mergeRanges(rangesRef.current);
+ }
+
+ function getUniqueWatchedSec() {
+ let sum = 0;
+ for (const [a, b] of rangesRef.current) sum += Math.max(0, b - a);
+ return Math.round(sum);
+ }
+
+ // commit the "current" playing segment to ranges (useful while playing)
+ function commitCurrentRange() {
+ const el = videoRef.current;
+ if (!el) return;
+ const start = currentRangeStartRef.current;
+ const now = el.currentTime;
+ if (start === null) return;
+ // only commit if we've moved forward at least 0.5s to avoid noise
+ if (now - start >= 0.5) {
+ addRange(start, now);
+ // keep currentRangeStart open at 'now' so we continue accumulating
+ currentRangeStartRef.current = now;
+ }
+ }
+
+ // send progress to server (same signature you had)
+ async function sendProgress(args: { videoId: string; playlistId?: string; duration: number; extra?: Record }) {
+ const { videoId, playlistId, duration, extra } = args;
+ const watchedSec = getUniqueWatchedSec();
+ const el = videoRef.current;
+ const lastPos = Math.floor(el?.currentTime ?? 0);
+
+ if (!duration || duration <= 0) return null;
+ if (watchedSec === 0 && (el?.paused ?? true)) return null;
+
+ const payload = { videoId, playlistId, watchedSec, lastPos, duration, ...extra };
+ try {
+ if (debug) console.debug("[tracker] sendProgress", payload);
+ const res = await fetch("/api/progress", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+
+ // If video not found, stop trying to track progress
+ if (!res.ok) {
+ if (res.status === 404) {
+ console.warn(`[tracker] Video ${videoId} not found, stopping progress tracking`);
+ stopAutoSend(); // Stop automatic progress tracking for deleted video
+ return null;
+ }
+ return null;
+ }
+
+ return res.ok ? await res.json().catch(() => null) : null;
+ } catch (err) {
+ if (debug) console.warn("[tracker] sendProgress error", err);
+ return null;
+ }
+ }
+
+ // start/stop auto-send
+ function startAutoSend(args: { videoId: string; playlistId?: string; duration: number }) {
+ stopAutoSend(); // clear existing
+
+ // immediate send (best-effort)
+ sendProgress(args).catch(() => {});
+
+ // periodic send
+ sendTimerRef.current = window.setInterval(() => {
+ const watched = getUniqueWatchedSec();
+ const el = videoRef.current;
+ const isPlaying = !!(el && !el.paused && !el.ended);
+ if (watched > 0 || isPlaying) {
+ sendProgress(args).catch(() => {});
+ }
+ }, sendIntervalMs) as unknown as number;
+
+ // commit currently playing ranges periodically (so continuous play is captured)
+ commitTimerRef.current = window.setInterval(() => {
+ commitCurrentRange();
+ }, commitIntervalSec * 1000) as unknown as number;
+
+ if (debug) console.debug("[tracker] startAutoSend", { sendIntervalMs, commitIntervalSec });
+ }
+
+ function stopAutoSend(opts?: { sendFinal?: boolean; videoId?: string; playlistId?: string; duration?: number }) {
+ if (sendTimerRef.current) {
+ window.clearInterval(sendTimerRef.current);
+ sendTimerRef.current = null;
+ }
+ if (commitTimerRef.current) {
+ window.clearInterval(commitTimerRef.current);
+ commitTimerRef.current = null;
+ }
+ if (opts?.sendFinal && opts.videoId && opts.duration) {
+ sendProgress({ videoId: opts.videoId, playlistId: opts.playlistId, duration: opts.duration }).catch(() => {});
+ }
+ if (debug) console.debug("[tracker] stopAutoSend");
+ }
+
+ // attach listeners to populate rangesRef
+ React.useEffect(() => {
+ const el = videoRef.current;
+ if (!el) {
+ if (debug) console.debug("[tracker] no video element to attach");
+ return;
+ }
+
+ currentRangeStartRef.current = null;
+ lastTimeRef.current = el.currentTime ?? 0;
+
+ const onPlay = () => {
+ currentRangeStartRef.current = el.currentTime;
+ lastTimeRef.current = el.currentTime;
+ if (debug) console.debug("[tracker] play", currentRangeStartRef.current);
+ };
+
+ const onPause = () => {
+ if (currentRangeStartRef.current !== null) {
+ addRange(currentRangeStartRef.current, el.currentTime);
+ currentRangeStartRef.current = null;
+ }
+ if (debug) console.debug("[tracker] pause, ranges:", rangesRef.current);
+ };
+
+ const onTimeUpdate = () => {
+ const now = el.currentTime;
+ const last = lastTimeRef.current ?? now;
+ // detect seek (big jump)
+ if (Math.abs(now - last) > 2.5) {
+ if (currentRangeStartRef.current !== null) {
+ addRange(currentRangeStartRef.current, last);
+ }
+ currentRangeStartRef.current = now;
+ if (debug) console.debug("[tracker] seek detected, start:", now);
+ }
+ lastTimeRef.current = now;
+ // we do not addRange here to avoid flooding; commit timer handles mid-play commits
+ };
+
+ const onEnded = () => {
+ if (currentRangeStartRef.current !== null) {
+ addRange(currentRangeStartRef.current, el.duration ?? lastTimeRef.current ?? 0);
+ currentRangeStartRef.current = null;
+ }
+ if (debug) console.debug("[tracker] ended, ranges:", rangesRef.current);
+ };
+
+ el.addEventListener("play", onPlay);
+ el.addEventListener("pause", onPause);
+ el.addEventListener("timeupdate", onTimeUpdate);
+ el.addEventListener("ended", onEnded);
+
+ return () => {
+ el.removeEventListener("play", onPlay);
+ el.removeEventListener("pause", onPause);
+ el.removeEventListener("timeupdate", onTimeUpdate);
+ el.removeEventListener("ended", onEnded);
+ stopAutoSend();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [videoRef.current]);
+
+ return {
+ getUniqueWatchedSec,
+ addRange,
+ sendProgress,
+ startAutoSend,
+ stopAutoSend,
+ };
+}
+
+
+export default function Page() {
+ const search = useSearchParams();
+ const router = useRouter();
+ const playlistId = search.get('playlistId') ?? undefined;
+ const videoId = search.get('videoId') ?? undefined;
+
+ const { playlist, isLoading: playlistLoading } = usePlaylist(playlistId);
+ const { video, next, isLoading: videoLoading } = useVideo(videoId);
+
+ // Segments state for current video and playlist videos
+ const [currentSegments, setCurrentSegments] = React.useState([]);
+ const [playlistSegments, setPlaylistSegments] = React.useState>({});
+ const [currentProgress, setCurrentProgress] = React.useState(0);
+
+ // Per-user unlock state
+ const [userUnlocks, setUserUnlocks] = React.useState>(new Set()); // Set of unlocked videoIds
+ const [videoInstantAccess, setVideoInstantAccess] = React.useState>({}); // videoId -> instantAccess
+ const [unlockingVideo, setUnlockingVideo] = React.useState(null); // videoId being unlocked
+
+ // inside your videoplayer component
+const videoRef = React.useRef(null);
+const tracker = useWatchTracker(videoRef, { sendIntervalMs: 5000 });
+
+// Fetch segments for current video
+React.useEffect(() => {
+ if (!videoId) return;
+
+ const fetchSegments = async () => {
+ try {
+ const res = await fetch(`/api/progress/segments?videoId=${videoId}`);
+ if (res.ok) {
+ const data = await res.json();
+ setCurrentSegments(data.segments ?? []);
+ }
+ const progressRes = await fetch(`/api/progress?videoId=${videoId}`);
+ if (progressRes.ok) {
+ const data = await progressRes.json();
+ setCurrentProgress(data.percent ?? 0);
+ }
+ } catch (err) {
+ console.error('Failed to fetch segments:', err);
+ }
+ };
+
+ fetchSegments();
+ // Refresh segments every 5 seconds
+ const interval = setInterval(fetchSegments, 5000);
+ return () => clearInterval(interval);
+}, [videoId]);
+
+// Fetch segments for all playlist videos
+React.useEffect(() => {
+ if (!playlist?.videos || playlist.videos.length === 0) return;
+
+ const fetchPlaylistSegments = async () => {
+ const segments: Record = {};
+ const instantAccess: Record = {};
+
+ for (const video of playlist.videos) {
+ // Fetch segments
+ try {
+ const res = await fetch(`/api/progress/segments?videoId=${video.id}`);
+ if (res.ok) {
+ const data = await res.json();
+ segments[video.id] = data.segments ?? [];
+ }
+ } catch (err) {
+ console.error(`Failed to fetch segments for video ${video.id}:`, err);
+ }
+
+ // Track instantAccess status from video object
+ instantAccess[video.id] = (video as any).instantAccess ?? false;
+ }
+
+ setPlaylistSegments(segments);
+ setVideoInstantAccess(instantAccess);
+ };
+
+ fetchPlaylistSegments();
+}, [playlist?.videos]);
+
+// Fetch user's VideoUnlock records
+React.useEffect(() => {
+ const fetchUserUnlocks = async () => {
+ try {
+ const res = await fetch('/api/user/unlocks');
+ if (res.ok) {
+ const data = await res.json();
+ const unlockedVideoIds = new Set((data.unlocks?.map((u: any) => u.videoId) ?? []) as string[]);
+ setUserUnlocks(unlockedVideoIds);
+ }
+ } catch (err) {
+ console.error('Failed to fetch user unlocks:', err);
+ }
+ };
+
+ fetchUserUnlocks();
+}, []);
+
+React.useEffect(() => {
+ if (!video) return;
+
+ let didCancel = false;
+ let poll: number | null = null;
+
+ const startWhenReady = () => {
+ if (didCancel) return;
+ const el = videoRef.current;
+ const duration = (video.durationSec ?? Math.floor(el?.duration ?? 0)) || 0;
+
+ if (el) {
+ tracker.startAutoSend({
+ videoId: String(video.id),
+ playlistId: playlist?.id,
+ duration,
+ });
+ } else {
+ // poll until the video element mounts (should be quick)
+ poll = window.setInterval(() => {
+ if (videoRef.current) {
+ if (poll) {
+ window.clearInterval(poll);
+ poll = null;
+ }
+ tracker.startAutoSend({
+ videoId: String(video.id),
+ playlistId: playlist?.id,
+ duration: (video.durationSec ?? Math.floor(videoRef.current?.duration ?? 0)) || 0,
+ });
+ }
+ }, 150) as unknown as number;
+ }
+ };
+
+ startWhenReady();
+
+ return () => {
+ didCancel = true;
+ if (poll) {
+ window.clearInterval(poll);
+ poll = null;
+ }
+ const duration = video?.durationSec ?? Math.floor(videoRef.current?.duration ?? 0);
+ // send final snapshot
+ tracker.stopAutoSend({ sendFinal: true, videoId: String(video?.id ?? ''), playlistId: playlist?.id, duration });
+ };
+// eslint-disable-next-line react-hooks/exhaustive-deps
+}, [video?.id, playlist?.id]);
+
+
+ const current: VideoFromApi = (video as VideoFromApi) ?? {
+ id: 'loading',
+ title: 'Loading…',
+ duration: '0:00',
+ thumbnail: '/01.jpg',
+ url: '',
+ };
+
+ const fmt = (v: VideoFromApi) => {
+ if (v.duration) return v.duration;
+ if (!v.durationSec && v.durationSec !== 0) return '';
+ const mins = Math.floor((v.durationSec ?? 0) / 60);
+ const secs = Math.floor((v.durationSec ?? 0) % 60)
+ .toString()
+ .padStart(2, '0');
+ return `${mins}:${secs}`;
+ };
+
+ const handleUnlock = async (v: VideoFromApi) => {
+ console.log('unlock request for', v.id);
+
+ // Prevent multiple unlock attempts
+ if (unlockingVideo === v.id) return;
+
+ setUnlockingVideo(v.id);
+
+ try {
+ // Can't unlock if globally locked
+ if ((v as any).locked) {
+ console.log('Video is globally locked, cannot unlock');
+ return;
+ }
+
+ // Can't unlock if already unlocked or has instant access
+ if ((v as any).instantAccess || userUnlocks.has(v.id)) {
+ console.log('Video already accessible');
+ router.push(`/videoplayer?playlistId=${playlist?.id}&videoId=${v.id}`);
+ return;
+ }
+
+ // Check if previous video in sequence is completed
+ if (!playlist?.videos) return;
+
+ const videoIndex = (v as any).index;
+ if (videoIndex <= 0) {
+ // First video should always be accessible
+ router.push(`/videoplayer?playlistId=${playlist?.id}&videoId=${v.id}`);
+ return;
+ }
+
+ // Find previous video
+ const previousVideo = playlist.videos.find((pv: any) => pv.index === videoIndex - 1);
+ if (!previousVideo) {
+ console.log('Previous video not found');
+ return;
+ }
+
+ // Check if previous video is completed
+ const progressRes = await fetch(`/api/progress?videoId=${previousVideo.id}`);
+ if (!progressRes.ok) {
+ console.error('Failed to fetch previous video progress');
+ alert('Error checking unlock requirements. Please try again.');
+ return;
+ }
+
+ const progressData = await progressRes.json();
+ const isCompleted = progressData.percent >= 90; // Consider 90% as completed
+
+ if (!isCompleted) {
+ console.log('Previous video not completed yet - need', Math.ceil(90 - progressData.percent), '% more');
+ // TODO: Show toast notification explaining unlock requirements
+ alert(`You need to complete ${Math.ceil(90 - progressData.percent)}% more of the previous video to unlock this one.`);
+ return;
+ }
+
+ // Previous video is completed, unlock this video
+ const unlockRes = await fetch('/api/user/unlocks', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ videoId: v.id }),
+ });
+
+ if (unlockRes.ok) {
+ // Update local state
+ setUserUnlocks(prev => new Set(prev).add(v.id));
+ console.log('Video unlocked successfully');
+ // Redirect to the unlocked video
+ router.push(`/videoplayer?playlistId=${playlist?.id}&videoId=${v.id}`);
+ } else {
+ const errorData = await unlockRes.json().catch(() => ({ error: 'Unknown error' }));
+ console.error('Failed to unlock video:', errorData.error);
+ alert(`Failed to unlock video: ${errorData.error}`);
+ }
+ } catch (err) {
+ console.error('Error checking unlock conditions:', err);
+ alert('Error checking if video can be unlocked. Please try again.');
+ } finally {
+ setUnlockingVideo(null);
+ }
+ };
+
+ const [isLiked, setIsLiked] = React.useState(false);
+ const { data: session } = useSession();
+ const isAdmin = (session as any)?.user?.role === 'admin' || (session as any)?.user?.role === 'superadmin';
+
+ React.useEffect(() => {
+ // Check if current video is liked on load
+ if (current.id) {
+ const checkLike = async () => {
+ try {
+ const res = await fetch(`/api/likes?videoId=${current.id}`);
+ if (res.ok) {
+ const data = await res.json();
+ setIsLiked(data.isLiked);
+ }
+ } catch (err) {
+ console.error('Failed to check if video is liked', err);
+ }
+ };
+ checkLike();
+ }
+ }, [current.id]);
+
+ const handleLike = async () => {
+ const newLikedState = !isLiked;
+ setIsLiked(newLikedState);
+
+ try {
+ const res = await fetch('/api/likes', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ videoId: current.id,
+ isLiked: newLikedState,
+ }),
+ });
+ if (!res.ok) {
+ // Revert on error
+ setIsLiked(!newLikedState);
+ console.error('Failed to toggle like');
+ }
+ } catch (err) {
+ // Revert on error
+ setIsLiked(!newLikedState);
+ console.error('Error toggling like', err);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {videoLoading ? (
+
Loading video…
+ ) : current.url ? (
+ // HLS player with MP4 fallback
+ // Prefers HLS if transcoding is complete, falls back to MP4
+
+ ) : (
+
Video not available
+ )}
+
+
+
+
+
+
+ {current.uploader?.image && (
+
+
+
+ )}
+
+
{current.title}
+ {current.uploader?.name && (
+
+ {current.uploader.name}
+
+ )}
+ {current.createdAt && (
+
+ Uploaded {formatDistanceToNow(new Date(current.createdAt), { addSuffix: true })}
+
+ )}
+
+
+
+ {isAdmin && (
+ router.push(`/admin/videos/${current.id}/edit`)}
+ >
+
+ Edit
+
+ )}
+
+ Like
+
+
+
+
+ {current.description && (
+
+ {current.description}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+
+
+ Notes
+
+ Keep lesson notes, transcript links, or chapter markers here.
+
+
+
+
+
+
+ Resources
+
+ Model files
+ Reference sheets
+ Assignments
+
+
+
+
*/}
+
+
+
+
+
+
+
+ );
+}
diff --git a/build-and-push.bat b/build-and-push.bat
new file mode 100644
index 0000000..1593d5d
--- /dev/null
+++ b/build-and-push.bat
@@ -0,0 +1,59 @@
+@echo off
+REM Build and push Docker image to Local Docker Registry
+REM Usage: build-and-push.bat [VERSION_TAG]
+
+setlocal enabledelayedexpansion
+
+REM Configuration
+set LOCAL_REGISTRY=192.168.0.107:5000
+set REPO=owi-cms
+set IMAGE_NAME=%LOCAL_REGISTRY%/%REPO%
+
+REM Get version tag (default to 'latest')
+if "%~1"=="" (
+ set VERSION_TAG=latest
+) else (
+ set VERSION_TAG=%~1
+)
+
+set FULL_IMAGE=%IMAGE_NAME%:%VERSION_TAG%
+
+echo.
+echo ==========================================
+echo Building Docker image: %FULL_IMAGE%
+echo ==========================================
+echo.
+
+REM Build the Docker image
+docker build ^
+ --tag %FULL_IMAGE% ^
+ --tag %IMAGE_NAME%:%VERSION_TAG% ^
+ --file Dockerfile ^
+ .
+
+if %ERRORLEVEL% neq 0 (
+ echo.
+ echo xx Build failed!
+ exit /b 1
+)
+
+echo.
+echo OK Build successful!
+echo.
+echo ==========================================
+echo Next steps:
+echo ==========================================
+echo.
+echo 1. Push to Local Docker Registry:
+echo docker push %FULL_IMAGE%
+echo docker push %IMAGE_NAME%:%VERSION_TAG%
+echo.
+echo 2. Push to GitHub Container Registry:
+echo docker push %FULL_IMAGE%
+echo docker push %IMAGE_NAME%:%VERSION_TAG%
+echo.
+echo 3. Update Dockge to pull the new image:
+echo Image: %FULL_IMAGE%
+echo Registry: %GITHUB_REGISTRY%
+echo.
+pause
diff --git a/build-and-push.sh b/build-and-push.sh
new file mode 100644
index 0000000..47ec992
--- /dev/null
+++ b/build-and-push.sh
@@ -0,0 +1,47 @@
+#!/bin/bash
+
+# Build and push Docker image to Local Docker Registry
+# Usage: ./build-and-push.sh [VERSION_TAG]
+
+set -e
+
+# Configuration
+LOCAL_REGISTRY="192.168.0.107:5000"
+REPO="owi-cms"
+IMAGE_NAME="$LOCAL_REGISTRY/$REPO"
+
+# Get version tag (default to 'latest')
+VERSION_TAG="${1:-latest}"
+FULL_IMAGE="$IMAGE_NAME:$VERSION_TAG"
+
+echo "=========================================="
+echo "Building Docker image: $FULL_IMAGE"
+echo "=========================================="
+
+# Build the Docker image
+docker build \
+ --tag "$FULL_IMAGE" \
+ --tag "$IMAGE_NAME:latest" \
+ --file Dockerfile \
+ .
+
+if [ $? -ne 0 ]; then
+ echo "❌ Build failed!"
+ exit 1
+fi
+
+echo ""
+echo "✅ Build successful!"
+echo ""
+echo "=========================================="
+echo "Next steps:"
+echo "=========================================="
+echo ""
+echo "1. Push to Local Docker Registry:"
+echo " docker push $FULL_IMAGE"
+echo " docker push $IMAGE_NAME:latest"
+echo ""
+echo "2. Update Dockge on TrueNAS to pull the new image:"
+echo " Image: $FULL_IMAGE"
+echo " Registry: $GITHUB_REGISTRY"
+echo ""
diff --git a/components.json b/components.json
new file mode 100644
index 0000000..87838e7
--- /dev/null
+++ b/components.json
@@ -0,0 +1,22 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "app/globals.css",
+ "baseColor": "gray",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "registries": {}
+}
diff --git a/components/JoinForm.tsx b/components/JoinForm.tsx
new file mode 100644
index 0000000..8d010ac
--- /dev/null
+++ b/components/JoinForm.tsx
@@ -0,0 +1,27 @@
+"use client";
+import React, { useState } from "react";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+
+export default function JoinForm() {
+ const [email, setEmail] = useState("");
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ // replace with real action (call API route or server action)
+ alert(`Thanks — we'll ping ${email}`);
+ setEmail("");
+ }
+
+ return (
+
+ ) => setEmail(e.target.value)}
+ placeholder="you@company.com"
+ aria-label="Email"
+ />
+ Join
+
+ );
+}
diff --git a/components/LandingPage.tsx b/components/LandingPage.tsx
new file mode 100644
index 0000000..0fd3f29
--- /dev/null
+++ b/components/LandingPage.tsx
@@ -0,0 +1,129 @@
+// app/components/LandingPage.tsx (Server Component)
+import React from "react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Check } from "lucide-react";
+import JoinForm from "./JoinForm";
+
+export default function LandingPage() {
+ return (
+
+
+
+
G4
+
+
Gruff Studio
+
UI playground · shadcn + Tailwind
+
+
+
+ Docs
+ Sign in
+
+
+
+
+
+
+
Beta
+
Build faster with shadcn components
+
A tiny example landing page to confirm your Tailwind + shadcn setup is working. Components are unopinionated and fully customizable with Tailwind.
+
+
+ Get started
+ Learn more
+
+
+
+ } title="Reusable" subtitle="Composable UI" />
+ } title="Accessible" subtitle="Focus & keyboard" />
+ } title="Themed" subtitle="Tailwind friendly" />
+ } title="Tiny" subtitle="Zero runtime" />
+
+
+
+
+
+
+ Join the waitlist
+ Drop your email and we’ll ping you when the demo is live.
+
+ No spam — only useful updates.
+
+
+
+
+
+
+
+
+
+
+
+
Server-side friendly
+
Use these components in server and client components.
+
+
+
+
+
+
+
+
+
+
+
+
+
Tailwind-ready
+
Customize tokens in tailwind.config.
+
+
+
+
+
+
+
+
+
+ Example features
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function Feature({ icon, title, subtitle }: { icon: React.ReactNode; title: string; subtitle: string }) {
+ return (
+
+ );
+}
+
+function FeatureCard({ title, desc }: { title: string; desc: string }) {
+ return (
+
+
+ {title}
+ {desc}
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/VideoCarousel.tsx b/components/VideoCarousel.tsx
new file mode 100644
index 0000000..422a582
--- /dev/null
+++ b/components/VideoCarousel.tsx
@@ -0,0 +1,158 @@
+"use client";
+
+import React, { useRef, useState, useEffect } from "react";
+import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+
+import { ChevronLeft, ChevronRight } from "lucide-react";
+
+export type VideoItem = {
+ id: string | number;
+ title: string;
+ duration: string; // formatted like "4:12"
+ thumbnailUrl: string;
+};
+
+type Props = {
+ categoryTitle: string;
+ videos: VideoItem[];
+ visibleCount?: number; // defaults to 4
+};
+
+export default function VideoCarousel({
+ categoryTitle,
+ videos,
+ visibleCount = 4,
+}: Props) {
+ const scrollerRef = useRef(null);
+ const [canScrollLeft, setCanScrollLeft] = useState(false);
+ const [canScrollRight, setCanScrollRight] = useState(false);
+
+ useEffect(() => {
+ const el = scrollerRef.current;
+ if (!el) return;
+ const update = () => {
+ setCanScrollLeft(el.scrollLeft > 0);
+ setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
+ };
+ update();
+ el.addEventListener("scroll", update, { passive: true });
+ window.addEventListener("resize", update);
+ return () => {
+ el.removeEventListener("scroll", update);
+ window.removeEventListener("resize", update);
+ };
+ }, [videos]);
+
+ const scrollByPage = (direction: "left" | "right") => {
+ const el = scrollerRef.current;
+ if (!el) return;
+ const amount = el.clientWidth; // scroll by visible area so next "page" shows
+ el.scrollBy({ left: direction === "left" ? -amount : amount, behavior: "smooth" });
+ };
+
+ // keyboard navigation
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === "ArrowLeft") scrollByPage("left");
+ if (e.key === "ArrowRight") scrollByPage("right");
+ };
+ window.addEventListener("keydown", handler);
+ return () => window.removeEventListener("keydown", handler);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+
+
{categoryTitle}
+
+ scrollByPage("left")}
+ disabled={!canScrollLeft}
+ >
+
+
+ scrollByPage("right")}
+ disabled={!canScrollRight}
+ >
+
+
+
+
+
+
+
+ {videos.map((v) => (
+
+
+
+
+
{v.duration}
+
+
+
+ {v.title}
+
+
+ {/* placeholder for action, e.g., menu or save */}
+
+
+
+ {v.duration}
+
+
+
+ ))}
+
+
+ {/* small gradients on the sides to indicate scrollability */}
+
+
+
+
+
+ );
+}
+
+/*
+Usage example:
+
+import VideoCarousel, { VideoItem } from "./VideoCarousel";
+
+const videos: VideoItem[] = [
+ { id: 1, title: "Fluffy cat plays with yarn", duration: "3:45", thumbnailUrl: "/thumb1.jpg" },
+ { id: 2, title: "Cat napping compilation", duration: "2:12", thumbnailUrl: "/thumb2.jpg" },
+ // ...more
+];
+
+
+
+Notes:
+- This file assumes shadcn/ui components exist at the given paths. Replace imports if your project structure differs.
+- Card widths are responsive: they use a percent width with a min-width to keep thumbnails readable on small screens.
+- The scroller uses native smooth scrolling so it works well on touch devices and with keyboards.
+*/
diff --git a/components/admin-notifications.tsx b/components/admin-notifications.tsx
new file mode 100644
index 0000000..ab370f0
--- /dev/null
+++ b/components/admin-notifications.tsx
@@ -0,0 +1,210 @@
+// components/admin-notifications.tsx
+'use client';
+
+import React from 'react';
+import { Heart, MessageCircle, BookOpen, List } from 'lucide-react';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import Image from 'next/image';
+import { formatDistanceToNow } from 'date-fns';
+
+interface Notification {
+ id: string;
+ type: 'like' | 'comment' | 'course_created' | 'playlist_created';
+ user?: {
+ id: string;
+ name?: string;
+ image?: string;
+ email: string;
+ } | null;
+ video?: {
+ id: string;
+ title: string;
+ };
+ course?: {
+ id: string;
+ title: string;
+ code: string;
+ };
+ playlist?: {
+ id: string;
+ title: string;
+ courseTitle: string;
+ };
+ content: string | null;
+ createdAt: Date;
+}
+
+export function AdminNotifications() {
+ const [notifications, setNotifications] = React.useState([]);
+ const [isLoading, setIsLoading] = React.useState(true);
+ const [error, setError] = React.useState(null);
+
+ React.useEffect(() => {
+ const fetchNotifications = async () => {
+ try {
+ setIsLoading(true);
+ const res = await fetch('/api/admin/notifications');
+ if (res.ok) {
+ const data = await res.json();
+ setNotifications(
+ data.notifications.map((n: any) => ({
+ ...n,
+ createdAt: new Date(n.createdAt),
+ }))
+ );
+ } else {
+ setError('Failed to fetch notifications');
+ }
+ } catch (err) {
+ console.error('Failed to fetch notifications:', err);
+ setError('Error loading notifications');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchNotifications();
+
+ // Refresh notifications every 30 seconds
+ const interval = setInterval(fetchNotifications, 30000);
+ return () => clearInterval(interval);
+ }, []);
+
+ if (isLoading) {
+ return (
+
+
+
+
+ Activity
+
+
+
+ Loading notifications...
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
+ Activity
+
+
+
+ {error}
+
+
+ );
+ }
+
+ if (notifications.length === 0) {
+ return (
+
+
+
+
+ Activity
+
+
+
+ No activity yet
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Activity ({notifications.length})
+
+
+
+ {notifications.map((notification) => (
+
+ {notification.user?.image && (
+
+
+
+ )}
+ {notification.type === 'course_created' && (
+
+
+
+ )}
+ {notification.type === 'playlist_created' && (
+
+
+
+ )}
+
+
+ {notification.type === 'like' && (
+
+ )}
+ {notification.type === 'comment' && (
+
+ )}
+ {notification.type === 'course_created' && (
+
+ )}
+ {notification.type === 'playlist_created' && (
+
+ )}
+
+ {notification.user ? (
+ notification.user.name || notification.user.email
+ ) : (
+ 'System'
+ )}
+
+
+ {formatDistanceToNow(notification.createdAt, {
+ addSuffix: true,
+ })}
+
+
+
+ {notification.type === 'like' &&
+ `liked ${notification.video?.title}`}
+ {notification.type === 'comment' &&
+ `commented on ${notification.video?.title}`}
+ {notification.type === 'course_created' &&
+ `created course: ${notification.course?.title}`}
+ {notification.type === 'playlist_created' &&
+ `created playlist: ${notification.playlist?.title}`}
+
+ {notification.content && (
+
+ "{notification.content}"
+
+ )}
+
+
+ ))}
+
+
+ );
+}
diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx
new file mode 100644
index 0000000..14e1623
--- /dev/null
+++ b/components/app-sidebar.tsx
@@ -0,0 +1,97 @@
+'use client';
+
+import * as React from 'react';
+import { useSession } from 'next-auth/react';
+import { IconDashboard, IconChartBar, IconFolder, IconSearch, IconShieldCheck } from '@tabler/icons-react';
+import { NavMain } from '@/components/nav-main';
+import { NavSecondary } from '@/components/nav-secondary';
+import { NavUser } from '@/components/nav-user';
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarHeader,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+} from '@/components/ui/sidebar';
+
+interface NavItem {
+ title: string;
+ url: string;
+ icon?: any;
+ items?: NavItem[];
+}
+
+const navMainBase: NavItem[] = [
+ { title: 'Library', url: '/dashboard', icon: IconDashboard },
+ { title: 'Liked Videos', url: '/dashboard/liked-videos', icon: IconChartBar },
+ { title: 'History', url: '/dashboard/watch-history', icon: IconFolder },
+];
+
+const navSecondary = [
+ { title: 'Search', url: '#', icon: IconSearch },
+];
+
+export function AppSidebar(props: React.ComponentProps) {
+ const { data: session } = useSession();
+ const role = (session as any)?.user?.role ?? 'user';
+
+ // Compose navMain and optionally add admin link for admins or superadmins
+ const navMain = React.useMemo(() => {
+ const base = [...navMainBase];
+
+ if (role === 'admin' || role === 'superadmin') {
+ // Put Admin at the top with submenu items
+ base.unshift({
+ title: 'Admin',
+ url: '/admin',
+ icon: IconShieldCheck,
+ // nested submenu items
+ items: [
+ { title: 'Manage Users', url: '/admin/users' },
+ { title: 'Manage Enrollments', url: '/admin/enrollments' },
+ { title: 'Manage Courses', url: '/admin/courses' },
+ { title: 'Manage Playlists', url: '/admin/playlists' },
+ { title: 'Manage Videos', url: '/admin/videos' },
+ ],
+ });
+ }
+
+ return base;
+ }, [role]);
+
+ const user = session?.user
+ ? {
+ name: session.user.name ?? 'User',
+ email: session.user.email ?? '',
+ avatar: session.user.image ?? '/avatars/shadcn.jpg',
+ }
+ : { name: 'Guest', email: 'guest@example.com', avatar: '/avatars/shadcn.jpg' };
+
+ return (
+
+
+
+
+
+
+
+ OW ANIMATION ARTS VAULT
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/chart-area-interactive.tsx b/components/chart-area-interactive.tsx
new file mode 100644
index 0000000..5b475ea
--- /dev/null
+++ b/components/chart-area-interactive.tsx
@@ -0,0 +1,291 @@
+"use client"
+
+import * as React from "react"
+import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
+
+import { useIsMobile } from "@/hooks/use-mobile"
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import {
+ ChartConfig,
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+} from "@/components/ui/chart"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ ToggleGroup,
+ ToggleGroupItem,
+} from "@/components/ui/toggle-group"
+
+export const description = "An interactive area chart"
+
+const chartData = [
+ { date: "2024-04-01", desktop: 222, mobile: 150 },
+ { date: "2024-04-02", desktop: 97, mobile: 180 },
+ { date: "2024-04-03", desktop: 167, mobile: 120 },
+ { date: "2024-04-04", desktop: 242, mobile: 260 },
+ { date: "2024-04-05", desktop: 373, mobile: 290 },
+ { date: "2024-04-06", desktop: 301, mobile: 340 },
+ { date: "2024-04-07", desktop: 245, mobile: 180 },
+ { date: "2024-04-08", desktop: 409, mobile: 320 },
+ { date: "2024-04-09", desktop: 59, mobile: 110 },
+ { date: "2024-04-10", desktop: 261, mobile: 190 },
+ { date: "2024-04-11", desktop: 327, mobile: 350 },
+ { date: "2024-04-12", desktop: 292, mobile: 210 },
+ { date: "2024-04-13", desktop: 342, mobile: 380 },
+ { date: "2024-04-14", desktop: 137, mobile: 220 },
+ { date: "2024-04-15", desktop: 120, mobile: 170 },
+ { date: "2024-04-16", desktop: 138, mobile: 190 },
+ { date: "2024-04-17", desktop: 446, mobile: 360 },
+ { date: "2024-04-18", desktop: 364, mobile: 410 },
+ { date: "2024-04-19", desktop: 243, mobile: 180 },
+ { date: "2024-04-20", desktop: 89, mobile: 150 },
+ { date: "2024-04-21", desktop: 137, mobile: 200 },
+ { date: "2024-04-22", desktop: 224, mobile: 170 },
+ { date: "2024-04-23", desktop: 138, mobile: 230 },
+ { date: "2024-04-24", desktop: 387, mobile: 290 },
+ { date: "2024-04-25", desktop: 215, mobile: 250 },
+ { date: "2024-04-26", desktop: 75, mobile: 130 },
+ { date: "2024-04-27", desktop: 383, mobile: 420 },
+ { date: "2024-04-28", desktop: 122, mobile: 180 },
+ { date: "2024-04-29", desktop: 315, mobile: 240 },
+ { date: "2024-04-30", desktop: 454, mobile: 380 },
+ { date: "2024-05-01", desktop: 165, mobile: 220 },
+ { date: "2024-05-02", desktop: 293, mobile: 310 },
+ { date: "2024-05-03", desktop: 247, mobile: 190 },
+ { date: "2024-05-04", desktop: 385, mobile: 420 },
+ { date: "2024-05-05", desktop: 481, mobile: 390 },
+ { date: "2024-05-06", desktop: 498, mobile: 520 },
+ { date: "2024-05-07", desktop: 388, mobile: 300 },
+ { date: "2024-05-08", desktop: 149, mobile: 210 },
+ { date: "2024-05-09", desktop: 227, mobile: 180 },
+ { date: "2024-05-10", desktop: 293, mobile: 330 },
+ { date: "2024-05-11", desktop: 335, mobile: 270 },
+ { date: "2024-05-12", desktop: 197, mobile: 240 },
+ { date: "2024-05-13", desktop: 197, mobile: 160 },
+ { date: "2024-05-14", desktop: 448, mobile: 490 },
+ { date: "2024-05-15", desktop: 473, mobile: 380 },
+ { date: "2024-05-16", desktop: 338, mobile: 400 },
+ { date: "2024-05-17", desktop: 499, mobile: 420 },
+ { date: "2024-05-18", desktop: 315, mobile: 350 },
+ { date: "2024-05-19", desktop: 235, mobile: 180 },
+ { date: "2024-05-20", desktop: 177, mobile: 230 },
+ { date: "2024-05-21", desktop: 82, mobile: 140 },
+ { date: "2024-05-22", desktop: 81, mobile: 120 },
+ { date: "2024-05-23", desktop: 252, mobile: 290 },
+ { date: "2024-05-24", desktop: 294, mobile: 220 },
+ { date: "2024-05-25", desktop: 201, mobile: 250 },
+ { date: "2024-05-26", desktop: 213, mobile: 170 },
+ { date: "2024-05-27", desktop: 420, mobile: 460 },
+ { date: "2024-05-28", desktop: 233, mobile: 190 },
+ { date: "2024-05-29", desktop: 78, mobile: 130 },
+ { date: "2024-05-30", desktop: 340, mobile: 280 },
+ { date: "2024-05-31", desktop: 178, mobile: 230 },
+ { date: "2024-06-01", desktop: 178, mobile: 200 },
+ { date: "2024-06-02", desktop: 470, mobile: 410 },
+ { date: "2024-06-03", desktop: 103, mobile: 160 },
+ { date: "2024-06-04", desktop: 439, mobile: 380 },
+ { date: "2024-06-05", desktop: 88, mobile: 140 },
+ { date: "2024-06-06", desktop: 294, mobile: 250 },
+ { date: "2024-06-07", desktop: 323, mobile: 370 },
+ { date: "2024-06-08", desktop: 385, mobile: 320 },
+ { date: "2024-06-09", desktop: 438, mobile: 480 },
+ { date: "2024-06-10", desktop: 155, mobile: 200 },
+ { date: "2024-06-11", desktop: 92, mobile: 150 },
+ { date: "2024-06-12", desktop: 492, mobile: 420 },
+ { date: "2024-06-13", desktop: 81, mobile: 130 },
+ { date: "2024-06-14", desktop: 426, mobile: 380 },
+ { date: "2024-06-15", desktop: 307, mobile: 350 },
+ { date: "2024-06-16", desktop: 371, mobile: 310 },
+ { date: "2024-06-17", desktop: 475, mobile: 520 },
+ { date: "2024-06-18", desktop: 107, mobile: 170 },
+ { date: "2024-06-19", desktop: 341, mobile: 290 },
+ { date: "2024-06-20", desktop: 408, mobile: 450 },
+ { date: "2024-06-21", desktop: 169, mobile: 210 },
+ { date: "2024-06-22", desktop: 317, mobile: 270 },
+ { date: "2024-06-23", desktop: 480, mobile: 530 },
+ { date: "2024-06-24", desktop: 132, mobile: 180 },
+ { date: "2024-06-25", desktop: 141, mobile: 190 },
+ { date: "2024-06-26", desktop: 434, mobile: 380 },
+ { date: "2024-06-27", desktop: 448, mobile: 490 },
+ { date: "2024-06-28", desktop: 149, mobile: 200 },
+ { date: "2024-06-29", desktop: 103, mobile: 160 },
+ { date: "2024-06-30", desktop: 446, mobile: 400 },
+]
+
+const chartConfig = {
+ visitors: {
+ label: "Visitors",
+ },
+ desktop: {
+ label: "Desktop",
+ color: "var(--primary)",
+ },
+ mobile: {
+ label: "Mobile",
+ color: "var(--primary)",
+ },
+} satisfies ChartConfig
+
+export function ChartAreaInteractive() {
+ const isMobile = useIsMobile()
+ const [timeRange, setTimeRange] = React.useState("90d")
+
+ React.useEffect(() => {
+ if (isMobile) {
+ setTimeRange("7d")
+ }
+ }, [isMobile])
+
+ const filteredData = chartData.filter((item) => {
+ const date = new Date(item.date)
+ const referenceDate = new Date("2024-06-30")
+ let daysToSubtract = 90
+ if (timeRange === "30d") {
+ daysToSubtract = 30
+ } else if (timeRange === "7d") {
+ daysToSubtract = 7
+ }
+ const startDate = new Date(referenceDate)
+ startDate.setDate(startDate.getDate() - daysToSubtract)
+ return date >= startDate
+ })
+
+ return (
+
+
+ Total Visitors
+
+
+ Total for the last 3 months
+
+ Last 3 months
+
+
+
+ Last 3 months
+ Last 30 days
+ Last 7 days
+
+
+
+
+
+
+
+ Last 3 months
+
+
+ Last 30 days
+
+
+ Last 7 days
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ const date = new Date(value)
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ })
+ }}
+ />
+ {
+ return new Date(value).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ })
+ }}
+ indicator="dot"
+ />
+ }
+ />
+
+
+
+
+
+
+ )
+}
diff --git a/components/comments-section.tsx b/components/comments-section.tsx
new file mode 100644
index 0000000..f752a78
--- /dev/null
+++ b/components/comments-section.tsx
@@ -0,0 +1,436 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { useSession } from 'next-auth/react';
+import { Button } from '@/components/ui/button';
+import { Textarea } from '@/components/ui/textarea';
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+import { formatDistanceToNow } from 'date-fns';
+import { Trash2 } from 'lucide-react';
+
+type User = {
+ id: string;
+ name: string | null;
+ email: string;
+ image: string | null;
+};
+
+type CommentReply = {
+ id: string;
+ userId: string;
+ commentId: string;
+ content: string;
+ createdAt: string;
+ user: User;
+};
+
+type Comment = {
+ id: string;
+ userId: string;
+ videoId: string;
+ content: string;
+ createdAt: string;
+ user: User;
+ replies: CommentReply[];
+};
+
+interface CommentsSectionProps {
+ videoId: string;
+}
+
+export function CommentsSection({ videoId }: CommentsSectionProps) {
+ const { data: session } = useSession();
+ const [comments, setComments] = useState([]);
+ const [newCommentContent, setNewCommentContent] = useState('');
+ const [replyingToId, setReplyingToId] = useState(null);
+ const [replyContent, setReplyContent] = useState('');
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [deletingCommentId, setDeletingCommentId] = useState(null);
+ const [deleteReplyDialogOpen, setDeleteReplyDialogOpen] = useState(false);
+ const [deletingReplyId, setDeletingReplyId] = useState(null);
+ const [deletingReplyCommentId, setDeletingReplyCommentId] = useState(null);
+ const isAdmin = (session?.user as any)?.role === 'admin' || (session?.user as any)?.role === 'superadmin';
+
+ const handleDeleteComment = async (commentId: string) => {
+ try {
+ const res = await fetch(`/api/comments/${commentId}`, {
+ method: 'DELETE',
+ });
+
+ if (res.ok) {
+ setComments(comments.filter((c) => c.id !== commentId));
+ setDeleteDialogOpen(false);
+ setDeletingCommentId(null);
+ }
+ } catch (err) {
+ console.error('Failed to delete comment:', err);
+ }
+ };
+
+ const handleDeleteReply = async (replyId: string, commentId: string) => {
+ try {
+ const res = await fetch(`/api/comments/reply/${replyId}`, {
+ method: 'DELETE',
+ });
+
+ if (res.ok) {
+ setComments(
+ comments.map((c) =>
+ c.id === commentId
+ ? {
+ ...c,
+ replies: c.replies.filter((r) => r.id !== replyId),
+ }
+ : c
+ )
+ );
+ setDeleteReplyDialogOpen(false);
+ setDeletingReplyId(null);
+ setDeletingReplyCommentId(null);
+ }
+ } catch (err) {
+ console.error('Failed to delete reply:', err);
+ }
+ };
+
+ // Fetch comments on mount and when videoId changes
+ useEffect(() => {
+ const fetchComments = async () => {
+ setIsLoading(true);
+ try {
+ const res = await fetch(`/api/comments?videoId=${videoId}`);
+ if (res.ok) {
+ const data = await res.json();
+ setComments(data);
+ }
+ } catch (err) {
+ console.error('Failed to fetch comments:', err);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchComments();
+ }, [videoId]);
+
+ const handleCommentSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!session?.user || !newCommentContent.trim()) return;
+
+ setIsSubmitting(true);
+ try {
+ const res = await fetch('/api/comments', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ videoId, content: newCommentContent }),
+ });
+
+ if (res.ok) {
+ const newComment = await res.json();
+ setComments([newComment, ...comments]);
+ setNewCommentContent('');
+ }
+ } catch (err) {
+ console.error('Failed to post comment:', err);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleReplySubmit = async (e: React.FormEvent, commentId: string) => {
+ e.preventDefault();
+ if (!session?.user || !replyContent.trim()) return;
+
+ setIsSubmitting(true);
+ try {
+ const res = await fetch(`/api/comments/${commentId}/reply`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ content: replyContent }),
+ });
+
+ if (res.ok) {
+ const newReply = await res.json();
+ setComments(
+ comments.map((c) =>
+ c.id === commentId
+ ? { ...c, replies: [...c.replies, newReply] }
+ : c
+ )
+ );
+ setReplyContent('');
+ setReplyingToId(null);
+ }
+ } catch (err) {
+ console.error('Failed to post reply:', err);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const getDisplayName = (user: User) => user.name || user.email.split('@')[0];
+ const getInitials = (user: User) => {
+ const name = getDisplayName(user);
+ return name
+ .split(' ')
+ .map((n) => n[0])
+ .join('')
+ .toUpperCase();
+ };
+
+ if (isLoading) {
+ return Loading comments...
;
+ }
+
+ return (
+
+
Comments ({comments.length})
+
+ {/* Add Comment Form */}
+ {session?.user ? (
+
+
+
+
+
+ {getInitials({
+ id: (session.user as any).id,
+ name: (session.user as any).name,
+ email: (session.user as any).email,
+ image: (session.user as any).image,
+ })}
+
+
+
+
setNewCommentContent(e.target.value)}
+ rows={2}
+ className="mb-2"
+ />
+
+ setNewCommentContent('')}
+ >
+ Cancel
+
+
+ {isSubmitting ? 'Posting...' : 'Comment'}
+
+
+
+
+
+ ) : (
+
+ Sign in to comment
+
+ )}
+
+ {/* Comments List */}
+
+ {comments.length === 0 ? (
+
No comments yet. Be the first to comment!
+ ) : (
+ comments.map((comment) => (
+
+ {/* Comment */}
+
+
+
+ {getInitials(comment.user)}
+
+
+
+
+
{getDisplayName(comment.user)}
+
+ {formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}
+
+ {isAdmin && (
+
{
+ if (!open) {
+ setDeleteDialogOpen(false);
+ setDeletingCommentId(null);
+ }
+ }}>
+ {
+ setDeleteDialogOpen(true);
+ setDeletingCommentId(comment.id);
+ }}
+ className="ml-auto p-1 hover:bg-destructive/20 rounded"
+ title="Delete comment"
+ >
+
+
+
+
+ Delete Comment
+
+ Are you sure you want to delete this comment?
+
+
+
+
Cancel
+
handleDeleteComment(comment.id)}
+ >
+ Delete
+
+
+
+
+ )}
+
+
{comment.content}
+
+ {session?.user && (
+
setReplyingToId(replyingToId === comment.id ? null : comment.id)}
+ >
+ {replyingToId === comment.id ? 'Cancel' : 'Reply'}
+
+ )}
+
+
+
+ {/* Reply Form */}
+ {replyingToId === comment.id && session?.user && (
+
handleReplySubmit(e, comment.id)}
+ className="ml-8 flex gap-3 mb-3"
+ >
+
+
+
+ {getInitials({
+ id: (session.user as any).id,
+ name: (session.user as any).name,
+ email: (session.user as any).email,
+ image: (session.user as any).image,
+ })}
+
+
+
+
setReplyContent(e.target.value)}
+ rows={2}
+ className="mb-2"
+ />
+
+ {
+ setReplyContent('');
+ setReplyingToId(null);
+ }}
+ >
+ Cancel
+
+
+ {isSubmitting ? 'Replying...' : 'Reply'}
+
+
+
+
+ )}
+
+ {/* Replies */}
+ {comment.replies.length > 0 && (
+
+ {comment.replies.map((reply) => (
+
+
+
+ {getInitials(reply.user)}
+
+
+
+
+
{getDisplayName(reply.user)}
+
+ {formatDistanceToNow(new Date(reply.createdAt), { addSuffix: true })}
+
+ {isAdmin && (
+
{
+ if (!open) {
+ setDeleteReplyDialogOpen(false);
+ setDeletingReplyId(null);
+ setDeletingReplyCommentId(null);
+ }
+ }}>
+ {
+ setDeleteReplyDialogOpen(true);
+ setDeletingReplyId(reply.id);
+ setDeletingReplyCommentId(comment.id);
+ }}
+ className="ml-auto p-1 hover:bg-destructive/20 rounded"
+ title="Delete reply"
+ >
+
+
+
+
+ Delete Reply
+
+ Are you sure you want to delete this reply?
+
+
+
+
Cancel
+
handleDeleteReply(reply.id, comment.id)}
+ >
+ Delete
+
+
+
+
+ )}
+
+
{reply.content}
+
+
+
+ ))}
+
+ )}
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/components/data-table.tsx b/components/data-table.tsx
new file mode 100644
index 0000000..4834681
--- /dev/null
+++ b/components/data-table.tsx
@@ -0,0 +1,807 @@
+"use client"
+
+import * as React from "react"
+import {
+ closestCenter,
+ DndContext,
+ KeyboardSensor,
+ MouseSensor,
+ TouchSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+ type UniqueIdentifier,
+} from "@dnd-kit/core"
+import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
+import {
+ arrayMove,
+ SortableContext,
+ useSortable,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable"
+import { CSS } from "@dnd-kit/utilities"
+import {
+ IconChevronDown,
+ IconChevronLeft,
+ IconChevronRight,
+ IconChevronsLeft,
+ IconChevronsRight,
+ IconCircleCheckFilled,
+ IconDotsVertical,
+ IconGripVertical,
+ IconLayoutColumns,
+ IconLoader,
+ IconPlus,
+ IconTrendingUp,
+} from "@tabler/icons-react"
+import {
+ ColumnDef,
+ ColumnFiltersState,
+ flexRender,
+ getCoreRowModel,
+ getFacetedRowModel,
+ getFacetedUniqueValues,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ Row,
+ SortingState,
+ useReactTable,
+ VisibilityState,
+} from "@tanstack/react-table"
+import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
+import { toast } from "sonner"
+import { z } from "zod"
+
+import { useIsMobile } from "@/hooks/use-mobile"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ ChartConfig,
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+} from "@/components/ui/chart"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Separator } from "@/components/ui/separator"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@/components/ui/tabs"
+
+export const schema = z.object({
+ id: z.number(),
+ header: z.string(),
+ type: z.string(),
+ status: z.string(),
+ target: z.string(),
+ limit: z.string(),
+ reviewer: z.string(),
+})
+
+// Create a separate component for the drag handle
+function DragHandle({ id }: { id: number }) {
+ const { attributes, listeners } = useSortable({
+ id,
+ })
+
+ return (
+
+
+ Drag to reorder
+
+ )
+}
+
+const columns: ColumnDef>[] = [
+ {
+ id: "drag",
+ header: () => null,
+ cell: ({ row }) => ,
+ },
+ {
+ id: "select",
+ header: ({ table }) => (
+
+ table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+
+ ),
+ cell: ({ row }) => (
+
+ row.toggleSelected(!!value)}
+ aria-label="Select row"
+ />
+
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: "header",
+ header: "Header",
+ cell: ({ row }) => {
+ return
+ },
+ enableHiding: false,
+ },
+ {
+ accessorKey: "type",
+ header: "Section Type",
+ cell: ({ row }) => (
+
+
+ {row.original.type}
+
+
+ ),
+ },
+ {
+ accessorKey: "status",
+ header: "Status",
+ cell: ({ row }) => (
+
+ {row.original.status === "Done" ? (
+
+ ) : (
+
+ )}
+ {row.original.status}
+
+ ),
+ },
+ {
+ accessorKey: "target",
+ header: () => Target
,
+ cell: ({ row }) => (
+ {
+ e.preventDefault()
+ toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
+ loading: `Saving ${row.original.header}`,
+ success: "Done",
+ error: "Error",
+ })
+ }}
+ >
+
+ Target
+
+
+
+ ),
+ },
+ {
+ accessorKey: "limit",
+ header: () => Limit
,
+ cell: ({ row }) => (
+ {
+ e.preventDefault()
+ toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
+ loading: `Saving ${row.original.header}`,
+ success: "Done",
+ error: "Error",
+ })
+ }}
+ >
+
+ Limit
+
+
+
+ ),
+ },
+ {
+ accessorKey: "reviewer",
+ header: "Reviewer",
+ cell: ({ row }) => {
+ const isAssigned = row.original.reviewer !== "Assign reviewer"
+
+ if (isAssigned) {
+ return row.original.reviewer
+ }
+
+ return (
+ <>
+
+ Reviewer
+
+
+
+
+
+
+ Eddie Lake
+
+ Jamik Tashpulatov
+
+
+
+ >
+ )
+ },
+ },
+ {
+ id: "actions",
+ cell: () => (
+
+
+
+
+ Open menu
+
+
+
+ Edit
+ Make a copy
+ Favorite
+
+ Delete
+
+
+ ),
+ },
+]
+
+function DraggableRow({ row }: { row: Row> }) {
+ const { transform, transition, setNodeRef, isDragging } = useSortable({
+ id: row.original.id,
+ })
+
+ return (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ )
+}
+
+export function DataTable({
+ data: initialData,
+}: {
+ data: z.infer[]
+}) {
+ const [data, setData] = React.useState(() => initialData)
+ const [rowSelection, setRowSelection] = React.useState({})
+ const [columnVisibility, setColumnVisibility] =
+ React.useState({})
+ const [columnFilters, setColumnFilters] = React.useState(
+ []
+ )
+ const [sorting, setSorting] = React.useState([])
+ const [pagination, setPagination] = React.useState({
+ pageIndex: 0,
+ pageSize: 10,
+ })
+ const sortableId = React.useId()
+ const sensors = useSensors(
+ useSensor(MouseSensor, {}),
+ useSensor(TouchSensor, {}),
+ useSensor(KeyboardSensor, {})
+ )
+
+ const dataIds = React.useMemo(
+ () => data?.map(({ id }) => id) || [],
+ [data]
+ )
+
+ const table = useReactTable({
+ data,
+ columns,
+ state: {
+ sorting,
+ columnVisibility,
+ rowSelection,
+ columnFilters,
+ pagination,
+ },
+ getRowId: (row) => row.id.toString(),
+ enableRowSelection: true,
+ onRowSelectionChange: setRowSelection,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onPaginationChange: setPagination,
+ getCoreRowModel: getCoreRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFacetedRowModel: getFacetedRowModel(),
+ getFacetedUniqueValues: getFacetedUniqueValues(),
+ })
+
+ function handleDragEnd(event: DragEndEvent) {
+ const { active, over } = event
+ if (active && over && active.id !== over.id) {
+ setData((data) => {
+ const oldIndex = dataIds.indexOf(active.id)
+ const newIndex = dataIds.indexOf(over.id)
+ return arrayMove(data, oldIndex, newIndex)
+ })
+ }
+ }
+
+ return (
+
+
+
+ View
+
+
+
+
+
+
+ Outline
+ Past Performance
+ Key Personnel
+ Focus Documents
+
+
+
+ Outline
+
+ Past Performance 3
+
+
+ Key Personnel 2
+
+ Focus Documents
+
+
+
+
+
+
+ Customize Columns
+ Columns
+
+
+
+
+ {table
+ .getAllColumns()
+ .filter(
+ (column) =>
+ typeof column.accessorFn !== "undefined" &&
+ column.getCanHide()
+ )
+ .map((column) => {
+ return (
+
+ column.toggleVisibility(!!value)
+ }
+ >
+ {column.id}
+
+ )
+ })}
+
+
+
+
+ Add Section
+
+
+
+
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ return (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+
+ )
+ })}
+
+ ))}
+
+
+ {table.getRowModel().rows?.length ? (
+
+ {table.getRowModel().rows.map((row) => (
+
+ ))}
+
+ ) : (
+
+
+ No results.
+
+
+ )}
+
+
+
+
+
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "}
+ {table.getFilteredRowModel().rows.length} row(s) selected.
+
+
+
+
+ Rows per page
+
+ {
+ table.setPageSize(Number(value))
+ }}
+ >
+
+
+
+
+ {[10, 20, 30, 40, 50].map((pageSize) => (
+
+ {pageSize}
+
+ ))}
+
+
+
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "}
+ {table.getPageCount()}
+
+
+ table.setPageIndex(0)}
+ disabled={!table.getCanPreviousPage()}
+ >
+ Go to first page
+
+
+ table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ Go to previous page
+
+
+ table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ Go to next page
+
+
+ table.setPageIndex(table.getPageCount() - 1)}
+ disabled={!table.getCanNextPage()}
+ >
+ Go to last page
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+const chartData = [
+ { month: "January", desktop: 186, mobile: 80 },
+ { month: "February", desktop: 305, mobile: 200 },
+ { month: "March", desktop: 237, mobile: 120 },
+ { month: "April", desktop: 73, mobile: 190 },
+ { month: "May", desktop: 209, mobile: 130 },
+ { month: "June", desktop: 214, mobile: 140 },
+]
+
+const chartConfig = {
+ desktop: {
+ label: "Desktop",
+ color: "var(--primary)",
+ },
+ mobile: {
+ label: "Mobile",
+ color: "var(--primary)",
+ },
+} satisfies ChartConfig
+
+function TableCellViewer({ item }: { item: z.infer }) {
+ const isMobile = useIsMobile()
+
+ return (
+
+
+
+ {item.header}
+
+
+
+
+ {item.header}
+
+ Showing total visitors for the last 6 months
+
+
+
+ {!isMobile && (
+ <>
+
+
+
+ value.slice(0, 3)}
+ hide
+ />
+ }
+ />
+
+
+
+
+
+
+
+ Trending up by 5.2% this month{" "}
+
+
+
+ Showing total visitors for the last 6 months. This is just
+ some random text to test the layout. It spans multiple lines
+ and should wrap around.
+
+
+
+ >
+ )}
+
+
+ Header
+
+
+
+
+ Type
+
+
+
+
+
+
+ Table of Contents
+
+
+ Executive Summary
+
+
+ Technical Approach
+
+ Design
+ Capabilities
+
+ Focus Documents
+
+ Narrative
+ Cover Page
+
+
+
+
+ Status
+
+
+
+
+
+ Done
+ In Progress
+ Not Started
+
+
+
+
+
+
+ Reviewer
+
+
+
+
+
+ Eddie Lake
+
+ Jamik Tashpulatov
+
+ Emily Whalen
+
+
+
+
+
+
+ Submit
+
+ Done
+
+
+
+
+ )
+}
diff --git a/components/hls.tsx b/components/hls.tsx
new file mode 100644
index 0000000..342ce05
--- /dev/null
+++ b/components/hls.tsx
@@ -0,0 +1,144 @@
+// app/components/hls.tsx
+"use client";
+import React from "react";
+import Hls from "hls.js";
+
+type Props = {
+ src: string;
+ fallbackSrc?: string; // MP4 fallback URL
+ // optional props if you want
+ controls?: boolean;
+ autoPlay?: boolean;
+ videoId?: string; // Auto-load subtitles from /subtitles/{videoId}.vtt
+ subtitles?: Array<{
+ src: string;
+ kind?: "subtitles" | "captions" | "descriptions" | "chapters" | "metadata";
+ srclang?: string;
+ label?: string;
+ }>;
+};
+
+export const HlsPlayer = React.forwardRef(function HlsPlayer(
+ { src, fallbackSrc, controls = true, autoPlay = false, videoId, subtitles = [] },
+ ref
+) {
+ const internalRef = React.useRef(null);
+ const [error, setError] = React.useState(null);
+
+ // allow parent ref to point to the underlying video
+ React.useImperativeHandle(ref, () => internalRef.current || ({} as HTMLVideoElement), [internalRef.current]);
+
+ React.useEffect(() => {
+ const video = internalRef.current;
+ if (!video) return;
+
+ // avoid attaching multiple Hls instances if src didn't change
+ let hls: Hls | null = null;
+ let hasError = false;
+
+ const loadHls = (sourceUrl: string) => {
+ // Check if it's an HLS URL
+ if (sourceUrl.endsWith('.m3u8')) {
+ if (video.canPlayType("application/vnd.apple.mpegurl")) {
+ // native HLS (Safari)
+ video.src = sourceUrl;
+ } else if (Hls.isSupported()) {
+ hls = new Hls();
+
+ // Handle HLS errors with fallback
+ hls.on(Hls.Events.ERROR, (event, data) => {
+ console.error('HLS Error:', event, data);
+ if (data.fatal) {
+ hasError = true;
+ // Try fallback if available
+ if (fallbackSrc) {
+ console.log('Falling back to MP4:', fallbackSrc);
+ setError(null);
+ loadMp4(fallbackSrc);
+ } else {
+ setError('Failed to load HLS stream');
+ }
+ }
+ });
+
+ hls.loadSource(sourceUrl);
+ hls.attachMedia(video);
+ } else {
+ // HLS not supported, try fallback
+ if (fallbackSrc) {
+ console.log('HLS not supported, using MP4 fallback');
+ loadMp4(fallbackSrc);
+ } else {
+ setError('HLS streaming not supported on this device');
+ // Try to load as MP4 anyway
+ video.src = sourceUrl;
+ }
+ }
+ } else {
+ // Non-HLS URL, load as MP4
+ loadMp4(sourceUrl);
+ }
+ };
+
+ const loadMp4 = (sourceUrl: string) => {
+ if (hls) {
+ hls.destroy();
+ hls = null;
+ }
+ video.src = sourceUrl;
+ };
+
+ loadHls(src);
+
+ // cleanup
+ return () => {
+ if (hls) {
+ hls.destroy();
+ hls = null;
+ }
+ // optionally pause and clear src
+ if (video) {
+ try { video.pause(); } catch {}
+ // video.src = "";
+ }
+ };
+ }, [src, fallbackSrc]);
+
+ return (
+ <>
+ e.preventDefault()}
+ controlsList="nodownload"
+ className="w-full"
+ >
+ {videoId && (
+
+ )}
+ {subtitles.map((subtitle, index) => (
+
+ ))}
+
+ {error && (
+ {error}
+ )}
+ >
+ );
+});
+
+export default HlsPlayer;
diff --git a/components/login-form.tsx b/components/login-form.tsx
new file mode 100644
index 0000000..422cc77
--- /dev/null
+++ b/components/login-form.tsx
@@ -0,0 +1,64 @@
+'use client';
+import { signIn } from 'next-auth/react';
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ Field,
+ FieldDescription,
+ FieldGroup,
+} from '@/components/ui/field';
+
+export function LoginForm({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+
+
+ Welcome
+ Please login with your Virtual Window Google account
+
+
+
+
+
+
+ signIn('google', { callbackUrl: '/dashboard' })
+ }
+ className="w-full flex items-center justify-center gap-2"
+ >
+
+
+
+ Login with Google
+
+
+
+
+
+
+
+ By clicking continue, you agree to our Terms of Service {' '}
+ and Privacy Policy .
+
+
+ );
+}
diff --git a/components/nav-documents.tsx b/components/nav-documents.tsx
new file mode 100644
index 0000000..b551e71
--- /dev/null
+++ b/components/nav-documents.tsx
@@ -0,0 +1,92 @@
+"use client"
+
+import {
+ IconDots,
+ IconFolder,
+ IconShare3,
+ IconTrash,
+ type Icon,
+} from "@tabler/icons-react"
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import {
+ SidebarGroup,
+ SidebarGroupLabel,
+ SidebarMenu,
+ SidebarMenuAction,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ useSidebar,
+} from "@/components/ui/sidebar"
+
+export function NavDocuments({
+ items,
+}: {
+ items: {
+ name: string
+ url: string
+ icon: Icon
+ }[]
+}) {
+ const { isMobile } = useSidebar()
+
+ return (
+
+ Documents
+
+ {items.map((item) => (
+
+
+
+
+ {item.name}
+
+
+
+
+
+
+ More
+
+
+
+
+
+ Open
+
+
+
+ Share
+
+
+
+
+ Delete
+
+
+
+
+ ))}
+
+
+
+ More
+
+
+
+
+ )
+}
diff --git a/components/nav-main.tsx b/components/nav-main.tsx
new file mode 100644
index 0000000..142040e
--- /dev/null
+++ b/components/nav-main.tsx
@@ -0,0 +1,91 @@
+"use client"
+
+import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+} from "@/components/ui/sidebar"
+import { ChevronRight, type LucideIcon } from "lucide-react"
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible"
+
+export function NavMain({
+ items,
+}: {
+ items: {
+ title: string
+ url: string
+ icon?: Icon
+ items?: { title: string; url: string }[]
+ isActive?: boolean
+ }[]
+}) {
+ return (
+
+ Platform
+
+ {items.map((item) => {
+ const hasSubItems = item.items && item.items.length > 0;
+
+ return (
+
+
+
+ {hasSubItems && (
+
+
+ {item.items?.map((subItem) => (
+
+
+
+ {subItem.title}
+
+
+
+ ))}
+
+
+ )}
+
+
+ );
+ })}
+
+
+ )
+}
diff --git a/components/nav-secondary.tsx b/components/nav-secondary.tsx
new file mode 100644
index 0000000..3f3636f
--- /dev/null
+++ b/components/nav-secondary.tsx
@@ -0,0 +1,42 @@
+"use client"
+
+import * as React from "react"
+import { type Icon } from "@tabler/icons-react"
+
+import {
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+} from "@/components/ui/sidebar"
+
+export function NavSecondary({
+ items,
+ ...props
+}: {
+ items: {
+ title: string
+ url: string
+ icon: Icon
+ }[]
+} & React.ComponentPropsWithoutRef) {
+ return (
+
+
+
+ {items.map((item) => (
+
+
+
+
+ {item.title}
+
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/components/nav-user.tsx b/components/nav-user.tsx
new file mode 100644
index 0000000..a913b9f
--- /dev/null
+++ b/components/nav-user.tsx
@@ -0,0 +1,105 @@
+'use client';
+import * as React from 'react';
+import { signOut } from 'next-auth/react';
+import {
+ IconDotsVertical,
+ IconLogout,
+} from '@tabler/icons-react';
+
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from '@/components/ui/avatar';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import {
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ useSidebar,
+} from '@/components/ui/sidebar';
+
+export function NavUser({
+ user,
+}: {
+ user: {
+ name: string;
+ email: string;
+ avatar: string;
+ };
+}) {
+ const { isMobile } = useSidebar();
+
+ return (
+
+
+
+
+
+
+
+ CP
+
+
+ {user.name}
+
+ {user.email}
+
+
+
+
+
+
+
+
+
+
+
+ CN
+
+
+ {user.name}
+
+ {user.email}
+
+
+
+
+
+
+
+ {/* SIGN OUT */}
+ {
+ // Prevent default menu behavior then sign out.
+ e.preventDefault?.();
+ // Redirect to home after sign out. Change callbackUrl as needed.
+ signOut({ redirect: true, callbackUrl: '/' });
+ }}
+ className="cursor-pointer"
+ >
+
+ Sign out
+
+
+
+
+
+ );
+}
diff --git a/components/section-cards.tsx b/components/section-cards.tsx
new file mode 100644
index 0000000..f714d25
--- /dev/null
+++ b/components/section-cards.tsx
@@ -0,0 +1,102 @@
+import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"
+
+import { Badge } from "@/components/ui/badge"
+import {
+ Card,
+ CardAction,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+
+export function SectionCards() {
+ return (
+
+
+
+ Total Revenue
+
+ $1,250.00
+
+
+
+
+ +12.5%
+
+
+
+
+
+ Trending up this month
+
+
+ Visitors for the last 6 months
+
+
+
+
+
+ New Customers
+
+ 1,234
+
+
+
+
+ -20%
+
+
+
+
+
+ Down 20% this period
+
+
+ Acquisition needs attention
+
+
+
+
+
+ Active Accounts
+
+ 45,678
+
+
+
+
+ +12.5%
+
+
+
+
+
+ Strong user retention
+
+ Engagement exceed targets
+
+
+
+
+ Growth Rate
+
+ 4.5%
+
+
+
+
+ +4.5%
+
+
+
+
+
+ Steady performance increase
+
+ Meets growth projections
+
+
+
+ )
+}
diff --git a/components/segmented-progress-bar.tsx b/components/segmented-progress-bar.tsx
new file mode 100644
index 0000000..2d63833
--- /dev/null
+++ b/components/segmented-progress-bar.tsx
@@ -0,0 +1,192 @@
+'use client';
+
+import React from 'react';
+
+export interface WatchSegment {
+ startSec: number;
+ endSec: number;
+ watchedAt: string;
+}
+
+interface SegmentedProgressBarProps {
+ segments: WatchSegment[];
+ duration: number;
+ percent: number;
+ className?: string;
+ height?: 'sm' | 'md' | 'lg';
+ showTooltip?: boolean;
+ interactive?: boolean;
+ onSegmentClick?: (segment: WatchSegment, position: number) => void;
+}
+
+const heightPixels = {
+ sm: '4px',
+ md: '8px',
+ lg: '12px',
+};
+
+export function SegmentedProgressBar({
+ segments,
+ duration,
+ percent,
+ className = '',
+ height = 'md',
+ showTooltip = true,
+ interactive = false,
+ onSegmentClick,
+}: SegmentedProgressBarProps) {
+ const [tooltipPos, setTooltipPos] = React.useState<{ x: number; time: string } | null>(null);
+ const containerRef = React.useRef(null);
+
+ // Normalize segments: merge overlapping ranges
+ const normalizedSegments = React.useMemo(() => {
+ if (segments.length === 0) return [];
+
+ const sorted = [...segments].sort((a, b) => a.startSec - b.startSec);
+ const merged: WatchSegment[] = [];
+
+ for (const seg of sorted) {
+ if (merged.length === 0) {
+ merged.push({ ...seg });
+ } else {
+ const last = merged[merged.length - 1];
+ // Check for overlap or adjacency (within 0.5s)
+ if (seg.startSec <= last.endSec + 0.5) {
+ // Merge
+ last.endSec = Math.max(last.endSec, seg.endSec);
+ } else {
+ // Gap, add new segment
+ merged.push({ ...seg });
+ }
+ }
+ }
+
+ return merged;
+ }, [segments]);
+
+ const handleMouseMove = React.useCallback(
+ (e: React.MouseEvent) => {
+ if (!showTooltip || !containerRef.current) return;
+
+ const rect = containerRef.current.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const percentage = Math.max(0, Math.min(1, x / rect.width));
+ const time = Math.round(percentage * duration);
+
+ setTooltipPos({
+ x,
+ time: formatTime(time),
+ });
+ },
+ [duration, showTooltip]
+ );
+
+ const handleMouseLeave = () => {
+ setTooltipPos(null);
+ };
+
+ const handleClick = (e: React.MouseEvent) => {
+ if (!interactive || !onSegmentClick || !containerRef.current) return;
+
+ const rect = containerRef.current.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const percentage = Math.max(0, Math.min(1, x / rect.width));
+ const position = Math.round(percentage * duration);
+
+ // Find which segment was clicked
+ for (const segment of normalizedSegments) {
+ if (position >= segment.startSec && position <= segment.endSec) {
+ onSegmentClick(segment, position);
+ break;
+ }
+ }
+ };
+
+ const barHeight = heightPixels[height];
+
+ return (
+
+ {/* Main progress bar container */}
+
+ {/* Watched segments */}
+ {normalizedSegments.map((segment, idx) => {
+ const startPercent = (segment.startSec / duration) * 100;
+ const endPercent = (segment.endSec / duration) * 100;
+ const width = endPercent - startPercent;
+
+ return (
+
{
+ e.currentTarget.style.backgroundColor = '#2563eb';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = '#3b82f6';
+ }}
+ />
+ );
+ })}
+
+ {/* Current progress line */}
+ {percent > 0 && (
+
+ )}
+
+
+ {/* Tooltip text - only show if showTooltip is true */}
+ {tooltipPos && showTooltip && (
+
+ {tooltipPos.time} / {formatTime(duration)}
+
+ )}
+
+ );
+}
+
+function formatTime(seconds: number): string {
+ const h = Math.floor(seconds / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ const s = Math.floor(seconds % 60);
+
+ if (h > 0) {
+ return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
+ }
+ return `${m}:${String(s).padStart(2, '0')}`;
+}
diff --git a/components/site-header.tsx b/components/site-header.tsx
new file mode 100644
index 0000000..d801cc3
--- /dev/null
+++ b/components/site-header.tsx
@@ -0,0 +1,116 @@
+'use client';
+
+import { usePathname } from 'next/navigation';
+import { Button } from "@/components/ui/button"
+import { Separator } from "@/components/ui/separator"
+import { SidebarTrigger } from "@/components/ui/sidebar"
+import Link from 'next/link';
+import { ChevronRight } from 'lucide-react';
+
+function getBreadcrumbs(pathname: string) {
+ // Map routes to breadcrumb labels
+ const routeMap: Record
= {
+ '/dashboard': [{ label: 'Dashboard', href: '/dashboard' }],
+ '/dashboard/liked-videos': [
+ { label: 'Dashboard', href: '/dashboard' },
+ { label: 'Liked Videos', href: '/dashboard/liked-videos' }
+ ],
+ '/dashboard/watch-history': [
+ { label: 'Dashboard', href: '/dashboard' },
+ { label: 'Watch History', href: '/dashboard/watch-history' }
+ ],
+ '/videoplayer': [
+ { label: 'Dashboard', href: '/dashboard' },
+ { label: 'Video Player', href: '/videoplayer' }
+ ],
+ '/admin': [{ label: 'Admin Panel', href: '/admin' }],
+ '/admin/users': [
+ { label: 'Admin Panel', href: '/admin' },
+ { label: 'Users', href: '/admin/users' }
+ ],
+ '/admin/courses': [
+ { label: 'Admin Panel', href: '/admin' },
+ { label: 'Courses', href: '/admin/courses' }
+ ],
+ '/admin/playlists': [
+ { label: 'Admin Panel', href: '/admin' },
+ { label: 'Playlists', href: '/admin/playlists' }
+ ],
+ '/admin/videos': [
+ { label: 'Admin Panel', href: '/admin' },
+ { label: 'Videos', href: '/admin/videos' }
+ ],
+ '/admin/enrollments': [
+ { label: 'Admin Panel', href: '/admin' },
+ { label: 'Enrollments', href: '/admin/enrollments' }
+ ],
+ '/login': [{ label: 'Login', href: '/login' }],
+ };
+
+ // Check for exact match first
+ if (routeMap[pathname]) {
+ return routeMap[pathname];
+ }
+
+ // Check for prefix matches (like /admin/users/[userId])
+ for (const [route, breadcrumbs] of Object.entries(routeMap)) {
+ if (pathname.startsWith(route + '/')) {
+ // For dynamic routes like /admin/users/[userId]/...
+ if (route === '/admin/users' && pathname.match(/^\/admin\/users\/[^/]+/)) {
+ return [
+ { label: 'Admin Panel', href: '/admin' },
+ { label: 'Users', href: '/admin/users' },
+ { label: 'User Details', href: pathname.split('/').slice(0, 4).join('/') }
+ ];
+ }
+ if (route === '/admin/videos' && pathname.match(/^\/admin\/videos\/[^/]+/)) {
+ return [
+ { label: 'Admin Panel', href: '/admin' },
+ { label: 'Videos', href: '/admin/videos' },
+ { label: 'Edit Video', href: pathname.split('/').slice(0, 4).join('/') }
+ ];
+ }
+ }
+ }
+
+ // Default fallback
+ return [{ label: 'Library', href: '/' }];
+}
+
+export function SiteHeader() {
+ const pathname = usePathname();
+ const breadcrumbs = getBreadcrumbs(pathname);
+
+ return (
+
+
+
+
+
+ {breadcrumbs.map((crumb, index) => (
+
+ {index > 0 && (
+
+ )}
+ {index === breadcrumbs.length - 1 ? (
+
+ {crumb.label}
+
+ ) : (
+
+ {crumb.label}
+
+ )}
+
+ ))}
+
+
+
+ )
+}
diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx
new file mode 100644
index 0000000..e018a73
--- /dev/null
+++ b/components/theme-provider.tsx
@@ -0,0 +1,11 @@
+"use client"
+
+import * as React from "react"
+import { ThemeProvider as NextThemesProvider } from "next-themes"
+
+export function ThemeProvider({
+ children,
+ ...props
+}: React.ComponentProps) {
+ return {children}
+}
\ No newline at end of file
diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx
new file mode 100644
index 0000000..4a8cca4
--- /dev/null
+++ b/components/ui/accordion.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import * as React from "react"
+import * as AccordionPrimitive from "@radix-ui/react-accordion"
+import { ChevronDownIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Accordion({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AccordionItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AccordionTrigger({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ svg]:rotate-180",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+ )
+}
+
+function AccordionContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..0863e40
--- /dev/null
+++ b/components/ui/alert-dialog.tsx
@@ -0,0 +1,157 @@
+"use client"
+
+import * as React from "react"
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
+
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+
+function AlertDialog({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx
new file mode 100644
index 0000000..71e428b
--- /dev/null
+++ b/components/ui/avatar.tsx
@@ -0,0 +1,53 @@
+"use client"
+
+import * as React from "react"
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
+
+import { cn } from "@/lib/utils"
+
+function Avatar({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
new file mode 100644
index 0000000..fd3a406
--- /dev/null
+++ b/components/ui/badge.tsx
@@ -0,0 +1,46 @@
+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 badgeVariants = cva(
+ "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-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 transition-[color,box-shadow] overflow-hidden",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span"
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000..eb88f32
--- /dev/null
+++ b/components/ui/breadcrumb.tsx
@@ -0,0 +1,109 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { ChevronRight, MoreHorizontal } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
+ return
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
+ return (
+
+ )
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ )
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot : "a"
+
+ return (
+
+ )
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+ svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? }
+
+ )
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+
+ More
+
+ )
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+}
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
new file mode 100644
index 0000000..21409a0
--- /dev/null
+++ b/components/ui/button.tsx
@@ -0,0 +1,60 @@
+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-md 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-background 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-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot : "button"
+
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/components/ui/card.tsx b/components/ui/card.tsx
new file mode 100644
index 0000000..681ad98
--- /dev/null
+++ b/components/ui/card.tsx
@@ -0,0 +1,92 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/components/ui/carousel.tsx b/components/ui/carousel.tsx
new file mode 100644
index 0000000..0e05a77
--- /dev/null
+++ b/components/ui/carousel.tsx
@@ -0,0 +1,241 @@
+"use client"
+
+import * as React from "react"
+import useEmblaCarousel, {
+ type UseEmblaCarouselType,
+} from "embla-carousel-react"
+import { ArrowLeft, ArrowRight } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+
+type CarouselApi = UseEmblaCarouselType[1]
+type UseCarouselParameters = Parameters
+type CarouselOptions = UseCarouselParameters[0]
+type CarouselPlugin = UseCarouselParameters[1]
+
+type CarouselProps = {
+ opts?: CarouselOptions
+ plugins?: CarouselPlugin
+ orientation?: "horizontal" | "vertical"
+ setApi?: (api: CarouselApi) => void
+}
+
+type CarouselContextProps = {
+ carouselRef: ReturnType[0]
+ api: ReturnType[1]
+ scrollPrev: () => void
+ scrollNext: () => void
+ canScrollPrev: boolean
+ canScrollNext: boolean
+} & CarouselProps
+
+const CarouselContext = React.createContext(null)
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext)
+
+ if (!context) {
+ throw new Error("useCarousel must be used within a ")
+ }
+
+ return context
+}
+
+function Carousel({
+ orientation = "horizontal",
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & CarouselProps) {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === "horizontal" ? "x" : "y",
+ },
+ plugins
+ )
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false)
+ const [canScrollNext, setCanScrollNext] = React.useState(false)
+
+ const onSelect = React.useCallback((api: CarouselApi) => {
+ if (!api) return
+ setCanScrollPrev(api.canScrollPrev())
+ setCanScrollNext(api.canScrollNext())
+ }, [])
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev()
+ }, [api])
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext()
+ }, [api])
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault()
+ scrollPrev()
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault()
+ scrollNext()
+ }
+ },
+ [scrollPrev, scrollNext]
+ )
+
+ React.useEffect(() => {
+ if (!api || !setApi) return
+ setApi(api)
+ }, [api, setApi])
+
+ React.useEffect(() => {
+ if (!api) return
+ onSelect(api)
+ api.on("reInit", onSelect)
+ api.on("select", onSelect)
+
+ return () => {
+ api?.off("select", onSelect)
+ }
+ }, [api, onSelect])
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
+ const { carouselRef, orientation } = useCarousel()
+
+ return (
+
+ )
+}
+
+function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
+ const { orientation } = useCarousel()
+
+ return (
+
+ )
+}
+
+function CarouselPrevious({
+ className,
+ variant = "outline",
+ size = "icon",
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel()
+
+ return (
+
+
+ Previous slide
+
+ )
+}
+
+function CarouselNext({
+ className,
+ variant = "outline",
+ size = "icon",
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollNext, canScrollNext } = useCarousel()
+
+ return (
+
+
+ Next slide
+
+ )
+}
+
+export {
+ type CarouselApi,
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselPrevious,
+ CarouselNext,
+}
diff --git a/components/ui/chart.tsx b/components/ui/chart.tsx
new file mode 100644
index 0000000..8b42f21
--- /dev/null
+++ b/components/ui/chart.tsx
@@ -0,0 +1,357 @@
+"use client"
+
+import * as React from "react"
+import * as RechartsPrimitive from "recharts"
+
+import { cn } from "@/lib/utils"
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const
+
+export type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode
+ icon?: React.ComponentType
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record }
+ )
+}
+
+type ChartContextProps = {
+ config: ChartConfig
+}
+
+const ChartContext = React.createContext(null)
+
+function useChart() {
+ const context = React.useContext(ChartContext)
+
+ if (!context) {
+ throw new Error("useChart must be used within a ")
+ }
+
+ return context
+}
+
+function ChartContainer({
+ id,
+ className,
+ children,
+ config,
+ ...props
+}: React.ComponentProps<"div"> & {
+ config: ChartConfig
+ children: React.ComponentProps<
+ typeof RechartsPrimitive.ResponsiveContainer
+ >["children"]
+}) {
+ const uniqueId = React.useId()
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ )
+}
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(
+ ([, config]) => config.theme || config.color
+ )
+
+ if (!colorConfig.length) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/next.svg b/public/next.svg
new file mode 100644
index 0000000..5174b28
--- /dev/null
+++ b/public/next.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/vault.exr b/public/vault.exr
new file mode 100644
index 0000000..cbb6703
Binary files /dev/null and b/public/vault.exr differ
diff --git a/public/vault.png b/public/vault.png
new file mode 100644
index 0000000..539b472
Binary files /dev/null and b/public/vault.png differ
diff --git a/public/vercel.svg b/public/vercel.svg
new file mode 100644
index 0000000..7705396
--- /dev/null
+++ b/public/vercel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/window.svg b/public/window.svg
new file mode 100644
index 0000000..b2b2a44
--- /dev/null
+++ b/public/window.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/run transcode.md b/run transcode.md
new file mode 100644
index 0000000..a7c1235
--- /dev/null
+++ b/run transcode.md
@@ -0,0 +1,5 @@
+docker run --rm `
+ -v V:/:/uploads `
+ --env UPLOADS_DIR=/uploads `
+ --env DATABASE_URL=postgresql://postgres:postgres@192.168.0.107:8084/cms?schema=public `
+ vault-transcoder
diff --git a/scripts/check-db-integrity.ts b/scripts/check-db-integrity.ts
new file mode 100644
index 0000000..33fe59e
--- /dev/null
+++ b/scripts/check-db-integrity.ts
@@ -0,0 +1,171 @@
+import { PrismaClient } from '@prisma/client';
+
+const prisma = new PrismaClient();
+
+async function validateDatabaseIntegrity() {
+ console.log('🔍 Running comprehensive database integrity check...');
+
+ try {
+ // 1. Check Video table integrity
+ console.log('\n📹 Checking Video table...');
+ const videos = await prisma.video.findMany({
+ include: {
+ playlist: true,
+ uploader: true,
+ _count: {
+ select: {
+ comments: true,
+ likes: true,
+ progress: true,
+ unlocks: true,
+ watchSegments: true,
+ videoCourses: true
+ }
+ }
+ }
+ });
+ console.log(`✅ Found ${videos.length} videos`);
+
+ // 2. Check for videos with missing playlists
+ const videosWithMissingPlaylists = videos.filter(v => !v.playlist);
+ if (videosWithMissingPlaylists.length > 0) {
+ console.log(`⚠️ Found ${videosWithMissingPlaylists.length} videos with missing playlists:`);
+ videosWithMissingPlaylists.forEach(v => {
+ console.log(` - Video ID: ${v.id}, Title: "${v.title}", Playlist ID: ${v.playlistId}`);
+ });
+ }
+
+ // 3. Check for playlists with no videos
+ console.log('\n📋 Checking Playlist table...');
+ const playlists = await prisma.playlist.findMany({
+ include: {
+ videos: true,
+ course: true,
+ _count: {
+ select: {
+ videos: true
+ }
+ }
+ }
+ });
+ console.log(`✅ Found ${playlists.length} playlists`);
+
+ const emptyPlaylists = playlists.filter(p => p.videos.length === 0);
+ if (emptyPlaylists.length > 0) {
+ console.log(`⚠️ Found ${emptyPlaylists.length} empty playlists:`);
+ emptyPlaylists.forEach(p => {
+ console.log(` - Playlist ID: ${p.id}, Title: "${p.title}", Course: ${p.course?.title || 'Unknown'}`);
+ });
+ }
+
+ // 4. Check VideoProgress integrity
+ console.log('\n📊 Checking VideoProgress table...');
+ const progressCount = await prisma.videoProgress.count();
+ console.log(`✅ Found ${progressCount} progress records`);
+
+ // Check for progress records with invalid video references
+ const invalidProgress = await prisma.$queryRaw`
+ SELECT vp.id, vp."videoId", vp."userId"
+ FROM "VideoProgress" vp
+ LEFT JOIN "Video" v ON vp."videoId" = v.id
+ WHERE v.id IS NULL
+ ` as Array<{id: string, videoId: string, userId: string}>;
+
+ if (invalidProgress.length > 0) {
+ console.log(`❌ Found ${invalidProgress.length} progress records with invalid video references:`);
+ invalidProgress.forEach(p => {
+ console.log(` - Progress ID: ${p.id}, Video ID: ${p.videoId}, User ID: ${p.userId}`);
+ });
+ }
+
+ // 5. Check VideoWatchSegment integrity
+ console.log('\n⏱️ Checking VideoWatchSegment table...');
+ const segmentsCount = await prisma.videoWatchSegment.count();
+ console.log(`✅ Found ${segmentsCount} watch segments`);
+
+ // Check for segments with invalid video references
+ const invalidSegments = await prisma.$queryRaw`
+ SELECT vws.id, vws."videoId", vws."userId"
+ FROM "VideoWatchSegment" vws
+ LEFT JOIN "Video" v ON vws."videoId" = v.id
+ WHERE v.id IS NULL
+ ` as Array<{id: string, videoId: string, userId: string}>;
+
+ if (invalidSegments.length > 0) {
+ console.log(`❌ Found ${invalidSegments.length} watch segments with invalid video references:`);
+ invalidSegments.forEach(s => {
+ console.log(` - Segment ID: ${s.id}, Video ID: ${s.videoId}, User ID: ${s.userId}`);
+ });
+ }
+
+ // 6. Check for duplicate video indexes within playlists
+ console.log('\n🔄 Checking for duplicate video indexes...');
+ for (const playlist of playlists) {
+ if (playlist.videos.length > 0) {
+ const indexes = playlist.videos.map(v => v.index);
+ const uniqueIndexes = [...new Set(indexes)];
+ if (indexes.length !== uniqueIndexes.length) {
+ console.log(`⚠️ Playlist "${playlist.title}" has duplicate video indexes:`);
+ const duplicates = indexes.filter((item, index) => indexes.indexOf(item) !== index);
+ console.log(` - Duplicate indexes: ${duplicates.join(', ')}`);
+ }
+ }
+ }
+
+ // 7. Check Enrollment integrity
+ console.log('\n📚 Checking Enrollment table...');
+ const enrollments = await prisma.enrollment.findMany({
+ include: {
+ user: true,
+ course: true
+ }
+ });
+ console.log(`✅ Found ${enrollments.length} enrollments`);
+
+ const enrollmentsWithMissingUser = enrollments.filter(e => !e.user);
+ const enrollmentsWithMissingCourse = enrollments.filter(e => !e.course);
+
+ if (enrollmentsWithMissingUser.length > 0) {
+ console.log(`❌ Found ${enrollmentsWithMissingUser.length} enrollments with missing users`);
+ }
+ if (enrollmentsWithMissingCourse.length > 0) {
+ console.log(`❌ Found ${enrollmentsWithMissingCourse.length} enrollments with missing courses`);
+ }
+
+ // 8. Summary
+ console.log('\n📋 Database Integrity Summary:');
+ console.log(` Videos: ${videos.length}`);
+ console.log(` Playlists: ${playlists.length} (${emptyPlaylists.length} empty)`);
+ console.log(` Progress Records: ${progressCount}`);
+ console.log(` Watch Segments: ${segmentsCount}`);
+ console.log(` Enrollments: ${enrollments.length}`);
+
+ const hasIssues = videosWithMissingPlaylists.length > 0 ||
+ invalidProgress.length > 0 ||
+ invalidSegments.length > 0 ||
+ enrollmentsWithMissingUser.length > 0 ||
+ enrollmentsWithMissingCourse.length > 0;
+
+ if (!hasIssues) {
+ console.log('✨ All integrity checks passed! Database appears healthy.');
+ } else {
+ console.log('⚠️ Some integrity issues found. See details above.');
+ }
+
+ } catch (error) {
+ console.error('❌ Error during integrity check:', error);
+ } finally {
+ await prisma.$disconnect();
+ }
+}
+
+// Run the integrity check
+validateDatabaseIntegrity()
+ .then(() => {
+ console.log('🔧 Database integrity check completed');
+ process.exit(0);
+ })
+ .catch((error) => {
+ console.error('💥 Integrity check failed:', error);
+ process.exit(1);
+ });
\ No newline at end of file
diff --git a/scripts/cleanup-completed-progress.ts b/scripts/cleanup-completed-progress.ts
new file mode 100644
index 0000000..9165f9b
--- /dev/null
+++ b/scripts/cleanup-completed-progress.ts
@@ -0,0 +1,59 @@
+import { PrismaClient } from '@prisma/client';
+
+const prisma = new PrismaClient();
+
+async function cleanupCompletedProgress() {
+ console.log('🧹 Cleaning up progress data for completed videos...');
+
+ try {
+ // Find all completed video progress records
+ const completedProgress = await prisma.videoProgress.findMany({
+ where: { completed: true },
+ select: { id: true, userId: true, videoId: true },
+ });
+
+ console.log(`📊 Found ${completedProgress.length} completed videos`);
+
+ if (completedProgress.length === 0) {
+ console.log('✅ No completed videos to clean up');
+ return;
+ }
+
+ let totalSegmentsDeleted = 0;
+
+ // For each completed video, delete its watch segments
+ // (we keep the VideoProgress record for history, but clean up the granular segment data)
+ for (const progress of completedProgress) {
+ const deletedSegments = await prisma.videoWatchSegment.deleteMany({
+ where: {
+ userId: progress.userId,
+ videoId: progress.videoId,
+ },
+ });
+
+ totalSegmentsDeleted += deletedSegments.count;
+
+ if (deletedSegments.count > 0) {
+ console.log(
+ ` ✓ Deleted ${deletedSegments.count} segments for video ${progress.videoId}`
+ );
+ }
+ }
+
+ console.log(`\n📈 Cleanup Summary:`);
+ console.log(` - Completed videos processed: ${completedProgress.length}`);
+ console.log(` - Watch segments deleted: ${totalSegmentsDeleted}`);
+ console.log(`✅ Cleanup complete!`);
+
+ } catch (err: any) {
+ console.error('❌ Error during cleanup:', err.message);
+ throw err;
+ } finally {
+ await prisma.$disconnect();
+ }
+}
+
+cleanupCompletedProgress().catch((err) => {
+ console.error('Fatal error:', err);
+ process.exit(1);
+});
diff --git a/scripts/cleanup-orphaned-data.ts b/scripts/cleanup-orphaned-data.ts
new file mode 100644
index 0000000..9778ced
--- /dev/null
+++ b/scripts/cleanup-orphaned-data.ts
@@ -0,0 +1,143 @@
+import { PrismaClient } from '@prisma/client';
+
+const prisma = new PrismaClient();
+
+async function cleanupOrphanedData() {
+ console.log('🔍 Checking for orphaned video references...');
+
+ try {
+ // Get all existing video IDs
+ const existingVideos = await prisma.video.findMany({
+ select: { id: true }
+ });
+ const existingVideoIds = new Set(existingVideos.map(v => v.id));
+ console.log(`📹 Found ${existingVideoIds.size} existing videos`);
+
+ let totalCleaned = 0;
+
+ // 1. Clean orphaned VideoProgress records
+ const allProgress = await prisma.videoProgress.findMany({
+ select: { id: true, videoId: true, userId: true }
+ });
+ const orphanedProgress = allProgress.filter(p => !existingVideoIds.has(p.videoId));
+
+ if (orphanedProgress.length > 0) {
+ console.log(`🧹 Found ${orphanedProgress.length} orphaned VideoProgress records`);
+ await prisma.videoProgress.deleteMany({
+ where: {
+ id: { in: orphanedProgress.map(p => p.id) }
+ }
+ });
+ console.log(`✅ Deleted ${orphanedProgress.length} orphaned VideoProgress records`);
+ totalCleaned += orphanedProgress.length;
+ }
+
+ // 2. Clean orphaned VideoWatchSegment records
+ const allSegments = await prisma.videoWatchSegment.findMany({
+ select: { id: true, videoId: true, userId: true }
+ });
+ const orphanedSegments = allSegments.filter(s => !existingVideoIds.has(s.videoId));
+
+ if (orphanedSegments.length > 0) {
+ console.log(`🧹 Found ${orphanedSegments.length} orphaned VideoWatchSegment records`);
+ await prisma.videoWatchSegment.deleteMany({
+ where: {
+ id: { in: orphanedSegments.map(s => s.id) }
+ }
+ });
+ console.log(`✅ Deleted ${orphanedSegments.length} orphaned VideoWatchSegment records`);
+ totalCleaned += orphanedSegments.length;
+ }
+
+ // 3. Clean orphaned VideoUnlock records
+ const allUnlocks = await prisma.videoUnlock.findMany({
+ select: { id: true, videoId: true, userId: true }
+ });
+ const orphanedUnlocks = allUnlocks.filter(u => !existingVideoIds.has(u.videoId));
+
+ if (orphanedUnlocks.length > 0) {
+ console.log(`🧹 Found ${orphanedUnlocks.length} orphaned VideoUnlock records`);
+ await prisma.videoUnlock.deleteMany({
+ where: {
+ id: { in: orphanedUnlocks.map(u => u.id) }
+ }
+ });
+ console.log(`✅ Deleted ${orphanedUnlocks.length} orphaned VideoUnlock records`);
+ totalCleaned += orphanedUnlocks.length;
+ }
+
+ // 4. Clean orphaned VideoLike records
+ const allLikes = await prisma.videoLike.findMany({
+ select: { id: true, videoId: true, userId: true }
+ });
+ const orphanedLikes = allLikes.filter(l => !existingVideoIds.has(l.videoId));
+
+ if (orphanedLikes.length > 0) {
+ console.log(`🧹 Found ${orphanedLikes.length} orphaned VideoLike records`);
+ await prisma.videoLike.deleteMany({
+ where: {
+ id: { in: orphanedLikes.map(l => l.id) }
+ }
+ });
+ console.log(`✅ Deleted ${orphanedLikes.length} orphaned VideoLike records`);
+ totalCleaned += orphanedLikes.length;
+ }
+
+ // 5. Clean orphaned Comment records
+ const allComments = await prisma.comment.findMany({
+ select: { id: true, videoId: true, userId: true }
+ });
+ const orphanedComments = allComments.filter(c => !existingVideoIds.has(c.videoId));
+
+ if (orphanedComments.length > 0) {
+ console.log(`🧹 Found ${orphanedComments.length} orphaned Comment records`);
+ await prisma.comment.deleteMany({
+ where: {
+ id: { in: orphanedComments.map(c => c.id) }
+ }
+ });
+ console.log(`✅ Deleted ${orphanedComments.length} orphaned Comment records`);
+ totalCleaned += orphanedComments.length;
+ }
+
+ // 6. Clean orphaned VideoCourse records
+ const allVideoCourses = await prisma.videoCourse.findMany({
+ select: { id: true, videoId: true, courseId: true }
+ });
+ const orphanedVideoCourses = allVideoCourses.filter(vc => !existingVideoIds.has(vc.videoId));
+
+ if (orphanedVideoCourses.length > 0) {
+ console.log(`🧹 Found ${orphanedVideoCourses.length} orphaned VideoCourse records`);
+ await prisma.videoCourse.deleteMany({
+ where: {
+ id: { in: orphanedVideoCourses.map(vc => vc.id) }
+ }
+ });
+ console.log(`✅ Deleted ${orphanedVideoCourses.length} orphaned VideoCourse records`);
+ totalCleaned += orphanedVideoCourses.length;
+ }
+
+ // 7. Report summary
+ if (totalCleaned === 0) {
+ console.log('✨ No orphaned video references found! Database is clean.');
+ } else {
+ console.log(`🎉 Cleanup complete! Removed ${totalCleaned} total orphaned records.`);
+ }
+
+ } catch (error) {
+ console.error('❌ Error during cleanup:', error);
+ } finally {
+ await prisma.$disconnect();
+ }
+}
+
+// Run the cleanup
+cleanupOrphanedData()
+ .then(() => {
+ console.log('🔧 Cleanup script completed');
+ process.exit(0);
+ })
+ .catch((error) => {
+ console.error('💥 Script failed:', error);
+ process.exit(1);
+ });
\ No newline at end of file
diff --git a/scripts/update-thumbnail-urls.ts b/scripts/update-thumbnail-urls.ts
new file mode 100644
index 0000000..3e0c757
--- /dev/null
+++ b/scripts/update-thumbnail-urls.ts
@@ -0,0 +1,26 @@
+// scripts/update-thumbnail-urls.ts
+// Update all existing thumbnail URLs from /uploads/thumbnails/ to /api/thumbnails/
+
+import { prisma } from "@/lib/prisma";
+
+async function main() {
+ try {
+ console.log("Updating thumbnail URLs...");
+
+ const result = await prisma.$executeRawUnsafe(
+ `UPDATE "Video"
+ SET thumbnail = REPLACE(thumbnail, '/uploads/thumbnails/', '/api/thumbnails/')
+ WHERE thumbnail IS NOT NULL
+ AND thumbnail LIKE '/uploads/thumbnails/%'`
+ );
+
+ console.log(`✓ Updated ${result} video records`);
+ } catch (error) {
+ console.error("Error updating thumbnail URLs:", error);
+ process.exit(1);
+ } finally {
+ await prisma.$disconnect();
+ }
+}
+
+main();
diff --git a/setup-transcoding.bat b/setup-transcoding.bat
new file mode 100644
index 0000000..316bb88
--- /dev/null
+++ b/setup-transcoding.bat
@@ -0,0 +1,161 @@
+@echo off
+REM HLS Transcoding Setup Script for Windows
+REM This script initializes the HLS transcoding infrastructure
+
+setlocal enabledelayedexpansion
+
+echo.
+echo ===========================================
+echo HLS Transcoding Setup Script for Windows
+echo ===========================================
+echo.
+
+REM Check if .env file exists
+if not exist ".env" (
+ echo ⚠ .env file not found
+ echo Creating .env from .env.example...
+ copy .env.example .env
+ echo ✓ .env file created
+ echo.
+ echo Please edit .env and configure your settings before proceeding
+ echo.
+)
+
+echo Step 1: Checking prerequisites
+echo.
+
+REM Check if Docker is installed
+docker --version >nul 2>&1
+if %errorlevel% neq 0 (
+ echo ✗ Docker is not installed
+ echo Please install Docker Desktop from https://www.docker.com/products/docker-desktop
+ exit /b 1
+)
+echo ✓ Docker is installed (%docker_version%)
+
+REM Check if Docker Compose is installed
+docker-compose --version >nul 2>&1
+if %errorlevel% neq 0 (
+ echo ✗ Docker Compose is not installed or not accessible
+ echo Make sure Docker Desktop includes Docker Compose (it does by default)
+ exit /b 1
+)
+echo ✓ Docker Compose is installed
+
+echo.
+echo Step 2: Creating required directories
+echo.
+
+REM Create uploads directory structure
+set UPLOADS_DIR=C:\Users\%USERNAME%\college-uploads
+if not exist "%UPLOADS_DIR%\videos" mkdir "%UPLOADS_DIR%\videos"
+if not exist "%UPLOADS_DIR%\hls" mkdir "%UPLOADS_DIR%\hls"
+
+echo ✓ Created %UPLOADS_DIR%\videos
+echo ✓ Created %UPLOADS_DIR%\hls
+
+REM Create PostgreSQL data directory
+set PG_DIR=C:\ProgramData\college-postgres
+if not exist "%PG_DIR%" mkdir "%PG_DIR%"
+
+echo ✓ Created PostgreSQL data directory
+
+echo.
+echo Step 3: Building Docker images
+echo.
+
+REM Build the transcoder image
+echo Building transcoder image...
+docker-compose build transcoder
+if %errorlevel% neq 0 (
+ echo ✗ Failed to build transcoder image
+ exit /b 1
+)
+echo ✓ Transcoder image built
+
+echo.
+echo Step 4: Starting services
+echo.
+
+REM Start all services
+docker-compose up -d
+if %errorlevel% neq 0 (
+ echo ✗ Failed to start services
+ exit /b 1
+)
+echo ✓ Services started
+
+echo.
+echo Step 5: Waiting for services to be ready
+echo.
+
+REM Wait for PostgreSQL (max 30 seconds)
+echo Waiting for PostgreSQL...
+set "retry=0"
+:wait_postgres
+if %retry% geq 30 goto postgres_timeout
+docker-compose exec -T postgres pg_isready -U cms_user >nul 2>&1
+if %errorlevel% neq 0 (
+ set /a retry+=1
+ timeout /t 1 /nobreak >nul
+ goto wait_postgres
+)
+echo ✓ PostgreSQL is ready
+goto skip_postgres_timeout
+
+:postgres_timeout
+echo ⚠ PostgreSQL did not become ready in time
+goto skip_postgres_timeout
+
+:skip_postgres_timeout
+
+REM Wait for CMS (max 60 seconds)
+echo Waiting for CMS application...
+set "retry=0"
+:wait_cms
+if %retry% geq 60 goto cms_timeout
+curl -s http://localhost:3000 >nul 2>&1
+if %errorlevel% neq 0 (
+ set /a retry+=1
+ timeout /t 1 /nobreak >nul
+ goto wait_cms
+)
+echo ✓ CMS is ready
+goto skip_cms_timeout
+
+:cms_timeout
+echo ⚠ CMS did not become ready in time
+
+:skip_cms_timeout
+
+echo.
+echo Step 6: Running database migration
+echo.
+
+docker-compose exec -T cms npx prisma migrate deploy
+if %errorlevel% neq 0 (
+ echo ⚠ Database migration had issues, but setup is proceeding
+) else (
+ echo ✓ Database migration completed
+)
+
+echo.
+echo ===========================================
+echo Setup Complete!
+echo ===========================================
+echo.
+echo Services are now running:
+echo * CMS: http://localhost:3000
+echo * PostgreSQL: localhost:5432
+echo * Transcoder: (background service)
+echo.
+echo Next steps:
+echo 1. Visit http://localhost:3000 and sign in
+echo 2. Upload MP4 files as an admin user
+echo 3. Watch the transcoder logs: docker-compose logs -f transcoder
+echo 4. Check HLS files: dir "%UPLOADS_DIR%\hls\{video_id}"
+echo.
+echo For more information, see TRANSCODING.md
+echo.
+echo Note: To configure custom paths, edit .env before running docker-compose commands
+echo.
diff --git a/setup-transcoding.sh b/setup-transcoding.sh
new file mode 100644
index 0000000..1f1afa0
--- /dev/null
+++ b/setup-transcoding.sh
@@ -0,0 +1,150 @@
+#!/bin/bash
+
+# HLS Transcoding Setup Script
+# This script initializes the HLS transcoding infrastructure
+
+set -e
+
+echo "==========================================="
+echo "HLS Transcoding Setup Script"
+echo "==========================================="
+echo ""
+
+# Colors for output
+GREEN='\033[0;32m'
+BLUE='\033[0;34m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+# Check if .env file exists
+if [ ! -f .env ]; then
+ echo -e "${YELLOW}⚠ .env file not found${NC}"
+ echo "Creating .env from .env.example..."
+ cp .env.example .env
+ echo -e "${GREEN}✓ .env file created${NC}"
+ echo ""
+ echo -e "${YELLOW}Please edit .env and configure your settings before proceeding${NC}"
+ echo ""
+fi
+
+echo -e "${BLUE}Step 1: Checking prerequisites${NC}"
+
+# Check if Docker is installed
+if ! command -v docker &> /dev/null; then
+ echo -e "${YELLOW}✗ Docker is not installed${NC}"
+ echo "Please install Docker from https://www.docker.com/products/docker-desktop"
+ exit 1
+fi
+echo -e "${GREEN}✓ Docker is installed${NC}"
+
+# Check if Docker Compose is installed
+if ! command -v docker-compose &> /dev/null; then
+ echo -e "${YELLOW}✗ Docker Compose is not installed${NC}"
+ echo "Please install Docker Compose or use 'docker compose'"
+ exit 1
+fi
+echo -e "${GREEN}✓ Docker Compose is installed${NC}"
+
+# Check if Node.js is installed
+if ! command -v node &> /dev/null; then
+ echo -e "${YELLOW}⚠ Node.js is not installed (optional for dev mode)${NC}"
+else
+ echo -e "${GREEN}✓ Node.js is installed ($(node --version))${NC}"
+fi
+
+echo ""
+echo -e "${BLUE}Step 2: Creating required directories${NC}"
+
+# Create uploads directory structure
+if [ -n "$UPLOADS_PATH" ]; then
+ UPLOADS_DIR="$UPLOADS_PATH"
+else
+ UPLOADS_DIR="/mnt/tank/apps/college-platform/uploads"
+fi
+
+mkdir -p "$UPLOADS_DIR/videos"
+mkdir -p "$UPLOADS_DIR/hls"
+sudo chown -R 1000:1000 "$UPLOADS_DIR" 2>/dev/null || true
+chmod -R 755 "$UPLOADS_DIR"
+
+echo -e "${GREEN}✓ Created $UPLOADS_DIR/videos${NC}"
+echo -e "${GREEN}✓ Created $UPLOADS_DIR/hls${NC}"
+
+# Create PostgreSQL data directory
+if [ -n "$POSTGRES_DATA_PATH" ]; then
+ PG_DIR="$POSTGRES_DATA_PATH"
+else
+ PG_DIR="/mnt/tank/apps/college-platform/postgres_data"
+fi
+
+mkdir -p "$PG_DIR"
+chmod 700 "$PG_DIR"
+
+echo -e "${GREEN}✓ Created PostgreSQL data directory${NC}"
+
+echo ""
+echo -e "${BLUE}Step 3: Building Docker images${NC}"
+
+# Build the transcoder image
+docker-compose build transcoder 2>&1 | tail -20
+echo -e "${GREEN}✓ Transcoder image built${NC}"
+
+echo ""
+echo -e "${BLUE}Step 4: Starting services${NC}"
+
+# Start all services
+docker-compose up -d
+
+echo -e "${GREEN}✓ Services started${NC}"
+
+echo ""
+echo -e "${BLUE}Step 5: Waiting for services to be ready${NC}"
+
+# Wait for PostgreSQL
+echo "Waiting for PostgreSQL..."
+for i in {1..30}; do
+ if docker-compose exec -T postgres pg_isready -U cms_user &> /dev/null; then
+ echo -e "${GREEN}✓ PostgreSQL is ready${NC}"
+ break
+ fi
+ echo -n "."
+ sleep 1
+done
+
+# Wait for CMS
+echo "Waiting for CMS application..."
+for i in {1..60}; do
+ if docker-compose exec -T cms wget --quiet --spider http://localhost:3000 2>/dev/null; then
+ echo -e "${GREEN}✓ CMS is ready${NC}"
+ break
+ fi
+ echo -n "."
+ sleep 1
+done
+
+# Run Prisma migration
+echo ""
+echo -e "${BLUE}Step 6: Running database migration${NC}"
+
+docker-compose exec -T cms npx prisma migrate deploy
+
+echo -e "${GREEN}✓ Database migration completed${NC}"
+
+echo ""
+echo "==========================================="
+echo -e "${GREEN}Setup Complete!${NC}"
+echo "==========================================="
+echo ""
+echo "Services are now running:"
+echo " • CMS: http://localhost:3000"
+echo " • PostgreSQL: localhost:5432"
+echo " • Transcoder: (background service)"
+echo ""
+echo "Next steps:"
+echo " 1. Visit http://localhost:3000 and sign in"
+echo " 2. Upload MP4 files as an admin user"
+echo " 3. Watch the transcoder logs: docker-compose logs -f transcoder"
+echo " 4. Check HLS files: ls -la $UPLOADS_DIR/hls/{video_id}/"
+echo ""
+echo "For more information, see TRANSCODING.md"
+echo ""
diff --git a/tailwind.config.cjs b/tailwind.config.cjs
new file mode 100644
index 0000000..23c550d
--- /dev/null
+++ b/tailwind.config.cjs
@@ -0,0 +1,13 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ darkMode: "class",
+ content: [
+ "./app/**/*.{ts,tsx,js,jsx}",
+ "./pages/**/*.{ts,tsx,js,jsx}",
+ "./components/**/*.{ts,tsx,js,jsx}"
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+};
diff --git a/transcoder-remote/Dockerfile b/transcoder-remote/Dockerfile
new file mode 100644
index 0000000..631af58
--- /dev/null
+++ b/transcoder-remote/Dockerfile
@@ -0,0 +1,44 @@
+# syntax=docker/dockerfile:1
+
+# ---------------------------------------------------------------------------
+# Stage 1 – Build TypeScript
+# ---------------------------------------------------------------------------
+FROM node:20-alpine AS builder
+
+WORKDIR /app
+
+COPY package*.json ./
+RUN npm install
+
+COPY tsconfig.json ./
+COPY src/ ./src/
+
+RUN npm run build
+
+# ---------------------------------------------------------------------------
+# Stage 2 – Runtime image
+# ---------------------------------------------------------------------------
+FROM node:20-alpine AS runner
+
+# Install FFmpeg (includes ffprobe).
+RUN apk add --no-cache ffmpeg
+
+WORKDIR /app
+
+# Production dependencies only.
+COPY package*.json ./
+RUN npm install --omit=dev
+
+# Compiled output from builder stage.
+COPY --from=builder /app/dist ./dist
+
+# Non-root user for defence-in-depth.
+RUN addgroup -g 1001 -S transcoder \
+ && adduser -u 1001 -S transcoder -G transcoder
+
+# Work directory the worker uses for temp files.
+RUN mkdir -p /work && chown transcoder:transcoder /work
+
+USER transcoder
+
+CMD ["npm", "run", "start"]
diff --git a/transcoder-remote/README.md b/transcoder-remote/README.md
new file mode 100644
index 0000000..d3f3993
--- /dev/null
+++ b/transcoder-remote/README.md
@@ -0,0 +1,196 @@
+# Remote Transcoder Worker
+
+Runs on **Windows Docker Desktop** (or any machine with Docker).
+Pulls video jobs from the Hetzner CMS over HTTPS, transcodes them locally using
+all available CPU cores, and pushes the finished HLS package back — no SSH,
+no shared drives, no VPS CPU load.
+
+---
+
+## How it works
+
+```
+Docker Desktop (local) Hetzner VPS (CMS)
+────────────────────── ─────────────────
+1. POST /api/transcoder/claim → Lock next uploaded video
+ ← { videoId, downloadUrl }
+2. GET /api/transcoder/download/:id → Stream source MP4
+3. ffmpeg (local CPU)
+ Generate HLS segments + playlists
+4. Create ZIP of HLS output
+5. POST /api/transcoder/upload/:id → Extract ZIP, validate, rename
+ Update status → "transcoded"
+6. Repeat until queue empty, exit 0
+```
+
+---
+
+## Prerequisites
+
+| Requirement | Notes |
+|---|---|
+| Docker Desktop | Windows, Mac, or Linux |
+| CMS environment variable `TRANSCODER_SECRET` | Add to CMS `.env` and redeploy |
+| CMS redeployed with new API routes | See **CMS changes** section |
+
+---
+
+## Setup
+
+### 1 — Add `TRANSCODER_SECRET` to the CMS
+
+In your Hetzner CMS `.env` (or however you manage secrets):
+
+```env
+TRANSCODER_SECRET=replace-with-a-strong-random-secret
+```
+
+Generate a secret with:
+```bash
+openssl rand -hex 32
+```
+
+Redeploy the CMS so the new API routes are live.
+
+### 2 — Configure the worker
+
+```bash
+cd transcoder-remote
+cp .env.example .env
+# Edit .env and fill in CMS_URL and TRANSCODER_SECRET
+```
+
+`.env` example:
+```env
+CMS_URL=https://cms.yourdomain.com
+TRANSCODER_SECRET=replace-with-a-strong-random-secret
+```
+
+---
+
+## Running
+
+### Build and run (one command)
+
+```powershell
+docker compose up --build
+```
+
+The container will:
+1. Build the TypeScript worker
+2. Start processing all queued videos
+3. Exit automatically when the queue is empty
+
+### Run again later (no rebuild)
+
+```powershell
+docker compose up
+```
+
+### Run without docker-compose
+
+```powershell
+docker build -t transcoder-remote .
+docker run --rm --env-file .env transcoder-remote
+```
+
+---
+
+## Log output
+
+```
+[Worker] Remote Transcoder starting
+[Worker] CMS : https://cms.yourdomain.com
+[Worker] Work dir: /work
+
+[Job] cm1a2b3c4d5e6f7g8h9i0j
+
+[Download] Starting...
+[Download] 312 MB received...
+[Download] 624 MB in 38s
+
+[Probe] Resolution: 1920x1080
+[HLS] Creating 1080p
+[HLS] Creating 720p
+[HLS] Creating 480p
+[Transcode] 1080p, 720p, 480p in 8m 14s
+
+[Package] Creating zip...
+[Package] 1.1 GB in 22s
+
+[Upload] Starting...
+[Upload] Done in 31s
+
+[Complete] cm1a2b3c4d5e6f7g8h9i0j in 9m 45s
+
+[Worker] Queue empty. Jobs processed this run: 1
+[Worker] Exiting.
+```
+
+---
+
+## CMS changes (already applied)
+
+Four new API routes were added to the CMS Next.js app:
+
+| Route | Purpose |
+|---|---|
+| `POST /api/transcoder/claim` | Atomically claim next job |
+| `GET /api/transcoder/download/:videoId` | Stream source MP4 |
+| `POST /api/transcoder/upload/:videoId` | Receive HLS zip |
+| `POST /api/transcoder/fail/:videoId` | Mark job failed |
+
+All routes require `Authorization: Bearer `.
+
+The claim endpoint uses `SELECT … FOR UPDATE SKIP LOCKED` so multiple workers can run concurrently without racing on the same job.
+
+---
+
+## Disk space requirements
+
+Each job requires roughly:
+
+| Step | Space |
+|---|---|
+| Downloaded MP4 | Up to ~2 GB |
+| HLS output (all variants) | ~1–4× source size |
+| ZIP archive | ~same as HLS output |
+| **Total per job** | **~4–8 GB** |
+
+Ensure Docker Desktop's virtual disk limit is large enough (Settings → Resources → Disk image size). 80 GB+ is recommended for large educational videos.
+
+Temp files are deleted automatically after each job.
+
+---
+
+## Transcoding settings
+
+Identical to the VPS transcoder — output is fully compatible with the existing
+HLS player:
+
+| Variant | Resolution | Video bitrate | Audio |
+|---|---|---|---|
+| 1080p | 1920×1080 | 3500k (max 4000k) | AAC 128k |
+| 720p | 1280×720 | 1800k (max 2000k) | AAC 128k |
+| 480p | 854×480 | 900k (max 1000k) | AAC 128k |
+
+Variants are skipped if the source resolution is smaller than the preset.
+
+---
+
+## Environment variables
+
+| Variable | Required | Description |
+|---|---|---|
+| `CMS_URL` | ✅ | Public HTTPS base URL of the CMS |
+| `TRANSCODER_SECRET` | ✅ | Shared secret matching `TRANSCODER_SECRET` on CMS |
+| `WORK_DIR` | optional | Temp directory inside container (default: `/work`) |
+
+---
+
+## Exit codes
+
+| Code | Meaning |
+|---|---|
+| `0` | Success (all jobs processed, or no jobs found) |
+| `1` | Fatal error (missing env vars, DB unreachable, etc.) |
diff --git a/transcoder-remote/docker-compose.yml b/transcoder-remote/docker-compose.yml
new file mode 100644
index 0000000..d5ff381
--- /dev/null
+++ b/transcoder-remote/docker-compose.yml
@@ -0,0 +1,18 @@
+services:
+ transcoder:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ # Load all variables from .env in this directory.
+ env_file: .env
+ environment:
+ WORK_DIR: /work
+ # Expose /work to the Docker host so you can inspect temp files during
+ # development. Remove this volume in production for fully ephemeral runs.
+ volumes:
+ - transcoder_work:/work
+ # The worker exits when the job queue is empty – do not restart it.
+ restart: "no"
+
+volumes:
+ transcoder_work:
diff --git a/transcoder-remote/package.json b/transcoder-remote/package.json
new file mode 100644
index 0000000..7fc62eb
--- /dev/null
+++ b/transcoder-remote/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "transcoder-remote",
+ "version": "1.0.0",
+ "description": "Remote HLS transcoder worker – runs on local desktop, transcodes videos from the Hetzner CMS via HTTPS API",
+ "main": "dist/index.js",
+ "scripts": {
+ "start": "node dist/index.js",
+ "build": "tsc",
+ "dev": "ts-node --esm src/index.ts"
+ },
+ "dependencies": {
+ "archiver": "^6.0.2"
+ },
+ "devDependencies": {
+ "@types/archiver": "^6.0.3",
+ "@types/node": "^20.0.0",
+ "ts-node": "^10.9.2",
+ "typescript": "^5.3.3"
+ }
+}
diff --git a/transcoder-remote/src/api-client.ts b/transcoder-remote/src/api-client.ts
new file mode 100644
index 0000000..4d63969
--- /dev/null
+++ b/transcoder-remote/src/api-client.ts
@@ -0,0 +1,133 @@
+// src/api-client.ts
+// Typed HTTP client for all CMS transcoder API endpoints.
+
+import * as fssync from "fs";
+import * as fs from "fs/promises";
+import { createReadStream, createWriteStream } from "fs";
+import { Readable } from "stream";
+import { pipeline } from "stream/promises";
+
+export interface JobInfo {
+ videoId: string;
+ downloadUrl: string;
+}
+
+export class TranscoderApiClient {
+ constructor(
+ private readonly cmsUrl: string,
+ private readonly secret: string
+ ) {}
+
+ private get authHeader(): Record {
+ return { Authorization: `Bearer ${this.secret}` };
+ }
+
+ /**
+ * Atomically claim the next available job.
+ * Returns null when the queue is empty.
+ */
+ async claimJob(): Promise {
+ const res = await fetch(`${this.cmsUrl}/api/transcoder/claim`, {
+ method: "POST",
+ headers: this.authHeader,
+ });
+
+ if (!res.ok) {
+ const body = await res.text();
+ throw new Error(`Claim failed: HTTP ${res.status} – ${body}`);
+ }
+
+ return res.json() as Promise;
+ }
+
+ /**
+ * Stream the source MP4 from the CMS directly to a local file path.
+ * Calls onProgress with the total bytes received so far (optional).
+ * Returns the total bytes downloaded.
+ */
+ async downloadVideo(
+ downloadUrl: string,
+ destPath: string,
+ onProgress?: (bytesReceived: number) => void
+ ): Promise {
+ const res = await fetch(downloadUrl, { headers: this.authHeader });
+
+ if (!res.ok) {
+ const body = await res.text();
+ throw new Error(`Download failed: HTTP ${res.status} – ${body}`);
+ }
+
+ if (!res.body) {
+ throw new Error("Download response had no body");
+ }
+
+ const writeStream = createWriteStream(destPath);
+ let totalBytes = 0;
+
+ const nodeReadable = Readable.fromWeb(
+ res.body as unknown as import("stream/web").ReadableStream
+ );
+
+ nodeReadable.on("data", (chunk: Buffer) => {
+ totalBytes += chunk.length;
+ onProgress?.(totalBytes);
+ });
+
+ await pipeline(nodeReadable, writeStream);
+ return totalBytes;
+ }
+
+ /**
+ * Stream a ZIP archive to the CMS upload endpoint.
+ * The CMS extracts the archive, validates it, and marks the video transcoded.
+ */
+ async uploadHls(videoId: string, zipPath: string): Promise {
+ const stat = await fs.stat(zipPath);
+ const readStream = createReadStream(zipPath);
+
+ // Node.js native fetch requires duplex: 'half' when body is a stream.
+ const res = await (fetch as typeof fetch)(
+ `${this.cmsUrl}/api/transcoder/upload/${videoId}`,
+ {
+ method: "POST",
+ headers: {
+ ...this.authHeader,
+ "Content-Type": "application/zip",
+ "Content-Length": stat.size.toString(),
+ },
+ body: Readable.toWeb(
+ readStream
+ ) as unknown as BodyInit,
+ // @ts-expect-error – required for streaming body in Node 18+ fetch
+ duplex: "half",
+ }
+ );
+
+ if (!res.ok) {
+ const body = await res.text();
+ throw new Error(`Upload failed: HTTP ${res.status} – ${body}`);
+ }
+ }
+
+ /**
+ * Tell the CMS that this job failed unrecoverably.
+ */
+ async markFailed(videoId: string, errorMessage: string): Promise {
+ try {
+ const res = await fetch(`${this.cmsUrl}/api/transcoder/fail/${videoId}`, {
+ method: "POST",
+ headers: { ...this.authHeader, "Content-Type": "application/json" },
+ body: JSON.stringify({ error: errorMessage }),
+ });
+
+ if (!res.ok) {
+ console.error(
+ `[markFailed] HTTP ${res.status} for ${videoId}: ${await res.text()}`
+ );
+ }
+ } catch (err) {
+ // Best-effort – don't throw if the fail call itself errors.
+ console.error(`[markFailed] Could not reach CMS for ${videoId}:`, err);
+ }
+ }
+}
diff --git a/transcoder-remote/src/index.ts b/transcoder-remote/src/index.ts
new file mode 100644
index 0000000..1b54fe2
--- /dev/null
+++ b/transcoder-remote/src/index.ts
@@ -0,0 +1,175 @@
+// src/index.ts
+// Remote transcoder worker entry point.
+//
+// Workflow for each job:
+// 1. Claim job via CMS API
+// 2. Download source MP4
+// 3. Transcode to HLS (FFmpeg – identical to VPS transcoder)
+// 4. Zip the HLS output
+// 5. Upload zip to CMS
+// 6. Repeat until queue is empty, then exit 0.
+
+import * as fs from "fs/promises";
+import * as path from "path";
+import { TranscoderApiClient } from "./api-client";
+import { transcodeToHls } from "./transcoder";
+import { createZipArchive } from "./packager";
+
+// ---------------------------------------------------------------------------
+// Environment validation
+// ---------------------------------------------------------------------------
+
+function requireEnv(name: string): string {
+ const val = process.env[name];
+ if (!val) {
+ console.error(`[Fatal] Missing required environment variable: ${name}`);
+ process.exit(1);
+ }
+ return val;
+}
+
+const CMS_URL = requireEnv("CMS_URL").replace(/\/$/, "");
+const TRANSCODER_SECRET = requireEnv("TRANSCODER_SECRET");
+const WORK_DIR = (process.env.WORK_DIR ?? "/work").replace(/\/$/, "");
+
+// ---------------------------------------------------------------------------
+// Formatting helpers
+// ---------------------------------------------------------------------------
+
+function fmtBytes(bytes: number): string {
+ if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
+ if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(0)} MB`;
+ return `${(bytes / 1024).toFixed(0)} KB`;
+}
+
+function fmtDuration(ms: number): string {
+ const s = Math.floor(ms / 1000);
+ const m = Math.floor(s / 60);
+ const h = Math.floor(m / 60);
+ if (h > 0) return `${h}h ${m % 60}m ${s % 60}s`;
+ if (m > 0) return `${m}m ${s % 60}s`;
+ return `${s}s`;
+}
+
+async function safeRm(...paths: string[]): Promise {
+ for (const p of paths) {
+ await fs.rm(p, { recursive: true, force: true }).catch(() => {});
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Single job handler
+// ---------------------------------------------------------------------------
+
+async function processJob(
+ client: TranscoderApiClient,
+ videoId: string,
+ downloadUrl: string
+): Promise {
+ const jobStart = Date.now();
+
+ const jobDir = path.join(WORK_DIR, videoId);
+ const inputMp4 = path.join(jobDir, "input.mp4");
+ const hlsDir = path.join(jobDir, "hls");
+ const zipPath = path.join(jobDir, "output.zip");
+
+ console.log(`\n[Job] ${videoId}`);
+
+ try {
+ await fs.mkdir(hlsDir, { recursive: true });
+
+ // 1. Download --------------------------------------------------------
+ console.log("[Download] Starting...");
+ const dlStart = Date.now();
+ let lastReported = 0;
+
+ const downloadedBytes = await client.downloadVideo(
+ downloadUrl,
+ inputMp4,
+ (received) => {
+ if (received - lastReported >= 100 * 1024 * 1024) {
+ lastReported = received;
+ process.stdout.write(`\r[Download] ${fmtBytes(received)} received...`);
+ }
+ }
+ );
+
+ process.stdout.write("\n");
+ console.log(
+ `[Download] ${fmtBytes(downloadedBytes)} in ${fmtDuration(Date.now() - dlStart)}`
+ );
+
+ // 2. Transcode -------------------------------------------------------
+ console.log("[Transcode] Starting...");
+ const txStart = Date.now();
+ const result = await transcodeToHls(inputMp4, hlsDir);
+ const txMs = Date.now() - txStart;
+
+ console.log(
+ `[Transcode] ${result.variants.join(", ")} in ${fmtDuration(txMs)}`
+ );
+
+ // 3. Package ---------------------------------------------------------
+ console.log("[Package] Creating zip...");
+ const pkgStart = Date.now();
+ const zipBytes = await createZipArchive(hlsDir, zipPath);
+ console.log(
+ `[Package] ${fmtBytes(zipBytes)} in ${fmtDuration(Date.now() - pkgStart)}`
+ );
+
+ // 4. Upload ----------------------------------------------------------
+ console.log("[Upload] Starting...");
+ const ulStart = Date.now();
+ await client.uploadHls(videoId, zipPath);
+ console.log(`[Upload] Done in ${fmtDuration(Date.now() - ulStart)}`);
+
+ console.log(`[Complete] ${videoId} in ${fmtDuration(Date.now() - jobStart)}`);
+ } finally {
+ // Always clean up local work directory.
+ await safeRm(jobDir);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Main loop
+// ---------------------------------------------------------------------------
+
+async function main(): Promise {
+ console.log("[Worker] Remote Transcoder starting");
+ console.log(`[Worker] CMS : ${CMS_URL}`);
+ console.log(`[Worker] Work dir: ${WORK_DIR}`);
+
+ await fs.mkdir(WORK_DIR, { recursive: true });
+
+ const client = new TranscoderApiClient(CMS_URL, TRANSCODER_SECRET);
+ let processed = 0;
+
+ while (true) {
+ const job = await client.claimJob();
+
+ if (!job) {
+ if (processed === 0) {
+ console.log("\n[Worker] No jobs queued – nothing to do.");
+ } else {
+ console.log(`\n[Worker] Queue empty. Jobs processed this run: ${processed}`);
+ }
+ break;
+ }
+
+ try {
+ await processJob(client, job.videoId, job.downloadUrl);
+ processed++;
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ console.error(`[Error] ${job.videoId}: ${msg}`);
+ await client.markFailed(job.videoId, msg);
+ }
+ }
+
+ console.log("[Worker] Exiting.");
+}
+
+main().catch((err) => {
+ console.error("[Fatal]", err);
+ process.exit(1);
+});
diff --git a/transcoder-remote/src/packager.ts b/transcoder-remote/src/packager.ts
new file mode 100644
index 0000000..3133c06
--- /dev/null
+++ b/transcoder-remote/src/packager.ts
@@ -0,0 +1,39 @@
+// src/packager.ts
+// Creates a flat ZIP archive of an HLS output directory.
+// Video segments (.ts) are already compressed, so zlib level 0 (store only)
+// is used to avoid wasting CPU on content that won't compress further.
+
+import archiver from "archiver";
+import { createWriteStream } from "fs";
+
+/**
+ * Zip the contents of sourceDir (flat – no parent prefix inside the zip).
+ * @returns Total bytes written to the zip file.
+ */
+export function createZipArchive(
+ sourceDir: string,
+ outputZipPath: string
+): Promise {
+ return new Promise((resolve, reject) => {
+ const output = createWriteStream(outputZipPath);
+ const archive = archiver("zip", {
+ zlib: { level: 0 }, // store-only; .ts segments are already compressed
+ });
+
+ output.on("close", () => resolve(archive.pointer()));
+
+ archive.on("error", reject);
+ archive.on("warning", (err) => {
+ if (err.code === "ENOENT") {
+ console.warn("[Packager] Warning:", err.message);
+ } else {
+ reject(err);
+ }
+ });
+
+ archive.pipe(output);
+ // false = files land at the root of the zip, not inside a subdirectory.
+ archive.directory(sourceDir, false);
+ archive.finalize();
+ });
+}
diff --git a/transcoder-remote/src/transcoder.ts b/transcoder-remote/src/transcoder.ts
new file mode 100644
index 0000000..aaccbfb
--- /dev/null
+++ b/transcoder-remote/src/transcoder.ts
@@ -0,0 +1,165 @@
+// src/transcoder.ts
+// FFmpeg + HLS logic – identical behaviour to the VPS transcoder.
+// Keeps all preset values, FFmpeg flags, and playlist structure unchanged
+// so output is fully compatible with the existing HLS player.
+
+import { spawn } from "child_process";
+import * as fs from "fs/promises";
+import * as path from "path";
+
+// ---------------------------------------------------------------------------
+// Presets – must stay in sync with the VPS transcoder.
+// ---------------------------------------------------------------------------
+
+const HLS_PRESETS = [
+ { name: "1080p", width: 1920, height: 1080, bitrate: "3500k", maxrate: "4000k" },
+ { name: "720p", width: 1280, height: 720, bitrate: "1800k", maxrate: "2000k" },
+ { name: "480p", width: 854, height: 480, bitrate: "900k", maxrate: "1000k" },
+] as const;
+
+type HlsPreset = (typeof HLS_PRESETS)[number];
+
+// ---------------------------------------------------------------------------
+// Subprocess helpers
+// ---------------------------------------------------------------------------
+
+function runFFmpeg(args: string[]): Promise {
+ return new Promise((resolve, reject) => {
+ const proc = spawn("ffmpeg", args, { stdio: "inherit" });
+ proc.on("error", reject);
+ proc.on("close", (code) => {
+ if (code === 0) resolve();
+ else reject(new Error(`FFmpeg exited with code ${code}`));
+ });
+ });
+}
+
+export async function probeResolution(
+ input: string
+): Promise<{ width: number; height: number }> {
+ return new Promise((resolve, reject) => {
+ const proc = spawn("ffprobe", [
+ "-v", "error",
+ "-select_streams", "v:0",
+ "-show_entries", "stream=width,height",
+ "-of", "csv=s=x:p=0",
+ input,
+ ]);
+
+ let output = "";
+ proc.stdout.on("data", (d: Buffer) => { output += d.toString(); });
+ proc.stderr.on("data", () => {/* suppress */});
+
+ proc.on("error", reject);
+ proc.on("close", (code) => {
+ if (code !== 0) return reject(new Error(`ffprobe exited with code ${code}`));
+ const parts = output.trim().split("x").map(Number);
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
+ return reject(new Error(`Could not parse resolution: "${output.trim()}"`));
+ }
+ resolve({ width: parts[0], height: parts[1] });
+ });
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Per-variant HLS stream
+// ---------------------------------------------------------------------------
+
+async function createVariant(
+ input: string,
+ outputDir: string,
+ preset: HlsPreset
+): Promise {
+ const segmentPattern = path.join(outputDir, `${preset.name}_%03d.ts`);
+ const playlistPath = path.join(outputDir, `${preset.name}.m3u8`);
+
+ const args = [
+ "-y",
+ "-i", input,
+ "-c:v", "libx264",
+ "-preset", "medium",
+ "-profile:v", "main",
+ "-crf", "20",
+ "-vf", `scale=w='min(${preset.width},iw)':h='min(${preset.height},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2`,
+ "-b:v", preset.bitrate,
+ "-maxrate", preset.maxrate,
+ "-bufsize", "4000k",
+ "-c:a", "aac",
+ "-b:a", "128k",
+ "-f", "hls",
+ "-hls_time", "6",
+ "-hls_playlist_type", "vod",
+ "-hls_flags", "independent_segments",
+ "-hls_segment_filename", segmentPattern,
+ playlistPath,
+ ];
+
+ console.log(`[HLS] Creating ${preset.name}`);
+ await runFFmpeg(args);
+}
+
+// ---------------------------------------------------------------------------
+// Master playlist
+// ---------------------------------------------------------------------------
+
+async function createMasterPlaylist(
+ outputDir: string,
+ variants: readonly string[]
+): Promise {
+ const lines: string[] = ["#EXTM3U", "#EXT-X-VERSION:3"];
+
+ for (const name of variants) {
+ const preset = HLS_PRESETS.find((p) => p.name === name)!;
+ const bandwidth = parseInt(preset.maxrate.replace("k", ""), 10) * 1000;
+ lines.push(
+ `#EXT-X-STREAM-INF:BANDWIDTH=${bandwidth},RESOLUTION=${preset.width}x${preset.height}`
+ );
+ lines.push(`${preset.name}.m3u8`);
+ }
+
+ await fs.writeFile(path.join(outputDir, "master.m3u8"), lines.join("\n"));
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+export interface TranscodeResult {
+ /** Names of variants that were generated, e.g. ["1080p","720p","480p"]. */
+ variants: string[];
+}
+
+/**
+ * Transcode inputPath to HLS inside outputDir.
+ * outputDir will be created if it does not exist.
+ * Behaviour is identical to the VPS transcoder.
+ */
+export async function transcodeToHls(
+ inputPath: string,
+ outputDir: string
+): Promise {
+ const { width, height } = await probeResolution(inputPath);
+ console.log(`[Probe] Resolution: ${width}x${height}`);
+
+ const allowed = HLS_PRESETS.filter(
+ (p) => p.width <= width && p.height <= height
+ );
+
+ if (allowed.length === 0) {
+ throw new Error(
+ `No suitable HLS variants for source resolution ${width}x${height}`
+ );
+ }
+
+ await fs.mkdir(outputDir, { recursive: true });
+
+ for (const preset of allowed) {
+ await createVariant(inputPath, outputDir, preset);
+ }
+
+ const variantNames = allowed.map((p) => p.name);
+ await createMasterPlaylist(outputDir, variantNames);
+
+ return { variants: variantNames };
+}
diff --git a/transcoder-remote/tsconfig.json b/transcoder-remote/tsconfig.json
new file mode 100644
index 0000000..2ab0598
--- /dev/null
+++ b/transcoder-remote/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "commonjs",
+ "lib": ["ES2022"],
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "resolveJsonModule": true
+ },
+ "include": ["src"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/transcoder/README.md b/transcoder/README.md
new file mode 100644
index 0000000..0facaae
--- /dev/null
+++ b/transcoder/README.md
@@ -0,0 +1,339 @@
+# HLS Transcoder Service
+
+A scalable HLS (HTTP Live Streaming) transcoding service for converting MP4 videos into adaptive bitrate HLS streams using FFmpeg.
+
+## Features
+
+- ✅ Automatic video detection and transcoding
+- ✅ Multiple quality variants (1080p, 720p, 480p)
+- ✅ Master playlist generation for adaptive bitrate streaming
+- ✅ Concurrent job processing
+- ✅ Database integration with Prisma
+- ✅ Comprehensive error handling and logging
+- ✅ Docker support with multi-stage builds
+- ✅ Health checks and graceful shutdown
+
+## Prerequisites
+
+### For Local Development
+- Node.js 18+
+- FFmpeg
+- PostgreSQL
+- TypeScript
+
+### For Docker
+- Docker Desktop (with Windows Support)
+- Docker Compose 2.0+
+
+## Quick Start
+
+### Development Mode
+
+```bash
+# Install dependencies
+cd transcoder
+npm install
+
+# Set environment variables
+export DATABASE_URL="postgresql://cms_user:changeme@localhost:5432/cms_db"
+export UPLOADS_DIR="/path/to/uploads"
+export NODE_ENV="development"
+
+# Run in development mode
+npm run dev
+
+# Or build and run
+npm run build
+npm start
+```
+
+### Docker Compose
+
+```bash
+# From project root
+docker-compose up -d
+
+# View logs
+docker-compose logs -f transcoder
+
+# Stop services
+docker-compose down
+```
+
+## Configuration
+
+### Environment Variables
+
+| Variable | Description | Default |
+|----------|-------------|---------|
+| `DATABASE_URL` | PostgreSQL connection string | (required) |
+| `UPLOADS_DIR` | Path to uploads directory | `/uploads` |
+| `POLL_INTERVAL` | Check for new videos every N ms | `5000` |
+| `CONCURRENT_JOBS` | Videos to transcode simultaneously | `2` |
+| `NODE_ENV` | Environment (development/production) | `production` |
+
+### Example Configuration
+
+```bash
+DATABASE_URL=postgresql://cms_user:secure_password@db.example.com:5432/cms_db
+UPLOADS_DIR=/mnt/storage/uploads
+POLL_INTERVAL=10000
+CONCURRENT_JOBS=4
+```
+
+## Output Format
+
+### Directory Structure
+```
+/uploads/hls/
+├── {video_id_1}/
+│ ├── master.m3u8 # Master playlist
+│ ├── 1080p.m3u8 # 1080p variant
+│ ├── 1080p_000.ts # TS segments
+│ ├── 1080p_001.ts
+│ ├── 720p.m3u8 # 720p variant
+│ ├── 720p_000.ts
+│ └── 480p.m3u8 # 480p variant
+└── {video_id_2}/
+ └── ...
+```
+
+### Master Playlist Format
+
+```m3u8
+#EXTM3U
+#EXT-X-VERSION:3
+#EXT-X-STREAM-INF:BANDWIDTH=3500000,RESOLUTION=1920x1080
+1080p.m3u8
+#EXT-X-STREAM-INF:BANDWIDTH=1800000,RESOLUTION=1280x720
+720p.m3u8
+#EXT-X-STREAM-INF:BANDWIDTH=900000,RESOLUTION=854x480
+480p.m3u8
+```
+
+## API Reference
+
+### Database Schema
+
+```prisma
+model Video {
+ id String @id
+ transcodingStatus String @default("uploaded")
+ // ... other fields
+
+ @@index([transcodingStatus])
+}
+```
+
+### Status Lifecycle
+
+```
+uploaded → transcoded (success)
+uploaded → failed (error)
+```
+
+## Building & Deploying
+
+### Build Docker Image
+
+```bash
+# Build locally
+docker build -f Dockerfile.transcoder -t college-transcoder:latest .
+
+# Build with custom tag
+docker build -f Dockerfile.transcoder -t my-registry.com/transcoder:v1.0 .
+
+# Push to registry
+docker push my-registry.com/transcoder:v1.0
+```
+
+### Docker Compose Deployment
+
+Update `docker-compose.yml`:
+
+```yaml
+transcoder:
+ image: my-registry.com/transcoder:v1.0
+ environment:
+ DATABASE_URL: postgresql://...
+ UPLOADS_DIR: /uploads
+ volumes:
+ - /mnt/storage/uploads:/uploads
+```
+
+### Production Checklist
+
+- [ ] Use strong PostgreSQL password
+- [ ] Set appropriate `CONCURRENT_JOBS` for your hardware
+- [ ] Monitor disk space requirements
+- [ ] Set up log rotation for transcoder logs
+- [ ] Configure backup for HLS output directory
+- [ ] Monitor transcoder health via container health checks
+- [ ] Set resource limits in Docker (CPU, memory)
+
+## Monitoring & Debugging
+
+### View Logs
+
+```bash
+# Docker Compose
+docker-compose logs -f transcoder
+
+# Single container
+docker logs -f college-transcoder
+
+# Filter logs by level
+docker logs college-transcoder | grep "\[Error\]"
+```
+
+### Check Transcoding Status
+
+```bash
+# PostgreSQL query
+psql $DATABASE_URL -c \
+ "SELECT id, title, transcodingStatus FROM \"Video\"
+ ORDER BY createdAt DESC LIMIT 10;"
+```
+
+### Verify HLS Files
+
+```bash
+# Check master playlist
+cat /uploads/hls/{video_id}/master.m3u8
+
+# Verify variant playlists
+for variant in 1080p 720p 480p; do
+ echo "=== $variant.m3u8 ===="
+ head -5 /uploads/hls/{video_id}/$variant.m3u8
+done
+
+# Check segment files
+ls -lh /uploads/hls/{video_id}/*.ts | head -5
+```
+
+### Test with FFprobe
+
+```bash
+# Check segment file
+ffprobe /uploads/hls/{video_id}/1080p_000.ts
+
+# Check master playlist validity
+ffprobe /uploads/hls/{video_id}/master.m3u8
+```
+
+## Performance Tuning
+
+### Adjust Batch Delay
+
+```bash
+# Faster processing (shorter delay between batches)
+export SLEEP_BETWEEN_BATCHES=10
+
+# Slower processing (longer delay, less CPU usage)
+export SLEEP_BETWEEN_BATCHES=60
+```
+
+### FFmpeg Presets
+
+Edit `transcoder/index.ts` to adjust quality presets:
+
+```typescript
+const HLS_PRESETS = [
+ { name: "1080p", width: 1920, height: 1080, bitrate: "3500k", maxrate: "4000k" },
+ { name: "720p", width: 1280, height: 720, bitrate: "1800k", maxrate: "2000k" },
+ { name: "480p", width: 854, height: 480, bitrate: "900k", maxrate: "1000k" },
+];
+```
+
+## Troubleshooting
+
+### Transcoder Won't Start
+
+**Check logs:**
+```bash
+docker logs college-transcoder 2>&1 | head -50
+```
+
+**Common issues:**
+- Database not accessible: Verify `DATABASE_URL`
+- FFmpeg not found: Check Docker image build
+- Uploads directory missing: Create `/uploads/videos` and `/uploads/hls`
+
+### No Videos Being Transcoded
+
+**Check video status:**
+```bash
+psql $DATABASE_URL -c "SELECT id, transcodingStatus FROM \"Video\";"
+```
+
+**Check file existence:**
+```bash
+ls -la /uploads/videos/
+```
+
+**Check permissions:**
+```bash
+stat /uploads/videos/ | grep Access
+chmod 755 /uploads/videos/
+chmod 644 /uploads/videos/*.mp4
+```
+
+### Transcoding Fails
+
+**Check FFmpeg:**
+```bash
+docker exec college-transcoder ffmpeg -version
+```
+
+**Check disk space:**
+```bash
+df -h /uploads
+# Need: ~3x original file size for temp files
+```
+
+**Check database:**
+```bash
+psql $DATABASE_URL
+SELECT * FROM "Video" WHERE transcodingStatus = 'failed';
+```
+
+## Development
+
+### Project Structure
+
+```
+transcoder/
+├── index.ts # Main transcoder logic
+├── package.json # Dependencies
+├── tsconfig.json # TypeScript config
+└── dist/ # Compiled output (generated)
+```
+
+### Build for Development
+
+```bash
+cd transcoder
+npm install
+npm run build
+```
+
+### Type Checking
+
+```bash
+cd transcoder
+npx tsc --noEmit
+```
+
+## Contributing
+
+To improve the transcoder:
+
+1. Add new quality presets to `HLS_PRESETS`
+2. Adjust FFmpeg encoding parameters
+3. Add metrics/monitoring
+4. Implement retry logic
+5. Support additional input formats
+
+## License
+
+Part of the OWI CMS project.
diff --git a/transcoder/dist/index.d.ts b/transcoder/dist/index.d.ts
new file mode 100644
index 0000000..e26a57a
--- /dev/null
+++ b/transcoder/dist/index.d.ts
@@ -0,0 +1,2 @@
+export {};
+//# sourceMappingURL=index.d.ts.map
\ No newline at end of file
diff --git a/transcoder/dist/index.d.ts.map b/transcoder/dist/index.d.ts.map
new file mode 100644
index 0000000..1be7a7a
--- /dev/null
+++ b/transcoder/dist/index.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":""}
\ No newline at end of file
diff --git a/transcoder/dist/index.js b/transcoder/dist/index.js
new file mode 100644
index 0000000..c893f36
--- /dev/null
+++ b/transcoder/dist/index.js
@@ -0,0 +1,211 @@
+"use strict";
+// transcoder.ts
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+ if (k2 === undefined) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() { return m[k]; } };
+ }
+ Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+ if (k2 === undefined) k2 = k;
+ o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+ o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+ var ownKeys = function(o) {
+ ownKeys = Object.getOwnPropertyNames || function (o) {
+ var ar = [];
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+ return ar;
+ };
+ return ownKeys(o);
+ };
+ return function (mod) {
+ if (mod && mod.__esModule) return mod;
+ var result = {};
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+ __setModuleDefault(result, mod);
+ return result;
+ };
+})();
+Object.defineProperty(exports, "__esModule", { value: true });
+const child_process_1 = require("child_process");
+const fs = __importStar(require("fs/promises"));
+const path = __importStar(require("path"));
+const client_1 = require("@prisma/client");
+const prisma = new client_1.PrismaClient();
+// -----------------------------
+// Configuration
+// -----------------------------
+const UPLOADS_DIR = process.env.UPLOADS_DIR || "/uploads";
+const ORIGINALS_DIR = path.join(UPLOADS_DIR, "videos");
+const HLS_ROOT = path.join(UPLOADS_DIR, "hls");
+// Safer bitrate ladder for education content
+const HLS_PRESETS = [
+ { name: "1080p", width: 1920, height: 1080, bitrate: "3500k", maxrate: "4000k" },
+ { name: "720p", width: 1280, height: 720, bitrate: "1800k", maxrate: "2000k" },
+ { name: "480p", width: 854, height: 480, bitrate: "900k", maxrate: "1000k" },
+];
+// -----------------------------
+// Utilities
+// -----------------------------
+async function fileExists(p) {
+ try {
+ await fs.access(p);
+ return true;
+ }
+ catch {
+ return false;
+ }
+}
+function runFFmpeg(args) {
+ return new Promise((resolve, reject) => {
+ const ffmpeg = (0, child_process_1.spawn)("ffmpeg", args, { stdio: "inherit" });
+ ffmpeg.on("close", (code) => {
+ if (code === 0)
+ resolve();
+ else
+ reject(new Error(`FFmpeg exited with code ${code}`));
+ });
+ });
+}
+async function probeResolution(input) {
+ return new Promise((resolve, reject) => {
+ const ffprobe = (0, child_process_1.spawn)("ffprobe", [
+ "-v", "error",
+ "-select_streams", "v:0",
+ "-show_entries", "stream=width,height",
+ "-of", "csv=s=x:p=0",
+ input,
+ ]);
+ let output = "";
+ ffprobe.stdout.on("data", (data) => {
+ output += data.toString();
+ });
+ ffprobe.on("close", (code) => {
+ if (code !== 0)
+ return reject(new Error("ffprobe failed"));
+ const [width, height] = output.trim().split("x").map(Number);
+ resolve({ width, height });
+ });
+ });
+}
+// -----------------------------
+// HLS Creation
+// -----------------------------
+async function createVariant(input, outputDir, preset) {
+ const segmentPattern = path.join(outputDir, `${preset.name}_%03d.ts`);
+ const playlistPath = path.join(outputDir, `${preset.name}.m3u8`);
+ const args = [
+ "-y",
+ "-i", input,
+ "-c:v", "libx264",
+ "-preset", "medium",
+ "-profile:v", "main",
+ "-crf", "20",
+ "-vf", `scale=w=${preset.width}:h=${preset.height}:force_original_aspect_ratio=decrease`,
+ "-b:v", preset.bitrate,
+ "-maxrate", preset.maxrate,
+ "-bufsize", "4000k",
+ "-c:a", "aac",
+ "-b:a", "128k",
+ "-f", "hls",
+ "-hls_time", "6",
+ "-hls_playlist_type", "vod",
+ "-hls_segment_filename", segmentPattern,
+ playlistPath,
+ ];
+ console.log(`[HLS] Creating ${preset.name}`);
+ await runFFmpeg(args);
+}
+async function createMasterPlaylist(outputDir, variants) {
+ const lines = [
+ "#EXTM3U",
+ "#EXT-X-VERSION:3",
+ ];
+ for (const variant of variants) {
+ const preset = HLS_PRESETS.find(p => p.name === variant);
+ const bandwidth = parseInt(preset.maxrate.replace("k", "")) * 1000;
+ lines.push(`#EXT-X-STREAM-INF:BANDWIDTH=${bandwidth},RESOLUTION=${preset.width}x${preset.height}`);
+ lines.push(`${preset.name}.m3u8`);
+ }
+ await fs.writeFile(path.join(outputDir, "master.m3u8"), lines.join("\n"));
+}
+// -----------------------------
+// Transcode One Video
+// -----------------------------
+async function transcodeVideo(videoId) {
+ console.log(`\n[Transcoding] ${videoId}`);
+ const inputPath = path.join(ORIGINALS_DIR, `${videoId}.mp4`);
+ const finalDir = path.join(HLS_ROOT, videoId);
+ const tempDir = path.join(HLS_ROOT, `${videoId}.tmp`);
+ if (!(await fileExists(inputPath))) {
+ throw new Error(`Original file not found: ${inputPath}`);
+ }
+ if (await fileExists(finalDir)) {
+ console.log(`[Skip] HLS already exists`);
+ return;
+ }
+ await fs.mkdir(tempDir, { recursive: true });
+ const { width, height } = await probeResolution(inputPath);
+ const allowedVariants = HLS_PRESETS.filter(p => p.width <= width && p.height <= height);
+ if (allowedVariants.length === 0) {
+ throw new Error("No suitable HLS variants for source resolution");
+ }
+ for (const preset of allowedVariants) {
+ await createVariant(inputPath, tempDir, preset);
+ }
+ await createMasterPlaylist(tempDir, allowedVariants.map(p => p.name));
+ // Atomic rename
+ await fs.rename(tempDir, finalDir);
+ console.log(`[Done] ${videoId}`);
+}
+// -----------------------------
+// Main Batch Worker
+// -----------------------------
+async function main() {
+ console.log("[Transcoder] Starting batch run");
+ await fs.mkdir(HLS_ROOT, { recursive: true });
+ const videos = await prisma.video.findMany({
+ where: { transcodingStatus: "uploaded" },
+ });
+ if (videos.length === 0) {
+ console.log("[Transcoder] Nothing to process");
+ return;
+ }
+ console.log(`[Transcoder] Found ${videos.length} videos`);
+ for (const video of videos) {
+ try {
+ // Atomically lock job
+ await prisma.video.update({
+ where: { id: video.id },
+ data: { transcodingStatus: "processing" },
+ });
+ await transcodeVideo(video.id);
+ await prisma.video.update({
+ where: { id: video.id },
+ data: { transcodingStatus: "transcoded" },
+ });
+ }
+ catch (err) {
+ console.error(`[Error] ${video.id}`, err);
+ await prisma.video.update({
+ where: { id: video.id },
+ data: { transcodingStatus: "failed" },
+ });
+ }
+ }
+ await prisma.$disconnect();
+ console.log("[Transcoder] Batch complete");
+}
+main().catch(async (err) => {
+ console.error("[Fatal]", err);
+ await prisma.$disconnect();
+ process.exit(1);
+});
+//# sourceMappingURL=index.js.map
\ No newline at end of file
diff --git a/transcoder/dist/index.js.map b/transcoder/dist/index.js.map
new file mode 100644
index 0000000..90bb350
--- /dev/null
+++ b/transcoder/dist/index.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"index.js","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":";AAAA,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAEhB,iDAAsC;AACtC,gDAAkC;AAElC,2CAA6B;AAC7B,2CAA8C;AAE9C,MAAM,MAAM,GAAG,IAAI,qBAAY,EAAE,CAAC;AAElC,gCAAgC;AAChC,gBAAgB;AAChB,gCAAgC;AAChC,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,UAAU,CAAC;AAC1D,MAAM,aAAa,GAAG,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAC;AACvD,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,KAAK,CAAC,CAAC;AAE/C,6CAA6C;AAC7C,MAAM,WAAW,GAAG;IAClB,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE;IAChF,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE;IAC9E,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE;CAC7E,CAAC;AAEF,gCAAgC;AAChC,YAAY;AACZ,gCAAgC;AAEhC,KAAK,UAAU,UAAU,CAAC,CAAS;IACjC,IAAI,CAAC;QACH,MAAM,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QACnB,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,SAAS,SAAS,CAAC,IAAc;IAC/B,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,MAAM,GAAG,IAAA,qBAAK,EAAC,QAAQ,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC,CAAC;QAE3D,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE;YAC1B,IAAI,IAAI,KAAK,CAAC;gBAAE,OAAO,EAAE,CAAC;;gBACrB,MAAM,CAAC,IAAI,KAAK,CAAC,2BAA2B,IAAI,EAAE,CAAC,CAAC,CAAC;QAC5D,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC;AAED,KAAK,UAAU,eAAe,CAAC,KAAa;IAC1C,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,OAAO,GAAG,IAAA,qBAAK,EAAC,SAAS,EAAE;YAC/B,IAAI,EAAE,OAAO;YACb,iBAAiB,EAAE,KAAK;YACxB,eAAe,EAAE,qBAAqB;YACtC,KAAK,EAAE,aAAa;YACpB,KAAK;SACN,CAAC,CAAC;QAEH,IAAI,MAAM,GAAG,EAAE,CAAC;QAChB,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE;YACjC,MAAM,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;QAC5B,CAAC,CAAC,CAAC;QAEH,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE;YAC3B,IAAI,IAAI,KAAK,CAAC;gBAAE,OAAO,MAAM,CAAC,IAAI,KAAK,CAAC,gBAAgB,CAAC,CAAC,CAAC;YAC3D,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;YAC7D,OAAO,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,CAAC;QAC7B,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC;AAED,gCAAgC;AAChC,eAAe;AACf,gCAAgC;AAEhC,KAAK,UAAU,aAAa,CAC1B,KAAa,EACb,SAAiB,EACjB,MAAkC;IAElC,MAAM,cAAc,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,GAAG,MAAM,CAAC,IAAI,UAAU,CAAC,CAAC;IACtE,MAAM,YAAY,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,GAAG,MAAM,CAAC,IAAI,OAAO,CAAC,CAAC;IAEjE,MAAM,IAAI,GAAG;QACX,IAAI;QACJ,IAAI,EAAE,KAAK;QAEX,MAAM,EAAE,SAAS;QACjB,SAAS,EAAE,QAAQ;QACnB,YAAY,EAAE,MAAM;QACpB,MAAM,EAAE,IAAI;QAEZ,KAAK,EAAE,WAAW,MAAM,CAAC,KAAK,MAAM,MAAM,CAAC,MAAM,uCAAuC;QAExF,MAAM,EAAE,MAAM,CAAC,OAAO;QACtB,UAAU,EAAE,MAAM,CAAC,OAAO;QAC1B,UAAU,EAAE,OAAO;QAEnB,MAAM,EAAE,KAAK;QACb,MAAM,EAAE,MAAM;QAEd,IAAI,EAAE,KAAK;QACX,WAAW,EAAE,GAAG;QAChB,oBAAoB,EAAE,KAAK;QAC3B,uBAAuB,EAAE,cAAc;QAEvC,YAAY;KACb,CAAC;IAEF,OAAO,CAAC,GAAG,CAAC,kBAAkB,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;IAC7C,MAAM,SAAS,CAAC,IAAI,CAAC,CAAC;AACxB,CAAC;AAED,KAAK,UAAU,oBAAoB,CAAC,SAAiB,EAAE,QAAkB;IACvE,MAAM,KAAK,GAAa;QACtB,SAAS;QACT,kBAAkB;KACnB,CAAC;IAEF,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,MAAM,MAAM,GAAG,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,OAAO,CAAE,CAAC;QAC1D,MAAM,SAAS,GAAG,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC;QAEnE,KAAK,CAAC,IAAI,CACR,+BAA+B,SAAS,eAAe,MAAM,CAAC,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,CACvF,CAAC;QACF,KAAK,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,IAAI,OAAO,CAAC,CAAC;IACpC,CAAC;IAED,MAAM,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,aAAa,CAAC,EAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;AAC5E,CAAC;AAED,gCAAgC;AAChC,sBAAsB;AACtB,gCAAgC;AAEhC,KAAK,UAAU,cAAc,CAAC,OAAe;IAC3C,OAAO,CAAC,GAAG,CAAC,mBAAmB,OAAO,EAAE,CAAC,CAAC;IAE1C,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE,GAAG,OAAO,MAAM,CAAC,CAAC;IAC7D,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;IAC9C,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,GAAG,OAAO,MAAM,CAAC,CAAC;IAEtD,IAAI,CAAC,CAAC,MAAM,UAAU,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC;QACnC,MAAM,IAAI,KAAK,CAAC,4BAA4B,SAAS,EAAE,CAAC,CAAC;IAC3D,CAAC;IAED,IAAI,MAAM,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC/B,OAAO,CAAC,GAAG,CAAC,2BAA2B,CAAC,CAAC;QACzC,OAAO;IACT,CAAC;IAED,MAAM,EAAE,CAAC,KAAK,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAE7C,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,eAAe,CAAC,SAAS,CAAC,CAAC;IAE3D,MAAM,eAAe,GAAG,WAAW,CAAC,MAAM,CACxC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,IAAI,KAAK,IAAI,CAAC,CAAC,MAAM,IAAI,MAAM,CAC5C,CAAC;IAEF,IAAI,eAAe,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACjC,MAAM,IAAI,KAAK,CAAC,gDAAgD,CAAC,CAAC;IACpE,CAAC;IAED,KAAK,MAAM,MAAM,IAAI,eAAe,EAAE,CAAC;QACrC,MAAM,aAAa,CAAC,SAAS,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC;IAClD,CAAC;IAED,MAAM,oBAAoB,CAAC,OAAO,EAAE,eAAe,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;IAEtE,gBAAgB;IAChB,MAAM,EAAE,CAAC,MAAM,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;IAEnC,OAAO,CAAC,GAAG,CAAC,UAAU,OAAO,EAAE,CAAC,CAAC;AACnC,CAAC;AAED,gCAAgC;AAChC,oBAAoB;AACpB,gCAAgC;AAEhC,KAAK,UAAU,IAAI;IACjB,OAAO,CAAC,GAAG,CAAC,iCAAiC,CAAC,CAAC;IAE/C,MAAM,EAAE,CAAC,KAAK,CAAC,QAAQ,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAE9C,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC;QACzC,KAAK,EAAE,EAAE,iBAAiB,EAAE,UAAU,EAAE;KACzC,CAAC,CAAC;IAEH,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACxB,OAAO,CAAC,GAAG,CAAC,iCAAiC,CAAC,CAAC;QAC/C,OAAO;IACT,CAAC;IAED,OAAO,CAAC,GAAG,CAAC,sBAAsB,MAAM,CAAC,MAAM,SAAS,CAAC,CAAC;IAE1D,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,IAAI,CAAC;YACH,sBAAsB;YACtB,MAAM,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC;gBACxB,KAAK,EAAE,EAAE,EAAE,EAAE,KAAK,CAAC,EAAE,EAAE;gBACvB,IAAI,EAAE,EAAE,iBAAiB,EAAE,YAAY,EAAE;aAC1C,CAAC,CAAC;YAEH,MAAM,cAAc,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;YAE/B,MAAM,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC;gBACxB,KAAK,EAAE,EAAE,EAAE,EAAE,KAAK,CAAC,EAAE,EAAE;gBACvB,IAAI,EAAE,EAAE,iBAAiB,EAAE,YAAY,EAAE;aAC1C,CAAC,CAAC;QAEL,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,OAAO,CAAC,KAAK,CAAC,WAAW,KAAK,CAAC,EAAE,EAAE,EAAE,GAAG,CAAC,CAAC;YAE1C,MAAM,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC;gBACxB,KAAK,EAAE,EAAE,EAAE,EAAE,KAAK,CAAC,EAAE,EAAE;gBACvB,IAAI,EAAE,EAAE,iBAAiB,EAAE,QAAQ,EAAE;aACtC,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IAED,MAAM,MAAM,CAAC,WAAW,EAAE,CAAC;IAC3B,OAAO,CAAC,GAAG,CAAC,6BAA6B,CAAC,CAAC;AAC7C,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,KAAK,EAAE,GAAG,EAAE,EAAE;IACzB,OAAO,CAAC,KAAK,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC;IAC9B,MAAM,MAAM,CAAC,WAAW,EAAE,CAAC;IAC3B,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
\ No newline at end of file
diff --git a/transcoder/index.ts b/transcoder/index.ts
new file mode 100644
index 0000000..a8f9f82
--- /dev/null
+++ b/transcoder/index.ts
@@ -0,0 +1,235 @@
+// transcoder.ts
+
+import { spawn } from "child_process";
+import * as fs from "fs/promises";
+import * as fssync from "fs";
+import * as path from "path";
+import { PrismaClient } from "@prisma/client";
+
+const prisma = new PrismaClient();
+
+process.umask(0o022);
+
+
+// -----------------------------
+// Configuration
+// -----------------------------
+const UPLOADS_DIR = process.env.UPLOADS_DIR || "/uploads";
+const ORIGINALS_DIR = path.join(UPLOADS_DIR, "videos");
+const HLS_ROOT = path.join(UPLOADS_DIR, "hls");
+
+// Safer bitrate ladder for education content
+const HLS_PRESETS = [
+ { name: "1080p", width: 1920, height: 1080, bitrate: "3500k", maxrate: "4000k" },
+ { name: "720p", width: 1280, height: 720, bitrate: "1800k", maxrate: "2000k" },
+ { name: "480p", width: 854, height: 480, bitrate: "900k", maxrate: "1000k" },
+];
+
+// -----------------------------
+// Utilities
+// -----------------------------
+
+async function fileExists(p: string): Promise {
+ try {
+ await fs.access(p);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function runFFmpeg(args: string[]): Promise {
+ return new Promise((resolve, reject) => {
+ const ffmpeg = spawn("ffmpeg", args, { stdio: "inherit" });
+
+ ffmpeg.on("close", (code) => {
+ if (code === 0) resolve();
+ else reject(new Error(`FFmpeg exited with code ${code}`));
+ });
+ });
+}
+
+async function probeResolution(input: string): Promise<{ width: number; height: number }> {
+ return new Promise((resolve, reject) => {
+ const ffprobe = spawn("ffprobe", [
+ "-v", "error",
+ "-select_streams", "v:0",
+ "-show_entries", "stream=width,height",
+ "-of", "csv=s=x:p=0",
+ input,
+ ]);
+
+ let output = "";
+ ffprobe.stdout.on("data", (data) => {
+ output += data.toString();
+ });
+
+ ffprobe.on("close", (code) => {
+ if (code !== 0) return reject(new Error("ffprobe failed"));
+ const [width, height] = output.trim().split("x").map(Number);
+ resolve({ width, height });
+ });
+ });
+}
+
+// -----------------------------
+// HLS Creation
+// -----------------------------
+
+async function createVariant(
+ input: string,
+ outputDir: string,
+ preset: typeof HLS_PRESETS[number]
+) {
+ const segmentPattern = path.join(outputDir, `${preset.name}_%03d.ts`);
+ const playlistPath = path.join(outputDir, `${preset.name}.m3u8`);
+
+ const args = [
+ "-y",
+ "-i", input,
+
+ "-c:v", "libx264",
+ "-preset", "medium",
+ "-profile:v", "main",
+ "-crf", "20",
+
+ "-vf", `scale=w='min(${preset.width},iw)':h='min(${preset.height},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2`,
+
+
+ "-b:v", preset.bitrate,
+ "-maxrate", preset.maxrate,
+ "-bufsize", "4000k",
+
+ "-c:a", "aac",
+ "-b:a", "128k",
+
+ "-f", "hls",
+ "-hls_time", "6",
+ "-hls_playlist_type", "vod",
+ "-hls_flags", "independent_segments",
+ "-hls_segment_filename", segmentPattern,
+
+ playlistPath,
+ ];
+
+ console.log(`[HLS] Creating ${preset.name}`);
+ await runFFmpeg(args);
+}
+
+async function createMasterPlaylist(outputDir: string, variants: string[]): Promise {
+ const lines: string[] = [
+ "#EXTM3U",
+ "#EXT-X-VERSION:3",
+ ];
+
+ for (const variant of variants) {
+ const preset = HLS_PRESETS.find(p => p.name === variant)!;
+ const bandwidth = parseInt(preset.maxrate.replace("k", "")) * 1000;
+
+ lines.push(
+ `#EXT-X-STREAM-INF:BANDWIDTH=${bandwidth},RESOLUTION=${preset.width}x${preset.height}`
+ );
+ lines.push(`${preset.name}.m3u8`);
+ }
+
+ await fs.writeFile(path.join(outputDir, "master.m3u8"), lines.join("\n"));
+}
+
+// -----------------------------
+// Transcode One Video
+// -----------------------------
+
+async function transcodeVideo(videoId: string) {
+ console.log(`\n[Transcoding] ${videoId}`);
+
+ const inputPath = path.join(ORIGINALS_DIR, `${videoId}.mp4`);
+ const finalDir = path.join(HLS_ROOT, videoId);
+ const tempDir = path.join(HLS_ROOT, `${videoId}.tmp`);
+
+ if (!(await fileExists(inputPath))) {
+ throw new Error(`Original file not found: ${inputPath}`);
+ }
+
+ if (await fileExists(finalDir)) {
+ console.log(`[Skip] HLS already exists`);
+ return;
+ }
+
+ await fs.mkdir(tempDir, { recursive: true });
+
+ const { width, height } = await probeResolution(inputPath);
+
+ const allowedVariants = HLS_PRESETS.filter(
+ p => p.width <= width && p.height <= height
+ );
+
+ if (allowedVariants.length === 0) {
+ throw new Error("No suitable HLS variants for source resolution");
+ }
+
+ for (const preset of allowedVariants) {
+ await createVariant(inputPath, tempDir, preset);
+ }
+
+ await createMasterPlaylist(tempDir, allowedVariants.map(p => p.name));
+
+ // Atomic rename
+ await fs.rename(tempDir, finalDir);
+
+ console.log(`[Done] ${videoId}`);
+}
+
+// -----------------------------
+// Main Batch Worker
+// -----------------------------
+
+async function main() {
+ console.log("[Transcoder] Starting batch run");
+
+ await fs.mkdir(HLS_ROOT, { recursive: true });
+
+ const videos = await prisma.video.findMany({
+ where: { transcodingStatus: "uploaded" },
+ });
+
+ if (videos.length === 0) {
+ console.log("[Transcoder] Nothing to process");
+ return;
+ }
+
+ console.log(`[Transcoder] Found ${videos.length} videos`);
+
+ for (const video of videos) {
+ try {
+ // Atomically lock job
+ await prisma.video.update({
+ where: { id: video.id },
+ data: { transcodingStatus: "processing" },
+ });
+
+ await transcodeVideo(video.id);
+
+ await prisma.video.update({
+ where: { id: video.id },
+ data: { transcodingStatus: "transcoded" },
+ });
+
+ } catch (err) {
+ console.error(`[Error] ${video.id}`, err);
+
+ await prisma.video.update({
+ where: { id: video.id },
+ data: { transcodingStatus: "failed" },
+ });
+ }
+ }
+
+ await prisma.$disconnect();
+ console.log("[Transcoder] Batch complete");
+}
+
+main().catch(async (err) => {
+ console.error("[Fatal]", err);
+ await prisma.$disconnect();
+ process.exit(1);
+});
diff --git a/transcoder/package.json b/transcoder/package.json
new file mode 100644
index 0000000..6bfdda8
--- /dev/null
+++ b/transcoder/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "hls-transcoder",
+ "version": "1.0.0",
+ "description": "HLS transcoder service for video streaming",
+ "main": "dist/index.js",
+ "scripts": {
+ "start": "node dist/index.js",
+ "dev": "ts-node index.ts",
+ "build": "tsc",
+ "watch": "tsc --watch",
+ "postinstall": "prisma generate"
+ },
+ "dependencies": {
+ "@prisma/client": "6.19.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20",
+ "prisma": "^6.19.0",
+ "typescript": "^5.3.3",
+ "ts-node": "^10.9.2"
+ }
+}
diff --git a/transcoder/tsconfig.json b/transcoder/tsconfig.json
new file mode 100644
index 0000000..92d5aab
--- /dev/null
+++ b/transcoder/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "commonjs",
+ "lib": ["ES2020"],
+ "outDir": "./dist",
+ "rootDir": "./",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true
+ },
+ "include": ["index.ts"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..bfbf042
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts",
+ "**/*.mts"
+ ],
+ "exclude": ["node_modules", "public"]
+}
diff --git a/utils/getVideoDuration.ts b/utils/getVideoDuration.ts
new file mode 100644
index 0000000..490cca5
--- /dev/null
+++ b/utils/getVideoDuration.ts
@@ -0,0 +1,34 @@
+// utils/getVideoDuration.ts
+export async function getVideoDuration(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ // create an object URL for the file
+ const url = URL.createObjectURL(file);
+ const video = document.createElement('video');
+
+ // Make sure it doesn't try to load UI, just metadata
+ video.preload = 'metadata';
+ video.src = url;
+
+ const cleanup = () => {
+ URL.revokeObjectURL(url);
+ video.removeAttribute('src');
+ video.load();
+ };
+
+ video.addEventListener('loadedmetadata', () => {
+ // duration in seconds (float)
+ const duration = video.duration;
+ cleanup();
+ // Some encodings return Infinity — guard that
+ if (!isFinite(duration) || duration <= 0) {
+ return reject(new Error('Could not determine duration'));
+ }
+ resolve(Math.round(duration)); // return integer seconds
+ });
+
+ video.addEventListener('error', (e) => {
+ cleanup();
+ reject(new Error('Error reading video metadata'));
+ });
+ });
+}