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 +
+
+ {/* Content */} +
+
+``` + +**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 +
+ + + Welcome + Please login with your account + + +
+ + + + + +
+
+
+ + By continuing, you agree to our Terms of Service and + Privacy Policy + +
+``` + +### Admin Form Patterns + +Located in `app/admin/*/` directories + +**Data Table Integration:** +```tsx + +``` + +**Modal Form Pattern:** +```tsx + + + + Create New Item + +
+ {/* Form fields */} +
+
+
+``` + +--- + +## 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 + +``` + +### 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(); + + +``` + +--- + +## 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'; + +Description + +// Regular images +Icon +``` + +### 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 + + + + + + + + +``` + +### 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. +

+ + + +
+
+ + + + + + Manage Users + + + +

+ View users and their activity. +

+ + + +
+
+ + + + + + Manage Courses + + + +

+ Create and manage courses. +

+ + + +
+
+ + + + + + Manage Playlists + + + +

+ Create and manage playlists. +

+ + + +
+
+ + + + + + Manage Videos + + + +

+ Upload and manage videos. +

+ + + +
+
+ + + + + + Manage Enrollments + + + +

+ Manage user enrollments. +

+ + + +
+
+ + + + + + Allowed Students + + + +

+ Manage whitelisted student access. +

+ + + +
+
+
+
+ +
+ + + 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}) +
+ +
+ + +
+
+
+ + + + + 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 })} + + +
+ + +
+
+
+ )) + )} +
+
+
+
+ + {/* Add Student Sheet */} + + + + Add Student + +
+
+ + setNewEmail(e.target.value)} + /> +
+
+ + setNewLevels(e.target.value)} + /> +

+ Comma-separated course codes +

+
+
+ + + + +
+
+ + {/* Edit Student Sheet */} + + + + Edit Student + + {editingStudent && ( +
+
+ + + setEditingStudent({ ...editingStudent, email: e.target.value }) + } + /> +
+
+ + + setEditingStudent({ ...editingStudent, levels: e.target.value }) + } + /> +
+
+ )} + + + + +
+
+ + {/* Delete Confirmation */} + + + Remove Student + + Are you sure? The student will be prevented from logging in, but their existing enrollments will remain. + +
+ Cancel + + Remove + +
+
+
+
+ ); +} 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 + + +
+ + + Course title + setCourseTitle(e.target.value)} + placeholder="3D ANIMATION 300" + /> + + + + Course code + setCourseCode(e.target.value)} + placeholder="3D100" + /> + + + + + + +
+
+
+ + + + 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 + + + +
handleCreate(e)} + className="grid grid-cols-1 md:grid-cols-4 gap-4 items-end" + > +
+ + + Student + + + +
+ +
+ + + Course + + + +
+ +
+ +
+
+ + {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() + : '—'} + + +
+ +
+
+
+ ))} +
+
+ )} +
+
+
+ + ); +} + +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 ? ( + {title} + ) : ( +
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 + + +
+ + + Playlist title + + e.g. Character Rigging Basics + + setPlaylistTitle(e.target.value)} + placeholder="Playlist title" + /> + + + + Assign to course (primary) + + Select the main course. The playlist will always be accessible from here. + + + + + + Assign to additional courses (optional) + + Select other courses where this playlist should appear. + +
+ {courses + .filter((c) => c.id !== playlistCourseId) + .map((c) => ( + + ))} +
+
+ + + + +
+
+
+
+ + + + Organize Playlist + + +
+
+ + Select playlist + + +
+
+ +
+ {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(''); + } + }} + /> + + +
+ ) : ( +
+ {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
+ +
+ ); + } + + 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 ( +
+
+ + +
+ + {/* 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: 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 })} + + + + +
+ )) + )} +
+
+
+
+
+ ); + +} +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} + + +
+ + + Video Title + setTitle(e.target.value)} + placeholder="Enter video title" + /> + + + + + Description + + + Optional description for the video + +