@karimsa/mdxserve 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -2
  3. package/client/App.tsx +7 -0
  4. package/client/CodeBlock.tsx +395 -0
  5. package/client/CrossFade.tsx +72 -0
  6. package/client/DocContext.ts +14 -0
  7. package/client/DocView.tsx +107 -0
  8. package/client/ErrorBox.tsx +23 -0
  9. package/client/Heading.tsx +31 -0
  10. package/client/HomeEmptyState.tsx +101 -0
  11. package/client/HomeView.tsx +78 -0
  12. package/client/ListingView.tsx +663 -0
  13. package/client/MdSection.tsx +234 -0
  14. package/client/MdSectionEditor.tsx +233 -0
  15. package/client/Mermaid.tsx +435 -0
  16. package/client/RenderErrorBoundary.tsx +40 -0
  17. package/client/Table.tsx +14 -0
  18. package/client/TaskCheckbox.tsx +38 -0
  19. package/client/api.ts +138 -0
  20. package/client/app.css +372 -0
  21. package/client/builtins/Badge.tsx +109 -0
  22. package/client/builtins/Button.tsx +111 -0
  23. package/client/builtins/Callout.tsx +97 -0
  24. package/client/builtins/Card.tsx +111 -0
  25. package/client/builtins/Chart.tsx +875 -0
  26. package/client/builtins/Diff.tsx +722 -0
  27. package/client/builtins/Dropdown.tsx +417 -0
  28. package/client/builtins/FileTree.tsx +87 -0
  29. package/client/builtins/Kbd.tsx +18 -0
  30. package/client/builtins/Screenshot.tsx +209 -0
  31. package/client/builtins/Sparkline.tsx +63 -0
  32. package/client/builtins/Tabs.tsx +169 -0
  33. package/client/builtins/Tooltip.tsx +52 -0
  34. package/client/builtins/chart-data.ts +133 -0
  35. package/client/builtins/index.ts +167 -0
  36. package/client/design/base/editor.css +151 -0
  37. package/client/design/base/prose.css +143 -0
  38. package/client/design/base/reset.css +79 -0
  39. package/client/design/tokens/colors.css +188 -0
  40. package/client/design/tokens/elevation.css +42 -0
  41. package/client/design/tokens/fonts.css +6 -0
  42. package/client/design/tokens/motion.css +76 -0
  43. package/client/design/tokens/spacing.css +34 -0
  44. package/client/design/tokens/typography.css +56 -0
  45. package/client/doc-module-cache.ts +17 -0
  46. package/client/editor-link.ts +27 -0
  47. package/client/entry.tsx +51 -0
  48. package/client/export-doc.ts +80 -0
  49. package/client/export-save.ts +96 -0
  50. package/client/favicon.svg +1 -0
  51. package/client/file-system-access.d.ts +29 -0
  52. package/client/format.ts +17 -0
  53. package/client/hooks.ts +34 -0
  54. package/client/lucide-icons.d.ts +9 -0
  55. package/client/mdx-components-base.ts +32 -0
  56. package/client/mdx-components.ts +18 -0
  57. package/client/mermaid-chart.ts +109 -0
  58. package/client/mermaid-direction.ts +73 -0
  59. package/client/motion.ts +104 -0
  60. package/client/platform.ts +16 -0
  61. package/client/route-path.ts +15 -0
  62. package/client/router.ts +452 -0
  63. package/client/shell/AppShell.tsx +401 -0
  64. package/client/shell/Footer.tsx +33 -0
  65. package/client/shell/NotFoundView.tsx +22 -0
  66. package/client/shell/Sidebar.tsx +169 -0
  67. package/client/shell/StandaloneShell.tsx +65 -0
  68. package/client/shell/TocRail.tsx +53 -0
  69. package/client/shell/TopBar.tsx +117 -0
  70. package/client/shell/use-doc-width.ts +61 -0
  71. package/client/shell/useToc.ts +77 -0
  72. package/client/ssr-entry.tsx +22 -0
  73. package/client/standalone-entry.tsx +51 -0
  74. package/client/state.ts +90 -0
  75. package/client/theme.ts +65 -0
  76. package/client/ui/Breadcrumb.tsx +49 -0
  77. package/client/ui/ConfirmDeleteDialog.tsx +113 -0
  78. package/client/ui/ExpandModal.tsx +342 -0
  79. package/client/ui/Icon.tsx +114 -0
  80. package/client/ui/IconButton.tsx +63 -0
  81. package/client/ui/Kbd.tsx +17 -0
  82. package/client/ui/PageNav.tsx +77 -0
  83. package/client/ui/ResizeHandle.tsx +201 -0
  84. package/client/ui/SearchDialog.tsx +187 -0
  85. package/client/ui/Tag.tsx +44 -0
  86. package/client/ui/Toast.tsx +189 -0
  87. package/client/ui/TocList.tsx +71 -0
  88. package/client/ui/icon-set.ts +102 -0
  89. package/client/ui/toast-count.ts +28 -0
  90. package/dist/cli.js +5091 -0
  91. package/dist/registry.json +703 -0
  92. package/dist/render-worker.js +145 -0
  93. package/package.json +115 -5
  94. package/skills/mdxserve/SKILL.md +178 -0
@@ -0,0 +1,663 @@
1
+ import { AnimatePresence, motion } from "framer-motion";
2
+ import { useAtom, useSetAtom } from "jotai";
3
+ import { useMutation } from "@tanstack/react-query";
4
+ import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
5
+ import { queryClient, trpc } from "./api";
6
+ import { formatModified, formatSize } from "./format";
7
+ import { stagger, TRANSITIONS, VARIANTS } from "./motion";
8
+ import { Icon } from "./ui/Icon";
9
+ import { ResizeHandle } from "./ui/ResizeHandle";
10
+ import { ConfirmDeleteDialog } from "./ui/ConfirmDeleteDialog";
11
+ import { pushToast } from "./ui/Toast";
12
+ import type { ListingEntry } from "./router";
13
+ import Button from "./builtins/Button";
14
+ import Dropdown from "./builtins/Dropdown";
15
+ import {
16
+ LISTING_MAX_WIDTH,
17
+ LISTING_MIN_WIDTH,
18
+ listingSortAtom,
19
+ listingWidthAtom,
20
+ type SortKey,
21
+ } from "./state";
22
+
23
+ function sortEntries(entries: ListingEntry[], sort: SortKey): ListingEntry[] {
24
+ const byName = (first: ListingEntry, second: ListingEntry) =>
25
+ first.name.localeCompare(second.name, undefined, { sensitivity: "base" });
26
+ return [...entries].sort((first, second) => {
27
+ if (first.isDir !== second.isDir) return first.isDir ? -1 : 1;
28
+ if (sort === "modified") {
29
+ const diff = (second.mtime ?? 0) - (first.mtime ?? 0);
30
+ if (diff !== 0) return diff;
31
+ }
32
+ return byName(first, second);
33
+ });
34
+ }
35
+
36
+ const rowVariants = {
37
+ initial: { opacity: 0, y: 4 },
38
+ enter: { opacity: 1, y: 0 },
39
+ };
40
+
41
+ function Row({
42
+ href,
43
+ icon,
44
+ label,
45
+ labelHtml,
46
+ sublabel,
47
+ muted,
48
+ size,
49
+ mtime,
50
+ selectable = false,
51
+ checked = false,
52
+ selectionActive = false,
53
+ onToggle,
54
+ dense = false,
55
+ active = false,
56
+ }: {
57
+ href: string | null;
58
+ icon: "folder" | "file";
59
+ label: string;
60
+ /** Server-rendered inline HTML for the label; wins over `label` when set. */
61
+ labelHtml?: string;
62
+ /** Secondary line under the label (e.g. the filename when a title is shown). */
63
+ sublabel?: string;
64
+ muted: boolean;
65
+ size?: number;
66
+ mtime?: number;
67
+ /** Whether this row may be checked for bulk delete (files only; never dirs or ".."). */
68
+ selectable?: boolean;
69
+ checked?: boolean;
70
+ /** Whether any row in the listing is currently selected (keeps unchecked boxes visible). */
71
+ selectionActive?: boolean;
72
+ onToggle?: () => void;
73
+ /** Sidebar mode: tighter padding and smaller type, no checkbox gutter. */
74
+ dense?: boolean;
75
+ /** Sidebar mode: this row is the doc currently being viewed. */
76
+ active?: boolean;
77
+ }) {
78
+ const inner = (
79
+ <>
80
+ <Icon
81
+ name={icon === "folder" ? "folder" : "file-text"}
82
+ size={dense ? "sm" : "md"}
83
+ className="shrink-0 text-text-subtle"
84
+ />
85
+ <span className="flex min-w-0 flex-col">
86
+ {labelHtml ? (
87
+ <span
88
+ className={
89
+ "listing-title truncate font-sans font-medium leading-normal " +
90
+ (dense ? "text-[length:var(--size-sm)]" : "text-[length:var(--size-md)]") +
91
+ (active ? " font-semibold text-text-accent" : "")
92
+ }
93
+ dangerouslySetInnerHTML={{ __html: labelHtml }}
94
+ />
95
+ ) : (
96
+ <span
97
+ className={
98
+ "truncate font-sans font-medium leading-normal " +
99
+ (dense ? "text-[length:var(--size-sm)]" : "text-[length:var(--size-md)]") +
100
+ (active ? " font-semibold text-text-accent" : "")
101
+ }
102
+ >
103
+ {label}
104
+ </span>
105
+ )}
106
+ {sublabel ? (
107
+ <span
108
+ className={
109
+ "truncate font-mono font-normal leading-normal text-text-subtle " +
110
+ (dense ? "text-[length:var(--size-2xs)]" : "text-[length:var(--size-xs)]")
111
+ }
112
+ >
113
+ {sublabel}
114
+ </span>
115
+ ) : null}
116
+ </span>
117
+ {typeof mtime === "number" || typeof size === "number" ? (
118
+ <span className="ml-auto flex shrink-0 items-center gap-4 pl-4 font-mono font-normal leading-[1.62] text-[length:var(--size-xs)] text-text-subtle tabular-nums">
119
+ {typeof mtime === "number" ? (
120
+ <span title={new Date(mtime).toLocaleString()}>{formatModified(mtime)}</span>
121
+ ) : null}
122
+ {typeof size === "number" ? (
123
+ <span className="w-16 text-right">{formatSize(size)}</span>
124
+ ) : null}
125
+ </span>
126
+ ) : null}
127
+ </>
128
+ );
129
+
130
+ // Dense rows have no checkbox gutter, so give the link its own left inset
131
+ // or the icon sits flush against the active pill's edge.
132
+ const rowPadding = dense ? "py-1.5 pl-2" : "py-2";
133
+
134
+ return (
135
+ <motion.div
136
+ variants={rowVariants}
137
+ className={
138
+ "group flex items-center rounded-md " +
139
+ (active ? "relative " : "") +
140
+ (checked ? "bg-surface-active" : active ? "" : "hover:bg-surface-hover")
141
+ }
142
+ >
143
+ {active ? (
144
+ <motion.span
145
+ layoutId="sidebar-active"
146
+ transition={TRANSITIONS.glide}
147
+ className="absolute inset-0 z-0 rounded-md bg-surface-accent-soft"
148
+ aria-hidden="true"
149
+ />
150
+ ) : null}
151
+ {dense ? null : (
152
+ <span data-print-hide className="flex w-7 shrink-0 justify-center">
153
+ {selectable ? (
154
+ <input
155
+ type="checkbox"
156
+ checked={checked}
157
+ aria-label={`Select ${label}`}
158
+ onChange={onToggle}
159
+ className={
160
+ "size-3.5 cursor-pointer accent-[var(--teal-550)] " +
161
+ (checked || selectionActive
162
+ ? "opacity-100"
163
+ : "opacity-0 group-hover:opacity-100 focus-visible:opacity-100")
164
+ }
165
+ />
166
+ ) : null}
167
+ </span>
168
+ )}
169
+ {muted || !href ? (
170
+ <div
171
+ className={`flex min-w-0 flex-1 cursor-default items-center gap-2 ${rowPadding} pr-3 text-text-subtle`}
172
+ >
173
+ {inner}
174
+ </div>
175
+ ) : (
176
+ // Plain <a>: the router's global click delegation (client/router.ts)
177
+ // intercepts this for client-side navigation; no per-row handler needed.
178
+ // The checkbox above is a sibling, not a descendant, so it never triggers it.
179
+ <a
180
+ href={href}
181
+ aria-current={active ? "page" : undefined}
182
+ className={
183
+ `flex min-w-0 flex-1 items-center gap-2 ${rowPadding} pr-3` +
184
+ (active ? " relative z-10" : "")
185
+ }
186
+ >
187
+ {inner}
188
+ </a>
189
+ )}
190
+ </motion.div>
191
+ );
192
+ }
193
+
194
+ const DAY_PRESETS = [7, 14, 30, 90];
195
+ const DAY_MS = 86_400_000;
196
+
197
+ const MENU_ITEM_BASE =
198
+ "flex w-full items-center justify-between gap-4 rounded-md px-2.5 py-1.5 text-left font-sans text-[length:var(--size-sm)]";
199
+
200
+ function menuItemClass(enabled: boolean): string {
201
+ return enabled
202
+ ? `${MENU_ITEM_BASE} cursor-pointer hover:bg-surface-hover`
203
+ : `${MENU_ITEM_BASE} cursor-default opacity-45`;
204
+ }
205
+
206
+ /** One-shot command menu for bulk-selecting files by age; replaces the current selection. */
207
+ function SelectMenu({
208
+ fileEntries,
209
+ onSelect,
210
+ }: {
211
+ fileEntries: ListingEntry[];
212
+ onSelect: (names: Set<string>) => void;
213
+ }) {
214
+ const [open, setOpen] = useState(false);
215
+ // Snapshot "now" when the menu opens so the live counts don't drift while it's open.
216
+ const [now, setNow] = useState(0);
217
+ const [customDays, setCustomDays] = useState(3);
218
+ const rootRef = useRef<HTMLDivElement>(null);
219
+
220
+ function toggle() {
221
+ if (!open) setNow(Date.now());
222
+ setOpen((current) => !current);
223
+ }
224
+
225
+ function olderThan(days: number): ListingEntry[] {
226
+ return fileEntries.filter(
227
+ (entry) => typeof entry.mtime === "number" && now - entry.mtime > days * DAY_MS,
228
+ );
229
+ }
230
+
231
+ function select(names: Set<string>) {
232
+ onSelect(names);
233
+ setOpen(false);
234
+ }
235
+
236
+ // Close on outside click, mirroring client/builtins/Dropdown.tsx.
237
+ useEffect(() => {
238
+ if (!open) return;
239
+ const onPointerDown = (event: PointerEvent) => {
240
+ if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
241
+ };
242
+ document.addEventListener("pointerdown", onPointerDown);
243
+ return () => document.removeEventListener("pointerdown", onPointerDown);
244
+ }, [open]);
245
+
246
+ function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
247
+ if (event.key === "Escape") {
248
+ event.preventDefault();
249
+ setOpen(false);
250
+ }
251
+ }
252
+
253
+ const customMatches = olderThan(customDays);
254
+
255
+ return (
256
+ <div ref={rootRef} className="relative" onKeyDown={onKeyDown}>
257
+ <Button
258
+ variant="secondary"
259
+ size="sm"
260
+ icon="list-checks"
261
+ iconRight="chevron-down"
262
+ disabled={fileEntries.length === 0}
263
+ onClick={toggle}
264
+ >
265
+ Select
266
+ </Button>
267
+ <AnimatePresence>
268
+ {open ? (
269
+ <motion.div
270
+ {...VARIANTS.pop}
271
+ className="absolute left-0 top-full z-[var(--z-dropdown)] mt-1 min-w-56 rounded-lg border border-border-default bg-surface-raised p-1 shadow-md"
272
+ >
273
+ <button
274
+ type="button"
275
+ disabled={fileEntries.length === 0}
276
+ onClick={
277
+ fileEntries.length > 0
278
+ ? () => select(new Set(fileEntries.map((entry) => entry.name)))
279
+ : undefined
280
+ }
281
+ className={menuItemClass(fileEntries.length > 0)}
282
+ >
283
+ <span>All files</span>
284
+ <span className="font-mono text-[length:var(--size-xs)] text-text-subtle tabular-nums">
285
+ {fileEntries.length}
286
+ </span>
287
+ </button>
288
+ {DAY_PRESETS.map((days) => {
289
+ const matches = olderThan(days);
290
+ return (
291
+ <button
292
+ key={days}
293
+ type="button"
294
+ disabled={matches.length === 0}
295
+ onClick={
296
+ matches.length > 0
297
+ ? () => select(new Set(matches.map((entry) => entry.name)))
298
+ : undefined
299
+ }
300
+ className={menuItemClass(matches.length > 0)}
301
+ >
302
+ <span>Older than {days} days</span>
303
+ <span className="font-mono text-[length:var(--size-xs)] text-text-subtle tabular-nums">
304
+ {matches.length}
305
+ </span>
306
+ </button>
307
+ );
308
+ })}
309
+ <div className="my-1 border-t border-border-subtle" />
310
+ <div className="flex items-center gap-2 px-2.5 py-1.5 font-sans text-[length:var(--size-sm)]">
311
+ <span>Older than</span>
312
+ <input
313
+ type="number"
314
+ min={1}
315
+ value={customDays}
316
+ data-bare-focus
317
+ onChange={(event) => {
318
+ const next = Number(event.target.value);
319
+ setCustomDays(Number.isFinite(next) && next >= 1 ? next : 1);
320
+ }}
321
+ onKeyDown={(event) => {
322
+ if (event.key === "Enter" && customMatches.length > 0) {
323
+ event.preventDefault();
324
+ select(new Set(customMatches.map((entry) => entry.name)));
325
+ }
326
+ }}
327
+ className="w-12 rounded border border-border-default bg-surface-card px-1 py-0.5 text-center font-mono text-[length:var(--size-xs)] tabular-nums"
328
+ />
329
+ <span>days</span>
330
+ <span
331
+ className={
332
+ "ml-auto font-mono text-[length:var(--size-xs)] tabular-nums text-text-subtle " +
333
+ (customMatches.length > 0 ? "" : "opacity-45")
334
+ }
335
+ >
336
+ {customMatches.length}
337
+ </span>
338
+ </div>
339
+ </motion.div>
340
+ ) : null}
341
+ </AnimatePresence>
342
+ </div>
343
+ );
344
+ }
345
+
346
+ const SORT_OPTIONS = [
347
+ { value: "name", label: "Name" },
348
+ { value: "modified", label: "Last modified" },
349
+ ];
350
+
351
+ export interface ListingViewRoute {
352
+ path: string;
353
+ /** The served root this folder lives in (no trailing slash). */
354
+ rootDir: string;
355
+ entries: ListingEntry[];
356
+ }
357
+
358
+ export function ListingView({
359
+ route,
360
+ singleRoot,
361
+ mode = "page",
362
+ activePath,
363
+ }: {
364
+ route: ListingViewRoute;
365
+ /** Whether the server serves only one root — hides the top-of-root parent row. */
366
+ singleRoot: boolean;
367
+ /** "sidebar": slim rail beside a doc — no selection/delete, no mtime/size, no resize handles, icon-only sort. */
368
+ mode?: "page" | "sidebar";
369
+ /** Route path of the doc being viewed; its row gets the active pill (sidebar mode). */
370
+ activePath?: string;
371
+ }) {
372
+ const { path, rootDir, entries } = route;
373
+ const sidebar = mode === "sidebar";
374
+ const atRootTop = path === `${rootDir}/`;
375
+ const segments = path.split("/").filter(Boolean);
376
+ const parentSegments = segments.slice(0, -1);
377
+ const parentHref = atRootTop
378
+ ? "/"
379
+ : parentSegments.length
380
+ ? `/${parentSegments.join("/")}/`
381
+ : "/";
382
+ const showParentRow = !atRootTop || !singleRoot;
383
+
384
+ const [sort, setSort] = useAtom(listingSortAtom);
385
+ const sorted = useMemo(() => sortEntries(entries, sort), [entries, sort]);
386
+ // Non-doc files aren't navigable in the sidebar rail and just cost space there.
387
+ const visible = useMemo(
388
+ () => (sidebar ? sorted.filter((entry) => entry.isDir || entry.isDoc) : sorted),
389
+ [sorted, sidebar],
390
+ );
391
+ const setWidth = useSetAtom(listingWidthAtom);
392
+ const container = useRef<HTMLDivElement>(null);
393
+
394
+ const [selectedNames, setSelectedNames] = useState<Set<string>>(new Set());
395
+ const [confirmOpen, setConfirmOpen] = useState(false);
396
+ const [pending, setPending] = useState(false);
397
+ useEffect(() => {
398
+ setSelectedNames(new Set());
399
+ setConfirmOpen(false);
400
+ }, [path]);
401
+
402
+ const fileEntries = useMemo(() => entries.filter((entry) => !entry.isDir), [entries]);
403
+ // Drop names that left the listing (watcher push, external deletes) from the
404
+ // set itself — otherwise a file recreated with the same name would come back
405
+ // already checked.
406
+ useEffect(() => {
407
+ setSelectedNames((current) => {
408
+ const live = new Set(fileEntries.map((entry) => entry.name));
409
+ const next = new Set([...current].filter((name) => live.has(name)));
410
+ return next.size === current.size ? current : next;
411
+ });
412
+ }, [fileEntries]);
413
+ // Derived pruning as well, so mid-render staleness can't reach the UI or the POST.
414
+ const selected = useMemo(
415
+ () => fileEntries.filter((entry) => selectedNames.has(entry.name)),
416
+ [fileEntries, selectedNames],
417
+ );
418
+ const selectionActive = selected.length > 0;
419
+
420
+ const toggleSelected = useCallback((name: string) => {
421
+ setSelectedNames((current) => {
422
+ const next = new Set(current);
423
+ if (next.has(name)) next.delete(name);
424
+ else next.add(name);
425
+ return next;
426
+ });
427
+ }, []);
428
+
429
+ // If every selected file vanishes while the dialog is up (external delete +
430
+ // watcher refresh), close it rather than offering to delete nothing.
431
+ useEffect(() => {
432
+ if (confirmOpen && !pending && selected.length === 0) setConfirmOpen(false);
433
+ }, [confirmOpen, pending, selected.length]);
434
+
435
+ // Escape clears the selection, unless a popover (e.g. SelectMenu) already
436
+ // consumed it, or the confirm dialog is up (it handles its own Escape).
437
+ // Sidebar mode has no selection to clear, so skip wiring the listener.
438
+ useEffect(() => {
439
+ if (sidebar) return;
440
+ function onKeyDown(event: globalThis.KeyboardEvent) {
441
+ if (event.defaultPrevented || confirmOpen) return;
442
+ if (event.key === "Escape" && selectionActive) setSelectedNames(new Set());
443
+ }
444
+ document.addEventListener("keydown", onKeyDown);
445
+ return () => document.removeEventListener("keydown", onKeyDown);
446
+ }, [sidebar, confirmOpen, selectionActive]);
447
+
448
+ const trashMutation = useMutation(
449
+ trpc.moveDocsToTrash.mutationOptions({
450
+ onSuccess: () => queryClient.invalidateQueries(trpc.getFolderListing.queryFilter({ path })),
451
+ }),
452
+ );
453
+
454
+ // Ref, not just state: a second click in the same tick would see stale
455
+ // `pending` and fire a duplicate request before React re-renders.
456
+ const pendingRef = useRef(false);
457
+ const handleConfirm = async () => {
458
+ if (pendingRef.current) return;
459
+ pendingRef.current = true;
460
+ setPending(true);
461
+ const byPath = new Map(selected.map((entry) => [`${path}${entry.name}`, entry.name]));
462
+ try {
463
+ const data = await trashMutation.mutateAsync({ paths: [...byPath.keys()] });
464
+ setConfirmOpen(false);
465
+ if (data.failed.length === 0) {
466
+ setSelectedNames(new Set());
467
+ pushToast({
468
+ tone: "ok",
469
+ icon: "trash-2",
470
+ text: `Moved ${data.deleted.length} ${data.deleted.length === 1 ? "file" : "files"} to Trash`,
471
+ });
472
+ } else {
473
+ // Keep only the failures selected so the user can see and retry them.
474
+ setSelectedNames(
475
+ new Set(
476
+ data.failed
477
+ .map((failure) => byPath.get(failure.path))
478
+ .filter((name): name is string => name !== undefined),
479
+ ),
480
+ );
481
+ console.error(data.failed);
482
+ pushToast({
483
+ tone: "danger",
484
+ text: `Couldn't delete ${data.failed.length} of ${byPath.size} files`,
485
+ });
486
+ }
487
+ } catch {
488
+ pushToast({
489
+ tone: "danger",
490
+ text: "Couldn't reach the server",
491
+ });
492
+ } finally {
493
+ pendingRef.current = false;
494
+ setPending(false);
495
+ }
496
+ };
497
+
498
+ return (
499
+ <div ref={container} className="relative">
500
+ {sidebar ? null : (
501
+ <>
502
+ <ResizeHandle
503
+ side="left"
504
+ container={container}
505
+ onResize={setWidth}
506
+ label="Resize listing"
507
+ minWidth={LISTING_MIN_WIDTH}
508
+ maxWidth={LISTING_MAX_WIDTH}
509
+ />
510
+ <ResizeHandle
511
+ side="right"
512
+ container={container}
513
+ onResize={setWidth}
514
+ label="Resize listing"
515
+ minWidth={LISTING_MIN_WIDTH}
516
+ maxWidth={LISTING_MAX_WIDTH}
517
+ />
518
+ </>
519
+ )}
520
+ {sidebar ? (
521
+ <div data-print-hide className="mb-1 flex justify-end">
522
+ <Dropdown
523
+ icon="arrow-up-down"
524
+ size="sm"
525
+ label="Sort"
526
+ value={sort}
527
+ onChange={(next) => setSort(next as SortKey)}
528
+ options={SORT_OPTIONS}
529
+ />
530
+ </div>
531
+ ) : (
532
+ <div className="mb-2 flex items-center justify-between gap-2">
533
+ <div data-print-hide className="flex items-center gap-2">
534
+ <SelectMenu fileEntries={fileEntries} onSelect={setSelectedNames} />
535
+ {selectionActive ? (
536
+ <>
537
+ <span className="font-sans font-medium leading-normal text-[length:var(--size-sm)] text-text-subtle tabular-nums">
538
+ {selected.length} selected
539
+ </span>
540
+ <Button variant="ghost" size="sm" onClick={() => setSelectedNames(new Set())}>
541
+ Clear
542
+ </Button>
543
+ <Button
544
+ variant="danger"
545
+ size="sm"
546
+ icon="trash-2"
547
+ onClick={() => setConfirmOpen(true)}
548
+ >
549
+ Delete…
550
+ </Button>
551
+ </>
552
+ ) : null}
553
+ </div>
554
+ <div className="flex items-center gap-2 font-sans font-medium leading-normal text-[length:var(--size-sm)] text-text-subtle">
555
+ <span>Sort by</span>
556
+ <div className="w-40">
557
+ <Dropdown
558
+ size="sm"
559
+ value={sort}
560
+ onChange={(next) => setSort(next as SortKey)}
561
+ options={SORT_OPTIONS}
562
+ />
563
+ </div>
564
+ </div>
565
+ </div>
566
+ )}
567
+ <motion.div
568
+ key={sort}
569
+ variants={stagger}
570
+ initial="initial"
571
+ animate="enter"
572
+ className={
573
+ sidebar
574
+ ? "flex flex-col gap-0.5"
575
+ : "divide-y divide-border-subtle border-y border-border-subtle"
576
+ }
577
+ >
578
+ {showParentRow ? (
579
+ <Row
580
+ href={parentHref}
581
+ icon="folder"
582
+ label=".."
583
+ muted={false}
584
+ selectable={false}
585
+ dense={sidebar}
586
+ active={false}
587
+ />
588
+ ) : null}
589
+ {visible.map((entry: ListingEntry) => {
590
+ const href = `${path}${entry.name}${entry.isDir ? "/" : ""}`;
591
+ const common = {
592
+ label: entry.name,
593
+ ...(sidebar ? {} : { size: entry.size, mtime: entry.mtime }),
594
+ };
595
+ const dense = sidebar;
596
+ const active = sidebar && href === activePath;
597
+ if (entry.isDir) {
598
+ return (
599
+ <Row
600
+ key={entry.name}
601
+ href={href}
602
+ icon="folder"
603
+ muted={false}
604
+ selectable={false}
605
+ dense={dense}
606
+ active={active}
607
+ {...common}
608
+ />
609
+ );
610
+ }
611
+ const selection = sidebar
612
+ ? {}
613
+ : {
614
+ selectable: true,
615
+ checked: selectedNames.has(entry.name),
616
+ selectionActive,
617
+ onToggle: () => toggleSelected(entry.name),
618
+ };
619
+ if (entry.isDoc) {
620
+ return (
621
+ <Row
622
+ key={entry.name}
623
+ href={href}
624
+ icon="file"
625
+ muted={false}
626
+ dense={dense}
627
+ active={active}
628
+ {...common}
629
+ {...selection}
630
+ label={entry.title ?? entry.name}
631
+ labelHtml={entry.titleHtml}
632
+ sublabel={entry.title ? entry.name : undefined}
633
+ />
634
+ );
635
+ }
636
+ return (
637
+ <Row
638
+ key={entry.name}
639
+ href={null}
640
+ icon="file"
641
+ muted
642
+ dense={dense}
643
+ active={active}
644
+ {...common}
645
+ {...selection}
646
+ />
647
+ );
648
+ })}
649
+ </motion.div>
650
+ {sidebar ? null : (
651
+ <ConfirmDeleteDialog
652
+ open={confirmOpen}
653
+ files={selected}
654
+ pending={pending}
655
+ onCancel={() => {
656
+ if (!pending) setConfirmOpen(false);
657
+ }}
658
+ onConfirm={handleConfirm}
659
+ />
660
+ )}
661
+ </div>
662
+ );
663
+ }