From a88cc415571f465e752af0a02a7d36fcec353476 Mon Sep 17 00:00:00 2001 From: Carlos Arias Date: Thu, 23 Jul 2026 23:23:33 +0000 Subject: [PATCH] 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() --- agents/console/server.mjs | 56 ++++++ api/public/controllers/adminprojects.php | 104 +++++++++-- app/src/pages/admin/projects/edit.astro | 140 +++++++++++---- app/src/pages/admin/projects/index.astro | 7 +- app/src/pages/admin/projects/new.astro | 220 +++++++++++++++++++++++ 5 files changed, 485 insertions(+), 42 deletions(-) create mode 100644 app/src/pages/admin/projects/new.astro diff --git a/agents/console/server.mjs b/agents/console/server.mjs index e6a8744..017d3af 100644 --- a/agents/console/server.mjs +++ b/agents/console/server.mjs @@ -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 --------------------------------------------------------- function json(res, status, obj) { const body = JSON.stringify(obj); @@ -319,6 +359,22 @@ const server = createServer(async (req, res) => { 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") { const id = url.searchParams.get("conversationId"); const job = id && jobs.get(id); diff --git a/api/public/controllers/adminprojects.php b/api/public/controllers/adminprojects.php index a0f4a7e..a863796 100644 --- a/api/public/controllers/adminprojects.php +++ b/api/public/controllers/adminprojects.php @@ -41,21 +41,77 @@ class AdminProjects extends PublicController { $this->requireAdmin(); $slug = (string) ($_GET['slug'] ?? ''); - $r = \Db::getRow('SELECT slug, title, cover_image, cover_alt, gallery, links - FROM cja_projects WHERE slug = ?', [$slug]); + $r = \Db::getRow('SELECT * FROM cja_projects WHERE slug = ?', [$slug]); if (!$r) { $this->json(null, 404, ['code' => 'not_found', 'message' => 'No such project.']); } $this->json([ - 'slug' => $r['slug'], - 'title' => $r['title'], - 'cover' => $r['cover_image'] ?? '', - 'coverAlt'=> $r['cover_alt'] ?? '', - 'gallery' => json_decode($r['gallery'] ?? '[]', true) ?: [], - 'links' => json_decode($r['links'] ?? '{}', true) ?: [], + 'slug' => $r['slug'], + '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'] ?? '', + 'categories' => json_decode($r['categories'] ?? '[]', true) ?: [], + 'stack' => json_decode($r['stack'] ?? '[]', true) ?: [], + 'gallery' => json_decode($r['gallery'] ?? '[]', 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 { $this->requireAdmin(); @@ -91,11 +147,39 @@ class AdminProjects extends PublicController $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), 'links' => json_encode($links, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), '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]); } diff --git a/app/src/pages/admin/projects/edit.astro b/app/src/pages/admin/projects/edit.astro index f68c51a..3a591e5 100644 --- a/app/src/pages/admin/projects/edit.astro +++ b/app/src/pages/admin/projects/edit.astro @@ -5,12 +5,64 @@ import AdminLayout from "../../../layouts/AdminLayout.astro"; ← All projects

Project

-

Upload or replace gallery images, set the Instagram reel, then publish.

+

Edit the details, gallery, and reel — then publish to the live site.

+ +
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
- + + +
+ +
+ + +
+ +
+ + +
+ +
+ -

Shown as the reel beside the case study.

@@ -23,6 +75,7 @@ import AdminLayout from "../../../layouts/AdminLayout.astro"; @@ -30,10 +83,18 @@ 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: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 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; } .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; } @@ -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; margin-top:2rem; border-top:1px solid var(--rule); 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); } +