- /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
113 lines
4.4 KiB
PHP
113 lines
4.4 KiB
PHP
<?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 : '';
|
|
}
|
|
}
|