query("SHOW COLUMNS FROM projects")->fetchAll(PDO::FETCH_ASSOC); $colNames = array_map(static fn($c) => (string)($c['Field'] ?? ''), $cols); $alter = []; if (!in_array('tech_json', $colNames, true)) $alter[] = "ADD COLUMN tech_json LONGTEXT NULL"; if (!in_array('links_json', $colNames, true)) $alter[] = "ADD COLUMN links_json LONGTEXT NULL"; if (!in_array('sort_order', $colNames, true)) $alter[] = "ADD COLUMN sort_order INT NOT NULL DEFAULT 0"; if (!in_array('slug', $colNames, true)) $alter[] = "ADD COLUMN slug VARCHAR(120) NULL"; if (!in_array('short_summary', $colNames, true)) $alter[] = "ADD COLUMN short_summary VARCHAR(240) NULL"; foreach ($cols as $c) { if (($c['Field'] ?? '') !== 'summary') continue; $type = strtolower((string)($c['Type'] ?? '')); $null = strtoupper((string)($c['Null'] ?? '')) === 'YES' ? 'NULL' : 'NOT NULL'; if (strpos($type, 'text') === false) $alter[] = "MODIFY summary TEXT {$null}"; break; } if ($alter) { pdo()->exec("ALTER TABLE projects " . implode(", ", $alter)); } } catch (Throwable $e) {} $project = [ 'slug' => '', 'title' => '', 'tag' => '', 'year' => (int)date('Y'), 'summary' => '', 'short_summary' => '', 'tech_json' => '[]', 'links_json' => '[]', 'sort_order' => 0, ]; $activeTab = (isset($_GET['tab']) && strtolower((string)$_GET['tab']) === 'gallery') ? 'gallery' : 'details'; if ($id) { $st = pdo()->prepare("SELECT id, slug, title, tag, year, summary, short_summary, tech_json, links_json, sort_order FROM projects WHERE id = ? LIMIT 1"); $st->execute([$id]); $row = $st->fetch(PDO::FETCH_ASSOC); if (!$row) { flash_set('danger', 'Project not found.'); header('Location: /public/admin/projects.php'); exit; } $project = $row; } $errors = []; function parse_list(string $raw): array { $parts = preg_split('/[\r\n,]+/', $raw) ?: []; $out = []; foreach ($parts as $p) { $p = trim((string)$p); if ($p !== '') $out[] = $p; } return array_values(array_unique($out)); } function parse_links(string $raw): array { $lines = preg_split('/\R/', $raw) ?: []; $links = []; foreach ($lines as $ln) { $ln = trim((string)$ln); if ($ln === '') continue; // Format: Label | https://... $label = $ln; $url = ''; if (strpos($ln, '|') !== false) { [$label, $url] = array_map('trim', explode('|', $ln, 2)); } if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) continue; if ($label === '') $label = 'Link'; $links[] = ['label' => $label, 'url' => $url]; } return $links; } if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!csrf_check((string)($_POST['csrf'] ?? ''))) { $errors[] = 'CSRF failed. Refresh and try again.'; } $slugRaw = trim((string)($_POST['slug'] ?? '')); $title = trim((string)($_POST['title'] ?? '')); $tag = trim((string)($_POST['tag'] ?? '')); $year = (int)($_POST['year'] ?? 0); $summary = trim((string)($_POST['summary'] ?? '')); $shortSummary = trim((string)($_POST['short_summary'] ?? '')); $techRaw = (string)($_POST['tech'] ?? ''); $linksRaw= (string)($_POST['links'] ?? ''); $sort = (int)($_POST['sort_order'] ?? 0); $slug = project_slugify($slugRaw !== '' ? $slugRaw : $title); if ($slug === '' && $title !== '') $slug = project_slugify($title); if ($slug === '' || !preg_match('/^[a-z0-9-]{2,120}$/', $slug)) $errors[] = 'Slug must be letters, numbers, or hyphen (auto-derived from title).'; if ($title === '' || mb_strlen($title) > 140) $errors[] = 'Title must be 1-140 chars.'; if ($tag === '' || mb_strlen($tag) > 50) $errors[] = 'Tag must be 1-50 chars.'; if ($year < 2000 || $year > 2100) $errors[] = 'Year must be 2000-2100.'; if ($summary === '' || mb_strlen($summary) > 5000) $errors[] = 'Summary is required (max 5000 chars).'; if ($shortSummary !== '' && mb_strlen($shortSummary) > 240) $errors[] = 'Card summary must be 240 chars or less.'; $tech = parse_list($techRaw); $links = parse_links($linksRaw); $techJson = json_encode($tech, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $linksJson = json_encode($links, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if ($slug !== '') { $st = pdo()->prepare("SELECT id FROM projects WHERE slug = ? AND id <> ? LIMIT 1"); $st->execute([$slug, $id]); if ($st->fetch()) $errors[] = 'Slug already in use.'; } if (!$errors) { if ($id) { $st = pdo()->prepare("UPDATE projects SET slug=?, title=?, tag=?, year=?, summary=?, short_summary=?, tech_json=?, links_json=?, sort_order=? WHERE id=?"); $st->execute([$slug, $title, $tag, $year, $summary, $shortSummary ?: null, $techJson, $linksJson, $sort, $id]); flash_set('success', 'Project updated.'); } else { $st = pdo()->prepare("INSERT INTO projects (slug, title, tag, year, summary, short_summary, tech_json, links_json, sort_order) VALUES (?,?,?,?,?,?,?,?,?)"); $st->execute([$slug, $title, $tag, $year, $summary, $shortSummary ?: null, $techJson, $linksJson, $sort]); flash_set('success', 'Project created.'); } header('Location: /public/admin/projects.php'); exit; } // repopulate if validation fails $project['slug'] = $slug; $project['title'] = $title; $project['tag'] = $tag; $project['year'] = $year; $project['summary'] = $summary; $project['short_summary'] = $shortSummary; $project['tech_json'] = $techJson; $project['links_json'] = $linksJson; $project['sort_order'] = $sort; } // Prefill textareas $techArr = json_decode((string)($project['tech_json'] ?? '[]'), true); $techTxt = is_array($techArr) ? implode("\n", $techArr) : ''; $linksArr = json_decode((string)($project['links_json'] ?? '[]'), true); $linksTxt = ''; if (is_array($linksArr)) { foreach ($linksArr as $l) { $lbl = (string)($l['label'] ?? ''); $url = (string)($l['url'] ?? ''); if ($url !== '') $linksTxt .= "{$lbl} | {$url}\n"; } } $linksTxt = trim($linksTxt); $mediaEndpoint = url_path('/public/admin/project_media.php'); $projectFolder = $id ? project_media_folder($project) : ''; include __DIR__ . '/_top.php'; ?>

Back
Letters, numbers, and hyphen. Lowercase slug is auto-built from the title.
Save the project first to unlock the media gallery.
Files live in
Project imgs/
JPEG, PNG or WebP up to 8MB; re-encoded via GD.
Drag & drop screenshots
or click to pick files
Paste an image URL and we'll fetch and sanitize it.