v0.1.0-alpha: interactive demo platform core

- Fastify API: demos/steps CRUD, content-addressed assets, scoped API keys
  (read/author/publish/admin, draft-only default per D4)
- Player: image + sandboxed DOM steps, hotspots, tooltips, keyboard nav,
  privacy-first beacons (no IP storage, viewer tokens only — R7)
- Editor: React SPA — demo list, drag-to-draw hotspots, annotations, publish
- MCP server: Streamable HTTP, scope-filtered tools, untrusted-data wrapping
  on viewer-supplied content (R8); platform never calls a model
- Capture extension (MV3): screenshot-flow recording, hotspots pre-placed
  from real clicks
- Docker: single app image + postgres, Traefik-ready (expose only)

E2E verified: capture->author->publish->play->beacon->lead->analytics + full
MCP tool surface with scope filtering.
This commit is contained in:
Scot Thom 2026-07-18 05:17:45 +00:00
commit 9b09f14916
33 changed files with 5555 additions and 0 deletions

12
apps/editor/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Demo Platform — Editor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

20
apps/editor/package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "@demo-platform/editor",
"version": "0.1.0",
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.7"
}
}

153
apps/editor/src/App.tsx Normal file
View file

@ -0,0 +1,153 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { api, getKey, setKey, type Demo, type Step, type Hotspot } from "./api";
export function App(): React.ReactElement {
const [key, setKeyState] = useState(getKey());
const [demos, setDemos] = useState<Demo[]>([]);
const [current, setCurrent] = useState<(Demo & { steps: Step[] }) | null>(null);
const [stepIdx, setStepIdx] = useState(0);
const [err, setErr] = useState("");
const refreshList = useCallback(() => {
api.listDemos().then(setDemos).catch((e) => setErr(String(e)));
}, []);
useEffect(() => {
if (key) refreshList();
}, [key, refreshList]);
const openDemo = (id: string) =>
api.getDemo(id).then((d) => { setCurrent(d); setStepIdx(0); setErr(""); }).catch((e) => setErr(String(e)));
const reload = () => current && openDemo(current.id);
if (!key) {
return (
<div className="center-card">
<h1>Demo Platform</h1>
<p>Paste your API key (printed to server logs on first boot).</p>
<form onSubmit={(e) => { e.preventDefault(); const v = (e.currentTarget.elements.namedItem("k") as HTMLInputElement).value.trim(); setKey(v); setKeyState(v); }}>
<input name="k" placeholder="dp_..." autoFocus />
<button type="submit">Continue</button>
</form>
</div>
);
}
if (!current) {
return (
<div className="page">
<header><h1>Demos</h1>
<button onClick={() => { const name = prompt("Demo name?"); if (name) api.createDemo(name).then(refreshList).catch((e) => setErr(String(e))); }}>+ New demo</button>
</header>
{err && <div className="err">{err}</div>}
<ul className="demo-list">
{demos.map((d) => (
<li key={d.id} onClick={() => openDemo(d.id)}>
<strong>{d.name}</strong>
<span className={`badge ${d.status}`}>{d.status}</span>
{d.share_token && <a href={`/p/${d.share_token}`} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>view </a>}
</li>
))}
{!demos.length && <li className="empty">No demos yet create one, then add screenshots as steps (or use the capture extension).</li>}
</ul>
</div>
);
}
const step = current.steps[stepIdx];
return (
<div className="editor">
<aside>
<button className="back" onClick={() => { setCurrent(null); refreshList(); }}> All demos</button>
<h2>{current.name}</h2>
<ol className="steps">
{current.steps.map((s, i) => (
<li key={s.id} className={i === stepIdx ? "active" : ""} onClick={() => setStepIdx(i)}>
<span>Step {i + 1}</span>
<button className="x" title="Delete step" onClick={(e) => { e.stopPropagation(); if (confirm("Delete step?")) api.deleteStep(s.id).then(reload); }}>×</button>
</li>
))}
</ol>
<label className="upload">
+ Add screenshot step
<input type="file" accept="image/*" multiple hidden onChange={async (e) => {
const files = Array.from(e.target.files ?? []);
for (const f of files) await api.addImageStep(current.id, f);
reload();
}} />
</label>
<button className="publish" onClick={() => api.publish(current.id).then(({ share_token }) => { alert(`Published: ${location.origin}/p/${share_token}`); reload(); })}>
{current.status === "published" ? "Republish" : "Publish"}
</button>
{current.share_token && <a className="share" href={`/p/${current.share_token}`} target="_blank" rel="noreferrer">Open player </a>}
</aside>
<main>
{err && <div className="err">{err}</div>}
{step ? <StepCanvas step={step} onSaved={reload} /> : <div className="empty">Add a step to begin.</div>}
</main>
</div>
);
}
function StepCanvas({ step, onSaved }: { step: Step; onSaved: () => void }): React.ReactElement {
const ref = useRef<HTMLDivElement>(null);
const [drag, setDrag] = useState<{ x: number; y: number } | null>(null);
const [box, setBox] = useState<Hotspot | null>(step.hotspot);
const [title, setTitle] = useState(step.annotation?.title ?? "");
const [body, setBody] = useState(step.annotation?.body ?? "");
useEffect(() => { setBox(step.hotspot); setTitle(step.annotation?.title ?? ""); setBody(step.annotation?.body ?? ""); }, [step.id]);
const pct = (e: React.MouseEvent): { x: number; y: number } => {
const r = ref.current!.getBoundingClientRect();
return {
x: Math.min(100, Math.max(0, ((e.clientX - r.left) / r.width) * 100)),
y: Math.min(100, Math.max(0, ((e.clientY - r.top) / r.height) * 100)),
};
};
return (
<div className="canvas-wrap">
<p className="hint">Drag on the snapshot to draw the hotspot. Click Save when done.</p>
<div
ref={ref}
className="canvas"
onMouseDown={(e) => setDrag(pct(e))}
onMouseMove={(e) => {
if (!drag) return;
const p = pct(e);
setBox({
x: Math.min(drag.x, p.x),
y: Math.min(drag.y, p.y),
w: Math.abs(p.x - drag.x),
h: Math.abs(p.y - drag.y),
});
}}
onMouseUp={() => setDrag(null)}
>
{step.snapshot_type === "image" && step.asset_id ? (
<img src={`/assets/${step.asset_id}`} draggable={false} alt="" />
) : (
<div className="dom-note">DOM step (rendered in player)</div>
)}
{box && (
<div className="hotspot" style={{ left: `${box.x}%`, top: `${box.y}%`, width: `${box.w}%`, height: `${box.h}%` }} />
)}
</div>
<div className="ann">
<input placeholder="Tooltip title" value={title} onChange={(e) => setTitle(e.target.value)} />
<textarea placeholder="Tooltip body" value={body} onChange={(e) => setBody(e.target.value)} rows={3} />
<div className="row">
<button onClick={() =>
api.patchStep(step.id, {
hotspot: box,
annotation: title || body ? { title, body } : null,
}).then(onSaved)
}>Save step</button>
<button className="ghost" onClick={() => setBox(null)}>Clear hotspot</button>
</div>
</div>
</div>
);
}

62
apps/editor/src/api.ts Normal file
View file

@ -0,0 +1,62 @@
export function getKey(): string {
return localStorage.getItem("dp_api_key") ?? "";
}
export function setKey(k: string): void {
localStorage.setItem("dp_api_key", k);
}
async function req<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method,
headers: {
Authorization: `Bearer ${getKey()}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
return res.json() as Promise<T>;
}
export interface Hotspot { x: number; y: number; w: number; h: number; }
export interface Annotation { title: string; body: string; }
export interface Step {
id: string;
position: number;
snapshot_type: "dom" | "image";
asset_id: string | null;
hotspot: Hotspot | null;
annotation: Annotation | null;
}
export interface Demo {
id: string;
name: string;
status: "draft" | "published";
share_token: string | null;
steps?: Step[];
}
export const api = {
listDemos: () => req<Demo[]>("GET", "/api/demos"),
getDemo: (id: string) => req<Demo & { steps: Step[] }>("GET", `/api/demos/${id}`),
createDemo: (name: string) => req<Demo>("POST", "/api/demos", { name }),
patchStep: (id: string, patch: Partial<Pick<Step, "hotspot" | "annotation" | "position">>) =>
req<Step>("PATCH", `/api/steps/${id}`, patch),
deleteStep: (id: string) => req<{ ok: boolean }>("DELETE", `/api/steps/${id}`),
publish: (id: string) => req<{ share_token: string; url: string }>("POST", `/api/demos/${id}/publish`),
addImageStep: async (demoId: string, file: File): Promise<Step> => {
const fd = new FormData();
fd.append("file", file);
const up = await fetch("/api/assets", {
method: "POST",
headers: { Authorization: `Bearer ${getKey()}` },
body: fd,
});
if (!up.ok) throw new Error(await up.text());
const asset = (await up.json()) as { id: string };
return req<Step>("POST", `/api/demos/${demoId}/steps`, {
snapshot_type: "image",
asset_id: asset.id,
});
},
};

6
apps/editor/src/main.tsx Normal file
View file

@ -0,0 +1,6 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(<App />);

View file

@ -0,0 +1,39 @@
:root { --accent:#2563eb; --bg:#f8fafc; --fg:#0f172a; --line:#e2e8f0; --muted:#64748b; }
* { box-sizing:border-box; margin:0; }
body { background:var(--bg); color:var(--fg); font:15px/1.5 system-ui,sans-serif; }
button { font:inherit; border:1px solid var(--line); background:#fff; border-radius:6px; padding:7px 14px; cursor:pointer; }
button:hover { border-color:var(--accent); color:var(--accent); }
input,textarea { font:inherit; border:1px solid var(--line); border-radius:6px; padding:8px 10px; width:100%; }
.center-card { max-width:380px; margin:14vh auto; background:#fff; border:1px solid var(--line); border-radius:12px; padding:28px; display:flex; flex-direction:column; gap:12px; }
.center-card form { display:flex; gap:8px; }
.page { max-width:760px; margin:0 auto; padding:28px 20px; }
.page header { display:flex; justify-content:space-between; align-items:center; margin-bottom:18px; }
.demo-list { list-style:none; padding:0; display:flex; flex-direction:column; gap:8px; }
.demo-list li { background:#fff; border:1px solid var(--line); border-radius:8px; padding:14px 16px; display:flex; gap:12px; align-items:center; cursor:pointer; }
.demo-list li:hover { border-color:var(--accent); }
.demo-list .empty { cursor:default; color:var(--muted); }
.badge { font-size:12px; border-radius:999px; padding:2px 10px; background:#f1f5f9; color:var(--muted); }
.badge.published { background:#dcfce7; color:#166534; }
.err { background:#fef2f2; color:#991b1b; border:1px solid #fecaca; border-radius:8px; padding:10px 14px; margin-bottom:12px; }
.editor { display:grid; grid-template-columns:240px 1fr; height:100vh; }
.editor aside { background:#fff; border-right:1px solid var(--line); padding:16px; display:flex; flex-direction:column; gap:10px; overflow-y:auto; }
.editor aside h2 { font-size:16px; }
.editor main { padding:20px; overflow-y:auto; }
.steps { list-style:none; padding:0; display:flex; flex-direction:column; gap:6px; }
.steps li { border:1px solid var(--line); border-radius:6px; padding:8px 10px; display:flex; justify-content:space-between; cursor:pointer; }
.steps li.active { border-color:var(--accent); background:#eff6ff; }
.steps .x { border:none; padding:0 6px; color:var(--muted); }
.upload { border:1px dashed var(--line); border-radius:6px; padding:10px; text-align:center; cursor:pointer; color:var(--muted); }
.upload:hover { border-color:var(--accent); color:var(--accent); }
.publish { background:var(--accent); color:#fff; border-color:var(--accent); }
.publish:hover { color:#fff; opacity:.9; }
.share { font-size:13px; text-align:center; }
.canvas-wrap { max-width:1000px; }
.hint { color:var(--muted); font-size:13px; margin-bottom:10px; }
.canvas { position:relative; border:1px solid var(--line); border-radius:8px; overflow:hidden; background:#fff; user-select:none; }
.canvas img { display:block; width:100%; }
.dom-note { padding:60px; color:var(--muted); text-align:center; }
.hotspot { position:absolute; border:2px solid var(--accent); background:rgba(37,99,235,.1); border-radius:4px; pointer-events:none; }
.ann { display:flex; flex-direction:column; gap:8px; margin-top:12px; }
.ann .row { display:flex; gap:8px; }
.ghost { color:var(--muted); }

View file

@ -0,0 +1,9 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
base: "/editor/",
build: { outDir: "../api/public/editor", emptyOutDir: true },
server: { proxy: { "/api": "http://localhost:3000", "/assets": "http://localhost:3000" } },
});