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:
@@ -64,8 +64,10 @@ export function HomeworkFilters({ courses }: { courses: Course[] }) {
|
||||
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||
>
|
||||
<option value="">Все статусы</option>
|
||||
<option value="pending">Ожидают проверки</option>
|
||||
<option value="reviewed">Проверено</option>
|
||||
<option value="pending">Новые</option>
|
||||
<option value="reviewing">На рассмотрении</option>
|
||||
<option value="approved">Одобрено</option>
|
||||
<option value="rejected">Отклонено</option>
|
||||
</select>
|
||||
|
||||
{/* Course */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user