diff --git a/agents/console/SETUP.md b/agents/console/SETUP.md index b9c62f8..abb58c5 100644 --- a/agents/console/SETUP.md +++ b/agents/console/SETUP.md @@ -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. diff --git a/agents/console/server.mjs b/agents/console/server.mjs index 27ed8fc..e6a8744 100644 --- a/agents/console/server.mjs +++ b/agents/console/server.mjs @@ -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); diff --git a/api/public/controllers/adminprojects.php b/api/public/controllers/adminprojects.php new file mode 100644 index 0000000..a0f4a7e --- /dev/null +++ b/api/public/controllers/adminprojects.php @@ -0,0 +1,113 @@ + [{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 : ''; + } +} diff --git a/app/src/layouts/AdminLayout.astro b/app/src/layouts/AdminLayout.astro new file mode 100644 index 0000000..0333d95 --- /dev/null +++ b/app/src/layouts/AdminLayout.astro @@ -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; +--- + + + +
+ + +Upload or replace gallery images, set the Instagram reel, then publish.
+ +Shown as the reel beside the case study.
+Add or replace gallery images and Instagram reels for each project.
+ +