Initial commit
This commit is contained in:
275
public/admin/projects.php
Normal file
275
public/admin/projects.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?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>
|
||||
Reference in New Issue
Block a user