wiki-viewer 1.0.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 (119) hide show
  1. package/.next/standalone/.github/workflows/publish.yml +37 -0
  2. package/.next/standalone/DESIGN.md +521 -0
  3. package/.next/standalone/README.md +283 -0
  4. package/.next/standalone/bin/wiki-viewer.js +158 -0
  5. package/.next/standalone/next.config.ts +11 -0
  6. package/.next/standalone/package.json +120 -0
  7. package/.next/standalone/pnpm-lock.yaml +5654 -0
  8. package/.next/standalone/pnpm-workspace.yaml +2 -0
  9. package/.next/standalone/postcss.config.mjs +2 -0
  10. package/.next/standalone/public/logo.svg +11 -0
  11. package/.next/standalone/public/public/logo.svg +11 -0
  12. package/.next/standalone/server.js +44 -0
  13. package/.next/standalone/src/app/api/app-proxy/[...path]/route.ts +247 -0
  14. package/.next/standalone/src/app/api/assets/[...path]/route.ts +70 -0
  15. package/.next/standalone/src/app/api/system/browse/route.ts +78 -0
  16. package/.next/standalone/src/app/api/system/clear-root/route.ts +9 -0
  17. package/.next/standalone/src/app/api/system/config/route.ts +10 -0
  18. package/.next/standalone/src/app/api/system/pins/route.ts +31 -0
  19. package/.next/standalone/src/app/api/system/reveal/route.ts +28 -0
  20. package/.next/standalone/src/app/api/system/root-status/route.ts +9 -0
  21. package/.next/standalone/src/app/api/system/set-root/route.ts +23 -0
  22. package/.next/standalone/src/app/api/upload/[...path]/route.ts +80 -0
  23. package/.next/standalone/src/app/api/wiki/app/route.ts +41 -0
  24. package/.next/standalone/src/app/api/wiki/content/route.ts +60 -0
  25. package/.next/standalone/src/app/api/wiki/file/route.ts +52 -0
  26. package/.next/standalone/src/app/api/wiki/folder/route.ts +26 -0
  27. package/.next/standalone/src/app/api/wiki/move/route.ts +45 -0
  28. package/.next/standalone/src/app/api/wiki/page/route.ts +84 -0
  29. package/.next/standalone/src/app/api/wiki/route.ts +98 -0
  30. package/.next/standalone/src/app/api/wiki/slugs/route.ts +50 -0
  31. package/.next/standalone/src/app/api/wiki/upload/route.ts +87 -0
  32. package/.next/standalone/src/app/api/wiki/watch/route.ts +86 -0
  33. package/.next/standalone/src/app/globals.css +716 -0
  34. package/.next/standalone/src/app/icon.svg +11 -0
  35. package/.next/standalone/src/app/layout.tsx +35 -0
  36. package/.next/standalone/src/app/page.tsx +1509 -0
  37. package/.next/standalone/src/components/confirm-dialog.tsx +56 -0
  38. package/.next/standalone/src/components/dir-picker.tsx +393 -0
  39. package/.next/standalone/src/components/editor/bubble-menu.tsx +286 -0
  40. package/.next/standalone/src/components/editor/callout-extension.ts +36 -0
  41. package/.next/standalone/src/components/editor/csv-viewer.tsx +335 -0
  42. package/.next/standalone/src/components/editor/editor-toolbar.tsx +564 -0
  43. package/.next/standalone/src/components/editor/editor.tsx +633 -0
  44. package/.next/standalone/src/components/editor/extensions/drag-handle.ts +325 -0
  45. package/.next/standalone/src/components/editor/extensions/heading-anchors.ts +55 -0
  46. package/.next/standalone/src/components/editor/extensions/resizable-image.tsx +159 -0
  47. package/.next/standalone/src/components/editor/extensions.ts +148 -0
  48. package/.next/standalone/src/components/editor/file-fallback-viewer.tsx +74 -0
  49. package/.next/standalone/src/components/editor/folder-index.tsx +235 -0
  50. package/.next/standalone/src/components/editor/google-doc-viewer.tsx +73 -0
  51. package/.next/standalone/src/components/editor/image-viewer.tsx +56 -0
  52. package/.next/standalone/src/components/editor/link-popover.tsx +111 -0
  53. package/.next/standalone/src/components/editor/media-popover.tsx +211 -0
  54. package/.next/standalone/src/components/editor/media-viewer.tsx +69 -0
  55. package/.next/standalone/src/components/editor/mermaid-viewer.tsx +285 -0
  56. package/.next/standalone/src/components/editor/node-app-viewer.tsx +269 -0
  57. package/.next/standalone/src/components/editor/notebook-viewer.tsx +370 -0
  58. package/.next/standalone/src/components/editor/office/docx-viewer.tsx +92 -0
  59. package/.next/standalone/src/components/editor/office/office-chrome.tsx +82 -0
  60. package/.next/standalone/src/components/editor/office/pptx-viewer.tsx +103 -0
  61. package/.next/standalone/src/components/editor/office/xlsx-viewer.tsx +116 -0
  62. package/.next/standalone/src/components/editor/pdf-viewer.tsx +31 -0
  63. package/.next/standalone/src/components/editor/slash-commands.tsx +454 -0
  64. package/.next/standalone/src/components/editor/source-viewer.tsx +212 -0
  65. package/.next/standalone/src/components/editor/table-menu.tsx +232 -0
  66. package/.next/standalone/src/components/editor/website-viewer.tsx +74 -0
  67. package/.next/standalone/src/components/editor/wiki-link-create-dialog.tsx +188 -0
  68. package/.next/standalone/src/components/editor/wiki-link-decorator.ts +79 -0
  69. package/.next/standalone/src/components/editor/wiki-link-extension.ts +120 -0
  70. package/.next/standalone/src/components/editor/wiki-link-picker.tsx +250 -0
  71. package/.next/standalone/src/components/layout/viewer-toolbar.tsx +54 -0
  72. package/.next/standalone/src/components/theme-provider.tsx +16 -0
  73. package/.next/standalone/src/components/theme-toggle.tsx +24 -0
  74. package/.next/standalone/src/components/ui/badge.tsx +35 -0
  75. package/.next/standalone/src/components/ui/button.tsx +56 -0
  76. package/.next/standalone/src/components/ui/card.tsx +63 -0
  77. package/.next/standalone/src/components/ui/collapsible.tsx +9 -0
  78. package/.next/standalone/src/components/ui/command.tsx +150 -0
  79. package/.next/standalone/src/components/ui/context-menu.tsx +191 -0
  80. package/.next/standalone/src/components/ui/dialog.tsx +119 -0
  81. package/.next/standalone/src/components/ui/dropdown-menu.tsx +194 -0
  82. package/.next/standalone/src/components/ui/input.tsx +22 -0
  83. package/.next/standalone/src/components/ui/label.tsx +25 -0
  84. package/.next/standalone/src/components/ui/popover.tsx +33 -0
  85. package/.next/standalone/src/components/ui/scroll-area.tsx +47 -0
  86. package/.next/standalone/src/components/ui/select.tsx +156 -0
  87. package/.next/standalone/src/components/ui/separator.tsx +30 -0
  88. package/.next/standalone/src/components/ui/sheet.tsx +140 -0
  89. package/.next/standalone/src/components/ui/skeleton.tsx +15 -0
  90. package/.next/standalone/src/components/ui/switch.tsx +28 -0
  91. package/.next/standalone/src/components/ui/tabs.tsx +54 -0
  92. package/.next/standalone/src/components/ui/textarea.tsx +21 -0
  93. package/.next/standalone/src/components/ui/tip.tsx +23 -0
  94. package/.next/standalone/src/components/ui/toggle-group.tsx +41 -0
  95. package/.next/standalone/src/components/ui/tooltip.tsx +27 -0
  96. package/.next/standalone/src/components/wiki/frontmatter-header.tsx +164 -0
  97. package/.next/standalone/src/lib/app-runner.ts +231 -0
  98. package/.next/standalone/src/lib/cabinets/tree.ts +75 -0
  99. package/.next/standalone/src/lib/config.ts +32 -0
  100. package/.next/standalone/src/lib/embeds/detect.ts +142 -0
  101. package/.next/standalone/src/lib/google/detect.ts +107 -0
  102. package/.next/standalone/src/lib/markdown/parse-frontmatter.ts +84 -0
  103. package/.next/standalone/src/lib/markdown/remark-wikilinks.ts +100 -0
  104. package/.next/standalone/src/lib/markdown/to-html.ts +168 -0
  105. package/.next/standalone/src/lib/markdown/to-markdown.ts +238 -0
  106. package/.next/standalone/src/lib/root-dir.ts +35 -0
  107. package/.next/standalone/src/lib/toast.ts +9 -0
  108. package/.next/standalone/src/lib/utils.ts +6 -0
  109. package/.next/standalone/src/lib/wiki-helpers.ts +36 -0
  110. package/.next/standalone/src/stores/ai-panel-store.ts +23 -0
  111. package/.next/standalone/src/stores/editor-store.ts +244 -0
  112. package/.next/standalone/src/stores/tree-store.ts +52 -0
  113. package/.next/standalone/src/stores/wiki-slugs-store.ts +74 -0
  114. package/.next/standalone/src/types/index.ts +47 -0
  115. package/.next/standalone/tailwind.config.ts +201 -0
  116. package/.next/standalone/tsconfig.json +41 -0
  117. package/README.md +283 -0
  118. package/bin/wiki-viewer.js +158 -0
  119. package/package.json +120 -0
@@ -0,0 +1,1509 @@
1
+ "use client";
2
+
3
+ import {
4
+ AlertCircle,
5
+ Check,
6
+ ChevronDown,
7
+ ChevronRight,
8
+ File,
9
+ FileText,
10
+ Folder,
11
+ FolderOpen,
12
+ FolderPlus,
13
+ Globe,
14
+ Image as ImageIcon,
15
+ Loader2,
16
+ Maximize2,
17
+ PanelLeftClose,
18
+ PanelLeftOpen,
19
+ Pencil,
20
+ RefreshCw,
21
+ Terminal,
22
+ Trash2,
23
+ Upload,
24
+ X,
25
+ } from "lucide-react";
26
+ import { useCallback, useEffect, useRef, useState } from "react";
27
+ import ReactMarkdown from "react-markdown";
28
+ import remarkGfm from "remark-gfm";
29
+ import { ConfirmDialog } from "@/components/confirm-dialog";
30
+ import { CsvViewer } from "@/components/editor/csv-viewer";
31
+ import { KBEditor } from "@/components/editor/editor";
32
+ import { FileFallbackViewer } from "@/components/editor/file-fallback-viewer";
33
+ import { ImageViewer } from "@/components/editor/image-viewer";
34
+ import { MediaViewer } from "@/components/editor/media-viewer";
35
+ import { MermaidViewer } from "@/components/editor/mermaid-viewer";
36
+ import { NotebookViewer } from "@/components/editor/notebook-viewer";
37
+ import { DocxViewer } from "@/components/editor/office/docx-viewer";
38
+ import { PptxViewer } from "@/components/editor/office/pptx-viewer";
39
+ import { XlsxViewer } from "@/components/editor/office/xlsx-viewer";
40
+ import { PdfViewer } from "@/components/editor/pdf-viewer";
41
+ import { SourceViewer } from "@/components/editor/source-viewer";
42
+ import { WebsiteViewer } from "@/components/editor/website-viewer";
43
+ import { NodeAppViewer } from "@/components/editor/node-app-viewer";
44
+ import { DirPicker } from "@/components/dir-picker";
45
+ import { ThemeToggle } from "@/components/theme-toggle";
46
+ import { Button } from "@/components/ui/button";
47
+ import { Card } from "@/components/ui/card";
48
+ import { FrontmatterHeader } from "@/components/wiki/frontmatter-header";
49
+ import { parseFrontmatter } from "@/lib/markdown/parse-frontmatter";
50
+ import remarkWikilinks from "@/lib/markdown/remark-wikilinks";
51
+ import { showError } from "@/lib/toast";
52
+ import { cn } from "@/lib/utils";
53
+ import { useEditorStore } from "@/stores/editor-store";
54
+ import { useWikiSlugsStore } from "@/stores/wiki-slugs-store";
55
+
56
+ interface TreeNode {
57
+ name: string;
58
+ path: string;
59
+ type: "dir" | "file" | "app" | "node-app";
60
+ size?: number;
61
+ modifiedAt: string;
62
+ children?: TreeNode[];
63
+ expanded?: boolean;
64
+ loading?: boolean;
65
+ }
66
+
67
+ type ViewerKind =
68
+ | "editor"
69
+ | "csv"
70
+ | "pdf"
71
+ | "mermaid"
72
+ | "notebook"
73
+ | "image"
74
+ | "media"
75
+ | "docx"
76
+ | "xlsx"
77
+ | "pptx"
78
+ | "source"
79
+ | "fallback"
80
+ | "app"
81
+ | "html"
82
+ | "node-app"
83
+ | "text";
84
+
85
+ function ext(name: string) {
86
+ return name.split(".").pop()?.toLowerCase() ?? "";
87
+ }
88
+
89
+ function viewerKindFor(
90
+ filename: string,
91
+ nodeType: "file" | "app" | "dir" | "node-app",
92
+ ): ViewerKind {
93
+ if (nodeType === "node-app") return "node-app";
94
+ if (nodeType === "app") return "app";
95
+ if (nodeType === "dir") return "fallback";
96
+ const fileExt = ext(filename);
97
+ if (!fileExt) return "fallback";
98
+ if (["md", "markdown"].includes(fileExt)) return "editor";
99
+ if (fileExt === "txt") return "text";
100
+ if (["csv", "tsv"].includes(fileExt)) return "csv";
101
+ if (fileExt === "pdf") return "pdf";
102
+ if (["mmd", "mermaid"].includes(fileExt)) return "mermaid";
103
+ if (fileExt === "ipynb") return "notebook";
104
+ if (
105
+ ["png", "jpg", "jpeg", "gif", "webp", "svg", "avif", "ico", "bmp"].includes(
106
+ fileExt,
107
+ )
108
+ )
109
+ return "image";
110
+ if (
111
+ ["mp4", "webm", "mov", "m4v", "mp3", "wav", "ogg", "m4a", "aac"].includes(
112
+ fileExt,
113
+ )
114
+ )
115
+ return "media";
116
+ if (fileExt === "docx") return "docx";
117
+ if (["xlsx", "xlsm"].includes(fileExt)) return "xlsx";
118
+ if (fileExt === "pptx") return "pptx";
119
+ if (fileExt === "html") return "html";
120
+ if (
121
+ [
122
+ "py", "js", "ts", "tsx", "jsx", "go", "rs", "java", "c", "cpp", "h",
123
+ "sh", "bash", "zsh", "rb", "php", "swift", "kt", "lua", "sql", "yaml",
124
+ "yml", "toml", "json", "xml", "css", "scss",
125
+ ].includes(fileExt)
126
+ )
127
+ return "source";
128
+ return "fallback";
129
+ }
130
+
131
+ function isText(name: string) {
132
+ const kind = viewerKindFor(name, "file");
133
+ return kind === "editor" || kind === "text";
134
+ }
135
+ function isImage(name: string) {
136
+ return viewerKindFor(name, "file") === "image";
137
+ }
138
+ function isHtmlFile(name: string) {
139
+ return viewerKindFor(name, "file") === "html";
140
+ }
141
+
142
+ async function fetchDir(dir: string): Promise<TreeNode[]> {
143
+ const res = await fetch(`/api/wiki?dir=${encodeURIComponent(dir)}`);
144
+ if (!res.ok) return [];
145
+ const data: {
146
+ entries: Array<{
147
+ name: string;
148
+ type: "dir" | "file" | "app";
149
+ size?: number;
150
+ modifiedAt: string;
151
+ }>;
152
+ } = await res.json();
153
+ return data.entries.map((e) => ({
154
+ name: e.name,
155
+ path: dir ? `${dir}/${e.name}` : e.name,
156
+ type: e.type,
157
+ size: e.size,
158
+ modifiedAt: e.modifiedAt,
159
+ expanded: false,
160
+ }));
161
+ }
162
+
163
+ function updateNodes(
164
+ nodes: TreeNode[],
165
+ targetPath: string,
166
+ updater: (n: TreeNode) => TreeNode,
167
+ ): TreeNode[] {
168
+ return nodes.map((n) => {
169
+ if (n.path === targetPath) return updater(n);
170
+ if (n.children)
171
+ return { ...n, children: updateNodes(n.children, targetPath, updater) };
172
+ return n;
173
+ });
174
+ }
175
+
176
+ function removeNode(nodes: TreeNode[], targetPath: string): TreeNode[] {
177
+ return nodes
178
+ .filter((n) => n.path !== targetPath)
179
+ .map((n) =>
180
+ n.children ? { ...n, children: removeNode(n.children, targetPath) } : n,
181
+ );
182
+ }
183
+
184
+ export default function Page() {
185
+ const slugsLoadedAt = useWikiSlugsStore((s) => s.loadedAt);
186
+ useEffect(() => {
187
+ void useWikiSlugsStore.getState().load();
188
+ }, []);
189
+ void slugsLoadedAt;
190
+
191
+ // null = checking, false = not set, true = ready
192
+ const [rootConfigured, setRootConfigured] = useState<boolean | null>(null);
193
+ const [rootPath, setRootPath] = useState<string | null>(null);
194
+
195
+ useEffect(() => {
196
+ fetch("/api/system/root-status")
197
+ .then((r) => r.json())
198
+ .then((d: { configured: boolean; path: string | null }) => {
199
+ setRootConfigured(d.configured);
200
+ setRootPath(d.path);
201
+ })
202
+ .catch(() => setRootConfigured(false));
203
+ }, []);
204
+
205
+ const editorCurrentPath = useEditorStore((s) => s.currentPath);
206
+
207
+ const [roots, setRoots] = useState<TreeNode[]>([]);
208
+ const [rootLoaded, setRootLoaded] = useState(false);
209
+ const [rootLoading, setRootLoading] = useState(false);
210
+ const rootLoadingRef = useRef(false);
211
+ const [refreshingTree, setRefreshingTree] = useState(false);
212
+
213
+ const [uploading, setUploading] = useState(false);
214
+ const [uploadError, setUploadError] = useState<string | null>(null);
215
+ const fileInputRef = useRef<HTMLInputElement>(null);
216
+ const uploadDirRef = useRef<string>("");
217
+
218
+ const [newFolderParent, setNewFolderParent] = useState<string | null>(null);
219
+ const [newFolderName, setNewFolderName] = useState("");
220
+ const [folderError, setFolderError] = useState<string | null>(null);
221
+
222
+ const [deletingPath, setDeletingPath] = useState<string | null>(null);
223
+ const [deletingIsDir, setDeletingIsDir] = useState(false);
224
+
225
+ const [openFile, setOpenFile] = useState<{
226
+ path: string;
227
+ name: string;
228
+ nodeType: "file" | "app" | "node-app";
229
+ } | null>(null);
230
+ const [appFullscreen, setAppFullscreen] = useState(false);
231
+ const [appKey, setAppKey] = useState(0);
232
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
233
+ const [fileContent, setFileContent] = useState<string | null>(null);
234
+ const [fileLoading, setFileLoading] = useState(false);
235
+ const [editing, setEditing] = useState(false);
236
+ const [editContent, setEditContent] = useState("");
237
+ const [saving, setSaving] = useState(false);
238
+ const [saveError, setSaveError] = useState<string | null>(null);
239
+
240
+ // Drag state
241
+ const dragNodeRef = useRef<TreeNode | null>(null);
242
+ const [dragOverPath, setDragOverPath] = useState<string | null>(null);
243
+
244
+ // Refs for watcher handler (avoids stale closures in the SSE effect)
245
+ const openFileRef = useRef<typeof openFile>(null);
246
+ const editingRef = useRef(false);
247
+ const refreshViewerRef = useRef<() => Promise<void>>(async () => {});
248
+
249
+ useEffect(() => {
250
+ if (rootLoaded || rootLoadingRef.current) return;
251
+ rootLoadingRef.current = true;
252
+ setRootLoading(true);
253
+
254
+ fetchDir("")
255
+ .then((nodes) => {
256
+ setRoots(nodes);
257
+ setRootLoaded(true);
258
+ setRootLoading(false);
259
+ })
260
+ .catch(() => {
261
+ rootLoadingRef.current = false;
262
+ setRootLoading(false);
263
+ });
264
+ }, [rootLoaded]);
265
+
266
+ const reloadDir = useCallback(async (dir: string) => {
267
+ const fresh = await fetchDir(dir);
268
+ if (dir === "") {
269
+ setRoots(fresh);
270
+ } else {
271
+ setRoots((prev) =>
272
+ updateNodes(prev, dir, (n) => ({
273
+ ...n,
274
+ children: fresh,
275
+ expanded: true,
276
+ })),
277
+ );
278
+ }
279
+ }, []);
280
+
281
+ const collectExpandedPaths = useCallback((nodes: TreeNode[]): string[] => {
282
+ const paths: string[] = [];
283
+ for (const n of nodes) {
284
+ if ((n.type === "dir" || n.type === "app") && n.expanded && n.children) {
285
+ paths.push(n.path);
286
+ paths.push(...collectExpandedPaths(n.children));
287
+ }
288
+ }
289
+ return paths;
290
+ }, []);
291
+
292
+ const refreshTree = useCallback(async () => {
293
+ setRefreshingTree(true);
294
+ try {
295
+ const expandedPaths = collectExpandedPaths(roots);
296
+ const fresh = await fetchDir("");
297
+ setRoots(fresh);
298
+ for (const p of expandedPaths) {
299
+ const dirFresh = await fetchDir(p);
300
+ setRoots((prev) =>
301
+ updateNodes(prev, p, (n) => ({
302
+ ...n,
303
+ children: dirFresh,
304
+ expanded: true,
305
+ })),
306
+ );
307
+ }
308
+ } finally {
309
+ setRefreshingTree(false);
310
+ }
311
+ }, [roots, collectExpandedPaths]);
312
+
313
+ const refreshViewer = useCallback(async () => {
314
+ if (!openFile) return;
315
+ const kind = viewerKindFor(openFile.name, openFile.nodeType);
316
+ if (!["editor", "text"].includes(kind)) return;
317
+ setFileLoading(true);
318
+ try {
319
+ const res = await fetch(
320
+ `/api/wiki/content?path=${encodeURIComponent(openFile.path)}`,
321
+ );
322
+ if (res.ok) {
323
+ const d: { content: string } = await res.json();
324
+ setFileContent(d.content);
325
+ }
326
+ } catch {
327
+ /* ignore */
328
+ }
329
+ setFileLoading(false);
330
+ }, [openFile]);
331
+
332
+ // Keep watcher refs in sync
333
+ useEffect(() => {
334
+ openFileRef.current = openFile;
335
+ }, [openFile]);
336
+ useEffect(() => {
337
+ editingRef.current = editing;
338
+ }, [editing]);
339
+ useEffect(() => {
340
+ refreshViewerRef.current = refreshViewer;
341
+ }, [refreshViewer]);
342
+
343
+ // File watcher: auto-update tree + open file via SSE
344
+ useEffect(() => {
345
+ if (!rootConfigured) return;
346
+
347
+ const pendingReloads = new Map<string, ReturnType<typeof setTimeout>>();
348
+
349
+ function scheduleReload(dir: string) {
350
+ if (pendingReloads.has(dir)) clearTimeout(pendingReloads.get(dir)!);
351
+ pendingReloads.set(
352
+ dir,
353
+ setTimeout(() => {
354
+ pendingReloads.delete(dir);
355
+ void reloadDir(dir);
356
+ }, 300),
357
+ );
358
+ }
359
+
360
+ const es = new EventSource("/api/wiki/watch");
361
+
362
+ es.onmessage = (event) => {
363
+ let data: { type: string; path: string };
364
+ try {
365
+ data = JSON.parse(event.data as string) as {
366
+ type: string;
367
+ path: string;
368
+ };
369
+ } catch {
370
+ return;
371
+ }
372
+ const { type, path: relPath } = data;
373
+
374
+ // Reload the affected parent dir in the tree
375
+ const parts = relPath.split("/");
376
+ const parentDir =
377
+ parts.length > 1 ? parts.slice(0, -1).join("/") : "";
378
+ scheduleReload(parentDir);
379
+
380
+ // If the open file changed externally and we're not editing, refresh it
381
+ if (
382
+ type === "change" &&
383
+ openFileRef.current?.path === relPath &&
384
+ !editingRef.current
385
+ ) {
386
+ const key = `__file__${relPath}`;
387
+ if (pendingReloads.has(key)) clearTimeout(pendingReloads.get(key)!);
388
+ pendingReloads.set(
389
+ key,
390
+ setTimeout(() => {
391
+ pendingReloads.delete(key);
392
+ void refreshViewerRef.current();
393
+ }, 400),
394
+ );
395
+ }
396
+ };
397
+
398
+ return () => {
399
+ es.close();
400
+ for (const t of pendingReloads.values()) clearTimeout(t);
401
+ };
402
+ }, [rootConfigured, reloadDir]);
403
+
404
+ async function toggleFolder(node: TreeNode) {
405
+ if (node.type !== "dir" && node.type !== "app") return;
406
+ if (!node.expanded) {
407
+ if (node.children === undefined) {
408
+ setRoots((prev) =>
409
+ updateNodes(prev, node.path, (n) => ({ ...n, loading: true })),
410
+ );
411
+ const children = await fetchDir(node.path);
412
+ setRoots((prev) =>
413
+ updateNodes(prev, node.path, (n) => ({
414
+ ...n,
415
+ loading: false,
416
+ children,
417
+ expanded: true,
418
+ })),
419
+ );
420
+ } else {
421
+ setRoots((prev) =>
422
+ updateNodes(prev, node.path, (n) => ({ ...n, expanded: true })),
423
+ );
424
+ }
425
+ } else {
426
+ setRoots((prev) =>
427
+ updateNodes(prev, node.path, (n) => ({ ...n, expanded: false })),
428
+ );
429
+ }
430
+ }
431
+
432
+ useEffect(() => {
433
+ setAppFullscreen(false);
434
+ }, []);
435
+
436
+ async function openViewer(node: TreeNode) {
437
+ setOpenFile({
438
+ path: node.path,
439
+ name: node.name,
440
+ nodeType:
441
+ node.type === "app"
442
+ ? "app"
443
+ : node.type === "node-app"
444
+ ? "node-app"
445
+ : "file",
446
+ });
447
+ setEditing(false);
448
+ setSaveError(null);
449
+ setFileContent(null);
450
+ const kind = viewerKindFor(node.name, node.type);
451
+ if (!["editor", "text"].includes(kind)) return;
452
+ setFileLoading(true);
453
+ try {
454
+ const res = await fetch(
455
+ `/api/wiki/content?path=${encodeURIComponent(node.path)}`,
456
+ );
457
+ if (res.ok) {
458
+ const d: { content: string } = await res.json();
459
+ setFileContent(d.content);
460
+ }
461
+ } catch {
462
+ /* ignore */
463
+ }
464
+ setFileLoading(false);
465
+ }
466
+
467
+ // biome-ignore lint/correctness/useExhaustiveDependencies: only react to editor path changes
468
+ useEffect(() => {
469
+ if (!editorCurrentPath) return;
470
+ if (openFile && openFile.path === editorCurrentPath) return;
471
+ const name = editorCurrentPath.split("/").pop() ?? editorCurrentPath;
472
+ void openViewer({
473
+ path: editorCurrentPath,
474
+ name,
475
+ type: "file",
476
+ } as TreeNode);
477
+ }, [editorCurrentPath]);
478
+
479
+ const handleChangeDir = async () => {
480
+ await fetch("/api/system/clear-root", { method: "POST" });
481
+ setOpenFile(null);
482
+ setFileContent(null);
483
+ setEditing(false);
484
+ setRoots([]);
485
+ setRootLoaded(false);
486
+ rootLoadingRef.current = false;
487
+ setRootPath(null);
488
+ setRootConfigured(false);
489
+ };
490
+
491
+ async function handleSave() {
492
+ if (!openFile) return;
493
+ setSaving(true);
494
+ setSaveError(null);
495
+ const res = await fetch("/api/wiki/content", {
496
+ method: "PUT",
497
+ headers: { "Content-Type": "application/json" },
498
+ body: JSON.stringify({ path: openFile.path, content: editContent }),
499
+ });
500
+ if (res.ok) {
501
+ setFileContent(editContent);
502
+ setEditing(false);
503
+ } else {
504
+ const e: { error?: string } = await res.json();
505
+ setSaveError(e.error ?? "Save failed");
506
+ }
507
+ setSaving(false);
508
+ }
509
+
510
+ const doUpload = useCallback(
511
+ async (files: FileList | File[], dir: string) => {
512
+ const list = Array.from(files);
513
+ if (!list.length) return;
514
+ setUploading(true);
515
+ setUploadError(null);
516
+ try {
517
+ for (const file of list) {
518
+ const fd = new FormData();
519
+ fd.append("file", file);
520
+ fd.append("dir", dir);
521
+ const res = await fetch("/api/wiki/upload", {
522
+ method: "POST",
523
+ body: fd,
524
+ });
525
+ if (!res.ok) {
526
+ const e: { error?: string } = await res.json();
527
+ setUploadError(e.error ?? "Upload failed");
528
+ showError(e.error ?? "Upload failed");
529
+ break;
530
+ }
531
+ }
532
+ await reloadDir(dir);
533
+ } catch {
534
+ setUploadError("Upload failed.");
535
+ } finally {
536
+ setUploading(false);
537
+ if (fileInputRef.current) fileInputRef.current.value = "";
538
+ }
539
+ },
540
+ [reloadDir],
541
+ );
542
+
543
+ function triggerUpload(dir: string) {
544
+ uploadDirRef.current = dir;
545
+ fileInputRef.current?.click();
546
+ }
547
+
548
+ async function handleCreateFolder() {
549
+ const name = newFolderName.trim();
550
+ if (!name || newFolderParent === null) return;
551
+ setFolderError(null);
552
+ const rel = newFolderParent ? `${newFolderParent}/${name}` : name;
553
+ const res = await fetch("/api/wiki/folder", {
554
+ method: "POST",
555
+ headers: { "Content-Type": "application/json" },
556
+ body: JSON.stringify({ path: rel }),
557
+ });
558
+ if (res.ok) {
559
+ setNewFolderParent(null);
560
+ setNewFolderName("");
561
+ await reloadDir(newFolderParent);
562
+ if (newFolderParent !== "") {
563
+ setRoots((prev) =>
564
+ updateNodes(prev, newFolderParent, (n) => ({ ...n, expanded: true })),
565
+ );
566
+ }
567
+ } else {
568
+ const e: { error?: string } = await res.json();
569
+ setFolderError(e.error ?? "Failed");
570
+ }
571
+ }
572
+
573
+ async function handleDelete() {
574
+ if (!deletingPath) return;
575
+ await fetch("/api/wiki", {
576
+ method: "DELETE",
577
+ headers: { "Content-Type": "application/json" },
578
+ body: JSON.stringify({ path: deletingPath }),
579
+ });
580
+ if (
581
+ openFile?.path === deletingPath ||
582
+ openFile?.path.startsWith(`${deletingPath}/`)
583
+ ) {
584
+ setOpenFile(null);
585
+ setFileContent(null);
586
+ }
587
+ setRoots((prev) => removeNode(prev, deletingPath));
588
+ setDeletingPath(null);
589
+ }
590
+
591
+ function handleDragStart(e: React.DragEvent, node: TreeNode) {
592
+ dragNodeRef.current = node;
593
+ e.dataTransfer.effectAllowed = "move";
594
+ e.dataTransfer.setData("text/plain", node.path);
595
+ }
596
+
597
+ function handleDragOver(
598
+ e: React.DragEvent,
599
+ targetPath: string,
600
+ targetType: "dir" | "root",
601
+ ) {
602
+ e.preventDefault();
603
+ e.stopPropagation();
604
+ const dragging = dragNodeRef.current;
605
+ if (!dragging) return;
606
+ if (
607
+ dragging.path === targetPath ||
608
+ targetPath.startsWith(`${dragging.path}/`)
609
+ )
610
+ return;
611
+ e.dataTransfer.dropEffect = "move";
612
+ setDragOverPath(targetType === "root" ? "" : targetPath);
613
+ }
614
+
615
+ async function handleDropOnFolder(e: React.DragEvent, targetDirPath: string) {
616
+ e.preventDefault();
617
+ e.stopPropagation();
618
+ setDragOverPath(null);
619
+ const node = dragNodeRef.current;
620
+ dragNodeRef.current = null;
621
+ if (!node) return;
622
+ if (
623
+ node.path === targetDirPath ||
624
+ targetDirPath.startsWith(`${node.path}/`)
625
+ )
626
+ return;
627
+
628
+ const newPath = targetDirPath ? `${targetDirPath}/${node.name}` : node.name;
629
+ if (newPath === node.path) return;
630
+
631
+ const res = await fetch("/api/wiki/move", {
632
+ method: "POST",
633
+ headers: { "Content-Type": "application/json" },
634
+ body: JSON.stringify({ from: node.path, to: newPath }),
635
+ });
636
+ if (res.ok) {
637
+ const sourceParent = node.path.includes("/")
638
+ ? node.path.split("/").slice(0, -1).join("/")
639
+ : "";
640
+ await reloadDir(sourceParent);
641
+ if (targetDirPath !== sourceParent) await reloadDir(targetDirPath);
642
+ if (openFile?.path === node.path)
643
+ setOpenFile({
644
+ path: newPath,
645
+ name: node.name,
646
+ nodeType: openFile.nodeType,
647
+ });
648
+ }
649
+ }
650
+
651
+ const openFileViewerKind = openFile
652
+ ? viewerKindFor(openFile.name, openFile.nodeType)
653
+ : null;
654
+
655
+ function renderNodes(nodes: TreeNode[], depth = 0): React.ReactNode {
656
+ return nodes.map((node) => (
657
+ <div key={node.path}>
658
+ <div
659
+ role="treeitem"
660
+ tabIndex={0}
661
+ draggable
662
+ onKeyDown={(e) => {
663
+ if (e.key === "Enter" || e.key === " ") {
664
+ e.preventDefault();
665
+ if (node.type === "dir") toggleFolder(node);
666
+ else if (node.type === "app") { openViewer(node); toggleFolder(node); }
667
+ else openViewer(node);
668
+ }
669
+ }}
670
+ onDragStart={(e) => handleDragStart(e, node)}
671
+ onDragOver={(e) =>
672
+ node.type === "dir"
673
+ ? handleDragOver(e, node.path, "dir")
674
+ : e.preventDefault()
675
+ }
676
+ onDragLeave={() => setDragOverPath(null)}
677
+ onDrop={(e) =>
678
+ node.type === "dir"
679
+ ? handleDropOnFolder(e, node.path)
680
+ : e.preventDefault()
681
+ }
682
+ className={cn(
683
+ "flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm cursor-pointer group transition-colors select-none",
684
+ openFile?.path === node.path
685
+ ? "bg-accent text-accent-foreground"
686
+ : "hover:bg-accent/50",
687
+ dragOverPath === node.path && "ring-2 ring-primary bg-primary-soft",
688
+ node.name.startsWith(".") && "opacity-40",
689
+ )}
690
+ style={{ paddingLeft: `${depth * 14 + 8}px` }}
691
+ onClick={() => {
692
+ if (node.type === "dir") toggleFolder(node);
693
+ else if (node.type === "app") { openViewer(node); toggleFolder(node); }
694
+ else openViewer(node);
695
+ }}
696
+ >
697
+ {(node.type === "dir" || node.type === "app") ? (
698
+ node.loading ? (
699
+ <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
700
+ ) : node.expanded ? (
701
+ <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
702
+ ) : (
703
+ <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
704
+ )
705
+ ) : (
706
+ <span className="w-3.5 shrink-0" />
707
+ )}
708
+
709
+ {node.type === "dir" ? (
710
+ node.expanded ? (
711
+ <FolderOpen className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-warning")} />
712
+ ) : (
713
+ <Folder className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-warning")} />
714
+ )
715
+ ) : node.type === "app" ? (
716
+ <Globe className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-accent")} />
717
+ ) : node.type === "node-app" ? (
718
+ <Terminal className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-emerald-500")} />
719
+ ) : isHtmlFile(node.name) ? (
720
+ <Globe className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-accent/70")} />
721
+ ) : isImage(node.name) ? (
722
+ <ImageIcon className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-sunshine-700")} />
723
+ ) : isText(node.name) ? (
724
+ <FileText className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-accent")} />
725
+ ) : (
726
+ <File className={cn("h-4 w-4 shrink-0", openFile?.path !== node.path && "text-muted-foreground")} />
727
+ )}
728
+
729
+ <span className="flex-1 truncate">{node.name}</span>
730
+
731
+ <div
732
+ className="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
733
+ onClick={(e) => e.stopPropagation()}
734
+ onKeyDown={(e) => e.stopPropagation()}
735
+ >
736
+ {node.type === "dir" && (
737
+ <>
738
+ <Button
739
+ size="sm"
740
+ variant="ghost"
741
+ className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
742
+ title="Upload here"
743
+ onClick={() => triggerUpload(node.path)}
744
+ >
745
+ <Upload className="h-3 w-3" />
746
+ </Button>
747
+ <Button
748
+ size="sm"
749
+ variant="ghost"
750
+ className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
751
+ title="New subfolder"
752
+ onClick={() => {
753
+ setNewFolderParent(node.path);
754
+ setNewFolderName("");
755
+ setFolderError(null);
756
+ }}
757
+ >
758
+ <FolderPlus className="h-3 w-3" />
759
+ </Button>
760
+ </>
761
+ )}
762
+ <Button
763
+ size="sm"
764
+ variant="ghost"
765
+ className="h-6 w-6 p-0 text-destructive hover:text-destructive"
766
+ title="Delete"
767
+ onClick={() => {
768
+ setDeletingPath(node.path);
769
+ setDeletingIsDir(node.type !== "file");
770
+ }}
771
+ >
772
+ <Trash2 className="h-3 w-3" />
773
+ </Button>
774
+ </div>
775
+ </div>
776
+
777
+ {newFolderParent === node.path && node.type === "dir" && (
778
+ <div
779
+ className="flex items-center gap-1.5 px-2 py-1"
780
+ style={{ paddingLeft: `${(depth + 1) * 14 + 8}px` }}
781
+ >
782
+ <span className="w-3.5 shrink-0" />
783
+ <Folder className="h-4 w-4 shrink-0 text-warning" />
784
+ <input
785
+ className="flex-1 bg-transparent text-sm outline-none border-b border-border min-w-0"
786
+ placeholder="Folder name"
787
+ value={newFolderName}
788
+ onChange={(e) => setNewFolderName(e.target.value)}
789
+ onKeyDown={(e) => {
790
+ if (e.key === "Enter") handleCreateFolder();
791
+ if (e.key === "Escape") {
792
+ setNewFolderParent(null);
793
+ setNewFolderName("");
794
+ }
795
+ }}
796
+ />
797
+ {folderError && (
798
+ <span className="text-xs text-destructive">{folderError}</span>
799
+ )}
800
+ <Button
801
+ size="sm"
802
+ variant="ghost"
803
+ className="h-6 w-6 p-0"
804
+ onClick={handleCreateFolder}
805
+ >
806
+ <Check className="h-3 w-3" />
807
+ </Button>
808
+ <Button
809
+ size="sm"
810
+ variant="ghost"
811
+ className="h-6 w-6 p-0"
812
+ onClick={() => {
813
+ setNewFolderParent(null);
814
+ setNewFolderName("");
815
+ }}
816
+ >
817
+ <X className="h-3 w-3" />
818
+ </Button>
819
+ </div>
820
+ )}
821
+
822
+ {(node.type === "dir" || node.type === "app") &&
823
+ node.expanded &&
824
+ node.children &&
825
+ node.children.length > 0 &&
826
+ renderNodes(node.children, depth + 1)}
827
+ {(node.type === "dir" || node.type === "app") &&
828
+ node.expanded &&
829
+ node.children?.length === 0 && (
830
+ <div
831
+ className="text-xs text-muted-foreground/50 py-0.5"
832
+ style={{
833
+ paddingLeft: `${(depth + 1) * 14 + 8 + 14 + 6 + 16 + 6}px`,
834
+ }}
835
+ >
836
+ Empty
837
+ </div>
838
+ )}
839
+ </div>
840
+ ));
841
+ }
842
+
843
+ return (
844
+ <div className="flex h-screen gap-0 overflow-hidden bg-background">
845
+ {rootConfigured === null && (
846
+ <div className="flex-1 flex items-center justify-center">
847
+ <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
848
+ </div>
849
+ )}
850
+ {rootConfigured === false && (
851
+ <DirPicker onSelect={(path) => {
852
+ setRootLoaded(false);
853
+ rootLoadingRef.current = false;
854
+ setRootPath(path);
855
+ setRootConfigured(true);
856
+ }} />
857
+ )}
858
+ {rootConfigured === true && <>
859
+ {/* Tree sidebar */}
860
+ {!sidebarCollapsed && (
861
+ <Card className="flex flex-col w-72 shrink-0 overflow-hidden rounded-none border-r border-l-0 border-t-0 border-b-0">
862
+ <div className="flex items-center justify-between px-3 py-2 border-b bg-muted shrink-0">
863
+ <div className="flex items-center gap-1.5">
864
+ <img src="/logo.svg" alt="Wiki Viewer" className="h-5 w-5" />
865
+ <span className="text-xs font-semibold tracking-tight">Wiki Viewer</span>
866
+ </div>
867
+ <div className="flex items-center gap-1">
868
+ <Button
869
+ size="sm"
870
+ variant="ghost"
871
+ className="h-7 w-7 p-0"
872
+ title="Refresh tree"
873
+ onClick={refreshTree}
874
+ disabled={refreshingTree}
875
+ >
876
+ {refreshingTree ? (
877
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
878
+ ) : (
879
+ <RefreshCw className="h-3.5 w-3.5" />
880
+ )}
881
+ </Button>
882
+ <Button
883
+ size="sm"
884
+ variant="ghost"
885
+ className="h-7 w-7 p-0"
886
+ title="New root folder"
887
+ onClick={() => {
888
+ setNewFolderParent("");
889
+ setNewFolderName("");
890
+ setFolderError(null);
891
+ }}
892
+ >
893
+ <FolderPlus className="h-3.5 w-3.5" />
894
+ </Button>
895
+ <Button
896
+ size="sm"
897
+ variant="ghost"
898
+ className="h-7 w-7 p-0"
899
+ title="Upload to root"
900
+ onClick={() => triggerUpload("")}
901
+ disabled={uploading}
902
+ >
903
+ {uploading ? (
904
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
905
+ ) : (
906
+ <Upload className="h-3.5 w-3.5" />
907
+ )}
908
+ </Button>
909
+ <ThemeToggle />
910
+ <Button
911
+ size="sm"
912
+ variant="ghost"
913
+ className="h-7 w-7 p-0"
914
+ title="Collapse sidebar"
915
+ onClick={() => setSidebarCollapsed(true)}
916
+ >
917
+ <PanelLeftClose className="h-3.5 w-3.5" />
918
+ </Button>
919
+ </div>
920
+ </div>
921
+
922
+ {uploadError && (
923
+ <div className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-destructive bg-destructive/10 shrink-0">
924
+ <AlertCircle className="h-3.5 w-3.5 shrink-0" />
925
+ {uploadError}
926
+ </div>
927
+ )}
928
+
929
+ {newFolderParent === "" && (
930
+ <div className="flex items-center gap-1.5 px-2 py-1 border-b shrink-0">
931
+ <Folder className="h-4 w-4 shrink-0 text-warning" />
932
+ <input
933
+ className="flex-1 bg-transparent text-sm outline-none border-b border-border min-w-0"
934
+ placeholder="Folder name"
935
+ value={newFolderName}
936
+ onChange={(e) => setNewFolderName(e.target.value)}
937
+ onKeyDown={(e) => {
938
+ if (e.key === "Enter") handleCreateFolder();
939
+ if (e.key === "Escape") {
940
+ setNewFolderParent(null);
941
+ setNewFolderName("");
942
+ }
943
+ }}
944
+ />
945
+ {folderError && (
946
+ <span className="text-xs text-destructive">{folderError}</span>
947
+ )}
948
+ <Button
949
+ size="sm"
950
+ variant="ghost"
951
+ className="h-6 w-6 p-0"
952
+ onClick={handleCreateFolder}
953
+ >
954
+ <Check className="h-3 w-3" />
955
+ </Button>
956
+ <Button
957
+ size="sm"
958
+ variant="ghost"
959
+ className="h-6 w-6 p-0"
960
+ onClick={() => {
961
+ setNewFolderParent(null);
962
+ setNewFolderName("");
963
+ }}
964
+ >
965
+ <X className="h-3 w-3" />
966
+ </Button>
967
+ </div>
968
+ )}
969
+
970
+ <div
971
+ className={cn(
972
+ "flex-1 overflow-auto py-1",
973
+ dragOverPath === "" &&
974
+ "ring-2 ring-inset ring-primary bg-primary/5",
975
+ )}
976
+ onDragOver={(e) => handleDragOver(e, "", "root")}
977
+ onDragLeave={(e) => {
978
+ if (!e.currentTarget.contains(e.relatedTarget as Node))
979
+ setDragOverPath(null);
980
+ }}
981
+ onDrop={(e) => handleDropOnFolder(e, "")}
982
+ >
983
+ {rootLoading ? (
984
+ <div className="flex justify-center py-6">
985
+ <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
986
+ </div>
987
+ ) : roots.length === 0 ? (
988
+ <div className="flex flex-col items-center gap-3 px-4 py-8 text-center">
989
+ <div className="rounded-full bg-muted p-3">
990
+ <FileText className="h-6 w-6 text-muted-foreground" />
991
+ </div>
992
+ <div className="space-y-1">
993
+ <p className="text-sm font-medium">No files yet</p>
994
+ <p className="text-xs text-muted-foreground">
995
+ Upload files or add them to the configured directory
996
+ </p>
997
+ </div>
998
+ <Button
999
+ size="sm"
1000
+ variant="outline"
1001
+ className="w-full gap-1.5 max-w-[180px]"
1002
+ onClick={() => triggerUpload("")}
1003
+ disabled={uploading}
1004
+ >
1005
+ {uploading ? (
1006
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
1007
+ ) : (
1008
+ <Upload className="h-3.5 w-3.5" />
1009
+ )}
1010
+ Upload Files
1011
+ </Button>
1012
+ </div>
1013
+ ) : (
1014
+ renderNodes(roots)
1015
+ )}
1016
+ </div>
1017
+ <div className="border-t px-3 py-2 flex items-center gap-2 bg-muted shrink-0">
1018
+ <span
1019
+ className="flex-1 text-xs text-muted-foreground font-mono truncate"
1020
+ title={rootPath ?? ""}
1021
+ >
1022
+ {rootPath ?? ""}
1023
+ </span>
1024
+ <Button
1025
+ size="sm"
1026
+ variant="ghost"
1027
+ className="h-6 shrink-0 text-xs px-2 text-muted-foreground hover:text-foreground"
1028
+ onClick={handleChangeDir}
1029
+ >
1030
+ Change
1031
+ </Button>
1032
+ </div>
1033
+ </Card>
1034
+ )}
1035
+
1036
+ {/* Right panel */}
1037
+ <div className="flex-1 flex flex-col min-w-0 relative">
1038
+ {sidebarCollapsed && (
1039
+ <Button
1040
+ size="sm"
1041
+ variant="ghost"
1042
+ className="absolute left-0 top-1/2 -translate-y-1/2 z-10 h-7 w-7 p-0 rounded-full bg-background/50 backdrop-blur-xl backdrop-saturate-150 border border-white/[0.08] text-foreground/60 hover:text-foreground/90 hover:bg-background/70 shadow-[0_0_12px_rgba(0,0,0,0.15)] [transform:translate(-50%,-50%)_translateZ(0)]"
1043
+ title="Show sidebar"
1044
+ onClick={() => setSidebarCollapsed(false)}
1045
+ >
1046
+ <PanelLeftOpen className="h-3.5 w-3.5" />
1047
+ </Button>
1048
+ )}
1049
+ {openFile ? (
1050
+ openFileViewerKind === "node-app" ? (
1051
+ <NodeAppViewer path={openFile.path} title={openFile.name} />
1052
+ ) : (openFileViewerKind === "app" || openFileViewerKind === "html") ? (
1053
+ // html files → direct asset URL; app folders → index.html (default)
1054
+ (() => {
1055
+ const websiteSrc =
1056
+ openFileViewerKind === "html"
1057
+ ? `/api/assets/${openFile.path}`
1058
+ : undefined;
1059
+ return appFullscreen ? (
1060
+ <WebsiteViewer
1061
+ path={openFile.path}
1062
+ title={openFile.name}
1063
+ src={websiteSrc}
1064
+ fullscreen
1065
+ onExit={() => setAppFullscreen(false)}
1066
+ />
1067
+ ) : (
1068
+ <div className="flex-1 flex flex-col overflow-hidden min-w-0">
1069
+ <div className="flex items-center justify-between px-4 py-2 border-b bg-muted shrink-0">
1070
+ <div className="flex items-center gap-2 min-w-0">
1071
+ <Globe className="h-4 w-4 shrink-0 text-accent" />
1072
+ <span
1073
+ className="text-sm font-normal truncate"
1074
+ title={openFile.path}
1075
+ >
1076
+ {openFile.path}
1077
+ </span>
1078
+ </div>
1079
+ <div className="flex items-center gap-1 shrink-0">
1080
+ <Button
1081
+ size="sm"
1082
+ variant="ghost"
1083
+ className="h-7 w-7 p-0"
1084
+ title="Refresh"
1085
+ onClick={() => setAppKey((k) => k + 1)}
1086
+ >
1087
+ <RefreshCw className="h-3.5 w-3.5" />
1088
+ </Button>
1089
+ <Button
1090
+ size="sm"
1091
+ variant="ghost"
1092
+ className="h-7 gap-1.5 text-xs"
1093
+ onClick={() => setAppFullscreen(true)}
1094
+ >
1095
+ <Maximize2 className="h-3.5 w-3.5" />
1096
+ Open fullscreen
1097
+ </Button>
1098
+ <Button
1099
+ size="sm"
1100
+ variant="ghost"
1101
+ className="h-7 w-7 p-0"
1102
+ onClick={() => setOpenFile(null)}
1103
+ >
1104
+ <X className="h-3.5 w-3.5" />
1105
+ </Button>
1106
+ </div>
1107
+ </div>
1108
+ <WebsiteViewer
1109
+ key={appKey}
1110
+ path={openFile.path}
1111
+ title={openFile.name}
1112
+ src={websiteSrc}
1113
+ />
1114
+ </div>
1115
+ );
1116
+ })()
1117
+ ) : (
1118
+ <div className="flex-1 flex flex-col overflow-hidden min-w-0">
1119
+ <div className="flex items-center justify-between px-4 py-2 border-b bg-muted shrink-0">
1120
+ <div className="flex items-center gap-2 min-w-0">
1121
+ {isImage(openFile.name) ? (
1122
+ <ImageIcon className="h-4 w-4 shrink-0 text-sunshine-700" />
1123
+ ) : isText(openFile.name) ? (
1124
+ <FileText className="h-4 w-4 shrink-0 text-accent" />
1125
+ ) : (
1126
+ <File className="h-4 w-4 shrink-0 text-muted-foreground" />
1127
+ )}
1128
+ <span
1129
+ className="text-sm font-normal truncate"
1130
+ title={openFile.path}
1131
+ >
1132
+ {openFile.path}
1133
+ </span>
1134
+ </div>
1135
+ <div className="flex items-center gap-1 shrink-0">
1136
+ {isText(openFile.name) &&
1137
+ !editing &&
1138
+ fileContent !== null && (
1139
+ <Button
1140
+ size="sm"
1141
+ variant="ghost"
1142
+ className="h-7 w-7 p-0"
1143
+ onClick={() => {
1144
+ setEditing(true);
1145
+ setEditContent(fileContent);
1146
+ setSaveError(null);
1147
+ void useEditorStore
1148
+ .getState()
1149
+ .loadPage(openFile.path);
1150
+ }}
1151
+ >
1152
+ <Pencil className="h-3.5 w-3.5" />
1153
+ </Button>
1154
+ )}
1155
+ {isText(openFile.name) &&
1156
+ !editing &&
1157
+ fileContent !== null && (
1158
+ <Button
1159
+ size="sm"
1160
+ variant="ghost"
1161
+ className="h-7 w-7 p-0"
1162
+ title="Refresh"
1163
+ onClick={refreshViewer}
1164
+ disabled={fileLoading}
1165
+ >
1166
+ {fileLoading ? (
1167
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
1168
+ ) : (
1169
+ <RefreshCw className="h-3.5 w-3.5" />
1170
+ )}
1171
+ </Button>
1172
+ )}
1173
+ <Button
1174
+ size="sm"
1175
+ variant="ghost"
1176
+ className="h-7 w-7 p-0"
1177
+ onClick={() => {
1178
+ setOpenFile(null);
1179
+ setFileContent(null);
1180
+ setEditing(false);
1181
+ }}
1182
+ >
1183
+ <X className="h-3.5 w-3.5" />
1184
+ </Button>
1185
+ </div>
1186
+ </div>
1187
+
1188
+ {editing ? (
1189
+ <div className="flex-1 flex flex-col overflow-hidden min-h-0">
1190
+ <KBEditor />
1191
+ </div>
1192
+ ) : (
1193
+ <div className="flex-1 overflow-auto p-4 min-h-0">
1194
+ {fileLoading ? (
1195
+ <div className="flex justify-center py-8">
1196
+ <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
1197
+ </div>
1198
+ ) : openFileViewerKind === "csv" ? (
1199
+ <CsvViewer path={openFile.path} title={openFile.name} />
1200
+ ) : openFileViewerKind === "pdf" ? (
1201
+ <PdfViewer path={openFile.path} title={openFile.name} />
1202
+ ) : openFileViewerKind === "mermaid" ? (
1203
+ <MermaidViewer
1204
+ path={openFile.path}
1205
+ title={openFile.name}
1206
+ />
1207
+ ) : openFileViewerKind === "notebook" ? (
1208
+ <NotebookViewer
1209
+ path={openFile.path}
1210
+ title={openFile.name}
1211
+ />
1212
+ ) : openFileViewerKind === "image" ? (
1213
+ <ImageViewer path={openFile.path} title={openFile.name} />
1214
+ ) : openFileViewerKind === "media" ? (
1215
+ <MediaViewer
1216
+ path={openFile.path}
1217
+ title={openFile.name}
1218
+ type={
1219
+ ["mp4", "webm", "mov", "m4v"].includes(
1220
+ ext(openFile.name),
1221
+ )
1222
+ ? "video"
1223
+ : "audio"
1224
+ }
1225
+ />
1226
+ ) : openFileViewerKind === "docx" ? (
1227
+ <DocxViewer path={openFile.path} title={openFile.name} />
1228
+ ) : openFileViewerKind === "xlsx" ? (
1229
+ <XlsxViewer path={openFile.path} title={openFile.name} />
1230
+ ) : openFileViewerKind === "pptx" ? (
1231
+ <PptxViewer path={openFile.path} title={openFile.name} />
1232
+ ) : openFileViewerKind === "source" ? (
1233
+ <SourceViewer
1234
+ path={openFile.path}
1235
+ title={openFile.name}
1236
+ />
1237
+ ) : openFileViewerKind === "fallback" ? (
1238
+ <FileFallbackViewer
1239
+ path={openFile.path}
1240
+ title={openFile.name}
1241
+ />
1242
+ ) : fileContent !== null ? (
1243
+ ["md", "markdown"].includes(ext(openFile.name)) ? (
1244
+ (() => {
1245
+ const { data, body } = parseFrontmatter(fileContent);
1246
+ return (
1247
+ <>
1248
+ <FrontmatterHeader
1249
+ data={data as Record<string, never>}
1250
+ />
1251
+ <ReactMarkdown
1252
+ remarkPlugins={[remarkGfm, remarkWikilinks]}
1253
+ components={{
1254
+ h1: ({ children }) => (
1255
+ <h1 className="text-2xl font-normal mt-6 mb-3 pb-1 border-b">
1256
+ {children}
1257
+ </h1>
1258
+ ),
1259
+ h2: ({ children }) => (
1260
+ <h2 className="text-xl font-normal mt-5 mb-2 pb-1 border-b">
1261
+ {children}
1262
+ </h2>
1263
+ ),
1264
+ h3: ({ children }) => (
1265
+ <h3 className="text-lg font-normal mt-4 mb-2">
1266
+ {children}
1267
+ </h3>
1268
+ ),
1269
+ h4: ({ children }) => (
1270
+ <h4 className="text-base font-normal mt-3 mb-1">
1271
+ {children}
1272
+ </h4>
1273
+ ),
1274
+ p: ({ children }) => (
1275
+ <p className="text-sm leading-relaxed mb-3">
1276
+ {children}
1277
+ </p>
1278
+ ),
1279
+ ul: ({ children }) => (
1280
+ <ul className="list-disc pl-5 mb-3 space-y-1 text-sm">
1281
+ {children}
1282
+ </ul>
1283
+ ),
1284
+ ol: ({ children }) => (
1285
+ <ol className="list-decimal pl-5 mb-3 space-y-1 text-sm">
1286
+ {children}
1287
+ </ol>
1288
+ ),
1289
+ li: ({ children }) => (
1290
+ <li className="leading-relaxed">
1291
+ {children}
1292
+ </li>
1293
+ ),
1294
+ blockquote: ({ children }) => (
1295
+ <blockquote className="border-l-4 border-muted-foreground/30 pl-4 italic text-muted-foreground my-3 text-sm">
1296
+ {children}
1297
+ </blockquote>
1298
+ ),
1299
+ code: ({ className, children, ...props }) => {
1300
+ const isBlock =
1301
+ className?.includes("language-");
1302
+ return isBlock ? (
1303
+ <code
1304
+ className={`block bg-muted rounded-sm px-3 py-2 text-xs font-mono overflow-x-auto my-3 ${className ?? ""}`}
1305
+ {...props}
1306
+ >
1307
+ {children}
1308
+ </code>
1309
+ ) : (
1310
+ <code
1311
+ className="bg-muted rounded-sm px-1 py-0.5 text-xs font-mono"
1312
+ {...props}
1313
+ >
1314
+ {children}
1315
+ </code>
1316
+ );
1317
+ },
1318
+ pre: ({ children }) => (
1319
+ <pre className="my-3 overflow-x-auto">
1320
+ {children}
1321
+ </pre>
1322
+ ),
1323
+ a: ({ href, children, ...rest }) => {
1324
+ const props = rest as Record<
1325
+ string,
1326
+ unknown
1327
+ >;
1328
+ if (props["data-wiki-link"] === "true") {
1329
+ const slug =
1330
+ (props["data-slug"] as string) ?? "";
1331
+ const anchor = props["data-anchor"] as
1332
+ | string
1333
+ | undefined;
1334
+ const broken =
1335
+ slug &&
1336
+ !useWikiSlugsStore
1337
+ .getState()
1338
+ .has(slug);
1339
+ return (
1340
+ <a
1341
+ href={href}
1342
+ className="wiki-link"
1343
+ data-wiki-link="true"
1344
+ data-slug={slug}
1345
+ data-anchor={anchor}
1346
+ data-broken={
1347
+ broken ? "true" : undefined
1348
+ }
1349
+ onClick={(e) => {
1350
+ e.preventDefault();
1351
+ if (!slug) return;
1352
+ const dir = useWikiSlugsStore
1353
+ .getState()
1354
+ .getDir(slug);
1355
+ if (!dir) return;
1356
+ const targetPath =
1357
+ dir === "root"
1358
+ ? `${slug}.md`
1359
+ : `${dir}/${slug}.md`;
1360
+ void openViewer({
1361
+ path: targetPath,
1362
+ name: `${slug}.md`,
1363
+ type: "file",
1364
+ } as TreeNode);
1365
+ if (anchor) {
1366
+ setTimeout(() => {
1367
+ document
1368
+ .getElementById(anchor)
1369
+ ?.scrollIntoView({
1370
+ behavior: "smooth",
1371
+ });
1372
+ }, 200);
1373
+ }
1374
+ }}
1375
+ >
1376
+ {children}
1377
+ </a>
1378
+ );
1379
+ }
1380
+ return (
1381
+ <a
1382
+ href={href}
1383
+ className="text-primary underline hover:no-underline"
1384
+ target="_blank"
1385
+ rel="noreferrer"
1386
+ >
1387
+ {children}
1388
+ </a>
1389
+ );
1390
+ },
1391
+ strong: ({ children }) => (
1392
+ <strong className="font-normal">
1393
+ {children}
1394
+ </strong>
1395
+ ),
1396
+ em: ({ children }) => (
1397
+ <em className="italic">{children}</em>
1398
+ ),
1399
+ hr: () => <hr className="my-4 border-border" />,
1400
+ table: ({ children }) => (
1401
+ <div className="overflow-x-auto my-3">
1402
+ <table className="w-full text-sm border-collapse">
1403
+ {children}
1404
+ </table>
1405
+ </div>
1406
+ ),
1407
+ th: ({ children }) => (
1408
+ <th className="border border-border px-3 py-1.5 bg-muted font-normal text-left">
1409
+ {children}
1410
+ </th>
1411
+ ),
1412
+ td: ({ children }) => (
1413
+ <td className="border border-border px-3 py-1.5">
1414
+ {children}
1415
+ </td>
1416
+ ),
1417
+ }}
1418
+ >
1419
+ {body}
1420
+ </ReactMarkdown>
1421
+ </>
1422
+ );
1423
+ })()
1424
+ ) : (
1425
+ <pre className="text-xs font-mono whitespace-pre-wrap break-words leading-relaxed">
1426
+ {fileContent}
1427
+ </pre>
1428
+ )
1429
+ ) : isText(openFile.name) ? (
1430
+ <p className="text-sm text-muted-foreground">
1431
+ Could not load file.
1432
+ </p>
1433
+ ) : (
1434
+ <p className="text-sm text-muted-foreground">
1435
+ Preview not available for this file type.
1436
+ </p>
1437
+ )}
1438
+ </div>
1439
+ )}
1440
+
1441
+ {editing && (
1442
+ <div className="border-t px-4 py-2 flex items-center justify-end gap-2 bg-muted shrink-0">
1443
+ {saveError && (
1444
+ <span className="text-xs text-destructive mr-auto">
1445
+ {saveError}
1446
+ </span>
1447
+ )}
1448
+ <Button
1449
+ size="sm"
1450
+ variant="ghost"
1451
+ onClick={() => {
1452
+ setEditing(false);
1453
+ setSaveError(null);
1454
+ }}
1455
+ >
1456
+ Cancel
1457
+ </Button>
1458
+ <Button
1459
+ size="sm"
1460
+ className="gap-1"
1461
+ onClick={handleSave}
1462
+ disabled={saving}
1463
+ >
1464
+ {saving && <Loader2 className="h-3 w-3 animate-spin" />}
1465
+ Save
1466
+ </Button>
1467
+ </div>
1468
+ )}
1469
+ </div>
1470
+ )
1471
+ ) : (
1472
+ <div className="flex-1 flex flex-col items-center justify-center">
1473
+ <div className="flex flex-col items-center gap-2 text-center px-4">
1474
+ <FileText className="h-8 w-8 text-muted-foreground" />
1475
+ <p className="text-sm text-muted-foreground">
1476
+ Select a file to view or edit
1477
+ </p>
1478
+ </div>
1479
+ </div>
1480
+ )}
1481
+ </div>
1482
+
1483
+ <input
1484
+ ref={fileInputRef}
1485
+ type="file"
1486
+ multiple
1487
+ className="hidden"
1488
+ onChange={(e) => {
1489
+ if (e.target.files) doUpload(e.target.files, uploadDirRef.current);
1490
+ }}
1491
+ />
1492
+
1493
+ <ConfirmDialog
1494
+ open={!!deletingPath}
1495
+ onOpenChange={(open) => {
1496
+ if (!open) setDeletingPath(null);
1497
+ }}
1498
+ title={deletingIsDir ? "Delete folder?" : "Delete file?"}
1499
+ description={
1500
+ deletingIsDir
1501
+ ? `"${deletingPath?.split("/").pop()}" and all its contents will be permanently deleted.`
1502
+ : `"${deletingPath?.split("/").pop()}" will be permanently removed.`
1503
+ }
1504
+ onConfirm={handleDelete}
1505
+ />
1506
+ </>}
1507
+ </div>
1508
+ );
1509
+ }