[{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 * 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'] ?? '', 'categories' => json_decode($r['categories'] ?? '[]', true) ?: [], 'stack' => json_decode($r['stack'] ?? '[]', true) ?: [], 'skills' => json_decode($r['skills'] ?? '[]', true) ?: [], 'metrics' => json_decode($r['metrics'] ?? '[]', 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(); $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'] ?? '')); // ---- 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, ]; 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); } // ---- skills: array of { group, items[] } ---------------------------- if (isset($in['skills']) && is_array($in['skills'])) { $skills = []; foreach ($in['skills'] as $g) { $group = mb_substr(trim((string) ($g['group'] ?? '')), 0, 60); $items = array_values(array_filter(array_map( fn($it) => mb_substr(trim((string) $it), 0, 60), (array) ($g['items'] ?? []) ))); if ($group !== '' && $items) { $skills[] = ['group' => $group, 'items' => $items]; } } $fields['skills'] = json_encode($skills, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } // ---- metrics: array of { value, label, note? } ---------------------- if (isset($in['metrics']) && is_array($in['metrics'])) { $metrics = []; foreach ($in['metrics'] as $m) { $value = mb_substr(trim((string) ($m['value'] ?? '')), 0, 40); $label = mb_substr(trim((string) ($m['label'] ?? '')), 0, 80); if ($value === '' && $label === '') continue; $metric = ['value' => $value, 'label' => $label]; $note = mb_substr(trim((string) ($m['note'] ?? '')), 0, 120); if ($note !== '') $metric['note'] = $note; $metrics[] = $metric; } $fields['metrics'] = json_encode($metrics, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } \Db::update('cja_projects', $fields, '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 : ''; } }