snatching amp's walkthrough for my own purposes mwhahaha traverse.dunkirk.sh/diagram/6121f05c-a5ef-4ecf-8ffc-02534c5e767c

feat: add server mode

dunkirk.sh 51a50f1e db1df6f8

verified
+454 -56
+32
src/config.ts
··· 1 + import { join } from "node:path"; 2 + import { homedir } from "node:os"; 3 + import type { TraverseConfig } from "./types.ts"; 4 + 5 + const DEFAULT_SHARE_URL = "https://traverse.dunkirk.sh"; 6 + 7 + function getConfigDir(): string { 8 + const platform = process.platform; 9 + if (platform === "darwin") { 10 + return join(homedir(), "Library", "Application Support", "traverse"); 11 + } 12 + const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); 13 + return join(xdg, "traverse"); 14 + } 15 + 16 + export function loadConfig(): TraverseConfig { 17 + if (process.env.TRAVERSE_SHARE_URL) { 18 + return { shareServerUrl: process.env.TRAVERSE_SHARE_URL }; 19 + } 20 + 21 + const configPath = join(getConfigDir(), "config.json"); 22 + 23 + try { 24 + const text = require("node:fs").readFileSync(configPath, "utf-8"); 25 + const parsed = JSON.parse(text); 26 + return { 27 + shareServerUrl: parsed.shareServerUrl || DEFAULT_SHARE_URL, 28 + }; 29 + } catch { 30 + return { shareServerUrl: DEFAULT_SHARE_URL }; 31 + } 32 + }
+281 -55
src/index.ts
··· 3 3 import { z } from "zod/v4"; 4 4 import { generateViewerHTML } from "./template.ts"; 5 5 import type { WalkthroughDiagram } from "./types.ts"; 6 + import { initDb, loadAllDiagrams, saveDiagram, deleteDiagramFromDb, generateId } from "./storage.ts"; 7 + import { loadConfig } from "./config.ts"; 6 8 7 9 const PORT = parseInt(process.env.TRAVERSE_PORT || "4173", 10); 10 + const MODE = (process.env.TRAVERSE_MODE || "local") as "local" | "server"; 8 11 const GIT_HASH = await Bun.$`git rev-parse --short HEAD`.text().then(s => s.trim()).catch(() => "dev"); 9 12 10 - // In-memory diagram store 11 - const diagrams = new Map<string, WalkthroughDiagram>(); 12 - let diagramCounter = 0; 13 + // Load config and init persistence 14 + const config = loadConfig(); 15 + initDb(); 16 + 17 + // Load persisted diagrams 18 + const diagrams = loadAllDiagrams(); 13 19 14 20 // --- Web server for serving interactive diagrams --- 15 21 Bun.serve({ 16 22 port: PORT, 17 - fetch(req) { 23 + async fetch(req) { 18 24 const url = new URL(req.url); 19 - const match = url.pathname.match(/^\/diagram\/(\w+)$/); 25 + const diagramMatch = url.pathname.match(/^\/diagram\/([\w-]+)$/); 20 26 21 - if (match) { 22 - const id = match[1]!; 27 + if (diagramMatch) { 28 + const id = diagramMatch[1]!; 23 29 const diagram = diagrams.get(id); 24 30 if (!diagram) { 25 31 return new Response(generate404HTML("Diagram not found", "This diagram doesn't exist or may have expired."), { ··· 27 33 headers: { "Content-Type": "text/html; charset=utf-8" }, 28 34 }); 29 35 } 30 - return new Response(generateViewerHTML(diagram, GIT_HASH, process.cwd()), { 36 + return new Response(generateViewerHTML(diagram, GIT_HASH, process.cwd(), { 37 + mode: MODE, 38 + shareServerUrl: config.shareServerUrl, 39 + diagramId: id, 40 + }), { 31 41 headers: { "Content-Type": "text/html; charset=utf-8" }, 32 42 }); 33 43 } 34 44 45 + // DELETE /api/diagrams/:id 46 + const apiMatch = url.pathname.match(/^\/api\/diagrams\/([\w-]+)$/); 47 + if (apiMatch && req.method === "DELETE") { 48 + const id = apiMatch[1]!; 49 + if (!diagrams.has(id)) { 50 + return Response.json({ error: "not found" }, { status: 404 }); 51 + } 52 + diagrams.delete(id); 53 + deleteDiagramFromDb(id); 54 + return Response.json({ ok: true, id }); 55 + } 56 + 57 + // POST /api/diagrams (server mode: accept diagrams from remote) 58 + if (url.pathname === "/api/diagrams" && req.method === "POST") { 59 + if (MODE !== "server") { 60 + return Response.json({ error: "POST only available in server mode" }, { status: 403 }); 61 + } 62 + try { 63 + const body = await req.json() as WalkthroughDiagram; 64 + if (!body.code || !body.summary || !body.nodes) { 65 + return Response.json({ error: "missing required fields: code, summary, nodes" }, { status: 400 }); 66 + } 67 + const id = generateId(); 68 + const diagram: WalkthroughDiagram = { 69 + code: body.code, 70 + summary: body.summary, 71 + nodes: body.nodes, 72 + createdAt: new Date().toISOString(), 73 + }; 74 + diagrams.set(id, diagram); 75 + saveDiagram(id, diagram); 76 + const diagramUrl = `${url.origin}/diagram/${id}`; 77 + return Response.json({ id, url: diagramUrl }, { 78 + status: 201, 79 + headers: { 80 + "Access-Control-Allow-Origin": "*", 81 + }, 82 + }); 83 + } catch { 84 + return Response.json({ error: "invalid JSON body" }, { status: 400 }); 85 + } 86 + } 87 + 88 + // OPTIONS /api/diagrams — CORS preflight 89 + if (url.pathname === "/api/diagrams" && req.method === "OPTIONS") { 90 + return new Response(null, { 91 + status: 204, 92 + headers: { 93 + "Access-Control-Allow-Origin": "*", 94 + "Access-Control-Allow-Methods": "POST, OPTIONS", 95 + "Access-Control-Allow-Headers": "Content-Type", 96 + }, 97 + }); 98 + } 99 + 35 100 if (url.pathname === "/icon.svg") { 36 101 return new Response(Bun.file(import.meta.dir + "/../icon.svg"), { 37 102 headers: { "Content-Type": "image/svg+xml" }, ··· 40 105 41 106 // List available diagrams 42 107 if (url.pathname === "/") { 43 - return new Response(generateIndexHTML(diagrams, GIT_HASH), { 108 + const html = MODE === "server" 109 + ? generateServerIndexHTML(diagrams.size, GIT_HASH) 110 + : generateLocalIndexHTML(diagrams, GIT_HASH); 111 + return new Response(html, { 44 112 headers: { "Content-Type": "text/html; charset=utf-8" }, 45 113 }); 46 114 } ··· 52 120 }, 53 121 }); 54 122 55 - // --- MCP Server --- 56 - const server = new McpServer({ 57 - name: "traverse", 58 - version: "0.1.0", 59 - }); 123 + // --- MCP Server (local mode only) --- 124 + if (MODE === "local") { 125 + const server = new McpServer({ 126 + name: "traverse", 127 + version: "0.1.0", 128 + }); 60 129 61 - const nodeMetadataSchema = z.object({ 62 - title: z.string(), 63 - description: z.string(), 64 - links: z 65 - .array(z.object({ label: z.string(), url: z.string() })) 66 - .optional(), 67 - codeSnippet: z.string().optional(), 68 - }); 130 + const nodeMetadataSchema = z.object({ 131 + title: z.string(), 132 + description: z.string(), 133 + links: z 134 + .array(z.object({ label: z.string(), url: z.string() })) 135 + .optional(), 136 + codeSnippet: z.string().optional(), 137 + }); 69 138 70 - server.registerTool("walkthrough_diagram", { 71 - title: "Walkthrough Diagram", 72 - description: `Render an interactive Mermaid diagram where users can click nodes to see details. 139 + server.registerTool("walkthrough_diagram", { 140 + title: "Walkthrough Diagram", 141 + description: `Render an interactive Mermaid diagram where users can click nodes to see details. 73 142 74 143 BEFORE calling this tool, deeply explore the codebase: 75 144 1. Use search/read tools to find key files, entry points, and architecture patterns ··· 83 152 - Descriptions: 2-3 paragraphs of markdown per node. Write for someone who has never seen this codebase — explain what the component does, how it works, and why it matters. Use \`code spans\` for identifiers and markdown headers to organize longer explanations 84 153 - Links: include file:line references from your exploration 85 154 - Code snippets: key excerpts (under 15 lines) showing the most important or representative code`, 86 - inputSchema: z.object({ 87 - code: z.string(), 88 - summary: z.string(), 89 - nodes: z.record(z.string(), nodeMetadataSchema), 90 - }), 91 - }, async ({ code, summary, nodes }) => { 92 - const id = String(++diagramCounter); 93 - const diagram: WalkthroughDiagram = { code, summary, nodes }; 94 - diagrams.set(id, diagram); 155 + inputSchema: z.object({ 156 + code: z.string(), 157 + summary: z.string(), 158 + nodes: z.record(z.string(), nodeMetadataSchema), 159 + }), 160 + }, async ({ code, summary, nodes }) => { 161 + const id = generateId(); 162 + const diagram: WalkthroughDiagram = { 163 + code, 164 + summary, 165 + nodes, 166 + createdAt: new Date().toISOString(), 167 + }; 168 + diagrams.set(id, diagram); 169 + saveDiagram(id, diagram); 95 170 96 - const url = `http://localhost:${PORT}/diagram/${id}`; 171 + const diagramUrl = `http://localhost:${PORT}/diagram/${id}`; 97 172 98 - return { 99 - content: [ 100 - { 101 - type: "text", 102 - text: `Interactive diagram ready.\n\nOpen in browser: ${url}\n\nClick nodes in the diagram to explore details about each component.`, 103 - }, 104 - ], 105 - }; 106 - }); 173 + return { 174 + content: [ 175 + { 176 + type: "text", 177 + text: `Interactive diagram ready.\n\nOpen in browser: ${diagramUrl}\n\nClick nodes in the diagram to explore details about each component.`, 178 + }, 179 + ], 180 + }; 181 + }); 107 182 108 - // Connect MCP server to stdio transport 109 - const transport = new StdioServerTransport(); 110 - await server.connect(transport); 183 + // Connect MCP server to stdio transport 184 + const transport = new StdioServerTransport(); 185 + await server.connect(transport); 186 + } 111 187 112 188 function generate404HTML(title: string, message: string): string { 113 189 return `<!DOCTYPE html> ··· 158 234 </html>`; 159 235 } 160 236 161 - function generateIndexHTML(diagrams: Map<string, WalkthroughDiagram>, gitHash: string): string { 237 + function generateLocalIndexHTML(diagrams: Map<string, WalkthroughDiagram>, gitHash: string): string { 162 238 const items = [...diagrams.entries()] 163 239 .map( 164 240 ([id, d]) => { ··· 167 243 const preview = nodes.slice(0, 4).map(n => escapeHTML(n.title)); 168 244 const extra = nodeCount > 4 ? ` <span class="more">+${nodeCount - 4}</span>` : ""; 169 245 const tags = preview.map(t => `<span class="tag">${t}</span>`).join("") + extra; 170 - return `<a href="/diagram/${id}" class="diagram-item"> 171 - <div class="diagram-header"> 172 - <span class="diagram-title">${escapeHTML(d.summary)}</span> 173 - <span class="diagram-meta">${nodeCount} node${nodeCount !== 1 ? "s" : ""}</span> 174 - </div> 175 - <div class="diagram-tags">${tags}</div> 176 - </a>`; 246 + return `<div class="diagram-item-wrap"> 247 + <a href="/diagram/${id}" class="diagram-item"> 248 + <div class="diagram-header"> 249 + <span class="diagram-title">${escapeHTML(d.summary)}</span> 250 + <span class="diagram-meta">${nodeCount} node${nodeCount !== 1 ? "s" : ""}</span> 251 + </div> 252 + <div class="diagram-tags">${tags}</div> 253 + </a> 254 + <button class="delete-btn" onclick="deleteDiagram('${escapeHTML(id)}', this)" title="Delete diagram"> 255 + <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 256 + <path d="M2 4h12M5.333 4V2.667a1.333 1.333 0 011.334-1.334h2.666a1.333 1.333 0 011.334 1.334V4m2 0v9.333a1.333 1.333 0 01-1.334 1.334H4.667a1.333 1.333 0 01-1.334-1.334V4h9.334z"/> 257 + </svg> 258 + </button> 259 + </div>`; 177 260 }, 178 261 ) 179 262 .join("\n"); ··· 238 321 max-width: 520px; margin: 0 auto; padding: 0 20px 48px; 239 322 display: flex; flex-direction: column; gap: 12px; 240 323 } 324 + .diagram-item-wrap { 325 + position: relative; 326 + display: flex; 327 + align-items: stretch; 328 + gap: 0; 329 + } 241 330 .diagram-item { 242 331 display: flex; flex-direction: column; gap: 10px; 243 332 padding: 16px; border: 1px solid var(--border); 244 333 border-radius: 8px; text-decoration: none; color: var(--text); 245 334 transition: border-color 0.15s, background 0.15s; 335 + flex: 1; 336 + min-width: 0; 246 337 } 247 338 .diagram-item:hover { 248 339 border-color: var(--text-muted); background: var(--code-bg); 249 340 } 341 + .delete-btn { 342 + position: absolute; 343 + top: 8px; 344 + right: 8px; 345 + background: none; 346 + border: none; 347 + color: var(--text-muted); 348 + cursor: pointer; 349 + padding: 4px; 350 + border-radius: 4px; 351 + opacity: 0; 352 + transition: opacity 0.15s, color 0.15s, background 0.15s; 353 + display: flex; 354 + align-items: center; 355 + justify-content: center; 356 + } 357 + .diagram-item-wrap:hover .delete-btn { 358 + opacity: 1; 359 + } 360 + .delete-btn:hover { 361 + color: #ef4444; 362 + background: rgba(239, 68, 68, 0.1); 363 + } 250 364 .diagram-header { 251 365 display: flex; align-items: center; justify-content: space-between; 252 366 } ··· 299 413 <p>Interactive code walkthrough diagrams</p> 300 414 </div> 301 415 ${content} 416 + </div> 417 + <footer class="site-footer"> 418 + <span>Made with &#x2764;&#xFE0F; by <a href="https://dunkirk.sh">Kieran Klukas</a></span> 419 + <a class="hash" href="https://github.com/taciturnaxolotl/traverse/commit/${escapeHTML(gitHash)}">${escapeHTML(gitHash)}</a> 420 + </footer> 421 + <script> 422 + async function deleteDiagram(id, btn) { 423 + if (!confirm('Delete this diagram?')) return; 424 + try { 425 + const res = await fetch('/api/diagrams/' + id, { method: 'DELETE' }); 426 + if (res.ok) { 427 + const wrap = btn.closest('.diagram-item-wrap'); 428 + wrap.remove(); 429 + // If no diagrams left, reload to show empty state 430 + if (!document.querySelector('.diagram-item-wrap')) { 431 + location.reload(); 432 + } 433 + } 434 + } catch (e) { 435 + console.error('Failed to delete diagram:', e); 436 + } 437 + } 438 + </script> 439 + </body> 440 + </html>`; 441 + } 442 + 443 + function generateServerIndexHTML(diagramCount: number, gitHash: string): string { 444 + return `<!DOCTYPE html> 445 + <html lang="en"> 446 + <head> 447 + <meta charset="UTF-8" /> 448 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 449 + <title>Traverse</title> 450 + <link rel="icon" href="/icon.svg" type="image/svg+xml" /> 451 + <style> 452 + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } 453 + :root { 454 + --bg: #fafafa; --bg-panel: #ffffff; --border: #e2e2e2; 455 + --text: #1a1a1a; --text-muted: #666; --accent: #2563eb; 456 + --code-bg: #f4f4f5; 457 + } 458 + @media (prefers-color-scheme: dark) { 459 + :root { 460 + --bg: #0a0a0a; --bg-panel: #141414; --border: #262626; 461 + --text: #e5e5e5; --text-muted: #a3a3a3; --accent: #3b82f6; 462 + --code-bg: #1c1c1e; 463 + } 464 + } 465 + body { 466 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 467 + background: var(--bg); color: var(--text); min-height: 100vh; 468 + display: flex; flex-direction: column; align-items: center; justify-content: center; 469 + } 470 + .landing { 471 + max-width: 480px; text-align: center; padding: 40px 20px; 472 + } 473 + .landing h1 { 474 + font-size: 32px; font-weight: 700; margin-bottom: 8px; 475 + } 476 + .landing .tagline { 477 + color: var(--text-muted); font-size: 16px; line-height: 1.5; 478 + margin-bottom: 32px; 479 + } 480 + .stat { 481 + display: inline-flex; align-items: center; gap: 8px; 482 + background: var(--code-bg); border: 1px solid var(--border); 483 + border-radius: 8px; padding: 10px 20px; 484 + font-size: 14px; color: var(--text-muted); 485 + margin-bottom: 32px; 486 + } 487 + .stat strong { 488 + font-size: 20px; font-weight: 700; color: var(--text); 489 + font-variant-numeric: tabular-nums; 490 + } 491 + .github-btn { 492 + display: inline-flex; align-items: center; gap: 8px; 493 + background: var(--text); color: var(--bg); 494 + border: none; border-radius: 8px; 495 + padding: 12px 24px; font-size: 15px; font-weight: 500; 496 + text-decoration: none; transition: opacity 0.15s; 497 + font-family: inherit; 498 + } 499 + .github-btn:hover { opacity: 0.85; } 500 + .github-btn svg { flex-shrink: 0; } 501 + .site-footer { 502 + position: fixed; bottom: 0; left: 0; right: 0; 503 + padding: 20px; 504 + font-size: 13px; color: var(--text-muted); 505 + display: flex; justify-content: space-between; align-items: center; 506 + } 507 + .site-footer a { color: var(--text); text-decoration: none; } 508 + .site-footer a:hover { text-decoration: underline; } 509 + .site-footer .hash { 510 + font-family: "SF Mono", "Fira Code", monospace; 511 + font-size: 11px; opacity: 0.6; 512 + color: var(--text-muted) !important; 513 + } 514 + </style> 515 + </head> 516 + <body> 517 + <div class="landing"> 518 + <h1>Traverse</h1> 519 + <p class="tagline">Interactive code walkthrough diagrams, shareable with anyone. Powered by an MCP server you run locally.</p> 520 + <div class="stat"> 521 + <strong>${diagramCount}</strong> diagram${diagramCount !== 1 ? "s" : ""} shared 522 + </div> 523 + <br /><br /> 524 + <a class="github-btn" href="https://github.com/taciturnaxolotl/traverse"> 525 + <svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg> 526 + View on GitHub 527 + </a> 302 528 </div> 303 529 <footer class="site-footer"> 304 530 <span>Made with &#x2764;&#xFE0F; by <a href="https://dunkirk.sh">Kieran Klukas</a></span>
+62
src/storage.ts
··· 1 + import { Database } from "bun:sqlite"; 2 + import { mkdirSync, existsSync } from "node:fs"; 3 + import { join } from "node:path"; 4 + import { homedir } from "node:os"; 5 + import type { WalkthroughDiagram } from "./types.ts"; 6 + 7 + export function getDataDir(): string { 8 + if (process.env.TRAVERSE_DATA_DIR) return process.env.TRAVERSE_DATA_DIR; 9 + 10 + const platform = process.platform; 11 + if (platform === "darwin") { 12 + return join(homedir(), "Library", "Application Support", "traverse"); 13 + } 14 + // Linux / other: XDG_DATA_HOME or fallback 15 + const xdg = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"); 16 + return join(xdg, "traverse"); 17 + } 18 + 19 + let db: Database; 20 + 21 + export function initDb(): Database { 22 + const dataDir = getDataDir(); 23 + if (!existsSync(dataDir)) { 24 + mkdirSync(dataDir, { recursive: true }); 25 + } 26 + 27 + const dbPath = join(dataDir, "traverse.db"); 28 + db = new Database(dbPath); 29 + db.run(` 30 + CREATE TABLE IF NOT EXISTS diagrams ( 31 + id TEXT PRIMARY KEY, 32 + summary TEXT, 33 + data TEXT, 34 + created_at TEXT 35 + ) 36 + `); 37 + return db; 38 + } 39 + 40 + export function loadAllDiagrams(): Map<string, WalkthroughDiagram> { 41 + const rows = db.query("SELECT id, data FROM diagrams").all() as { id: string; data: string }[]; 42 + const map = new Map<string, WalkthroughDiagram>(); 43 + for (const row of rows) { 44 + map.set(row.id, JSON.parse(row.data)); 45 + } 46 + return map; 47 + } 48 + 49 + export function saveDiagram(id: string, diagram: WalkthroughDiagram): void { 50 + db.run( 51 + "INSERT OR REPLACE INTO diagrams (id, summary, data, created_at) VALUES (?, ?, ?, ?)", 52 + [id, diagram.summary, JSON.stringify(diagram), new Date().toISOString()] 53 + ); 54 + } 55 + 56 + export function deleteDiagramFromDb(id: string): void { 57 + db.run("DELETE FROM diagrams WHERE id = ?", [id]); 58 + } 59 + 60 + export function generateId(): string { 61 + return crypto.randomUUID(); 62 + }
+74 -1
src/template.ts
··· 1 1 import type { WalkthroughDiagram } from "./types.ts"; 2 2 3 - export function generateViewerHTML(diagram: WalkthroughDiagram, gitHash: string = "dev", projectRoot: string = ""): string { 3 + interface ViewerOptions { 4 + mode?: "local" | "server"; 5 + shareServerUrl?: string; 6 + diagramId?: string; 7 + } 8 + 9 + export function generateViewerHTML(diagram: WalkthroughDiagram, gitHash: string = "dev", projectRoot: string = "", options: ViewerOptions = {}): string { 4 10 const diagramJSON = JSON.stringify(diagram).replace(/<\//g, "<\\/"); 11 + const { mode = "local", shareServerUrl = "", diagramId = "" } = options; 5 12 6 13 return `<!DOCTYPE html> 7 14 <html lang="en"> ··· 129 136 overflow: hidden; 130 137 text-overflow: ellipsis; 131 138 white-space: nowrap; 139 + } 140 + 141 + .share-btn { 142 + margin-left: auto; 143 + flex-shrink: 0; 144 + display: flex; 145 + align-items: center; 146 + gap: 5px; 147 + background: none; 148 + border: 1px solid var(--border); 149 + border-radius: 6px; 150 + padding: 4px 10px; 151 + font-size: 12px; 152 + color: var(--text-muted); 153 + cursor: pointer; 154 + transition: color 0.15s, border-color 0.15s, background 0.15s; 155 + font-family: inherit; 156 + } 157 + .share-btn:hover { 158 + color: var(--text); 159 + border-color: var(--text-muted); 160 + background: var(--code-bg); 161 + } 162 + .share-btn.shared { 163 + color: #16a34a; 164 + border-color: #16a34a; 132 165 } 133 166 134 167 .diagram-wrap { ··· 581 614 <span class="breadcrumb-title" id="breadcrumb-title">${escapeHTML(diagram.summary)}</span> 582 615 <span class="sep header-sep">&rsaquo;</span> 583 616 <span class="header-node" id="header-node"></span> 617 + ${mode === "local" && shareServerUrl ? `<button class="share-btn" id="share-btn" title="Share diagram"> 618 + <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 619 + <path d="M4 12V14H12V12M8 2V10M5 5L8 2L11 5"/> 620 + </svg> 621 + <span>Share</span> 622 + </button>` : ""} 584 623 </div> 585 624 586 625 <div class="diagram-wrap"> ··· 606 645 607 646 const DIAGRAM_DATA = ${diagramJSON}; 608 647 const PROJECT_ROOT = ${JSON.stringify(projectRoot)}; 648 + const SHARE_SERVER_URL = ${JSON.stringify(shareServerUrl)}; 649 + const DIAGRAM_ID = ${JSON.stringify(diagramId)}; 650 + const VIEWER_MODE = ${JSON.stringify(mode)}; 609 651 610 652 const COPY_ICON = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="5" width="8" height="8" rx="1.5"/><path d="M3 11V3a1.5 1.5 0 011.5-1.5H11"/></svg>'; 611 653 const CHECK_ICON = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8.5l3.5 3.5 6.5-7"/></svg>'; ··· 886 928 } 887 929 888 930 init(); 931 + 932 + // Share button handler 933 + const shareBtn = document.getElementById("share-btn"); 934 + if (shareBtn && SHARE_SERVER_URL) { 935 + shareBtn.addEventListener("click", async () => { 936 + shareBtn.disabled = true; 937 + try { 938 + const res = await fetch(SHARE_SERVER_URL + "/api/diagrams", { 939 + method: "POST", 940 + headers: { "Content-Type": "application/json" }, 941 + body: JSON.stringify(DIAGRAM_DATA), 942 + }); 943 + if (!res.ok) throw new Error("Share failed"); 944 + const data = await res.json(); 945 + await navigator.clipboard.writeText(data.url); 946 + shareBtn.querySelector("span").textContent = "Copied link!"; 947 + shareBtn.classList.add("shared"); 948 + setTimeout(() => { 949 + shareBtn.querySelector("span").textContent = "Share"; 950 + shareBtn.classList.remove("shared"); 951 + }, 2000); 952 + } catch (e) { 953 + shareBtn.querySelector("span").textContent = "Failed"; 954 + setTimeout(() => { 955 + shareBtn.querySelector("span").textContent = "Share"; 956 + }, 2000); 957 + } finally { 958 + shareBtn.disabled = false; 959 + } 960 + }); 961 + } 889 962 </script> 890 963 </body> 891 964 </html>`;
+5
src/types.ts
··· 14 14 code: string; 15 15 summary: string; 16 16 nodes: Record<string, NodeMetadata>; 17 + createdAt?: string; 18 + } 19 + 20 + export interface TraverseConfig { 21 + shareServerUrl: string; 17 22 }