Add optional audio response for students in homework submissions

- 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>
This commit is contained in:
2026-04-08 14:17:46 +05:00
parent 3855bbd4be
commit 48a9398905
10 changed files with 152 additions and 33 deletions
+30 -11
View File
@@ -14,6 +14,7 @@ interface Course {
description: string | null;
coverImage: string | null;
published: boolean;
allowAudio: boolean;
categoryId: string | null;
}
@@ -24,6 +25,7 @@ interface Category {
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);
@@ -45,6 +47,7 @@ export function CourseEditForm({ course, categories = [] }: { course: Course; ca
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));
@@ -99,17 +102,33 @@ export function CourseEditForm({ course, categories = [] }: { course: Course; ca
{uploading && <span className="text-sm text-slate-400">Загрузка...</span>}
</div>
</div>
<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 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}>