2026-07-23 22:55:32 +00:00
|
|
|
<?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'] ?? '');
|
2026-07-23 23:23:33 +00:00
|
|
|
$r = \Db::getRow('SELECT * FROM cja_projects WHERE slug = ?', [$slug]);
|
2026-07-23 22:55:32 +00:00
|
|
|
if (!$r) {
|
|
|
|
|
$this->json(null, 404, ['code' => 'not_found', 'message' => 'No such project.']);
|
|
|
|
|
}
|
|
|
|
|
$this->json([
|
2026-07-23 23:23:33 +00:00
|
|
|
'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) ?: [],
|
2026-07-23 23:44:50 +00:00
|
|
|
'skills' => json_decode($r['skills'] ?? '[]', true) ?: [],
|
|
|
|
|
'metrics' => json_decode($r['metrics'] ?? '[]', true) ?: [],
|
2026-07-23 23:23:33 +00:00
|
|
|
'gallery' => json_decode($r['gallery'] ?? '[]', true) ?: [],
|
|
|
|
|
'links' => json_decode($r['links'] ?? '{}', true) ?: [],
|
2026-07-23 22:55:32 +00:00
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 23:23:33 +00:00
|
|
|
/** 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';
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 22:55:32 +00:00
|
|
|
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'] ?? ''));
|
|
|
|
|
|
2026-07-23 23:23:33 +00:00
|
|
|
// ---- text/content fields --------------------------------------------
|
|
|
|
|
$title = mb_substr(trim((string) ($in['title'] ?? '')), 0, 160);
|
|
|
|
|
$fields = [
|
2026-07-23 22:55:32 +00:00
|
|
|
'gallery' => json_encode($gallery, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
|
|
|
|
'links' => json_encode($links, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
|
|
|
|
'cover_image' => $cover,
|
2026-07-23 23:23:33 +00:00
|
|
|
];
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 23:44:50 +00:00
|
|
|
// ---- 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 23:23:33 +00:00
|
|
|
\Db::update('cja_projects', $fields, 'project_id = ?', [$id]);
|
2026-07-23 22:55:32 +00:00
|
|
|
|
|
|
|
|
$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 : '';
|
|
|
|
|
}
|
|
|
|
|
}
|