Initial commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user