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,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, "&")
|
|
88
|
+
.replace(/</g, "<")
|
|
89
|
+
.replace(/>/g, ">");
|
|
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 [{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'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
|
+
}
|