demo-platform/apps/editor/src/App.tsx
scot 9b09f14916 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.
2026-07-18 05:17:45 +00:00

153 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}