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
+539
View File
@@ -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>
);
}