Initial commit

This commit is contained in:
twotalesanimation
2026-06-11 10:46:09 +02:00
commit 81ad7e4ea9
223 changed files with 39530 additions and 0 deletions
@@ -0,0 +1,268 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import Image from 'next/image';
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Field,
FieldGroup,
FieldLabel,
FieldDescription,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
interface VideoData {
id: string;
title: string;
description: string;
thumbnail?: string;
playlistTitle: string;
courseTitle: string;
restrictedCourseIds?: string[];
}
interface CourseData {
id: string;
title: string;
}
export default function EditVideoClient({ video }: { video: VideoData }) {
const router = useRouter();
const [title, setTitle] = useState(video.title);
const [description, setDescription] = useState(video.description);
const [thumbFile, setThumbFile] = useState<File | null>(null);
const [thumbPreview, setThumbPreview] = useState(video.thumbnail);
const [loading, setLoading] = useState(false);
const [courses, setCourses] = useState<CourseData[]>([]);
const [coursesLoading, setCoursesLoading] = useState(true);
const [restrictedCourseIds, setRestrictedCourseIds] = useState<string[]>(
video.restrictedCourseIds ?? []
);
useEffect(() => {
let cancelled = false;
setCoursesLoading(true);
fetch('/api/admin/meta')
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (!cancelled) {
setCourses(json.courses ?? []);
}
})
.catch((err) => {
console.error('Failed to load courses', err);
if (!cancelled) {
setCourses([]);
}
})
.finally(() => {
if (!cancelled) setCoursesLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const handleThumbnailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setThumbFile(file);
const reader = new FileReader();
reader.onload = (event) => {
setThumbPreview(event.target?.result as string);
};
reader.readAsDataURL(file);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const formData = new FormData();
formData.append('videoId', video.id);
formData.append('title', title);
formData.append('description', description);
if (thumbFile) {
formData.append('thumbnail', thumbFile);
}
formData.append('restrictedCourseIds', JSON.stringify(restrictedCourseIds));
const res = await fetch('/api/admin/update-video', {
method: 'POST',
body: formData,
});
if (res.ok) {
toast.success('Video updated successfully');
router.push('/admin/videos');
} else {
const err = await res.text();
toast.error('Failed to update video: ' + err);
}
} catch (err: any) {
toast.error('Error: ' + String(err.message ?? err));
} finally {
setLoading(false);
}
};
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">
Edit Video
</h1>
<p className="text-muted-foreground">
{video.courseTitle} {video.playlistTitle}
</p>
</div>
<Card>
<CardHeader>
<CardTitle>{video.title}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="title">Video Title</FieldLabel>
<Input
id="title"
required
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter video title"
/>
</Field>
<Field>
<FieldLabel htmlFor="description">
Description
</FieldLabel>
<FieldDescription>
Optional description for the video
</FieldDescription>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter video description"
rows={4}
/>
</Field>
<Field>
<FieldLabel>Restricted courses</FieldLabel>
<FieldDescription>
Only users enrolled in the checked courses will see this video. Leave
empty to allow all enrolled courses to access it.
</FieldDescription>
{coursesLoading ? (
<div className="text-sm text-muted-foreground">Loading courses</div>
) : courses.length === 0 ? (
<div className="text-sm text-muted-foreground">No courses available.</div>
) : (
<div className="grid gap-2 text-sm">
{courses.map((course) => (
<label
key={course.id}
className="flex items-center gap-2 text-sm font-medium"
>
<input
type="checkbox"
checked={restrictedCourseIds.includes(course.id)}
onChange={() => {
setRestrictedCourseIds((prev) =>
prev.includes(course.id)
? prev.filter((id) => id !== course.id)
: [...prev, course.id]
);
}}
className="accent-primary"
/>
<span>{course.title}</span>
</label>
))}
</div>
)}
</Field>
<Field>
<FieldLabel htmlFor="thumbnail">Thumbnail</FieldLabel>
<FieldDescription>
Upload a new thumbnail image (optional)
</FieldDescription>
<div className="grid w-full max-w-sm items-center gap-3">
<Input
id="thumbnail"
type="file"
accept="image/*"
onChange={handleThumbnailChange}
className="block w-full text-sm"
/>
{thumbPreview ? (
<div className="relative w-40 h-24">
<Image
src={thumbPreview}
alt="Thumbnail preview"
fill
unoptimized
className="object-cover rounded"
/>
</div>
) : null}
</div>
</Field>
<div className="flex gap-2 pt-4">
<Button type="submit" disabled={loading}>
{loading ? 'Saving...' : 'Save Changes'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.push('/admin/videos')}
>
Cancel
</Button>
</div>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+57
View File
@@ -0,0 +1,57 @@
// app/admin/videos/[videoId]/edit/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
import { redirect } from 'next/navigation';
import EditVideoClient from './edit-video-client';
export default async function EditVideoPage({
params,
}: {
params: Promise<{ videoId: string }>;
}) {
const { videoId } = await params;
const session = await getServerSession(authOptions);
const role = (session as any)?.user?.role ?? null;
if (!session?.user) {
redirect('/login');
}
if (!(role === 'admin' || role === 'superadmin')) {
redirect('/dashboard');
}
const video = await prisma.video.findUnique({
where: { id: videoId },
include: {
playlist: {
include: {
course: true,
},
},
videoCourses: true,
},
});
if (!video) {
redirect('/admin/videos');
}
return (
<EditVideoClient
video={{
id: video.id,
title: video.title,
description: (video as any).description || '',
thumbnail: video.thumbnail ?? undefined,
playlistTitle: video.playlist.title,
courseTitle: video.playlist.course.title,
restrictedCourseIds: video.videoCourses
.filter((vc) => vc.exclusive)
.map((vc) => vc.courseId),
}}
/>
);
}
+815
View File
@@ -0,0 +1,815 @@
'use client';
import React, { useEffect, useState } from 'react';
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { Badge } from '@/components/ui/badge';
import { SiteHeader } from '@/components/site-header';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { Progress } from '@/components/ui/progress';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Field,
FieldGroup,
FieldLabel,
FieldDescription,
} from '@/components/ui/field';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Trash2Icon, Edit, Check, X } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { getVideoDuration } from '@/utils/getVideoDuration';
type Course = { id: string; title: string; code?: string };
type Playlist = { id: string; title: string; courseId: string };
type Video = {
id: string;
title: string;
durationSec?: number;
thumbnail?: string;
url: string;
index: number;
locked: boolean;
instantAccess: boolean;
playlistId: string;
playlist: {
id: string;
title: string;
course: {
id: string;
title: string;
};
};
restrictedCourseIds?: string[];
};
function VideosUI() {
const router = useRouter();
const [courses, setCourses] = useState<Course[]>([]);
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
// form state
const [videoTitle, setVideoTitle] = useState('');
const [videoFile, setVideoFile] = useState<File | null>(null);
const [videoPlaylistId, setVideoPlaylistId] = useState('');
const [thumbFile, setThumbFile] = useState<File | null>(null);
const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [togglingId, setTogglingId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState('');
const [savingEditId, setSavingEditId] = useState<string | null>(null);
// Manual file copy mode
const [useManualFileCopy, setUseManualFileCopy] = useState(false);
const [pendingVideoId, setPendingVideoId] = useState<string | null>(null);
const [finalizingVideoId, setFinalizingVideoId] = useState<string | null>(null);
useEffect(() => {
fetchData();
}, []);
async function toggleInstantAccess(videoId: string, currentValue: boolean) {
setTogglingId(videoId);
try {
const res = await fetch(`/api/admin/videos/${videoId}/instant-access`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ instantAccess: !currentValue }),
});
if (res.ok) {
const data = await res.json();
// Update the video in state
setVideos((prev) =>
prev.map((v) =>
v.id === videoId ? { ...v, instantAccess: data.video.instantAccess } : v
)
);
toast.success(`Video ${!currentValue ? 'set to' : 'removed from'} instant access`);
} else {
toast.error('Failed to toggle instant access');
}
} catch (err) {
console.error('Error toggling instant access:', err);
toast.error('Error toggling instant access');
} finally {
setTogglingId(null);
}
}
async function toggleLocked(videoId: string, currentValue: boolean) {
setTogglingId(videoId);
try {
const res = await fetch(`/api/admin/videos/${videoId}/locked`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locked: !currentValue }),
});
if (res.ok) {
const data = await res.json();
// Update the video in state
setVideos((prev) =>
prev.map((v) =>
v.id === videoId ? { ...v, locked: data.video.locked } : v
)
);
toast.success(`Video ${!currentValue ? 'locked' : 'unlocked'}`);
} else {
toast.error('Failed to toggle lock status');
}
} catch (err) {
console.error('Error toggling locked:', err);
toast.error('Error toggling lock status');
} finally {
setTogglingId(null);
}
}
async function fetchData() {
try {
const [metaRes, videosRes] = await Promise.all([
fetch('/api/admin/meta'),
fetch('/api/admin/videos'),
]);
if (metaRes.ok) {
const json = await metaRes.json();
setCourses(json.courses || []);
setPlaylists(json.playlists || []);
if (!videoPlaylistId && json.playlists?.[0]) {
setVideoPlaylistId(json.playlists[0].id);
}
}
if (videosRes.ok) {
const json = await videosRes.json();
// Ensure data is serialized properly
setVideos(json.map((v: any) => ({
id: v.id,
title: v.title,
durationSec: v.durationSec,
thumbnail: v.thumbnail,
url: v.url,
index: v.index,
locked: v.locked,
instantAccess: v.instantAccess,
playlistId: v.playlistId,
playlist: {
id: v.playlist.id,
title: v.playlist.title,
course: {
id: v.playlist.course.id,
title: v.playlist.course.title,
},
},
restrictedCourseIds: (v.videoCourses ?? [])
.filter((assignment: any) => assignment.exclusive)
.map((assignment: any) => assignment.courseId),
})));
}
} catch (err) {
console.error('Failed to fetch data', err);
toast.error('Failed to fetch data');
}
}
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0] ?? null;
setVideoFile(f);
setVideoDurationSec(null);
if (!f) return;
try {
const secs = await getVideoDuration(f);
setVideoDurationSec(secs);
toast.success(`Duration: ${secs}s`);
} catch (err) {
console.warn('duration read failed', err);
toast.error('Could not read duration (server will measure)');
}
}
async function uploadVideo(e: React.FormEvent) {
e.preventDefault();
if (!videoPlaylistId) {
toast.error('Select a playlist');
return;
}
// Manual file copy mode
if (useManualFileCopy) {
if (!videoFile) {
toast.error('Select a file to get the filename');
return;
}
// Create video entry without uploading the actual file
setLoading(true);
try {
const form = new FormData();
form.append('title', videoTitle);
form.append('playlistId', videoPlaylistId);
form.append('manualFileCopy', 'true'); // Flag to skip file upload
if (videoDurationSec) form.append('durationSec', String(videoDurationSec));
if (thumbFile) form.append('thumbnail', thumbFile);
const res = await fetch('/api/admin/upload', {
method: 'POST',
body: form,
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error ?? 'Upload failed');
return;
}
// Show modal with the video ID
setPendingVideoId(data.video.id);
toast.success(`Video created! Copy file as: ${data.video.id}.mp4`);
} catch (err: any) {
console.error('upload error', err);
toast.error(String(err?.message ?? 'Upload failed'));
} finally {
setLoading(false);
}
return;
}
// Normal upload mode
if (!videoFile) {
toast.error('Pick a file first');
return;
}
setLoading(true);
setUploadProgress(0);
try {
const form = new FormData();
form.append('file', videoFile);
form.append('title', videoTitle);
form.append('playlistId', videoPlaylistId);
if (videoDurationSec) form.append('durationSec', String(videoDurationSec));
if (thumbFile) form.append('thumbnail', thumbFile);
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/admin/upload');
xhr.upload.onprogress = (ev) => {
if (ev.lengthComputable) {
const pct = Math.round((ev.loaded / ev.total) * 100);
setUploadProgress(pct);
}
};
xhr.onload = async () => {
let body: any = null;
try {
body = xhr.responseText ? JSON.parse(xhr.responseText) : null;
} catch (err) {
body = { error: xhr.responseText };
}
if (xhr.status >= 200 && xhr.status < 300) {
toast.success('Upload complete');
setUploadProgress(null);
setVideoFile(null);
setVideoTitle('');
setVideoDurationSec(null);
setThumbFile(null);
await fetchData();
router.refresh();
resolve();
} else {
const errMsg = body?.error ?? `Upload failed (${xhr.status})`;
toast.error(errMsg);
setUploadProgress(null);
reject(new Error(errMsg));
}
};
xhr.onerror = () => {
toast.error('Upload failed (network)');
setUploadProgress(null);
reject(new Error('network error'));
};
// Timeout for 2GB uploads over Tailscale: 50 minutes
xhr.timeout = 50 * 60 * 1000;
xhr.ontimeout = () => {
toast.error('Upload timed out');
setUploadProgress(null);
reject(new Error('timeout'));
};
xhr.send(form);
});
} catch (err: any) {
console.error('upload error', err);
if (!uploadProgress) setUploadProgress(null);
toast.error(String(err?.message ?? 'Upload failed'));
} finally {
setLoading(false);
}
}
async function finalizeManualUpload(videoId: string) {
setFinalizingVideoId(videoId);
try {
const res = await fetch('/api/admin/upload/finalize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId }),
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error ?? 'Finalization failed');
return;
}
toast.success(`Video finalized! ${data.message}`);
setPendingVideoId(null);
setVideoFile(null);
setVideoTitle('');
setVideoDurationSec(null);
setThumbFile(null);
setUseManualFileCopy(false);
await fetchData();
router.refresh();
} catch (err: any) {
console.error('finalize error', err);
toast.error(String(err?.message ?? 'Finalization failed'));
} finally {
setFinalizingVideoId(null);
}
}
async function deleteVideo(videoId: string) {
setDeletingId(videoId);
try {
const res = await fetch(`/api/admin/delete-video`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId }),
});
if (res.ok) {
toast.success('Video deleted');
await fetchData();
router.refresh();
} else {
const txt = await res.text();
toast.error('Failed to delete video: ' + txt);
}
} catch (err: any) {
toast.error('Error: ' + String(err.message ?? err));
} finally {
setDeletingId(null);
}
}
async function updateVideoTitle(videoId: string, newTitle: string) {
if (!newTitle.trim()) {
toast.error('Title cannot be empty');
return;
}
setSavingEditId(videoId);
try {
const res = await fetch(`/api/admin/update-video`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId, title: newTitle }),
});
if (res.ok) {
toast.success('Video updated');
setEditingId(null);
setEditingTitle('');
await fetchData();
router.refresh();
} else {
const txt = await res.text();
toast.error('Failed to update video: ' + txt);
}
} catch (err: any) {
toast.error('Error: ' + String(err.message ?? err));
} finally {
setSavingEditId(null);
}
}
return (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<Card>
<CardHeader>
<CardTitle>Upload Video</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={uploadVideo}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="videoTitle">Video title</FieldLabel>
<FieldDescription>Title shown in the playlist.</FieldDescription>
<Input
id="videoTitle"
required
value={videoTitle}
onChange={(e) => setVideoTitle(e.target.value)}
placeholder="e.g. Lesson 1 — Intro to the Rig"
/>
</Field>
<Field>
<div className="flex items-center gap-3">
<Switch
id="manualFileCopy"
checked={useManualFileCopy}
onCheckedChange={setUseManualFileCopy}
/>
<label htmlFor="manualFileCopy" className="text-sm cursor-pointer">
Manual file copy mode
</label>
</div>
<FieldDescription>
{useManualFileCopy
? 'Creates DB entry and thumbnail. You will copy the file manually to the server.'
: 'Upload file directly from browser.'}
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="videoPlaylist">Playlist</FieldLabel>
<FieldDescription>
Select which playlist this video belongs to.
</FieldDescription>
<Select
value={videoPlaylistId}
onValueChange={(val) => setVideoPlaylistId(val)}
>
<SelectTrigger aria-label="Choose playlist">
<SelectValue placeholder="Select playlist" />
</SelectTrigger>
<SelectContent>
{playlists.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.title}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="videoFile">Video file</FieldLabel>
<FieldDescription>
{useManualFileCopy
? 'Select the file to get its name, then copy manually to: /uploads/videos/{videoId}.mp4'
: 'Large files will be uploaded to the server.'}
</FieldDescription>
<div className="grid w-full max-w-sm items-center gap-3">
<Input
id="videoFile"
type="file"
accept="video/*"
onChange={handleFileChange}
className="block w-full text-sm"
required={!useManualFileCopy}
/>
{videoDurationSec ? (
<div className="text-sm text-muted-foreground">
Duration: {videoDurationSec}s
</div>
) : null}
{uploadProgress !== null && !useManualFileCopy ? (
<div className="w-full">
<div className="flex items-center justify-between mb-1">
<div className="text-sm">Uploading</div>
<div className="text-xs text-muted-foreground">
{uploadProgress}%
</div>
</div>
<Progress value={uploadProgress} />
</div>
) : null}
</div>
</Field>
<Field>
<FieldLabel htmlFor="thumbFile">Thumbnail</FieldLabel>
<div className="grid w-full max-w-sm items-center gap-3">
<Input
id="thumbFile"
type="file"
accept="image/*"
onChange={(e) => setThumbFile(e.target.files?.[0] ?? null)}
className="block w-full text-sm"
/>
{thumbFile ? (
<img
src={URL.createObjectURL(thumbFile)}
className="w-32 h-20 rounded object-cover border mt-2"
/>
) : null}
</div>
</Field>
<Field>
<Button
type="submit"
disabled={loading || (!useManualFileCopy && !videoFile) || !videoPlaylistId}
>
{loading
? useManualFileCopy
? 'Creating…'
: 'Uploading…'
: useManualFileCopy
? 'Create & Setup Manual Copy'
: 'Upload video'}
</Button>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
{/* Manual file copy modal */}
{pendingVideoId && (
<Card className="border-blue-200 bg-blue-50">
<CardHeader>
<CardTitle className="text-blue-900">File Ready for Manual Copy</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-white p-4 rounded border border-blue-200">
<p className="text-sm text-muted-foreground mb-2">
Copy your video file to the following location on your server:
</p>
<code className="block bg-muted p-3 rounded text-sm font-mono mb-2">
/uploads/videos/{pendingVideoId}.mp4
</code>
<p className="text-sm text-muted-foreground">
The file must be named exactly as shown above (using the video ID).
</p>
</div>
<Button
onClick={() => finalizeManualUpload(pendingVideoId)}
disabled={finalizingVideoId !== null}
className="w-full"
>
{finalizingVideoId ? 'Checking file…' : 'I have placed the file'}
</Button>
<Button
variant="outline"
onClick={() => setPendingVideoId(null)}
disabled={finalizingVideoId !== null}
className="w-full"
>
Cancel
</Button>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Videos</CardTitle>
</CardHeader>
<CardContent>
{videos.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No videos yet
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Thumbnail</TableHead>
<TableHead>Title</TableHead>
<TableHead>Playlist</TableHead>
<TableHead>Course</TableHead>
<TableHead className="text-center">Locked</TableHead>
<TableHead className="text-center">Instant Access</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{videos.map((video) => (
<TableRow key={video.id}>
<TableCell>
{video.thumbnail ? (
<img
src={video.thumbnail}
alt={video.title}
className="w-16 h-9 object-cover rounded text-xs"
/>
) : (
<div className="w-16 h-9 bg-muted rounded flex items-center justify-center">
<span className="text-xs text-muted-foreground">
No image
</span>
</div>
)}
</TableCell>
<TableCell className="font-medium max-w-xs">
{editingId === video.id ? (
<div className="flex gap-2 items-center">
<Input
value={editingTitle}
onChange={(e) => setEditingTitle(e.target.value)}
className="h-8"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
updateVideoTitle(video.id, editingTitle);
}
if (e.key === 'Escape') {
setEditingId(null);
setEditingTitle('');
}
}}
/>
<Button
variant="ghost"
size="sm"
onClick={() => updateVideoTitle(video.id, editingTitle)}
disabled={savingEditId === video.id}
>
<Check className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingId(null);
setEditingTitle('');
}}
disabled={savingEditId === video.id}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<div className="flex flex-col gap-1">
<div className="flex gap-2 items-center">
<span className="truncate">{video.title}</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingId(video.id);
setEditingTitle(video.title);
}}
>
<Edit className="w-4 h-4" />
</Button>
</div>
{video.restrictedCourseIds?.length ? (
<div className="flex flex-wrap gap-1">
{video.restrictedCourseIds.map((courseId) => {
const course = courses.find((c) => c.id === courseId);
const label = course?.code ?? course?.title ?? 'Course';
return (
<Badge
key={`${video.id}-${courseId}`}
variant="secondary"
>
{label}
</Badge>
);
})}
</div>
) : null}
</div>
)}
</TableCell>
<TableCell>{video.playlist.title}</TableCell>
<TableCell>{video.playlist.course.title}</TableCell>
<TableCell className="text-center">
<Switch
checked={video.locked}
onCheckedChange={() => toggleLocked(video.id, video.locked)}
disabled={togglingId === video.id}
aria-label="Toggle lock status"
/>
</TableCell>
<TableCell className="text-center">
<Switch
checked={video.instantAccess}
onCheckedChange={() => toggleInstantAccess(video.id, video.instantAccess)}
disabled={togglingId === video.id}
aria-label="Toggle instant access"
/>
</TableCell>
<TableCell className="text-right">
<div className="flex gap-1 justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/admin/videos/${video.id}/edit`)}
>
<Edit className="w-4 h-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={deletingId === video.id}
>
<Trash2Icon className="w-4 h-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Video</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{video.title}"?
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-2 justify-end">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteVideo(video.id)}
>
Delete
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
export default function VideosAdminClient() {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<VideosUI />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+22
View File
@@ -0,0 +1,22 @@
// app/admin/videos/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { redirect } from 'next/navigation';
import VideosAdminClient from './admin-client';
export const dynamic = 'force-dynamic';
export default async function VideosAdminPage() {
const session = await getServerSession(authOptions);
const role = (session as any)?.user?.role ?? null;
if (!session?.user) {
redirect('/login');
}
if (!(role === 'admin' || role === 'superadmin')) {
redirect('/dashboard');
}
return <VideosAdminClient />;
}