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}>
+3 -2
View File
@@ -5,9 +5,10 @@ import { useState, useRef } from "react";
interface AudioRecorderProps {
value: string | null;
onChange: (url: string | null) => void;
uploadUrl?: string;
}
export function AudioRecorder({ value, onChange }: AudioRecorderProps) {
export function AudioRecorder({ value, onChange, uploadUrl = "/api/curator/audio-upload" }: AudioRecorderProps) {
const [state, setState] = useState<"idle" | "recording" | "recorded" | "uploading">("idle");
const [localUrl, setLocalUrl] = useState<string | null>(null);
const mediaRecorder = useRef<MediaRecorder | null>(null);
@@ -46,7 +47,7 @@ export function AudioRecorder({ value, onChange }: AudioRecorderProps) {
const blob = new Blob(chunks.current, { type: mimeType.current });
const form = new FormData();
form.append("file", blob, "audio-response.webm");
const res = await fetch("/api/curator/audio-upload", { method: "POST", body: form });
const res = await fetch(uploadUrl, { method: "POST", body: form });
const data = await res.json();
if (data.url) {
onChange(data.url);
+62 -15
View File
@@ -2,12 +2,15 @@
import { useState, useTransition } from "react";
import { submitHomework } from "@/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions";
import { AudioRecorder } from "@/components/curator/audio-recorder";
interface HWFile { name: string; url: string; size: number }
interface Feedback {
id: string;
text: string;
files?: HWFile[];
audioUrl?: string | null;
createdAt: Date;
curator: { name: string };
}
@@ -16,6 +19,7 @@ interface Submission {
id: string;
text: string | null;
files: HWFile[];
audioUrl?: string | null;
submittedAt: Date;
feedbacks: Feedback[];
}
@@ -25,6 +29,7 @@ interface Props {
submission: Submission | null;
slug: string;
lessonId: string;
allowAudio?: boolean;
}
function formatSize(bytes: number) {
@@ -33,9 +38,10 @@ function formatSize(bytes: number) {
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
}
export function HomeworkSection({ homework, submission, slug, lessonId }: Props) {
export function HomeworkSection({ homework, submission, slug, lessonId, allowAudio = false }: Props) {
const [text, setText] = useState(submission?.text ?? "");
const [files, setFiles] = useState<HWFile[]>(submission?.files ?? []);
const [audioUrl, setAudioUrl] = useState<string | null>(submission?.audioUrl ?? null);
const [uploading, setUploading] = useState(false);
const [pending, startTransition] = useTransition();
const [editing, setEditing] = useState(!submission);
@@ -72,7 +78,7 @@ export function HomeworkSection({ homework, submission, slug, lessonId }: Props)
}
function handleSubmit() {
startTransition(() => submitHomework(homework.id, slug, lessonId, text, files));
startTransition(() => submitHomework(homework.id, slug, lessonId, text, files, audioUrl));
setEditing(false);
}
@@ -91,18 +97,44 @@ export function HomeworkSection({ homework, submission, slug, lessonId }: Props)
<div className="px-4 py-3 text-sm whitespace-pre-wrap opacity-70" style={{ border: "2px solid var(--border)" }}>
{submission!.text || "—"}
</div>
</div>
{submission!.feedbacks.map((fb) => (
<div key={fb.id} className="px-4 py-3 space-y-1" style={{ border: "2px solid var(--foreground)", background: "var(--accent)" }}>
<div className="flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-widest">Обратная связь</p>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{fb.curator.name} · {new Date(fb.createdAt).toLocaleDateString("ru-RU")}
</span>
{submission!.audioUrl && (
<div className="px-4 py-2" style={{ border: "1px solid var(--border)" }}>
<p className="text-xs mb-1" style={{ color: "var(--muted-foreground)" }}>Аудио-ответ:</p>
<audio controls src={submission!.audioUrl} style={{ height: 32, width: "100%" }} />
</div>
<p className="text-sm whitespace-pre-wrap">{fb.text}</p>
</div>
))}
)}
</div>
{submission!.feedbacks.map((fb) => {
const fbFiles = fb.files ?? [];
return (
<div key={fb.id} className="px-4 py-3 space-y-2" style={{ border: "2px solid var(--foreground)", background: "var(--accent)" }}>
<div className="flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-widest">Обратная связь</p>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{fb.curator.name} · {new Date(fb.createdAt).toLocaleDateString("ru-RU")}
</span>
</div>
<p className="text-sm whitespace-pre-wrap">{fb.text}</p>
{fbFiles.length > 0 && (
<div className="space-y-1">
{fbFiles.map((f) => (
<a key={f.url} href={f.url} target="_blank" rel="noopener noreferrer"
className="flex items-center gap-2 text-xs underline">
<span>📎</span><span>{f.name}</span>
<span style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span>
</a>
))}
</div>
)}
{fb.audioUrl && (
<div>
<p className="text-xs mb-1" style={{ color: "var(--muted-foreground)" }}>Аудио от куратора:</p>
<audio controls src={fb.audioUrl} style={{ height: 32 }} />
</div>
)}
</div>
);
})}
</div>
)}
@@ -127,6 +159,12 @@ export function HomeworkSection({ homework, submission, slug, lessonId }: Props)
{submission.text}
</div>
)}
{submission.audioUrl && (
<div className="px-4 py-2" style={{ border: "1px solid var(--border)" }}>
<p className="text-xs mb-1" style={{ color: "var(--muted-foreground)" }}>Аудио-ответ:</p>
<audio controls src={submission.audioUrl} style={{ height: 32, width: "100%" }} />
</div>
)}
{submission.files.length > 0 && (
<div className="space-y-1">
{submission.files.map((f) => (
@@ -169,12 +207,21 @@ export function HomeworkSection({ homework, submission, slug, lessonId }: Props)
</div>
)}
{/* Audio recorder */}
{allowAudio && (
<AudioRecorder
value={audioUrl}
onChange={setAudioUrl}
uploadUrl="/api/student/audio-upload"
/>
)}
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={handleSubmit}
disabled={pending || (!text.trim() && files.length === 0)}
disabled={pending || (!text.trim() && files.length === 0 && !audioUrl)}
className="btn-aubade btn-aubade-accent text-sm px-5 py-2"
style={{ opacity: pending || (!text.trim() && files.length === 0) ? 0.6 : 1 }}
style={{ opacity: pending || (!text.trim() && files.length === 0 && !audioUrl) ? 0.6 : 1 }}
>
{pending ? "Отправка..." : submission ? "Обновить ответ" : "Сдать работу"}
</button>