/* global React, PR, PRCover, PRComments, PRCreatives */
const { useState, useEffect, useRef } = React;
const { I, Avatar, StagePill, DecisionPill, RiskDot, formatDate, daysAgo, metricDelta } = PR;
const { Cover } = PRCover;
const { CommentsPanel } = PRComments;
const { CreativesPanel } = PRCreatives;
// ── Pencil icon ───────────────────────────────────────────────────────────────
function PencilIcon() {
return (
);
}
// ── Cover edit overlay ────────────────────────────────────────────────────────
function CoverEditOverlay({ onSave }) {
const [open, setOpen] = useState(false);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [error, setError] = useState(null);
const fileInputRef = useRef(null);
const panelRef = useRef(null);
const uploadFile = async (file) => {
if (!file || !file.type.startsWith("image/")) { setError("Only images allowed"); return; }
setUploading(true); setError(null);
try {
const form = new FormData();
form.append("file", file);
const resp = await fetch("/api/upload", { method: "POST", body: form });
if (!resp.ok) { const e = await resp.json().catch(() => ({})); throw new Error(e.detail || "Upload failed"); }
const { url } = await resp.json();
await onSave(url);
setOpen(false);
} catch (e) {
setError(e.message);
} finally {
setUploading(false);
}
};
// Paste anywhere while panel is open
useEffect(() => {
if (!open) return;
const handler = (e) => {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith("image/")) { e.preventDefault(); uploadFile(item.getAsFile()); return; }
}
};
document.addEventListener("paste", handler);
return () => document.removeEventListener("paste", handler);
}, [open]);
// Click outside closes panel
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (panelRef.current && !panelRef.current.contains(e.target)) setOpen(false);
};
setTimeout(() => document.addEventListener("mousedown", handler), 0);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
return (
{open && (
{error &&
{error}
}
!uploading && fileInputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => { e.preventDefault(); setDragOver(false); const f = e.dataTransfer.files[0]; if (f) uploadFile(f); }}
>
{uploading ? "Uploading…" : "Click · drag & drop · Ctrl+V to paste"}
{ const f = e.target.files[0]; if (f) uploadFile(f); e.target.value = ""; }} />
)}
);
}
// ── Generic inline-editable field ─────────────────────────────────────────────
function InlineField({ value, onSave, type = "text", options = [], placeholder = "Click to edit…", children, rows = 3, style, className = "" }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const mouseDownPos = useRef(null);
const inputRef = useRef(null);
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
const el = inputRef.current;
if (el.setSelectionRange && el.value) {
el.setSelectionRange(el.value.length, el.value.length);
}
}
}, [editing]);
const startEdit = (e) => {
const sel = window.getSelection();
if (sel && sel.toString().length > 0) return;
if (mouseDownPos.current) {
const dx = Math.abs(e.clientX - mouseDownPos.current.x);
const dy = Math.abs(e.clientY - mouseDownPos.current.y);
if (dx > 4 || dy > 4) return;
}
setDraft(value ?? "");
setError(null);
setEditing(true);
};
const save = async () => {
if (saving) return;
setSaving(true);
setError(null);
try {
await onSave(draft);
setEditing(false);
} catch (err) {
setError(err.message || "Failed to save");
} finally {
setSaving(false);
}
};
const cancel = () => { setEditing(false); setError(null); };
const handleKeyDown = (e) => {
if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); cancel(); return; }
if (type !== "textarea" && e.key === "Enter") { e.preventDefault(); save(); return; }
if (type === "textarea" && e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); save(); return; }
};
if (editing) {
return (
);
}
return (
{ mouseDownPos.current = { x: e.clientX, y: e.clientY }; }}
onClick={startEdit}
>
{children != null ? children
: value ?
{value}
:
{placeholder}}
);
}
// ── Inline tag selector ───────────────────────────────────────────────────────
function InlineTagField({ value, allTags, onSave }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState([]);
const [saving, setSaving] = useState(false);
const mouseDownPos = useRef(null);
const startEdit = (e) => {
const sel = window.getSelection();
if (sel && sel.toString().length > 0) return;
if (mouseDownPos.current) {
const dx = Math.abs(e.clientX - mouseDownPos.current.x);
const dy = Math.abs(e.clientY - mouseDownPos.current.y);
if (dx > 4 || dy > 4) return;
}
setDraft([...(value || [])]);
setEditing(true);
};
const toggle = (tag) =>
setDraft(prev => prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]);
const save = async () => {
setSaving(true);
try {
await onSave(draft);
setEditing(false);
} finally {
setSaving(false);
}
};
if (editing) {
return (
{draft.map(t => (
{t}
toggle(t)}>×
))}
{(allTags || []).filter(t => !draft.includes(t)).map(t => (
toggle(t)}>{t}
))}
);
}
return (
{ mouseDownPos.current = { x: e.clientX, y: e.clientY }; }}
onClick={startEdit}
>
{value && value.length
? value.map(t => {t})
: No genre tags}
);
}
// ── Detail / R&D Passport ─────────────────────────────────────────────────────
function PassportView({ data, hypothesisId, onDecide, onAdvance, onRefresh, user, embedded }) {
const h = data.hypotheses.find(x => x.id === hypothesisId) || data.hypotheses[0];
const [assets, setAssets] = useState([]);
const [history, setHistory] = useState([]);
useEffect(() => {
if (!h) return;
fetch(`/api/hypotheses/${h.id}/assets`).then(r => r.ok ? r.json() : []).then(setAssets);
fetch(`/api/hypotheses/${h.id}/history`).then(r => r.ok ? r.json() : []).then(setHistory);
}, [h?.id]);
if (!h) return Select a hypothesis to view its passport.
;
const a = data.authors.find(x => x.id === h.author);
const m = metricDelta(h);
const stageIdx = data.stages.findIndex(s => s.id === h.stage);
const STAGE_ORDER = ["idea", "greybox", "prototype", "coldtest", "decision"];
const canAdvance = !h.decision && STAGE_ORDER.includes(h.stage) && STAGE_ORDER.indexOf(h.stage) < STAGE_ORDER.length - 1;
const canDecide = h.stage === "decision" && !h.decision;
const isDecided = !!h.decision;
const historyMap = {};
history.forEach(item => { historyMap[item.to_stage] = item; });
const deleteAsset = async (id) => {
await fetch(`/api/hypotheses/${h.id}/assets/${id}`, { method: "DELETE" });
setAssets(prev => prev.filter(a => a.id !== id));
};
const refreshAssets = () => {
fetch(`/api/hypotheses/${h.id}/assets`).then(r => r.json()).then(setAssets);
};
const [assetForm, setAssetForm] = useState({ name: "", url: "", asset_type: "other" });
const [addingAsset, setAddingAsset] = useState(false);
const submitAsset = async (e) => {
e.preventDefault();
await fetch(`/api/hypotheses/${h.id}/assets`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(assetForm),
});
setAssetForm({ name: "", url: "", asset_type: "other" });
setAddingAsset(false);
refreshAssets();
};
// ── Inline patch helper ───────────────────────────────────────────────────
const patchField = async (field, newValue) => {
const payload = {
title: h.title,
core_loop: h.coreLoop || "",
tags: h.tags || [],
key_metric: h.keyMetric !== "TBD" ? (h.keyMetric || "") : "",
target: h.target !== "—" ? (h.target || "") : "",
current_value: h.current !== "—" ? (h.current || "") : "",
risk: h.risk || "med",
summary: h.summary || "",
cover_url: h.coverUrl || "",
author_id: parseInt(h.author) || undefined,
};
payload[field] = newValue;
const resp = await fetch(`/api/hypotheses/${h.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || "Failed to save");
}
onRefresh && onRefresh();
};
return (
{/* Cover */}
patchField("cover_url", url)} />
{/* Header */}
{h.id}
·
{h.pivotedFrom && (
<>·
↳ pivot from {h.pivotedFrom}>
)}
{h.decision && (
<>·
>
)}
patchField("title", v)}
type="text"
style={{ flex: 1, minWidth: 0 }}
>
{h.title}
{canAdvance && (
)}
{canDecide && (
)}
{a &&
}
{I.clock}Started {formatDate(h.started)}
{I.clock}Updated {daysAgo(h.updated)}
{h.tags.map(t => {t})}
{/* Two-column content */}
Core loop
patchField("core_loop", v)}
type="textarea"
placeholder="Describe the core loop: player does X → gets Y → enables Z"
rows={4}
>
{h.coreLoop || Not defined yet.}
Summary
patchField("summary", v)}
type="textarea"
placeholder="One-paragraph description of the concept…"
rows={3}
>
{h.summary
? {h.summary}
: No summary yet. Click to add one.
}
Metrics
Primary
patchField("current_value", v)}
placeholder="—"
>
{h.current === "—" ? "—" : h.current}
target
patchField("target", v)}
placeholder="—"
style={{ display: "inline-flex" }}
>
{h.target}
·
patchField("key_metric", v)}
placeholder="TBD"
style={{ display: "inline-flex" }}
>
{h.keyMetric}
Assets
{assets.length || h.assets}
files & links
Risk
patchField("risk", v)}
type="select"
options={[
{ value: "low", label: "Low" },
{ value: "med", label: "Medium" },
{ value: "high", label: "High" },
]}
>
Stage timeline
{data.stages.map((s, i) => {
const status = i < stageIdx ? "done" : i === stageIdx ? "current" : "";
const item = historyMap[s.id];
return (
{s.label}
{item ? item.changed_at.slice(0, 10) : "—"}
{item?.notes &&
{item.notes}
}
{!item?.notes && status === "current" && (
In progress.
)}
{!item?.notes && status === "" && (
Not yet reached.
)}
);
})}
{/* Side panel */}
);
}
window.PRPassport = { PassportView };