Add homework system (admin, student, curator)

Admin:
- HomeworkEditor in lesson page: create/update/delete assignment description

Student:
- HomeworkSection in lesson page: view assignment, submit text + files
- Resubmission allowed until curator gives feedback
- Shows feedback from curator with date and name

Curator:
- New layout with Second Brain dark sidebar (replaces green theme)
- /curator/dashboard: stats cards (pending, total, reviewed this week)
- /curator/homework: list of all submissions, pending highlighted
- /curator/homework/[id]: review submission, write feedback, redirect after send

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 14:13:24 +05:00
parent d0c8c6dd53
commit 543d5b2d5e
13 changed files with 836 additions and 31 deletions
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { useState, useTransition } from "react";
import { saveHomework, deleteHomework } from "@/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/homework-actions";
interface Props {
lessonId: string;
initial: { id: string; description: string } | null;
}
export function HomeworkEditor({ lessonId, initial }: Props) {
const [editing, setEditing] = useState(!initial);
const [text, setText] = useState(initial?.description ?? "");
const [saved, setSaved] = useState(false);
const [pending, startTransition] = useTransition();
const inputStyle = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
width: "100%",
padding: "0.5rem 0.75rem",
fontSize: "0.875rem",
fontFamily: "inherit",
resize: "vertical" as const,
minHeight: "120px",
};
function handleSave() {
if (!text.trim()) return;
startTransition(async () => {
await saveHomework(lessonId, text.trim());
setSaved(true);
setEditing(false);
setTimeout(() => setSaved(false), 2000);
});
}
function handleDelete() {
if (!confirm("Удалить домашнее задание? Все сданные работы будут удалены.")) return;
startTransition(async () => {
await deleteHomework(lessonId);
setText("");
setEditing(true);
});
}
if (!editing && initial) {
return (
<div className="space-y-3">
<div className="px-4 py-3 text-sm whitespace-pre-wrap" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
{text || initial.description}
</div>
<div className="flex gap-2">
<button onClick={() => setEditing(true)} className="btn-aubade text-xs px-3 py-1.5">
Редактировать
</button>
<button onClick={handleDelete} disabled={pending} className="text-xs px-3 py-1.5" style={{ color: "oklch(0.577 0.245 27.325)" }}>
Удалить ДЗ
</button>
{saved && <span className="text-xs self-center" style={{ color: "var(--muted-foreground)" }}> Сохранено</span>}
</div>
</div>
);
}
return (
<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)")}
/>
<div className="flex items-center gap-2">
<button
onClick={handleSave}
disabled={pending || !text.trim()}
className="btn-aubade btn-aubade-accent text-xs px-4 py-1.5"
style={{ opacity: pending || !text.trim() ? 0.6 : 1 }}
>
{pending ? "Сохранение..." : "Сохранить задание"}
</button>
{initial && (
<button onClick={() => { setEditing(false); setText(initial.description); }} className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Отмена
</button>
)}
</div>
</div>
);
}
+195
View File
@@ -0,0 +1,195 @@
"use client";
import { useState, useTransition } from "react";
import { submitHomework } from "@/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions";
interface HWFile { name: string; url: string; size: number }
interface Feedback {
id: string;
text: string;
createdAt: Date;
curator: { name: string };
}
interface Submission {
id: string;
text: string | null;
files: HWFile[];
submittedAt: Date;
feedbacks: Feedback[];
}
interface Props {
homework: { id: string; description: string };
submission: Submission | null;
slug: string;
lessonId: string;
}
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 }: Props) {
const [text, setText] = useState(submission?.text ?? "");
const [files, setFiles] = useState<HWFile[]>(submission?.files ?? []);
const [uploading, setUploading] = useState(false);
const [pending, startTransition] = useTransition();
const [editing, setEditing] = useState(!submission);
const isReviewed = submission && submission.feedbacks.length > 0;
const inputStyle = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
width: "100%",
padding: "0.5rem 0.75rem",
fontSize: "0.875rem",
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));
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 || "—"}
</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>
</div>
<p className="text-sm whitespace-pre-wrap">{fb.text}</p>
</div>
))}
</div>
)}
{/* Submitted, pending review */}
{submission && !isReviewed && !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)" }}>
{submission.text}
</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 && !isReviewed && (
<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>
)}
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={handleSubmit}
disabled={pending || (!text.trim() && files.length === 0)}
className="btn-aubade btn-aubade-accent text-sm px-5 py-2"
style={{ opacity: pending || (!text.trim() && files.length === 0) ? 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>
);
}