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