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 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 11:55:07 +05:00
parent 15df731e37
commit 39d84a3db2
4 changed files with 182 additions and 19 deletions
@@ -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)" }}
>
<span className="text-lg">📎</span>
<FileFormatBadge url={file.url} />
<span className="flex-1 font-medium">{file.name}</span>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{formatSize(file.size)}
+26 -5
View File
@@ -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 });
}
+114 -13
View File
@@ -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<string | null>(null);
const [editingLabel, setEditingLabel] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
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 && (
<div className="space-y-2">
{files.map((f) => (
<div key={f.id} className="flex items-center gap-3 px-3 py-2.5 text-sm" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
<span className="text-base">📎</span>
<a href={f.url} target="_blank" rel="noopener noreferrer" className="flex-1 underline font-medium">{f.name}</a>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span>
<button onClick={() => handleDelete(f.id)} className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>
<div
key={f.id}
className="flex items-center gap-3 px-3 py-2.5 text-sm"
style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}
>
<FileFormatBadge url={f.url} />
{editingId === f.id ? (
<input
autoFocus
value={editingLabel}
onChange={(e) => setEditingLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") saveLabel(f.id);
if (e.key === "Escape") cancelEdit();
}}
onBlur={() => saveLabel(f.id)}
className="flex-1 text-sm px-2 py-0.5"
style={{
border: "1px solid var(--foreground)",
background: "var(--background)",
outline: "none",
fontFamily: "inherit",
}}
/>
) : (
<button
type="button"
onClick={() => startEdit(f)}
className="flex-1 text-left font-medium"
title="Нажмите, чтобы изменить название"
style={{ color: "var(--foreground)" }}
>
{f.name}
</button>
)}
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{formatSize(f.size)}
</span>
<button
type="button"
onClick={() => handleDelete(f.id)}
className="text-xs"
style={{ color: "oklch(0.577 0.245 27.325)" }}
>
Удалить
</button>
</div>
))}
</div>
)}
<div className="flex items-center gap-3">
<label className="btn-aubade text-xs cursor-pointer">
<div className="flex items-center gap-2">
<input
type="text"
value={labelInput}
onChange={(e) => setLabelInput(e.target.value)}
placeholder="Название (например, Презентация)"
className="flex-1 text-sm px-3 py-2"
style={{
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
fontFamily: "inherit",
}}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="btn-aubade text-xs whitespace-nowrap"
style={{ flexShrink: 0, opacity: uploading ? 0.6 : 1 }}
>
{uploading ? "Загрузка..." : "+ Добавить файл"}
<input type="file" className="sr-only" onChange={handleUpload} disabled={uploading} />
</label>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>PDF, ZIP, DOCX, XLSX до 100 МБ</span>
</button>
<input
ref={fileInputRef}
type="file"
className="sr-only"
onChange={handleUpload}
disabled={uploading}
accept=".pdf,.zip,.docx,.xlsx,.doc,.xls,.pptx,.ppt,.mp4,.mp3"
/>
</div>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>PDF, ZIP, DOCX, XLSX до 100 МБ</p>
</div>
);
}
@@ -0,0 +1,40 @@
const FORMAT_MAP: Record<string, { label: string; bg: string }> = {
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 (
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
minWidth: "44px",
padding: "2px 6px",
fontSize: "10px",
fontWeight: "800",
letterSpacing: "0.06em",
background: bg,
color: "#fff",
flexShrink: 0,
}}
>
{label}
</span>
);
}