From 39d84a3db25dd8cb4e793a0d26d59ec917c33af2 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Sun, 26 Apr 2026 11:55:07 +0500 Subject: [PATCH] Add labeled file materials with format badge - Store human-readable label in LessonFile.name via optional label field on upload - Add PATCH endpoint to rename existing files inline - Admin: label input before upload, click-to-edit inline rename - Student: colored format badge (PDF/DOCX/XLSX/ZIP/etc) replaces paperclip emoji Co-Authored-By: Claude Sonnet 4.6 --- .../[slug]/lessons/[lessonId]/page.tsx | 3 +- src/app/api/admin/lesson-files/route.ts | 31 ++++- src/components/admin/lesson-files-manager.tsx | 127 ++++++++++++++++-- src/components/shared/file-format-badge.tsx | 40 ++++++ 4 files changed, 182 insertions(+), 19 deletions(-) create mode 100644 src/components/shared/file-format-badge.tsx diff --git a/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx b/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx index 24291f9..10f7132 100644 --- a/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx +++ b/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx @@ -8,6 +8,7 @@ import { LessonContent } from "@/components/student/lesson-content"; import { LessonCompleteButton } from "@/components/student/lesson-complete-button"; import { HomeworkSection } from "@/components/student/homework-section"; import { LessonComments } from "@/components/student/lesson-comments"; +import { FileFormatBadge } from "@/components/shared/file-format-badge"; interface Props { params: Promise<{ slug: string; lessonId: string }>; @@ -123,7 +124,7 @@ export default async function LessonPage({ params }: Props) { className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:[border-color:var(--foreground)]" style={{ border: "2px solid var(--border)" }} > - 📎 + {file.name} {formatSize(file.size)} diff --git a/src/app/api/admin/lesson-files/route.ts b/src/app/api/admin/lesson-files/route.ts index 8e55e36..f4469ef 100644 --- a/src/app/api/admin/lesson-files/route.ts +++ b/src/app/api/admin/lesson-files/route.ts @@ -5,15 +5,21 @@ import { prisma } from "@/lib/prisma"; import { uploadFile, deleteFile } from "@/lib/s3"; import { randomUUID } from "crypto"; -export async function POST(req: NextRequest) { +async function requireAdmin(req: NextRequest) { const session = await auth.api.getSession({ headers: await headers() }); - if (!session || session.user.role !== "admin") { + if (!session || session.user.role !== "admin") return null; + return session; +} + +export async function POST(req: NextRequest) { + if (!await requireAdmin(req)) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const form = await req.formData(); const file = form.get("file") as File | null; const lessonId = form.get("lessonId") as string | null; + const label = (form.get("label") as string | null)?.trim() || null; if (!file || !lessonId) return NextResponse.json({ error: "Missing fields" }, { status: 400 }); const ext = file.name.split(".").pop() ?? "bin"; @@ -22,15 +28,30 @@ export async function POST(req: NextRequest) { const url = await uploadFile(key, buffer, file.type); const lessonFile = await prisma.lessonFile.create({ - data: { lessonId, name: file.name, url, size: file.size }, + data: { lessonId, name: label ?? file.name, url, size: file.size }, }); return NextResponse.json(lessonFile); } +export async function PATCH(req: NextRequest) { + if (!await requireAdmin(req)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { fileId, label } = await req.json(); + if (!fileId || typeof label !== "string") { + return NextResponse.json({ error: "Missing fields" }, { status: 400 }); + } + const updated = await prisma.lessonFile.update({ + where: { id: fileId }, + data: { name: label.trim() || undefined }, + }); + return NextResponse.json(updated); +} + export async function DELETE(req: NextRequest) { - const session = await auth.api.getSession({ headers: await headers() }); - if (!session || session.user.role !== "admin") { + if (!await requireAdmin(req)) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } diff --git a/src/components/admin/lesson-files-manager.tsx b/src/components/admin/lesson-files-manager.tsx index 5bca7c9..c4685d9 100644 --- a/src/components/admin/lesson-files-manager.tsx +++ b/src/components/admin/lesson-files-manager.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState } from "react"; -import { Button } from "@/components/ui/button"; +import { useState, useRef } from "react"; +import { FileFormatBadge } from "@/components/shared/file-format-badge"; interface LessonFile { id: string; @@ -13,6 +13,10 @@ interface LessonFile { export function LessonFilesManager({ lessonId, initialFiles }: { lessonId: string; initialFiles: LessonFile[] }) { const [files, setFiles] = useState(initialFiles); const [uploading, setUploading] = useState(false); + const [labelInput, setLabelInput] = useState(""); + const [editingId, setEditingId] = useState(null); + const [editingLabel, setEditingLabel] = useState(""); + const fileInputRef = useRef(null); async function handleUpload(e: React.ChangeEvent) { const file = e.target.files?.[0]; @@ -21,9 +25,13 @@ export function LessonFilesManager({ lessonId, initialFiles }: { lessonId: strin const fd = new FormData(); fd.append("file", file); fd.append("lessonId", lessonId); + if (labelInput.trim()) fd.append("label", labelInput.trim()); const res = await fetch("/api/admin/lesson-files", { method: "POST", body: fd }); const created = await res.json(); - if (created.id) setFiles((prev) => [...prev, created]); + if (created.id) { + setFiles((prev) => [...prev, created]); + setLabelInput(""); + } setUploading(false); e.target.value = ""; } @@ -38,6 +46,31 @@ export function LessonFilesManager({ lessonId, initialFiles }: { lessonId: strin setFiles((prev) => prev.filter((f) => f.id !== fileId)); } + async function saveLabel(fileId: string) { + const trimmed = editingLabel.trim(); + if (!trimmed) return cancelEdit(); + const res = await fetch("/api/admin/lesson-files", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fileId, label: trimmed }), + }); + const updated = await res.json(); + if (updated.id) { + setFiles((prev) => prev.map((f) => (f.id === fileId ? { ...f, name: updated.name } : f))); + } + cancelEdit(); + } + + function startEdit(file: LessonFile) { + setEditingId(file.id); + setEditingLabel(file.name); + } + + function cancelEdit() { + setEditingId(null); + setEditingLabel(""); + } + function formatSize(bytes: number) { if (bytes < 1024) return `${bytes} Б`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`; @@ -49,24 +82,92 @@ export function LessonFilesManager({ lessonId, initialFiles }: { lessonId: strin {files.length > 0 && (
{files.map((f) => ( -
- 📎 - {f.name} - {formatSize(f.size)} - + )} + + {formatSize(f.size)} + +
))}
)} -
-
); } diff --git a/src/components/shared/file-format-badge.tsx b/src/components/shared/file-format-badge.tsx new file mode 100644 index 0000000..3f4b48b --- /dev/null +++ b/src/components/shared/file-format-badge.tsx @@ -0,0 +1,40 @@ +const FORMAT_MAP: Record = { + pdf: { label: "PDF", bg: "#DC2626" }, + zip: { label: "ZIP", bg: "#D97706" }, + docx: { label: "DOCX", bg: "#2563EB" }, + doc: { label: "DOC", bg: "#2563EB" }, + xlsx: { label: "XLSX", bg: "#16A34A" }, + xls: { label: "XLS", bg: "#16A34A" }, + pptx: { label: "PPTX", bg: "#EA580C" }, + ppt: { label: "PPT", bg: "#EA580C" }, + mp4: { label: "MP4", bg: "#7C3AED" }, + mp3: { label: "MP3", bg: "#7C3AED" }, +}; + +export function getFileFormatInfo(url: string): { label: string; bg: string } { + const ext = url.split("?")[0].split(".").pop()?.toLowerCase() ?? ""; + return FORMAT_MAP[ext] ?? { label: ext.toUpperCase().slice(0, 4) || "FILE", bg: "#6B7280" }; +} + +export function FileFormatBadge({ url }: { url: string }) { + const { label, bg } = getFileFormatInfo(url); + return ( + + {label} + + ); +}