Initial commit

This commit is contained in:
twotalesanimation
2026-06-11 10:46:09 +02:00
commit 81ad7e4ea9
223 changed files with 39530 additions and 0 deletions
+37
View File
@@ -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
+47
View File
@@ -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/
+46
View File
@@ -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"]
+52
View File
@@ -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"]
+277
View File
@@ -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
+1138
View File
File diff suppressed because it is too large Load Diff
+91
View File
@@ -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
+208
View File
@@ -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 (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Admin Panel</h1>
<p className="text-muted-foreground">
Manage your users, courses, playlists, videos, and enrollments.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card className="hover:bg-accent cursor-pointer transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconChartBar className="w-5 h-5" />
Video Statistics
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
View video performance metrics and analytics.
</p>
<Link href="/admin/stats">
<Button variant="outline" className="w-full">
Go to Stats
</Button>
</Link>
</CardContent>
</Card>
<Card className="hover:bg-accent cursor-pointer transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconUsersManage className="w-5 h-5" />
Manage Users
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
View users and their activity.
</p>
<Link href="/admin/users">
<Button variant="outline" className="w-full">
Go to Users
</Button>
</Link>
</CardContent>
</Card>
<Card className="hover:bg-accent cursor-pointer transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconBooks className="w-5 h-5" />
Manage Courses
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Create and manage courses.
</p>
<Link href="/admin/courses">
<Button variant="outline" className="w-full">
Go to Courses
</Button>
</Link>
</CardContent>
</Card>
<Card className="hover:bg-accent cursor-pointer transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconList className="w-5 h-5" />
Manage Playlists
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Create and manage playlists.
</p>
<Link href="/admin/playlists">
<Button variant="outline" className="w-full">
Go to Playlists
</Button>
</Link>
</CardContent>
</Card>
<Card className="hover:bg-accent cursor-pointer transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconFileUpload className="w-5 h-5" />
Manage Videos
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Upload and manage videos.
</p>
<Link href="/admin/videos">
<Button variant="outline" className="w-full">
Go to Videos
</Button>
</Link>
</CardContent>
</Card>
<Card className="hover:bg-accent cursor-pointer transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconUsers className="w-5 h-5" />
Manage Enrollments
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Manage user enrollments.
</p>
<Link href="/admin/enrollments">
<Button variant="outline" className="w-full">
Go to Enrollments
</Button>
</Link>
</CardContent>
</Card>
<Card className="hover:bg-accent cursor-pointer transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconUsersManage className="w-5 h-5" />
Allowed Students
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Manage whitelisted student access.
</p>
<Link href="/admin/allowed-students">
<Button variant="outline" className="w-full">
Go to Students
</Button>
</Link>
</CardContent>
</Card>
</div>
</div>
<div className="lg:col-span-1">
<Card>
<CardHeader>
<CardTitle>Activity Feed</CardTitle>
</CardHeader>
<CardContent>
<AdminNotifications />
</CardContent>
</Card>
</div>
</div>
</div>
);
}
export default function AdminClient() {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<AdminUI />
</div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+393
View File
@@ -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<AllowedStudent[]>([]);
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<AllowedStudent | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(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<HTMLInputElement>) => {
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 <div className="p-4 text-sm text-muted-foreground">Loading allowed students...</div>;
}
return (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Allowed Students ({students.length})</CardTitle>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => setIsAddDialogOpen(true)}
className="gap-2"
>
<Plus className="h-4 w-4" />
Add Student
</Button>
<div className="relative">
<input
type="file"
accept=".csv"
onChange={handleImportCSV}
disabled={isImportLoading}
className="hidden"
id="csv-upload"
/>
<Button
size="sm"
variant="outline"
onClick={() => document.getElementById('csv-upload')?.click()}
disabled={isImportLoading}
className="gap-2"
>
<Upload className="h-4 w-4" />
Import CSV
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Course Codes</TableHead>
<TableHead>Status</TableHead>
<TableHead>Added</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{students.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
No students added yet
</TableCell>
</TableRow>
) : (
students.map((student) => (
<TableRow key={student.id}>
<TableCell className="font-medium">{student.email}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{student.levels.split(',').map((code) => (
<Badge key={code.trim()} variant="outline">
{code.trim()}
</Badge>
))}
</div>
</TableCell>
<TableCell>
<Badge variant={student.active ? 'default' : 'secondary'}>
{student.active ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDistanceToNow(new Date(student.createdAt), { addSuffix: true })}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
setEditingStudent(student);
setIsEditDialogOpen(true);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => {
setDeleteId(student.id);
setIsDeleteAlertOpen(true);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Add Student Sheet */}
<Sheet open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>Add Student</SheetTitle>
</SheetHeader>
<div className="flex flex-col gap-4 mt-4">
<div>
<label className="text-sm font-medium">Email</label>
<Input
placeholder="student@university.edu"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
/>
</div>
<div>
<label className="text-sm font-medium">Course Codes</label>
<Input
placeholder="3D100,3D200,3D300"
value={newLevels}
onChange={(e) => setNewLevels(e.target.value)}
/>
<p className="text-xs text-muted-foreground mt-1">
Comma-separated course codes
</p>
</div>
</div>
<SheetFooter className="mt-6">
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleAddStudent}>Add Student</Button>
</SheetFooter>
</SheetContent>
</Sheet>
{/* Edit Student Sheet */}
<Sheet open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>Edit Student</SheetTitle>
</SheetHeader>
{editingStudent && (
<div className="flex flex-col gap-4 mt-4">
<div>
<label className="text-sm font-medium">Email</label>
<Input
value={editingStudent.email}
onChange={(e) =>
setEditingStudent({ ...editingStudent, email: e.target.value })
}
/>
</div>
<div>
<label className="text-sm font-medium">Course Codes</label>
<Input
placeholder="3D100,3D200,3D300"
value={editingStudent.levels}
onChange={(e) =>
setEditingStudent({ ...editingStudent, levels: e.target.value })
}
/>
</div>
</div>
)}
<SheetFooter className="mt-6">
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleEditStudent}>Save Changes</Button>
</SheetFooter>
</SheetContent>
</Sheet>
{/* Delete Confirmation */}
<AlertDialog open={isDeleteAlertOpen} onOpenChange={setIsDeleteAlertOpen}>
<AlertDialogContent>
<AlertDialogTitle>Remove Student</AlertDialogTitle>
<AlertDialogDescription>
Are you sure? The student will be prevented from logging in, but their existing enrollments will remain.
</AlertDialogDescription>
<div className="flex justify-end gap-2">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteStudent} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Remove
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+34
View File
@@ -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 (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<SiteHeader />
<AllowedStudentsClient />
</SidebarInset>
</SidebarProvider>
);
}
+247
View File
@@ -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<Course[]>([]);
const [loading, setLoading] = useState(false);
const [courseTitle, setCourseTitle] = useState('');
const [courseCode, setCourseCode] = useState('');
const [deletingId, setDeletingId] = useState<string | null>(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 (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<Card>
<CardHeader>
<CardTitle>Create Course</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={createCourse}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="courseTitle">Course title</FieldLabel>
<Input
id="courseTitle"
required
value={courseTitle}
onChange={(e) => setCourseTitle(e.target.value)}
placeholder="3D ANIMATION 300"
/>
</Field>
<Field>
<FieldLabel htmlFor="courseCode">Course code</FieldLabel>
<Input
id="courseCode"
value={courseCode}
onChange={(e) => setCourseCode(e.target.value)}
placeholder="3D100"
/>
</Field>
<Field>
<Button type="submit" disabled={loading}>
Create course
</Button>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Courses</CardTitle>
</CardHeader>
<CardContent>
{courses.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No courses yet
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Code</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{courses.map((course) => (
<TableRow key={course.id}>
<TableCell className="font-medium">
{course.title}
</TableCell>
<TableCell>{course.code || '—'}</TableCell>
<TableCell className="text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={deletingId === course.id}
>
<Trash2Icon className="w-4 h-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Course</AlertDialogTitle>
<AlertDialogDescription>
Are you sure? This will delete the course and all
associated playlists and videos.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-2 justify-end">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteCourse(course.id)}
>
Delete
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
export default function CoursesAdminClient() {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<CoursesUI />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+22
View File
@@ -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 <CoursesAdminClient />;
}
+349
View File
@@ -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<User[]>([]);
const [courses, setCourses] = useState<Course[]>([]);
const [enrollments, setEnrollments] = useState<Enrollment[]>([]);
// placeholder sentinel
const [selectedUser, setSelectedUser] = useState<string>('none');
const [selectedCourse, setSelectedCourse] = useState<string>('none');
const [role, setRole] = useState<string>('student');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<Card className="mb-6">
<CardHeader>
<CardTitle>Assign enrollment</CardTitle>
</CardHeader>
<CardContent>
<form
onSubmit={(e) => handleCreate(e)}
className="grid grid-cols-1 md:grid-cols-4 gap-4 items-end"
>
<div className="col-span-2">
<FieldGroup>
<Field>
<FieldLabel htmlFor="student">Student</FieldLabel>
<Select
value={selectedUser}
onValueChange={(v) => {
console.debug('Select user changed ->', v);
setSelectedUser(v);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a user" />
</SelectTrigger>
<SelectContent>
{users.map((u) => (
<SelectItem key={String(u.id)} value={String(u.id)}>
{u.name ? `${u.name}${u.email}` : u.email}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</FieldGroup>
</div>
<div className="col-span-1">
<FieldGroup>
<Field>
<FieldLabel htmlFor="course">Course</FieldLabel>
<Select
value={selectedCourse}
onValueChange={(v) => {
console.debug('Select course changed ->', v);
setSelectedCourse(v);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a course" />
</SelectTrigger>
<SelectContent>
{courses.map((c) => (
<SelectItem key={String(c.id)} value={String(c.id)}>
{c.title} | {c.code}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</FieldGroup>
</div>
<div className="col-span-1 flex gap-2">
<Button
type="submit"
disabled={saving}
className="self-end w-full"
>
Add
</Button>
</div>
</form>
{error && (
<div className="mt-3 text-sm text-destructive">{error}</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Current enrollments</CardTitle>
</CardHeader>
<CardContent className="overflow-x-auto">
{loading && enrollments.length === 0 ? (
<div className="p-4">Loading</div>
) : enrollments.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">
No enrollments yet.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]">Student</TableHead>
<TableHead className="w-[300px]">Email</TableHead>
<TableHead>Course</TableHead>
<TableHead>Role</TableHead>
<TableHead>Enrolled</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{enrollments.map((en) => (
<TableRow key={en.id}>
<TableCell className="font-medium">
{en.user.name ?? en.user.email}
</TableCell>
<TableCell className="font-medium">
{en.user.email}
</TableCell>
<TableCell className="italic">
{en.course.title}{' '}
<span className="text-xs text-muted-foreground">
| {en.course.code}
</span>
</TableCell>
<TableCell>{en.role ?? 'student'}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{en.createdAt
? new Date(en.createdAt).toLocaleString()
: '—'}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(en.id)}
disabled={saving}
>
Remove
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
export default function AdminClient() {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<AdminUI />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+22
View File
@@ -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 <AdminClient />;
}
+23
View File
@@ -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 <AdminClient />;
}
+539
View File
@@ -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<Course[]>([]);
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [loading, setLoading] = useState(false);
const [playlistTitle, setPlaylistTitle] = useState('');
const [playlistCourseId, setPlaylistCourseId] = useState('');
const [additionalCourseIds, setAdditionalCourseIds] = useState<string[]>([]);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState('');
const [savingEditId, setSavingEditId] = useState<string | null>(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<string>('');
const [videos, setVideos] = useState<Array<{ id: string; title: string; thumbnail?: string; index: number; durationSec?: number }>>([]);
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 (
<div ref={setNodeRef} style={style} className="w-40 p-2">
<div className="cursor-move" {...attributes} {...listeners}>
{thumbnail ? (
<img src={thumbnail} alt={title} className="w-full h-24 object-cover rounded" />
) : (
<div className="w-full h-24 bg-muted rounded flex items-center justify-center">No image</div>
)}
<div className="text-sm mt-2 truncate">{title}</div>
</div>
</div>
);
}
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 (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<Card>
<CardHeader>
<CardTitle>Create Playlist</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={createPlaylist}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="playlistTitle">Playlist title</FieldLabel>
<FieldDescription>
e.g. Character Rigging Basics
</FieldDescription>
<Input
id="playlistTitle"
required
value={playlistTitle}
onChange={(e) => setPlaylistTitle(e.target.value)}
placeholder="Playlist title"
/>
</Field>
<Field>
<FieldLabel htmlFor="playlistCourse">Assign to course (primary)</FieldLabel>
<FieldDescription>
Select the main course. The playlist will always be accessible from here.
</FieldDescription>
<Select
value={playlistCourseId}
onValueChange={(val) => setPlaylistCourseId(val)}
>
<SelectTrigger aria-label="Choose Primary Course">
<SelectValue placeholder="Choose Primary Course" />
</SelectTrigger>
<SelectContent>
{courses.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.title} {c.code ? `| ${c.code}` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Assign to additional courses (optional)</FieldLabel>
<FieldDescription>
Select other courses where this playlist should appear.
</FieldDescription>
<div className="space-y-2 mt-2">
{courses
.filter((c) => c.id !== playlistCourseId)
.map((c) => (
<label key={c.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={additionalCourseIds.includes(c.id)}
onChange={(e) => {
if (e.target.checked) {
setAdditionalCourseIds([...additionalCourseIds, c.id]);
} else {
setAdditionalCourseIds(additionalCourseIds.filter((id) => id !== c.id));
}
}}
className="w-4 h-4"
/>
<span>
{c.title} {c.code ? `| ${c.code}` : ''}
</span>
</label>
))}
</div>
</Field>
<Field>
<Button type="submit" disabled={loading || !playlistCourseId}>
Create playlist
</Button>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Organize Playlist</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 items-end">
<div>
<Field>
<FieldLabel htmlFor="organizePlaylist">Select playlist</FieldLabel>
<Select
value={selectedPlaylistId}
onValueChange={(val) => {
setSelectedPlaylistId(val);
fetchPlaylistVideos(val);
}}
>
<SelectTrigger aria-label="Choose playlist">
<SelectValue placeholder="Choose playlist to organize" />
</SelectTrigger>
<SelectContent>
{playlists.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.title}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
</div>
<div className="mt-4">
{videos.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">No videos loaded</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={videos.map((v) => v.id)} strategy={verticalListSortingStrategy}>
<div className="flex gap-4 overflow-auto py-2">
{videos.map((v) => (
<SortableItem key={v.id} id={v.id} title={v.title} thumbnail={v.thumbnail} />
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Playlists</CardTitle>
</CardHeader>
<CardContent>
{playlists.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No playlists yet
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Course</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{playlists.map((playlist) => (
<TableRow key={playlist.id}>
<TableCell className="font-medium">
{editingId === playlist.id ? (
<div className="flex gap-2 items-center">
<Input
value={editingTitle}
onChange={(e) => 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('');
}
}}
/>
<Button
variant="ghost"
size="sm"
onClick={() => updatePlaylistTitle(playlist.id, editingTitle)}
disabled={savingEditId === playlist.id}
>
<Check className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingId(null);
setEditingTitle('');
}}
disabled={savingEditId === playlist.id}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<div className="flex gap-2 items-center">
<span>{playlist.title}</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingId(playlist.id);
setEditingTitle(playlist.title);
}}
>
<Edit2 className="w-4 h-4" />
</Button>
</div>
)}
</TableCell>
<TableCell>{getCourseTitle(playlist.courseId)}</TableCell>
<TableCell className="text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={deletingId === playlist.id || editingId === playlist.id}
>
<Trash2Icon className="w-4 h-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Playlist</AlertDialogTitle>
<AlertDialogDescription>
Are you sure? This will delete the playlist and all
associated videos.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-2 justify-end">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deletePlaylist(playlist.id)}
>
Delete
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
export default function PlaylistsAdminClient() {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<PlaylistsUI />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+22
View File
@@ -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 <PlaylistsAdminClient />;
}
+298
View File
@@ -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<VideoStats[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<keyof VideoStats>('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 }) => (
<TableHead
className="cursor-pointer hover:bg-muted whitespace-nowrap"
onClick={() => handleSort(column)}
>
<div className="flex items-center gap-1">
{label}
{sortBy === column && (
<span className="text-xs">
{sortOrder === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
</TableHead>
);
if (error) {
return (
<div className="flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-lg text-red-800">
<AlertCircle className="w-5 h-5" />
<div>
<p className="font-semibold">Error loading stats</p>
<p className="text-sm">{error}</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Video Statistics</h1>
<p className="text-muted-foreground">
Performance metrics for all videos in the system
</p>
</div>
<Badge variant="outline" className="text-sm">
{stats.length} Videos
</Badge>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Views
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.reduce((sum, s) => sum + s.views, 0).toLocaleString()}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Unique Viewers
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.reduce((sum, s) => sum + s.totalViewers, 0).toLocaleString()}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Engagements
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.reduce((sum, s) => sum + s.engagement, 0).toLocaleString()}
</div>
<p className="text-xs text-muted-foreground mt-1">
Likes + Comments
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Avg. Completion Rate
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{(
stats.reduce((sum, s) => sum + parseFloat(s.completionRate), 0) /
(stats.length || 1)
).toFixed(1)}
%
</div>
</CardContent>
</Card>
</div>
{/* Data Table */}
<Card>
<CardHeader>
<CardTitle>Video Performance Details</CardTitle>
</CardHeader>
<CardContent>
<div className="w-full overflow-x-auto">
{loading ? (
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : stats.length === 0 ? (
<div className="text-center py-4 text-muted-foreground">
No videos found
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<SortHeader column="title" label="Title" />
<SortHeader column="uploaderName" label="Uploader" />
<SortHeader column="views" label="Unlocks" />
<SortHeader column="totalViewers" label="Viewers" />
<SortHeader column="completions" label="Completions" />
<SortHeader column="completionRate" label="Completion %" />
<SortHeader column="avgPercentWatched" label="Avg % Watched" />
<SortHeader column="engagement" label="Engagement" />
<SortHeader column="likes" label="Likes" />
<SortHeader column="comments" label="Comments" />
<SortHeader column="totalSecondsWatched" label="Total Seconds Watched" />
<SortHeader column="durationSec" label="Duration" />
</TableRow>
</TableHeader>
<TableBody>
{sortedStats.map((video) => (
<TableRow key={video.id}>
<TableCell className="font-medium whitespace-nowrap">
<div className="flex flex-col">
<span className="max-w-xs truncate">{video.title}</span>
<span className="text-xs text-muted-foreground">
{video.playlistTitle}
</span>
</div>
</TableCell>
<TableCell className="whitespace-nowrap text-sm">
{video.uploaderName}
</TableCell>
<TableCell className="text-sm font-medium">
{video.views}
</TableCell>
<TableCell className="text-sm font-medium">
{video.totalViewers}
</TableCell>
<TableCell className="text-sm">
{video.completions}
</TableCell>
<TableCell className="text-sm font-semibold">
<Badge
variant={
parseFloat(video.completionRate) >= 50
? 'default'
: parseFloat(video.completionRate) >= 25
? 'secondary'
: 'destructive'
}
>
{video.completionRate}%
</Badge>
</TableCell>
<TableCell className="text-sm">
{video.avgPercentWatched.toFixed(1)}%
</TableCell>
<TableCell className="text-sm font-bold">
{video.engagement}
</TableCell>
<TableCell className="text-sm">
<Badge variant="outline">{video.likes}</Badge>
</TableCell>
<TableCell className="text-sm">
<Badge variant="outline">{video.comments}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{video.totalSecondsWatched.toLocaleString()}s
</TableCell>
<TableCell className="text-sm whitespace-nowrap">
{formatDuration(video.durationSec)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</CardContent>
</Card>
</div>
);
}
+26
View File
@@ -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 (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<StatsClient />
</div>
);
}
+26
View File
@@ -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 <UserDetailClient userId={userId} />;
}
@@ -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<UserDetail | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [progressSegments, setProgressSegments] = useState<Record<string, WatchSegment[]>>({});
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [selectedRole, setSelectedRole] = useState<string>('');
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<string, WatchSegment[]> = {};
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 (
<div className="p-4 text-sm text-muted-foreground">Loading user details...</div>
);
}
if (!user) {
return (
<div className="p-4">
<div className="text-red-500">User not found</div>
<Button onClick={() => router.back()} className="mt-4">
Go back
</Button>
</div>
);
}
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 (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
onClick={() => router.back()}
>
<ArrowLeft className="w-4 h-4" />
Back
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setIsDeleteAlertOpen(true)}
disabled={isDeleting}
className="gap-2"
>
<Trash2 className="w-4 h-4" />
Delete User
</Button>
</div>
{/* User Header */}
<Card>
<CardHeader>
<div className="flex items-start gap-4">
<Avatar className="w-16 h-16">
<AvatarImage src={user.image || undefined} alt={user.name || user.email} />
<AvatarFallback>
{getInitials(user.name, user.email)}
</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold">
{user.name || user.email}
</h1>
<Badge variant={user.role === 'admin' ? 'secondary' : user.role === 'superadmin' ? 'default' : 'outline'}>
{user.role}
</Badge>
</div>
<p className="text-sm text-muted-foreground">{user.email}</p>
<p className="text-xs text-muted-foreground">
Joined {formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
</p>
</div>
</div>
</CardHeader>
</Card>
{/* Role Assignment Card (Superadmin Only) */}
{isSuperadmin && (
<Card>
<CardHeader>
<CardTitle>Assign Role</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-end gap-4">
<div className="flex-1">
<label className="block text-sm font-medium mb-2">User Role</label>
<Select value={selectedRole} onValueChange={setSelectedRole}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User (Student)</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="superadmin">Superadmin</SelectItem>
</SelectContent>
</Select>
</div>
<Button
onClick={() => setIsRoleChangeAlertOpen(true)}
disabled={selectedRole === user.role || isUpdatingRole}
className="gap-2"
>
<Save className="w-4 h-4" />
{isUpdatingRole ? 'Updating...' : 'Update Role'}
</Button>
</div>
<p className="text-xs text-muted-foreground">
<strong>User:</strong> Regular student with access to enrolled courses
<br />
<strong>Admin:</strong> Can manage courses, playlists, videos, and users
<br />
<strong>Superadmin:</strong> Full access + can assign roles to other admins
</p>
</CardContent>
</Card>
)}
{/* Enrollments */}
{user.enrollments.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Enrollments ({user.enrollments.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{user.enrollments.map((enrollment, idx) => (
<Badge key={idx} variant="outline">
{enrollment.course.title}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Tabs for Watch History and Comments */}
<Tabs defaultValue="history" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="history">
Watch History ({user.progress.length})
</TabsTrigger>
<TabsTrigger value="comments">
Comments ({user.comments.length})
</TabsTrigger>
</TabsList>
{/* Watch History Tab */}
<TabsContent value="history">
<Card>
<CardContent className="p-0">
{user.progress.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
No watch history
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Video</TableHead>
<TableHead>Watched</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Progress</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Updated</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{user.progress.map((progress) => (
<TableRow key={progress.id}>
<TableCell className="font-medium">
{progress.video.title}
</TableCell>
<TableCell>
{formatSeconds(progress.watchedSec)}
</TableCell>
<TableCell>
{progress.durationSec
? formatSeconds(progress.durationSec)
: '-'}
</TableCell>
<TableCell className="max-w-md">
<SegmentedProgressBar
segments={progressSegments[progress.videoId] ?? []}
duration={progress.durationSec ?? 1}
percent={progress.percent}
height="sm"
showTooltip={true}
/>
</TableCell>
<TableCell>
{progress.completed ? (
<Badge>Completed</Badge>
) : (
<Badge variant="outline">In Progress</Badge>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDistanceToNow(new Date(progress.updatedAt), {
addSuffix: true,
})}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Comments Tab */}
<TabsContent value="comments">
<Card>
<CardContent className="p-4">
{user.comments.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
No comments
</div>
) : (
<div className="space-y-4">
{user.comments.map((comment) => (
<div key={comment.id} className="border rounded-lg p-3 space-y-2">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium">
Video: {comment.video.title}
</p>
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(comment.createdAt), {
addSuffix: true,
})}
</p>
</div>
{comment.replies.length > 0 && (
<Badge variant="outline" className="text-xs">
{comment.replies.length} replies
</Badge>
)}
</div>
<p className="text-sm text-foreground">{comment.content}</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Delete User Alert Dialog */}
<AlertDialog open={isDeleteAlertOpen} onOpenChange={setIsDeleteAlertOpen}>
<AlertDialogContent>
<AlertDialogTitle>Delete User</AlertDialogTitle>
<AlertDialogDescription>
<div className="space-y-3">
<p>
Are you sure you want to delete <span className="font-semibold">{user?.email}</span>?
</p>
<p className="text-sm text-destructive">
This will permanently delete:
</p>
<ul className="text-sm list-disc list-inside space-y-1 ml-2 text-destructive">
<li>User account and profile</li>
<li>All enrollments</li>
<li>All progress and watch history</li>
<li>All comments and replies</li>
<li>All video likes</li>
<li>All video unlocks</li>
</ul>
<p className="text-xs text-muted-foreground mt-3">
This action cannot be undone.
</p>
</div>
</AlertDialogDescription>
<div className="flex justify-end gap-2">
<AlertDialogCancel disabled={isDeleting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteUser}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? 'Deleting...' : 'Delete User'}
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
{/* Role Change Confirmation Dialog */}
<AlertDialog open={isRoleChangeAlertOpen} onOpenChange={setIsRoleChangeAlertOpen}>
<AlertDialogContent>
<AlertDialogTitle>Update User Role</AlertDialogTitle>
<AlertDialogDescription>
<div className="space-y-3">
<p>
Change <span className="font-semibold">{user?.email}</span>'s role from{' '}
<span className="font-semibold">{user?.role}</span> to{' '}
<span className="font-semibold">{selectedRole}</span>?
</p>
{selectedRole === 'superadmin' && (
<p className="text-sm text-amber-600">
⚠️ This user will have full access including the ability to assign roles to other users.
</p>
)}
{selectedRole === 'user' && user?.role !== 'user' && (
<p className="text-sm text-blue-600">
️ This user will lose admin access but will still have access to enrolled courses.
</p>
)}
</div>
</AlertDialogDescription>
<div className="flex justify-end gap-2">
<AlertDialogCancel disabled={isUpdatingRole}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleUpdateRole}
disabled={isUpdatingRole}
>
{isUpdatingRole ? 'Updating...' : 'Update Role'}
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
export default function UserDetailClient({ userId }: UserDetailClientProps) {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<UserDetailClientUI userId={userId} />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+185
View File
@@ -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<User[]>([]);
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 (
<div className="p-4 text-sm text-muted-foreground">Loading users...</div>
);
}
return (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<Card>
<CardHeader>
<CardTitle>Total: {users.length} users</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Name</TableHead>
<TableHead>Role</TableHead>
<TableHead>Courses</TableHead>
<TableHead>Last Activity</TableHead>
<TableHead>Joined</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
No users found
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.email}</TableCell>
<TableCell>{user.name || '-'}</TableCell>
<TableCell>
<Badge variant={getRoleBadgeVariant(user.role)}>
{user.role}
</Badge>
</TableCell>
<TableCell>
{user.enrollments.length > 0 ? (
<div className="flex gap-1 flex-wrap">
{user.enrollments.map((enrollment, idx) => (
<Badge key={idx} variant="outline" className="text-xs">
{enrollment.course.title}
</Badge>
))}
</div>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
</TableCell>
<TableCell>
{user.lastActivity ? (
formatDistanceToNow(new Date(user.lastActivity), { addSuffix: true })
) : (
<span className="text-muted-foreground text-sm">No activity</span>
)}
</TableCell>
<TableCell>
{formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/admin/users/${user.id}`)}
>
<Eye className="w-4 h-4" />
View
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
export default function sersAdminClient() {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<UsersAdminClientUI />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+22
View File
@@ -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 <UsersAdminClient />;
}
@@ -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<File | null>(null);
const [thumbPreview, setThumbPreview] = useState(video.thumbnail);
const [loading, setLoading] = useState(false);
const [courses, setCourses] = useState<CourseData[]>([]);
const [coursesLoading, setCoursesLoading] = useState(true);
const [restrictedCourseIds, setRestrictedCourseIds] = useState<string[]>(
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<HTMLInputElement>) => {
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 (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">
Edit Video
</h1>
<p className="text-muted-foreground">
{video.courseTitle} {video.playlistTitle}
</p>
</div>
<Card>
<CardHeader>
<CardTitle>{video.title}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="title">Video Title</FieldLabel>
<Input
id="title"
required
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter video title"
/>
</Field>
<Field>
<FieldLabel htmlFor="description">
Description
</FieldLabel>
<FieldDescription>
Optional description for the video
</FieldDescription>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter video description"
rows={4}
/>
</Field>
<Field>
<FieldLabel>Restricted courses</FieldLabel>
<FieldDescription>
Only users enrolled in the checked courses will see this video. Leave
empty to allow all enrolled courses to access it.
</FieldDescription>
{coursesLoading ? (
<div className="text-sm text-muted-foreground">Loading courses</div>
) : courses.length === 0 ? (
<div className="text-sm text-muted-foreground">No courses available.</div>
) : (
<div className="grid gap-2 text-sm">
{courses.map((course) => (
<label
key={course.id}
className="flex items-center gap-2 text-sm font-medium"
>
<input
type="checkbox"
checked={restrictedCourseIds.includes(course.id)}
onChange={() => {
setRestrictedCourseIds((prev) =>
prev.includes(course.id)
? prev.filter((id) => id !== course.id)
: [...prev, course.id]
);
}}
className="accent-primary"
/>
<span>{course.title}</span>
</label>
))}
</div>
)}
</Field>
<Field>
<FieldLabel htmlFor="thumbnail">Thumbnail</FieldLabel>
<FieldDescription>
Upload a new thumbnail image (optional)
</FieldDescription>
<div className="grid w-full max-w-sm items-center gap-3">
<Input
id="thumbnail"
type="file"
accept="image/*"
onChange={handleThumbnailChange}
className="block w-full text-sm"
/>
{thumbPreview ? (
<div className="relative w-40 h-24">
<Image
src={thumbPreview}
alt="Thumbnail preview"
fill
unoptimized
className="object-cover rounded"
/>
</div>
) : null}
</div>
</Field>
<div className="flex gap-2 pt-4">
<Button type="submit" disabled={loading}>
{loading ? 'Saving...' : 'Save Changes'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.push('/admin/videos')}
>
Cancel
</Button>
</div>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+57
View File
@@ -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 (
<EditVideoClient
video={{
id: video.id,
title: video.title,
description: (video as any).description || '',
thumbnail: video.thumbnail ?? undefined,
playlistTitle: video.playlist.title,
courseTitle: video.playlist.course.title,
restrictedCourseIds: video.videoCourses
.filter((vc) => vc.exclusive)
.map((vc) => vc.courseId),
}}
/>
);
}
+815
View File
@@ -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<Course[]>([]);
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
// form state
const [videoTitle, setVideoTitle] = useState('');
const [videoFile, setVideoFile] = useState<File | null>(null);
const [videoPlaylistId, setVideoPlaylistId] = useState('');
const [thumbFile, setThumbFile] = useState<File | null>(null);
const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [togglingId, setTogglingId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState('');
const [savingEditId, setSavingEditId] = useState<string | null>(null);
// Manual file copy mode
const [useManualFileCopy, setUseManualFileCopy] = useState(false);
const [pendingVideoId, setPendingVideoId] = useState<string | null>(null);
const [finalizingVideoId, setFinalizingVideoId] = useState<string | null>(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<HTMLInputElement>) {
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<void>((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 (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<Card>
<CardHeader>
<CardTitle>Upload Video</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={uploadVideo}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="videoTitle">Video title</FieldLabel>
<FieldDescription>Title shown in the playlist.</FieldDescription>
<Input
id="videoTitle"
required
value={videoTitle}
onChange={(e) => setVideoTitle(e.target.value)}
placeholder="e.g. Lesson 1 — Intro to the Rig"
/>
</Field>
<Field>
<div className="flex items-center gap-3">
<Switch
id="manualFileCopy"
checked={useManualFileCopy}
onCheckedChange={setUseManualFileCopy}
/>
<label htmlFor="manualFileCopy" className="text-sm cursor-pointer">
Manual file copy mode
</label>
</div>
<FieldDescription>
{useManualFileCopy
? 'Creates DB entry and thumbnail. You will copy the file manually to the server.'
: 'Upload file directly from browser.'}
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="videoPlaylist">Playlist</FieldLabel>
<FieldDescription>
Select which playlist this video belongs to.
</FieldDescription>
<Select
value={videoPlaylistId}
onValueChange={(val) => setVideoPlaylistId(val)}
>
<SelectTrigger aria-label="Choose playlist">
<SelectValue placeholder="Select playlist" />
</SelectTrigger>
<SelectContent>
{playlists.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.title}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="videoFile">Video file</FieldLabel>
<FieldDescription>
{useManualFileCopy
? 'Select the file to get its name, then copy manually to: /uploads/videos/{videoId}.mp4'
: 'Large files will be uploaded to the server.'}
</FieldDescription>
<div className="grid w-full max-w-sm items-center gap-3">
<Input
id="videoFile"
type="file"
accept="video/*"
onChange={handleFileChange}
className="block w-full text-sm"
required={!useManualFileCopy}
/>
{videoDurationSec ? (
<div className="text-sm text-muted-foreground">
Duration: {videoDurationSec}s
</div>
) : null}
{uploadProgress !== null && !useManualFileCopy ? (
<div className="w-full">
<div className="flex items-center justify-between mb-1">
<div className="text-sm">Uploading</div>
<div className="text-xs text-muted-foreground">
{uploadProgress}%
</div>
</div>
<Progress value={uploadProgress} />
</div>
) : null}
</div>
</Field>
<Field>
<FieldLabel htmlFor="thumbFile">Thumbnail</FieldLabel>
<div className="grid w-full max-w-sm items-center gap-3">
<Input
id="thumbFile"
type="file"
accept="image/*"
onChange={(e) => setThumbFile(e.target.files?.[0] ?? null)}
className="block w-full text-sm"
/>
{thumbFile ? (
<img
src={URL.createObjectURL(thumbFile)}
className="w-32 h-20 rounded object-cover border mt-2"
/>
) : null}
</div>
</Field>
<Field>
<Button
type="submit"
disabled={loading || (!useManualFileCopy && !videoFile) || !videoPlaylistId}
>
{loading
? useManualFileCopy
? 'Creating…'
: 'Uploading…'
: useManualFileCopy
? 'Create & Setup Manual Copy'
: 'Upload video'}
</Button>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
{/* Manual file copy modal */}
{pendingVideoId && (
<Card className="border-blue-200 bg-blue-50">
<CardHeader>
<CardTitle className="text-blue-900">File Ready for Manual Copy</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-white p-4 rounded border border-blue-200">
<p className="text-sm text-muted-foreground mb-2">
Copy your video file to the following location on your server:
</p>
<code className="block bg-muted p-3 rounded text-sm font-mono mb-2">
/uploads/videos/{pendingVideoId}.mp4
</code>
<p className="text-sm text-muted-foreground">
The file must be named exactly as shown above (using the video ID).
</p>
</div>
<Button
onClick={() => finalizeManualUpload(pendingVideoId)}
disabled={finalizingVideoId !== null}
className="w-full"
>
{finalizingVideoId ? 'Checking file…' : 'I have placed the file'}
</Button>
<Button
variant="outline"
onClick={() => setPendingVideoId(null)}
disabled={finalizingVideoId !== null}
className="w-full"
>
Cancel
</Button>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Videos</CardTitle>
</CardHeader>
<CardContent>
{videos.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No videos yet
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Thumbnail</TableHead>
<TableHead>Title</TableHead>
<TableHead>Playlist</TableHead>
<TableHead>Course</TableHead>
<TableHead className="text-center">Locked</TableHead>
<TableHead className="text-center">Instant Access</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{videos.map((video) => (
<TableRow key={video.id}>
<TableCell>
{video.thumbnail ? (
<img
src={video.thumbnail}
alt={video.title}
className="w-16 h-9 object-cover rounded text-xs"
/>
) : (
<div className="w-16 h-9 bg-muted rounded flex items-center justify-center">
<span className="text-xs text-muted-foreground">
No image
</span>
</div>
)}
</TableCell>
<TableCell className="font-medium max-w-xs">
{editingId === video.id ? (
<div className="flex gap-2 items-center">
<Input
value={editingTitle}
onChange={(e) => 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('');
}
}}
/>
<Button
variant="ghost"
size="sm"
onClick={() => updateVideoTitle(video.id, editingTitle)}
disabled={savingEditId === video.id}
>
<Check className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingId(null);
setEditingTitle('');
}}
disabled={savingEditId === video.id}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<div className="flex flex-col gap-1">
<div className="flex gap-2 items-center">
<span className="truncate">{video.title}</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingId(video.id);
setEditingTitle(video.title);
}}
>
<Edit className="w-4 h-4" />
</Button>
</div>
{video.restrictedCourseIds?.length ? (
<div className="flex flex-wrap gap-1">
{video.restrictedCourseIds.map((courseId) => {
const course = courses.find((c) => c.id === courseId);
const label = course?.code ?? course?.title ?? 'Course';
return (
<Badge
key={`${video.id}-${courseId}`}
variant="secondary"
>
{label}
</Badge>
);
})}
</div>
) : null}
</div>
)}
</TableCell>
<TableCell>{video.playlist.title}</TableCell>
<TableCell>{video.playlist.course.title}</TableCell>
<TableCell className="text-center">
<Switch
checked={video.locked}
onCheckedChange={() => toggleLocked(video.id, video.locked)}
disabled={togglingId === video.id}
aria-label="Toggle lock status"
/>
</TableCell>
<TableCell className="text-center">
<Switch
checked={video.instantAccess}
onCheckedChange={() => toggleInstantAccess(video.id, video.instantAccess)}
disabled={togglingId === video.id}
aria-label="Toggle instant access"
/>
</TableCell>
<TableCell className="text-right">
<div className="flex gap-1 justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/admin/videos/${video.id}/edit`)}
>
<Edit className="w-4 h-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={deletingId === video.id}
>
<Trash2Icon className="w-4 h-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Video</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{video.title}"?
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-2 justify-end">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteVideo(video.id)}
>
Delete
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
export default function VideosAdminClient() {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<VideosUI />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+22
View File
@@ -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 <VideosAdminClient />;
}
@@ -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 }
);
}
}
+192
View File
@@ -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 }
);
}
}
+27
View File
@@ -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 });
}
}
+42
View File
@@ -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 });
}
}
+40
View File
@@ -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 }
);
}
}
+40
View File
@@ -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 }
);
}
}
+37
View File
@@ -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 }
);
}
}
@@ -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 });
}
}
+19
View File
@@ -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 });
}
}
+168
View File
@@ -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 }
);
}
}
+34
View File
@@ -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 });
}
}
+55
View File
@@ -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 });
}
}
+106
View File
@@ -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 }
);
}
}
+48
View File
@@ -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 }
);
}
}
+77
View File
@@ -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 }
);
}
}
+141
View File
@@ -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 }
);
}
}
+150
View File
@@ -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<number | null> {
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 });
}
}
+394
View File
@@ -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<number | null> {
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 });
}
}
@@ -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 }
);
}
}
+150
View File
@@ -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 }
);
}
}
+68
View File
@@ -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 }
);
}
}
@@ -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 }
);
}
}
@@ -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 }
);
}
}
+53
View File
@@ -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 }
);
}
}
+7
View File
@@ -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 };
+57
View File
@@ -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 }
);
}
}
@@ -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 }
);
}
}
+36
View File
@@ -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 }
);
}
}
+35
View File
@@ -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 }
);
}
}
+123
View File
@@ -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 }
);
}
}
+8
View File
@@ -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);
}
+94
View File
@@ -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 });
}
}
+122
View File
@@ -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 }
);
}
}
+122
View File
@@ -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 }
);
}
}
+53
View File
@@ -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 }
);
}
}
+88
View File
@@ -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 }
);
}
}
+59
View File
@@ -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 });
}
}
+133
View File
@@ -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 });
}
+225
View File
@@ -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 }
);
}
}
+45
View File
@@ -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 }
);
}
}
+117
View File
@@ -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 });
}
}
+60
View File
@@ -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 });
}
}
@@ -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 });
}
}
@@ -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 });
}
}
@@ -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<Uint8Array>
);
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 }
);
}
}
+104
View File
@@ -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 }
);
}
}
+9
View File
@@ -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);
}
+109
View File
@@ -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<string>([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 });
}
}
+109
View File
@@ -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 });
}
}
+81
View File
@@ -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 }
);
}
}
+44
View File
@@ -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 }
);
}
}
+484
View File
@@ -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<Record<string, VideoProgressData>>({});
const [userUnlocks, setUserUnlocks] = React.useState<Set<string>>(new Set());
const [latestVideos, setLatestVideos] = React.useState<any[]>([]);
const [latestVideosLoading, setLatestVideosLoading] = React.useState(true);
const playlistsToShow = React.useMemo(() => {
const playlistMap = new Map<
string,
{
playlist: any;
courses: Map<string, { id: string; title?: string; code?: string }>;
}
>();
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<string, { id: string; title?: string; code?: string }>();
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<string, VideoProgressData> = {};
// 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 (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
{isLoading && latestVideosLoading ? (
<div className="px-4 lg:px-16">Loading</div>
) : (
<div className="space-y-8">
{/* Latest Videos Section */}
{!latestVideosLoading && latestVideos.length > 0 && (
<div className="px-4 lg:px-16">
<div className="mb-4">
<h2 className="text-base font-medium">Latest Videos</h2>
<p className="text-xs text-muted-foreground">
Recently uploaded from your enrolled courses
</p>
</div>
<Carousel opts={{ align: 'start' }} className="w-full">
<CarouselContent>
{latestVideos.map((v: any) => (
<CarouselItem
key={v.id}
className="md:basis-1/3 lg:basis-1/5"
>
<Card
className="@container/card overflow-hidden cursor-pointer"
onClick={() => {
if (v.playlist?.id) {
handleOpenVideo(v.playlist.id, v.id);
}
}}
>
<CardHeader className='px-2'>
<div className="relative overflow-hidden rounded-sm aspect-video bg-gray-100">
<img
src={v.thumbnail ?? '/01.jpg'}
alt={v.title}
className="w-full h-full object-cover"
loading="lazy"
/>
</div>
<div className="flex gap-2 items-start pt-2">
{v.uploader?.image && (
<div className="relative w-9 h-9 rounded-full overflow-hidden shrink-0">
<Image
src={v.uploader.image}
alt={v.uploader.name ?? 'Uploader'}
fill
unoptimized
className="object-cover"
/>
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-1">
<CardTitle className="text-sm font-medium truncate">
{v.title}
</CardTitle>
<Badge className="shrink-0">
{formatDuration(v.durationSec)}
</Badge>
</div>
{v.uploader?.name && (
<p className="text-xs text-muted-foreground truncate">
{v.uploader.name}
</p>
)}
{v.playlist?.title && (
<p className="text-xs text-muted-foreground truncate">
{v.playlist.title}
</p>
)}
<SegmentedProgressBar
className='mt-2'
segments={videoProgress[v.id]?.segments ?? []}
duration={v.durationSec ?? 1}
percent={videoProgress[v.id]?.percent ?? 0}
height="sm"
showTooltip={false}
/>
</div>
</div>
</CardHeader>
</Card>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
)}
{/* Playlists Section */}
{playlistsToShow.length === 0 && latestVideos.length === 0 ? (
<div className="px-4 lg:px-16">No content available.</div>
) : (
<>
{playlistsToShow.length > 0 && (
<>
<div className="px-4 lg:px-16">
<h2 className="text-base font-medium">Playlists</h2>
<p className="text-xs text-muted-foreground">
Showing {playlistsToShow.length} playlist{playlistsToShow.length === 1 ? '' : 's'}
</p>
</div>
{playlistsToShow.map((pl: any) => (
<div key={pl.id} className="px-4 lg:px-16 mb-8">
<div className="flex items-center justify-between mb-2">
<div>
<h3 className="text-sm font-semibold">{pl.title}</h3>
<div className="text-xs text-muted-foreground">
{pl.description}
</div>
{pl.coursesForDisplay?.length ? (
<div className="flex flex-wrap gap-1 mt-1">
{pl.coursesForDisplay.map((course: any) => (
<Badge key={course.id} variant="outline">
{course.code ?? course.title}
</Badge>
))}
</div>
) : null}
</div>
</div>
<Carousel opts={{ align: 'start' }} className="w-full">
<CarouselContent>
{pl.videos?.map((v: VideoItem) => (
<CarouselItem
key={v.id}
className="md:basis-1/3 lg:basis-1/5"
>
<Card
className="@container/card overflow-hidden cursor-pointer"
onClick={() => {
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);
}
}}
>
<CardHeader className='px-2'>
<div className="relative overflow-hidden rounded-sm aspect-video bg-gray-100">
<img
src={v.thumbnail ?? '/01.jpg'}
alt={v.title}
className="w-full h-full object-cover"
loading="lazy"
/>
{(v as any).locked || (!(v as any).instantAccess && (v as any).index !== 0 && !userUnlocks.has(v.id)) ? (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<Lock className="w-5 h-5 text-white" />
</div>
) : null}
</div>
<div className="flex gap-2 items-start pt-2">
{(v as any).uploader?.image && (
<div className="relative w-9 h-9 rounded-full overflow-hidden shrink-0">
<Image
src={(v as any).uploader.image}
alt={(v as any).uploader.name ?? 'Uploader'}
fill
unoptimized
className="object-cover"
/>
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-1">
<CardTitle className="text-sm font-medium truncate">
{v.title}
</CardTitle>
<Badge className="shrink-0">
{formatDuration(v.durationSec)}
</Badge>
</div>
{(v as any).uploader?.name && (
<p className="text-xs text-muted-foreground truncate">
{(v as any).uploader.name}
</p>
)}
<SegmentedProgressBar
className='mt-2'
segments={videoProgress[v.id]?.segments ?? []}
duration={v.durationSec ?? 1}
percent={videoProgress[v.id]?.percent ?? 0}
height="sm"
showTooltip={false}
/>
</div>
</div>
</CardHeader>
</Card>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
))}
</>
)}
</>
)}
</div>
)}
</div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+225
View File
@@ -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<LikedVideoItem[]>([]);
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 (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8">
<Card>
<CardHeader>
<CardTitle>Liked Videos</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-8">
<p className="text-muted-foreground">Loading...</p>
</div>
</CardContent>
</Card>
</div>
</SidebarInset>
</SidebarProvider>
);
}
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8">
<Card>
<CardHeader>
<CardTitle>Liked Videos</CardTitle>
</CardHeader>
<CardContent>
{likes.length === 0 ? (
<div className="flex items-center justify-center py-8">
<p className="text-muted-foreground">No liked videos yet</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Thumbnail</TableHead>
<TableHead>Title</TableHead>
<TableHead>Playlist</TableHead>
<TableHead>Course</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Liked On</TableHead>
<TableHead>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{likes.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div
className="cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => handleVideoClick(item.video.id)}
>
{item.video.thumbnail ? (
<div className="relative w-24 h-14">
<Image
src={item.video.thumbnail}
alt={item.video.title}
fill
unoptimized
className="object-cover rounded"
/>
</div>
) : (
<div className="w-24 h-14 bg-muted rounded flex items-center justify-center">
<span className="text-xs text-muted-foreground">
No image
</span>
</div>
)}
</div>
</TableCell>
<TableCell>
<p
className="font-medium max-w-xs truncate cursor-pointer hover:underline"
onClick={() => handleVideoClick(item.video.id)}
>
{item.video.title}
</p>
</TableCell>
<TableCell>
<span className="text-sm">
{item.video.playlist.title}
</span>
</TableCell>
<TableCell>
<span className="text-sm">
{item.video.playlist.course.title}
</span>
</TableCell>
<TableCell>
<span className="text-sm">
{formatTime(item.video.durationSec)}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">
{formatDate(item.createdAt)}
</span>
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
onClick={() => handleVideoClick(item.video.id)}
>
Watch
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+20
View File
@@ -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 <LikedVideosClient />;
}
+11
View File
@@ -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 <DashboardClient />;
}
+210
View File
@@ -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<WatchHistoryItem[]>([]);
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 (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8">
<Card>
<CardHeader>
<CardTitle>Watch History</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-8">
<p className="text-muted-foreground">Loading...</p>
</div>
</CardContent>
</Card>
</div>
</SidebarInset>
</SidebarProvider>
);
}
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8">
<Card>
<CardHeader>
<CardTitle>Watch History</CardTitle>
</CardHeader>
<CardContent>
{history.length === 0 ? (
<div className="flex items-center justify-center py-8">
<p className="text-muted-foreground">No videos watched yet</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Thumbnail</TableHead>
<TableHead>Title</TableHead>
<TableHead>Last Position</TableHead>
<TableHead>Last Watched</TableHead>
<TableHead>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{history.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div
className="cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => handleVideoClick(item.video.id)}
>
{item.video.thumbnail ? (
<div className="relative w-24 h-14">
<Image
src={item.video.thumbnail}
alt={item.video.title}
fill
unoptimized
className="object-cover rounded"
/>
</div>
) : (
<div className="w-24 h-14 bg-muted rounded flex items-center justify-center">
<span className="text-xs text-muted-foreground">
No image
</span>
</div>
)}
</div>
</TableCell>
<TableCell>
<p
className="font-medium max-w-xs truncate cursor-pointer hover:underline"
onClick={() => handleVideoClick(item.video.id)}
>
{item.video.title}
</p>
</TableCell>
<TableCell>
<span className="text-sm">
{formatTime(item.lastPos)} /{' '}
{formatTime(item.video.durationSec)}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">
{formatDate(item.updatedAt)}
</span>
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
onClick={() => handleVideoClick(item.video.id)}
>
Resume
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+20
View File
@@ -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 <WatchHistoryClient />;
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

+122
View File
@@ -0,0 +1,122 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.646 0.222 41.116);
--primary-foreground: oklch(0.98 0.016 73.684);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.75 0.183 55.934);
--chart-1: oklch(0.837 0.128 66.29);
--chart-2: oklch(0.705 0.213 47.604);
--chart-3: oklch(0.646 0.222 41.116);
--chart-4: oklch(0.553 0.195 38.402);
--chart-5: oklch(0.47 0.157 37.304);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.646 0.222 41.116);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.75 0.183 55.934);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.705 0.213 47.604);
--primary-foreground: oklch(0.98 0.016 73.684);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.408 0.123 38.172);
--chart-1: oklch(0.837 0.128 66.29);
--chart-2: oklch(0.705 0.213 47.604);
--chart-3: oklch(0.646 0.222 41.116);
--chart-4: oklch(0.553 0.195 38.402);
--chart-5: oklch(0.47 0.157 37.304);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.705 0.213 47.604);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.408 0.123 38.172);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+27
View File
@@ -0,0 +1,27 @@
// app/layout.tsx (server)
import './globals.css';
import { Providers } from './providers';
import type { ReactNode } from 'react';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { Toaster } from "@/components/ui/sonner"
export const metadata = {
title: 'OW ANIMATION ARTS VAULT',
description: '...',
};
export default async function RootLayout({ children }: { children: ReactNode }) {
// get server session and pass into client SessionProvider for identical initial markup
const session = await getServerSession(authOptions);
return (
<html lang="en">
<body className="antialiased bg-background text-foreground">
{/* pass session to Providers to avoid client/server mismatch */}
<Providers session={session}>{children}</Providers>
<Toaster />
</body>
</html>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { GalleryVerticalEnd } from "lucide-react"
import { LoginForm } from "@/components/login-form"
export default function LoginPage() {
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<a href="#" className="flex items-center gap-2 self-center font-medium">
<img
src="/icon.svg" // put your svg here
alt=""
className="size-5" // same sizing as your icon
/>
OW ANIMATION ARTS VAULT
</a>
<img
src="/vault.png"
alt="Vault"
className="w-full"
/>
<LoginForm />
</div>
</div>
)
}
+53
View File
@@ -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 (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<div className="flex items-center gap-2 self-center font-medium">
<AlertCircle className="h-6 w-6 text-destructive" />
<span>Access Denied</span>
</div>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Not Registered</CardTitle>
<CardDescription>
Your email is not registered to access this platform.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="rounded-lg bg-muted p-4 text-sm">
<p className="mb-2 font-medium">What does this mean?</p>
<p className="text-muted-foreground">
This is a university-only platform. If you believe you should have access, please contact your administrator or department coordinator.
</p>
</div>
<div className="flex gap-2">
<Link href="/login" className="flex-1">
<Button type="button" variant="outline" className="w-full">
Try Another Email
</Button>
</Link>
<Link href="/" className="flex-1">
<Button type="button" className="w-full">
Go Home
</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { GalleryVerticalEnd } from "lucide-react"
import { LoginForm } from "@/components/login-form"
export default function LoginPage() {
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<a href="#" className="flex items-center gap-2 self-center font-medium">
<img
src="/icon.svg" // put your svg here
alt=""
className="size-5" // same sizing as your icon
/>
OW ANIMATION ARTS VAULT
</a>
<img
src="/vault.png"
alt="Vault"
className="w-full"
/>
<LoginForm />
</div>
</div>
)
}
+22
View File
@@ -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 (
<SessionProvider session={session}>
<ThemeProvider
attribute="class" // toggles `class="dark"` on <html>
defaultTheme="dark" // default to dark to match server if you want dark by default
enableSystem={false}
>
{children}
</ThemeProvider>
</SessionProvider>
);
}
+785
View File
@@ -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<HTMLVideoElement | null>,
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<Array<[number, number]>>([]);
const lastTimeRef = React.useRef<number | null>(null);
const currentRangeStartRef = React.useRef<number | null>(null);
const sendTimerRef = React.useRef<number | null>(null);
const commitTimerRef = React.useRef<number | null>(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<string, any> }) {
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<WatchSegment[]>([]);
const [playlistSegments, setPlaylistSegments] = React.useState<Record<string, WatchSegment[]>>({});
const [currentProgress, setCurrentProgress] = React.useState(0);
// Per-user unlock state
const [userUnlocks, setUserUnlocks] = React.useState<Set<string>>(new Set()); // Set of unlocked videoIds
const [videoInstantAccess, setVideoInstantAccess] = React.useState<Record<string, boolean>>({}); // videoId -> instantAccess
const [unlockingVideo, setUnlockingVideo] = React.useState<string | null>(null); // videoId being unlocked
// inside your videoplayer component
const videoRef = React.useRef<HTMLVideoElement | null>(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<string, WatchSegment[]> = {};
const instantAccess: Record<string, boolean> = {};
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 (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 gap-4 p-4 lg:p-6">
<div className="flex-1 min-w-0">
<Card className="mb-4">
<CardContent className="p-0">
<div className="relative w-full overflow-hidden">
{videoLoading ? (
<div className="flex items-center justify-center h-80">Loading video</div>
) : current.url ? (
// HLS player with MP4 fallback
// Prefers HLS if transcoding is complete, falls back to MP4
<HlsPlayer
src={current.videoUrls?.hlsUrl || current.url}
fallbackSrc={current.url}
videoId={current.id}
ref={videoRef}
/>
) : (
<div className="flex items-center justify-center h-80">Video not available</div>
)}
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<div className="flex items-start justify-between w-full gap-4">
<div className="flex-1 flex gap-3">
{current.uploader?.image && (
<div className="relative w-12 h-12 rounded-full overflow-hidden shrink-0">
<Image
src={current.uploader.image}
alt={current.uploader.name ?? 'Uploader'}
fill
unoptimized
className="object-cover"
/>
</div>
)}
<div className="flex-1 min-w-0">
<CardTitle className="text-lg">{current.title}</CardTitle>
{current.uploader?.name && (
<p className="text-sm text-muted-foreground mt-1">
{current.uploader.name}
</p>
)}
{current.createdAt && (
<p className="text-xs text-muted-foreground">
Uploaded {formatDistanceToNow(new Date(current.createdAt), { addSuffix: true })}
</p>
)}
</div>
</div>
<div className="flex gap-2 shrink-0">
{isAdmin && (
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/admin/videos/${current.id}/edit`)}
>
<Edit className="w-4 h-4" />
Edit
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={handleLike}
className={isLiked ? 'text-red-500' : ''}
>
Like
<Heart
className="w-5 h-5"
fill={isLiked ? 'currentColor' : 'none'}
/>
</Button>
</div>
</div>
{current.description && (
<p className="text-sm text-muted-foreground w-full">
{current.description}
</p>
)}
<div className="w-full mt-4 pt-4 border-t">
<SegmentedProgressBar
segments={currentSegments}
duration={current.durationSec ?? 1}
percent={currentProgress}
height="md"
showTooltip={true}
/>
</div>
</CardFooter>
</Card>
<Card>
<CardContent>
<CommentsSection videoId={current.id} />
</CardContent>
</Card>
{/* <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card>
<CardContent>
<h3 className="font-medium">Notes</h3>
<p className="mt-2 text-sm text-muted-foreground">
Keep lesson notes, transcript links, or chapter markers here.
</p>
</CardContent>
</Card>
<Card>
<CardContent>
<h3 className="font-medium">Resources</h3>
<ul className="mt-2 text-sm text-muted-foreground list-disc ml-5">
<li>Model files</li>
<li>Reference sheets</li>
<li>Assignments</li>
</ul>
</CardContent>
</Card>
</div> */}
</div>
<aside className="w-80 hidden lg:block">
<h2 className="text-sm font-semibold mb-3">Playlist</h2>
<div className="flex flex-col gap-3">
{playlistLoading ? (
<div>Loading playlist</div>
) : (
playlist?.videos?.map((v: VideoFromApi) => {
const active = String(v.id) === String(current.id);
const watchedPercent = 0; // Replace with actual watched % from your state/API
return (
<div
key={v.id}
className={`relative rounded-md border p-2 flex flex-col gap-2 cursor-pointer transition
${
active ? 'border-primary bg-muted' : 'border-transparent hover:border-border'
}`}
onClick={() => {
// Check if video is accessible based on the logic:
// 1. If locked at schema level, always deny
if ((v as any).locked) return;
// 2. If instantAccess, always allow
if ((v as any).instantAccess) {
router.push(`/videoplayer?playlistId=${playlist.id}&videoId=${v.id}`);
return;
}
// 3. If first video (index 0), always allow
if ((v as any).index === 0) {
router.push(`/videoplayer?playlistId=${playlist.id}&videoId=${v.id}`);
return;
}
// 4. Otherwise, check if user has unlocked it
if (userUnlocks.has(v.id)) {
router.push(`/videoplayer?playlistId=${playlist.id}&videoId=${v.id}`);
}
}}
>
<div className="flex items-center gap-3">
<div className="relative w-20 h-12 shrink-0 overflow-hidden rounded">
<img src={v.thumbnail ?? '/01.jpg'} alt={v.title} className="w-full h-full object-cover" loading="lazy" />
{(v as any).locked || (!(v as any).instantAccess && (v as any).index !== 0 && !userUnlocks.has(v.id)) ? (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<Lock className="w-5 h-5 text-white" />
</div>
) : null}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<div className="text-sm font-medium truncate">{v.title}</div>
<Badge>{fmt(v)}</Badge>
</div>
<div className="text-xs text-muted-foreground truncate mt-1">
{(v as any).locked ? 'Locked' : (v as any).instantAccess || (v as any).index === 0 ? 'Available' : userUnlocks.has(v.id) ? 'Available' : active ? 'Now playing' : 'Locked'}
</div>
</div>
{(v as any).locked || (!(v as any).instantAccess && (v as any).index !== 0 && !userUnlocks.has(v.id)) ? (
<div className="ml-2">
<Button
size="sm"
variant={(v as any).locked ? "secondary" : "default"}
disabled={(v as any).locked || unlockingVideo === v.id}
onClick={(e) => {
e.stopPropagation();
handleUnlock(v);
}}
>
{unlockingVideo === v.id ? 'Unlocking...' : (v as any).locked ? 'Locked' : 'Unlock'}
</Button>
</div>
) : null}
</div>
<SegmentedProgressBar
segments={playlistSegments[v.id] ?? []}
duration={v.durationSec ?? 1}
percent={0}
height="sm"
showTooltip={false}
/>
</div>
);
})
)}
</div>
</aside>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+59
View File
@@ -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
+47
View File
@@ -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 ""
+22
View File
@@ -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": {}
}
+27
View File
@@ -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 (
<form className="mt-4 flex gap-2" onSubmit={handleSubmit}>
<Input
value={email}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
placeholder="you@company.com"
aria-label="Email"
/>
<Button type="submit">Join</Button>
</form>
);
}
+129
View File
@@ -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 (
<div className="min-h-screen">
<header className="max-w-6xl mx-auto px-6 py-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="rounded-full w-10 h-10 bg-black/90 text-white flex items-center justify-center font-bold">G4</div>
<div>
<h1 className="text-lg font-semibold">Gruff Studio</h1>
<p className="text-xs text-slate-500">UI playground · shadcn + Tailwind</p>
</div>
</div>
<nav className="flex items-center gap-3">
<Button variant="ghost" size="sm">Docs</Button>
<Button size="sm">Sign in</Button>
</nav>
</header>
<main className="max-w-6xl mx-auto px-6 py-12">
<section className="grid grid-cols-1 md:grid-cols-2 gap-8 items-center">
<div>
<Badge className="mb-4">Beta</Badge>
<h2 className="text-4xl font-extrabold leading-tight">Build faster with shadcn components</h2>
<p className="mt-4 text-slate-600">A tiny example landing page to confirm your Tailwind + shadcn setup is working. Components are unopinionated and fully customizable with Tailwind.</p>
<div className="mt-6 flex gap-4">
<Button>Get started</Button>
<Button variant="outline">Learn more</Button>
</div>
<div className="mt-8 grid grid-cols-2 gap-3 sm:grid-cols-4">
<Feature icon={<Check className="w-4 h-4" />} title="Reusable" subtitle="Composable UI" />
<Feature icon={<Check className="w-4 h-4" />} title="Accessible" subtitle="Focus & keyboard" />
<Feature icon={<Check className="w-4 h-4" />} title="Themed" subtitle="Tailwind friendly" />
<Feature icon={<Check className="w-4 h-4" />} title="Tiny" subtitle="Zero runtime" />
</div>
</div>
<div>
<Card>
<CardContent className="p-6">
<CardTitle>Join the waitlist</CardTitle>
<CardDescription>Drop your email and well ping you when the demo is live.</CardDescription>
<JoinForm />
<p className="mt-3 text-xs text-slate-500">No spam only useful updates.</p>
</CardContent>
</Card>
<div className="mt-6 grid grid-cols-1 gap-3">
<Card>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className="rounded-md bg-slate-100 p-2">
<Check className="w-5 h-5 text-slate-700" />
</div>
<div>
<p className="font-medium">Server-side friendly</p>
<p className="text-sm text-slate-500">Use these components in server and client components.</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className="rounded-md bg-slate-100 p-2">
<Check className="w-5 h-5 text-slate-700" />
</div>
<div>
<p className="font-medium">Tailwind-ready</p>
<p className="text-sm text-slate-500">Customize tokens in tailwind.config.</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</section>
<section className="mt-16">
<h3 className="text-2xl font-semibold">Example features</h3>
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<FeatureCard title="Fast dev loop" desc="Hot reload, Tailwind JIT, and tiny components." />
<FeatureCard title="Prisma-ready" desc="Wire your database and serve content from server components." />
<FeatureCard title="Accessible" desc="Built with accessibility and UX in mind." />
</div>
</section>
</main>
<footer className="border-t mt-12 py-6">
<div className="max-w-6xl mx-auto px-6 flex flex-col md:flex-row items-center justify-between gap-4">
<p className="text-sm text-slate-500">© {new Date().getFullYear()} Gruff Studio</p>
<div className="flex items-center gap-3 text-sm text-slate-600">Made with using shadcn components</div>
</div>
</footer>
</div>
);
}
function Feature({ icon, title, subtitle }: { icon: React.ReactNode; title: string; subtitle: string }) {
return (
<div className="flex items-center gap-3 p-3 bg-white rounded-lg shadow-sm">
<div className="w-8 h-8 bg-slate-100 rounded flex items-center justify-center">{icon}</div>
<div>
<div className="font-medium text-sm">{title}</div>
<div className="text-xs text-slate-500">{subtitle}</div>
</div>
</div>
);
}
function FeatureCard({ title, desc }: { title: string; desc: string }) {
return (
<Card>
<CardContent>
<h4 className="font-semibold">{title}</h4>
<p className="mt-2 text-sm text-slate-500">{desc}</p>
</CardContent>
</Card>
);
}
+158
View File
@@ -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<HTMLDivElement | null>(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 (
<section className="w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">{categoryTitle}</h2>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
aria-label="Scroll left"
onClick={() => scrollByPage("left")}
disabled={!canScrollLeft}
>
<ChevronLeft size={18} />
</Button>
<Button
variant="outline"
size="icon"
aria-label="Scroll right"
onClick={() => scrollByPage("right")}
disabled={!canScrollRight}
>
<ChevronRight size={18} />
</Button>
</div>
</div>
<div className="relative">
<div
ref={scrollerRef}
role="list"
aria-label={`${categoryTitle} videos`}
className="flex gap-4 overflow-x-auto scroll-smooth py-2 pb-4 -mx-2 px-2"
style={{ scrollbarGutter: "stable" }}
>
{videos.map((v) => (
<article
key={v.id}
role="listitem"
className="shrink-0 w-[22%] min-w-[220px] max-w-[260px]"
>
<Card className="@container/card overflow-hidden">
<div className="relative">
<img
src={v.thumbnailUrl}
alt={v.title}
className="w-full h-40 object-cover block"
loading="lazy"
/>
<Badge className="absolute right-2 bottom-2">{v.duration}</Badge>
</div>
<CardHeader>
<CardTitle className="text-sm font-medium line-clamp-2" title={v.title}>
{v.title}
</CardTitle>
<CardAction>
{/* placeholder for action, e.g., menu or save */}
</CardAction>
</CardHeader>
<CardFooter className="px-3 py-2">
<div className="text-muted-foreground text-xs">{v.duration}</div>
</CardFooter>
</Card>
</article>
))}
</div>
{/* small gradients on the sides to indicate scrollability */}
<div className="pointer-events-none absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-white/90 to-transparent dark:from-slate-900/90" />
<div className="pointer-events-none absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-white/90 to-transparent dark:from-slate-900/90" />
</div>
</section>
);
}
/*
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
];
<VideoCarousel categoryTitle="Cute Cats" videos={videos} visibleCount={4} />
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.
*/
+210
View File
@@ -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<Notification[]>([]);
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageCircle className="w-5 h-5" />
Activity
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">Loading notifications...</p>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageCircle className="w-5 h-5" />
Activity
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-red-500">{error}</p>
</CardContent>
</Card>
);
}
if (notifications.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageCircle className="w-5 h-5" />
Activity
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">No activity yet</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageCircle className="w-5 h-5" />
Activity ({notifications.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 max-h-96 overflow-y-auto">
{notifications.map((notification) => (
<div
key={notification.id}
className="flex gap-3 pb-4 border-b last:border-b-0"
>
{notification.user?.image && (
<div className="relative w-10 h-10 rounded-full overflow-hidden shrink-0">
<Image
src={notification.user.image}
alt={notification.user.name ?? 'User'}
fill
unoptimized
sizes="40px"
className="object-cover"
/>
</div>
)}
{notification.type === 'course_created' && (
<div className="w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center shrink-0">
<BookOpen className="w-5 h-5 text-purple-600" />
</div>
)}
{notification.type === 'playlist_created' && (
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center shrink-0">
<List className="w-5 h-5 text-blue-600" />
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{notification.type === 'like' && (
<Heart className="w-4 h-4 text-red-500 shrink-0" />
)}
{notification.type === 'comment' && (
<MessageCircle className="w-4 h-4 text-blue-500 shrink-0" />
)}
{notification.type === 'course_created' && (
<BookOpen className="w-4 h-4 text-purple-600 shrink-0" />
)}
{notification.type === 'playlist_created' && (
<List className="w-4 h-4 text-blue-600 shrink-0" />
)}
<p className="text-sm font-medium truncate">
{notification.user ? (
notification.user.name || notification.user.email
) : (
'System'
)}
</p>
<p className="text-xs text-muted-foreground shrink-0">
{formatDistanceToNow(notification.createdAt, {
addSuffix: true,
})}
</p>
</div>
<p className="text-xs text-muted-foreground truncate">
{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}`}
</p>
{notification.content && (
<p className="text-sm text-foreground mt-1 line-clamp-2">
"{notification.content}"
</p>
)}
</div>
</div>
))}
</CardContent>
</Card>
);
}
+97
View File
@@ -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<typeof Sidebar>) {
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 (
<Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild className="data-[slot=sidebar-menu-button]:p-1.5!">
<a href="/dashboard">
<img src="/icon.svg" alt="" className="size-5" />
<span className="text-base font-semibold">OW ANIMATION ARTS VAULT</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={navMain} />
<NavSecondary items={navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>
<NavUser user={user} />
</SidebarFooter>
</Sidebar>
);
}

Some files were not shown because too many files have changed in this diff Show More