"use client"; /** * TASK-0103 — the file picker (page sidebar, Requirements §4). * * "Files" tab listing the files of the active page's scope, with a * workspace/page filter chip, a client-side name search, a grid * (thumbnail, name, size, uploader, date) and click-to-insert. Inserting is * a BLOCK INSERT referencing the existing `fileId` (no re-upload) at the * selected block's position (or page end). * * A11y (§7): the grid is keyboard-navigable (roving tabindex + Enter). */ import { useMemo, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { FileImage, Search, X } from "lucide-react"; import { blockApi } from "@/lib/api"; import { useEditorStore } from "@/stores/editor-store"; import { filesApi, humanSize, kindForMime, type FileListItem } from "@/lib/files/api"; import { filterByName, sortPickerFiles, type PickerSort } from "@/lib/files/picker"; import { cn } from "@/lib/utils"; import type { Block } from "@/lib/types"; interface FilePickerProps { workspaceId: string; pageId: string; /** The current page id — the "In this page" scope filters by it. */ onClose: () => void; } type Scope = "page" | "workspace"; export function FilePicker({ workspaceId, pageId, onClose }: FilePickerProps) { const [scope, setScope] = useState("page"); const [search, setSearch] = useState(""); const [sort, setSort] = useState("updated"); const gridRef = useRef(null); const [activeIndex, setActiveIndex] = useState(0); const queryClient = useQueryClient(); const listQuery = useQuery({ queryKey: ["files", "picker", workspaceId, scope, pageId], queryFn: () => filesApi.list( scope === "page" ? { scope: "page", pageId, workspaceId, limit: 100 } : { scope: "workspace", workspaceId, limit: 100 }, ), }); const all = listQuery.data?.data ?? []; // Client-side filter (spec §4: "search box (client filter by name)") + // sort, both pure + unit-tested (lib/files/picker.ts). const files = useMemo(() => sortPickerFiles(filterByName(all, search), sort), [all, search, sort]); /** Insert the file as a `file` block at the cursor: after the selected * block's position (or at the end when nothing is selected). */ const insertAtCursor = async (file: FileListItem) => { try { const selectedId = useEditorStore.getState().selectedBlockId; let position: string | undefined; if (selectedId) { // the selected block's fractional position (from the cached list) const cached = queryClient.getQueryData<{ data: Block[] }>([ "pages", pageId, "blocks", "top", ]); const sel = cached?.data?.find((b) => b.id === selectedId); position = sel?.position; } await blockApi.insert({ pageId, parentId: null, type: "file", properties: { fileId: file.id, name: file.name }, ...(position ? { position } : {}), }); onClose(); } catch { // the block list refetches; a transient failure surfaces as a toast // from blockApi's caller-less path — keep the panel open for a retry } }; const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === "ArrowRight" || e.key === "ArrowDown" || e.key === "Tab") { e.preventDefault(); setActiveIndex((i) => Math.min(files.length - 1, i + 1)); } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); setActiveIndex((i) => Math.max(0, i - 1)); } else if (e.key === "Enter") { e.preventDefault(); const f = files[activeIndex]; if (f) void insertAtCursor(f); } else if (e.key === "Escape") { onClose(); } // keep the focused tile in view if (["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp"].includes(e.key)) { requestAnimationFrame(() => { gridRef.current?.querySelector('[data-tile-focus="true"]')?.focus(); }); } }; return (

Files

{(["page", "workspace"] as Scope[]).map((s) => ( ))}
{listQuery.isLoading ? (

Loading…

) : files.length === 0 ? (

No files here yet — drag one in.

) : (
    {files.map((f, i) => ( setActiveIndex(i)} onInsert={() => void insertAtCursor(f)} /> ))}
)}
); } function FileTile({ file, focused, onFocus, onInsert, }: { file: FileListItem; focused: boolean; onFocus: () => void; onInsert: () => void; }) { const kind = kindForMime(file.mimeType); return (
  • ); }