1"use client";
2
3
14import { useMemo, useRef, useState } from "react";
15import { useQuery, useQueryClient } from "@tanstack/react-query";
16import { FileImage, Search, X } from "lucide-react";
17import { blockApi } from "@/lib/api";
18import { useEditorStore } from "@/stores/editor-store";
19import { filesApi, humanSize, kindForMime, type FileListItem } from "@/lib/files/api";
20import { filterByName, sortPickerFiles, type PickerSort } from "@/lib/files/picker";
21import { cn } from "@/lib/utils";
22import type { Block } from "@/lib/types";
23
24interface FilePickerProps {
25 workspaceId: string;
26 pageId: string;
27
28 onClose: () => void;
29}
30
31type Scope = "page" | "workspace";
32
33export function FilePicker({ workspaceId, pageId, onClose }: FilePickerProps) {
34 const [scope, setScope] = useState<Scope>("page");
35 const [search, setSearch] = useState("");
36 const [sort, setSort] = useState<PickerSort>("updated");
37 const gridRef = useRef<HTMLDivElement>(null);
38 const [activeIndex, setActiveIndex] = useState(0);
39 const queryClient = useQueryClient();
40
41 const listQuery = useQuery({
42 queryKey: ["files", "picker", workspaceId, scope, pageId],
43 queryFn: () =>
44 filesApi.list(
45 scope === "page" ? { scope: "page", pageId, workspaceId, limit: 100 } : { scope: "workspace", workspaceId, limit: 100 },
46 ),
47 });
48
49 const all = listQuery.data?.data ?? [];
50
51
52 const files = useMemo(() => sortPickerFiles(filterByName(all, search), sort), [all, search, sort]);
53
54
56 const insertAtCursor = async (file: FileListItem) => {
57 try {
58 const selectedId = useEditorStore.getState().selectedBlockId;
59 let position: string | undefined;
60 if (selectedId) {
61
62 const cached = queryClient.getQueryData<{ data: Block[] }>([
63 "pages",
64 pageId,
65 "blocks",
66 "top",
67 ]);
68 const sel = cached?.data?.find((b) => b.id === selectedId);
69 position = sel?.position;
70 }
71 await blockApi.insert({
72 pageId,
73 parentId: null,
74 type: "file",
75 properties: { fileId: file.id, name: file.name },
76 ...(position ? { position } : {}),
77 });
78 onClose();
79 } catch {
80
81
82 }
83 };
84
85 const onKeyDown = (e: React.KeyboardEvent) => {
86 if (e.key === "ArrowRight" || e.key === "ArrowDown" || e.key === "Tab") {
87 e.preventDefault();
88 setActiveIndex((i) => Math.min(files.length - 1, i + 1));
89 } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
90 e.preventDefault();
91 setActiveIndex((i) => Math.max(0, i - 1));
92 } else if (e.key === "Enter") {
93 e.preventDefault();
94 const f = files[activeIndex];
95 if (f) void insertAtCursor(f);
96 } else if (e.key === "Escape") {
97 onClose();
98 }
99
100 if (["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp"].includes(e.key)) {
101 requestAnimationFrame(() => {
102 gridRef.current?.querySelector<HTMLElement>('[data-tile-focus="true"]')?.focus();
103 });
104 }
105 };
106
107 return (
108 <div
109 className="absolute right-0 top-0 z-30 flex h-full w-80 flex-col border-l border-border bg-background shadow-xl"
110 role="dialog"
111 aria-label="Files"
112 onKeyDown={onKeyDown}
113 >
114 <div className="flex items-center justify-between border-b border-border p-3">
115 <h2 className="text-sm font-semibold">Files</h2>
116 <button type="button" onClick={onClose} aria-label="Close files panel" className="rounded p-1 text-muted-foreground hover:bg-accent">
117 <X className="size-4" aria-hidden />
118 </button>
119 </div>
120
121 <div className="space-y-2 border-b border-border p-3">
122 <div className="flex items-center gap-2">
123 <div className="flex flex-1 gap-1" role="group" aria-label="Filter by scope">
124 {(["page", "workspace"] as Scope[]).map((s) => (
125 <button
126 key={s}
127 type="button"
128 aria-pressed={scope === s}
129 onClick={() => setScope(s)}
130 className={cn(
131 "rounded-full border px-2.5 py-0.5 text-xs",
132 scope === s ? "border-primary bg-primary/10 text-primary" : "border-border text-muted-foreground hover:bg-accent",
133 )}
134 >
135 {s === "page" ? "In this page" : "In workspace"}
136 </button>
137 ))}
138 </div>
139 <select
140 value={sort}
141 onChange={(e) => setSort(e.target.value as PickerSort)}
142 aria-label="Sort files"
143 className="rounded-md border border-border bg-background px-1.5 py-0.5 text-xs"
144 >
145 <option value="updated">Updated</option>
146 <option value="name">Name</option>
147 <option value="size">Size</option>
148 <option value="date">Newest</option>
149 </select>
150 </div>
151 <label className="relative block">
152 <Search className="pointer-events-none absolute left-2 top-1.5 size-3.5 text-muted-foreground" aria-hidden />
153 <input
154 value={search}
155 onChange={(e) => {
156 setSearch(e.target.value);
157 setActiveIndex(0);
158 }}
159 placeholder="Search files by name"
160 aria-label="Search files by name"
161 className="w-full rounded-md border border-border bg-transparent py-1.5 pl-7 pr-2 text-sm outline-none focus:border-primary"
162 />
163 </label>
164 </div>
165
166 <div ref={gridRef} className="min-h-0 flex-1 overflow-y-auto p-3" role="listbox" aria-label="Files">
167 {listQuery.isLoading ? (
168 <p className="text-xs text-muted-foreground">Loading…</p>
169 ) : files.length === 0 ? (
170 <p className="px-1 py-2 text-xs text-muted-foreground">No files here yet — drag one in.</p>
171 ) : (
172 <ul className="grid grid-cols-2 gap-2">
173 {files.map((f, i) => (
174 <FileTile
175 key={f.id}
176 file={f}
177 focused={i === activeIndex}
178 onFocus={() => setActiveIndex(i)}
179 onInsert={() => void insertAtCursor(f)}
180 />
181 ))}
182 </ul>
183 )}
184 </div>
185 </div>
186 );
187}
188
189function FileTile({
190 file,
191 focused,
192 onFocus,
193 onInsert,
194}: {
195 file: FileListItem;
196 focused: boolean;
197 onFocus: () => void;
198 onInsert: () => void;
199}) {
200 const kind = kindForMime(file.mimeType);
201 return (
202 <li role="option" aria-selected={focused}>
203 <button
204 type="button"
205 data-tile-focus={focused ? "true" : undefined}
206 tabIndex={focused ? 0 : -1}
207 onFocus={onFocus}
208 onClick={onInsert}
209 title="Insert into the page"
210 className={cn(
211 "flex w-full flex-col gap-1 rounded-md border p-2 text-left outline-none focus:border-primary focus:ring-1 focus:ring-primary",
212 focused ? "border-primary" : "border-border hover:bg-accent",
213 )}
214 >
215 <span className="flex aspect-video w-full items-center justify-center overflow-hidden rounded bg-muted">
216 {file.thumbnailUrl && kind === "image" ? (
217
218 <img src={file.thumbnailUrl} alt="" className="size-full object-cover" />
219 ) : (
220 <FileImage className="size-5 text-muted-foreground" aria-hidden />
221 )}
222 </span>
223 <span className="block truncate text-xs font-medium">{file.name}</span>
224 <span className="block truncate text-[11px] text-muted-foreground">
225 {humanSize(file.size)}
226 {file.uploadedByName ? ` · ${file.uploadedByName}` : ""}
227 </span>
228 </button>
229 </li>
230 );
231}
232