From 81ad7e4ea9096ddb1a5747de9690ccf17322ca2e Mon Sep 17 00:00:00 2001
From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com>
Date: Thu, 11 Jun 2026 10:46:09 +0200
Subject: [PATCH] Initial commit
---
.dockerignore | 37 +
.gitignore | 47 +
Dockerfile | 46 +
Dockerfile.transcoder | 52 +
TRANSCODING.md | 277 +
UI_DESIGN_SYSTEM.md | 1138 ++
VPS_UPLOAD_CONFIG.md | 91 +
app/admin/admin-client.tsx | 208 +
app/admin/allowed-students/admin-client.tsx | 393 +
app/admin/allowed-students/page.tsx | 34 +
app/admin/courses/admin-client.tsx | 247 +
app/admin/courses/page.tsx | 22 +
app/admin/enrollments/admin-client.tsx | 349 +
app/admin/enrollments/page.tsx | 22 +
app/admin/page.tsx | 23 +
app/admin/playlists/admin-client.tsx | 539 +
app/admin/playlists/page.tsx | 22 +
app/admin/stats-client.tsx | 298 +
app/admin/stats/page.tsx | 26 +
app/admin/users/[userId]/page.tsx | 26 +
.../users/[userId]/user-detail-client.tsx | 564 +
app/admin/users/admin-client.tsx | 185 +
app/admin/users/page.tsx | 22 +
.../[videoId]/edit/edit-video-client.tsx | 268 +
app/admin/videos/[videoId]/edit/page.tsx | 57 +
app/admin/videos/admin-client.tsx | 815 ++
app/admin/videos/page.tsx | 22 +
.../admin/allowed-students/import/route.ts | 156 +
app/api/admin/allowed-students/route.ts | 192 +
app/api/admin/create-course/route.ts | 27 +
app/api/admin/create-playlist/route.ts | 42 +
app/api/admin/delete-course/route.ts | 40 +
app/api/admin/delete-playlist/route.ts | 40 +
app/api/admin/delete-video/route.ts | 37 +
.../admin/manage-playlist-courses/route.ts | 108 +
app/api/admin/meta/route.ts | 19 +
app/api/admin/notifications/route.ts | 168 +
app/api/admin/playlist-videos/route.ts | 34 +
app/api/admin/reorder-videos/route.ts | 55 +
app/api/admin/stats/route.ts | 106 +
app/api/admin/update-playlist/route.ts | 48 +
app/api/admin/update-user-role/route.ts | 77 +
app/api/admin/update-video/route.ts | 141 +
app/api/admin/upload/finalize/route.ts | 150 +
app/api/admin/upload/route.ts | 394 +
.../progress/[videoId]/segments/route.ts | 37 +
app/api/admin/users/[userId]/route.ts | 150 +
app/api/admin/users/route.ts | 68 +
.../videos/[videoId]/instant-access/route.ts | 96 +
.../admin/videos/[videoId]/locked/route.ts | 96 +
app/api/admin/videos/route.ts | 53 +
app/api/auth/[...nextauth]/route.ts | 7 +
app/api/auth/check-student.ts | 57 +
app/api/comments/[commentId]/reply/route.ts | 62 +
app/api/comments/[commentId]/route.ts | 36 +
app/api/comments/reply/[replyId]/route.ts | 35 +
app/api/comments/route.ts | 123 +
app/api/courses/route.ts | 8 +
app/api/enrollments/route.ts | 94 +
app/api/enrollments/sync.ts | 122 +
app/api/enrollments/sync/route.ts | 122 +
app/api/likes/all/route.ts | 53 +
app/api/likes/route.ts | 88 +
app/api/playlists/[id]/route.ts | 59 +
app/api/playlists/route.ts | 133 +
app/api/progress/route.ts | 225 +
app/api/progress/segments/route.ts | 45 +
app/api/thumbnails/[...path]/route.ts | 117 +
app/api/transcoder/claim/route.ts | 60 +
.../transcoder/download/[videoId]/route.ts | 72 +
app/api/transcoder/fail/[videoId]/route.ts | 54 +
app/api/transcoder/upload/[videoId]/route.ts | 140 +
app/api/user/unlocks/route.ts | 104 +
app/api/users/route.ts | 9 +
app/api/videos/[id]/route.ts | 109 +
app/api/videos/hls/[...path]/route.ts | 109 +
app/api/videos/latest/route.ts | 81 +
app/api/watch-history/route.ts | 44 +
app/dashboard/dashboard-client.tsx | 484 +
app/dashboard/liked-videos-client.tsx | 225 +
app/dashboard/liked-videos/page.tsx | 20 +
app/dashboard/page.tsx | 11 +
app/dashboard/watch-history-client.tsx | 210 +
app/dashboard/watch-history/page.tsx | 20 +
app/favicon.ico | Bin 0 -> 6542 bytes
app/globals.css | 122 +
app/layout.tsx | 27 +
app/login/page.tsx | 26 +
app/login/unauthorized/page.tsx | 53 +
app/page.tsx | 26 +
app/providers.tsx | 22 +
app/videoplayer/page.tsx | 785 ++
build-and-push.bat | 59 +
build-and-push.sh | 47 +
components.json | 22 +
components/JoinForm.tsx | 27 +
components/LandingPage.tsx | 129 +
components/VideoCarousel.tsx | 158 +
components/admin-notifications.tsx | 210 +
components/app-sidebar.tsx | 97 +
components/chart-area-interactive.tsx | 291 +
components/comments-section.tsx | 436 +
components/data-table.tsx | 807 ++
components/hls.tsx | 144 +
components/login-form.tsx | 64 +
components/nav-documents.tsx | 92 +
components/nav-main.tsx | 91 +
components/nav-secondary.tsx | 42 +
components/nav-user.tsx | 105 +
components/section-cards.tsx | 102 +
components/segmented-progress-bar.tsx | 192 +
components/site-header.tsx | 116 +
components/theme-provider.tsx | 11 +
components/ui/accordion.tsx | 66 +
components/ui/alert-dialog.tsx | 157 +
components/ui/avatar.tsx | 53 +
components/ui/badge.tsx | 46 +
components/ui/breadcrumb.tsx | 109 +
components/ui/button.tsx | 60 +
components/ui/card.tsx | 92 +
components/ui/carousel.tsx | 241 +
components/ui/chart.tsx | 357 +
components/ui/checkbox.tsx | 32 +
components/ui/collapsible.tsx | 33 +
components/ui/drawer.tsx | 135 +
components/ui/dropdown-menu.tsx | 257 +
components/ui/field.tsx | 248 +
components/ui/input.tsx | 21 +
components/ui/label.tsx | 24 +
components/ui/progress.tsx | 31 +
components/ui/select.tsx | 187 +
components/ui/separator.tsx | 28 +
components/ui/sheet.tsx | 139 +
components/ui/sidebar.tsx | 726 ++
components/ui/skeleton.tsx | 13 +
components/ui/sonner.tsx | 40 +
components/ui/switch.tsx | 31 +
components/ui/table.tsx | 116 +
components/ui/tabs.tsx | 66 +
components/ui/textarea.tsx | 18 +
components/ui/toggle-group.tsx | 83 +
components/ui/toggle.tsx | 47 +
components/ui/tooltip.tsx | 61 +
docker-compose.yml | 103 +
eslint.config.mjs | 18 +
hooks/use-mobile.ts | 19 +
hooks/usePlaylist.ts | 42 +
hooks/usePlaylists.ts | 37 +
hooks/useVideo.ts | 47 +
lib/admin-check.ts | 11 +
lib/auth-check.ts | 32 +
lib/auth-options.ts | 189 +
lib/normalize-email.ts | 8 +
lib/prisma.ts | 16 +
lib/transcoder-auth.ts | 27 +
lib/transcoding-status.ts | 52 +
lib/utils.ts | 6 +
lib/video-urls.ts | 50 +
migration.sql | Bin 0 -> 3544 bytes
next.config.ts | 15 +
package-lock.json | 9575 +++++++++++++++++
package.json | 78 +
pnpm-lock.yaml | 6624 ++++++++++++
postcss.config.mjs | 7 +
prisma.config.ts | 13 +
.../20251121094650_init/migration.sql | 173 +
.../migration.sql | 2 +
.../migration.sql | 2 +
.../migration.sql | 16 +
.../migration.sql | 2 +
.../migration.sql | 24 +
.../migration.sql | 2 +
.../20251126062900_add_comments/migration.sql | 45 +
.../migration.sql | 11 +
.../migration.sql | 33 +
.../migration.sql | 31 +
.../migration.sql | 8 +
.../migration.sql | 11 +
.../migration.sql | 14 +
.../migration.sql | 21 +
.../migration.sql | 22 +
.../migration.sql | 28 +
.../add_transcoding_status/migration.sql | 5 +
prisma/migrations/migration_lock.toml | 3 +
prisma/reset-db.ts | 53 +
prisma/schema.prisma | 266 +
prisma/seed.js | 75 +
prisma/seed.ts | 129 +
public/file.svg | 1 +
public/globe.svg | 1 +
public/icon.svg | 27 +
public/next.svg | 1 +
public/vault.exr | Bin 0 -> 2211911 bytes
public/vault.png | Bin 0 -> 662335 bytes
public/vercel.svg | 1 +
public/window.svg | 1 +
run transcode.md | 5 +
scripts/check-db-integrity.ts | 171 +
scripts/cleanup-completed-progress.ts | 59 +
scripts/cleanup-orphaned-data.ts | 143 +
scripts/update-thumbnail-urls.ts | 26 +
setup-transcoding.bat | 161 +
setup-transcoding.sh | 150 +
tailwind.config.cjs | 13 +
transcoder-remote/Dockerfile | 44 +
transcoder-remote/README.md | 196 +
transcoder-remote/docker-compose.yml | 18 +
transcoder-remote/package.json | 20 +
transcoder-remote/src/api-client.ts | 133 +
transcoder-remote/src/index.ts | 175 +
transcoder-remote/src/packager.ts | 39 +
transcoder-remote/src/transcoder.ts | 165 +
transcoder-remote/tsconfig.json | 15 +
transcoder/README.md | 339 +
transcoder/dist/index.d.ts | 2 +
transcoder/dist/index.d.ts.map | 1 +
transcoder/dist/index.js | 211 +
transcoder/dist/index.js.map | 1 +
transcoder/index.ts | 235 +
transcoder/package.json | 22 +
transcoder/tsconfig.json | 19 +
tsconfig.json | 34 +
utils/getVideoDuration.ts | 34 +
223 files changed, 39530 insertions(+)
create mode 100644 .dockerignore
create mode 100644 .gitignore
create mode 100644 Dockerfile
create mode 100644 Dockerfile.transcoder
create mode 100644 TRANSCODING.md
create mode 100644 UI_DESIGN_SYSTEM.md
create mode 100644 VPS_UPLOAD_CONFIG.md
create mode 100644 app/admin/admin-client.tsx
create mode 100644 app/admin/allowed-students/admin-client.tsx
create mode 100644 app/admin/allowed-students/page.tsx
create mode 100644 app/admin/courses/admin-client.tsx
create mode 100644 app/admin/courses/page.tsx
create mode 100644 app/admin/enrollments/admin-client.tsx
create mode 100644 app/admin/enrollments/page.tsx
create mode 100644 app/admin/page.tsx
create mode 100644 app/admin/playlists/admin-client.tsx
create mode 100644 app/admin/playlists/page.tsx
create mode 100644 app/admin/stats-client.tsx
create mode 100644 app/admin/stats/page.tsx
create mode 100644 app/admin/users/[userId]/page.tsx
create mode 100644 app/admin/users/[userId]/user-detail-client.tsx
create mode 100644 app/admin/users/admin-client.tsx
create mode 100644 app/admin/users/page.tsx
create mode 100644 app/admin/videos/[videoId]/edit/edit-video-client.tsx
create mode 100644 app/admin/videos/[videoId]/edit/page.tsx
create mode 100644 app/admin/videos/admin-client.tsx
create mode 100644 app/admin/videos/page.tsx
create mode 100644 app/api/admin/allowed-students/import/route.ts
create mode 100644 app/api/admin/allowed-students/route.ts
create mode 100644 app/api/admin/create-course/route.ts
create mode 100644 app/api/admin/create-playlist/route.ts
create mode 100644 app/api/admin/delete-course/route.ts
create mode 100644 app/api/admin/delete-playlist/route.ts
create mode 100644 app/api/admin/delete-video/route.ts
create mode 100644 app/api/admin/manage-playlist-courses/route.ts
create mode 100644 app/api/admin/meta/route.ts
create mode 100644 app/api/admin/notifications/route.ts
create mode 100644 app/api/admin/playlist-videos/route.ts
create mode 100644 app/api/admin/reorder-videos/route.ts
create mode 100644 app/api/admin/stats/route.ts
create mode 100644 app/api/admin/update-playlist/route.ts
create mode 100644 app/api/admin/update-user-role/route.ts
create mode 100644 app/api/admin/update-video/route.ts
create mode 100644 app/api/admin/upload/finalize/route.ts
create mode 100644 app/api/admin/upload/route.ts
create mode 100644 app/api/admin/users/[userId]/progress/[videoId]/segments/route.ts
create mode 100644 app/api/admin/users/[userId]/route.ts
create mode 100644 app/api/admin/users/route.ts
create mode 100644 app/api/admin/videos/[videoId]/instant-access/route.ts
create mode 100644 app/api/admin/videos/[videoId]/locked/route.ts
create mode 100644 app/api/admin/videos/route.ts
create mode 100644 app/api/auth/[...nextauth]/route.ts
create mode 100644 app/api/auth/check-student.ts
create mode 100644 app/api/comments/[commentId]/reply/route.ts
create mode 100644 app/api/comments/[commentId]/route.ts
create mode 100644 app/api/comments/reply/[replyId]/route.ts
create mode 100644 app/api/comments/route.ts
create mode 100644 app/api/courses/route.ts
create mode 100644 app/api/enrollments/route.ts
create mode 100644 app/api/enrollments/sync.ts
create mode 100644 app/api/enrollments/sync/route.ts
create mode 100644 app/api/likes/all/route.ts
create mode 100644 app/api/likes/route.ts
create mode 100644 app/api/playlists/[id]/route.ts
create mode 100644 app/api/playlists/route.ts
create mode 100644 app/api/progress/route.ts
create mode 100644 app/api/progress/segments/route.ts
create mode 100644 app/api/thumbnails/[...path]/route.ts
create mode 100644 app/api/transcoder/claim/route.ts
create mode 100644 app/api/transcoder/download/[videoId]/route.ts
create mode 100644 app/api/transcoder/fail/[videoId]/route.ts
create mode 100644 app/api/transcoder/upload/[videoId]/route.ts
create mode 100644 app/api/user/unlocks/route.ts
create mode 100644 app/api/users/route.ts
create mode 100644 app/api/videos/[id]/route.ts
create mode 100644 app/api/videos/hls/[...path]/route.ts
create mode 100644 app/api/videos/latest/route.ts
create mode 100644 app/api/watch-history/route.ts
create mode 100644 app/dashboard/dashboard-client.tsx
create mode 100644 app/dashboard/liked-videos-client.tsx
create mode 100644 app/dashboard/liked-videos/page.tsx
create mode 100644 app/dashboard/page.tsx
create mode 100644 app/dashboard/watch-history-client.tsx
create mode 100644 app/dashboard/watch-history/page.tsx
create mode 100644 app/favicon.ico
create mode 100644 app/globals.css
create mode 100644 app/layout.tsx
create mode 100644 app/login/page.tsx
create mode 100644 app/login/unauthorized/page.tsx
create mode 100644 app/page.tsx
create mode 100644 app/providers.tsx
create mode 100644 app/videoplayer/page.tsx
create mode 100644 build-and-push.bat
create mode 100644 build-and-push.sh
create mode 100644 components.json
create mode 100644 components/JoinForm.tsx
create mode 100644 components/LandingPage.tsx
create mode 100644 components/VideoCarousel.tsx
create mode 100644 components/admin-notifications.tsx
create mode 100644 components/app-sidebar.tsx
create mode 100644 components/chart-area-interactive.tsx
create mode 100644 components/comments-section.tsx
create mode 100644 components/data-table.tsx
create mode 100644 components/hls.tsx
create mode 100644 components/login-form.tsx
create mode 100644 components/nav-documents.tsx
create mode 100644 components/nav-main.tsx
create mode 100644 components/nav-secondary.tsx
create mode 100644 components/nav-user.tsx
create mode 100644 components/section-cards.tsx
create mode 100644 components/segmented-progress-bar.tsx
create mode 100644 components/site-header.tsx
create mode 100644 components/theme-provider.tsx
create mode 100644 components/ui/accordion.tsx
create mode 100644 components/ui/alert-dialog.tsx
create mode 100644 components/ui/avatar.tsx
create mode 100644 components/ui/badge.tsx
create mode 100644 components/ui/breadcrumb.tsx
create mode 100644 components/ui/button.tsx
create mode 100644 components/ui/card.tsx
create mode 100644 components/ui/carousel.tsx
create mode 100644 components/ui/chart.tsx
create mode 100644 components/ui/checkbox.tsx
create mode 100644 components/ui/collapsible.tsx
create mode 100644 components/ui/drawer.tsx
create mode 100644 components/ui/dropdown-menu.tsx
create mode 100644 components/ui/field.tsx
create mode 100644 components/ui/input.tsx
create mode 100644 components/ui/label.tsx
create mode 100644 components/ui/progress.tsx
create mode 100644 components/ui/select.tsx
create mode 100644 components/ui/separator.tsx
create mode 100644 components/ui/sheet.tsx
create mode 100644 components/ui/sidebar.tsx
create mode 100644 components/ui/skeleton.tsx
create mode 100644 components/ui/sonner.tsx
create mode 100644 components/ui/switch.tsx
create mode 100644 components/ui/table.tsx
create mode 100644 components/ui/tabs.tsx
create mode 100644 components/ui/textarea.tsx
create mode 100644 components/ui/toggle-group.tsx
create mode 100644 components/ui/toggle.tsx
create mode 100644 components/ui/tooltip.tsx
create mode 100644 docker-compose.yml
create mode 100644 eslint.config.mjs
create mode 100644 hooks/use-mobile.ts
create mode 100644 hooks/usePlaylist.ts
create mode 100644 hooks/usePlaylists.ts
create mode 100644 hooks/useVideo.ts
create mode 100644 lib/admin-check.ts
create mode 100644 lib/auth-check.ts
create mode 100644 lib/auth-options.ts
create mode 100644 lib/normalize-email.ts
create mode 100644 lib/prisma.ts
create mode 100644 lib/transcoder-auth.ts
create mode 100644 lib/transcoding-status.ts
create mode 100644 lib/utils.ts
create mode 100644 lib/video-urls.ts
create mode 100644 migration.sql
create mode 100644 next.config.ts
create mode 100644 package-lock.json
create mode 100644 package.json
create mode 100644 pnpm-lock.yaml
create mode 100644 postcss.config.mjs
create mode 100644 prisma.config.ts
create mode 100644 prisma/migrations/20251121094650_init/migration.sql
create mode 100644 prisma/migrations/20251124070120_add_email_verified_to_user/migration.sql
create mode 100644 prisma/migrations/20251124083316_add_user_role/migration.sql
create mode 100644 prisma/migrations/20251124142723_update_progress/migration.sql
create mode 100644 prisma/migrations/20251124153638_update_progress/migration.sql
create mode 100644 prisma/migrations/20251125164010_add_video_likes/migration.sql
create mode 100644 prisma/migrations/20251126061714_add_video_description/migration.sql
create mode 100644 prisma/migrations/20251126062900_add_comments/migration.sql
create mode 100644 prisma/migrations/20251126065622_add_cascade_delete/migration.sql
create mode 100644 prisma/migrations/20251127072634_add_video_watch_segments/migration.sql
create mode 100644 prisma/migrations/20251127085246_add_video_unlock_system/migration.sql
create mode 100644 prisma/migrations/20251127094406_add_video_uploader/migration.sql
create mode 100644 prisma/migrations/20251127144241_add_user_tracking_to_courses_and_playlists/migration.sql
create mode 100644 prisma/migrations/20260208092841_add_allowed_students/migration.sql
create mode 100644 prisma/migrations/20260208100000_add_cascade_deletes/migration.sql
create mode 100644 prisma/migrations/20260210155004_add_course_playlist_mapping/migration.sql
create mode 100644 prisma/migrations/20260211152229_add_video_course_restrictions/migration.sql
create mode 100644 prisma/migrations/add_transcoding_status/migration.sql
create mode 100644 prisma/migrations/migration_lock.toml
create mode 100644 prisma/reset-db.ts
create mode 100644 prisma/schema.prisma
create mode 100644 prisma/seed.js
create mode 100644 prisma/seed.ts
create mode 100644 public/file.svg
create mode 100644 public/globe.svg
create mode 100644 public/icon.svg
create mode 100644 public/next.svg
create mode 100644 public/vault.exr
create mode 100644 public/vault.png
create mode 100644 public/vercel.svg
create mode 100644 public/window.svg
create mode 100644 run transcode.md
create mode 100644 scripts/check-db-integrity.ts
create mode 100644 scripts/cleanup-completed-progress.ts
create mode 100644 scripts/cleanup-orphaned-data.ts
create mode 100644 scripts/update-thumbnail-urls.ts
create mode 100644 setup-transcoding.bat
create mode 100644 setup-transcoding.sh
create mode 100644 tailwind.config.cjs
create mode 100644 transcoder-remote/Dockerfile
create mode 100644 transcoder-remote/README.md
create mode 100644 transcoder-remote/docker-compose.yml
create mode 100644 transcoder-remote/package.json
create mode 100644 transcoder-remote/src/api-client.ts
create mode 100644 transcoder-remote/src/index.ts
create mode 100644 transcoder-remote/src/packager.ts
create mode 100644 transcoder-remote/src/transcoder.ts
create mode 100644 transcoder-remote/tsconfig.json
create mode 100644 transcoder/README.md
create mode 100644 transcoder/dist/index.d.ts
create mode 100644 transcoder/dist/index.d.ts.map
create mode 100644 transcoder/dist/index.js
create mode 100644 transcoder/dist/index.js.map
create mode 100644 transcoder/index.ts
create mode 100644 transcoder/package.json
create mode 100644 transcoder/tsconfig.json
create mode 100644 tsconfig.json
create mode 100644 utils/getVideoDuration.ts
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..0c4a88d
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,37 @@
+.git
+.gitignore
+node_modules
+.next
+.env.local
+.env.*.local
+.DS_Store
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+.turbo
+.swc
+.vscode
+.idea
+*.swp
+*.swo
+*~
+.cache
+coverage
+dist
+build
+out
+.env.example
+README.md
+LICENSE
+.editorconfig
+.prettierrc
+.eslintrc*
+.gitattributes
+public/videos
+public/thumbnails
+# Note: public/uploads is no longer used; uploads are stored in UPLOADS_DIR (/uploads)
+uploads
+.turbopack
+.turbopack-cache
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7478d14
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,47 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+
+/lib/generated/prisma
+
+# uploaded media (large binary files - store outside git)
+/public/videos/
+/public/thumbnails/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..2b94fdb
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,46 @@
+# Multi-stage Dockerfile for Next.js CMS
+# Stage 1: Build
+FROM node:20-alpine AS builder
+
+WORKDIR /app
+
+# Copy package files
+COPY package.json pnpm-lock.yaml ./
+
+# Install dependencies
+RUN npm install -g pnpm && pnpm install --frozen-lockfile
+
+# Copy source code
+COPY . .
+
+# Generate Prisma client
+RUN pnpm exec prisma generate
+
+# Build Next.js application
+RUN npm run build
+
+# Stage 2: Production Runtime
+FROM node:20-alpine
+
+WORKDIR /app
+
+# Install dumb-init for proper signal handling
+RUN apk add --no-cache dumb-init
+
+# Copy from builder: node_modules and .next build output
+COPY --from=builder /app/node_modules ./node_modules
+COPY --from=builder /app/.next ./.next
+COPY --from=builder /app/public ./public
+COPY --from=builder /app/package.json ./package.json
+
+# Set production environment
+ENV NODE_ENV=production
+
+# Expose port 3000
+EXPOSE 3000
+
+# Use dumb-init to handle signals properly
+ENTRYPOINT ["dumb-init", "--"]
+
+# Start Next.js production server
+CMD ["npm", "run", "start"]
diff --git a/Dockerfile.transcoder b/Dockerfile.transcoder
new file mode 100644
index 0000000..e56c3d4
--- /dev/null
+++ b/Dockerfile.transcoder
@@ -0,0 +1,52 @@
+# ==============================
+# Build stage
+# ==============================
+FROM node:20-alpine AS builder
+
+WORKDIR /app
+
+# Copy package files and Prisma schema FIRST
+COPY transcoder/package.json transcoder/tsconfig.json ./
+COPY prisma ./prisma
+
+# Set a temporary DATABASE_URL for Prisma schema generation during build
+ENV DATABASE_URL="postgresql://user:password@localhost:5432/dummy"
+
+# Install all deps (including dev)
+RUN npm install
+
+# Copy source
+COPY transcoder ./
+
+# Build TypeScript
+RUN npm run build
+
+
+# ==============================
+# Runtime stage
+# ==============================
+FROM node:20-alpine
+
+# Install FFmpeg
+RUN apk add --no-cache ffmpeg
+
+WORKDIR /app
+
+# Copy package files
+COPY transcoder/package.json ./
+
+# Copy node_modules from builder (includes all dependencies)
+COPY --from=builder /app/node_modules ./node_modules
+
+# Copy compiled output
+COPY --from=builder /app/dist ./dist
+
+# Copy Prisma schema
+COPY prisma ./prisma
+
+# Environment defaults (can be overridden)
+ENV NODE_ENV=production
+ENV UPLOADS_DIR=/uploads
+
+# The container runs once and exits
+CMD ["node", "dist/index.js"]
diff --git a/TRANSCODING.md b/TRANSCODING.md
new file mode 100644
index 0000000..2363af1
--- /dev/null
+++ b/TRANSCODING.md
@@ -0,0 +1,277 @@
+# HLS Transcoding Setup Documentation
+
+## Overview
+
+This system implements HLS (HTTP Live Streaming) encoding and adaptive bitrate streaming for video content. Videos are stored as MP4s initially and are automatically transcoded to HLS format using FFmpeg.
+
+## Architecture
+
+### Components
+
+1. **CMS Application** (Next.js)
+ - Allows admin users to upload MP4 videos
+ - Videos are stored in `/uploads/videos/{video_id}.mp4`
+ - Videos are marked with `transcodingStatus = 'uploaded'` initially
+ - Video player supports HLS with MP4 fallback
+
+2. **Transcoder Service** (Node.js + FFmpeg)
+ - Runs as a batch job, processing all videos with `transcodingStatus = 'uploaded'`
+ - Reads MP4 from storage and converts to HLS format
+ - Creates multiple quality variants (based on source resolution):
+ - **1080p**: 1920x1080 @ 3500 kbps (if source supports)
+ - **720p**: 1280x720 @ 1800 kbps (if source supports)
+ - **480p**: 854x480 @ 900 kbps (if source supports)
+ - Stores files in `/uploads/hls/{video_id}.tmp/` during transcoding
+ - Creates a master playlist (`master.m3u8`) combining all variants
+ - Atomically renames folder from `.tmp` to final location
+ - Updates database to `transcodingStatus = 'transcoded'` on success
+ - Marks as `'failed'` if transcoding fails
+ - Runs in a loop with configurable delay between batches
+
+3. **Database Schema**
+ - `Video` model includes `transcodingStatus` field
+ - Valid values: `'uploaded'` | `'processing'` | `'transcoded'` | `'failed'`
+
+4. **Video Player**
+ - Uses `HlsPlayer` component with HLS.js
+ - Automatically selects source based on availability:
+ - If `transcodingStatus = 'transcoded'`: Uses `/api/videos/hls/{video_id}/master.m3u8`
+ - Otherwise: Falls back to original MP4 file
+
+## Directory Structure
+
+```
+/uploads/
+├── videos/
+│ ├── {video_id_1}.mp4
+│ ├── {video_id_2}.mp4
+│ └── ...
+└── hls/
+ ├── {video_id_1}/
+ │ ├── master.m3u8 (Master playlist)
+ │ ├── 1080p.m3u8
+ │ ├── 1080p_000.ts
+ │ ├── 1080p_001.ts
+ │ ├── 720p.m3u8
+ │ ├── 720p_000.ts
+ │ ├── 480p.m3u8
+ │ └── ...
+ └── {video_id_2}/
+ └── ...
+```
+
+## Database Migration
+
+The migration adds `transcodingStatus` field to the `Video` model:
+
+```sql
+ALTER TABLE "Video" ADD COLUMN "transcodingStatus" TEXT NOT NULL DEFAULT 'uploaded';
+CREATE INDEX "Video_transcodingStatus_idx" ON "Video"("transcodingStatus");
+```
+
+## Docker Setup
+
+### Building the Transcoder
+
+The transcoder is built as a multi-stage Docker image:
+
+```bash
+docker build -f Dockerfile.transcoder -t vault-transcoder:latest .
+```
+
+### Running with Docker Compose
+
+```bash
+docker-compose up -d
+```
+
+Services started:
+- **cms** (port 3000): Next.js CMS application
+- **postgres**: PostgreSQL database
+- **transcoder**: HLS transcoding service
+
+### Environment Variables
+
+Create a `.env` file in the project root:
+
+```
+# Transcoder (set automatically via docker-compose)
+DATABASE_URL=postgresql://cms_user:changeme@postgres:5432/cms_db
+UPLOADS_DIR=/uploads
+POLL_INTERVAL=5000
+CONCURRENT_JOBS=2
+
+# CMS specific (as before)
+NEXTAUTH_SECRET=your-secret-key
+NEXTAUTH_URL=http://localhost:3000
+GOOGLE_CLIENT_ID=...
+GOOGLE_CLIENT_SECRET=...
+ALLOWED_ADMINS=admin@university.edu
+
+# Paths
+UPLOADS_PATH=/mnt/tank/apps/college-platform/uploads
+POSTGRES_DATA_PATH=/mnt/tank/apps/college-platform/postgres_data
+```
+
+## API Endpoints
+
+### Get Video Details
+
+```
+GET /api/videos/{videoId}
+```
+
+Response includes:
+
+```json
+{
+ "video": {
+ "id": "...",
+ "title": "...",
+ "url": "/videos/{video_id}.mp4",
+ "transcodingStatus": "transcoded",
+ "videoUrls": {
+ "hlsUrl": "/api/videos/hls/{video_id}/master.m3u8",
+ "mp4Url": "/videos/{video_id}.mp4"
+ }
+ }
+}
+```
+
+## File Format: HLS Playlist Examples
+
+### Master Playlist (`master.m3u8`)
+
+```m3u8
+#EXTM3U
+#EXT-X-VERSION:3
+#EXT-X-STREAM-INF:BANDWIDTH=3500000,RESOLUTION=1920x1080
+1080p.m3u8
+#EXT-X-STREAM-INF:BANDWIDTH=1800000,RESOLUTION=1280x720
+720p.m3u8
+#EXT-X-STREAM-INF:BANDWIDTH=900000,RESOLUTION=854x480
+480p.m3u8
+```
+
+### Variant Playlist (`1080p.m3u8`)
+
+```m3u8
+#EXTM3U
+#EXT-X-VERSION:3
+#EXT-X-TARGETDURATION:6
+#EXT-X-PLAYLIST-TYPE:VOD
+#EXTINF:6.0,
+1080p_000.ts
+#EXTINF:6.0,
+1080p_001.ts
+...
+#EXT-X-ENDLIST
+```
+
+## Development Mode
+
+For local development without Docker:
+
+1. Ensure FFmpeg is installed on your system
+2. Set environment variables:
+ ```bash
+ export DATABASE_URL=postgresql://cms_user:changeme@localhost:5432/cms_db
+ export UPLOADS_DIR=/path/to/uploads
+ ```
+3. Run the transcoder:
+ ```bash
+ cd transcoder
+ npm install
+ npm run dev
+ ```
+
+## Monitoring & Logs
+
+### CMS Logs
+
+```bash
+docker logs college-cms
+```
+
+### Transcoder Logs
+
+```bash
+docker logs college-transcoder
+```
+
+Look for lines starting with:
+- `[Transcoding]` - Transcoding progress
+- `[HLS]` - HLS specific operations
+- `[Database]` - Database updates
+- `[Error]` - Any errors encountered
+
+### Checking Transcoding Status
+
+```bash
+# Check video transcoding status
+psql -U cms_user -d cms_db -c "SELECT id, title, transcodingStatus FROM \"Video\";"
+
+# Monitor real-time transcoding
+watch -n 1 'docker exec college-transcoder head -20 /app/transcoder.log'
+```
+
+## Troubleshooting
+
+### Transcoder Not Finding Videos
+
+**Issue**: Transcoder logs show "MP4 file not found"
+
+**Solution**:
+1. Verify MP4 files are in `/uploads/videos/` directory
+2. Check file permissions: `chmod 644 /uploads/videos/*.mp4`
+3. Ensure the path matches the `UPLOADS_DIR` environment variable
+
+### HLS Files Not Generated
+
+**Issue**: Videos stay in `'uploaded'` state
+
+**Solution**:
+1. Check FFmpeg is installed in container: `docker exec college-transcoder ffmpeg -version`
+2. Check database connectivity: `docker logs college-transcoder | grep "DATABASE_URL"`
+3. Verify disk space: `df -h /uploads`
+
+### Video Player Shows MP4 Instead of HLS
+
+**Issue**: HLS not playing even after transcoding
+
+**Solution**:
+1. Verify `transcodingStatus = 'transcoded'` in database
+2. Check HLS files exist: `ls /uploads/hls/{video_id}/`
+3. Verify master playlist is valid: `cat /uploads/hls/{video_id}/master.m3u8`
+
+### Performance Issues
+
+**Recommendation**: Adjust `CONCURRENT_JOBS` based on available system resources:
+- 1-2 jobs for systems with <4 CPU cores
+- 2-4 jobs for systems with 4-8 CPU cores
+- Increase `POLL_INTERVAL` if CPU usage is high
+
+## Client-Side Configuration
+
+The video player is configured to:
+
+1. Try HLS playlist first if available
+2. Fall back to MP4 on HLS failure
+3. Use adaptive bitrate selection when HLS is available
+4. Support all modern browsers (Chrome, Firefox, Safari, Edge)
+
+## Security Considerations
+
+1. **Storage Access**: Only the transcoder container has write access to `/uploads/hls/`
+2. **Database**: Use strong PostgreSQL password in production
+3. **Network**: Both containers connect through Docker network, no external ports exposed
+4. **File Validation**: Consider adding video file validation before transcoding
+
+## Future Enhancements
+
+1. Add thumbnail extraction during transcoding
+2. Implement retry logic for failed transcoding jobs
+3. Add progress tracking API for long-running transcodes
+4. Support multiple audio tracks/subtitles
+5. Implement cache invalidation for CDN
+6. Add metrics and monitoring dashboard
diff --git a/UI_DESIGN_SYSTEM.md b/UI_DESIGN_SYSTEM.md
new file mode 100644
index 0000000..a58b0be
--- /dev/null
+++ b/UI_DESIGN_SYSTEM.md
@@ -0,0 +1,1138 @@
+# OW Animation Arts Vault - UI Design System & Layout Guide
+
+This document outlines the complete UI/style and layout architecture used in this codebase. Use this guide to create additional applications for the same client with consistent design and user experience.
+
+---
+
+## Table of Contents
+
+1. [Technology Stack](#technology-stack)
+2. [Project Architecture](#project-architecture)
+3. [Color System & Theming](#color-system--theming)
+4. [Layout System](#layout-system)
+5. [Navigation & Sidebar](#navigation--sidebar)
+6. [Page Layouts](#page-layouts)
+7. [Component Library](#component-library)
+8. [Typography & Spacing](#typography--spacing)
+9. [Form Patterns](#form-patterns)
+10. [Responsive Design](#responsive-design)
+11. [Dark Mode Implementation](#dark-mode-implementation)
+12. [Reusable Patterns](#reusable-patterns)
+13. [Asset Guidelines](#asset-guidelines)
+
+---
+
+## 1. Technology Stack
+
+### Core Framework
+- **Next.js 16.0.3** - React framework with App Router
+- **React 18+** - UI library
+- **TypeScript** - Type safety
+- **Tailwind CSS 3.4+** - Utility-first CSS framework
+
+### UI Component Library
+- **shadcn/ui** - Pre-built, composable React components
+- **@radix-ui/** - Unstyled, accessible component primitives
+- **Lucide React** - Beautiful, consistent icon library
+- **Tabler Icons** - Alternative icon set
+
+### State Management & Data
+- **NextAuth.js** - Authentication
+- **Next-Themes** - Theme management (dark/light mode)
+- **Prisma ORM** - Database abstraction
+- **TanStack React Table** - Advanced table data management
+- **Sonner** - Toast notifications
+
+### Additional Libraries
+- **@dnd-kit** - Drag and drop functionality
+- **Embla Carousel** - Carousel/slider component
+- **Recharts** - Data visualization charts
+- **HLS.js** - Video streaming (HLS protocol)
+- **date-fns** - Date utilities
+- **class-variance-authority (CVA)** - Component variant management
+- **clsx** - Conditional className merging
+
+---
+
+## 2. Project Architecture
+
+### Directory Structure
+
+```
+app/
+├── layout.tsx # Root layout (server component)
+├── page.tsx # Landing/home page
+├── providers.tsx # Client-side providers (SessionProvider, ThemeProvider)
+├── globals.css # Global styles & CSS variables
+├── login/
+│ ├── page.tsx # Login page
+│ └── unauthorized/
+├── dashboard/
+│ ├── page.tsx # Protected dashboard
+│ ├── dashboard-client.tsx # Client component with content
+│ ├── liked-videos/
+│ └── watch-history/
+├── admin/
+│ ├── page.tsx # Admin gate (server component)
+│ ├── admin-client.tsx # Admin UI (client component)
+│ ├── users/
+│ ├── courses/
+│ ├── playlists/
+│ ├── videos/
+│ ├── enrollments/
+│ └── stats/
+├── api/ # API routes for server operations
+└── videoplayer/ # Video player page
+
+components/
+├── app-sidebar.tsx # Main navigation sidebar
+├── site-header.tsx # Top header with breadcrumbs
+├── nav-main.tsx # Primary navigation menu
+├── nav-secondary.tsx # Secondary navigation items
+├── nav-user.tsx # User profile dropdown
+├── login-form.tsx # Google OAuth login form
+├── data-table.tsx # Advanced data table component
+├── hls.tsx # Video player (HLS streaming)
+├── chart-area-interactive.tsx # Interactive chart component
+├── comments-section.tsx # Comments/discussion area
+├── admin-notifications.tsx # Admin notifications
+├── ui/ # shadcn/ui components
+│ ├── button.tsx
+│ ├── card.tsx
+│ ├── sidebar.tsx
+│ ├── input.tsx
+│ ├── label.tsx
+│ ├── badge.tsx
+│ ├── dropdown-menu.tsx
+│ ├── dialog.tsx
+│ ├── drawer.tsx
+│ ├── table.tsx
+│ ├── tabs.tsx
+│ ├── carousel.tsx
+│ ├── chart.tsx
+│ ├── select.tsx
+│ ├── checkbox.tsx
+│ ├── field.tsx
+│ ├── collapsible.tsx
+│ └── ... (27+ components)
+
+lib/
+├── utils.ts # Utility functions (cn for className merging)
+├── auth-options.ts # NextAuth configuration
+├── auth-check.ts # Authentication helpers
+├── prisma.ts # Prisma client
+└── video-urls.ts # Video URL generation
+
+hooks/
+├── use-mobile.ts # Mobile breakpoint hook
+├── useVideo.ts # Video data hooks
+├── usePlaylists.ts # Playlist data hooks
+└── usePlaylist.ts # Single playlist hook
+
+public/
+├── icon.svg # App icon
+├── vault.png # Vault image (login page)
+└── vault.exr # Vault image (high-res)
+```
+
+### Component Structure Pattern
+
+**Server Component (pages):**
+```tsx
+// app/dashboard/page.tsx
+import { requireUser } from '@/lib/auth-check';
+import DashboardClient from './dashboard-client';
+
+export default async function DashboardPage() {
+ const session = await requireUser('/login');
+ return ;
+}
+```
+
+**Client Component (content):**
+```tsx
+// app/dashboard/dashboard-client.tsx
+'use client';
+import React from 'react';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
+
+export default function DashboardClient() {
+ return (
+
+
+
+
+
+ {/* Page content */}
+
+
+
+ );
+}
+```
+
+---
+
+## 3. Color System & Theming
+
+### CSS Variables (OKLch Color Space)
+
+The design system uses **OKLch color space** for perceptually uniform colors across light and dark modes.
+
+**Light Mode (`:root`):**
+```css
+--background: oklch(1 0 0); /* White */
+--foreground: oklch(0.141 0.005 285.823); /* Near Black */
+--card: oklch(1 0 0); /* White */
+--card-foreground: oklch(0.141 0.005 285.823); /* Near Black */
+--primary: oklch(0.646 0.222 41.116); /* Warm Yellow/Orange */
+--primary-foreground: oklch(0.98 0.016 73.684); /* Light cream */
+--secondary: oklch(0.967 0.001 286.375); /* Light gray */
+--secondary-foreground: oklch(0.21 0.006 285.885);
+--muted: oklch(0.967 0.001 286.375); /* Light gray (buttons, etc) */
+--muted-foreground: oklch(0.552 0.016 285.938); /* Medium gray (text) */
+--accent: oklch(0.967 0.001 286.375); /* Light gray */
+--accent-foreground: oklch(0.21 0.006 285.885);
+--destructive: oklch(0.577 0.245 27.325); /* Red */
+--border: oklch(0.92 0.004 286.32); /* Light gray */
+--input: oklch(0.92 0.004 286.32); /* Light gray */
+--ring: oklch(0.75 0.183 55.934); /* Orange/Gold for focus */
+```
+
+**Dark Mode (`.dark`):**
+```css
+.dark {
+ --background: oklch(0.141 0.005 285.823); /* Near Black */
+ --foreground: oklch(0.985 0 0); /* White */
+ --card: oklch(0.21 0.006 285.885); /* Dark gray */
+ --card-foreground: oklch(0.985 0 0); /* White */
+ --primary: oklch(0.705 0.213 47.604); /* Lighter Orange */
+ --secondary: oklch(0.274 0.006 286.033); /* Dark gray */
+ --muted: oklch(0.274 0.006 286.033); /* Dark gray */
+ --muted-foreground: oklch(0.705 0.015 286.067);
+ --border: oklch(1 0 0 / 10%); /* White with 10% opacity */
+ --input: oklch(1 0 0 / 15%); /* White with 15% opacity */
+ --ring: oklch(0.408 0.123 38.172); /* Red-orange for focus */
+}
+```
+
+### Chart Colors
+```css
+--chart-1: oklch(0.837 0.128 66.29); /* Warm yellow */
+--chart-2: oklch(0.705 0.213 47.604); /* Orange */
+--chart-3: oklch(0.646 0.222 41.116); /* Dark orange */
+--chart-4: oklch(0.553 0.195 38.402); /* Red-orange */
+--chart-5: oklch(0.47 0.157 37.304); /* Deep red-orange */
+```
+
+### Sidebar Colors
+```css
+--sidebar: oklch(0.985 0 0);
+--sidebar-foreground: oklch(0.141 0.005 285.823);
+--sidebar-primary: oklch(0.646 0.222 41.116);
+--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
+--sidebar-accent: oklch(0.967 0.001 286.375);
+--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
+--sidebar-border: oklch(0.92 0.004 286.32);
+--sidebar-ring: oklch(0.75 0.183 55.934);
+```
+
+### Color Usage
+
+| Color | Usage |
+|-------|-------|
+| **Primary** | Main CTA buttons, active links, selected items |
+| **Secondary** | Secondary buttons, badges |
+| **Muted** | Disabled states, placeholder text, subtle backgrounds |
+| **Accent** | Hover states, subtle highlights |
+| **Destructive** | Delete buttons, error states |
+| **Ring** | Focus indicators, form input focus |
+
+---
+
+## 4. Layout System
+
+### Main Layout Structure
+
+The application uses a **sidebar + content** layout pattern:
+
+```
+┌─────────────────────────────┐
+│ SidebarProvider │
+├──────────┬──────────────────┤
+│ │ │
+│AppSidebar│ SidebarInset │
+│ ├──────────────────┤
+│ │ SiteHeader │
+│ Logo │ (breadcrumbs) │
+│ Nav ├──────────────────┤
+│ Items │ │
+│ │ Main Content │
+│ Footer │ │
+│ (User) │ │
+│ │ │
+└──────────┴──────────────────┘
+```
+
+### Layout Implementation
+
+```tsx
+// Standard page layout
+export default function DashboardClient() {
+ return (
+
+
+
+
+
+ {/* Page content */}
+
+
+
+ );
+}
+```
+
+### Content Container Sizing
+
+```tsx
+// Wide content area with centered max-width
+
+ {/* Content */}
+
+
+// Grid layout patterns
+
+ {/* Columns adjust: 1 on mobile, 3 on desktop */}
+
+
+
+ {/* Responsive grid: 1 → 2 → 3 columns */}
+
+```
+
+---
+
+## 5. Navigation & Sidebar
+
+### AppSidebar Component
+
+Located in `components/app-sidebar.tsx`
+
+**Features:**
+- Collapsible sidebar (offcanvas mode on mobile)
+- Uses `next-auth` for user session and roles
+- Dynamic navigation based on user role (admin/superadmin/user)
+- Logo and brand in header
+- User profile footer with avatar
+
+**Navigation Items:**
+
+**For Regular Users:**
+- Library → `/dashboard`
+- Liked Videos → `/dashboard/liked-videos`
+- History → `/dashboard/watch-history`
+- Search → `#` (secondary item)
+
+**For Admins/Superadmins (Additional):**
+- Admin (collapsible parent with submenu):
+ - Manage Users → `/admin/users`
+ - Manage Enrollments → `/admin/enrollments`
+ - Manage Courses → `/admin/courses`
+ - Manage Playlists → `/admin/playlists`
+ - Manage Videos → `/admin/videos`
+
+### SiteHeader Component
+
+Located in `components/site-header.tsx`
+
+**Features:**
+- Responsive breadcrumb navigation
+- Sidebar toggle trigger button
+- Dynamic breadcrumbs based on current route
+- Breadcrumb mapping for all main routes
+
+**Breadcrumb Pattern:**
+```
+Dashboard
+Dashboard > Liked Videos
+Dashboard > Watch History
+Admin Panel
+Admin Panel > Users > User Details
+Admin Panel > Videos > Edit Video
+```
+
+### NavMain Component
+
+Located in `components/nav-main.tsx`
+
+**Features:**
+- Collapsible menu items with expandable submenus
+- Icon support (Tabler icons)
+- Smooth transitions with chevron rotation
+- Accessible keyboard navigation
+
+```tsx
+// Usage
+
+```
+
+### NavSecondary Component
+
+Located in `components/nav-secondary.tsx`
+
+For secondary navigation items (Search, Help, etc.)
+
+### NavUser Component
+
+Located in `components/nav-user.tsx`
+
+**Features:**
+- User avatar display
+- User name and email
+- Dropdown menu with user actions
+- Sign out button
+- Accessible user menu
+
+---
+
+## 6. Page Layouts
+
+### Login Page
+
+**Location:** `app/login/page.tsx`
+
+**Layout Structure:**
+```
+Centered Container (min-h-screen, flex-col)
+├── Brand Logo + Text (top)
+├── Vault Image
+└── Login Form (Card with Google OAuth button)
+```
+
+**Styling:**
+```tsx
+
+```
+
+**Key Features:**
+- Centered, max-width container (sm = 448px)
+- Muted background color
+- Responsive padding (6 on mobile, 10 on desktop)
+- Logo + app name at top
+- Vault imagery
+- Google OAuth login form in card
+
+### Dashboard Page
+
+**Location:** `app/dashboard/page.tsx` (server) → `dashboard-client.tsx` (client)
+
+**Layout Structure:**
+```
+Sidebar + Content Layout
+├── AppSidebar
+└── SidebarInset
+ ├── SiteHeader (breadcrumbs)
+ └── Main Content
+ ├── Hero Section (Welcome)
+ ├── Video Carousels (by course/playlist)
+ ├── Segmented Progress Bars
+ └── Lock indicators for restricted content
+```
+
+**Content Structure:**
+```tsx
+
+ {/* Section: Welcome/Title */}
+
+
Welcome back, {userName}
+
Continue your learning
+
+
+ {/* Section: Courses/Playlists with Video Carousels */}
+ {playlists.map(playlist => (
+
+
{playlist.title}
+
+ {/* Video cards with progress bars */}
+
+
+ ))}
+
+```
+
+### Admin Page
+
+**Location:** `app/admin/page.tsx` (server) → `admin-client.tsx` (client)
+
+**Layout Structure:**
+```
+Sidebar + Content Layout
+├── AppSidebar (with Admin menu)
+└── SidebarInset
+ ├── SiteHeader (breadcrumbs)
+ └── Main Content
+ ├── Page Title & Description
+ ├── Grid of Management Cards (2-3 columns)
+ │ ├── Video Statistics Card
+ │ ├── Manage Users Card
+ │ ├── Manage Courses Card
+ │ ├── Manage Playlists Card
+ │ ├── Manage Videos Card
+ │ └── Manage Enrollments Card
+ └── Admin Notifications (right sidebar)
+```
+
+**Admin Grid Layout:**
+```tsx
+
+
+
+ {/* Admin cards */}
+
+
+
+
+```
+
+---
+
+## 7. Component Library
+
+### shadcn/ui Components
+
+The following pre-built components are used throughout the application:
+
+| Component | Used For |
+|-----------|----------|
+| **Button** | CTAs, form submissions, interactive elements |
+| **Card** | Container for content sections |
+| **Input** | Text input fields |
+| **Label** | Form labels |
+| **Badge** | Tags, status indicators, trending indicators |
+| **Sidebar** | Main navigation (with collapsible support) |
+| **Dropdown Menu** | User menus, action menus |
+| **Dialog** | Modal dialogs |
+| **Drawer** | Mobile-friendly off-canvas menus |
+| **Table** | Data tables with sorting, filtering, pagination |
+| **Tabs** | Tab navigation |
+| **Carousel** | Image/video sliders |
+| **Chart** | Data visualization (AreaChart, BarChart, etc.) |
+| **Select** | Dropdown select fields |
+| **Checkbox** | Multi-select checkboxes |
+| **Form/Field** | Form field containers and validation |
+| **Collapsible** | Expandable/collapsible sections |
+| **Progress** | Progress bars |
+| **Avatar** | User profile pictures |
+| **Separator** | Visual dividers |
+| **Toast/Sonner** | Toast notifications |
+| **Tooltip** | Info tooltips |
+
+### Custom Components
+
+**HlsPlayer** (`components/hls.tsx`)
+- HLS video streaming with fallback to MP4
+- Subtitle/caption support
+- Controls with no-download protection
+- Used in: Video player pages
+
+**DataTable** (`components/data-table.tsx`)
+- Advanced table with sorting, filtering, pagination
+- Column visibility toggle
+- Drag-and-drop row reordering (via dnd-kit)
+- Export functionality
+- Used in: Admin management pages (Users, Videos, Courses, etc.)
+
+**SegmentedProgressBar** (`components/segmented-progress-bar.tsx`)
+- Shows watched segments of video
+- Visual indication of progress across duration
+- Used in: Dashboard video carousels
+
+**VideoCarousel** (`components/VideoCarousel.tsx`)
+- Embla carousel-based video slider
+- Responsive grid layout
+- Lock indicators for restricted content
+- Used in: Dashboard, video listings
+
+**CommentsSection** (`components/comments-section.tsx`)
+- Comments display and threading
+- Used in: Video player pages
+
+**LoginForm** (`components/login-form.tsx`)
+- Google OAuth integration
+- Card-based layout
+- Terms and privacy links
+- Used in: Login page
+
+---
+
+## 8. Typography & Spacing
+
+### Font Families
+
+The project uses **Geist** font family (Next.js default):
+- `--font-sans: var(--font-geist-sans)` - Primary (body text, UI)
+- `--font-mono: var(--font-geist-mono)` - Monospace (code, technical text)
+
+Imported in `layout.tsx`:
+```tsx
+import { Geist, Geist_Mono } from "next/font/google";
+```
+
+### Typography Scale
+
+| Element | Class | Size | Weight | Usage |
+|---------|-------|------|--------|-------|
+| H1 | `.text-3xl` | 30px | bold (700) | Page titles |
+| H2 | `.text-2xl` | 24px | bold (700) | Section titles |
+| H3 | `.text-xl` | 20px | semibold (600) | Subsection titles |
+| H4 | `.text-lg` | 18px | semibold (600) | Card titles |
+| Body | `.text-base` | 16px | normal (400) | Body text |
+| Small | `.text-sm` | 14px | normal (400) | Labels, captions, secondary text |
+| Extra Small | `.text-xs` | 12px | normal (400) | Tiny labels |
+
+### Spacing Scale
+
+Tailwind default spacing (4px base):
+
+| Class | Size |
+|-------|------|
+| `gap-1` | 4px |
+| `gap-2` | 8px |
+| `gap-3` | 12px |
+| `gap-4` | 16px |
+| `gap-6` | 24px |
+| `gap-8` | 32px |
+| `p-4` | 16px padding |
+| `p-6` | 24px padding |
+| `py-4` | 16px vertical padding |
+| `px-4` | 16px horizontal padding |
+
+### Text Opacity/Color Hierarchy
+
+```tsx
+// Primary text
+Main content
+
+// Secondary text
+Secondary info
+
+// Subtle text
+Caption
+```
+
+---
+
+## 9. Form Patterns
+
+### Login Form Pattern
+
+Located in `components/login-form.tsx`
+
+```tsx
+
+```
+
+### Admin Form Patterns
+
+Located in `app/admin/*/` directories
+
+**Data Table Integration:**
+```tsx
+
+```
+
+**Modal Form Pattern:**
+```tsx
+
+
+
+ Create New Item
+
+
+
+
+```
+
+---
+
+## 10. Responsive Design
+
+### Tailwind Breakpoints
+
+```
+sm: 640px
+md: 768px
+lg: 1024px
+xl: 1280px
+2xl: 1536px
+```
+
+### Mobile-First Approach
+
+All responsive classes use mobile-first convention:
+
+```tsx
+// Mobile: 1 column, Tablet: 2 columns, Desktop: 3 columns
+
+ {/* Content */}
+
+
+// Mobile: padding-4, Desktop: padding-6
+
+ {/* Content */}
+
+
+// Mobile: hidden, Desktop: visible
+
+ {/* Content only on desktop */}
+
+```
+
+### Mobile-Specific Components
+
+- **Drawer** - Off-canvas menus on mobile (instead of Dialog)
+- **Sidebar Collapsible** - Sidebar collapses to mobile menu on small screens
+- **useMobile Hook** - `hooks/use-mobile.ts` for React-based breakpoint logic
+
+```tsx
+const isMobile = useIsMobile();
+
+return isMobile ? : ;
+```
+
+### Container Queries
+
+Used for responsive component sizing:
+
+```tsx
+
+
+ Responsive heading
+
+
+```
+
+---
+
+## 11. Dark Mode Implementation
+
+### Theme Provider Setup
+
+Located in `app/providers.tsx`:
+
+```tsx
+
+ defaultTheme="dark" // Dark by default
+ enableSystem={false} // Don't auto-detect system preference
+>
+ {children}
+
+```
+
+### Dark Mode Styling
+
+CSS variables automatically switch in `.dark` class:
+
+```tsx
+// Light mode (automatic)
+:root {
+ --background: oklch(1 0 0); /* White */
+ --foreground: oklch(0.141 0.005 285.823); /* Dark gray */
+}
+
+// Dark mode (when .dark is on )
+.dark {
+ --background: oklch(0.141 0.005 285.823); /* Dark gray */
+ --foreground: oklch(0.985 0 0); /* White */
+}
+```
+
+### Toggle Theme Button Pattern
+
+```tsx
+// In component
+const { theme, setTheme } = useTheme();
+
+ setTheme(theme === 'dark' ? 'light' : 'dark')}
+>
+ {theme === 'dark' ? : }
+
+```
+
+---
+
+## 12. Reusable Patterns
+
+### Protected Page Pattern
+
+**Server Component (checks auth):**
+```tsx
+// app/dashboard/page.tsx
+import { requireUser } from '@/lib/auth-check';
+import DashboardClient from './dashboard-client';
+
+export default async function DashboardPage() {
+ const session = await requireUser('/login');
+ return ;
+}
+```
+
+**Client Component (renders content):**
+```tsx
+// app/dashboard/dashboard-client.tsx
+'use client';
+import React from 'react';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+
+export default function DashboardClient() {
+ return (
+
+
+
+
+
+
+
+ );
+}
+```
+
+### Role-Based Navigation
+
+Located in `components/app-sidebar.tsx`:
+
+```tsx
+const { data: session } = useSession();
+const role = session?.user?.role ?? 'user';
+
+const navMain = React.useMemo(() => {
+ const base = [...navMainBase];
+
+ if (role === 'admin' || role === 'superadmin') {
+ base.unshift({
+ title: 'Admin',
+ url: '/admin',
+ icon: IconShieldCheck,
+ items: [/*submenu items*/]
+ });
+ }
+
+ return base;
+}, [role]);
+```
+
+### Session Initialization
+
+Located in `app/layout.tsx`:
+
+```tsx
+export default async function RootLayout({ children }) {
+ // Get server session once, pass to client providers
+ const session = await getServerSession(authOptions);
+
+ return (
+
+
+
+ {children}
+
+
+
+
+ );
+}
+```
+
+### Data Fetching Pattern
+
+Using `useFetch`/SWR for client-side data:
+
+```tsx
+// hooks/usePlaylists.ts
+export function usePlaylists() {
+ const [subjects, setSubjects] = React.useState([]);
+ const [isLoading, setIsLoading] = React.useState(true);
+
+ React.useEffect(() => {
+ fetchPlaylists().then(data => {
+ setSubjects(data);
+ setIsLoading(false);
+ });
+ }, []);
+
+ return { subjects, isLoading };
+}
+
+// Usage in component
+export default function DashboardClient() {
+ const { subjects, isLoading } = usePlaylists();
+
+ if (isLoading) return ;
+
+ return <>{/* Content */}>;
+}
+```
+
+### Carousel Pattern
+
+```tsx
+// Embla-based carousel
+
+
+ {videos.map(video => (
+
+
+
+ ))}
+
+
+
+
+```
+
+### Badge & Status Pattern
+
+```tsx
+// Icon + Badge combination
+
+
+
+ +12.5%
+
+
+
+// Status badge
+
+ {status}
+
+```
+
+---
+
+## 13. Asset Guidelines
+
+### Logo & Branding
+
+- **Icon**: `/public/icon.svg` - Small 5x5 size in sidebar header
+- **Vault Image**: `/public/vault.png` - 1:1 aspect ratio for login page
+- **High-Res Alternative**: `/public/vault.exr` - For promotional use
+
+### Image Optimization
+
+```tsx
+// Use Next.js Image for optimization
+import Image from 'next/image';
+
+
+
+// Regular images
+
+```
+
+### Approved Icon Sets
+
+1. **Tabler Icons** (`@tabler/icons-react`) - Primary
+ - Large collection (4000+)
+ - Consistent stroke weight
+ - Import: `import { IconHome, IconUsers } from '@tabler/icons-react';`
+
+2. **Lucide React** (`lucide-react`) - Secondary
+ - Modern, clean icons
+ - Import: `import { Home, Users } from 'lucide-react';`
+
+3. **SVG Files** - Custom branding
+ - Small icons in public/
+ - Optimized for performance
+
+### Sizing Conventions
+
+```tsx
+// Icon sizes
+ /* 12px */
+ /* 16px */
+ /* 20px */
+ /* 24px */
+ /* 32px */
+
+// Image sizes
+ /* Full width */
+ /* Max 448px */
+ /* Max 512px */
+```
+
+---
+
+## Quick Reference - Common Patterns
+
+### Button Variants
+```tsx
+Default
+Secondary
+Outline
+Ghost
+Delete
+Disabled
+Small
+Large
+```
+
+### Card Layout
+```tsx
+
+
+ Title
+ Subtitle
+
+
+ {/* Content */}
+
+
+ {/* Footer */}
+
+
+```
+
+### Container Classes
+```tsx
+// Full width
+className="w-full"
+
+// Max width containers
+className="w-full max-w-sm" /* 448px */
+className="w-full max-w-md" /* 512px */
+className="w-full max-w-lg" /* 576px */
+className="w-full max-w-2xl" /* 672px */
+
+// Flex containers
+className="flex flex-col gap-4"
+className="flex items-center justify-between"
+
+// Grid containers
+className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
+```
+
+### Responsive Padding
+```tsx
+className="p-4 md:p-6 lg:p-8" /* All sides */
+className="px-4 py-6" /* Horizontal & vertical */
+className="p-4 md:py-6 md:px-8" /* Different per breakpoint */
+```
+
+### Text & Styling
+```tsx
+className="text-foreground" /* Main text */
+className="text-muted-foreground" /* Secondary text */
+className="text-sm text-muted-foreground" /* Label text */
+className="font-semibold" /* Bold text */
+className="line-clamp-1" /* Truncate to 1 line */
+className="text-center" /* Centered text */
+```
+
+---
+
+## Migration Checklist for New Projects
+
+When creating a new app with this design system:
+
+- [ ] Copy `globals.css` (CSS variables)
+- [ ] Copy `tailwind.config.cjs`
+- [ ] Copy `components/ui/` directory (all base components)
+- [ ] Copy `components/app-sidebar.tsx`, `site-header.tsx`, `nav-*.tsx`
+- [ ] Copy `app/providers.tsx` (SessionProvider, ThemeProvider)
+- [ ] Copy `lib/utils.ts` (utility functions)
+- [ ] Copy `lib/auth-options.ts` and auth-related files
+- [ ] Install dependencies from `package.json`
+- [ ] Update branding assets (/public icons, logos)
+- [ ] Customize navigation items in `app-sidebar.tsx`
+- [ ] Update theme colors in `globals.css` if needed
+- [ ] Test responsive design on mobile/tablet/desktop
+
+---
+
+## Key Takeaways
+
+1. **Framework**: Next.js 16 with shadcn/ui component library
+2. **Styling**: Tailwind CSS with OKLch color variables
+3. **Layout**: Sidebar + content pattern with collapsible nav
+4. **Dark Mode**: CSS class-based switching with automatic variable updates
+5. **Components**: Pre-built shadcn/ui components + custom components (HLS, DataTable, etc.)
+6. **Responsiveness**: Mobile-first, Tailwind breakpoints (sm/md/lg/xl)
+7. **Navigation**: Role-based dynamic nav, breadcrumb system
+8. **Auth**: NextAuth.js with Google OAuth integration
+9. **Icons**: Tabler Icons (primary), Lucide (secondary)
+10. **Consistency**: Use className patterns, spacing scale, and color variables throughout
+
+---
+
+**Last Updated**: February 2026
+**Version**: 1.0
+
+For questions or updates to this design system guide, refer to the component files and config files referenced throughout.
diff --git a/VPS_UPLOAD_CONFIG.md b/VPS_UPLOAD_CONFIG.md
new file mode 100644
index 0000000..a732a07
--- /dev/null
+++ b/VPS_UPLOAD_CONFIG.md
@@ -0,0 +1,91 @@
+# VPS Upload Timeout Fix
+
+## Problem
+Uploads timing out after moving to VPS + npm environment.
+
+## Root Causes
+1. Prisma connection pool timeout too short for network latency
+2. FFprobe duration extraction timing out on slow disk I/O
+3. Missing socket/request timeout configuration
+4. Database connection pool exhaustion
+
+## Solutions Applied
+
+### 1. Prisma Client Configuration
+Updated `lib/prisma.ts` with:
+- Connection timeout handling for slow VPS networks
+- Proper pool configuration support
+- Removed 'info' logging (reduces noise in production)
+
+### 2. FFprobe Timeout Protection
+Updated `app/api/admin/upload/route.ts`:
+- Added 15-second timeout for FFprobe duration extraction
+- Falls back gracefully if FFprobe takes too long
+- Prevents blocking the entire upload handler
+
+### 3. Next.js Server Configuration
+Updated `next.config.ts`:
+- Added socket timeout configuration (45 seconds default)
+- VPS-aware settings for slow network environments
+
+## Required Environment Variables
+
+Add these to your `.env.local` (or VPS environment):
+
+```
+# Database connection with proper pool settings for VPS
+# Adjust pool_max based on your VPS CPU cores
+DATABASE_URL="postgresql://user:password@host:5432/dbname?schema=public&pool_max=5&socket_timeout=45000&connect_timeout=10000"
+
+# Optional: Override default socket timeout (in milliseconds)
+DATABASE_SOCKET_TIMEOUT=45000
+
+# Optional: For testing, you can increase the API timeout
+# This is already set to 300s in the route, but ensure your nginx/proxy respects it
+```
+
+## PostgreSQL Connection String Parameters
+If using PostgreSQL, ensure your `DATABASE_URL` includes:
+- `pool_max=5` — Limit connections (adjust to CPU cores)
+- `socket_timeout=45000` — Socket timeout in ms
+- `connect_timeout=10000` — Connection establishment timeout in ms
+
+Example PostgreSQL URL:
+```
+postgresql://user:password@vps-host:5432/dbname?schema=public&pool_max=5&socket_timeout=45000&connect_timeout=10000
+```
+
+## Next.js Server Configuration
+If running with a proxy (nginx/Apache), ensure:
+
+```nginx
+# nginx example
+location /api/admin/upload {
+ proxy_pass http://next-server;
+ proxy_connect_timeout 60s;
+ proxy_send_timeout 300s; # 5 minutes for large uploads
+ proxy_read_timeout 300s; # 5 minutes for large uploads
+ client_max_body_size 1024M; # Adjust based on your max video size
+}
+```
+
+## Testing
+After applying these changes:
+
+1. Restart your Node.js server
+2. Test with a medium-sized video (100MB+)
+3. Check logs: `pm2 logs` or your logging system
+4. Monitor database connections: `SELECT count(*) FROM pg_stat_activity;` in psql
+
+## Additional Tuning
+
+### If still timing out:
+1. **Check disk I/O**: `iostat -x 1` on VPS
+2. **Monitor database**: Check if queries are slow
+3. **Increase FFprobe timeout**: Edit line in `app/api/admin/upload/route.ts` (currently 15000ms)
+4. **Vertical scaling**: Increase VPS CPU for FFprobe processing
+
+### For very large files (>500MB):
+- Consider streaming directly to cloud storage (S3, etc.)
+- Implement chunked uploads
+- Use a separate transcoding queue service
diff --git a/app/admin/admin-client.tsx b/app/admin/admin-client.tsx
new file mode 100644
index 0000000..6a93c5e
--- /dev/null
+++ b/app/admin/admin-client.tsx
@@ -0,0 +1,208 @@
+// app/admin/admin-client.tsx
+'use client';
+import React from 'react';
+import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { AdminNotifications } from '@/components/admin-notifications';
+
+import { Button } from '@/components/ui/button';
+import Link from 'next/link';
+import {
+ IconFileUpload,
+ IconBooks,
+ IconList,
+ IconUsers,
+ IconUsers as IconUsersManage,
+ IconChartBar,
+} from '@tabler/icons-react';
+
+function AdminUI() {
+ return (
+
+
+
Admin Panel
+
+ Manage your users, courses, playlists, videos, and enrollments.
+
+
+
+
+
+
+
+
+
+
+ Video Statistics
+
+
+
+
+ View video performance metrics and analytics.
+
+
+
+ Go to Stats
+
+
+
+
+
+
+
+
+
+ Manage Users
+
+
+
+
+ View users and their activity.
+
+
+
+ Go to Users
+
+
+
+
+
+
+
+
+
+ Manage Courses
+
+
+
+
+ Create and manage courses.
+
+
+
+ Go to Courses
+
+
+
+
+
+
+
+
+
+ Manage Playlists
+
+
+
+
+ Create and manage playlists.
+
+
+
+ Go to Playlists
+
+
+
+
+
+
+
+
+
+ Manage Videos
+
+
+
+
+ Upload and manage videos.
+
+
+
+ Go to Videos
+
+
+
+
+
+
+
+
+
+ Manage Enrollments
+
+
+
+
+ Manage user enrollments.
+
+
+
+ Go to Enrollments
+
+
+
+
+
+
+
+
+
+ Allowed Students
+
+
+
+
+ Manage whitelisted student access.
+
+
+
+ Go to Students
+
+
+
+
+
+
+
+
+
+
+ Activity Feed
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function AdminClient() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/admin/allowed-students/admin-client.tsx b/app/admin/allowed-students/admin-client.tsx
new file mode 100644
index 0000000..69da327
--- /dev/null
+++ b/app/admin/allowed-students/admin-client.tsx
@@ -0,0 +1,393 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetFooter,
+} from '@/components/ui/sheet';
+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogTitle } from '@/components/ui/alert-dialog';
+import { toast } from 'sonner';
+import { Trash2, Edit, Upload, Plus } from 'lucide-react';
+import { formatDistanceToNow } from 'date-fns';
+
+type AllowedStudent = {
+ id: string;
+ email: string;
+ levels: string;
+ active: boolean;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export default function AllowedStudentsClient() {
+ const [students, setStudents] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
+ const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
+ const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false);
+ const [isImportLoading, setIsImportLoading] = useState(false);
+ const [editingStudent, setEditingStudent] = useState(null);
+ const [deleteId, setDeleteId] = useState(null);
+ const [newEmail, setNewEmail] = useState('');
+ const [newLevels, setNewLevels] = useState('');
+
+ // Fetch students on mount
+ useEffect(() => {
+ fetchStudents();
+ }, []);
+
+ const fetchStudents = async () => {
+ setIsLoading(true);
+ try {
+ const res = await fetch('/api/admin/allowed-students');
+ if (res.ok) {
+ const data = await res.json();
+ setStudents(data);
+ } else {
+ toast.error('Failed to fetch students');
+ }
+ } catch (err) {
+ console.error('Failed to fetch students:', err);
+ toast.error('Error fetching students');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleAddStudent = async () => {
+ if (!newEmail.trim() || !newLevels.trim()) {
+ toast.error('Email and course codes required');
+ return;
+ }
+
+ try {
+ const res = await fetch('/api/admin/allowed-students', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ email: newEmail.trim(),
+ levels: newLevels.trim(),
+ }),
+ });
+
+ if (res.status === 201) {
+ toast.success('Student added');
+ setNewEmail('');
+ setNewLevels('');
+ setIsAddDialogOpen(false);
+ fetchStudents();
+ } else if (res.status === 409) {
+ toast.error('Email already registered');
+ } else {
+ const data = await res.json();
+ toast.error(data.error || 'Failed to add student');
+ }
+ } catch (err) {
+ console.error('Failed to add student:', err);
+ toast.error('Error adding student');
+ }
+ };
+
+ const handleEditStudent = async () => {
+ if (!editingStudent) return;
+
+ try {
+ const res = await fetch('/api/admin/allowed-students', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ id: editingStudent.id,
+ email: editingStudent.email,
+ levels: editingStudent.levels,
+ active: editingStudent.active,
+ }),
+ });
+
+ if (res.ok) {
+ toast.success('Student updated');
+ setIsEditDialogOpen(false);
+ setEditingStudent(null);
+ fetchStudents();
+ } else {
+ const data = await res.json();
+ toast.error(data.error || 'Failed to update student');
+ }
+ } catch (err) {
+ console.error('Failed to update student:', err);
+ toast.error('Error updating student');
+ }
+ };
+
+ const handleDeleteStudent = async () => {
+ if (!deleteId) return;
+
+ try {
+ const res = await fetch(`/api/admin/allowed-students?id=${deleteId}`, {
+ method: 'DELETE',
+ });
+
+ if (res.ok) {
+ toast.success('Student removed');
+ setIsDeleteAlertOpen(false);
+ setDeleteId(null);
+ fetchStudents();
+ } else {
+ toast.error('Failed to delete student');
+ }
+ } catch (err) {
+ console.error('Failed to delete student:', err);
+ toast.error('Error deleting student');
+ }
+ };
+
+ const handleImportCSV = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ setIsImportLoading(true);
+ try {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const res = await fetch('/api/admin/allowed-students/import', {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (res.ok) {
+ const result = await res.json();
+ toast.success(`Imported: ${result.imported}, Updated: ${result.updated}`);
+ if (result.errors.length > 0) {
+ toast.warning(`${result.errors.length} errors during import`);
+ }
+ fetchStudents();
+ } else {
+ const data = await res.json();
+ toast.error(data.error || 'Failed to import students');
+ }
+ } catch (err) {
+ console.error('Failed to import CSV:', err);
+ toast.error('Error importing students');
+ } finally {
+ setIsImportLoading(false);
+ }
+ };
+
+ if (isLoading) {
+ return Loading allowed students...
;
+ }
+
+ return (
+
+
+
+ Allowed Students ({students.length})
+
+
setIsAddDialogOpen(true)}
+ className="gap-2"
+ >
+
+ Add Student
+
+
+
+ document.getElementById('csv-upload')?.click()}
+ disabled={isImportLoading}
+ className="gap-2"
+ >
+
+ Import CSV
+
+
+
+
+
+
+
+
+ Email
+ Course Codes
+ Status
+ Added
+ Actions
+
+
+
+ {students.length === 0 ? (
+
+
+ No students added yet
+
+
+ ) : (
+ students.map((student) => (
+
+ {student.email}
+
+
+ {student.levels.split(',').map((code) => (
+
+ {code.trim()}
+
+ ))}
+
+
+
+
+ {student.active ? 'Active' : 'Inactive'}
+
+
+
+ {formatDistanceToNow(new Date(student.createdAt), { addSuffix: true })}
+
+
+
+ {
+ setEditingStudent(student);
+ setIsEditDialogOpen(true);
+ }}
+ >
+
+
+ {
+ setDeleteId(student.id);
+ setIsDeleteAlertOpen(true);
+ }}
+ >
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+ {/* Add Student Sheet */}
+
+
+
+ Add Student
+
+
+
+ Email
+ setNewEmail(e.target.value)}
+ />
+
+
+
Course Codes
+
setNewLevels(e.target.value)}
+ />
+
+ Comma-separated course codes
+
+
+
+
+ setIsAddDialogOpen(false)}>
+ Cancel
+
+ Add Student
+
+
+
+
+ {/* Edit Student Sheet */}
+
+
+
+ Edit Student
+
+ {editingStudent && (
+
+ )}
+
+ setIsEditDialogOpen(false)}>
+ Cancel
+
+ Save Changes
+
+
+
+
+ {/* Delete Confirmation */}
+
+
+ Remove Student
+
+ Are you sure? The student will be prevented from logging in, but their existing enrollments will remain.
+
+
+
+
+
+ );
+}
diff --git a/app/admin/allowed-students/page.tsx b/app/admin/allowed-students/page.tsx
new file mode 100644
index 0000000..d560a03
--- /dev/null
+++ b/app/admin/allowed-students/page.tsx
@@ -0,0 +1,34 @@
+// app/admin/allowed-students/page.tsx
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { redirect } from 'next/navigation';
+import { SidebarProvider } from '@/components/ui/sidebar';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import { SidebarInset } from '@/components/ui/sidebar';
+import AllowedStudentsClient from './admin-client';
+
+export const dynamic = 'force-dynamic';
+
+export default async function AllowedStudentsPage() {
+ const session = await getServerSession(authOptions);
+ const role = (session as any)?.user?.role ?? null;
+
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ if (!(role === 'admin' || role === 'superadmin')) {
+ redirect('/dashboard');
+ }
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/admin/courses/admin-client.tsx b/app/admin/courses/admin-client.tsx
new file mode 100644
index 0000000..1e16167
--- /dev/null
+++ b/app/admin/courses/admin-client.tsx
@@ -0,0 +1,247 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import { useRouter } from 'next/navigation';
+import { toast } from 'sonner';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ Field,
+ FieldGroup,
+ FieldLabel,
+} from '@/components/ui/field';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog';
+import { Trash2Icon } from 'lucide-react';
+
+type Course = { id: string; title: string; code?: string };
+
+function CoursesUI() {
+ const router = useRouter();
+ const [courses, setCourses] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [courseTitle, setCourseTitle] = useState('');
+ const [courseCode, setCourseCode] = useState('');
+ const [deletingId, setDeletingId] = useState(null);
+
+ useEffect(() => {
+ fetchCourses();
+ }, []);
+
+ async function fetchCourses() {
+ try {
+ const res = await fetch('/api/admin/meta');
+ if (res.ok) {
+ const json = await res.json();
+ setCourses(json.courses || []);
+ }
+ } catch (err) {
+ console.error('Failed to fetch courses', err);
+ toast.error('Failed to fetch courses');
+ }
+ }
+
+ async function createCourse(e: React.FormEvent) {
+ e.preventDefault();
+ setLoading(true);
+ try {
+ const res = await fetch('/api/admin/create-course', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ title: courseTitle, code: courseCode }),
+ });
+ if (res.ok) {
+ toast.success('Course created');
+ setCourseTitle('');
+ setCourseCode('');
+ await fetchCourses();
+ router.refresh();
+ } else {
+ const txt = await res.text();
+ toast.error('Failed to create course: ' + txt);
+ }
+ } catch (err: any) {
+ toast.error('Error: ' + String(err.message ?? err));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function deleteCourse(courseId: string) {
+ setDeletingId(courseId);
+ try {
+ const res = await fetch(`/api/admin/delete-course`, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ courseId }),
+ });
+ if (res.ok) {
+ toast.success('Course deleted');
+ await fetchCourses();
+ router.refresh();
+ } else {
+ const txt = await res.text();
+ toast.error('Failed to delete course: ' + txt);
+ }
+ } catch (err: any) {
+ toast.error('Error: ' + String(err.message ?? err));
+ } finally {
+ setDeletingId(null);
+ }
+ }
+
+ return (
+
+
+
+ Create Course
+
+
+
+
+
+
+
+
+ Courses
+
+
+ {courses.length === 0 ? (
+
+ No courses yet
+
+ ) : (
+
+
+
+
+ Title
+ Code
+ Actions
+
+
+
+ {courses.map((course) => (
+
+
+ {course.title}
+
+ {course.code || '—'}
+
+
+
+
+
+
+
+
+
+ Delete Course
+
+ Are you sure? This will delete the course and all
+ associated playlists and videos.
+
+
+
+
Cancel
+
deleteCourse(course.id)}
+ >
+ Delete
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ );
+}
+
+export default function CoursesAdminClient() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/admin/courses/page.tsx b/app/admin/courses/page.tsx
new file mode 100644
index 0000000..0aa655b
--- /dev/null
+++ b/app/admin/courses/page.tsx
@@ -0,0 +1,22 @@
+// app/admin/courses/page.tsx
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { redirect } from 'next/navigation';
+import CoursesAdminClient from './admin-client';
+
+export const dynamic = 'force-dynamic';
+
+export default async function CoursesAdminPage() {
+ const session = await getServerSession(authOptions);
+ const role = (session as any)?.user?.role ?? null;
+
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ if (!(role === 'admin' || role === 'superadmin')) {
+ redirect('/dashboard');
+ }
+
+ return ;
+}
diff --git a/app/admin/enrollments/admin-client.tsx b/app/admin/enrollments/admin-client.tsx
new file mode 100644
index 0000000..03de60b
--- /dev/null
+++ b/app/admin/enrollments/admin-client.tsx
@@ -0,0 +1,349 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import { useRouter } from 'next/navigation';
+import { toast } from 'sonner';
+import { Progress } from '@/components/ui/progress';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ CardFooter,
+} from '@/components/ui/card';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import {
+ Field,
+ FieldGroup,
+ FieldLabel,
+ FieldDescription,
+} from '@/components/ui/field';
+
+import {
+ Table,
+ TableBody,
+ TableCaption,
+ TableCell,
+ TableFooter,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+
+type User = { id: number | string; name?: string | null; email: string };
+type Course = { id: number | string; title: string; code: string };
+type Enrollment = {
+ id: number;
+ role?: string | null;
+ createdAt?: string;
+ user: User;
+ course: Course;
+};
+
+function AdminUI() {
+ const [users, setUsers] = useState([]);
+ const [courses, setCourses] = useState([]);
+ const [enrollments, setEnrollments] = useState([]);
+
+ // placeholder sentinel
+ const [selectedUser, setSelectedUser] = useState('none');
+ const [selectedCourse, setSelectedCourse] = useState('none');
+ const [role, setRole] = useState('student');
+
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ setLoading(true);
+ setError(null);
+
+ Promise.all([
+ fetch('/api/users')
+ .then((r) => (r.ok ? r.json() : Promise.reject(r)))
+ .catch(() => []),
+ fetch('/api/courses')
+ .then((r) => (r.ok ? r.json() : Promise.reject(r)))
+ .catch(() => []),
+ fetch('/api/enrollments')
+ .then((r) => (r.ok ? r.json() : Promise.reject(r)))
+ .catch(() => []),
+ ])
+ .then(([u, c, e]) => {
+ setUsers(u ?? []);
+ setCourses(c ?? []);
+ setEnrollments(e ?? []);
+ })
+ .catch((err) => {
+ console.error('Failed to load admin data', err);
+ setError('Failed to load admin data');
+ })
+ .finally(() => setLoading(false));
+ }, []);
+
+ function extractIdString(val: unknown): string | null {
+ const s = val === null || val === undefined ? '' : String(val).trim();
+ console.debug('extractIdString raw value:', s);
+
+ if (!s) return null;
+
+ if (s.length > 0) return s;
+
+ return null;
+ }
+
+ async function handleCreate(e?: React.FormEvent) {
+ e?.preventDefault();
+ setError(null);
+
+ const userIdStr = extractIdString(selectedUser);
+ const courseIdStr = extractIdString(selectedCourse);
+
+ if (!userIdStr || !courseIdStr) {
+ setError(
+ `Invalid selection. user="${String(selectedUser)}" course="${String(
+ selectedCourse
+ )}". Expected values containing numeric ids or direct ids.`
+ );
+ return;
+ }
+
+ // send strings to the server (your Prisma schema expects String ids)
+ const payload = {
+ userId: String(userIdStr),
+ courseId: String(courseIdStr),
+ };
+ console.debug('Creating enrollment with payload:', payload);
+
+ setSaving(true);
+ try {
+ const res = await fetch('/api/enrollments', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ const data = await res.json();
+ if (!res.ok) {
+ setError(data?.error || 'Failed to create enrollment');
+ } else {
+ setEnrollments((prev) => [data, ...prev]);
+ setSelectedUser('none');
+ setSelectedCourse('none');
+ setRole('student');
+ }
+ } catch (err) {
+ console.error('Network error while creating enrollment', err);
+ setError('Network error while creating enrollment');
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function handleDelete(id: number) {
+ if (!confirm('Remove this enrollment?')) return;
+ setSaving(true);
+ setError(null);
+ try {
+ const res = await fetch('/api/enrollments', {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id }),
+ });
+ const data = await res.json();
+ if (!res.ok && !data?.success) {
+ setError(data?.error || 'Failed to delete enrollment');
+ } else {
+ setEnrollments((prev) => prev.filter((en) => en.id !== id));
+ }
+ } catch (err) {
+ console.error('Network error while deleting enrollment', err);
+ setError('Network error while deleting enrollment');
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+
+
+ Assign enrollment
+
+
+
+
+
+ {error && (
+ {error}
+ )}
+
+
+
+
+
+ Current enrollments
+
+
+
+ {loading && enrollments.length === 0 ? (
+ Loading…
+ ) : enrollments.length === 0 ? (
+
+ No enrollments yet.
+
+ ) : (
+
+
+
+ Student
+ Email
+ Course
+ Role
+ Enrolled
+ Actions
+
+
+
+ {enrollments.map((en) => (
+
+
+ {en.user.name ?? en.user.email}
+
+
+ {en.user.email}
+
+
+ {en.course.title}{' '}
+
+ | {en.course.code}
+
+
+ {en.role ?? 'student'}
+
+ {en.createdAt
+ ? new Date(en.createdAt).toLocaleString()
+ : '—'}
+
+
+
+ handleDelete(en.id)}
+ disabled={saving}
+ >
+ Remove
+
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+ );
+}
+
+export default function AdminClient() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/admin/enrollments/page.tsx b/app/admin/enrollments/page.tsx
new file mode 100644
index 0000000..9544e84
--- /dev/null
+++ b/app/admin/enrollments/page.tsx
@@ -0,0 +1,22 @@
+// app/admin/enrollments/page.tsx
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { redirect } from 'next/navigation';
+import AdminClient from './admin-client';
+
+export const dynamic = 'force-dynamic';
+
+export default async function EnrollmentsAdminPage() {
+ const session = await getServerSession(authOptions);
+ const role = (session as any)?.user?.role ?? null;
+
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ if (!(role === 'admin' || role === 'superadmin')) {
+ redirect('/dashboard');
+ }
+
+ return ;
+}
diff --git a/app/admin/page.tsx b/app/admin/page.tsx
new file mode 100644
index 0000000..896d319
--- /dev/null
+++ b/app/admin/page.tsx
@@ -0,0 +1,23 @@
+// app/admin/page.tsx (server component)
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { redirect } from 'next/navigation';
+import AdminClient from './admin-client'; // the client UI component (see below)
+
+export const dynamic = 'force-dynamic';
+
+export default async function AdminPage() {
+ const session = await getServerSession(authOptions);
+ const role = (session as any)?.user?.role ?? null;
+
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ if (!(role === 'admin' || role === 'superadmin')) {
+ redirect('/dashboard');
+ }
+
+ // authorised — render the client admin UI
+ return ;
+}
diff --git a/app/admin/playlists/admin-client.tsx b/app/admin/playlists/admin-client.tsx
new file mode 100644
index 0000000..d85a335
--- /dev/null
+++ b/app/admin/playlists/admin-client.tsx
@@ -0,0 +1,539 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import { useRouter } from 'next/navigation';
+import { toast } from 'sonner';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ Field,
+ FieldGroup,
+ FieldLabel,
+ FieldDescription,
+} from '@/components/ui/field';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog';
+import { Trash2Icon, Edit2, Check, X } from 'lucide-react';
+import {
+ DndContext,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ closestCenter,
+} from '@dnd-kit/core';
+import {
+ arrayMove,
+ SortableContext,
+ verticalListSortingStrategy,
+ useSortable,
+} from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+
+type Course = { id: string; title: string; code?: string };
+type Playlist = { id: string; title: string; courseId: string };
+
+function PlaylistsUI() {
+ const router = useRouter();
+ const [courses, setCourses] = useState([]);
+ const [playlists, setPlaylists] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [playlistTitle, setPlaylistTitle] = useState('');
+ const [playlistCourseId, setPlaylistCourseId] = useState('');
+ const [additionalCourseIds, setAdditionalCourseIds] = useState([]);
+ const [deletingId, setDeletingId] = useState(null);
+ const [editingId, setEditingId] = useState(null);
+ const [editingTitle, setEditingTitle] = useState('');
+ const [savingEditId, setSavingEditId] = useState(null);
+
+ useEffect(() => {
+ fetchData();
+ }, []);
+
+ async function fetchData() {
+ try {
+ const res = await fetch('/api/admin/meta');
+ if (res.ok) {
+ const json = await res.json();
+ setCourses(json.courses || []);
+ setPlaylists(json.playlists || []);
+ if (!playlistCourseId && json.courses?.[0]) {
+ setPlaylistCourseId(json.courses[0].id);
+ }
+ }
+ } catch (err) {
+ console.error('Failed to fetch data', err);
+ toast.error('Failed to fetch data');
+ }
+ }
+
+ async function createPlaylist(e: React.FormEvent) {
+ e.preventDefault();
+ if (!playlistCourseId) {
+ toast.error('Pick a primary course first');
+ return;
+ }
+ setLoading(true);
+ try {
+ const res = await fetch('/api/admin/create-playlist', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title: playlistTitle,
+ courseId: playlistCourseId,
+ additionalCourseIds,
+ }),
+ });
+ if (res.ok) {
+ toast.success('Playlist created');
+ setPlaylistTitle('');
+ setAdditionalCourseIds([]);
+ await fetchData();
+ router.refresh();
+ } else {
+ const txt = await res.text();
+ toast.error('Failed to create playlist: ' + txt);
+ }
+ } catch (err: any) {
+ toast.error('Error: ' + String(err.message ?? err));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function deletePlaylist(playlistId: string) {
+ setDeletingId(playlistId);
+ try {
+ const res = await fetch(`/api/admin/delete-playlist`, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ playlistId }),
+ });
+ if (res.ok) {
+ toast.success('Playlist deleted');
+ await fetchData();
+ router.refresh();
+ } else {
+ const txt = await res.text();
+ toast.error('Failed to delete playlist: ' + txt);
+ }
+ } catch (err: any) {
+ toast.error('Error: ' + String(err.message ?? err));
+ } finally {
+ setDeletingId(null);
+ }
+ }
+
+ async function updatePlaylistTitle(playlistId: string, newTitle: string) {
+ if (!newTitle.trim()) {
+ toast.error('Title cannot be empty');
+ return;
+ }
+
+ setSavingEditId(playlistId);
+ try {
+ const res = await fetch(`/api/admin/update-playlist`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ playlistId, title: newTitle }),
+ });
+ if (res.ok) {
+ toast.success('Playlist updated');
+ setEditingId(null);
+ setEditingTitle('');
+ await fetchData();
+ router.refresh();
+ } else {
+ const txt = await res.text();
+ toast.error('Failed to update playlist: ' + txt);
+ }
+ } catch (err: any) {
+ toast.error('Error: ' + String(err.message ?? err));
+ } finally {
+ setSavingEditId(null);
+ }
+ }
+
+ // Organizer state & helpers
+ const [selectedPlaylistId, setSelectedPlaylistId] = useState('');
+ const [videos, setVideos] = useState>([]);
+ const sensors = useSensors(useSensor(PointerSensor));
+
+ function SortableItem({ id, title, thumbnail }: { id: string; title: string; thumbnail?: string }) {
+ const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ } as React.CSSProperties;
+
+ return (
+
+
+ {thumbnail ? (
+
+ ) : (
+
No image
+ )}
+
{title}
+
+
+ );
+ }
+
+ async function fetchPlaylistVideos(playlistId: string) {
+ if (!playlistId) return;
+ try {
+ const res = await fetch(`/api/admin/playlist-videos?playlistId=${playlistId}`);
+ if (res.ok) {
+ const data = await res.json();
+ setVideos(data || []);
+ } else {
+ toast.error('Failed to load playlist videos');
+ }
+ } catch (err) {
+ console.error('Failed to fetch playlist videos', err);
+ toast.error('Failed to load playlist videos');
+ }
+ }
+
+ async function handleDragEnd(e: any) {
+ const { active, over } = e;
+ if (!over || active.id === over.id) return;
+ const oldIndex = videos.findIndex((v) => v.id === active.id);
+ const newIndex = videos.findIndex((v) => v.id === over.id);
+ if (oldIndex === -1 || newIndex === -1) return;
+ const newOrder = arrayMove(videos, oldIndex, newIndex);
+ setVideos(newOrder.map((v, idx) => ({ ...v, index: idx })));
+
+ // Persist order
+ try {
+ const orderedIds = newOrder.map((v) => v.id);
+ const res = await fetch('/api/admin/reorder-videos', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ playlistId: selectedPlaylistId, orderedIds }),
+ });
+ if (!res.ok) {
+ toast.error('Failed to save new order');
+ // refetch to revert
+ await fetchPlaylistVideos(selectedPlaylistId);
+ } else {
+ toast.success('Order saved');
+ }
+ } catch (err) {
+ console.error('Failed to save order', err);
+ toast.error('Failed to save new order');
+ await fetchPlaylistVideos(selectedPlaylistId);
+ }
+ }
+
+ const getCourseTitle = (courseId: string) => {
+ return courses.find((c) => c.id === courseId)?.title || 'Unknown Course';
+ };
+
+ return (
+
+
+
+ Create Playlist
+
+
+
+
+
+
+
+
+ Organize Playlist
+
+
+
+
+
+ Select playlist
+ {
+ setSelectedPlaylistId(val);
+ fetchPlaylistVideos(val);
+ }}
+ >
+
+
+
+
+ {playlists.map((p) => (
+
+ {p.title}
+
+ ))}
+
+
+
+
+
+
+
+ {videos.length === 0 ? (
+
No videos loaded
+ ) : (
+
+ v.id)} strategy={verticalListSortingStrategy}>
+
+ {videos.map((v) => (
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+
+ Playlists
+
+
+ {playlists.length === 0 ? (
+
+ No playlists yet
+
+ ) : (
+
+
+
+
+ Title
+ Course
+ Actions
+
+
+
+ {playlists.map((playlist) => (
+
+
+ {editingId === playlist.id ? (
+
+ setEditingTitle(e.target.value)}
+ className="h-8"
+ autoFocus
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ updatePlaylistTitle(playlist.id, editingTitle);
+ }
+ if (e.key === 'Escape') {
+ setEditingId(null);
+ setEditingTitle('');
+ }
+ }}
+ />
+ updatePlaylistTitle(playlist.id, editingTitle)}
+ disabled={savingEditId === playlist.id}
+ >
+
+
+ {
+ setEditingId(null);
+ setEditingTitle('');
+ }}
+ disabled={savingEditId === playlist.id}
+ >
+
+
+
+ ) : (
+
+ {playlist.title}
+ {
+ setEditingId(playlist.id);
+ setEditingTitle(playlist.title);
+ }}
+ >
+
+
+
+ )}
+
+ {getCourseTitle(playlist.courseId)}
+
+
+
+
+
+
+
+
+
+ Delete Playlist
+
+ Are you sure? This will delete the playlist and all
+ associated videos.
+
+
+
+
Cancel
+
deletePlaylist(playlist.id)}
+ >
+ Delete
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ );
+}
+
+export default function PlaylistsAdminClient() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/admin/playlists/page.tsx b/app/admin/playlists/page.tsx
new file mode 100644
index 0000000..efb60d0
--- /dev/null
+++ b/app/admin/playlists/page.tsx
@@ -0,0 +1,22 @@
+// app/admin/playlists/page.tsx
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { redirect } from 'next/navigation';
+import PlaylistsAdminClient from './admin-client';
+
+export const dynamic = 'force-dynamic';
+
+export default async function PlaylistsAdminPage() {
+ const session = await getServerSession(authOptions);
+ const role = (session as any)?.user?.role ?? null;
+
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ if (!(role === 'admin' || role === 'superadmin')) {
+ redirect('/dashboard');
+ }
+
+ return ;
+}
diff --git a/app/admin/stats-client.tsx b/app/admin/stats-client.tsx
new file mode 100644
index 0000000..acf5626
--- /dev/null
+++ b/app/admin/stats-client.tsx
@@ -0,0 +1,298 @@
+// app/admin/stats-client.tsx
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { Skeleton } from '@/components/ui/skeleton';
+import { AlertCircle } from 'lucide-react';
+
+interface VideoStats {
+ id: string;
+ title: string;
+ playlistTitle: string;
+ uploaderName: string;
+ views: number;
+ totalViewers: number;
+ completions: number;
+ completionRate: string;
+ likes: number;
+ comments: number;
+ engagement: number;
+ avgPercentWatched: number;
+ totalSecondsWatched: number;
+ totalSegments: number;
+ durationSec: number | null;
+ createdAt: string;
+}
+
+export default function StatsClient() {
+ const [stats, setStats] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [sortBy, setSortBy] = useState('views');
+ const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
+
+ useEffect(() => {
+ const fetchStats = async () => {
+ try {
+ setLoading(true);
+ const response = await fetch('/api/admin/stats');
+ if (!response.ok) throw new Error('Failed to fetch stats');
+ const data = await response.json();
+ setStats(data);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'An error occurred');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchStats();
+ }, []);
+
+ const handleSort = (column: keyof VideoStats) => {
+ if (sortBy === column) {
+ setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
+ } else {
+ setSortBy(column);
+ setSortOrder('desc');
+ }
+ };
+
+ const sortedStats = [...stats].sort((a, b) => {
+ const aVal = a[sortBy];
+ const bVal = b[sortBy];
+
+ if (typeof aVal === 'string' && typeof bVal === 'string') {
+ return sortOrder === 'asc'
+ ? aVal.localeCompare(bVal)
+ : bVal.localeCompare(aVal);
+ }
+
+ const aNum = typeof aVal === 'number' ? aVal : 0;
+ const bNum = typeof bVal === 'number' ? bVal : 0;
+
+ return sortOrder === 'asc' ? aNum - bNum : bNum - aNum;
+ });
+
+ const formatDuration = (seconds: number | null) => {
+ if (!seconds) return 'N/A';
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+ return `${hours}h ${minutes}m ${secs}s`;
+ };
+
+ const SortHeader = ({ column, label }: { column: keyof VideoStats; label: string }) => (
+ handleSort(column)}
+ >
+
+ {label}
+ {sortBy === column && (
+
+ {sortOrder === 'asc' ? '↑' : '↓'}
+
+ )}
+
+
+ );
+
+ if (error) {
+ return (
+
+
+
+
Error loading stats
+
{error}
+
+
+ );
+ }
+
+ return (
+
+
+
+
Video Statistics
+
+ Performance metrics for all videos in the system
+
+
+
+ {stats.length} Videos
+
+
+
+ {/* Summary Cards */}
+
+
+
+
+ Total Views
+
+
+
+
+ {stats.reduce((sum, s) => sum + s.views, 0).toLocaleString()}
+
+
+
+
+
+
+
+ Unique Viewers
+
+
+
+
+ {stats.reduce((sum, s) => sum + s.totalViewers, 0).toLocaleString()}
+
+
+
+
+
+
+
+ Total Engagements
+
+
+
+
+ {stats.reduce((sum, s) => sum + s.engagement, 0).toLocaleString()}
+
+
+ Likes + Comments
+
+
+
+
+
+
+
+ Avg. Completion Rate
+
+
+
+
+ {(
+ stats.reduce((sum, s) => sum + parseFloat(s.completionRate), 0) /
+ (stats.length || 1)
+ ).toFixed(1)}
+ %
+
+
+
+
+
+ {/* Data Table */}
+
+
+ Video Performance Details
+
+
+
+ {loading ? (
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+ ) : stats.length === 0 ? (
+
+ No videos found
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {sortedStats.map((video) => (
+
+
+
+ {video.title}
+
+ {video.playlistTitle}
+
+
+
+
+ {video.uploaderName}
+
+
+ {video.views}
+
+
+ {video.totalViewers}
+
+
+ {video.completions}
+
+
+ = 50
+ ? 'default'
+ : parseFloat(video.completionRate) >= 25
+ ? 'secondary'
+ : 'destructive'
+ }
+ >
+ {video.completionRate}%
+
+
+
+ {video.avgPercentWatched.toFixed(1)}%
+
+
+ {video.engagement}
+
+
+ {video.likes}
+
+
+ {video.comments}
+
+
+ {video.totalSecondsWatched.toLocaleString()}s
+
+
+ {formatDuration(video.durationSec)}
+
+
+ ))}
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/app/admin/stats/page.tsx b/app/admin/stats/page.tsx
new file mode 100644
index 0000000..993bb26
--- /dev/null
+++ b/app/admin/stats/page.tsx
@@ -0,0 +1,26 @@
+// app/admin/stats/page.tsx
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { redirect } from 'next/navigation';
+import StatsClient from '../stats-client';
+
+export const dynamic = 'force-dynamic';
+
+export default async function StatsPage() {
+ const session = await getServerSession(authOptions);
+ const role = (session as any)?.user?.role ?? null;
+
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ if (!(role === 'admin' || role === 'superadmin')) {
+ redirect('/dashboard');
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/app/admin/users/[userId]/page.tsx b/app/admin/users/[userId]/page.tsx
new file mode 100644
index 0000000..bebeb07
--- /dev/null
+++ b/app/admin/users/[userId]/page.tsx
@@ -0,0 +1,26 @@
+// app/admin/users/[userId]/page.tsx
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { redirect } from 'next/navigation';
+import UserDetailClient from './user-detail-client';
+
+export default async function UserDetailPage({
+ params,
+}: {
+ params: Promise<{ userId: string }>;
+}) {
+ const session = await getServerSession(authOptions);
+ const role = (session as any)?.user?.role ?? null;
+
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ if (!(role === 'admin' || role === 'superadmin')) {
+ redirect('/dashboard');
+ }
+
+ const { userId } = await params;
+
+ return ;
+}
diff --git a/app/admin/users/[userId]/user-detail-client.tsx b/app/admin/users/[userId]/user-detail-client.tsx
new file mode 100644
index 0000000..6b13cc0
--- /dev/null
+++ b/app/admin/users/[userId]/user-detail-client.tsx
@@ -0,0 +1,564 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import { useSession } from 'next-auth/react';
+import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import { SegmentedProgressBar, WatchSegment } from '@/components/segmented-progress-bar';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import { formatDistanceToNow } from 'date-fns';
+import { ArrowLeft, Trash2, Save } from 'lucide-react';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+import { toast } from 'sonner';
+
+type Video = {
+ id: string;
+ title: string;
+};
+
+type UserProgress = {
+ id: string;
+ videoId: string;
+ video: Video;
+ watchedSec: number;
+ lastPos: number | null;
+ percent: number;
+ completed: boolean;
+ durationSec: number | null;
+ updatedAt: string;
+ createdAt: string;
+};
+
+type UserComment = {
+ id: string;
+ content: string;
+ createdAt: string;
+ video: Video;
+ replies: Array<{ id: string }>;
+};
+
+type UserDetail = {
+ id: string;
+ email: string;
+ name: string | null;
+ image: string | null;
+ role: string;
+ createdAt: string;
+ enrollments: Array<{
+ course: {
+ id: string;
+ title: string;
+ };
+ }>;
+ progress: UserProgress[];
+ comments: UserComment[];
+};
+
+interface UserDetailClientProps {
+ userId: string;
+}
+
+function UserDetailClientUI({ userId }: UserDetailClientProps) {
+ const { data: session } = useSession();
+ const [user, setUser] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [progressSegments, setProgressSegments] = useState>({});
+ const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [selectedRole, setSelectedRole] = useState('');
+ const [isRoleChangeAlertOpen, setIsRoleChangeAlertOpen] = useState(false);
+ const [isUpdatingRole, setIsUpdatingRole] = useState(false);
+ const router = useRouter();
+
+ const isSuperadmin = (session?.user as any)?.role === 'superadmin';
+
+ useEffect(() => {
+ const fetchUserDetail = async () => {
+ setIsLoading(true);
+ try {
+ const res = await fetch(`/api/admin/users/${userId}`);
+ if (res.ok) {
+ const data = await res.json();
+ setUser(data);
+ setSelectedRole(data.role);
+
+ // Fetch segments for all progress entries
+ const segments: Record = {};
+ for (const prog of data.progress) {
+ try {
+ const segRes = await fetch(`/api/admin/users/${userId}/progress/${prog.videoId}/segments`);
+ if (segRes.ok) {
+ const segData = await segRes.json();
+ segments[prog.videoId] = segData.segments ?? [];
+ }
+ } catch (err) {
+ console.error(`Failed to fetch segments for video ${prog.videoId}:`, err);
+ }
+ }
+ setProgressSegments(segments);
+ }
+ } catch (err) {
+ console.error('Failed to fetch user detail:', err);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchUserDetail();
+ }, [userId]);
+
+ const handleDeleteUser = async () => {
+ setIsDeleting(true);
+ try {
+ const res = await fetch(`/api/admin/users/${userId}`, {
+ method: 'DELETE',
+ });
+
+ if (res.ok) {
+ toast.success('User deleted successfully');
+ router.push('/admin/users');
+ } else {
+ const data = await res.json();
+ toast.error(data.error || 'Failed to delete user');
+ }
+ } catch (err) {
+ console.error('Failed to delete user:', err);
+ toast.error('Error deleting user');
+ } finally {
+ setIsDeleting(false);
+ setIsDeleteAlertOpen(false);
+ }
+ };
+
+ const handleUpdateRole = async () => {
+ if (!user || selectedRole === user.role) {
+ setIsRoleChangeAlertOpen(false);
+ return;
+ }
+
+ setIsUpdatingRole(true);
+ try {
+ const res = await fetch('/api/admin/update-user-role', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ userId,
+ newRole: selectedRole,
+ }),
+ });
+
+ if (res.ok) {
+ const data = await res.json();
+ setUser({ ...user, role: selectedRole });
+ toast.success(`User role updated to ${selectedRole}`);
+ setIsRoleChangeAlertOpen(false);
+ } else {
+ const data = await res.json();
+ toast.error(data.error || 'Failed to update user role');
+ setSelectedRole(user.role);
+ }
+ } catch (err) {
+ console.error('Failed to update user role:', err);
+ toast.error('Error updating user role');
+ setSelectedRole(user.role);
+ } finally {
+ setIsUpdatingRole(false);
+ }
+ };
+
+ if (isLoading) {
+ return (
+ Loading user details...
+ );
+ }
+
+ if (!user) {
+ return (
+
+
+
User not found
+
router.back()} className="mt-4">
+ Go back
+
+
+ );
+ }
+
+ const getInitials = (name: string | null, email: string) => {
+ if (!name) {
+ return email.substring(0, 2).toUpperCase();
+ }
+ return name
+ .split(' ')
+ .map((n) => n[0])
+ .join('')
+ .toUpperCase();
+ };
+
+ const formatSeconds = (seconds: number) => {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+ if (hours > 0) {
+ return `${hours}h ${minutes}m ${secs}s`;
+ }
+ return `${minutes}m ${secs}s`;
+ };
+
+ return (
+
+
+
router.back()}
+ >
+
+ Back
+
+
setIsDeleteAlertOpen(true)}
+ disabled={isDeleting}
+ className="gap-2"
+ >
+
+ Delete User
+
+
+
+ {/* User Header */}
+
+
+
+
+
+
+ {getInitials(user.name, user.email)}
+
+
+
+
+
+ {user.name || user.email}
+
+
+ {user.role}
+
+
+
{user.email}
+
+ Joined {formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
+
+
+
+
+
+
+ {/* Role Assignment Card (Superadmin Only) */}
+ {isSuperadmin && (
+
+
+ Assign Role
+
+
+
+
+ User Role
+
+
+
+
+
+ User (Student)
+ Admin
+ Superadmin
+
+
+
+
setIsRoleChangeAlertOpen(true)}
+ disabled={selectedRole === user.role || isUpdatingRole}
+ className="gap-2"
+ >
+
+ {isUpdatingRole ? 'Updating...' : 'Update Role'}
+
+
+
+ • User: Regular student with access to enrolled courses
+
+ • Admin: Can manage courses, playlists, videos, and users
+
+ • Superadmin: Full access + can assign roles to other admins
+
+
+
+ )}
+
+ {/* Enrollments */}
+ {user.enrollments.length > 0 && (
+
+
+ Enrollments ({user.enrollments.length})
+
+
+
+ {user.enrollments.map((enrollment, idx) => (
+
+ {enrollment.course.title}
+
+ ))}
+
+
+
+ )}
+
+ {/* Tabs for Watch History and Comments */}
+
+
+
+ Watch History ({user.progress.length})
+
+
+ Comments ({user.comments.length})
+
+
+
+ {/* Watch History Tab */}
+
+
+
+ {user.progress.length === 0 ? (
+
+ No watch history
+
+ ) : (
+
+
+
+
+ Video
+ Watched
+ Duration
+ Progress
+ Status
+ Last Updated
+
+
+
+ {user.progress.map((progress) => (
+
+
+ {progress.video.title}
+
+
+ {formatSeconds(progress.watchedSec)}
+
+
+ {progress.durationSec
+ ? formatSeconds(progress.durationSec)
+ : '-'}
+
+
+
+
+
+ {progress.completed ? (
+ Completed
+ ) : (
+ In Progress
+ )}
+
+
+ {formatDistanceToNow(new Date(progress.updatedAt), {
+ addSuffix: true,
+ })}
+
+
+ ))}
+
+
+
+ )}
+
+
+
+
+ {/* Comments Tab */}
+
+
+
+ {user.comments.length === 0 ? (
+
+ No comments
+
+ ) : (
+
+ {user.comments.map((comment) => (
+
+
+
+
+ Video: {comment.video.title}
+
+
+ {formatDistanceToNow(new Date(comment.createdAt), {
+ addSuffix: true,
+ })}
+
+
+ {comment.replies.length > 0 && (
+
+ {comment.replies.length} replies
+
+ )}
+
+
{comment.content}
+
+ ))}
+
+ )}
+
+
+
+
+
+ {/* Delete User Alert Dialog */}
+
+
+ Delete User
+
+
+
+ Are you sure you want to delete {user?.email} ?
+
+
+ ⚠️ This will permanently delete:
+
+
+ User account and profile
+ All enrollments
+ All progress and watch history
+ All comments and replies
+ All video likes
+ All video unlocks
+
+
+ This action cannot be undone.
+
+
+
+
+
+ Cancel
+
+
+ {isDeleting ? 'Deleting...' : 'Delete User'}
+
+
+
+
+
+ {/* Role Change Confirmation Dialog */}
+
+
+ Update User Role
+
+
+
+ Change {user?.email} 's role from{' '}
+ {user?.role} to{' '}
+ {selectedRole} ?
+
+ {selectedRole === 'superadmin' && (
+
+ ⚠️ This user will have full access including the ability to assign roles to other users.
+
+ )}
+ {selectedRole === 'user' && user?.role !== 'user' && (
+
+ ℹ️ This user will lose admin access but will still have access to enrolled courses.
+
+ )}
+
+
+
+
+ Cancel
+
+
+ {isUpdatingRole ? 'Updating...' : 'Update Role'}
+
+
+
+
+
+ );
+}
+
+
+export default function UserDetailClient({ userId }: UserDetailClientProps) {
+ return (
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/admin/users/admin-client.tsx b/app/admin/users/admin-client.tsx
new file mode 100644
index 0000000..d290fad
--- /dev/null
+++ b/app/admin/users/admin-client.tsx
@@ -0,0 +1,185 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { formatDistanceToNow } from 'date-fns';
+import { Eye } from 'lucide-react';
+
+type User = {
+ id: string;
+ email: string;
+ name: string | null;
+ image: string | null;
+ role: string;
+ createdAt: Date;
+ enrollments: Array<{
+ course: {
+ id: string;
+ title: string;
+ };
+ }>;
+ lastActivity: Date | null;
+};
+
+function UsersAdminClientUI() {
+ const [users, setUsers] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const router = useRouter();
+
+ useEffect(() => {
+ const fetchUsers = async () => {
+ setIsLoading(true);
+ try {
+ const res = await fetch('/api/admin/users');
+ if (res.ok) {
+ const data = await res.json();
+ setUsers(data);
+ }
+ } catch (err) {
+ console.error('Failed to fetch users:', err);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchUsers();
+ }, []);
+
+ const getRoleBadgeVariant = (role: string) => {
+ switch (role) {
+ case 'superadmin':
+ return 'default';
+ case 'admin':
+ return 'secondary';
+ default:
+ return 'outline';
+ }
+ };
+
+ if (isLoading) {
+ return (
+ Loading users...
+ );
+ }
+
+ return (
+
+
+
+ Total: {users.length} users
+
+
+
+
+
+ Email
+ Name
+ Role
+ Courses
+ Last Activity
+ Joined
+ Actions
+
+
+
+ {users.length === 0 ? (
+
+
+ No users found
+
+
+ ) : (
+ users.map((user) => (
+
+ {user.email}
+ {user.name || '-'}
+
+
+ {user.role}
+
+
+
+ {user.enrollments.length > 0 ? (
+
+ {user.enrollments.map((enrollment, idx) => (
+
+ {enrollment.course.title}
+
+ ))}
+
+ ) : (
+ -
+ )}
+
+
+ {user.lastActivity ? (
+ formatDistanceToNow(new Date(user.lastActivity), { addSuffix: true })
+ ) : (
+ No activity
+ )}
+
+
+ {formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
+
+
+ router.push(`/admin/users/${user.id}`)}
+ >
+
+ View
+
+
+
+ ))
+ )}
+
+
+
+
+
+ );
+
+}
+export default function sersAdminClient() {
+ return (
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx
new file mode 100644
index 0000000..2ad6bc2
--- /dev/null
+++ b/app/admin/users/page.tsx
@@ -0,0 +1,22 @@
+// app/admin/users/page.tsx
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { redirect } from 'next/navigation';
+import UsersAdminClient from './admin-client';
+
+export const dynamic = 'force-dynamic';
+
+export default async function UsersAdminPage() {
+ const session = await getServerSession(authOptions);
+ const role = (session as any)?.user?.role ?? null;
+
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ if (!(role === 'admin' || role === 'superadmin')) {
+ redirect('/dashboard');
+ }
+
+ return ;
+}
diff --git a/app/admin/videos/[videoId]/edit/edit-video-client.tsx b/app/admin/videos/[videoId]/edit/edit-video-client.tsx
new file mode 100644
index 0000000..792fc27
--- /dev/null
+++ b/app/admin/videos/[videoId]/edit/edit-video-client.tsx
@@ -0,0 +1,268 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { toast } from 'sonner';
+import Image from 'next/image';
+import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ Field,
+ FieldGroup,
+ FieldLabel,
+ FieldDescription,
+} from '@/components/ui/field';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import { Textarea } from '@/components/ui/textarea';
+
+interface VideoData {
+ id: string;
+ title: string;
+ description: string;
+ thumbnail?: string;
+ playlistTitle: string;
+ courseTitle: string;
+ restrictedCourseIds?: string[];
+}
+
+interface CourseData {
+ id: string;
+ title: string;
+}
+
+export default function EditVideoClient({ video }: { video: VideoData }) {
+ const router = useRouter();
+ const [title, setTitle] = useState(video.title);
+ const [description, setDescription] = useState(video.description);
+ const [thumbFile, setThumbFile] = useState(null);
+ const [thumbPreview, setThumbPreview] = useState(video.thumbnail);
+ const [loading, setLoading] = useState(false);
+ const [courses, setCourses] = useState([]);
+ const [coursesLoading, setCoursesLoading] = useState(true);
+ const [restrictedCourseIds, setRestrictedCourseIds] = useState(
+ video.restrictedCourseIds ?? []
+ );
+
+ useEffect(() => {
+ let cancelled = false;
+ setCoursesLoading(true);
+ fetch('/api/admin/meta')
+ .then(async (res) => {
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const json = await res.json();
+ if (!cancelled) {
+ setCourses(json.courses ?? []);
+ }
+ })
+ .catch((err) => {
+ console.error('Failed to load courses', err);
+ if (!cancelled) {
+ setCourses([]);
+ }
+ })
+ .finally(() => {
+ if (!cancelled) setCoursesLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const handleThumbnailChange = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (file) {
+ setThumbFile(file);
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ setThumbPreview(event.target?.result as string);
+ };
+ reader.readAsDataURL(file);
+ }
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLoading(true);
+
+ try {
+ const formData = new FormData();
+ formData.append('videoId', video.id);
+ formData.append('title', title);
+ formData.append('description', description);
+ if (thumbFile) {
+ formData.append('thumbnail', thumbFile);
+ }
+ formData.append('restrictedCourseIds', JSON.stringify(restrictedCourseIds));
+
+ const res = await fetch('/api/admin/update-video', {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (res.ok) {
+ toast.success('Video updated successfully');
+ router.push('/admin/videos');
+ } else {
+ const err = await res.text();
+ toast.error('Failed to update video: ' + err);
+ }
+ } catch (err: any) {
+ toast.error('Error: ' + String(err.message ?? err));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ Edit Video
+
+
+ {video.courseTitle} • {video.playlistTitle}
+
+
+
+
+
+ {video.title}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/admin/videos/[videoId]/edit/page.tsx b/app/admin/videos/[videoId]/edit/page.tsx
new file mode 100644
index 0000000..5bffbf3
--- /dev/null
+++ b/app/admin/videos/[videoId]/edit/page.tsx
@@ -0,0 +1,57 @@
+// app/admin/videos/[videoId]/edit/page.tsx
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+import { redirect } from 'next/navigation';
+import EditVideoClient from './edit-video-client';
+
+export default async function EditVideoPage({
+ params,
+}: {
+ params: Promise<{ videoId: string }>;
+}) {
+ const { videoId } = await params;
+
+ const session = await getServerSession(authOptions);
+ const role = (session as any)?.user?.role ?? null;
+
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ if (!(role === 'admin' || role === 'superadmin')) {
+ redirect('/dashboard');
+ }
+
+ const video = await prisma.video.findUnique({
+ where: { id: videoId },
+ include: {
+ playlist: {
+ include: {
+ course: true,
+ },
+ },
+ videoCourses: true,
+ },
+ });
+
+ if (!video) {
+ redirect('/admin/videos');
+ }
+
+ return (
+ vc.exclusive)
+ .map((vc) => vc.courseId),
+ }}
+ />
+ );
+}
diff --git a/app/admin/videos/admin-client.tsx b/app/admin/videos/admin-client.tsx
new file mode 100644
index 0000000..7bb48c6
--- /dev/null
+++ b/app/admin/videos/admin-client.tsx
@@ -0,0 +1,815 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
+import { AppSidebar } from '@/components/app-sidebar';
+import { Badge } from '@/components/ui/badge';
+import { SiteHeader } from '@/components/site-header';
+import { useRouter } from 'next/navigation';
+import { toast } from 'sonner';
+import { Progress } from '@/components/ui/progress';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ Field,
+ FieldGroup,
+ FieldLabel,
+ FieldDescription,
+} from '@/components/ui/field';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog';
+import { Trash2Icon, Edit, Check, X } from 'lucide-react';
+import { Switch } from '@/components/ui/switch';
+import { getVideoDuration } from '@/utils/getVideoDuration';
+
+type Course = { id: string; title: string; code?: string };
+type Playlist = { id: string; title: string; courseId: string };
+type Video = {
+ id: string;
+ title: string;
+ durationSec?: number;
+ thumbnail?: string;
+ url: string;
+ index: number;
+ locked: boolean;
+ instantAccess: boolean;
+ playlistId: string;
+ playlist: {
+ id: string;
+ title: string;
+ course: {
+ id: string;
+ title: string;
+ };
+ };
+ restrictedCourseIds?: string[];
+};
+
+function VideosUI() {
+ const router = useRouter();
+ const [courses, setCourses] = useState([]);
+ const [playlists, setPlaylists] = useState([]);
+ const [videos, setVideos] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [uploadProgress, setUploadProgress] = useState(null);
+
+ // form state
+ const [videoTitle, setVideoTitle] = useState('');
+ const [videoFile, setVideoFile] = useState(null);
+ const [videoPlaylistId, setVideoPlaylistId] = useState('');
+ const [thumbFile, setThumbFile] = useState(null);
+ const [videoDurationSec, setVideoDurationSec] = useState(null);
+ const [deletingId, setDeletingId] = useState(null);
+ const [togglingId, setTogglingId] = useState(null);
+ const [editingId, setEditingId] = useState(null);
+ const [editingTitle, setEditingTitle] = useState('');
+ const [savingEditId, setSavingEditId] = useState(null);
+
+ // Manual file copy mode
+ const [useManualFileCopy, setUseManualFileCopy] = useState(false);
+ const [pendingVideoId, setPendingVideoId] = useState(null);
+ const [finalizingVideoId, setFinalizingVideoId] = useState(null);
+
+ useEffect(() => {
+ fetchData();
+ }, []);
+
+ async function toggleInstantAccess(videoId: string, currentValue: boolean) {
+ setTogglingId(videoId);
+ try {
+ const res = await fetch(`/api/admin/videos/${videoId}/instant-access`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ instantAccess: !currentValue }),
+ });
+
+ if (res.ok) {
+ const data = await res.json();
+ // Update the video in state
+ setVideos((prev) =>
+ prev.map((v) =>
+ v.id === videoId ? { ...v, instantAccess: data.video.instantAccess } : v
+ )
+ );
+ toast.success(`Video ${!currentValue ? 'set to' : 'removed from'} instant access`);
+ } else {
+ toast.error('Failed to toggle instant access');
+ }
+ } catch (err) {
+ console.error('Error toggling instant access:', err);
+ toast.error('Error toggling instant access');
+ } finally {
+ setTogglingId(null);
+ }
+ }
+
+ async function toggleLocked(videoId: string, currentValue: boolean) {
+ setTogglingId(videoId);
+ try {
+ const res = await fetch(`/api/admin/videos/${videoId}/locked`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ locked: !currentValue }),
+ });
+
+ if (res.ok) {
+ const data = await res.json();
+ // Update the video in state
+ setVideos((prev) =>
+ prev.map((v) =>
+ v.id === videoId ? { ...v, locked: data.video.locked } : v
+ )
+ );
+ toast.success(`Video ${!currentValue ? 'locked' : 'unlocked'}`);
+ } else {
+ toast.error('Failed to toggle lock status');
+ }
+ } catch (err) {
+ console.error('Error toggling locked:', err);
+ toast.error('Error toggling lock status');
+ } finally {
+ setTogglingId(null);
+ }
+ }
+
+ async function fetchData() {
+ try {
+ const [metaRes, videosRes] = await Promise.all([
+ fetch('/api/admin/meta'),
+ fetch('/api/admin/videos'),
+ ]);
+
+ if (metaRes.ok) {
+ const json = await metaRes.json();
+ setCourses(json.courses || []);
+ setPlaylists(json.playlists || []);
+ if (!videoPlaylistId && json.playlists?.[0]) {
+ setVideoPlaylistId(json.playlists[0].id);
+ }
+ }
+
+ if (videosRes.ok) {
+ const json = await videosRes.json();
+ // Ensure data is serialized properly
+ setVideos(json.map((v: any) => ({
+ id: v.id,
+ title: v.title,
+ durationSec: v.durationSec,
+ thumbnail: v.thumbnail,
+ url: v.url,
+ index: v.index,
+ locked: v.locked,
+ instantAccess: v.instantAccess,
+ playlistId: v.playlistId,
+ playlist: {
+ id: v.playlist.id,
+ title: v.playlist.title,
+ course: {
+ id: v.playlist.course.id,
+ title: v.playlist.course.title,
+ },
+ },
+ restrictedCourseIds: (v.videoCourses ?? [])
+ .filter((assignment: any) => assignment.exclusive)
+ .map((assignment: any) => assignment.courseId),
+ })));
+ }
+ } catch (err) {
+ console.error('Failed to fetch data', err);
+ toast.error('Failed to fetch data');
+ }
+ }
+
+ async function handleFileChange(e: React.ChangeEvent) {
+ const f = e.target.files?.[0] ?? null;
+ setVideoFile(f);
+ setVideoDurationSec(null);
+ if (!f) return;
+ try {
+ const secs = await getVideoDuration(f);
+ setVideoDurationSec(secs);
+ toast.success(`Duration: ${secs}s`);
+ } catch (err) {
+ console.warn('duration read failed', err);
+ toast.error('Could not read duration (server will measure)');
+ }
+ }
+
+ async function uploadVideo(e: React.FormEvent) {
+ e.preventDefault();
+ if (!videoPlaylistId) {
+ toast.error('Select a playlist');
+ return;
+ }
+
+ // Manual file copy mode
+ if (useManualFileCopy) {
+ if (!videoFile) {
+ toast.error('Select a file to get the filename');
+ return;
+ }
+ // Create video entry without uploading the actual file
+ setLoading(true);
+ try {
+ const form = new FormData();
+ form.append('title', videoTitle);
+ form.append('playlistId', videoPlaylistId);
+ form.append('manualFileCopy', 'true'); // Flag to skip file upload
+ if (videoDurationSec) form.append('durationSec', String(videoDurationSec));
+ if (thumbFile) form.append('thumbnail', thumbFile);
+
+ const res = await fetch('/api/admin/upload', {
+ method: 'POST',
+ body: form,
+ });
+
+ const data = await res.json();
+
+ if (!res.ok) {
+ toast.error(data.error ?? 'Upload failed');
+ return;
+ }
+
+ // Show modal with the video ID
+ setPendingVideoId(data.video.id);
+ toast.success(`Video created! Copy file as: ${data.video.id}.mp4`);
+ } catch (err: any) {
+ console.error('upload error', err);
+ toast.error(String(err?.message ?? 'Upload failed'));
+ } finally {
+ setLoading(false);
+ }
+ return;
+ }
+
+ // Normal upload mode
+ if (!videoFile) {
+ toast.error('Pick a file first');
+ return;
+ }
+
+ setLoading(true);
+ setUploadProgress(0);
+
+ try {
+ const form = new FormData();
+ form.append('file', videoFile);
+ form.append('title', videoTitle);
+ form.append('playlistId', videoPlaylistId);
+ if (videoDurationSec) form.append('durationSec', String(videoDurationSec));
+ if (thumbFile) form.append('thumbnail', thumbFile);
+
+ await new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.open('POST', '/api/admin/upload');
+
+ xhr.upload.onprogress = (ev) => {
+ if (ev.lengthComputable) {
+ const pct = Math.round((ev.loaded / ev.total) * 100);
+ setUploadProgress(pct);
+ }
+ };
+
+ xhr.onload = async () => {
+ let body: any = null;
+ try {
+ body = xhr.responseText ? JSON.parse(xhr.responseText) : null;
+ } catch (err) {
+ body = { error: xhr.responseText };
+ }
+
+ if (xhr.status >= 200 && xhr.status < 300) {
+ toast.success('Upload complete');
+ setUploadProgress(null);
+ setVideoFile(null);
+ setVideoTitle('');
+ setVideoDurationSec(null);
+ setThumbFile(null);
+ await fetchData();
+ router.refresh();
+ resolve();
+ } else {
+ const errMsg = body?.error ?? `Upload failed (${xhr.status})`;
+ toast.error(errMsg);
+ setUploadProgress(null);
+ reject(new Error(errMsg));
+ }
+ };
+
+ xhr.onerror = () => {
+ toast.error('Upload failed (network)');
+ setUploadProgress(null);
+ reject(new Error('network error'));
+ };
+
+ // Timeout for 2GB uploads over Tailscale: 50 minutes
+ xhr.timeout = 50 * 60 * 1000;
+ xhr.ontimeout = () => {
+ toast.error('Upload timed out');
+ setUploadProgress(null);
+ reject(new Error('timeout'));
+ };
+
+ xhr.send(form);
+ });
+ } catch (err: any) {
+ console.error('upload error', err);
+ if (!uploadProgress) setUploadProgress(null);
+ toast.error(String(err?.message ?? 'Upload failed'));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function finalizeManualUpload(videoId: string) {
+ setFinalizingVideoId(videoId);
+ try {
+ const res = await fetch('/api/admin/upload/finalize', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ videoId }),
+ });
+
+ const data = await res.json();
+
+ if (!res.ok) {
+ toast.error(data.error ?? 'Finalization failed');
+ return;
+ }
+
+ toast.success(`Video finalized! ${data.message}`);
+ setPendingVideoId(null);
+ setVideoFile(null);
+ setVideoTitle('');
+ setVideoDurationSec(null);
+ setThumbFile(null);
+ setUseManualFileCopy(false);
+ await fetchData();
+ router.refresh();
+ } catch (err: any) {
+ console.error('finalize error', err);
+ toast.error(String(err?.message ?? 'Finalization failed'));
+ } finally {
+ setFinalizingVideoId(null);
+ }
+ }
+
+ async function deleteVideo(videoId: string) {
+ setDeletingId(videoId);
+ try {
+ const res = await fetch(`/api/admin/delete-video`, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ videoId }),
+ });
+ if (res.ok) {
+ toast.success('Video deleted');
+ await fetchData();
+ router.refresh();
+ } else {
+ const txt = await res.text();
+ toast.error('Failed to delete video: ' + txt);
+ }
+ } catch (err: any) {
+ toast.error('Error: ' + String(err.message ?? err));
+ } finally {
+ setDeletingId(null);
+ }
+ }
+
+ async function updateVideoTitle(videoId: string, newTitle: string) {
+ if (!newTitle.trim()) {
+ toast.error('Title cannot be empty');
+ return;
+ }
+
+ setSavingEditId(videoId);
+ try {
+ const res = await fetch(`/api/admin/update-video`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ videoId, title: newTitle }),
+ });
+ if (res.ok) {
+ toast.success('Video updated');
+ setEditingId(null);
+ setEditingTitle('');
+ await fetchData();
+ router.refresh();
+ } else {
+ const txt = await res.text();
+ toast.error('Failed to update video: ' + txt);
+ }
+ } catch (err: any) {
+ toast.error('Error: ' + String(err.message ?? err));
+ } finally {
+ setSavingEditId(null);
+ }
+ }
+
+ return (
+
+
+
+ Upload Video
+
+
+
+
+
+ Video title
+ Title shown in the playlist.
+ setVideoTitle(e.target.value)}
+ placeholder="e.g. Lesson 1 — Intro to the Rig"
+ />
+
+
+
+
+
+
+ Manual file copy mode
+
+
+
+ {useManualFileCopy
+ ? 'Creates DB entry and thumbnail. You will copy the file manually to the server.'
+ : 'Upload file directly from browser.'}
+
+
+
+
+ Playlist
+
+ Select which playlist this video belongs to.
+
+
+ setVideoPlaylistId(val)}
+ >
+
+
+
+
+ {playlists.map((p) => (
+
+ {p.title}
+
+ ))}
+
+
+
+
+
+ Video file
+
+ {useManualFileCopy
+ ? 'Select the file to get its name, then copy manually to: /uploads/videos/{videoId}.mp4'
+ : 'Large files will be uploaded to the server.'}
+
+
+
+ {videoDurationSec ? (
+
+ Duration: {videoDurationSec}s
+
+ ) : null}
+
+ {uploadProgress !== null && !useManualFileCopy ? (
+
+
+
Uploading
+
+ {uploadProgress}%
+
+
+
+
+ ) : null}
+
+
+
+ Thumbnail
+
+
setThumbFile(e.target.files?.[0] ?? null)}
+ className="block w-full text-sm"
+ />
+
+ {thumbFile ? (
+
+ ) : null}
+
+
+
+
+
+ {loading
+ ? useManualFileCopy
+ ? 'Creating…'
+ : 'Uploading…'
+ : useManualFileCopy
+ ? 'Create & Setup Manual Copy'
+ : 'Upload video'}
+
+
+
+
+
+
+
+ {/* Manual file copy modal */}
+ {pendingVideoId && (
+
+
+ File Ready for Manual Copy
+
+
+
+
+ Copy your video file to the following location on your server:
+
+
+ /uploads/videos/{pendingVideoId}.mp4
+
+
+ The file must be named exactly as shown above (using the video ID).
+
+
+
+ finalizeManualUpload(pendingVideoId)}
+ disabled={finalizingVideoId !== null}
+ className="w-full"
+ >
+ {finalizingVideoId ? 'Checking file…' : 'I have placed the file'}
+
+ setPendingVideoId(null)}
+ disabled={finalizingVideoId !== null}
+ className="w-full"
+ >
+ Cancel
+
+
+
+ )}
+
+
+
+ Videos
+
+
+ {videos.length === 0 ? (
+
+ No videos yet
+
+ ) : (
+
+
+
+
+ Thumbnail
+ Title
+ Playlist
+ Course
+ Locked
+ Instant Access
+ Actions
+
+
+
+ {videos.map((video) => (
+
+
+ {video.thumbnail ? (
+
+ ) : (
+
+
+ No image
+
+
+ )}
+
+
+ {editingId === video.id ? (
+
+ setEditingTitle(e.target.value)}
+ className="h-8"
+ autoFocus
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ updateVideoTitle(video.id, editingTitle);
+ }
+ if (e.key === 'Escape') {
+ setEditingId(null);
+ setEditingTitle('');
+ }
+ }}
+ />
+ updateVideoTitle(video.id, editingTitle)}
+ disabled={savingEditId === video.id}
+ >
+
+
+ {
+ setEditingId(null);
+ setEditingTitle('');
+ }}
+ disabled={savingEditId === video.id}
+ >
+
+
+
+ ) : (
+
+
+ {video.title}
+ {
+ setEditingId(video.id);
+ setEditingTitle(video.title);
+ }}
+ >
+
+
+
+ {video.restrictedCourseIds?.length ? (
+
+ {video.restrictedCourseIds.map((courseId) => {
+ const course = courses.find((c) => c.id === courseId);
+ const label = course?.code ?? course?.title ?? 'Course';
+ return (
+
+ {label}
+
+ );
+ })}
+
+ ) : null}
+
+ )}
+
+ {video.playlist.title}
+ {video.playlist.course.title}
+
+ toggleLocked(video.id, video.locked)}
+ disabled={togglingId === video.id}
+ aria-label="Toggle lock status"
+ />
+
+
+ toggleInstantAccess(video.id, video.instantAccess)}
+ disabled={togglingId === video.id}
+ aria-label="Toggle instant access"
+ />
+
+
+
+
router.push(`/admin/videos/${video.id}/edit`)}
+ >
+
+
+
+
+
+
+
+
+
+
+ Delete Video
+
+ Are you sure you want to delete "{video.title}"?
+
+
+
+
Cancel
+
deleteVideo(video.id)}
+ >
+ Delete
+
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ );
+}
+
+export default function VideosAdminClient() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/admin/videos/page.tsx b/app/admin/videos/page.tsx
new file mode 100644
index 0000000..b47a17e
--- /dev/null
+++ b/app/admin/videos/page.tsx
@@ -0,0 +1,22 @@
+// app/admin/videos/page.tsx
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { redirect } from 'next/navigation';
+import VideosAdminClient from './admin-client';
+
+export const dynamic = 'force-dynamic';
+
+export default async function VideosAdminPage() {
+ const session = await getServerSession(authOptions);
+ const role = (session as any)?.user?.role ?? null;
+
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ if (!(role === 'admin' || role === 'superadmin')) {
+ redirect('/dashboard');
+ }
+
+ return ;
+}
diff --git a/app/api/admin/allowed-students/import/route.ts b/app/api/admin/allowed-students/import/route.ts
new file mode 100644
index 0000000..786c107
--- /dev/null
+++ b/app/api/admin/allowed-students/import/route.ts
@@ -0,0 +1,156 @@
+// app/api/admin/allowed-students/import/route.ts
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+import { normalizeEmail } from '@/lib/normalize-email';
+
+// Check if user is admin
+async function checkAdmin(req: NextRequest) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return null;
+ }
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return null;
+ }
+
+ return session;
+}
+
+// POST import students from CSV
+export async function POST(req: NextRequest) {
+ try {
+ const session = await checkAdmin(req);
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const formData = await req.formData();
+ const file = formData.get('file') as File;
+
+ if (!file) {
+ return NextResponse.json({ error: 'No file provided' }, { status: 400 });
+ }
+
+ // Read file
+ const text = await file.text();
+ const lines = text.split('\n').filter((line) => line.trim().length > 0);
+
+ if (lines.length === 0) {
+ return NextResponse.json({ error: 'CSV file is empty' }, { status: 400 });
+ }
+
+ // Skip header row if it exists (assume first line is header if email doesn't look like email)
+ let startIndex = 0;
+ if (lines[0].toLowerCase().includes('email')) {
+ startIndex = 1;
+ }
+
+ const results = {
+ imported: 0,
+ updated: 0,
+ errors: [] as Array<{ row: number; email: string; error: string }>,
+ };
+
+ for (let i = startIndex; i < lines.length; i++) {
+ const line = lines[i].trim();
+ if (!line) continue;
+
+ const [email, levels] = line.split(',').map((v) => v.trim());
+ const normalizedEmail = normalizeEmail(email);
+
+ if (!normalizedEmail || !levels) {
+ results.errors.push({
+ row: i + 1,
+ email: email || 'N/A',
+ error: 'Invalid format: expected "email,courseCodes"',
+ });
+ continue;
+ }
+
+ // Validate email format
+ if (!email.includes('@')) {
+ results.errors.push({
+ row: i + 1,
+ email,
+ error: 'Invalid email format',
+ });
+ continue;
+ }
+
+ // Validate levels are valid course codes
+ const levelArray = levels.split('|').map((l) => l.trim());
+ let invalidLevel = null;
+
+ for (const level of levelArray) {
+ const course = await prisma.course.findUnique({ where: { code: level } });
+ if (!course) {
+ invalidLevel = level;
+ break;
+ }
+ }
+
+ if (invalidLevel) {
+ results.errors.push({
+ row: i + 1,
+ email,
+ error: `Course code '${invalidLevel}' not found`,
+ });
+ continue;
+ }
+
+ try {
+ // Check if student already exists
+ const existing = await prisma.allowedStudent.findFirst({
+ where: {
+ email: {
+ equals: normalizedEmail,
+ mode: 'insensitive',
+ },
+ },
+ });
+
+ if (existing) {
+ // Update existing
+ await prisma.allowedStudent.update({
+ where: { id: existing.id },
+ data: {
+ email: normalizedEmail,
+ levels: levelArray.join(','),
+ active: true,
+ },
+ });
+ results.updated++;
+ } else {
+ // Create new
+ await prisma.allowedStudent.create({
+ data: {
+ email: normalizedEmail,
+ levels: levelArray.join(','),
+ active: true,
+ },
+ });
+ results.imported++;
+ }
+ } catch (err) {
+ results.errors.push({
+ row: i + 1,
+ email,
+ error: `Database error: ${(err as any)?.message || 'Unknown error'}`,
+ });
+ }
+ }
+
+ return NextResponse.json(results);
+ } catch (err) {
+ console.error('POST /api/admin/allowed-students/import error', err);
+ return NextResponse.json(
+ { error: 'Failed to import students' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/allowed-students/route.ts b/app/api/admin/allowed-students/route.ts
new file mode 100644
index 0000000..e58273d
--- /dev/null
+++ b/app/api/admin/allowed-students/route.ts
@@ -0,0 +1,192 @@
+// app/api/admin/allowed-students/route.ts
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+import { normalizeEmail } from '@/lib/normalize-email';
+
+// Check if user is admin
+async function checkAdmin(req: NextRequest) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return null;
+ }
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return null;
+ }
+
+ return session;
+}
+
+// GET all allowed students
+export async function GET(req: NextRequest) {
+ try {
+ const session = await checkAdmin(req);
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const students = await prisma.allowedStudent.findMany({
+ orderBy: { createdAt: 'desc' },
+ });
+
+ return NextResponse.json(students);
+ } catch (err) {
+ console.error('GET /api/admin/allowed-students error', err);
+ return NextResponse.json(
+ { error: 'Failed to fetch allowed students' },
+ { status: 500 }
+ );
+ }
+}
+
+// POST create new allowed student
+export async function POST(req: NextRequest) {
+ try {
+ const session = await checkAdmin(req);
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const body = await req.json();
+ const { email, levels } = body;
+ const normalizedEmail = normalizeEmail(email);
+
+ if (!normalizedEmail || !levels) {
+ return NextResponse.json(
+ { error: 'Email and levels required' },
+ { status: 400 }
+ );
+ }
+
+ // Validate levels are valid course codes
+ const levelArray = levels.split(',').map((l: string) => l.trim());
+ for (const level of levelArray) {
+ const course = await prisma.course.findUnique({ where: { code: level } });
+ if (!course) {
+ return NextResponse.json(
+ { error: `Course code '${level}' not found` },
+ { status: 400 }
+ );
+ }
+ }
+
+ // Check if student already exists
+ const existing = await prisma.allowedStudent.findFirst({
+ where: {
+ email: {
+ equals: normalizedEmail,
+ mode: 'insensitive',
+ },
+ },
+ });
+
+ if (existing) {
+ return NextResponse.json(
+ { error: 'Email already registered' },
+ { status: 409 }
+ );
+ }
+
+ const student = await prisma.allowedStudent.create({
+ data: {
+ email: normalizedEmail,
+ levels: levelArray.join(','),
+ active: true,
+ },
+ });
+
+ return NextResponse.json(student, { status: 201 });
+ } catch (err) {
+ console.error('POST /api/admin/allowed-students error', err);
+ return NextResponse.json(
+ { error: 'Failed to create allowed student' },
+ { status: 500 }
+ );
+ }
+}
+
+// PUT update allowed student
+export async function PUT(req: NextRequest) {
+ try {
+ const session = await checkAdmin(req);
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const body = await req.json();
+ const { id, email, levels, active } = body;
+ const normalizedEmail = email ? normalizeEmail(email) : null;
+
+ if (!id) {
+ return NextResponse.json({ error: 'Student ID required' }, { status: 400 });
+ }
+
+ if (email && !normalizedEmail) {
+ return NextResponse.json({ error: 'Valid email required' }, { status: 400 });
+ }
+
+ // Validate levels if provided
+ if (levels) {
+ const levelArray = levels.split(',').map((l: string) => l.trim());
+ for (const level of levelArray) {
+ const course = await prisma.course.findUnique({ where: { code: level } });
+ if (!course) {
+ return NextResponse.json(
+ { error: `Course code '${level}' not found` },
+ { status: 400 }
+ );
+ }
+ }
+ }
+
+ const student = await prisma.allowedStudent.update({
+ where: { id },
+ data: {
+ ...(normalizedEmail && { email: normalizedEmail }),
+ ...(levels && { levels }),
+ ...(active !== undefined && { active }),
+ },
+ });
+
+ return NextResponse.json(student);
+ } catch (err) {
+ console.error('PUT /api/admin/allowed-students error', err);
+ return NextResponse.json(
+ { error: 'Failed to update allowed student' },
+ { status: 500 }
+ );
+ }
+}
+
+// DELETE allowed student
+export async function DELETE(req: NextRequest) {
+ try {
+ const session = await checkAdmin(req);
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(req.url);
+ const id = searchParams.get('id');
+
+ if (!id) {
+ return NextResponse.json({ error: 'Student ID required' }, { status: 400 });
+ }
+
+ await prisma.allowedStudent.delete({
+ where: { id },
+ });
+
+ return NextResponse.json({ message: 'Student removed' });
+ } catch (err) {
+ console.error('DELETE /api/admin/allowed-students error', err);
+ return NextResponse.json(
+ { error: 'Failed to delete allowed student' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/create-course/route.ts b/app/api/admin/create-course/route.ts
new file mode 100644
index 0000000..f3f7f7e
--- /dev/null
+++ b/app/api/admin/create-course/route.ts
@@ -0,0 +1,27 @@
+// app/api/admin/create-course/route.ts
+import { NextResponse } from 'next/server';
+import { prisma } from '@/lib/prisma';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+
+async function checkAdmin() {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) throw { status: 401, message: 'Unauthorized' };
+ const allowed = (process.env.ALLOWED_ADMINS || '').split(',').map(s=>s.trim()).filter(Boolean);
+ if (allowed.length && !allowed.includes(session.user.email)) throw { status: 403, message: 'Forbidden' };
+}
+
+export async function POST(req: Request) {
+ try {
+ await checkAdmin();
+ const body = await req.json();
+ const { title, code } = body;
+ if (!title) return NextResponse.json({ error: 'missing title' }, { status: 400 });
+
+ const course = await prisma.course.create({ data: { title, code } });
+ return NextResponse.json({ course });
+ } catch (err: any) {
+ console.error(err);
+ return NextResponse.json({ error: err?.message ?? 'server' }, { status: err?.status ?? 500 });
+ }
+}
diff --git a/app/api/admin/create-playlist/route.ts b/app/api/admin/create-playlist/route.ts
new file mode 100644
index 0000000..6d64e78
--- /dev/null
+++ b/app/api/admin/create-playlist/route.ts
@@ -0,0 +1,42 @@
+// app/api/admin/create-playlist/route.ts
+import { NextResponse } from 'next/server';
+import { prisma } from '@/lib/prisma';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+
+async function checkAdmin() {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) throw { status: 401, message: 'Unauthorized' };
+ const allowed = (process.env.ALLOWED_ADMINS || '').split(',').map(s=>s.trim()).filter(Boolean);
+ if (allowed.length && !allowed.includes(session.user.email)) throw { status: 403, message: 'Forbidden' };
+}
+
+export async function POST(req: Request) {
+ try {
+ await checkAdmin();
+ const { title, courseId, additionalCourseIds } = await req.json();
+ if (!title || !courseId) return NextResponse.json({ error: 'missing fields' }, { status: 400 });
+
+ const playlist = await prisma.playlist.create({
+ data: {
+ title,
+ courseId,
+ // Create CoursePlaylist mappings for additional courses
+ courses: {
+ create: (additionalCourseIds || []).map((cid: string) => ({
+ courseId: cid,
+ })),
+ },
+ },
+ include: {
+ courses: {
+ include: { course: true },
+ },
+ },
+ });
+ return NextResponse.json({ playlist });
+ } catch (err: any) {
+ console.error(err);
+ return NextResponse.json({ error: err?.message ?? 'server' }, { status: err?.status ?? 500 });
+ }
+}
diff --git a/app/api/admin/delete-course/route.ts b/app/api/admin/delete-course/route.ts
new file mode 100644
index 0000000..80675d5
--- /dev/null
+++ b/app/api/admin/delete-course/route.ts
@@ -0,0 +1,40 @@
+// app/api/admin/delete-course/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function DELETE(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ const body = await req.json().catch(() => ({}));
+ const { courseId } = body ?? {};
+
+ if (!courseId)
+ return NextResponse.json(
+ { error: 'courseId required' },
+ { status: 400 }
+ );
+
+ // Delete course (cascade will handle playlists and videos via DB constraints)
+ await prisma.course.delete({
+ where: { id: courseId },
+ });
+
+ return NextResponse.json({ success: true });
+ } catch (err: any) {
+ console.error('delete course error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/delete-playlist/route.ts b/app/api/admin/delete-playlist/route.ts
new file mode 100644
index 0000000..e7aaa64
--- /dev/null
+++ b/app/api/admin/delete-playlist/route.ts
@@ -0,0 +1,40 @@
+// app/api/admin/delete-playlist/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function DELETE(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ const body = await req.json().catch(() => ({}));
+ const { playlistId } = body ?? {};
+
+ if (!playlistId)
+ return NextResponse.json(
+ { error: 'playlistId required' },
+ { status: 400 }
+ );
+
+ // Delete playlist (cascade will handle videos via DB constraints)
+ await prisma.playlist.delete({
+ where: { id: playlistId },
+ });
+
+ return NextResponse.json({ success: true });
+ } catch (err: any) {
+ console.error('delete playlist error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/delete-video/route.ts b/app/api/admin/delete-video/route.ts
new file mode 100644
index 0000000..877d74e
--- /dev/null
+++ b/app/api/admin/delete-video/route.ts
@@ -0,0 +1,37 @@
+// app/api/admin/delete-video/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function DELETE(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ const body = await req.json().catch(() => ({}));
+ const { videoId } = body ?? {};
+
+ if (!videoId)
+ return NextResponse.json({ error: 'videoId required' }, { status: 400 });
+
+ // Delete video (cascade will handle progress records)
+ await prisma.video.delete({
+ where: { id: videoId },
+ });
+
+ return NextResponse.json({ success: true });
+ } catch (err: any) {
+ console.error('delete video error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/manage-playlist-courses/route.ts b/app/api/admin/manage-playlist-courses/route.ts
new file mode 100644
index 0000000..902b8f7
--- /dev/null
+++ b/app/api/admin/manage-playlist-courses/route.ts
@@ -0,0 +1,108 @@
+// app/api/admin/manage-playlist-courses/route.ts
+// Assign or remove a playlist from additional courses
+import { NextResponse } from 'next/server';
+import { prisma } from '@/lib/prisma';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+
+async function checkAdmin() {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) throw { status: 401, message: 'Unauthorized' };
+ const allowed = (process.env.ALLOWED_ADMINS || '').split(',').map(s=>s.trim()).filter(Boolean);
+ if (allowed.length && !allowed.includes(session.user.email)) throw { status: 403, message: 'Forbidden' };
+}
+
+// GET: Get all courses a playlist is assigned to
+export async function GET(req: Request) {
+ try {
+ await checkAdmin();
+ const { searchParams } = new URL(req.url);
+ const playlistId = searchParams.get('playlistId');
+
+ if (!playlistId) return NextResponse.json({ error: 'playlistId required' }, { status: 400 });
+
+ const playlist = await prisma.playlist.findUnique({
+ where: { id: playlistId },
+ include: {
+ course: true,
+ courses: {
+ include: { course: true },
+ },
+ },
+ });
+
+ if (!playlist) return NextResponse.json({ error: 'Playlist not found' }, { status: 404 });
+
+ // Return primary course + all additional courses
+ const allCourses = [
+ playlist.course,
+ ...playlist.courses.map(cp => cp.course),
+ ];
+
+ return NextResponse.json({ playlist, allCourses });
+ } catch (err: any) {
+ console.error(err);
+ return NextResponse.json({ error: err?.message ?? 'server' }, { status: err?.status ?? 500 });
+ }
+}
+
+// POST: Assign playlist to an additional course
+export async function POST(req: Request) {
+ try {
+ await checkAdmin();
+ const { playlistId, courseId } = await req.json();
+
+ if (!playlistId || !courseId) return NextResponse.json({ error: 'missing fields' }, { status: 400 });
+
+ // Check if already assigned
+ const existing = await prisma.coursePlaylist.findFirst({
+ where: { playlistId, courseId },
+ });
+
+ if (existing) return NextResponse.json({ error: 'Already assigned' }, { status: 400 });
+
+ const assignment = await prisma.coursePlaylist.create({
+ data: { playlistId, courseId },
+ include: { course: true },
+ });
+
+ return NextResponse.json({ assignment });
+ } catch (err: any) {
+ console.error(err);
+ return NextResponse.json({ error: err?.message ?? 'server' }, { status: err?.status ?? 500 });
+ }
+}
+
+// DELETE: Remove playlist from a course
+export async function DELETE(req: Request) {
+ try {
+ await checkAdmin();
+ const { searchParams } = new URL(req.url);
+ const playlistId = searchParams.get('playlistId');
+ const courseId = searchParams.get('courseId');
+
+ if (!playlistId || !courseId) return NextResponse.json({ error: 'missing fields' }, { status: 400 });
+
+ // Don't allow deleting the primary course assignment
+ const playlist = await prisma.playlist.findUnique({
+ where: { id: playlistId },
+ });
+
+ if (!playlist) return NextResponse.json({ error: 'Playlist not found' }, { status: 404 });
+
+ if (playlist.courseId === courseId) {
+ return NextResponse.json({ error: 'Cannot remove primary course' }, { status: 400 });
+ }
+
+ await prisma.coursePlaylist.delete({
+ where: {
+ courseId_playlistId: { playlistId, courseId },
+ },
+ });
+
+ return NextResponse.json({ success: true });
+ } catch (err: any) {
+ console.error(err);
+ return NextResponse.json({ error: err?.message ?? 'server' }, { status: err?.status ?? 500 });
+ }
+}
diff --git a/app/api/admin/meta/route.ts b/app/api/admin/meta/route.ts
new file mode 100644
index 0000000..73ed1aa
--- /dev/null
+++ b/app/api/admin/meta/route.ts
@@ -0,0 +1,19 @@
+// app/api/admin/meta/route.ts
+import { NextResponse } from 'next/server';
+import { prisma } from '@/lib/prisma';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+
+export async function GET() {
+ // Basic protection: ensure signed-in user is admin. Modify as needed.
+ // If you want stricter, get session from context or check roles.
+ // Here we skip session check to let dev access; but production should check.
+ try {
+ const courses = await prisma.course.findMany({ orderBy: { title: 'asc' } });
+ const playlists = await prisma.playlist.findMany({ orderBy: { title: 'asc' } });
+ return NextResponse.json({ courses, playlists });
+ } catch (err) {
+ console.error(err);
+ return NextResponse.json({ error: 'server' }, { status: 500 });
+ }
+}
diff --git a/app/api/admin/notifications/route.ts b/app/api/admin/notifications/route.ts
new file mode 100644
index 0000000..541bdcd
--- /dev/null
+++ b/app/api/admin/notifications/route.ts
@@ -0,0 +1,168 @@
+// app/api/admin/notifications/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ // Get current user
+ const user = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ select: { id: true },
+ });
+
+ if (!user) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ // Get all videos uploaded by this admin or superadmin
+ const uploadedVideos = await prisma.video.findMany({
+ where: { userId: user.id },
+ select: { id: true, title: true },
+ });
+
+ const videoIds = uploadedVideos.map((v) => v.id);
+
+ if (videoIds.length === 0) {
+ return NextResponse.json({
+ likes: [],
+ comments: [],
+ total: 0,
+ });
+ }
+
+ // Fetch recent likes on uploaded videos
+ const likes = await prisma.videoLike.findMany({
+ where: { videoId: { in: videoIds } },
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ image: true,
+ email: true,
+ },
+ },
+ video: {
+ select: {
+ id: true,
+ title: true,
+ },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ take: 50,
+ });
+
+ // Fetch recent comments on uploaded videos
+ const comments = await prisma.comment.findMany({
+ where: { videoId: { in: videoIds } },
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ image: true,
+ email: true,
+ },
+ },
+ video: {
+ select: {
+ id: true,
+ title: true,
+ },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ take: 50,
+ });
+
+ // Fetch recently created courses by this user
+ const createdCourses = await prisma.course.findMany({
+ where: { userId: user.id },
+ select: {
+ id: true,
+ title: true,
+ code: true,
+ createdAt: true,
+ },
+ orderBy: { createdAt: 'desc' },
+ take: 50,
+ });
+
+ // Fetch recently created playlists by this user
+ const createdPlaylists = await prisma.playlist.findMany({
+ where: { userId: user.id },
+ include: {
+ course: {
+ select: {
+ id: true,
+ title: true,
+ },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ take: 50,
+ });
+
+ // Combine and sort by date
+ const allNotifications = [
+ ...likes.map((like) => ({
+ id: like.id,
+ type: 'like' as const,
+ user: like.user,
+ video: like.video,
+ content: null,
+ createdAt: like.createdAt,
+ })),
+ ...comments.map((comment) => ({
+ id: comment.id,
+ type: 'comment' as const,
+ user: comment.user,
+ video: comment.video,
+ content: comment.content,
+ createdAt: comment.createdAt,
+ })),
+ ...createdCourses.map((course) => ({
+ id: course.id,
+ type: 'course_created' as const,
+ user: null,
+ course: { id: course.id, title: course.title, code: course.code },
+ content: null,
+ createdAt: course.createdAt,
+ })),
+ ...createdPlaylists.map((playlist) => ({
+ id: playlist.id,
+ type: 'playlist_created' as const,
+ user: null,
+ playlist: { id: playlist.id, title: playlist.title, courseTitle: playlist.course.title },
+ content: null,
+ createdAt: playlist.createdAt,
+ })),
+ ].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
+
+ return NextResponse.json({
+ likes,
+ comments,
+ notifications: allNotifications,
+ total: allNotifications.length,
+ });
+ } catch (err: any) {
+ console.error('get notifications error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/playlist-videos/route.ts b/app/api/admin/playlist-videos/route.ts
new file mode 100644
index 0000000..c34d9cb
--- /dev/null
+++ b/app/api/admin/playlist-videos/route.ts
@@ -0,0 +1,34 @@
+// app/api/admin/playlist-videos/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const { searchParams } = new URL(req.url);
+ const playlistId = searchParams.get('playlistId');
+ if (!playlistId)
+ return NextResponse.json({ error: 'playlistId required' }, { status: 400 });
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ const videos = await prisma.video.findMany({
+ where: { playlistId },
+ select: { id: true, title: true, thumbnail: true, index: true, durationSec: true },
+ orderBy: { index: 'asc' },
+ });
+
+ return NextResponse.json(videos);
+ } catch (err: any) {
+ console.error('playlist videos GET error', err);
+ return NextResponse.json({ error: err?.message ?? 'server error' }, { status: 500 });
+ }
+}
diff --git a/app/api/admin/reorder-videos/route.ts b/app/api/admin/reorder-videos/route.ts
new file mode 100644
index 0000000..b13240d
--- /dev/null
+++ b/app/api/admin/reorder-videos/route.ts
@@ -0,0 +1,55 @@
+// app/api/admin/reorder-videos/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function POST(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ const body = await req.json().catch(() => ({}));
+ const { playlistId, orderedIds } = body ?? {};
+ if (!playlistId || !Array.isArray(orderedIds))
+ return NextResponse.json({ error: 'playlistId and orderedIds required' }, { status: 400 });
+
+ // Validate that all video ids belong to the playlist (optional)
+ const vids = await prisma.video.findMany({ where: { playlistId }, select: { id: true } });
+ const validIds = new Set(vids.map((v) => v.id));
+ const invalid = orderedIds.find((id: string) => !validIds.has(id));
+ if (invalid)
+ return NextResponse.json({ error: 'Invalid video id in orderedIds' }, { status: 400 });
+
+ // Use two-phase update to avoid unique constraint violation:
+ // Phase 1: Set all indices to negative temporary values
+ // Phase 2: Set to final positive values
+ await prisma.$transaction([
+ // Phase 1: Set temporary negative indices to avoid conflicts
+ ...orderedIds.map((id: string, idx: number) =>
+ prisma.video.update({
+ where: { id },
+ data: { index: -(idx + 1) }, // Use negative values: -1, -2, -3, etc.
+ })
+ ),
+ // Phase 2: Set to final indices
+ ...orderedIds.map((id: string, idx: number) =>
+ prisma.video.update({
+ where: { id },
+ data: { index: idx },
+ })
+ ),
+ ]);
+
+ return NextResponse.json({ success: true });
+ } catch (err: any) {
+ console.error('reorder videos error', err);
+ return NextResponse.json({ error: err?.message ?? 'server error' }, { status: 500 });
+ }
+}
diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts
new file mode 100644
index 0000000..345fab8
--- /dev/null
+++ b/app/api/admin/stats/route.ts
@@ -0,0 +1,106 @@
+// app/api/admin/stats/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+import { Prisma } from '@prisma/client';
+
+export async function GET(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ // Fetch all videos with aggregated stats using raw queries for better performance
+ const videos = await prisma.video.findMany({
+ include: {
+ playlist: {
+ select: {
+ title: true,
+ },
+ },
+ uploader: {
+ select: {
+ id: true,
+ name: true,
+ },
+ },
+ _count: {
+ select: {
+ likes: true,
+ comments: true,
+ unlocks: true,
+ },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ });
+
+ // Fetch detailed stats for each video using raw queries for better performance
+ const statsData = await prisma.$queryRaw<
+ Array<{
+ videoId: string;
+ totalViewers: number;
+ totalSecondsWatched: bigint;
+ avgPercentWatched: number | null;
+ totalSegments: number;
+ completionCount: number;
+ }>
+ >`
+ SELECT
+ p."videoId",
+ COUNT(DISTINCT p."userId") as "totalViewers",
+ CAST(COALESCE(SUM(p."watchedSec"), 0) AS BIGINT) as "totalSecondsWatched",
+ ROUND(CAST(AVG(p."percent") AS NUMERIC), 2) as "avgPercentWatched",
+ (SELECT COUNT(*) FROM "VideoWatchSegment" ws WHERE ws."videoId" = p."videoId") as "totalSegments",
+ COUNT(CASE WHEN p."completed" = true THEN 1 END) as "completionCount"
+ FROM "VideoProgress" p
+ GROUP BY p."videoId"
+ `;
+
+ // Create a map for quick lookup
+ const statsMap = new Map(statsData.map(item => [item.videoId, item]));
+
+ // Combine video data with stats
+ const videosWithStats = videos.map(video => {
+ const stats = statsMap.get(video.id);
+ const views = video._count.unlocks || 0;
+ const totalViewers = stats ? Number(stats.totalViewers) : 0;
+ const completions = stats ? Number(stats.completionCount) : 0;
+ const engagement = (video._count.likes || 0) + (video._count.comments || 0);
+ const avgWatched = stats?.avgPercentWatched ? Number(stats.avgPercentWatched) : 0;
+
+ return {
+ id: video.id,
+ title: video.title,
+ playlistTitle: video.playlist.title,
+ uploaderName: video.uploader?.name || 'Unknown',
+ views,
+ totalViewers,
+ completions,
+ completionRate: totalViewers > 0 ? ((completions / totalViewers) * 100).toFixed(2) : '0.00',
+ likes: video._count.likes,
+ comments: video._count.comments,
+ engagement,
+ avgPercentWatched: avgWatched,
+ totalSecondsWatched: stats ? Number(stats.totalSecondsWatched) : 0,
+ totalSegments: stats ? Number(stats.totalSegments) : 0,
+ durationSec: video.durationSec,
+ createdAt: video.createdAt,
+ };
+ });
+
+ return NextResponse.json(videosWithStats);
+ } catch (err: any) {
+ console.error('get stats error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/update-playlist/route.ts b/app/api/admin/update-playlist/route.ts
new file mode 100644
index 0000000..aacde0d
--- /dev/null
+++ b/app/api/admin/update-playlist/route.ts
@@ -0,0 +1,48 @@
+// app/api/admin/update-playlist/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function PATCH(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ const { playlistId, title } = await req.json();
+
+ if (!playlistId) {
+ return NextResponse.json(
+ { error: 'playlistId required' },
+ { status: 400 }
+ );
+ }
+
+ if (!title || typeof title !== 'string' || title.trim().length === 0) {
+ return NextResponse.json(
+ { error: 'title required and must be non-empty' },
+ { status: 400 }
+ );
+ }
+
+ const updatedPlaylist = await prisma.playlist.update({
+ where: { id: playlistId },
+ data: { title: title.trim() },
+ });
+
+ return NextResponse.json(updatedPlaylist, { status: 200 });
+ } catch (err: any) {
+ console.error('update playlist error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/update-user-role/route.ts b/app/api/admin/update-user-role/route.ts
new file mode 100644
index 0000000..f9a7127
--- /dev/null
+++ b/app/api/admin/update-user-role/route.ts
@@ -0,0 +1,77 @@
+// app/api/admin/update-user-role/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function PATCH(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ // Only superadmins can assign roles
+ const role = (session as any)?.user?.role ?? null;
+ if (role !== 'superadmin') {
+ return NextResponse.json(
+ { error: 'Only superadmins can assign roles' },
+ { status: 403 }
+ );
+ }
+
+ const body = await req.json();
+ const { userId, newRole } = body;
+
+ if (!userId || !newRole) {
+ return NextResponse.json(
+ { error: 'userId and newRole required' },
+ { status: 400 }
+ );
+ }
+
+ // Validate newRole
+ const validRoles = ['user', 'admin', 'superadmin'];
+ if (!validRoles.includes(newRole)) {
+ return NextResponse.json(
+ { error: `Invalid role. Must be one of: ${validRoles.join(', ')}` },
+ { status: 400 }
+ );
+ }
+
+ // Prevent self-demotion from superadmin
+ const currentUser = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ });
+
+ if (currentUser?.id === userId && newRole !== 'superadmin') {
+ return NextResponse.json(
+ { error: 'Cannot demote yourself from superadmin' },
+ { status: 400 }
+ );
+ }
+
+ // Update user role
+ const updatedUser = await prisma.user.update({
+ where: { id: userId },
+ data: { role: newRole },
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ role: true,
+ },
+ });
+
+ return NextResponse.json({
+ message: 'User role updated successfully',
+ user: updatedUser,
+ });
+ } catch (err: any) {
+ console.error('update user role error:', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/update-video/route.ts b/app/api/admin/update-video/route.ts
new file mode 100644
index 0000000..11ad84a
--- /dev/null
+++ b/app/api/admin/update-video/route.ts
@@ -0,0 +1,141 @@
+// app/api/admin/update-video/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+import { writeFile, mkdir } from 'fs/promises';
+import { join } from 'path';
+
+export async function PATCH(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ const { videoId, title } = await req.json();
+
+ if (!videoId) {
+ return NextResponse.json(
+ { error: 'videoId required' },
+ { status: 400 }
+ );
+ }
+
+ if (!title || typeof title !== 'string' || title.trim().length === 0) {
+ return NextResponse.json(
+ { error: 'title required and must be non-empty' },
+ { status: 400 }
+ );
+ }
+
+ const updatedVideo = await prisma.video.update({
+ where: { id: videoId },
+ data: { title: title.trim() },
+ });
+
+ return NextResponse.json(updatedVideo, { status: 200 });
+ } catch (err: any) {
+ console.error('update video error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
+
+export async function POST(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ const formData = await req.formData().catch(() => null);
+ if (!formData) {
+ return NextResponse.json({ error: 'Invalid form data' }, { status: 400 });
+ }
+
+ const videoId = formData.get('videoId') as string;
+ const title = formData.get('title') as string;
+ const description = formData.get('description') as string | null;
+ const thumbnail = formData.get('thumbnail') as File | null;
+ const restricted = formData.get('restrictedCourseIds') as string | null;
+
+ if (!videoId)
+ return NextResponse.json({ error: 'videoId required' }, { status: 400 });
+
+ // Build update data
+ const updateData: any = {};
+ if (title) updateData.title = title;
+ if (description !== null) updateData.description = description;
+
+ let restrictedCourseIds: string[] | null = null;
+ if (restricted) {
+ try {
+ const parsed = JSON.parse(restricted);
+ if (Array.isArray(parsed)) {
+ restrictedCourseIds = parsed.filter((id) => typeof id === 'string' && id);
+ }
+ } catch (err) {
+ console.warn('Invalid restrictedCourseIds payload', err);
+ }
+ }
+
+ // Handle thumbnail upload if provided
+ if (thumbnail && thumbnail.size > 0) {
+ const bytes = await thumbnail.arrayBuffer();
+ const buffer = Buffer.from(bytes);
+
+ // Save to public/thumbnails
+ const uploadDir = join(process.cwd(), 'public', 'thumbnails');
+ await mkdir(uploadDir, { recursive: true });
+
+ const filename = `${videoId}-${Date.now()}.${thumbnail.type.split('/')[1] || 'jpg'}`;
+ const filepath = join(uploadDir, filename);
+ await writeFile(filepath, buffer);
+
+ updateData.thumbnail = `/thumbnails/${filename}`;
+ }
+
+ if (Object.keys(updateData).length === 0 && !thumbnail && restrictedCourseIds === null) {
+ return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
+ }
+
+ const updated = await prisma.video.update({
+ where: { id: videoId },
+ data: updateData,
+ });
+
+ if (restrictedCourseIds !== null) {
+ await prisma.videoCourse.deleteMany({ where: { videoId } });
+ if (restrictedCourseIds.length > 0) {
+ await prisma.videoCourse.createMany({
+ data: restrictedCourseIds.map((courseId) => ({
+ videoId,
+ courseId,
+ exclusive: true,
+ })),
+ skipDuplicates: true,
+ });
+ }
+ }
+
+ return NextResponse.json({ success: true, video: updated });
+ } catch (err: any) {
+ console.error('update video error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/upload/finalize/route.ts b/app/api/admin/upload/finalize/route.ts
new file mode 100644
index 0000000..da6d925
--- /dev/null
+++ b/app/api/admin/upload/finalize/route.ts
@@ -0,0 +1,150 @@
+// app/api/admin/upload/finalize/route.ts
+import { NextResponse } from "next/server";
+import fs from "fs";
+import path from "path";
+import { spawn } from "child_process";
+import { prisma } from "@/lib/prisma";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth-options";
+
+const UPLOADS_DIR = process.env.UPLOADS_DIR || "/uploads";
+
+async function getVideoDurationFromFile(filePath: string): Promise {
+ return new Promise((resolve) => {
+ try {
+ const ffprobe = spawn("ffprobe", [
+ "-v", "error",
+ "-show_entries", "format=duration",
+ "-of", "default=noprint_wrappers=1:nokey=1:nokey=1",
+ filePath,
+ ]);
+
+ let output = "";
+ let timedOut = false;
+
+ const timeoutId = setTimeout(() => {
+ timedOut = true;
+ ffprobe.kill();
+ resolve(null);
+ }, 30000);
+
+ ffprobe.stdout.on("data", (data) => {
+ output += data.toString();
+ });
+
+ ffprobe.on("close", (code) => {
+ clearTimeout(timeoutId);
+ if (!timedOut && code === 0) {
+ const duration = parseFloat(output.trim());
+ if (!isNaN(duration) && isFinite(duration) && duration > 0) {
+ resolve(Math.round(duration));
+ } else {
+ resolve(null);
+ }
+ } else {
+ resolve(null);
+ }
+ });
+
+ ffprobe.on("error", () => {
+ clearTimeout(timeoutId);
+ resolve(null);
+ });
+ } catch (err) {
+ resolve(null);
+ }
+ });
+}
+
+async function checkAdmin() {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) throw { status: 401, message: "Unauthorized" };
+ const allowed = (process.env.ALLOWED_ADMINS || "")
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean);
+ if (allowed.length && !allowed.includes(session.user.email))
+ throw { status: 403, message: "Forbidden" };
+ return session;
+}
+
+export async function POST(req: Request) {
+ try {
+ console.log('[Upload/Finalize] Request received');
+
+ await checkAdmin();
+
+ const body = await req.json();
+ const { videoId } = body;
+
+ if (!videoId) {
+ return NextResponse.json({ error: "videoId required" }, { status: 400 });
+ }
+
+ console.log('[Upload/Finalize] Checking for video:', videoId);
+
+ // Check if video exists in DB
+ const video = await prisma.video.findUnique({
+ where: { id: videoId },
+ });
+
+ if (!video) {
+ return NextResponse.json({ error: "video not found" }, { status: 404 });
+ }
+
+ // Check if file exists at the expected path
+ const videoPath = path.join(UPLOADS_DIR, "videos", `${videoId}.mp4`);
+ console.log('[Upload/Finalize] Checking file at:', videoPath);
+
+ try {
+ await fs.promises.access(videoPath, fs.constants.F_OK);
+ } catch {
+ return NextResponse.json(
+ { error: `File not found at ${videoPath}. Please copy the file there first.` },
+ { status: 404 }
+ );
+ }
+
+ console.log('[Upload/Finalize] File found, extracting duration');
+
+ // Extract duration if not already set
+ let durationSec = video.durationSec;
+ if (!durationSec) {
+ try {
+ const extractedDuration = await getVideoDurationFromFile(videoPath);
+ if (extractedDuration !== null) {
+ durationSec = extractedDuration;
+ console.log('[Upload/Finalize] Extracted duration:', durationSec);
+ } else {
+ console.warn('[Upload/Finalize] Could not extract duration');
+ }
+ } catch (err) {
+ console.warn('[Upload/Finalize] Duration extraction error:', err);
+ }
+ }
+
+ // Update video: set URL, duration, and transcoding status to trigger processing
+ const videoUrl = `/uploads/videos/${videoId}.mp4`;
+ const updatedVideo = await prisma.video.update({
+ where: { id: videoId },
+ data: {
+ url: videoUrl,
+ transcodingStatus: 'uploaded', // Mark as ready for transcoding
+ ...(durationSec !== null && { durationSec }),
+ },
+ });
+
+ console.log('[Upload/Finalize] Video updated successfully with URL:', videoUrl);
+
+ return NextResponse.json({
+ success: true,
+ video: updatedVideo,
+ message: `Video finalized${durationSec ? ` (${durationSec}s)` : ''}. HLS transcoding will start shortly.`,
+ }, { status: 200 });
+ } catch (err: any) {
+ console.error("[Upload/Finalize] Error:", err);
+ const status = err?.status ?? 500;
+ const message = err?.message ?? "server error";
+ return NextResponse.json({ error: message }, { status });
+ }
+}
diff --git a/app/api/admin/upload/route.ts b/app/api/admin/upload/route.ts
new file mode 100644
index 0000000..7e73a5a
--- /dev/null
+++ b/app/api/admin/upload/route.ts
@@ -0,0 +1,394 @@
+// app/api/admin/upload/route.ts
+import { NextResponse } from "next/server";
+import fs from "fs";
+import path from "path";
+import { IncomingMessage } from "http";
+import { spawn } from "child_process";
+import { pipeline } from "stream/promises";
+import formidable, { File as FormidableFile } from "formidable";
+import { prisma } from "@/lib/prisma";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth-options";
+
+// For 2GB uploads over Tailscale (~10-50 Mbps), need 30-45 minutes
+export const maxDuration = 2700; // 45 minutes for very large uploads over slow connections
+
+// Use ConfigurableUPLOADS_DIR from environment or default to /uploads
+const UPLOADS_DIR = process.env.UPLOADS_DIR || "/uploads";
+
+// Helper to extract video duration using FFprobe
+async function getVideoDurationFromFile(filePath: string): Promise {
+ return new Promise((resolve) => {
+ try {
+ const ffprobe = spawn("ffprobe", [
+ "-v", "error",
+ "-show_entries", "format=duration",
+ "-of", "default=noprint_wrappers=1:nokey=1:nokey=1",
+ filePath,
+ ]);
+
+ let output = "";
+ let timedOut = false;
+
+ // Large 2GB files may take longer to scan; timeout after 30s
+ const timeoutId = setTimeout(() => {
+ timedOut = true;
+ ffprobe.kill();
+ resolve(null); // Return null instead of blocking
+ }, 30000);
+
+ ffprobe.stdout.on("data", (data) => {
+ output += data.toString();
+ });
+
+ ffprobe.on("close", (code) => {
+ clearTimeout(timeoutId);
+ if (!timedOut && code === 0) {
+ const duration = parseFloat(output.trim());
+ if (!isNaN(duration) && isFinite(duration) && duration > 0) {
+ resolve(Math.round(duration));
+ } else {
+ resolve(null);
+ }
+ } else {
+ resolve(null);
+ }
+ });
+
+ ffprobe.on("error", () => {
+ clearTimeout(timeoutId);
+ resolve(null);
+ });
+ } catch (err) {
+ resolve(null);
+ }
+ });
+}
+
+function parseForm(req: IncomingMessage): Promise<{ fields: any; files: any }> {
+ const form = formidable({
+ multiples: false,
+ maxFileSize: 2.5 * 1024 * 1024 * 1024, // 2.5GB max file size
+ maxFieldsSize: 10 * 1024 * 1024, // 10MB for all fields combined
+ maxFields: 50,
+ keepExtensions: true,
+ });
+ return new Promise((resolve, reject) => {
+ form.parse(req, (err, fields, files) => {
+ if (err) {
+ console.error('[Upload] Formidable parse error:', err.code, err.message);
+ reject(err);
+ }
+ else resolve({ fields, files });
+ });
+ });
+}
+
+async function checkAdmin() {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) throw { status: 401, message: "Unauthorized" };
+ const allowed = (process.env.ALLOWED_ADMINS || "")
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean);
+ if (allowed.length && !allowed.includes(session.user.email))
+ throw { status: 403, message: "Forbidden" };
+ return session;
+}
+
+export async function POST(req: Request) {
+ // track saved paths so we can cleanup on error
+ let savedVideoDest: string | undefined;
+ let savedThumbDest: string | undefined;
+
+ try {
+ console.log('[Upload] Request received, content-type:', req.headers.get("content-type"));
+
+ const nodeReq = (req as any).req ?? (globalThis as any).__NEXT_INIT?.req ?? null;
+ const session = await checkAdmin();
+
+ console.log('[Upload] Admin check passed for:', session.user?.email);
+
+ // helper to stream a File directly to disk (handles large files efficiently)
+ async function saveVideoStream(file: File, origName?: string) {
+ const filename = `${Date.now()}-${String(origName ?? "upload.mp4")}`;
+ const dest = path.join(UPLOADS_DIR, "videos", filename);
+
+ // Ensure directory exists
+ await fs.promises.mkdir(path.dirname(dest), { recursive: true });
+
+ // Create write stream to destination
+ const writeStream = fs.createWriteStream(dest);
+
+ try {
+ // Stream the file directly to disk without buffering
+ // file.stream() returns a Web ReadableStream, convert to Node.js stream
+ const nodeStream = file.stream() as any;
+ await pipeline(nodeStream, writeStream);
+
+ // Ensure the file is readable by all processes (mode 644)
+ await fs.promises.chmod(dest, 0o644);
+
+ return { filename, dest };
+ } catch (err) {
+ // Clean up the partially written file if stream fails
+ try {
+ await fs.promises.unlink(dest);
+ } catch (_) {}
+ throw err;
+ }
+ }
+
+ // helper to save a thumbnail buffer to UPLOADS_DIR/thumbnails
+ async function saveThumbBuffer(buffer: Buffer, ext = "jpg") {
+ const filename = `${Date.now()}-thumb.${ext.replace(/^\./, "")}`;
+ const dest = path.join(UPLOADS_DIR, "thumbnails", filename);
+ await fs.promises.mkdir(path.dirname(dest), { recursive: true });
+ await fs.promises.writeFile(dest, buffer);
+ // Ensure the file is readable by all processes (mode 644)
+ await fs.promises.chmod(dest, 0o644);
+ return { filename, dest };
+ }
+
+ let title: string | undefined;
+ let playlistId: string | undefined;
+ let durationSec: number | null = null;
+ let savedFilename: string | undefined;
+
+ // **IMPORTANT**: single thumbnailUrl used in both branches
+ let thumbnailUrl: string | null = null;
+
+ if (!nodeReq) {
+ // Request.formData() flow (some Next.js environments)
+ let formData: FormData | null = null;
+ try {
+ formData = await req.formData();
+ } catch (e: any) {
+ console.error("Failed to parse body as FormData.", e, "content-type=", req.headers.get("content-type"));
+ return NextResponse.json(
+ { error: "Failed to parse body as FormData. Ensure request is sent with multipart/form-data." },
+ { status: 400 }
+ );
+ }
+
+ const isManualCopy = formData.get("manualFileCopy") === "true";
+ const file = isManualCopy ? null : (formData.get("file") as Blob | null);
+ title = (formData.get("title") as string) || undefined;
+ playlistId = (formData.get("playlistId") as string) || undefined;
+ const durationField = formData.get("durationSec") as string | null;
+ if (durationField) {
+ const n = Number(durationField);
+ if (!isNaN(n)) durationSec = Math.round(n);
+ }
+
+ // thumbnail (optional)
+ const thumb = formData.get("thumbnail") as Blob | null;
+ if (thumb) {
+ try {
+ const thumbArrayBuffer = await thumb.arrayBuffer();
+ const thumbBuffer = Buffer.from(thumbArrayBuffer);
+ // try to infer extension from name, fallback to jpg
+ const fName = (thumb as any).name ?? "";
+ const extMatch = fName.match(/\.([a-z0-9]+)$/i);
+ const ext = extMatch ? extMatch[1] : "jpg";
+ const saved = await saveThumbBuffer(thumbBuffer, ext);
+ thumbnailUrl = `/api/thumbnails/${saved.filename}`;
+ savedThumbDest = saved.dest;
+ } catch (err) {
+ console.warn("thumbnail save failed (formData)", err);
+ // not fatal — we simply leave thumbnailUrl null
+ }
+ }
+
+ if (!isManualCopy) {
+ if (!file) return NextResponse.json({ error: "no file" }, { status: 400 });
+
+ // Stream the large file directly to disk without buffering
+ const origName = (file as any).name ?? `upload-${Date.now()}.mp4`;
+ const saved = await saveVideoStream(file as File, origName);
+ savedFilename = saved.filename;
+ savedVideoDest = saved.dest;
+ } else {
+ // Manual copy mode: don't save file, just mark for manual copy
+ console.log('[Upload] Manual file copy mode enabled');
+ savedFilename = undefined;
+ savedVideoDest = undefined;
+ }
+ } else {
+ // formidable flow (Node IncomingMessage available)
+ console.log('[Upload] Using formidable flow for file upload');
+ const { fields, files } = await parseForm(nodeReq as IncomingMessage);
+ console.log('[Upload] Formidable parsing complete, fields:', Object.keys(fields), 'files:', Object.keys(files));
+
+ const f: FormidableFile | undefined =
+ (files && (files.file as FormidableFile)) || (files && Object.values(files)[0]);
+ if (!f) return NextResponse.json({ error: "no file found" }, { status: 400 });
+
+ // handle thumbnail file if present in formidable files
+ const thumbFile = (files && (files.thumbnail as FormidableFile)) || undefined;
+ if (thumbFile) {
+ try {
+ const tPath = (thumbFile as any).filepath || (thumbFile as any).path;
+ const originalThumbName = (thumbFile as any).originalFilename || path.basename(tPath);
+ const tExt = path.extname(originalThumbName) || ".jpg";
+ const thumbFilename = `${Date.now()}-thumb${tExt}`;
+ const thumbDest = path.join(UPLOADS_DIR, "thumbnails", thumbFilename);
+ await fs.promises.mkdir(path.dirname(thumbDest), { recursive: true });
+ await fs.promises.copyFile(tPath, thumbDest);
+ // Ensure the file is readable by all processes (mode 644)
+ await fs.promises.chmod(thumbDest, 0o644);
+ thumbnailUrl = `/api/thumbnails/${thumbFilename}`;
+ savedThumbDest = thumbDest;
+ } catch (err) {
+ console.warn("thumbnail save failed (formidable)", err);
+ }
+ }
+
+ // copy video file to UPLOADS_DIR/videos
+ const filePath = (f as any).filepath || (f as any).path;
+ const originalFilename = (f as any).originalFilename || (f as any).name || path.basename(filePath);
+ const filename = `${Date.now()}-${originalFilename}`;
+ const dest = path.join(UPLOADS_DIR, "videos", filename);
+
+ console.log('[Upload] Copying video file', { size: (f as any).size, path: filePath, dest });
+
+ await fs.promises.mkdir(path.dirname(dest), { recursive: true });
+ await fs.promises.copyFile(filePath, dest);
+ // Ensure the file is readable by all processes (mode 644)
+ await fs.promises.chmod(dest, 0o644);
+
+ console.log('[Upload] Video file copied successfully');
+
+ title = fields.title ?? originalFilename;
+ playlistId = fields.playlistId;
+ const durationField = fields.durationSec ?? fields.duration ?? null;
+ if (durationField) {
+ const n = Number(durationField);
+ if (!isNaN(n)) durationSec = Math.round(n);
+ }
+
+ savedFilename = filename;
+ savedVideoDest = dest;
+ }
+
+ // validate playlist: avoid FK errors
+ if (!playlistId) {
+ // cleanup if needed
+ if (savedVideoDest) await fs.promises.unlink(savedVideoDest).catch(() => {});
+ if (savedThumbDest) await fs.promises.unlink(savedThumbDest).catch(() => {});
+ return NextResponse.json({ error: "playlistId required" }, { status: 400 });
+ }
+
+ const playlist = await prisma.playlist.findUnique({ where: { id: playlistId } });
+ if (!playlist) {
+ if (savedVideoDest) await fs.promises.unlink(savedVideoDest).catch(() => {});
+ if (savedThumbDest) await fs.promises.unlink(savedThumbDest).catch(() => {});
+ return NextResponse.json({ error: "playlist not found" }, { status: 400 });
+ }
+
+ // Get current user
+ const user = await prisma.user.findUnique({
+ where: { email: session.user?.email ?? "" },
+ select: { id: true },
+ });
+ if (!user) {
+ if (savedVideoDest) await fs.promises.unlink(savedVideoDest).catch(() => {});
+ if (savedThumbDest) await fs.promises.unlink(savedThumbDest).catch(() => {});
+ return NextResponse.json({ error: "user not found" }, { status: 400 });
+ }
+
+ // If duration not provided by client, try to extract it from the saved video file
+ if (durationSec === null && savedVideoDest) {
+ try {
+ const extractedDuration = await getVideoDurationFromFile(savedVideoDest);
+ if (extractedDuration !== null) {
+ durationSec = extractedDuration;
+ console.log(`[Upload] Extracted duration: ${durationSec}s from ${savedVideoDest}`);
+ } else {
+ console.warn(`[Upload] Could not extract duration from ${savedVideoDest}`);
+ }
+ } catch (err) {
+ console.warn(`[Upload] Duration extraction error:`, err);
+ }
+ }
+
+ // For manual copy mode, check if we have a file
+ const isManualMode = !savedVideoDest;
+
+ // Calculate index: find max index in playlist and add 1
+ const maxIndexVideo = await prisma.video.findFirst({
+ where: { playlistId },
+ orderBy: { index: 'desc' },
+ select: { index: true },
+ });
+ const desiredIndex = (maxIndexVideo?.index ?? -1) + 1;
+
+ // First, create the video record to get its ID
+ const video = await prisma.video.create({
+ data: {
+ title: title ?? savedFilename ?? 'Untitled',
+ url: '', // Will be set below or after rename
+ thumbnail: thumbnailUrl,
+ index: desiredIndex,
+ playlistId,
+ userId: user.id,
+ transcodingStatus: isManualMode ? 'pending_manual_file' : 'uploaded',
+ ...(durationSec !== null && { durationSec }),
+ },
+ });
+
+ // If manual copy mode, set the URL and return
+ if (isManualMode) {
+ const videoUrl = `/uploads/videos/${video.id}.mp4`;
+ await prisma.video.update({
+ where: { id: video.id },
+ data: { url: videoUrl },
+ });
+ console.log('[Upload] Manual copy mode: video created with ID', video.id, 'URL:', videoUrl);
+ return NextResponse.json({ video: { ...video, url: videoUrl } }, { status: 201 });
+ }
+
+ // Now rename the uploaded file to use the video ID
+ const finalFilename = `${video.id}.mp4`;
+ const finalDest = path.join(UPLOADS_DIR, "videos", finalFilename);
+ try {
+ if (savedVideoDest) {
+ await fs.promises.rename(savedVideoDest, finalDest);
+ }
+ // Update the URL in the database to reflect the final filename
+ await prisma.video.update({
+ where: { id: video.id },
+ data: { url: `/uploads/videos/${finalFilename}` },
+ });
+ } catch (err) {
+ console.error("Error renaming video file:", err);
+ // If rename fails, cleanup and delete the record
+ await prisma.video.delete({ where: { id: video.id } });
+ throw err;
+ }
+
+ return NextResponse.json({ video: { ...video, url: `/uploads/videos/${finalFilename}` } }, { status: 201 });
+ } catch (err: any) {
+ console.error("upload error", {
+ code: err?.code,
+ message: err?.message,
+ errno: err?.errno,
+ }, err);
+
+ // cleanup saved files if something failed
+ if (savedVideoDest) {
+ try {
+ await fs.promises.unlink(savedVideoDest);
+ } catch (_) {}
+ }
+ if (savedThumbDest) {
+ try {
+ await fs.promises.unlink(savedThumbDest);
+ } catch (_) {}
+ }
+
+ const status = err?.status ?? 500;
+ const message = err?.message ?? (err?.toString ? err.toString() : "server error");
+ return NextResponse.json({ error: message }, { status });
+ }
+}
diff --git a/app/api/admin/users/[userId]/progress/[videoId]/segments/route.ts b/app/api/admin/users/[userId]/progress/[videoId]/segments/route.ts
new file mode 100644
index 0000000..879b102
--- /dev/null
+++ b/app/api/admin/users/[userId]/progress/[videoId]/segments/route.ts
@@ -0,0 +1,37 @@
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET(
+ req: Request,
+ { params }: { params: Promise<{ userId: string; videoId: string }> }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ // Check if user is admin
+ const admin = await prisma.user.findUnique({ where: { email: session.user.email } });
+ if (!admin || (admin.role !== 'admin' && admin.role !== 'superadmin'))
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+
+ const { userId, videoId } = await params;
+
+ // Fetch all watch segments for the specified user+video
+ const segments = await prisma.videoWatchSegment.findMany({
+ where: { userId, videoId },
+ select: { startSec: true, endSec: true, watchedAt: true },
+ orderBy: { createdAt: 'asc' },
+ });
+
+ return NextResponse.json({ segments });
+ } catch (err: any) {
+ console.error('admin segments GET error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/users/[userId]/route.ts b/app/api/admin/users/[userId]/route.ts
new file mode 100644
index 0000000..13b3289
--- /dev/null
+++ b/app/api/admin/users/[userId]/route.ts
@@ -0,0 +1,150 @@
+// app/api/admin/users/[userId]/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET(
+ req: Request,
+ { params }: { params: Promise<{ userId: string }> }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ const { userId } = await params;
+
+ // Fetch user with all related data
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ image: true,
+ role: true,
+ createdAt: true,
+ enrollments: {
+ select: {
+ course: {
+ select: {
+ id: true,
+ title: true,
+ },
+ },
+ },
+ },
+ progress: {
+ select: {
+ id: true,
+ videoId: true,
+ watchedSec: true,
+ lastPos: true,
+ percent: true,
+ completed: true,
+ durationSec: true,
+ updatedAt: true,
+ createdAt: true,
+ video: {
+ select: {
+ id: true,
+ title: true,
+ },
+ },
+ },
+ orderBy: {
+ updatedAt: 'desc',
+ },
+ },
+ comments: {
+ select: {
+ id: true,
+ content: true,
+ createdAt: true,
+ video: {
+ select: {
+ id: true,
+ title: true,
+ },
+ },
+ replies: {
+ select: {
+ id: true,
+ },
+ },
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ },
+ },
+ });
+
+ if (!user) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ return NextResponse.json(user);
+ } catch (err: any) {
+ console.error('fetch user detail error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
+
+export async function DELETE(
+ req: Request,
+ { params }: { params: Promise<{ userId: string }> }
+) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ const { userId } = await params;
+
+ // Prevent deleting yourself
+ const currentUser = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ });
+
+ if (currentUser?.id === userId) {
+ return NextResponse.json(
+ { error: 'Cannot delete your own account' },
+ { status: 400 }
+ );
+ }
+
+ // Delete user with cascading deletes handled by Prisma schema
+ // The schema has onDelete: Cascade for most relations
+ const deletedUser = await prisma.user.delete({
+ where: { id: userId },
+ });
+
+ return NextResponse.json({
+ message: 'User deleted successfully',
+ deleted: deletedUser.email,
+ });
+ } catch (err: any) {
+ console.error('delete user error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts
new file mode 100644
index 0000000..8092e40
--- /dev/null
+++ b/app/api/admin/users/route.ts
@@ -0,0 +1,68 @@
+// app/api/admin/users/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ // Fetch all users with enrollments and last activity
+ const users = await prisma.user.findMany({
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ image: true,
+ role: true,
+ createdAt: true,
+ enrollments: {
+ select: {
+ course: {
+ select: {
+ id: true,
+ title: true,
+ },
+ },
+ },
+ },
+ progress: {
+ select: {
+ updatedAt: true,
+ },
+ orderBy: {
+ updatedAt: 'desc',
+ },
+ take: 1,
+ },
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ });
+
+ // Map to include last activity
+ const usersWithActivity = users.map((user) => ({
+ ...user,
+ lastActivity: user.progress[0]?.updatedAt ?? null,
+ progress: undefined, // Remove the progress array
+ }));
+
+ return NextResponse.json(usersWithActivity);
+ } catch (err: any) {
+ console.error('fetch users error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/videos/[videoId]/instant-access/route.ts b/app/api/admin/videos/[videoId]/instant-access/route.ts
new file mode 100644
index 0000000..8f5e4bc
--- /dev/null
+++ b/app/api/admin/videos/[videoId]/instant-access/route.ts
@@ -0,0 +1,96 @@
+// app/api/admin/videos/[videoId]/instant-access/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function PATCH(req: Request, context: any) {
+ try {
+ // Unwrap params
+ let params = context?.params;
+ if (typeof params?.then === 'function') params = await params;
+
+ const videoId = params?.videoId;
+ if (!videoId)
+ return NextResponse.json({ error: 'videoId required' }, { status: 400 });
+
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ // Check admin role
+ const user = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ select: { id: true, role: true },
+ });
+
+ if (!user || (user.role !== 'admin' && user.role !== 'superadmin'))
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+
+ const body = await req.json().catch(() => ({}));
+ const { instantAccess } = body ?? {};
+
+ if (typeof instantAccess !== 'boolean')
+ return NextResponse.json(
+ { error: 'instantAccess boolean value required' },
+ { status: 400 }
+ );
+
+ // Update the video
+ const video = await prisma.video.update({
+ where: { id: videoId },
+ data: { instantAccess },
+ select: { id: true, title: true, instantAccess: true, locked: true },
+ });
+
+ return NextResponse.json({ ok: true, video });
+ } catch (err: any) {
+ console.error('admin instant-access PATCH error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
+
+export async function GET(req: Request, context: any) {
+ try {
+ // Unwrap params
+ let params = context?.params;
+ if (typeof params?.then === 'function') params = await params;
+
+ const videoId = params?.videoId;
+ if (!videoId)
+ return NextResponse.json({ error: 'videoId required' }, { status: 400 });
+
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ // Check admin role
+ const user = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ select: { id: true, role: true },
+ });
+
+ if (!user || (user.role !== 'admin' && user.role !== 'superadmin'))
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+
+ // Get the video
+ const video = await prisma.video.findUnique({
+ where: { id: videoId },
+ select: { id: true, title: true, instantAccess: true, locked: true },
+ });
+
+ if (!video)
+ return NextResponse.json({ error: 'video not found' }, { status: 404 });
+
+ return NextResponse.json({ video });
+ } catch (err: any) {
+ console.error('admin instant-access GET error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/videos/[videoId]/locked/route.ts b/app/api/admin/videos/[videoId]/locked/route.ts
new file mode 100644
index 0000000..bab58f8
--- /dev/null
+++ b/app/api/admin/videos/[videoId]/locked/route.ts
@@ -0,0 +1,96 @@
+// app/api/admin/videos/[videoId]/locked/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function PATCH(req: Request, context: any) {
+ try {
+ // Unwrap params
+ let params = context?.params;
+ if (typeof params?.then === 'function') params = await params;
+
+ const videoId = params?.videoId;
+ if (!videoId)
+ return NextResponse.json({ error: 'videoId required' }, { status: 400 });
+
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ // Check admin role
+ const user = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ select: { id: true, role: true },
+ });
+
+ if (!user || (user.role !== 'admin' && user.role !== 'superadmin'))
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+
+ const body = await req.json().catch(() => ({}));
+ const { locked } = body ?? {};
+
+ if (typeof locked !== 'boolean')
+ return NextResponse.json(
+ { error: 'locked boolean value required' },
+ { status: 400 }
+ );
+
+ // Update the video
+ const video = await prisma.video.update({
+ where: { id: videoId },
+ data: { locked },
+ select: { id: true, title: true, locked: true, instantAccess: true },
+ });
+
+ return NextResponse.json({ ok: true, video });
+ } catch (err: any) {
+ console.error('admin locked PATCH error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
+
+export async function GET(req: Request, context: any) {
+ try {
+ // Unwrap params
+ let params = context?.params;
+ if (typeof params?.then === 'function') params = await params;
+
+ const videoId = params?.videoId;
+ if (!videoId)
+ return NextResponse.json({ error: 'videoId required' }, { status: 400 });
+
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ // Check admin role
+ const user = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ select: { id: true, role: true },
+ });
+
+ if (!user || (user.role !== 'admin' && user.role !== 'superadmin'))
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+
+ // Get the video
+ const video = await prisma.video.findUnique({
+ where: { id: videoId },
+ select: { id: true, title: true, locked: true, instantAccess: true },
+ });
+
+ if (!video)
+ return NextResponse.json({ error: 'video not found' }, { status: 404 });
+
+ return NextResponse.json({ video });
+ } catch (err: any) {
+ console.error('admin locked GET error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/admin/videos/route.ts b/app/api/admin/videos/route.ts
new file mode 100644
index 0000000..de18db9
--- /dev/null
+++ b/app/api/admin/videos/route.ts
@@ -0,0 +1,53 @@
+// app/api/admin/videos/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const role = (session as any)?.user?.role ?? null;
+ if (!(role === 'admin' || role === 'superadmin')) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ // Fetch all videos with their playlist and course info
+ const videos = await prisma.video.findMany({
+ include: {
+ playlist: {
+ include: {
+ course: {
+ select: {
+ id: true,
+ title: true,
+ },
+ },
+ },
+ },
+ uploader: {
+ select: {
+ id: true,
+ name: true,
+ image: true,
+ },
+ },
+ videoCourses: {
+ include: { course: true },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ });
+
+ return NextResponse.json(videos);
+ } catch (err: any) {
+ console.error('get videos error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts
new file mode 100644
index 0000000..2425311
--- /dev/null
+++ b/app/api/auth/[...nextauth]/route.ts
@@ -0,0 +1,7 @@
+// app/api/auth/[...nextauth]/route.ts
+import NextAuth from "next-auth";
+import { authOptions } from "@/lib/auth-options";
+
+const handler = NextAuth(authOptions);
+
+export { handler as GET, handler as POST };
diff --git a/app/api/auth/check-student.ts b/app/api/auth/check-student.ts
new file mode 100644
index 0000000..1e68ac7
--- /dev/null
+++ b/app/api/auth/check-student.ts
@@ -0,0 +1,57 @@
+// app/api/auth/check-student.ts
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+import { prisma } from '@/lib/prisma';
+import { normalizeEmail } from '@/lib/normalize-email';
+
+export async function POST(req: NextRequest) {
+ try {
+ const body = await req.json();
+ const email = body?.email;
+ const normalizedEmail = normalizeEmail(email);
+
+ if (!normalizedEmail) {
+ return NextResponse.json({ error: 'Email required' }, { status: 400 });
+ }
+
+ const allowedStudent = await prisma.allowedStudent.findFirst({
+ where: {
+ email: {
+ equals: normalizedEmail,
+ mode: 'insensitive',
+ },
+ },
+ });
+
+ if (!allowedStudent || !allowedStudent.active) {
+ return NextResponse.json(
+ {
+ allowed: false,
+ message: 'Email not registered for access'
+ },
+ { status: 200 }
+ );
+ }
+
+ // Parse course levels
+ const levels = allowedStudent.levels
+ .split(',')
+ .map((level) => level.trim())
+ .filter((level) => level.length > 0);
+
+ return NextResponse.json(
+ {
+ allowed: true,
+ levels,
+ studentId: allowedStudent.id,
+ },
+ { status: 200 }
+ );
+ } catch (err) {
+ console.error('POST /api/auth/check-student error', err);
+ return NextResponse.json(
+ { error: 'Failed to check student status' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/comments/[commentId]/reply/route.ts b/app/api/comments/[commentId]/reply/route.ts
new file mode 100644
index 0000000..012fb30
--- /dev/null
+++ b/app/api/comments/[commentId]/reply/route.ts
@@ -0,0 +1,62 @@
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ commentId: string }> }
+) {
+ const { commentId } = await params;
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const { content } = await request.json();
+
+ if (!content || typeof content !== 'string' || content.trim().length === 0) {
+ return NextResponse.json(
+ { error: 'Non-empty content is required' },
+ { status: 400 }
+ );
+ }
+
+ // Verify comment exists
+ const comment = await prisma.comment.findUnique({
+ where: { id: commentId },
+ });
+
+ if (!comment) {
+ return NextResponse.json({ error: 'Comment not found' }, { status: 404 });
+ }
+
+ const reply = await prisma.commentReply.create({
+ data: {
+ commentId,
+ userId: (session.user as any).id,
+ content: content.trim(),
+ },
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ image: true,
+ },
+ },
+ },
+ });
+
+ return NextResponse.json(reply, { status: 201 });
+ } catch (error) {
+ console.error('Failed to create reply:', error);
+ return NextResponse.json(
+ { error: 'Failed to create reply' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/comments/[commentId]/route.ts b/app/api/comments/[commentId]/route.ts
new file mode 100644
index 0000000..1c4c820
--- /dev/null
+++ b/app/api/comments/[commentId]/route.ts
@@ -0,0 +1,36 @@
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ commentId: string }> }
+) {
+ const { commentId } = await params;
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const role = (session.user as any).role;
+ if (role !== 'admin' && role !== 'superadmin') {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ try {
+ // Delete the comment and its replies (cascade via Prisma)
+ const comment = await prisma.comment.delete({
+ where: { id: commentId },
+ });
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error('Failed to delete comment:', error);
+ return NextResponse.json(
+ { error: 'Failed to delete comment' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/comments/reply/[replyId]/route.ts b/app/api/comments/reply/[replyId]/route.ts
new file mode 100644
index 0000000..70feaf5
--- /dev/null
+++ b/app/api/comments/reply/[replyId]/route.ts
@@ -0,0 +1,35 @@
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ replyId: string }> }
+) {
+ const { replyId } = await params;
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const role = (session.user as any).role;
+ if (role !== 'admin' && role !== 'superadmin') {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ }
+
+ try {
+ const reply = await prisma.commentReply.delete({
+ where: { id: replyId },
+ });
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error('Failed to delete reply:', error);
+ return NextResponse.json(
+ { error: 'Failed to delete reply' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/comments/route.ts b/app/api/comments/route.ts
new file mode 100644
index 0000000..3dd77b3
--- /dev/null
+++ b/app/api/comments/route.ts
@@ -0,0 +1,123 @@
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function GET(request: NextRequest) {
+ const videoId = request.nextUrl.searchParams.get('videoId');
+
+ if (!videoId) {
+ return NextResponse.json(
+ { error: 'videoId is required' },
+ { status: 400 }
+ );
+ }
+
+ try {
+ const comments = await prisma.comment.findMany({
+ where: { videoId },
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ image: true,
+ },
+ },
+ replies: {
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ image: true,
+ },
+ },
+ },
+ orderBy: {
+ createdAt: 'asc',
+ },
+ },
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ });
+
+ return NextResponse.json(comments);
+ } catch (error) {
+ console.error('Failed to fetch comments:', error);
+ return NextResponse.json(
+ { error: 'Failed to fetch comments' },
+ { status: 500 }
+ );
+ }
+}
+
+export async function POST(request: NextRequest) {
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const { videoId, content } = await request.json();
+
+ if (!videoId || !content || typeof content !== 'string' || content.trim().length === 0) {
+ return NextResponse.json(
+ { error: 'videoId and non-empty content are required' },
+ { status: 400 }
+ );
+ }
+
+ // Verify video exists
+ const video = await prisma.video.findUnique({
+ where: { id: videoId },
+ });
+
+ if (!video) {
+ return NextResponse.json({ error: 'Video not found' }, { status: 404 });
+ }
+
+ const comment = await prisma.comment.create({
+ data: {
+ videoId,
+ userId: (session.user as any).id,
+ content: content.trim(),
+ },
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ image: true,
+ },
+ },
+ replies: {
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ image: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ return NextResponse.json(comment, { status: 201 });
+ } catch (error) {
+ console.error('Failed to create comment:', error);
+ return NextResponse.json(
+ { error: 'Failed to create comment' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/courses/route.ts b/app/api/courses/route.ts
new file mode 100644
index 0000000..132e8a2
--- /dev/null
+++ b/app/api/courses/route.ts
@@ -0,0 +1,8 @@
+// app/api/users/route.ts
+import { NextResponse } from "next/server";
+import { prisma } from "../../../lib/prisma";
+
+export async function GET() {
+ const courses = await prisma.course.findMany({ select: { id: true, title: true, code: true } });
+ return NextResponse.json(courses);
+}
diff --git a/app/api/enrollments/route.ts b/app/api/enrollments/route.ts
new file mode 100644
index 0000000..a02e131
--- /dev/null
+++ b/app/api/enrollments/route.ts
@@ -0,0 +1,94 @@
+// /app/api/enrollments/route.ts
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+import { PrismaClient } from '@prisma/client';
+
+const prisma = new PrismaClient();
+
+// --- GET --------------------------------------------------------------------
+
+export async function GET() {
+ try {
+ const enrollments = await prisma.enrollment.findMany({
+ include: {
+ user: { select: { id: true, name: true, email: true } },
+ course: { select: { id: true, title: true, code: true, } },
+ },
+ orderBy: { createdAt: 'asc' },
+ });
+ return NextResponse.json(enrollments);
+ } catch (err) {
+ console.error('GET /api/enrollments error', err);
+ return NextResponse.json({ error: 'Failed to fetch enrollments' }, { status: 500 });
+ }
+}
+
+// --- POST --------------------------------------------------------------------
+
+export async function POST(req: NextRequest) {
+ try {
+ const body = await req.json();
+
+ // incoming payload example:
+ // { userId: "cmicsu2030000uaec0sp56gso", courseId: "cmicrx1b70000uahwilu0gh9u", role: "student" }
+ const userId: string | undefined = body.userId;
+ const courseId: string | undefined = body.courseId;
+ // const role: string = body.role ?? 'student';
+
+ if (!userId || !courseId) {
+ return NextResponse.json({ error: 'Missing userId or courseId' }, { status: 400 });
+ }
+
+ // Optional: Prevent duplicate enrolments
+ const existing = await prisma.enrollment.findFirst({
+ where: { userId: userId, courseId: courseId },
+ });
+
+ if (existing) {
+ return NextResponse.json(
+ { error: 'Enrollment already exists' },
+ { status: 409 }
+ );
+ }
+
+ // Create enrollment using STRING IDs
+ const enrollment = await prisma.enrollment.create({
+ data: {
+ user: { connect: { id: userId } },
+ course: { connect: { id: courseId } },
+ // role: role,
+ },
+ include: {
+ user: { select: { id: true, name: true, email: true } },
+ course: { select: { id: true, title: true } },
+ },
+ });
+
+ return NextResponse.json(enrollment, { status: 201 });
+ } catch (err) {
+ console.error('POST /api/enrollments error', err);
+ return NextResponse.json({ error: 'Failed to create enrollment' }, { status: 500 });
+ }
+}
+
+// --- DELETE --------------------------------------------------------------------
+
+export async function DELETE(req: NextRequest) {
+ try {
+ const body = await req.json();
+ const id = body?.id;
+
+ if (!id) {
+ return NextResponse.json({ error: 'Missing id' }, { status: 400 });
+ }
+
+ await prisma.enrollment.delete({
+ where: { id: id }, // STRING — not Number(id)
+ });
+
+ return NextResponse.json({ success: true });
+ } catch (err) {
+ console.error('DELETE /api/enrollments error', err);
+ return NextResponse.json({ error: 'Failed to delete enrollment' }, { status: 500 });
+ }
+}
diff --git a/app/api/enrollments/sync.ts b/app/api/enrollments/sync.ts
new file mode 100644
index 0000000..2d60dfa
--- /dev/null
+++ b/app/api/enrollments/sync.ts
@@ -0,0 +1,122 @@
+// app/api/enrollments/sync.ts
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function POST(req: NextRequest) {
+ console.log('🔥 [SYNC-ENROLLMENTS] POST endpoint called');
+ console.log('🔥 [SYNC-ENROLLMENTS] Request method:', req.method);
+ console.log('🔥 [SYNC-ENROLLMENTS] Request URL:', req.url);
+
+ try {
+ const session = await getServerSession(authOptions);
+ console.log('🔥 [SYNC-ENROLLMENTS] Session check:', !!session?.user?.email);
+ console.log('🔥 [SYNC-ENROLLMENTS] Session user:', session?.user);
+
+ if (!session?.user?.email) {
+ console.log('🔥 [SYNC-ENROLLMENTS] No session or email, returning 401');
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const userEmail = session.user.email;
+ console.log('🔥 [SYNC-ENROLLMENTS] About to parse request body...');
+
+ const body = await req.json();
+ console.log('🔥 [SYNC-ENROLLMENTS] Raw body received:', JSON.stringify(body, null, 2));
+
+ const levels: string[] = body?.levels || [];
+ console.log('🔥 [SYNC-ENROLLMENTS] Extracted levels:', levels);
+ console.log('🔥 [SYNC-ENROLLMENTS] Levels type check - isArray:', Array.isArray(levels));
+ console.log('🔥 [SYNC-ENROLLMENTS] Levels length:', levels.length);
+
+ console.log('🔥 [SYNC-ENROLLMENTS] User:', userEmail);
+ console.log('🔥 [SYNC-ENROLLMENTS] Received levels:', levels);
+
+ if (!Array.isArray(levels) || levels.length === 0) {
+ console.log('🔥 [SYNC-ENROLLMENTS] VALIDATION FAILED!');
+ console.log('🔥 [SYNC-ENROLLMENTS] levels isArray:', Array.isArray(levels));
+ console.log('🔥 [SYNC-ENROLLMENTS] levels length:', levels?.length);
+ console.log('🔥 [SYNC-ENROLLMENTS] levels value:', levels);
+ console.log('🔥 [SYNC-ENROLLMENTS] Returning 400 - Levels array required');
+ return NextResponse.json(
+ { error: 'Levels array required' },
+ { status: 400 }
+ );
+ }
+
+ // Get user
+ const user = await prisma.user.findUnique({
+ where: { email: userEmail },
+ });
+ if (!user) {
+ console.log('[SYNC-ENROLLMENTS] User not found in database:', userEmail);
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ console.log('[SYNC-ENROLLMENTS] Found user:', user.id, user.email);
+
+ const createdEnrollments = [];
+
+ // For each course level, find the course and create enrollment
+ for (const level of levels) {
+ try {
+ console.log('[SYNC-ENROLLMENTS] Looking for course with code:', level);
+
+ const course = await prisma.course.findUnique({
+ where: { code: level },
+ });
+
+ if (!course) {
+ console.warn(`[SYNC-ENROLLMENTS] Course code not found: ${level}`);
+ continue;
+ }
+
+ console.log('[SYNC-ENROLLMENTS] Found course:', course.id, course.code, course.title);
+
+ // Check if enrollment already exists
+ const existing = await prisma.enrollment.findFirst({
+ where: { userId: user.id, courseId: course.id },
+ });
+
+ if (existing) {
+ console.log('[SYNC-ENROLLMENTS] Enrollment already exists:', existing.id);
+ } else {
+ const enrollment = await prisma.enrollment.create({
+ data: {
+ userId: user.id,
+ courseId: course.id,
+ },
+ });
+ console.log('[SYNC-ENROLLMENTS] Created new enrollment:', enrollment.id);
+ createdEnrollments.push({
+ courseCode: level,
+ courseId: course.id,
+ enrollmentId: enrollment.id,
+ });
+ }
+ } catch (err) {
+ console.error(`[SYNC-ENROLLMENTS] Failed to create enrollment for level ${level}:`, err);
+ }
+ }
+
+ console.log('[SYNC-ENROLLMENTS] Summary - Created enrollments:', createdEnrollments.length);
+ console.log('[SYNC-ENROLLMENTS] Details:', createdEnrollments);
+
+ return NextResponse.json(
+ {
+ message: 'Enrollments synced',
+ created: createdEnrollments,
+ },
+ { status: 200 }
+ );
+ } catch (err) {
+ console.error('🔥 [SYNC-ENROLLMENTS] FATAL ERROR:', err);
+ console.error('🔥 [SYNC-ENROLLMENTS] Error stack:', err instanceof Error ? err.stack : 'No stack');
+ return NextResponse.json(
+ { error: 'Failed to sync enrollments' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/enrollments/sync/route.ts b/app/api/enrollments/sync/route.ts
new file mode 100644
index 0000000..767657a
--- /dev/null
+++ b/app/api/enrollments/sync/route.ts
@@ -0,0 +1,122 @@
+// app/api/enrollments/sync/route.ts
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function POST(req: NextRequest) {
+ console.log('🔥 [SYNC-ENROLLMENTS] POST endpoint called');
+ console.log('🔥 [SYNC-ENROLLMENTS] Request method:', req.method);
+ console.log('🔥 [SYNC-ENROLLMENTS] Request URL:', req.url);
+
+ try {
+ const session = await getServerSession(authOptions);
+ console.log('🔥 [SYNC-ENROLLMENTS] Session check:', !!session?.user?.email);
+ console.log('🔥 [SYNC-ENROLLMENTS] Session user:', session?.user);
+
+ if (!session?.user?.email) {
+ console.log('🔥 [SYNC-ENROLLMENTS] No session or email, returning 401');
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const userEmail = session.user.email;
+ console.log('🔥 [SYNC-ENROLLMENTS] About to parse request body...');
+
+ const body = await req.json();
+ console.log('🔥 [SYNC-ENROLLMENTS] Raw body received:', JSON.stringify(body, null, 2));
+
+ const levels: string[] = body?.levels || [];
+ console.log('🔥 [SYNC-ENROLLMENTS] Extracted levels:', levels);
+ console.log('🔥 [SYNC-ENROLLMENTS] Levels type check - isArray:', Array.isArray(levels));
+ console.log('🔥 [SYNC-ENROLLMENTS] Levels length:', levels.length);
+
+ console.log('🔥 [SYNC-ENROLLMENTS] User:', userEmail);
+ console.log('🔥 [SYNC-ENROLLMENTS] Received levels:', levels);
+
+ if (!Array.isArray(levels) || levels.length === 0) {
+ console.log('🔥 [SYNC-ENROLLMENTS] VALIDATION FAILED!');
+ console.log('🔥 [SYNC-ENROLLMENTS] levels isArray:', Array.isArray(levels));
+ console.log('🔥 [SYNC-ENROLLMENTS] levels length:', levels?.length);
+ console.log('🔥 [SYNC-ENROLLMENTS] levels value:', levels);
+ console.log('🔥 [SYNC-ENROLLMENTS] Returning 400 - Levels array required');
+ return NextResponse.json(
+ { error: 'Levels array required' },
+ { status: 400 }
+ );
+ }
+
+ // Get user
+ const user = await prisma.user.findUnique({
+ where: { email: userEmail },
+ });
+ if (!user) {
+ console.log('[SYNC-ENROLLMENTS] User not found in database:', userEmail);
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ console.log('[SYNC-ENROLLMENTS] Found user:', user.id, user.email);
+
+ const createdEnrollments = [];
+
+ // For each course level, find the course and create enrollment
+ for (const level of levels) {
+ try {
+ console.log('[SYNC-ENROLLMENTS] Looking for course with code:', level);
+
+ const course = await prisma.course.findUnique({
+ where: { code: level },
+ });
+
+ if (!course) {
+ console.warn(`[SYNC-ENROLLMENTS] Course code not found: ${level}`);
+ continue;
+ }
+
+ console.log('[SYNC-ENROLLMENTS] Found course:', course.id, course.code, course.title);
+
+ // Check if enrollment already exists
+ const existing = await prisma.enrollment.findFirst({
+ where: { userId: user.id, courseId: course.id },
+ });
+
+ if (existing) {
+ console.log('[SYNC-ENROLLMENTS] Enrollment already exists:', existing.id);
+ } else {
+ const enrollment = await prisma.enrollment.create({
+ data: {
+ userId: user.id,
+ courseId: course.id,
+ },
+ });
+ console.log('[SYNC-ENROLLMENTS] Created new enrollment:', enrollment.id);
+ createdEnrollments.push({
+ courseCode: level,
+ courseId: course.id,
+ enrollmentId: enrollment.id,
+ });
+ }
+ } catch (err) {
+ console.error(`[SYNC-ENROLLMENTS] Failed to create enrollment for level ${level}:`, err);
+ }
+ }
+
+ console.log('[SYNC-ENROLLMENTS] Summary - Created enrollments:', createdEnrollments.length);
+ console.log('[SYNC-ENROLLMENTS] Details:', createdEnrollments);
+
+ return NextResponse.json(
+ {
+ message: 'Enrollments synced',
+ created: createdEnrollments,
+ },
+ { status: 200 }
+ );
+ } catch (err) {
+ console.error('🔥 [SYNC-ENROLLMENTS] FATAL ERROR:', err);
+ console.error('🔥 [SYNC-ENROLLMENTS] Error stack:', err instanceof Error ? err.stack : 'No stack');
+ return NextResponse.json(
+ { error: 'Failed to sync enrollments' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/likes/all/route.ts b/app/api/likes/all/route.ts
new file mode 100644
index 0000000..c4c8116
--- /dev/null
+++ b/app/api/likes/all/route.ts
@@ -0,0 +1,53 @@
+// app/api/likes/all/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const user = await prisma.user.findUnique({ where: { email: session.user.email } });
+ if (!user)
+ return NextResponse.json({ error: 'user not found' }, { status: 404 });
+
+ // Fetch all videos liked by user, sorted by most recent
+ const likes = await prisma.videoLike.findMany({
+ where: { userId: user.id },
+ include: {
+ video: {
+ select: {
+ id: true,
+ title: true,
+ thumbnail: true,
+ durationSec: true,
+ url: true,
+ playlist: {
+ select: {
+ title: true,
+ course: {
+ select: {
+ id: true,
+ title: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ });
+
+ return NextResponse.json(likes);
+ } catch (err: any) {
+ console.error('get liked videos error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/likes/route.ts b/app/api/likes/route.ts
new file mode 100644
index 0000000..67d5e48
--- /dev/null
+++ b/app/api/likes/route.ts
@@ -0,0 +1,88 @@
+// app/api/likes/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function POST(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const body = await req.json().catch(() => ({}));
+ const { videoId, isLiked } = body ?? {};
+
+ if (!videoId)
+ return NextResponse.json({ error: 'videoId required' }, { status: 400 });
+
+ const user = await prisma.user.findUnique({ where: { email: session.user.email } });
+ if (!user)
+ return NextResponse.json({ error: 'user not found' }, { status: 404 });
+
+ if (isLiked) {
+ // Add like
+ const existingLike = await prisma.videoLike.findUnique({
+ where: { userId_videoId: { userId: user.id, videoId } },
+ });
+
+ if (existingLike) {
+ return NextResponse.json({ success: true, message: 'Already liked' });
+ }
+
+ await prisma.videoLike.create({
+ data: {
+ userId: user.id,
+ videoId,
+ },
+ });
+
+ return NextResponse.json({ success: true, message: 'Video liked' });
+ } else {
+ // Remove like
+ await prisma.videoLike.deleteMany({
+ where: { userId: user.id, videoId },
+ });
+
+ return NextResponse.json({ success: true, message: 'Video unliked' });
+ }
+ } catch (err: any) {
+ console.error('like toggle error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
+
+export async function GET(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const { searchParams } = new URL(req.url);
+ const videoId = searchParams.get('videoId');
+
+ const user = await prisma.user.findUnique({ where: { email: session.user.email } });
+ if (!user)
+ return NextResponse.json({ error: 'user not found' }, { status: 404 });
+
+ if (videoId) {
+ // Check if a specific video is liked
+ const like = await prisma.videoLike.findUnique({
+ where: { userId_videoId: { userId: user.id, videoId } },
+ });
+ return NextResponse.json({ isLiked: !!like });
+ } else {
+ // Get all liked videos for user
+ return NextResponse.json({ error: 'videoId required for check' }, { status: 400 });
+ }
+ } catch (err: any) {
+ console.error('like check error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/playlists/[id]/route.ts b/app/api/playlists/[id]/route.ts
new file mode 100644
index 0000000..5b7acb8
--- /dev/null
+++ b/app/api/playlists/[id]/route.ts
@@ -0,0 +1,59 @@
+// app/api/playlists/[id]/route.ts
+import { NextResponse } from "next/server";
+import { prisma } from "../../../../lib/prisma";
+import { getServerSession } from "next-auth";
+import { authOptions } from "../../../../lib/auth-options";
+
+export async function GET(req: Request, context: any) {
+ try {
+ // Unwrap params (Next may provide a Promise)
+ let params = context?.params;
+ if (typeof params?.then === "function") params = await params;
+
+ const id = params?.id;
+ if (!id) return NextResponse.json({ error: "Missing playlist id" }, { status: 400 });
+
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const user = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ });
+ if (!user) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const playlist = await prisma.playlist.findUnique({
+ where: { id },
+ include: {
+ videos: {
+ include: {
+ uploader: {
+ select: {
+ id: true,
+ name: true,
+ image: true,
+ },
+ },
+ },
+ orderBy: { index: "asc" },
+ },
+ course: true,
+ },
+ });
+
+ if (!playlist) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ const enrolled = await prisma.enrollment.findUnique({
+ where: { userId_courseId: { userId: user.id, courseId: playlist.courseId } },
+ });
+ if (!enrolled) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+
+ return NextResponse.json({ playlist });
+ } catch (err: any) {
+ console.error("GET /api/playlists/[id] error:", err);
+ return NextResponse.json({ error: "Server error" }, { status: 500 });
+ }
+}
diff --git a/app/api/playlists/route.ts b/app/api/playlists/route.ts
new file mode 100644
index 0000000..08e7598
--- /dev/null
+++ b/app/api/playlists/route.ts
@@ -0,0 +1,133 @@
+// app/api/playlists/route.ts
+import { NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth-options";
+import { prisma } from "@/lib/prisma";
+
+type VideoWithCourses = {
+ videoCourses?: Array<{ courseId: string; exclusive: boolean }>
+};
+
+function mapRestrictedCourseIds(video: VideoWithCourses) {
+ if (!video?.videoCourses?.length) return [];
+ return video.videoCourses
+ .filter((assignment) => assignment.exclusive)
+ .map((assignment) => assignment.courseId);
+}
+
+function filterRestrictedVideos(videos: any[], userCourseIds: string[]) {
+ return videos
+ .filter((video) => {
+ const restrictedCourseIds = mapRestrictedCourseIds(video);
+ if (restrictedCourseIds.length === 0) return true;
+ return restrictedCourseIds.some((courseId: string) =>
+ userCourseIds.includes(courseId)
+ );
+ })
+ .map((video) => ({
+ ...video,
+ restrictedCourseIds: mapRestrictedCourseIds(video),
+ }));
+}
+
+export async function GET(req: Request) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const user = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ include: {
+ enrollments: {
+ include: { course: true },
+ },
+ },
+ });
+
+ const courseIds = user?.enrollments.map((e) => e.courseId) ?? [];
+
+ // Get playlists directly assigned to enrolled courses
+ const coursesWithPlaylists = await prisma.course.findMany({
+ where: { id: { in: courseIds } },
+ include: {
+ playlists: {
+ include: {
+ videos: {
+ include: {
+ uploader: {
+ select: {
+ id: true,
+ name: true,
+ image: true,
+ },
+ },
+ videoCourses: true,
+ },
+ orderBy: { index: "asc" },
+ },
+ courses: {
+ include: { course: true },
+ },
+ },
+ orderBy: { sortOrder: "asc" },
+ },
+ },
+ orderBy: { title: "asc" },
+ });
+
+ // Also get playlists assigned to courses via CoursePlaylist mapping
+ const additionalPlaylists = await prisma.coursePlaylist.findMany({
+ where: { courseId: { in: courseIds } },
+ include: {
+ playlist: {
+ include: {
+ videos: {
+ include: {
+ uploader: {
+ select: {
+ id: true,
+ name: true,
+ image: true,
+ },
+ },
+ videoCourses: true,
+ },
+ orderBy: { index: "asc" },
+ },
+ courses: {
+ include: { course: true },
+ },
+ },
+ },
+ course: true,
+ },
+ });
+
+ // Merge results: add additional playlists to their respective courses
+ const playlistMap = new Map();
+ additionalPlaylists.forEach(({ course, playlist }) => {
+ if (!playlistMap.has(course.id)) {
+ playlistMap.set(course.id, []);
+ }
+ playlist.videos = filterRestrictedVideos(playlist.videos, courseIds);
+ playlistMap.get(course.id).push(playlist);
+ });
+
+ coursesWithPlaylists.forEach((course) => {
+ course.playlists.forEach((playlist) => {
+ playlist.videos = filterRestrictedVideos(playlist.videos, courseIds);
+ });
+
+ const additional = playlistMap.get(course.id) || [];
+ const existingIds = new Set(course.playlists.map((p) => p.id));
+ const newPlaylists = additional.filter((p: any) => !existingIds.has(p.id));
+ newPlaylists.forEach((playlist: any) => {
+ playlist.videos = filterRestrictedVideos(playlist.videos, courseIds);
+ });
+ course.playlists.push(...newPlaylists);
+ course.playlists.sort((a, b) => a.sortOrder - b.sortOrder);
+ });
+
+ return NextResponse.json({ subjects: coursesWithPlaylists });
+}
diff --git a/app/api/progress/route.ts b/app/api/progress/route.ts
new file mode 100644
index 0000000..6690037
--- /dev/null
+++ b/app/api/progress/route.ts
@@ -0,0 +1,225 @@
+// app/api/progress/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const userEmail = session.user.email;
+ const { searchParams } = new URL(req.url);
+ const videoId = searchParams.get('videoId');
+
+ if (!videoId)
+ return NextResponse.json({ error: 'videoId required' }, { status: 400 });
+
+ const user = await prisma.user.findUnique({ where: { email: userEmail } });
+ if (!user)
+ return NextResponse.json({ error: 'user not found' }, { status: 404 });
+
+ // Validate that the video exists
+ const video = await prisma.video.findUnique({ where: { id: videoId } });
+ if (!video) {
+ console.debug(`[PROGRESS] Video not found: ${videoId} (likely deleted video with cached browser reference)`);
+ return NextResponse.json({ error: 'video not found' }, { status: 404 });
+ }
+
+ const progress = await prisma.videoProgress.findUnique({
+ where: { userId_videoId: { userId: user.id, videoId } },
+ select: { percent: true, lastPos: true, watchedSec: true },
+ });
+
+ return NextResponse.json({
+ percent: progress?.percent ?? 0,
+ lastPos: progress?.lastPos ?? 0,
+ watchedSec: progress?.watchedSec ?? 0,
+ });
+ } catch (err: any) {
+ console.error('progress GET error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
+
+// Helper function to calculate unique watched seconds from segments
+function calculateWatchedSeconds(segments: Array<{ startSec: number; endSec: number }>): number {
+ if (segments.length === 0) return 0;
+
+ // Sort and merge overlapping ranges
+ const sorted = segments.sort((a, b) => a.startSec - b.startSec);
+ const merged: Array<[number, number]> = [];
+
+ for (const seg of sorted) {
+ if (merged.length === 0) {
+ merged.push([seg.startSec, seg.endSec]);
+ } else {
+ const last = merged[merged.length - 1];
+ if (seg.startSec <= last[1] + 0.5) {
+ // Overlapping or adjacent, merge
+ last[1] = Math.max(last[1], seg.endSec);
+ } else {
+ // Gap, new range
+ merged.push([seg.startSec, seg.endSec]);
+ }
+ }
+ }
+
+ let total = 0;
+ for (const [start, end] of merged) {
+ total += Math.max(0, end - start);
+ }
+ return Math.round(total);
+}
+
+export async function POST(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const userEmail = session.user.email;
+
+ const body = await req.json().catch(() => ({}));
+ const { videoId, playlistId, watchedSec, lastPos, duration } = body ?? {};
+
+ if (!videoId)
+ return NextResponse.json({ error: 'videoId required' }, { status: 400 });
+ if (!duration || Number(duration) <= 0)
+ return NextResponse.json({ error: 'duration required' }, { status: 400 });
+
+ const watched = Number(watchedSec ?? 0);
+ const lastPosition = Number(lastPos ?? 0);
+ const dur = Number(duration);
+
+ // look up user id
+ const user = await prisma.user.findUnique({ where: { email: userEmail } });
+ if (!user)
+ return NextResponse.json({ error: 'user not found' }, { status: 404 });
+
+ // Validate that the video exists
+ const video = await prisma.video.findUnique({ where: { id: videoId } });
+ if (!video) {
+ // Log at debug level since this is expected when users have old cached references
+ console.debug(`[PROGRESS] Video not found: ${videoId} (likely deleted video with cached browser reference)`);
+ return NextResponse.json({ error: 'video not found' }, { status: 404 });
+ }
+
+ // Ensure VideoProgress exists or create it
+ let videoProgress = await prisma.videoProgress.findUnique({
+ where: { userId_videoId: { userId: user.id, videoId } },
+ });
+
+ if (!videoProgress) {
+ videoProgress = await prisma.videoProgress.create({
+ data: {
+ userId: user.id,
+ videoId,
+ watchedSec: 0,
+ lastPos: 0,
+ percent: 0,
+ durationSec: dur,
+ },
+ });
+ }
+
+ // Record the watch segment if watched seconds > 0
+ let newSegment = null;
+ if (watched > 0) {
+ newSegment = await prisma.videoWatchSegment.create({
+ data: {
+ userId: user.id,
+ videoId,
+ startSec: Math.max(0, lastPosition - watched),
+ endSec: lastPosition,
+ },
+ });
+ }
+
+ // Fetch all segments for this user+video to recalculate totals
+ const allSegments = await prisma.videoWatchSegment.findMany({
+ where: { userId: user.id, videoId },
+ select: { startSec: true, endSec: true },
+ orderBy: { createdAt: 'asc' },
+ });
+
+ // Calculate total unique watched seconds
+ const totalWatchedSec = calculateWatchedSeconds(allSegments);
+
+ // Calculate completion percentage
+ const ratio = dur > 0 ? totalWatchedSec / dur : 0;
+ const percentInt = Math.min(100, Math.round(ratio * 100));
+ const completed = percentInt >= 80; // Changed from 90 to 80 for unlock threshold
+
+ // Update VideoProgress with recalculated values
+ const upserted = await prisma.videoProgress.update({
+ where: { userId_videoId: { userId: user.id, videoId } },
+ data: {
+ watchedSec: totalWatchedSec,
+ lastPos: Math.max(videoProgress.lastPos ?? 0, lastPosition),
+ percent: percentInt,
+ durationSec: dur,
+ completed,
+ updatedAt: new Date(),
+ },
+ });
+
+ // If 80% watched, unlock next video in playlist for this user
+ let unlockedNext = null;
+ if (completed && playlistId) {
+ // find current video
+ const current = await prisma.video.findUnique({ where: { id: videoId } });
+ if (current && current.playlistId === playlistId) {
+ // Find next video that is NOT instant access and NOT globally locked
+ // Skip any instant access videos in the sequence
+ const nextVideos = await prisma.video.findMany({
+ where: {
+ playlistId,
+ index: { gt: current.index },
+ locked: false,
+ instantAccess: false,
+ },
+ orderBy: { index: 'asc' },
+ take: 1,
+ });
+
+ if (nextVideos.length > 0) {
+ const next = nextVideos[0];
+ // Create a VideoUnlock record for this user (per-user unlock tracking)
+ const unlock = await prisma.videoUnlock.upsert({
+ where: { userId_videoId: { userId: user.id, videoId: next.id } },
+ update: {}, // if already exists, do nothing
+ create: { userId: user.id, videoId: next.id },
+ });
+ unlockedNext = { id: next.id, title: next.title };
+ }
+ }
+ }
+
+ return NextResponse.json({ ok: true, progress: upserted, unlockedNext, segment: newSegment });
+ } catch (err: any) {
+ console.error('progress error', err);
+
+ // Handle foreign key constraint violations
+ if (err.code === 'P2003') {
+ const constraint = err.meta?.constraint_name;
+ if (constraint?.includes('videoId')) {
+ console.error(`[PROGRESS] Foreign key violation - invalid videoId: ${err.meta}`);
+ return NextResponse.json(
+ { error: 'Invalid video reference' },
+ { status: 400 }
+ );
+ }
+ }
+
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/progress/segments/route.ts b/app/api/progress/segments/route.ts
new file mode 100644
index 0000000..c4a84e4
--- /dev/null
+++ b/app/api/progress/segments/route.ts
@@ -0,0 +1,45 @@
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const userEmail = session.user.email;
+ const { searchParams } = new URL(req.url);
+ const videoId = searchParams.get('videoId');
+
+ if (!videoId)
+ return NextResponse.json({ error: 'videoId required' }, { status: 400 });
+
+ const user = await prisma.user.findUnique({ where: { email: userEmail } });
+ if (!user)
+ return NextResponse.json({ error: 'user not found' }, { status: 404 });
+
+ // Validate that the video exists
+ const video = await prisma.video.findUnique({ where: { id: videoId } });
+ if (!video) {
+ console.debug(`[PROGRESS] Video not found for segments: ${videoId} (likely deleted video with cached browser reference)`);
+ return NextResponse.json({ error: 'video not found' }, { status: 404 });
+ }
+
+ // Fetch all watch segments for this user+video
+ const segments = await prisma.videoWatchSegment.findMany({
+ where: { userId: user.id, videoId },
+ select: { startSec: true, endSec: true, watchedAt: true },
+ orderBy: { createdAt: 'asc' },
+ });
+
+ return NextResponse.json({ segments });
+ } catch (err: any) {
+ console.error('segments GET error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/thumbnails/[...path]/route.ts b/app/api/thumbnails/[...path]/route.ts
new file mode 100644
index 0000000..91e48d8
--- /dev/null
+++ b/app/api/thumbnails/[...path]/route.ts
@@ -0,0 +1,117 @@
+// /api/thumbnails/[...path]/route.ts
+// This endpoint serves thumbnails from the thumbnails directory
+
+import { NextResponse } from 'next/server';
+import * as fs from 'fs';
+import * as path from 'path';
+
+// Path to thumbnails directory (should match UPLOADS_DIR/thumbnails)
+const THUMBNAILS_DIR = path.join(process.env.UPLOADS_DIR || '/uploads', 'thumbnails');
+
+export async function GET(
+ request: Request,
+ context: { params: Promise<{ path?: string[] }> }
+) {
+ try {
+ const params = await context.params;
+ const pathSegments = params?.path || [];
+
+ if (pathSegments.length === 0) {
+ return NextResponse.json(
+ { error: 'Invalid thumbnail request' },
+ { status: 400 }
+ );
+ }
+
+ // Construct the file path and validate it
+ const requestedPath = path.join(THUMBNAILS_DIR, ...pathSegments);
+
+ // Security: prevent directory traversal attacks
+ if (!requestedPath.startsWith(THUMBNAILS_DIR)) {
+ return NextResponse.json(
+ { error: 'Invalid request' },
+ { status: 403 }
+ );
+ }
+
+ // Check if file exists
+ if (!fs.existsSync(requestedPath)) {
+ console.log(`[Thumbnails] File not found: ${requestedPath}`);
+ return NextResponse.json(
+ { error: 'Not found' },
+ { status: 404 }
+ );
+ }
+
+ // Read the file
+ const fileContent = fs.readFileSync(requestedPath);
+
+ // Determine content type based on file extension
+ let contentType = 'application/octet-stream';
+
+ if (requestedPath.endsWith('.jpg') || requestedPath.endsWith('.jpeg')) {
+ contentType = 'image/jpeg';
+ } else if (requestedPath.endsWith('.png')) {
+ contentType = 'image/png';
+ } else if (requestedPath.endsWith('.webp')) {
+ contentType = 'image/webp';
+ } else if (requestedPath.endsWith('.gif')) {
+ contentType = 'image/gif';
+ }
+
+ // Return the file with appropriate headers
+ return new NextResponse(fileContent, {
+ status: 200,
+ headers: {
+ 'Content-Type': contentType,
+ 'Cache-Control': 'public, max-age=31536000, immutable', // Cache for 1 year (thumbnails don't change)
+ 'Access-Control-Allow-Origin': '*', // Allow CORS if needed
+ },
+ });
+ } catch (error) {
+ console.error('[Thumbnails] Error serving thumbnail file:', error);
+ return NextResponse.json(
+ { error: 'Internal server error' },
+ { status: 500 }
+ );
+ }
+}
+
+// HEAD request support for thumbnail validation
+export async function HEAD(
+ request: Request,
+ context: { params: Promise<{ path?: string[] }> }
+) {
+ try {
+ const params = await context.params;
+ const pathSegments = params?.path || [];
+
+ if (pathSegments.length === 0) {
+ return new NextResponse(null, { status: 400 });
+ }
+
+ const requestedPath = path.join(THUMBNAILS_DIR, ...pathSegments);
+
+ // Security: prevent directory traversal attacks
+ if (!requestedPath.startsWith(THUMBNAILS_DIR)) {
+ return new NextResponse(null, { status: 403 });
+ }
+
+ // Check if file exists
+ if (!fs.existsSync(requestedPath)) {
+ return new NextResponse(null, { status: 404 });
+ }
+
+ // Return headers only
+ return new NextResponse(null, {
+ status: 200,
+ headers: {
+ 'Content-Type': 'image/jpeg', // Default for HEAD requests
+ 'Cache-Control': 'public, max-age=31536000, immutable',
+ },
+ });
+ } catch (error) {
+ console.error('[Thumbnails] Error in HEAD request:', error);
+ return new NextResponse(null, { status: 500 });
+ }
+}
diff --git a/app/api/transcoder/claim/route.ts b/app/api/transcoder/claim/route.ts
new file mode 100644
index 0000000..a0a074e
--- /dev/null
+++ b/app/api/transcoder/claim/route.ts
@@ -0,0 +1,60 @@
+// app/api/transcoder/claim/route.ts
+// Atomically claims the next available transcoding job.
+// Uses SELECT … FOR UPDATE SKIP LOCKED so multiple workers never race on the
+// same video.
+
+import { NextResponse } from "next/server";
+import { prisma } from "@/lib/prisma";
+import { Prisma } from "@prisma/client";
+import { verifyTranscoderToken } from "@/lib/transcoder-auth";
+
+export const dynamic = "force-dynamic";
+
+export async function POST(request: Request) {
+ if (!verifyTranscoderToken(request)) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ try {
+ const video = await prisma.$transaction(async (tx) => {
+ // Atomically lock the oldest uploaded video, skipping rows already
+ // locked by concurrent workers.
+ const rows = await tx.$queryRaw<{ id: string }[]>(
+ Prisma.sql`
+ SELECT id
+ FROM "Video"
+ WHERE "transcodingStatus" = 'uploaded'
+ ORDER BY "createdAt" ASC
+ LIMIT 1
+ FOR UPDATE SKIP LOCKED
+ `
+ );
+
+ if (rows.length === 0) return null;
+
+ return tx.video.update({
+ where: { id: rows[0].id },
+ data: { transcodingStatus: "processing" },
+ });
+ });
+
+ if (!video) {
+ // No work available – return null body so the worker knows to stop.
+ return NextResponse.json(null, { status: 200 });
+ }
+
+ // Build the download URL from the public CMS base URL.
+ const cmsBase =
+ process.env.NEXTAUTH_URL?.replace(/\/$/, "") ??
+ process.env.CMS_PUBLIC_URL?.replace(/\/$/, "") ??
+ "";
+
+ return NextResponse.json({
+ videoId: video.id,
+ downloadUrl: `${cmsBase}/api/transcoder/download/${video.id}`,
+ });
+ } catch (err) {
+ console.error("[Transcoder Claim] Error:", err);
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+}
diff --git a/app/api/transcoder/download/[videoId]/route.ts b/app/api/transcoder/download/[videoId]/route.ts
new file mode 100644
index 0000000..e1b14b6
--- /dev/null
+++ b/app/api/transcoder/download/[videoId]/route.ts
@@ -0,0 +1,72 @@
+// app/api/transcoder/download/[videoId]/route.ts
+// Streams the original MP4 directly to the remote transcoder worker.
+// Never buffers the file in memory.
+
+import { NextResponse } from "next/server";
+import * as fssync from "fs";
+import * as fs from "fs/promises";
+import * as path from "path";
+import { Readable } from "stream";
+import { prisma } from "@/lib/prisma";
+import { verifyTranscoderToken } from "@/lib/transcoder-auth";
+
+// Allow up to 45 minutes for large file transfers.
+export const maxDuration = 2700;
+
+const UPLOADS_DIR = process.env.UPLOADS_DIR ?? "/uploads";
+const ORIGINALS_DIR = path.join(UPLOADS_DIR, "videos");
+
+// Narrow character set – CUIDs are alphanumeric plus underscore/dash.
+const SAFE_ID = /^[a-zA-Z0-9_-]{1,64}$/;
+
+export async function GET(
+ request: Request,
+ context: { params: Promise<{ videoId: string }> }
+) {
+ if (!verifyTranscoderToken(request)) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { videoId } = await context.params;
+
+ if (!SAFE_ID.test(videoId)) {
+ return NextResponse.json({ error: "Invalid video ID" }, { status: 400 });
+ }
+
+ const video = await prisma.video.findUnique({ where: { id: videoId } });
+ if (!video) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
+ // Only allow download while the job is actively claimed.
+ if (video.transcodingStatus !== "processing") {
+ return NextResponse.json(
+ { error: "Video is not in processing state" },
+ { status: 409 }
+ );
+ }
+
+ const filePath = path.join(ORIGINALS_DIR, `${videoId}.mp4`);
+
+ // Security: ensure the resolved path stays within ORIGINALS_DIR.
+ const resolved = path.resolve(filePath);
+ if (!resolved.startsWith(path.resolve(ORIGINALS_DIR))) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ try {
+ const stat = await fs.stat(resolved);
+ const nodeStream = fssync.createReadStream(resolved);
+ const webStream = Readable.toWeb(nodeStream) as ReadableStream;
+
+ return new Response(webStream, {
+ headers: {
+ "Content-Type": "video/mp4",
+ "Content-Length": stat.size.toString(),
+ "Content-Disposition": `attachment; filename="${videoId}.mp4"`,
+ },
+ });
+ } catch {
+ return NextResponse.json({ error: "File not found on disk" }, { status: 404 });
+ }
+}
diff --git a/app/api/transcoder/fail/[videoId]/route.ts b/app/api/transcoder/fail/[videoId]/route.ts
new file mode 100644
index 0000000..e610a17
--- /dev/null
+++ b/app/api/transcoder/fail/[videoId]/route.ts
@@ -0,0 +1,54 @@
+// app/api/transcoder/fail/[videoId]/route.ts
+// Marks a video job as failed. Called by the remote worker when transcoding
+// or upload encounters an unrecoverable error.
+
+import { NextResponse } from "next/server";
+import { prisma } from "@/lib/prisma";
+import { verifyTranscoderToken } from "@/lib/transcoder-auth";
+
+const SAFE_ID = /^[a-zA-Z0-9_-]{1,64}$/;
+
+export async function POST(
+ request: Request,
+ context: { params: Promise<{ videoId: string }> }
+) {
+ if (!verifyTranscoderToken(request)) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { videoId } = await context.params;
+
+ if (!SAFE_ID.test(videoId)) {
+ return NextResponse.json({ error: "Invalid video ID" }, { status: 400 });
+ }
+
+ const video = await prisma.video.findUnique({ where: { id: videoId } });
+ if (!video) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
+ try {
+ await prisma.video.update({
+ where: { id: videoId },
+ data: { transcodingStatus: "failed" },
+ });
+
+ // Log the error message from the worker if provided.
+ let workerError = "";
+ try {
+ const body = await request.json();
+ workerError = typeof body?.error === "string" ? body.error : "";
+ } catch {
+ // Body may be empty – that's fine.
+ }
+
+ console.error(
+ `[Transcoder Fail] ${videoId}${workerError ? ` – ${workerError}` : ""}`
+ );
+
+ return NextResponse.json({ success: true });
+ } catch (err) {
+ console.error(`[Transcoder Fail] DB error for ${videoId}:`, err);
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+}
diff --git a/app/api/transcoder/upload/[videoId]/route.ts b/app/api/transcoder/upload/[videoId]/route.ts
new file mode 100644
index 0000000..52ee620
--- /dev/null
+++ b/app/api/transcoder/upload/[videoId]/route.ts
@@ -0,0 +1,140 @@
+// app/api/transcoder/upload/[videoId]/route.ts
+// Receives a ZIP archive of the completed HLS package from the remote worker,
+// extracts it to disk, validates it, renames the temp dir to its final name,
+// and marks the video as transcoded.
+//
+// The request body must be raw application/zip (no multipart wrapper).
+// The file is streamed to disk before extraction – never fully buffered in RAM.
+
+import { NextResponse } from "next/server";
+import * as fssync from "fs";
+import * as fs from "fs/promises";
+import * as path from "path";
+import { Readable } from "stream";
+import { pipeline } from "stream/promises";
+import * as unzipper from "unzipper";
+import { prisma } from "@/lib/prisma";
+import { verifyTranscoderToken } from "@/lib/transcoder-auth";
+
+// Allow up to 45 minutes for very large uploads.
+export const maxDuration = 2700;
+
+const UPLOADS_DIR = process.env.UPLOADS_DIR ?? "/uploads";
+const HLS_ROOT = path.join(UPLOADS_DIR, "hls");
+
+const SAFE_ID = /^[a-zA-Z0-9_-]{1,64}$/;
+
+export async function POST(
+ request: Request,
+ context: { params: Promise<{ videoId: string }> }
+) {
+ if (!verifyTranscoderToken(request)) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { videoId } = await context.params;
+
+ if (!SAFE_ID.test(videoId)) {
+ return NextResponse.json({ error: "Invalid video ID" }, { status: 400 });
+ }
+
+ const video = await prisma.video.findUnique({ where: { id: videoId } });
+ if (!video) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+ if (video.transcodingStatus !== "processing") {
+ return NextResponse.json(
+ { error: "Video is not in processing state" },
+ { status: 409 }
+ );
+ }
+
+ await fs.mkdir(HLS_ROOT, { recursive: true });
+
+ const tempZipPath = path.join(HLS_ROOT, `${videoId}.incoming.zip`);
+ const tempDir = path.join(HLS_ROOT, `${videoId}.tmp`);
+ const finalDir = path.join(HLS_ROOT, videoId);
+
+ // Security: path traversal guard.
+ for (const p of [tempZipPath, tempDir, finalDir]) {
+ if (!path.resolve(p).startsWith(path.resolve(HLS_ROOT))) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+ }
+
+ try {
+ if (!request.body) {
+ return NextResponse.json({ error: "Empty request body" }, { status: 400 });
+ }
+
+ // 1. Stream upload body to a temp zip file on disk.
+ const nodeReadable = Readable.fromWeb(
+ request.body as ReadableStream
+ );
+ const writeStream = fssync.createWriteStream(tempZipPath);
+ await pipeline(nodeReadable, writeStream);
+
+ // 2. Prepare extraction directory.
+ if (fssync.existsSync(tempDir)) {
+ await fs.rm(tempDir, { recursive: true, force: true });
+ }
+ await fs.mkdir(tempDir, { recursive: true });
+
+ // 3. Stream-extract the zip.
+ await fssync
+ .createReadStream(tempZipPath)
+ .pipe(unzipper.Extract({ path: tempDir }))
+ .promise();
+
+ // 4. Remove temp zip.
+ await fs.unlink(tempZipPath).catch(() => {});
+
+ // 5. Validate contents.
+ const masterPath = path.join(tempDir, "master.m3u8");
+ if (!fssync.existsSync(masterPath)) {
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
+ return NextResponse.json(
+ { error: "master.m3u8 not found in archive" },
+ { status: 422 }
+ );
+ }
+
+ const files = await fs.readdir(tempDir);
+ const hasVariant = files.some(
+ (f) => f.endsWith(".m3u8") && f !== "master.m3u8"
+ );
+ if (!hasVariant) {
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
+ return NextResponse.json(
+ { error: "No variant playlist found in archive" },
+ { status: 422 }
+ );
+ }
+
+ // 6. Remove any stale final directory and atomically rename.
+ if (fssync.existsSync(finalDir)) {
+ await fs.rm(finalDir, { recursive: true, force: true });
+ }
+ await fs.rename(tempDir, finalDir);
+
+ // 7. Mark as transcoded in the database.
+ await prisma.video.update({
+ where: { id: videoId },
+ data: { transcodingStatus: "transcoded" },
+ });
+
+ console.log(`[Transcoder Upload] ${videoId} – success (${files.length} files)`);
+ return NextResponse.json({ success: true });
+ } catch (err) {
+ console.error(`[Transcoder Upload] Error for ${videoId}:`, err);
+
+ // Best-effort cleanup.
+ await fs.unlink(tempZipPath).catch(() => {});
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
+
+ return NextResponse.json(
+ { error: "Upload processing failed" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/user/unlocks/route.ts b/app/api/user/unlocks/route.ts
new file mode 100644
index 0000000..490b5cf
--- /dev/null
+++ b/app/api/user/unlocks/route.ts
@@ -0,0 +1,104 @@
+// app/api/user/unlocks/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const userEmail = session.user.email;
+
+ const user = await prisma.user.findUnique({
+ where: { email: userEmail },
+ select: { id: true },
+ });
+
+ if (!user)
+ return NextResponse.json({ error: 'user not found' }, { status: 404 });
+
+ // Fetch all VideoUnlock records for this user
+ const unlocks = await prisma.videoUnlock.findMany({
+ where: { userId: user.id },
+ select: { id: true, videoId: true, unlockedAt: true, createdAt: true },
+ orderBy: { unlockedAt: 'desc' },
+ });
+
+ return NextResponse.json({ unlocks });
+ } catch (err: any) {
+ console.error('user unlocks GET error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
+
+export async function POST(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const userEmail = session.user.email;
+ const body = await req.json();
+ const { videoId } = body;
+
+ if (!videoId) {
+ return NextResponse.json({ error: 'videoId required' }, { status: 400 });
+ }
+
+ const user = await prisma.user.findUnique({
+ where: { email: userEmail },
+ select: { id: true },
+ });
+
+ if (!user)
+ return NextResponse.json({ error: 'user not found' }, { status: 404 });
+
+ // Verify video exists
+ const video = await prisma.video.findUnique({
+ where: { id: videoId },
+ select: { id: true, locked: true, instantAccess: true },
+ });
+
+ if (!video) {
+ return NextResponse.json({ error: 'video not found' }, { status: 404 });
+ }
+
+ // Check if already unlocked
+ const existingUnlock = await prisma.videoUnlock.findUnique({
+ where: { userId_videoId: { userId: user.id, videoId } },
+ });
+
+ if (existingUnlock) {
+ return NextResponse.json({
+ message: 'Video already unlocked',
+ unlock: existingUnlock
+ });
+ }
+
+ // Create the unlock record
+ const unlock = await prisma.videoUnlock.create({
+ data: {
+ userId: user.id,
+ videoId: videoId,
+ },
+ });
+
+ return NextResponse.json({
+ message: 'Video unlocked successfully',
+ unlock
+ });
+
+ } catch (err: any) {
+ console.error('user unlocks POST error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/users/route.ts b/app/api/users/route.ts
new file mode 100644
index 0000000..485b6f2
--- /dev/null
+++ b/app/api/users/route.ts
@@ -0,0 +1,9 @@
+// app/api/users/route.ts
+import { NextResponse } from "next/server";
+import { prisma } from "../../../lib/prisma";
+
+
+export async function GET() {
+ const users = await prisma.user.findMany({ select: { id: true, name: true, email: true } });
+ return NextResponse.json(users);
+}
diff --git a/app/api/videos/[id]/route.ts b/app/api/videos/[id]/route.ts
new file mode 100644
index 0000000..a807688
--- /dev/null
+++ b/app/api/videos/[id]/route.ts
@@ -0,0 +1,109 @@
+// app/api/videos/[id]/route.ts
+import { NextResponse } from "next/server";
+import { prisma } from "../../../../lib/prisma";
+import { getServerSession } from "next-auth";
+import { authOptions } from "../../../../lib/auth-options";
+import { getVideoUrls } from "../../../../lib/video-urls";
+
+export async function GET(req: Request, context: any) {
+ try {
+ // unwrap params
+ let params = context?.params;
+ if (typeof params?.then === "function") params = await params;
+
+ const id = params?.id;
+ if (!id) return NextResponse.json({ error: "Missing video id" }, { status: 400 });
+
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const user = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ include: {
+ enrollments: true,
+ },
+ });
+ if (!user) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const video = await prisma.video.findUnique({
+ where: { id },
+ include: {
+ playlist: {
+ include: {
+ course: true,
+ courses: true,
+ },
+ },
+ uploader: {
+ select: {
+ id: true,
+ name: true,
+ image: true,
+ },
+ },
+ videoCourses: true,
+ },
+ });
+ if (!video) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ const userCourseIds = user.enrollments.map((enrollment) => enrollment.courseId);
+
+ const playlistCourseIds = new Set([video.playlist.courseId]);
+ (video.playlist.courses || []).forEach((mapping) => {
+ playlistCourseIds.add(mapping.courseId);
+ });
+
+ const hasPlaylistAccess = [...playlistCourseIds].some((courseId) =>
+ userCourseIds.includes(courseId)
+ );
+ if (!hasPlaylistAccess) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const restrictedCourseIds = (video.videoCourses || [])
+ .filter((assignment) => assignment.exclusive)
+ .map((assignment) => assignment.courseId);
+ if (
+ restrictedCourseIds.length > 0 &&
+ !restrictedCourseIds.some((courseId) => userCourseIds.includes(courseId))
+ ) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ // Find the next video (index = current.index + 1)
+ const next = await prisma.video.findFirst({
+ where: {
+ playlistId: video.playlistId,
+ index: video.index + 1,
+ },
+ include: {
+ uploader: {
+ select: {
+ id: true,
+ name: true,
+ image: true,
+ },
+ },
+ },
+ });
+
+ // Add video URLs with HLS support
+ const videoUrls = getVideoUrls(video.id, video.url, video.transcodingStatus as any);
+ const responseVideo = {
+ ...video,
+ videoUrls,
+ transcodingStatus: video.transcodingStatus,
+ restrictedCourseIds,
+ };
+
+ // Ensure we return the URL field explicitly (player expects `video.url`).
+ return NextResponse.json({ video: responseVideo, next });
+ } catch (err: any) {
+ console.error("GET /api/videos/[id] error:", err);
+ return NextResponse.json({ error: "Server error" }, { status: 500 });
+ }
+}
diff --git a/app/api/videos/hls/[...path]/route.ts b/app/api/videos/hls/[...path]/route.ts
new file mode 100644
index 0000000..8fb8b5f
--- /dev/null
+++ b/app/api/videos/hls/[...path]/route.ts
@@ -0,0 +1,109 @@
+// /api/videos/hls/[...path]/route.ts
+// This endpoint serves HLS playlists and segments from the HLS directory
+
+import { NextResponse } from 'next/server';
+import * as fs from 'fs';
+import * as path from 'path';
+
+// Path to HLS directory (should match UPLOADS_DIR/hls)
+const HLS_DIR = path.join(process.env.UPLOADS_DIR || '/uploads', 'hls');
+
+export async function GET(
+ request: Request,
+ context: { params: Promise<{ path?: string[] }> }
+) {
+ try {
+ const params = await context.params;
+ const pathSegments = params?.path || [];
+
+ if (pathSegments.length === 0) {
+ return NextResponse.json(
+ { error: 'Invalid HLS request' },
+ { status: 400 }
+ );
+ }
+
+ // Construct the file path and validate it
+ const requestedPath = path.join(HLS_DIR, ...pathSegments);
+
+ // Security: prevent directory traversal attacks
+ if (!requestedPath.startsWith(HLS_DIR)) {
+ return NextResponse.json(
+ { error: 'Invalid request' },
+ { status: 403 }
+ );
+ }
+
+ // Check if file exists
+ if (!fs.existsSync(requestedPath)) {
+ console.log(`[HLS] File not found: ${requestedPath}`);
+ return NextResponse.json(
+ { error: 'Not found' },
+ { status: 404 }
+ );
+ }
+
+ // Read the file
+ const fileContent = fs.readFileSync(requestedPath);
+
+ // Determine content type based on file extension
+ let contentType = 'application/octet-stream';
+
+ if (requestedPath.endsWith('.m3u8')) {
+ contentType = 'application/vnd.apple.mpegurl';
+ } else if (requestedPath.endsWith('.ts')) {
+ contentType = 'video/mp2t';
+ } else if (requestedPath.endsWith('.mp4')) {
+ contentType = 'video/mp4';
+ }
+
+ // Return the file with appropriate headers
+ return new NextResponse(fileContent, {
+ status: 200,
+ headers: {
+ 'Content-Type': contentType,
+ 'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
+ 'Access-Control-Allow-Origin': '*', // Allow CORS if needed
+ 'Accept-Ranges': 'bytes',
+ },
+ });
+ } catch (error) {
+ console.error('[HLS] Error serving HLS file:', error);
+ return NextResponse.json(
+ { error: 'Internal server error' },
+ { status: 500 }
+ );
+ }
+}
+
+// HEAD request support for playlist validation
+export async function HEAD(
+ request: Request,
+ context: { params: Promise<{ path?: string[] }> }
+) {
+ try {
+ const params = await context.params;
+ const pathSegments = params?.path || [];
+
+ if (pathSegments.length === 0) {
+ return new NextResponse(null, { status: 400 });
+ }
+
+ const requestedPath = path.join(HLS_DIR, ...pathSegments);
+
+ // Security: prevent directory traversal attacks
+ if (!requestedPath.startsWith(HLS_DIR)) {
+ return new NextResponse(null, { status: 403 });
+ }
+
+ // Check if file exists
+ if (!fs.existsSync(requestedPath)) {
+ return new NextResponse(null, { status: 404 });
+ }
+
+ return new NextResponse(null, { status: 200 });
+ } catch (error) {
+ console.error('[HLS] Error in HEAD request:', error);
+ return new NextResponse(null, { status: 500 });
+ }
+}
diff --git a/app/api/videos/latest/route.ts b/app/api/videos/latest/route.ts
new file mode 100644
index 0000000..2404f1a
--- /dev/null
+++ b/app/api/videos/latest/route.ts
@@ -0,0 +1,81 @@
+// app/api/videos/latest/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET() {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const user = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ });
+
+ if (!user) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ // Get all courses the user is enrolled in
+ const enrolledCourses = await prisma.enrollment.findMany({
+ where: { userId: user.id },
+ select: { courseId: true },
+ });
+
+ const enrolledCourseIds = enrolledCourses.map((e) => e.courseId);
+
+ if (enrolledCourseIds.length === 0) {
+ return NextResponse.json({ videos: [] });
+ }
+
+ // Get the latest 10 videos from those courses
+ // Videos can be in playlists that belong to enrolled courses
+ const videos = await prisma.video.findMany({
+ where: {
+ playlist: {
+ OR: [
+ { courseId: { in: enrolledCourseIds } },
+ {
+ courses: {
+ some: { courseId: { in: enrolledCourseIds } },
+ },
+ },
+ ],
+ },
+ },
+ select: {
+ id: true,
+ title: true,
+ durationSec: true,
+ thumbnail: true,
+ createdAt: true,
+ uploader: {
+ select: {
+ id: true,
+ name: true,
+ image: true,
+ },
+ },
+ playlist: {
+ select: {
+ id: true,
+ title: true,
+ },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ take: 10,
+ });
+
+ return NextResponse.json({ videos });
+ } catch (err: any) {
+ console.error('GET /api/videos/latest error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'Server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/watch-history/route.ts b/app/api/watch-history/route.ts
new file mode 100644
index 0000000..56b66c1
--- /dev/null
+++ b/app/api/watch-history/route.ts
@@ -0,0 +1,44 @@
+// app/api/watch-history/route.ts
+import { NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { prisma } from '@/lib/prisma';
+
+export async function GET(req: Request) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email)
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const userEmail = session.user.email;
+
+ const user = await prisma.user.findUnique({ where: { email: userEmail } });
+ if (!user)
+ return NextResponse.json({ error: 'user not found' }, { status: 404 });
+
+ // Fetch all videos watched by user, sorted by most recently updated
+ const watchHistory = await prisma.videoProgress.findMany({
+ where: { userId: user.id },
+ include: {
+ video: {
+ select: {
+ id: true,
+ title: true,
+ thumbnail: true,
+ durationSec: true,
+ url: true,
+ },
+ },
+ },
+ orderBy: { updatedAt: 'desc' },
+ });
+
+ return NextResponse.json(watchHistory);
+ } catch (err: any) {
+ console.error('watch history GET error', err);
+ return NextResponse.json(
+ { error: err?.message ?? 'server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/dashboard/dashboard-client.tsx b/app/dashboard/dashboard-client.tsx
new file mode 100644
index 0000000..1965b75
--- /dev/null
+++ b/app/dashboard/dashboard-client.tsx
@@ -0,0 +1,484 @@
+'use client';
+import * as React from 'react';
+import { useRouter } from 'next/navigation';
+import { useSession } from 'next-auth/react';
+import { usePlaylists } from '@/hooks/usePlaylists';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselNext,
+ CarouselPrevious,
+} from '@/components/ui/carousel';
+import { Badge } from '@/components/ui/badge';
+import { SegmentedProgressBar, WatchSegment } from '@/components/segmented-progress-bar';
+
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+
+import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
+import { Lock } from 'lucide-react';
+import Image from 'next/image';
+
+type VideoItem = {
+ id: string;
+ title: string;
+ durationSec?: number;
+ thumbnail?: string;
+ locked?: boolean;
+ instantAccess?: boolean;
+ uploader?: {
+ id: string;
+ name?: string;
+ image?: string;
+ };
+};
+
+type VideoProgressData = {
+ percent: number;
+ watchedSec: number;
+ segments: WatchSegment[];
+};
+
+export default function DashboardClient() {
+ const router = useRouter();
+ const { data: session } = useSession();
+ const { subjects, isLoading } = usePlaylists();
+ const [videoProgress, setVideoProgress] = React.useState>({});
+ const [userUnlocks, setUserUnlocks] = React.useState>(new Set());
+ const [latestVideos, setLatestVideos] = React.useState([]);
+ const [latestVideosLoading, setLatestVideosLoading] = React.useState(true);
+ const playlistsToShow = React.useMemo(() => {
+ const playlistMap = new Map<
+ string,
+ {
+ playlist: any;
+ courses: Map;
+ }
+ >();
+
+ subjects.forEach((subject: any) => {
+ (subject.playlists || []).forEach((playlist: any) => {
+ const entry = playlistMap.get(playlist.id);
+ const courseInfo = {
+ id: subject.id,
+ title: subject.title,
+ code: subject.code,
+ };
+
+ if (entry) {
+ entry.courses.set(courseInfo.id, courseInfo);
+ } else {
+ const coursesMap = new Map();
+ coursesMap.set(courseInfo.id, courseInfo);
+ playlistMap.set(playlist.id, { playlist, courses: coursesMap });
+ }
+ });
+ });
+
+ return Array.from(playlistMap.values()).map(({ playlist, courses }) => ({
+ ...playlist,
+ coursesForDisplay: Array.from(courses.values()),
+ }));
+ }, [subjects]);
+
+ // Fetch user's unlocks
+ React.useEffect(() => {
+ const fetchUserUnlocks = async () => {
+ try {
+ const res = await fetch('/api/user/unlocks');
+ if (res.ok) {
+ const data = await res.json();
+ const unlockedVideoIds = new Set((data.unlocks?.map((u: any) => u.videoId) ?? []) as string[]);
+ setUserUnlocks(unlockedVideoIds);
+ }
+ } catch (err) {
+ console.error('Failed to fetch user unlocks:', err);
+ }
+ };
+
+ fetchUserUnlocks();
+ }, []);
+
+ // Fetch latest videos from enrolled courses
+ React.useEffect(() => {
+ const fetchLatestVideos = async () => {
+ try {
+ setLatestVideosLoading(true);
+ const res = await fetch('/api/videos/latest');
+ if (res.ok) {
+ const data = await res.json();
+ setLatestVideos(data.videos ?? []);
+ }
+ } catch (err) {
+ console.error('Failed to fetch latest videos:', err);
+ } finally {
+ setLatestVideosLoading(false);
+ }
+ };
+
+ fetchLatestVideos();
+ }, []);
+
+ // Auto-sync enrollments for allowed students on first login
+ React.useEffect(() => {
+ const syncEnrollments = async () => {
+ if (!session?.user) return;
+
+ const userLevels = (session.user as any)?.levels;
+ console.log('[DASHBOARD] User levels from session:', userLevels);
+
+ if (!userLevels) {
+ console.log('[DASHBOARD] No levels found in session');
+ return;
+ }
+
+ const levels = userLevels
+ .split(',')
+ .map((level: string) => level.trim())
+ .filter((level: string) => level.length > 0);
+
+ console.log('[DASHBOARD] Parsed levels:', levels);
+
+ if (levels.length === 0) {
+ console.log('[DASHBOARD] No valid levels after parsing');
+ return;
+ }
+
+ try {
+ console.log('[DASHBOARD] Calling sync-enrollments with:', { levels });
+ const res = await fetch('/api/enrollments/sync', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ levels }),
+ });
+
+ if (!res.ok) {
+ console.error('[DASHBOARD] Failed to sync enrollments, status:', res.status);
+ } else {
+ const result = await res.json();
+ console.log('[DASHBOARD] Sync enrollments result:', result);
+ }
+ } catch (err) {
+ console.error('[DASHBOARD] Error syncing enrollments:', err);
+ }
+ };
+
+ syncEnrollments();
+ }, [session]);
+
+ React.useEffect(() => {
+ const fetchProgress = async () => {
+ const progressMap: Record = {};
+
+ // Fetch progress for playlist videos
+ for (const playlist of playlistsToShow) {
+ for (const video of playlist.videos || []) {
+ try {
+ const [progressRes, segmentsRes] = await Promise.all([
+ fetch(`/api/progress?videoId=${video.id}`),
+ fetch(`/api/progress/segments?videoId=${video.id}`),
+ ]);
+
+ if (progressRes.ok) {
+ const progressData = await progressRes.json();
+ const segmentsData = segmentsRes.ok ? await segmentsRes.json() : { segments: [] };
+ progressMap[video.id] = {
+ percent: progressData.percent ?? 0,
+ watchedSec: progressData.watchedSec ?? 0,
+ segments: segmentsData.segments ?? [],
+ };
+ }
+ } catch (err) {
+ console.error(`Failed to fetch progress for video ${video.id}`, err);
+ progressMap[video.id] = { percent: 0, watchedSec: 0, segments: [] };
+ }
+ }
+ }
+
+ // Fetch progress for latest videos
+ for (const video of latestVideos) {
+ try {
+ const [progressRes, segmentsRes] = await Promise.all([
+ fetch(`/api/progress?videoId=${video.id}`),
+ fetch(`/api/progress/segments?videoId=${video.id}`),
+ ]);
+
+ if (progressRes.ok) {
+ const progressData = await progressRes.json();
+ const segmentsData = segmentsRes.ok ? await segmentsRes.json() : { segments: [] };
+ progressMap[video.id] = {
+ percent: progressData.percent ?? 0,
+ watchedSec: progressData.watchedSec ?? 0,
+ segments: segmentsData.segments ?? [],
+ };
+ }
+ } catch (err) {
+ console.error(`Failed to fetch progress for video ${video.id}`, err);
+ progressMap[video.id] = { percent: 0, watchedSec: 0, segments: [] };
+ }
+ }
+
+ setVideoProgress(progressMap);
+ };
+
+ if (playlistsToShow.length > 0 || latestVideos.length > 0) {
+ fetchProgress();
+ }
+ }, [playlistsToShow, latestVideos]);
+
+ const handleOpenVideo = (playlistId: string, videoId: string) => {
+ // navigate to videoplayer page using query params (keeps your current structure)
+ router.push(`/videoplayer?playlistId=${playlistId}&videoId=${videoId}`);
+ };
+
+ const formatDuration = (s?: number) => {
+ if (!s && s !== 0) return '';
+ const mins = Math.floor(s! / 60);
+ const secs = Math.floor(s! % 60)
+ .toString()
+ .padStart(2, '0');
+ return `${mins}:${secs}`;
+ };
+
+ return (
+
+
+
+
+
+
+
+ {isLoading && latestVideosLoading ? (
+
Loading…
+ ) : (
+
+ {/* Latest Videos Section */}
+ {!latestVideosLoading && latestVideos.length > 0 && (
+
+
+
Latest Videos
+
+ Recently uploaded from your enrolled courses
+
+
+
+
+
+ {latestVideos.map((v: any) => (
+
+ {
+ if (v.playlist?.id) {
+ handleOpenVideo(v.playlist.id, v.id);
+ }
+ }}
+ >
+
+
+
+
+
+ {v.uploader?.image && (
+
+
+
+ )}
+
+
+
+ {v.title}
+
+
+ {formatDuration(v.durationSec)}
+
+
+ {v.uploader?.name && (
+
+ {v.uploader.name}
+
+ )}
+ {v.playlist?.title && (
+
+ {v.playlist.title}
+
+ )}
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+ )}
+
+ {/* Playlists Section */}
+ {playlistsToShow.length === 0 && latestVideos.length === 0 ? (
+
No content available.
+ ) : (
+ <>
+ {playlistsToShow.length > 0 && (
+ <>
+
+
Playlists
+
+ Showing {playlistsToShow.length} playlist{playlistsToShow.length === 1 ? '' : 's'}
+
+
+ {playlistsToShow.map((pl: any) => (
+
+
+
+
{pl.title}
+
+ {pl.description}
+
+ {pl.coursesForDisplay?.length ? (
+
+ {pl.coursesForDisplay.map((course: any) => (
+
+ {course.code ?? course.title}
+
+ ))}
+
+ ) : null}
+
+
+
+
+
+ {pl.videos?.map((v: VideoItem) => (
+
+ {
+ if ((v as any).locked) return;
+ if ((v as any).instantAccess) {
+ handleOpenVideo(pl.id, v.id);
+ return;
+ }
+ if ((v as any).index === 0) {
+ handleOpenVideo(pl.id, v.id);
+ return;
+ }
+ if (userUnlocks.has(v.id)) {
+ handleOpenVideo(pl.id, v.id);
+ }
+ }}
+ >
+
+
+
+ {(v as any).locked || (!(v as any).instantAccess && (v as any).index !== 0 && !userUnlocks.has(v.id)) ? (
+
+
+
+ ) : null}
+
+
+ {(v as any).uploader?.image && (
+
+
+
+ )}
+
+
+
+ {v.title}
+
+
+ {formatDuration(v.durationSec)}
+
+
+ {(v as any).uploader?.name && (
+
+ {(v as any).uploader.name}
+
+ )}
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+ ))}
+ >
+ )}
+ >
+ )}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/app/dashboard/liked-videos-client.tsx b/app/dashboard/liked-videos-client.tsx
new file mode 100644
index 0000000..4c82b5f
--- /dev/null
+++ b/app/dashboard/liked-videos-client.tsx
@@ -0,0 +1,225 @@
+'use client';
+
+import * as React from 'react';
+import { useRouter } from 'next/navigation';
+import Image from 'next/image';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
+import { Button } from '@/components/ui/button';
+
+interface LikedVideoItem {
+ id: string;
+ videoId: string;
+ createdAt: string;
+ video: {
+ id: string;
+ title: string;
+ thumbnail?: string;
+ durationSec?: number;
+ url: string;
+ playlist: {
+ title: string;
+ course: {
+ id: string;
+ title: string;
+ };
+ };
+ };
+}
+
+export default function LikedVideosClient() {
+ const router = useRouter();
+ const [likes, setLikes] = React.useState([]);
+ const [isLoading, setIsLoading] = React.useState(true);
+
+ React.useEffect(() => {
+ const fetchLikes = async () => {
+ try {
+ const res = await fetch('/api/likes/all');
+ if (res.ok) {
+ const data = await res.json();
+ setLikes(data);
+ }
+ } catch (err) {
+ console.error('Failed to fetch liked videos', err);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchLikes();
+ }, []);
+
+ const handleVideoClick = (videoId: string) => {
+ router.push(`/videoplayer?videoId=${videoId}`);
+ };
+
+ const formatTime = (seconds?: number) => {
+ if (!seconds) return '0:00';
+ const mins = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
+ };
+
+ const formatDate = (dateString: string) => {
+ const date = new Date(dateString);
+ return date.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+ Liked Videos
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ Liked Videos
+
+
+ {likes.length === 0 ? (
+
+ ) : (
+
+
+
+
+ Thumbnail
+ Title
+ Playlist
+ Course
+ Duration
+ Liked On
+ Action
+
+
+
+ {likes.map((item) => (
+
+
+ handleVideoClick(item.video.id)}
+ >
+ {item.video.thumbnail ? (
+
+
+
+ ) : (
+
+
+ No image
+
+
+ )}
+
+
+
+ handleVideoClick(item.video.id)}
+ >
+ {item.video.title}
+
+
+
+
+ {item.video.playlist.title}
+
+
+
+
+ {item.video.playlist.course.title}
+
+
+
+
+ {formatTime(item.video.durationSec)}
+
+
+
+
+ {formatDate(item.createdAt)}
+
+
+
+ handleVideoClick(item.video.id)}
+ >
+ Watch
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/app/dashboard/liked-videos/page.tsx b/app/dashboard/liked-videos/page.tsx
new file mode 100644
index 0000000..6e8f8b3
--- /dev/null
+++ b/app/dashboard/liked-videos/page.tsx
@@ -0,0 +1,20 @@
+// app/dashboard/liked-videos/page.tsx
+import { Metadata } from 'next';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { redirect } from 'next/navigation';
+import LikedVideosClient from '../liked-videos-client';
+
+export const metadata: Metadata = {
+ title: 'Liked Videos | CMS',
+ description: 'View your liked videos',
+};
+
+export default async function LikedVideosPage() {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ return ;
+}
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
new file mode 100644
index 0000000..c3e9e77
--- /dev/null
+++ b/app/dashboard/page.tsx
@@ -0,0 +1,11 @@
+// app/dashboard/page.tsx (server component)
+import { requireUser } from '@/lib/auth-check';
+import DashboardClient from './dashboard-client'; // will be your existing client UI
+
+export default async function DashboardPage() {
+ // will redirect to /login if not authenticated
+ const session = await requireUser('/login');
+
+ // you can optionally pass session to client via props if desired:
+ return ;
+}
diff --git a/app/dashboard/watch-history-client.tsx b/app/dashboard/watch-history-client.tsx
new file mode 100644
index 0000000..57c8463
--- /dev/null
+++ b/app/dashboard/watch-history-client.tsx
@@ -0,0 +1,210 @@
+'use client';
+
+import * as React from 'react';
+import { useRouter } from 'next/navigation';
+import Image from 'next/image';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
+import { Button } from '@/components/ui/button';
+
+interface WatchHistoryItem {
+ id: string;
+ videoId: string;
+ lastPos?: number;
+ updatedAt: string;
+ video: {
+ id: string;
+ title: string;
+ thumbnail?: string;
+ durationSec?: number;
+ url: string;
+ };
+}
+
+export default function WatchHistoryClient() {
+ const router = useRouter();
+ const [history, setHistory] = React.useState([]);
+ const [isLoading, setIsLoading] = React.useState(true);
+
+ React.useEffect(() => {
+ const fetchHistory = async () => {
+ try {
+ const res = await fetch('/api/watch-history');
+ if (res.ok) {
+ const data = await res.json();
+ setHistory(data);
+ }
+ } catch (err) {
+ console.error('Failed to fetch watch history', err);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchHistory();
+ }, []);
+
+ const handleVideoClick = (videoId: string) => {
+ router.push(`/videoplayer?videoId=${videoId}`);
+ };
+
+ const formatTime = (seconds?: number) => {
+ if (!seconds) return '0:00';
+ const mins = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
+ };
+
+ const formatDate = (dateString: string) => {
+ const date = new Date(dateString);
+ return date.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+ Watch History
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ Watch History
+
+
+ {history.length === 0 ? (
+
+
No videos watched yet
+
+ ) : (
+
+
+
+
+ Thumbnail
+ Title
+ Last Position
+ Last Watched
+ Action
+
+
+
+ {history.map((item) => (
+
+
+ handleVideoClick(item.video.id)}
+ >
+ {item.video.thumbnail ? (
+
+
+
+ ) : (
+
+
+ No image
+
+
+ )}
+
+
+
+ handleVideoClick(item.video.id)}
+ >
+ {item.video.title}
+
+
+
+
+ {formatTime(item.lastPos)} /{' '}
+ {formatTime(item.video.durationSec)}
+
+
+
+
+ {formatDate(item.updatedAt)}
+
+
+
+ handleVideoClick(item.video.id)}
+ >
+ Resume
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/app/dashboard/watch-history/page.tsx b/app/dashboard/watch-history/page.tsx
new file mode 100644
index 0000000..eb1b47a
--- /dev/null
+++ b/app/dashboard/watch-history/page.tsx
@@ -0,0 +1,20 @@
+// app/dashboard/watch-history/page.tsx
+import { Metadata } from 'next';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth-options';
+import { redirect } from 'next/navigation';
+import WatchHistoryClient from '../watch-history-client';
+
+export const metadata: Metadata = {
+ title: 'Watch History | CMS',
+ description: 'View your video watch history',
+};
+
+export default async function WatchHistoryPage() {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ redirect('/login');
+ }
+
+ return ;
+}
diff --git a/app/favicon.ico b/app/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..4977ba978ef9dfd6887e3910bbbcae7a050baa2e
GIT binary patch
literal 6542
zcmbtYcT^Ku)PIwN5IP7_M5XLX5NS5V0tp}%q=|F^g94(IP^Fupi2}A|6%Zj&7ZDL*
zl_EtFU_lWSgk342L{K_HD4`@I-{79}{r8=-=X~?eyx*Jq?!9y8zPZ2qZrEBMmk`||
z3ILEedE%%Y04QXL0yr#kTa5gB4Y?_WnLCG_A^U_yc!qcb6EE_4Z`G3lo)^9Cygj|5
zf?K?gATs(VkDAy=_Dv7myvux_jb(A}qe-|o;*m0nw^*a<`Ua>jnJfk9t&Ht0I6}r&
zDSeNkH}N29o9Er4&%TCecx81}F}p*H
z!i0f#Bgc+6b)|C~C>1Zb2rYjev3dI}4j5oju=u@*pELS}AM+Le
zv3cz`@VO_3$v3!UfW2H$y;u{v8AR)P;niq&AEyk@W-vHSL+TdrcB8lR_F9;
zHuX^03IJdwLASK+#8V1W+@2R8zIfsNE2Y+^(M+m>YITxJR#7)HCNCN{Rd^r7{aea
z@WV&rTxVmQOA3ReHxZwb!6;vYw75?{5L&~IV`9Z!3BN@m!ur9M$}s~HK1zl-ElQhkKo#TlYv;vC%4r@n&NR6B`n9{Y
z8qKnczDDW&qO>%mbu%-Pyl4u0qZQyM@dNi|yAvlW~5lcdcrR6v#q$kM_bol1Q6PS*+ILa@?i
z`k!WuZcAoX#Tp=aH1}8OT*h7R6P}GT*?m85Qv$7=Fpf${J92|R)6d`CKrirU$zy65
zP(RJIo@kB?sWuoxr|dMu)V>B@K_y*{
z{UY>a^|2Tdk*w%^uI~3g&1d`NGV2IC$N*sAyRPdl9`~mAs?<-0=sdovB|>~xQ0tqv
zZjrg;#hxba9@5M!5SG*N%hvH%>@w=W*?6neVE8u%?v4vEe8@~Zb?0qLYq=bv38byb
zKReN-y#)a%VR-7X@%={@BRU%}K?q(uS?ZzhbN5R~vEBvJ`$`lF5bpreX%#(*DqmXw
z2JHm~E_(LQ!FI4cdN4I^4BM*fIdG)Ug=P{^G~JS7Q=BG!J8eCxS3O5H+!r*bU&
zFtDFWRNuw-1w9pM*i1$6;oADIf0(UtwA$1w0wG2n&vMC&`RzSG;8pOvt){Al<(*ACDlIf<-zzR(-+qbn$`$;XbNuA4{
z7WV>^sS@G$tCB;AXUqcc#_o}-pZc(mcPWBH7xvKV3}UL$-D<`9^%o_4ZDLu^E!OG7
z%Pzl$m06ACIbF5-F5g?fCp}nnxHv_UCDDX|7=x?&GrplUL{gN5Ok@MXiJSVlUO!Uga+C%B#cBM1)VM>w91&q#g
z;^2E@e3GI1+u_O;sc1Vay)e!i*QIG!>%=3M7oWn;7z5(E!B&O(aE;>bom2KZR3
zFH$CWra1XZ374e!PJ9fVCO{!SpCpc2BVplqrUaI~6B!7q{5#wig^W3?*Vxm0A4pm2
zD)blF@#4kJ7@zVJxaQ|(idGV(No*DEX&)zEeeGP|Q%|DQmsG-?)3|4DTgGk5j37CR
zG%8Ss|9ZFLqVJOxv{DjSKrg-s3f&mh#5aJagR(@nuo}afrXO#?7DlBYwJ+tX<)3(X
zSjv=`;loyG8*g^x)w_AVgu$0UUsVz=vSUEMcK0WY6VC)!x+0aJ6m0{VRFYtWfTA?d
zt-G33aB?Wo0kILK^Z(9Q%+Styx=E!Hq*XEY)pE_<-FYCDfP>tn_>e~F8D6}I8N>QO
z!@eH_|GF|vcyHgm;W;Z$dhXd2$<>{MvE{|bm=W`tB-eoDe*t6X!4+Xs-|7uV7iCaw
zJEZ?gRF+rVjd>9+`&Td$B^gsVk)oQFJ1mthjWVdlp5G})Ath~u3$yra1&zK27DO=)
zdTj?an4zfk6u7{Pt@0l#{++jk@r2Va+5`R>rt0ZY)`J46;VO3uqX(I_^K{C=N*gk7
zD9&J61O$kU8ksNK=sh(~a69ukt77txFzEFrq^)#VbWVnAp0W#Z6xI
z#wJcRR;?du%o(MQN-*5yJ2SJtMx?fsb>ssAk|gSrQy7yR8_@sd-71o(YG#bYK+Kzo!3v`NKAn
z6)nhgTnt&(pouEY!R{oMXf0?ROo7*4%cTiGUro7Kk>2z7>X@Eo6hS
z*t3#%0QrYJOex7+nC6s
zZW~Dc@wgQ*4=8#e#i|y})y{lu-^adFr#J1#6xW9%Ay(h5%!Avn+U8eMh7e`*l0aU3
zzGkYSt}8e3BmnR6;K(_Asg=CAuEpIxL7g4|ZM#6N8{T%jFG}h`BS0TVgbo8YX1$xS
zp2K8asSQG!twCfRskHN-sOOZf-aohs9E5kaih*_o@xb50Q7)QxbTKXlz~8ZOrWjUi
zK;`w^EM8F#;eI?cER<*>+z>omuL^+nPvfLPD)C;Oim+0?5)QPAfNoBfw>|4B1r-
zKuAjJ=&ucs+*rk_F70XA4Kh$6hK*=B6kqeAYjML0fLM7#iGG)*J)QkmpK5gICeIHG4hUye1&d1}U%jr11gSx!8yfDahT&jk
zslQGU4Yb=bPf57dYJGnTI*=D0(cD|ISsqmv>?wsbMtjA8aQNeF^*AO`t)K+$=8U-T
z{W7IM_=;SdfC2#`2-0ORp38Axn?Qin3$e0zb%d3s+mXj$07~0HsS~={;Sc8;T=mM&
z_Z{GgW_leek{j7;ipcXJ)|wJ^XgM+JL|3t_GytQ@;8x}qvmo;=lw3rCJ{_?3)>+<+
z>Z2jxHei+2Wfyih#g~cLd%AhcET!``o)A!N2c(?APHrMY;h^zP-T#Akd0#S_Y|1zGQZ4MdIW&<
zFQDgX=HR~GKxY853^qW$@X%=haI)amD5=^0kJ%}V^(H)$+!NgPSH>e~@ZYa-tEfP~
z0npJJ>pFoA2|7|)lIb`u34i+1KHBy6hw7{K*Y&QIxw_D}|MGe3&8YW}SGs4y0(ZHi
zJNhQ~q|`lhI#7LWyh+ffHGuAL_4e3NJ)@jh7I?cFM=O69G3uxWmx?S!?b_J?-i=ib
zIMpzAWsndpH!f`z0sd^dK^g=NR!-{EMWOV^)B1+RZbH63UBS1?SGOcS7w#z>Va0aF
zf!XLu!*_W}KwVzo7t?VQ95CuX_)Zi`(&mRB9=CvVB#qi$n}WA4OZL26DG+q_^T?51
zn2u9y%5<>pAr-(1ugM#8RfBDt1cx-CmD5zRD^;(KBVwSIN+&x)R~;n`hm1v`vf!;j
z`WxcM&5VOYpnVl8k*a-VC|1qViO`qQ#OuSeu>fX|xvRk~!e7I8)8cX3E<;ooF{rF!
zTNFSR(zta31Mepa{~SektO7=7_`vc|d#RT@ucSCk2^P{qx6w`EVZx;aEm7yk#+-Ep
zl<$n%dq$`+C_QRkde9l?e7(+^Gxa*aU7lo8Q9nvjBeE+MrAe4Gy8H$2`@MXcf?
z%do57p;9T;Amk%V^A+`D+&FN{*9i>2R;r
zq>s>=F2B2?Q%Ab>$30~S99TnAjXA#M`uDIApUo(^E|oH{JzVN9_FVHvEewd;EZ7K5
zg5kb5p2>iAdzh>nuj(j4c0QonaU415*y$pU%V3s?15*%W!=_hQRMwYmKivQ3Cq%xm
zM#YcF5mAU?gzO|ycU)J`K-7XLL|Yl^@;@vodvGZTEry_o0KB`%NX+;IRvtxU(+eCB
zHW@y?sD^50Q_^6G6
zhleVR#sjn3C3G-wRf8L3^6*$);Xn^<wV%6IEktCnpg=XH7~ntXdx|A~Th2v%ieLC%qNrjYnGF>qU{PDs=3kgwp^+{TaY
zDZfW&nkrKmhLZ`df~`Rxyl(YWdv#cUYcj^$aHSi}Qt7rmkV!{_mTz^*If7#zNA26g
zKTifz)FXp#(6Y69x~{HQ{~6?>DmEoIi$|RkHZgk#4os`9CJBcqOyE&u$*ZF-)4*yW`V%>H&Av3b
zmZqhynvrx=Z$hX9D3mbc}mP=)HtL2htz3}g|>s2wIsb3(*
z_hUh89yOJ}xXW8UNWZjabdC38AS>qW?IkgNR}h3nRl3=HP(H8hmMp1y0;%#Srwb2L
z5i*7m10^~YUP{lyVmu!pHJ%1oyrk7#y4{?8^I4k!*n1Z5Mgs(al|zKQ%QEk?tmo-
z2KoiVD*G|!Zpx<-f`?+brBaI9^nsl0l0%s^AdW(`2X9pJ?2MZPhQa5|MIf8Nb8sx0
z_%=(Y!IAzs&;QA6s`Jx{N)hAgIA~rRoKB@!lOc7ZrI&a~2n8PXiYj_IQ5yMUi!(7C
zO@6isecgseXf0^n&pU63{&sP!MG#RW$6c&BlUijXuqUHxA%-$|(?~zEDww;-9b$A(
zR?=*kOXa-Vv~D?Ax#Cj)P)T<^_sI$4^_nq>4_P%@(P;PpxD&rNF-O=NX~ByPR6+5p
zjNSPDcg?SE9I08_^Fm#<<%4$k~en`!TWmrey$;-5!v?@{S}leZj}(pKj^7`Z&qff8pzXIoDih4tm(L-h&5Z46qLxuT+|C!~4sqGPZqyG)5c6kywdJ
z1A~Ooj}_ru)L$Zio4wTjyE58*UGz&5#^l$#mNRzu?7-lVGh#d1gM&|ljn~gU
zRxc-v8#*f%;YXHWFPP3_tI>h|L%7w@_x$5pzLD1so
zCUzMq3tohI7fIVLb4}=w{=Gd1JLQ3V;S8tTcpVKZJ^Sq@1(5Dm2THk39s=*X4SH9E
zfqX$LhjA|u4fAGxJ`PU()-%dO{C5XbuwrZ9@j@D-$Z96f9NpkJ||TOOgM-Wy
+
+ {/* pass session to Providers to avoid client/server mismatch */}
+ {children}
+
+
+
+ );
+}
diff --git a/app/login/page.tsx b/app/login/page.tsx
new file mode 100644
index 0000000..2bc0649
--- /dev/null
+++ b/app/login/page.tsx
@@ -0,0 +1,26 @@
+import { GalleryVerticalEnd } from "lucide-react"
+
+import { LoginForm } from "@/components/login-form"
+
+export default function LoginPage() {
+ return (
+
+ )
+}
diff --git a/app/login/unauthorized/page.tsx b/app/login/unauthorized/page.tsx
new file mode 100644
index 0000000..c4abdd0
--- /dev/null
+++ b/app/login/unauthorized/page.tsx
@@ -0,0 +1,53 @@
+// app/login/unauthorized/page.tsx
+'use client';
+import { AlertCircle } from 'lucide-react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import Link from 'next/link';
+import { signOut } from 'next-auth/react';
+import { useEffect } from 'react';
+
+export default function UnauthorizedPage() {
+ // Clear any partial session data when landing on this page
+ useEffect(() => {
+ signOut({ redirect: false });
+ }, []);
+ return (
+
+
+
+
+
+ Not Registered
+
+ Your email is not registered to access this platform.
+
+
+
+
+
What does this mean?
+
+ This is a university-only platform. If you believe you should have access, please contact your administrator or department coordinator.
+
+
+
+
+
+ Try Another Email
+
+
+
+
+ Go Home
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
new file mode 100644
index 0000000..2bc0649
--- /dev/null
+++ b/app/page.tsx
@@ -0,0 +1,26 @@
+import { GalleryVerticalEnd } from "lucide-react"
+
+import { LoginForm } from "@/components/login-form"
+
+export default function LoginPage() {
+ return (
+
+ )
+}
diff --git a/app/providers.tsx b/app/providers.tsx
new file mode 100644
index 0000000..0908ca5
--- /dev/null
+++ b/app/providers.tsx
@@ -0,0 +1,22 @@
+// app/providers.tsx (client)
+'use client';
+
+import { SessionProvider } from 'next-auth/react';
+import { ThemeProvider } from 'next-themes';
+import type { PropsWithChildren } from 'react';
+
+type Props = PropsWithChildren<{ session?: any }>;
+
+export function Providers({ children, session }: Props) {
+ return (
+
+
+ defaultTheme="dark" // default to dark to match server if you want dark by default
+ enableSystem={false}
+ >
+ {children}
+
+
+ );
+}
diff --git a/app/videoplayer/page.tsx b/app/videoplayer/page.tsx
new file mode 100644
index 0000000..aa9e700
--- /dev/null
+++ b/app/videoplayer/page.tsx
@@ -0,0 +1,785 @@
+'use client';
+
+import React from 'react';
+import { useSearchParams, useRouter } from 'next/navigation';
+import { usePlaylist } from '@/hooks/usePlaylist';
+import { useVideo } from '@/hooks/useVideo';
+
+import {
+ Card,
+ CardContent,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { SegmentedProgressBar, WatchSegment } from '@/components/segmented-progress-bar';
+
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import { CommentsSection } from '@/components/comments-section';
+import { formatDistanceToNow } from 'date-fns';
+
+import { Lock, Heart, Edit } from 'lucide-react';
+
+import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
+import { Button } from '@/components/ui/button';
+import { HlsPlayer } from '@/components/hls';
+import { useSession } from 'next-auth/react';
+import Image from 'next/image';
+
+type VideoFromApi = {
+ id: string;
+ title: string;
+ description?: string | null;
+ durationSec?: number | null;
+ duration?: string | null;
+ thumbnail?: string | null;
+ url?: string | null;
+ locked?: boolean;
+ createdAt?: string | null;
+ transcodingStatus?: string;
+ videoUrls?: {
+ hlsUrl: string | null;
+ mp4Url: string;
+ };
+ uploader?: {
+ id: string;
+ name?: string;
+ image?: string;
+ } | null;
+};
+
+export function useWatchTracker(
+ videoRef: React.RefObject,
+ opts: { sendIntervalMs?: number; commitIntervalSec?: number; debug?: boolean } = {}
+) {
+ const sendIntervalMs = opts.sendIntervalMs ?? 5000;
+ const commitIntervalSec = opts.commitIntervalSec ?? 3;
+ const debug = !!opts.debug;
+
+ const rangesRef = React.useRef>([]);
+ const lastTimeRef = React.useRef(null);
+ const currentRangeStartRef = React.useRef(null);
+ const sendTimerRef = React.useRef(null);
+ const commitTimerRef = React.useRef(null);
+
+ // merging helper
+ function mergeRanges(ranges: Array<[number, number]>) {
+ if (!ranges.length) return [];
+ ranges.sort((a, b) => a[0] - b[0]);
+ const merged: Array<[number, number]> = [];
+ for (const [s, e] of ranges) {
+ if (!merged.length) merged.push([s, e]);
+ else {
+ const last = merged[merged.length - 1];
+ if (s <= last[1] + 0.5) last[1] = Math.max(last[1], e);
+ else merged.push([s, e]);
+ }
+ }
+ return merged;
+ }
+
+ function addRange(s: number, e: number) {
+ if (e <= s) return;
+ if (debug) console.debug("[tracker] addRange", s, e);
+ rangesRef.current.push([s, e]);
+ rangesRef.current = mergeRanges(rangesRef.current);
+ }
+
+ function getUniqueWatchedSec() {
+ let sum = 0;
+ for (const [a, b] of rangesRef.current) sum += Math.max(0, b - a);
+ return Math.round(sum);
+ }
+
+ // commit the "current" playing segment to ranges (useful while playing)
+ function commitCurrentRange() {
+ const el = videoRef.current;
+ if (!el) return;
+ const start = currentRangeStartRef.current;
+ const now = el.currentTime;
+ if (start === null) return;
+ // only commit if we've moved forward at least 0.5s to avoid noise
+ if (now - start >= 0.5) {
+ addRange(start, now);
+ // keep currentRangeStart open at 'now' so we continue accumulating
+ currentRangeStartRef.current = now;
+ }
+ }
+
+ // send progress to server (same signature you had)
+ async function sendProgress(args: { videoId: string; playlistId?: string; duration: number; extra?: Record }) {
+ const { videoId, playlistId, duration, extra } = args;
+ const watchedSec = getUniqueWatchedSec();
+ const el = videoRef.current;
+ const lastPos = Math.floor(el?.currentTime ?? 0);
+
+ if (!duration || duration <= 0) return null;
+ if (watchedSec === 0 && (el?.paused ?? true)) return null;
+
+ const payload = { videoId, playlistId, watchedSec, lastPos, duration, ...extra };
+ try {
+ if (debug) console.debug("[tracker] sendProgress", payload);
+ const res = await fetch("/api/progress", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+
+ // If video not found, stop trying to track progress
+ if (!res.ok) {
+ if (res.status === 404) {
+ console.warn(`[tracker] Video ${videoId} not found, stopping progress tracking`);
+ stopAutoSend(); // Stop automatic progress tracking for deleted video
+ return null;
+ }
+ return null;
+ }
+
+ return res.ok ? await res.json().catch(() => null) : null;
+ } catch (err) {
+ if (debug) console.warn("[tracker] sendProgress error", err);
+ return null;
+ }
+ }
+
+ // start/stop auto-send
+ function startAutoSend(args: { videoId: string; playlistId?: string; duration: number }) {
+ stopAutoSend(); // clear existing
+
+ // immediate send (best-effort)
+ sendProgress(args).catch(() => {});
+
+ // periodic send
+ sendTimerRef.current = window.setInterval(() => {
+ const watched = getUniqueWatchedSec();
+ const el = videoRef.current;
+ const isPlaying = !!(el && !el.paused && !el.ended);
+ if (watched > 0 || isPlaying) {
+ sendProgress(args).catch(() => {});
+ }
+ }, sendIntervalMs) as unknown as number;
+
+ // commit currently playing ranges periodically (so continuous play is captured)
+ commitTimerRef.current = window.setInterval(() => {
+ commitCurrentRange();
+ }, commitIntervalSec * 1000) as unknown as number;
+
+ if (debug) console.debug("[tracker] startAutoSend", { sendIntervalMs, commitIntervalSec });
+ }
+
+ function stopAutoSend(opts?: { sendFinal?: boolean; videoId?: string; playlistId?: string; duration?: number }) {
+ if (sendTimerRef.current) {
+ window.clearInterval(sendTimerRef.current);
+ sendTimerRef.current = null;
+ }
+ if (commitTimerRef.current) {
+ window.clearInterval(commitTimerRef.current);
+ commitTimerRef.current = null;
+ }
+ if (opts?.sendFinal && opts.videoId && opts.duration) {
+ sendProgress({ videoId: opts.videoId, playlistId: opts.playlistId, duration: opts.duration }).catch(() => {});
+ }
+ if (debug) console.debug("[tracker] stopAutoSend");
+ }
+
+ // attach listeners to populate rangesRef
+ React.useEffect(() => {
+ const el = videoRef.current;
+ if (!el) {
+ if (debug) console.debug("[tracker] no video element to attach");
+ return;
+ }
+
+ currentRangeStartRef.current = null;
+ lastTimeRef.current = el.currentTime ?? 0;
+
+ const onPlay = () => {
+ currentRangeStartRef.current = el.currentTime;
+ lastTimeRef.current = el.currentTime;
+ if (debug) console.debug("[tracker] play", currentRangeStartRef.current);
+ };
+
+ const onPause = () => {
+ if (currentRangeStartRef.current !== null) {
+ addRange(currentRangeStartRef.current, el.currentTime);
+ currentRangeStartRef.current = null;
+ }
+ if (debug) console.debug("[tracker] pause, ranges:", rangesRef.current);
+ };
+
+ const onTimeUpdate = () => {
+ const now = el.currentTime;
+ const last = lastTimeRef.current ?? now;
+ // detect seek (big jump)
+ if (Math.abs(now - last) > 2.5) {
+ if (currentRangeStartRef.current !== null) {
+ addRange(currentRangeStartRef.current, last);
+ }
+ currentRangeStartRef.current = now;
+ if (debug) console.debug("[tracker] seek detected, start:", now);
+ }
+ lastTimeRef.current = now;
+ // we do not addRange here to avoid flooding; commit timer handles mid-play commits
+ };
+
+ const onEnded = () => {
+ if (currentRangeStartRef.current !== null) {
+ addRange(currentRangeStartRef.current, el.duration ?? lastTimeRef.current ?? 0);
+ currentRangeStartRef.current = null;
+ }
+ if (debug) console.debug("[tracker] ended, ranges:", rangesRef.current);
+ };
+
+ el.addEventListener("play", onPlay);
+ el.addEventListener("pause", onPause);
+ el.addEventListener("timeupdate", onTimeUpdate);
+ el.addEventListener("ended", onEnded);
+
+ return () => {
+ el.removeEventListener("play", onPlay);
+ el.removeEventListener("pause", onPause);
+ el.removeEventListener("timeupdate", onTimeUpdate);
+ el.removeEventListener("ended", onEnded);
+ stopAutoSend();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [videoRef.current]);
+
+ return {
+ getUniqueWatchedSec,
+ addRange,
+ sendProgress,
+ startAutoSend,
+ stopAutoSend,
+ };
+}
+
+
+export default function Page() {
+ const search = useSearchParams();
+ const router = useRouter();
+ const playlistId = search.get('playlistId') ?? undefined;
+ const videoId = search.get('videoId') ?? undefined;
+
+ const { playlist, isLoading: playlistLoading } = usePlaylist(playlistId);
+ const { video, next, isLoading: videoLoading } = useVideo(videoId);
+
+ // Segments state for current video and playlist videos
+ const [currentSegments, setCurrentSegments] = React.useState([]);
+ const [playlistSegments, setPlaylistSegments] = React.useState>({});
+ const [currentProgress, setCurrentProgress] = React.useState(0);
+
+ // Per-user unlock state
+ const [userUnlocks, setUserUnlocks] = React.useState>(new Set()); // Set of unlocked videoIds
+ const [videoInstantAccess, setVideoInstantAccess] = React.useState>({}); // videoId -> instantAccess
+ const [unlockingVideo, setUnlockingVideo] = React.useState(null); // videoId being unlocked
+
+ // inside your videoplayer component
+const videoRef = React.useRef(null);
+const tracker = useWatchTracker(videoRef, { sendIntervalMs: 5000 });
+
+// Fetch segments for current video
+React.useEffect(() => {
+ if (!videoId) return;
+
+ const fetchSegments = async () => {
+ try {
+ const res = await fetch(`/api/progress/segments?videoId=${videoId}`);
+ if (res.ok) {
+ const data = await res.json();
+ setCurrentSegments(data.segments ?? []);
+ }
+ const progressRes = await fetch(`/api/progress?videoId=${videoId}`);
+ if (progressRes.ok) {
+ const data = await progressRes.json();
+ setCurrentProgress(data.percent ?? 0);
+ }
+ } catch (err) {
+ console.error('Failed to fetch segments:', err);
+ }
+ };
+
+ fetchSegments();
+ // Refresh segments every 5 seconds
+ const interval = setInterval(fetchSegments, 5000);
+ return () => clearInterval(interval);
+}, [videoId]);
+
+// Fetch segments for all playlist videos
+React.useEffect(() => {
+ if (!playlist?.videos || playlist.videos.length === 0) return;
+
+ const fetchPlaylistSegments = async () => {
+ const segments: Record = {};
+ const instantAccess: Record = {};
+
+ for (const video of playlist.videos) {
+ // Fetch segments
+ try {
+ const res = await fetch(`/api/progress/segments?videoId=${video.id}`);
+ if (res.ok) {
+ const data = await res.json();
+ segments[video.id] = data.segments ?? [];
+ }
+ } catch (err) {
+ console.error(`Failed to fetch segments for video ${video.id}:`, err);
+ }
+
+ // Track instantAccess status from video object
+ instantAccess[video.id] = (video as any).instantAccess ?? false;
+ }
+
+ setPlaylistSegments(segments);
+ setVideoInstantAccess(instantAccess);
+ };
+
+ fetchPlaylistSegments();
+}, [playlist?.videos]);
+
+// Fetch user's VideoUnlock records
+React.useEffect(() => {
+ const fetchUserUnlocks = async () => {
+ try {
+ const res = await fetch('/api/user/unlocks');
+ if (res.ok) {
+ const data = await res.json();
+ const unlockedVideoIds = new Set((data.unlocks?.map((u: any) => u.videoId) ?? []) as string[]);
+ setUserUnlocks(unlockedVideoIds);
+ }
+ } catch (err) {
+ console.error('Failed to fetch user unlocks:', err);
+ }
+ };
+
+ fetchUserUnlocks();
+}, []);
+
+React.useEffect(() => {
+ if (!video) return;
+
+ let didCancel = false;
+ let poll: number | null = null;
+
+ const startWhenReady = () => {
+ if (didCancel) return;
+ const el = videoRef.current;
+ const duration = (video.durationSec ?? Math.floor(el?.duration ?? 0)) || 0;
+
+ if (el) {
+ tracker.startAutoSend({
+ videoId: String(video.id),
+ playlistId: playlist?.id,
+ duration,
+ });
+ } else {
+ // poll until the video element mounts (should be quick)
+ poll = window.setInterval(() => {
+ if (videoRef.current) {
+ if (poll) {
+ window.clearInterval(poll);
+ poll = null;
+ }
+ tracker.startAutoSend({
+ videoId: String(video.id),
+ playlistId: playlist?.id,
+ duration: (video.durationSec ?? Math.floor(videoRef.current?.duration ?? 0)) || 0,
+ });
+ }
+ }, 150) as unknown as number;
+ }
+ };
+
+ startWhenReady();
+
+ return () => {
+ didCancel = true;
+ if (poll) {
+ window.clearInterval(poll);
+ poll = null;
+ }
+ const duration = video?.durationSec ?? Math.floor(videoRef.current?.duration ?? 0);
+ // send final snapshot
+ tracker.stopAutoSend({ sendFinal: true, videoId: String(video?.id ?? ''), playlistId: playlist?.id, duration });
+ };
+// eslint-disable-next-line react-hooks/exhaustive-deps
+}, [video?.id, playlist?.id]);
+
+
+ const current: VideoFromApi = (video as VideoFromApi) ?? {
+ id: 'loading',
+ title: 'Loading…',
+ duration: '0:00',
+ thumbnail: '/01.jpg',
+ url: '',
+ };
+
+ const fmt = (v: VideoFromApi) => {
+ if (v.duration) return v.duration;
+ if (!v.durationSec && v.durationSec !== 0) return '';
+ const mins = Math.floor((v.durationSec ?? 0) / 60);
+ const secs = Math.floor((v.durationSec ?? 0) % 60)
+ .toString()
+ .padStart(2, '0');
+ return `${mins}:${secs}`;
+ };
+
+ const handleUnlock = async (v: VideoFromApi) => {
+ console.log('unlock request for', v.id);
+
+ // Prevent multiple unlock attempts
+ if (unlockingVideo === v.id) return;
+
+ setUnlockingVideo(v.id);
+
+ try {
+ // Can't unlock if globally locked
+ if ((v as any).locked) {
+ console.log('Video is globally locked, cannot unlock');
+ return;
+ }
+
+ // Can't unlock if already unlocked or has instant access
+ if ((v as any).instantAccess || userUnlocks.has(v.id)) {
+ console.log('Video already accessible');
+ router.push(`/videoplayer?playlistId=${playlist?.id}&videoId=${v.id}`);
+ return;
+ }
+
+ // Check if previous video in sequence is completed
+ if (!playlist?.videos) return;
+
+ const videoIndex = (v as any).index;
+ if (videoIndex <= 0) {
+ // First video should always be accessible
+ router.push(`/videoplayer?playlistId=${playlist?.id}&videoId=${v.id}`);
+ return;
+ }
+
+ // Find previous video
+ const previousVideo = playlist.videos.find((pv: any) => pv.index === videoIndex - 1);
+ if (!previousVideo) {
+ console.log('Previous video not found');
+ return;
+ }
+
+ // Check if previous video is completed
+ const progressRes = await fetch(`/api/progress?videoId=${previousVideo.id}`);
+ if (!progressRes.ok) {
+ console.error('Failed to fetch previous video progress');
+ alert('Error checking unlock requirements. Please try again.');
+ return;
+ }
+
+ const progressData = await progressRes.json();
+ const isCompleted = progressData.percent >= 90; // Consider 90% as completed
+
+ if (!isCompleted) {
+ console.log('Previous video not completed yet - need', Math.ceil(90 - progressData.percent), '% more');
+ // TODO: Show toast notification explaining unlock requirements
+ alert(`You need to complete ${Math.ceil(90 - progressData.percent)}% more of the previous video to unlock this one.`);
+ return;
+ }
+
+ // Previous video is completed, unlock this video
+ const unlockRes = await fetch('/api/user/unlocks', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ videoId: v.id }),
+ });
+
+ if (unlockRes.ok) {
+ // Update local state
+ setUserUnlocks(prev => new Set(prev).add(v.id));
+ console.log('Video unlocked successfully');
+ // Redirect to the unlocked video
+ router.push(`/videoplayer?playlistId=${playlist?.id}&videoId=${v.id}`);
+ } else {
+ const errorData = await unlockRes.json().catch(() => ({ error: 'Unknown error' }));
+ console.error('Failed to unlock video:', errorData.error);
+ alert(`Failed to unlock video: ${errorData.error}`);
+ }
+ } catch (err) {
+ console.error('Error checking unlock conditions:', err);
+ alert('Error checking if video can be unlocked. Please try again.');
+ } finally {
+ setUnlockingVideo(null);
+ }
+ };
+
+ const [isLiked, setIsLiked] = React.useState(false);
+ const { data: session } = useSession();
+ const isAdmin = (session as any)?.user?.role === 'admin' || (session as any)?.user?.role === 'superadmin';
+
+ React.useEffect(() => {
+ // Check if current video is liked on load
+ if (current.id) {
+ const checkLike = async () => {
+ try {
+ const res = await fetch(`/api/likes?videoId=${current.id}`);
+ if (res.ok) {
+ const data = await res.json();
+ setIsLiked(data.isLiked);
+ }
+ } catch (err) {
+ console.error('Failed to check if video is liked', err);
+ }
+ };
+ checkLike();
+ }
+ }, [current.id]);
+
+ const handleLike = async () => {
+ const newLikedState = !isLiked;
+ setIsLiked(newLikedState);
+
+ try {
+ const res = await fetch('/api/likes', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ videoId: current.id,
+ isLiked: newLikedState,
+ }),
+ });
+ if (!res.ok) {
+ // Revert on error
+ setIsLiked(!newLikedState);
+ console.error('Failed to toggle like');
+ }
+ } catch (err) {
+ // Revert on error
+ setIsLiked(!newLikedState);
+ console.error('Error toggling like', err);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {videoLoading ? (
+
Loading video…
+ ) : current.url ? (
+ // HLS player with MP4 fallback
+ // Prefers HLS if transcoding is complete, falls back to MP4
+
+ ) : (
+
Video not available
+ )}
+
+
+
+
+
+
+ {current.uploader?.image && (
+
+
+
+ )}
+
+
{current.title}
+ {current.uploader?.name && (
+
+ {current.uploader.name}
+
+ )}
+ {current.createdAt && (
+
+ Uploaded {formatDistanceToNow(new Date(current.createdAt), { addSuffix: true })}
+
+ )}
+
+
+
+ {isAdmin && (
+ router.push(`/admin/videos/${current.id}/edit`)}
+ >
+
+ Edit
+
+ )}
+
+ Like
+
+
+
+
+ {current.description && (
+
+ {current.description}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+
+
+ Notes
+
+ Keep lesson notes, transcript links, or chapter markers here.
+
+
+
+
+
+
+ Resources
+
+ Model files
+ Reference sheets
+ Assignments
+
+
+
+
*/}
+
+
+
+
+
+
+
+ );
+}
diff --git a/build-and-push.bat b/build-and-push.bat
new file mode 100644
index 0000000..1593d5d
--- /dev/null
+++ b/build-and-push.bat
@@ -0,0 +1,59 @@
+@echo off
+REM Build and push Docker image to Local Docker Registry
+REM Usage: build-and-push.bat [VERSION_TAG]
+
+setlocal enabledelayedexpansion
+
+REM Configuration
+set LOCAL_REGISTRY=192.168.0.107:5000
+set REPO=owi-cms
+set IMAGE_NAME=%LOCAL_REGISTRY%/%REPO%
+
+REM Get version tag (default to 'latest')
+if "%~1"=="" (
+ set VERSION_TAG=latest
+) else (
+ set VERSION_TAG=%~1
+)
+
+set FULL_IMAGE=%IMAGE_NAME%:%VERSION_TAG%
+
+echo.
+echo ==========================================
+echo Building Docker image: %FULL_IMAGE%
+echo ==========================================
+echo.
+
+REM Build the Docker image
+docker build ^
+ --tag %FULL_IMAGE% ^
+ --tag %IMAGE_NAME%:%VERSION_TAG% ^
+ --file Dockerfile ^
+ .
+
+if %ERRORLEVEL% neq 0 (
+ echo.
+ echo xx Build failed!
+ exit /b 1
+)
+
+echo.
+echo OK Build successful!
+echo.
+echo ==========================================
+echo Next steps:
+echo ==========================================
+echo.
+echo 1. Push to Local Docker Registry:
+echo docker push %FULL_IMAGE%
+echo docker push %IMAGE_NAME%:%VERSION_TAG%
+echo.
+echo 2. Push to GitHub Container Registry:
+echo docker push %FULL_IMAGE%
+echo docker push %IMAGE_NAME%:%VERSION_TAG%
+echo.
+echo 3. Update Dockge to pull the new image:
+echo Image: %FULL_IMAGE%
+echo Registry: %GITHUB_REGISTRY%
+echo.
+pause
diff --git a/build-and-push.sh b/build-and-push.sh
new file mode 100644
index 0000000..47ec992
--- /dev/null
+++ b/build-and-push.sh
@@ -0,0 +1,47 @@
+#!/bin/bash
+
+# Build and push Docker image to Local Docker Registry
+# Usage: ./build-and-push.sh [VERSION_TAG]
+
+set -e
+
+# Configuration
+LOCAL_REGISTRY="192.168.0.107:5000"
+REPO="owi-cms"
+IMAGE_NAME="$LOCAL_REGISTRY/$REPO"
+
+# Get version tag (default to 'latest')
+VERSION_TAG="${1:-latest}"
+FULL_IMAGE="$IMAGE_NAME:$VERSION_TAG"
+
+echo "=========================================="
+echo "Building Docker image: $FULL_IMAGE"
+echo "=========================================="
+
+# Build the Docker image
+docker build \
+ --tag "$FULL_IMAGE" \
+ --tag "$IMAGE_NAME:latest" \
+ --file Dockerfile \
+ .
+
+if [ $? -ne 0 ]; then
+ echo "❌ Build failed!"
+ exit 1
+fi
+
+echo ""
+echo "✅ Build successful!"
+echo ""
+echo "=========================================="
+echo "Next steps:"
+echo "=========================================="
+echo ""
+echo "1. Push to Local Docker Registry:"
+echo " docker push $FULL_IMAGE"
+echo " docker push $IMAGE_NAME:latest"
+echo ""
+echo "2. Update Dockge on TrueNAS to pull the new image:"
+echo " Image: $FULL_IMAGE"
+echo " Registry: $GITHUB_REGISTRY"
+echo ""
diff --git a/components.json b/components.json
new file mode 100644
index 0000000..87838e7
--- /dev/null
+++ b/components.json
@@ -0,0 +1,22 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "app/globals.css",
+ "baseColor": "gray",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "registries": {}
+}
diff --git a/components/JoinForm.tsx b/components/JoinForm.tsx
new file mode 100644
index 0000000..8d010ac
--- /dev/null
+++ b/components/JoinForm.tsx
@@ -0,0 +1,27 @@
+"use client";
+import React, { useState } from "react";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+
+export default function JoinForm() {
+ const [email, setEmail] = useState("");
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ // replace with real action (call API route or server action)
+ alert(`Thanks — we'll ping ${email}`);
+ setEmail("");
+ }
+
+ return (
+
+ ) => setEmail(e.target.value)}
+ placeholder="you@company.com"
+ aria-label="Email"
+ />
+ Join
+
+ );
+}
diff --git a/components/LandingPage.tsx b/components/LandingPage.tsx
new file mode 100644
index 0000000..0fd3f29
--- /dev/null
+++ b/components/LandingPage.tsx
@@ -0,0 +1,129 @@
+// app/components/LandingPage.tsx (Server Component)
+import React from "react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Check } from "lucide-react";
+import JoinForm from "./JoinForm";
+
+export default function LandingPage() {
+ return (
+
+
+
+
G4
+
+
Gruff Studio
+
UI playground · shadcn + Tailwind
+
+
+
+ Docs
+ Sign in
+
+
+
+
+
+
+
Beta
+
Build faster with shadcn components
+
A tiny example landing page to confirm your Tailwind + shadcn setup is working. Components are unopinionated and fully customizable with Tailwind.
+
+
+ Get started
+ Learn more
+
+
+
+ } title="Reusable" subtitle="Composable UI" />
+ } title="Accessible" subtitle="Focus & keyboard" />
+ } title="Themed" subtitle="Tailwind friendly" />
+ } title="Tiny" subtitle="Zero runtime" />
+
+
+
+
+
+
+ Join the waitlist
+ Drop your email and we’ll ping you when the demo is live.
+
+ No spam — only useful updates.
+
+
+
+
+
+
+
+
+
+
+
+
Server-side friendly
+
Use these components in server and client components.
+
+
+
+
+
+
+
+
+
+
+
+
+
Tailwind-ready
+
Customize tokens in tailwind.config.
+
+
+
+
+
+
+
+
+
+ Example features
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function Feature({ icon, title, subtitle }: { icon: React.ReactNode; title: string; subtitle: string }) {
+ return (
+
+ );
+}
+
+function FeatureCard({ title, desc }: { title: string; desc: string }) {
+ return (
+
+
+ {title}
+ {desc}
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/VideoCarousel.tsx b/components/VideoCarousel.tsx
new file mode 100644
index 0000000..422a582
--- /dev/null
+++ b/components/VideoCarousel.tsx
@@ -0,0 +1,158 @@
+"use client";
+
+import React, { useRef, useState, useEffect } from "react";
+import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+
+import { ChevronLeft, ChevronRight } from "lucide-react";
+
+export type VideoItem = {
+ id: string | number;
+ title: string;
+ duration: string; // formatted like "4:12"
+ thumbnailUrl: string;
+};
+
+type Props = {
+ categoryTitle: string;
+ videos: VideoItem[];
+ visibleCount?: number; // defaults to 4
+};
+
+export default function VideoCarousel({
+ categoryTitle,
+ videos,
+ visibleCount = 4,
+}: Props) {
+ const scrollerRef = useRef(null);
+ const [canScrollLeft, setCanScrollLeft] = useState(false);
+ const [canScrollRight, setCanScrollRight] = useState(false);
+
+ useEffect(() => {
+ const el = scrollerRef.current;
+ if (!el) return;
+ const update = () => {
+ setCanScrollLeft(el.scrollLeft > 0);
+ setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
+ };
+ update();
+ el.addEventListener("scroll", update, { passive: true });
+ window.addEventListener("resize", update);
+ return () => {
+ el.removeEventListener("scroll", update);
+ window.removeEventListener("resize", update);
+ };
+ }, [videos]);
+
+ const scrollByPage = (direction: "left" | "right") => {
+ const el = scrollerRef.current;
+ if (!el) return;
+ const amount = el.clientWidth; // scroll by visible area so next "page" shows
+ el.scrollBy({ left: direction === "left" ? -amount : amount, behavior: "smooth" });
+ };
+
+ // keyboard navigation
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === "ArrowLeft") scrollByPage("left");
+ if (e.key === "ArrowRight") scrollByPage("right");
+ };
+ window.addEventListener("keydown", handler);
+ return () => window.removeEventListener("keydown", handler);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+
+
{categoryTitle}
+
+ scrollByPage("left")}
+ disabled={!canScrollLeft}
+ >
+
+
+ scrollByPage("right")}
+ disabled={!canScrollRight}
+ >
+
+
+
+
+
+
+
+ {videos.map((v) => (
+
+
+
+
+
{v.duration}
+
+
+
+ {v.title}
+
+
+ {/* placeholder for action, e.g., menu or save */}
+
+
+
+ {v.duration}
+
+
+
+ ))}
+
+
+ {/* small gradients on the sides to indicate scrollability */}
+
+
+
+
+
+ );
+}
+
+/*
+Usage example:
+
+import VideoCarousel, { VideoItem } from "./VideoCarousel";
+
+const videos: VideoItem[] = [
+ { id: 1, title: "Fluffy cat plays with yarn", duration: "3:45", thumbnailUrl: "/thumb1.jpg" },
+ { id: 2, title: "Cat napping compilation", duration: "2:12", thumbnailUrl: "/thumb2.jpg" },
+ // ...more
+];
+
+
+
+Notes:
+- This file assumes shadcn/ui components exist at the given paths. Replace imports if your project structure differs.
+- Card widths are responsive: they use a percent width with a min-width to keep thumbnails readable on small screens.
+- The scroller uses native smooth scrolling so it works well on touch devices and with keyboards.
+*/
diff --git a/components/admin-notifications.tsx b/components/admin-notifications.tsx
new file mode 100644
index 0000000..ab370f0
--- /dev/null
+++ b/components/admin-notifications.tsx
@@ -0,0 +1,210 @@
+// components/admin-notifications.tsx
+'use client';
+
+import React from 'react';
+import { Heart, MessageCircle, BookOpen, List } from 'lucide-react';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import Image from 'next/image';
+import { formatDistanceToNow } from 'date-fns';
+
+interface Notification {
+ id: string;
+ type: 'like' | 'comment' | 'course_created' | 'playlist_created';
+ user?: {
+ id: string;
+ name?: string;
+ image?: string;
+ email: string;
+ } | null;
+ video?: {
+ id: string;
+ title: string;
+ };
+ course?: {
+ id: string;
+ title: string;
+ code: string;
+ };
+ playlist?: {
+ id: string;
+ title: string;
+ courseTitle: string;
+ };
+ content: string | null;
+ createdAt: Date;
+}
+
+export function AdminNotifications() {
+ const [notifications, setNotifications] = React.useState([]);
+ const [isLoading, setIsLoading] = React.useState(true);
+ const [error, setError] = React.useState(null);
+
+ React.useEffect(() => {
+ const fetchNotifications = async () => {
+ try {
+ setIsLoading(true);
+ const res = await fetch('/api/admin/notifications');
+ if (res.ok) {
+ const data = await res.json();
+ setNotifications(
+ data.notifications.map((n: any) => ({
+ ...n,
+ createdAt: new Date(n.createdAt),
+ }))
+ );
+ } else {
+ setError('Failed to fetch notifications');
+ }
+ } catch (err) {
+ console.error('Failed to fetch notifications:', err);
+ setError('Error loading notifications');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchNotifications();
+
+ // Refresh notifications every 30 seconds
+ const interval = setInterval(fetchNotifications, 30000);
+ return () => clearInterval(interval);
+ }, []);
+
+ if (isLoading) {
+ return (
+
+
+
+
+ Activity
+
+
+
+ Loading notifications...
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
+ Activity
+
+
+
+ {error}
+
+
+ );
+ }
+
+ if (notifications.length === 0) {
+ return (
+
+
+
+
+ Activity
+
+
+
+ No activity yet
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Activity ({notifications.length})
+
+
+
+ {notifications.map((notification) => (
+
+ {notification.user?.image && (
+
+
+
+ )}
+ {notification.type === 'course_created' && (
+
+
+
+ )}
+ {notification.type === 'playlist_created' && (
+
+
+
+ )}
+
+
+ {notification.type === 'like' && (
+
+ )}
+ {notification.type === 'comment' && (
+
+ )}
+ {notification.type === 'course_created' && (
+
+ )}
+ {notification.type === 'playlist_created' && (
+
+ )}
+
+ {notification.user ? (
+ notification.user.name || notification.user.email
+ ) : (
+ 'System'
+ )}
+
+
+ {formatDistanceToNow(notification.createdAt, {
+ addSuffix: true,
+ })}
+
+
+
+ {notification.type === 'like' &&
+ `liked ${notification.video?.title}`}
+ {notification.type === 'comment' &&
+ `commented on ${notification.video?.title}`}
+ {notification.type === 'course_created' &&
+ `created course: ${notification.course?.title}`}
+ {notification.type === 'playlist_created' &&
+ `created playlist: ${notification.playlist?.title}`}
+
+ {notification.content && (
+
+ "{notification.content}"
+
+ )}
+
+
+ ))}
+
+
+ );
+}
diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx
new file mode 100644
index 0000000..14e1623
--- /dev/null
+++ b/components/app-sidebar.tsx
@@ -0,0 +1,97 @@
+'use client';
+
+import * as React from 'react';
+import { useSession } from 'next-auth/react';
+import { IconDashboard, IconChartBar, IconFolder, IconSearch, IconShieldCheck } from '@tabler/icons-react';
+import { NavMain } from '@/components/nav-main';
+import { NavSecondary } from '@/components/nav-secondary';
+import { NavUser } from '@/components/nav-user';
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarHeader,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+} from '@/components/ui/sidebar';
+
+interface NavItem {
+ title: string;
+ url: string;
+ icon?: any;
+ items?: NavItem[];
+}
+
+const navMainBase: NavItem[] = [
+ { title: 'Library', url: '/dashboard', icon: IconDashboard },
+ { title: 'Liked Videos', url: '/dashboard/liked-videos', icon: IconChartBar },
+ { title: 'History', url: '/dashboard/watch-history', icon: IconFolder },
+];
+
+const navSecondary = [
+ { title: 'Search', url: '#', icon: IconSearch },
+];
+
+export function AppSidebar(props: React.ComponentProps) {
+ const { data: session } = useSession();
+ const role = (session as any)?.user?.role ?? 'user';
+
+ // Compose navMain and optionally add admin link for admins or superadmins
+ const navMain = React.useMemo(() => {
+ const base = [...navMainBase];
+
+ if (role === 'admin' || role === 'superadmin') {
+ // Put Admin at the top with submenu items
+ base.unshift({
+ title: 'Admin',
+ url: '/admin',
+ icon: IconShieldCheck,
+ // nested submenu items
+ items: [
+ { title: 'Manage Users', url: '/admin/users' },
+ { title: 'Manage Enrollments', url: '/admin/enrollments' },
+ { title: 'Manage Courses', url: '/admin/courses' },
+ { title: 'Manage Playlists', url: '/admin/playlists' },
+ { title: 'Manage Videos', url: '/admin/videos' },
+ ],
+ });
+ }
+
+ return base;
+ }, [role]);
+
+ const user = session?.user
+ ? {
+ name: session.user.name ?? 'User',
+ email: session.user.email ?? '',
+ avatar: session.user.image ?? '/avatars/shadcn.jpg',
+ }
+ : { name: 'Guest', email: 'guest@example.com', avatar: '/avatars/shadcn.jpg' };
+
+ return (
+
+
+
+
+
+
+
+ OW ANIMATION ARTS VAULT
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/chart-area-interactive.tsx b/components/chart-area-interactive.tsx
new file mode 100644
index 0000000..5b475ea
--- /dev/null
+++ b/components/chart-area-interactive.tsx
@@ -0,0 +1,291 @@
+"use client"
+
+import * as React from "react"
+import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
+
+import { useIsMobile } from "@/hooks/use-mobile"
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import {
+ ChartConfig,
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+} from "@/components/ui/chart"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ ToggleGroup,
+ ToggleGroupItem,
+} from "@/components/ui/toggle-group"
+
+export const description = "An interactive area chart"
+
+const chartData = [
+ { date: "2024-04-01", desktop: 222, mobile: 150 },
+ { date: "2024-04-02", desktop: 97, mobile: 180 },
+ { date: "2024-04-03", desktop: 167, mobile: 120 },
+ { date: "2024-04-04", desktop: 242, mobile: 260 },
+ { date: "2024-04-05", desktop: 373, mobile: 290 },
+ { date: "2024-04-06", desktop: 301, mobile: 340 },
+ { date: "2024-04-07", desktop: 245, mobile: 180 },
+ { date: "2024-04-08", desktop: 409, mobile: 320 },
+ { date: "2024-04-09", desktop: 59, mobile: 110 },
+ { date: "2024-04-10", desktop: 261, mobile: 190 },
+ { date: "2024-04-11", desktop: 327, mobile: 350 },
+ { date: "2024-04-12", desktop: 292, mobile: 210 },
+ { date: "2024-04-13", desktop: 342, mobile: 380 },
+ { date: "2024-04-14", desktop: 137, mobile: 220 },
+ { date: "2024-04-15", desktop: 120, mobile: 170 },
+ { date: "2024-04-16", desktop: 138, mobile: 190 },
+ { date: "2024-04-17", desktop: 446, mobile: 360 },
+ { date: "2024-04-18", desktop: 364, mobile: 410 },
+ { date: "2024-04-19", desktop: 243, mobile: 180 },
+ { date: "2024-04-20", desktop: 89, mobile: 150 },
+ { date: "2024-04-21", desktop: 137, mobile: 200 },
+ { date: "2024-04-22", desktop: 224, mobile: 170 },
+ { date: "2024-04-23", desktop: 138, mobile: 230 },
+ { date: "2024-04-24", desktop: 387, mobile: 290 },
+ { date: "2024-04-25", desktop: 215, mobile: 250 },
+ { date: "2024-04-26", desktop: 75, mobile: 130 },
+ { date: "2024-04-27", desktop: 383, mobile: 420 },
+ { date: "2024-04-28", desktop: 122, mobile: 180 },
+ { date: "2024-04-29", desktop: 315, mobile: 240 },
+ { date: "2024-04-30", desktop: 454, mobile: 380 },
+ { date: "2024-05-01", desktop: 165, mobile: 220 },
+ { date: "2024-05-02", desktop: 293, mobile: 310 },
+ { date: "2024-05-03", desktop: 247, mobile: 190 },
+ { date: "2024-05-04", desktop: 385, mobile: 420 },
+ { date: "2024-05-05", desktop: 481, mobile: 390 },
+ { date: "2024-05-06", desktop: 498, mobile: 520 },
+ { date: "2024-05-07", desktop: 388, mobile: 300 },
+ { date: "2024-05-08", desktop: 149, mobile: 210 },
+ { date: "2024-05-09", desktop: 227, mobile: 180 },
+ { date: "2024-05-10", desktop: 293, mobile: 330 },
+ { date: "2024-05-11", desktop: 335, mobile: 270 },
+ { date: "2024-05-12", desktop: 197, mobile: 240 },
+ { date: "2024-05-13", desktop: 197, mobile: 160 },
+ { date: "2024-05-14", desktop: 448, mobile: 490 },
+ { date: "2024-05-15", desktop: 473, mobile: 380 },
+ { date: "2024-05-16", desktop: 338, mobile: 400 },
+ { date: "2024-05-17", desktop: 499, mobile: 420 },
+ { date: "2024-05-18", desktop: 315, mobile: 350 },
+ { date: "2024-05-19", desktop: 235, mobile: 180 },
+ { date: "2024-05-20", desktop: 177, mobile: 230 },
+ { date: "2024-05-21", desktop: 82, mobile: 140 },
+ { date: "2024-05-22", desktop: 81, mobile: 120 },
+ { date: "2024-05-23", desktop: 252, mobile: 290 },
+ { date: "2024-05-24", desktop: 294, mobile: 220 },
+ { date: "2024-05-25", desktop: 201, mobile: 250 },
+ { date: "2024-05-26", desktop: 213, mobile: 170 },
+ { date: "2024-05-27", desktop: 420, mobile: 460 },
+ { date: "2024-05-28", desktop: 233, mobile: 190 },
+ { date: "2024-05-29", desktop: 78, mobile: 130 },
+ { date: "2024-05-30", desktop: 340, mobile: 280 },
+ { date: "2024-05-31", desktop: 178, mobile: 230 },
+ { date: "2024-06-01", desktop: 178, mobile: 200 },
+ { date: "2024-06-02", desktop: 470, mobile: 410 },
+ { date: "2024-06-03", desktop: 103, mobile: 160 },
+ { date: "2024-06-04", desktop: 439, mobile: 380 },
+ { date: "2024-06-05", desktop: 88, mobile: 140 },
+ { date: "2024-06-06", desktop: 294, mobile: 250 },
+ { date: "2024-06-07", desktop: 323, mobile: 370 },
+ { date: "2024-06-08", desktop: 385, mobile: 320 },
+ { date: "2024-06-09", desktop: 438, mobile: 480 },
+ { date: "2024-06-10", desktop: 155, mobile: 200 },
+ { date: "2024-06-11", desktop: 92, mobile: 150 },
+ { date: "2024-06-12", desktop: 492, mobile: 420 },
+ { date: "2024-06-13", desktop: 81, mobile: 130 },
+ { date: "2024-06-14", desktop: 426, mobile: 380 },
+ { date: "2024-06-15", desktop: 307, mobile: 350 },
+ { date: "2024-06-16", desktop: 371, mobile: 310 },
+ { date: "2024-06-17", desktop: 475, mobile: 520 },
+ { date: "2024-06-18", desktop: 107, mobile: 170 },
+ { date: "2024-06-19", desktop: 341, mobile: 290 },
+ { date: "2024-06-20", desktop: 408, mobile: 450 },
+ { date: "2024-06-21", desktop: 169, mobile: 210 },
+ { date: "2024-06-22", desktop: 317, mobile: 270 },
+ { date: "2024-06-23", desktop: 480, mobile: 530 },
+ { date: "2024-06-24", desktop: 132, mobile: 180 },
+ { date: "2024-06-25", desktop: 141, mobile: 190 },
+ { date: "2024-06-26", desktop: 434, mobile: 380 },
+ { date: "2024-06-27", desktop: 448, mobile: 490 },
+ { date: "2024-06-28", desktop: 149, mobile: 200 },
+ { date: "2024-06-29", desktop: 103, mobile: 160 },
+ { date: "2024-06-30", desktop: 446, mobile: 400 },
+]
+
+const chartConfig = {
+ visitors: {
+ label: "Visitors",
+ },
+ desktop: {
+ label: "Desktop",
+ color: "var(--primary)",
+ },
+ mobile: {
+ label: "Mobile",
+ color: "var(--primary)",
+ },
+} satisfies ChartConfig
+
+export function ChartAreaInteractive() {
+ const isMobile = useIsMobile()
+ const [timeRange, setTimeRange] = React.useState("90d")
+
+ React.useEffect(() => {
+ if (isMobile) {
+ setTimeRange("7d")
+ }
+ }, [isMobile])
+
+ const filteredData = chartData.filter((item) => {
+ const date = new Date(item.date)
+ const referenceDate = new Date("2024-06-30")
+ let daysToSubtract = 90
+ if (timeRange === "30d") {
+ daysToSubtract = 30
+ } else if (timeRange === "7d") {
+ daysToSubtract = 7
+ }
+ const startDate = new Date(referenceDate)
+ startDate.setDate(startDate.getDate() - daysToSubtract)
+ return date >= startDate
+ })
+
+ return (
+
+
+ Total Visitors
+
+
+ Total for the last 3 months
+
+ Last 3 months
+
+
+
+ Last 3 months
+ Last 30 days
+ Last 7 days
+
+
+
+
+
+
+
+ Last 3 months
+
+
+ Last 30 days
+
+
+ Last 7 days
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ const date = new Date(value)
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ })
+ }}
+ />
+ {
+ return new Date(value).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ })
+ }}
+ indicator="dot"
+ />
+ }
+ />
+
+
+
+
+
+
+ )
+}
diff --git a/components/comments-section.tsx b/components/comments-section.tsx
new file mode 100644
index 0000000..f752a78
--- /dev/null
+++ b/components/comments-section.tsx
@@ -0,0 +1,436 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+import { useSession } from 'next-auth/react';
+import { Button } from '@/components/ui/button';
+import { Textarea } from '@/components/ui/textarea';
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+import { formatDistanceToNow } from 'date-fns';
+import { Trash2 } from 'lucide-react';
+
+type User = {
+ id: string;
+ name: string | null;
+ email: string;
+ image: string | null;
+};
+
+type CommentReply = {
+ id: string;
+ userId: string;
+ commentId: string;
+ content: string;
+ createdAt: string;
+ user: User;
+};
+
+type Comment = {
+ id: string;
+ userId: string;
+ videoId: string;
+ content: string;
+ createdAt: string;
+ user: User;
+ replies: CommentReply[];
+};
+
+interface CommentsSectionProps {
+ videoId: string;
+}
+
+export function CommentsSection({ videoId }: CommentsSectionProps) {
+ const { data: session } = useSession();
+ const [comments, setComments] = useState([]);
+ const [newCommentContent, setNewCommentContent] = useState('');
+ const [replyingToId, setReplyingToId] = useState(null);
+ const [replyContent, setReplyContent] = useState('');
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [deletingCommentId, setDeletingCommentId] = useState(null);
+ const [deleteReplyDialogOpen, setDeleteReplyDialogOpen] = useState(false);
+ const [deletingReplyId, setDeletingReplyId] = useState(null);
+ const [deletingReplyCommentId, setDeletingReplyCommentId] = useState(null);
+ const isAdmin = (session?.user as any)?.role === 'admin' || (session?.user as any)?.role === 'superadmin';
+
+ const handleDeleteComment = async (commentId: string) => {
+ try {
+ const res = await fetch(`/api/comments/${commentId}`, {
+ method: 'DELETE',
+ });
+
+ if (res.ok) {
+ setComments(comments.filter((c) => c.id !== commentId));
+ setDeleteDialogOpen(false);
+ setDeletingCommentId(null);
+ }
+ } catch (err) {
+ console.error('Failed to delete comment:', err);
+ }
+ };
+
+ const handleDeleteReply = async (replyId: string, commentId: string) => {
+ try {
+ const res = await fetch(`/api/comments/reply/${replyId}`, {
+ method: 'DELETE',
+ });
+
+ if (res.ok) {
+ setComments(
+ comments.map((c) =>
+ c.id === commentId
+ ? {
+ ...c,
+ replies: c.replies.filter((r) => r.id !== replyId),
+ }
+ : c
+ )
+ );
+ setDeleteReplyDialogOpen(false);
+ setDeletingReplyId(null);
+ setDeletingReplyCommentId(null);
+ }
+ } catch (err) {
+ console.error('Failed to delete reply:', err);
+ }
+ };
+
+ // Fetch comments on mount and when videoId changes
+ useEffect(() => {
+ const fetchComments = async () => {
+ setIsLoading(true);
+ try {
+ const res = await fetch(`/api/comments?videoId=${videoId}`);
+ if (res.ok) {
+ const data = await res.json();
+ setComments(data);
+ }
+ } catch (err) {
+ console.error('Failed to fetch comments:', err);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchComments();
+ }, [videoId]);
+
+ const handleCommentSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!session?.user || !newCommentContent.trim()) return;
+
+ setIsSubmitting(true);
+ try {
+ const res = await fetch('/api/comments', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ videoId, content: newCommentContent }),
+ });
+
+ if (res.ok) {
+ const newComment = await res.json();
+ setComments([newComment, ...comments]);
+ setNewCommentContent('');
+ }
+ } catch (err) {
+ console.error('Failed to post comment:', err);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleReplySubmit = async (e: React.FormEvent, commentId: string) => {
+ e.preventDefault();
+ if (!session?.user || !replyContent.trim()) return;
+
+ setIsSubmitting(true);
+ try {
+ const res = await fetch(`/api/comments/${commentId}/reply`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ content: replyContent }),
+ });
+
+ if (res.ok) {
+ const newReply = await res.json();
+ setComments(
+ comments.map((c) =>
+ c.id === commentId
+ ? { ...c, replies: [...c.replies, newReply] }
+ : c
+ )
+ );
+ setReplyContent('');
+ setReplyingToId(null);
+ }
+ } catch (err) {
+ console.error('Failed to post reply:', err);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const getDisplayName = (user: User) => user.name || user.email.split('@')[0];
+ const getInitials = (user: User) => {
+ const name = getDisplayName(user);
+ return name
+ .split(' ')
+ .map((n) => n[0])
+ .join('')
+ .toUpperCase();
+ };
+
+ if (isLoading) {
+ return Loading comments...
;
+ }
+
+ return (
+
+
Comments ({comments.length})
+
+ {/* Add Comment Form */}
+ {session?.user ? (
+
+
+
+
+
+ {getInitials({
+ id: (session.user as any).id,
+ name: (session.user as any).name,
+ email: (session.user as any).email,
+ image: (session.user as any).image,
+ })}
+
+
+
+
setNewCommentContent(e.target.value)}
+ rows={2}
+ className="mb-2"
+ />
+
+ setNewCommentContent('')}
+ >
+ Cancel
+
+
+ {isSubmitting ? 'Posting...' : 'Comment'}
+
+
+
+
+
+ ) : (
+
+ Sign in to comment
+
+ )}
+
+ {/* Comments List */}
+
+ {comments.length === 0 ? (
+
No comments yet. Be the first to comment!
+ ) : (
+ comments.map((comment) => (
+
+ {/* Comment */}
+
+
+
+ {getInitials(comment.user)}
+
+
+
+
+
{getDisplayName(comment.user)}
+
+ {formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}
+
+ {isAdmin && (
+
{
+ if (!open) {
+ setDeleteDialogOpen(false);
+ setDeletingCommentId(null);
+ }
+ }}>
+ {
+ setDeleteDialogOpen(true);
+ setDeletingCommentId(comment.id);
+ }}
+ className="ml-auto p-1 hover:bg-destructive/20 rounded"
+ title="Delete comment"
+ >
+
+
+
+
+ Delete Comment
+
+ Are you sure you want to delete this comment?
+
+
+
+
Cancel
+
handleDeleteComment(comment.id)}
+ >
+ Delete
+
+
+
+
+ )}
+
+
{comment.content}
+
+ {session?.user && (
+
setReplyingToId(replyingToId === comment.id ? null : comment.id)}
+ >
+ {replyingToId === comment.id ? 'Cancel' : 'Reply'}
+
+ )}
+
+
+
+ {/* Reply Form */}
+ {replyingToId === comment.id && session?.user && (
+
handleReplySubmit(e, comment.id)}
+ className="ml-8 flex gap-3 mb-3"
+ >
+
+
+
+ {getInitials({
+ id: (session.user as any).id,
+ name: (session.user as any).name,
+ email: (session.user as any).email,
+ image: (session.user as any).image,
+ })}
+
+
+
+
setReplyContent(e.target.value)}
+ rows={2}
+ className="mb-2"
+ />
+
+ {
+ setReplyContent('');
+ setReplyingToId(null);
+ }}
+ >
+ Cancel
+
+
+ {isSubmitting ? 'Replying...' : 'Reply'}
+
+
+
+
+ )}
+
+ {/* Replies */}
+ {comment.replies.length > 0 && (
+
+ {comment.replies.map((reply) => (
+
+
+
+ {getInitials(reply.user)}
+
+
+
+
+
{getDisplayName(reply.user)}
+
+ {formatDistanceToNow(new Date(reply.createdAt), { addSuffix: true })}
+
+ {isAdmin && (
+
{
+ if (!open) {
+ setDeleteReplyDialogOpen(false);
+ setDeletingReplyId(null);
+ setDeletingReplyCommentId(null);
+ }
+ }}>
+ {
+ setDeleteReplyDialogOpen(true);
+ setDeletingReplyId(reply.id);
+ setDeletingReplyCommentId(comment.id);
+ }}
+ className="ml-auto p-1 hover:bg-destructive/20 rounded"
+ title="Delete reply"
+ >
+
+
+
+
+ Delete Reply
+
+ Are you sure you want to delete this reply?
+
+
+
+
Cancel
+
handleDeleteReply(reply.id, comment.id)}
+ >
+ Delete
+
+
+
+
+ )}
+
+
{reply.content}
+
+
+
+ ))}
+
+ )}
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/components/data-table.tsx b/components/data-table.tsx
new file mode 100644
index 0000000..4834681
--- /dev/null
+++ b/components/data-table.tsx
@@ -0,0 +1,807 @@
+"use client"
+
+import * as React from "react"
+import {
+ closestCenter,
+ DndContext,
+ KeyboardSensor,
+ MouseSensor,
+ TouchSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+ type UniqueIdentifier,
+} from "@dnd-kit/core"
+import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
+import {
+ arrayMove,
+ SortableContext,
+ useSortable,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable"
+import { CSS } from "@dnd-kit/utilities"
+import {
+ IconChevronDown,
+ IconChevronLeft,
+ IconChevronRight,
+ IconChevronsLeft,
+ IconChevronsRight,
+ IconCircleCheckFilled,
+ IconDotsVertical,
+ IconGripVertical,
+ IconLayoutColumns,
+ IconLoader,
+ IconPlus,
+ IconTrendingUp,
+} from "@tabler/icons-react"
+import {
+ ColumnDef,
+ ColumnFiltersState,
+ flexRender,
+ getCoreRowModel,
+ getFacetedRowModel,
+ getFacetedUniqueValues,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ Row,
+ SortingState,
+ useReactTable,
+ VisibilityState,
+} from "@tanstack/react-table"
+import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
+import { toast } from "sonner"
+import { z } from "zod"
+
+import { useIsMobile } from "@/hooks/use-mobile"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ ChartConfig,
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+} from "@/components/ui/chart"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Separator } from "@/components/ui/separator"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@/components/ui/tabs"
+
+export const schema = z.object({
+ id: z.number(),
+ header: z.string(),
+ type: z.string(),
+ status: z.string(),
+ target: z.string(),
+ limit: z.string(),
+ reviewer: z.string(),
+})
+
+// Create a separate component for the drag handle
+function DragHandle({ id }: { id: number }) {
+ const { attributes, listeners } = useSortable({
+ id,
+ })
+
+ return (
+
+
+ Drag to reorder
+
+ )
+}
+
+const columns: ColumnDef>[] = [
+ {
+ id: "drag",
+ header: () => null,
+ cell: ({ row }) => ,
+ },
+ {
+ id: "select",
+ header: ({ table }) => (
+
+ table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+
+ ),
+ cell: ({ row }) => (
+
+ row.toggleSelected(!!value)}
+ aria-label="Select row"
+ />
+
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: "header",
+ header: "Header",
+ cell: ({ row }) => {
+ return
+ },
+ enableHiding: false,
+ },
+ {
+ accessorKey: "type",
+ header: "Section Type",
+ cell: ({ row }) => (
+
+
+ {row.original.type}
+
+
+ ),
+ },
+ {
+ accessorKey: "status",
+ header: "Status",
+ cell: ({ row }) => (
+
+ {row.original.status === "Done" ? (
+
+ ) : (
+
+ )}
+ {row.original.status}
+
+ ),
+ },
+ {
+ accessorKey: "target",
+ header: () => Target
,
+ cell: ({ row }) => (
+ {
+ e.preventDefault()
+ toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
+ loading: `Saving ${row.original.header}`,
+ success: "Done",
+ error: "Error",
+ })
+ }}
+ >
+
+ Target
+
+
+
+ ),
+ },
+ {
+ accessorKey: "limit",
+ header: () => Limit
,
+ cell: ({ row }) => (
+ {
+ e.preventDefault()
+ toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
+ loading: `Saving ${row.original.header}`,
+ success: "Done",
+ error: "Error",
+ })
+ }}
+ >
+
+ Limit
+
+
+
+ ),
+ },
+ {
+ accessorKey: "reviewer",
+ header: "Reviewer",
+ cell: ({ row }) => {
+ const isAssigned = row.original.reviewer !== "Assign reviewer"
+
+ if (isAssigned) {
+ return row.original.reviewer
+ }
+
+ return (
+ <>
+
+ Reviewer
+
+
+
+
+
+
+ Eddie Lake
+
+ Jamik Tashpulatov
+
+
+
+ >
+ )
+ },
+ },
+ {
+ id: "actions",
+ cell: () => (
+
+
+
+
+ Open menu
+
+
+
+ Edit
+ Make a copy
+ Favorite
+
+ Delete
+
+
+ ),
+ },
+]
+
+function DraggableRow({ row }: { row: Row> }) {
+ const { transform, transition, setNodeRef, isDragging } = useSortable({
+ id: row.original.id,
+ })
+
+ return (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ )
+}
+
+export function DataTable({
+ data: initialData,
+}: {
+ data: z.infer[]
+}) {
+ const [data, setData] = React.useState(() => initialData)
+ const [rowSelection, setRowSelection] = React.useState({})
+ const [columnVisibility, setColumnVisibility] =
+ React.useState({})
+ const [columnFilters, setColumnFilters] = React.useState(
+ []
+ )
+ const [sorting, setSorting] = React.useState([])
+ const [pagination, setPagination] = React.useState({
+ pageIndex: 0,
+ pageSize: 10,
+ })
+ const sortableId = React.useId()
+ const sensors = useSensors(
+ useSensor(MouseSensor, {}),
+ useSensor(TouchSensor, {}),
+ useSensor(KeyboardSensor, {})
+ )
+
+ const dataIds = React.useMemo(
+ () => data?.map(({ id }) => id) || [],
+ [data]
+ )
+
+ const table = useReactTable({
+ data,
+ columns,
+ state: {
+ sorting,
+ columnVisibility,
+ rowSelection,
+ columnFilters,
+ pagination,
+ },
+ getRowId: (row) => row.id.toString(),
+ enableRowSelection: true,
+ onRowSelectionChange: setRowSelection,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onPaginationChange: setPagination,
+ getCoreRowModel: getCoreRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFacetedRowModel: getFacetedRowModel(),
+ getFacetedUniqueValues: getFacetedUniqueValues(),
+ })
+
+ function handleDragEnd(event: DragEndEvent) {
+ const { active, over } = event
+ if (active && over && active.id !== over.id) {
+ setData((data) => {
+ const oldIndex = dataIds.indexOf(active.id)
+ const newIndex = dataIds.indexOf(over.id)
+ return arrayMove(data, oldIndex, newIndex)
+ })
+ }
+ }
+
+ return (
+
+
+
+ View
+
+
+
+
+
+
+ Outline
+ Past Performance
+ Key Personnel
+ Focus Documents
+
+
+
+ Outline
+
+ Past Performance 3
+
+
+ Key Personnel 2
+
+ Focus Documents
+
+
+
+
+
+
+ Customize Columns
+ Columns
+
+
+
+
+ {table
+ .getAllColumns()
+ .filter(
+ (column) =>
+ typeof column.accessorFn !== "undefined" &&
+ column.getCanHide()
+ )
+ .map((column) => {
+ return (
+
+ column.toggleVisibility(!!value)
+ }
+ >
+ {column.id}
+
+ )
+ })}
+
+
+
+
+ Add Section
+
+
+
+
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ return (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+
+ )
+ })}
+
+ ))}
+
+
+ {table.getRowModel().rows?.length ? (
+
+ {table.getRowModel().rows.map((row) => (
+
+ ))}
+
+ ) : (
+
+
+ No results.
+
+
+ )}
+
+
+
+
+
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "}
+ {table.getFilteredRowModel().rows.length} row(s) selected.
+
+
+
+
+ Rows per page
+
+ {
+ table.setPageSize(Number(value))
+ }}
+ >
+
+
+
+
+ {[10, 20, 30, 40, 50].map((pageSize) => (
+
+ {pageSize}
+
+ ))}
+
+
+
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "}
+ {table.getPageCount()}
+
+
+ table.setPageIndex(0)}
+ disabled={!table.getCanPreviousPage()}
+ >
+ Go to first page
+
+
+ table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ Go to previous page
+
+
+ table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ Go to next page
+
+
+ table.setPageIndex(table.getPageCount() - 1)}
+ disabled={!table.getCanNextPage()}
+ >
+ Go to last page
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+const chartData = [
+ { month: "January", desktop: 186, mobile: 80 },
+ { month: "February", desktop: 305, mobile: 200 },
+ { month: "March", desktop: 237, mobile: 120 },
+ { month: "April", desktop: 73, mobile: 190 },
+ { month: "May", desktop: 209, mobile: 130 },
+ { month: "June", desktop: 214, mobile: 140 },
+]
+
+const chartConfig = {
+ desktop: {
+ label: "Desktop",
+ color: "var(--primary)",
+ },
+ mobile: {
+ label: "Mobile",
+ color: "var(--primary)",
+ },
+} satisfies ChartConfig
+
+function TableCellViewer({ item }: { item: z.infer }) {
+ const isMobile = useIsMobile()
+
+ return (
+
+
+
+ {item.header}
+
+
+
+
+ {item.header}
+
+ Showing total visitors for the last 6 months
+
+
+
+ {!isMobile && (
+ <>
+
+
+
+ value.slice(0, 3)}
+ hide
+ />
+ }
+ />
+
+
+
+
+
+
+
+ Trending up by 5.2% this month{" "}
+
+
+
+ Showing total visitors for the last 6 months. This is just
+ some random text to test the layout. It spans multiple lines
+ and should wrap around.
+
+
+
+ >
+ )}
+
+
+ Header
+
+
+
+
+ Type
+
+
+
+
+
+
+ Table of Contents
+
+
+ Executive Summary
+
+
+ Technical Approach
+
+ Design
+ Capabilities
+
+ Focus Documents
+
+ Narrative
+ Cover Page
+
+
+
+
+ Status
+
+
+
+
+
+ Done
+ In Progress
+ Not Started
+
+
+
+
+
+
+ Reviewer
+
+
+
+
+
+ Eddie Lake
+
+ Jamik Tashpulatov
+
+ Emily Whalen
+
+
+
+
+
+
+ Submit
+
+ Done
+
+
+
+
+ )
+}
diff --git a/components/hls.tsx b/components/hls.tsx
new file mode 100644
index 0000000..342ce05
--- /dev/null
+++ b/components/hls.tsx
@@ -0,0 +1,144 @@
+// app/components/hls.tsx
+"use client";
+import React from "react";
+import Hls from "hls.js";
+
+type Props = {
+ src: string;
+ fallbackSrc?: string; // MP4 fallback URL
+ // optional props if you want
+ controls?: boolean;
+ autoPlay?: boolean;
+ videoId?: string; // Auto-load subtitles from /subtitles/{videoId}.vtt
+ subtitles?: Array<{
+ src: string;
+ kind?: "subtitles" | "captions" | "descriptions" | "chapters" | "metadata";
+ srclang?: string;
+ label?: string;
+ }>;
+};
+
+export const HlsPlayer = React.forwardRef(function HlsPlayer(
+ { src, fallbackSrc, controls = true, autoPlay = false, videoId, subtitles = [] },
+ ref
+) {
+ const internalRef = React.useRef(null);
+ const [error, setError] = React.useState(null);
+
+ // allow parent ref to point to the underlying video
+ React.useImperativeHandle(ref, () => internalRef.current || ({} as HTMLVideoElement), [internalRef.current]);
+
+ React.useEffect(() => {
+ const video = internalRef.current;
+ if (!video) return;
+
+ // avoid attaching multiple Hls instances if src didn't change
+ let hls: Hls | null = null;
+ let hasError = false;
+
+ const loadHls = (sourceUrl: string) => {
+ // Check if it's an HLS URL
+ if (sourceUrl.endsWith('.m3u8')) {
+ if (video.canPlayType("application/vnd.apple.mpegurl")) {
+ // native HLS (Safari)
+ video.src = sourceUrl;
+ } else if (Hls.isSupported()) {
+ hls = new Hls();
+
+ // Handle HLS errors with fallback
+ hls.on(Hls.Events.ERROR, (event, data) => {
+ console.error('HLS Error:', event, data);
+ if (data.fatal) {
+ hasError = true;
+ // Try fallback if available
+ if (fallbackSrc) {
+ console.log('Falling back to MP4:', fallbackSrc);
+ setError(null);
+ loadMp4(fallbackSrc);
+ } else {
+ setError('Failed to load HLS stream');
+ }
+ }
+ });
+
+ hls.loadSource(sourceUrl);
+ hls.attachMedia(video);
+ } else {
+ // HLS not supported, try fallback
+ if (fallbackSrc) {
+ console.log('HLS not supported, using MP4 fallback');
+ loadMp4(fallbackSrc);
+ } else {
+ setError('HLS streaming not supported on this device');
+ // Try to load as MP4 anyway
+ video.src = sourceUrl;
+ }
+ }
+ } else {
+ // Non-HLS URL, load as MP4
+ loadMp4(sourceUrl);
+ }
+ };
+
+ const loadMp4 = (sourceUrl: string) => {
+ if (hls) {
+ hls.destroy();
+ hls = null;
+ }
+ video.src = sourceUrl;
+ };
+
+ loadHls(src);
+
+ // cleanup
+ return () => {
+ if (hls) {
+ hls.destroy();
+ hls = null;
+ }
+ // optionally pause and clear src
+ if (video) {
+ try { video.pause(); } catch {}
+ // video.src = "";
+ }
+ };
+ }, [src, fallbackSrc]);
+
+ return (
+ <>
+ e.preventDefault()}
+ controlsList="nodownload"
+ className="w-full"
+ >
+ {videoId && (
+
+ )}
+ {subtitles.map((subtitle, index) => (
+
+ ))}
+
+ {error && (
+ {error}
+ )}
+ >
+ );
+});
+
+export default HlsPlayer;
diff --git a/components/login-form.tsx b/components/login-form.tsx
new file mode 100644
index 0000000..422cc77
--- /dev/null
+++ b/components/login-form.tsx
@@ -0,0 +1,64 @@
+'use client';
+import { signIn } from 'next-auth/react';
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ Field,
+ FieldDescription,
+ FieldGroup,
+} from '@/components/ui/field';
+
+export function LoginForm({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+
+
+ Welcome
+ Please login with your Virtual Window Google account
+
+
+
+
+
+
+ signIn('google', { callbackUrl: '/dashboard' })
+ }
+ className="w-full flex items-center justify-center gap-2"
+ >
+
+
+
+ Login with Google
+
+
+
+
+
+
+
+ By clicking continue, you agree to our Terms of Service {' '}
+ and Privacy Policy .
+
+
+ );
+}
diff --git a/components/nav-documents.tsx b/components/nav-documents.tsx
new file mode 100644
index 0000000..b551e71
--- /dev/null
+++ b/components/nav-documents.tsx
@@ -0,0 +1,92 @@
+"use client"
+
+import {
+ IconDots,
+ IconFolder,
+ IconShare3,
+ IconTrash,
+ type Icon,
+} from "@tabler/icons-react"
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import {
+ SidebarGroup,
+ SidebarGroupLabel,
+ SidebarMenu,
+ SidebarMenuAction,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ useSidebar,
+} from "@/components/ui/sidebar"
+
+export function NavDocuments({
+ items,
+}: {
+ items: {
+ name: string
+ url: string
+ icon: Icon
+ }[]
+}) {
+ const { isMobile } = useSidebar()
+
+ return (
+
+ Documents
+
+ {items.map((item) => (
+
+
+
+
+ {item.name}
+
+
+
+
+
+
+ More
+
+
+
+
+
+ Open
+
+
+
+ Share
+
+
+
+
+ Delete
+
+
+
+
+ ))}
+
+
+
+ More
+
+
+
+
+ )
+}
diff --git a/components/nav-main.tsx b/components/nav-main.tsx
new file mode 100644
index 0000000..142040e
--- /dev/null
+++ b/components/nav-main.tsx
@@ -0,0 +1,91 @@
+"use client"
+
+import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+} from "@/components/ui/sidebar"
+import { ChevronRight, type LucideIcon } from "lucide-react"
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible"
+
+export function NavMain({
+ items,
+}: {
+ items: {
+ title: string
+ url: string
+ icon?: Icon
+ items?: { title: string; url: string }[]
+ isActive?: boolean
+ }[]
+}) {
+ return (
+
+ Platform
+
+ {items.map((item) => {
+ const hasSubItems = item.items && item.items.length > 0;
+
+ return (
+
+
+
+ {hasSubItems && (
+
+
+ {item.items?.map((subItem) => (
+
+
+
+ {subItem.title}
+
+
+
+ ))}
+
+
+ )}
+
+
+ );
+ })}
+
+
+ )
+}
diff --git a/components/nav-secondary.tsx b/components/nav-secondary.tsx
new file mode 100644
index 0000000..3f3636f
--- /dev/null
+++ b/components/nav-secondary.tsx
@@ -0,0 +1,42 @@
+"use client"
+
+import * as React from "react"
+import { type Icon } from "@tabler/icons-react"
+
+import {
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+} from "@/components/ui/sidebar"
+
+export function NavSecondary({
+ items,
+ ...props
+}: {
+ items: {
+ title: string
+ url: string
+ icon: Icon
+ }[]
+} & React.ComponentPropsWithoutRef) {
+ return (
+
+
+
+ {items.map((item) => (
+
+
+
+
+ {item.title}
+
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/components/nav-user.tsx b/components/nav-user.tsx
new file mode 100644
index 0000000..a913b9f
--- /dev/null
+++ b/components/nav-user.tsx
@@ -0,0 +1,105 @@
+'use client';
+import * as React from 'react';
+import { signOut } from 'next-auth/react';
+import {
+ IconDotsVertical,
+ IconLogout,
+} from '@tabler/icons-react';
+
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from '@/components/ui/avatar';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import {
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ useSidebar,
+} from '@/components/ui/sidebar';
+
+export function NavUser({
+ user,
+}: {
+ user: {
+ name: string;
+ email: string;
+ avatar: string;
+ };
+}) {
+ const { isMobile } = useSidebar();
+
+ return (
+
+
+
+
+
+
+
+ CP
+
+
+ {user.name}
+
+ {user.email}
+
+
+
+
+
+
+
+
+
+
+
+ CN
+
+
+ {user.name}
+
+ {user.email}
+
+
+
+
+
+
+
+ {/* SIGN OUT */}
+ {
+ // Prevent default menu behavior then sign out.
+ e.preventDefault?.();
+ // Redirect to home after sign out. Change callbackUrl as needed.
+ signOut({ redirect: true, callbackUrl: '/' });
+ }}
+ className="cursor-pointer"
+ >
+
+ Sign out
+
+
+
+
+
+ );
+}
diff --git a/components/section-cards.tsx b/components/section-cards.tsx
new file mode 100644
index 0000000..f714d25
--- /dev/null
+++ b/components/section-cards.tsx
@@ -0,0 +1,102 @@
+import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"
+
+import { Badge } from "@/components/ui/badge"
+import {
+ Card,
+ CardAction,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+
+export function SectionCards() {
+ return (
+
+
+
+ Total Revenue
+
+ $1,250.00
+
+
+
+
+ +12.5%
+
+
+
+
+
+ Trending up this month
+
+
+ Visitors for the last 6 months
+
+
+
+
+
+ New Customers
+
+ 1,234
+
+
+
+
+ -20%
+
+
+
+
+
+ Down 20% this period
+
+
+ Acquisition needs attention
+
+
+
+
+
+ Active Accounts
+
+ 45,678
+
+
+
+
+ +12.5%
+
+
+
+
+
+ Strong user retention
+
+ Engagement exceed targets
+
+
+
+
+ Growth Rate
+
+ 4.5%
+
+
+
+
+ +4.5%
+
+
+
+
+
+ Steady performance increase
+
+ Meets growth projections
+
+
+
+ )
+}
diff --git a/components/segmented-progress-bar.tsx b/components/segmented-progress-bar.tsx
new file mode 100644
index 0000000..2d63833
--- /dev/null
+++ b/components/segmented-progress-bar.tsx
@@ -0,0 +1,192 @@
+'use client';
+
+import React from 'react';
+
+export interface WatchSegment {
+ startSec: number;
+ endSec: number;
+ watchedAt: string;
+}
+
+interface SegmentedProgressBarProps {
+ segments: WatchSegment[];
+ duration: number;
+ percent: number;
+ className?: string;
+ height?: 'sm' | 'md' | 'lg';
+ showTooltip?: boolean;
+ interactive?: boolean;
+ onSegmentClick?: (segment: WatchSegment, position: number) => void;
+}
+
+const heightPixels = {
+ sm: '4px',
+ md: '8px',
+ lg: '12px',
+};
+
+export function SegmentedProgressBar({
+ segments,
+ duration,
+ percent,
+ className = '',
+ height = 'md',
+ showTooltip = true,
+ interactive = false,
+ onSegmentClick,
+}: SegmentedProgressBarProps) {
+ const [tooltipPos, setTooltipPos] = React.useState<{ x: number; time: string } | null>(null);
+ const containerRef = React.useRef(null);
+
+ // Normalize segments: merge overlapping ranges
+ const normalizedSegments = React.useMemo(() => {
+ if (segments.length === 0) return [];
+
+ const sorted = [...segments].sort((a, b) => a.startSec - b.startSec);
+ const merged: WatchSegment[] = [];
+
+ for (const seg of sorted) {
+ if (merged.length === 0) {
+ merged.push({ ...seg });
+ } else {
+ const last = merged[merged.length - 1];
+ // Check for overlap or adjacency (within 0.5s)
+ if (seg.startSec <= last.endSec + 0.5) {
+ // Merge
+ last.endSec = Math.max(last.endSec, seg.endSec);
+ } else {
+ // Gap, add new segment
+ merged.push({ ...seg });
+ }
+ }
+ }
+
+ return merged;
+ }, [segments]);
+
+ const handleMouseMove = React.useCallback(
+ (e: React.MouseEvent) => {
+ if (!showTooltip || !containerRef.current) return;
+
+ const rect = containerRef.current.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const percentage = Math.max(0, Math.min(1, x / rect.width));
+ const time = Math.round(percentage * duration);
+
+ setTooltipPos({
+ x,
+ time: formatTime(time),
+ });
+ },
+ [duration, showTooltip]
+ );
+
+ const handleMouseLeave = () => {
+ setTooltipPos(null);
+ };
+
+ const handleClick = (e: React.MouseEvent) => {
+ if (!interactive || !onSegmentClick || !containerRef.current) return;
+
+ const rect = containerRef.current.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const percentage = Math.max(0, Math.min(1, x / rect.width));
+ const position = Math.round(percentage * duration);
+
+ // Find which segment was clicked
+ for (const segment of normalizedSegments) {
+ if (position >= segment.startSec && position <= segment.endSec) {
+ onSegmentClick(segment, position);
+ break;
+ }
+ }
+ };
+
+ const barHeight = heightPixels[height];
+
+ return (
+
+ {/* Main progress bar container */}
+
+ {/* Watched segments */}
+ {normalizedSegments.map((segment, idx) => {
+ const startPercent = (segment.startSec / duration) * 100;
+ const endPercent = (segment.endSec / duration) * 100;
+ const width = endPercent - startPercent;
+
+ return (
+
{
+ e.currentTarget.style.backgroundColor = '#2563eb';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = '#3b82f6';
+ }}
+ />
+ );
+ })}
+
+ {/* Current progress line */}
+ {percent > 0 && (
+
+ )}
+
+
+ {/* Tooltip text - only show if showTooltip is true */}
+ {tooltipPos && showTooltip && (
+
+ {tooltipPos.time} / {formatTime(duration)}
+
+ )}
+
+ );
+}
+
+function formatTime(seconds: number): string {
+ const h = Math.floor(seconds / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ const s = Math.floor(seconds % 60);
+
+ if (h > 0) {
+ return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
+ }
+ return `${m}:${String(s).padStart(2, '0')}`;
+}
diff --git a/components/site-header.tsx b/components/site-header.tsx
new file mode 100644
index 0000000..d801cc3
--- /dev/null
+++ b/components/site-header.tsx
@@ -0,0 +1,116 @@
+'use client';
+
+import { usePathname } from 'next/navigation';
+import { Button } from "@/components/ui/button"
+import { Separator } from "@/components/ui/separator"
+import { SidebarTrigger } from "@/components/ui/sidebar"
+import Link from 'next/link';
+import { ChevronRight } from 'lucide-react';
+
+function getBreadcrumbs(pathname: string) {
+ // Map routes to breadcrumb labels
+ const routeMap: Record
= {
+ '/dashboard': [{ label: 'Dashboard', href: '/dashboard' }],
+ '/dashboard/liked-videos': [
+ { label: 'Dashboard', href: '/dashboard' },
+ { label: 'Liked Videos', href: '/dashboard/liked-videos' }
+ ],
+ '/dashboard/watch-history': [
+ { label: 'Dashboard', href: '/dashboard' },
+ { label: 'Watch History', href: '/dashboard/watch-history' }
+ ],
+ '/videoplayer': [
+ { label: 'Dashboard', href: '/dashboard' },
+ { label: 'Video Player', href: '/videoplayer' }
+ ],
+ '/admin': [{ label: 'Admin Panel', href: '/admin' }],
+ '/admin/users': [
+ { label: 'Admin Panel', href: '/admin' },
+ { label: 'Users', href: '/admin/users' }
+ ],
+ '/admin/courses': [
+ { label: 'Admin Panel', href: '/admin' },
+ { label: 'Courses', href: '/admin/courses' }
+ ],
+ '/admin/playlists': [
+ { label: 'Admin Panel', href: '/admin' },
+ { label: 'Playlists', href: '/admin/playlists' }
+ ],
+ '/admin/videos': [
+ { label: 'Admin Panel', href: '/admin' },
+ { label: 'Videos', href: '/admin/videos' }
+ ],
+ '/admin/enrollments': [
+ { label: 'Admin Panel', href: '/admin' },
+ { label: 'Enrollments', href: '/admin/enrollments' }
+ ],
+ '/login': [{ label: 'Login', href: '/login' }],
+ };
+
+ // Check for exact match first
+ if (routeMap[pathname]) {
+ return routeMap[pathname];
+ }
+
+ // Check for prefix matches (like /admin/users/[userId])
+ for (const [route, breadcrumbs] of Object.entries(routeMap)) {
+ if (pathname.startsWith(route + '/')) {
+ // For dynamic routes like /admin/users/[userId]/...
+ if (route === '/admin/users' && pathname.match(/^\/admin\/users\/[^/]+/)) {
+ return [
+ { label: 'Admin Panel', href: '/admin' },
+ { label: 'Users', href: '/admin/users' },
+ { label: 'User Details', href: pathname.split('/').slice(0, 4).join('/') }
+ ];
+ }
+ if (route === '/admin/videos' && pathname.match(/^\/admin\/videos\/[^/]+/)) {
+ return [
+ { label: 'Admin Panel', href: '/admin' },
+ { label: 'Videos', href: '/admin/videos' },
+ { label: 'Edit Video', href: pathname.split('/').slice(0, 4).join('/') }
+ ];
+ }
+ }
+ }
+
+ // Default fallback
+ return [{ label: 'Library', href: '/' }];
+}
+
+export function SiteHeader() {
+ const pathname = usePathname();
+ const breadcrumbs = getBreadcrumbs(pathname);
+
+ return (
+
+
+
+
+
+ {breadcrumbs.map((crumb, index) => (
+
+ {index > 0 && (
+
+ )}
+ {index === breadcrumbs.length - 1 ? (
+
+ {crumb.label}
+
+ ) : (
+
+ {crumb.label}
+
+ )}
+
+ ))}
+
+
+
+ )
+}
diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx
new file mode 100644
index 0000000..e018a73
--- /dev/null
+++ b/components/theme-provider.tsx
@@ -0,0 +1,11 @@
+"use client"
+
+import * as React from "react"
+import { ThemeProvider as NextThemesProvider } from "next-themes"
+
+export function ThemeProvider({
+ children,
+ ...props
+}: React.ComponentProps) {
+ return {children}
+}
\ No newline at end of file
diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx
new file mode 100644
index 0000000..4a8cca4
--- /dev/null
+++ b/components/ui/accordion.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import * as React from "react"
+import * as AccordionPrimitive from "@radix-ui/react-accordion"
+import { ChevronDownIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Accordion({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AccordionItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AccordionTrigger({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ svg]:rotate-180",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+ )
+}
+
+function AccordionContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..0863e40
--- /dev/null
+++ b/components/ui/alert-dialog.tsx
@@ -0,0 +1,157 @@
+"use client"
+
+import * as React from "react"
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
+
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+
+function AlertDialog({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx
new file mode 100644
index 0000000..71e428b
--- /dev/null
+++ b/components/ui/avatar.tsx
@@ -0,0 +1,53 @@
+"use client"
+
+import * as React from "react"
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
+
+import { cn } from "@/lib/utils"
+
+function Avatar({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
new file mode 100644
index 0000000..fd3a406
--- /dev/null
+++ b/components/ui/badge.tsx
@@ -0,0 +1,46 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span"
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000..eb88f32
--- /dev/null
+++ b/components/ui/breadcrumb.tsx
@@ -0,0 +1,109 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { ChevronRight, MoreHorizontal } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
+ return
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
+ return (
+
+ )
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ )
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot : "a"
+
+ return (
+
+ )
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+ svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? }
+
+ )
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+
+ More
+
+ )
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+}
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
new file mode 100644
index 0000000..21409a0
--- /dev/null
+++ b/components/ui/button.tsx
@@ -0,0 +1,60 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost:
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot : "button"
+
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/components/ui/card.tsx b/components/ui/card.tsx
new file mode 100644
index 0000000..681ad98
--- /dev/null
+++ b/components/ui/card.tsx
@@ -0,0 +1,92 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/components/ui/carousel.tsx b/components/ui/carousel.tsx
new file mode 100644
index 0000000..0e05a77
--- /dev/null
+++ b/components/ui/carousel.tsx
@@ -0,0 +1,241 @@
+"use client"
+
+import * as React from "react"
+import useEmblaCarousel, {
+ type UseEmblaCarouselType,
+} from "embla-carousel-react"
+import { ArrowLeft, ArrowRight } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+
+type CarouselApi = UseEmblaCarouselType[1]
+type UseCarouselParameters = Parameters
+type CarouselOptions = UseCarouselParameters[0]
+type CarouselPlugin = UseCarouselParameters[1]
+
+type CarouselProps = {
+ opts?: CarouselOptions
+ plugins?: CarouselPlugin
+ orientation?: "horizontal" | "vertical"
+ setApi?: (api: CarouselApi) => void
+}
+
+type CarouselContextProps = {
+ carouselRef: ReturnType[0]
+ api: ReturnType[1]
+ scrollPrev: () => void
+ scrollNext: () => void
+ canScrollPrev: boolean
+ canScrollNext: boolean
+} & CarouselProps
+
+const CarouselContext = React.createContext(null)
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext)
+
+ if (!context) {
+ throw new Error("useCarousel must be used within a ")
+ }
+
+ return context
+}
+
+function Carousel({
+ orientation = "horizontal",
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & CarouselProps) {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === "horizontal" ? "x" : "y",
+ },
+ plugins
+ )
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false)
+ const [canScrollNext, setCanScrollNext] = React.useState(false)
+
+ const onSelect = React.useCallback((api: CarouselApi) => {
+ if (!api) return
+ setCanScrollPrev(api.canScrollPrev())
+ setCanScrollNext(api.canScrollNext())
+ }, [])
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev()
+ }, [api])
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext()
+ }, [api])
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault()
+ scrollPrev()
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault()
+ scrollNext()
+ }
+ },
+ [scrollPrev, scrollNext]
+ )
+
+ React.useEffect(() => {
+ if (!api || !setApi) return
+ setApi(api)
+ }, [api, setApi])
+
+ React.useEffect(() => {
+ if (!api) return
+ onSelect(api)
+ api.on("reInit", onSelect)
+ api.on("select", onSelect)
+
+ return () => {
+ api?.off("select", onSelect)
+ }
+ }, [api, onSelect])
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
+ const { carouselRef, orientation } = useCarousel()
+
+ return (
+
+ )
+}
+
+function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
+ const { orientation } = useCarousel()
+
+ return (
+
+ )
+}
+
+function CarouselPrevious({
+ className,
+ variant = "outline",
+ size = "icon",
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel()
+
+ return (
+
+
+ Previous slide
+
+ )
+}
+
+function CarouselNext({
+ className,
+ variant = "outline",
+ size = "icon",
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollNext, canScrollNext } = useCarousel()
+
+ return (
+
+
+ Next slide
+
+ )
+}
+
+export {
+ type CarouselApi,
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselPrevious,
+ CarouselNext,
+}
diff --git a/components/ui/chart.tsx b/components/ui/chart.tsx
new file mode 100644
index 0000000..8b42f21
--- /dev/null
+++ b/components/ui/chart.tsx
@@ -0,0 +1,357 @@
+"use client"
+
+import * as React from "react"
+import * as RechartsPrimitive from "recharts"
+
+import { cn } from "@/lib/utils"
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const
+
+export type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode
+ icon?: React.ComponentType
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record }
+ )
+}
+
+type ChartContextProps = {
+ config: ChartConfig
+}
+
+const ChartContext = React.createContext(null)
+
+function useChart() {
+ const context = React.useContext(ChartContext)
+
+ if (!context) {
+ throw new Error("useChart must be used within a ")
+ }
+
+ return context
+}
+
+function ChartContainer({
+ id,
+ className,
+ children,
+ config,
+ ...props
+}: React.ComponentProps<"div"> & {
+ config: ChartConfig
+ children: React.ComponentProps<
+ typeof RechartsPrimitive.ResponsiveContainer
+ >["children"]
+}) {
+ const uniqueId = React.useId()
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ )
+}
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(
+ ([, config]) => config.theme || config.color
+ )
+
+ if (!colorConfig.length) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/next.svg b/public/next.svg
new file mode 100644
index 0000000..5174b28
--- /dev/null
+++ b/public/next.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/vault.exr b/public/vault.exr
new file mode 100644
index 0000000000000000000000000000000000000000..cbb6703cb7a2b015ae435328f61aa2a4d7cb7f40
GIT binary patch
literal 2211911
zcmd?PcU+U{x-OiMKoV+5=%9q&A@n*DLg+F5_g`nv@)AYd#wo(+tO^&$UVzySz;B#O~g
z#wx4+vpcpUcUY_*{a4lRs0fiDzo5|I$SBYh7)b4GSoqn<;HUs)VB{N23h-Ap0s-rc
zM(^pbf1`x|?4`=_Z_g^9VXTARl+&}C6D?X6l+0e6O3sO`7`E0la;jf*B1nypFFzIJ<
zfS)#2_vc9Zp7H~3IXNIQGWaYBu=>ZCey8Dn@qVEJk;?x_gUr~ZKS%wWU8<(6qxLWQ
zkv?JJfQP>T0Ot^-fz{ReSG{>U@H)zZA^Hy&qaQPwAN`Rx;3ge~2Z5@N3QGIo
z?yvqYCn%5uhzD#2k_6*H8ek(391J=HND+9^BRYYP^q|N~fBW?3;eY3{b`ah{|Bax`=6P%
z_y5ea6aHtWUG+aRZ5ohv8}KIdpXm@-W;F5-f%%Vl=8t~lKQXze_>thh!Q_AJ_I-(+ecxf}^w+<+UHJAg2y|t{
zUMIIbe@m20KapK|N=15R_TZG6vj;8}$MR@iT|>jyI~S>~rKL8%yQ<*7Z(*q7gHE;0JADTg}`zFPoeqr36ipYkuo8-B_+jMx8^fA_xYCu_21?n>Wb2*3h(pp9!-Vc799R1
zEilxTU`**3h4;Wz^q}A_Lgj7#C4~M#!6iY7gMveZI^YFicu-(?GzFx01+@VcK|pZ#
zpY(vWLxzC9W+M
z`AfhGy+}X20V75Ty4M1Fa3G-rN5+6&V!%D)0<6=c(KYB*06+lSh_1;f-VY@pCqR^-
z1NmkE`3_`mI7-uj0P#i>3?M)XGy@2|hJZf+Bz*}`0VWp!8=$<0%ye5oMFa9({~68j
zKppUc1LlA)Am))F;PMgz$dDPZy9?FGX%~lo8h}Iz)2*4^$=|E{lSL=
zu5YrxT>%|l`6hBW`ry^!DAzYhU=4IQ!u3t$HyYQgL)rsiaOE5Pw+Ue7aN@x?=;7o^
z(BZw4-&zi7C%;7wo>hDQXd{~rld
zA0WYafItoZqR1lxy>v9y1_ZhQfimco&ZkEN%9tJ=O&J8rAVy%@FbI^vr-uMp<}qjl
z*ogoI(Fx_QpbR6>UrdAei)|TFL%}0X{6VUQbnXNKG4>#xpBe7e=?(xhAO>B)AT7oS
zustF{;69;_{LyzMon9Hd#@M6)6Vi7kol5Cv@drNx(CC7%#W#vz>r?g9e9DV>`C1MeOMr>oGJ;VrN2l@VO>(^^ln%1@Io(F??~*qtjg-
z)e>Mhy6u4@0G7_sKwJRZ5YW*%&Jl>l2oJC=9U(w;j`Z@=W%LUjIKY)aaz{)Fq#OAS
z;7JJG?OQZJjsR!UQvrH};WK(J0FB-SNO(xU>yXLdkOmMX*8%;Pc9PCuU_gmZ%5?Un
z0c`r2PJ8h0_YV$-Pks{s=8tm%UzHa~PUpfj%G;q1w88i&&W$^M^i
ze_#>>$Z*M;ENqvx!TSe5eGF*d4>vE_ry@02eA+?X9?9l$v&RL%!cgyDSj{`Kin3ke
z@`P<{t;|$QTAef1nxKm7O<()g2SS>DV=YB$U|KZd@O{fS}
zZXarZ%gT7!jn-I=)>GPYb42_jn?-K#l+5bS7f~rp_7~Rhv#%x>m~B>U#w9HCiyFJq
ziB21xwba*X4jDe-HmhY!DAlxf<0K?|x)-n2D?**%Q?hwr&hwQ3r?Zh7b4GJ3D^1~7
zZ`mw`Pt1!N%YK!z@Yxi9(`vtay1Q9p=*cbfqK1{lz22)UD+3$KR?K2);a;x!$L*O{
zU)8a_hT4iohYjqdka(E|3~iT_>(#_A{KRybR-oqZDmPU5?^mi{69si>viVX6zI<-^1sl
zMl$)f2B1CJ_lIqfJQCG&igV+HGm^3xebl#0z;+nQYl677~<_htYy>QWmE|uQ1kgpQh
zsKRM~H@V)y{F_rYx?YYunFGWxNr7X|%UTl3PhPiOmi~rr%YZd0FDf*fN(63_GP%ZB4}6cGi;Kg(1k~Usb^A{eR@&xCvbbXWyW$%4SNSA)$Lq1lin$*jwYCmsTbvEmZM}~FO
zPYE6yY1|hol6{4yjWk-=IwpNf*bho4yurwm7QE!jej_!3Y3{4oQoKi5<7F^=j4hrZ
zUF9ik@r+wn;}mOB*5n1Qj1sf%WS-|biINfx0x;ITCmv-3bF!o-dydT)=RIDp{*vZ8
z;m94KB4MN`%I}7(VBKJzi#oU`+3t93FRAPgO=WS%9=JRq-bFJ;Dv*zI+D-
z0{oS@+wrwA$E7EA2b!(y1`0C0UVNp#_d8}2-XB0fX
zrH61V)gzU7|G7ANW%C->mtm1r8&D255;e#po6g4{@osPKN-FvJlJZSWgmt=2;kVV*?Apx0SzP9?y7C8UaGmTu3V-3!WSiV@y!c@8
zF-cdvi`jeR9Y_`p;1TH1WaW~F<^IW)vPKX|#8NMI+
zrW%v4URqW7Ov*3z33$l#+VQaICgT`$z?&pce&4U-J
zdF;=Jtal0ZUv%r!At1}-(>p{s1Z2j0D%`ip=Y-Pd*|y`Q_xDWY?QT2TM+>;e#sUW)x5ZQUoX?_MWw}i_yxFZEm9SYqxBi
zj%mYKCegVMnY;z$VYyOU2iBtVGi`1jV;e>8?kj;$ryFMTh$7Vit}Aymg_*ht13i9rNi8c&te&3O!M0L|Wm2`mOipFLttU2g_0M@*
zk2@wwXL_&o^F}pqUIM7#?q|`}q(1Ce{bdoP74u{DK+G@IT_uHU6e?}3Pk3XpN1TYCodf$d4)2FjPcZi(I(w*@
z<8{Kbi-HK_j?Wh5)&UZPXZR`q@)RKpcW1ZSE)DL1tdpaXN(rpSJBBl$s|avV;`WRA
zhf)))GK#l%DZU=#LSv(~OOk4{LK)b}J+bn8GHM^5e!eOeKfY4a(v9e65lG|*IKNri
zbZmHPvF@?KMfD)NV>L$TqUL*?inxNT@_aH(k&C!1LuY?Q2Df
z6>|7G_>F1FyV6=!(abP$-7_*gGW=(FJ4lNo^QEwKZL}(}hwBk(Hv;)H
z_^A$z<_{KwjIq&Fp$&>!Fju{i4Buq=E3!haZBZ1vW3@j8e6;VjyeWHQ?fC=Oo{-A|ffcUpXJR9Wrv`d{
zhFRO0xFp^_K8)bp8sjrTgig`kHPnf^={3g>C(GZFc=6L{Cs~$%;tQ!PnhGME4Qlt7
zgdZGBZrEjFJ&`gY-)xbm9cwG@$jy6@RGMZfD{XfEaX|7Xq9@H)#?lxAx(7-X8A%Wy
z!QddpsPDBAWdCtIb$KHmlf5z7WIdfF)F!F_@D?u$V^3IeLQ$r
zWKx1DLlr3Yt1=NK)~KQTmqT}pH(XfuT(x%LDZ8pd2v^7Pp!*~RiuY3w9U(cT+5#t%
zYK{1lp5PDbnDVfZgWq8X(mtp}G5o=6)<&jK7pg6iml??n>dN|bdHD-*)(V~iuTfTP
zQQqFWs4i5x;(3Qe3=A?Do`k>f}I3peA2sEVtiq
zILQdQr7^y+D|x;miTGUd*J9pV{62P_FDuBzac`mFsAu3)XF&ZBjboA&z6c2g>9KI`
zARhCtKJip%ToiLZt6rhaz}}0eDbzP1vhqqR;!G6w$Hy~gQAYl3XDk(B_RIAU1Dz{!
zEE{L*d_tr|9Lp@n9puley|8L^@As;N!r7!QNn8D%2oK5rG2Dd*p0cn%b6L
zZ6$n6j~!;!Z0wS*l^PzZ`=jS_m~*alvuu%1A+HUw8F5~WM`$8ZoZFZ`O_Muk%p$uP
z%JOgn+j6Jg#fDo`+w8--I=BHtODf21nX_VHA=(+iwVeSeQToaL9=Wm?7ZZ9}
zd||{kS|G$BKdFk#I~CSJ>r*OB8%{{D=NQ{+ufntS<&e<(SFcEHg=9}lr-i#tCJ+qE
zM5g7>6@&?lLloELm{)(=D)l#)W2%#7;!)#BO{uQ4UA@JZLP;ZR=SU_(0%Z}YF&Pr3
z>U^m^HtrXiV}|}$lHlm{Ry3wrH-9eCUeO8H6&WtwIUBWv0c#kkJ`#5o)Y}x
zl&Vho!e!Dk`|{d94BNpN`;ilIj`?yTLuu0eMB!d@UeWB~);>vZ$nBtz~A(gcJ
zPi`60EDc{CCZakTxV8Sc#VxmWpFf#48#<6wmp|Pi+FwMfQbiBMR67ycE11nh^qlN9
zmwaZmY75J)!o$d7Y4GG*oON;5%m(dGe8qQ~ch&TvZZ?ZIu=!#oLRql>4;?k_HXed=mUG1PA!o9m6chFRz#uHi+S^UD=x(M$+WRA89;!
zZ=P$qSKj%0&PH81EM~9##Lv89_e)#Zc8pa8`-C}v^EJQsa#qTMGOABh>(3WA5>ymu
zuu4z2q6*B5ESf7cg*%>bSQ;M9NU%-#4b;R`UhUl^H6qE2sVg>eqsaiLvhp7nD
zMv+~AqavEX4Ut^T(4;;LMonTc;po$$wMNXPM$(Z%PijpTiJ36rWK-~G;{=ouGnX7<
zqM2~e}a>l^2acusV;H5a|P@`9iuJ{+ez
zAJflgUau>DllFL8+A*1B?6l4o&CRubIchoai>-_0HGSv@lZGQ;nTgg;?QGTF_^5jUym-u
zvuo|N_|Qxw=7$kz=72qSJI~ANvE+S+di>)G>b
zEN^I^UeV54&SZLrBv1bqS?d3^^?nKNJ8Cb*ir;BnWOvQui`Jva@KGsfsYtb5jIX0Z
z8x__6{>lbi&aU5P9(d>ze$AF(&eX;=oNJmkTxCx+0y`)aq$BH#k_lsR1`0))xKbXt
zgd~rZq9*^s%ZFlVDRx(jy`pS0s>>(mZ`a8_bf)RViIy5>$*L)Z>oR_^VxDTW3S4&-IUX$kc9Geho4J!Ro`@E
znisu~HDPLhb(z4NyJAXA`jvg{dVqIPHIMa^6~zrpw!y~6Xpg7zNpLA!8~8TiC!;1L
zo|A>UIILOBS1`Sdva!Ch8Zs1V`*dU2;^M4)h^Mt2bp4t*?oeM6<~C?-SB92wFLAU)
z1zmf8Z>io)cOdUOthc^*=a#lMmV_F+*(r?=((ja5;KIj+VN!zfJ@HlZxpGa9Ib$q(
zMAph`JIs%^(8dZzjg|
zFGaGqKIoN-pjc05AWz7JfwRL{9}-$py}m?5FFCv#Gc;CIh3|gQEEo~8ZgL38nhFeZ
zCTEILpTHh^t0WZvQJUe!^3Qr
zZJnIf18US?b7c7%;$IPEk%yfIzQ2ajZ&fP4+
z_mN4a9#1Uq+iUw_z!3s<=%{>Y)E$~ufUg?G-Ci(WpoLV+E~oG$WuL#1$kvP5aNi5I
zR!;fc9C?-CJJi=4ux4YXS6R6+!ZE;BAKA~rL=Y334z(zCNO@!}obCOptsn~fUMu1O@8*RdU@C%=>M|M*i?(M&gYHxM(i;lWJZ_36envgDEWsbcntb1}mQlqJlp1I63cx5?
zK%MzO+^4~nOqq7Wbm^;W;B!osUAtw8ndd
zKqjeK-C6DodEHxD6`_`;4)-f1nf^@rC_q9x;xX5dTV)YWY}={xk-;-)c22ud4|Fc9
z+Kt7dK|aHj*=|zgffb>L=@(~vu;R||3TnRg>V0!7b9MSXxr^QFVTB>L-$x8ILc}qh
zvP=ZgwTR6kC{Pl#B2%4-9uRMnhYPu07A@(^jKIB@Uo2n?>T+Qfe=~XfN--2JG?`PQy%?nhv
zlua{dIQd@QR}NHTesNIEorkga8HioH5wI-GAJD~oQ9FH?d*M%LjqC4p7*0h
ze6Uvj_~7d|Q|860rz`s-Kf{yTuN6VeWtW|t+$pY>X^HVpSEN3f!Ym;E>qQQ?fYf}7nfp((4eQb
zGEz|zRZlc)O^a%)yG!ui=K^;Z_Xf?iEL26hluNW`C&Co!wXn}cO{OVHxNIH1@w~0m
z^+dngVh1IY|E||x9ehrOM
zj(qX@jFqEP&L>Tl{g>Q{$GE9y?C!(c6VnAvEcuK-C&}xZMJ(~8;
zo)#f{-G8vkMl-z=c{k->=b#_8Jm4w0!VwU&p=wUB7(Vo
zx#!E4c9#QB33C@*ew^{=T%~SY=kIB>X?0vk+;_1?
zTGDA}qTx%dbYNbvNx1cwxoh?@u!Fo()&^%L0aGdVY9~bQE$;cEQh%3gnG%a}&c*^o
zLeW<8i#NFC*a}HjXYR~%M#kD&e(i6yzra43C!YNbbB>bzb{6}ktDt(UGYD7UJP=9U
z(+kc|jHpMguME|UuN9D3SEhrf>}sT*X!)Z!%`Gev2syLavlzE;s__2z95Me$sF(fBf!0P
z{+9EcP5nRx+gl34?t_aI^IN;0_%sa6T+Sf8KN6AaCrnl}o(DKO>yz~;E>Rr#U~eSZb5H_NZCa0?p`1y{Vrk4
z_M*X?Shqj(oM$BUBWv=NN6k?iqS%XOFLz%xiPQyP%(SB#VOl$FCEUs<9KHQ}#F!LV
zwP8{o-0SWBoS(5+q&?{#IE$Yo6Ngo&X2sj
z2g3Tt@Gp0DxIGGQP)$6e8vp)R*@Q>}0%M6&O!wBLR)JZ3UKyAm&Ea^SQvP4DJ$!sH
zV;@~X@3C>z{)W1W2@g-}oTtaiEmtkDb-J`zJ8yUs`%C$p>RFpBJFJs_`&2h1}snX>QYpxKn&%i=p=NJ6l1=qjFvd=IC+s
z$M80%+`xtbAIY??3DqDC*%E9ZvZS2g0(vQtR04M`28pM9o8AQXNFyWus5r?Q&W$|)iRfNqvoCGx5h+70n3kqL
zkP4CLBF0)Pe;ufx?#1kr+A&+(llV~{H(2E38=aJ?I;Xii97Ok~4icrxVTEFzS&&&_
zxi?^ifVl5|vR;9EhH7_p$1?Ma;FmEEgA($EBYH(~xj&+?gaQ*Zeh>
zb@5?Qb}g_kON-5ANW$+j87b|x4apW-n&E>cyMs^rZ$GXvF2VKsx+#^&CEx0_PMMmj
zFt@PB20yS4zSs)20mp+L3W1enVaOi#1>Y03)<&k=3tIs`Z@DX+{IkoWIv1bmRO16k
z?i!0OJ@z@+mi3xj>KbjZPlu2{4ioZspm&`5q$vKXb^ZmLhsD*foL?iz*;?aT&&3>ew-mK@+D8+zq(^v!#((`4
zs%v4NftsF9Pl}9V(J85b4xu6u5w5mMlzpixa3nQ5$#__OyF_lP}D
zG#ARXF;Z@MnOH%g7FXTwtr9KqHZZYFxSi6%W!Eg;pTe4*Oab$)+DAW1;hzx?I@P6V
zHD{yk7~Wi7VObV)aIB^V(x;hl$T<)EPn$Nv;cC_7lH#=b#G}6{$;I-cwkCP^5lKKK
zA|E1qowj;99K4Wo3QI(6wK{~_*hi1(aDM*DNM50VB8n9<@>ORS%O>6i(@=J&Mqku1
zZ=Rfat!*V$Oj+8oO+FWxN=3&s)#P>w6+UQHnxY9N$a%AyL?e*$puTkch{n>u7D67~T><_O#UaL{G!k*0spY73qT8aW$fsv&19jd?tSDlWP`)@Al^EN>ptPF&?g_Vl?LpzRvUju)rix{d4=
z;jYq?&3zV}q;RXLKRi-dG3fZtUSHG9K7s?G8M?&lJcd1;g1tmZHH}R8D%&k-omSCf
z{lF_cpZHsL6qZSRRhm^Zw=e=C$_hj!l^>+*Gz3+UQJYD#8=n
zv#YhGT&g0sr3x7UH;9CvgWK_f69kx`LJ)SfVMzjSbycyl>8Ls;4LO=o=0A2uiAy3Q
zX#T^y%l7EJVnk<5VBYieydHI-(0mZRwZU
z-iow4?PUW+%7}J~c7TPKlS-%!-zlFA8+}rNxB@J9BQcwIzPH_Lyrg3CY`REewDlS<
zLc~gGed$eEQdIGqko65csBs-njltzA%TbRj%Ei)5FGyi?)F-d6q4Ed3FG4=?d;fBc
zwIU?J8CuRG9*9iT3C_$I)>>`Kgbf5`G}5*U0)br>5#@*o14-V2KUBb<8qlRa7No{*IQN>yV$v$m#{(
z54}6>o>t*wKZ9Si@+XHP1@_g3)`v`RN+ab(gb^a^{i^#IPmMaZnzG
ziIXRNAi2oHcc3Qfdh)#HOrU5e}6XhExl=s@Uyc
zMSBcsn5?3)Z6y_2Wf*5@C0`YG#x8Z}}+Q$IK2b9qx3%C5W=`i8KjvoRI+
zY||x0tl7!acK#~zteG``2v@N*#|^nH1IR-m@4KATX~ZUPRTQ|sK}wRfqk$Pdh|c1z
zXzUTQNABSBi51E{CzaP9nnF%UjCRL)dS@k#ag{;$C|RQUxUe#L0A!)QIK7BZa9g(#88wsp2=OnC&2s=EB9c4bR?k7t1f!TmsIS
zZ7SZgEStyjysP#vfoXicVashr^8=zrFQwP}zOFU)*S;+8-48l$4?VU+TMqz-b454J
zAeJ}ApuoWhZeJJw;O$x*u)LPv{x#G2n6_pS=r~J|=-%)+PHt=9S*)m@GqRbyV{&RV
zpahpedB}q0I7k5-AY2;|4Y8mhShoxpTY>{&lBDCAl7UfpC$JoJf=~8EvIfClj_E1J
zU!>FR7^3#w^vtyRb;nrQ?XI=#L>D%kh8U5jB`J>G+;&Vul9JoO&(gk{ci?d?4Q=W`
zxe!uwqMG0k%%}34`c@Gyh`yd
zwUcIBDJM&4RyAt0+KW;iPvWptQpjsJ3<~AO^lpJs1u|$TJ@Z48(+xRFK&yi
zWP#V6Ae`r-7CIb4w}dq8i!tk{&bd{Ot>ie&2e|jc6Ks$O=ck`(PbuM2Ah+SGs;rym%Autvk<}yUs*t2vEe^_C19En|QMICm
z9EVj}pzQtoGg55bYG)!(YC1nxT3-R(XVOCE8mGbyyk90VL21S*gOd(hRcCmzlCmXt
zu$opvLL6^(O+AbSMIQd7-)x#E*y47s?u&E7+$WY+xfIANcu}{I&zNv^5{{GdJd?1L
zM>-dTLlQ>vB~MHB*kLp>JL8#ipEN6WYRZ+F)+r@dkUtbX?3;wGvIyLF&bMIUNw~%8
zCb2HtV!)2%1Nk${76|d%>!v(WUlic!awHbjzRb2?dhz`uXfvtJLVTUMCtFp^t3^!8
zw|xlA!r51$?lw*ITr*2P2ouaOxo+tm8IiFfAgA1)a?5U&*;^=L`SxgIK4yhWPCII(
zNEYrLc3fj7JegXA_s5$YTbG&}s7xZk_Wi1k=L*pluW+Jp%%JUrD#a{0og0r0)|J6+
z*2*Z$hsC*E5Fh_O%12h|N0y0oDj6~DDT$q6e;#M^XILL_B4WmTb>Kn)My%!{IIuQ2
z_^ovJiJv5$&J
zaLp@hW>$v|&s&W6c>3wI)u%kU$vu{8>RF&nShdCle8wqTm*(@PIU2u*>_)8(3?bY7
zOlf|EM2u@r()_YJhiri@m!77%ZyviROmBnPT{TOkKLuOMvC_6y^+HtTDhs#2MVd+a
zg0H5eI-!~5pKnl2@DuW!qYgdkXc`dreno%Mp?|`Ga?@IoOq0iu3OKgW12^6j^H@%w
zSAp|8W;!ZGc1_yX&<~Z-%j(WyJzJLT=~E5YP3pMCgB;ijhP|;#6Wr~!tS@y;9Yhf=
zJiIAHO#6g^jG`O9KeO=3E34(4mPkIS`2gVs(WQbaF>s~^d~+UMOS3@?L{xKEtN!k
zkgiYjo6ol1QAr8UF;u{0whk*)40z6sss>s}jKHrq3-vAmUjiIDKL188;U;7oB6*zK
zIiUqvWit9gSf1HQ-dFHwSqyUHIU6Y3B8oJPemg&S?