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