39d84a3db2
- 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>
174 lines
5.8 KiB
TypeScript
174 lines
5.8 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef } from "react";
|
||
import { FileFormatBadge } from "@/components/shared/file-format-badge";
|
||
|
||
interface LessonFile {
|
||
id: string;
|
||
name: string;
|
||
url: string;
|
||
size: number;
|
||
}
|
||
|
||
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];
|
||
if (!file) return;
|
||
setUploading(true);
|
||
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]);
|
||
setLabelInput("");
|
||
}
|
||
setUploading(false);
|
||
e.target.value = "";
|
||
}
|
||
|
||
async function handleDelete(fileId: string) {
|
||
if (!confirm("Удалить файл?")) return;
|
||
await fetch("/api/admin/lesson-files", {
|
||
method: "DELETE",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ fileId }),
|
||
});
|
||
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)} КБ`;
|
||
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{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)" }}
|
||
>
|
||
<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-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 ? "Загрузка..." : "+ Добавить файл"}
|
||
</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>
|
||
);
|
||
}
|