Add homework review workflow: statuses, audio, file attachments, tabs

- HomeworkSubmission: add status (PENDING/REVIEWING/APPROVED/REJECTED) + statusAt
- HomeworkFeedback: add files (Json) + audioUrl fields
- Curator detail page: meta table, content tabs, feedback history with audio/files
- FeedbackForm: file upload, audio recorder (Web Audio API + S3), action buttons
- AudioRecorder component: record → preview → upload to S3
- ContentTabs: toggle between homework description and lesson content (TipTap read-only)
- Homework list: 4-color status badges with proper filtering
- API routes: /api/curator/upload and /api/curator/audio-upload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 14:01:55 +05:00
parent 768a38b9d3
commit 3855bbd4be
15 changed files with 743 additions and 98 deletions
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { useState, useRef } from "react";
interface AudioRecorderProps {
value: string | null;
onChange: (url: string | null) => void;
}
export function AudioRecorder({ value, onChange }: AudioRecorderProps) {
const [state, setState] = useState<"idle" | "recording" | "recorded" | "uploading">("idle");
const [localUrl, setLocalUrl] = useState<string | null>(null);
const mediaRecorder = useRef<MediaRecorder | null>(null);
const chunks = useRef<Blob[]>([]);
const mimeType = useRef<string>("audio/webm");
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mr = new MediaRecorder(stream);
mimeType.current = mr.mimeType || "audio/webm";
chunks.current = [];
mr.ondataavailable = (e) => {
if (e.data.size > 0) chunks.current.push(e.data);
};
mr.onstop = () => {
const blob = new Blob(chunks.current, { type: mimeType.current });
setLocalUrl(URL.createObjectURL(blob));
setState("recorded");
stream.getTracks().forEach((t) => t.stop());
};
mediaRecorder.current = mr;
mr.start();
setState("recording");
} catch {
alert("Нет доступа к микрофону");
}
}
function stopRecording() {
mediaRecorder.current?.stop();
}
async function uploadAudio() {
setState("uploading");
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 data = await res.json();
if (data.url) {
onChange(data.url);
}
setState("idle");
}
function reset() {
setLocalUrl(null);
chunks.current = [];
setState("idle");
}
if (value) {
return (
<div className="flex items-center gap-3">
<span className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Аудио-ответ:
</span>
<audio controls src={value} style={{ height: 32 }} />
<button
type="button"
onClick={() => onChange(null)}
className="text-xs"
style={{ color: "oklch(0.577 0.245 27.325)" }}
>
Удалить
</button>
</div>
);
}
return (
<div className="flex items-center gap-2 flex-wrap">
{state === "idle" && (
<button
type="button"
onClick={startRecording}
className="btn-aubade text-xs px-3 py-1.5 flex items-center gap-1.5"
>
🎤 Записать аудио-ответ
</button>
)}
{state === "recording" && (
<button
type="button"
onClick={stopRecording}
className="btn-aubade-accent text-xs px-3 py-1.5 flex items-center gap-1.5 animate-pulse"
>
Остановить запись
</button>
)}
{state === "recorded" && localUrl && (
<div className="flex items-center gap-2">
<audio controls src={localUrl} style={{ height: 32 }} />
<button
type="button"
onClick={uploadAudio}
className="btn-aubade text-xs px-3 py-1.5"
>
Загрузить
</button>
<button
type="button"
onClick={reset}
className="text-xs"
style={{ color: "var(--muted-foreground)" }}
>
Перезаписать
</button>
</div>
)}
{state === "uploading" && (
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Загрузка аудио...
</span>
)}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Underline from "@tiptap/extension-underline";
export function ContentViewer({ content }: { content: unknown }) {
const editor = useEditor({
extensions: [StarterKit, Link, Underline],
content: content as object,
editable: false,
immediatelyRender: false,
});
if (!content) {
return (
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>
Контент урока не добавлен
</p>
);
}
return (
<div
className="prose prose-sm max-w-none text-sm"
style={{ color: "var(--foreground)" }}
>
<EditorContent editor={editor} />
</div>
);
}