v0.2.0-alpha: DOM capture, all three output slices, lead gates
- Extension DOM mode: auth-context serializer — inlines CSS + images as data URLs, freezes form state, masks [data-dp-mask], strips scripts (R3 client) - API: extractDataUrls rewrites large inline resources into content-addressed assets (R3 server; verified 939KB inline -> 82-byte row + served asset) - Lead-gate steps: config on step, player overlay form blocks advance, submits to leads endpoint (editor toggle + MCP set_lead_gate) - Step-guide export (v2 slice): authed markdown + public HTML at /p/:token/guide - Video export (v3 slice, v0): ffmpeg slideshow of image steps + optional narration mux; DOM rasterization deferred to Chromium service (R4) - Editor: reorder arrows, embed-code copy, guide link, video export - REST reorder endpoint; MCP surface now 13 tools (export_guide, set_lead_gate, render_video) - ffmpeg added to runtime image E2E verified incl. R3 extraction round-trip, lead_gate in public JSON, guide renders, MCP agent exported guide + rendered video.
This commit is contained in:
parent
9b09f14916
commit
a535e15253
18 changed files with 541 additions and 22 deletions
|
|
@ -65,7 +65,11 @@ export function App(): React.ReactElement {
|
|||
{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>
|
||||
<span className="mini">
|
||||
<button className="x" title="Move up" disabled={i === 0} onClick={(e) => { e.stopPropagation(); const ids = current.steps.map((x) => x.id); [ids[i - 1], ids[i]] = [ids[i], ids[i - 1]]; api.reorder(current.id, ids).then(reload); }}>↑</button>
|
||||
<button className="x" title="Move down" disabled={i === current.steps.length - 1} onClick={(e) => { e.stopPropagation(); const ids = current.steps.map((x) => x.id); [ids[i + 1], ids[i]] = [ids[i], ids[i + 1]]; api.reorder(current.id, ids).then(reload); }}>↓</button>
|
||||
<button className="x" title="Delete step" onClick={(e) => { e.stopPropagation(); if (confirm("Delete step?")) api.deleteStep(s.id).then(reload); }}>×</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
|
@ -81,6 +85,14 @@ export function App(): React.ReactElement {
|
|||
{current.status === "published" ? "Republish" : "Publish"}
|
||||
</button>
|
||||
{current.share_token && <a className="share" href={`/p/${current.share_token}`} target="_blank" rel="noreferrer">Open player ↗</a>}
|
||||
{current.share_token && <a className="share" href={`/p/${current.share_token}/guide`} target="_blank" rel="noreferrer">Step guide ↗</a>}
|
||||
{current.share_token && (
|
||||
<button onClick={() => {
|
||||
const code = `<iframe src="${location.origin}/p/${current.share_token}" width="100%" height="600" style="border:1px solid #e2e8f0;border-radius:8px" allowfullscreen></iframe>`;
|
||||
navigator.clipboard.writeText(code); alert("Embed code copied:\n\n" + code);
|
||||
}}>Copy embed code</button>
|
||||
)}
|
||||
<button onClick={() => api.renderVideo(current.id).then((r) => alert(`Video rendered${r.skipped_dom_steps ? ` (${r.skipped_dom_steps} DOM steps skipped)` : ""}. Download: ${location.origin}${r.download}`)).catch((e) => alert(String(e)))}>Export video</button>
|
||||
</aside>
|
||||
<main>
|
||||
{err && <div className="err">{err}</div>}
|
||||
|
|
@ -96,8 +108,9 @@ function StepCanvas({ step, onSaved }: { step: Step; onSaved: () => void }): Rea
|
|||
const [box, setBox] = useState<Hotspot | null>(step.hotspot);
|
||||
const [title, setTitle] = useState(step.annotation?.title ?? "");
|
||||
const [body, setBody] = useState(step.annotation?.body ?? "");
|
||||
const [gated, setGated] = useState(!!step.lead_gate?.enabled);
|
||||
|
||||
useEffect(() => { setBox(step.hotspot); setTitle(step.annotation?.title ?? ""); setBody(step.annotation?.body ?? ""); }, [step.id]);
|
||||
useEffect(() => { setBox(step.hotspot); setTitle(step.annotation?.title ?? ""); setBody(step.annotation?.body ?? ""); setGated(!!step.lead_gate?.enabled); }, [step.id]);
|
||||
|
||||
const pct = (e: React.MouseEvent): { x: number; y: number } => {
|
||||
const r = ref.current!.getBoundingClientRect();
|
||||
|
|
@ -138,11 +151,16 @@ function StepCanvas({ step, onSaved }: { step: Step; onSaved: () => void }): Rea
|
|||
<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} />
|
||||
<label className="gate"><input type="checkbox" checked={gated} onChange={(e) => setGated(e.target.checked)} /> Lead gate: require name + email before advancing past this step</label>
|
||||
<div className="row">
|
||||
<button onClick={() =>
|
||||
api.patchStep(step.id, {
|
||||
hotspot: box,
|
||||
annotation: title || body ? { title, body } : null,
|
||||
lead_gate: gated ? { enabled: true, fields: [
|
||||
{ name: "name", label: "Your name", required: true },
|
||||
{ name: "email", label: "Work email", required: true },
|
||||
] } : null,
|
||||
}).then(onSaved)
|
||||
}>Save step</button>
|
||||
<button className="ghost" onClick={() => setBox(null)}>Clear hotspot</button>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ async function req<T>(method: string, path: string, body?: unknown): Promise<T>
|
|||
|
||||
export interface Hotspot { x: number; y: number; w: number; h: number; }
|
||||
export interface Annotation { title: string; body: string; }
|
||||
export interface LeadGate { enabled: boolean; fields: { name: string; label: string; required: boolean }[]; }
|
||||
export interface Step {
|
||||
id: string;
|
||||
position: number;
|
||||
|
|
@ -27,6 +28,7 @@ export interface Step {
|
|||
asset_id: string | null;
|
||||
hotspot: Hotspot | null;
|
||||
annotation: Annotation | null;
|
||||
lead_gate?: LeadGate | null;
|
||||
}
|
||||
export interface Demo {
|
||||
id: string;
|
||||
|
|
@ -40,10 +42,12 @@ 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">>) =>
|
||||
patchStep: (id: string, patch: Partial<Pick<Step, "hotspot" | "annotation" | "position" | "lead_gate">>) =>
|
||||
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`),
|
||||
reorder: (id: string, ids: string[]) => req<{ ok: boolean }>("POST", `/api/demos/${id}/reorder`, { ordered_step_ids: ids }),
|
||||
renderVideo: (id: string) => req<{ asset_id: string; download: string; skipped_dom_steps: number }>("POST", `/api/demos/${id}/video`, {}),
|
||||
addImageStep: async (demoId: string, file: File): Promise<Step> => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
|
|
|
|||
|
|
@ -37,3 +37,8 @@ input,textarea { font:inherit; border:1px solid var(--line); border-radius:6px;
|
|||
.ann { display:flex; flex-direction:column; gap:8px; margin-top:12px; }
|
||||
.ann .row { display:flex; gap:8px; }
|
||||
.ghost { color:var(--muted); }
|
||||
|
||||
.mini { display:flex; gap:2px; }
|
||||
.mini .x[disabled] { opacity:.3; cursor:default; }
|
||||
.gate { font-size:13px; color:var(--muted); display:flex; gap:8px; align-items:center; }
|
||||
.gate input { width:auto; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue