admin: Web Designer agent builds full project pages from a brief

- console /build-project: Read-only agent reads uploaded images and authors a
  complete cja_projects record (copy, kind/period/role, categories, stack,
  grouped skills, metrics, per-image captions). Server re-zips gallery srcs.
- adminprojects save()/get(): handle skills + metrics JSON columns
- New Project form: 'Build with Web Designer' + full editable preview
  (skills groups, metric cards, captions merged into gallery)
- editor: round-trip skills + metrics editing
This commit is contained in:
Carlos Arias 2026-07-23 23:44:50 +00:00
parent a88cc41557
commit faef1c3229
4 changed files with 387 additions and 60 deletions

View file

@ -297,6 +297,108 @@ function draftCopy({ name, category, draft, prompt }) {
}); });
} }
// ---- Web Designer: build a full structured project from a brief --------------
// Reads the uploaded images and authors a COMPLETE cja_projects record (copy +
// skills + metrics + captions). Read-only (no Write/Edit/Bash) — it returns
// structured data the server validates; it never mutates the repo.
const MEDIA_RE = /^\/media\/[\w.-]+$/;
const MAX_BUILD_IMAGES = 8;
function buildProject({ name, category, draft, prompt, images, instagram }) {
// keep only safe /media/ srcs, map to on-disk paths the agent can Read (cwd = REPO)
const srcs = (Array.isArray(images) ? images : [])
.map((im) => String(im?.src || ""))
.filter((s) => MEDIA_RE.test(s));
const useSrcs = srcs.slice(0, MAX_BUILD_IMAGES);
if (srcs.length > MAX_BUILD_IMAGES) {
console.log(`[build-project] capping images ${srcs.length} -> ${MAX_BUILD_IMAGES} (${srcs.length - MAX_BUILD_IMAGES} not captioned)`);
}
const paths = useSrcs.map((s) => "app/public" + s); // /media/x -> app/public/media/x
const imageLines = paths.length ? paths.map((p, i) => ` ${i + 1}. ${p}`).join("\n") : "";
const ask = [
"You are the Web Designer for Carlos Arias's portfolio (carlosarias.co) — an Agentic AI & Automation Engineer who builds for law firms and service businesses.",
"Your job: turn the brief below into a COMPLETE project case study for a fixed, on-brand page template. You author STRUCTURED CONTENT, not HTML or layout.",
"",
"Brand (from brand/BRAND.md — honor it): sumi-e, ink on washi paper, a single vermillion seal, restraint. Voice is precise, understated, confident — no hype, no buzzwords, no exclamation marks. Never describe colours or layout; the template owns all styling.",
"",
"The page auto-renders these sections from the fields you return — populate the ones the brief supports, leave the rest empty:",
"- header: title + one-line lede",
"- facts rail: kind (project type), period (timeline label), role",
"- metrics: a few outcome stats, each {value, label, note?}",
"- gallery: your caption for each image below",
"- body: the case study",
"- skills: grouped disciplines, each {group, items[]}",
"- stack: technologies used, as plain strings",
"- categories: 1-3 short tags",
"",
`Project name: ${name}`,
category ? `Category hint: ${category}` : "",
instagram ? "There is an Instagram reel for this project." : "",
draft ? `The author's rough draft / notes:\n${draft}` : "",
prompt ? `The author's special instructions (follow these):\n${prompt}` : "",
"",
paths.length
? `Uploaded images — READ each file and caption it from what is ACTUALLY shown. Keep captions short and specific; do not invent UI or content that isn't visible:\n${imageLines}`
: "No images were uploaded.",
"",
"Body rules: Markdown only, using ONLY ## / ### headings, paragraphs, - bullet lists, and **bold**. No images, no HTML, no tables. Open with a short overview; where it fits include a '## Highlights' bulleted section. 150-350 words.",
"",
"Return ONLY a JSON object (no markdown fence, no commentary) with these keys:",
'- "summary": one sentence (<=160 chars) for the projects list.',
'- "lede": one punchy opening line.',
'- "body": the Markdown case study.',
'- "kind": short project type, e.g. "Website" or "SaaS / Media" (or "").',
'- "period": a timeline label, e.g. "2024 — present" (or "").',
'- "role": Carlos\'s role on the project (or "").',
'- "categories": array of 1-3 short strings.',
'- "stack": array of technology strings.',
'- "skills": array of {"group": string, "items": [string, ...]}.',
'- "metrics": array of {"value": string, "label": string, "note"?: string}. Use an empty array if the brief has no real numbers — do NOT invent metrics.',
`- "captions": array of exactly ${paths.length} strings, one per uploaded image IN ORDER.`,
`- "alts": array of exactly ${paths.length} short alt-text strings, one per image IN ORDER.`,
].filter(Boolean).join("\n");
return new Promise((resolve) => {
const args = ["-p", ask, "--output-format", "json", "--model", DEFAULT_MODEL, "--allowedTools", "Read"];
const child = spawn(CLAUDE, args, { cwd: REPO, env: agentEnv() });
let out = "";
child.stdout.on("data", (d) => (out += d));
child.on("close", () => {
try {
const envelope = JSON.parse(out);
const text = typeof envelope.result === "string" ? envelope.result : "";
const obj = JSON.parse(stripFence(text));
// Re-zip the gallery server-side: trust OUR srcs, take the model's
// caption/alt by index. The model never dictates a file path.
const caps = Array.isArray(obj.captions) ? obj.captions : [];
const alts = Array.isArray(obj.alts) ? obj.alts : [];
const gallery = useSrcs.map((src, i) => ({
src,
alt: String(alts[i] || "").slice(0, 200),
caption: String(caps[i] || "").slice(0, 120),
}));
const strArr = (a) => (Array.isArray(a) ? a.map((x) => String(x)).filter(Boolean) : []);
resolve({
ok: true,
summary: obj.summary || "",
lede: obj.lede || "",
body: obj.body || "",
kind: obj.kind || "",
period: obj.period || "",
role: obj.role || "",
categories: strArr(obj.categories),
stack: strArr(obj.stack),
skills: Array.isArray(obj.skills) ? obj.skills : [],
metrics: Array.isArray(obj.metrics) ? obj.metrics : [],
gallery,
});
} catch { resolve({ ok: false }); }
});
child.on("error", () => resolve({ ok: false }));
});
}
// ---- request helpers --------------------------------------------------------- // ---- request helpers ---------------------------------------------------------
function json(res, status, obj) { function json(res, status, obj) {
const body = JSON.stringify(obj); const body = JSON.stringify(obj);
@ -375,6 +477,22 @@ const server = createServer(async (req, res) => {
return json(res, 200, d); return json(res, 200, d);
} }
// Web Designer: build a full structured project from a brief + images.
if (path === "/build-project" && method === "POST") {
if (busy) return json(res, 429, { error: "Busy — try again in a moment." });
const body = await readBody(req);
const name = String(body.name || "").trim();
if (!name) return json(res, 400, { error: "Add a project name first." });
const env = loadEnv();
if (!env.CLAUDE_CODE_OAUTH_TOKEN) return json(res, 401, { error: "No Claude token set." });
busy = true;
const d = await buildProject(body);
busy = false;
if (!d.ok) return json(res, 500, { error: "The Web Designer couldn't finish — try again." });
return json(res, 200, d);
}
if (path === "/stream") { if (path === "/stream") {
const id = url.searchParams.get("conversationId"); const id = url.searchParams.get("conversationId");
const job = id && jobs.get(id); const job = id && jobs.get(id);

View file

@ -58,6 +58,8 @@ class AdminProjects extends PublicController
'cover' => $r['cover_image'] ?? '', 'cover' => $r['cover_image'] ?? '',
'categories' => json_decode($r['categories'] ?? '[]', true) ?: [], 'categories' => json_decode($r['categories'] ?? '[]', true) ?: [],
'stack' => json_decode($r['stack'] ?? '[]', true) ?: [], 'stack' => json_decode($r['stack'] ?? '[]', true) ?: [],
'skills' => json_decode($r['skills'] ?? '[]', true) ?: [],
'metrics' => json_decode($r['metrics'] ?? '[]', true) ?: [],
'gallery' => json_decode($r['gallery'] ?? '[]', true) ?: [], 'gallery' => json_decode($r['gallery'] ?? '[]', true) ?: [],
'links' => json_decode($r['links'] ?? '{}', true) ?: [], 'links' => json_decode($r['links'] ?? '{}', true) ?: [],
]); ]);
@ -179,6 +181,37 @@ class AdminProjects extends PublicController
$fields['stack'] = json_encode($stack, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $fields['stack'] = json_encode($stack, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
} }
// ---- skills: array of { group, items[] } ----------------------------
if (isset($in['skills']) && is_array($in['skills'])) {
$skills = [];
foreach ($in['skills'] as $g) {
$group = mb_substr(trim((string) ($g['group'] ?? '')), 0, 60);
$items = array_values(array_filter(array_map(
fn($it) => mb_substr(trim((string) $it), 0, 60),
(array) ($g['items'] ?? [])
)));
if ($group !== '' && $items) {
$skills[] = ['group' => $group, 'items' => $items];
}
}
$fields['skills'] = json_encode($skills, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
// ---- metrics: array of { value, label, note? } ----------------------
if (isset($in['metrics']) && is_array($in['metrics'])) {
$metrics = [];
foreach ($in['metrics'] as $m) {
$value = mb_substr(trim((string) ($m['value'] ?? '')), 0, 40);
$label = mb_substr(trim((string) ($m['label'] ?? '')), 0, 80);
if ($value === '' && $label === '') continue;
$metric = ['value' => $value, 'label' => $label];
$note = mb_substr(trim((string) ($m['note'] ?? '')), 0, 120);
if ($note !== '') $metric['note'] = $note;
$metrics[] = $metric;
}
$fields['metrics'] = json_encode($metrics, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
\Db::update('cja_projects', $fields, 'project_id = ?', [$id]); \Db::update('cja_projects', $fields, 'project_id = ?', [$id]);
$this->json(['ok' => true, 'slug' => $slug]); $this->json(['ok' => true, 'slug' => $slug]);

View file

@ -60,6 +60,16 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
<textarea id="f-body" rows="14" class="mono"></textarea> <textarea id="f-body" rows="14" class="mono"></textarea>
</section> </section>
<section class="block">
<div class="head"><label>Metrics</label><button type="button" class="btn ghost sm" id="addMetric">+ Add metric</button></div>
<div id="metrics" class="rows"></div>
</section>
<section class="block">
<div class="head"><label>Skills applied</label><button type="button" class="btn ghost sm" id="addSkill">+ Add group</button></div>
<div id="skills" class="rows"></div>
</section>
<section class="block"> <section class="block">
<label for="ig">Instagram reel URL</label> <label for="ig">Instagram reel URL</label>
<input type="url" id="ig" placeholder="https://instagram.com/reel/…" /> <input type="url" id="ig" placeholder="https://instagram.com/reel/…" />
@ -96,6 +106,13 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
padding:.5rem 0; font-size:.95rem; color:var(--ink); font-family:var(--sans); } padding:.5rem 0; font-size:.95rem; color:var(--ink); font-family:var(--sans); }
.sel select:focus { outline:none; border-bottom-color:var(--seal); } .sel select:focus { outline:none; border-bottom-color:var(--seal); }
.btn.sm { padding:.35rem .7rem; font-size:.7rem; } .btn.sm { padding:.35rem .7rem; font-size:.7rem; }
.rows { display:flex; flex-direction:column; gap:.6rem; }
.row { display:flex; gap:.6rem; align-items:center; }
.row input { flex:1; }
.row .rm { flex:0 0 auto; width:1.9rem; height:1.9rem; border:1px solid var(--rule); background:transparent;
color:var(--ink3); border-radius:2px; cursor:pointer; font-size:.9rem; line-height:1; }
.row .rm:hover { color:var(--seal); border-color:var(--seal); }
.row .grp { flex:0 0 9rem; } .row .val { flex:0 0 6rem; }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(11rem,1fr)); gap:1rem; } .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(11rem,1fr)); gap:1rem; }
.slot { border:1px solid var(--rule); border-radius:2px; overflow:hidden; display:flex; flex-direction:column; } .slot { border:1px solid var(--rule); border-radius:2px; overflow:hidden; display:flex; flex-direction:column; }
.thumb { position:relative; aspect-ratio:4/3; background:var(--card); display:flex; align-items:center; .thumb { position:relative; aspect-ratio:4/3; background:var(--card); display:flex; align-items:center;
@ -128,7 +145,11 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
const msg = document.getElementById("msg"); const msg = document.getElementById("msg");
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const setMsg = (t, cls = "") => { msg.textContent = t; msg.className = "msg " + cls; }; const setMsg = (t, cls = "") => { msg.textContent = t; msg.className = "msg " + cls; };
let items = []; let items = []; // gallery
let skills = []; // [{group, items:[...]}]
let metrics = []; // [{value, label, note}]
const skillsEl = document.getElementById("skills");
const metricsEl = document.getElementById("metrics");
function render() { function render() {
galleryEl.innerHTML = ""; galleryEl.innerHTML = "";
@ -169,6 +190,44 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
const splitList = (s) => s.split(",").map((x) => x.trim()).filter(Boolean); const splitList = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
// ---- skills groups -----------------------------------------------------
function renderSkills() {
skillsEl.innerHTML = "";
skills.forEach((g, i) => {
const row = document.createElement("div");
row.className = "row";
row.innerHTML = `
<input class="grp" placeholder="Group (e.g. Engineering)" value="${(g.group || "").replace(/"/g, "&quot;")}" data-grp>
<input placeholder="Items, comma-separated" value="${(g.items || []).join(", ").replace(/"/g, "&quot;")}" data-items>
<button type="button" class="rm" title="Remove" data-rm>×</button>`;
row.querySelector("[data-grp]").addEventListener("input", (e) => { skills[i].group = e.target.value; });
row.querySelector("[data-items]").addEventListener("input", (e) => { skills[i].items = splitList(e.target.value); });
row.querySelector("[data-rm]").addEventListener("click", () => { skills.splice(i, 1); renderSkills(); });
skillsEl.appendChild(row);
});
}
$("addSkill").addEventListener("click", () => { skills.push({ group: "", items: [] }); renderSkills(); });
// ---- metrics -----------------------------------------------------------
function renderMetrics() {
metricsEl.innerHTML = "";
metrics.forEach((m, i) => {
const row = document.createElement("div");
row.className = "row";
row.innerHTML = `
<input class="val" placeholder="Value" value="${(m.value || "").replace(/"/g, "&quot;")}" data-val>
<input placeholder="Label" value="${(m.label || "").replace(/"/g, "&quot;")}" data-label>
<input placeholder="Note (optional)" value="${(m.note || "").replace(/"/g, "&quot;")}" data-note>
<button type="button" class="rm" title="Remove" data-rm>×</button>`;
row.querySelector("[data-val]").addEventListener("input", (e) => { metrics[i].value = e.target.value; });
row.querySelector("[data-label]").addEventListener("input", (e) => { metrics[i].label = e.target.value; });
row.querySelector("[data-note]").addEventListener("input", (e) => { metrics[i].note = e.target.value; });
row.querySelector("[data-rm]").addEventListener("click", () => { metrics.splice(i, 1); renderMetrics(); });
metricsEl.appendChild(row);
});
}
$("addMetric").addEventListener("click", () => { metrics.push({ value: "", label: "", note: "" }); renderMetrics(); });
$("save").addEventListener("click", async () => { $("save").addEventListener("click", async () => {
const btn = $("save"); btn.disabled = true; setMsg("Saving…"); const btn = $("save"); btn.disabled = true; setMsg("Saving…");
try { try {
@ -187,6 +246,8 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
body: $("f-body").value, body: $("f-body").value,
categories: splitList($("f-cats").value), categories: splitList($("f-cats").value),
stack: splitList($("f-stack").value), stack: splitList($("f-stack").value),
skills,
metrics,
gallery: items, gallery: items,
links, links,
}; };
@ -224,7 +285,9 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
$("f-stack").value = (p.stack || []).join(", "); $("f-stack").value = (p.stack || []).join(", ");
$("ig").value = p.links?.instagram || ""; $("ig").value = p.links?.instagram || "";
items = Array.isArray(p.gallery) ? p.gallery : []; items = Array.isArray(p.gallery) ? p.gallery : [];
render(); skills = Array.isArray(p.skills) ? p.skills : [];
metrics = Array.isArray(p.metrics) ? p.metrics : [];
render(); renderSkills(); renderMetrics();
} catch { setMsg("Couldn't load the project.", "error"); } } catch { setMsg("Couldn't load the project.", "error"); }
})(); })();
</script> </script>

View file

@ -5,7 +5,7 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
<AdminLayout title="New project"> <AdminLayout title="New project">
<a href="/admin/projects" class="back">← All projects</a> <a href="/admin/projects" class="back">← All projects</a>
<h1>New project</h1> <h1>New project</h1>
<p class="lead">Give it a name and a rough draft. The agent can polish the copy — then upload images and add a reel.</p> <p class="lead">Give the Web Designer a brief — a rough draft, any special instructions, and your images. It reads the images and builds the whole page. You review everything before it goes live.</p>
<section class="block two"> <section class="block two">
<div class="wide"> <div class="wide">
@ -13,47 +13,78 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
<input type="text" id="f-name" placeholder="Ginny Goldman — firm site" /> <input type="text" id="f-name" placeholder="Ginny Goldman — firm site" />
</div> </div>
<div class="wide"> <div class="wide">
<label for="f-cats">Category (comma-separated)</label> <label for="f-cats">Category hint (comma-separated)</label>
<input type="text" id="f-cats" placeholder="Websites, Law firms" /> <input type="text" id="f-cats" placeholder="Websites, Law firms" />
</div> </div>
</section> </section>
<section class="block"> <section class="block">
<label for="f-draft">Description draft</label> <label for="f-draft">Description draft</label>
<textarea id="f-draft" rows="5" placeholder="Rough notes — what it is, who it's for, what you built. Doesn't have to be polished."></textarea> <textarea id="f-draft" rows="5" placeholder="Rough notes — what it is, who it's for, what you built, any results. Doesn't have to be polished."></textarea>
</section>
<section class="block">
<label for="f-prompt">Special instructions for the Web Designer <span class="opt">optional</span></label>
<textarea id="f-prompt" rows="3" placeholder="e.g. Lead with the results, keep it concise, emphasize the automation work, add a Highlights section."></textarea>
</section> </section>
<section class="block"> <section class="block">
<div class="head"> <div class="head">
<label for="f-prompt">Prompt for the agent <span class="opt">optional</span></label> <label>Images</label>
<button type="button" class="btn ghost sm" id="draftBtn">✷ Draft the copy</button>
</div>
<textarea id="f-prompt" rows="3" placeholder="e.g. Make this sound confident and concise, add a Highlights section, lead with the results."></textarea>
<p class="hint" id="draftMsg" role="status" aria-live="polite"></p>
</section>
<section class="block generated" id="genWrap" hidden>
<div class="head"><label>Generated copy — edit anything before saving</label></div>
<label for="g-summary" class="sub">Summary (projects list)</label>
<textarea id="g-summary" rows="2"></textarea>
<label for="g-lede" class="sub">Lede (page opener)</label>
<textarea id="g-lede" rows="2"></textarea>
<label for="g-body" class="sub">Case study (Markdown)</label>
<textarea id="g-body" rows="12" class="mono"></textarea>
</section>
<section class="block">
<div class="head">
<label>Upload images</label>
<button type="button" class="btn ghost sm" id="addSlot">+ Add image</button> <button type="button" class="btn ghost sm" id="addSlot">+ Add image</button>
</div> </div>
<div id="gallery" class="grid"></div> <div id="gallery" class="grid"></div>
<p class="hint">Upload screenshots or photos. The Web Designer reads each one and captions it.</p>
</section> </section>
<section class="block"> <section class="block">
<label for="ig">Instagram reel URL <span class="opt">optional</span></label> <label for="ig">Instagram reel URL <span class="opt">optional</span></label>
<input type="url" id="ig" placeholder="https://instagram.com/reel/…" /> <input type="url" id="ig" placeholder="https://instagram.com/reel/…" />
<p class="hint">Paste a link to an existing reel, or add one later from the editor. To create one, use Instagram / your video tool, then paste the link here.</p> <p class="hint">Paste a link to an existing reel, or add one later from the editor.</p>
</section>
<div class="build-bar">
<button type="button" class="btn" id="buildBtn">✷ Build with Web Designer</button>
<p class="msg" id="buildMsg" role="status" aria-live="polite"></p>
</div>
<!-- ============ agent output — everything editable before creating ======= -->
<section class="generated" id="genWrap" hidden>
<p class="gen-head">The Web Designer built this. Edit anything, then create the project.</p>
<div class="block">
<label for="g-summary" class="sub">Summary <span class="opt">projects list</span></label>
<textarea id="g-summary" rows="2"></textarea>
</div>
<div class="block">
<label for="g-lede" class="sub">Lede <span class="opt">page opener</span></label>
<textarea id="g-lede" rows="2"></textarea>
</div>
<div class="block two">
<div><label for="g-kind" class="sub">Type</label><input type="text" id="g-kind" /></div>
<div><label for="g-period" class="sub">Timeline</label><input type="text" id="g-period" /></div>
<div class="wide"><label for="g-role" class="sub">Role</label><input type="text" id="g-role" /></div>
<div class="wide"><label for="g-cats" class="sub">Categories (comma-separated)</label><input type="text" id="g-cats" /></div>
<div class="wide"><label for="g-stack" class="sub">Built with (comma-separated)</label><input type="text" id="g-stack" /></div>
</div>
<div class="block">
<div class="head"><label class="sub">Metrics</label><button type="button" class="btn ghost sm" id="addMetric">+ Add metric</button></div>
<div id="metrics" class="rows"></div>
</div>
<div class="block">
<div class="head"><label class="sub">Skills applied</label><button type="button" class="btn ghost sm" id="addSkill">+ Add group</button></div>
<div id="skills" class="rows"></div>
</div>
<div class="block">
<label for="g-body" class="sub">Case study (Markdown)</label>
<textarea id="g-body" rows="14" class="mono"></textarea>
</div>
<p class="hint">Image captions are filled into each image above — scroll up to tweak them.</p>
</section> </section>
<div class="footer"> <div class="footer">
@ -65,20 +96,37 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
.back { font-family:var(--mono); font-size:.62rem; letter-spacing:.12em; text-transform:uppercase; .back { font-family:var(--mono); font-size:.62rem; letter-spacing:.12em; text-transform:uppercase;
color:var(--ink4); text-decoration:none; display:inline-block; margin-bottom:1.25rem; } color:var(--ink4); text-decoration:none; display:inline-block; margin-bottom:1.25rem; }
.back:hover { color:var(--ink); } .back:hover { color:var(--ink); }
.block { margin:1.75rem 0; } .block { margin:1.5rem 0; }
.block.two { display:grid; grid-template-columns:1fr 1fr; gap:1.25rem 2rem; } .block.two { display:grid; grid-template-columns:1fr 1fr; gap:1.25rem 2rem; }
.block.two .wide { grid-column:1 / -1; } .block.two .wide { grid-column:1 / -1; }
.block .head { display:flex; align-items:center; gap:1rem; margin-bottom:1rem; } .block .head { display:flex; align-items:center; gap:1rem; margin-bottom:1rem; }
.block .head label { margin:0; } .block .head label { margin:0; }
.opt { font-size:.55rem; color:var(--ink4); letter-spacing:.08em; } .opt { font-size:.55rem; color:var(--ink4); letter-spacing:.08em; }
label.sub { margin-top:1.1rem; } label.sub { margin-top:0; }
textarea { width:100%; border:1px solid var(--rule); background:transparent; border-radius:2px; textarea { width:100%; border:1px solid var(--rule); background:transparent; border-radius:2px;
padding:.6rem .7rem; font-size:.9rem; color:var(--ink); font-family:var(--sans); line-height:1.55; resize:vertical; } padding:.6rem .7rem; font-size:.9rem; color:var(--ink); font-family:var(--sans); line-height:1.55; resize:vertical; }
textarea:focus { outline:none; border-color:var(--seal); } textarea:focus { outline:none; border-color:var(--seal); }
textarea.mono { font-family:var(--mono); font-size:.82rem; } textarea.mono { font-family:var(--mono); font-size:.82rem; }
.generated { border-left:2px solid var(--seal); padding-left:1.1rem; }
.hint { font-size:.78rem; color:var(--ink3); margin:.5rem 0 0; } .hint { font-size:.78rem; color:var(--ink3); margin:.5rem 0 0; }
.btn.sm { padding:.35rem .7rem; font-size:.7rem; } .btn.sm { padding:.35rem .7rem; font-size:.7rem; }
.build-bar { display:flex; align-items:center; gap:1.25rem; margin:2rem 0; padding:1.1rem 0;
border-top:1px solid var(--rule); border-bottom:1px solid var(--rule); }
.generated { border-left:2px solid var(--seal); padding-left:1.25rem; margin:2rem 0; }
.gen-head { font-family:var(--mono); font-size:.62rem; letter-spacing:.1em; text-transform:uppercase;
color:var(--seal); margin:0 0 1.25rem; }
/* skills / metrics rows */
.rows { display:flex; flex-direction:column; gap:.6rem; }
.row { display:flex; gap:.6rem; align-items:center; }
.row input { flex:1; }
.row .rm { flex:0 0 auto; width:1.9rem; height:1.9rem; border:1px solid var(--rule); background:transparent;
color:var(--ink3); border-radius:2px; cursor:pointer; font-size:.9rem; line-height:1; }
.row .rm:hover { color:var(--seal); border-color:var(--seal); }
.row .grp { flex:0 0 9rem; }
.row .val { flex:0 0 6rem; }
/* gallery */
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(11rem,1fr)); gap:1rem; } .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(11rem,1fr)); gap:1rem; }
.slot { border:1px solid var(--rule); border-radius:2px; overflow:hidden; display:flex; flex-direction:column; } .slot { border:1px solid var(--rule); border-radius:2px; overflow:hidden; display:flex; flex-direction:column; }
.thumb { position:relative; aspect-ratio:4/3; background:var(--card); display:flex; align-items:center; .thumb { position:relative; aspect-ratio:4/3; background:var(--card); display:flex; align-items:center;
@ -98,6 +146,7 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
.slot .acts label { border-right:1px solid var(--rule); } .slot .acts label { border-right:1px solid var(--rule); }
.slot .acts button:hover { color:var(--seal); } .slot .acts label:hover { color:var(--ink); } .slot .acts button:hover { color:var(--seal); } .slot .acts label:hover { color:var(--ink); }
.slot .acts input { display:none; } .slot .acts input { display:none; }
.footer { position:sticky; bottom:0; display:flex; align-items:center; gap:1.5rem; padding:1rem 0; .footer { position:sticky; bottom:0; display:flex; align-items:center; gap:1.5rem; padding:1rem 0;
margin-top:2rem; border-top:1px solid var(--rule); margin-top:2rem; border-top:1px solid var(--rule);
background:color-mix(in srgb, var(--paper) 92%, transparent); } background:color-mix(in srgb, var(--paper) 92%, transparent); }
@ -105,13 +154,15 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
<script> <script>
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const msg = $("msg"), draftMsg = $("draftMsg"); const msg = $("msg"), buildMsg = $("buildMsg");
const setMsg = (t, cls = "") => { msg.textContent = t; msg.className = "msg " + cls; }; const setMsg = (t, cls = "") => { msg.textContent = t; msg.className = "msg " + cls; };
const galleryEl = $("gallery"); const galleryEl = $("gallery"), skillsEl = $("skills"), metricsEl = $("metrics");
let items = []; let items = []; // gallery: [{src, alt, caption}]
let skills = []; // [{group, items:[...]}]
let metrics = []; // [{value, label, note}]
// ---- gallery ----------------------------------------------------------- // ---- gallery -----------------------------------------------------------
function render() { function renderGallery() {
galleryEl.innerHTML = ""; galleryEl.innerHTML = "";
items.forEach((it, i) => { items.forEach((it, i) => {
const slot = document.createElement("div"); const slot = document.createElement("div");
@ -126,7 +177,7 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
<button type="button" data-remove>Remove</button> <button type="button" data-remove>Remove</button>
</div>`; </div>`;
slot.querySelector("[data-cap]").addEventListener("input", (e) => { items[i].caption = e.target.value; }); slot.querySelector("[data-cap]").addEventListener("input", (e) => { items[i].caption = e.target.value; });
slot.querySelector("[data-remove]").addEventListener("click", () => { items.splice(i, 1); render(); }); slot.querySelector("[data-remove]").addEventListener("click", () => { items.splice(i, 1); renderGallery(); });
slot.querySelector("[data-file]").addEventListener("change", (e) => upload(e.target.files[0], i, slot)); slot.querySelector("[data-file]").addEventListener("change", (e) => upload(e.target.files[0], i, slot));
galleryEl.appendChild(slot); galleryEl.appendChild(slot);
}); });
@ -139,48 +190,106 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
const fd = new FormData(); fd.append("file", file); const fd = new FormData(); fd.append("file", file);
const r = await fetch("/api/media/upload", { method: "POST", credentials: "same-origin", body: fd }); const r = await fetch("/api/media/upload", { method: "POST", credentials: "same-origin", body: fd });
const d = await r.json(); const d = await r.json();
if (r.ok && d?.data?.url) { items[i].src = d.data.url; render(); } if (r.ok && d?.data?.url) { items[i].src = d.data.url; renderGallery(); }
else setMsg(d?.error?.message || "Upload failed.", "error"); else buildMsg.textContent = d?.error?.message || "Upload failed.";
} catch { setMsg("Upload failed.", "error"); } } catch { buildMsg.textContent = "Upload failed."; }
finally { thumb.classList.remove("busy"); } finally { thumb.classList.remove("busy"); }
} }
$("addSlot").addEventListener("click", () => { items.push({ src: "", alt: "", caption: "" }); render(); }); $("addSlot").addEventListener("click", () => { items.push({ src: "", alt: "", caption: "" }); renderGallery(); });
// ---- draft copy with the agent ---------------------------------------- // ---- skills groups -----------------------------------------------------
$("draftBtn").addEventListener("click", async () => { function renderSkills() {
skillsEl.innerHTML = "";
skills.forEach((g, i) => {
const row = document.createElement("div");
row.className = "row";
row.innerHTML = `
<input class="grp" placeholder="Group (e.g. Engineering)" value="${(g.group || "").replace(/"/g, "&quot;")}" data-grp>
<input placeholder="Items, comma-separated" value="${(g.items || []).join(", ").replace(/"/g, "&quot;")}" data-items>
<button type="button" class="rm" title="Remove" data-rm>×</button>`;
row.querySelector("[data-grp]").addEventListener("input", (e) => { skills[i].group = e.target.value; });
row.querySelector("[data-items]").addEventListener("input", (e) => { skills[i].items = splitList(e.target.value); });
row.querySelector("[data-rm]").addEventListener("click", () => { skills.splice(i, 1); renderSkills(); });
skillsEl.appendChild(row);
});
}
$("addSkill").addEventListener("click", () => { skills.push({ group: "", items: [] }); renderSkills(); });
// ---- metrics -----------------------------------------------------------
function renderMetrics() {
metricsEl.innerHTML = "";
metrics.forEach((m, i) => {
const row = document.createElement("div");
row.className = "row";
row.innerHTML = `
<input class="val" placeholder="Value" value="${(m.value || "").replace(/"/g, "&quot;")}" data-val>
<input placeholder="Label" value="${(m.label || "").replace(/"/g, "&quot;")}" data-label>
<input placeholder="Note (optional)" value="${(m.note || "").replace(/"/g, "&quot;")}" data-note>
<button type="button" class="rm" title="Remove" data-rm>×</button>`;
row.querySelector("[data-val]").addEventListener("input", (e) => { metrics[i].value = e.target.value; });
row.querySelector("[data-label]").addEventListener("input", (e) => { metrics[i].label = e.target.value; });
row.querySelector("[data-note]").addEventListener("input", (e) => { metrics[i].note = e.target.value; });
row.querySelector("[data-rm]").addEventListener("click", () => { metrics.splice(i, 1); renderMetrics(); });
metricsEl.appendChild(row);
});
}
$("addMetric").addEventListener("click", () => { metrics.push({ value: "", label: "", note: "" }); renderMetrics(); });
const splitList = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
// ---- build with the Web Designer --------------------------------------
$("buildBtn").addEventListener("click", async () => {
const name = $("f-name").value.trim(); const name = $("f-name").value.trim();
if (!name) { draftMsg.textContent = "Add a project name first."; return; } if (!name) { buildMsg.textContent = "Add a project name first."; return; }
const btn = $("draftBtn"); btn.disabled = true; const btn = $("buildBtn"); btn.disabled = true; $("create").disabled = true;
draftMsg.textContent = "Drafting… this takes a moment."; buildMsg.textContent = "Web Designer is building your page — reading images, writing sections… (~12 min)";
try { try {
const r = await fetch("/devconsole/draft", { const r = await fetch("/devconsole/build-project", {
method: "POST", credentials: "same-origin", method: "POST", credentials: "same-origin",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
name, category: $("f-cats").value.trim(), name,
draft: $("f-draft").value.trim(), prompt: $("f-prompt").value.trim(), category: $("f-cats").value.trim(),
draft: $("f-draft").value.trim(),
prompt: $("f-prompt").value.trim(),
images: items.filter((it) => it.src).map((it) => ({ src: it.src })),
instagram: $("ig").value.trim(),
}), }),
}); });
const d = await r.json(); const d = await r.json();
if (!r.ok) { draftMsg.textContent = d?.error || "Couldn't draft the copy."; return; } if (!r.ok) { buildMsg.textContent = d?.error || "Couldn't build the page."; return; }
$("g-summary").value = d.summary || ""; $("g-summary").value = d.summary || "";
$("g-lede").value = d.lede || ""; $("g-lede").value = d.lede || "";
$("g-body").value = d.body || ""; $("g-body").value = d.body || "";
$("g-kind").value = d.kind || "";
$("g-period").value = d.period || "";
$("g-role").value = d.role || "";
$("g-cats").value = (d.categories || []).join(", ");
$("g-stack").value = (d.stack || []).join(", ");
skills = Array.isArray(d.skills) ? d.skills : [];
metrics = Array.isArray(d.metrics) ? d.metrics : [];
renderSkills(); renderMetrics();
// merge the agent's captions back into the matching gallery slots
if (Array.isArray(d.gallery)) {
const bySrc = new Map(d.gallery.map((g) => [g.src, g]));
items.forEach((it) => { const g = bySrc.get(it.src); if (g) { it.caption = g.caption; it.alt = g.alt; } });
renderGallery();
}
$("genWrap").hidden = false; $("genWrap").hidden = false;
draftMsg.textContent = "Drafted — review and edit below, then create."; buildMsg.textContent = "Built — review everything below, then create.";
} catch { draftMsg.textContent = "Couldn't reach the agent."; } } catch { buildMsg.textContent = "Couldn't reach the Web Designer."; }
finally { btn.disabled = false; } finally { btn.disabled = false; $("create").disabled = false; }
}); });
// ---- create ------------------------------------------------------------ // ---- create ------------------------------------------------------------
const splitList = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
$("create").addEventListener("click", async () => { $("create").addEventListener("click", async () => {
const name = $("f-name").value.trim(); const name = $("f-name").value.trim();
if (!name) { setMsg("Give the project a name.", "error"); return; } if (!name) { setMsg("Give the project a name.", "error"); return; }
const btn = $("create"); btn.disabled = true; setMsg("Creating…"); const btn = $("create"); btn.disabled = true; setMsg("Creating…");
const built = !$("genWrap").hidden;
try { try {
// 1) create the blank record → slug
const cr = await fetch("/api/adminprojects/create", { const cr = await fetch("/api/adminprojects/create", {
method: "POST", credentials: "same-origin", method: "POST", credentials: "same-origin",
headers: { "content-type": "application/json" }, body: JSON.stringify({ title: name }), headers: { "content-type": "application/json" }, body: JSON.stringify({ title: name }),
@ -189,17 +298,22 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
if (!cr.ok || !cd?.data?.slug) { setMsg(cd?.error?.message || "Couldn't create the project.", "error"); btn.disabled = false; return; } if (!cr.ok || !cd?.data?.slug) { setMsg(cd?.error?.message || "Couldn't create the project.", "error"); btn.disabled = false; return; }
const slug = cd.data.slug; const slug = cd.data.slug;
// 2) save all the content in one go
const links = {}; const links = {};
const ig = $("ig").value.trim(); const ig = $("ig").value.trim();
if (ig) links.instagram = ig; if (ig) links.instagram = ig;
const genShown = !$("genWrap").hidden;
const payload = { const payload = {
slug, title: name, slug, title: name,
categories: splitList($("f-cats").value), categories: built ? splitList($("g-cats").value) : splitList($("f-cats").value),
summary: genShown ? $("g-summary").value.trim() : "", stack: built ? splitList($("g-stack").value) : [],
lede: genShown ? $("g-lede").value.trim() : "", summary: built ? $("g-summary").value.trim() : "",
body: genShown ? $("g-body").value : $("f-draft").value, lede: built ? $("g-lede").value.trim() : "",
body: built ? $("g-body").value : $("f-draft").value,
kind: built ? $("g-kind").value.trim() : "",
period: built ? $("g-period").value.trim() : "",
role: built ? $("g-role").value.trim() : "",
skills: built ? skills : [],
metrics: built ? metrics : [],
gallery: items, links, gallery: items, links,
}; };
const sv = await fetch("/api/adminprojects/save", { const sv = await fetch("/api/adminprojects/save", {
@ -209,7 +323,6 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
const sd = await sv.json(); const sd = await sv.json();
if (!sv.ok || !sd?.data?.ok) { setMsg(sd?.error?.message || "Created, but saving details failed.", "error"); btn.disabled = false; return; } if (!sv.ok || !sd?.data?.ok) { setMsg(sd?.error?.message || "Created, but saving details failed.", "error"); btn.disabled = false; return; }
// 3) publish
setMsg("Publishing to the live site…"); setMsg("Publishing to the live site…");
const pub = await fetch("/devconsole/rebuild", { method: "POST", credentials: "same-origin" }); const pub = await fetch("/devconsole/rebuild", { method: "POST", credentials: "same-origin" });
if (pub.ok) { setMsg("Published — opening the editor…", "ok"); location.href = "/admin/projects/edit?slug=" + encodeURIComponent(slug); } if (pub.ok) { setMsg("Published — opening the editor…", "ok"); location.href = "/admin/projects/edit?slug=" + encodeURIComponent(slug); }