admin: New Project page with agent-drafted copy + full editor

- /admin/projects/new: name, category, description draft, agent prompt,
  image upload, Instagram link
- console /draft endpoint: one-shot Claude copy drafting (summary/lede/body)
- editor now edits all project text fields, not just gallery
- adminprojects create() + expanded save()
This commit is contained in:
Carlos Arias 2026-07-23 23:23:33 +00:00
parent a2da708418
commit a88cc41557
5 changed files with 485 additions and 42 deletions

View file

@ -257,6 +257,46 @@ function git(args) {
}); });
} }
// ---- one-shot copy drafting --------------------------------------------------
// Turn a rough draft + prompt into polished project copy. No tools, no file
// writes, no preview — just returns text for the admin to review before saving.
function stripFence(t) {
const m = String(t).match(/```(?:json)?\s*([\s\S]*?)```/);
return (m ? m[1] : t).trim();
}
function draftCopy({ name, category, draft, prompt }) {
const ask = [
"You are writing copy for Carlos Arias's portfolio (carlosarias.co) — an Agentic AI & Automation Engineer who builds for law firms and service businesses.",
"Voice: precise, understated, confident. No hype, no buzzwords, no exclamation marks. Write a project case study.",
"",
`Project name: ${name}`,
category ? `Category: ${category}` : "",
draft ? `The author's rough draft / notes:\n${draft}` : "",
prompt ? `The author's instructions:\n${prompt}` : "",
"",
"Return ONLY a JSON object (no markdown fence, no commentary) with these string keys:",
'- "summary": one sentence (<=160 chars) for the projects list.',
'- "lede": one punchy opening line for the top of the project page.',
'- "body": the case study in Markdown. Use ## for section headings and - for bullets. Open with a short overview, and where it fits include a "## Highlights" bulleted section. 150-350 words.',
].filter(Boolean).join("\n");
return new Promise((resolve) => {
const args = ["-p", ask, "--output-format", "json", "--model", DEFAULT_MODEL, "--allowedTools", ""];
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));
resolve({ ok: true, summary: obj.summary || "", lede: obj.lede || "", body: obj.body || "" });
} 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);
@ -319,6 +359,22 @@ const server = createServer(async (req, res) => {
return json(res, 200, { conversationId: job.conversationId }); return json(res, 200, { conversationId: job.conversationId });
} }
// Draft project copy from a rough note + prompt (used by the New Project form).
if (path === "/draft" && 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 draftCopy(body);
busy = false;
if (!d.ok) return json(res, 500, { error: "Couldn't generate a draft — 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

@ -41,21 +41,77 @@ class AdminProjects extends PublicController
{ {
$this->requireAdmin(); $this->requireAdmin();
$slug = (string) ($_GET['slug'] ?? ''); $slug = (string) ($_GET['slug'] ?? '');
$r = \Db::getRow('SELECT slug, title, cover_image, cover_alt, gallery, links $r = \Db::getRow('SELECT * FROM cja_projects WHERE slug = ?', [$slug]);
FROM cja_projects WHERE slug = ?', [$slug]);
if (!$r) { if (!$r) {
$this->json(null, 404, ['code' => 'not_found', 'message' => 'No such project.']); $this->json(null, 404, ['code' => 'not_found', 'message' => 'No such project.']);
} }
$this->json([ $this->json([
'slug' => $r['slug'], 'slug' => $r['slug'],
'title' => $r['title'], 'title' => $r['title'],
'kind' => $r['kind'] ?? '',
'period' => $r['period_label'] ?? '',
'status' => $r['status'] ?? 'building',
'summary' => $r['summary'] ?? '',
'lede' => $r['lede'] ?? '',
'body' => $r['body'] ?? '',
'role' => $r['role'] ?? '',
'cover' => $r['cover_image'] ?? '', 'cover' => $r['cover_image'] ?? '',
'coverAlt'=> $r['cover_alt'] ?? '', 'categories' => json_decode($r['categories'] ?? '[]', true) ?: [],
'stack' => json_decode($r['stack'] ?? '[]', 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) ?: [],
]); ]);
} }
/** Create a blank project from a title and return its slug. */
public function create(): void
{
$this->requireAdmin();
$this->guardPublic('adminprojects_create', 30, 300);
$in = json_decode((string) file_get_contents('php://input'), true) ?: [];
$title = mb_substr(trim((string) ($in['title'] ?? '')), 0, 160);
if ($title === '') {
$this->json(null, 400, ['code' => 'no_title', 'message' => 'Give the project a title.']);
}
// unique slug from the title
$base = $this->slugify($title);
$slug = $base;
$n = 2;
while (\Db::getValue('SELECT project_id FROM cja_projects WHERE slug = ?', [$slug])) {
$slug = "{$base}-{$n}";
$n++;
}
$order = (int) \Db::getValue('SELECT COALESCE(MAX(sort_order), 0) + 10 FROM cja_projects');
\Db::insert('cja_projects', [
'slug' => $slug,
'title' => $title,
'kind' => '',
'period_label' => 'In development',
'status' => 'building',
'summary' => '',
'categories' => '[]',
'stack' => '[]',
'gallery' => '[]',
'links' => '{}',
'published' => 1,
'published_at' => date('Y-m-d H:i:s'),
'sort_order' => $order,
]);
$this->json(['ok' => true, 'slug' => $slug]);
}
private function slugify(string $s): string
{
$s = strtolower(trim($s));
$s = preg_replace('/[^a-z0-9]+/', '-', $s);
$s = trim($s, '-');
return $s !== '' ? mb_substr($s, 0, 100) : 'project';
}
public function save(): void public function save(): void
{ {
$this->requireAdmin(); $this->requireAdmin();
@ -91,11 +147,39 @@ class AdminProjects extends PublicController
$cover = $this->safeMedia((string) ($in['cover_image'] ?? '')); $cover = $this->safeMedia((string) ($in['cover_image'] ?? ''));
\Db::update('cja_projects', [ // ---- text/content fields --------------------------------------------
$title = mb_substr(trim((string) ($in['title'] ?? '')), 0, 160);
$fields = [
'gallery' => json_encode($gallery, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), 'gallery' => json_encode($gallery, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
'links' => json_encode($links, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), 'links' => json_encode($links, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
'cover_image' => $cover, 'cover_image' => $cover,
], 'project_id = ?', [$id]); ];
if ($title !== '') $fields['title'] = $title;
if (isset($in['kind'])) $fields['kind'] = mb_substr(trim((string) $in['kind']), 0, 60);
if (isset($in['period'])) $fields['period_label'] = mb_substr(trim((string) $in['period']), 0, 60);
if (isset($in['summary'])) $fields['summary'] = mb_substr(trim((string) $in['summary']), 0, 500);
if (isset($in['lede'])) $fields['lede'] = mb_substr(trim((string) $in['lede']), 0, 1000);
if (isset($in['body'])) $fields['body'] = (string) $in['body'];
if (isset($in['role'])) $fields['role'] = mb_substr(trim((string) $in['role']), 0, 200);
$allowedStatus = ['live', 'building', 'archived', 'concept'];
if (isset($in['status']) && in_array($in['status'], $allowedStatus, true)) {
$fields['status'] = $in['status'];
}
if (isset($in['categories']) && is_array($in['categories'])) {
$cats = array_values(array_filter(array_map(
fn($c) => mb_substr(trim((string) $c), 0, 40), $in['categories']
)));
$fields['categories'] = json_encode($cats, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
if (isset($in['stack']) && is_array($in['stack'])) {
$stack = array_values(array_filter(array_map(
fn($c) => mb_substr(trim((string) $c), 0, 40), $in['stack']
)));
$fields['stack'] = json_encode($stack, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
\Db::update('cja_projects', $fields, 'project_id = ?', [$id]);
$this->json(['ok' => true, 'slug' => $slug]); $this->json(['ok' => true, 'slug' => $slug]);
} }

View file

@ -5,12 +5,64 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
<AdminLayout title="Edit project"> <AdminLayout title="Edit project">
<a href="/admin/projects" class="back">← All projects</a> <a href="/admin/projects" class="back">← All projects</a>
<h1 id="ptitle">Project</h1> <h1 id="ptitle">Project</h1>
<p class="lead">Upload or replace gallery images, set the Instagram reel, then publish.</p> <p class="lead">Edit the details, gallery, and reel — then publish to the live site.</p>
<section class="block two">
<div>
<label for="f-title">Title</label>
<input type="text" id="f-title" />
</div>
<div>
<label for="f-status">Status</label>
<div class="sel">
<select id="f-status">
<option value="live">Live</option>
<option value="building">In development</option>
<option value="archived">Archived</option>
<option value="concept">Concept</option>
</select>
</div>
</div>
<div>
<label for="f-kind">Type</label>
<input type="text" id="f-kind" placeholder="SaaS, Tooling, SaaS / Media…" />
</div>
<div>
<label for="f-period">Timeline</label>
<input type="text" id="f-period" placeholder="2020 — present" />
</div>
<div class="wide">
<label for="f-role">Role</label>
<input type="text" id="f-role" placeholder="Founder — product, engineering…" />
</div>
<div class="wide">
<label for="f-cats">Categories (comma-separated)</label>
<input type="text" id="f-cats" placeholder="Websites, SaaS, Agentic" />
</div>
<div class="wide">
<label for="f-stack">Built with (comma-separated)</label>
<input type="text" id="f-stack" placeholder="Astro, PHP, MariaDB" />
</div>
</section>
<section class="block"> <section class="block">
<label>Instagram reel URL</label> <label for="f-summary">Summary (one line, shown on the projects list)</label>
<textarea id="f-summary" rows="2"></textarea>
</section>
<section class="block">
<label for="f-lede">Lede (the big opening line on the project page)</label>
<textarea id="f-lede" rows="2"></textarea>
</section>
<section class="block">
<label for="f-body">Case study (Markdown — use ## for headings, - for bullets)</label>
<textarea id="f-body" rows="14" class="mono"></textarea>
</section>
<section class="block">
<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/…" />
<p class="hint">Shown as the reel beside the case study.</p>
</section> </section>
<section class="block"> <section class="block">
@ -23,6 +75,7 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
<div class="footer"> <div class="footer">
<button type="button" class="btn" id="save">Save &amp; publish</button> <button type="button" class="btn" id="save">Save &amp; publish</button>
<a class="view" id="viewLink" target="_blank">View live ↗</a>
<p class="msg" id="msg" role="status" aria-live="polite"></p> <p class="msg" id="msg" role="status" aria-live="polite"></p>
</div> </div>
@ -30,10 +83,18 @@ 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:2rem 0; } .block { margin:1.75rem 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 { display:flex; align-items:center; gap:1rem; margin-bottom:1rem; }
.block .head label { margin:0; } .block .head label { margin:0; }
.hint { font-size:.78rem; color:var(--ink4); margin:.4rem 0 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; }
.sel select { width:100%; border:0; border-bottom:1px solid var(--rule); background:transparent;
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; } .btn.sm { padding:.35rem .7rem; font-size:.7rem; }
.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; }
@ -57,15 +118,16 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
.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); }
.view { font-family:var(--mono); font-size:.62rem; letter-spacing:.1em; text-transform:uppercase;
color:var(--ink4); text-decoration:none; } .view:hover { color:var(--ink); }
</style> </style>
<script> <script>
const slug = new URLSearchParams(location.search).get("slug") || ""; const slug = new URLSearchParams(location.search).get("slug") || "";
const galleryEl = document.getElementById("gallery"); const galleryEl = document.getElementById("gallery");
const msg = document.getElementById("msg"); const msg = document.getElementById("msg");
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; };
// in-memory model: [{src, alt, caption}]
let items = []; let items = [];
function render() { function render() {
@ -94,56 +156,74 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
const thumb = slot.querySelector("[data-thumb]"); const thumb = slot.querySelector("[data-thumb]");
thumb.classList.add("busy"); thumb.classList.add("busy");
try { try {
const fd = new FormData(); const fd = new FormData(); fd.append("file", file);
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) { if (r.ok && d?.data?.url) { items[i].src = d.data.url; render(); }
items[i].src = d.data.url; else setMsg(d?.error?.message || "Upload failed.", "error");
render();
} else {
setMsg(d?.error?.message || "Upload failed.", "error");
}
} catch { setMsg("Upload failed.", "error"); } } catch { setMsg("Upload failed.", "error"); }
finally { thumb.classList.remove("busy"); } finally { thumb.classList.remove("busy"); }
} }
document.getElementById("addSlot").addEventListener("click", () => { $("addSlot").addEventListener("click", () => { items.push({ src: "", alt: "", caption: "" }); render(); });
items.push({ src: "", alt: "", caption: "" });
render();
});
document.getElementById("save").addEventListener("click", async () => { const splitList = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
const btn = document.getElementById("save");
btn.disabled = true; setMsg("Saving…"); $("save").addEventListener("click", async () => {
const btn = $("save"); btn.disabled = true; setMsg("Saving…");
try { try {
const links = {}; const links = {};
const ig = document.getElementById("ig").value.trim(); const ig = $("ig").value.trim();
if (ig) links.instagram = ig; if (ig) links.instagram = ig;
const payload = {
slug,
title: $("f-title").value.trim(),
status: $("f-status").value,
kind: $("f-kind").value.trim(),
period: $("f-period").value.trim(),
role: $("f-role").value.trim(),
summary: $("f-summary").value.trim(),
lede: $("f-lede").value.trim(),
body: $("f-body").value,
categories: splitList($("f-cats").value),
stack: splitList($("f-stack").value),
gallery: items,
links,
};
const save = await fetch("/api/adminprojects/save", { const save = await fetch("/api/adminprojects/save", {
method: "POST", credentials: "same-origin", method: "POST", credentials: "same-origin",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" }, body: JSON.stringify(payload),
body: JSON.stringify({ slug, gallery: items, links }),
}); });
const sd = await save.json(); const sd = await save.json();
if (!save.ok || !sd?.data?.ok) { setMsg(sd?.error?.message || "Save failed.", "error"); btn.disabled = false; return; } if (!save.ok || !sd?.data?.ok) { setMsg(sd?.error?.message || "Save failed.", "error"); btn.disabled = false; return; }
setMsg("Saved — publishing to the live site…"); setMsg("Saved — 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. Changes are live.", "ok"); setMsg(pub.ok ? "Published. Changes are live." : "Saved, but the publish build failed.", pub.ok ? "ok" : "error");
else setMsg("Saved, but the publish build failed. Try again.", "error");
} catch { setMsg("Something went wrong.", "error"); } } catch { setMsg("Something went wrong.", "error"); }
finally { btn.disabled = false; } finally { btn.disabled = false; }
}); });
(async () => { (async () => {
if (!slug) { setMsg("No project specified.", "error"); return; } if (!slug) { setMsg("No project specified.", "error"); return; }
$("viewLink").href = "/projects/" + slug;
try { try {
const r = await fetch("/api/adminprojects/get?slug=" + encodeURIComponent(slug), { credentials: "same-origin" }); const r = await fetch("/api/adminprojects/get?slug=" + encodeURIComponent(slug), { credentials: "same-origin" });
const d = await r.json(); const d = await r.json();
if (!r.ok || !d?.data) { setMsg("Couldn't load the project.", "error"); return; } if (!r.ok || !d?.data) { setMsg("Couldn't load the project.", "error"); return; }
document.getElementById("ptitle").textContent = d.data.title; const p = d.data;
document.getElementById("ig").value = d.data.links?.instagram || ""; $("ptitle").textContent = p.title || slug;
items = Array.isArray(d.data.gallery) ? d.data.gallery : []; $("f-title").value = p.title || "";
$("f-status").value = p.status || "building";
$("f-kind").value = p.kind || "";
$("f-period").value = p.period || "";
$("f-role").value = p.role || "";
$("f-summary").value = p.summary || "";
$("f-lede").value = p.lede || "";
$("f-body").value = p.body || "";
$("f-cats").value = (p.categories || []).join(", ");
$("f-stack").value = (p.stack || []).join(", ");
$("ig").value = p.links?.instagram || "";
items = Array.isArray(p.gallery) ? p.gallery : [];
render(); render();
} catch { setMsg("Couldn't load the project.", "error"); } } catch { setMsg("Couldn't load the project.", "error"); }
})(); })();

View file

@ -3,8 +3,11 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
--- ---
<AdminLayout title="Projects"> <AdminLayout title="Projects">
<div style="display:flex;align-items:baseline;gap:1.25rem">
<h1>Projects</h1> <h1>Projects</h1>
<p class="lead">Add or replace gallery images and Instagram reels for each project.</p> <a href="/admin/projects/new" class="btn" style="margin-left:auto;text-decoration:none">+ New project</a>
</div>
<p class="lead">Edit content, galleries, and Instagram reels — or create a new project.</p>
<div id="list" style="display:flex;flex-direction:column">Loading…</div> <div id="list" style="display:flex;flex-direction:column">Loading…</div>

View file

@ -0,0 +1,220 @@
---
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>
<section class="block two">
<div class="wide">
<label for="f-name">Project name</label>
<input type="text" id="f-name" placeholder="Ginny Goldman — firm site" />
</div>
<div class="wide">
<label for="f-cats">Category (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>
</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>
<button type="button" class="btn ghost sm" id="addSlot">+ Add image</button>
</div>
<div id="gallery" class="grid"></div>
</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>
</section>
<div class="footer">
<button type="button" class="btn" id="create">Create project</button>
<p class="msg" id="msg" role="status" aria-live="polite"></p>
</div>
<style>
.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.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; }
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; }
.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;
justify-content:center; overflow:hidden; }
.thumb img { width:100%; height:100%; object-fit:cover; }
.thumb .empty { font-family:var(--mono); font-size:.6rem; letter-spacing:.1em; text-transform:uppercase; color:var(--ink4); }
.thumb.busy::after { content:"Uploading…"; position:absolute; inset:0; display:flex; align-items:center;
justify-content:center; background:color-mix(in srgb, var(--paper) 80%, transparent);
font-family:var(--mono); font-size:.65rem; color:var(--ink3); }
.slot .cap { border:0; border-top:1px solid var(--rule); background:transparent; padding:.5rem .6rem;
font-size:.8rem; color:var(--ink); font-family:var(--sans); width:100%; }
.slot .cap:focus { outline:none; }
.slot .acts { display:flex; border-top:1px solid var(--rule); }
.slot .acts label, .slot .acts button { flex:1; text-align:center; padding:.45rem; font-family:var(--mono);
font-size:.58rem; letter-spacing:.08em; text-transform:uppercase; color:var(--ink3); cursor:pointer;
background:none; border:0; }
.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); }
</style>
<script>
const $ = (id) => document.getElementById(id);
const msg = $("msg"), draftMsg = $("draftMsg");
const setMsg = (t, cls = "") => { msg.textContent = t; msg.className = "msg " + cls; };
const galleryEl = $("gallery");
let items = [];
// ---- gallery -----------------------------------------------------------
function render() {
galleryEl.innerHTML = "";
items.forEach((it, i) => {
const slot = document.createElement("div");
slot.className = "slot";
slot.innerHTML = `
<div class="thumb" data-thumb>
${it.src ? `<img src="${it.src}" alt="">` : `<span class="empty">No image</span>`}
</div>
<input class="cap" placeholder="Caption" value="${(it.caption || "").replace(/"/g, "&quot;")}" data-cap>
<div class="acts">
<label>${it.src ? "Replace" : "Upload"}<input type="file" accept="image/*" data-file></label>
<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-file]").addEventListener("change", (e) => upload(e.target.files[0], i, slot));
galleryEl.appendChild(slot);
});
}
async function upload(file, i, slot) {
if (!file) return;
const thumb = slot.querySelector("[data-thumb]");
thumb.classList.add("busy");
try {
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"); }
finally { thumb.classList.remove("busy"); }
}
$("addSlot").addEventListener("click", () => { items.push({ src: "", alt: "", caption: "" }); render(); });
// ---- draft copy with the agent ----------------------------------------
$("draftBtn").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.";
try {
const r = await fetch("/devconsole/draft", {
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(),
}),
});
const d = await r.json();
if (!r.ok) { draftMsg.textContent = d?.error || "Couldn't draft the copy."; return; }
$("g-summary").value = d.summary || "";
$("g-lede").value = d.lede || "";
$("g-body").value = d.body || "";
$("genWrap").hidden = false;
draftMsg.textContent = "Drafted — review and edit below, then create.";
} catch { draftMsg.textContent = "Couldn't reach the agent."; }
finally { btn.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…");
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 }),
});
const cd = await cr.json();
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,
gallery: items, links,
};
const sv = await fetch("/api/adminprojects/save", {
method: "POST", credentials: "same-origin",
headers: { "content-type": "application/json" }, body: JSON.stringify(payload),
});
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); }
else setMsg("Saved, but the publish build failed. Open the editor to retry.", "error");
} catch { setMsg("Something went wrong.", "error"); btn.disabled = false; }
});
</script>
</AdminLayout>