1"use client";
2
3
33import { useCallback, useEffect, useRef, useState } from "react";
34import { useQuery, useQueryClient } from "@tanstack/react-query";
35import {
36 Download,
37 ExternalLink,
38 File as FileIcon,
39 FileAudio,
40 FileText,
41 FileVideo,
42 GripVertical,
43 Link as LinkIcon,
44 Loader2,
45 MoreHorizontal,
46 Music,
47 Pencil,
48 RefreshCw,
49 ShieldAlert,
50 ShieldCheck,
51 Trash2,
52 X,
53} from "lucide-react";
54import type { Block } from "@/lib/types";
55import { blockApi } from "@/lib/api";
56import { API_BASE } from "@/lib/constants";
57import { filesApi, humanSize, kindForMime, type FileDetail, type FileKind } from "@/lib/files/api";
58import { isTerminalStatus, type UploadProgress } from "@/lib/files/upload";
59import { useUploadStore } from "@/lib/files/upload-store";
60import { toast } from "@/stores/ui-store";
61import { usePage } from "@/hooks/use-pages";
62import { useFileUploads } from "@/lib/files/use-file-uploads";
63import { Dialog, DialogFooter } from "@/components/dialogs/dialog";
64import { ConfirmDialog } from "@/components/ui/confirm-dialog";
65import { FilePermissionsDialog } from "@/components/dialogs/file-permissions-dialog";
66
67interface FileBlockProps {
68 block: Block;
69}
70
71const KIND_ICONS: Record<FileKind, typeof FileIcon> = {
72 image: FileText,
73 video: FileVideo,
74 audio: FileAudio,
75 pdf: FileText,
76 doc: FileText,
77 other: FileIcon,
78};
79
80export function FileBlock({ block }: FileBlockProps) {
81 const fileId = block.properties.fileId as string | undefined;
82
83
84 if (!fileId && block.properties.src) return <LegacyFileCard block={block} />;
85 if (!fileId) {
86
87
88
89 return <FilePickerBlock block={block} />;
90 }
91 return <LiveFileBlock block={block} fileId={fileId} />;
92}
93
94
95
96
97function FilePickerBlock({ block }: { block: Block }) {
98 const { data: page } = usePage(block.pageId);
99 const workspaceId = page?.workspaceId ?? null;
100 const { start } = useFileUploads(workspaceId, block.pageId);
101 const inputRef = useRef<HTMLInputElement>(null);
102
103
104
105 const onFilesMulti = async (list: FileList | null) => {
106 const files = Array.from(list ?? []);
107 const first = files[0];
108 if (!first) return;
109 const rest = files.slice(1);
110
111
112 await start(first, { parentId: block.parentId, position: null, updateBlockId: block.id });
113 for (const f of rest) {
114 await start(f, { parentId: block.parentId, position: null });
115 }
116 };
117
118 return (
119 <div className="flex items-center gap-3 rounded-md border border-dashed border-border p-3">
120 <span className="flex size-9 shrink-0 items-center justify-center rounded bg-muted">
121 <FileIcon className="size-4 text-muted-foreground" aria-hidden />
122 </span>
123 <span className="min-w-0 flex-1 text-sm text-muted-foreground">No file yet</span>
124 <button
125 type="button"
126 onClick={() => inputRef.current?.click()}
127 className="shrink-0 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent"
128 >
129 Add file
130 </button>
131 <input
132 ref={inputRef}
133 data-testid="file-input"
134 type="file"
135 className="sr-only"
136 aria-label="Choose a file to attach"
137 multiple
138 onChange={(e) => void onFilesMulti(e.target.files)}
139 />
140 </div>
141 );
142}
143
144
145
146function LiveFileBlock({ block, fileId }: { block: Block; fileId: string }) {
147
148
149
150
151 const query = useQuery({
152 queryKey: ["files", fileId],
153 queryFn: () => filesApi.get(fileId),
154 refetchInterval: (query) => {
155 const data = query.state.data as FileDetail | undefined;
156 if (!data) return 2000;
157 if (!isTerminalStatus(data.status)) return 2000;
158
159 if (!data.expiresAt) return false;
160 const expiresMs = new Date(data.expiresAt).getTime() - Date.now();
161 return expiresMs < 60_000 ? 5000 : false;
162 },
163 staleTime: 30_000,
164 });
165
166
167 const live = useUploadStore((s) => s.active[fileId]);
168 const qc = useQueryClient();
169
170 const [captionOpen, setCaptionOpen] = useState(false);
171
172 const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number } | null>(null);
173 const [renameOpen, setRenameOpen] = useState(false);
174 const [permsOpen, setPermsOpen] = useState(false);
175 const [trashConfirmOpen, setTrashConfirmOpen] = useState(false);
176
177
178
179 const detail = query.data;
180
181
182 const patchProps = useCallback(
183 async (patch: Record<string, unknown>) => {
184 await blockApi.update(block.id, { properties: { ...block.properties, ...patch } });
185 },
186 [block.id, block.properties],
187 );
188
189
190
191
192 const { data: page } = usePage(block.pageId);
193 const workspaceId = page?.workspaceId ?? null;
194 const { start } = useFileUploads(workspaceId, block.pageId);
195 const replaceInputRef = useRef<HTMLInputElement>(null);
196
197 const closeMenu = () => setMenuAnchor(null);
198
199 const doRename = async (name: string) => {
200 const trimmed = name.trim();
201 if (!trimmed) return;
202 try {
203 await filesApi.rename(fileId, trimmed, { workspaceId: workspaceId ?? undefined });
204 await patchProps({ name: trimmed });
205 qc.invalidateQueries({ queryKey: ["files", fileId] });
206 toast("success", "File renamed");
207 setRenameOpen(false);
208 } catch (e) {
209 toast("error", "Rename failed", e instanceof Error ? e.message : undefined);
210 }
211 };
212
213 const doTrash = async () => {
214 try {
215 await filesApi.trash(fileId, { workspaceId: workspaceId ?? undefined });
216
217
218 await blockApi.remove(block.id).catch(() => undefined);
219 qc.invalidateQueries({ queryKey: ["files", "browser"] });
220 toast("success", "Moved to trash", "Restore it from the Files → Trash tab.");
221 setTrashConfirmOpen(false);
222 } catch (e) {
223 toast("error", "Delete failed", e instanceof Error ? e.message : undefined);
224 }
225 };
226
227 const onReplaceFile = (list: FileList | null) => {
228 const file = list?.[0];
229 if (!file) return;
230 void start(file, {
231 parentId: block.parentId,
232 position: null,
233 updateBlockId: block.id,
234 replaceFileId: fileId,
235 }).then(() => toast("success", "New version uploaded"));
236 };
237
238 if (live) {
239 return <UploadProgressCard block={block} fileId={fileId} progress={live} />;
240 }
241
242 if (!detail || !query.data) {
243 if (query.isError) {
244 return (
245 <ErrorCard
246 message="File not found or you no longer have access."
247 onRemove={() => void blockApi.remove(block.id)}
248 />
249 );
250 }
251 return <Skeleton status="Loading…" />;
252 }
253
254 if (detail.status === "VIRUS_DETECTED") {
255 return (
256 <div
257 role="alert"
258 className="flex items-start gap-3 rounded-md border border-red-300 bg-red-50 p-3 text-sm"
259 >
260 <ShieldAlert className="mt-0.5 size-4 shrink-0 text-red-600" aria-hidden />
261 <div className="min-w-0">
262 <p className="font-medium text-red-800">Blocked by virus scan — contact an admin</p>
263 <p className="truncate text-red-700">{detail.name}</p>
264 </div>
265 </div>
266 );
267 }
268
269 if (detail.status === "FAILED") {
270 const err = (detail.metadata?.pipeline as { error?: string } | undefined)?.error;
271 return (
272 <ErrorCard
273 message={err ?? "Processing failed — the file could not be prepared."}
274 onRemove={() => void blockApi.remove(block.id)}
275 />
276 );
277 }
278
279 if (detail.status !== "READY") {
280 return <Skeleton status={detail.status === "SCANNING" ? "Scanning…" : "Waiting for upload…"} />;
281 }
282
283
284 const kind = kindForMime(detail.mimeType);
285 const props = block.properties;
286
287 return (
288 <div data-testid={`file-card-${detail.id}`} className="group/file w-full">
289 <div className="relative">
290 {}
291 <div className="absolute right-1 top-1 z-10 opacity-0 transition-opacity group-hover/file:opacity-100 focus-within:opacity-100">
292 <button
293 type="button"
294 aria-label={`Actions for ${detail.name}`}
295 aria-haspopup="menu"
296 aria-expanded={menuAnchor !== null}
297 onClick={(e) => {
298 e.stopPropagation();
299 setMenuAnchor({ x: e.clientX, y: e.clientY });
300 }}
301 className="flex size-7 items-center justify-center rounded-md border border-border bg-background/90 text-muted-foreground shadow-sm hover:bg-accent hover:text-foreground"
302 >
303 <MoreHorizontal className="size-4" aria-hidden />
304 </button>
305 </div>
306 {kind === "image" && <ImageRender detail={detail} props={props} patchProps={patchProps} />}
307 {kind === "video" && <VideoRender detail={detail} />}
308 {kind === "audio" && <AudioRender detail={detail} />}
309 {kind === "pdf" && <PdfRender detail={detail} />}
310 {(kind === "doc" || kind === "other") && (
311 <DocCard detail={detail} onCopyLink={() => void copyShareLink(detail.id)} />
312 )}
313 </div>
314 {(kind === "image" || kind === "video") && (
315 <CaptionRow
316 open={captionOpen}
317 onToggle={() => setCaptionOpen((v) => !v)}
318 caption={props.caption ?? ""}
319 onSave={(caption) => void patchProps({ caption: caption || undefined })}
320 />
321 )}
322
323 {menuAnchor && (
324 <FileBlockMenu
325 x={menuAnchor.x}
326 y={menuAnchor.y}
327 onClose={closeMenu}
328 onReplace={() => {
329 closeMenu();
330 replaceInputRef.current?.click();
331 }}
332 onRename={() => {
333 closeMenu();
334 setRenameOpen(true);
335 }}
336 onPermissions={() => {
337 closeMenu();
338 setPermsOpen(true);
339 }}
340 onDelete={() => {
341 closeMenu();
342 setTrashConfirmOpen(true);
343 }}
344 />
345 )}
346
347 {}
348 <input
349 ref={replaceInputRef}
350 type="file"
351 className="sr-only"
352 aria-label={`Choose a replacement for ${detail.name}`}
353 onChange={(e) => {
354 onReplaceFile(e.target.files);
355 e.target.value = "";
356 }}
357 />
358
359 <RenameFileDialog open={renameOpen} initialName={detail.name} onClose={() => setRenameOpen(false)} onSave={(name) => void doRename(name)} />
360
361 <ConfirmDialog
362 open={trashConfirmOpen}
363 title="Delete this file?"
364 description={
365 <>
366 <span className="font-medium text-foreground">{detail.name}</span> moves to the workspace trash (the
367 current version is kept). Restore it from Files → Trash, or purge it there to delete for good.
368 </>
369 }
370 confirmLabel="Delete"
371 destructive
372 busy={false}
373 onConfirm={() => void doTrash()}
374 onClose={() => setTrashConfirmOpen(false)}
375 />
376
377 {permsOpen && (
378 <FilePermissionsDialog
379 open={permsOpen}
380 fileId={fileId}
381 fileName={detail.name}
382 workspaceId={workspaceId ?? ""}
383 onClose={() => setPermsOpen(false)}
384 />
385 )}
386 </div>
387 );
388}
389
390
391
392function FileBlockMenu({
393 x,
394 y,
395 onClose,
396 onReplace,
397 onRename,
398 onPermissions,
399 onDelete,
400}: {
401 x: number;
402 y: number;
403 onClose: () => void;
404 onReplace: () => void;
405 onRename: () => void;
406 onPermissions: () => void;
407 onDelete: () => void;
408}) {
409 const ref = useRef<HTMLDivElement>(null);
410
411 useEffect(() => {
412 const onKey = (e: KeyboardEvent) => {
413 if (e.key === "Escape") onClose();
414 };
415 const onClick = (e: MouseEvent) => {
416 if (ref.current && !ref.current.contains(e.target as Node)) onClose();
417 };
418 document.addEventListener("keydown", onKey);
419 document.addEventListener("mousedown", onClick);
420 return () => {
421 document.removeEventListener("keydown", onKey);
422 document.removeEventListener("mousedown", onClick);
423 };
424 }, [onClose]);
425
426
427 const style = {
428 top: Math.min(y, (typeof window !== "undefined" ? window.innerHeight : 800) - 220),
429 left: Math.min(x, (typeof window !== "undefined" ? window.innerWidth : 1200) - 230),
430 };
431
432 const item = "flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent";
433
434 return (
435 <div
436 ref={ref}
437 role="menu"
438 aria-label="File actions"
439 style={style}
440 className="fixed z-[95] w-52 rounded-md border border-border bg-background p-1 shadow-xl"
441 >
442 <button type="button" role="menuitem" onClick={onReplace} className={item}>
443 <RefreshCw className="size-4 text-muted-foreground" aria-hidden />
444 Replace
445 </button>
446 <button type="button" role="menuitem" onClick={onRename} className={item}>
447 <Pencil className="size-4 text-muted-foreground" aria-hidden />
448 Rename
449 </button>
450 <button type="button" role="menuitem" onClick={onPermissions} className={item}>
451 <ShieldCheck className="size-4 text-muted-foreground" aria-hidden />
452 Permissions…
453 </button>
454 <div className="my-1 border-t border-border" aria-hidden />
455 <button
456 type="button"
457 role="menuitem"
458 onClick={onDelete}
459 className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"
460 >
461 <Trash2 className="size-4" aria-hidden />
462 Delete
463 </button>
464 </div>
465 );
466}
467
468
469function RenameFileDialog({
470 open,
471 initialName,
472 onClose,
473 onSave,
474}: {
475 open: boolean;
476 initialName: string;
477 onClose: () => void;
478 onSave: (name: string) => void;
479}) {
480 const [name, setName] = useState(initialName);
481 useEffect(() => {
482 if (open) setName(initialName);
483 }, [open, initialName]);
484 return (
485 <Dialog
486 open={open}
487 title="Rename file"
488 onClose={onClose}
489 footer={
490 <DialogFooter
491 onCancel={onClose}
492 onAction={() => onSave(name)}
493 actionLabel="Rename"
494 disabled={!name.trim() || name.trim() === initialName}
495 />
496 }
497 >
498 <form
499 onSubmit={(e) => {
500 e.preventDefault();
501 onSave(name);
502 }}
503 >
504 <label htmlFor="file-rename-name" className="mb-1 block text-sm font-medium">
505 File name
506 </label>
507 <input
508 id="file-rename-name"
509 type="text"
510 value={name}
511 onChange={(e) => setName(e.target.value)}
512 autoFocus
513 className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/40"
514 />
515 </form>
516 </Dialog>
517 );
518}
519
520
521
522function ImageRender({
523 detail,
524 props,
525 patchProps,
526}: {
527 detail: FileDetail;
528 props: Block["properties"];
529 patchProps: (patch: Record<string, unknown>) => Promise<void>;
530}) {
531 const widthPercent = clampPercent((props.widthPercent as number | undefined) ?? 100);
532 const frameRef = useRef<HTMLDivElement>(null);
533 const [dragging, setDragging] = useState(false);
534
535 const onHandlePointerMove = (e: React.PointerEvent) => {
536 if (!dragging || !frameRef.current) return;
537 const rect = frameRef.current.getBoundingClientRect();
538 const pct = ((e.clientX - rect.left) / rect.width) * 100;
539
540 frameRef.current.querySelector<HTMLElement>("[data-image-frame]")?.style.setProperty(
541 "width",
542 `${clampPercent(pct)}%`,
543 );
544 };
545
546 return (
547 <div ref={frameRef} className="relative inline-block max-w-full align-top" onPointerMove={onHandlePointerMove}>
548 {}
549 <img
550 data-image-frame
551 src={detail.previewUrl}
552 alt={props.caption || detail.name}
553 style={{ width: `${widthPercent}%` }}
554 className="block max-w-full rounded-md"
555 loading="lazy"
556 />
557 <div
558 role="group"
559 aria-label={`Image width ${widthPercent}% — use the presets or the drag handle`}
560 className="mt-1 flex items-center gap-1"
561 >
562 {[50, 75, 100].map((p) => (
563 <button
564 key={p}
565 type="button"
566 aria-pressed={widthPercent === p}
567 onClick={() => void patchProps({ widthPercent: p })}
568 className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-accent"
569 >
570 {p}%
571 </button>
572 ))}
573 <button
574 type="button"
575 aria-label="Drag to resize"
576 onPointerDown={(e) => {
577 (e.target as HTMLElement).setPointerCapture(e.pointerId);
578 setDragging(true);
579 }}
580 onPointerUp={(e) => {
581 (e.target as HTMLElement).releasePointerCapture(e.pointerId);
582 setDragging(false);
583 const frame = frameRef.current?.querySelector<HTMLElement>("[data-image-frame]");
584 const pct = frame ? (parseFloat(frame.style.width || "100") || 100) : widthPercent;
585 void patchProps({ widthPercent: Math.round(clampPercent(pct)) });
586 }}
587 className="ml-1 cursor-ew-resize rounded p-1 text-muted-foreground hover:bg-accent"
588 >
589 <GripVertical className="size-3.5" aria-hidden />
590 </button>
591 </div>
592 </div>
593 );
594}
595
596function VideoRender({ detail }: { detail: FileDetail }) {
597 const duration = (detail.metadata?.media as { duration?: number } | undefined)?.duration;
598 return (
599 <video
600 controls
601 src={detail.previewUrl}
602 poster={detail.thumbnailUrl ?? undefined}
603 className="max-h-[70vh] max-w-full rounded-md bg-black"
604 aria-label={`${detail.name}${typeof duration === "number" ? `, ${Math.round(duration)} seconds` : ""}`}
605 >
606 <track kind="captions" />
607 </video>
608 );
609}
610
611function AudioRender({ detail }: { detail: FileDetail }) {
612 const duration = (detail.metadata?.media as { duration?: number } | undefined)?.duration;
613 return (
614 <div className="flex items-center gap-3 rounded-md border border-border p-3">
615 <Music className="size-5 shrink-0 text-muted-foreground" aria-hidden />
616 <audio
617 controls
618 src={detail.previewUrl}
619 className="h-9 min-w-0 flex-1"
620 aria-label={`${detail.name}${typeof duration === "number" ? `, ${Math.round(duration)} seconds` : ""}`}
621 />
622 </div>
623 );
624}
625
626function PdfRender({ detail }: { detail: FileDetail }) {
627 return (
628 <div className="w-full overflow-hidden rounded-md border border-border">
629 <iframe
630 title={detail.name}
631 src={detail.previewUrl}
632 sandbox="allow-scripts allow-popups"
633 referrerPolicy="no-referrer"
634 className="h-[70vh] w-full bg-white"
635 />
636 <div className="flex items-center gap-2 border-t border-border p-2">
637 <a
638 href={detail.previewUrl}
639 target="_blank"
640 rel="noopener noreferrer"
641 className="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent"
642 >
643 <ExternalLink className="size-3" aria-hidden /> Open in new tab
644 </a>
645 <DownloadButton detail={detail} />
646 </div>
647 </div>
648 );
649}
650
651function DocCard({ detail, onCopyLink }: { detail: FileDetail; onCopyLink: () => void }) {
652 const Icon = KIND_ICONS[kindForMime(detail.mimeType)];
653 return (
654 <div className="flex items-center gap-3 rounded-md border border-border p-3">
655 <span className="flex size-9 shrink-0 items-center justify-center rounded bg-muted">
656 <Icon className="size-4 text-muted-foreground" aria-hidden />
657 </span>
658 <span className="min-w-0 flex-1">
659 <span data-testid={`file-name-${detail.id}`} className="block truncate text-sm font-medium">{detail.name}</span>
660 <span className="block text-xs text-muted-foreground">
661 {[detail.mimeType, humanSize(detail.size), detail.versions.length > 1 ? `${detail.versions.length} versions` : null]
662 .filter(Boolean)
663 .join(" · ")}
664 </span>
665 </span>
666 <div className="flex shrink-0 items-center gap-1">
667 <button
668 type="button"
669 title="Copy share link"
670 aria-label="Copy share link"
671 onClick={onCopyLink}
672 className="rounded-md border border-border p-1.5 text-muted-foreground hover:bg-accent"
673 >
674 <LinkIcon className="size-3.5" aria-hidden />
675 </button>
676 <DownloadButton detail={detail} />
677 </div>
678 </div>
679 );
680}
681
682function DownloadButton({ detail }: { detail: FileDetail }) {
683 return (
684 <button
685 type="button"
686 data-testid={`file-download-${detail.id}`}
687 onClick={() => {
688
689
690
691
692 if (!detail.downloadUrl) return;
693 window.location.href = detail.downloadUrl;
694 }}
695 className="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent"
696 >
697 <Download className="size-3" aria-hidden /> Download
698 </button>
699 );
700}
701
702
703
704function Skeleton({ status }: { status: string }) {
705 return (
706 <div className="flex items-center gap-3 rounded-md border border-border p-4" role="status" aria-live="polite">
707 <Loader2 className="size-4 animate-spin text-muted-foreground" aria-hidden />
708 <span className="text-sm text-muted-foreground">{status}</span>
709 </div>
710 );
711}
712
713function ErrorCard({ message, onRemove }: { message: string; onRemove: () => void }) {
714 return (
715 <div
716 role="alert"
717 className="flex items-start gap-3 rounded-md border border-border bg-muted/40 p-3 text-sm"
718 >
719 <ShieldAlert className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
720 <div className="min-w-0 flex-1">
721 <p className="text-muted-foreground">{message}</p>
722 </div>
723 <button
724 type="button"
725 onClick={onRemove}
726 className="inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2 py-1 text-xs font-medium hover:bg-accent"
727 >
728 <Trash2 className="size-3" aria-hidden /> Remove
729 </button>
730 </div>
731 );
732}
733
734function UploadProgressCard({
735 block,
736 fileId,
737 progress,
738}: {
739 block: Block;
740 fileId: string;
741 progress: UploadProgress;
742}) {
743 const cancelFn = useUploadStore((s) => s.cancelFns[fileId]);
744
745 const announced = Math.min(100, Math.floor(progress.fraction * 100) / 25) * 25;
746 return (
747 <div className="w-full rounded-md border border-border p-3" role="status" aria-live="polite" aria-atomic="true">
748 <p className="sr-only">Uploading {block.properties.name ?? "file"}: {announced} percent</p>
749 <div className="flex items-center gap-3">
750 <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" aria-hidden />
751 <div className="min-w-0 flex-1">
752 <div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
753 <div
754 className="h-full rounded-full bg-primary transition-[width] duration-150"
755 style={{ width: `${Math.round(progress.fraction * 100)}%` }}
756 />
757 </div>
758 <p className="mt-1 text-xs text-muted-foreground">
759 {progress.fraction < 1
760 ? `${Math.round(progress.fraction * 100)} % · ${humanSize(progress.loaded)} of ${humanSize(progress.total)}${progress.speedBps > 0 ? ` · ${humanSize(progress.speedBps)}/s` : ""}`
761 : "Uploading complete — processing…"}
762 </p>
763 </div>
764 {progress.phase === "uploading" && cancelFn ? (
765 <button
766 type="button"
767 onClick={cancelFn}
768 className="inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2 py-1 text-xs font-medium hover:bg-accent"
769 >
770 <X className="size-3" aria-hidden /> Cancel
771 </button>
772 ) : null}
773 </div>
774 </div>
775 );
776}
777
778function CaptionRow({
779 open,
780 onToggle,
781 caption,
782 onSave,
783}: {
784 open: boolean;
785 onToggle: () => void;
786 caption: string;
787 onSave: (caption: string) => void;
788}) {
789 const [draft, setDraft] = useState(caption);
790 useEffect(() => setDraft(caption), [caption, open]);
791 return (
792 <div className="mt-1">
793 {open ? (
794 <input
795 autoFocus
796 value={draft}
797 onChange={(e) => setDraft(e.target.value)}
798 onBlur={() => onSave(draft)}
799 onKeyDown={(e) => {
800 if (e.key === "Enter" || e.key === "Escape") onSave(draft);
801 }}
802 placeholder="Add a caption…"
803 aria-label="Caption"
804 className="w-full rounded border border-border bg-transparent px-1 py-0.5 text-center text-xs text-muted-foreground outline-none focus:border-primary"
805 />
806 ) : (
807 <>
808 {caption ? (
809 <p className="text-center text-xs text-muted-foreground">{caption}</p>
810 ) : null}
811 <button
812 type="button"
813 onClick={onToggle}
814 className="mx-auto block rounded px-1.5 py-0.5 text-[11px] text-muted-foreground opacity-0 transition-opacity hover:opacity-100 focus:opacity-100 group-hover:opacity-100"
815 >
816 + Caption
817 </button>
818 </>
819 )}
820 </div>
821 );
822}
823
824
825
826
831function LegacyFileCard({ block }: { block: Block }) {
832 const { src, name, size, mimeType } = block.properties;
833 const sizeLabel = typeof size === "number" ? humanSize(size) : undefined;
834 return (
835 <div className="flex items-center gap-3 rounded-md border border-border p-3">
836 <span className="flex size-9 shrink-0 items-center justify-center rounded bg-muted">
837 <FileIcon className="size-4 text-muted-foreground" aria-hidden />
838 </span>
839 <span className="min-w-0 flex-1">
840 <span className="block truncate text-sm font-medium">{name ?? "File"}</span>
841 <span className="block text-xs text-muted-foreground">{[mimeType, sizeLabel].filter(Boolean).join(" · ")}</span>
842 </span>
843 {src ? (
844 <a
845 href={src}
846 target="_blank"
847 rel="noopener noreferrer"
848 download={typeof name === "string" ? name : undefined}
849 className="shrink-0 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent"
850 >
851 Download
852 </a>
853 ) : null}
854 </div>
855 );
856}
857
858function clampPercent(n: number): number {
859 if (!Number.isFinite(n)) return 100;
860 return Math.min(100, Math.max(25, n));
861}
862
863async function copyShareLink(fileId: string): Promise<void> {
864
865
866
867 const url = `${API_BASE}/files/${fileId}`;
868 try {
869 await navigator.clipboard.writeText(url);
870 toast("success", "Link copied");
871 } catch {
872 toast("error", "Copy failed", "This file's link requires workspace access.");
873 }
874}
875