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,454 @@
1
+ "use client";
2
+
3
+ import type { Editor } from "@tiptap/react";
4
+ import {
5
+ AlertTriangle,
6
+ CheckSquare,
7
+ Code,
8
+ File,
9
+ Heading1,
10
+ Heading2,
11
+ Heading3,
12
+ ImageIcon,
13
+ Info,
14
+ List,
15
+ ListOrdered,
16
+ Minus,
17
+ Quote,
18
+ Sigma,
19
+ Table,
20
+ Type,
21
+ Video,
22
+ } from "lucide-react";
23
+ import { useCallback, useEffect, useRef, useState } from "react";
24
+ import { cn } from "@/lib/utils";
25
+ import { useEditorStore } from "@/stores/editor-store";
26
+ import { type MediaKind, MediaPopover } from "./media-popover";
27
+
28
+ type PopoverKind = null | { type: "media"; kind: MediaKind };
29
+
30
+ interface SlashCommand {
31
+ label: string;
32
+ icon: React.ComponentType<{ className?: string }>;
33
+ description: string;
34
+ category: "basic" | "media" | "advanced";
35
+ action:
36
+ | { type: "direct"; run: (editor: Editor) => void }
37
+ | { type: "popover"; kind: Exclude<PopoverKind, null> };
38
+ }
39
+
40
+ const commands: SlashCommand[] = [
41
+ // Basic
42
+ {
43
+ label: "Text",
44
+ icon: Type,
45
+ description: "Start writing plain text",
46
+ category: "basic",
47
+ action: {
48
+ type: "direct",
49
+ run: (editor) => editor.chain().focus().setParagraph().run(),
50
+ },
51
+ },
52
+ {
53
+ label: "Heading 1",
54
+ icon: Heading1,
55
+ description: "Large section heading",
56
+ category: "basic",
57
+ action: {
58
+ type: "direct",
59
+ run: (editor) => editor.chain().focus().toggleHeading({ level: 1 }).run(),
60
+ },
61
+ },
62
+ {
63
+ label: "Heading 2",
64
+ icon: Heading2,
65
+ description: "Medium section heading",
66
+ category: "basic",
67
+ action: {
68
+ type: "direct",
69
+ run: (editor) => editor.chain().focus().toggleHeading({ level: 2 }).run(),
70
+ },
71
+ },
72
+ {
73
+ label: "Heading 3",
74
+ icon: Heading3,
75
+ description: "Small section heading",
76
+ category: "basic",
77
+ action: {
78
+ type: "direct",
79
+ run: (editor) => editor.chain().focus().toggleHeading({ level: 3 }).run(),
80
+ },
81
+ },
82
+ {
83
+ label: "Bullet List",
84
+ icon: List,
85
+ description: "Create a bullet list",
86
+ category: "basic",
87
+ action: {
88
+ type: "direct",
89
+ run: (editor) => editor.chain().focus().toggleBulletList().run(),
90
+ },
91
+ },
92
+ {
93
+ label: "Numbered List",
94
+ icon: ListOrdered,
95
+ description: "Create a numbered list",
96
+ category: "basic",
97
+ action: {
98
+ type: "direct",
99
+ run: (editor) => editor.chain().focus().toggleOrderedList().run(),
100
+ },
101
+ },
102
+ {
103
+ label: "Checklist",
104
+ icon: CheckSquare,
105
+ description: "Create a task checklist",
106
+ category: "basic",
107
+ action: {
108
+ type: "direct",
109
+ run: (editor) => editor.chain().focus().toggleTaskList().run(),
110
+ },
111
+ },
112
+ {
113
+ label: "Code Block",
114
+ icon: Code,
115
+ description: "Insert a code block",
116
+ category: "basic",
117
+ action: {
118
+ type: "direct",
119
+ run: (editor) => editor.chain().focus().toggleCodeBlock().run(),
120
+ },
121
+ },
122
+ {
123
+ label: "Blockquote",
124
+ icon: Quote,
125
+ description: "Insert a blockquote",
126
+ category: "basic",
127
+ action: {
128
+ type: "direct",
129
+ run: (editor) => editor.chain().focus().toggleBlockquote().run(),
130
+ },
131
+ },
132
+ {
133
+ label: "Divider",
134
+ icon: Minus,
135
+ description: "Insert a horizontal rule",
136
+ category: "basic",
137
+ action: {
138
+ type: "direct",
139
+ run: (editor) => editor.chain().focus().setHorizontalRule().run(),
140
+ },
141
+ },
142
+ {
143
+ label: "Table",
144
+ icon: Table,
145
+ description: "Insert a 3x3 table",
146
+ category: "basic",
147
+ action: {
148
+ type: "direct",
149
+ run: (editor) =>
150
+ editor
151
+ .chain()
152
+ .focus()
153
+ .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
154
+ .run(),
155
+ },
156
+ },
157
+
158
+ // Media
159
+ {
160
+ label: "Image",
161
+ icon: ImageIcon,
162
+ description: "Upload, paste URL, or drop an image",
163
+ category: "media",
164
+ action: { type: "popover", kind: { type: "media", kind: "image" } },
165
+ },
166
+ {
167
+ label: "Video",
168
+ icon: Video,
169
+ description: "Upload or paste a video URL",
170
+ category: "media",
171
+ action: { type: "popover", kind: { type: "media", kind: "video" } },
172
+ },
173
+ {
174
+ label: "File",
175
+ icon: File,
176
+ description: "Attach any file to this page",
177
+ category: "media",
178
+ action: { type: "popover", kind: { type: "media", kind: "file" } },
179
+ },
180
+
181
+ // Advanced
182
+ {
183
+ label: "Callout",
184
+ icon: Info,
185
+ description: "Insert an info callout",
186
+ category: "advanced",
187
+ action: {
188
+ type: "direct",
189
+ run: (editor) =>
190
+ editor.chain().focus().wrapIn("callout", { type: "info" }).run(),
191
+ },
192
+ },
193
+ {
194
+ label: "Warning",
195
+ icon: AlertTriangle,
196
+ description: "Insert a warning callout",
197
+ category: "advanced",
198
+ action: {
199
+ type: "direct",
200
+ run: (editor) =>
201
+ editor.chain().focus().wrapIn("callout", { type: "warning" }).run(),
202
+ },
203
+ },
204
+ {
205
+ label: "Math",
206
+ icon: Sigma,
207
+ description: "Insert a LaTeX math expression",
208
+ category: "advanced",
209
+ action: {
210
+ type: "direct",
211
+ run: (editor) => editor.chain().focus().insertContent("$x=y$").run(),
212
+ },
213
+ },
214
+ ];
215
+
216
+ interface SlashCommandsProps {
217
+ editor: Editor | null;
218
+ }
219
+
220
+ export function SlashCommands({ editor }: SlashCommandsProps) {
221
+ const [open, setOpen] = useState(false);
222
+ const [query, setQuery] = useState("");
223
+ const [selectedIndex, setSelectedIndex] = useState(0);
224
+ const [position, setPosition] = useState({ top: 0, left: 0 });
225
+ const [popover, setPopover] = useState<PopoverKind>(null);
226
+ const menuRef = useRef<HTMLDivElement>(null);
227
+ const pagePath = useEditorStore((s) => s.currentPath);
228
+
229
+ const filtered = commands.filter(
230
+ (cmd) =>
231
+ cmd.label.toLowerCase().includes(query.toLowerCase()) ||
232
+ cmd.description.toLowerCase().includes(query.toLowerCase()),
233
+ );
234
+
235
+ const handleClose = useCallback(() => {
236
+ setOpen(false);
237
+ setQuery("");
238
+ setSelectedIndex(0);
239
+ }, []);
240
+
241
+ const handleSelect = useCallback(
242
+ (command: SlashCommand) => {
243
+ if (!editor) return;
244
+ const { from } = editor.state.selection;
245
+ const slashStart = from - query.length - 1;
246
+ editor.chain().focus().deleteRange({ from: slashStart, to: from }).run();
247
+
248
+ if (command.action.type === "direct") {
249
+ command.action.run(editor);
250
+ handleClose();
251
+ } else {
252
+ setPopover(command.action.kind);
253
+ setOpen(false);
254
+ setQuery("");
255
+ }
256
+ },
257
+ [editor, query, handleClose],
258
+ );
259
+
260
+ useEffect(() => {
261
+ if (!editor) return;
262
+
263
+ const handleKeyDown = (event: KeyboardEvent) => {
264
+ if (!open) {
265
+ if (event.key === "/") {
266
+ const { from } = editor.state.selection;
267
+ const textBefore = editor.state.doc.textBetween(
268
+ Math.max(0, from - 1),
269
+ from,
270
+ );
271
+ if (
272
+ from === 1 ||
273
+ textBefore === "" ||
274
+ textBefore === "\n" ||
275
+ textBefore === " "
276
+ ) {
277
+ const coords = editor.view.coordsAtPos(from);
278
+ const editorRect = editor.view.dom.getBoundingClientRect();
279
+ setPosition({
280
+ top: coords.bottom - editorRect.top + 4,
281
+ left: coords.left - editorRect.left,
282
+ });
283
+ setOpen(true);
284
+ setQuery("");
285
+ setSelectedIndex(0);
286
+ }
287
+ }
288
+ return;
289
+ }
290
+
291
+ if (event.key === "Escape") {
292
+ event.preventDefault();
293
+ handleClose();
294
+ } else if (event.key === "ArrowDown") {
295
+ event.preventDefault();
296
+ setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
297
+ } else if (event.key === "ArrowUp") {
298
+ event.preventDefault();
299
+ setSelectedIndex((i) => Math.max(i - 1, 0));
300
+ } else if (event.key === "Enter") {
301
+ event.preventDefault();
302
+ if (filtered[selectedIndex]) handleSelect(filtered[selectedIndex]);
303
+ } else if (event.key === "Backspace") {
304
+ if (query.length === 0) handleClose();
305
+ else {
306
+ setQuery((q) => q.slice(0, -1));
307
+ setSelectedIndex(0);
308
+ }
309
+ } else if (event.key === " ") {
310
+ handleClose();
311
+ } else if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) {
312
+ setQuery((q) => q + event.key);
313
+ setSelectedIndex(0);
314
+ }
315
+ };
316
+
317
+ window.addEventListener("keydown", handleKeyDown, true);
318
+ return () => window.removeEventListener("keydown", handleKeyDown, true);
319
+ }, [editor, open, query, selectedIndex, filtered, handleClose, handleSelect]);
320
+
321
+ useEffect(() => {
322
+ if (!open) return;
323
+ const handleClick = (e: MouseEvent) => {
324
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
325
+ handleClose();
326
+ }
327
+ };
328
+ window.addEventListener("mousedown", handleClick);
329
+ return () => window.removeEventListener("mousedown", handleClick);
330
+ }, [open, handleClose]);
331
+
332
+ const insertMedia = (
333
+ kind: MediaKind,
334
+ payload: { url: string; alt?: string; mimeType?: string },
335
+ ) => {
336
+ if (!editor) return;
337
+ const { url, alt, mimeType } = payload;
338
+ const type = mimeType ?? "";
339
+ const isImage =
340
+ kind === "image" ||
341
+ type.startsWith("image/") ||
342
+ /\.(png|jpe?g|gif|webp|svg|avif)(\?|$)/i.test(url);
343
+
344
+ if (isImage) {
345
+ editor
346
+ .chain()
347
+ .focus()
348
+ .setImage({ src: url, alt: alt ?? "" })
349
+ .run();
350
+ } else {
351
+ editor
352
+ .chain()
353
+ .focus()
354
+ .insertContent(`<a href="${url}">${alt ?? url}</a>`)
355
+ .run();
356
+ }
357
+ setPopover(null);
358
+ };
359
+
360
+ const _insertEmoji = (native: string) => {
361
+ if (!editor) return;
362
+ editor.chain().focus().insertContent(native).run();
363
+ setPopover(null);
364
+ };
365
+
366
+ const renderPopover = () => {
367
+ if (!popover || !editor) return null;
368
+ const anchor = position;
369
+ if (popover.type === "media") {
370
+ if (!pagePath) return null;
371
+ return (
372
+ <MediaPopover
373
+ kind={popover.kind}
374
+ pagePath={pagePath}
375
+ anchor={anchor}
376
+ onCancel={() => setPopover(null)}
377
+ onInsert={(payload) => insertMedia(popover.kind, payload)}
378
+ />
379
+ );
380
+ }
381
+ return null;
382
+ };
383
+
384
+ if ((!open || filtered.length === 0) && !popover) return null;
385
+
386
+ const byCategory = new Map<string, SlashCommand[]>();
387
+ for (const cmd of filtered) {
388
+ const list = byCategory.get(cmd.category) ?? [];
389
+ list.push(cmd);
390
+ byCategory.set(cmd.category, list);
391
+ }
392
+ const order: { key: string; title: string }[] = [
393
+ { key: "basic", title: "Basic" },
394
+ { key: "media", title: "Media" },
395
+ { key: "advanced", title: "Advanced" },
396
+ ];
397
+
398
+ const flatCommands: SlashCommand[] = filtered;
399
+
400
+ return (
401
+ <>
402
+ {open && filtered.length > 0 && (
403
+ <div
404
+ ref={menuRef}
405
+ className="absolute z-50 w-[280px] bg-popover border border-border rounded-lg shadow-lg py-1 overflow-hidden max-h-[380px] overflow-y-auto"
406
+ style={{ top: position.top, left: position.left }}
407
+ >
408
+ {order.map((group) => {
409
+ const items = byCategory.get(group.key);
410
+ if (!items || items.length === 0) return null;
411
+ return (
412
+ <div key={group.key}>
413
+ <div className="text-[9px] uppercase tracking-wider text-muted-foreground px-3 pt-2 pb-1">
414
+ {group.title}
415
+ </div>
416
+ {items.map((cmd) => {
417
+ const flatIndex = flatCommands.indexOf(cmd);
418
+ const Icon = cmd.icon;
419
+ return (
420
+ <button
421
+ key={cmd.label}
422
+ onMouseDown={(e) => {
423
+ e.preventDefault();
424
+ handleSelect(cmd);
425
+ }}
426
+ onMouseEnter={() => setSelectedIndex(flatIndex)}
427
+ className={cn(
428
+ "flex items-center gap-3 w-full px-3 py-1.5 text-left transition-colors",
429
+ flatIndex === selectedIndex
430
+ ? "bg-accent text-accent-foreground"
431
+ : "hover:bg-accent/50",
432
+ )}
433
+ >
434
+ <Icon className="h-4 w-4 text-muted-foreground shrink-0" />
435
+ <div className="min-w-0">
436
+ <p className="text-[12px] font-medium truncate">
437
+ {cmd.label}
438
+ </p>
439
+ <p className="text-[10px] text-muted-foreground truncate">
440
+ {cmd.description}
441
+ </p>
442
+ </div>
443
+ </button>
444
+ );
445
+ })}
446
+ </div>
447
+ );
448
+ })}
449
+ </div>
450
+ )}
451
+ {renderPopover()}
452
+ </>
453
+ );
454
+ }
@@ -0,0 +1,212 @@
1
+ "use client";
2
+
3
+ import { toHtml } from "hast-util-to-html";
4
+ import { common, createLowlight } from "lowlight";
5
+ import { Check, Copy, Download, ExternalLink, WrapText } from "lucide-react";
6
+ import { useCallback, useEffect, useMemo, useState } from "react";
7
+ import { ViewerToolbar } from "@/components/layout/viewer-toolbar";
8
+ import { Button } from "@/components/ui/button";
9
+
10
+ interface SourceViewerProps {
11
+ path: string;
12
+ title: string;
13
+ }
14
+
15
+ const lowlight = createLowlight(common);
16
+
17
+ const EXT_TO_LANG: Record<string, string> = {
18
+ ".js": "javascript",
19
+ ".cjs": "javascript",
20
+ ".mjs": "javascript",
21
+ ".ts": "typescript",
22
+ ".tsx": "typescript",
23
+ ".jsx": "javascript",
24
+ ".py": "python",
25
+ ".rb": "ruby",
26
+ ".php": "php",
27
+ ".sh": "bash",
28
+ ".bash": "bash",
29
+ ".zsh": "bash",
30
+ ".ps1": "powershell",
31
+ ".css": "css",
32
+ ".scss": "scss",
33
+ ".html": "xml",
34
+ ".json": "json",
35
+ ".jsonc": "json",
36
+ ".yaml": "yaml",
37
+ ".yml": "yaml",
38
+ ".toml": "ini",
39
+ ".ini": "ini",
40
+ ".xml": "xml",
41
+ ".sql": "sql",
42
+ ".graphql": "graphql",
43
+ ".gql": "graphql",
44
+ ".go": "go",
45
+ ".rs": "rust",
46
+ ".swift": "swift",
47
+ ".java": "java",
48
+ ".kt": "kotlin",
49
+ ".kts": "kotlin",
50
+ ".c": "c",
51
+ ".cpp": "cpp",
52
+ ".h": "c",
53
+ ".env": "bash",
54
+ ".txt": "",
55
+ ".text": "",
56
+ ".log": "",
57
+ ".rst": "",
58
+ ".mdx": "markdown",
59
+ };
60
+
61
+ function detectLanguage(filename: string): string {
62
+ const ext = filename.includes(".")
63
+ ? `.${filename.split(".").pop()?.toLowerCase()}`
64
+ : "";
65
+ return EXT_TO_LANG[ext] ?? "";
66
+ }
67
+
68
+ function formatBadge(filename: string): string {
69
+ if (!filename.includes(".")) return "TEXT";
70
+ return filename.split(".").pop()?.toUpperCase() ?? "TEXT";
71
+ }
72
+
73
+ export function SourceViewer({ path }: SourceViewerProps) {
74
+ const [content, setContent] = useState<string | null>(null);
75
+ const [loading, setLoading] = useState(true);
76
+ const [wrap, setWrap] = useState(false);
77
+ const [copied, setCopied] = useState(false);
78
+
79
+ const assetUrl = `/api/assets/${path}`;
80
+ const filename = path.split("/").pop() || path;
81
+ const language = detectLanguage(filename);
82
+
83
+ const fetchContent = useCallback(async () => {
84
+ setLoading(true);
85
+ try {
86
+ const res = await fetch(assetUrl);
87
+ if (res.ok) {
88
+ const text = await res.text();
89
+ setContent(text);
90
+ }
91
+ } catch {
92
+ /* ignore */
93
+ } finally {
94
+ setLoading(false);
95
+ }
96
+ }, [assetUrl]);
97
+
98
+ useEffect(() => {
99
+ void fetchContent();
100
+ }, [fetchContent]);
101
+
102
+ const highlightedLines = useMemo(() => {
103
+ if (!content) return [];
104
+ try {
105
+ const tree = language
106
+ ? lowlight.highlight(language, content)
107
+ : lowlight.highlightAuto(content);
108
+ const html = toHtml(tree);
109
+ // Split by newlines while preserving HTML tags that span lines
110
+ return html.split("\n");
111
+ } catch {
112
+ // Fallback: no highlighting
113
+ return content
114
+ .split("\n")
115
+ .map((line) =>
116
+ line
117
+ .replace(/&/g, "&amp;")
118
+ .replace(/</g, "&lt;")
119
+ .replace(/>/g, "&gt;"),
120
+ );
121
+ }
122
+ }, [content, language]);
123
+
124
+ const copyToClipboard = () => {
125
+ if (!content) return;
126
+ navigator.clipboard.writeText(content);
127
+ setCopied(true);
128
+ setTimeout(() => setCopied(false), 2000);
129
+ };
130
+
131
+ return (
132
+ <div className="flex-1 flex flex-col overflow-hidden">
133
+ <ViewerToolbar
134
+ path={path}
135
+ badge={formatBadge(filename)}
136
+ sublabel={language || undefined}
137
+ >
138
+ <Button
139
+ variant="ghost"
140
+ size="sm"
141
+ className={`h-7 gap-1.5 text-xs ${wrap ? "bg-muted" : ""}`}
142
+ onClick={() => setWrap((v) => !v)}
143
+ title={wrap ? "Disable line wrap" : "Enable line wrap"}
144
+ >
145
+ <WrapText className="h-3.5 w-3.5" />
146
+ Wrap
147
+ </Button>
148
+ <Button
149
+ variant="ghost"
150
+ size="sm"
151
+ className="h-7 gap-1.5 text-xs"
152
+ onClick={copyToClipboard}
153
+ title="Copy file contents"
154
+ >
155
+ {copied ? (
156
+ <Check className="h-3.5 w-3.5 text-green-500" />
157
+ ) : (
158
+ <Copy className="h-3.5 w-3.5" />
159
+ )}
160
+ {copied ? "Copied" : "Copy"}
161
+ </Button>
162
+ <Button
163
+ variant="ghost"
164
+ size="sm"
165
+ className="h-7 gap-1.5 text-xs"
166
+ onClick={() => {
167
+ const a = document.createElement("a");
168
+ a.href = assetUrl;
169
+ a.download = filename;
170
+ a.click();
171
+ }}
172
+ title="Download file"
173
+ >
174
+ <Download className="h-3.5 w-3.5" />
175
+ Download
176
+ </Button>
177
+ <Button
178
+ variant="ghost"
179
+ size="sm"
180
+ className="h-7 gap-1.5 text-xs"
181
+ onClick={() => window.open(assetUrl, "_blank")}
182
+ >
183
+ <ExternalLink className="h-3.5 w-3.5" />
184
+ Raw
185
+ </Button>
186
+ </ViewerToolbar>
187
+ <div className="flex-1 overflow-auto source-viewer-code bg-[#1e1e1e]">
188
+ {loading ? (
189
+ <div className="flex items-center justify-center h-full text-muted-foreground text-sm">
190
+ Loading...
191
+ </div>
192
+ ) : (
193
+ <table className="w-full border-collapse text-[13px] leading-relaxed font-mono">
194
+ <tbody>
195
+ {highlightedLines.map((lineHtml, i) => (
196
+ <tr key={i} className="hover:bg-white/5">
197
+ <td className="w-12 pr-4 text-right text-[#858585] select-none align-top sticky left-0 bg-[#1e1e1e]">
198
+ {i + 1}
199
+ </td>
200
+ <td
201
+ className={`text-[#d4d4d4] pl-2 ${wrap ? "whitespace-pre-wrap break-all" : "whitespace-pre"}`}
202
+ dangerouslySetInnerHTML={{ __html: lineHtml || " " }}
203
+ />
204
+ </tr>
205
+ ))}
206
+ </tbody>
207
+ </table>
208
+ )}
209
+ </div>
210
+ </div>
211
+ );
212
+ }