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:
parent
a2da708418
commit
a88cc41557
5 changed files with 485 additions and 42 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
'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'] ?? '',
|
||||
'coverAlt'=> $r['cover_alt'] ?? '',
|
||||
'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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,64 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
|
|||
<AdminLayout title="Edit project">
|
||||
<a href="/admin/projects" class="back">← All projects</a>
|
||||
<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">
|
||||
<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/…" />
|
||||
<p class="hint">Shown as the reel beside the case study.</p>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
|
|
@ -23,6 +75,7 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
|
|||
|
||||
<div class="footer">
|
||||
<button type="button" class="btn" id="save">Save & publish</button>
|
||||
<a class="view" id="viewLink" target="_blank">View live ↗</a>
|
||||
<p class="msg" id="msg" role="status" aria-live="polite"></p>
|
||||
</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;
|
||||
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); }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const slug = new URLSearchParams(location.search).get("slug") || "";
|
||||
const galleryEl = document.getElementById("gallery");
|
||||
const msg = document.getElementById("msg");
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const setMsg = (t, cls = "") => { msg.textContent = t; msg.className = "msg " + cls; };
|
||||
|
||||
// in-memory model: [{src, alt, caption}]
|
||||
let items = [];
|
||||
|
||||
function render() {
|
||||
|
|
@ -94,56 +156,74 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
|
|||
const thumb = slot.querySelector("[data-thumb]");
|
||||
thumb.classList.add("busy");
|
||||
try {
|
||||
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 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");
|
||||
}
|
||||
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"); }
|
||||
}
|
||||
|
||||
document.getElementById("addSlot").addEventListener("click", () => {
|
||||
items.push({ src: "", alt: "", caption: "" });
|
||||
render();
|
||||
});
|
||||
$("addSlot").addEventListener("click", () => { items.push({ src: "", alt: "", caption: "" }); render(); });
|
||||
|
||||
document.getElementById("save").addEventListener("click", async () => {
|
||||
const btn = document.getElementById("save");
|
||||
btn.disabled = true; setMsg("Saving…");
|
||||
const splitList = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
|
||||
|
||||
$("save").addEventListener("click", async () => {
|
||||
const btn = $("save"); btn.disabled = true; setMsg("Saving…");
|
||||
try {
|
||||
const links = {};
|
||||
const ig = document.getElementById("ig").value.trim();
|
||||
const ig = $("ig").value.trim();
|
||||
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", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ slug, gallery: items, links }),
|
||||
headers: { "content-type": "application/json" }, body: JSON.stringify(payload),
|
||||
});
|
||||
const sd = await save.json();
|
||||
if (!save.ok || !sd?.data?.ok) { setMsg(sd?.error?.message || "Save failed.", "error"); btn.disabled = false; return; }
|
||||
setMsg("Saved — publishing to the live site…");
|
||||
const pub = await fetch("/devconsole/rebuild", { method: "POST", credentials: "same-origin" });
|
||||
if (pub.ok) setMsg("Published. Changes are live.", "ok");
|
||||
else setMsg("Saved, but the publish build failed. Try again.", "error");
|
||||
setMsg(pub.ok ? "Published. Changes are live." : "Saved, but the publish build failed.", pub.ok ? "ok" : "error");
|
||||
} catch { setMsg("Something went wrong.", "error"); }
|
||||
finally { btn.disabled = false; }
|
||||
});
|
||||
|
||||
(async () => {
|
||||
if (!slug) { setMsg("No project specified.", "error"); return; }
|
||||
$("viewLink").href = "/projects/" + slug;
|
||||
try {
|
||||
const r = await fetch("/api/adminprojects/get?slug=" + encodeURIComponent(slug), { credentials: "same-origin" });
|
||||
const d = await r.json();
|
||||
if (!r.ok || !d?.data) { setMsg("Couldn't load the project.", "error"); return; }
|
||||
document.getElementById("ptitle").textContent = d.data.title;
|
||||
document.getElementById("ig").value = d.data.links?.instagram || "";
|
||||
items = Array.isArray(d.data.gallery) ? d.data.gallery : [];
|
||||
const p = d.data;
|
||||
$("ptitle").textContent = p.title || slug;
|
||||
$("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();
|
||||
} catch { setMsg("Couldn't load the project.", "error"); }
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -3,8 +3,11 @@ import AdminLayout from "../../../layouts/AdminLayout.astro";
|
|||
---
|
||||
|
||||
<AdminLayout title="Projects">
|
||||
<div style="display:flex;align-items:baseline;gap:1.25rem">
|
||||
<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>
|
||||
|
||||
|
|
|
|||
220
app/src/pages/admin/projects/new.astro
Normal file
220
app/src/pages/admin/projects/new.astro
Normal 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, """)}" 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>
|
||||
Loading…
Reference in a new issue