Phase 2/3: projects media admin (galleries + Instagram)

- /admin/projects list + /admin/projects/edit gallery editor (AdminLayout chrome,
  session-guarded). Upload/replace images per slot, captions, add/remove,
  Instagram reel URL. Save -> cja_projects -> rebuild -> live.
- adminprojects API (list/get/save); media path validation (local paths only).
- runner /rebuild endpoint: build public/ + commit after structured edits.
- Ownership: app/ + public/ now owned by carlos-arias-agent:caweb so the agent
  can rebuild its own output; www serves via the caweb group. Documented in
  SETUP.md — never build as root.

Verified end to end: upload image -> save to gallery -> rebuild -> image live on
the project page and served.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DoFYZY9gkGPNDqZ7NuEa9a
This commit is contained in:
Carlos Arias 2026-07-23 22:55:32 +00:00
parent dc69d1fd0d
commit a2da708418
6 changed files with 425 additions and 0 deletions

View file

@ -41,3 +41,19 @@ Full edit→preview→serve loop proven end to end.
check → proxy to `127.0.0.1:3011`. Runner never exposed directly.
- Wire `DevConsole.astro` to the runner.
- systemd unit to keep the runner alive.
## Build ownership (IMPORTANT)
- `app/` and `public/` are owned by **carlos-arias-agent:caweb**. The agent runs
all builds and must own its output to overwrite it.
- **Never run `npm run build` as root** — it creates root-owned files the agent
then can't overwrite (EPERM in vite prepare-out-dir). If it happens, fix with:
`chown -R carlos-arias-agent:caweb app public`.
- Rebuild via the runner (`POST /devconsole/rebuild`) or
`sudo -u carlos-arias-agent env HOME=/var/lib/carlos-arias-agent … npm run build`.
- www serves `public/` via the shared `caweb` group — no chown-to-www needed.
## Phase 2/3 delivered
- Media upload: `POST /api/media/upload` (GD/ffmpeg optimise → app/public/media).
- Projects admin: `/admin/projects` (list) + `/admin/projects/edit?slug=` (gallery
upload/replace + Instagram URL). Saves to cja_projects, then rebuilds.
- `POST /devconsole/rebuild` regenerates the live site after structured edits.

View file

@ -353,6 +353,20 @@ const server = createServer(async (req, res) => {
return json(res, 200, { ok: true, live: job.page || "/" });
}
// Rebuild the live site — used after structured content edits (projects
// gallery, etc.) that changed the DB rather than files. Builds public/ and
// commits any new media files (a no-op commit is fine).
if (path === "/rebuild" && method === "POST") {
if (busy) return json(res, 429, { error: "Busy — try again in a moment." });
busy = true;
const built = await build({});
if (!built.ok) { busy = false; return json(res, 500, { error: "Build failed." }); }
await git(["add", "--", "app/public/media", "app", "brand", "api/db", "api/cli"]);
await git(["commit", "-q", "-m", "console: content update"]); // ok if nothing to commit
busy = false;
return json(res, 200, { ok: true });
}
if (path === "/discard" && method === "POST") {
if (busy) return json(res, 429, { error: "Busy — try again in a moment." });
const { conversationId } = await readBody(req);

View file

@ -0,0 +1,113 @@
<?php
use App\Controllers\PublicController;
/**
* Structured editing of projects for the admin console (galleries, Instagram,
* cover). Content lives in cja_projects; the static site reads it at build
* time, so a save is followed by a rebuild (the console's /devconsole/rebuild).
*
* GET /api/adminprojects/list -> [{slug,title,galleryCount,...}]
* GET /api/adminprojects/get?slug=... -> full editable record
* POST /api/adminprojects/save -> { slug, gallery, links, cover_image }
*
* All admin-gated. Only media-related fields are writable here; the freeform
* agent console handles prose and layout.
*/
class AdminProjects extends PublicController
{
public function list(): void
{
$this->requireAdmin();
$rows = \Db::select('SELECT slug, title, status, gallery, links
FROM cja_projects ORDER BY sort_order ASC');
$out = array_map(function ($r) {
$gallery = json_decode($r['gallery'] ?? '[]', true) ?: [];
$links = json_decode($r['links'] ?? '{}', true) ?: [];
$filled = count(array_filter($gallery, fn($g) => !empty($g['src'])));
return [
'slug' => $r['slug'],
'title' => $r['title'],
'status' => $r['status'],
'gallery' => count($gallery),
'galleryFilled' => $filled,
'hasInstagram' => !empty($links['instagram']),
];
}, $rows);
$this->json(['projects' => $out]);
}
public function get(): void
{
$this->requireAdmin();
$slug = (string) ($_GET['slug'] ?? '');
$r = \Db::getRow('SELECT slug, title, cover_image, cover_alt, gallery, links
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) ?: [],
]);
}
public function save(): void
{
$this->requireAdmin();
$this->guardPublic('adminprojects_save', 60, 300);
$in = json_decode((string) file_get_contents('php://input'), true) ?: [];
$slug = (string) ($in['slug'] ?? '');
$id = \Db::getValue('SELECT project_id FROM cja_projects WHERE slug = ?', [$slug]);
if (!$id) {
$this->json(null, 404, ['code' => 'not_found', 'message' => 'No such project.']);
}
// ---- gallery: array of {src, alt, caption} -------------------------
$gallery = [];
foreach ((array) ($in['gallery'] ?? []) as $g) {
$src = $this->safeMedia((string) ($g['src'] ?? ''));
$gallery[] = [
'src' => $src,
'alt' => mb_substr(trim((string) ($g['alt'] ?? '')), 0, 200),
'caption' => mb_substr(trim((string) ($g['caption'] ?? '')), 0, 120),
];
}
// ---- links: only known keys, only real URLs ------------------------
$links = [];
foreach (['live', 'instagram', 'github'] as $k) {
$v = trim((string) ($in['links'][$k] ?? ''));
if ($v !== '' && filter_var($v, FILTER_VALIDATE_URL)) {
$links[$k] = $v;
}
}
$cover = $this->safeMedia((string) ($in['cover_image'] ?? ''));
\Db::update('cja_projects', [
'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]);
$this->json(['ok' => true, 'slug' => $slug]);
}
/**
* Only allow local media paths (uploads live under /media/ or /projects/),
* never arbitrary or off-site URLs the src is rendered into the page.
*/
private function safeMedia(string $s): string
{
$s = trim($s);
if ($s === '') return '';
return preg_match('#^/(media|projects|portfolio)/[\w./-]+$#', $s) ? $s : '';
}
}

View file

@ -0,0 +1,86 @@
---
// Minimal admin chrome for the dashboard pages. Public HTML (the real gate is
// on every API endpoint), but it redirects to /admin if there's no session, so
// the pages are never usefully reachable without logging in.
const { title = "Admin" } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} — Carlos Arias</title>
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<style is:global>
:root {
--paper:#f6f5f2; --ink:#14151a; --ink2:#3d3f47; --ink3:#6e7078; --ink4:#9a9ca3;
--rule:#dfddd7; --seal:#d0342c; --card:#efeeea;
--serif:"Fraunces",ui-serif,Georgia,serif; --sans:"Inter",ui-sans-serif,system-ui,sans-serif;
--mono:"JetBrains Mono",ui-monospace,monospace;
}
@media (prefers-color-scheme: dark) {
:root { --paper:#121317; --ink:#edece8; --ink2:#c2c1bd; --ink3:#8b8d95; --ink4:#5e606a;
--rule:#2a2c33; --seal:#e8564a; --card:#191a1f; }
}
* { box-sizing:border-box; }
body { margin:0; background:var(--paper); color:var(--ink); font-family:var(--sans);
font-size:15px; line-height:1.5; }
a { color:inherit; }
.admin-top { position:sticky; top:0; z-index:10; display:flex; align-items:center; gap:.85rem;
padding:.9rem 1.25rem; border-bottom:1px solid var(--rule);
background:color-mix(in srgb, var(--paper) 90%, transparent); backdrop-filter:blur(8px); }
.admin-seal { display:inline-flex; align-items:center; justify-content:center; width:1.6rem; height:1.6rem;
border-radius:2px; background:var(--seal); color:#fff; font-family:var(--serif); font-size:.8rem; }
.admin-top .name { font-family:var(--mono); font-size:.62rem; letter-spacing:.14em; text-transform:uppercase; color:var(--ink3); }
.admin-top nav { margin-left:auto; display:flex; gap:1.25rem; align-items:center; }
.admin-top nav a, .admin-top nav button { font-family:var(--mono); font-size:.62rem; letter-spacing:.1em;
text-transform:uppercase; color:var(--ink3); text-decoration:none; background:none; border:0; cursor:pointer; }
.admin-top nav a:hover, .admin-top nav button:hover { color:var(--ink); }
main { max-width:60rem; margin:0 auto; padding:2.5rem 1.25rem 5rem; }
h1 { font-family:var(--serif); font-weight:400; font-size:2rem; letter-spacing:-.01em; margin:0 0 .4rem; }
.lead { color:var(--ink3); margin:0 0 2rem; }
.btn { border:1px solid var(--ink); background:var(--ink); color:var(--paper); border-radius:2px;
padding:.7rem 1.3rem; font-size:.85rem; font-weight:500; cursor:pointer; font-family:var(--sans); }
.btn:disabled { opacity:.55; cursor:default; }
.btn.ghost { background:transparent; color:var(--ink); border-color:var(--rule); }
label { font-family:var(--mono); font-size:.6rem; letter-spacing:.14em; text-transform:uppercase; color:var(--ink4);
display:block; margin-bottom:.35rem; }
input[type=text], input[type=url] { 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); }
input:focus { outline:none; border-bottom-color:var(--seal); }
.msg { font-size:.85rem; min-height:1.2em; }
.msg.error { color:var(--seal); } .msg.ok { color:var(--seal); }
</style>
</head>
<body>
<header class="admin-top">
<span class="admin-seal" aria-hidden="true">印</span>
<span class="name">Carlos Arias · Admin</span>
<nav>
<a href="/admin/projects">Projects</a>
<a href="/" target="_blank">View site ↗</a>
<button type="button" data-logout>Sign out</button>
</nav>
</header>
<main>
<slot />
</main>
<script>
// Guard: no admin session -> back to login.
(async () => {
try {
const r = await fetch("/api/adminauth/me", { credentials: "same-origin" });
const d = await r.json();
if (!d?.data?.admin) location.replace("/admin");
} catch { location.replace("/admin"); }
})();
document.querySelector("[data-logout]")?.addEventListener("click", async () => {
try { await fetch("/api/adminauth/logout", { method: "POST", credentials: "same-origin" }); } catch {}
location.replace("/admin");
});
</script>
</body>
</html>

View file

@ -0,0 +1,151 @@
---
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>
<section class="block">
<label>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">
<div class="head">
<label>Gallery</label>
<button type="button" class="btn ghost sm" id="addSlot">+ Add image</button>
</div>
<div id="gallery" class="grid"></div>
</section>
<div class="footer">
<button type="button" class="btn" id="save">Save &amp; publish</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:2rem 0; }
.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; }
.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 slug = new URLSearchParams(location.search).get("slug") || "";
const galleryEl = document.getElementById("gallery");
const msg = document.getElementById("msg");
const setMsg = (t, cls = "") => { msg.textContent = t; msg.className = "msg " + cls; };
// in-memory model: [{src, alt, caption}]
let items = [];
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"); }
}
document.getElementById("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…");
try {
const links = {};
const ig = document.getElementById("ig").value.trim();
if (ig) links.instagram = ig;
const save = await fetch("/api/adminprojects/save", {
method: "POST", credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify({ slug, gallery: items, links }),
});
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");
} catch { setMsg("Something went wrong.", "error"); }
finally { btn.disabled = false; }
});
(async () => {
if (!slug) { setMsg("No project specified.", "error"); return; }
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 : [];
render();
} catch { setMsg("Couldn't load the project.", "error"); }
})();
</script>
</AdminLayout>

View file

@ -0,0 +1,45 @@
---
import AdminLayout from "../../../layouts/AdminLayout.astro";
---
<AdminLayout title="Projects">
<h1>Projects</h1>
<p class="lead">Add or replace gallery images and Instagram reels for each project.</p>
<div id="list" style="display:flex;flex-direction:column">Loading…</div>
<style>
.prow { display:flex; align-items:center; gap:1rem; padding:1.1rem 0; border-top:1px solid var(--rule);
text-decoration:none; color:inherit; }
.prow:last-child { border-bottom:1px solid var(--rule); }
.prow .t { font-family:var(--serif); font-size:1.25rem; }
.prow .meta { margin-left:auto; display:flex; gap:1.25rem; font-family:var(--mono); font-size:.68rem;
letter-spacing:.06em; color:var(--ink4); }
.prow .meta .on { color:var(--seal); }
.prow:hover .t { text-decoration:underline; }
</style>
<script>
const list = document.getElementById("list");
(async () => {
try {
const r = await fetch("/api/adminprojects/list", { credentials: "same-origin" });
const d = await r.json();
if (!d?.data?.projects) { list.textContent = "Couldn't load projects."; return; }
list.innerHTML = "";
for (const p of d.data.projects) {
const a = document.createElement("a");
a.href = "/admin/projects/edit?slug=" + encodeURIComponent(p.slug);
a.className = "prow";
a.innerHTML =
`<span class="t">${p.title}</span>
<span class="meta">
<span class="${p.galleryFilled ? "on" : ""}">${p.galleryFilled}/${p.gallery} images</span>
<span class="${p.hasInstagram ? "on" : ""}">${p.hasInstagram ? "Reel ✓" : "No reel"}</span>
</span>`;
list.appendChild(a);
}
} catch { list.textContent = "Couldn't load projects."; }
})();
</script>
</AdminLayout>