[{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 : ''; } }