Initial commit

This commit is contained in:
root
2025-12-23 13:18:58 +02:00
commit 2ef7528ee9
36 changed files with 5983 additions and 0 deletions

6
public/admin/_bottom.php Normal file
View File

@@ -0,0 +1,6 @@
</div>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

45
public/admin/_top.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
if (!isset($pageTitle)) $pageTitle = 'Admin';
$csrf = csrf_token();
$flash = flash_get();
?>
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title><?= htmlspecialchars($pageTitle) ?></title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/css/admin.css">
<?php if (!empty($extraCss)): ?>
<?php foreach ((array)$extraCss as $css): ?>
<link rel="stylesheet" href="<?= htmlspecialchars((string)$css) ?>">
<?php endforeach; ?>
<?php endif; ?>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark">
<div class="container py-3">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#nav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="nav">
<ul class="navbar-nav ms-auto gap-1">
<li class="nav-item"><a class="nav-link" href="/public/admin/projects.php">Projects</a></li>
<li class="nav-item"><a class="nav-link" href="/public/admin/skills.php">Skills</a></li>
<li class="nav-item"><a class="nav-link" href="/public/admin/contacts.php">Contacts</a></li>
<li class="nav-item"><a class="nav-link" href="/">View site</a></li>
<li class="nav-item"><a class="nav-link" href="/public/admin/logout.php">Logout</a></li>
</ul>
</div>
</div>
</nav>
<div class="container pb-5">
<?php if ($flash): ?>
<div class="alert alert-<?= htmlspecialchars($flash['type']) ?> card-glass border-0 mt-3">
<?= htmlspecialchars($flash['msg']) ?>
</div>
<?php endif; ?>

278
public/admin/contacts.php Normal file
View File

@@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../includes/bootstrap.php';
require_admin_login();
$pageTitle = 'Contact Requests | Admin';
// ensure table exists (aligns with API)
$tableError = '';
try {
pdo()->exec("
CREATE TABLE IF NOT EXISTS contact_requests (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(80) NOT NULL,
email VARCHAR(120) NOT NULL,
message TEXT NOT NULL,
status ENUM('new','read','archived') NOT NULL DEFAULT 'new',
created_at DATETIME NOT NULL,
ip VARCHAR(45) NULL,
user_agent VARCHAR(255) NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
} catch (Throwable $e) {
$tableError = 'Could not ensure contact table exists.';
}
$statusFilter = (string)($_GET['status'] ?? 'all');
$validStatuses = ['all', 'new', 'read', 'archived'];
if (!in_array($statusFilter, $validStatuses, true)) $statusFilter = 'all';
$counts = ['total' => 0, 'new' => 0, 'read' => 0, 'archived' => 0];
try {
$counts['total'] = (int)pdo()->query("SELECT COUNT(*) FROM contact_requests")->fetchColumn();
$counts['new'] = (int)pdo()->query("SELECT COUNT(*) FROM contact_requests WHERE status='new'")->fetchColumn();
$counts['read'] = (int)pdo()->query("SELECT COUNT(*) FROM contact_requests WHERE status='read'")->fetchColumn();
$counts['archived'] = (int)pdo()->query("SELECT COUNT(*) FROM contact_requests WHERE status='archived'")->fetchColumn();
} catch (Throwable $e) {}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!csrf_check((string)($_POST['csrf'] ?? ''))) {
flash_set('danger', 'Bad CSRF token.');
header('Location: /public/admin/contacts.php?status=' . urlencode($statusFilter));
exit;
}
$action = (string)($_POST['action'] ?? '');
$id = (int)($_POST['id'] ?? 0);
if ($id > 0) {
if ($action === 'set_status') {
$newStatus = (string)($_POST['status'] ?? '');
if (in_array($newStatus, ['new', 'read', 'archived'], true)) {
$st = pdo()->prepare("UPDATE contact_requests SET status=? WHERE id=?");
$st->execute([$newStatus, $id]);
flash_set('success', 'Status updated.');
}
} elseif ($action === 'delete') {
$st = pdo()->prepare("DELETE FROM contact_requests WHERE id=?");
$st->execute([$id]);
flash_set('success', 'Contact deleted.');
}
}
header('Location: /public/admin/contacts.php?status=' . urlencode($statusFilter));
exit;
}
$requests = [];
if (!$tableError) {
try {
$sql = "SELECT id, name, email, message, status, created_at, ip FROM contact_requests";
$params = [];
if ($statusFilter !== 'all') {
$sql .= " WHERE status = ?";
$params[] = $statusFilter;
}
$sql .= " ORDER BY created_at DESC, id DESC LIMIT 200";
$st = pdo()->prepare($sql);
$st->execute($params);
$requests = $st->fetchAll() ?: [];
} catch (Throwable $e) {
$tableError = 'Could not load contact requests.';
}
}
$extraCss = ['/public/css/contacts.css'];
include __DIR__ . '/_top.php';
?>
<div class="bg-aurora"></div>
<div class="bg-grid"></div>
<div class="d-flex align-items-center justify-content-between mt-4 flex-wrap gap-2">
<div>
<div class="text-white-50 small">Inbox</div>
<h1 class="h3 m-0 brand">Contact Requests</h1>
<div class="text-white-50 small">Review and triage all incoming messages.</div>
</div>
<div class="d-flex gap-2">
<a class="btn btn-outline-light" href="/public/admin/dashboard.php">Dashboard</a>
</div>
</div>
<?php if ($tableError): ?>
<div class="alert alert-danger card-glass border-0 mt-3"><?= htmlspecialchars($tableError) ?></div>
<?php endif; ?>
<?php if ($flash): ?>
<div class="alert alert-<?= htmlspecialchars($flash['type']) ?> card-glass border-0 mt-3"><?= htmlspecialchars($flash['msg']) ?></div>
<?php endif; ?>
<div class="row g-3 mt-3">
<div class="col-12 col-lg-4">
<div class="card card-glass border-0 h-100">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div>
<div class="text-white-50 small">New</div>
<div class="h3 m-0 fw-bold text-white"><?= (int)$counts['new'] ?></div>
</div>
<span class="badge bg-light text-dark">Inbox</span>
</div>
<div class="mt-3 text-white-50 small">Unread messages waiting for review.</div>
<div class="mt-3 d-flex gap-2">
<a class="btn btn-violet w-100" href="/public/admin/contacts.php?status=new">View new</a>
<a class="btn btn-outline-light w-100" href="/public/admin/contacts.php">All</a>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-8">
<div class="card card-glass border-0">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div>
<div class="text-white-50 small">Totals</div>
<div class="fw-bold text-white"><?= (int)$counts['total'] ?> messages</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<span class="pill">Read: <?= (int)$counts['read'] ?></span>
<span class="pill">Archived: <?= (int)$counts['archived'] ?></span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card card-glass border-0 mt-3">
<div class="p-3 border-bottom border-white/10 d-flex align-items-center justify-content-between flex-wrap gap-2">
<div class="fw-bold">Latest messages</div>
<div class="d-flex align-items-center gap-2">
<?php foreach ($validStatuses as $s): ?>
<?php $isActive = ($statusFilter === $s); ?>
<a class="btn btn-sm <?= $isActive ? 'btn-violet' : 'btn-outline-light' ?>"
href="/public/admin/contacts.php?status=<?= urlencode($s) ?>">
<?= htmlspecialchars(ucfirst($s)) ?>
</a>
<?php endforeach; ?>
</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>From</th>
<th style="width:140px">Status</th>
<th>Message</th>
<th style="width:160px">Received</th>
<th style="width:190px">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($requests as $c): ?>
<?php
$snippet = mb_strimwidth((string)$c['message'], 0, 120, '…', 'UTF-8');
$status = (string)$c['status'];
$created = (string)$c['created_at'];
?>
<tr>
<td class="text-white-50">#<?= (int)$c['id'] ?></td>
<td>
<div class="fw-semibold"><?= htmlspecialchars((string)$c['name']) ?></div>
<div class="text-white-50 small">
<a class="text-white-50" href="mailto:<?= htmlspecialchars((string)$c['email']) ?>">
<?= htmlspecialchars((string)$c['email']) ?>
</a>
</div>
</td>
<td>
<span class="badge text-bg-light"><?= htmlspecialchars(ucfirst($status)) ?></span>
</td>
<td>
<div class="text-white"><?= htmlspecialchars($snippet) ?></div>
<button
class="btn btn-outline-light btn-sm mt-1 view-msg"
type="button"
data-name="<?= htmlspecialchars((string)$c['name'], ENT_QUOTES) ?>"
data-email="<?= htmlspecialchars((string)$c['email'], ENT_QUOTES) ?>"
data-message="<?= htmlspecialchars((string)$c['message'], ENT_QUOTES) ?>"
>View</button>
</td>
<td>
<div><?= htmlspecialchars($created) ?></div>
<?php if (!empty($c['ip'])): ?>
<div class="text-white-50 small"><?= htmlspecialchars((string)$c['ip']) ?></div>
<?php endif; ?>
</td>
<td>
<form method="post" class="d-inline">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="set_status">
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<input type="hidden" name="status" value="<?= $status === 'new' ? 'read' : 'new' ?>">
<button class="btn btn-outline-light btn-sm"><?= $status === 'new' ? 'Mark read' : 'Mark new' ?></button>
</form>
<form method="post" class="d-inline ms-1">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="set_status">
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<input type="hidden" name="status" value="archived">
<button class="btn btn-outline-secondary btn-sm">Archive</button>
</form>
<form method="post" class="d-inline ms-1" onsubmit="return confirm('Delete this message?');">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
<button class="btn btn-outline-danger btn-sm">Delete</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$requests): ?>
<tr><td colspan="6" class="text-center text-white-50 py-4">No contact requests yet.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<div class="modal fade" id="msgModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content text-bg-dark border border-white/10">
<div class="modal-header">
<h5 class="modal-title" id="msgTitle">Message</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="text-white/70 small" id="msgMeta"></div>
<pre class="mt-3 text-white" style="white-space: pre-wrap;" id="msgBody"></pre>
</div>
</div>
</div>
</div>
<script>
(function ($) {
$(function () {
$('.view-msg').on('click', function () {
const $btn = $(this);
const name = $btn.data('name') || 'Unknown';
const email = $btn.data('email') || '';
const msg = $btn.data('message') || '';
$('#msgTitle').text(name);
$('#msgMeta').text(email);
$('#msgBody').text(msg);
const modal = bootstrap.Modal.getOrCreateInstance($('#msgModal')[0]);
modal.show();
});
});
})(jQuery);
</script>
<?php include __DIR__ . '/_bottom.php'; ?>

258
public/admin/dashboard.php Normal file
View File

@@ -0,0 +1,258 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../includes/bootstrap.php';
require_admin_login();
if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(32));
$f = flash_get();
$projectsCount = 0;
$skillsCount = 0;
$contactCounts = ['total' => 0, 'new' => 0];
$recentContacts = [];
try { $projectsCount = (int)pdo()->query("SELECT COUNT(*) FROM projects")->fetchColumn(); } catch (Throwable $e) {}
try { $skillsCount = (int)pdo()->query("SELECT COUNT(*) FROM skills")->fetchColumn(); } catch (Throwable $e) {}
try {
pdo()->exec("
CREATE TABLE IF NOT EXISTS contact_requests (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(80) NOT NULL,
email VARCHAR(120) NOT NULL,
message TEXT NOT NULL,
status ENUM('new','read','archived') NOT NULL DEFAULT 'new',
created_at DATETIME NOT NULL,
ip VARCHAR(45) NULL,
user_agent VARCHAR(255) NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
$contactCounts['total'] = (int)pdo()->query("SELECT COUNT(*) FROM contact_requests")->fetchColumn();
$contactCounts['new'] = (int)pdo()->query("SELECT COUNT(*) FROM contact_requests WHERE status='new'")->fetchColumn();
$recentContacts = pdo()->query("SELECT id, name, email, message, status, created_at FROM contact_requests ORDER BY created_at DESC, id DESC LIMIT 5")->fetchAll() ?: [];
} catch (Throwable $e) {}
$hasRefresh = false;
$hasAccess = false;
try {
$row = pdo()->query("SELECT refresh_token, access_token, access_expires FROM spotify_tokens WHERE id=1")->fetch();
if ($row) {
$hasRefresh = !empty($row['refresh_token']);
$hasAccess = !empty($row['access_token']) && (int)($row['access_expires'] ?? 0) > (time() + 30);
}
} catch (Throwable $e) {}
?>
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Admin Dashboard</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/remixicon@4.6.0/fonts/remixicon.css" rel="stylesheet">
<link rel="stylesheet" href="/public/css/admin.css">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: { extend: { colors: { ink:"#07060a", neon:"#a78bfa" } } }
}
</script>
<style>
.eq span{height:6px}
.sp-playing .eq span{animation:eq .8s infinite ease-in-out alternate}
.sp-playing .eq span:nth-child(2){animation-delay:.15s}
.sp-playing .eq span:nth-child(3){animation-delay:.3s}
.sp-playing .eq span:nth-child(4){animation-delay:.45s}
@keyframes eq { from{height:4px} to{height:14px} }
.sp-playing #spImg{animation:spin 4s linear infinite}
@keyframes spin { from{transform:rotate(0)} to{transform:rotate(360deg)} }
</style>
</head>
<body class="min-vh-100 bg-ink text-white">
<div class="max-w-6xl mx-auto px-4 py-6">
<div class="flex items-center justify-between gap-3 mb-4">
<div>
<h1 class="text-2xl font-extrabold tracking-tight">Dashboard</h1>
</div>
<div class="flex items-center gap-2">
<a class="btn btn-outline-light rounded-xl" href="<?= htmlspecialchars(url_path('/')) ?>">Home</a>
<a class="btn btn-outline-light rounded-xl" href="<?= htmlspecialchars(url_path('/public/admin/logout.php')) ?>">Logout</a>
</div>
</div>
<?php if ($f): ?>
<div class="alert alert-<?= htmlspecialchars($f['type']) ?> mb-4"><?= htmlspecialchars($f['msg']) ?></div>
<?php endif; ?>
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<!-- Projects -->
<div class="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-4">
<div class="flex items-center justify-between">
<div class="w-11 h-11 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
<i class="ri-layout-4-line"></i>
</div>
<div class="text-right">
<div class="text-white/50 text-sm">Projects</div>
<div class="text-3xl font-extrabold tracking-tight"><?= $projectsCount ?></div>
</div>
</div>
<div class="mt-4 pt-4 border-t border-white/10 flex gap-2">
<a class="btn btn-primary w-100 rounded-xl" href="<?= htmlspecialchars(url_path('/public/admin/projects.php?new=1')) ?>">Add</a>
<a class="btn btn-outline-light w-100 rounded-xl" href="<?= htmlspecialchars(url_path('/public/admin/projects.php')) ?>">Manage</a>
</div>
</div>
<!-- Skills -->
<div class="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-4">
<div class="flex items-center justify-between">
<div class="w-11 h-11 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
<i class="ri-stack-fill"></i>
</div>
<div class="text-right">
<div class="text-white/50 text-sm">Tech Stack</div>
<div class="text-3xl font-extrabold tracking-tight"><?= $skillsCount ?></div>
</div>
</div>
<div class="text-white/50 text-sm mt-2">Edit languages and frameworks for your homepage.</div>
<div class="mt-4 pt-4 border-t border-white/10 flex gap-2">
<a class="btn btn-primary w-100 rounded-xl" href="<?= htmlspecialchars(url_path('/public/admin/skills.php?new=1')) ?>">Add</a>
<a class="btn btn-outline-light w-100 rounded-xl" href="<?= htmlspecialchars(url_path('/public/admin/skills.php')) ?>">Adjust</a>
</div>
</div>
<!-- Contacts -->
<div class="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-4">
<div class="flex items-center justify-between">
<div class="w-11 h-11 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
<i class="ri-mail-send-line"></i>
</div>
<div class="text-right">
<div class="text-white/50 text-sm">Contacts</div>
<div class="text-3xl font-extrabold tracking-tight"><?= (int)$contactCounts['total'] ?></div>
<div class="text-xs text-white/60"><?= (int)$contactCounts['new'] ?> new</div>
</div>
</div>
<div class="mt-4 pt-4 border-t border-white/10 flex gap-2">
<a class="btn btn-primary w-100 rounded-xl" href="<?= htmlspecialchars(url_path('/public/admin/contacts.php')) ?>">Inbox</a>
<a class="btn btn-outline-light w-100 rounded-xl" href="<?= htmlspecialchars(url_path('/public/admin/contacts.php?status=new')) ?>">New</a>
</div>
</div>
<!-- Spotify -->
<div id="spAdminCard" class="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-4">
<div class="flex items-center justify-between">
<div class="w-11 h-11 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
<i class="ri-spotify-line"></i>
</div>
<div class="text-right">
<div class="text-white/50 text-sm">Spotify</div>
<div class="font-semibold">
<?= $hasRefresh ? 'Refresh token set' : ($hasAccess ? 'Access token set' : 'Not configured') ?>
</div>
</div>
</div>
<div class="mt-4 pt-4 border-t border-white/10">
<div class="flex items-center justify-between">
<div class="text-white/50 text-sm" id="spMode">Offline</div>
<div class="eq flex items-end gap-[3px]" aria-hidden="true">
<span class="w-[3px] rounded bg-green-500 opacity-90"></span>
<span class="w-[3px] rounded bg-green-500 opacity-90"></span>
<span class="w-[3px] rounded bg-green-500 opacity-90"></span>
<span class="w-[3px] rounded bg-green-500 opacity-90"></span>
</div>
</div>
<a id="spA" href="#" target="_blank" class="mt-3 flex items-center gap-3 no-underline text-white">
<img id="spImg" class="w-14 h-14 rounded-2xl object-cover bg-white/5 border border-white/10" alt="Album art">
<div class="min-w-0">
<div id="spT" class="font-semibold truncate">-</div>
<div id="spR" class="text-white/60 text-sm truncate">-</div>
</div>
</a>
<div class="text-white/50 text-sm mt-2">
Shows <b>Now Playing</b> or <b>Last Played</b>. Temporary token expires; refresh token is best.
</div>
<div class="flex gap-2 mt-3">
<a class="btn btn-outline-light w-100 rounded-xl" href="<?= htmlspecialchars(url_path('/public/admin/spotify_token.php')) ?>">Set Token</a>
<button class="btn btn-primary w-100 rounded-xl" id="spRefreshBtn" type="button">Refresh</button>
</div>
</div>
</div>
</div>
<?php if ($recentContacts): ?>
<div class="mt-6 card-glass rounded-2xl p-4">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-2">
<div>
<div class="text-white-50 text-sm">Inbox preview</div>
<div class="font-bold">Latest contact requests</div>
</div>
<a class="btn btn-outline-light btn-sm rounded-xl" href="<?= htmlspecialchars(url_path('/public/admin/contacts.php')) ?>">View all</a>
</div>
<div class="list-group list-group-flush">
<?php foreach ($recentContacts as $c): ?>
<div class="list-group-item bg-transparent text-white px-0 d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold"><?= htmlspecialchars((string)$c['name']) ?></div>
<div class="text-white-50 small"><?= htmlspecialchars(mb_strimwidth((string)$c['message'], 0, 120, '...')) ?></div>
</div>
<div class="text-end">
<span class="badge text-bg-light"><?= htmlspecialchars(ucfirst((string)$c['status'])) ?></span>
<div class="text-white-50 small"><?= htmlspecialchars((string)$c['created_at']) ?></div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
(function($){
function render(res){
const $card = $("#spAdminCard");
$card.removeClass("sp-playing");
if(!res || !res.ok){
$("#spMode").text("Offline");
$("#spT").text("Not configured");
$("#spR").text("Set token to enable");
$("#spA").attr("href","#");
$("#spImg").removeAttr("src");
return;
}
const mode = res.mode || "offline";
const t = res.track || {};
$("#spMode").text(mode === "playing" ? "Now Playing" : (mode === "recent" ? "Last Played" : "Offline"));
$("#spT").text(t.title || "-");
$("#spR").text(t.artist || "-");
$("#spA").attr("href", t.url || "#");
if(t.art) $("#spImg").attr("src", t.art);
if(mode === "playing") $card.addClass("sp-playing");
}
function load(){
$.ajax({ url:"/api/spotify.php", method:"GET", dataType:"json", cache:false, timeout:8000 })
.done(render)
.fail(()=>render(null));
}
$(function(){
$("#spRefreshBtn").on("click", load);
load();
});
})(jQuery);
</script>
</body>
</html>

170
public/admin/login.php Normal file
View File

@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../includes/bootstrap.php';
// already logged in? go dashboard
if (admin_is_logged_in()) {
header('Location: ' . url_path('/public/admin/dashboard.php'));
exit;
}
$err = '';
$remember = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrf = (string)($_POST['csrf'] ?? '');
$user = trim((string)($_POST['username'] ?? ''));
$pass = (string)($_POST['password'] ?? '');
$remember = isset($_POST['remember']) && (string)$_POST['remember'] !== '';
if (!csrf_check($csrf)) {
$err = 'Invalid session. Refresh and try again.';
} elseif ($user === '' || strlen($user) > 50) {
$err = 'Invalid username.';
} elseif ($pass === '') {
$err = 'Password is required.';
} else {
// Prepared statement => SQLi safe
$st = pdo()->prepare("SELECT id, username, pass_hash FROM admin_users WHERE username = ? LIMIT 1");
$st->execute([$user]);
$a = $st->fetch(PDO::FETCH_ASSOC);
if (!$a || empty($a['pass_hash']) || !password_verify($pass, (string)$a['pass_hash'])) {
$err = 'Wrong username or password.';
} else {
$algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_DEFAULT;
$opts = ['memory_cost' => 1 << 17, 'time_cost' => 4, 'threads' => 2];
if (password_needs_rehash((string)$a['pass_hash'], $algo, $opts)) {
try {
$newHash = password_hash($pass, $algo, $opts);
$up = pdo()->prepare("UPDATE admin_users SET pass_hash=? WHERE id=?");
$up->execute([$newHash, (int)$a['id']]);
} catch (Throwable $e) {
// best-effort; continue login
}
}
admin_login($a, $remember);
header('Location: ' . url_path('/public/admin/dashboard.php'));
exit;
}
}
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Admin Login</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/remixicon@4.6.0/fonts/remixicon.css" rel="stylesheet">
<style>
.bg-aurora{position:fixed;inset:0;background:
radial-gradient(1000px 500px at 20% 20%, rgba(139,92,246,.30), transparent 60%),
radial-gradient(900px 600px at 80% 30%, rgba(217,70,239,.22), transparent 60%),
radial-gradient(900px 700px at 55% 90%, rgba(167,139,250,.18), transparent 60%);z-index:-2;}
.bg-grid{position:fixed;inset:0;background-image:
linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px);
background-size:48px 48px;opacity:.12;mask-image:radial-gradient(circle at 50% 20%, #000 0%, transparent 65%);z-index:-1;}
.glass{background:rgba(16,14,26,.62);border:1px solid rgba(255,255,255,.10);
backdrop-filter:blur(18px) saturate(150%);box-shadow:0 12px 50px rgba(0,0,0,.45);}
.btn-glow{box-shadow:0 0 0 1px rgba(167,139,250,.25), 0 18px 40px rgba(139,92,246,.25);}
</style>
</head>
<body class="min-h-screen bg-black text-white flex items-center justify-center px-4">
<div class="bg-aurora"></div>
<div class="bg-grid"></div>
<div class="w-full max-w-md">
<div class="mb-5 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="h-12 w-12 grid place-items-center rounded-2xl bg-gradient-to-br from-violet-500 to-fuchsia-500 font-black text-black">GM</div>
<div>
<div class="text-2xl font-extrabold bg-gradient-to-r from-violet-300 via-fuchsia-400 to-violet-400 bg-clip-text text-transparent">
Welcome back
</div>
<div class="text-sm text-white/60">Login to manage your portfolio</div>
</div>
</div>
<div class="hidden sm:flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-2 text-xs text-white/80">
<i class="ri-shield-keyhole-line text-fuchsia-300"></i> Secure
</div>
</div>
<div class="glass rounded-3xl p-6 relative overflow-hidden">
<div class="pointer-events-none absolute inset-0 rounded-3xl ring-1 ring-white/10"></div>
<?php if ($err): ?>
<div class="mb-4 rounded-2xl border border-red-400/20 bg-red-500/10 px-4 py-3 text-sm">
<div class="flex items-start gap-2">
<i class="ri-error-warning-line mt-0.5 text-red-300"></i>
<div><?= htmlspecialchars($err) ?></div>
</div>
</div>
<?php endif; ?>
<form method="post" autocomplete="off" class="space-y-4">
<input type="hidden" name="csrf" value="<?= htmlspecialchars(csrf_token()) ?>">
<div>
<label class="mb-2 block text-sm text-white/70">Username</label>
<div class="relative">
<input name="username" maxlength="50" required
value="<?= htmlspecialchars((string)($_POST['username'] ?? '')) ?>"
placeholder="admin"
class="w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 pr-11 text-white placeholder:text-white/35 outline-none
focus:border-violet-400/50 focus:ring-4 focus:ring-violet-500/20">
<i class="ri-user-3-line absolute right-4 top-1/2 -translate-y-1/2 text-white/45"></i>
</div>
</div>
<div>
<label class="mb-2 block text-sm text-white/70">Password</label>
<div class="relative">
<input id="pw" type="password" name="password" required placeholder="Your password"
class="w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 pr-20 text-white placeholder:text-white/35 outline-none
focus:border-violet-400/50 focus:ring-4 focus:ring-violet-500/20">
<button type="button" id="togglePw"
class="absolute right-3 top-1/2 -translate-y-1/2 rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs text-white/80
hover:bg-white/10 active:scale-95 transition">
<span id="eyeIcon"><i class="ri-eye-line"></i></span>
</button>
</div>
</div>
<div class="flex items-center justify-between text-sm text-white/80 pt-1">
<label class="inline-flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" name="remember" value="1" <?= $remember ? 'checked' : '' ?>
class="h-4 w-4 rounded border-white/20 bg-white/5 text-violet-400 focus:ring-violet-500/40">
<span>Remember me for 120 days</span>
</label>
<span class="text-white/50 text-xs">Secure cookie</span>
</div>
<button class="w-full rounded-2xl bg-gradient-to-br from-violet-500 to-fuchsia-500 py-3 font-extrabold text-black btn-glow
hover:brightness-110 active:scale-[0.99] transition">
<i class="ri-login-circle-line"></i> Login
</button>
</form>
</div>
</div>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script>
$(function(){
$('#togglePw').on('click', function(){
const $pw = $('#pw');
const isPwd = $pw.attr('type') === 'password';
$pw.attr('type', isPwd ? 'text' : 'password');
$('#eyeIcon').html(isPwd ? '<i class="ri-eye-off-line"></i>' : '<i class="ri-eye-line"></i>');
});
});
</script>
</body>
</html>

8
public/admin/logout.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../includes/bootstrap.php';
admin_logout();
header('Location: ' . url_path('/public/admin/login.php'));
exit;

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../includes/bootstrap.php';
require_admin_login();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /public/admin/projects.php');
exit;
}
if (!csrf_verify($_POST['csrf'] ?? null)) {
flash_set('danger', 'CSRF failed.');
header('Location: /public/admin/projects.php');
exit;
}
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) {
flash_set('danger', 'Invalid project id.');
header('Location: /public/admin/projects.php');
exit;
}
$st = db()->prepare("DELETE FROM projects WHERE id = ?");
$st->execute([$id]);
flash_set('success', 'Project deleted.');
header('Location: /public/admin/projects.php');
exit;

View File

@@ -0,0 +1,554 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../includes/bootstrap.php';
require_admin_login();
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$pageTitle = $id ? 'Edit Project | Admin' : 'New Project | Admin';
// bring optional columns up-to-date (idempotent)
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";
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';
?>
<div class="d-flex align-items-center justify-content-between mt-4 flex-wrap gap-2">
<h1 class="h3 m-0 brand"><?= $id ? 'Edit Project' : 'New Project' ?></h1>
<a class="btn btn-outline-light" href="/public/admin/projects.php">Back</a>
</div>
<?php if ($errors): ?>
<div class="alert alert-danger card-glass border-0 mt-3">
<ul class="mb-0">
<?php foreach ($errors as $e): ?><li><?= htmlspecialchars($e) ?></li><?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<div class="card card-glass border-0 mt-3 p-4">
<ul class="nav nav-pills gap-2" id="projectTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link <?= $activeTab === 'details' ? 'active' : '' ?>" id="tabDetailsBtn" data-bs-toggle="tab" data-bs-target="#tabDetails" type="button" role="tab" aria-controls="tabDetails" aria-selected="<?= $activeTab === 'details' ? 'true' : 'false' ?>">
Details
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link <?= $activeTab === 'gallery' ? 'active' : '' ?>" id="tabGalleryBtn" data-bs-toggle="tab" data-bs-target="#tabGallery" type="button" role="tab" aria-controls="tabGallery" aria-selected="<?= $activeTab === 'gallery' ? 'true' : 'false' ?>">
Gallery
</button>
</li>
</ul>
<div class="tab-content mt-3">
<div class="tab-pane fade <?= $activeTab === 'details' ? 'show active' : '' ?>" id="tabDetails" role="tabpanel" aria-labelledby="tabDetailsBtn">
<form method="post">
<input type="hidden" name="csrf" value="<?= htmlspecialchars(csrf_token()) ?>">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label text-white-50">Title</label>
<input class="form-control" name="title" maxlength="140" required
value="<?= htmlspecialchars((string)$project['title']) ?>">
</div>
<div class="col-md-3">
<label class="form-label text-white-50">Year</label>
<input class="form-control" name="year" type="number" min="2000" max="2100" required
value="<?= (int)$project['year'] ?>">
</div>
<div class="col-md-3">
<label class="form-label text-white-50">Tag</label>
<input class="form-control" name="tag" maxlength="50" required
value="<?= htmlspecialchars((string)$project['tag']) ?>">
</div>
<div class="col-md-6">
<label class="form-label text-white-50">Slug (auto)</label>
<input class="form-control" name="slug" maxlength="120"
value="<?= htmlspecialchars((string)($project['slug'] ?? '')) ?>"
placeholder="my-project-name">
<div class="form-text text-white-50">Letters, numbers, and hyphen. Lowercase slug is auto-built from the title.</div>
</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="<?= (int)($project['sort_order'] ?? 0) ?>">
</div>
<div class="col-12">
<label class="form-label text-white-50">Summary</label>
<textarea class="form-control" name="summary" rows="4" required><?= htmlspecialchars((string)$project['summary']) ?></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 clamp the full summary."><?= htmlspecialchars((string)($project['short_summary'] ?? '')) ?></textarea>
</div>
<div class="col-md-6">
<label class="form-label text-white-50">Tech (one per line OR comma separated)</label>
<textarea class="form-control" name="tech" rows="7" placeholder="PHP&#10;MariaDB&#10;jQuery"><?= htmlspecialchars($techTxt) ?></textarea>
</div>
<div class="col-md-6">
<label class="form-label text-white-50">Links (one per line): <code>Label | https://url</code></label>
<textarea class="form-control" name="links" rows="7" placeholder="Repo | https://github.com/..."><?= htmlspecialchars($linksTxt) ?></textarea>
</div>
</div>
<div class="mt-4 d-flex gap-2">
<button class="btn btn-violet"><?= $id ? 'Save Changes' : 'Create Project' ?></button>
<a class="btn btn-outline-light" href="/public/admin/projects.php">Cancel</a>
</div>
</form>
</div>
<div class="tab-pane fade <?= $activeTab === 'gallery' ? 'show active' : '' ?>" id="tabGallery" role="tabpanel" aria-labelledby="tabGalleryBtn">
<?php if (!$id): ?>
<div class="alert alert-info card-glass border-0 mt-3">
Save the project first to unlock the media gallery.
</div>
<?php else: ?>
<div id="mediaManager"
data-project-id="<?= (int)$id ?>"
data-csrf="<?= htmlspecialchars(csrf_token()) ?>"
data-endpoint="<?= htmlspecialchars($mediaEndpoint) ?>">
<div class="d-flex flex-wrap justify-content-between gap-2 mb-3">
<div>
<div class="text-white-50 small">Files live in</div>
<div class="fw-bold">Project imgs/<?= htmlspecialchars($projectFolder) ?></div>
</div>
<div class="text-white-50 small">JPEG, PNG or WebP up to 8MB; re-encoded via GD.</div>
</div>
<div class="row g-3 align-items-stretch">
<div class="col-lg-5">
<div id="mediaDrop" class="media-drop rounded-3" role="button" tabindex="0">
<div class="text-center">
<div class="fw-semibold">Drag &amp; drop screenshots</div>
<div class="text-white-50 small">or click to pick files</div>
</div>
<input type="file" id="mediaInput" accept="image/*" multiple hidden>
</div>
<div class="small text-white-50 mt-2">Paste an image URL and we'll fetch and sanitize it.</div>
<div class="input-group mt-2">
<input type="url" class="form-control" id="mediaUrlInput" placeholder="https://example.com/screenshot.jpg">
<button class="btn btn-outline-light" type="button" id="mediaUrlBtn">Add URL</button>
</div>
<div class="small mt-2" id="mediaStatus" aria-live="polite"></div>
</div>
<div class="col-lg-7">
<div class="media-grid" id="mediaGrid" aria-live="polite"></div>
</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>
<script>
(function ($) {
$(function () {
const $manager = $("#mediaManager");
if (!$manager.length) return;
const endpoint = $manager.data("endpoint");
const projectId = $manager.data("project-id");
const csrf = $manager.data("csrf");
const $drop = $("#mediaDrop");
const $input = $("#mediaInput");
const $grid = $("#mediaGrid");
const $status = $("#mediaStatus");
const $urlInput = $("#mediaUrlInput");
const $urlBtn = $("#mediaUrlBtn");
const setStatus = (msg, type) => {
if (!$status.length) return;
const cls = type === "error" ? "text-danger" : (type === "success" ? "text-success" : "text-white-50");
$status.attr("class", "small mt-2 " + cls).text(msg || "");
};
const bytesToSize = (bytes) => {
if (!bytes) return "0 KB";
const kb = bytes / 1024;
if (kb < 1024) return kb.toFixed(1) + " KB";
return (kb / 1024).toFixed(1) + " MB";
};
const render = (files) => {
if (!$grid.length) return;
$grid.empty();
if (!files || !files.length) {
$("<div/>", { class: "text-white-50 small" })
.text("No images yet. Drop files or paste a URL to begin.")
.appendTo($grid);
return;
}
files.forEach((f) => {
const sizeLabel = bytesToSize(f.size || 0);
const $card = $("<div/>", { class: "media-card" });
$("<img/>", { src: f.url, alt: f.name }).appendTo($card);
$("<div/>", { class: "media-meta" })
.html(
'<div class="fw-semibold small text-truncate">' + f.name + "</div>" +
'<div class="text-white-50 small">' +
(f.width || "?") + "x" + (f.height || "?") + " - " + sizeLabel + "</div>"
)
.appendTo($card);
$("<div/>", { class: "media-actions" })
.append(
$("<button/>", {
type: "button",
class: "btn btn-sm btn-outline-light",
text: "Delete"
}).on("click", function () {
if (confirm("Remove this image?")) deleteFile(f.name);
})
)
.appendTo($card);
$grid.append($card);
});
};
const fetchList = () => {
setStatus("Loading images...");
return $.ajax({
url: `${endpoint}?action=list&project_id=${encodeURIComponent(projectId)}&csrf=${encodeURIComponent(csrf)}`,
method: "GET",
dataType: "json",
cache: false
})
.done((data) => {
if (!data || data.ok !== true) {
setStatus((data && data.error) ? data.error : "Unable to load images", "error");
return;
}
render(data.files || []);
const files = data.files || [];
setStatus(files.length ? "" : "Drop files or paste a URL to upload.", "muted");
})
.fail((xhr) => {
const msg = (xhr && xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText || "Failed to load images";
setStatus(msg, "error");
});
};
const uploadFiles = (list) => {
const files = Array.from(list || []).filter((f) => (f.type || "").startsWith("image/"));
if (!files.length) {
setStatus("Only image files are accepted.", "error");
return;
}
setStatus("Uploading...", "muted");
const form = new FormData();
form.append("action", "upload");
form.append("project_id", projectId);
form.append("csrf", csrf);
files.slice(0, 10).forEach((f) => form.append("files[]", f));
$.ajax({
url: endpoint,
method: "POST",
data: form,
processData: false,
contentType: false,
cache: false,
dataType: "json"
})
.done((data) => {
if (!data || data.ok !== true) {
setStatus((data && data.error) ? data.error : "Upload failed", "error");
return;
}
setStatus(`Saved ${data.saved || 0} file(s).`, "success");
fetchList();
})
.fail((xhr) => {
const msg = (xhr && xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText || "Upload failed";
setStatus(msg, "error");
});
};
const importUrl = () => {
const url = ($urlInput.val() || "").trim();
if (!url) {
setStatus("Paste an image URL first.", "error");
return;
}
setStatus("Fetching URL...", "muted");
const form = new FormData();
form.append("action", "fetch_url");
form.append("project_id", projectId);
form.append("csrf", csrf);
form.append("image_url", url);
$.ajax({
url: endpoint,
method: "POST",
data: form,
processData: false,
contentType: false,
cache: false,
dataType: "json"
})
.done((data) => {
if (!data || data.ok !== true) {
setStatus((data && data.error) ? data.error : "Import failed", "error");
return;
}
setStatus("Image imported.", "success");
$urlInput.val("");
fetchList();
})
.fail((xhr) => {
const msg = (xhr && xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText || "Import failed";
setStatus(msg, "error");
});
};
const deleteFile = (name) => {
const form = new FormData();
form.append("action", "delete");
form.append("project_id", projectId);
form.append("csrf", csrf);
form.append("name", name);
$.ajax({
url: endpoint,
method: "POST",
data: form,
processData: false,
contentType: false,
cache: false,
dataType: "json"
})
.done((data) => {
if (!data || data.ok !== true) {
setStatus((data && data.error) ? data.error : "Delete failed", "error");
return;
}
setStatus("Image removed.", "success");
fetchList();
})
.fail((xhr) => {
const msg = (xhr && xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText || "Delete failed";
setStatus(msg, "error");
});
};
if ($drop.length && $input.length) {
$drop.on("dragenter dragover", (e) => {
e.preventDefault();
$drop.addClass("is-drag");
});
$drop.on("dragleave drop", (e) => {
e.preventDefault();
$drop.removeClass("is-drag");
});
$drop.on("drop", (e) => {
e.preventDefault();
$drop.removeClass("is-drag");
uploadFiles(e.originalEvent.dataTransfer.files);
});
$drop.on("click", () => $input.trigger("click"));
$drop.on("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
$input.trigger("click");
}
});
$input.on("change", function () {
uploadFiles(this.files);
this.value = "";
});
}
if ($urlBtn.length) $urlBtn.on("click", importUrl);
if ($urlInput.length) {
$urlInput.on("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
importUrl();
}
});
}
fetchList();
});
})(jQuery);
</script>
<link rel="stylesheet" href="/public/css/edit_project.css">
<?php include __DIR__ . '/_bottom.php'; ?>

View File

@@ -0,0 +1,332 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../includes/bootstrap.php';
require_admin_login();
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate');
const MEDIA_MAX_BYTES = 8 * 1024 * 1024; // 8MB
const MEDIA_MAX_SIDE = 4000; // max width/height
function respond(array $payload, int $status = 200): void
{
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_SLASHES);
exit;
}
function require_csrf(): string
{
$token = (string)($_POST['csrf'] ?? $_GET['csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
if (!csrf_check($token)) {
respond(['ok' => false, 'error' => 'Invalid CSRF token.'], 400);
}
return $token;
}
function ensure_gd(): void
{
if (!extension_loaded('gd')) {
respond(['ok' => false, 'error' => 'GD extension is required on the server.'], 500);
}
}
function load_project(int $id): array
{
$st = pdo()->prepare("SELECT id, slug, title FROM projects WHERE id = ? LIMIT 1");
$st->execute([$id]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if (!$row) {
respond(['ok' => false, 'error' => 'Project not found.'], 404);
}
return $row;
}
function ensure_media_dir(string $dir): void
{
if (!is_dir($dir)) {
if (!@mkdir($dir, 0755, true) && !is_dir($dir)) {
respond(['ok' => false, 'error' => 'Cannot create media directory.'], 500);
}
}
if (!is_writable($dir)) {
respond(['ok' => false, 'error' => 'Media directory is not writable.'], 500);
}
}
function list_media(string $dir, string $folder): array
{
$files = [];
if (!is_dir($dir)) return $files;
foreach (glob($dir . DIRECTORY_SEPARATOR . '*.{jpg,jpeg,png,webp}', GLOB_BRACE) ?: [] as $path) {
if (!is_file($path)) continue;
$name = basename($path);
$stat = @stat($path) ?: [];
$size = (int)($stat['size'] ?? 0);
$mtime = (int)($stat['mtime'] ?? time());
$dim = @getimagesize($path) ?: [];
$files[] = [
'name' => $name,
'url' => project_media_url($folder, $name),
'size' => $size,
'width' => (int)($dim[0] ?? 0),
'height' => (int)($dim[1] ?? 0),
'modified'=> $mtime,
];
}
usort($files, fn($a, $b) => ($b['modified'] ?? 0) <=> ($a['modified'] ?? 0));
return $files;
}
function normalize_orientation($img, array $info, string $binary)
{
if (!function_exists('exif_read_data')) return $img;
$mime = strtolower((string)($info['mime'] ?? ''));
if ($mime !== 'image/jpeg') return $img;
$orientation = 0;
try {
$exif = @exif_read_data('data://image/jpeg;base64,' . base64_encode($binary), 'IFD0');
if (!empty($exif['Orientation'])) $orientation = (int)$exif['Orientation'];
} catch (Throwable $e) {
return $img;
}
if ($orientation < 2 || $orientation > 8) return $img;
$canFlip = function_exists('imageflip');
switch ($orientation) {
case 2: if ($canFlip) imageflip($img, IMG_FLIP_HORIZONTAL); break;
case 3: $img = imagerotate($img, 180, 0); break;
case 4: if ($canFlip) imageflip($img, IMG_FLIP_VERTICAL); break;
case 5: $img = imagerotate($img, 90, 0); if ($canFlip) imageflip($img, IMG_FLIP_HORIZONTAL); break;
case 6: $img = imagerotate($img, -90, 0); break;
case 7: $img = imagerotate($img, -90, 0); if ($canFlip) imageflip($img, IMG_FLIP_HORIZONTAL); break;
case 8: $img = imagerotate($img, 90, 0); break;
}
return $img;
}
function save_image(string $binary, string $dir, string $folder): array
{
ensure_gd();
if (strlen($binary) > MEDIA_MAX_BYTES) {
respond(['ok' => false, 'error' => 'Image exceeds 8MB limit.'], 400);
}
$info = @getimagesizefromstring($binary);
if (!$info || empty($info['mime'])) {
respond(['ok' => false, 'error' => 'Unsupported or invalid image.'], 400);
}
$mime = strtolower((string)$info['mime']);
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
respond(['ok' => false, 'error' => 'Only JPEG, PNG or WebP images are allowed.'], 400);
}
$img = @imagecreatefromstring($binary);
if (!$img) {
respond(['ok' => false, 'error' => 'Unable to read the image data.'], 400);
}
$img = normalize_orientation($img, $info, $binary);
$width = imagesx($img);
$height = imagesy($img);
$maxSide = MEDIA_MAX_SIDE;
if ($width > $maxSide || $height > $maxSide) {
$scale = $maxSide / max($width, $height);
$newW = (int)max(1, floor($width * $scale));
$newH = (int)max(1, floor($height * $scale));
$dst = imagecreatetruecolor($newW, $newH);
imagealphablending($dst, true);
imagesavealpha($dst, true);
imagecopyresampled($dst, $img, 0, 0, 0, 0, $newW, $newH, $width, $height);
imagedestroy($img);
$img = $dst;
$width = $newW;
$height = $newH;
}
$ext = 'jpg';
if ($mime === 'image/png') $ext = 'png';
if ($mime === 'image/webp' && function_exists('imagewebp')) $ext = 'webp';
$filename = 'img-' . date('Ymd-His') . '-' . bin2hex(random_bytes(3)) . '.' . $ext;
$path = $dir . DIRECTORY_SEPARATOR . $filename;
switch ($ext) {
case 'png':
imagepng($img, $path, 6);
break;
case 'webp':
imagewebp($img, $path, 85);
break;
default:
imagejpeg($img, $path, 85);
break;
}
imagedestroy($img);
$stat = @stat($path) ?: [];
$dim2 = @getimagesize($path) ?: [];
return [
'name' => $filename,
'url' => project_media_url($folder, $filename),
'size' => (int)($stat['size'] ?? 0),
'width' => (int)($dim2[0] ?? $width),
'height' => (int)($dim2[1] ?? $height),
'modified' => (int)($stat['mtime'] ?? time()),
];
}
function read_uploaded_file(array $files, int $idx): string
{
$tmp = (string)($files['tmp_name'][$idx] ?? '');
$size = (int)($files['size'][$idx] ?? 0);
$err = (int)($files['error'][$idx] ?? UPLOAD_ERR_NO_FILE);
if ($err !== UPLOAD_ERR_OK) {
throw new RuntimeException('Upload failed for one file.');
}
if (!is_uploaded_file($tmp)) {
throw new RuntimeException('Suspicious upload rejected.');
}
if ($size > MEDIA_MAX_BYTES) {
throw new RuntimeException('File is larger than 8MB.');
}
$data = file_get_contents($tmp);
if ($data === false) {
throw new RuntimeException('Unable to read upload.');
}
if (strlen($data) > MEDIA_MAX_BYTES) {
throw new RuntimeException('File is larger than 8MB.');
}
return $data;
}
function fetch_remote_image(string $url): string
{
$url = trim($url);
if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) {
throw new RuntimeException('Please provide a valid image URL.');
}
if (!preg_match('#^https?://#i', $url)) {
throw new RuntimeException('Only http/https URLs are allowed.');
}
$data = '';
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_MAXREDIRS => 0,
CURLOPT_USERAGENT => 'PortfolioMediaFetcher/1.0',
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_BUFFERSIZE => 102400,
]);
$data = (string)curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch) || $code >= 400 || $code === 0) {
curl_close($ch);
throw new RuntimeException('Failed to download image (HTTP ' . $code . ').');
}
curl_close($ch);
} else {
$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 10,
'header' => "User-Agent: PortfolioMediaFetcher/1.0\r\n",
],
]);
$fh = @fopen($url, 'rb', false, $ctx);
if (!$fh) throw new RuntimeException('Could not open the URL.');
while (!feof($fh) && strlen($data) <= MEDIA_MAX_BYTES) {
$chunk = fread($fh, 8192);
if ($chunk === false) break;
$data .= $chunk;
if (strlen($data) > MEDIA_MAX_BYTES) {
fclose($fh);
throw new RuntimeException('Remote image is larger than 8MB.');
}
}
fclose($fh);
}
if ($data === '' || strlen($data) === 0) {
throw new RuntimeException('Image download returned empty data.');
}
if (strlen($data) > MEDIA_MAX_BYTES) {
throw new RuntimeException('Remote image is larger than 8MB.');
}
return $data;
}
// ----- Main routing -----
$csrfToken = require_csrf();
$projectId = (int)($_GET['project_id'] ?? $_POST['project_id'] ?? 0);
if ($projectId <= 0) respond(['ok' => false, 'error' => 'Missing project id.'], 400);
$project = load_project($projectId);
$folder = project_media_folder($project);
$dir = project_media_dir($project);
ensure_media_dir($dir);
$action = strtolower((string)($_REQUEST['action'] ?? ($_SERVER['REQUEST_METHOD'] === 'GET' ? 'list' : '')));
try {
if ($action === 'list') {
respond(['ok' => true, 'files' => list_media($dir, $folder)]);
}
if ($action === 'upload') {
$files = $_FILES['files'] ?? null;
if (!$files || !isset($files['tmp_name']) || !is_array($files['tmp_name'])) {
respond(['ok' => false, 'error' => 'No files received.'], 400);
}
$saved = [];
$total = count($files['tmp_name']);
for ($i = 0; $i < $total; $i++) {
$data = read_uploaded_file($files, $i);
$saved[] = save_image($data, $dir, $folder);
if (count($saved) >= 10) break; // prevent huge multi-upload bursts
}
respond(['ok' => true, 'saved' => count($saved), 'files' => $saved]);
}
if ($action === 'fetch_url') {
$url = (string)($_POST['image_url'] ?? '');
$data = fetch_remote_image($url);
$file = save_image($data, $dir, $folder);
respond(['ok' => true, 'saved' => 1, 'files' => [$file]]);
}
if ($action === 'delete') {
$name = (string)($_POST['name'] ?? '');
if ($name === '' || !preg_match('/^[a-zA-Z0-9._-]+$/', $name)) {
respond(['ok' => false, 'error' => 'Bad filename.'], 400);
}
$path = realpath($dir . DIRECTORY_SEPARATOR . $name);
$dirReal = realpath($dir);
if (!$path || !$dirReal || strpos($path, $dirReal) !== 0 || !is_file($path)) {
respond(['ok' => false, 'error' => 'File not found.'], 404);
}
@unlink($path);
respond(['ok' => true]);
}
respond(['ok' => false, 'error' => 'Unknown action.'], 400);
} catch (Throwable $e) {
respond(['ok' => false, 'error' => $e->getMessage()], 400);
}

275
public/admin/projects.php Normal file
View 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>

199
public/admin/signup.php Normal file
View File

@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../includes/bootstrap.php';
// already logged in? go dashboard
if (admin_is_logged_in()) {
header('Location: ' . url_path('/public/admin/dashboard.php'));
exit;
}
// allow registration only if there are no admin users yet (first-time setup)
$cnt = (int)pdo()->query("SELECT COUNT(*) FROM admin_users")->fetchColumn();
if ($cnt > 0) {
header('Location: ' . url_path('/public/admin/login.php'));
exit;
}
$err = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrf = (string)($_POST['csrf'] ?? '');
$user = trim((string)($_POST['username'] ?? ''));
$pass = (string)($_POST['password'] ?? '');
$pass2 = (string)($_POST['password2'] ?? '');
if (!csrf_check($csrf)) {
$err = 'Invalid session. Refresh and try again.';
} elseif (!preg_match('/^[a-zA-Z0-9_]{3,32}$/', $user)) {
$err = 'Username: 3-32 chars (letters, numbers, underscore).';
} elseif (strlen($pass) < 10) {
$err = 'Password must be at least 10 characters.';
} elseif ($pass !== $pass2) {
$err = 'Passwords do not match.';
} else {
// even though cnt==0, still check just in case (race condition)
$st = pdo()->prepare("SELECT id FROM admin_users WHERE username=? LIMIT 1");
$st->execute([$user]);
if ($st->fetch()) {
$err = 'Username already exists.';
} else {
$algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_DEFAULT;
$hash = password_hash($pass, $algo, [
'memory_cost' => 1 << 17, // 128MB
'time_cost' => 4,
'threads' => 2,
]);
$ins = pdo()->prepare("INSERT INTO admin_users (username, pass_hash) VALUES (?, ?)");
$ins->execute([$user, $hash]);
$id = (int)pdo()->lastInsertId();
admin_login(['id' => $id, 'username' => $user]);
header('Location: ' . url_path('/public/admin/dashboard.php'));
exit;
}
}
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Setup Admin</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="<?= htmlspecialchars(url_path('/public/css/app.css')) ?>">
<link href="https://cdn.jsdelivr.net/npm/remixicon@4.6.0/fonts/remixicon.css" rel="stylesheet">
</head>
<body class="min-vh-100 d-flex align-items-center text-white">
<div class="bg-aurora"></div>
<div class="bg-grid"></div>
<div class="container" style="max-width: 720px;">
<div class="card card-glass p-4 p-md-5">
<div class="d-flex align-items-center justify-content-between gap-3 flex-wrap mb-3">
<div class="d-flex align-items-center gap-3">
<div class="brand-badge">GM</div>
<div>
<div class="pill d-inline-flex align-items-center gap-2">
<span class="dot"></span> First-time setup
</div>
<h1 class="h3 mb-0 mt-2 hero-gradient">Create Admin Account</h1>
<div class="text-white-50 small">This page auto-disables after the first admin is created.</div>
</div>
</div>
<span class="tag"><i class="ri-lock-2-line"></i> Secure</span>
</div>
<?php if ($err): ?>
<div class="alert alert-danger border-0" style="background: rgba(239,68,68,.15); color:#fff;">
<i class="ri-error-warning-line"></i> <?= htmlspecialchars($err) ?>
</div>
<?php endif; ?>
<form method="post" autocomplete="off" class="mt-3">
<input type="hidden" name="csrf" value="<?= htmlspecialchars(csrf_token()) ?>">
<div class="row g-3">
<div class="col-12">
<label class="form-label text-white-50">Username</label>
<div class="position-relative">
<input class="field" name="username" maxlength="32" required
placeholder="georgi_admin"
value="<?= htmlspecialchars((string)($_POST['username'] ?? '')) ?>">
<span class="position-absolute top-50 translate-middle-y end-0 me-3 text-white-50">
<i class="ri-user-3-line"></i>
</span>
</div>
<div class="form-text text-white-50">Allowed: letters, numbers, underscore. 3-32 chars.</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label text-white-50">Password</label>
<div class="position-relative">
<input id="pw" class="field" type="password" name="password" required placeholder="Min 10 characters">
<button type="button" class="btn btn-sm position-absolute top-50 translate-middle-y end-0 me-2"
id="togglePw"
style="background: rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.10); color:#fff; border-radius: 12px;">
<i class="ri-eye-line"></i>
</button>
</div>
<div class="mt-2">
<div class="bar"><div class="bar-fill" id="pwBar"></div></div>
<div class="small text-white-50 mt-1" id="pwHint">Use 10+ chars, mix letters + numbers.</div>
</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label text-white-50">Confirm password</label>
<div class="position-relative">
<input id="pw2" class="field" type="password" name="password2" required placeholder="Repeat password">
<span class="position-absolute top-50 translate-middle-y end-0 me-3 text-white-50" id="matchIcon">
<i class="ri-shield-check-line"></i>
</span>
</div>
</div>
<div class="col-12 mt-2">
<button class="btn w-100 py-3"
style="background: linear-gradient(135deg, rgba(139,92,246,.95), rgba(217,70,239,.85)); border:0; color:#0b0712; font-weight:800; border-radius: 16px;">
<i class="ri-sparkling-2-line"></i> Create Admin
</button>
</div>
<div class="col-12 text-center">
<div class="text-white-50 small">
After setup, you will be redirected to the dashboard.
</div>
</div>
</div>
</form>
</div>
</div>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script>
$(function(){
// toggle password
$('#togglePw').on('click', function(){
const $pw = $('#pw');
const isPwd = $pw.attr('type') === 'password';
$pw.attr('type', isPwd ? 'text' : 'password');
$(this).find('i').attr('class', isPwd ? 'ri-eye-off-line' : 'ri-eye-line');
});
function scorePassword(p){
let s = 0;
if (!p) return 0;
if (p.length >= 10) s += 35;
if (p.length >= 14) s += 15;
if (/[a-z]/.test(p)) s += 15;
if (/[A-Z]/.test(p)) s += 10;
if (/[0-9]/.test(p)) s += 15;
if (/[^a-zA-Z0-9]/.test(p)) s += 10;
return Math.min(100, s);
}
function updatePwUI(){
const p = $('#pw').val() || '';
const s = scorePassword(p);
$('#pwBar').css('width', s + '%');
const p2 = $('#pw2').val() || '';
const match = p.length > 0 && p === p2;
$('#matchIcon i').attr('class', match ? 'ri-checkbox-circle-line' : 'ri-shield-check-line');
$('#matchIcon').toggleClass('text-success', match).toggleClass('text-white-50', !match);
}
$('#pw, #pw2').on('input', updatePwUI);
updatePwUI();
});
</script>
</body>
</html>

103
public/admin/skill_edit.php Normal file
View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../includes/bootstrap.php';
require_admin_login();
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$pageTitle = $id ? 'Edit Skill • Admin' : 'New Skill • Admin';
$skill = ['label' => '', 'level' => 0, 'icon' => 'ri-code-s-slash-line'];
if ($id) {
$st = db()->prepare("SELECT * FROM skills WHERE id = ? LIMIT 1");
$st->execute([$id]);
$row = $st->fetch();
if (!$row) { flash_set('danger', 'Skill not found.'); header('Location: /public/admin/skills.php'); exit; }
$skill = $row;
}
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!csrf_verify($_POST['csrf'] ?? null)) $errors[] = 'CSRF failed. Refresh and try again.';
$label = trim((string)($_POST['label'] ?? ''));
$level = (int)($_POST['level'] ?? 0);
$icon = trim((string)($_POST['icon'] ?? ''));
if ($label === '' || mb_strlen($label) > 100) $errors[] = 'Label must be 1100 chars.';
if ($level < 0 || $level > 100) $errors[] = 'Level must be 0100.';
if ($icon === '' || mb_strlen($icon) > 80) $errors[] = 'Icon must be 180 chars (e.g. ri-database-2-line).';
if (!$errors) {
if ($id) {
$st = db()->prepare("UPDATE skills SET label=?, level=?, icon=? WHERE id=?");
$st->execute([$label, $level, $icon, $id]);
flash_set('success', 'Skill updated.');
} else {
$st = db()->prepare("INSERT INTO skills(label, level, icon) VALUES (?,?,?)");
$st->execute([$label, $level, $icon]);
flash_set('success', 'Skill created.');
}
header('Location: /public/admin/skills.php');
exit;
}
$skill['label'] = $label;
$skill['level'] = $level;
$skill['icon'] = $icon;
}
include __DIR__ . '/_top.php';
?>
<div class="d-flex align-items-center justify-content-between mt-4 flex-wrap gap-2">
<h1 class="h3 m-0 brand"><?= $id ? 'Edit Skill' : 'New Skill' ?></h1>
<a class="btn btn-outline-light" href="/public/admin/skills.php">Back</a>
</div>
<?php if ($errors): ?>
<div class="alert alert-danger card-glass border-0 mt-3">
<ul class="mb-0">
<?php foreach ($errors as $e): ?><li><?= htmlspecialchars($e) ?></li><?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<div class="card card-glass border-0 mt-3 p-4">
<form method="post">
<input type="hidden" name="csrf" value="<?= htmlspecialchars(csrf_token()) ?>">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label text-white-50">Label</label>
<input class="form-control" name="label" maxlength="100" required
value="<?= htmlspecialchars((string)$skill['label']) ?>">
</div>
<div class="col-md-3">
<label class="form-label text-white-50">Level (0100)</label>
<input class="form-control" name="level" type="number" min="0" max="100" required
value="<?= (int)$skill['level'] ?>">
</div>
<div class="col-md-6">
<label class="form-label text-white-50">Remixicon class</label>
<input class="form-control" name="icon" maxlength="80" required
value="<?= htmlspecialchars((string)$skill['icon']) ?>">
<div class="form-text text-white-50">
Example: <code>ri-database-2-line</code> / <code>ri-code-s-slash-line</code>
</div>
</div>
</div>
<div class="mt-4 d-flex gap-2">
<button class="btn btn-violet"><?= $id ? 'Save Changes' : 'Create Skill' ?></button>
<a class="btn btn-outline-light" href="/public/admin/skills.php">Cancel</a>
</div>
</form>
</div>
<?php include __DIR__ . '/_bottom.php'; ?>

521
public/admin/skills.php Normal file
View File

@@ -0,0 +1,521 @@
<?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>

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../includes/bootstrap.php';
require_admin_login();
function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }
if (!empty($_GET['error'])) {
echo "<pre>Spotify error: " . h((string)$_GET['error']) . "</pre>";
exit;
}
$code = (string)($_GET['code'] ?? '');
$state = (string)($_GET['state'] ?? '');
if ($code === '' || $state === '') {
echo "<pre>Missing code/state.\n\nGET:\n"; print_r($_GET); echo "</pre>";
exit;
}
$expected = (string)($_SESSION['sp_state'] ?? '');
unset($_SESSION['sp_state']);
if ($expected === '' || !hash_equals($expected, $state)) {
echo "<pre>State mismatch.\nExpected: ".h($expected)."\nGot: ".h($state)."</pre>";
echo "<p>Use the SAME host everywhere (127.0.0.1 vs localhost) or cookies wont match.</p>";
exit;
}
if (SPOTIFY_CLIENT_ID === '' || SPOTIFY_CLIENT_SECRET === '' || SPOTIFY_REDIRECT_URI === '') {
http_response_code(500);
echo "<pre>Missing Spotify config.</pre>";
exit;
}
$ch = curl_init('https://accounts.spotify.com/api/token');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Basic ' . base64_encode(SPOTIFY_CLIENT_ID . ':' . SPOTIFY_CLIENT_SECRET),
'Content-Type: application/x-www-form-urlencoded',
],
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => SPOTIFY_REDIRECT_URI,
]),
CURLOPT_TIMEOUT => 15,
]);
$raw = curl_exec($ch);
$http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
$data = is_string($raw) ? json_decode($raw, true) : null;
if ($raw === false || $http < 200 || $http >= 300 || !is_array($data)) {
echo "<pre>Token exchange failed.\nHTTP: {$http}\nCurl: ".h($err ?: 'none')."\nRaw:\n".h((string)$raw)."\n</pre>";
exit;
}
$access = (string)($data['access_token'] ?? '');
$refresh = (string)($data['refresh_token'] ?? '');
$expires = time() + (int)($data['expires_in'] ?? 3600);
if ($access === '') {
echo "<pre>No access_token returned.\n"; print_r($data); echo "</pre>";
exit;
}
pdo()->exec("
CREATE TABLE IF NOT EXISTS spotify_tokens (
id INT PRIMARY KEY,
refresh_token TEXT NULL,
access_token TEXT NULL,
access_expires INT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
pdo()->exec("INSERT IGNORE INTO spotify_tokens (id) VALUES (1)");
if ($refresh !== '') {
pdo()->prepare("UPDATE spotify_tokens SET refresh_token=?, access_token=?, access_expires=? WHERE id=1")
->execute([$refresh, $access, $expires]);
flash_set('success', 'Spotify connected. Refresh token saved.');
} else {
pdo()->prepare("UPDATE spotify_tokens SET access_token=?, access_expires=? WHERE id=1")
->execute([$access, $expires]);
flash_set('warning', 'Connected, but no refresh token returned (already authorized before). Revoke access in Spotify account settings and try again.');
}
header('Location: ' . url_path('/public/admin/dashboard.php'));
exit;

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../includes/bootstrap.php';
require_admin_login();
if (SPOTIFY_CLIENT_ID === '' || SPOTIFY_REDIRECT_URI === '') {
http_response_code(500);
echo "Missing Spotify client_id or redirect_uri";
exit;
}
$state = bin2hex(random_bytes(16));
$_SESSION['sp_state'] = $state;
$scope = 'user-read-currently-playing user-read-recently-played';
$url = 'https://accounts.spotify.com/authorize?' . http_build_query([
'response_type' => 'code',
'client_id' => SPOTIFY_CLIENT_ID,
'scope' => $scope,
'redirect_uri' => SPOTIFY_REDIRECT_URI,
'state' => $state,
'show_dialog' => 'true',
]);
header('Location: ' . $url);
exit;

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../includes/bootstrap.php';
require_admin_login();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!csrf_check((string)($_POST['csrf'] ?? ''))) {
flash_set('danger', 'Bad CSRF token.');
header('Location: ' . url_path('/public/admin/dashboard.php'));
exit;
}
$token = trim((string)($_POST['access_token'] ?? ''));
$ttl = (int)($_POST['ttl'] ?? 3600);
if ($ttl < 300) $ttl = 300;
if ($token === '') {
flash_set('danger', 'Token is empty.');
header('Location: ' . url_path('/public/admin/spotify_token.php'));
exit;
}
$exp = time() + $ttl;
// ensure schema
try { pdo()->exec("ALTER TABLE spotify_tokens ADD COLUMN access_token TEXT NULL"); } catch (Throwable $e) {}
try { pdo()->exec("ALTER TABLE spotify_tokens ADD COLUMN access_expires INT NULL"); } catch (Throwable $e) {}
try { pdo()->exec("INSERT IGNORE INTO spotify_tokens (id) VALUES (1)"); } catch (Throwable $e) {}
pdo()->prepare("UPDATE spotify_tokens SET access_token=?, access_expires=? WHERE id=1")
->execute([$token, $exp]);
flash_set('success', 'Spotify access token saved (temporary).');
header('Location: ' . url_path('/public/admin/dashboard.php'));
exit;
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Spotify Token • Admin</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/remixicon@4.6.0/fonts/remixicon.css" rel="stylesheet">
<link rel="stylesheet" href="<?= htmlspecialchars(url_path('/css/admin.css')) ?>">
</head>
<body class="min-vh-100">
<div class="bg-aurora"></div>
<div class="bg-grid"></div>
<div class="topbar-glass py-3 mb-4">
<div class="container d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-3">
<div class="brand-badge">GM</div>
<div>
<div class="text-white-50 small">Portfolio Admin</div>
<div class="fw-bold hero-gradient">Spotify Token</div>
</div>
</div>
<div class="d-flex gap-2">
<a class="navlink" href="<?= htmlspecialchars(url_path('/public/admin/dashboard.php')) ?>"><i class="ri-dashboard-line"></i><span>Dashboard</span></a>
<a class="navlink" href="<?= htmlspecialchars(url_path('/public/admin/logout.php')) ?>"><i class="ri-logout-box-r-line"></i><span>Logout</span></a>
</div>
</div>
</div>
<div class="container pb-5" style="max-width: 900px;">
<div class="card-glass p-4 p-md-5 rounded-4">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-2">
<div class="pill d-inline-flex align-items-center gap-2">
<span class="dot"></span> Temporary method
</div>
<span class="tag"><i class="ri-alert-line"></i> Expires</span>
</div>
<h1 class="h3 m-0">Paste Spotify Access Token</h1>
<div class="text-white-50 mt-2">
Access tokens expire (usually ~1h). Best solution is storing a refresh token and letting <code>/api/spotify.php</code> refresh automatically.
</div>
<form method="post" class="row g-3 mt-3">
<input type="hidden" name="csrf" value="<?= htmlspecialchars(csrf_token()) ?>">
<div class="col-12">
<label class="form-label text-white-50">Access token</label>
<textarea class="form-control" name="access_token" rows="5" required></textarea>
</div>
<div class="col-md-4">
<label class="form-label text-white-50">TTL seconds</label>
<input class="form-control" name="ttl" type="number" value="3600" min="300">
</div>
<div class="col-12">
<button class="btn btn-violet px-4 py-2"><i class="ri-save-3-line"></i> Save Token</button>
<a class="btn btn-outline-light ms-2" href="<?= htmlspecialchars(url_path('/public/admin/dashboard.php')) ?>">Back</a>
</div>
</form>
</div>
</div>
</body>
</html>

194
public/css/admin.css Normal file
View File

@@ -0,0 +1,194 @@
:root{
--ink:#07060a;
--glass: rgba(16, 14, 26, .62);
--glass2: rgba(18, 15, 28, .62);
--b: rgba(255,255,255,.10);
--muted: rgba(255,255,255,.72);
--muted2: rgba(255,255,255,.50);
--violet: rgba(139,92,246,.95);
--fuchsia: rgba(217,70,239,.85);
}
html,body{height:100%}
body{background:var(--ink); color:#fff}
/* Helps native form controls (especially <select>) use dark UI */
html{ color-scheme: dark; }
/* Background */
.bg-aurora{
position: fixed; inset: 0;
background:
radial-gradient(1000px 500px at 20% 20%, rgba(139,92,246,.30), transparent 60%),
radial-gradient(900px 600px at 80% 30%, rgba(217,70,239,.22), transparent 60%),
radial-gradient(900px 700px at 55% 90%, rgba(167,139,250,.18), transparent 60%);
z-index:-2;
}
.bg-grid{
position: fixed; inset: 0;
background-image:
linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px);
background-size: 48px 48px;
opacity:.12;
mask-image: radial-gradient(circle at 50% 20%, #000 0%, transparent 65%);
z-index:-1;
}
/* Topbar */
.topbar-glass{
background: var(--glass2);
border: 1px solid var(--b);
backdrop-filter: blur(16px) saturate(150%);
box-shadow: 0 10px 40px rgba(0,0,0,.35);
}
.brand-badge{
width: 40px; height: 40px;
display:grid; place-items:center;
border-radius: 14px;
background: linear-gradient(135deg, var(--violet), var(--fuchsia));
font-weight: 900;
color:#0b0712;
}
.hero-gradient{
background: linear-gradient(90deg, #a78bfa, #d946ef, #8b5cf6);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* Links */
.navlink{
display:inline-flex; align-items:center; gap:.55rem;
padding:.55rem .85rem;
border-radius: 14px;
text-decoration:none;
color: rgba(255,255,255,.78);
transition: transform .12s ease, background .12s ease;
}
.navlink:hover{ background: rgba(255,255,255,.06); transform: translateY(-1px); }
.navlink i{ font-size: 1.1rem; }
/* Cards */
.card-glass{
background: var(--glass);
border: 1px solid var(--b);
backdrop-filter: blur(16px) saturate(150%);
box-shadow: 0 12px 50px rgba(0,0,0,.35);
}
/* Pills / tags */
.pill{
padding: .45rem .75rem;
border-radius: 999px;
background: rgba(255,255,255,.06);
border: 1px solid var(--b);
color: rgba(255,255,255,.80);
font-size: .9rem;
}
.dot{
width: 8px; height: 8px;
border-radius: 999px;
background: #34d399;
box-shadow: 0 0 18px rgba(52,211,153,.55);
}
.tag{
font-size: .75rem;
padding: .25rem .55rem;
border-radius: 999px;
background: rgba(139,92,246,.18);
border: 1px solid rgba(139,92,246,.25);
color: rgba(255,255,255,.85);
}
/* Bootstrap overrides to match glass */
.form-control, .form-select, textarea{
border-radius: 14px !important;
background: rgba(255,255,255,.06) !important;
border: 1px solid rgba(255,255,255,.10) !important;
color: #fff !important;
}
.form-control:focus, textarea:focus, .form-select:focus{
border-color: rgba(167,139,250,.55) !important;
box-shadow: 0 0 0 4px rgba(139,92,246,.18) !important;
}
.form-control::placeholder, textarea::placeholder{
color: rgba(255,255,255,.35) !important;
}
/* Make <select> arrow + padding consistent and readable */
.form-select{
padding-right: 2.6rem !important;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24'%3E%3Cpath fill='%23a78bfa' d='M7 10l5 5l5-5z'/%3E%3C/svg%3E") !important;
background-repeat: no-repeat !important;
background-position: right 1rem center !important;
background-size: 18px 18px !important;
}
/* Dropdown list colors (works where browser allows styling) */
.form-select option{
background-color: #0c0a14;
color: rgba(255,255,255,.92);
}
/* Selected option contrast (supported in some browsers) */
.form-select option:checked{
background: linear-gradient(135deg, rgba(139,92,246,.55), rgba(217,70,239,.45));
color: #fff;
}
.btn-violet{
border:0 !important;
color:#0b0712 !important;
font-weight:900 !important;
border-radius: 14px !important;
background: linear-gradient(135deg, var(--violet), var(--fuchsia)) !important;
box-shadow: 0 0 0 1px rgba(167,139,250,.25), 0 18px 40px rgba(139,92,246,.18);
}
.btn-violet:hover{ filter: brightness(1.08); transform: translateY(-1px); }
.btn-outline-light{
border-radius: 14px !important;
border: 1px solid rgba(255,255,255,.12) !important;
background: rgba(255,255,255,.06) !important;
}
.btn-outline-light:hover{ background: rgba(255,255,255,.10) !important; }
/* Optional: Bootstrap dropdown menus (if you use .dropdown anywhere) */
.dropdown-menu{
background: rgba(16,14,26,.88) !important;
border: 1px solid rgba(255,255,255,.10) !important;
backdrop-filter: blur(16px) saturate(150%);
box-shadow: 0 18px 70px rgba(0,0,0,.45);
border-radius: 14px;
padding: .4rem;
}
.dropdown-item{
color: rgba(255,255,255,.86) !important;
border-radius: 10px;
padding: .55rem .7rem;
}
.dropdown-item:hover,
.dropdown-item:focus{
background: rgba(167,139,250,.16) !important;
color: #fff !important;
}
.dropdown-item.active,
.dropdown-item:active{
background: linear-gradient(135deg, rgba(139,92,246,.95), rgba(217,70,239,.85)) !important;
color: #fff !important;
}
.table-dark{
--bs-table-bg: transparent;
--bs-table-striped-bg: rgba(255,255,255,.03);
--bs-table-hover-bg: rgba(255,255,255,.05);
--bs-table-border-color: rgba(255,255,255,.08);
}
.table thead th{
color: rgba(255,255,255,.70);
border-bottom-color: rgba(255,255,255,.10) !important;
}

306
public/css/app.css Normal file
View File

@@ -0,0 +1,306 @@
.topbar-glass{
background: rgba(18, 15, 28, .62);
border: 1px solid rgba(255,255,255,.10);
backdrop-filter: blur(16px) saturate(150%);
box-shadow: 0 10px 40px rgba(0,0,0,.35);
}
.brand-badge{
width: 36px; height: 36px;
display:grid; place-items:center;
border-radius: 12px;
background: linear-gradient(135deg, rgba(139,92,246,.95), rgba(217,70,239,.85));
font-weight: 800;
}
.navlink{
display:inline-flex; align-items:center; gap:.5rem;
padding:.55rem .85rem;
border-radius: 14px;
text-decoration:none;
color: rgba(255,255,255,.78);
transition: transform .12s ease, background .12s ease;
}
.navlink:hover{ background: rgba(255,255,255,.06); transform: translateY(-1px); }
.navlink span{ font-size: .95rem; }
.bg-aurora{
position: fixed; inset: 0;
background:
radial-gradient(1000px 500px at 20% 20%, rgba(139,92,246,.30), transparent 60%),
radial-gradient(900px 600px at 80% 30%, rgba(217,70,239,.22), transparent 60%),
radial-gradient(900px 700px at 55% 90%, rgba(167,139,250,.18), transparent 60%);
filter: blur(0px);
z-index:-2;
}
.bg-grid{
position: fixed; inset: 0;
background-image:
linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px);
background-size: 48px 48px;
opacity:.12;
mask-image: radial-gradient(circle at 50% 20%, #000 0%, transparent 65%);
z-index:-1;
}
.card-glass{
background: rgba(16, 14, 26, .62);
border: 1px solid rgba(255,255,255,.10);
backdrop-filter: blur(16px) saturate(150%);
box-shadow: 0 12px 50px rgba(0,0,0,.35);
}
.pill{
padding: .45rem .75rem;
border-radius: 999px;
background: rgba(255,255,255,.06);
border: 1px solid rgba(255,255,255,.10);
color: rgba(255,255,255,.80);
font-size: .9rem;
}
.dot{
width: 8px; height: 8px;
border-radius: 999px;
background: #34d399;
box-shadow: 0 0 18px rgba(52,211,153,.55);
}
.hero-gradient{
background: linear-gradient(90deg, #a78bfa, #d946ef, #8b5cf6);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.iconlink{
width: 42px; height: 42px;
display:grid; place-items:center;
border-radius: 14px;
background: rgba(255,255,255,.06);
border: 1px solid rgba(255,255,255,.10);
text-decoration:none;
transition: transform .12s ease;
}
.iconlink:hover{ transform: translateY(-2px); }
.tag{
font-size: .75rem;
padding: .25rem .55rem;
border-radius: 999px;
background: rgba(139,92,246,.18);
border: 1px solid rgba(139,92,246,.25);
color: rgba(255,255,255,.85);
}
.chip{
padding: .45rem .7rem;
border-radius: 999px;
border: 1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.06);
color: rgba(255,255,255,.75);
}
.chip.active{
background: rgba(139,92,246,.30);
border-color: rgba(139,92,246,.35);
color: #fff;
}
.project-grid{
align-items: flex-start;
}
.project-card{
transition: transform .12s ease, border-color .12s ease;
display: flex;
flex-direction: column;
align-self: start;
height: auto;
gap: 10px;
}
.project-card:hover{ transform: translateY(-2px); border-color: rgba(167,139,250,.35); }
.project-card .summary-clamp{
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 3.6em;
line-height: 1.2;
}
.project-card .project-footer{ margin-top: auto; }
.project-card .tech-row{
max-height: 64px;
min-height: 28px;
overflow: hidden;
flex-wrap: wrap;
}
.field{
width:100%;
padding: .9rem 1rem;
border-radius: 14px;
background: rgba(255,255,255,.06);
border: 1px solid rgba(255,255,255,.10);
outline: none;
color: #fff;
}
.field:focus{
border-color: rgba(167,139,250,.55);
box-shadow: 0 0 0 4px rgba(139,92,246,.18);
}
.bar{
height: 12px;
border-radius: 999px;
background: rgba(255,255,255,.08);
overflow:hidden;
}
.bar-fill{
height: 100%;
width: 0%;
border-radius: 999px;
background: linear-gradient(90deg, rgba(139,92,246,.95), rgba(217,70,239,.85));
transition: width 900ms cubic-bezier(.2,.9,.2,1);
}
.social{
display:flex; align-items:center; justify-content:center;
gap:.6rem;
padding: 1rem;
border-radius: 18px;
text-decoration:none;
background: rgba(255,255,255,.06);
border: 1px solid rgba(255,255,255,.10);
color:#fff;
transition: transform .12s ease;
}
.social:hover{ transform: translateY(-2px); }
.pulse-bars{ display:flex; gap:4px; align-items:flex-end; }
.pulse-bars span{
width:4px; height:10px; border-radius: 4px;
background: rgba(34,197,94,.9);
animation: beat 900ms infinite ease-in-out alternate;
}
.pulse-bars span:nth-child(2){ animation-delay: 120ms; height: 14px; }
.pulse-bars span:nth-child(3){ animation-delay: 240ms; height: 8px; }
.pulse-bars span:nth-child(4){ animation-delay: 360ms; height: 12px; }
@keyframes beat{ from{ transform: translateY(0); opacity:.6 } to{ transform: translateY(-4px); opacity:1 } }
#spotifyCard.is-playing #spArt { animation: spin 4s linear infinite; }
@keyframes spin { from {transform:rotate(0)} to {transform:rotate(360deg)} }
/* Project modal gallery */
.pm-gallery{
display:grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 10px;
}
.pm-gallery a{
position: relative;
display:block;
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(255,255,255,.10);
background: rgba(255,255,255,.04);
box-shadow: 0 8px 30px rgba(0,0,0,.30);
}
.pm-gallery img{
width: 100%;
height: 120px;
object-fit: cover;
display:block;
transition: transform .16s ease;
}
.pm-gallery a:hover img{ transform: scale(1.03); }
/* Animated PFP (neon ring) */
.pfp-wrap{
position: relative;
width: 96px;
height: 96px;
border-radius: 999px;
display: grid;
place-items: center;
overflow: visible;
transform: translateZ(0);
animation: pfp-float 5.5s ease-in-out infinite;
}
.pfp-img{
width: 88px;
height: 88px;
border-radius: 999px;
object-fit: cover;
border: 1px solid rgba(255,255,255,.12);
box-shadow:
0 18px 60px rgba(0,0,0,.55),
0 0 0 6px rgba(255,255,255,.03);
position: relative;
z-index: 2;
}
.pfp-ring{
position: absolute;
inset: 0;
border-radius: 999px;
background: conic-gradient(
from 180deg,
rgba(139,92,246,.0),
rgba(139,92,246,.85),
rgba(217,70,239,.85),
rgba(167,139,250,.85),
rgba(139,92,246,.0)
);
filter: drop-shadow(0 0 18px rgba(167,139,250,.22));
animation: pfp-spin 4.2s linear infinite;
z-index: 1;
}
.pfp-ring::after{
content:"";
position:absolute;
inset: 6px;
border-radius: 999px;
background: rgba(7,6,10,.92);
border: 1px solid rgba(255,255,255,.08);
}
.pfp-wrap::before{
content:"";
position:absolute;
inset:-14px;
border-radius: 999px;
background: radial-gradient(circle, rgba(167,139,250,.18), transparent 60%);
filter: blur(10px);
opacity: .75;
animation: pfp-pulse 2.6s ease-in-out infinite;
}
.pfp-wrap::after{
content:"";
position:absolute;
inset: -30%;
border-radius: 999px;
background: linear-gradient(115deg, transparent 40%, rgba(255,255,255,.16) 50%, transparent 60%);
transform: translateX(-70%) rotate(12deg);
animation: pfp-shine 3.8s ease-in-out infinite;
pointer-events: none;
z-index: 3;
mix-blend-mode: screen;
opacity: .55;
}
.pfp-wrap:hover{
animation-play-state: paused;
transform: translateY(-2px);
}
.pfp-wrap:hover .pfp-ring{
filter: drop-shadow(0 0 22px rgba(217,70,239,.26));
}
@keyframes pfp-spin { to { transform: rotate(360deg); } }
@keyframes pfp-float { 0%,100%{ transform: translateY(0); } 50%{ transform: translateY(-8px); } }
@keyframes pfp-pulse { 0%,100%{ opacity:.55; transform: scale(.98); } 50%{ opacity:.9; transform: scale(1.03); } }
@keyframes pfp-shine { 0%{ transform: translateX(-70%) rotate(12deg); } 55%{ transform: translateX(70%) rotate(12deg); } 100%{ transform: translateX(70%) rotate(12deg); } }

105
public/css/contacts.css Normal file
View File

@@ -0,0 +1,105 @@
:root{
--ink:#07060a;
--glass: rgba(16, 14, 26, .72);
--glass2: rgba(18, 16, 30, .82);
--b: rgba(255,255,255,.12);
--muted: rgba(255,255,255,.78);
--muted2: rgba(255,255,255,.55);
--violet: rgba(139,92,246,.95);
--fuchsia: rgba(217,70,239,.88);
--table-border: rgba(255,255,255,.08);
}
body{
background: var(--ink);
color: #fff;
}
.bg-aurora, .bg-grid{
position: fixed; inset: 0; pointer-events: none;
}
.bg-aurora{
background:
radial-gradient(1100px 520px at 20% 20%, rgba(139,92,246,.32), transparent 60%),
radial-gradient(900px 620px at 78% 24%, rgba(217,70,239,.22), transparent 60%),
radial-gradient(980px 640px at 60% 86%, rgba(167,139,250,.20), transparent 60%);
z-index:-2;
}
.bg-grid{
background-image:
linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px);
background-size: 48px 48px;
opacity:.12;
mask-image: radial-gradient(circle at 50% 20%, #000 0%, transparent 70%);
z-index:-1;
}
.card-glass{
background: linear-gradient(140deg, var(--glass), var(--glass2));
border: 1px solid var(--b);
backdrop-filter: blur(16px) saturate(150%);
box-shadow: 0 16px 60px rgba(0,0,0,.45);
border-radius: 18px;
}
.pill{
padding: .35rem .7rem;
border-radius: 999px;
background: rgba(255,255,255,.06);
border: 1px solid var(--b);
color: var(--muted);
font-size: .82rem;
}
.table-dark{
--bs-table-bg: transparent;
--bs-table-striped-bg: rgba(255,255,255,.03);
--bs-table-hover-bg: rgba(255,255,255,.07);
--bs-table-border-color: var(--table-border);
}
.table-dark th{
color: var(--muted);
border-bottom-color: var(--table-border) !important;
font-weight: 700;
}
.table-dark td{
color: #fff;
border-top-color: var(--table-border) !important;
}
.table-hover tbody tr:hover{
background: rgba(255,255,255,.03);
}
.btn-violet{
border:0 !important;
color:#0b0712 !important;
font-weight:900 !important;
border-radius: 14px !important;
background: linear-gradient(135deg, var(--violet), var(--fuchsia)) !important;
box-shadow: 0 0 0 1px rgba(167,139,250,.25), 0 18px 40px rgba(139,92,246,.22);
}
.btn-outline-light{
border-radius: 14px !important;
border: 1px solid rgba(255,255,255,.16) !important;
background: rgba(255,255,255,.07) !important;
color: #fff !important;
}
.btn-outline-light:hover{ background: rgba(255,255,255,.15) !important; }
.btn-outline-secondary{
border-radius: 14px !important;
}
.badge.text-bg-light{
color:#0b0712;
font-weight: 800;
}
.table-message{
color: #e6e6e6;
line-height: 1.35;
}
.alert{
border-radius: 14px;
}

381
public/css/edit_project.css Normal file
View File

@@ -0,0 +1,381 @@
/*
Admin Glass Theme (Purple Aurora)
- Keeps your gradient style
- Fixes dropdowns/selects (no white background, readable states)
*/
:root{
/* Base */
--ink: #07060a;
--panel: rgba(16, 14, 26, .62);
--panel-strong: rgba(16, 14, 26, .86);
/* Borders / text */
--border: rgba(255,255,255,.10);
--border-strong: rgba(255,255,255,.16);
--text: rgba(255,255,255,.90);
--muted: rgba(255,255,255,.65);
/* Accents */
--violet: rgba(139,92,246,.95);
--magenta: rgba(217,70,239,.85);
--neon: rgba(167,139,250,.90);
/* Effects */
--shadow: 0 12px 50px rgba(0,0,0,.35);
--shadow-strong: 0 18px 70px rgba(0,0,0,.45);
--ring: 0 0 0 4px rgba(139,92,246,.18);
/* Radii */
--r-sm: 10px;
--r-md: 14px;
--r-lg: 18px;
}
/*
Base
*/
html, body{ height: 100%; }
body{
background: var(--ink);
color: var(--text);
}
a{ color: rgba(167,139,250,.95); }
a:hover{ color: rgba(217,70,239,.95); }
::selection{
background: rgba(167,139,250,.22);
color: #fff;
}
.container{ max-width: 1100px; }
/*
Background helpers
*/
.bg-aurora{
position: fixed;
inset: 0;
background:
radial-gradient(1000px 500px at 20% 20%, rgba(139,92,246,.28), transparent 60%),
radial-gradient(900px 600px at 80% 30%, rgba(217,70,239,.20), transparent 60%),
radial-gradient(900px 700px at 55% 90%, rgba(167,139,250,.16), transparent 60%);
z-index: -2;
}
.bg-grid{
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px);
background-size: 48px 48px;
opacity: .12;
mask-image: radial-gradient(circle at 50% 20%, #000 0%, transparent 65%);
z-index: -1;
}
/*
Brand / Titles
*/
h1.brand, .brand{
font-weight: 800;
letter-spacing: -.02em;
}
.brand{
background: linear-gradient(90deg, #a78bfa, #d946ef, #8b5cf6);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/*
Glass cards
*/
.card-glass,
.card.card-glass{
background: var(--panel);
border: 1px solid var(--border) !important;
backdrop-filter: blur(16px) saturate(150%);
box-shadow: var(--shadow);
border-radius: var(--r-lg);
}
/*
Alerts
*/
.alert.card-glass{ color: var(--text); }
.alert-danger{
background: rgba(220, 38, 38, .12) !important;
border: 1px solid rgba(220, 38, 38, .25) !important;
}
.alert-success{
background: rgba(34,197,94,.12) !important;
border: 1px solid rgba(34,197,94,.22) !important;
}
.alert-warning{
background: rgba(234,179,8,.12) !important;
border: 1px solid rgba(234,179,8,.22) !important;
}
/* Optional: Bootstrap info alert used in your Gallery tab */
.alert-info{
background: rgba(59,130,246,.12) !important;
border: 1px solid rgba(59,130,246,.22) !important;
color: var(--text) !important;
}
/* nicer list spacing in errors */
.alert ul{ padding-left: 1.25rem; }
.alert li{ margin: .2rem 0; }
/*
Tabs (Bootstrap nav-pills)
*/
.nav-pills .nav-link{
color: rgba(255,255,255,.78);
background: rgba(255,255,255,.04);
border: 1px solid rgba(255,255,255,.08);
border-radius: var(--r-md);
padding: .55rem .85rem;
}
.nav-pills .nav-link:hover{
background: rgba(255,255,255,.06);
border-color: rgba(255,255,255,.12);
color: rgba(255,255,255,.92);
}
.nav-pills .nav-link.active{
color: #fff;
background: linear-gradient(135deg, rgba(139,92,246,.95), rgba(217,70,239,.85));
border-color: rgba(167,139,250,.30);
box-shadow: 0 12px 30px rgba(139,92,246,.18);
}
/*
Forms
*/
.form-label{ color: var(--muted) !important; font-size: .92rem; }
.form-text{ color: rgba(255,255,255,.55) !important; }
/* Inputs / Textareas */
.form-control,
textarea.form-control{
background: rgba(255,255,255,.06) !important;
border: 1px solid rgba(255,255,255,.10) !important;
color: #fff !important;
border-radius: var(--r-md) !important;
padding: .85rem 1rem !important;
outline: none !important;
box-shadow: none !important;
}
textarea.form-control{ line-height: 1.35; }
.form-control::placeholder{ color: rgba(255,255,255,.35); }
.form-control:focus,
textarea.form-control:focus{
border-color: rgba(167,139,250,.55) !important;
box-shadow: var(--ring) !important;
}
/* Disabled/readonly */
.form-control:disabled,
.form-control[readonly]{
opacity: .75;
background: rgba(255,255,255,.04) !important;
}
/*
SELECTS (Dropdown fields) — fixes “white background” issue
Works for both:
- Bootstrap 5: .form-select
- Older Bootstrap: select.form-control
*/
.form-select,
select.form-control{
background-color: rgba(255,255,255,.06) !important;
border: 1px solid rgba(255,255,255,.10) !important;
color: #fff !important;
border-radius: var(--r-md) !important;
padding: .85rem 2.65rem .85rem 1rem !important;
/* Make sure OS arrow doesn't force ugly styles */
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
/* Custom arrow */
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24'%3E%3Cpath fill='%23a78bfa' d='M7 10l5 5l5-5z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 1rem center;
background-size: 18px 18px;
}
.form-select:focus,
select.form-control:focus{
border-color: rgba(167,139,250,.55) !important;
box-shadow: var(--ring) !important;
}
/* Options: some browsers ignore this (native menu), but when supported it helps */
.form-select option,
select.form-control option{
background: #0c0a14;
color: rgba(255,255,255,.92);
}
/*
Bootstrap dropdown menus (the popup menus)
*/
.dropdown-menu{
background: var(--panel-strong) !important;
border: 1px solid var(--border) !important;
backdrop-filter: blur(18px) saturate(160%);
box-shadow: var(--shadow-strong);
border-radius: var(--r-md);
padding: .4rem;
}
.dropdown-divider{
border-top-color: rgba(255,255,255,.12) !important;
}
.dropdown-item{
color: rgba(255,255,255,.86) !important;
border-radius: var(--r-sm);
padding: .55rem .7rem;
}
.dropdown-item:hover,
.dropdown-item:focus{
background: rgba(167,139,250,.16) !important;
color: #fff !important;
}
.dropdown-item.active,
.dropdown-item:active{
background: linear-gradient(135deg, rgba(139,92,246,.95), rgba(217,70,239,.85)) !important;
color: #fff !important;
}
/*
Buttons
*/
.btn{
border-radius: var(--r-md) !important;
padding: .55rem .95rem !important;
font-weight: 600 !important;
transition: transform .12s ease, filter .12s ease, background .12s ease;
}
.btn:hover{ transform: translateY(-1px); }
.btn-violet{
color: #fff !important;
border: 1px solid rgba(167,139,250,.28) !important;
background: linear-gradient(135deg, rgba(139,92,246,.95), rgba(217,70,239,.85)) !important;
box-shadow: 0 12px 30px rgba(139,92,246,.18);
}
.btn-violet:hover{ filter: brightness(1.06); }
.btn-outline-light{
color: rgba(255,255,255,.85) !important;
border: 1px solid rgba(255,255,255,.18) !important;
background: rgba(255,255,255,.06) !important;
}
.btn-outline-light:hover{ background: rgba(255,255,255,.10) !important; }
/*
Code tag
*/
code{
color: rgba(167,139,250,.95);
background: rgba(167,139,250,.10);
border: 1px solid rgba(167,139,250,.18);
padding: .15rem .35rem;
border-radius: 10px;
}
/*
Media manager (drop zone / grid)
*/
.media-drop{
border: 1px dashed rgba(255,255,255,.25);
background: rgba(255,255,255,.04);
min-height: 170px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background .15s ease, border-color .15s ease, transform .12s ease;
}
.media-drop:hover{
background: rgba(255,255,255,.07);
transform: translateY(-1px);
}
.media-drop.is-drag{
border-color: rgba(167,139,250,.65);
box-shadow: 0 0 0 2px rgba(167,139,250,.25);
background: rgba(167,139,250,.08);
}
.media-grid{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.media-card{
border: 1px solid rgba(255,255,255,.08);
border-radius: var(--r-md);
background: rgba(255,255,255,.04);
overflow: hidden;
position: relative;
box-shadow: 0 8px 30px rgba(0,0,0,.25);
}
.media-card img{
width: 100%;
height: 140px;
object-fit: cover;
display: block;
}
.media-meta{ padding: .65rem .8rem; }
.media-actions{
position: absolute;
top: 10px;
right: 10px;
}
.media-actions .btn{
padding: .3rem .6rem !important;
border-radius: 10px !important;
backdrop-filter: blur(6px);
}
/*
Mobile polish
*/
@media (max-width: 576px){
.btn{ width: 100%; }
.mt-4.d-flex.gap-2{ flex-direction: column; }
}

9
public/js/admin.js Normal file
View File

@@ -0,0 +1,9 @@
(function($){
"use strict";
$(function(){
$(document).on("click","[data-confirm]",function(e){
const msg = $(this).data("confirm") || "Are you sure?";
if(!confirm(msg)) e.preventDefault();
});
});
})(jQuery);

383
public/js/app.js Normal file
View File

@@ -0,0 +1,383 @@
(function ($) {
"use strict";
const state = {
spTimer: null,
spEnabled: true,
activeId: null
};
function showToast(msg) {
const $toast = $("#appToast");
if (!$toast.length) return;
$("#toastMsg").text(msg);
const t = bootstrap.Toast.getOrCreateInstance($toast[0], { delay: 2600 });
t.show();
}
function smoothScrollTo(hash) {
const $target = $(hash);
if (!$target.length) return;
$("html, body").stop(true).animate(
{ scrollTop: $target.offset().top - 90 },
450
);
}
function openProjectModal(projectId) {
const p = (window.PROJECTS || []).find(x => x.id === projectId);
if (!p) return;
$("#pmTitle").text(p.title + " • " + p.year);
$("#pmSummary").text(p.summary);
const $tech = $("#pmTech").empty();
(p.tech || []).forEach(t => $("<span/>").addClass("tag").text(t).appendTo($tech));
const $links = $("#pmLinks").empty();
if (p.links && p.links.length) {
p.links.forEach(l => {
$("<a/>", {
class: "btn btn-light btn-sm",
href: l.url,
target: "_blank",
rel: "noreferrer"
}).text(l.label).appendTo($links);
});
} else {
$("<span/>", { class: "text-white/50 text-sm" }).text("No public links yet.").appendTo($links);
}
const $gallery = $("#pmGallery").empty();
const imgs = Array.isArray(p.images) ? p.images : [];
if (imgs.length) {
imgs.forEach((url, idx) => {
$("<a/>", {
class: "pm-thumb",
href: url,
target: "_blank",
rel: "noreferrer"
}).append(
$("<img/>", { src: url, alt: `${p.title} screenshot ${idx + 1}` })
).appendTo($gallery);
});
} else {
$("<div/>", { class: "text-white/50 small" }).text("No screenshots yet.").appendTo($gallery);
}
const $modal = $("#projectModal");
if ($modal.length) {
const modal = bootstrap.Modal.getOrCreateInstance($modal[0]);
modal.show();
}
}
function applyProjectFilter(tag) {
tag = (tag || "all").toString().toLowerCase();
$(".chip").removeClass("active");
$(`.chip[data-filter="${tag}"]`).addClass("active");
$(".project-card").each(function () {
const $c = $(this);
const t = ($c.data("project-tag") || "").toString().toLowerCase();
const show = (tag === "all") || (t === tag);
if (show) $c.stop(true).fadeIn(140);
else $c.stop(true).fadeOut(140);
});
}
function animateBarsInView() {
const winTop = $(window).scrollTop();
const winBottom = winTop + $(window).height();
$(".bar").each(function () {
const $bar = $(this);
if ($bar.data("done")) return;
const top = $bar.offset().top;
if (top < winBottom - 80) {
const level = parseInt($bar.data("level"), 10) || 0;
$bar.find(".bar-fill").css("width", Math.max(0, Math.min(level, 100)) + "%");
$bar.data("done", true);
}
});
}
// Scrollspy (active navlink)
function setActiveNav(id) {
if (!id || state.activeId === id) return;
state.activeId = id;
$(".navlink").removeClass("is-active");
$(`.navlink[href="#${id}"]`).addClass("is-active");
}
function initScrollSpy() {
const ids = ["about", "projects", "stack", "gaming", "contact"];
const $sections = ids
.map(id => $("#" + id))
.filter($el => $el.length);
if (!$sections.length) return;
const updateActive = () => {
const y = $(window).scrollTop() + 120;
let bestId = null;
$sections.forEach($el => {
const top = $el.offset().top;
const bottom = top + $el.outerHeight();
if (y >= top && y < bottom) bestId = $el.attr("id");
});
if (!bestId) {
$sections.forEach($el => {
if (y >= $el.offset().top - 40) bestId = $el.attr("id");
});
}
if (bestId) setActiveNav(bestId);
};
updateActive();
$(window).on("scroll resize", updateActive);
}
// Spotify
function renderSpotify(payload) {
const $card = $("#spotifyCard");
if (!payload || !payload.ok) {
$("#spStatus").text("Spotify not configured");
$(".pulse-bars").hide();
$card.removeClass("is-playing");
return;
}
const mode = payload.mode; // playing | recent | offline
const t = payload.track || {};
$("#spTitle").text(t.title || "—");
$("#spArtist").text(t.artist || "—");
$("#spLink").attr("href", t.url || "#");
if (t.art) $("#spArt").attr("src", t.art).show();
else $("#spArt").removeAttr("src").hide();
if (mode === "playing") {
$("#spStatus").text("Now Playing");
$(".pulse-bars").show();
$card.addClass("is-playing");
} else if (mode === "recent") {
$("#spStatus").text("Last Played");
$(".pulse-bars").hide();
$card.removeClass("is-playing");
} else {
$("#spStatus").text("Offline");
$(".pulse-bars").hide();
$card.removeClass("is-playing");
}
}
function fetchSpotify() {
if (!state.spEnabled) return;
$.ajax({
url: "/api/spotify.php",
method: "GET",
dataType: "json",
cache: false,
timeout: 8000
})
.done(renderSpotify)
.fail(function () {
$("#spStatus").text("Spotify unavailable");
$(".pulse-bars").hide();
});
}
// Spotify Widget
var SpotifyWidget = (function ($) {
var cfg = { endpoint: '/api/spotify.php' };
var $widget, $song, $artist, $status, $link, $visualizer;
var $artEl; // could be <img> or <div>
function init() {
$widget = $('#spotify-widget');
$song = $('#spotify-song');
$artist = $('#spotify-artist');
$status = $('#spotify-status-text');
$link = $('#spotify-link');
$visualizer = $('.eq-visualizer');
$artEl = $('#spotify-art');
// Replace the low-contrast logo with a badge (same idea as your JS)
var $logoImg = $('.spotify-logo-icon');
if ($logoImg.length) {
var $badge = $('<span/>', {
'class': 'spotify-badge',
'role': 'img',
'aria-label': 'Spotify',
'text': 'Spotify'
});
$logoImg.replaceWith($badge);
}
update();
setInterval(update, 15000);
}
function update() {
$.ajax({
url: cfg.endpoint,
method: 'GET',
dataType: 'json',
cache: false
})
.done(function (data) {
if (!data || data.ok !== true) {
hide();
return;
}
if (data.mode === 'offline') {
hide();
return;
}
render(data);
})
.fail(function (xhr) {
console.warn('Spotify widget API failed:', xhr.status, xhr.responseText);
hide();
});
}
function hide() {
$widget.addClass('hidden');
}
function render(data) {
var t = (data.track || {});
$widget.removeClass('hidden');
$song.text(t.title || 'Not Playing');
$artist.text(t.artist || 'Spotify');
$link.attr('href', t.url || '#');
// album art
if (t.art) {
if ($artEl.prop('tagName') === 'IMG') {
$artEl.attr('src', t.art);
$artEl.attr('alt', (t.title || 'Track') + ' album art');
} else {
$artEl.css({
backgroundImage: "url('" + t.art + "')",
backgroundSize: 'cover',
backgroundPosition: 'center'
});
$artEl.find('i[data-feather]').remove();
}
}
if (data.mode === 'playing') {
$status.text('Now Playing');
$visualizer.css('display', 'flex');
} else {
$status.text('Last Played');
$visualizer.css('display', 'none');
}
// marquee if long
if ((t.title || '').length > 25) {
$song.css('animation', 'marquee 10s linear infinite');
} else {
$song.css('animation', 'none');
}
}
return { init: init };
})(jQuery);
// Contact form (AJAX)
function bindContactForm() {
const contactEndpoint = (window.CONTACT_ENDPOINT || "/api/contact.php");
$("#contactForm").on("submit", function (e) {
e.preventDefault();
const $form = $(this);
const $btn = $form.find("button[type=submit]");
$btn.prop("disabled", true);
const payload = $form.serialize();
$.ajax({
url: contactEndpoint,
method: "POST",
dataType: "json",
data: payload,
timeout: 12000
})
.done(handleRes)
.fail(function () {
showToast("Network error while sending.");
})
.always(function () {
$btn.prop("disabled", false);
});
function handleRes(res) {
if (res && res.ok) {
showToast("Message sent.");
$form[0].reset();
} else {
showToast((res && res.error) ? res.error : "Could not send.");
}
}
});
}
// Init
$(function () {
// Smooth scroll
$(document).on("click", "a[data-scroll], a[href^='#']", function (e) {
const href = $(this).attr("href");
if (!href || href === "#") return;
if (href.startsWith("#")) {
e.preventDefault();
smoothScrollTo(href);
// update nav immediately on click
setActiveNav(href.replace("#", ""));
// close offcanvas if open
const $off = $("#mobileNav");
if ($off.length) bootstrap.Offcanvas.getOrCreateInstance($off[0]).hide();
}
});
// Project card click
$(".project-card").on("click", function () {
openProjectModal($(this).data("project-id"));
});
// Filter chips
$(".chip").on("click", function () {
applyProjectFilter($(this).data("filter"));
});
// Bars
animateBarsInView();
$(window).on("scroll", animateBarsInView);
// Scrollspy active nav animation
initScrollSpy();
// Contact
bindContactForm();
// Spotify polling
fetchSpotify();
state.spTimer = setInterval(fetchSpotify, 20000);
});
})(jQuery);