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

522 lines
22 KiB
PHP

<?php
declare(strict_types=1);
require __DIR__ . '/../../includes/bootstrap.php';
require_admin_login();
// ensure schema supports categories + parent relation
try {
$cols = pdo()->query("SHOW COLUMNS FROM skills")->fetchAll(PDO::FETCH_COLUMN, 0);
$alter = [];
if (!in_array('category', $cols, true)) $alter[] = "ADD COLUMN category VARCHAR(32) NOT NULL DEFAULT 'language'";
if (!in_array('parent_id', $cols, true)) $alter[] = "ADD COLUMN parent_id INT NULL DEFAULT NULL";
if ($alter) {
pdo()->exec("ALTER TABLE skills " . implode(", ", $alter));
}
} catch (Throwable $e) {}
// ensure pivot table for multi-language frameworks
try {
pdo()->exec("
CREATE TABLE IF NOT EXISTS skill_links (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
framework_id INT NOT NULL,
language_id INT NOT NULL,
UNIQUE KEY uniq_fw_lang (framework_id, language_id),
KEY idx_fw (framework_id),
KEY idx_lang (language_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
// seed from legacy parent_id if any exist
pdo()->exec("
INSERT IGNORE INTO skill_links (framework_id, language_id)
SELECT id AS framework_id, parent_id AS language_id
FROM skills
WHERE category='framework' AND parent_id IS NOT NULL AND parent_id > 0
");
} catch (Throwable $e) {}
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();
$allowedCategories = ['language','framework'];
function normalize_ids(array $ids): array {
$out = [];
foreach ($ids as $v) {
$v = (int)$v;
if ($v > 0) $out[$v] = $v;
}
return array_values($out);
}
function fetch_language_ids(): array {
try {
$ids = pdo()->query("SELECT id FROM skills WHERE category='language'")->fetchAll(PDO::FETCH_COLUMN, 0);
return array_map('intval', $ids ?: []);
} catch (Throwable $e) {
return [];
}
}
function sync_framework_languages(int $frameworkId, array $languageIds, array $validLanguageIds): void {
$validSet = array_flip($validLanguageIds);
$languageIds = array_values(array_filter(array_unique(array_map('intval', $languageIds)), function ($id) use ($validSet) {
return $id > 0 && isset($validSet[$id]);
}));
pdo()->prepare("DELETE FROM skill_links WHERE framework_id=?")->execute([$frameworkId]);
if (!$languageIds) return;
$ins = pdo()->prepare("INSERT IGNORE INTO skill_links (framework_id, language_id) VALUES (?, ?)");
foreach ($languageIds as $lid) {
$ins->execute([$frameworkId, $lid]);
}
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!csrf_ok((string)($_POST['csrf'] ?? ''))) {
flash_set('danger', 'Bad CSRF token.');
header('Location: /public/admin/skills.php'); exit;
}
// DELETE (handled first, so it never falls into update_many)
if (isset($_POST['delete_id'])) {
$id = (int)($_POST['delete_id'] ?? 0);
if ($id > 0) {
pdo()->prepare("UPDATE skills SET parent_id=NULL WHERE parent_id=?")->execute([$id]);
pdo()->prepare("DELETE FROM skill_links WHERE framework_id=? OR language_id=?")->execute([$id, $id]);
pdo()->prepare("DELETE FROM skills WHERE id=?")->execute([$id]);
flash_set('success', 'Skill deleted.');
}
header('Location: /public/admin/skills.php'); exit;
}
$action = (string)($_POST['action'] ?? '');
if ($action === 'create') {
$label = trim((string)($_POST['label'] ?? ''));
$icon = trim((string)($_POST['icon'] ?? 'ri-code-s-slash-line'));
$level = max(0, min(100, (int)($_POST['level'] ?? 0)));
$sort = (int)($_POST['sort_order'] ?? 0);
$category = strtolower(trim((string)($_POST['category'] ?? 'language')));
if (!in_array($category, $allowedCategories, true)) $category = 'language';
$parentIds = $category === 'framework' ? normalize_ids((array)($_POST['parent_id_new'] ?? [])) : [];
$validLanguageIds = $category === 'framework' ? fetch_language_ids() : [];
if ($parentIds && $validLanguageIds) {
$parentIds = array_values(array_intersect($parentIds, $validLanguageIds));
}
if ($label === '') {
flash_set('danger', 'Label is required.');
} else {
if ($category === 'framework') {
if (!$parentIds) {
flash_set('danger', 'Select one or more parent languages for this framework.');
header('Location: /public/admin/skills.php'); exit;
}
} else {
$parentIds = [];
}
$primaryParent = ($category === 'framework' && $parentIds) ? (int)$parentIds[0] : null;
pdo()->prepare("INSERT INTO skills (label, icon, level, sort_order, category, parent_id) VALUES (?,?,?,?,?,?)")
->execute([$label, $icon, $level, $sort, $category, $primaryParent]);
$newId = (int)pdo()->lastInsertId();
if ($category === 'framework' && $newId > 0) {
sync_framework_languages($newId, $parentIds, $validLanguageIds);
}
flash_set('success', 'Skill added.');
}
header('Location: /public/admin/skills.php'); exit;
}
if ($action === 'update_many') {
$ids = $_POST['id'] ?? [];
$categoriesMap = [];
foreach ($ids as $i => $idRaw) {
$cid = (int)$idRaw;
$catRaw = strtolower(trim((string)($_POST['category'][$i] ?? 'language')));
$categoriesMap[$cid] = in_array($catRaw, $allowedCategories, true) ? $catRaw : 'language';
}
// use DB-backed language ids to avoid losing links if a row is flipped to framework
$validLanguageIds = fetch_language_ids();
$parentSelections = (array)($_POST['parent_id'] ?? []);
foreach ($ids as $i => $idRaw) {
$id = (int)$idRaw;
if ($id <= 0) continue;
$label = trim((string)($_POST['label'][$i] ?? ''));
$icon = trim((string)($_POST['icon'][$i] ?? 'ri-code-s-slash-line'));
$level = max(0, min(100, (int)($_POST['level'][$i] ?? 0)));
$sort = (int)($_POST['sort_order'][$i] ?? 0);
$category = $categoriesMap[$id] ?? 'language';
$selectedParents = ($category === 'framework') ? normalize_ids((array)($parentSelections[$id] ?? [])) : [];
if ($selectedParents && $validLanguageIds) {
$selectedParents = array_values(array_intersect($selectedParents, $validLanguageIds));
}
$primaryParent = ($category === 'framework' && $selectedParents) ? (int)$selectedParents[0] : null;
pdo()->prepare("UPDATE skills SET label=?, icon=?, level=?, sort_order=?, category=?, parent_id=? WHERE id=?")
->execute([$label, $icon, $level, $sort, $category, $primaryParent, $id]);
if ($category === 'framework') {
sync_framework_languages($id, $selectedParents, $validLanguageIds);
// if this was previously a language, clear stale links where it was used as a parent
pdo()->prepare("DELETE FROM skill_links WHERE language_id=? AND framework_id!=?")->execute([$id, $id]);
} else {
// if it is (or is now) a language, only remove links where it was the framework
pdo()->prepare("DELETE FROM skill_links WHERE framework_id=?")->execute([$id]);
}
}
flash_set('success', 'Skills updated.');
header('Location: /public/admin/skills.php'); exit;
}
}
$skills = [];
try {
$skills = pdo()->query("SELECT id,label,icon,level,sort_order,category,parent_id FROM skills ORDER BY (category='language') DESC, sort_order ASC, id ASC")->fetchAll() ?: [];
} catch (Throwable $e) {}
$skillLinks = [];
try {
$skillLinks = pdo()->query("SELECT framework_id, language_id FROM skill_links")->fetchAll(PDO::FETCH_ASSOC) ?: [];
} catch (Throwable $e) {}
$frameworkParentMap = [];
foreach ($skillLinks as $link) {
$fw = (int)($link['framework_id'] ?? 0);
$lang = (int)($link['language_id'] ?? 0);
if ($fw > 0 && $lang > 0) {
if (!isset($frameworkParentMap[$fw])) $frameworkParentMap[$fw] = [];
$frameworkParentMap[$fw][$lang] = $lang;
}
}
$languageOptions = array_values(array_filter($skills, function ($s) {
return strtolower((string)($s['category'] ?? 'language')) === 'language';
}));
$languageLabelMap = [];
foreach ($languageOptions as $lang) {
$languageLabelMap[(int)$lang['id']] = (string)$lang['label'];
}
// fallback to legacy parent_id if no links saved yet
foreach ($skills as $s) {
$cat = strtolower((string)($s['category'] ?? 'language'));
if ($cat !== 'framework') continue;
$fwId = (int)($s['id'] ?? 0);
$pid = (int)($s['parent_id'] ?? 0);
if ($fwId > 0 && $pid > 0 && empty($frameworkParentMap[$fwId])) {
$frameworkParentMap[$fwId][$pid] = $pid;
}
}
$frameworkParentMap = array_map('array_values', $frameworkParentMap);
$openNew = isset($_GET['new']);
?>
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Skills • Admin</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/public/css/admin.css">
<style>
.lvl{width:120px}
body { font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif; }
.table-responsive { overflow: visible; }
.parent-wrap { position: relative; }
.new-skill-card { position: relative; z-index: 50; overflow: visible; }
.parent-picker-btn {
background: linear-gradient(120deg, rgba(255,255,255,.05), rgba(139,92,246,.08));
border: 1px solid rgba(255,255,255,.08);
color: #f8fafc;
transition: all .18s ease;
}
.parent-picker-btn:hover {
border-color: rgba(167,139,250,.6);
box-shadow: 0 12px 28px rgba(0,0,0,.2);
}
.parent-summary {
font-weight: 600;
letter-spacing: 0.01em;
}
.parent-menu {
max-height: 260px;
overflow-y: auto;
box-shadow: 0 18px 38px rgba(0,0,0,.3);
z-index: 5000;
position: absolute;
inset: auto auto auto 0;
min-width: 100%;
}
.parent-option {
padding: 8px 10px;
border-radius: 10px;
transition: background .15s ease, border-color .15s ease;
border: 1px solid transparent;
}
.parent-option:hover {
background: rgba(255,255,255,.05);
border-color: rgba(255,255,255,.1);
}
</style>
</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">Tech Stack (%)</h1>
<div class="text-white-50 small">Create languages and nest frameworks beneath them.</div>
</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 new-skill-card">
<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 bar on the homepage</div>
</div>
<button class="btn btn-violet" type="button" data-scroll-target="#newSkill">
Add Skill
</button>
</div>
<div class="mt-3" id="newSkill">
<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-5">
<label class="form-label text-white-50">Label</label>
<input class="form-control" name="label" required maxlength="120" placeholder="PHP / Laravel">
</div>
<div class="col-md-3">
<label class="form-label text-white-50">Icon (RemixIcon class)</label>
<input class="form-control" name="icon" maxlength="80" value="ri-code-s-slash-line">
</div>
<div class="col-md-2">
<label class="form-label text-white-50">%</label>
<input class="form-control" name="level" type="number" min="0" max="100" placeholder="100">
</div>
<div class="col-md-1">
<label class="form-label text-white-50">Sort</label>
<input class="form-control" name="sort_order" type="number" value="0">
</div>
<div class="col-md-4">
<label class="form-label text-white-50">Category</label>
<select class="form-select" name="category" data-parent-target="#parent-new-wrap">
<option value="language">Language</option>
<option value="framework">Framework</option>
</select>
</div>
<div class="col-md-8">
<div class="parent-wrap d-none" data-parent-wrap id="parent-new-wrap">
<label class="form-label text-white-50">Parent languages (for frameworks)</label>
<div class="dropdown w-100">
<button class="btn parent-picker-btn w-100 d-flex justify-content-between align-items-center" type="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" data-bs-display="static">
<span class="parent-summary" data-parent-summary>Select parent languages</span>
<i class="ri-arrow-down-s-line ms-2"></i>
</button>
<div class="dropdown-menu dropdown-menu-dark w-100 p-2 parent-menu">
<?php foreach ($languageOptions as $lang): ?>
<label class="parent-option d-flex align-items-center gap-2 text-white-75 small mb-1">
<input class="form-check-input" type="checkbox" name="parent_id_new[]" value="<?= (int)$lang['id'] ?>" data-label="<?= htmlspecialchars((string)$lang['label']) ?>">
<span><?= htmlspecialchars((string)$lang['label']) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<div class="form-text text-white-50 mt-1">Choose one or more languages to nest its frameworks.</div>
</div>
</div>
<div class="col-12">
<button class="btn btn-violet">Save Skill</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">Edit Skills</div>
<div class="text-white-50 small"><?= count($skills) ?> total</div>
</div>
<!-- ONE form only -->
<form method="post">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
<input type="hidden" name="action" value="update_many">
<div class="table-responsive">
<table class="table table-dark table-hover mb-0 align-middle">
<thead>
<tr>
<th style="width:70px">ID</th>
<th>Label</th>
<th style="width:200px">Icon</th>
<th style="width:140px">Category</th>
<th style="width:240px">Parents</th>
<th class="lvl">%</th>
<th style="width:90px">Sort</th>
<th style="width:110px">Delete</th>
</tr>
</thead>
<tbody>
<?php foreach ($skills as $s): ?>
<tr>
<td class="text-white-50">
<?= (int)$s['id'] ?>
<input type="hidden" name="id[]" value="<?= (int)$s['id'] ?>">
</td>
<td><input class="form-control" name="label[]" value="<?= htmlspecialchars((string)$s['label']) ?>" maxlength="120"></td>
<td><input class="form-control" name="icon[]" value="<?= htmlspecialchars((string)$s['icon']) ?>" maxlength="80"></td>
<td>
<?php $cat = strtolower((string)($s['category'] ?? 'language')); ?>
<select class="form-select" name="category[]" data-parent-target="#parent-<?= (int)$s['id'] ?>-wrap">
<option value="language" <?= $cat === 'language' ? 'selected' : '' ?>>Language</option>
<option value="framework" <?= $cat === 'framework' ? 'selected' : '' ?>>Framework</option>
</select>
</td>
<td>
<?php $selectedParents = $frameworkParentMap[(int)$s['id']] ?? []; ?>
<?php
$summaryText = 'Select parent languages';
$labels = [];
foreach ($selectedParents as $pid) {
if (isset($languageLabelMap[$pid])) $labels[] = $languageLabelMap[$pid];
}
if ($labels) $summaryText = implode(', ', $labels);
?>
<div class="parent-wrap <?= $cat === 'framework' ? '' : 'd-none' ?>" data-parent-wrap id="parent-<?= (int)$s['id'] ?>-wrap">
<div class="dropdown w-100">
<button class="btn parent-picker-btn w-100 d-flex justify-content-between align-items-center" type="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" data-bs-display="static">
<span class="parent-summary" data-parent-summary><?= htmlspecialchars($summaryText) ?></span>
<i class="ri-arrow-down-s-line ms-2"></i>
</button>
<div class="dropdown-menu dropdown-menu-dark w-100 p-2 parent-menu">
<?php foreach ($languageOptions as $lang): ?>
<?php if ((int)$lang['id'] === (int)$s['id']) continue; ?>
<label class="parent-option d-flex align-items-center gap-2 text-white-75 small mb-1">
<input
class="form-check-input"
type="checkbox"
name="parent_id[<?= (int)$s['id'] ?>][]"
value="<?= (int)$lang['id'] ?>"
data-label="<?= htmlspecialchars((string)$lang['label']) ?>"
<?= in_array((int)$lang['id'], $selectedParents, true) ? 'checked' : '' ?>
>
<span><?= htmlspecialchars((string)$lang['label']) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
</div>
</td>
<td><input class="form-control" name="level[]" type="number" min="0" max="100" value="<?= (int)$s['level'] ?>"></td>
<td><input class="form-control" name="sort_order[]" type="number" value="<?= (int)$s['sort_order'] ?>"></td>
<td>
<!-- NO inner form -->
<button
class="btn btn-outline-danger btn-sm"
type="submit"
name="delete_id"
value="<?= (int)$s['id'] ?>"
data-confirm="Delete this skill?"
>Del</button>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$skills): ?>
<tr><td colspan="8" class="text-center text-white-50 py-4">No skills yet.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<div class="p-3 border-top border-white/10">
<button class="btn btn-violet">Save All Changes</button>
</div>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
(function ($) {
$(function () {
$('[data-scroll-target]').on('click', function () {
const target = $(this).data('scroll-target');
if (!target) return;
const $dest = $(target);
if ($dest.length) {
$('html, body').animate({ scrollTop: $dest.offset().top - 20 }, 250);
}
});
const syncSummary = ($wrap) => {
const labels = $wrap
.find('input[type="checkbox"]:checked')
.map(function () { return $(this).data("label") || $(this).val(); })
.get();
$wrap.find('[data-parent-summary]').text(labels.length ? labels.join(', ') : 'Select parent languages');
};
const updateParentVisibility = ($select) => {
const targetSelector = $select.data('parent-target');
if (!targetSelector) return;
const $wrap = $(targetSelector);
if (!$wrap.length) return;
const show = $select.val() === 'framework';
$wrap.toggleClass('d-none', !show);
if (!show) {
$wrap.find('input[type="checkbox"]').prop('checked', false);
$wrap.find('select').each(function () {
$(this).find('option').prop('selected', false);
});
}
syncSummary($wrap);
};
$('[data-parent-target]').each(function () {
const $select = $(this);
updateParentVisibility($select);
$select.on('change', () => updateParentVisibility($select));
});
$('.parent-wrap').each(function () {
const $wrap = $(this);
$wrap.find('input[type="checkbox"]').on('change', () => syncSummary($wrap));
syncSummary($wrap);
});
});
})(jQuery);
</script>
</body>
</html>