'use client'; import React, { useEffect, useState } from 'react'; import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'; import { AppSidebar } from '@/components/app-sidebar'; import { Badge } from '@/components/ui/badge'; import { SiteHeader } from '@/components/site-header'; import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; import { Progress } from '@/components/ui/progress'; import { Card, CardContent, CardHeader, CardTitle, } from '@/components/ui/card'; import { Field, FieldGroup, FieldLabel, FieldDescription, } from '@/components/ui/field'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog'; import { Trash2Icon, Edit, Check, X } from 'lucide-react'; import { Switch } from '@/components/ui/switch'; import { getVideoDuration } from '@/utils/getVideoDuration'; type Course = { id: string; title: string; code?: string }; type Playlist = { id: string; title: string; courseId: string }; type Video = { id: string; title: string; durationSec?: number; thumbnail?: string; url: string; index: number; locked: boolean; instantAccess: boolean; playlistId: string; playlist: { id: string; title: string; course: { id: string; title: string; }; }; restrictedCourseIds?: string[]; }; function VideosUI() { const router = useRouter(); const [courses, setCourses] = useState([]); const [playlists, setPlaylists] = useState([]); const [videos, setVideos] = useState([]); const [loading, setLoading] = useState(false); const [uploadProgress, setUploadProgress] = useState(null); // form state const [videoTitle, setVideoTitle] = useState(''); const [videoFile, setVideoFile] = useState(null); const [videoPlaylistId, setVideoPlaylistId] = useState(''); const [thumbFile, setThumbFile] = useState(null); const [videoDurationSec, setVideoDurationSec] = useState(null); const [deletingId, setDeletingId] = useState(null); const [togglingId, setTogglingId] = useState(null); const [editingId, setEditingId] = useState(null); const [editingTitle, setEditingTitle] = useState(''); const [savingEditId, setSavingEditId] = useState(null); // Manual file copy mode const [useManualFileCopy, setUseManualFileCopy] = useState(false); const [pendingVideoId, setPendingVideoId] = useState(null); const [finalizingVideoId, setFinalizingVideoId] = useState(null); useEffect(() => { fetchData(); }, []); async function toggleInstantAccess(videoId: string, currentValue: boolean) { setTogglingId(videoId); try { const res = await fetch(`/api/admin/videos/${videoId}/instant-access`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instantAccess: !currentValue }), }); if (res.ok) { const data = await res.json(); // Update the video in state setVideos((prev) => prev.map((v) => v.id === videoId ? { ...v, instantAccess: data.video.instantAccess } : v ) ); toast.success(`Video ${!currentValue ? 'set to' : 'removed from'} instant access`); } else { toast.error('Failed to toggle instant access'); } } catch (err) { console.error('Error toggling instant access:', err); toast.error('Error toggling instant access'); } finally { setTogglingId(null); } } async function toggleLocked(videoId: string, currentValue: boolean) { setTogglingId(videoId); try { const res = await fetch(`/api/admin/videos/${videoId}/locked`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ locked: !currentValue }), }); if (res.ok) { const data = await res.json(); // Update the video in state setVideos((prev) => prev.map((v) => v.id === videoId ? { ...v, locked: data.video.locked } : v ) ); toast.success(`Video ${!currentValue ? 'locked' : 'unlocked'}`); } else { toast.error('Failed to toggle lock status'); } } catch (err) { console.error('Error toggling locked:', err); toast.error('Error toggling lock status'); } finally { setTogglingId(null); } } async function fetchData() { try { const [metaRes, videosRes] = await Promise.all([ fetch('/api/admin/meta'), fetch('/api/admin/videos'), ]); if (metaRes.ok) { const json = await metaRes.json(); setCourses(json.courses || []); setPlaylists(json.playlists || []); if (!videoPlaylistId && json.playlists?.[0]) { setVideoPlaylistId(json.playlists[0].id); } } if (videosRes.ok) { const json = await videosRes.json(); // Ensure data is serialized properly setVideos(json.map((v: any) => ({ id: v.id, title: v.title, durationSec: v.durationSec, thumbnail: v.thumbnail, url: v.url, index: v.index, locked: v.locked, instantAccess: v.instantAccess, playlistId: v.playlistId, playlist: { id: v.playlist.id, title: v.playlist.title, course: { id: v.playlist.course.id, title: v.playlist.course.title, }, }, restrictedCourseIds: (v.videoCourses ?? []) .filter((assignment: any) => assignment.exclusive) .map((assignment: any) => assignment.courseId), }))); } } catch (err) { console.error('Failed to fetch data', err); toast.error('Failed to fetch data'); } } async function handleFileChange(e: React.ChangeEvent) { const f = e.target.files?.[0] ?? null; setVideoFile(f); setVideoDurationSec(null); if (!f) return; try { const secs = await getVideoDuration(f); setVideoDurationSec(secs); toast.success(`Duration: ${secs}s`); } catch (err) { console.warn('duration read failed', err); toast.error('Could not read duration (server will measure)'); } } async function uploadVideo(e: React.FormEvent) { e.preventDefault(); if (!videoPlaylistId) { toast.error('Select a playlist'); return; } // Manual file copy mode if (useManualFileCopy) { if (!videoFile) { toast.error('Select a file to get the filename'); return; } // Create video entry without uploading the actual file setLoading(true); try { const form = new FormData(); form.append('title', videoTitle); form.append('playlistId', videoPlaylistId); form.append('manualFileCopy', 'true'); // Flag to skip file upload if (videoDurationSec) form.append('durationSec', String(videoDurationSec)); if (thumbFile) form.append('thumbnail', thumbFile); const res = await fetch('/api/admin/upload', { method: 'POST', body: form, }); const data = await res.json(); if (!res.ok) { toast.error(data.error ?? 'Upload failed'); return; } // Show modal with the video ID setPendingVideoId(data.video.id); toast.success(`Video created! Copy file as: ${data.video.id}.mp4`); } catch (err: any) { console.error('upload error', err); toast.error(String(err?.message ?? 'Upload failed')); } finally { setLoading(false); } return; } // Normal upload mode if (!videoFile) { toast.error('Pick a file first'); return; } setLoading(true); setUploadProgress(0); try { const form = new FormData(); form.append('file', videoFile); form.append('title', videoTitle); form.append('playlistId', videoPlaylistId); if (videoDurationSec) form.append('durationSec', String(videoDurationSec)); if (thumbFile) form.append('thumbnail', thumbFile); await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/admin/upload'); xhr.upload.onprogress = (ev) => { if (ev.lengthComputable) { const pct = Math.round((ev.loaded / ev.total) * 100); setUploadProgress(pct); } }; xhr.onload = async () => { let body: any = null; try { body = xhr.responseText ? JSON.parse(xhr.responseText) : null; } catch (err) { body = { error: xhr.responseText }; } if (xhr.status >= 200 && xhr.status < 300) { toast.success('Upload complete'); setUploadProgress(null); setVideoFile(null); setVideoTitle(''); setVideoDurationSec(null); setThumbFile(null); await fetchData(); router.refresh(); resolve(); } else { const errMsg = body?.error ?? `Upload failed (${xhr.status})`; toast.error(errMsg); setUploadProgress(null); reject(new Error(errMsg)); } }; xhr.onerror = () => { toast.error('Upload failed (network)'); setUploadProgress(null); reject(new Error('network error')); }; // Timeout for 2GB uploads over Tailscale: 50 minutes xhr.timeout = 50 * 60 * 1000; xhr.ontimeout = () => { toast.error('Upload timed out'); setUploadProgress(null); reject(new Error('timeout')); }; xhr.send(form); }); } catch (err: any) { console.error('upload error', err); if (!uploadProgress) setUploadProgress(null); toast.error(String(err?.message ?? 'Upload failed')); } finally { setLoading(false); } } async function finalizeManualUpload(videoId: string) { setFinalizingVideoId(videoId); try { const res = await fetch('/api/admin/upload/finalize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ videoId }), }); const data = await res.json(); if (!res.ok) { toast.error(data.error ?? 'Finalization failed'); return; } toast.success(`Video finalized! ${data.message}`); setPendingVideoId(null); setVideoFile(null); setVideoTitle(''); setVideoDurationSec(null); setThumbFile(null); setUseManualFileCopy(false); await fetchData(); router.refresh(); } catch (err: any) { console.error('finalize error', err); toast.error(String(err?.message ?? 'Finalization failed')); } finally { setFinalizingVideoId(null); } } async function deleteVideo(videoId: string) { setDeletingId(videoId); try { const res = await fetch(`/api/admin/delete-video`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ videoId }), }); if (res.ok) { toast.success('Video deleted'); await fetchData(); router.refresh(); } else { const txt = await res.text(); toast.error('Failed to delete video: ' + txt); } } catch (err: any) { toast.error('Error: ' + String(err.message ?? err)); } finally { setDeletingId(null); } } async function updateVideoTitle(videoId: string, newTitle: string) { if (!newTitle.trim()) { toast.error('Title cannot be empty'); return; } setSavingEditId(videoId); try { const res = await fetch(`/api/admin/update-video`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ videoId, title: newTitle }), }); if (res.ok) { toast.success('Video updated'); setEditingId(null); setEditingTitle(''); await fetchData(); router.refresh(); } else { const txt = await res.text(); toast.error('Failed to update video: ' + txt); } } catch (err: any) { toast.error('Error: ' + String(err.message ?? err)); } finally { setSavingEditId(null); } } return (
Upload Video
Video title Title shown in the playlist. setVideoTitle(e.target.value)} placeholder="e.g. Lesson 1 — Intro to the Rig" />
{useManualFileCopy ? 'Creates DB entry and thumbnail. You will copy the file manually to the server.' : 'Upload file directly from browser.'}
Playlist Select which playlist this video belongs to. Video file {useManualFileCopy ? 'Select the file to get its name, then copy manually to: /uploads/videos/{videoId}.mp4' : 'Large files will be uploaded to the server.'}
{videoDurationSec ? (
Duration: {videoDurationSec}s
) : null} {uploadProgress !== null && !useManualFileCopy ? (
Uploading
{uploadProgress}%
) : null}
Thumbnail
setThumbFile(e.target.files?.[0] ?? null)} className="block w-full text-sm" /> {thumbFile ? ( ) : null}
{/* Manual file copy modal */} {pendingVideoId && ( File Ready for Manual Copy

Copy your video file to the following location on your server:

/uploads/videos/{pendingVideoId}.mp4

The file must be named exactly as shown above (using the video ID).

)} Videos {videos.length === 0 ? (
No videos yet
) : (
Thumbnail Title Playlist Course Locked Instant Access Actions {videos.map((video) => ( {video.thumbnail ? ( {video.title} ) : (
No image
)}
{editingId === video.id ? (
setEditingTitle(e.target.value)} className="h-8" autoFocus onKeyDown={(e) => { if (e.key === 'Enter') { updateVideoTitle(video.id, editingTitle); } if (e.key === 'Escape') { setEditingId(null); setEditingTitle(''); } }} />
) : (
{video.title}
{video.restrictedCourseIds?.length ? (
{video.restrictedCourseIds.map((courseId) => { const course = courses.find((c) => c.id === courseId); const label = course?.code ?? course?.title ?? 'Course'; return ( {label} ); })}
) : null}
)}
{video.playlist.title} {video.playlist.course.title} toggleLocked(video.id, video.locked)} disabled={togglingId === video.id} aria-label="Toggle lock status" /> toggleInstantAccess(video.id, video.instantAccess)} disabled={togglingId === video.id} aria-label="Toggle instant access" />
Delete Video Are you sure you want to delete "{video.title}"?
Cancel deleteVideo(video.id)} > Delete
))}
)}
); } export default function VideosAdminClient() { return (
); }