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,269 @@
1
+ "use client";
2
+
3
+ import {
4
+ AlertCircle,
5
+ ExternalLink,
6
+ Loader2,
7
+ Play,
8
+ RefreshCw,
9
+ Square,
10
+ Terminal,
11
+ } from "lucide-react";
12
+ import { useCallback, useEffect, useRef, useState } from "react";
13
+ import { ViewerToolbar } from "@/components/layout/viewer-toolbar";
14
+ import { Button } from "@/components/ui/button";
15
+ import type { AppStatus } from "@/lib/app-runner";
16
+
17
+ interface Props {
18
+ path: string;
19
+ title: string;
20
+ }
21
+
22
+ interface StatusResponse {
23
+ status: AppStatus;
24
+ port?: number;
25
+ error?: string;
26
+ logs: string[];
27
+ }
28
+
29
+ const STATUS_LABEL: Record<AppStatus, string> = {
30
+ stopped: "Stopped",
31
+ installing: "Installing dependencies…",
32
+ starting: "Starting…",
33
+ running: "Running",
34
+ error: "Error",
35
+ };
36
+
37
+ export function NodeAppViewer({ path, title }: Props) {
38
+ const [status, setStatus] = useState<AppStatus>("stopped");
39
+ const [port, setPort] = useState<number | null>(null);
40
+ const [logs, setLogs] = useState<string[]>([]);
41
+ const [error, setError] = useState<string | null>(null);
42
+ const [showLogs, setShowLogs] = useState(false);
43
+ const [iframeKey, setIframeKey] = useState(0);
44
+ const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
45
+ const logsEndRef = useRef<HTMLDivElement>(null);
46
+
47
+ // Proxy URL — all traffic flows through wiki-viewer (works remotely)
48
+ const proxyUrl = `/api/app-proxy/${path}/`;
49
+
50
+ const stopPolling = () => {
51
+ if (pollRef.current) {
52
+ clearInterval(pollRef.current);
53
+ pollRef.current = null;
54
+ }
55
+ };
56
+
57
+ const fetchStatus = useCallback(async () => {
58
+ try {
59
+ const res = await fetch(`/api/wiki/app?path=${encodeURIComponent(path)}`);
60
+ if (!res.ok) return;
61
+ const data: StatusResponse = await res.json();
62
+ setStatus(data.status);
63
+ setLogs(data.logs ?? []);
64
+ if (data.port) setPort(data.port);
65
+ if (data.error) setError(data.error);
66
+ if (data.status === "running" || data.status === "stopped" || data.status === "error") {
67
+ stopPolling();
68
+ }
69
+ } catch {}
70
+ }, [path]);
71
+
72
+ // Poll while in transient states
73
+ useEffect(() => {
74
+ if (status === "installing" || status === "starting") {
75
+ if (!pollRef.current) {
76
+ pollRef.current = setInterval(fetchStatus, 800);
77
+ }
78
+ } else {
79
+ stopPolling();
80
+ }
81
+ return stopPolling;
82
+ }, [status, fetchStatus]);
83
+
84
+ // Auto-scroll logs
85
+ useEffect(() => {
86
+ logsEndRef.current?.scrollIntoView({ behavior: "smooth" });
87
+ }, [logs]);
88
+
89
+ // Fetch current status on mount (app may already be running)
90
+ useEffect(() => {
91
+ fetchStatus();
92
+ }, [fetchStatus]);
93
+
94
+ const handleLaunch = async () => {
95
+ setError(null);
96
+ setLogs([]);
97
+ setStatus("starting");
98
+ try {
99
+ const res = await fetch("/api/wiki/app", {
100
+ method: "POST",
101
+ headers: { "Content-Type": "application/json" },
102
+ body: JSON.stringify({ path }),
103
+ });
104
+ const data: { port?: number; error?: string } = await res.json();
105
+ if (!res.ok || data.error) {
106
+ setStatus("error");
107
+ setError(data.error ?? "Failed to start");
108
+ return;
109
+ }
110
+ if (data.port) setPort(data.port);
111
+ // Start polling for readiness
112
+ await fetchStatus();
113
+ } catch (e) {
114
+ setStatus("error");
115
+ setError(String(e));
116
+ }
117
+ };
118
+
119
+ const handleStop = async () => {
120
+ await fetch("/api/wiki/app", {
121
+ method: "DELETE",
122
+ headers: { "Content-Type": "application/json" },
123
+ body: JSON.stringify({ path }),
124
+ });
125
+ setStatus("stopped");
126
+ setPort(null);
127
+ };
128
+
129
+ const handleRestart = async () => {
130
+ await handleStop();
131
+ await handleLaunch();
132
+ setIframeKey((k) => k + 1);
133
+ };
134
+
135
+ const isTransient = status === "installing" || status === "starting";
136
+
137
+ return (
138
+ <div className="flex-1 flex flex-col overflow-hidden">
139
+ <ViewerToolbar path={path} badge="Node app">
140
+ {status === "running" && (
141
+ <>
142
+ <Button
143
+ variant="ghost"
144
+ size="sm"
145
+ className="h-7 gap-1.5 text-xs"
146
+ onClick={() => setIframeKey((k) => k + 1)}
147
+ >
148
+ <RefreshCw className="h-3.5 w-3.5" />
149
+ Refresh
150
+ </Button>
151
+ <Button
152
+ variant="ghost"
153
+ size="sm"
154
+ className="h-7 gap-1.5 text-xs"
155
+ onClick={() => window.open(proxyUrl, "_blank")}
156
+ >
157
+ <ExternalLink className="h-3.5 w-3.5" />
158
+ Open in new tab
159
+ </Button>
160
+ <Button
161
+ variant="ghost"
162
+ size="sm"
163
+ className="h-7 gap-1.5 text-xs"
164
+ onClick={handleRestart}
165
+ >
166
+ <RefreshCw className="h-3.5 w-3.5" />
167
+ Restart
168
+ </Button>
169
+ <Button
170
+ variant="ghost"
171
+ size="sm"
172
+ className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive"
173
+ onClick={handleStop}
174
+ >
175
+ <Square className="h-3.5 w-3.5" />
176
+ Stop
177
+ </Button>
178
+ </>
179
+ )}
180
+ <Button
181
+ variant="ghost"
182
+ size="sm"
183
+ className="h-7 gap-1.5 text-xs"
184
+ onClick={() => setShowLogs((v) => !v)}
185
+ >
186
+ <Terminal className="h-3.5 w-3.5" />
187
+ Logs
188
+ </Button>
189
+ </ViewerToolbar>
190
+
191
+ {/* Logs panel */}
192
+ {showLogs && (
193
+ <div className="border-b bg-black/90 text-green-400 font-mono text-xs h-40 overflow-auto p-2 shrink-0">
194
+ {logs.length === 0 ? (
195
+ <span className="text-muted-foreground">No output yet.</span>
196
+ ) : (
197
+ logs.map((l, i) => (
198
+ // biome-ignore lint/suspicious/noArrayIndexKey: log lines are append-only
199
+ <div key={i} className="whitespace-pre-wrap leading-5">
200
+ {l}
201
+ </div>
202
+ ))
203
+ )}
204
+ <div ref={logsEndRef} />
205
+ </div>
206
+ )}
207
+
208
+ {/* Main content */}
209
+ <div className="flex-1 flex flex-col overflow-hidden">
210
+ {status === "stopped" && (
211
+ <div className="flex-1 flex flex-col items-center justify-center gap-4">
212
+ <div className="text-center space-y-1">
213
+ <p className="text-sm font-medium">{title}</p>
214
+ <p className="text-xs text-muted-foreground">
215
+ Node.js app — will be started on a local port
216
+ </p>
217
+ </div>
218
+ <Button onClick={handleLaunch} className="gap-2">
219
+ <Play className="h-4 w-4" />
220
+ Launch app
221
+ </Button>
222
+ </div>
223
+ )}
224
+
225
+ {isTransient && (
226
+ <div className="flex-1 flex flex-col items-center justify-center gap-3">
227
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
228
+ <p className="text-sm text-muted-foreground">
229
+ {STATUS_LABEL[status]}
230
+ </p>
231
+ {port && (
232
+ <p className="text-xs text-muted-foreground/60">
233
+ port {port}
234
+ </p>
235
+ )}
236
+ </div>
237
+ )}
238
+
239
+ {status === "error" && (
240
+ <div className="flex-1 flex flex-col items-center justify-center gap-4 p-6">
241
+ <AlertCircle className="h-8 w-8 text-destructive" />
242
+ <div className="text-center space-y-1 max-w-md">
243
+ <p className="text-sm font-medium text-destructive">
244
+ Failed to start app
245
+ </p>
246
+ <p className="text-xs text-muted-foreground break-words">
247
+ {error ?? "Unknown error"}
248
+ </p>
249
+ </div>
250
+ <Button onClick={handleLaunch} variant="outline" className="gap-2">
251
+ <RefreshCw className="h-4 w-4" />
252
+ Try again
253
+ </Button>
254
+ </div>
255
+ )}
256
+
257
+ {status === "running" && (
258
+ <iframe
259
+ key={iframeKey}
260
+ src={proxyUrl}
261
+ className="flex-1 w-full border-0 bg-card"
262
+ title={title}
263
+ sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-top-navigation-by-user-activation"
264
+ />
265
+ )}
266
+ </div>
267
+ </div>
268
+ );
269
+ }
@@ -0,0 +1,370 @@
1
+ "use client";
2
+
3
+ import { toHtml } from "hast-util-to-html";
4
+ import { common, createLowlight } from "lowlight";
5
+ import { AlertCircle, Check, Copy, Download, ExternalLink } 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
+ import { markdownToHtml } from "@/lib/markdown/to-html";
10
+
11
+ interface NotebookViewerProps {
12
+ path: string;
13
+ title: string;
14
+ }
15
+
16
+ // Minimal nbformat v4 typing — only what we render.
17
+ type StringOrLines = string | string[];
18
+
19
+ interface NotebookOutputBase {
20
+ output_type: string;
21
+ }
22
+ interface StreamOutput extends NotebookOutputBase {
23
+ output_type: "stream";
24
+ name: "stdout" | "stderr";
25
+ text: StringOrLines;
26
+ }
27
+ interface DataOutput extends NotebookOutputBase {
28
+ output_type: "execute_result" | "display_data";
29
+ execution_count?: number | null;
30
+ data: Record<string, StringOrLines>;
31
+ }
32
+ interface ErrorOutput extends NotebookOutputBase {
33
+ output_type: "error";
34
+ ename: string;
35
+ evalue: string;
36
+ traceback: string[];
37
+ }
38
+ type NotebookOutput = StreamOutput | DataOutput | ErrorOutput;
39
+
40
+ interface NotebookCellBase {
41
+ cell_type: "code" | "markdown" | "raw";
42
+ source: StringOrLines;
43
+ metadata?: Record<string, unknown>;
44
+ }
45
+ interface CodeCell extends NotebookCellBase {
46
+ cell_type: "code";
47
+ execution_count?: number | null;
48
+ outputs?: NotebookOutput[];
49
+ }
50
+ interface MarkdownCell extends NotebookCellBase {
51
+ cell_type: "markdown";
52
+ }
53
+ interface RawCell extends NotebookCellBase {
54
+ cell_type: "raw";
55
+ }
56
+ type NotebookCell = CodeCell | MarkdownCell | RawCell;
57
+
58
+ interface Notebook {
59
+ cells?: NotebookCell[];
60
+ metadata?: {
61
+ kernelspec?: { name?: string; display_name?: string };
62
+ language_info?: { name?: string };
63
+ };
64
+ }
65
+
66
+ const lowlight = createLowlight(common);
67
+
68
+ function joinSource(s: StringOrLines): string {
69
+ return Array.isArray(s) ? s.join("") : (s ?? "");
70
+ }
71
+
72
+ // Strip ANSI escape sequences from stream output / tracebacks.
73
+ // Pure literal regex over control-char range — terse, no eval.
74
+ const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
75
+ function stripAnsi(s: string): string {
76
+ return s.replace(ANSI_RE, "");
77
+ }
78
+
79
+ function highlightCode(code: string, language: string): string {
80
+ try {
81
+ const tree = language
82
+ ? lowlight.highlight(language, code)
83
+ : lowlight.highlightAuto(code);
84
+ return toHtml(tree);
85
+ } catch {
86
+ return code
87
+ .replace(/&/g, "&amp;")
88
+ .replace(/</g, "&lt;")
89
+ .replace(/>/g, "&gt;");
90
+ }
91
+ }
92
+
93
+ function CellOutput({ output }: { output: NotebookOutput }) {
94
+ if (output.output_type === "stream") {
95
+ const text = stripAnsi(joinSource(output.text));
96
+ const isErr = output.name === "stderr";
97
+ return (
98
+ <pre
99
+ className={`whitespace-pre-wrap font-mono text-[12.5px] leading-relaxed px-4 py-3 rounded-md ${
100
+ isErr
101
+ ? "bg-[rgba(139,46,62,0.06)] text-[#8B2E3E]"
102
+ : "bg-[#F5EEDC] text-[#2A221B]"
103
+ }`}
104
+ >
105
+ {text}
106
+ </pre>
107
+ );
108
+ }
109
+
110
+ if (output.output_type === "error") {
111
+ const tb = output.traceback.map(stripAnsi).join("\n");
112
+ return (
113
+ <pre className="whitespace-pre-wrap font-mono text-[12.5px] leading-relaxed px-4 py-3 rounded-md bg-[rgba(139,46,62,0.08)] text-[#8B2E3E] border border-[rgba(139,46,62,0.18)]">
114
+ <span className="font-semibold">
115
+ {output.ename}: {output.evalue}
116
+ </span>
117
+ {tb ? `\n\n${tb}` : ""}
118
+ </pre>
119
+ );
120
+ }
121
+
122
+ // execute_result | display_data — pick best MIME
123
+ const data = output.data || {};
124
+ if (data["image/png"]) {
125
+ const src = `data:image/png;base64,${joinSource(data["image/png"]).replace(/\s/g, "")}`;
126
+ return (
127
+ <img
128
+ src={src}
129
+ alt="output"
130
+ className="max-w-full rounded-md bg-card p-2"
131
+ />
132
+ );
133
+ }
134
+ if (data["image/jpeg"]) {
135
+ const src = `data:image/jpeg;base64,${joinSource(data["image/jpeg"]).replace(/\s/g, "")}`;
136
+ return (
137
+ <img
138
+ src={src}
139
+ alt="output"
140
+ className="max-w-full rounded-md bg-card p-2"
141
+ />
142
+ );
143
+ }
144
+ if (data["image/svg+xml"]) {
145
+ const svg = joinSource(data["image/svg+xml"]);
146
+ return (
147
+ <div
148
+ className="max-w-full rounded-md bg-card p-2 overflow-auto"
149
+ dangerouslySetInnerHTML={{ __html: svg }}
150
+ />
151
+ );
152
+ }
153
+ if (data["text/html"]) {
154
+ // Sandbox arbitrary HTML (pandas, plotly) so scripts can't escape.
155
+ const html = joinSource(data["text/html"]);
156
+ return (
157
+ <iframe
158
+ srcDoc={`<!doctype html><html><head><base target="_blank"><style>body{margin:0;padding:8px;font-family:-apple-system,BlinkMacSystemFont,Inter,system-ui,sans-serif;background:#FFF9E9;color:#2A221B;font-size:13px}table{border-collapse:collapse}th,td{border:1px solid #D4C4B0;padding:4px 8px;text-align:left}thead{background:#EFE5CC}</style></head><body>${html}</body></html>`}
159
+ sandbox="allow-scripts"
160
+ className="w-full bg-[#FFF9E9] rounded-md border border-[#E8DDC5]"
161
+ style={{ height: 360 }}
162
+ />
163
+ );
164
+ }
165
+ if (data["text/plain"]) {
166
+ return (
167
+ <pre className="whitespace-pre-wrap font-mono text-[12.5px] leading-relaxed px-4 py-3 rounded-md bg-[#F5EEDC] text-[#2A221B]">
168
+ {stripAnsi(joinSource(data["text/plain"]))}
169
+ </pre>
170
+ );
171
+ }
172
+ return null;
173
+ }
174
+
175
+ function CodeCellView({
176
+ cell,
177
+ language,
178
+ }: {
179
+ cell: CodeCell;
180
+ language: string;
181
+ }) {
182
+ const source = joinSource(cell.source);
183
+ const html = useMemo(
184
+ () => highlightCode(source, language),
185
+ [source, language],
186
+ );
187
+ const count = cell.execution_count ?? " ";
188
+ const hasOutputs = (cell.outputs?.length ?? 0) > 0;
189
+
190
+ return (
191
+ <div className="grid grid-cols-[60px_1fr] gap-3 mb-5">
192
+ <div className="text-right pt-3 select-none font-mono text-[11px] text-[#8B5E3C]">
193
+ In&nbsp;[{count}]:
194
+ </div>
195
+ <div>
196
+ <pre className="whitespace-pre overflow-x-auto font-mono text-[13px] leading-relaxed px-4 py-3 rounded-md bg-[#FFF9E9] border border-[#E8DDC5] text-[#2A221B]">
197
+ <code dangerouslySetInnerHTML={{ __html: html }} />
198
+ </pre>
199
+
200
+ {hasOutputs && (
201
+ <div className="mt-2 grid grid-cols-[60px_1fr] gap-3">
202
+ <div className="text-right pt-3 select-none font-mono text-[11px] text-[#8B2E3E]">
203
+ Out[{count}]:
204
+ </div>
205
+ <div className="space-y-2">
206
+ {cell.outputs?.map((output, i) => (
207
+ <CellOutput key={i} output={output} />
208
+ ))}
209
+ </div>
210
+ </div>
211
+ )}
212
+ </div>
213
+ </div>
214
+ );
215
+ }
216
+
217
+ function MarkdownCellView({ cell }: { cell: MarkdownCell }) {
218
+ const [html, setHtml] = useState<string>("");
219
+ useEffect(() => {
220
+ let cancelled = false;
221
+ void markdownToHtml(joinSource(cell.source)).then((h) => {
222
+ if (!cancelled) setHtml(h);
223
+ });
224
+ return () => {
225
+ cancelled = true;
226
+ };
227
+ }, [cell.source]);
228
+ return (
229
+ <div
230
+ className="prose prose-sm max-w-none mb-5 px-1 [&_h1]:font-serif [&_h2]:font-serif [&_h3]:font-serif [&_a]:text-[#8B5E3C] [&_a:hover]:underline [&_code]:bg-[#F5EEDC] [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-[#8B2E3E]"
231
+ dangerouslySetInnerHTML={{ __html: html }}
232
+ />
233
+ );
234
+ }
235
+
236
+ function RawCellView({ cell }: { cell: RawCell }) {
237
+ return (
238
+ <pre className="whitespace-pre-wrap font-mono text-[12.5px] leading-relaxed px-4 py-3 rounded-md bg-[#F5EEDC] text-[#2A221B] mb-5">
239
+ {joinSource(cell.source)}
240
+ </pre>
241
+ );
242
+ }
243
+
244
+ export function NotebookViewer({ path }: NotebookViewerProps) {
245
+ const [notebook, setNotebook] = useState<Notebook | null>(null);
246
+ const [loading, setLoading] = useState(true);
247
+ const [error, setError] = useState<string | null>(null);
248
+ const [copied, setCopied] = useState(false);
249
+
250
+ const assetUrl = `/api/assets/${path}`;
251
+ const filename = path.split("/").pop() || path;
252
+
253
+ const fetchNotebook = useCallback(async () => {
254
+ setLoading(true);
255
+ setError(null);
256
+ try {
257
+ const res = await fetch(assetUrl);
258
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
259
+ const json = (await res.json()) as Notebook;
260
+ setNotebook(json);
261
+ } catch (e) {
262
+ setError(e instanceof Error ? e.message : "Failed to load notebook");
263
+ } finally {
264
+ setLoading(false);
265
+ }
266
+ }, [assetUrl]);
267
+
268
+ useEffect(() => {
269
+ void fetchNotebook();
270
+ }, [fetchNotebook]);
271
+
272
+ const language =
273
+ notebook?.metadata?.language_info?.name ||
274
+ notebook?.metadata?.kernelspec?.name ||
275
+ "python";
276
+
277
+ const cellCount = notebook?.cells?.length ?? 0;
278
+ const codeCellCount =
279
+ notebook?.cells?.filter((c) => c.cell_type === "code").length ?? 0;
280
+ const hasAnyOutputs =
281
+ notebook?.cells?.some(
282
+ (c) => c.cell_type === "code" && (c.outputs?.length ?? 0) > 0,
283
+ ) ?? false;
284
+
285
+ const copyJupyterCommand = () => {
286
+ navigator.clipboard.writeText(`jupyter lab ${path}`);
287
+ setCopied(true);
288
+ setTimeout(() => setCopied(false), 2000);
289
+ };
290
+
291
+ return (
292
+ <div className="flex-1 flex flex-col overflow-hidden">
293
+ <ViewerToolbar
294
+ path={path}
295
+ badge="IPYNB"
296
+ sublabel={`${cellCount} cells · ${codeCellCount} code · ${language}`}
297
+ >
298
+ <Button
299
+ variant="ghost"
300
+ size="sm"
301
+ className="h-7 gap-1.5 text-xs"
302
+ onClick={copyJupyterCommand}
303
+ title="Copy: jupyter lab <path>"
304
+ >
305
+ {copied ? (
306
+ <Check className="h-3.5 w-3.5 text-green-500" />
307
+ ) : (
308
+ <Copy className="h-3.5 w-3.5" />
309
+ )}
310
+ {copied ? "Copied" : "Copy run cmd"}
311
+ </Button>
312
+ <Button
313
+ variant="ghost"
314
+ size="sm"
315
+ className="h-7 gap-1.5 text-xs"
316
+ onClick={() => {
317
+ const a = document.createElement("a");
318
+ a.href = assetUrl;
319
+ a.download = filename;
320
+ a.click();
321
+ }}
322
+ >
323
+ <Download className="h-3.5 w-3.5" />
324
+ Download
325
+ </Button>
326
+ <Button
327
+ variant="ghost"
328
+ size="sm"
329
+ className="h-7 gap-1.5 text-xs"
330
+ onClick={() => window.open(assetUrl, "_blank")}
331
+ >
332
+ <ExternalLink className="h-3.5 w-3.5" />
333
+ Raw JSON
334
+ </Button>
335
+ </ViewerToolbar>
336
+
337
+ <div className="flex-1 overflow-auto bg-[#F5EEDC]">
338
+ {loading ? (
339
+ <div className="flex items-center justify-center h-full text-[#7A6B5D] text-sm">
340
+ Loading notebook…
341
+ </div>
342
+ ) : error ? (
343
+ <div className="flex items-center justify-center h-full text-[#8B2E3E] text-sm gap-2">
344
+ <AlertCircle className="h-4 w-4" /> {error}
345
+ </div>
346
+ ) : notebook ? (
347
+ <div className="max-w-[1100px] mx-auto py-8 px-6">
348
+ {!hasAnyOutputs && codeCellCount > 0 && (
349
+ <div className="mb-6 rounded-md border border-[#E8DDC5] bg-[#FFF9E9] px-4 py-3 text-[13px] text-[#7A6B5D]">
350
+ <span className="font-semibold text-[#0c0a09]">
351
+ This notebook hasn&apos;t been run yet.
352
+ </span>{" "}
353
+ Code and markdown cells display below; outputs appear once the
354
+ author runs the notebook in Jupyter (or you do, then re-save).
355
+ </div>
356
+ )}
357
+
358
+ {notebook.cells?.map((cell, i) => {
359
+ if (cell.cell_type === "markdown")
360
+ return <MarkdownCellView key={i} cell={cell} />;
361
+ if (cell.cell_type === "raw")
362
+ return <RawCellView key={i} cell={cell} />;
363
+ return <CodeCellView key={i} cell={cell} language={language} />;
364
+ })}
365
+ </div>
366
+ ) : null}
367
+ </div>
368
+ </div>
369
+ );
370
+ }