- 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.
153 lines
6.2 KiB
TypeScript
153 lines
6.2 KiB
TypeScript
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>
|
||
);
|
||
}
|