v0.4.0-alpha: duplicate_demo, editor UI for branches/chapters, delete, personalized links

- duplicate: REST + MCP tool + editor button. Deep copy with branch targets
  and chapter targets remapped into the copy; lead gates + raster cache
  carried; copy is always a private draft (no inherited share token).
  Verified: 3 steps / 2 chapters / 1 branch all internally consistent.
- delete demo: REST + editor button; FK cascade verified (0 orphans)
- Editor: draw-mode toggle (hotspot vs branch) with target-step picker and
  branch list; chapters manager panel; personalized-link generator;
  duplicate/delete on demo list
- MCP surface: 16 tools
- USER-GUIDE updated: new editor features + personalization prompt now uses
  duplicate_demo
This commit is contained in:
Scot Thom 2026-07-18 06:35:48 +00:00
parent 4333cd23ef
commit 3806fa917b
6 changed files with 265 additions and 26 deletions

View file

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { api, getKey, setKey, type Demo, type Step, type Hotspot } from "./api";
import { api, getKey, setKey, type Demo, type Step, type Hotspot, type Branch, type Chapter } from "./api";
export function App(): React.ReactElement {
const [key, setKeyState] = useState(getKey());
@ -17,7 +17,7 @@ export function App(): React.ReactElement {
}, [key, refreshList]);
const openDemo = (id: string) =>
api.getDemo(id).then((d) => { setCurrent(d); setStepIdx(0); setErr(""); }).catch((e) => setErr(String(e)));
api.getDemo(id).then((d) => { setCurrent(d); setStepIdx((i) => Math.min(i, Math.max(0, d.steps.length - 1))); setErr(""); }).catch((e) => setErr(String(e)));
const reload = () => current && openDemo(current.id);
@ -47,6 +47,9 @@ export function App(): React.ReactElement {
<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>}
<span className="spacer" />
<button className="x" title="Duplicate" onClick={(e) => { e.stopPropagation(); api.duplicate(d.id).then(refreshList).catch((er) => setErr(String(er))); }}></button>
<button className="x" title="Delete demo" onClick={(e) => { e.stopPropagation(); if (confirm(`Delete "${d.name}" and all its steps, analytics and leads?`)) api.deleteDemo(d.id).then(refreshList).catch((er) => setErr(String(er))); }}>×</button>
</li>
))}
{!demos.length && <li className="empty">No demos yet create one, then add screenshots as steps (or use the capture extension).</li>}
@ -81,6 +84,9 @@ export function App(): React.ReactElement {
reload();
}} />
</label>
<ChaptersPanel demo={current} onSaved={reload} />
<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>
@ -92,25 +98,80 @@ export function App(): React.ReactElement {
navigator.clipboard.writeText(code); alert("Embed code copied:\n\n" + code);
}}>Copy embed code</button>
)}
{current.share_token && (
<button onClick={() => {
const v = prompt("Viewer token for this recipient (e.g. acme-corp):");
if (!v) return;
const link = `${location.origin}/p/${current.share_token}?v=${encodeURIComponent(v.trim())}`;
navigator.clipboard.writeText(link); alert("Personalized link copied:\n\n" + link);
}}>Copy personalized link</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>}
{step ? <StepCanvas step={step} onSaved={reload} /> : <div className="empty">Add a step to begin.</div>}
{step ? <StepCanvas step={step} allSteps={current.steps} onSaved={reload} /> : <div className="empty">Add a step to begin.</div>}
</main>
</div>
);
}
function StepCanvas({ step, onSaved }: { step: Step; onSaved: () => void }): React.ReactElement {
function ChaptersPanel({ demo, onSaved }: { demo: Demo & { steps: Step[] }; onSaved: () => void }): React.ReactElement {
const [chapters, setChapters] = useState<Chapter[]>(demo.chapters ?? []);
const [title, setTitle] = useState("");
const [target, setTarget] = useState(demo.steps[0]?.id ?? "");
useEffect(() => { setChapters(demo.chapters ?? []); }, [demo.id, demo.chapters]);
const save = (next: Chapter[]) => {
setChapters(next);
api.patchDemo(demo.id, { chapters: next.length ? next : null }).then(onSaved);
};
const stepNo = (id: string) => demo.steps.findIndex((s) => s.id === id) + 1;
return (
<details className="panel">
<summary>Chapters ({chapters.length})</summary>
<ul className="chapter-list">
{chapters.map((c, i) => (
<li key={i}>
<span>{c.title} Step {stepNo(c.step_id) || "?"}</span>
<button className="x" onClick={() => save(chapters.filter((_, j) => j !== i))}>×</button>
</li>
))}
</ul>
<div className="chapter-add">
<input placeholder="Chapter title" value={title} onChange={(e) => setTitle(e.target.value)} />
<select value={target} onChange={(e) => setTarget(e.target.value)}>
{demo.steps.map((s, i) => <option key={s.id} value={s.id}>Step {i + 1}</option>)}
</select>
<button disabled={!title.trim() || !target} onClick={() => { save([...chapters, { title: title.trim(), step_id: target }]); setTitle(""); }}>Add</button>
</div>
</details>
);
}
type DrawMode = "hotspot" | "branch";
function StepCanvas({ step, allSteps, onSaved }: { step: Step; allSteps: Step[]; onSaved: () => void }): React.ReactElement {
const ref = useRef<HTMLDivElement>(null);
const [mode, setMode] = useState<DrawMode>("hotspot");
const [drag, setDrag] = useState<{ x: number; y: number } | null>(null);
const [box, setBox] = useState<Hotspot | null>(step.hotspot);
const [branches, setBranches] = useState<Branch[]>(step.branches ?? []);
const [pendingBranch, setPendingBranch] = useState<Hotspot | null>(null);
const [branchTarget, setBranchTarget] = useState("");
const [branchLabel, setBranchLabel] = useState("");
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 ?? ""); setGated(!!step.lead_gate?.enabled); }, [step.id]);
useEffect(() => {
setBox(step.hotspot); setBranches(step.branches ?? []); setPendingBranch(null);
setTitle(step.annotation?.title ?? ""); setBody(step.annotation?.body ?? "");
setGated(!!step.lead_gate?.enabled); setMode("hotspot");
}, [step.id]);
const pct = (e: React.MouseEvent): { x: number; y: number } => {
const r = ref.current!.getBoundingClientRect();
@ -120,9 +181,27 @@ function StepCanvas({ step, onSaved }: { step: Step; onSaved: () => void }): Rea
};
};
const stepNo = (id: string) => allSteps.findIndex((s) => s.id === id) + 1;
const otherSteps = allSteps.filter((s) => s.id !== step.id);
const saveStep = () =>
api.patchStep(step.id, {
hotspot: box,
annotation: title || body ? { title, body } : null,
branches: branches.length ? branches : null,
lead_gate: gated ? { enabled: true, fields: [
{ name: "name", label: "Your name", required: true },
{ name: "email", label: "Work email", required: true },
] } : null,
}).then(onSaved);
return (
<div className="canvas-wrap">
<p className="hint">Drag on the snapshot to draw the hotspot. Click Save when done.</p>
<div className="mode-row">
<span className="hint">Draw:</span>
<label><input type="radio" checked={mode === "hotspot"} onChange={() => setMode("hotspot")} /> Next-step hotspot</label>
<label><input type="radio" checked={mode === "branch"} onChange={() => setMode("branch")} /> Branch hotspot</label>
</div>
<div
ref={ref}
className="canvas"
@ -130,12 +209,13 @@ function StepCanvas({ step, onSaved }: { step: Step; onSaved: () => void }): Rea
onMouseMove={(e) => {
if (!drag) return;
const p = pct(e);
setBox({
const b = {
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),
});
};
if (mode === "hotspot") setBox(b); else setPendingBranch(b);
}}
onMouseUp={() => setDrag(null)}
>
@ -144,25 +224,51 @@ function StepCanvas({ step, onSaved }: { step: Step; onSaved: () => void }): Rea
) : (
<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}%` }} />
)}
{box && <div className="hotspot" style={{ left: `${box.x}%`, top: `${box.y}%`, width: `${box.w}%`, height: `${box.h}%` }} />}
{branches.map((b, i) => (
<div key={i} className="hotspot branch" title={b.label} style={{ left: `${b.x}%`, top: `${b.y}%`, width: `${b.w}%`, height: `${b.h}%` }} />
))}
{pendingBranch && <div className="hotspot branch pending" style={{ left: `${pendingBranch.x}%`, top: `${pendingBranch.y}%`, width: `${pendingBranch.w}%`, height: `${pendingBranch.h}%` }} />}
</div>
{mode === "branch" && (
<div className="branch-form">
{pendingBranch ? (
<>
<input placeholder="Label (optional)" value={branchLabel} onChange={(e) => setBranchLabel(e.target.value)} />
<select value={branchTarget} onChange={(e) => setBranchTarget(e.target.value)}>
<option value="">Jump to</option>
{otherSteps.map((s) => <option key={s.id} value={s.id}>Step {stepNo(s.id)}</option>)}
</select>
<button disabled={!branchTarget} onClick={() => {
setBranches([...branches, { ...pendingBranch, label: branchLabel.trim() || undefined, target_step_id: branchTarget }]);
setPendingBranch(null); setBranchLabel(""); setBranchTarget("");
}}>Add branch</button>
<button className="ghost" onClick={() => setPendingBranch(null)}>Cancel</button>
</>
) : (
<span className="hint">Drag on the snapshot to draw a branch region, then pick its target step.</span>
)}
</div>
)}
{branches.length > 0 && (
<ul className="branch-list">
{branches.map((b, i) => (
<li key={i}>
<span>{b.label || "Branch"} Step {stepNo(b.target_step_id) || "?"}</span>
<button className="x" onClick={() => setBranches(branches.filter((_, j) => j !== i))}>×</button>
</li>
))}
</ul>
)}
<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 onClick={saveStep}>Save step</button>
<button className="ghost" onClick={() => setBox(null)}>Clear hotspot</button>
</div>
</div>

View file

@ -20,6 +20,8 @@ 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 Branch { x: number; y: number; w: number; h: number; label?: string; target_step_id: string; }
export interface Chapter { title: string; step_id: string; }
export interface LeadGate { enabled: boolean; fields: { name: string; label: string; required: boolean }[]; }
export interface Step {
id: string;
@ -29,12 +31,14 @@ export interface Step {
hotspot: Hotspot | null;
annotation: Annotation | null;
lead_gate?: LeadGate | null;
branches?: Branch[] | null;
}
export interface Demo {
id: string;
name: string;
status: "draft" | "published";
share_token: string | null;
chapters?: Chapter[] | null;
steps?: Step[];
}
@ -42,10 +46,14 @@ 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" | "lead_gate">>) =>
patchStep: (id: string, patch: Partial<Pick<Step, "hotspot" | "annotation" | "position" | "lead_gate" | "branches">>) =>
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`),
patchDemo: (id: string, patch: { name?: string; chapters?: Chapter[] | null }) =>
req<Demo>("PATCH", `/api/demos/${id}`, patch),
duplicate: (id: string) => req<{ id: string; name: string }>("POST", `/api/demos/${id}/duplicate`, {}),
deleteDemo: (id: string) => req<{ ok: boolean }>("DELETE", `/api/demos/${id}`),
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> => {

View file

@ -42,3 +42,15 @@ input,textarea { font:inherit; border:1px solid var(--line); border-radius:6px;
.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; }
.spacer { flex:1; }
.hotspot.branch { border-style:dashed; background:rgba(37,99,235,.05); }
.hotspot.branch.pending { border-color:#d97706; }
.mode-row { display:flex; gap:14px; align-items:center; margin-bottom:8px; font-size:13px; color:var(--muted); }
.mode-row input { width:auto; }
.branch-form { display:flex; gap:8px; margin-top:10px; align-items:center; flex-wrap:wrap; }
.branch-form input, .branch-form select { width:auto; flex:1; min-width:120px; }
.branch-list, .chapter-list { list-style:none; padding:0; margin:8px 0 0; display:flex; flex-direction:column; gap:4px; font-size:13px; }
.branch-list li, .chapter-list li { display:flex; justify-content:space-between; align-items:center; background:#f8fafc; border:1px solid var(--line); border-radius:6px; padding:5px 10px; }
.panel { border:1px solid var(--line); border-radius:6px; padding:8px 10px; }
.panel summary { cursor:pointer; font-size:13px; color:var(--muted); }
.chapter-add { display:flex; flex-direction:column; gap:6px; margin-top:8px; }