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:
@@ -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)}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user