d2094c9541
Apply linkify() to student submission text (curator review page + student cabinet: reviewed/approved/pending) and to curator feedback text, so pasted links (e.g. Yandex.Disk) render as clickable anchors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
285 lines
12 KiB
TypeScript
285 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useTransition } from "react";
|
|
import { submitHomework } from "@/lib/actions/student-actions";
|
|
import { AudioRecorder } from "@/components/curator/audio-recorder";
|
|
import { linkify } from "@/lib/linkify";
|
|
|
|
interface HWFile { name: string; url: string; size: number }
|
|
|
|
interface Feedback {
|
|
id: string;
|
|
text: string;
|
|
files?: HWFile[];
|
|
audioUrl?: string | null;
|
|
createdAt: Date;
|
|
curator: { name: string };
|
|
}
|
|
|
|
interface Submission {
|
|
id: string;
|
|
text: string | null;
|
|
files: HWFile[];
|
|
audioUrl?: string | null;
|
|
submittedAt: Date;
|
|
status: string;
|
|
feedbacks: Feedback[];
|
|
}
|
|
|
|
interface Props {
|
|
homework: { id: string; description: string };
|
|
submission: Submission | null;
|
|
slug: string;
|
|
lessonId: string;
|
|
allowAudio?: boolean;
|
|
}
|
|
|
|
function formatSize(bytes: number) {
|
|
if (bytes < 1024) return `${bytes} Б`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
|
|
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
|
|
}
|
|
|
|
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);
|
|
|
|
const isReviewed = submission && submission.feedbacks.length > 0;
|
|
const isApproved = submission?.status === "APPROVED";
|
|
// Финализировано: есть фидбек ИЛИ ДЗ принято кнопкой без ответа. Нельзя пересдавать.
|
|
const isLocked = Boolean(isReviewed || isApproved);
|
|
|
|
const inputStyle = {
|
|
border: "2px solid var(--border)",
|
|
background: "var(--background)",
|
|
outline: "none",
|
|
width: "100%",
|
|
padding: "0.5rem 0.75rem",
|
|
fontSize: "16px",
|
|
fontFamily: "inherit",
|
|
resize: "vertical" as const,
|
|
minHeight: "140px",
|
|
};
|
|
|
|
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
setUploading(true);
|
|
const fd = new FormData();
|
|
fd.append("file", file);
|
|
const res = await fetch("/api/student/homework-upload", { method: "POST", body: fd });
|
|
const data = await res.json();
|
|
if (data.url) setFiles((prev) => [...prev, data]);
|
|
setUploading(false);
|
|
e.target.value = "";
|
|
}
|
|
|
|
function removeFile(url: string) {
|
|
setFiles((prev) => prev.filter((f) => f.url !== url));
|
|
}
|
|
|
|
function handleSubmit() {
|
|
startTransition(() => submitHomework(homework.id, slug, lessonId, text, files, audioUrl));
|
|
setEditing(false);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Assignment description */}
|
|
<div className="px-4 py-3 text-sm whitespace-pre-wrap" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
|
|
{homework.description}
|
|
</div>
|
|
|
|
{/* Submitted & reviewed */}
|
|
{isReviewed && (
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>Ваш ответ</p>
|
|
<div className="px-4 py-3 text-sm whitespace-pre-wrap opacity-70" style={{ border: "2px solid var(--border)" }}>
|
|
{submission!.text ? linkify(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>
|
|
)}
|
|
</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">{linkify(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>
|
|
)}
|
|
|
|
{/* Принято без письменного ответа */}
|
|
{isApproved && !isReviewed && submission && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs px-2 py-0.5 font-bold uppercase tracking-widest" style={{ border: "2px solid var(--foreground)", background: "var(--accent)" }}>
|
|
ДЗ принято ✓
|
|
</span>
|
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
|
Сдано {new Date(submission.submittedAt).toLocaleDateString("ru-RU")}
|
|
</span>
|
|
</div>
|
|
{submission.text && (
|
|
<div className="px-4 py-3 text-sm whitespace-pre-wrap opacity-70" style={{ border: "2px solid var(--border)" }}>
|
|
{linkify(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) => (
|
|
<a key={f.url} href={f.url} target="_blank" rel="noopener noreferrer"
|
|
className="flex items-center gap-2 px-3 py-2 text-xs" style={{ border: "2px solid var(--border)" }}>
|
|
<span>📎</span>
|
|
<span className="flex-1 underline">{f.name}</span>
|
|
<span style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span>
|
|
</a>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Submitted, pending review */}
|
|
{submission && !isLocked && !editing && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs px-2 py-0.5 font-bold uppercase tracking-widest" style={{ border: "2px solid var(--border)", background: "var(--color-surface)", color: "var(--muted-foreground)" }}>
|
|
На проверке
|
|
</span>
|
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
|
Сдано {new Date(submission.submittedAt).toLocaleDateString("ru-RU")}
|
|
</span>
|
|
</div>
|
|
<button onClick={() => setEditing(true)} className="text-xs underline" style={{ color: "var(--muted-foreground)" }}>
|
|
Изменить
|
|
</button>
|
|
</div>
|
|
{submission.text && (
|
|
<div className="px-4 py-3 text-sm whitespace-pre-wrap" style={{ border: "2px solid var(--border)" }}>
|
|
{linkify(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) => (
|
|
<a key={f.url} href={f.url} target="_blank" rel="noopener noreferrer"
|
|
className="flex items-center gap-2 px-3 py-2 text-xs"
|
|
style={{ border: "2px solid var(--border)" }}>
|
|
<span>📎</span>
|
|
<span className="flex-1 underline">{f.name}</span>
|
|
<span style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span>
|
|
</a>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Form */}
|
|
{editing && !isLocked && (
|
|
<div className="space-y-3">
|
|
<textarea
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value)}
|
|
style={inputStyle}
|
|
placeholder="Напишите ваш ответ..."
|
|
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
|
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
|
/>
|
|
|
|
{/* File list */}
|
|
{files.length > 0 && (
|
|
<div className="space-y-1">
|
|
{files.map((f) => (
|
|
<div key={f.url} className="flex items-center gap-2 px-3 py-2 text-xs" style={{ border: "2px solid var(--border)" }}>
|
|
<span>📎</span>
|
|
<span className="flex-1">{f.name}</span>
|
|
<span style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span>
|
|
<button onClick={() => removeFile(f.url)} style={{ color: "oklch(0.577 0.245 27.325)" }}>✕</button>
|
|
</div>
|
|
))}
|
|
</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 && !audioUrl)}
|
|
className="btn-aubade btn-aubade-accent text-sm px-5 py-2"
|
|
style={{ opacity: pending || (!text.trim() && files.length === 0 && !audioUrl) ? 0.6 : 1 }}
|
|
>
|
|
{pending ? "Отправка..." : submission ? "Обновить ответ" : "Сдать работу"}
|
|
</button>
|
|
<label className="btn-aubade text-xs px-3 py-2 cursor-pointer">
|
|
{uploading ? "Загрузка..." : "📎 Прикрепить файл"}
|
|
<input type="file" className="sr-only" onChange={handleFileUpload} disabled={uploading} />
|
|
</label>
|
|
{submission && (
|
|
<button onClick={() => setEditing(false)} className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
|
Отмена
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|