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,633 @@
1
+ "use client";
2
+
3
+ import { cellAround, isInTable } from "@tiptap/pm/tables";
4
+ import { EditorContent, useEditor } from "@tiptap/react";
5
+ import { Code2, FilePlus, Loader2, Sparkles } from "lucide-react";
6
+ import { useCallback, useEffect, useRef, useState } from "react";
7
+ import { findNodeByPath } from "@/lib/cabinets/tree";
8
+ import { markdownToHtml } from "@/lib/markdown/to-html";
9
+ import { htmlToMarkdown } from "@/lib/markdown/to-markdown";
10
+ import { useAIPanelStore } from "@/stores/ai-panel-store";
11
+ import { useEditorStore } from "@/stores/editor-store";
12
+ import { useTreeStore } from "@/stores/tree-store";
13
+ import { useWikiSlugsStore } from "@/stores/wiki-slugs-store";
14
+ import type { TreeNode } from "@/types";
15
+ import { EditorBubbleMenu } from "./bubble-menu";
16
+ import { EditorToolbar } from "./editor-toolbar";
17
+ import { editorExtensions } from "./extensions";
18
+ import { FolderIndex } from "./folder-index";
19
+ import { SlashCommands } from "./slash-commands";
20
+ import { TableMenu } from "./table-menu";
21
+ import {
22
+ useWikiLinkCreate,
23
+ type WikiCreateResult,
24
+ } from "./wiki-link-create-dialog";
25
+ import { WikiLinkPicker } from "./wiki-link-picker";
26
+
27
+ async function uploadFile(
28
+ pagePath: string,
29
+ file: File,
30
+ ): Promise<string | null> {
31
+ const formData = new FormData();
32
+ formData.append("file", file);
33
+ try {
34
+ const res = await fetch(`/api/upload/${pagePath}`, {
35
+ method: "POST",
36
+ body: formData,
37
+ });
38
+ if (!res.ok) return null;
39
+ const data = await res.json();
40
+ return data.url;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function flattenTree(nodes: TreeNode[]): { path: string; name: string }[] {
47
+ const result: { path: string; name: string }[] = [];
48
+ for (const node of nodes) {
49
+ result.push({ path: node.path, name: node.name });
50
+ if (node.children) result.push(...flattenTree(node.children));
51
+ }
52
+ return result;
53
+ }
54
+
55
+ function findPageBySlug(
56
+ slug: string,
57
+ currentPath: string | null,
58
+ nodes: TreeNode[],
59
+ ): string | null {
60
+ const allPages = flattenTree(nodes);
61
+ // The slug matches the last segment of the path
62
+ const matches = allPages.filter(
63
+ (p) => p.name === slug || p.path.endsWith(`/${slug}`),
64
+ );
65
+ if (matches.length === 0) return null;
66
+ if (matches.length === 1) return matches[0].path;
67
+
68
+ // Prefer sibling pages (same parent directory as current page)
69
+ if (currentPath) {
70
+ const parentDir = currentPath.includes("/")
71
+ ? currentPath.substring(0, currentPath.lastIndexOf("/"))
72
+ : "";
73
+ const sibling = matches.find(
74
+ (m) => m.path === (parentDir ? `${parentDir}/${slug}` : slug),
75
+ );
76
+ if (sibling) return sibling.path;
77
+ }
78
+ return matches[0].path;
79
+ }
80
+
81
+ function navigateToPage(
82
+ targetPath: string,
83
+ selectPage: (path: string) => void,
84
+ expandPath: (path: string) => void,
85
+ ) {
86
+ const parts = targetPath.split("/");
87
+ for (let i = 1; i < parts.length; i++) {
88
+ expandPath(parts.slice(0, i).join("/"));
89
+ }
90
+ selectPage(targetPath);
91
+ useEditorStore.getState().loadPage(targetPath);
92
+ // Scroll editor container to top
93
+ setTimeout(() => {
94
+ document.querySelector("[data-editor-scroll]")?.scrollTo(0, 0);
95
+ }, 0);
96
+ }
97
+
98
+ function resolveInternalLink(
99
+ href: string,
100
+ currentPath: string | null,
101
+ nodes: TreeNode[],
102
+ ): string | null {
103
+ const allPages = flattenTree(nodes);
104
+
105
+ // Clean up the href: strip .md extension, leading ./ or /
106
+ const linkPath = href
107
+ .replace(/\.md$/, "")
108
+ .replace(/^\.\//, "")
109
+ .replace(/^\//, "");
110
+
111
+ // 1. Try as absolute path (exact match in tree)
112
+ const exactMatch = allPages.find((p) => p.path === linkPath);
113
+ if (exactMatch) return exactMatch.path;
114
+
115
+ // 2. Try relative to current page's directory
116
+ if (currentPath) {
117
+ const parentDir = currentPath.includes("/")
118
+ ? currentPath.substring(0, currentPath.lastIndexOf("/"))
119
+ : "";
120
+ const relativePath = parentDir ? `${parentDir}/${linkPath}` : linkPath;
121
+ const relMatch = allPages.find((p) => p.path === relativePath);
122
+ if (relMatch) return relMatch.path;
123
+ }
124
+
125
+ // 3. Try matching by last segment (slug-style lookup)
126
+ const slug = linkPath.includes("/") ? linkPath.split("/").pop()! : linkPath;
127
+ return findPageBySlug(slug, currentPath, nodes);
128
+ }
129
+
130
+ export function KBEditor() {
131
+ const {
132
+ currentPath,
133
+ content,
134
+ saveStatus,
135
+ frontmatter,
136
+ isLoading,
137
+ loadStatus,
138
+ createMissingPage,
139
+ } = useEditorStore();
140
+ const nodes = useTreeStore((s) => s.nodes);
141
+ const isRtl = frontmatter?.dir === "rtl";
142
+ const { open: openAI, clearMessages } = useAIPanelStore();
143
+ const { open: openWikiCreate, Dialog: WikiCreateDialog } =
144
+ useWikiLinkCreate();
145
+ // Keep a stable ref so the click handler closure can call the latest version
146
+ // without being re-created on every render.
147
+ const openWikiCreateRef =
148
+ useRef<(slug: string) => Promise<WikiCreateResult>>(openWikiCreate);
149
+ openWikiCreateRef.current = openWikiCreate;
150
+
151
+ const isLoadingRef = useRef(false);
152
+ const [sourceMode, setSourceMode] = useState(false);
153
+ const [sourceText, setSourceText] = useState("");
154
+ // Reset the tab to "page" whenever the path changes — opening a new folder
155
+ // shouldn't skip its index.md if the previous folder was on Files. Has to
156
+ // be an effect (not state-during-render) because Tiptap's EditorContent
157
+ // calls flushSync internally; setState during the parent render explodes
158
+ // when EditorContent renders in the same pass.
159
+ const [folderTab, setFolderTab] = useState<"page" | "files">("page");
160
+ useEffect(() => {
161
+ setFolderTab("page");
162
+ }, []);
163
+
164
+ // Prime the slug index once on mount so wiki-link broken-state and
165
+ // the autocomplete picker both have data immediately.
166
+ useEffect(() => {
167
+ void useWikiSlugsStore.getState().load();
168
+ }, []);
169
+
170
+ const handleUpdate = useCallback(
171
+ ({ editor }: { editor: ReturnType<typeof useEditor> }) => {
172
+ if (isLoadingRef.current || !editor) return;
173
+ const html = editor.getHTML();
174
+ const md = htmlToMarkdown(html);
175
+ useEditorStore.getState().updateContent(md);
176
+ },
177
+ [],
178
+ );
179
+
180
+ const editor = useEditor({
181
+ extensions: editorExtensions,
182
+ content: "",
183
+ onUpdate: handleUpdate,
184
+ editorProps: {
185
+ attributes: {
186
+ class:
187
+ "focus:outline-none min-h-[calc(100vh-12rem)] px-4 sm:px-8 py-6 max-w-3xl mx-auto",
188
+ },
189
+ handleKeyDown: (view, event) => {
190
+ if (
191
+ (event.metaKey || event.ctrlKey) &&
192
+ event.key.toLowerCase() === "a" &&
193
+ isInTable(view.state)
194
+ ) {
195
+ const $cell = cellAround(view.state.selection.$from);
196
+ const cell = $cell?.nodeAfter;
197
+ if (!$cell || !cell) return false;
198
+
199
+ const from = $cell.pos + 1;
200
+ const to = $cell.pos + cell.nodeSize - 1;
201
+ if (
202
+ view.state.selection.from === from &&
203
+ view.state.selection.to === to
204
+ ) {
205
+ return false;
206
+ }
207
+
208
+ event.preventDefault();
209
+ editor?.chain().focus().setTextSelection({ from, to }).run();
210
+ return true;
211
+ }
212
+
213
+ return false;
214
+ },
215
+ handleClick: (_view, _pos, event) => {
216
+ const target = event.target as HTMLElement;
217
+ const link = target.closest("a") as HTMLAnchorElement | null;
218
+ if (!link) return false;
219
+
220
+ const href = link.getAttribute("href");
221
+ if (!href) return false;
222
+
223
+ // Wiki-links inserted by the WikiLink mark
224
+ if (link.dataset.wikiLink === "true") {
225
+ event.preventDefault();
226
+ event.stopPropagation();
227
+ const slug = link.dataset.slug ?? "";
228
+ const anchor = link.dataset.anchor ?? null;
229
+ if (!slug) return true;
230
+
231
+ const slugStore = useWikiSlugsStore.getState();
232
+ if (slugStore.has(slug)) {
233
+ const dir = slugStore.getDir(slug);
234
+ const pagePath =
235
+ dir === null || dir === "root"
236
+ ? `${slug}.md`
237
+ : `${dir}/${slug}.md`;
238
+ void useEditorStore.getState().loadPage(pagePath);
239
+ if (anchor) {
240
+ setTimeout(() => {
241
+ document
242
+ .querySelector(`[id="${anchor}"]`)
243
+ ?.scrollIntoView({ behavior: "smooth" });
244
+ }, 200);
245
+ }
246
+ } else {
247
+ void openWikiCreateRef.current(slug).then((result) => {
248
+ if (result.ok) {
249
+ const dir = result.dir ?? "entities";
250
+ const pagePath = `${dir}/${result.slug}.md`;
251
+ void useEditorStore.getState().loadPage(pagePath);
252
+ }
253
+ });
254
+ }
255
+ return true;
256
+ }
257
+
258
+ // Wiki-links: #page:slug
259
+ if (href.startsWith("#page:")) {
260
+ event.preventDefault();
261
+ event.stopPropagation();
262
+ const slug = href.replace("#page:", "");
263
+ const { nodes, selectPage, expandPath } = useTreeStore.getState();
264
+ const activePath = useEditorStore.getState().currentPath;
265
+ const targetPath = findPageBySlug(slug, activePath, nodes);
266
+ if (targetPath) {
267
+ navigateToPage(targetPath, selectPage, expandPath);
268
+ }
269
+ return true;
270
+ }
271
+
272
+ // Internal links: relative paths to .md files or other KB pages
273
+ // Skip external URLs and API asset links (PDFs, images)
274
+ if (/^https?:\/\//.test(href) || href.startsWith("/api/")) return false;
275
+ if (href.startsWith("mailto:") || href.startsWith("tel:")) return false;
276
+
277
+ event.preventDefault();
278
+ event.stopPropagation();
279
+
280
+ const { nodes, selectPage, expandPath } = useTreeStore.getState();
281
+ const activePath = useEditorStore.getState().currentPath;
282
+
283
+ // Resolve the link target to a KB page path
284
+ const targetPath = resolveInternalLink(href, activePath, nodes);
285
+ if (targetPath) {
286
+ navigateToPage(targetPath, selectPage, expandPath);
287
+ }
288
+ return true;
289
+ },
290
+ handlePaste: (_view, event) => {
291
+ const files = event.clipboardData?.files;
292
+ const pagePath = useEditorStore.getState().currentPath;
293
+
294
+ // 1. File paste → upload then insert appropriate node
295
+ if (files && files.length > 0 && pagePath) {
296
+ for (const file of Array.from(files)) {
297
+ uploadFile(pagePath, file).then((url) => {
298
+ if (!url || !editor) return;
299
+ if (file.type.startsWith("image/")) {
300
+ editor
301
+ .chain()
302
+ .focus()
303
+ .setImage({ src: url, alt: file.name })
304
+ .run();
305
+ } else {
306
+ editor
307
+ .chain()
308
+ .focus()
309
+ .insertContent(`<a href="${url}">${file.name}</a>`)
310
+ .run();
311
+ }
312
+ });
313
+ }
314
+ return true;
315
+ }
316
+
317
+ return false;
318
+ },
319
+ handleDrop: (_view, event) => {
320
+ const files = event.dataTransfer?.files;
321
+ if (!files || files.length === 0) return false;
322
+
323
+ const pagePath = useEditorStore.getState().currentPath;
324
+ if (!pagePath) return false;
325
+
326
+ event.preventDefault();
327
+ for (const file of Array.from(files)) {
328
+ uploadFile(pagePath, file).then((url) => {
329
+ if (!url || !editor) return;
330
+ if (file.type.startsWith("image/")) {
331
+ editor
332
+ .chain()
333
+ .focus()
334
+ .setImage({ src: url, alt: file.name })
335
+ .run();
336
+ } else {
337
+ editor
338
+ .chain()
339
+ .focus()
340
+ .insertContent(`<a href="${url}">${file.name}</a>`)
341
+ .run();
342
+ }
343
+ });
344
+ }
345
+ return true;
346
+ },
347
+ },
348
+ immediatelyRender: false,
349
+ });
350
+
351
+ // When content updates from store (after loadPage), set it in editor
352
+ const prevPathRef = useRef<string | null>(null);
353
+ const renderedKeyRef = useRef<string | null>(null);
354
+ const [renderedPath, setRenderedPath] = useState<string | null>(null);
355
+ useEffect(() => {
356
+ if (!editor || currentPath === null) return;
357
+ // Skip if content hasn't actually changed (same path, dirty edit)
358
+ if (
359
+ useEditorStore.getState().isDirty &&
360
+ currentPath === prevPathRef.current
361
+ )
362
+ return;
363
+ // During page navigation the store briefly holds content="" while the
364
+ // fetch is in flight. Rendering that empty string into ProseMirror is
365
+ // pure waste — every extension runs a full schema pass twice per
366
+ // navigation. Skip until the real content arrives.
367
+ if (isLoading && content === "") return;
368
+ // Dedupe identical (path, content) renders — e.g. cached paint followed
369
+ // by a fresh fetch that returned the same markdown.
370
+ const key = `${currentPath} ${content}`;
371
+ if (renderedKeyRef.current === key) {
372
+ if (renderedPath !== currentPath) setRenderedPath(currentPath);
373
+ return;
374
+ }
375
+ prevPathRef.current = currentPath;
376
+
377
+ const setContent = async () => {
378
+ isLoadingRef.current = true;
379
+ const html = await markdownToHtml(content, currentPath);
380
+ editor.commands.setContent(html);
381
+ renderedKeyRef.current = key;
382
+ setRenderedPath(currentPath);
383
+ setTimeout(() => {
384
+ isLoadingRef.current = false;
385
+ }, 50);
386
+ };
387
+
388
+ setContent();
389
+ }, [editor, content, currentPath, isLoading, renderedPath]);
390
+
391
+ const showLoadingOverlay =
392
+ currentPath !== null && (isLoading || renderedPath !== currentPath);
393
+
394
+ const handleOpenAI = () => {
395
+ clearMessages();
396
+ openAI();
397
+ };
398
+
399
+ if (currentPath === null) {
400
+ return (
401
+ <div className="flex-1 flex items-center justify-center text-muted-foreground">
402
+ <div className="text-center space-y-3">
403
+ <p className="text-lg font-medium tracking-[-0.02em]">
404
+ No page selected
405
+ </p>
406
+ <p className="text-sm text-muted-foreground/70">
407
+ Select a page from the sidebar or create a new one
408
+ </p>
409
+ </div>
410
+ </div>
411
+ );
412
+ }
413
+
414
+ // Path resolved to a folder (or otherwise-missing target) without an
415
+ // index.md. Render an explicit placeholder + Create CTA instead of
416
+ // dropping the user into an empty editor that pretends to be the page.
417
+ if (loadStatus === "missing") {
418
+ const slug = currentPath.split("/").pop() || currentPath;
419
+ const inferredTitle = slug
420
+ .replace(/[-_]+/g, " ")
421
+ .replace(/\b\w/g, (c) => c.toUpperCase());
422
+ const folderNode = findNodeByPath(nodes, currentPath);
423
+ const folderChildren = folderNode?.children ?? [];
424
+ const hasChildren = folderChildren.length > 0;
425
+ return (
426
+ <div className="flex-1 overflow-y-auto">
427
+ <div className="max-w-3xl mx-auto px-6 py-10 space-y-6">
428
+ <div className="space-y-3">
429
+ <p className="text-lg font-medium tracking-[-0.02em] text-foreground">
430
+ {inferredTitle}
431
+ </p>
432
+ <p className="text-sm text-muted-foreground/80">
433
+ This folder doesn&apos;t have an{" "}
434
+ <code className="px-1 py-0.5 rounded bg-muted text-[12px]">
435
+ index.md
436
+ </code>
437
+ {hasChildren ? " yet — its contents are listed below." : " yet."}
438
+ </p>
439
+ <button
440
+ onClick={() => void createMissingPage(inferredTitle)}
441
+ className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-[13px] font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
442
+ >
443
+ <FilePlus className="h-3.5 w-3.5" />
444
+ Create page
445
+ </button>
446
+ </div>
447
+ {hasChildren && (
448
+ <FolderIndex
449
+ key={currentPath}
450
+ folderPath={currentPath}
451
+ entries={folderChildren}
452
+ />
453
+ )}
454
+ </div>
455
+ </div>
456
+ );
457
+ }
458
+
459
+ const toggleSourceMode = async () => {
460
+ if (!sourceMode) {
461
+ // Switching TO source mode — grab current markdown
462
+ setSourceText(useEditorStore.getState().content);
463
+ setSourceMode(true);
464
+ } else {
465
+ // Switching FROM source mode — apply changes
466
+ useEditorStore.getState().updateContent(sourceText);
467
+ if (editor) {
468
+ isLoadingRef.current = true;
469
+ const html = await markdownToHtml(sourceText, currentPath ?? undefined);
470
+ editor.commands.setContent(html);
471
+ setTimeout(() => {
472
+ isLoadingRef.current = false;
473
+ }, 50);
474
+ }
475
+ setSourceMode(false);
476
+ }
477
+ };
478
+
479
+ // Folder pages with both an index.md (loadStatus === "ok") AND children
480
+ // get a Page / Files tab strip so users can switch between the page body
481
+ // and the directory listing without leaving the route.
482
+ const renderedFolderNode = findNodeByPath(nodes, currentPath);
483
+ const renderedFolderChildren =
484
+ renderedFolderNode?.type === "directory" ||
485
+ renderedFolderNode?.type === "cabinet"
486
+ ? (renderedFolderNode.children ?? [])
487
+ : [];
488
+ const showFolderTabs = renderedFolderChildren.length > 0;
489
+ const onFilesTab = showFolderTabs && folderTab === "files";
490
+
491
+ return (
492
+ <>
493
+ <div className="flex-1 flex flex-col overflow-hidden">
494
+ {showFolderTabs && (
495
+ <div className="flex items-center gap-1 px-3 pt-2 border-b border-border">
496
+ <button
497
+ onClick={() => setFolderTab("page")}
498
+ className={`px-3 py-1.5 text-[12px] rounded-t-md border-b-2 -mb-px transition-colors ${
499
+ folderTab === "page"
500
+ ? "border-primary text-foreground"
501
+ : "border-transparent text-muted-foreground hover:text-foreground"
502
+ }`}
503
+ aria-pressed={folderTab === "page"}
504
+ >
505
+ Page
506
+ </button>
507
+ <button
508
+ onClick={() => setFolderTab("files")}
509
+ className={`px-3 py-1.5 text-[12px] rounded-t-md border-b-2 -mb-px transition-colors ${
510
+ folderTab === "files"
511
+ ? "border-primary text-foreground"
512
+ : "border-transparent text-muted-foreground hover:text-foreground"
513
+ }`}
514
+ aria-pressed={folderTab === "files"}
515
+ >
516
+ Files
517
+ <span className="ml-1.5 text-muted-foreground/60">
518
+ {renderedFolderChildren.length}
519
+ </span>
520
+ </button>
521
+ </div>
522
+ )}
523
+ {onFilesTab ? (
524
+ <div className="flex-1 overflow-y-auto">
525
+ <div className="max-w-3xl mx-auto px-6 py-6">
526
+ <FolderIndex
527
+ key={currentPath}
528
+ folderPath={currentPath}
529
+ entries={renderedFolderChildren}
530
+ />
531
+ </div>
532
+ </div>
533
+ ) : (
534
+ <>
535
+ <div className="flex items-center min-w-0">
536
+ <div className="flex-1 min-w-0">
537
+ {!sourceMode && <EditorToolbar editor={editor} />}
538
+ </div>
539
+ <button
540
+ onClick={toggleSourceMode}
541
+ className={`flex items-center gap-1.5 px-3 py-1 mr-2 text-[11px] rounded-md transition-colors border border-border ${
542
+ sourceMode
543
+ ? "bg-primary text-primary-foreground"
544
+ : "text-muted-foreground hover:bg-accent"
545
+ }`}
546
+ >
547
+ <Code2 className="h-3 w-3" />
548
+ {sourceMode ? "Preview" : "Markdown"}
549
+ </button>
550
+ </div>
551
+
552
+ {sourceMode ? (
553
+ <div
554
+ className="flex-1 overflow-y-auto p-4"
555
+ dir={isRtl ? "rtl" : undefined}
556
+ >
557
+ <textarea
558
+ value={sourceText}
559
+ onChange={(e) => setSourceText(e.target.value)}
560
+ className="w-full h-full min-h-[calc(100vh-12rem)] bg-transparent font-mono text-[13px] leading-relaxed resize-none focus:outline-none"
561
+ spellCheck={false}
562
+ />
563
+ </div>
564
+ ) : (
565
+ <div className="flex-1 relative" dir={isRtl ? "rtl" : undefined}>
566
+ <div
567
+ className="absolute inset-0 overflow-y-auto"
568
+ data-editor-scroll
569
+ >
570
+ <EditorContent editor={editor} />
571
+ <EditorBubbleMenu editor={editor} />
572
+ <TableMenu editor={editor} />
573
+ <SlashCommands editor={editor} />
574
+ <WikiLinkPicker
575
+ editor={editor}
576
+ onCreateRequest={openWikiCreateRef.current}
577
+ />
578
+
579
+ {/* AI Edit Prompt + slash hint */}
580
+ <div className="max-w-3xl mx-auto px-8 pb-8 flex items-center gap-4">
581
+ <button
582
+ onClick={handleOpenAI}
583
+ className="group flex items-center gap-2 text-[13px] text-muted-foreground/50 hover:text-muted-foreground transition-colors cursor-pointer"
584
+ >
585
+ <Sparkles className="h-3.5 w-3.5 group-hover:text-primary transition-colors" />
586
+ <span>Edit with AI</span>
587
+ </button>
588
+ <span className="text-[11px] text-muted-foreground/30 select-none">
589
+ <kbd className="rounded px-1 py-0.5 font-mono text-[10px] ring-1 ring-foreground/10">
590
+ /
591
+ </kbd>{" "}
592
+ for commands
593
+ </span>
594
+ </div>
595
+ </div>
596
+
597
+ {showLoadingOverlay && (
598
+ <div
599
+ className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-md z-20 pointer-events-none"
600
+ aria-hidden="true"
601
+ >
602
+ <Loader2 className="h-5 w-5 animate-spin text-muted-foreground/70" />
603
+ </div>
604
+ )}
605
+ </div>
606
+ )}
607
+
608
+ {/* Status bar */}
609
+ <div className="flex items-center justify-between px-4 py-1 border-t border-border text-xs text-muted-foreground/60">
610
+ <span className="text-[10.5px] text-muted-foreground/30 select-none hidden sm:block">
611
+ <kbd className="rounded px-1 font-mono text-[9.5px] ring-1 ring-foreground/10">
612
+ ⌘S
613
+ </kbd>{" "}
614
+ save
615
+ <span className="mx-1.5 opacity-40">·</span>
616
+ <kbd className="rounded px-1 font-mono text-[9.5px] ring-1 ring-foreground/10">
617
+ /
618
+ </kbd>{" "}
619
+ commands
620
+ </span>
621
+ <span>
622
+ {saveStatus === "saving" && "Saving..."}
623
+ {saveStatus === "saved" && "Saved"}
624
+ {saveStatus === "error" && "Save failed"}
625
+ </span>
626
+ </div>
627
+ </>
628
+ )}
629
+ </div>
630
+ {WikiCreateDialog}
631
+ </>
632
+ );
633
+ }