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