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.
- package/.next/standalone/.github/workflows/publish.yml +37 -0
- package/.next/standalone/DESIGN.md +521 -0
- package/.next/standalone/README.md +283 -0
- package/.next/standalone/bin/wiki-viewer.js +158 -0
- package/.next/standalone/next.config.ts +11 -0
- package/.next/standalone/package.json +120 -0
- package/.next/standalone/pnpm-lock.yaml +5654 -0
- package/.next/standalone/pnpm-workspace.yaml +2 -0
- package/.next/standalone/postcss.config.mjs +2 -0
- package/.next/standalone/public/logo.svg +11 -0
- package/.next/standalone/public/public/logo.svg +11 -0
- package/.next/standalone/server.js +44 -0
- package/.next/standalone/src/app/api/app-proxy/[...path]/route.ts +247 -0
- package/.next/standalone/src/app/api/assets/[...path]/route.ts +70 -0
- package/.next/standalone/src/app/api/system/browse/route.ts +78 -0
- package/.next/standalone/src/app/api/system/clear-root/route.ts +9 -0
- package/.next/standalone/src/app/api/system/config/route.ts +10 -0
- package/.next/standalone/src/app/api/system/pins/route.ts +31 -0
- package/.next/standalone/src/app/api/system/reveal/route.ts +28 -0
- package/.next/standalone/src/app/api/system/root-status/route.ts +9 -0
- package/.next/standalone/src/app/api/system/set-root/route.ts +23 -0
- package/.next/standalone/src/app/api/upload/[...path]/route.ts +80 -0
- package/.next/standalone/src/app/api/wiki/app/route.ts +41 -0
- package/.next/standalone/src/app/api/wiki/content/route.ts +60 -0
- package/.next/standalone/src/app/api/wiki/file/route.ts +52 -0
- package/.next/standalone/src/app/api/wiki/folder/route.ts +26 -0
- package/.next/standalone/src/app/api/wiki/move/route.ts +45 -0
- package/.next/standalone/src/app/api/wiki/page/route.ts +84 -0
- package/.next/standalone/src/app/api/wiki/route.ts +98 -0
- package/.next/standalone/src/app/api/wiki/slugs/route.ts +50 -0
- package/.next/standalone/src/app/api/wiki/upload/route.ts +87 -0
- package/.next/standalone/src/app/api/wiki/watch/route.ts +86 -0
- package/.next/standalone/src/app/globals.css +716 -0
- package/.next/standalone/src/app/icon.svg +11 -0
- package/.next/standalone/src/app/layout.tsx +35 -0
- package/.next/standalone/src/app/page.tsx +1509 -0
- package/.next/standalone/src/components/confirm-dialog.tsx +56 -0
- package/.next/standalone/src/components/dir-picker.tsx +393 -0
- package/.next/standalone/src/components/editor/bubble-menu.tsx +286 -0
- package/.next/standalone/src/components/editor/callout-extension.ts +36 -0
- package/.next/standalone/src/components/editor/csv-viewer.tsx +335 -0
- package/.next/standalone/src/components/editor/editor-toolbar.tsx +564 -0
- package/.next/standalone/src/components/editor/editor.tsx +633 -0
- package/.next/standalone/src/components/editor/extensions/drag-handle.ts +325 -0
- package/.next/standalone/src/components/editor/extensions/heading-anchors.ts +55 -0
- package/.next/standalone/src/components/editor/extensions/resizable-image.tsx +159 -0
- package/.next/standalone/src/components/editor/extensions.ts +148 -0
- package/.next/standalone/src/components/editor/file-fallback-viewer.tsx +74 -0
- package/.next/standalone/src/components/editor/folder-index.tsx +235 -0
- package/.next/standalone/src/components/editor/google-doc-viewer.tsx +73 -0
- package/.next/standalone/src/components/editor/image-viewer.tsx +56 -0
- package/.next/standalone/src/components/editor/link-popover.tsx +111 -0
- package/.next/standalone/src/components/editor/media-popover.tsx +211 -0
- package/.next/standalone/src/components/editor/media-viewer.tsx +69 -0
- package/.next/standalone/src/components/editor/mermaid-viewer.tsx +285 -0
- package/.next/standalone/src/components/editor/node-app-viewer.tsx +269 -0
- package/.next/standalone/src/components/editor/notebook-viewer.tsx +370 -0
- package/.next/standalone/src/components/editor/office/docx-viewer.tsx +92 -0
- package/.next/standalone/src/components/editor/office/office-chrome.tsx +82 -0
- package/.next/standalone/src/components/editor/office/pptx-viewer.tsx +103 -0
- package/.next/standalone/src/components/editor/office/xlsx-viewer.tsx +116 -0
- package/.next/standalone/src/components/editor/pdf-viewer.tsx +31 -0
- package/.next/standalone/src/components/editor/slash-commands.tsx +454 -0
- package/.next/standalone/src/components/editor/source-viewer.tsx +212 -0
- package/.next/standalone/src/components/editor/table-menu.tsx +232 -0
- package/.next/standalone/src/components/editor/website-viewer.tsx +74 -0
- package/.next/standalone/src/components/editor/wiki-link-create-dialog.tsx +188 -0
- package/.next/standalone/src/components/editor/wiki-link-decorator.ts +79 -0
- package/.next/standalone/src/components/editor/wiki-link-extension.ts +120 -0
- package/.next/standalone/src/components/editor/wiki-link-picker.tsx +250 -0
- package/.next/standalone/src/components/layout/viewer-toolbar.tsx +54 -0
- package/.next/standalone/src/components/theme-provider.tsx +16 -0
- package/.next/standalone/src/components/theme-toggle.tsx +24 -0
- package/.next/standalone/src/components/ui/badge.tsx +35 -0
- package/.next/standalone/src/components/ui/button.tsx +56 -0
- package/.next/standalone/src/components/ui/card.tsx +63 -0
- package/.next/standalone/src/components/ui/collapsible.tsx +9 -0
- package/.next/standalone/src/components/ui/command.tsx +150 -0
- package/.next/standalone/src/components/ui/context-menu.tsx +191 -0
- package/.next/standalone/src/components/ui/dialog.tsx +119 -0
- package/.next/standalone/src/components/ui/dropdown-menu.tsx +194 -0
- package/.next/standalone/src/components/ui/input.tsx +22 -0
- package/.next/standalone/src/components/ui/label.tsx +25 -0
- package/.next/standalone/src/components/ui/popover.tsx +33 -0
- package/.next/standalone/src/components/ui/scroll-area.tsx +47 -0
- package/.next/standalone/src/components/ui/select.tsx +156 -0
- package/.next/standalone/src/components/ui/separator.tsx +30 -0
- package/.next/standalone/src/components/ui/sheet.tsx +140 -0
- package/.next/standalone/src/components/ui/skeleton.tsx +15 -0
- package/.next/standalone/src/components/ui/switch.tsx +28 -0
- package/.next/standalone/src/components/ui/tabs.tsx +54 -0
- package/.next/standalone/src/components/ui/textarea.tsx +21 -0
- package/.next/standalone/src/components/ui/tip.tsx +23 -0
- package/.next/standalone/src/components/ui/toggle-group.tsx +41 -0
- package/.next/standalone/src/components/ui/tooltip.tsx +27 -0
- package/.next/standalone/src/components/wiki/frontmatter-header.tsx +164 -0
- package/.next/standalone/src/lib/app-runner.ts +231 -0
- package/.next/standalone/src/lib/cabinets/tree.ts +75 -0
- package/.next/standalone/src/lib/config.ts +32 -0
- package/.next/standalone/src/lib/embeds/detect.ts +142 -0
- package/.next/standalone/src/lib/google/detect.ts +107 -0
- package/.next/standalone/src/lib/markdown/parse-frontmatter.ts +84 -0
- package/.next/standalone/src/lib/markdown/remark-wikilinks.ts +100 -0
- package/.next/standalone/src/lib/markdown/to-html.ts +168 -0
- package/.next/standalone/src/lib/markdown/to-markdown.ts +238 -0
- package/.next/standalone/src/lib/root-dir.ts +35 -0
- package/.next/standalone/src/lib/toast.ts +9 -0
- package/.next/standalone/src/lib/utils.ts +6 -0
- package/.next/standalone/src/lib/wiki-helpers.ts +36 -0
- package/.next/standalone/src/stores/ai-panel-store.ts +23 -0
- package/.next/standalone/src/stores/editor-store.ts +244 -0
- package/.next/standalone/src/stores/tree-store.ts +52 -0
- package/.next/standalone/src/stores/wiki-slugs-store.ts +74 -0
- package/.next/standalone/src/types/index.ts +47 -0
- package/.next/standalone/tailwind.config.ts +201 -0
- package/.next/standalone/tsconfig.json +41 -0
- package/README.md +283 -0
- package/bin/wiki-viewer.js +158 -0
- 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
|
+
}
|