Initial commit
This commit is contained in:
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
+122
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 we’ll 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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user