48a9398905
- Course: add allowAudio toggle (per-course setting, off by default) - HomeworkSubmission: add audioUrl field - Student: AudioRecorder in homework form when allowAudio is enabled - Student: show audio player in submission view and curator feedback view - Curator: show student audio on submission detail page - AudioRecorder: accept uploadUrl prop (reused for student/curator) - API: /api/student/audio-upload route for S3 upload Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
144 lines
5.7 KiB
TypeScript
144 lines
5.7 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useTransition } from "react";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { Button } from "@/components/ui/button";
|
||
import { updateCourse, deleteCourse } from "@/app/admin/courses/actions";
|
||
|
||
interface Course {
|
||
id: string;
|
||
title: string;
|
||
slug: string;
|
||
description: string | null;
|
||
coverImage: string | null;
|
||
published: boolean;
|
||
allowAudio: boolean;
|
||
categoryId: string | null;
|
||
}
|
||
|
||
interface Category {
|
||
id: string;
|
||
title: string;
|
||
}
|
||
|
||
export function CourseEditForm({ course, categories = [] }: { course: Course; categories?: Category[] }) {
|
||
const [published, setPublished] = useState(course.published);
|
||
const [allowAudio, setAllowAudio] = useState(course.allowAudio);
|
||
const [coverImage, setCoverImage] = useState(course.coverImage ?? "");
|
||
const [categoryId, setCategoryId] = useState(course.categoryId ?? "");
|
||
const [uploading, setUploading] = useState(false);
|
||
const [pending, startTransition] = useTransition();
|
||
|
||
async function handleImageUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
setUploading(true);
|
||
const fd = new FormData();
|
||
fd.append("file", file);
|
||
const res = await fetch("/api/admin/upload", { method: "POST", body: fd });
|
||
const data = await res.json();
|
||
if (data.url) setCoverImage(data.url);
|
||
setUploading(false);
|
||
}
|
||
|
||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||
e.preventDefault();
|
||
const fd = new FormData(e.currentTarget);
|
||
fd.set("published", String(published));
|
||
fd.set("allowAudio", String(allowAudio));
|
||
fd.set("coverImage", coverImage);
|
||
fd.set("categoryId", categoryId);
|
||
startTransition(() => updateCourse(course.id, fd));
|
||
}
|
||
|
||
function handleDelete() {
|
||
if (!confirm("Удалить курс? Это действие нельзя отменить.")) return;
|
||
startTransition(() => deleteCourse(course.id));
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit} className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="title">Название</Label>
|
||
<Input id="title" name="title" defaultValue={course.title} required />
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="slug">Slug</Label>
|
||
<Input id="slug" name="slug" defaultValue={course.slug} required />
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="description">Описание</Label>
|
||
<Textarea id="description" name="description" defaultValue={course.description ?? ""} rows={3} />
|
||
</div>
|
||
{categories.length > 0 && (
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="categoryId">Категория</Label>
|
||
<select
|
||
id="categoryId"
|
||
value={categoryId}
|
||
onChange={(e) => setCategoryId(e.target.value)}
|
||
className="w-full px-3 py-2 text-sm bg-transparent"
|
||
style={{ border: "2px solid var(--border)", color: "var(--foreground)", fontFamily: "var(--font-sans)" }}
|
||
>
|
||
<option value="">Без категории</option>
|
||
{categories.map((c) => (
|
||
<option key={c.id} value={c.id}>{c.title}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
<div className="space-y-1.5">
|
||
<Label>Обложка</Label>
|
||
<div className="flex items-center gap-3">
|
||
{coverImage && (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img src={coverImage} alt="cover" className="w-16 h-10 object-cover rounded-md border" />
|
||
)}
|
||
<Input type="file" accept="image/*" onChange={handleImageUpload} disabled={uploading} className="max-w-xs" />
|
||
{uploading && <span className="text-sm text-slate-400">Загрузка...</span>}
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={published}
|
||
onClick={() => setPublished(!published)}
|
||
className={`relative w-10 h-6 rounded-full transition-colors ${published ? "bg-green-500" : "bg-slate-300"}`}
|
||
>
|
||
<span className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${published ? "translate-x-4" : ""}`} />
|
||
</button>
|
||
<span className="text-sm text-slate-600">{published ? "Опубликован" : "Черновик"}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={allowAudio}
|
||
onClick={() => setAllowAudio(!allowAudio)}
|
||
className={`relative w-10 h-6 rounded-full transition-colors ${allowAudio ? "bg-blue-500" : "bg-slate-300"}`}
|
||
>
|
||
<span className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${allowAudio ? "translate-x-4" : ""}`} />
|
||
</button>
|
||
<span className="text-sm text-slate-600">
|
||
{allowAudio ? "🎤 Аудио-ответ в ДЗ включён" : "Аудио-ответ в ДЗ выключен"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex justify-between pt-2">
|
||
<Button type="button" variant="destructive" onClick={handleDelete} disabled={pending}>
|
||
Удалить курс
|
||
</Button>
|
||
<Button type="submit" disabled={pending || uploading}>
|
||
{pending ? "Сохранение..." : "Сохранить"}
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
);
|
||
}
|