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:
parent
a88cc41557
commit
faef1c3229
4 changed files with 387 additions and 60 deletions
|
|
@ -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 ---------------------------------------------------------
|
||||
function json(res, status, obj) {
|
||||
const body = JSON.stringify(obj);
|
||||
|
|
@ -375,6 +477,22 @@ const server = createServer(async (req, res) => {
|
|||
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") {
|
||||
const id = url.searchParams.get("conversationId");
|
||||
const job = id && jobs.get(id);
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ class AdminProjects extends PublicController
|
|||
'cover' => $r['cover_image'] ?? '',
|
||||
'categories' => json_decode($r['categories'] ?? '[]', 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) ?: [],
|
||||
'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);
|
||||
}
|
||||
|
||||
// ---- 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]);
|
||||
|
||||
$this->json(['ok' => true, 'slug' => $slug]);
|
||||
|
|
|
|||
|
|
@ -60,6 +60,16 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
|
|||
<textarea id="f-body" rows="14" class="mono"></textarea>
|
||||
</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">
|
||||
<label for="ig">Instagram reel URL</label>
|
||||
<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); }
|
||||
.sel select:focus { outline:none; border-bottom-color:var(--seal); }
|
||||
.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; }
|
||||
.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;
|
||||
|
|
@ -128,7 +145,11 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
|
|||
const msg = document.getElementById("msg");
|
||||
const $ = (id) => document.getElementById(id);
|
||||
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() {
|
||||
galleryEl.innerHTML = "";
|
||||
|
|
@ -169,6 +190,44 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
|
|||
|
||||
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, """)}" data-grp>
|
||||
<input placeholder="Items, comma-separated" value="${(g.items || []).join(", ").replace(/"/g, """)}" 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, """)}" data-val>
|
||||
<input placeholder="Label" value="${(m.label || "").replace(/"/g, """)}" data-label>
|
||||
<input placeholder="Note (optional)" value="${(m.note || "").replace(/"/g, """)}" 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 () => {
|
||||
const btn = $("save"); btn.disabled = true; setMsg("Saving…");
|
||||
try {
|
||||
|
|
@ -187,6 +246,8 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
|
|||
body: $("f-body").value,
|
||||
categories: splitList($("f-cats").value),
|
||||
stack: splitList($("f-stack").value),
|
||||
skills,
|
||||
metrics,
|
||||
gallery: items,
|
||||
links,
|
||||
};
|
||||
|
|
@ -224,7 +285,9 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
|
|||
$("f-stack").value = (p.stack || []).join(", ");
|
||||
$("ig").value = p.links?.instagram || "";
|
||||
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"); }
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
|
|||
<AdminLayout title="New project">
|
||||
<a href="/admin/projects" class="back">← All projects</a>
|
||||
<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">
|
||||
<div class="wide">
|
||||
|
|
@ -13,47 +13,78 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
|
|||
<input type="text" id="f-name" placeholder="Ginny Goldman — firm site" />
|
||||
</div>
|
||||
<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" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<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 class="block">
|
||||
<div class="head">
|
||||
<label for="f-prompt">Prompt for the agent <span class="opt">optional</span></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>
|
||||
<label>Images</label>
|
||||
<button type="button" class="btn ghost sm" id="addSlot">+ Add image</button>
|
||||
</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 class="block">
|
||||
<label for="ig">Instagram reel URL <span class="opt">optional</span></label>
|
||||
<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>
|
||||
|
||||
<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;
|
||||
color:var(--ink4); text-decoration:none; display:inline-block; margin-bottom:1.25rem; }
|
||||
.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 .wide { grid-column:1 / -1; }
|
||||
.block .head { display:flex; align-items:center; gap:1rem; margin-bottom:1rem; }
|
||||
.block .head label { margin:0; }
|
||||
.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;
|
||||
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.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; }
|
||||
.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; }
|
||||
.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;
|
||||
|
|
@ -98,6 +146,7 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
|
|||
.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 input { display:none; }
|
||||
|
||||
.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);
|
||||
background:color-mix(in srgb, var(--paper) 92%, transparent); }
|
||||
|
|
@ -105,13 +154,15 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
|
|||
|
||||
<script>
|
||||
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 galleryEl = $("gallery");
|
||||
let items = [];
|
||||
const galleryEl = $("gallery"), skillsEl = $("skills"), metricsEl = $("metrics");
|
||||
let items = []; // gallery: [{src, alt, caption}]
|
||||
let skills = []; // [{group, items:[...]}]
|
||||
let metrics = []; // [{value, label, note}]
|
||||
|
||||
// ---- gallery -----------------------------------------------------------
|
||||
function render() {
|
||||
function renderGallery() {
|
||||
galleryEl.innerHTML = "";
|
||||
items.forEach((it, i) => {
|
||||
const slot = document.createElement("div");
|
||||
|
|
@ -126,7 +177,7 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
|
|||
<button type="button" data-remove>Remove</button>
|
||||
</div>`;
|
||||
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));
|
||||
galleryEl.appendChild(slot);
|
||||
});
|
||||
|
|
@ -139,48 +190,106 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
|
|||
const fd = new FormData(); fd.append("file", file);
|
||||
const r = await fetch("/api/media/upload", { method: "POST", credentials: "same-origin", body: fd });
|
||||
const d = await r.json();
|
||||
if (r.ok && d?.data?.url) { items[i].src = d.data.url; render(); }
|
||||
else setMsg(d?.error?.message || "Upload failed.", "error");
|
||||
} catch { setMsg("Upload failed.", "error"); }
|
||||
if (r.ok && d?.data?.url) { items[i].src = d.data.url; renderGallery(); }
|
||||
else buildMsg.textContent = d?.error?.message || "Upload failed.";
|
||||
} catch { buildMsg.textContent = "Upload failed."; }
|
||||
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 ----------------------------------------
|
||||
$("draftBtn").addEventListener("click", async () => {
|
||||
// ---- 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, """)}" data-grp>
|
||||
<input placeholder="Items, comma-separated" value="${(g.items || []).join(", ").replace(/"/g, """)}" 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, """)}" data-val>
|
||||
<input placeholder="Label" value="${(m.label || "").replace(/"/g, """)}" data-label>
|
||||
<input placeholder="Note (optional)" value="${(m.note || "").replace(/"/g, """)}" 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();
|
||||
if (!name) { draftMsg.textContent = "Add a project name first."; return; }
|
||||
const btn = $("draftBtn"); btn.disabled = true;
|
||||
draftMsg.textContent = "Drafting… this takes a moment.";
|
||||
if (!name) { buildMsg.textContent = "Add a project name first."; return; }
|
||||
const btn = $("buildBtn"); btn.disabled = true; $("create").disabled = true;
|
||||
buildMsg.textContent = "Web Designer is building your page — reading images, writing sections… (~1–2 min)";
|
||||
try {
|
||||
const r = await fetch("/devconsole/draft", {
|
||||
const r = await fetch("/devconsole/build-project", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name, category: $("f-cats").value.trim(),
|
||||
draft: $("f-draft").value.trim(), prompt: $("f-prompt").value.trim(),
|
||||
name,
|
||||
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();
|
||||
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-lede").value = d.lede || "";
|
||||
$("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;
|
||||
draftMsg.textContent = "Drafted — review and edit below, then create.";
|
||||
} catch { draftMsg.textContent = "Couldn't reach the agent."; }
|
||||
finally { btn.disabled = false; }
|
||||
buildMsg.textContent = "Built — review everything below, then create.";
|
||||
} catch { buildMsg.textContent = "Couldn't reach the Web Designer."; }
|
||||
finally { btn.disabled = false; $("create").disabled = false; }
|
||||
});
|
||||
|
||||
// ---- create ------------------------------------------------------------
|
||||
const splitList = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
|
||||
|
||||
$("create").addEventListener("click", async () => {
|
||||
const name = $("f-name").value.trim();
|
||||
if (!name) { setMsg("Give the project a name.", "error"); return; }
|
||||
const btn = $("create"); btn.disabled = true; setMsg("Creating…");
|
||||
const built = !$("genWrap").hidden;
|
||||
try {
|
||||
// 1) create the blank record → slug
|
||||
const cr = await fetch("/api/adminprojects/create", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
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; }
|
||||
const slug = cd.data.slug;
|
||||
|
||||
// 2) save all the content in one go
|
||||
const links = {};
|
||||
const ig = $("ig").value.trim();
|
||||
if (ig) links.instagram = ig;
|
||||
const genShown = !$("genWrap").hidden;
|
||||
|
||||
const payload = {
|
||||
slug, title: name,
|
||||
categories: splitList($("f-cats").value),
|
||||
summary: genShown ? $("g-summary").value.trim() : "",
|
||||
lede: genShown ? $("g-lede").value.trim() : "",
|
||||
body: genShown ? $("g-body").value : $("f-draft").value,
|
||||
categories: built ? splitList($("g-cats").value) : splitList($("f-cats").value),
|
||||
stack: built ? splitList($("g-stack").value) : [],
|
||||
summary: built ? $("g-summary").value.trim() : "",
|
||||
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,
|
||||
};
|
||||
const sv = await fetch("/api/adminprojects/save", {
|
||||
|
|
@ -209,7 +323,6 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
|
|||
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; }
|
||||
|
||||
// 3) publish
|
||||
setMsg("Publishing to the live site…");
|
||||
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); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue