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:
@@ -0,0 +1,46 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
interface HomeworkFile {
|
||||
name: string;
|
||||
url: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export async function submitHomework(
|
||||
homeworkId: string,
|
||||
slug: string,
|
||||
lessonId: string,
|
||||
text: string,
|
||||
files: HomeworkFile[]
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) throw new Error("Unauthorized");
|
||||
|
||||
const existing = await prisma.homeworkSubmission.findFirst({
|
||||
where: { homeworkId, userId: session.user.id },
|
||||
include: { feedbacks: true },
|
||||
});
|
||||
|
||||
// Don't allow resubmission if feedback already given
|
||||
if (existing?.feedbacks && existing.feedbacks.length > 0) {
|
||||
throw new Error("Работа уже проверена");
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await prisma.homeworkSubmission.update({
|
||||
where: { id: existing.id },
|
||||
data: { text, files: files as object[], submittedAt: new Date() },
|
||||
});
|
||||
} else {
|
||||
await prisma.homeworkSubmission.create({
|
||||
data: { homeworkId, userId: session.user.id, text, files: files as object[] },
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { auth } from "@/lib/auth";
|
||||
import { KinescopePlayer } from "@/components/player/kinescope-player";
|
||||
import { LessonContent } from "@/components/student/lesson-content";
|
||||
import { LessonCompleteButton } from "@/components/student/lesson-complete-button";
|
||||
import { HomeworkSection } from "@/components/student/homework-section";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ slug: string; lessonId: string }>;
|
||||
@@ -22,6 +23,7 @@ export default async function LessonPage({ params }: Props) {
|
||||
where: { id: lessonId, ...(isAdmin ? {} : { published: true }) },
|
||||
include: {
|
||||
files: { orderBy: { createdAt: "asc" } },
|
||||
homework: true,
|
||||
module: {
|
||||
include: {
|
||||
course: {
|
||||
@@ -49,6 +51,14 @@ export default async function LessonPage({ params }: Props) {
|
||||
: null,
|
||||
]);
|
||||
|
||||
// Fetch homework submission for this student
|
||||
const homeworkSubmission = lesson?.homework && session && !isAdmin
|
||||
? await prisma.homeworkSubmission.findFirst({
|
||||
where: { homeworkId: lesson.homework.id, userId: session.user.id },
|
||||
include: { feedbacks: { include: { curator: { select: { name: true } } }, orderBy: { createdAt: "desc" } } },
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!lesson || lesson.module.course.slug !== slug) notFound();
|
||||
|
||||
const isCompleted = !!progress;
|
||||
@@ -118,6 +128,24 @@ export default async function LessonPage({ params }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Homework */}
|
||||
{lesson.homework && !isAdmin && (
|
||||
<div className="mb-8">
|
||||
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
||||
Домашнее задание
|
||||
</p>
|
||||
<HomeworkSection
|
||||
homework={lesson.homework}
|
||||
submission={homeworkSubmission ? {
|
||||
...homeworkSubmission,
|
||||
files: (homeworkSubmission.files as { name: string; url: string; size: number }[]) ?? [],
|
||||
} : null}
|
||||
slug={slug}
|
||||
lessonId={lessonId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Complete button + Prev/Next navigation */}
|
||||
<div
|
||||
className="flex items-center justify-between pt-6 mt-6"
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
|
||||
}
|
||||
|
||||
export async function saveHomework(lessonId: string, description: string) {
|
||||
await requireAdmin();
|
||||
await prisma.homework.upsert({
|
||||
where: { lessonId },
|
||||
update: { description },
|
||||
create: { lessonId, description },
|
||||
});
|
||||
revalidatePath(`/admin/courses`);
|
||||
}
|
||||
|
||||
export async function deleteHomework(lessonId: string) {
|
||||
await requireAdmin();
|
||||
await prisma.homework.delete({ where: { lessonId } });
|
||||
revalidatePath(`/admin/courses`);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { LessonEditor } from "@/components/admin/lesson-editor";
|
||||
import { LessonFilesManager } from "@/components/admin/lesson-files-manager";
|
||||
import { HomeworkEditor } from "@/components/admin/homework-editor";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ courseId: string; moduleId: string; lessonId: string }>;
|
||||
@@ -15,6 +16,7 @@ export default async function LessonEditorPage({ params }: Props) {
|
||||
where: { id: lessonId },
|
||||
include: {
|
||||
files: { orderBy: { createdAt: "asc" } },
|
||||
homework: true,
|
||||
module: {
|
||||
include: { course: { select: { title: true, slug: true } } },
|
||||
},
|
||||
@@ -52,12 +54,20 @@ export default async function LessonEditorPage({ params }: Props) {
|
||||
</div>
|
||||
|
||||
{/* Files section */}
|
||||
<div className="card-aubade p-6">
|
||||
<div className="card-aubade p-6 mb-6">
|
||||
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
|
||||
Файлы и материалы
|
||||
</p>
|
||||
<LessonFilesManager lessonId={lessonId} initialFiles={lesson.files} />
|
||||
</div>
|
||||
|
||||
{/* Homework section */}
|
||||
<div className="card-aubade p-6">
|
||||
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
|
||||
Домашнее задание
|
||||
</p>
|
||||
<HomeworkEditor lessonId={lessonId} initial={lesson.homework} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { uploadFile } from "@/lib/s3";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const form = await req.formData();
|
||||
const file = form.get("file") as File | null;
|
||||
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
|
||||
|
||||
const ext = file.name.split(".").pop() ?? "bin";
|
||||
const key = `homework/${session.user.id}/${randomUUID()}.${ext}`;
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const url = await uploadFile(key, buffer, file.type);
|
||||
|
||||
return NextResponse.json({ name: file.name, url, size: file.size });
|
||||
}
|
||||
@@ -1,43 +1,57 @@
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { LogoutButton } from "@/components/layout/logout-button";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function CuratorDashboard() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session) redirect("/login");
|
||||
if (session.user.role !== "curator" && session.user.role !== "admin") {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
const [pending, total, recentFeedbacks] = await Promise.all([
|
||||
prisma.homeworkSubmission.count({ where: { feedbacks: { none: {} } } }),
|
||||
prisma.homeworkSubmission.count(),
|
||||
prisma.homeworkFeedback.count({
|
||||
where: {
|
||||
createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
|
||||
curatorId: session.user.id,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-green-50">
|
||||
<header className="bg-white border-b border-green-100 px-6 py-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-green-900">Second Brain — Куратор</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">{session.user.name}</span>
|
||||
<LogoutButton />
|
||||
<div className="p-8 max-w-3xl">
|
||||
<h1 className="text-2xl font-bold mb-1">Обзор</h1>
|
||||
<p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>Панель куратора</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||
<StatCard label="Ожидают проверки" value={pending} accent={pending > 0} />
|
||||
<StatCard label="Всего сдано" value={total} />
|
||||
<StatCard label="Проверено за 7 дней" value={recentFeedbacks} />
|
||||
</div>
|
||||
|
||||
{pending > 0 ? (
|
||||
<Link href="/curator/homework" className="btn-aubade btn-aubade-accent inline-flex items-center gap-2 px-5 py-2.5 text-sm">
|
||||
Перейти к проверке ({pending}) →
|
||||
</Link>
|
||||
) : (
|
||||
<div className="card-aubade p-8 text-center">
|
||||
<p className="text-3xl mb-2">✓</p>
|
||||
<p className="font-bold">Все работы проверены</p>
|
||||
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>Новых заданий нет</p>
|
||||
</div>
|
||||
</header>
|
||||
<main className="max-w-4xl mx-auto px-6 py-10">
|
||||
<h2 className="text-2xl font-semibold text-gray-800 mb-2">
|
||||
Панель куратора
|
||||
</h2>
|
||||
<p className="text-gray-500 mb-8">Здесь будут домашние задания на проверку.</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white rounded-2xl border border-green-100 p-6">
|
||||
<p className="text-3xl mb-2">📝</p>
|
||||
<p className="font-medium text-gray-800">Домашние задания</p>
|
||||
<p className="text-sm text-gray-400 mt-1">Новых заданий нет</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-green-100 p-6">
|
||||
<p className="text-3xl mb-2">👥</p>
|
||||
<p className="font-medium text-gray-800">Мои ученики</p>
|
||||
<p className="text-sm text-gray-400 mt-1">Откроется в Этапе 3</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
|
||||
return (
|
||||
<div className="card-aubade p-4">
|
||||
<p className="text-3xl font-bold" style={{ color: accent ? "oklch(0.577 0.245 27.325)" : "var(--foreground)" }}>
|
||||
{value}
|
||||
</p>
|
||||
<p className="text-xs mt-1 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function submitFeedback(submissionId: string, text: string) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session || (session.user.role !== "curator" && session.user.role !== "admin")) {
|
||||
throw new Error("Forbidden");
|
||||
}
|
||||
|
||||
await prisma.homeworkFeedback.create({
|
||||
data: { submissionId, curatorId: session.user.id, text },
|
||||
});
|
||||
|
||||
revalidatePath("/curator/homework");
|
||||
revalidatePath(`/curator/homework/${submissionId}`);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { submitFeedback } from "./actions";
|
||||
|
||||
export function FeedbackForm({ submissionId }: { submissionId: string }) {
|
||||
const [text, setText] = useState("");
|
||||
const [pending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!text.trim()) return;
|
||||
startTransition(async () => {
|
||||
await submitFeedback(submissionId, text.trim());
|
||||
router.push("/curator/homework");
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||
Написать фидбек
|
||||
</p>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
required
|
||||
placeholder="Напишите обратную связь студенту..."
|
||||
style={{
|
||||
border: "2px solid var(--border)",
|
||||
background: "var(--background)",
|
||||
outline: "none",
|
||||
width: "100%",
|
||||
padding: "0.5rem 0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
fontFamily: "inherit",
|
||||
resize: "vertical",
|
||||
minHeight: "120px",
|
||||
}}
|
||||
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending || !text.trim()}
|
||||
className="btn-aubade btn-aubade-accent px-5 py-2 text-sm"
|
||||
style={{ opacity: pending || !text.trim() ? 0.6 : 1 }}
|
||||
>
|
||||
{pending ? "Отправка..." : "Отправить фидбек"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { FeedbackForm } from "./feedback-form";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ submissionId: 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 default async function SubmissionPage({ params }: Props) {
|
||||
const { submissionId } = await params;
|
||||
|
||||
const submission = await prisma.homeworkSubmission.findUnique({
|
||||
where: { id: submissionId },
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
feedbacks: {
|
||||
include: { curator: { select: { name: true } } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
homework: {
|
||||
include: {
|
||||
lesson: {
|
||||
select: {
|
||||
title: true,
|
||||
module: { select: { title: true, course: { select: { title: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!submission) notFound();
|
||||
|
||||
const files = (submission.files as { name: string; url: string; size: number }[]) ?? [];
|
||||
const isReviewed = submission.feedbacks.length > 0;
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-2xl">
|
||||
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||
<Link href="/curator/homework" className="hover:underline">ДЗ на проверку</Link>
|
||||
<span className="mx-2">/</span>
|
||||
<span style={{ color: "var(--foreground)" }}>{submission.user.name}</span>
|
||||
</nav>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold">{submission.homework.lesson.title}</h1>
|
||||
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||
{submission.homework.lesson.module.course.title} · {submission.homework.lesson.module.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Student info */}
|
||||
<div className="flex items-center justify-between px-4 py-3 mb-6" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{submission.user.name}</p>
|
||||
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{submission.user.email}</p>
|
||||
</div>
|
||||
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
Сдано {new Date(submission.submittedAt).toLocaleDateString("ru-RU")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Homework description */}
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: "var(--muted-foreground)" }}>Задание</p>
|
||||
<div className="px-4 py-3 text-sm whitespace-pre-wrap" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
|
||||
{submission.homework.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Student answer */}
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: "var(--muted-foreground)" }}>Ответ студента</p>
|
||||
{submission.text ? (
|
||||
<div className="px-4 py-3 text-sm whitespace-pre-wrap" style={{ border: "2px solid var(--border)" }}>
|
||||
{submission.text}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>Текст не добавлен</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Files */}
|
||||
{files.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: "var(--muted-foreground)" }}>Прикреплённые файлы</p>
|
||||
<div className="space-y-1">
|
||||
{files.map((f) => (
|
||||
<a
|
||||
key={f.url}
|
||||
href={f.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 px-3 py-2 text-sm"
|
||||
style={{ border: "2px solid var(--border)" }}
|
||||
>
|
||||
<span>📎</span>
|
||||
<span className="flex-1 underline">{f.name}</span>
|
||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Existing feedback */}
|
||||
{submission.feedbacks.map((fb) => (
|
||||
<div key={fb.id} className="mb-4 px-4 py-3" style={{ border: "2px solid var(--foreground)", background: "var(--accent)" }}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<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>
|
||||
))}
|
||||
|
||||
{/* Feedback form */}
|
||||
{!isReviewed && <FeedbackForm submissionId={submissionId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function HomeworkListPage() {
|
||||
const submissions = await prisma.homeworkSubmission.findMany({
|
||||
orderBy: { submittedAt: "desc" },
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
feedbacks: { select: { id: true } },
|
||||
homework: {
|
||||
include: {
|
||||
lesson: {
|
||||
select: {
|
||||
title: true,
|
||||
module: { select: { title: true, course: { select: { title: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const pending = submissions.filter((s) => s.feedbacks.length === 0);
|
||||
const reviewed = submissions.filter((s) => s.feedbacks.length > 0);
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-3xl">
|
||||
<h1 className="text-2xl font-bold mb-1">Домашние задания</h1>
|
||||
<p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>
|
||||
{pending.length} ожидают проверки · {reviewed.length} проверено
|
||||
</p>
|
||||
|
||||
{pending.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
||||
Ожидают проверки
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{pending.map((s) => (
|
||||
<SubmissionRow key={s.id} submission={s} pending />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reviewed.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
||||
Проверено
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{reviewed.map((s) => (
|
||||
<SubmissionRow key={s.id} submission={s} pending={false} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submissions.length === 0 && (
|
||||
<div className="card-aubade p-10 text-center">
|
||||
<p className="text-3xl mb-2">📭</p>
|
||||
<p className="font-bold">Работ пока нет</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmissionRow({
|
||||
submission,
|
||||
pending,
|
||||
}: {
|
||||
submission: {
|
||||
id: string;
|
||||
submittedAt: Date;
|
||||
user: { name: string; email: string };
|
||||
homework: {
|
||||
lesson: {
|
||||
title: string;
|
||||
module: { title: string; course: { title: string } };
|
||||
};
|
||||
};
|
||||
};
|
||||
pending: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/curator/homework/${submission.id}`}
|
||||
className="flex items-center gap-4 px-4 py-3 text-sm transition-colors"
|
||||
style={{ border: `2px solid ${pending ? "var(--foreground)" : "var(--border)"}`, display: "flex" }}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{submission.user.name}</p>
|
||||
<p className="text-xs truncate" style={{ color: "var(--muted-foreground)" }}>
|
||||
{submission.homework.lesson.module.course.title} · {submission.homework.lesson.title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span
|
||||
className="text-xs px-2 py-0.5"
|
||||
style={{
|
||||
border: "1px solid var(--border)",
|
||||
background: pending ? "var(--foreground)" : "transparent",
|
||||
color: pending ? "var(--background)" : "var(--muted-foreground)",
|
||||
}}
|
||||
>
|
||||
{pending ? "Новое" : "Проверено"}
|
||||
</span>
|
||||
<p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||
{new Date(submission.submittedAt).toLocaleDateString("ru-RU")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { LogoutButton } from "@/components/layout/logout-button";
|
||||
|
||||
export default async function CuratorLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) redirect("/login");
|
||||
if (session.user.role !== "curator" && session.user.role !== "admin") redirect("/dashboard");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex" style={{ backgroundColor: "var(--background)" }}>
|
||||
{/* Sidebar */}
|
||||
<aside className="w-52 shrink-0 flex flex-col min-h-screen" style={{ backgroundColor: "var(--sidebar-bg)" }}>
|
||||
<div className="px-5 py-5" style={{ borderBottom: "1px solid rgba(255,255,255,0.08)" }}>
|
||||
<p className="text-sm font-bold tracking-wide" style={{ color: "#F5F5F0" }}>Second Brain</p>
|
||||
<p className="text-xs mt-0.5 uppercase tracking-widest" style={{ color: "#888" }}>Куратор</p>
|
||||
</div>
|
||||
<nav className="flex-1 py-3 space-y-0.5 px-2">
|
||||
<NavLink href="/curator/dashboard">Обзор</NavLink>
|
||||
<NavLink href="/curator/homework">ДЗ на проверку</NavLink>
|
||||
</nav>
|
||||
<div className="px-4 py-4" style={{ borderTop: "1px solid rgba(255,255,255,0.08)" }}>
|
||||
<p className="text-xs mb-2 truncate" style={{ color: "#888" }}>{session.user.name}</p>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="block px-3 py-2 text-sm rounded-sm transition-colors"
|
||||
style={{ color: "#CCCCCC" }}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user