From 753e0d866199f4aaef0d4062a154c1737df7dde8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 16:04:54 +0000 Subject: [PATCH 1/4] feat(web): add PRD Creator tool to /tools Adds a full-featured PRD creation tool at /tools/prd-creator with: - Guided template sections: overview, competitor analysis, user stories, options (with pros/cons), implementation plan, completion criteria, and follow-up tasks & notes - Local storage persistence with autosave (debounced 900ms) - PRD library sidebar: browse, switch, and delete saved PRDs - Collapsible sections with state persisted per-document - Copy as Markdown (full structured export) - Export as PNG or JPEG via canvas renderer (no external deps) - Registers on the /tools index page --- web/src/pages/tools/_PRDCreatorContent.tsx | 1002 ++++++++++++++++++++ web/src/pages/tools/index.tsx | 8 + web/src/pages/tools/prd-creator.module.css | 900 ++++++++++++++++++ web/src/pages/tools/prd-creator.tsx | 19 + 4 files changed, 1929 insertions(+) create mode 100644 web/src/pages/tools/_PRDCreatorContent.tsx create mode 100644 web/src/pages/tools/prd-creator.module.css create mode 100644 web/src/pages/tools/prd-creator.tsx diff --git a/web/src/pages/tools/_PRDCreatorContent.tsx b/web/src/pages/tools/_PRDCreatorContent.tsx new file mode 100644 index 00000000..a30f175c --- /dev/null +++ b/web/src/pages/tools/_PRDCreatorContent.tsx @@ -0,0 +1,1002 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { + Plus, X, ChevronDown, FileText, Image, ClipboardCopy, Trash2, +} from 'lucide-react'; +import styles from './prd-creator.module.css'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface Competitor { + name: string; + strengths: string; + weaknesses: string; + notes: string; +} + +interface UserStory { + persona: string; + action: string; + benefit: string; +} + +interface PRDOption { + title: string; + description: string; + pros: string[]; + cons: string[]; +} + +interface Phase { + phase: string; + description: string; + timeline: string; +} + +interface CheckItem { + text: string; + done: boolean; +} + +interface PRD { + id: string; + title: string; + created: number; + modified: number; + overview: string; + competitors: Competitor[]; + userStories: UserStory[]; + options: PRDOption[]; + plan: Phase[]; + criteria: CheckItem[]; + tasks: CheckItem[]; + notes: string; + collapsed: Record; +} + +// ── Storage helpers ─────────────────────────────────────────────────────────── + +const STORE_KEY = 'prd-studio-v1'; + +function loadPRDs(): PRD[] { + try { + return JSON.parse(localStorage.getItem(STORE_KEY) ?? '[]') as PRD[]; + } catch { + return []; + } +} + +function savePRDs(prds: PRD[]) { + try { + localStorage.setItem(STORE_KEY, JSON.stringify(prds)); + } catch {} +} + +function mkId() { + return 'prd_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7); +} + +function mkPRD(): PRD { + return { + id: mkId(), + title: '', + created: Date.now(), + modified: Date.now(), + overview: '', + competitors: [{ name: '', strengths: '', weaknesses: '', notes: '' }], + userStories: [{ persona: '', action: '', benefit: '' }], + options: [{ title: '', description: '', pros: [''], cons: [''] }], + plan: [{ phase: '', description: '', timeline: '' }], + criteria: [{ text: '', done: false }], + tasks: [{ text: '', done: false }], + notes: '', + collapsed: {}, + }; +} + +function fmtDate(ts: number) { + const d = new Date(ts); + const diff = Date.now() - ts; + if (diff < 60_000) return 'just now'; + if (diff < 3_600_000) return Math.floor(diff / 60_000) + 'm ago'; + if (diff < 86_400_000) return Math.floor(diff / 3_600_000) + 'h ago'; + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +// ── Auto-resize textarea hook ───────────────────────────────────────────────── + +function useAutoResize(dep: unknown) { + useEffect(() => { + document.querySelectorAll('textarea[data-auto]').forEach((el) => { + el.style.height = 'auto'; + el.style.height = el.scrollHeight + 'px'; + }); + }, [dep]); +} + +// ── Section wrapper ─────────────────────────────────────────────────────────── + +interface SectionProps { + num: number; + title: string; + collapsed: boolean; + onToggle: () => void; + children: React.ReactNode; +} + +function Section({ num, title, collapsed, onToggle, children }: SectionProps) { + return ( +
+
+ {String(num).padStart(2, '0')} + {title} + +
+ {!collapsed &&
{children}
} +
+ ); +} + +// ── Markdown export ─────────────────────────────────────────────────────────── + +function toMarkdown(p: PRD): string { + let md = `# ${p.title || 'Untitled PRD'}\n_${new Date(p.created).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}_\n\n`; + + if (p.overview?.trim()) + md += `## Overview\n\n${p.overview}\n\n`; + + const comps = p.competitors.filter((c) => c.name?.trim()); + if (comps.length) { + md += `## Competitor Analysis\n\n| Competitor | Strengths | Weaknesses | Notes |\n|---|---|---|---|\n`; + comps.forEach((c) => { md += `| ${c.name} | ${c.strengths ?? ''} | ${c.weaknesses ?? ''} | ${c.notes ?? ''} |\n`; }); + md += '\n'; + } + + const stories = p.userStories.filter((s) => s.persona?.trim() || s.action?.trim()); + if (stories.length) { + md += `## User Stories\n\n`; + stories.forEach((s) => { md += `- As a **${s.persona || '…'}**, I want to **${s.action || '…'}**, so that **${s.benefit || '…'}**\n`; }); + md += '\n'; + } + + const opts = p.options.filter((o) => o.title?.trim()); + if (opts.length) { + md += `## Options\n\n`; + opts.forEach((o, i) => { + md += `### Option ${i + 1}: ${o.title}\n\n`; + if (o.description?.trim()) md += `${o.description}\n\n`; + const pros = o.pros.filter((x) => x?.trim()); + const cons = o.cons.filter((x) => x?.trim()); + if (pros.length) { md += `**Pros**\n`; pros.forEach((x) => { md += `- ${x}\n`; }); md += '\n'; } + if (cons.length) { md += `**Cons**\n`; cons.forEach((x) => { md += `- ${x}\n`; }); md += '\n'; } + }); + } + + const phases = p.plan.filter((ph) => ph.phase?.trim()); + if (phases.length) { + md += `## Implementation Plan\n\n`; + phases.forEach((ph, i) => { + md += `### Phase ${i + 1}: ${ph.phase}${ph.timeline ? ` _(${ph.timeline})_` : ''}\n\n`; + if (ph.description?.trim()) md += `${ph.description}\n\n`; + }); + } + + const crit = p.criteria.filter((c) => c.text?.trim()); + if (crit.length) { + md += `## Completion Criteria\n\n`; + crit.forEach((c) => { md += `- [${c.done ? 'x' : ' '}] ${c.text}\n`; }); + md += '\n'; + } + + const tasks = p.tasks.filter((t) => t.text?.trim()); + if (tasks.length || p.notes?.trim()) { + md += `## Follow-up\n\n`; + if (tasks.length) { md += `### Tasks\n\n`; tasks.forEach((t) => { md += `- [${t.done ? 'x' : ' '}] ${t.text}\n`; }); md += '\n'; } + if (p.notes?.trim()) md += `### Notes\n\n${p.notes}\n\n`; + } + + return md; +} + +// ── Canvas export ───────────────────────────────────────────────────────────── + +function renderToCanvas(p: PRD): HTMLCanvasElement { + const SC = 2, W = 960, M = 60, CW = W - M * 2; + const LH = 1.6; + + const C = { + bg: '#FFFFFF', text: '#1A1816', sub: '#6B6560', light: '#9E9890', + accent: '#1A5C40', mid: '#2D8A62', alight: '#EAF4EE', + danger: '#B83B2A', border: '#E2DED8', + }; + + const mc = document.createElement('canvas'); + mc.width = W; mc.height = 200; + const mx = mc.getContext('2d')!; + + function countLines(ctx: CanvasRenderingContext2D, text: string, maxW: number, font: string) { + if (!text?.trim()) return 0; + ctx.font = font; + const words = text.trim().split(/\s+/); + let lines = 1, line = ''; + for (const w of words) { + const t = line ? line + ' ' + w : w; + if (ctx.measureText(t).width > maxW && line) { lines++; line = w; } else line = t; + } + return lines; + } + + function textH(ctx: CanvasRenderingContext2D, text: string, maxW: number, sz: number) { + return countLines(ctx, text, maxW, `${sz}px system-ui`) * sz * LH; + } + + let H = M + 40 + 20 + 1 + 24; + if (p.overview?.trim()) H += 36 + textH(mx, p.overview, CW, 14) + 20; + const comps = p.competitors.filter((c) => c.name?.trim()); + if (comps.length) { H += 36 + 20 + comps.length * 48 + 20; } + const stories = p.userStories.filter((s) => s.persona?.trim() || s.action?.trim()); + stories.forEach((s) => { H += textH(mx, `As a ${s.persona||'…'}, I want to ${s.action||'…'}, so that ${s.benefit||'…'}.`, CW - 16, 13) + 12; }); + if (stories.length) H += 36 + 20; + const opts = p.options.filter((o) => o.title?.trim()); + if (opts.length) { + H += 36 + 8; + opts.forEach((o) => { + H += 32 + (o.description?.trim() ? textH(mx, o.description, CW, 13) + 8 : 0) + + Math.max(o.pros.filter((x) => x?.trim()).length, o.cons.filter((x) => x?.trim()).length) * 20 + 40; + }); + H += 12; + } + const phases = p.plan.filter((ph) => ph.phase?.trim()); + if (phases.length) { + H += 36 + 8; + phases.forEach((ph) => { H += 28 + (ph.description?.trim() ? textH(mx, ph.description, CW - 32, 13) + 4 : 0) + 16; }); + H += 12; + } + const crit = p.criteria.filter((c) => c.text?.trim()); + if (crit.length) H += 36 + 8 + crit.length * 22 + 20; + const tasks = p.tasks.filter((t) => t.text?.trim()); + if (tasks.length || p.notes?.trim()) { + H += 36 + 8; + if (tasks.length) H += tasks.length * 22 + 12; + if (p.notes?.trim()) H += 20 + textH(mx, p.notes, CW, 13) + 8; + H += 12; + } + H += M; + + const canvas = document.createElement('canvas'); + canvas.width = W * SC; canvas.height = H * SC; + const ctx = canvas.getContext('2d')!; + ctx.scale(SC, SC); + ctx.fillStyle = C.bg; ctx.fillRect(0, 0, W, H); + + function drawText(text: string, x: number, y: number, maxW: number, sz: number, weight: string, color: string, lh = LH) { + if (!text?.trim()) return y; + ctx.font = `${weight} ${sz}px system-ui`; + ctx.fillStyle = color; + const words = text.trim().split(/\s+/); + let line = '', cy = y; + for (const w of words) { + const t = line ? line + ' ' + w : w; + if (ctx.measureText(t).width > maxW && line) { ctx.fillText(line, x, cy); cy += sz * lh; line = w; } else line = t; + } + if (line) { ctx.fillText(line, x, cy); cy += sz * lh; } + return cy; + } + + function secHeader(label: string, y: number) { + ctx.fillStyle = C.accent; ctx.fillRect(M, y, 3, 24); + ctx.font = 'bold 14px Georgia,serif'; ctx.fillStyle = C.text; + ctx.fillText(label, M + 12, y + 17); + ctx.fillStyle = C.border; ctx.fillRect(M, y + 26, CW, 1); + return y + 36; + } + + function checkbox(x: number, y: number, checked: boolean) { + ctx.strokeStyle = checked ? C.accent : C.border; + ctx.lineWidth = 1.5; ctx.strokeRect(x, y, 14, 14); + if (checked) { + ctx.fillStyle = C.accent; ctx.fillRect(x, y, 14, 14); + ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(x + 3, y + 7); ctx.lineTo(x + 6, y + 11); ctx.lineTo(x + 11, y + 4); ctx.stroke(); + } + ctx.lineWidth = 1; + } + + let y = M; + + ctx.font = 'bold 28px Georgia,serif'; ctx.fillStyle = C.text; + ctx.fillText(p.title || 'Untitled PRD', M, y + 28); y += 42; + ctx.font = '12px system-ui'; ctx.fillStyle = C.light; + ctx.fillText('PRD · ' + new Date(p.created).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }), M, y); + y += 18; ctx.fillStyle = C.border; ctx.fillRect(M, y, CW, 1); y += 24; + + if (p.overview?.trim()) { + y = secHeader('Overview', y); + y = drawText(p.overview, M, y + 2, CW, 14, 'normal', C.text) + 16; + } + + if (comps.length) { + y = secHeader('Competitor Analysis', y); y += 4; + const cw = [CW * 0.22, CW * 0.30, CW * 0.30, CW * 0.18]; + const cx = [M, M + cw[0], M + cw[0] + cw[1], M + cw[0] + cw[1] + cw[2]]; + ctx.font = 'bold 9px system-ui'; ctx.fillStyle = C.light; + ['COMPETITOR', 'STRENGTHS', 'WEAKNESSES', 'NOTES'].forEach((h, i) => ctx.fillText(h, cx[i], y)); + y += 4; ctx.fillStyle = C.border; ctx.fillRect(M, y, CW, 1); y += 12; + comps.forEach((comp) => { + ctx.font = 'bold 13px system-ui'; ctx.fillStyle = C.text; ctx.fillText(comp.name, cx[0], y + 13); + const sY = drawText(comp.strengths, cx[1], y + 2, cw[1] - 12, 12, 'normal', C.mid); + const wY = drawText(comp.weaknesses, cx[2], y + 2, cw[2] - 12, 12, 'normal', C.danger); + drawText(comp.notes, cx[3], y + 2, cw[3] - 4, 12, 'normal', C.sub); + y = Math.max(y + 28, sY, wY) + 10; + ctx.fillStyle = C.border; ctx.fillRect(M, y - 4, CW, 1); + }); + y += 16; + } + + if (stories.length) { + y = secHeader('User Stories', y); y += 4; + stories.forEach((s) => { + ctx.fillStyle = C.mid; ctx.beginPath(); ctx.arc(M + 5, y + 7, 3.5, 0, Math.PI * 2); ctx.fill(); + const line = `As a ${s.persona || '…'}, I want to ${s.action || '…'}, so that ${s.benefit || '…'}.`; + y = drawText(line, M + 16, y, CW - 16, 13, 'normal', C.text) + 10; + }); + y += 10; + } + + if (opts.length) { + y = secHeader('Options', y); y += 8; + opts.forEach((o, i) => { + ctx.fillStyle = C.alight; ctx.fillRect(M, y, CW, 28); + ctx.font = 'bold 9px system-ui'; ctx.fillStyle = C.mid; ctx.fillText(`OPTION ${i + 1}`, M + 12, y + 18); + ctx.font = 'bold 13px Georgia,serif'; ctx.fillStyle = C.text; ctx.fillText(o.title, M + 80, y + 18); + y += 32; + if (o.description?.trim()) y = drawText(o.description, M, y + 2, CW, 13, 'normal', C.sub) + 10; + const pros = o.pros.filter((x) => x?.trim()); + const cons = o.cons.filter((x) => x?.trim()); + const hw = (CW - 20) / 2, px = M, cx2 = M + hw + 20; + let py = y, cy2 = y; + ctx.font = 'bold 10px system-ui'; ctx.fillStyle = C.mid; ctx.fillText('PROS', px, py); py += 16; + pros.forEach((pr) => { ctx.fillStyle = C.mid; ctx.beginPath(); ctx.arc(px + 5, py + 3, 3, 0, Math.PI * 2); ctx.fill(); py = drawText(pr, px + 14, py - 2, hw - 14, 12, 'normal', C.text) + 4; }); + ctx.font = 'bold 10px system-ui'; ctx.fillStyle = C.danger; ctx.fillText('CONS', cx2, cy2); cy2 += 16; + cons.forEach((co) => { ctx.fillStyle = C.danger; ctx.beginPath(); ctx.arc(cx2 + 5, cy2 + 3, 3, 0, Math.PI * 2); ctx.fill(); cy2 = drawText(co, cx2 + 14, cy2 - 2, hw - 14, 12, 'normal', C.text) + 4; }); + y = Math.max(py, cy2) + 16; + if (i < opts.length - 1) { ctx.fillStyle = C.border; ctx.fillRect(M, y - 8, CW, 1); } + }); + y += 12; + } + + if (phases.length) { + y = secHeader('Implementation Plan', y); y += 8; + phases.forEach((ph, i) => { + ctx.fillStyle = C.accent; ctx.beginPath(); ctx.arc(M + 11, y + 13, 11, 0, Math.PI * 2); ctx.fill(); + ctx.font = 'bold 11px system-ui'; ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; + ctx.fillText(String(i + 1), M + 11, y + 17); ctx.textAlign = 'left'; + ctx.font = 'bold 14px Georgia,serif'; ctx.fillStyle = C.text; ctx.fillText(ph.phase, M + 28, y + 18); + if (ph.timeline) { + ctx.font = '11px system-ui'; ctx.fillStyle = C.light; + const tw = ctx.measureText(ph.phase).width; + ctx.fillText(' · ' + ph.timeline, M + 28 + tw + 4, y + 18); + } + y += 28; + if (ph.description?.trim()) y = drawText(ph.description, M + 28, y, CW - 28, 13, 'normal', C.sub) + 6; + y += 14; + }); + y += 8; + } + + if (crit.length) { + y = secHeader('Completion Criteria', y); y += 8; + crit.forEach((c) => { checkbox(M, y + 2, c.done); ctx.font = '13px system-ui'; ctx.fillStyle = c.done ? C.light : C.text; ctx.fillText(c.text, M + 22, y + 14); y += 22; }); + y += 12; + } + + if (tasks.length || p.notes?.trim()) { + y = secHeader('Follow-up Tasks & Notes', y); y += 8; + tasks.forEach((t) => { checkbox(M, y + 2, t.done); ctx.font = '13px system-ui'; ctx.fillStyle = t.done ? C.light : C.text; ctx.fillText(t.text, M + 22, y + 14); y += 22; }); + if (tasks.length) y += 8; + if (p.notes?.trim()) { + ctx.font = 'bold 10px system-ui'; ctx.fillStyle = C.light; ctx.fillText('NOTES', M, y); y += 16; + drawText(p.notes, M, y, CW, 13, 'normal', C.sub); + } + y += 12; + } + + ctx.fillStyle = C.border; ctx.fillRect(M, y + 4, CW, 1); y += 14; + ctx.font = '11px system-ui'; ctx.fillStyle = C.light; + ctx.fillText('Generated by PRD Studio', M, y); + ctx.textAlign = 'right'; + ctx.fillText(new Date().toLocaleDateString(), M + CW, y); + ctx.textAlign = 'left'; + + return canvas; +} + +// ── Main component ──────────────────────────────────────────────────────────── + +export function PRDCreatorContent() { + const [prds, setPRDs] = useState(() => loadPRDs()); + const [currentId, setCurrentId] = useState(() => { + const stored = loadPRDs(); + return stored.length ? stored[0].id : null; + }); + const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); + const [exportOpen, setExportOpen] = useState(false); + const [toastMsg, setToastMsg] = useState(''); + const [toastVisible, setToastVisible] = useState(false); + const saveTimer = useRef | null>(null); + const toastTimer = useRef | null>(null); + const [, forceUpdate] = useState(0); + + const current = prds.find((p) => p.id === currentId) ?? null; + + useAutoResize(current); + + // Save to localStorage whenever prds changes + useEffect(() => { savePRDs(prds); }, [prds]); + + const scheduleSave = useCallback(() => { + setSaveStatus('saving'); + if (saveTimer.current) clearTimeout(saveTimer.current); + saveTimer.current = setTimeout(() => { + setPRDs((prev) => { + const next = prev.map((p) => + p.id === currentId ? { ...p, modified: Date.now() } : p + ); + savePRDs(next); + return next; + }); + setSaveStatus('saved'); + setTimeout(() => setSaveStatus('idle'), 2000); + }, 900); + }, [currentId]); + + function showToast(msg: string) { + setToastMsg(msg); + setToastVisible(true); + if (toastTimer.current) clearTimeout(toastTimer.current); + toastTimer.current = setTimeout(() => setToastVisible(false), 2400); + } + + // Mutate current PRD's field immutably + function updateCurrent(updater: (p: PRD) => PRD) { + setPRDs((prev) => prev.map((p) => (p.id === currentId ? updater(p) : p))); + scheduleSave(); + } + + function newPRD() { + const p = mkPRD(); + setPRDs((prev) => [p, ...prev]); + setCurrentId(p.id); + setTimeout(() => document.getElementById('prd-title-input')?.focus(), 50); + } + + function deletePRD(id: string) { + if (!window.confirm('Delete this PRD?')) return; + setPRDs((prev) => prev.filter((p) => p.id !== id)); + if (currentId === id) { + const remaining = prds.filter((p) => p.id !== id); + setCurrentId(remaining.length ? remaining[0].id : null); + } + showToast('PRD deleted'); + } + + function toggleSection(key: string) { + if (!current) return; + updateCurrent((p) => ({ ...p, collapsed: { ...p.collapsed, [key]: !p.collapsed[key] } })); + } + + // ── Competitor helpers ────────────────────────────────────────────────────── + + function setCompField(i: number, field: keyof Competitor, val: string) { + updateCurrent((p) => { + const competitors = p.competitors.map((c, idx) => idx === i ? { ...c, [field]: val } : c); + return { ...p, competitors }; + }); + } + + function addComp() { updateCurrent((p) => ({ ...p, competitors: [...p.competitors, { name: '', strengths: '', weaknesses: '', notes: '' }] })); } + function delComp(i: number) { + if ((current?.competitors.length ?? 0) <= 1) return; + updateCurrent((p) => ({ ...p, competitors: p.competitors.filter((_, idx) => idx !== i) })); + } + + // ── Story helpers ─────────────────────────────────────────────────────────── + + function setStoryField(i: number, field: keyof UserStory, val: string) { + updateCurrent((p) => { + const userStories = p.userStories.map((s, idx) => idx === i ? { ...s, [field]: val } : s); + return { ...p, userStories }; + }); + } + + function addStory() { updateCurrent((p) => ({ ...p, userStories: [...p.userStories, { persona: '', action: '', benefit: '' }] })); } + function delStory(i: number) { + if ((current?.userStories.length ?? 0) <= 1) return; + updateCurrent((p) => ({ ...p, userStories: p.userStories.filter((_, idx) => idx !== i) })); + } + + // ── Option helpers ────────────────────────────────────────────────────────── + + function setOptField(i: number, field: 'title' | 'description', val: string) { + updateCurrent((p) => { + const options = p.options.map((o, idx) => idx === i ? { ...o, [field]: val } : o); + return { ...p, options }; + }); + } + + function setBullet(i: number, type: 'pros' | 'cons', j: number, val: string) { + updateCurrent((p) => { + const options = p.options.map((o, idx) => { + if (idx !== i) return o; + const list = [...o[type]]; + list[j] = val; + return { ...o, [type]: list }; + }); + return { ...p, options }; + }); + } + + function addBullet(i: number, type: 'pros' | 'cons') { + updateCurrent((p) => { + const options = p.options.map((o, idx) => + idx === i ? { ...o, [type]: [...o[type], ''] } : o + ); + return { ...p, options }; + }); + } + + function delBullet(i: number, type: 'pros' | 'cons', j: number) { + updateCurrent((p) => { + const options = p.options.map((o, idx) => { + if (idx !== i || o[type].length <= 1) return o; + return { ...o, [type]: o[type].filter((_, k) => k !== j) }; + }); + return { ...p, options }; + }); + } + + function addOpt() { updateCurrent((p) => ({ ...p, options: [...p.options, { title: '', description: '', pros: [''], cons: [''] }] })); } + function delOpt(i: number) { + if ((current?.options.length ?? 0) <= 1) return; + updateCurrent((p) => ({ ...p, options: p.options.filter((_, idx) => idx !== i) })); + } + + // ── Plan helpers ──────────────────────────────────────────────────────────── + + function setPhaseField(i: number, field: keyof Phase, val: string) { + updateCurrent((p) => { + const plan = p.plan.map((ph, idx) => idx === i ? { ...ph, [field]: val } : ph); + return { ...p, plan }; + }); + } + + function addPhase() { updateCurrent((p) => ({ ...p, plan: [...p.plan, { phase: '', description: '', timeline: '' }] })); } + function delPhase(i: number) { + if ((current?.plan.length ?? 0) <= 1) return; + updateCurrent((p) => ({ ...p, plan: p.plan.filter((_, idx) => idx !== i) })); + } + + // ── Criteria / task helpers ───────────────────────────────────────────────── + + function setCheckField(section: 'criteria' | 'tasks', i: number, field: 'text' | 'done', val: string | boolean) { + updateCurrent((p) => { + const list = p[section].map((c, idx) => idx === i ? { ...c, [field]: val } : c); + return { ...p, [section]: list }; + }); + } + + function addCheck(section: 'criteria' | 'tasks') { + updateCurrent((p) => ({ ...p, [section]: [...p[section], { text: '', done: false }] })); + } + + function delCheck(section: 'criteria' | 'tasks', i: number) { + if ((current?.[section].length ?? 0) <= 1) return; + updateCurrent((p) => ({ ...p, [section]: p[section].filter((_, idx) => idx !== i) })); + } + + // ── Exports ───────────────────────────────────────────────────────────────── + + function copyMarkdown() { + if (!current) return; + const md = toMarkdown(current); + navigator.clipboard.writeText(md) + .then(() => showToast('Markdown copied to clipboard')) + .catch(() => { + const ta = document.createElement('textarea'); + ta.value = md; document.body.appendChild(ta); ta.select(); + document.execCommand('copy'); document.body.removeChild(ta); + showToast('Markdown copied'); + }); + } + + function exportImage(fmt: 'png' | 'jpeg') { + if (!current) return; + showToast('Generating image…'); + setTimeout(() => { + try { + const canvas = renderToCanvas(current); + const type = fmt === 'jpeg' ? 'image/jpeg' : 'image/png'; + const url = canvas.toDataURL(type, 0.95); + const a = document.createElement('a'); + a.download = (current.title || 'prd').replace(/[^a-z0-9]/gi, '-').toLowerCase() + '.' + fmt; + a.href = url; a.click(); + showToast('Exported successfully'); + } catch (err) { + showToast('Export failed'); + } + }, 50); + } + + // ── Render ────────────────────────────────────────────────────────────────── + + const sorted = [...prds].sort((a, b) => b.modified - a.modified); + + return ( +
+ {/* Sidebar */} + + + {/* Main */} +
+ {current ? ( + <> + {/* Topbar */} +
+ updateCurrent((p) => ({ ...p, title: e.target.value }))} + /> +
+ {saveStatus === 'saving' ? 'Saving…' : saveStatus === 'saved' ? 'Saved' : ''} +
+
+ +
+ + {exportOpen && ( +
+
{ setExportOpen(false); exportImage('png'); }}> + Export as PNG +
+
{ setExportOpen(false); exportImage('jpeg'); }}> + Export as JPEG +
+
+ )} +
+
+
+ + {/* Sections */} +
setExportOpen(false)}> +
+ + {/* 1. Overview */} +
toggleSection('overview')}> +