276 lines
12 KiB
PHP
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>
|