commit 2ef7528ee9df408d88fa004a2751c3a867b468e6 Author: root Date: Tue Dec 23 13:18:58 2025 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0fafd18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/config/spotify_secrets.php +/.env diff --git a/api/contact.php b/api/contact.php new file mode 100644 index 0000000..fb929bf --- /dev/null +++ b/api/contact.php @@ -0,0 +1,115 @@ + false, 'error' => 'method_not_allowed'], 405); +} + +$csrf = (string)($_POST['csrf'] ?? ''); +if (!csrf_check($csrf)) { + json_out(['ok' => false, 'error' => 'Invalid session, refresh and try again.'], 400); +} + +// Honeypot to deter bots +if (!empty($_POST['website'])) { + json_out(['ok' => true]); +} + +$last = (int)($_SESSION['contact_last'] ?? 0); +if ($last && (time() - $last) < 20) { + json_out(['ok' => false, 'error' => 'Please wait a moment before sending again.'], 429); +} + +$name = trim((string)($_POST['name'] ?? '')); +$email = trim((string)($_POST['email'] ?? '')); +$message = trim((string)($_POST['message'] ?? '')); + +if ($name === '' || mb_strlen($name) > 80) { + json_out(['ok' => false, 'error' => 'Invalid name.'], 400); +} +if (!filter_var($email, FILTER_VALIDATE_EMAIL) || mb_strlen($email) > 120) { + json_out(['ok' => false, 'error' => 'Invalid email.'], 400); +} +if ($message === '' || mb_strlen($message) > 2000) { + json_out(['ok' => false, 'error' => 'Invalid message length.'], 400); +} + +$name = str_replace(["\r", "\n"], ' ', $name); +$email = str_replace(["\r", "\n"], '', $email); + +// Ensure table exists +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) { + json_out(['ok' => false, 'error' => 'Server error (contact table).'], 500); +} + +$ipRaw = (string)($_SERVER['REMOTE_ADDR'] ?? ''); +$ip = substr($ipRaw !== '' ? $ipRaw : '127.0.0.1', 0, 45); +$ua = substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255); +$now = date('Y-m-d H:i:s'); +$id = null; + +try { + $st = pdo()->prepare("INSERT INTO contact_requests (name, email, message, status, created_at, ip, user_agent) + VALUES (?, ?, ?, 'new', ?, ?, ?)"); + $st->execute([$name, $email, $message, $now, $ip, $ua]); + $id = (int)pdo()->lastInsertId(); +} catch (Throwable $e) { + json_out(['ok' => false, 'error' => 'Server error (save failed).'], 500); +} + +// Optional email notification if configured +$delivered = false; +$to = getenv('CONTACT_TO') ?: ''; +if ($to !== '') { + $subject = "Portfolio contact from {$name}"; + $body = + "Name: {$name}\n" . + "Email: {$email}\n\n" . + "Message:\n{$message}\n"; + + $headers = [ + 'From: Portfolio ', + 'Reply-To: ' . $email, + 'Content-Type: text/plain; charset=utf-8' + ]; + + $delivered = @mail($to, $subject, $body, implode("\r\n", $headers)) === true; +} + +$_SESSION['contact_last'] = time(); + +json_out([ + 'ok' => true, + 'id' => $id, + 'delivered' => $delivered, + 'ip' => $ip, +]); diff --git a/api/spotify.php b/api/spotify.php new file mode 100644 index 0000000..f064c17 --- /dev/null +++ b/api/spotify.php @@ -0,0 +1,179 @@ +false, 'error'=>$code], $extra)); +} + +function spotify_refresh_access_token(string $refreshToken): array { + if (!defined('SPOTIFY_CLIENT_ID') || !defined('SPOTIFY_CLIENT_SECRET')) { + return ['ok'=>false, 'why'=>'spotify_constants_missing']; + } + if (SPOTIFY_CLIENT_ID === '' || SPOTIFY_CLIENT_SECRET === '') { + return ['ok'=>false, 'why'=>'spotify_client_empty']; + } + + $ch = curl_init('https://accounts.spotify.com/api/token'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query([ + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + ]), + CURLOPT_HTTPHEADER => [ + 'Authorization: Basic ' . base64_encode(SPOTIFY_CLIENT_ID . ':' . SPOTIFY_CLIENT_SECRET), + 'Content-Type: application/x-www-form-urlencoded', + ], + CURLOPT_TIMEOUT => 10, + ]); + + $raw = curl_exec($ch); + $curlErr = curl_error($ch); + $http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = is_string($raw) ? json_decode($raw, true) : null; + + if (!is_string($raw)) { + return ['ok'=>false,'why'=>'curl_failed','curl_error'=>$curlErr ?: 'unknown']; + } + + if ($http < 200 || $http >= 300) { + return ['ok'=>false,'why'=>'spotify_token_http','status'=>$http,'spotify'=>$data,'raw'=>$raw]; + } + + if (!is_array($data) || empty($data['access_token'])) { + return ['ok'=>false,'why'=>'spotify_token_bad_json','status'=>$http,'spotify'=>$data,'raw'=>$raw]; + } + + return ['ok'=>true,'data'=>$data]; +} + +function spotify_api_get(string $url, string $accessToken): array { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $accessToken, + 'Accept: application/json', + ], + CURLOPT_TIMEOUT => 10, + ]); + + $raw = curl_exec($ch); + $curlErr = curl_error($ch); + $http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = is_string($raw) ? json_decode($raw, true) : null; + return [$http, $data, $raw, $curlErr]; +} + +try { + // sanity: does pdo() exist? + if (!function_exists('pdo')) safe_err('pdo_function_missing'); + + // what DB are we using? + $dbName = pdo()->query("SELECT DATABASE()")->fetchColumn(); + if (!$dbName) safe_err('no_database_selected'); + + // does table exist? + $stmt = pdo()->query("SHOW TABLES LIKE 'spotify_tokens'"); + $tbl = $stmt ? $stmt->fetchColumn() : null; + if (!$tbl) safe_err('table_missing', ['db'=>$dbName]); + + // fetch row + $q = pdo()->query("SELECT refresh_token, access_token, access_expires FROM spotify_tokens WHERE id=1"); + if (!$q) safe_err('query_failed'); + $row = $q->fetch(PDO::FETCH_ASSOC); + if (!$row) safe_err('row_missing_id_1', ['db'=>$dbName]); + + $refresh = trim((string)($row['refresh_token'] ?? '')); + $access = trim((string)($row['access_token'] ?? '')); + $exp = (int)($row['access_expires'] ?? 0); + + if ($refresh === '') safe_err('refresh_token_empty_in_db', ['db'=>$dbName]); + + // refresh if needed + if ($access === '' || $exp <= (time() + 30)) { + $new = spotify_refresh_access_token($refresh); + if (empty($new['ok'])) { + safe_err('refresh_failed', ['detail'=>$new, 'db'=>$dbName]); + } + + $access = (string)$new['data']['access_token']; + $exp = time() + (int)($new['data']['expires_in'] ?? 3600); + + $ok = pdo()->prepare("UPDATE spotify_tokens SET access_token=?, access_expires=? WHERE id=1") + ->execute([$access, $exp]); + if (!$ok) safe_err('db_update_failed'); + } + + // now playing + [$http, $data, $raw, $curlErr] = spotify_api_get('https://api.spotify.com/v1/me/player/currently-playing', $access); + + if ($http === 200 && is_array($data) && !empty($data['item'])) { + $t = $data['item']; + $artists = []; + foreach (($t['artists'] ?? []) as $a) $artists[] = (string)($a['name'] ?? ''); + $img = (string)($t['album']['images'][0]['url'] ?? ''); + + json_out([ + 'ok' => true, + 'mode' => !empty($data['is_playing']) ? 'playing' : 'recent', + 'track' => [ + 'title' => (string)($t['name'] ?? ''), + 'artist' => trim(implode(', ', array_filter($artists))), + 'art' => $img, + 'url' => (string)($t['external_urls']['spotify'] ?? ''), + ], + ]); + } + + // fallback: recently played + [$http2, $data2, $raw2, $curlErr2] = spotify_api_get('https://api.spotify.com/v1/me/player/recently-played?limit=1', $access); + $item = (is_array($data2) && !empty($data2['items'][0]['track'])) ? $data2['items'][0]['track'] : null; + + if ($http2 === 200 && is_array($item)) { + $artists = []; + foreach (($item['artists'] ?? []) as $a) $artists[] = (string)($a['name'] ?? ''); + $img = (string)($item['album']['images'][0]['url'] ?? ''); + + json_out([ + 'ok' => true, + 'mode' => 'recent', + 'track' => [ + 'title' => (string)($item['name'] ?? ''), + 'artist' => trim(implode(', ', array_filter($artists))), + 'art' => $img, + 'url' => (string)($item['external_urls']['spotify'] ?? ''), + ], + ]); + } + + safe_err('no_track', [ + 'currently_playing_http' => $http, + 'recent_http' => $http2 + ]); + +} catch (Throwable $e) { + safe_err('exception', [ + 'type' => get_class($e), + 'msg' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]); +} diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..878837f --- /dev/null +++ b/config/app.php @@ -0,0 +1,12 @@ + [ + 'host' => 'localhost:3306', + 'name' => 'ajofficial_portfolio', + 'user' => 'ajofficial', + 'pass' => 'V!kGU62je7%^rKZDU', + 'charset' => 'utf8mb4', + ], + +]; diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..8591eaa Binary files /dev/null and b/favicon.ico differ diff --git a/img/ajpfp.png b/img/ajpfp.png new file mode 100644 index 0000000..8591eaa Binary files /dev/null and b/img/ajpfp.png differ diff --git a/includes/auth.php b/includes/auth.php new file mode 100644 index 0000000..530a878 --- /dev/null +++ b/includes/auth.php @@ -0,0 +1,241 @@ +exec(" + CREATE TABLE IF NOT EXISTS admin_remember_tokens ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + admin_id INT UNSIGNED NOT NULL, + selector CHAR(24) NOT NULL UNIQUE, + token_hash CHAR(64) NOT NULL, + expires_at INT UNSIGNED NOT NULL, + created_at INT UNSIGNED NOT NULL, + INDEX idx_admin_expires (admin_id, expires_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + "); + } catch (Throwable $e) { + // fail silently; remember-me stays disabled + } + + $done = true; +} + +function admin_clear_remember_cookie(): void { + setcookie(ADMIN_REMEMBER_COOKIE, '', [ + 'expires' => time() - 3600, + 'path' => admin_cookie_path(), + 'secure' => is_https(), + 'httponly' => true, + 'samesite' => 'Lax', + ]); + unset($_COOKIE[ADMIN_REMEMBER_COOKIE]); +} + +function admin_delete_remember_tokens(?int $adminId = null, ?string $selector = null): void { + admin_ensure_remember_table(); + + $now = time(); + $sql = ''; + $args = []; + + if ($selector !== null) { + $sql = "DELETE FROM admin_remember_tokens WHERE selector = ?"; + $args = [$selector]; + } elseif ($adminId !== null) { + $sql = "DELETE FROM admin_remember_tokens WHERE admin_id = ? OR expires_at < ?"; + $args = [$adminId, $now]; + } else { + $sql = "DELETE FROM admin_remember_tokens WHERE expires_at < ?"; + $args = [$now]; + } + + try { + pdo()->prepare($sql)->execute($args); + } catch (Throwable $e) { + // ignore cleanup issues + } +} + +function admin_issue_remember_token(int $adminId): void { + if ($adminId <= 0) return; + + admin_delete_remember_tokens($adminId); + + $selector = bin2hex(random_bytes(12)); // 24 chars, indexed lookup + $token = bin2hex(random_bytes(32)); // 64 chars secret + $hash = hash('sha256', $token); + $expires = time() + ADMIN_REMEMBER_TTL; + + try { + pdo()->prepare(" + INSERT INTO admin_remember_tokens (admin_id, selector, token_hash, expires_at, created_at) + VALUES (?, ?, ?, ?, ?) + ")->execute([$adminId, $selector, $hash, $expires, time()]); + } catch (Throwable $e) { + return; + } + + $cookieVal = $selector . ':' . $token; + setcookie(ADMIN_REMEMBER_COOKIE, $cookieVal, [ + 'expires' => $expires, + 'path' => admin_cookie_path(), + 'secure' => is_https(), + 'httponly' => true, + 'samesite' => 'Lax', + ]); + $_COOKIE[ADMIN_REMEMBER_COOKIE] = $cookieVal; +} + +function admin_start_session(array $adminRow): void { + if (session_status() === PHP_SESSION_ACTIVE) { + session_regenerate_id(true); + } + $_SESSION['admin_id'] = (int)($adminRow['id'] ?? 0); + $_SESSION['admin_user'] = (string)($adminRow['username'] ?? ''); +} + +function admin_forget_remember_me(): void { + $selector = null; + $cookie = (string)($_COOKIE[ADMIN_REMEMBER_COOKIE] ?? ''); + if (strpos($cookie, ':') !== false) { + [$selector] = explode(':', $cookie, 2); + } + + if ($selector) { + admin_delete_remember_tokens(null, $selector); + } + if (!empty($_SESSION['admin_id'])) { + admin_delete_remember_tokens((int)$_SESSION['admin_id']); + } else { + admin_delete_remember_tokens(); + } + + admin_clear_remember_cookie(); +} + +function admin_try_remember_login(): bool { + static $checked = false; + if ($checked) return !empty($_SESSION['admin_id']); + $checked = true; + + $cookie = (string)($_COOKIE[ADMIN_REMEMBER_COOKIE] ?? ''); + if ($cookie === '' || strpos($cookie, ':') === false) return false; + + [$selector, $token] = explode(':', $cookie, 2); + if ($selector === '' || $token === '') { + admin_clear_remember_cookie(); + return false; + } + + // guard against oversized/invalid payloads before hitting DB + if (!preg_match('/^[a-f0-9]{24}$/i', $selector) || !preg_match('/^[a-f0-9]{64}$/i', $token)) { + admin_clear_remember_cookie(); + return false; + } + + admin_delete_remember_tokens(); // prune expired + + try { + $st = pdo()->prepare("SELECT admin_id, token_hash, expires_at FROM admin_remember_tokens WHERE selector = ? LIMIT 1"); + $st->execute([$selector]); + $row = $st->fetch(); + } catch (Throwable $e) { + return false; + } + + if (!$row) { + admin_clear_remember_cookie(); + return false; + } + + if ((int)$row['expires_at'] < time()) { + admin_delete_remember_tokens(null, $selector); + admin_clear_remember_cookie(); + return false; + } + + $expected = (string)($row['token_hash'] ?? ''); + if (!hash_equals($expected, hash('sha256', $token))) { + admin_delete_remember_tokens(null, $selector); + admin_clear_remember_cookie(); + return false; + } + + try { + $u = pdo()->prepare("SELECT id, username FROM admin_users WHERE id = ? LIMIT 1"); + $u->execute([(int)$row['admin_id']]); + $adminRow = $u->fetch() ?: []; + } catch (Throwable $e) { + return false; + } + + if (empty($adminRow)) { + admin_delete_remember_tokens(null, $selector); + admin_clear_remember_cookie(); + return false; + } + + admin_start_session($adminRow); + admin_issue_remember_token((int)$adminRow['id']); // rotate token after successful auto-login + return true; +} + +function admin_is_logged_in(): bool { + if (!empty($_SESSION['admin_id'])) return true; + return admin_try_remember_login(); +} + +function admin_login(array $adminRow, bool $remember = false): void { + admin_start_session($adminRow); + if ($remember) { + admin_issue_remember_token((int)$_SESSION['admin_id']); + } else { + admin_forget_remember_me(); + } +} + +function admin_logout(): void { + admin_forget_remember_me(); + unset($_SESSION['admin_id'], $_SESSION['admin_user']); +} + +function require_admin_login(): void { + if (!admin_is_logged_in()) { + header('Location: ' . url_path('/public/admin/login.php')); + exit; + } +} + +// Optional: normal site users (not admin panel) +function user_is_logged_in(): bool { + return !empty($_SESSION['uid']); +} +function user_login(array $user): void { + if (session_status() === PHP_SESSION_ACTIVE) { + session_regenerate_id(true); + } + $_SESSION['uid'] = (int)($user['id'] ?? 0); +} +function user_logout(): void { + unset($_SESSION['uid']); +} +function require_user_login(): void { + if (!user_is_logged_in()) { + header('Location: ' . url_path('/login.php')); + exit; + } +} diff --git a/includes/bootstrap.php b/includes/bootstrap.php new file mode 100644 index 0000000..6d9b8e6 --- /dev/null +++ b/includes/bootstrap.php @@ -0,0 +1,43 @@ + 0, + 'path' => $basePath ? $basePath . '/' : '/', + 'secure' => is_https(), + 'httponly' => true, + 'samesite' => 'Lax', + ]); + session_start(); +} + +require_once __DIR__ . '/db.php'; +require_once __DIR__ . '/flash.php'; +require_once __DIR__ . '/csrf.php'; +require_once __DIR__ . '/auth.php'; +require_once __DIR__ . '/media.php'; diff --git a/includes/csrf.php b/includes/csrf.php new file mode 100644 index 0000000..f38cdf1 --- /dev/null +++ b/includes/csrf.php @@ -0,0 +1,23 @@ +'; +} + +/** + * Validate a CSRF token (uses provided token or POST body if omitted). + */ +function csrf_check(?string $token = null): bool { + $sent = $token ?? (string)($_POST['csrf'] ?? ''); + $stored = (string)($_SESSION['_csrf'] ?? ($_SESSION['csrf'] ?? '')); + return ($sent !== '' && $stored !== '' && hash_equals($stored, $sent)); +} diff --git a/includes/db.php b/includes/db.php new file mode 100644 index 0000000..c5dd332 --- /dev/null +++ b/includes/db.php @@ -0,0 +1,25 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + + return $pdo; +} diff --git a/includes/flash.php b/includes/flash.php new file mode 100644 index 0000000..1fbc69c --- /dev/null +++ b/includes/flash.php @@ -0,0 +1,13 @@ + $type, 'msg' => $msg]; +} + +function flash_get(): ?array { + if (empty($_SESSION['_flash'])) return null; + $f = $_SESSION['_flash']; + unset($_SESSION['_flash']); + return $f; +} diff --git a/includes/media.php b/includes/media.php new file mode 100644 index 0000000..f663ab8 --- /dev/null +++ b/includes/media.php @@ -0,0 +1,80 @@ + project_media_url($folder, basename($path)), + 'modified' => (int)($stat['mtime'] ?? 0), + ]; + } + + usort($files, fn($a, $b) => ($b['modified'] ?? 0) <=> ($a['modified'] ?? 0)); + return array_values(array_map(fn($f) => (string)$f['url'], $files)); +} + +// Slugify a string to lower-case letters/numbers/hyphens. +function project_slugify(string $value): string +{ + $value = trim($value); + $value = preg_replace('/[^a-z0-9]+/i', '-', $value) ?? ''; + $value = trim($value, '-'); + $value = function_exists('mb_strtolower') ? mb_strtolower($value) : strtolower($value); + return $value; +} diff --git a/includes/spotify.php b/includes/spotify.php new file mode 100644 index 0000000..3b10521 --- /dev/null +++ b/includes/spotify.php @@ -0,0 +1,144 @@ + true, + CURLOPT_POSTFIELDS => http_build_query($fields), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 12, + CURLOPT_HTTPHEADER => array_merge([ + 'Content-Type: application/x-www-form-urlencoded' + ], $headers), + ]); + $raw = curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + + if ($raw === false) return ['ok'=>false,'code'=>$code,'error'=>$err ?: 'curl_error','data'=>null]; + $data = json_decode($raw, true); + return ['ok'=>($code >= 200 && $code < 300),'code'=>$code,'error'=>$err ?: null,'data'=>$data]; +} + +function spotify_http_get(string $url, string $accessToken): array { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $accessToken, + 'Accept: application/json', + ], + ]); + $raw = curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + + if ($raw === false) return ['ok'=>false,'code'=>$code,'error'=>$err ?: 'curl_error','data'=>null]; + $data = ($raw !== '' ? json_decode($raw, true) : null); + return ['ok'=>($code >= 200 && $code < 300),'code'=>$code,'error'=>$err ?: null,'data'=>$data]; +} + +function spotify_save_tokens(?string $refresh, ?string $access, int $expiresIn): void { + $expiresAt = time() + max(0, $expiresIn) - 30; // safety margin + $stmt = pdo()->prepare(" + UPDATE spotify_tokens + SET refresh_token = COALESCE(?, refresh_token), + access_token = ?, + access_expires = ?, + updated_at = ? + WHERE id = 1 + "); + $stmt->execute([$refresh, $access, $expiresAt, time()]); +} + +function spotify_get_stored_row(): ?array { + try { + $row = pdo()->query("SELECT refresh_token, access_token, access_expires FROM spotify_tokens WHERE id=1")->fetch(); + return $row ?: null; + } catch (Throwable $e) { + return null; + } +} + +function spotify_exchange_code_for_tokens(string $code): bool { + $cid = spotify_client_id(); + $sec = spotify_client_secret(); + $redir = spotify_redirect_uri(); + if ($cid === '' || $sec === '' || $redir === '') return false; + + $basic = base64_encode($cid . ':' . $sec); + $res = spotify_http_post( + 'https://accounts.spotify.com/api/token', + [ + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => $redir, + ], + ['Authorization: Basic ' . $basic] + ); + + if (!$res['ok'] || !is_array($res['data'])) return false; + + $access = (string)($res['data']['access_token'] ?? ''); + $refresh = (string)($res['data']['refresh_token'] ?? ''); + $expires = (int)($res['data']['expires_in'] ?? 0); + + if ($access === '' || $refresh === '' || $expires <= 0) return false; + spotify_save_tokens($refresh, $access, $expires); + return true; +} + +function spotify_refresh_access_token(): ?string { + $row = spotify_get_stored_row(); + $refresh = (string)($row['refresh_token'] ?? ''); + if ($refresh === '') return null; + + $cid = spotify_client_id(); + $sec = spotify_client_secret(); + if ($cid === '' || $sec === '') return null; + + $basic = base64_encode($cid . ':' . $sec); + $res = spotify_http_post( + 'https://accounts.spotify.com/api/token', + [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $refresh, + ], + ['Authorization: Basic ' . $basic] + ); + + if (!$res['ok'] || !is_array($res['data'])) return null; + + $access = (string)($res['data']['access_token'] ?? ''); + $expires = (int)($res['data']['expires_in'] ?? 0); + if ($access === '' || $expires <= 0) return null; + + // refresh token usually not returned here; keep existing one + spotify_save_tokens(null, $access, $expires); + return $access; +} + +function spotify_get_access_token(): ?string { + $row = spotify_get_stored_row(); + if (!$row) return null; + + $access = (string)($row['access_token'] ?? ''); + $exp = (int)($row['access_expires'] ?? 0); + + if ($access !== '' && $exp > time() + 30) return $access; + return spotify_refresh_access_token(); +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..15ce8f0 --- /dev/null +++ b/index.php @@ -0,0 +1,718 @@ + homepage stack + $skillLinks = []; + try { + $skillLinks = pdo()->query("SELECT framework_id, language_id FROM skill_links")->fetchAll(PDO::FETCH_ASSOC); + } catch (Throwable $e) {} + + $rows = pdo()->query("SELECT id, label, level, icon, category, parent_id FROM skills ORDER BY sort_order ASC, id ASC")->fetchAll(); + if ($rows && is_array($rows)) { + $languages = []; + $frameworks = []; + $frameworkLanguageMap = []; + foreach ($skillLinks as $lnk) { + $fw = (int)($lnk['framework_id'] ?? 0); + $lang = (int)($lnk['language_id'] ?? 0); + if ($fw > 0 && $lang > 0) { + if (!isset($frameworkLanguageMap[$fw])) $frameworkLanguageMap[$fw] = []; + $frameworkLanguageMap[$fw][$lang] = $lang; + } + } + + foreach ($rows as $r) { + $cat = strtolower((string)($r['category'] ?? 'language')); + $item = [ + 'id' => (int)($r['id'] ?? 0), + 'label' => (string)($r['label'] ?? ''), + 'level' => (int)($r['level'] ?? 0), + 'icon' => (string)($r['icon'] ?? 'ri-code-s-slash-line'), + 'parent_id' => (int)($r['parent_id'] ?? 0), + ]; + + if ($cat === 'framework') { + $frameworks[(int)$item['id']] = $item; + } else { + $item['frameworks'] = []; + $languageLabels[(int)$item['id']] = $item['label']; + $languages[(int)$item['id']] = $item; + } + } + + // attach frameworks to languages via pivot map (fallback to legacy parent_id) + foreach ($frameworks as $fwId => $fw) { + $langIds = array_values($frameworkLanguageMap[$fwId] ?? []); + if (!$langIds && $fw['parent_id'] > 0) $langIds = [$fw['parent_id']]; + + $frameworks[$fwId]['language_ids'] = $langIds; + $frameworks[$fwId]['language_labels'] = []; + foreach ($langIds as $lid) { + if (isset($languageLabels[$lid])) { + $frameworks[$fwId]['language_labels'][] = $languageLabels[$lid]; + $fwItem = $fw; + $fwItem['parent_id'] = $lid; + $languages[$lid]['frameworks'][] = $fwItem; + } + } + } + + if ($languages) { + foreach ($languages as $id => $lang) { + $lang['frameworks'] = array_values($lang['frameworks'] ?? []); + $stack[] = $lang; + } + } + $frameworks = array_values($frameworks); + } + + // projects table -> homepage projects + $rows = []; + try { + $rows = pdo()->query("SELECT id, slug, title, tag, year, summary, short_summary, tech_json, links_json FROM projects ORDER BY sort_order ASC, id DESC")->fetchAll(); + } catch (Throwable $e) { + $rows = pdo()->query("SELECT id, slug, title, tag, year, summary, tech_json, links_json FROM projects ORDER BY sort_order ASC, id DESC")->fetchAll(); + if (is_array($rows)) { + foreach ($rows as &$r) { $r['short_summary'] = null; } + unset($r); + } + } + if ($rows && is_array($rows)) { + $tmp = []; + $tagSet = []; + foreach ($rows as $r) { + $tech = []; + $links = []; + $slugVal = project_slugify((string)($r['slug'] ?? '')); + if ($slugVal === '') { + $slugVal = project_slugify((string)($r['title'] ?? '')) ?: ('p' . (int)$r['id']); + } + + $techJson = (string)($r['tech_json'] ?? ''); + $linksJson = (string)($r['links_json'] ?? ''); + + if ($techJson !== '') { + $decoded = json_decode($techJson, true); + if (is_array($decoded)) $tech = $decoded; + } + if ($linksJson !== '') { + $decoded = json_decode($linksJson, true); + if (is_array($decoded)) $links = $decoded; + } + + $projectForMedia = [ + 'id' => (int)($r['id'] ?? 0), + 'slug' => $slugVal, + 'title' => (string)($r['title'] ?? ''), + ]; + + $shortCard = trim((string)($r['short_summary'] ?? '')); + if ($shortCard === '') { + $shortCard = project_excerpt((string)($r['summary'] ?? ''), 180); + } + + $tmp[] = [ + 'id' => (string)$slugVal, + 'title' => (string)($r['title'] ?? ''), + 'tag' => (string)($r['tag'] ?? ''), + 'year' => (string)($r['year'] ?? ''), + 'summary' => (string)($r['summary'] ?? ''), + 'summary_short' => $shortCard, + 'tech' => is_array($tech) ? $tech : [], + 'links' => is_array($links) ? $links : [], + 'images' => project_media_files($projectForMedia), + ]; + + $tag = trim((string)($r['tag'] ?? '')); + if ($tag !== '') $tagSet[$tag] = true; + } + if ($tmp) $projects = $tmp; + if ($tagSet) $projectTags = array_keys($tagSet); + } + } +} catch (Throwable $e) { + // keep fallback arrays +} + +$profile = [ + 'name' => 'Georgi Mushatov', + 'role' => 'Full Stack Developer & Software Engineer', + 'location' => 'Varna,BG', + 'github' => 'https://github.com/AJOffishal', + 'instagram' => 'https://instagram.com/1_3_aj_official_3_7/', + 'tiktok' => 'https://tiktok.com/@ajbtgd', +]; + +$projectsJson = json_encode($projects, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); +$initial = function_exists('mb_substr') ? mb_substr($profile['name'], 0, 1) : substr($profile['name'], 0, 1); +$pfpUrl = url_path('/img/' . rawurlencode('ajpfp.png')); +?> + + + + + + <?= htmlspecialchars($profile['name']) ?> • Portfolio + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + + + + + + +
+
+
+ +
+
+
Menu
+ +
+ +
+ +
+ + +
+ + +
+
+
+
+ Profile picture +
+ +
+ Not Avalaibe +
+
+ +

+ + + + + + + + + + + +

+ + + +

+ . +

+ + +
+
+
+ + Loading Spotify… +
+ +
+ + + Album art +
+
+
+
+
+ +
+ + + + +
+ + +
+
+
+
What I like building
+ +
+
    +
  • Video Games & Websites.
  • +
  • Discord Bots using discord.js.
  • +
  • Tools & launchers.
  • +
+
+ +
+
Focus
+
Full Stack
+
Lua • PHP • AJAX • jQuery • MariaDB • C# • C++
+
+ Build fast → harden security → ship clean UI. +
+
+ +
+
Workflow
+
Git + Gitea
+
Clean commits, tags
+
+ Stable releases + readable history. +
+
+
+ +
+ + + +
+
+
+

About Me

+
+
+ +
+
+ +
+
+
+

+ I’m Georgi Mushatov, a 15-year-old high schooler from Varna, Bulgaria. + I’m into video games and building websites (front-end + back-end). I like making + community sites, dashboards and tools that connect real data using APIs. + My focus is clean UI and solid functionality - making things work smoothly end-to-end. +

+
+ +
+ Video Games + APIs + Automation +
+
+ +
+
Quick facts
+
    +
  • Fast prototyping → clean refactor
  • +
  • Secure auth + CSRF patterns
  • +
  • Git workflow daily
  • +
+
+
+
+ + +
+
+
+

Project Library

+

Click a card for details.

+
+
+ + + + +
+
+ +
+ + + +
+
+ + +
+

Tech Stack

+ +
+ +
+
+
+ +
+
+
%
+
+
+
+
+ + +
Frameworks / Librarys / Game Engines
+
+ + + + + + +
+ +
No frameworks added yet.
+ +
+ + + +
No skills added yet.
+ +
+ +
+

Frameworks / Librarys / Game Engines

+ +
+ +
+
+
+ +
+
+ + +
Languages:
+ +
+
+
%
+
+
+
+
+
+ + + +
No frameworks added yet.
+ +
+
+
+ +
+

Gaming + Web Focus

+ +
+
+
+
1) Community Websites
+ +
+

+ Landing pages, team/clean sites, game community hubs - fast, clean and easy to use. +

+
+ +
+
+
2) Dashboards
+ +
+

+ Simple dashboards for data, versions, stats and pages that keep things organized. +

+
+ +
+
+
3) APIs + Logic
+ +
+

+ PHP + SQL + AJAX/jQuery to connect APIs and build real functionality behind the UI. +

+
+
+
+ + + +
+

Get in Touch

+ +
+
+
+ + + +
+ + +
+
+ + +
+
+ + +
+ + +
+
+ +
+ + +
+
What to include
+
    +
  • Goal (website, dashboard, tool)
  • +
  • Deadline + must-have features
  • +
  • Any game/community context
  • +
+
+
+
+
+ +
+
© • Built with PHP • Tailwind • Bootstrap • AJAX • jQuery
+
+
+ + + +
+ +
+ + + + + + + + + + + diff --git a/public/admin/_bottom.php b/public/admin/_bottom.php new file mode 100644 index 0000000..082c177 --- /dev/null +++ b/public/admin/_bottom.php @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/admin/_top.php b/public/admin/_top.php new file mode 100644 index 0000000..844cab8 --- /dev/null +++ b/public/admin/_top.php @@ -0,0 +1,45 @@ + + + + + + + <?= htmlspecialchars($pageTitle) ?> + + + + + + + + + + + +
+ +
+ +
+ diff --git a/public/admin/contacts.php b/public/admin/contacts.php new file mode 100644 index 0000000..0ebc9c2 --- /dev/null +++ b/public/admin/contacts.php @@ -0,0 +1,278 @@ +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'; +?> + +
+
+ +
+
+
Inbox
+

Contact Requests

+
Review and triage all incoming messages.
+
+
+ Dashboard +
+
+ + +
+ + + +
+ + +
+
+
+
+
+
+
New
+
+
+ Inbox +
+
Unread messages waiting for review.
+
+ View new + All +
+
+
+
+
+
+
+
+
+
Totals
+
messages
+
+
+ Read: + Archived: +
+
+
+
+
+
+ +
+
+
Latest messages
+
+ + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDFromStatusMessageReceivedActions
# +
+
+ + + +
+
+ + +
+ +
+
+ +
+ +
+
+ + + + + +
+
+ + + + + +
+
+ + + + +
+
No contact requests yet.
+
+
+ + + + + + diff --git a/public/admin/dashboard.php b/public/admin/dashboard.php new file mode 100644 index 0000000..a9c59de --- /dev/null +++ b/public/admin/dashboard.php @@ -0,0 +1,258 @@ + 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) {} +?> + + + + + + Admin Dashboard + + + + + + + + + + + +
+
+
+

Dashboard

+
+ +
+ Home + Logout +
+
+ + +
+ + +
+ +
+
+
+ +
+
+
Projects
+
+
+
+
+ Add + Manage +
+
+ + +
+
+
+ +
+
+
Tech Stack
+
+
+
+
Edit languages and frameworks for your homepage.
+
+ Add + Adjust +
+
+ + +
+
+
+ +
+
+
Contacts
+
+
new
+
+
+
+ Inbox + New +
+
+ + +
+
+
+ +
+
+
Spotify
+
+ +
+
+
+ +
+
+
Offline
+ +
+ + + Album art +
+
-
+
-
+
+
+ +
+ Shows Now Playing or Last Played. Temporary token expires; refresh token is best. +
+ +
+ Set Token + +
+
+
+
+ + +
+
+
+
Inbox preview
+
Latest contact requests
+
+ View all +
+
+ +
+
+
+
+
+
+ +
+
+
+ +
+
+ +
+ + + + + + diff --git a/public/admin/login.php b/public/admin/login.php new file mode 100644 index 0000000..c6b7d0a --- /dev/null +++ b/public/admin/login.php @@ -0,0 +1,170 @@ + 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; + } + } +} +?> + + + + + + Admin Login + + + + + + + + +
+
+ +
+
+
+
GM
+
+
+ Welcome back +
+
Login to manage your portfolio
+
+
+ +
+ +
+
+ + +
+
+ +
+
+
+ + +
+ + +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ + Secure cookie +
+ + +
+
+
+ + + + + diff --git a/public/admin/logout.php b/public/admin/logout.php new file mode 100644 index 0000000..4a20531 --- /dev/null +++ b/public/admin/logout.php @@ -0,0 +1,8 @@ +prepare("DELETE FROM projects WHERE id = ?"); +$st->execute([$id]); + +flash_set('success', 'Project deleted.'); +header('Location: /public/admin/projects.php'); +exit; diff --git a/public/admin/project_edit.php b/public/admin/project_edit.php new file mode 100644 index 0000000..57229e1 --- /dev/null +++ b/public/admin/project_edit.php @@ -0,0 +1,554 @@ +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'; +?> + +
+

+ Back +
+ + +
+
    +
  • +
+
+ + +
+ + +
+
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
Letters, numbers, and hyphen. Lowercase slug is auto-built from the title.
+
+ +
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + Cancel +
+
+
+ +
+ +
+ Save the project first to unlock the media gallery. +
+ +
+
+
+
Files live in
+
Project imgs/
+
+
JPEG, PNG or WebP up to 8MB; re-encoded via GD.
+
+ +
+
+
+
+
Drag & drop screenshots
+
or click to pick files
+
+ +
+
Paste an image URL and we'll fetch and sanitize it.
+
+ + +
+
+
+
+
+
+
+
+ +
+
+
+ + + + + + diff --git a/public/admin/project_media.php b/public/admin/project_media.php new file mode 100644 index 0000000..85b84d9 --- /dev/null +++ b/public/admin/project_media.php @@ -0,0 +1,332 @@ + 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); +} diff --git a/public/admin/projects.php b/public/admin/projects.php new file mode 100644 index 0000000..f61e9c4 --- /dev/null +++ b/public/admin/projects.php @@ -0,0 +1,275 @@ +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; +?> + + + + + + Projects • Admin + + + + + +
+
+
+

Projects

+
+ +
+ + +
+ + +
+
+
+
Add New
+
Create a new project card
+
+ Save a project, then use its Gallery button. +
+ +
+
+ + + +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+ +
+
+
Existing Projects
+
total
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
IDTitleYearTagActions
+
+
+
+ Edit + Gallery +
+ + + + +
+
No projects yet.
+
+
+ +
+ + + + + diff --git a/public/admin/signup.php b/public/admin/signup.php new file mode 100644 index 0000000..e664028 --- /dev/null +++ b/public/admin/signup.php @@ -0,0 +1,199 @@ +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; + } + } +} +?> + + + + + + Setup Admin + + + + + + + + +
+
+ +
+
+
+
+
GM
+
+
+ First-time setup +
+

Create Admin Account

+
This page auto-disables after the first admin is created.
+
+
+ Secure +
+ + +
+ +
+ + +
+ + +
+
+ +
+ + + + +
+
Allowed: letters, numbers, underscore. 3-32 chars.
+
+ +
+ +
+ + +
+ +
+
+
Use 10+ chars, mix letters + numbers.
+
+
+ +
+ +
+ + + + +
+
+ +
+ +
+ +
+
+ After setup, you will be redirected to the dashboard. +
+
+
+
+
+
+ + + + + diff --git a/public/admin/skill_edit.php b/public/admin/skill_edit.php new file mode 100644 index 0000000..150145d --- /dev/null +++ b/public/admin/skill_edit.php @@ -0,0 +1,103 @@ + '', '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 1–100 chars.'; + if ($level < 0 || $level > 100) $errors[] = 'Level must be 0–100.'; + if ($icon === '' || mb_strlen($icon) > 80) $errors[] = 'Icon must be 1–80 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'; +?> +
+

+ Back +
+ + +
+
    +
  • +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ Example: ri-database-2-line / ri-code-s-slash-line +
+
+
+ +
+ + Cancel +
+
+
+ + diff --git a/public/admin/skills.php b/public/admin/skills.php new file mode 100644 index 0000000..e53a661 --- /dev/null +++ b/public/admin/skills.php @@ -0,0 +1,521 @@ +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']); +?> + + + + + + Skills • Admin + + + + + + +
+ +
+
+

Tech Stack (%)

+
Create languages and nest frameworks beneath them.
+
+ +
+ + +
+ + +
+
+
+
Add New
+
Create a new bar on the homepage
+
+ +
+ +
+
+ + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
Choose one or more languages to nest its frameworks.
+
+
+ +
+ +
+
+
+
+ +
+
+
Edit Skills
+
total
+
+ + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDLabelIconCategoryParents%SortDelete
+ + + + + + + + +
+ +
+
+ + +
No skills yet.
+
+ +
+ +
+
+
+ +
+ + + + + diff --git a/public/admin/spotify_callback.php b/public/admin/spotify_callback.php new file mode 100644 index 0000000..dd03e3d --- /dev/null +++ b/public/admin/spotify_callback.php @@ -0,0 +1,96 @@ +Spotify error: " . h((string)$_GET['error']) . ""; + exit; +} + +$code = (string)($_GET['code'] ?? ''); +$state = (string)($_GET['state'] ?? ''); + +if ($code === '' || $state === '') { + echo "
Missing code/state.\n\nGET:\n"; print_r($_GET); echo "
"; + exit; +} + +$expected = (string)($_SESSION['sp_state'] ?? ''); +unset($_SESSION['sp_state']); + +if ($expected === '' || !hash_equals($expected, $state)) { + echo "
State mismatch.\nExpected: ".h($expected)."\nGot: ".h($state)."
"; + echo "

Use the SAME host everywhere (127.0.0.1 vs localhost) or cookies won’t match.

"; + exit; +} + +if (SPOTIFY_CLIENT_ID === '' || SPOTIFY_CLIENT_SECRET === '' || SPOTIFY_REDIRECT_URI === '') { + http_response_code(500); + echo "
Missing Spotify config.
"; + 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 "
Token exchange failed.\nHTTP: {$http}\nCurl: ".h($err ?: 'none')."\nRaw:\n".h((string)$raw)."\n
"; + exit; +} + +$access = (string)($data['access_token'] ?? ''); +$refresh = (string)($data['refresh_token'] ?? ''); +$expires = time() + (int)($data['expires_in'] ?? 3600); + +if ($access === '') { + echo "
No access_token returned.\n"; print_r($data); echo "
"; + 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; diff --git a/public/admin/spotify_connect.php b/public/admin/spotify_connect.php new file mode 100644 index 0000000..46f2330 --- /dev/null +++ b/public/admin/spotify_connect.php @@ -0,0 +1,28 @@ + 'code', + 'client_id' => SPOTIFY_CLIENT_ID, + 'scope' => $scope, + 'redirect_uri' => SPOTIFY_REDIRECT_URI, + 'state' => $state, + 'show_dialog' => 'true', +]); + +header('Location: ' . $url); +exit; diff --git a/public/admin/spotify_token.php b/public/admin/spotify_token.php new file mode 100644 index 0000000..cb8dac4 --- /dev/null +++ b/public/admin/spotify_token.php @@ -0,0 +1,105 @@ +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; +} +?> + + + + + + Spotify Token • Admin + + + + + + +
+
+ +
+
+
+
GM
+
+
Portfolio Admin
+
Spotify Token
+
+
+ +
+
+ +
+
+
+
+ Temporary method +
+ Expires +
+ +

Paste Spotify Access Token

+
+ Access tokens expire (usually ~1h). Best solution is storing a refresh token and letting /api/spotify.php refresh automatically. +
+ +
+ + +
+ + +
+ +
+ + +
+ +
+ + Back +
+
+
+
+ + diff --git a/public/css/admin.css b/public/css/admin.css new file mode 100644 index 0000000..8b554fc --- /dev/null +++ b/public/css/admin.css @@ -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 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; +} diff --git a/public/css/app.css b/public/css/app.css new file mode 100644 index 0000000..95bd230 --- /dev/null +++ b/public/css/app.css @@ -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); } } diff --git a/public/css/contacts.css b/public/css/contacts.css new file mode 100644 index 0000000..d732d02 --- /dev/null +++ b/public/css/contacts.css @@ -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; +} diff --git a/public/css/edit_project.css b/public/css/edit_project.css new file mode 100644 index 0000000..717ea96 --- /dev/null +++ b/public/css/edit_project.css @@ -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; } +} diff --git a/public/js/admin.js b/public/js/admin.js new file mode 100644 index 0000000..38b8951 --- /dev/null +++ b/public/js/admin.js @@ -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); diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..8d7fd94 --- /dev/null +++ b/public/js/app.js @@ -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 => $("").addClass("tag").text(t).appendTo($tech)); + + const $links = $("#pmLinks").empty(); + if (p.links && p.links.length) { + p.links.forEach(l => { + $("", { + class: "btn btn-light btn-sm", + href: l.url, + target: "_blank", + rel: "noreferrer" + }).text(l.label).appendTo($links); + }); + } else { + $("", { 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) => { + $("", { + class: "pm-thumb", + href: url, + target: "_blank", + rel: "noreferrer" + }).append( + $("", { src: url, alt: `${p.title} screenshot ${idx + 1}` }) + ).appendTo($gallery); + }); + } else { + $("
", { 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 or
+ + 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 = $('', { + '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);