import { useState, useRef, useCallback } from "react"; import { Popover, ActionIcon, Text, UnstyledButton } from "@mantine/core"; import { IconPaperclip, IconUpload, IconFile, IconX, } from "@tabler/icons-react"; import { IBaseProperty } from "@/features/base/types/base.types"; import cellClasses from "@/features/base/styles/cells.module.css"; import { uploadFile } from "@/features/page/services/page-service"; import { getFileUrl } from "@/lib/config"; export type FileValue = { id: string; fileName: string; mimeType?: string; fileSize?: number; // `/api/files/{id}/{fileName}` — same shape the editor's attachment // node view uses. `getFileUrl` strips the `/api/` prefix and // prepends the backend host to produce a fetchable URL. Stored on // upload so the original filename round-trips even if the cell // value is moved to a row where the file's storage path no longer // resolves from the cell's pageId. url?: string; }; function buildFileUrl(file: Pick): string { return file.url ?? `/api/files/${file.id}/${encodeURIComponent(file.fileName)}`; } type CellFileProps = { value: unknown; property: IBaseProperty; rowId: string; isEditing: boolean; onCommit: (value: unknown) => void; onCancel: () => void; }; function formatFileSize(bytes?: number): string { if (!bytes) return ""; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function parseFiles(value: unknown): FileValue[] { if (!Array.isArray(value)) return []; return value.filter( (f): f is FileValue => f && typeof f === "object" && "id" in f && "fileName" in f, ); } export function CellFile({ value, property, isEditing, onCommit, onCancel, }: CellFileProps) { const files = parseFiles(value); const fileInputRef = useRef(null); const [uploading, setUploading] = useState(false); const handleRemove = useCallback( (fileId: string) => { const updated = files.filter((f) => f.id !== fileId); onCommit(updated.length > 0 ? updated : null); }, [files, onCommit], ); const handleUpload = useCallback( async (fileList: FileList | null) => { if (!fileList || fileList.length === 0) return; setUploading(true); const newFiles: FileValue[] = [...files]; // Reuse the page-attachment upload pipeline. A base IS a page // (isBase=true) — the server's /files/upload endpoint accepts the // base's pageId, runs the standard pageAccessService.validateCanEdit // check (which lines up with Base edit at the space-role level per // the casl rules), and stores the attachment via the same flow as // any other page attachment. for (const file of Array.from(fileList)) { try { const attachment = await uploadFile(file, property.pageId); newFiles.push({ id: attachment.id, fileName: attachment.fileName, mimeType: attachment.mimeType, fileSize: attachment.fileSize, url: `/api/files/${attachment.id}/${encodeURIComponent(attachment.fileName)}`, }); } catch (err) { console.error("File upload failed:", err); } } setUploading(false); onCommit(newFiles.length > 0 ? newFiles : null); }, [files, property.pageId, onCommit], ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); onCancel(); } }, [onCancel], ); const MAX_VISIBLE = 2; if (isEditing) { return (
{files.length === 0 && !uploading && ( No files attached )} {files.map((file) => (
{file.fileName} {file.fileSize != null && ( {formatFileSize(file.fileSize)} )} handleRemove(file.id)} >
))} { handleUpload(e.target.files); e.target.value = ""; }} /> fileInputRef.current?.click()} disabled={uploading} style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 0", marginTop: 4, fontSize: "var(--mantine-font-size-xs)", color: uploading ? "var(--mantine-color-gray-5)" : "var(--mantine-color-blue-6)", }} > {uploading ? "Uploading..." : "Add file"}
); } if (files.length === 0) { return ; } return ; } function FileList({ files, maxVisible, }: { files: FileValue[]; maxVisible: number; }) { const visible = files.slice(0, maxVisible); const overflow = files.length - maxVisible; return (
{visible.map((file) => ( {file.fileName} ))} {overflow > 0 && ( +{overflow} )}
); }