Files
aj-portfolio/public/admin/projects.php
2025-12-23 13:18:58 +02:00

276 lines
12 KiB
PHP

<?php
declare(strict_types=1);
require __DIR__ . '/../../includes/bootstrap.php';
require_admin_login();
// auto schema patch
try {
$cols = pdo()->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";
// Ensure summary can hold long text (avoid Data too long errors)
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) {
// ignore (no permission, etc.)
}
if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(32));
function csrf_ok($t): bool { return is_string($t) && hash_equals($_SESSION['csrf'], $t); }
if (!function_exists('flash_set')) {
function flash_set(string $type, string $msg): void { $_SESSION['_flash'] = ['type'=>$type,'msg'=>$msg]; }
}
if (!function_exists('flash_get')) {
function flash_get(): ?array { $f=$_SESSION['_flash']??null; unset($_SESSION['_flash']); return is_array($f)?$f:null; }
}
$f = flash_get();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!csrf_ok((string)($_POST['csrf'] ?? ''))) {
flash_set('danger', 'Bad CSRF token.');
header('Location: /public/admin/projects.php'); exit;
}
$action = (string)($_POST['action'] ?? '');
if ($action === 'create') {
$title = trim((string)($_POST['title'] ?? ''));
$tag = trim((string)($_POST['tag'] ?? ''));
$year = trim((string)($_POST['year'] ?? ''));
$summary = trim((string)($_POST['summary'] ?? ''));
$shortSummary = trim((string)($_POST['short_summary'] ?? ''));
$slug = trim((string)($_POST['slug'] ?? ''));
$sort = (int)($_POST['sort_order'] ?? 0);
$techCsv = trim((string)($_POST['tech_csv'] ?? ''));
$tech = array_values(array_filter(array_map('trim', explode(',', $techCsv))));
$linksLines = preg_split("/\r\n|\n|\r/", (string)($_POST['links_lines'] ?? '')) ?: [];
$links = [];
foreach ($linksLines as $line) {
$line = trim($line);
if ($line === '') continue;
// format: Label | https://url
$parts = array_map('trim', explode('|', $line, 2));
$label = $parts[0] ?? '';
$url = $parts[1] ?? '';
if ($label !== '' && $url !== '') $links[] = ['label'=>$label,'url'=>$url];
}
$slug = project_slugify($slug !== '' ? $slug : $title);
if ($slug === '' && $title !== '') $slug = project_slugify($title);
if ($title === '') {
flash_set('danger', 'Title is required.');
} elseif ($slug === '' || !preg_match('/^[a-z0-9-]{2,120}$/', $slug)) {
flash_set('danger', 'Slug must be letters, numbers, or hyphen (auto-derived from title).');
} elseif ($summary === '' || mb_strlen($summary) > 5000) {
flash_set('danger', 'Summary must be 1-5000 characters.');
} elseif ($shortSummary !== '' && mb_strlen($shortSummary) > 240) {
flash_set('danger', 'Card summary must be 240 characters or less.');
} else {
$techJson = json_encode($tech, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
$linksJson = json_encode($links, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
$st = pdo()->prepare("SELECT id FROM projects WHERE slug = ? LIMIT 1");
$st->execute([$slug]);
if ($st->fetch()) {
flash_set('danger', 'Slug already in use.');
} else {
pdo()->prepare("INSERT INTO projects (slug,title,tag,year,summary,short_summary,tech_json,links_json,sort_order)
VALUES (?,?,?,?,?,?,?,?,?)")
->execute([$slug,$title,$tag,$year,$summary,$shortSummary ?: null,$techJson,$linksJson,$sort]);
$newId = (int)pdo()->lastInsertId();
flash_set('success', 'Project added. Upload screenshots in the Gallery tab.');
$redirect = '/public/admin/project_edit.php';
if ($newId > 0) $redirect .= '?id=' . $newId . '&tab=gallery#tabGallery';
header('Location: ' . $redirect); exit;
}
}
header('Location: /public/admin/projects.php'); exit;
}
if ($action === 'delete') {
$id = (int)($_POST['id'] ?? 0);
if ($id > 0) {
pdo()->prepare("DELETE FROM projects WHERE id=?")->execute([$id]);
flash_set('success', 'Project deleted.');
}
header('Location: /public/admin/projects.php'); exit;
}
}
$projects = [];
try {
$projects = pdo()->query("SELECT id, slug, title, tag, year, summary, tech_json, links_json, sort_order
FROM projects ORDER BY sort_order ASC, id DESC")->fetchAll() ?: [];
} catch (Throwable $e) {}
$newParam = $_GET['new'] ?? null;
$openNew = $newParam !== null;
?>
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Projects • Admin</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/public/css/admin.css">
<link rel="stylesheet" href="/public/css/edit_project.css">
</head>
<body class="min-vh-100">
<div class="container py-4" style="max-width: 1100px">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="h3 m-0">Projects</h1>
</div>
<div class="d-flex gap-2">
<a class="btn btn-outline-light" href="/public/admin/dashboard.php">Dashboard</a>
<a class="btn btn-outline-light" href="/public/admin/logout.php">Logout</a>
</div>
</div>
<?php if ($f): ?>
<div class="alert alert-<?= htmlspecialchars($f['type']) ?>"><?= htmlspecialchars($f['msg']) ?></div>
<?php endif; ?>
<div class="card card-glass p-3 mb-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="text-white-50 small">Add New</div>
<div class="fw-bold">Create a new project card</div>
</div>
<span class="text-white-50 small">Save a project, then use its Gallery button.</span>
</div>
<div class="mt-3" id="newProject">
<form method="post" class="row g-3">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
<input type="hidden" name="action" value="create">
<div class="col-md-6">
<label class="form-label text-white-50">Title</label>
<input class="form-control" name="title" required maxlength="120">
</div>
<div class="col-md-3">
<label class="form-label text-white-50">Year</label>
<input class="form-control" name="year" maxlength="10" placeholder="2025">
</div>
<div class="col-md-3">
<label class="form-label text-white-50">Tag</label>
<input class="form-control" name="tag" maxlength="40" placeholder="Concept / Live / Tool">
</div>
<div class="col-md-6">
<label class="form-label text-white-50">Slug (optional)</label>
<input class="form-control" name="slug" maxlength="60" placeholder="my-project-name">
</div>
<div class="col-md-3">
<label class="form-label text-white-50">Sort order</label>
<input class="form-control" name="sort_order" type="number" value="0">
</div>
<div class="col-12">
<label class="form-label text-white-50">Summary</label>
<textarea class="form-control" name="summary" rows="2" maxlength="5000"></textarea>
</div>
<div class="col-12">
<label class="form-label text-white-50">Card summary (optional, short preview)</label>
<textarea class="form-control" name="short_summary" rows="2" maxlength="240"
placeholder="If blank, the preview will auto-clamp the full summary."></textarea>
</div>
<div class="col-12">
<label class="form-label text-white-50">Tech (comma separated)</label>
<input class="form-control" name="tech_csv" placeholder="PHP, MariaDB, PDO, AJAX, Tailwind">
</div>
<div class="col-12">
<label class="form-label text-white-50">Links (one per line: Label | URL)</label>
<textarea class="form-control" name="links_lines" rows="2" placeholder="GitHub | https://github.com/..."></textarea>
</div>
<div class="col-12">
<button class="btn btn-violet">Save Project</button>
</div>
</form>
</div>
</div>
<div class="card card-glass p-0 overflow-hidden">
<div class="p-3 border-bottom border-white/10 d-flex justify-content-between align-items-center">
<div class="fw-bold">Existing Projects</div>
<div class="text-white-50 small"><?= count($projects) ?> total</div>
</div>
<div class="table-responsive">
<table class="table table-dark table-hover mb-0 align-middle">
<thead>
<tr>
<th style="width:70px">ID</th>
<th>Title</th>
<th style="width:110px">Year</th>
<th style="width:160px">Tag</th>
<th style="width:120px">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($projects as $p): ?>
<tr>
<td class="text-white-50"><?= (int)$p['id'] ?></td>
<td>
<div class="fw-semibold"><?= htmlspecialchars((string)$p['title']) ?></div>
<div class="text-white-50 small"><?= htmlspecialchars((string)$p['summary']) ?></div>
</td>
<td class="text-white-50"><?= htmlspecialchars((string)$p['year']) ?></td>
<td><span class="badge text-bg-light"><?= htmlspecialchars((string)$p['tag']) ?></span></td>
<td>
<a class="btn btn-outline-light btn-sm" href="/public/admin/project_edit.php?id=<?= (int)$p['id'] ?>">Edit</a>
<a class="btn btn-outline-light btn-sm" href="/public/admin/project_edit.php?id=<?= (int)$p['id'] ?>&tab=gallery#tabGallery">Gallery</a>
<form method="post" class="d-inline">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?= (int)$p['id'] ?>">
<button class="btn btn-outline-danger btn-sm" data-confirm="Delete this project?">Del</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$projects): ?>
<tr><td colspan="5" class="text-center text-white-50 py-4">No projects yet.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
</body>
</html>