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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user