seedproject-web/api/public/controllers/adminprojects.php

231 lines
9.8 KiB
PHP
Raw Normal View History

<?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 * 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 : '';
}
}