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,286 @@
1
+ "use client";
2
+
3
+ import type { Editor } from "@tiptap/react";
4
+ import { BubbleMenu } from "@tiptap/react/menus";
5
+ import {
6
+ AlignCenter,
7
+ AlignJustify,
8
+ AlignLeft,
9
+ AlignRight,
10
+ Bold,
11
+ Code,
12
+ Italic,
13
+ Link as LinkIcon,
14
+ Strikethrough,
15
+ Subscript as SubIcon,
16
+ Superscript as SuperIcon,
17
+ Underline as UnderlineIcon,
18
+ } from "lucide-react";
19
+ import { useEffect, useState } from "react";
20
+ import { cn } from "@/lib/utils";
21
+ import { LinkPopover } from "./link-popover";
22
+
23
+ interface Props {
24
+ editor: Editor | null;
25
+ }
26
+
27
+ type OpenPopover =
28
+ | null
29
+ | { type: "align"; range: { from: number; to: number } }
30
+ | {
31
+ type: "link";
32
+ range: { from: number; to: number };
33
+ existing: string;
34
+ anchor: { top: number; left: number };
35
+ };
36
+
37
+ export function EditorBubbleMenu({ editor }: Props) {
38
+ const [popover, setPopover] = useState<OpenPopover>(null);
39
+
40
+ useEffect(() => {
41
+ if (!popover) return;
42
+ const handle = (e: MouseEvent) => {
43
+ const target = e.target as HTMLElement | null;
44
+ if (target?.closest("[data-bubble-popover='true']")) return;
45
+ setPopover(null);
46
+ };
47
+ const t = window.setTimeout(
48
+ () => window.addEventListener("mousedown", handle),
49
+ 10,
50
+ );
51
+ return () => {
52
+ window.clearTimeout(t);
53
+ window.removeEventListener("mousedown", handle);
54
+ };
55
+ }, [popover]);
56
+
57
+ if (!editor) return null;
58
+
59
+ const btn = (active: boolean) =>
60
+ cn(
61
+ "h-7 w-7 flex items-center justify-center rounded hover:bg-accent text-foreground/80 transition-colors",
62
+ active && "bg-accent text-foreground",
63
+ );
64
+
65
+ const captureRange = () => {
66
+ const { from, to } = editor.state.selection;
67
+ return { from, to };
68
+ };
69
+
70
+ const restore = (range: { from: number; to: number }) =>
71
+ editor.chain().focus().setTextSelection(range).run();
72
+
73
+ const toggleMark =
74
+ (run: () => void) => (e: React.MouseEvent<HTMLButtonElement>) => {
75
+ e.preventDefault();
76
+ run();
77
+ };
78
+
79
+ const openAlign = (e: React.MouseEvent<HTMLButtonElement>) => {
80
+ e.preventDefault();
81
+ setPopover({ type: "align", range: captureRange() });
82
+ };
83
+
84
+ const openLink = (e: React.MouseEvent<HTMLButtonElement>) => {
85
+ e.preventDefault();
86
+ const existing = editor.getAttributes("link")?.href ?? "";
87
+ const btnRect = e.currentTarget.getBoundingClientRect();
88
+ setPopover({
89
+ type: "link",
90
+ range: captureRange(),
91
+ existing,
92
+ anchor: { top: btnRect.bottom + 6, left: btnRect.left },
93
+ });
94
+ };
95
+
96
+ const applyAlign = (align: "left" | "center" | "right" | "justify") => {
97
+ if (popover?.type !== "align") return;
98
+ restore(popover.range);
99
+ editor.chain().focus().setTextAlign(align).run();
100
+ setPopover(null);
101
+ };
102
+
103
+ const applyLink = (url: string) => {
104
+ if (popover?.type !== "link") return;
105
+ restore(popover.range);
106
+ editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
107
+ setPopover(null);
108
+ };
109
+
110
+ const removeLink = () => {
111
+ if (popover?.type !== "link") return;
112
+ restore(popover.range);
113
+ editor.chain().focus().unsetLink().run();
114
+ setPopover(null);
115
+ };
116
+
117
+ return (
118
+ <>
119
+ <BubbleMenu
120
+ editor={editor}
121
+ options={{ placement: "top", offset: 8 }}
122
+ className="flex items-center gap-0.5 px-1 py-1 bg-popover border border-border rounded-sm shadow-lg"
123
+ >
124
+ <button
125
+ type="button"
126
+ className={btn(editor.isActive("bold"))}
127
+ onMouseDown={(e) => e.preventDefault()}
128
+ onClick={toggleMark(() => editor.chain().focus().toggleBold().run())}
129
+ aria-label="Bold"
130
+ >
131
+ <Bold className="w-3.5 h-3.5" />
132
+ </button>
133
+ <button
134
+ type="button"
135
+ className={btn(editor.isActive("italic"))}
136
+ onMouseDown={(e) => e.preventDefault()}
137
+ onClick={toggleMark(() =>
138
+ editor.chain().focus().toggleItalic().run(),
139
+ )}
140
+ aria-label="Italic"
141
+ >
142
+ <Italic className="w-3.5 h-3.5" />
143
+ </button>
144
+ <button
145
+ type="button"
146
+ className={btn(editor.isActive("underline"))}
147
+ onMouseDown={(e) => e.preventDefault()}
148
+ onClick={toggleMark(() =>
149
+ editor.chain().focus().toggleUnderline().run(),
150
+ )}
151
+ aria-label="Underline"
152
+ >
153
+ <UnderlineIcon className="w-3.5 h-3.5" />
154
+ </button>
155
+ <button
156
+ type="button"
157
+ className={btn(editor.isActive("strike"))}
158
+ onMouseDown={(e) => e.preventDefault()}
159
+ onClick={toggleMark(() =>
160
+ editor.chain().focus().toggleStrike().run(),
161
+ )}
162
+ aria-label="Strikethrough"
163
+ >
164
+ <Strikethrough className="w-3.5 h-3.5" />
165
+ </button>
166
+ <button
167
+ type="button"
168
+ className={btn(editor.isActive("code"))}
169
+ onMouseDown={(e) => e.preventDefault()}
170
+ onClick={toggleMark(() => editor.chain().focus().toggleCode().run())}
171
+ aria-label="Inline code"
172
+ >
173
+ <Code className="w-3.5 h-3.5" />
174
+ </button>
175
+ <div className="w-px h-5 bg-border mx-1" />
176
+ <button
177
+ type="button"
178
+ className={btn(editor.isActive("superscript"))}
179
+ onMouseDown={(e) => e.preventDefault()}
180
+ onClick={toggleMark(() =>
181
+ editor.chain().focus().toggleSuperscript().run(),
182
+ )}
183
+ aria-label="Superscript"
184
+ >
185
+ <SuperIcon className="w-3.5 h-3.5" />
186
+ </button>
187
+ <button
188
+ type="button"
189
+ className={btn(editor.isActive("subscript"))}
190
+ onMouseDown={(e) => e.preventDefault()}
191
+ onClick={toggleMark(() =>
192
+ editor.chain().focus().toggleSubscript().run(),
193
+ )}
194
+ aria-label="Subscript"
195
+ >
196
+ <SubIcon className="w-3.5 h-3.5" />
197
+ </button>
198
+ <div className="w-px h-5 bg-border mx-1" />
199
+ <button
200
+ type="button"
201
+ className={btn(editor.isActive("link"))}
202
+ onMouseDown={(e) => e.preventDefault()}
203
+ onClick={openLink}
204
+ aria-label="Link"
205
+ >
206
+ <LinkIcon className="w-3.5 h-3.5" />
207
+ </button>
208
+ <div className="w-px h-5 bg-border mx-1" />
209
+ <div className="relative">
210
+ <button
211
+ type="button"
212
+ className={btn(false)}
213
+ onMouseDown={(e) => e.preventDefault()}
214
+ onClick={openAlign}
215
+ aria-label="Align"
216
+ >
217
+ <AlignLeft className="w-3.5 h-3.5" />
218
+ </button>
219
+ {popover?.type === "align" && (
220
+ <div
221
+ data-bubble-popover="true"
222
+ className="absolute top-full left-0 mt-1 z-50 bg-popover border border-border rounded-sm shadow-lg p-1 flex gap-0.5"
223
+ >
224
+ <button
225
+ type="button"
226
+ className={btn(editor.isActive({ textAlign: "left" }))}
227
+ onMouseDown={(e) => e.preventDefault()}
228
+ onClick={() => applyAlign("left")}
229
+ aria-label="Align left"
230
+ >
231
+ <AlignLeft className="w-3.5 h-3.5" />
232
+ </button>
233
+ <button
234
+ type="button"
235
+ className={btn(editor.isActive({ textAlign: "center" }))}
236
+ onMouseDown={(e) => e.preventDefault()}
237
+ onClick={() => applyAlign("center")}
238
+ aria-label="Align center"
239
+ >
240
+ <AlignCenter className="w-3.5 h-3.5" />
241
+ </button>
242
+ <button
243
+ type="button"
244
+ className={btn(editor.isActive({ textAlign: "right" }))}
245
+ onMouseDown={(e) => e.preventDefault()}
246
+ onClick={() => applyAlign("right")}
247
+ aria-label="Align right"
248
+ >
249
+ <AlignRight className="w-3.5 h-3.5" />
250
+ </button>
251
+ <button
252
+ type="button"
253
+ className={btn(editor.isActive({ textAlign: "justify" }))}
254
+ onMouseDown={(e) => e.preventDefault()}
255
+ onClick={() => applyAlign("justify")}
256
+ aria-label="Justify"
257
+ >
258
+ <AlignJustify className="w-3.5 h-3.5" />
259
+ </button>
260
+ </div>
261
+ )}
262
+ </div>
263
+ </BubbleMenu>
264
+
265
+ {popover?.type === "link" && (
266
+ <div
267
+ data-bubble-popover="true"
268
+ style={{
269
+ position: "fixed",
270
+ top: popover.anchor.top,
271
+ left: popover.anchor.left,
272
+ zIndex: 60,
273
+ }}
274
+ >
275
+ <LinkPopover
276
+ anchor={{ top: 0, left: 0 }}
277
+ initialUrl={popover.existing}
278
+ onCancel={() => setPopover(null)}
279
+ onApply={applyLink}
280
+ onRemove={popover.existing ? removeLink : undefined}
281
+ />
282
+ </div>
283
+ )}
284
+ </>
285
+ );
286
+ }
@@ -0,0 +1,36 @@
1
+ import { mergeAttributes, Node } from "@tiptap/core";
2
+
3
+ export const CalloutExtension = Node.create({
4
+ name: "callout",
5
+ group: "block",
6
+ content: "block+",
7
+ defining: true,
8
+
9
+ addAttributes() {
10
+ return {
11
+ type: {
12
+ default: "info",
13
+ parseHTML: (element: HTMLElement) =>
14
+ element.getAttribute("data-callout-type") || "info",
15
+ renderHTML: (attributes: Record<string, unknown>) => ({
16
+ "data-callout-type": attributes.type,
17
+ }),
18
+ },
19
+ };
20
+ },
21
+
22
+ parseHTML() {
23
+ return [{ tag: 'div[data-callout="true"]' }];
24
+ },
25
+
26
+ renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
27
+ return [
28
+ "div",
29
+ mergeAttributes(HTMLAttributes, {
30
+ "data-callout": "true",
31
+ class: `callout callout-${HTMLAttributes["data-callout-type"] || "info"}`,
32
+ }),
33
+ 0,
34
+ ];
35
+ },
36
+ });
@@ -0,0 +1,335 @@
1
+ "use client";
2
+
3
+ import { Code2, Download, Save } from "lucide-react";
4
+ import { useCallback, useEffect, useState } from "react";
5
+ import { ViewerToolbar } from "@/components/layout/viewer-toolbar";
6
+ import { Button } from "@/components/ui/button";
7
+
8
+ interface CsvViewerProps {
9
+ path: string;
10
+ title: string;
11
+ }
12
+
13
+ function parseCsv(text: string): string[][] {
14
+ const rows: string[][] = [];
15
+ let current = "";
16
+ let inQuotes = false;
17
+ let row: string[] = [];
18
+
19
+ for (let i = 0; i < text.length; i++) {
20
+ const ch = text[i];
21
+ const next = text[i + 1];
22
+
23
+ if (inQuotes) {
24
+ if (ch === '"' && next === '"') {
25
+ current += '"';
26
+ i++;
27
+ } else if (ch === '"') {
28
+ inQuotes = false;
29
+ } else {
30
+ current += ch;
31
+ }
32
+ } else {
33
+ if (ch === '"') {
34
+ inQuotes = true;
35
+ } else if (ch === ",") {
36
+ row.push(current);
37
+ current = "";
38
+ } else if (ch === "\n" || (ch === "\r" && next === "\n")) {
39
+ row.push(current);
40
+ current = "";
41
+ if (row.length > 0) rows.push(row);
42
+ row = [];
43
+ if (ch === "\r") i++;
44
+ } else {
45
+ current += ch;
46
+ }
47
+ }
48
+ }
49
+ row.push(current);
50
+ if (row.some((c) => c !== "")) rows.push(row);
51
+
52
+ return rows;
53
+ }
54
+
55
+ function rowsToCsv(rows: string[][]): string {
56
+ return rows
57
+ .map((row) =>
58
+ row
59
+ .map((cell) => {
60
+ if (cell.includes(",") || cell.includes('"') || cell.includes("\n")) {
61
+ return `"${cell.replace(/"/g, '""')}"`;
62
+ }
63
+ return cell;
64
+ })
65
+ .join(","),
66
+ )
67
+ .join("\n");
68
+ }
69
+
70
+ export function CsvViewer({ path }: CsvViewerProps) {
71
+ const [rows, setRows] = useState<string[][]>([]);
72
+ const [rawText, setRawText] = useState("");
73
+ const [sourceMode, setSourceMode] = useState(false);
74
+ const [dirty, setDirty] = useState(false);
75
+ const [saving, setSaving] = useState(false);
76
+ const [editCell, setEditCell] = useState<{ r: number; c: number } | null>(
77
+ null,
78
+ );
79
+
80
+ const csvUrl = `/api/assets/${path}`;
81
+
82
+ useEffect(() => {
83
+ fetch(csvUrl)
84
+ .then((r) => r.text())
85
+ .then((text) => {
86
+ setRawText(text);
87
+ setRows(parseCsv(text));
88
+ setDirty(false);
89
+ });
90
+ }, [csvUrl]);
91
+
92
+ const updateCell = useCallback((r: number, c: number, value: string) => {
93
+ setRows((prev) => {
94
+ const next = prev.map((row) => [...row]);
95
+ // Ensure row and column exist
96
+ while (next.length <= r) next.push([]);
97
+ while (next[r].length <= c) next[r].push("");
98
+ next[r][c] = value;
99
+ return next;
100
+ });
101
+ setDirty(true);
102
+ }, []);
103
+
104
+ const addRow = () => {
105
+ const cols = rows[0]?.length || 1;
106
+ setRows((prev) => [...prev, new Array(cols).fill("")]);
107
+ setDirty(true);
108
+ };
109
+
110
+ const addColumn = () => {
111
+ setRows((prev) => prev.map((row) => [...row, ""]));
112
+ setDirty(true);
113
+ };
114
+
115
+ const deleteRow = (r: number) => {
116
+ setRows((prev) => prev.filter((_, i) => i !== r));
117
+ setDirty(true);
118
+ };
119
+
120
+ const handleSave = async () => {
121
+ setSaving(true);
122
+ const csv = sourceMode ? rawText : rowsToCsv(rows);
123
+ try {
124
+ await fetch(`/api/assets/${path}`, {
125
+ method: "PUT",
126
+ headers: { "Content-Type": "text/plain" },
127
+ body: csv,
128
+ });
129
+ if (!sourceMode) {
130
+ setRawText(csv);
131
+ } else {
132
+ setRows(parseCsv(rawText));
133
+ }
134
+ setDirty(false);
135
+ } catch (e) {
136
+ console.error("Failed to save CSV:", e);
137
+ }
138
+ setSaving(false);
139
+ };
140
+
141
+ const toggleSource = () => {
142
+ if (sourceMode) {
143
+ // Switching back to table — parse the raw text
144
+ setRows(parseCsv(rawText));
145
+ } else {
146
+ // Switching to source — serialize current table
147
+ setRawText(rowsToCsv(rows));
148
+ }
149
+ setSourceMode(!sourceMode);
150
+ };
151
+
152
+ const headers = rows[0] || [];
153
+ const dataRows = rows.slice(1);
154
+
155
+ return (
156
+ <div className="flex-1 flex flex-col overflow-hidden">
157
+ <ViewerToolbar
158
+ path={path}
159
+ badge={`CSV${rows.length > 0 ? ` (${rows.length - 1} rows)` : ""}`}
160
+ >
161
+ {dirty && (
162
+ <Button
163
+ variant="ghost"
164
+ size="sm"
165
+ className="h-7 gap-1.5 text-xs"
166
+ onClick={handleSave}
167
+ disabled={saving}
168
+ >
169
+ <Save className="h-3.5 w-3.5" />
170
+ {saving ? "Saving..." : "Save"}
171
+ </Button>
172
+ )}
173
+ <button
174
+ onClick={toggleSource}
175
+ className={`flex items-center gap-1.5 px-2.5 py-1 text-[11px] rounded-md transition-colors border border-border ${
176
+ sourceMode
177
+ ? "bg-primary text-primary-foreground"
178
+ : "text-muted-foreground hover:bg-accent"
179
+ }`}
180
+ >
181
+ <Code2 className="h-3 w-3" />
182
+ {sourceMode ? "Table" : "Source"}
183
+ </button>
184
+ <button
185
+ onClick={() => window.open(csvUrl, "_blank")}
186
+ className="inline-flex items-center justify-center rounded-md h-8 w-8 hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer"
187
+ title="Download CSV"
188
+ >
189
+ <Download className="h-4 w-4" />
190
+ </button>
191
+ </ViewerToolbar>
192
+
193
+ {sourceMode ? (
194
+ <div className="flex-1 overflow-y-auto p-4">
195
+ <textarea
196
+ value={rawText}
197
+ onChange={(e) => {
198
+ setRawText(e.target.value);
199
+ setDirty(true);
200
+ }}
201
+ className="w-full h-full min-h-[calc(100vh-12rem)] bg-transparent font-mono text-[13px] leading-relaxed resize-none focus:outline-none"
202
+ spellCheck={false}
203
+ />
204
+ </div>
205
+ ) : (
206
+ <div className="flex-1 overflow-auto">
207
+ <table className="w-full border-collapse text-[13px]">
208
+ <thead className="sticky top-0 z-10">
209
+ <tr className="bg-muted/80 backdrop-blur-sm">
210
+ <th className="border border-border px-1 py-1 text-center text-[10px] text-muted-foreground w-8">
211
+ #
212
+ </th>
213
+ {headers.map((header, c) => (
214
+ <th
215
+ key={c}
216
+ className="border border-border px-3 py-1.5 text-left font-medium text-foreground min-w-[100px]"
217
+ >
218
+ {editCell?.r === 0 && editCell?.c === c ? (
219
+ <input
220
+ autoFocus
221
+ className="w-full bg-transparent outline-none"
222
+ value={header}
223
+ onChange={(e) => updateCell(0, c, e.target.value)}
224
+ onBlur={() => setEditCell(null)}
225
+ onKeyDown={(e) => {
226
+ if (e.key === "Enter" || e.key === "Escape")
227
+ setEditCell(null);
228
+ if (e.key === "Tab") {
229
+ e.preventDefault();
230
+ setEditCell({
231
+ r: 0,
232
+ c: c + 1 < headers.length ? c + 1 : 0,
233
+ });
234
+ }
235
+ }}
236
+ />
237
+ ) : (
238
+ <span
239
+ className="cursor-text"
240
+ onDoubleClick={() => setEditCell({ r: 0, c })}
241
+ >
242
+ {header || "\u00A0"}
243
+ </span>
244
+ )}
245
+ </th>
246
+ ))}
247
+ </tr>
248
+ </thead>
249
+ <tbody>
250
+ {dataRows.map((row, ri) => {
251
+ const r = ri + 1; // actual row index (0 is header)
252
+ return (
253
+ <tr
254
+ key={ri}
255
+ className="hover:bg-accent/30 transition-colors group"
256
+ >
257
+ <td className="border border-border px-1 py-1 text-center text-[10px] text-muted-foreground/50">
258
+ <span className="group-hover:hidden">{ri + 1}</span>
259
+ <button
260
+ className="hidden group-hover:inline text-red-400 hover:text-red-300 text-[10px]"
261
+ onClick={() => deleteRow(r)}
262
+ title="Delete row"
263
+ >
264
+ ×
265
+ </button>
266
+ </td>
267
+ {headers.map((_, c) => {
268
+ const value = row[c] ?? "";
269
+ const isEditing = editCell?.r === r && editCell?.c === c;
270
+ return (
271
+ <td
272
+ key={c}
273
+ className="border border-border px-3 py-1.5"
274
+ >
275
+ {isEditing ? (
276
+ <input
277
+ autoFocus
278
+ className="w-full bg-transparent outline-none"
279
+ value={value}
280
+ onChange={(e) => updateCell(r, c, e.target.value)}
281
+ onBlur={() => setEditCell(null)}
282
+ onKeyDown={(e) => {
283
+ if (e.key === "Escape") setEditCell(null);
284
+ if (e.key === "Enter")
285
+ setEditCell({ r: r + 1, c });
286
+ if (e.key === "Tab") {
287
+ e.preventDefault();
288
+ if (e.shiftKey) {
289
+ setEditCell({ r, c: Math.max(0, c - 1) });
290
+ } else {
291
+ setEditCell({
292
+ r: c + 1 >= headers.length ? r + 1 : r,
293
+ c: c + 1 >= headers.length ? 0 : c + 1,
294
+ });
295
+ }
296
+ }
297
+ }}
298
+ />
299
+ ) : (
300
+ <span
301
+ className="cursor-text"
302
+ onDoubleClick={() => setEditCell({ r, c })}
303
+ >
304
+ {value || "\u00A0"}
305
+ </span>
306
+ )}
307
+ </td>
308
+ );
309
+ })}
310
+ </tr>
311
+ );
312
+ })}
313
+ </tbody>
314
+ </table>
315
+
316
+ {/* Add row/column buttons */}
317
+ <div className="flex items-center gap-2 px-4 py-2 border-t border-border">
318
+ <button
319
+ onClick={addRow}
320
+ className="text-[11px] text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded hover:bg-accent"
321
+ >
322
+ + Add row
323
+ </button>
324
+ <button
325
+ onClick={addColumn}
326
+ className="text-[11px] text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded hover:bg-accent"
327
+ >
328
+ + Add column
329
+ </button>
330
+ </div>
331
+ </div>
332
+ )}
333
+ </div>
334
+ );
335
+ }