Initial commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/config/spotify_secrets.php
|
||||
/.env
|
||||
115
api/contact.php
Normal file
115
api/contact.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../includes/bootstrap.php';
|
||||
|
||||
// ensure session csrf token exists (parity with frontend)
|
||||
if (empty($_SESSION['_csrf']) && !empty($_SESSION['csrf'])) {
|
||||
$_SESSION['_csrf'] = $_SESSION['csrf'];
|
||||
}
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
function json_out(array $payload, int $code = 200): void {
|
||||
http_response_code($code);
|
||||
echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
json_out(['ok' => 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 <no-reply@' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '>',
|
||||
'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,
|
||||
]);
|
||||
179
api/spotify.php
Normal file
179
api/spotify.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// KEEP YOUR PATH - if this file is /api/spotify.php in project root, this is correct.
|
||||
// If your file is /api/spotify.php then change to: require __DIR__ . '/../../includes/bootstrap.php';
|
||||
require __DIR__ . '/../includes/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
function json_out(array $a): void {
|
||||
echo json_encode($a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
function safe_err(string $code, array $extra = []): void {
|
||||
json_out(array_merge(['ok'=>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(),
|
||||
]);
|
||||
}
|
||||
12
config/app.php
Normal file
12
config/app.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
return [
|
||||
|
||||
'db' => [
|
||||
'host' => 'localhost:3306',
|
||||
'name' => 'ajofficial_portfolio',
|
||||
'user' => 'ajofficial',
|
||||
'pass' => 'V!kGU62je7%^rKZDU',
|
||||
'charset' => 'utf8mb4',
|
||||
],
|
||||
|
||||
];
|
||||
BIN
favicon.ico
Normal file
BIN
favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
img/ajpfp.png
Normal file
BIN
img/ajpfp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
241
includes/auth.php
Normal file
241
includes/auth.php
Normal file
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/csrf.php';
|
||||
|
||||
const ADMIN_REMEMBER_COOKIE = 'admin_remember';
|
||||
const ADMIN_REMEMBER_TTL = 120 * 24 * 60 * 60; // 120 days
|
||||
|
||||
function admin_cookie_path(): string {
|
||||
$base = rtrim((string)($GLOBALS['BASE_PATH'] ?? ''), '/');
|
||||
return $base ? $base . '/' : '/';
|
||||
}
|
||||
|
||||
function admin_ensure_remember_table(): void {
|
||||
static $done = false;
|
||||
if ($done) return;
|
||||
|
||||
try {
|
||||
pdo()->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;
|
||||
}
|
||||
}
|
||||
43
includes/bootstrap.php
Normal file
43
includes/bootstrap.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$app = require __DIR__ . '/../config/app.php';
|
||||
$sp = require __DIR__ . '/../config/spotify_secrets.php';
|
||||
|
||||
define('SPOTIFY_CLIENT_ID', (string)($sp['client_id'] ?? ''));
|
||||
define('SPOTIFY_CLIENT_SECRET', (string)($sp['client_secret'] ?? ''));
|
||||
define('SPOTIFY_REDIRECT_URI', (string)($sp['redirect_uri'] ?? ''));
|
||||
|
||||
$basePath = rtrim((string)($app['base_path'] ?? ''), '/');
|
||||
$GLOBALS['APP_CFG'] = $app;
|
||||
$GLOBALS['BASE_PATH'] = $basePath;
|
||||
|
||||
function url_path(string $path): string {
|
||||
$base = rtrim((string)($GLOBALS['BASE_PATH'] ?? ''), '/');
|
||||
$path = '/' . ltrim($path, '/');
|
||||
return $base === '' ? $path : $base . $path;
|
||||
}
|
||||
|
||||
function is_https(): bool {
|
||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') return true;
|
||||
if (!empty($_SERVER['SERVER_PORT']) && (int)$_SERVER['SERVER_PORT'] === 443) return true;
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower((string)$_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 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';
|
||||
23
includes/csrf.php
Normal file
23
includes/csrf.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function csrf_token(): string {
|
||||
if (empty($_SESSION['_csrf'])) {
|
||||
$_SESSION['_csrf'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
return (string)$_SESSION['_csrf'];
|
||||
}
|
||||
|
||||
function csrf_field(): string {
|
||||
$t = htmlspecialchars(csrf_token(), ENT_QUOTES, 'UTF-8');
|
||||
return '<input type="hidden" name="csrf" value="'.$t.'">';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
25
includes/db.php
Normal file
25
includes/db.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function pdo(): PDO {
|
||||
static $pdo;
|
||||
if ($pdo instanceof PDO) return $pdo;
|
||||
|
||||
$app = require __DIR__ . '/../config/app.php';
|
||||
$db = $app['db'];
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;dbname=%s;charset=%s',
|
||||
$db['host'],
|
||||
$db['name'],
|
||||
$db['charset']
|
||||
);
|
||||
|
||||
$pdo = new PDO($dsn, $db['user'], $db['pass'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
13
includes/flash.php
Normal file
13
includes/flash.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function flash_set(string $type, string $msg): void {
|
||||
$_SESSION['_flash'] = ['type' => $type, 'msg' => $msg];
|
||||
}
|
||||
|
||||
function flash_get(): ?array {
|
||||
if (empty($_SESSION['_flash'])) return null;
|
||||
$f = $_SESSION['_flash'];
|
||||
unset($_SESSION['_flash']);
|
||||
return $f;
|
||||
}
|
||||
80
includes/media.php
Normal file
80
includes/media.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// Absolute path to the shared "Project imgs" directory.
|
||||
function project_media_base_path(): string
|
||||
{
|
||||
return dirname(__DIR__) . DIRECTORY_SEPARATOR . 'Project imgs';
|
||||
}
|
||||
|
||||
// Compute a safe folder name for a project.
|
||||
function project_media_folder(array $project): string
|
||||
{
|
||||
$slug = trim((string)($project['slug'] ?? ''));
|
||||
if ($slug !== '' && preg_match('/^[a-zA-Z0-9-]+$/', $slug)) {
|
||||
return strtolower($slug);
|
||||
}
|
||||
|
||||
$title = trim((string)($project['title'] ?? ''));
|
||||
if ($title !== '') {
|
||||
$folder = strtolower((string)preg_replace('/[^a-z0-9]+/i', '-', $title));
|
||||
$folder = trim($folder, '-');
|
||||
if ($folder !== '') return $folder;
|
||||
}
|
||||
|
||||
if (!empty($project['id'])) {
|
||||
return 'project-' . (int)$project['id'];
|
||||
}
|
||||
|
||||
return 'project-media';
|
||||
}
|
||||
|
||||
|
||||
// Return the full filesystem path for the project's media directory.
|
||||
function project_media_dir(array $project): string
|
||||
{
|
||||
$dir = project_media_base_path() . DIRECTORY_SEPARATOR . project_media_folder($project);
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0755, true);
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
|
||||
// Build a public URL for a stored media file.
|
||||
|
||||
function project_media_url(string $folder, string $filename): string
|
||||
{
|
||||
$base = '/Project%20imgs';
|
||||
return url_path($base . '/' . rawurlencode($folder) . '/' . rawurlencode($filename));
|
||||
}
|
||||
|
||||
// Return a list of image URLs for a project (sorted newest first).
|
||||
function project_media_files(array $project): array
|
||||
{
|
||||
$folder = project_media_folder($project);
|
||||
$dir = project_media_base_path() . DIRECTORY_SEPARATOR . $folder;
|
||||
if (!is_dir($dir)) return [];
|
||||
$files = [];
|
||||
foreach (glob($dir . DIRECTORY_SEPARATOR . '*.{jpg,jpeg,png,webp}', GLOB_BRACE) ?: [] as $path) {
|
||||
if (!is_file($path)) continue;
|
||||
$stat = @stat($path) ?: [];
|
||||
$files[] = [
|
||||
'url' => 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;
|
||||
}
|
||||
144
includes/spotify.php
Normal file
144
includes/spotify.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function spotify_cfg(string $key, string $default = ''): string {
|
||||
$v = getenv($key);
|
||||
if ($v !== false && $v !== '') return $v;
|
||||
return $default;
|
||||
}
|
||||
|
||||
function spotify_client_id(): string { return spotify_cfg('SPOTIFY_CLIENT_ID'); }
|
||||
function spotify_client_secret(): string { return spotify_cfg('SPOTIFY_CLIENT_SECRET'); }
|
||||
function spotify_redirect_uri(): string { return spotify_cfg('SPOTIFY_REDIRECT_URI'); }
|
||||
|
||||
function spotify_http_post(string $url, array $fields, array $headers = []): array {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => 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();
|
||||
}
|
||||
718
index.php
Normal file
718
index.php
Normal file
@@ -0,0 +1,718 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/includes/bootstrap.php';
|
||||
|
||||
// unify CSRF token for forms
|
||||
$csrfToken = csrf_token();
|
||||
$_SESSION['csrf'] = $csrfToken;
|
||||
$_SESSION['_csrf'] = $csrfToken;
|
||||
|
||||
$stack = [];
|
||||
$frameworks = [];
|
||||
$languageLabels = [];
|
||||
$projects = [];
|
||||
$projectTags = [];
|
||||
|
||||
function project_excerpt(string $text, int $max = 160): string {
|
||||
$text = trim($text);
|
||||
if ($text === '') return '';
|
||||
if (function_exists('mb_strlen') && function_exists('mb_substr')) {
|
||||
if (mb_strlen($text) <= $max) return $text;
|
||||
return rtrim(mb_substr($text, 0, $max - 1)) . '…';
|
||||
}
|
||||
if (strlen($text) <= $max) return $text;
|
||||
return rtrim(substr($text, 0, $max - 1)) . '…';
|
||||
}
|
||||
|
||||
try {
|
||||
if (function_exists('pdo')) {
|
||||
// skills table -> 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'));
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><?= htmlspecialchars($profile['name']) ?> • Portfolio</title>
|
||||
|
||||
|
||||
<link rel="icon" href="/favicon.ico?v=3" sizes="any">
|
||||
|
||||
<link rel="apple-touch-icon" href="<?= htmlspecialchars($pfpUrl) ?>">
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
ink: "#07060a",
|
||||
night: "#0b0a12",
|
||||
violet: "#8b5cf6",
|
||||
magenta: "#d946ef",
|
||||
neon: "#a78bfa"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
|
||||
<link href="https://cdn.jsdelivr.net/npm/remixicon@4.3.0/fonts/remixicon.css" rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet" href="<?= htmlspecialchars(url_path('/public/css/app.css')) ?>" />
|
||||
|
||||
<!-- minimal CSS for active-section animation -->
|
||||
<style>
|
||||
.navlink { position: relative; border-radius: 999px; padding: .55rem .85rem; transition: transform .18s ease, background .18s ease, box-shadow .18s ease; }
|
||||
.navlink:hover { transform: translateY(-1px); }
|
||||
.navlink.is-active {
|
||||
background: rgba(139, 92, 246, .18);
|
||||
box-shadow: 0 0 0 1px rgba(167, 139, 250, .25) inset, 0 10px 30px rgba(139, 92, 246, .18);
|
||||
}
|
||||
.navlink.is-active::after{
|
||||
content:"";
|
||||
position:absolute;
|
||||
left:12px; right:12px;
|
||||
bottom:6px;
|
||||
height:2px;
|
||||
border-radius:999px;
|
||||
background: linear-gradient(90deg, rgba(139,92,246,.0), rgba(139,92,246,.9), rgba(217,70,239,.9), rgba(139,92,246,.0));
|
||||
filter: drop-shadow(0 0 8px rgba(167,139,250,.4));
|
||||
animation: glowline .9s ease;
|
||||
}
|
||||
@keyframes glowline { from { transform: scaleX(.2); opacity: .2;} to { transform: scaleX(1); opacity:1; } }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-ink text-white selection:bg-violet/40">
|
||||
<div class="bg-aurora" aria-hidden="true"></div>
|
||||
<div class="bg-grid" aria-hidden="true"></div>
|
||||
|
||||
<header class="fixed top-0 left-0 right-0 z-50">
|
||||
<div class="mx-auto max-w-6xl px-4 py-3">
|
||||
<div class="topbar-glass flex items-center justify-between gap-3 rounded-2xl px-4 py-3">
|
||||
<a href="#home" class="flex items-center gap-3 no-underline" data-scroll>
|
||||
<span class="brand-badge"><?= htmlspecialchars($initial) ?></span>
|
||||
<span class="font-semibold tracking-tight"><?= htmlspecialchars($profile['name']) ?></span>
|
||||
</a>
|
||||
|
||||
<nav class="hidden md:flex items-center gap-1" aria-label="Primary">
|
||||
<a class="navlink" href="#about" data-scroll><i class="ri-user-3-fill"></i><span>About</span></a>
|
||||
<a class="navlink" href="#projects" data-scroll><i class="ri-grid-fill"></i><span>Projects</span></a>
|
||||
<a class="navlink" href="#stack" data-scroll><i class="ri-stack-fill"></i><span>Stack</span></a>
|
||||
<a class="navlink" href="#gaming" data-scroll><i class="ri-gamepad-fill"></i><span>Gaming</span></a>
|
||||
<a class="navlink" href="#contact" data-scroll><i class="ri-mail-fill"></i><span>Contact</span></a>
|
||||
</nav>
|
||||
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<a href="<?= htmlspecialchars(url_path('/public/admin/login.php')) ?>" class="btn btn-outline-light btn-sm">
|
||||
<i class="ri-login-circle-line me-1"></i>Login
|
||||
</a>
|
||||
<a href="<?= htmlspecialchars(url_path('/public/admin/signup.php')) ?>" class="btn btn-light btn-sm">
|
||||
<i class="ri-user-add-line me-1"></i>Create
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button class="md:hidden btn btn-sm btn-outline-light" type="button"
|
||||
data-bs-toggle="offcanvas" data-bs-target="#mobileNav" aria-controls="mobileNav">
|
||||
<i class="fa-solid fa-bars"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="offcanvas offcanvas-end text-bg-dark" tabindex="-1" id="mobileNav" aria-labelledby="mobileNavLabel">
|
||||
<div class="offcanvas-header">
|
||||
<h5 class="offcanvas-title" id="mobileNavLabel">Menu</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
<a class="navlink block mb-2" href="#about" data-scroll><i class="ri-user-3-fill"></i><span>About</span></a>
|
||||
<a class="navlink block mb-2" href="#projects" data-scroll><i class="ri-grid-fill"></i><span>Projects</span></a>
|
||||
<a class="navlink block mb-2" href="#stack" data-scroll><i class="ri-stack-fill"></i><span>Stack</span></a>
|
||||
<a class="navlink block mb-2" href="#gaming" data-scroll><i class="ri-gamepad-fill"></i><span>Gaming</span></a>
|
||||
<a class="navlink block mb-2" href="#contact" data-scroll><i class="ri-mail-fill"></i><span>Contact</span></a>
|
||||
|
||||
<hr class="border-white/10 my-3">
|
||||
|
||||
<a href="<?= htmlspecialchars(url_path('/public/admin/login.php')) ?>" class="btn btn-outline-light w-100 mb-2">
|
||||
<i class="ri-login-circle-line me-1"></i>Login
|
||||
</a>
|
||||
<a href="<?= htmlspecialchars(url_path('/public/admin/signup.php')) ?>" class="btn btn-light w-100">
|
||||
<i class="ri-user-add-line me-1"></i>Create Account
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main id="home" class="pt-28">
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="mx-auto max-w-6xl px-4 py-10" id="hero">
|
||||
|
||||
<!-- CENTERED HERO (desktop + mobile) -->
|
||||
<div class="mx-auto max-w-3xl text-center space-y-5">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="pfp-wrap">
|
||||
<div class="pfp-ring"></div>
|
||||
<img
|
||||
class="pfp-img"
|
||||
src="<?= htmlspecialchars($pfpUrl) ?>"
|
||||
alt="Profile picture"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="pill inline-flex items-center gap-2">
|
||||
<span class="dot"></span> Not Avalaibe
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-center font-extrabold tracking-tight">
|
||||
<?php
|
||||
$name = trim((string)($profile['name'] ?? ''));
|
||||
$parts = preg_split('/\s+/', $name, 2);
|
||||
$first = $parts[0] ?? '';
|
||||
$last = $parts[1] ?? '';
|
||||
?>
|
||||
|
||||
<span class="block leading-none text-5xl sm:text-6xl md:text-7xl whitespace-nowrap">
|
||||
<span class="text-white/90 drop-shadow-md"><?= htmlspecialchars($first) ?></span>
|
||||
<span class="hero-gradient drop-shadow-md"> <?= $last !== '' ? ' ' . htmlspecialchars($last) : '' ?></span>
|
||||
</span>
|
||||
|
||||
|
||||
<span class="block mt-4 text-white/85 font-black leading-tight drop-shadow-md text-2xl sm:text-3xl md:text-4xl">
|
||||
<?= htmlspecialchars($profile['role']) ?>
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
|
||||
|
||||
<p class="text-white/70 text-lg">
|
||||
<?= htmlspecialchars($profile['location']) ?>.
|
||||
</p>
|
||||
|
||||
<!-- Spotify widget -->
|
||||
<div id="spotifyCard" class="card-glass p-4 rounded-2xl mx-auto" style="max-width: 560px;">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center justify-center gap-2 text-sm text-white/70 w-100">
|
||||
<i class="fa-brands fa-spotify text-green-400"></i>
|
||||
<span id="spStatus">Loading Spotify…</span>
|
||||
</div>
|
||||
<div class="pulse-bars" aria-hidden="true">
|
||||
<span></span><span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a id="spLink" href="#" target="_blank" class="mt-3 flex gap-3 items-center justify-center text-center no-underline">
|
||||
<img id="spArt" class="w-14 h-14 rounded-xl object-cover bg-white/5" alt="Album art" />
|
||||
<div class="min-w-0 text-center">
|
||||
<div id="spTitle" class="font-semibold truncate">—</div>
|
||||
<div id="spArtist" class="text-white/60 truncate text-sm">—</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 flex-wrap justify-center">
|
||||
<a href="#projects" class="btn btn-light btn-lg" data-scroll>
|
||||
<i class="ri-rocket-2-fill me-2"></i>Explore Projects
|
||||
</a>
|
||||
<a href="#contact" class="btn btn-outline-light btn-lg" data-scroll>
|
||||
<i class="ri-chat-3-fill me-2"></i>Contact
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 text-white/70 justify-center">
|
||||
<a class="iconlink" href="<?= htmlspecialchars($profile['github']) ?>" target="_blank" rel="noreferrer">
|
||||
<i class="fa-brands fa-github"></i>
|
||||
</a>
|
||||
<a class="iconlink" href="<?= htmlspecialchars($profile['instagram']) ?>" target="_blank" rel="noreferrer">
|
||||
<i class="fa-brands fa-instagram"></i>
|
||||
</a>
|
||||
<a class="iconlink" href="<?= htmlspecialchars($profile['tiktok']) ?>" target="_blank" rel="noreferrer">
|
||||
<i class="fa-brands fa-tiktok"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HERO CARDS -->
|
||||
<div class="mt-10 grid gap-4 lg:grid-cols-3 mx-auto" style="max-width: 980px;">
|
||||
<div class="card-glass rounded-2xl p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-white/70 text-sm">What I like building</div>
|
||||
<i class="ri-sparkling-2-fill text-violet text-xl"></i>
|
||||
</div>
|
||||
<ul class="mt-3 space-y-2 text-white/80">
|
||||
<li><i class="ri-check-line text-neon me-2"></i>Video Games & Websites.</li>
|
||||
<li><i class="ri-check-line text-neon me-2"></i>Discord Bots using discord.js.</li>
|
||||
<li><i class="ri-check-line text-neon me-2"></i>Tools & launchers.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card-glass rounded-2xl p-5">
|
||||
<div class="text-white/60 text-sm">Focus</div>
|
||||
<div class="text-2xl font-bold mt-1">Full Stack</div>
|
||||
<div class="text-white/60 text-sm mt-2">Lua • PHP • AJAX • jQuery • MariaDB • C# • C++</div>
|
||||
<div class="mt-4 text-white/70 text-sm">
|
||||
Build fast → harden security → ship clean UI.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-glass rounded-2xl p-5">
|
||||
<div class="text-white/60 text-sm">Workflow</div>
|
||||
<div class="text-2xl font-bold mt-1">Git + Gitea</div>
|
||||
<div class="text-white/60 text-sm mt-2">Clean commits, tags</div>
|
||||
<div class="mt-4 text-white/70 text-sm">
|
||||
Stable releases + readable history.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
<!-- ABOUT ME -->
|
||||
<section id="about" class="mx-auto max-w-6xl px-4 py-14">
|
||||
<div class="flex items-end justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold">About Me</h2>
|
||||
</div>
|
||||
<div class="text-white/60 text-sm">
|
||||
<i class="ri-map-pin-2-fill text-neon"></i> <?= htmlspecialchars($profile['location']) ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-4 lg:grid-cols-3">
|
||||
<div class="card-glass rounded-2xl p-5 lg:col-span-2">
|
||||
<div class="text-white/80 leading-relaxed space-y-3">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<span class="tag">Video Games</span>
|
||||
<span class="tag">APIs</span>
|
||||
<span class="tag">Automation</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-glass rounded-2xl p-5">
|
||||
<div class="font-semibold mb-2">Quick facts</div>
|
||||
<ul class="space-y-2 text-white/70">
|
||||
<li><i class="ri-flashlight-fill text-neon me-2"></i>Fast prototyping → clean refactor</li>
|
||||
<li><i class="ri-shield-check-fill text-neon me-2"></i>Secure auth + CSRF patterns</li>
|
||||
<li><i class="ri-code-box-fill text-neon me-2"></i>Git workflow daily</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PROJECTS -->
|
||||
<section id="projects" class="mx-auto max-w-6xl px-4 py-14">
|
||||
<div class="flex items-end justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold">Project Library</h2>
|
||||
<p class="text-white/60">Click a card for details.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="chip active" data-filter="all">All</button>
|
||||
<?php foreach ($projectTags as $tag): ?>
|
||||
<button class="chip" data-filter="<?= htmlspecialchars(strtolower((string)$tag)) ?>">
|
||||
<?= htmlspecialchars((string)$tag) ?>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-4 md:grid-cols-2 lg:grid-cols-3 project-grid">
|
||||
<?php foreach ($projects as $p): ?>
|
||||
<button type="button"
|
||||
class="project-card card-glass rounded-2xl p-5 text-start"
|
||||
data-project-id="<?= htmlspecialchars((string)$p['id']) ?>"
|
||||
data-project-tag="<?= htmlspecialchars(strtolower((string)$p['tag'])) ?>">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs text-white/60"><?= htmlspecialchars((string)$p['year']) ?></div>
|
||||
<span class="badge rounded-pill text-bg-light"><?= htmlspecialchars((string)$p['tag']) ?></span>
|
||||
</div>
|
||||
<div class="mt-3 text-xl font-bold"><?= htmlspecialchars((string)$p['title']) ?></div>
|
||||
<?php $cardSummary = (string)($p['summary_short'] ?? $p['summary'] ?? ''); ?>
|
||||
<div class="mt-2 text-white/70 summary-clamp"><?= htmlspecialchars($cardSummary) ?></div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2 tech-row">
|
||||
<?php foreach (($p['tech'] ?? []) as $t): ?>
|
||||
<span class="tag"><?= htmlspecialchars((string)$t) ?></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 text-white/70 flex items-center gap-2 project-footer">
|
||||
<i class="ri-information-fill"></i><span>View details</span>
|
||||
</div>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- STACK -->
|
||||
<section id="stack" class="mx-auto max-w-6xl px-4 py-14">
|
||||
<h2 class="text-3xl font-bold">Tech Stack</h2>
|
||||
|
||||
<div class="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
<?php foreach ($stack as $lang): ?>
|
||||
<div class="card-glass rounded-2xl p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="<?= htmlspecialchars((string)$lang['icon']) ?> text-2xl text-neon"></i>
|
||||
<div class="font-semibold"><?= htmlspecialchars((string)$lang['label']) ?></div>
|
||||
</div>
|
||||
<div class="text-white/60 text-sm"><?= (int)$lang['level'] ?>%</div>
|
||||
</div>
|
||||
<div class="bar mt-4" data-level="<?= (int)$lang['level'] ?>">
|
||||
<div class="bar-fill" style="width:0%"></div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($lang['frameworks'])): ?>
|
||||
<div class="mt-4 text-white/60 text-sm">Frameworks / Librarys / Game Engines</div>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<?php foreach ($lang['frameworks'] as $fw): ?>
|
||||
<span class="tag">
|
||||
<i class="<?= htmlspecialchars((string)$fw['icon']) ?> me-1"></i>
|
||||
<?= htmlspecialchars((string)$fw['label']) ?>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="mt-3 text-white/50 text-sm">No frameworks added yet.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (!$stack): ?>
|
||||
<div class="text-white/60">No skills added yet.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="mt-10">
|
||||
<h3 class="text-2xl font-bold">Frameworks / Librarys / Game Engines</h3>
|
||||
|
||||
<div class="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<?php foreach ($frameworks as $fw): ?>
|
||||
<div class="card-glass rounded-2xl p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="<?= htmlspecialchars((string)$fw['icon'] ?? 'ri-code-s-slash-line') ?> text-2xl text-neon"></i>
|
||||
<div>
|
||||
<div class="font-semibold"><?= htmlspecialchars((string)$fw['label']) ?></div>
|
||||
<?php
|
||||
$langs = (array)($fw['language_labels'] ?? []);
|
||||
$langLine = implode(', ', array_filter($langs, 'strlen'));
|
||||
?>
|
||||
<?php if ($langLine !== ''): ?>
|
||||
<div class="text-white/50 text-xs">Languages: <?= htmlspecialchars($langLine) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-white/60 text-sm"><?= (int)($fw['level'] ?? 0) ?>%</div>
|
||||
</div>
|
||||
<div class="bar mt-4" data-level="<?= (int)($fw['level'] ?? 0) ?>">
|
||||
<div class="bar-fill" style="width:0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (!$frameworks): ?>
|
||||
<div class="text-white/60">No frameworks added yet.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="gaming" class="mx-auto max-w-6xl px-4 py-14">
|
||||
<h2 class="text-3xl font-bold">Gaming + Web Focus</h2>
|
||||
|
||||
<div class="mt-6 grid gap-4 lg:grid-cols-3">
|
||||
<div class="card-glass rounded-2xl p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-bold">1) Community Websites</div>
|
||||
<i class="ri-group-fill text-violet text-xl"></i>
|
||||
</div>
|
||||
<p class="text-white/70 mt-2">
|
||||
Landing pages, team/clean sites, game community hubs - fast, clean and easy to use.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card-glass rounded-2xl p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-bold">2) Dashboards</div>
|
||||
<i class="ri-dashboard-2-fill text-violet text-xl"></i>
|
||||
</div>
|
||||
<p class="text-white/70 mt-2">
|
||||
Simple dashboards for data, versions, stats and pages that keep things organized.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card-glass rounded-2xl p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-bold">3) APIs + Logic</div>
|
||||
<i class="ri-code-box-fill text-violet text-xl"></i>
|
||||
</div>
|
||||
<p class="text-white/70 mt-2">
|
||||
PHP + SQL + AJAX/jQuery to connect APIs and build real functionality behind the UI.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- CONTACT -->
|
||||
<section id="contact" class="mx-auto max-w-6xl px-4 py-14 pb-24">
|
||||
<h2 class="text-3xl font-bold">Get in Touch</h2>
|
||||
|
||||
<div class="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
<div class="card-glass rounded-2xl p-5">
|
||||
<form id="contactForm" class="space-y-3" autocomplete="off">
|
||||
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrfToken) ?>">
|
||||
<input type="text" name="website" class="hidden" tabindex="-1" autocomplete="off">
|
||||
|
||||
<div>
|
||||
<label class="text-sm text-white/70">Name</label>
|
||||
<input name="name" class="field" required maxlength="80" placeholder="Your name">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-white/70">Email</label>
|
||||
<input name="email" type="email" class="field" required maxlength="120" placeholder="you@domain.com">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-white/70">Message</label>
|
||||
<textarea name="message" class="field h-32" required maxlength="2000"
|
||||
placeholder="Tell me about your project…"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-light btn-lg w-100">
|
||||
<i class="ri-send-plane-2-fill me-2"></i>Send Message
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card-glass rounded-2xl p-5">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<a class="social" href="<?= htmlspecialchars($profile['github']) ?>" target="_blank" rel="noreferrer">
|
||||
<i class="fa-brands fa-github"></i><span>GitHub</span>
|
||||
</a>
|
||||
<a class="social" href="<?= htmlspecialchars($profile['instagram']) ?>" target="_blank" rel="noreferrer">
|
||||
<i class="fa-brands fa-instagram"></i><span>Instagram</span>
|
||||
</a>
|
||||
<a class="social" href="<?= htmlspecialchars($profile['tiktok']) ?>" target="_blank" rel="noreferrer">
|
||||
<i class="fa-brands fa-tiktok"></i><span>TikTok</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 text-white/70">
|
||||
<div class="font-semibold">What to include</div>
|
||||
<ul class="mt-2 space-y-1">
|
||||
<li><i class="ri-arrow-right-s-line text-neon"></i>Goal (website, dashboard, tool)</li>
|
||||
<li><i class="ri-arrow-right-s-line text-neon"></i>Deadline + must-have features</li>
|
||||
<li><i class="ri-arrow-right-s-line text-neon"></i>Any game/community context</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="py-10 text-center text-white/50">
|
||||
<div>© <?= date('Y') ?> <?= htmlspecialchars($profile['name']) ?> • Built with PHP • Tailwind • Bootstrap • AJAX • jQuery</div>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<div class="modal fade" id="projectModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content text-bg-dark border border-white/10">
|
||||
<div class="modal-header border-white/10">
|
||||
<h5 class="modal-title" id="pmTitle">Project</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="text-white/70" id="pmSummary"></div>
|
||||
<div class="mt-3 flex flex-wrap gap-2" id="pmTech"></div>
|
||||
<div class="mt-4 flex gap-2 flex-wrap" id="pmLinks"></div>
|
||||
<div class="mt-4" id="pmGalleryWrap">
|
||||
<div class="text-white/60 text-sm mb-2">Gallery</div>
|
||||
<div class="pm-gallery" id="pmGallery"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div id="appToast" class="toast text-bg-dark border border-white/10" role="alert" aria-live="polite">
|
||||
<div class="toast-body" id="toastMsg">…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.PROJECTS = <?= $projectsJson ?>;
|
||||
</script>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="<?= htmlspecialchars(url_path('/public/js/app.js')) ?>"></script>
|
||||
<script>
|
||||
window.CONTACT_ENDPOINT = <?= json_encode(url_path('/api/contact.php'), JSON_UNESCAPED_SLASHES) ?>;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
6
public/admin/_bottom.php
Normal file
6
public/admin/_bottom.php
Normal file
@@ -0,0 +1,6 @@
|
||||
</div>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
45
public/admin/_top.php
Normal file
45
public/admin/_top.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (!isset($pageTitle)) $pageTitle = 'Admin';
|
||||
$csrf = csrf_token();
|
||||
$flash = flash_get();
|
||||
?>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title><?= htmlspecialchars($pageTitle) ?></title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<?php if (!empty($extraCss)): ?>
|
||||
<?php foreach ((array)$extraCss as $css): ?>
|
||||
<link rel="stylesheet" href="<?= htmlspecialchars((string)$css) ?>">
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||
<div class="container py-3">
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#nav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="nav">
|
||||
<ul class="navbar-nav ms-auto gap-1">
|
||||
<li class="nav-item"><a class="nav-link" href="/public/admin/projects.php">Projects</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/public/admin/skills.php">Skills</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/public/admin/contacts.php">Contacts</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/">View site</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/public/admin/logout.php">Logout</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container pb-5">
|
||||
<?php if ($flash): ?>
|
||||
<div class="alert alert-<?= htmlspecialchars($flash['type']) ?> card-glass border-0 mt-3">
|
||||
<?= htmlspecialchars($flash['msg']) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
278
public/admin/contacts.php
Normal file
278
public/admin/contacts.php
Normal file
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../../includes/bootstrap.php';
|
||||
require_admin_login();
|
||||
|
||||
$pageTitle = 'Contact Requests | Admin';
|
||||
|
||||
// ensure table exists (aligns with API)
|
||||
$tableError = '';
|
||||
try {
|
||||
pdo()->exec("
|
||||
CREATE TABLE IF NOT EXISTS contact_requests (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(80) NOT NULL,
|
||||
email VARCHAR(120) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
status ENUM('new','read','archived') NOT NULL DEFAULT 'new',
|
||||
created_at DATETIME NOT NULL,
|
||||
ip VARCHAR(45) NULL,
|
||||
user_agent VARCHAR(255) NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
");
|
||||
} catch (Throwable $e) {
|
||||
$tableError = 'Could not ensure contact table exists.';
|
||||
}
|
||||
|
||||
$statusFilter = (string)($_GET['status'] ?? 'all');
|
||||
$validStatuses = ['all', 'new', 'read', 'archived'];
|
||||
if (!in_array($statusFilter, $validStatuses, true)) $statusFilter = 'all';
|
||||
|
||||
$counts = ['total' => 0, 'new' => 0, 'read' => 0, 'archived' => 0];
|
||||
try {
|
||||
$counts['total'] = (int)pdo()->query("SELECT COUNT(*) FROM contact_requests")->fetchColumn();
|
||||
$counts['new'] = (int)pdo()->query("SELECT COUNT(*) FROM contact_requests WHERE status='new'")->fetchColumn();
|
||||
$counts['read'] = (int)pdo()->query("SELECT COUNT(*) FROM contact_requests WHERE status='read'")->fetchColumn();
|
||||
$counts['archived'] = (int)pdo()->query("SELECT COUNT(*) FROM contact_requests WHERE status='archived'")->fetchColumn();
|
||||
} catch (Throwable $e) {}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!csrf_check((string)($_POST['csrf'] ?? ''))) {
|
||||
flash_set('danger', 'Bad CSRF token.');
|
||||
header('Location: /public/admin/contacts.php?status=' . urlencode($statusFilter));
|
||||
exit;
|
||||
}
|
||||
|
||||
$action = (string)($_POST['action'] ?? '');
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
|
||||
if ($id > 0) {
|
||||
if ($action === 'set_status') {
|
||||
$newStatus = (string)($_POST['status'] ?? '');
|
||||
if (in_array($newStatus, ['new', 'read', 'archived'], true)) {
|
||||
$st = pdo()->prepare("UPDATE contact_requests SET status=? WHERE id=?");
|
||||
$st->execute([$newStatus, $id]);
|
||||
flash_set('success', 'Status updated.');
|
||||
}
|
||||
} elseif ($action === 'delete') {
|
||||
$st = pdo()->prepare("DELETE FROM contact_requests WHERE id=?");
|
||||
$st->execute([$id]);
|
||||
flash_set('success', 'Contact deleted.');
|
||||
}
|
||||
}
|
||||
|
||||
header('Location: /public/admin/contacts.php?status=' . urlencode($statusFilter));
|
||||
exit;
|
||||
}
|
||||
|
||||
$requests = [];
|
||||
if (!$tableError) {
|
||||
try {
|
||||
$sql = "SELECT id, name, email, message, status, created_at, ip FROM contact_requests";
|
||||
$params = [];
|
||||
if ($statusFilter !== 'all') {
|
||||
$sql .= " WHERE status = ?";
|
||||
$params[] = $statusFilter;
|
||||
}
|
||||
$sql .= " ORDER BY created_at DESC, id DESC LIMIT 200";
|
||||
$st = pdo()->prepare($sql);
|
||||
$st->execute($params);
|
||||
$requests = $st->fetchAll() ?: [];
|
||||
} catch (Throwable $e) {
|
||||
$tableError = 'Could not load contact requests.';
|
||||
}
|
||||
}
|
||||
|
||||
$extraCss = ['/public/css/contacts.css'];
|
||||
include __DIR__ . '/_top.php';
|
||||
?>
|
||||
|
||||
<div class="bg-aurora"></div>
|
||||
<div class="bg-grid"></div>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mt-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<div class="text-white-50 small">Inbox</div>
|
||||
<h1 class="h3 m-0 brand">Contact Requests</h1>
|
||||
<div class="text-white-50 small">Review and triage all incoming messages.</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a class="btn btn-outline-light" href="/public/admin/dashboard.php">Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($tableError): ?>
|
||||
<div class="alert alert-danger card-glass border-0 mt-3"><?= htmlspecialchars($tableError) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($flash): ?>
|
||||
<div class="alert alert-<?= htmlspecialchars($flash['type']) ?> card-glass border-0 mt-3"><?= htmlspecialchars($flash['msg']) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row g-3 mt-3">
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="card card-glass border-0 h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<div class="text-white-50 small">New</div>
|
||||
<div class="h3 m-0 fw-bold text-white"><?= (int)$counts['new'] ?></div>
|
||||
</div>
|
||||
<span class="badge bg-light text-dark">Inbox</span>
|
||||
</div>
|
||||
<div class="mt-3 text-white-50 small">Unread messages waiting for review.</div>
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<a class="btn btn-violet w-100" href="/public/admin/contacts.php?status=new">View new</a>
|
||||
<a class="btn btn-outline-light w-100" href="/public/admin/contacts.php">All</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="card card-glass border-0">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<div>
|
||||
<div class="text-white-50 small">Totals</div>
|
||||
<div class="fw-bold text-white"><?= (int)$counts['total'] ?> messages</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<span class="pill">Read: <?= (int)$counts['read'] ?></span>
|
||||
<span class="pill">Archived: <?= (int)$counts['archived'] ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-glass border-0 mt-3">
|
||||
<div class="p-3 border-bottom border-white/10 d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<div class="fw-bold">Latest messages</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<?php foreach ($validStatuses as $s): ?>
|
||||
<?php $isActive = ($statusFilter === $s); ?>
|
||||
<a class="btn btn-sm <?= $isActive ? 'btn-violet' : 'btn-outline-light' ?>"
|
||||
href="/public/admin/contacts.php?status=<?= urlencode($s) ?>">
|
||||
<?= htmlspecialchars(ucfirst($s)) ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0 align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:70px">ID</th>
|
||||
<th>From</th>
|
||||
<th style="width:140px">Status</th>
|
||||
<th>Message</th>
|
||||
<th style="width:160px">Received</th>
|
||||
<th style="width:190px">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($requests as $c): ?>
|
||||
<?php
|
||||
$snippet = mb_strimwidth((string)$c['message'], 0, 120, '…', 'UTF-8');
|
||||
$status = (string)$c['status'];
|
||||
$created = (string)$c['created_at'];
|
||||
?>
|
||||
<tr>
|
||||
<td class="text-white-50">#<?= (int)$c['id'] ?></td>
|
||||
<td>
|
||||
<div class="fw-semibold"><?= htmlspecialchars((string)$c['name']) ?></div>
|
||||
<div class="text-white-50 small">
|
||||
<a class="text-white-50" href="mailto:<?= htmlspecialchars((string)$c['email']) ?>">
|
||||
<?= htmlspecialchars((string)$c['email']) ?>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge text-bg-light"><?= htmlspecialchars(ucfirst($status)) ?></span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-white"><?= htmlspecialchars($snippet) ?></div>
|
||||
<button
|
||||
class="btn btn-outline-light btn-sm mt-1 view-msg"
|
||||
type="button"
|
||||
data-name="<?= htmlspecialchars((string)$c['name'], ENT_QUOTES) ?>"
|
||||
data-email="<?= htmlspecialchars((string)$c['email'], ENT_QUOTES) ?>"
|
||||
data-message="<?= htmlspecialchars((string)$c['message'], ENT_QUOTES) ?>"
|
||||
>View</button>
|
||||
</td>
|
||||
<td>
|
||||
<div><?= htmlspecialchars($created) ?></div>
|
||||
<?php if (!empty($c['ip'])): ?>
|
||||
<div class="text-white-50 small"><?= htmlspecialchars((string)$c['ip']) ?></div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
|
||||
<input type="hidden" name="action" value="set_status">
|
||||
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
|
||||
<input type="hidden" name="status" value="<?= $status === 'new' ? 'read' : 'new' ?>">
|
||||
<button class="btn btn-outline-light btn-sm"><?= $status === 'new' ? 'Mark read' : 'Mark new' ?></button>
|
||||
</form>
|
||||
<form method="post" class="d-inline ms-1">
|
||||
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
|
||||
<input type="hidden" name="action" value="set_status">
|
||||
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
|
||||
<input type="hidden" name="status" value="archived">
|
||||
<button class="btn btn-outline-secondary btn-sm">Archive</button>
|
||||
</form>
|
||||
<form method="post" class="d-inline ms-1" onsubmit="return confirm('Delete this message?');">
|
||||
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
|
||||
<button class="btn btn-outline-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (!$requests): ?>
|
||||
<tr><td colspan="6" class="text-center text-white-50 py-4">No contact requests yet.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="msgModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content text-bg-dark border border-white/10">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="msgTitle">Message</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="text-white/70 small" id="msgMeta"></div>
|
||||
<pre class="mt-3 text-white" style="white-space: pre-wrap;" id="msgBody"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function ($) {
|
||||
$(function () {
|
||||
$('.view-msg').on('click', function () {
|
||||
const $btn = $(this);
|
||||
const name = $btn.data('name') || 'Unknown';
|
||||
const email = $btn.data('email') || '';
|
||||
const msg = $btn.data('message') || '';
|
||||
$('#msgTitle').text(name);
|
||||
$('#msgMeta').text(email);
|
||||
$('#msgBody').text(msg);
|
||||
const modal = bootstrap.Modal.getOrCreateInstance($('#msgModal')[0]);
|
||||
modal.show();
|
||||
});
|
||||
});
|
||||
})(jQuery);
|
||||
</script>
|
||||
|
||||
<?php include __DIR__ . '/_bottom.php'; ?>
|
||||
258
public/admin/dashboard.php
Normal file
258
public/admin/dashboard.php
Normal file
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/../../includes/bootstrap.php';
|
||||
require_admin_login();
|
||||
|
||||
if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(32));
|
||||
|
||||
$f = flash_get();
|
||||
|
||||
$projectsCount = 0;
|
||||
$skillsCount = 0;
|
||||
$contactCounts = ['total' => 0, 'new' => 0];
|
||||
$recentContacts = [];
|
||||
try { $projectsCount = (int)pdo()->query("SELECT COUNT(*) FROM projects")->fetchColumn(); } catch (Throwable $e) {}
|
||||
try { $skillsCount = (int)pdo()->query("SELECT COUNT(*) FROM skills")->fetchColumn(); } catch (Throwable $e) {}
|
||||
try {
|
||||
pdo()->exec("
|
||||
CREATE TABLE IF NOT EXISTS contact_requests (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(80) NOT NULL,
|
||||
email VARCHAR(120) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
status ENUM('new','read','archived') NOT NULL DEFAULT 'new',
|
||||
created_at DATETIME NOT NULL,
|
||||
ip VARCHAR(45) NULL,
|
||||
user_agent VARCHAR(255) NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
");
|
||||
$contactCounts['total'] = (int)pdo()->query("SELECT COUNT(*) FROM contact_requests")->fetchColumn();
|
||||
$contactCounts['new'] = (int)pdo()->query("SELECT COUNT(*) FROM contact_requests WHERE status='new'")->fetchColumn();
|
||||
$recentContacts = pdo()->query("SELECT id, name, email, message, status, created_at FROM contact_requests ORDER BY created_at DESC, id DESC LIMIT 5")->fetchAll() ?: [];
|
||||
} catch (Throwable $e) {}
|
||||
|
||||
$hasRefresh = false;
|
||||
$hasAccess = false;
|
||||
try {
|
||||
$row = pdo()->query("SELECT refresh_token, access_token, access_expires FROM spotify_tokens WHERE id=1")->fetch();
|
||||
if ($row) {
|
||||
$hasRefresh = !empty($row['refresh_token']);
|
||||
$hasAccess = !empty($row['access_token']) && (int)($row['access_expires'] ?? 0) > (time() + 30);
|
||||
}
|
||||
} catch (Throwable $e) {}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Admin Dashboard</title>
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/remixicon@4.6.0/fonts/remixicon.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/public/css/admin.css">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: { extend: { colors: { ink:"#07060a", neon:"#a78bfa" } } }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.eq span{height:6px}
|
||||
.sp-playing .eq span{animation:eq .8s infinite ease-in-out alternate}
|
||||
.sp-playing .eq span:nth-child(2){animation-delay:.15s}
|
||||
.sp-playing .eq span:nth-child(3){animation-delay:.3s}
|
||||
.sp-playing .eq span:nth-child(4){animation-delay:.45s}
|
||||
@keyframes eq { from{height:4px} to{height:14px} }
|
||||
.sp-playing #spImg{animation:spin 4s linear infinite}
|
||||
@keyframes spin { from{transform:rotate(0)} to{transform:rotate(360deg)} }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="min-vh-100 bg-ink text-white">
|
||||
<div class="max-w-6xl mx-auto px-4 py-6">
|
||||
<div class="flex items-center justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight">Dashboard</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<a class="btn btn-outline-light rounded-xl" href="<?= htmlspecialchars(url_path('/')) ?>">Home</a>
|
||||
<a class="btn btn-outline-light rounded-xl" href="<?= htmlspecialchars(url_path('/public/admin/logout.php')) ?>">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($f): ?>
|
||||
<div class="alert alert-<?= htmlspecialchars($f['type']) ?> mb-4"><?= htmlspecialchars($f['msg']) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<!-- Projects -->
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="w-11 h-11 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
|
||||
<i class="ri-layout-4-line"></i>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-white/50 text-sm">Projects</div>
|
||||
<div class="text-3xl font-extrabold tracking-tight"><?= $projectsCount ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-white/10 flex gap-2">
|
||||
<a class="btn btn-primary w-100 rounded-xl" href="<?= htmlspecialchars(url_path('/public/admin/projects.php?new=1')) ?>">Add</a>
|
||||
<a class="btn btn-outline-light w-100 rounded-xl" href="<?= htmlspecialchars(url_path('/public/admin/projects.php')) ?>">Manage</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skills -->
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="w-11 h-11 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
|
||||
<i class="ri-stack-fill"></i>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-white/50 text-sm">Tech Stack</div>
|
||||
<div class="text-3xl font-extrabold tracking-tight"><?= $skillsCount ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-white/50 text-sm mt-2">Edit languages and frameworks for your homepage.</div>
|
||||
<div class="mt-4 pt-4 border-t border-white/10 flex gap-2">
|
||||
<a class="btn btn-primary w-100 rounded-xl" href="<?= htmlspecialchars(url_path('/public/admin/skills.php?new=1')) ?>">Add</a>
|
||||
<a class="btn btn-outline-light w-100 rounded-xl" href="<?= htmlspecialchars(url_path('/public/admin/skills.php')) ?>">Adjust</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contacts -->
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="w-11 h-11 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
|
||||
<i class="ri-mail-send-line"></i>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-white/50 text-sm">Contacts</div>
|
||||
<div class="text-3xl font-extrabold tracking-tight"><?= (int)$contactCounts['total'] ?></div>
|
||||
<div class="text-xs text-white/60"><?= (int)$contactCounts['new'] ?> new</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-white/10 flex gap-2">
|
||||
<a class="btn btn-primary w-100 rounded-xl" href="<?= htmlspecialchars(url_path('/public/admin/contacts.php')) ?>">Inbox</a>
|
||||
<a class="btn btn-outline-light w-100 rounded-xl" href="<?= htmlspecialchars(url_path('/public/admin/contacts.php?status=new')) ?>">New</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spotify -->
|
||||
<div id="spAdminCard" class="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="w-11 h-11 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
|
||||
<i class="ri-spotify-line"></i>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-white/50 text-sm">Spotify</div>
|
||||
<div class="font-semibold">
|
||||
<?= $hasRefresh ? 'Refresh token set' : ($hasAccess ? 'Access token set' : 'Not configured') ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-4 border-t border-white/10">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-white/50 text-sm" id="spMode">Offline</div>
|
||||
<div class="eq flex items-end gap-[3px]" aria-hidden="true">
|
||||
<span class="w-[3px] rounded bg-green-500 opacity-90"></span>
|
||||
<span class="w-[3px] rounded bg-green-500 opacity-90"></span>
|
||||
<span class="w-[3px] rounded bg-green-500 opacity-90"></span>
|
||||
<span class="w-[3px] rounded bg-green-500 opacity-90"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a id="spA" href="#" target="_blank" class="mt-3 flex items-center gap-3 no-underline text-white">
|
||||
<img id="spImg" class="w-14 h-14 rounded-2xl object-cover bg-white/5 border border-white/10" alt="Album art">
|
||||
<div class="min-w-0">
|
||||
<div id="spT" class="font-semibold truncate">-</div>
|
||||
<div id="spR" class="text-white/60 text-sm truncate">-</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="text-white/50 text-sm mt-2">
|
||||
Shows <b>Now Playing</b> or <b>Last Played</b>. Temporary token expires; refresh token is best.
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-3">
|
||||
<a class="btn btn-outline-light w-100 rounded-xl" href="<?= htmlspecialchars(url_path('/public/admin/spotify_token.php')) ?>">Set Token</a>
|
||||
<button class="btn btn-primary w-100 rounded-xl" id="spRefreshBtn" type="button">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($recentContacts): ?>
|
||||
<div class="mt-6 card-glass rounded-2xl p-4">
|
||||
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-2">
|
||||
<div>
|
||||
<div class="text-white-50 text-sm">Inbox preview</div>
|
||||
<div class="font-bold">Latest contact requests</div>
|
||||
</div>
|
||||
<a class="btn btn-outline-light btn-sm rounded-xl" href="<?= htmlspecialchars(url_path('/public/admin/contacts.php')) ?>">View all</a>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
<?php foreach ($recentContacts as $c): ?>
|
||||
<div class="list-group-item bg-transparent text-white px-0 d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="fw-semibold"><?= htmlspecialchars((string)$c['name']) ?></div>
|
||||
<div class="text-white-50 small"><?= htmlspecialchars(mb_strimwidth((string)$c['message'], 0, 120, '...')) ?></div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<span class="badge text-bg-light"><?= htmlspecialchars(ucfirst((string)$c['status'])) ?></span>
|
||||
<div class="text-white-50 small"><?= htmlspecialchars((string)$c['created_at']) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
(function($){
|
||||
function render(res){
|
||||
const $card = $("#spAdminCard");
|
||||
$card.removeClass("sp-playing");
|
||||
|
||||
if(!res || !res.ok){
|
||||
$("#spMode").text("Offline");
|
||||
$("#spT").text("Not configured");
|
||||
$("#spR").text("Set token to enable");
|
||||
$("#spA").attr("href","#");
|
||||
$("#spImg").removeAttr("src");
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = res.mode || "offline";
|
||||
const t = res.track || {};
|
||||
|
||||
$("#spMode").text(mode === "playing" ? "Now Playing" : (mode === "recent" ? "Last Played" : "Offline"));
|
||||
$("#spT").text(t.title || "-");
|
||||
$("#spR").text(t.artist || "-");
|
||||
$("#spA").attr("href", t.url || "#");
|
||||
if(t.art) $("#spImg").attr("src", t.art);
|
||||
|
||||
if(mode === "playing") $card.addClass("sp-playing");
|
||||
}
|
||||
|
||||
function load(){
|
||||
$.ajax({ url:"/api/spotify.php", method:"GET", dataType:"json", cache:false, timeout:8000 })
|
||||
.done(render)
|
||||
.fail(()=>render(null));
|
||||
}
|
||||
|
||||
$(function(){
|
||||
$("#spRefreshBtn").on("click", load);
|
||||
load();
|
||||
});
|
||||
})(jQuery);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
170
public/admin/login.php
Normal file
170
public/admin/login.php
Normal file
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../../includes/bootstrap.php';
|
||||
|
||||
// already logged in? go dashboard
|
||||
if (admin_is_logged_in()) {
|
||||
header('Location: ' . url_path('/public/admin/dashboard.php'));
|
||||
exit;
|
||||
}
|
||||
|
||||
$err = '';
|
||||
$remember = false;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$csrf = (string)($_POST['csrf'] ?? '');
|
||||
$user = trim((string)($_POST['username'] ?? ''));
|
||||
$pass = (string)($_POST['password'] ?? '');
|
||||
$remember = isset($_POST['remember']) && (string)$_POST['remember'] !== '';
|
||||
|
||||
if (!csrf_check($csrf)) {
|
||||
$err = 'Invalid session. Refresh and try again.';
|
||||
} elseif ($user === '' || strlen($user) > 50) {
|
||||
$err = 'Invalid username.';
|
||||
} elseif ($pass === '') {
|
||||
$err = 'Password is required.';
|
||||
} else {
|
||||
// Prepared statement => SQLi safe
|
||||
$st = pdo()->prepare("SELECT id, username, pass_hash FROM admin_users WHERE username = ? LIMIT 1");
|
||||
$st->execute([$user]);
|
||||
$a = $st->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$a || empty($a['pass_hash']) || !password_verify($pass, (string)$a['pass_hash'])) {
|
||||
$err = 'Wrong username or password.';
|
||||
} else {
|
||||
$algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_DEFAULT;
|
||||
$opts = ['memory_cost' => 1 << 17, 'time_cost' => 4, 'threads' => 2];
|
||||
if (password_needs_rehash((string)$a['pass_hash'], $algo, $opts)) {
|
||||
try {
|
||||
$newHash = password_hash($pass, $algo, $opts);
|
||||
$up = pdo()->prepare("UPDATE admin_users SET pass_hash=? WHERE id=?");
|
||||
$up->execute([$newHash, (int)$a['id']]);
|
||||
} catch (Throwable $e) {
|
||||
// best-effort; continue login
|
||||
}
|
||||
}
|
||||
|
||||
admin_login($a, $remember);
|
||||
header('Location: ' . url_path('/public/admin/dashboard.php'));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Admin Login</title>
|
||||
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/remixicon@4.6.0/fonts/remixicon.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
.bg-aurora{position:fixed;inset:0;background:
|
||||
radial-gradient(1000px 500px at 20% 20%, rgba(139,92,246,.30), transparent 60%),
|
||||
radial-gradient(900px 600px at 80% 30%, rgba(217,70,239,.22), transparent 60%),
|
||||
radial-gradient(900px 700px at 55% 90%, rgba(167,139,250,.18), transparent 60%);z-index:-2;}
|
||||
.bg-grid{position:fixed;inset:0;background-image:
|
||||
linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px);
|
||||
background-size:48px 48px;opacity:.12;mask-image:radial-gradient(circle at 50% 20%, #000 0%, transparent 65%);z-index:-1;}
|
||||
.glass{background:rgba(16,14,26,.62);border:1px solid rgba(255,255,255,.10);
|
||||
backdrop-filter:blur(18px) saturate(150%);box-shadow:0 12px 50px rgba(0,0,0,.45);}
|
||||
.btn-glow{box-shadow:0 0 0 1px rgba(167,139,250,.25), 0 18px 40px rgba(139,92,246,.25);}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="min-h-screen bg-black text-white flex items-center justify-center px-4">
|
||||
<div class="bg-aurora"></div>
|
||||
<div class="bg-grid"></div>
|
||||
|
||||
<div class="w-full max-w-md">
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-12 w-12 grid place-items-center rounded-2xl bg-gradient-to-br from-violet-500 to-fuchsia-500 font-black text-black">GM</div>
|
||||
<div>
|
||||
<div class="text-2xl font-extrabold bg-gradient-to-r from-violet-300 via-fuchsia-400 to-violet-400 bg-clip-text text-transparent">
|
||||
Welcome back
|
||||
</div>
|
||||
<div class="text-sm text-white/60">Login to manage your portfolio</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden sm:flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-2 text-xs text-white/80">
|
||||
<i class="ri-shield-keyhole-line text-fuchsia-300"></i> Secure
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass rounded-3xl p-6 relative overflow-hidden">
|
||||
<div class="pointer-events-none absolute inset-0 rounded-3xl ring-1 ring-white/10"></div>
|
||||
|
||||
<?php if ($err): ?>
|
||||
<div class="mb-4 rounded-2xl border border-red-400/20 bg-red-500/10 px-4 py-3 text-sm">
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="ri-error-warning-line mt-0.5 text-red-300"></i>
|
||||
<div><?= htmlspecialchars($err) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" autocomplete="off" class="space-y-4">
|
||||
<input type="hidden" name="csrf" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm text-white/70">Username</label>
|
||||
<div class="relative">
|
||||
<input name="username" maxlength="50" required
|
||||
value="<?= htmlspecialchars((string)($_POST['username'] ?? '')) ?>"
|
||||
placeholder="admin"
|
||||
class="w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 pr-11 text-white placeholder:text-white/35 outline-none
|
||||
focus:border-violet-400/50 focus:ring-4 focus:ring-violet-500/20">
|
||||
<i class="ri-user-3-line absolute right-4 top-1/2 -translate-y-1/2 text-white/45"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm text-white/70">Password</label>
|
||||
<div class="relative">
|
||||
<input id="pw" type="password" name="password" required placeholder="Your password"
|
||||
class="w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 pr-20 text-white placeholder:text-white/35 outline-none
|
||||
focus:border-violet-400/50 focus:ring-4 focus:ring-violet-500/20">
|
||||
<button type="button" id="togglePw"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs text-white/80
|
||||
hover:bg-white/10 active:scale-95 transition">
|
||||
<span id="eyeIcon"><i class="ri-eye-line"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm text-white/80 pt-1">
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer select-none">
|
||||
<input type="checkbox" name="remember" value="1" <?= $remember ? 'checked' : '' ?>
|
||||
class="h-4 w-4 rounded border-white/20 bg-white/5 text-violet-400 focus:ring-violet-500/40">
|
||||
<span>Remember me for 120 days</span>
|
||||
</label>
|
||||
<span class="text-white/50 text-xs">Secure cookie</span>
|
||||
</div>
|
||||
|
||||
<button class="w-full rounded-2xl bg-gradient-to-br from-violet-500 to-fuchsia-500 py-3 font-extrabold text-black btn-glow
|
||||
hover:brightness-110 active:scale-[0.99] transition">
|
||||
<i class="ri-login-circle-line"></i> Login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<script>
|
||||
$(function(){
|
||||
$('#togglePw').on('click', function(){
|
||||
const $pw = $('#pw');
|
||||
const isPwd = $pw.attr('type') === 'password';
|
||||
$pw.attr('type', isPwd ? 'text' : 'password');
|
||||
$('#eyeIcon').html(isPwd ? '<i class="ri-eye-off-line"></i>' : '<i class="ri-eye-line"></i>');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
8
public/admin/logout.php
Normal file
8
public/admin/logout.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../../includes/bootstrap.php';
|
||||
|
||||
admin_logout();
|
||||
header('Location: ' . url_path('/public/admin/login.php'));
|
||||
exit;
|
||||
32
public/admin/project_delete.php
Normal file
32
public/admin/project_delete.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/../../includes/bootstrap.php';
|
||||
|
||||
|
||||
|
||||
require_admin_login();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header('Location: /public/admin/projects.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!csrf_verify($_POST['csrf'] ?? null)) {
|
||||
flash_set('danger', 'CSRF failed.');
|
||||
header('Location: /public/admin/projects.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
flash_set('danger', 'Invalid project id.');
|
||||
header('Location: /public/admin/projects.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$st = db()->prepare("DELETE FROM projects WHERE id = ?");
|
||||
$st->execute([$id]);
|
||||
|
||||
flash_set('success', 'Project deleted.');
|
||||
header('Location: /public/admin/projects.php');
|
||||
exit;
|
||||
554
public/admin/project_edit.php
Normal file
554
public/admin/project_edit.php
Normal file
@@ -0,0 +1,554 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../../includes/bootstrap.php';
|
||||
require_admin_login();
|
||||
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
$pageTitle = $id ? 'Edit Project | Admin' : 'New Project | Admin';
|
||||
|
||||
// bring optional columns up-to-date (idempotent)
|
||||
try {
|
||||
$cols = pdo()->query("SHOW COLUMNS FROM projects")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$colNames = array_map(static fn($c) => (string)($c['Field'] ?? ''), $cols);
|
||||
$alter = [];
|
||||
|
||||
if (!in_array('tech_json', $colNames, true)) $alter[] = "ADD COLUMN tech_json LONGTEXT NULL";
|
||||
if (!in_array('links_json', $colNames, true)) $alter[] = "ADD COLUMN links_json LONGTEXT NULL";
|
||||
if (!in_array('sort_order', $colNames, true)) $alter[] = "ADD COLUMN sort_order INT NOT NULL DEFAULT 0";
|
||||
if (!in_array('slug', $colNames, true)) $alter[] = "ADD COLUMN slug VARCHAR(120) NULL";
|
||||
if (!in_array('short_summary', $colNames, true)) $alter[] = "ADD COLUMN short_summary VARCHAR(240) NULL";
|
||||
|
||||
foreach ($cols as $c) {
|
||||
if (($c['Field'] ?? '') !== 'summary') continue;
|
||||
$type = strtolower((string)($c['Type'] ?? ''));
|
||||
$null = strtoupper((string)($c['Null'] ?? '')) === 'YES' ? 'NULL' : 'NOT NULL';
|
||||
if (strpos($type, 'text') === false) $alter[] = "MODIFY summary TEXT {$null}";
|
||||
break;
|
||||
}
|
||||
if ($alter) {
|
||||
pdo()->exec("ALTER TABLE projects " . implode(", ", $alter));
|
||||
}
|
||||
} catch (Throwable $e) {}
|
||||
|
||||
$project = [
|
||||
'slug' => '',
|
||||
'title' => '',
|
||||
'tag' => '',
|
||||
'year' => (int)date('Y'),
|
||||
'summary' => '',
|
||||
'short_summary' => '',
|
||||
'tech_json' => '[]',
|
||||
'links_json' => '[]',
|
||||
'sort_order' => 0,
|
||||
];
|
||||
|
||||
$activeTab = (isset($_GET['tab']) && strtolower((string)$_GET['tab']) === 'gallery') ? 'gallery' : 'details';
|
||||
|
||||
if ($id) {
|
||||
$st = pdo()->prepare("SELECT id, slug, title, tag, year, summary, short_summary, tech_json, links_json, sort_order FROM projects WHERE id = ? LIMIT 1");
|
||||
$st->execute([$id]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
flash_set('danger', 'Project not found.');
|
||||
header('Location: /public/admin/projects.php');
|
||||
exit;
|
||||
}
|
||||
$project = $row;
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
|
||||
function parse_list(string $raw): array {
|
||||
$parts = preg_split('/[\r\n,]+/', $raw) ?: [];
|
||||
$out = [];
|
||||
foreach ($parts as $p) {
|
||||
$p = trim((string)$p);
|
||||
if ($p !== '') $out[] = $p;
|
||||
}
|
||||
return array_values(array_unique($out));
|
||||
}
|
||||
|
||||
function parse_links(string $raw): array {
|
||||
$lines = preg_split('/\R/', $raw) ?: [];
|
||||
$links = [];
|
||||
foreach ($lines as $ln) {
|
||||
$ln = trim((string)$ln);
|
||||
if ($ln === '') continue;
|
||||
|
||||
// Format: Label | https://...
|
||||
$label = $ln;
|
||||
$url = '';
|
||||
if (strpos($ln, '|') !== false) {
|
||||
[$label, $url] = array_map('trim', explode('|', $ln, 2));
|
||||
}
|
||||
|
||||
if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) continue;
|
||||
if ($label === '') $label = 'Link';
|
||||
|
||||
$links[] = ['label' => $label, 'url' => $url];
|
||||
}
|
||||
return $links;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!csrf_check((string)($_POST['csrf'] ?? ''))) {
|
||||
$errors[] = 'CSRF failed. Refresh and try again.';
|
||||
}
|
||||
|
||||
$slugRaw = trim((string)($_POST['slug'] ?? ''));
|
||||
$title = trim((string)($_POST['title'] ?? ''));
|
||||
$tag = trim((string)($_POST['tag'] ?? ''));
|
||||
$year = (int)($_POST['year'] ?? 0);
|
||||
$summary = trim((string)($_POST['summary'] ?? ''));
|
||||
$shortSummary = trim((string)($_POST['short_summary'] ?? ''));
|
||||
$techRaw = (string)($_POST['tech'] ?? '');
|
||||
$linksRaw= (string)($_POST['links'] ?? '');
|
||||
$sort = (int)($_POST['sort_order'] ?? 0);
|
||||
|
||||
$slug = project_slugify($slugRaw !== '' ? $slugRaw : $title);
|
||||
if ($slug === '' && $title !== '') $slug = project_slugify($title);
|
||||
|
||||
if ($slug === '' || !preg_match('/^[a-z0-9-]{2,120}$/', $slug)) $errors[] = 'Slug must be letters, numbers, or hyphen (auto-derived from title).';
|
||||
if ($title === '' || mb_strlen($title) > 140) $errors[] = 'Title must be 1-140 chars.';
|
||||
if ($tag === '' || mb_strlen($tag) > 50) $errors[] = 'Tag must be 1-50 chars.';
|
||||
if ($year < 2000 || $year > 2100) $errors[] = 'Year must be 2000-2100.';
|
||||
if ($summary === '' || mb_strlen($summary) > 5000) $errors[] = 'Summary is required (max 5000 chars).';
|
||||
if ($shortSummary !== '' && mb_strlen($shortSummary) > 240) $errors[] = 'Card summary must be 240 chars or less.';
|
||||
|
||||
$tech = parse_list($techRaw);
|
||||
$links = parse_links($linksRaw);
|
||||
|
||||
$techJson = json_encode($tech, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$linksJson = json_encode($links, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
if ($slug !== '') {
|
||||
$st = pdo()->prepare("SELECT id FROM projects WHERE slug = ? AND id <> ? LIMIT 1");
|
||||
$st->execute([$slug, $id]);
|
||||
if ($st->fetch()) $errors[] = 'Slug already in use.';
|
||||
}
|
||||
|
||||
if (!$errors) {
|
||||
if ($id) {
|
||||
$st = pdo()->prepare("UPDATE projects SET slug=?, title=?, tag=?, year=?, summary=?, short_summary=?, tech_json=?, links_json=?, sort_order=? WHERE id=?");
|
||||
$st->execute([$slug, $title, $tag, $year, $summary, $shortSummary ?: null, $techJson, $linksJson, $sort, $id]);
|
||||
flash_set('success', 'Project updated.');
|
||||
} else {
|
||||
$st = pdo()->prepare("INSERT INTO projects (slug, title, tag, year, summary, short_summary, tech_json, links_json, sort_order) VALUES (?,?,?,?,?,?,?,?,?)");
|
||||
$st->execute([$slug, $title, $tag, $year, $summary, $shortSummary ?: null, $techJson, $linksJson, $sort]);
|
||||
flash_set('success', 'Project created.');
|
||||
}
|
||||
|
||||
header('Location: /public/admin/projects.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
// repopulate if validation fails
|
||||
$project['slug'] = $slug;
|
||||
$project['title'] = $title;
|
||||
$project['tag'] = $tag;
|
||||
$project['year'] = $year;
|
||||
$project['summary'] = $summary;
|
||||
$project['short_summary'] = $shortSummary;
|
||||
$project['tech_json'] = $techJson;
|
||||
$project['links_json'] = $linksJson;
|
||||
$project['sort_order'] = $sort;
|
||||
}
|
||||
|
||||
// Prefill textareas
|
||||
$techArr = json_decode((string)($project['tech_json'] ?? '[]'), true);
|
||||
$techTxt = is_array($techArr) ? implode("\n", $techArr) : '';
|
||||
|
||||
$linksArr = json_decode((string)($project['links_json'] ?? '[]'), true);
|
||||
$linksTxt = '';
|
||||
if (is_array($linksArr)) {
|
||||
foreach ($linksArr as $l) {
|
||||
$lbl = (string)($l['label'] ?? '');
|
||||
$url = (string)($l['url'] ?? '');
|
||||
if ($url !== '') $linksTxt .= "{$lbl} | {$url}\n";
|
||||
}
|
||||
}
|
||||
$linksTxt = trim($linksTxt);
|
||||
$mediaEndpoint = url_path('/public/admin/project_media.php');
|
||||
$projectFolder = $id ? project_media_folder($project) : '';
|
||||
|
||||
include __DIR__ . '/_top.php';
|
||||
?>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mt-4 flex-wrap gap-2">
|
||||
<h1 class="h3 m-0 brand"><?= $id ? 'Edit Project' : 'New Project' ?></h1>
|
||||
<a class="btn btn-outline-light" href="/public/admin/projects.php">Back</a>
|
||||
</div>
|
||||
|
||||
<?php if ($errors): ?>
|
||||
<div class="alert alert-danger card-glass border-0 mt-3">
|
||||
<ul class="mb-0">
|
||||
<?php foreach ($errors as $e): ?><li><?= htmlspecialchars($e) ?></li><?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card card-glass border-0 mt-3 p-4">
|
||||
<ul class="nav nav-pills gap-2" id="projectTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link <?= $activeTab === 'details' ? 'active' : '' ?>" id="tabDetailsBtn" data-bs-toggle="tab" data-bs-target="#tabDetails" type="button" role="tab" aria-controls="tabDetails" aria-selected="<?= $activeTab === 'details' ? 'true' : 'false' ?>">
|
||||
Details
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link <?= $activeTab === 'gallery' ? 'active' : '' ?>" id="tabGalleryBtn" data-bs-toggle="tab" data-bs-target="#tabGallery" type="button" role="tab" aria-controls="tabGallery" aria-selected="<?= $activeTab === 'gallery' ? 'true' : 'false' ?>">
|
||||
Gallery
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content mt-3">
|
||||
<div class="tab-pane fade <?= $activeTab === 'details' ? 'show active' : '' ?>" id="tabDetails" role="tabpanel" aria-labelledby="tabDetailsBtn">
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label text-white-50">Title</label>
|
||||
<input class="form-control" name="title" maxlength="140" required
|
||||
value="<?= htmlspecialchars((string)$project['title']) ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label text-white-50">Year</label>
|
||||
<input class="form-control" name="year" type="number" min="2000" max="2100" required
|
||||
value="<?= (int)$project['year'] ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label text-white-50">Tag</label>
|
||||
<input class="form-control" name="tag" maxlength="50" required
|
||||
value="<?= htmlspecialchars((string)$project['tag']) ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label text-white-50">Slug (auto)</label>
|
||||
<input class="form-control" name="slug" maxlength="120"
|
||||
value="<?= htmlspecialchars((string)($project['slug'] ?? '')) ?>"
|
||||
placeholder="my-project-name">
|
||||
<div class="form-text text-white-50">Letters, numbers, and hyphen. Lowercase slug is auto-built from the title.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label text-white-50">Sort order</label>
|
||||
<input class="form-control" name="sort_order" type="number" value="<?= (int)($project['sort_order'] ?? 0) ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label text-white-50">Summary</label>
|
||||
<textarea class="form-control" name="summary" rows="4" required><?= htmlspecialchars((string)$project['summary']) ?></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label text-white-50">Card summary (optional, short preview)</label>
|
||||
<textarea class="form-control" name="short_summary" rows="2" maxlength="240"
|
||||
placeholder="If blank, the preview will clamp the full summary."><?= htmlspecialchars((string)($project['short_summary'] ?? '')) ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label text-white-50">Tech (one per line OR comma separated)</label>
|
||||
<textarea class="form-control" name="tech" rows="7" placeholder="PHP MariaDB jQuery"><?= htmlspecialchars($techTxt) ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label text-white-50">Links (one per line): <code>Label | https://url</code></label>
|
||||
<textarea class="form-control" name="links" rows="7" placeholder="Repo | https://github.com/..."><?= htmlspecialchars($linksTxt) ?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 d-flex gap-2">
|
||||
<button class="btn btn-violet"><?= $id ? 'Save Changes' : 'Create Project' ?></button>
|
||||
<a class="btn btn-outline-light" href="/public/admin/projects.php">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade <?= $activeTab === 'gallery' ? 'show active' : '' ?>" id="tabGallery" role="tabpanel" aria-labelledby="tabGalleryBtn">
|
||||
<?php if (!$id): ?>
|
||||
<div class="alert alert-info card-glass border-0 mt-3">
|
||||
Save the project first to unlock the media gallery.
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div id="mediaManager"
|
||||
data-project-id="<?= (int)$id ?>"
|
||||
data-csrf="<?= htmlspecialchars(csrf_token()) ?>"
|
||||
data-endpoint="<?= htmlspecialchars($mediaEndpoint) ?>">
|
||||
<div class="d-flex flex-wrap justify-content-between gap-2 mb-3">
|
||||
<div>
|
||||
<div class="text-white-50 small">Files live in</div>
|
||||
<div class="fw-bold">Project imgs/<?= htmlspecialchars($projectFolder) ?></div>
|
||||
</div>
|
||||
<div class="text-white-50 small">JPEG, PNG or WebP up to 8MB; re-encoded via GD.</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 align-items-stretch">
|
||||
<div class="col-lg-5">
|
||||
<div id="mediaDrop" class="media-drop rounded-3" role="button" tabindex="0">
|
||||
<div class="text-center">
|
||||
<div class="fw-semibold">Drag & drop screenshots</div>
|
||||
<div class="text-white-50 small">or click to pick files</div>
|
||||
</div>
|
||||
<input type="file" id="mediaInput" accept="image/*" multiple hidden>
|
||||
</div>
|
||||
<div class="small text-white-50 mt-2">Paste an image URL and we'll fetch and sanitize it.</div>
|
||||
<div class="input-group mt-2">
|
||||
<input type="url" class="form-control" id="mediaUrlInput" placeholder="https://example.com/screenshot.jpg">
|
||||
<button class="btn btn-outline-light" type="button" id="mediaUrlBtn">Add URL</button>
|
||||
</div>
|
||||
<div class="small mt-2" id="mediaStatus" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="col-lg-7">
|
||||
<div class="media-grid" id="mediaGrid" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function ($) {
|
||||
$(function () {
|
||||
const $manager = $("#mediaManager");
|
||||
if (!$manager.length) return;
|
||||
|
||||
const endpoint = $manager.data("endpoint");
|
||||
const projectId = $manager.data("project-id");
|
||||
const csrf = $manager.data("csrf");
|
||||
const $drop = $("#mediaDrop");
|
||||
const $input = $("#mediaInput");
|
||||
const $grid = $("#mediaGrid");
|
||||
const $status = $("#mediaStatus");
|
||||
const $urlInput = $("#mediaUrlInput");
|
||||
const $urlBtn = $("#mediaUrlBtn");
|
||||
|
||||
const setStatus = (msg, type) => {
|
||||
if (!$status.length) return;
|
||||
const cls = type === "error" ? "text-danger" : (type === "success" ? "text-success" : "text-white-50");
|
||||
$status.attr("class", "small mt-2 " + cls).text(msg || "");
|
||||
};
|
||||
|
||||
const bytesToSize = (bytes) => {
|
||||
if (!bytes) return "0 KB";
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return kb.toFixed(1) + " KB";
|
||||
return (kb / 1024).toFixed(1) + " MB";
|
||||
};
|
||||
|
||||
const render = (files) => {
|
||||
if (!$grid.length) return;
|
||||
$grid.empty();
|
||||
if (!files || !files.length) {
|
||||
$("<div/>", { class: "text-white-50 small" })
|
||||
.text("No images yet. Drop files or paste a URL to begin.")
|
||||
.appendTo($grid);
|
||||
return;
|
||||
}
|
||||
|
||||
files.forEach((f) => {
|
||||
const sizeLabel = bytesToSize(f.size || 0);
|
||||
const $card = $("<div/>", { class: "media-card" });
|
||||
|
||||
$("<img/>", { src: f.url, alt: f.name }).appendTo($card);
|
||||
|
||||
$("<div/>", { class: "media-meta" })
|
||||
.html(
|
||||
'<div class="fw-semibold small text-truncate">' + f.name + "</div>" +
|
||||
'<div class="text-white-50 small">' +
|
||||
(f.width || "?") + "x" + (f.height || "?") + " - " + sizeLabel + "</div>"
|
||||
)
|
||||
.appendTo($card);
|
||||
|
||||
$("<div/>", { class: "media-actions" })
|
||||
.append(
|
||||
$("<button/>", {
|
||||
type: "button",
|
||||
class: "btn btn-sm btn-outline-light",
|
||||
text: "Delete"
|
||||
}).on("click", function () {
|
||||
if (confirm("Remove this image?")) deleteFile(f.name);
|
||||
})
|
||||
)
|
||||
.appendTo($card);
|
||||
|
||||
$grid.append($card);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchList = () => {
|
||||
setStatus("Loading images...");
|
||||
return $.ajax({
|
||||
url: `${endpoint}?action=list&project_id=${encodeURIComponent(projectId)}&csrf=${encodeURIComponent(csrf)}`,
|
||||
method: "GET",
|
||||
dataType: "json",
|
||||
cache: false
|
||||
})
|
||||
.done((data) => {
|
||||
if (!data || data.ok !== true) {
|
||||
setStatus((data && data.error) ? data.error : "Unable to load images", "error");
|
||||
return;
|
||||
}
|
||||
render(data.files || []);
|
||||
const files = data.files || [];
|
||||
setStatus(files.length ? "" : "Drop files or paste a URL to upload.", "muted");
|
||||
})
|
||||
.fail((xhr) => {
|
||||
const msg = (xhr && xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText || "Failed to load images";
|
||||
setStatus(msg, "error");
|
||||
});
|
||||
};
|
||||
|
||||
const uploadFiles = (list) => {
|
||||
const files = Array.from(list || []).filter((f) => (f.type || "").startsWith("image/"));
|
||||
if (!files.length) {
|
||||
setStatus("Only image files are accepted.", "error");
|
||||
return;
|
||||
}
|
||||
setStatus("Uploading...", "muted");
|
||||
const form = new FormData();
|
||||
form.append("action", "upload");
|
||||
form.append("project_id", projectId);
|
||||
form.append("csrf", csrf);
|
||||
files.slice(0, 10).forEach((f) => form.append("files[]", f));
|
||||
|
||||
$.ajax({
|
||||
url: endpoint,
|
||||
method: "POST",
|
||||
data: form,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
cache: false,
|
||||
dataType: "json"
|
||||
})
|
||||
.done((data) => {
|
||||
if (!data || data.ok !== true) {
|
||||
setStatus((data && data.error) ? data.error : "Upload failed", "error");
|
||||
return;
|
||||
}
|
||||
setStatus(`Saved ${data.saved || 0} file(s).`, "success");
|
||||
fetchList();
|
||||
})
|
||||
.fail((xhr) => {
|
||||
const msg = (xhr && xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText || "Upload failed";
|
||||
setStatus(msg, "error");
|
||||
});
|
||||
};
|
||||
|
||||
const importUrl = () => {
|
||||
const url = ($urlInput.val() || "").trim();
|
||||
if (!url) {
|
||||
setStatus("Paste an image URL first.", "error");
|
||||
return;
|
||||
}
|
||||
setStatus("Fetching URL...", "muted");
|
||||
const form = new FormData();
|
||||
form.append("action", "fetch_url");
|
||||
form.append("project_id", projectId);
|
||||
form.append("csrf", csrf);
|
||||
form.append("image_url", url);
|
||||
|
||||
$.ajax({
|
||||
url: endpoint,
|
||||
method: "POST",
|
||||
data: form,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
cache: false,
|
||||
dataType: "json"
|
||||
})
|
||||
.done((data) => {
|
||||
if (!data || data.ok !== true) {
|
||||
setStatus((data && data.error) ? data.error : "Import failed", "error");
|
||||
return;
|
||||
}
|
||||
setStatus("Image imported.", "success");
|
||||
$urlInput.val("");
|
||||
fetchList();
|
||||
})
|
||||
.fail((xhr) => {
|
||||
const msg = (xhr && xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText || "Import failed";
|
||||
setStatus(msg, "error");
|
||||
});
|
||||
};
|
||||
|
||||
const deleteFile = (name) => {
|
||||
const form = new FormData();
|
||||
form.append("action", "delete");
|
||||
form.append("project_id", projectId);
|
||||
form.append("csrf", csrf);
|
||||
form.append("name", name);
|
||||
|
||||
$.ajax({
|
||||
url: endpoint,
|
||||
method: "POST",
|
||||
data: form,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
cache: false,
|
||||
dataType: "json"
|
||||
})
|
||||
.done((data) => {
|
||||
if (!data || data.ok !== true) {
|
||||
setStatus((data && data.error) ? data.error : "Delete failed", "error");
|
||||
return;
|
||||
}
|
||||
setStatus("Image removed.", "success");
|
||||
fetchList();
|
||||
})
|
||||
.fail((xhr) => {
|
||||
const msg = (xhr && xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText || "Delete failed";
|
||||
setStatus(msg, "error");
|
||||
});
|
||||
};
|
||||
|
||||
if ($drop.length && $input.length) {
|
||||
$drop.on("dragenter dragover", (e) => {
|
||||
e.preventDefault();
|
||||
$drop.addClass("is-drag");
|
||||
});
|
||||
$drop.on("dragleave drop", (e) => {
|
||||
e.preventDefault();
|
||||
$drop.removeClass("is-drag");
|
||||
});
|
||||
$drop.on("drop", (e) => {
|
||||
e.preventDefault();
|
||||
$drop.removeClass("is-drag");
|
||||
uploadFiles(e.originalEvent.dataTransfer.files);
|
||||
});
|
||||
$drop.on("click", () => $input.trigger("click"));
|
||||
$drop.on("keydown", (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
$input.trigger("click");
|
||||
}
|
||||
});
|
||||
$input.on("change", function () {
|
||||
uploadFiles(this.files);
|
||||
this.value = "";
|
||||
});
|
||||
}
|
||||
|
||||
if ($urlBtn.length) $urlBtn.on("click", importUrl);
|
||||
if ($urlInput.length) {
|
||||
$urlInput.on("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
importUrl();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fetchList();
|
||||
});
|
||||
})(jQuery);
|
||||
</script>
|
||||
|
||||
|
||||
<link rel="stylesheet" href="/public/css/edit_project.css">
|
||||
<?php include __DIR__ . '/_bottom.php'; ?>
|
||||
332
public/admin/project_media.php
Normal file
332
public/admin/project_media.php
Normal file
@@ -0,0 +1,332 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../../includes/bootstrap.php';
|
||||
require_admin_login();
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
|
||||
const MEDIA_MAX_BYTES = 8 * 1024 * 1024; // 8MB
|
||||
const MEDIA_MAX_SIDE = 4000; // max width/height
|
||||
|
||||
function respond(array $payload, int $status = 200): void
|
||||
{
|
||||
http_response_code($status);
|
||||
echo json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
function require_csrf(): string
|
||||
{
|
||||
$token = (string)($_POST['csrf'] ?? $_GET['csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
|
||||
if (!csrf_check($token)) {
|
||||
respond(['ok' => false, 'error' => 'Invalid CSRF token.'], 400);
|
||||
}
|
||||
return $token;
|
||||
}
|
||||
|
||||
function ensure_gd(): void
|
||||
{
|
||||
if (!extension_loaded('gd')) {
|
||||
respond(['ok' => false, 'error' => 'GD extension is required on the server.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
function load_project(int $id): array
|
||||
{
|
||||
$st = pdo()->prepare("SELECT id, slug, title FROM projects WHERE id = ? LIMIT 1");
|
||||
$st->execute([$id]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
respond(['ok' => false, 'error' => 'Project not found.'], 404);
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
function ensure_media_dir(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
if (!@mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||||
respond(['ok' => false, 'error' => 'Cannot create media directory.'], 500);
|
||||
}
|
||||
}
|
||||
if (!is_writable($dir)) {
|
||||
respond(['ok' => false, 'error' => 'Media directory is not writable.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
function list_media(string $dir, string $folder): array
|
||||
{
|
||||
$files = [];
|
||||
if (!is_dir($dir)) return $files;
|
||||
|
||||
foreach (glob($dir . DIRECTORY_SEPARATOR . '*.{jpg,jpeg,png,webp}', GLOB_BRACE) ?: [] as $path) {
|
||||
if (!is_file($path)) continue;
|
||||
$name = basename($path);
|
||||
$stat = @stat($path) ?: [];
|
||||
$size = (int)($stat['size'] ?? 0);
|
||||
$mtime = (int)($stat['mtime'] ?? time());
|
||||
$dim = @getimagesize($path) ?: [];
|
||||
$files[] = [
|
||||
'name' => $name,
|
||||
'url' => project_media_url($folder, $name),
|
||||
'size' => $size,
|
||||
'width' => (int)($dim[0] ?? 0),
|
||||
'height' => (int)($dim[1] ?? 0),
|
||||
'modified'=> $mtime,
|
||||
];
|
||||
}
|
||||
|
||||
usort($files, fn($a, $b) => ($b['modified'] ?? 0) <=> ($a['modified'] ?? 0));
|
||||
return $files;
|
||||
}
|
||||
|
||||
function normalize_orientation($img, array $info, string $binary)
|
||||
{
|
||||
if (!function_exists('exif_read_data')) return $img;
|
||||
$mime = strtolower((string)($info['mime'] ?? ''));
|
||||
if ($mime !== 'image/jpeg') return $img;
|
||||
|
||||
$orientation = 0;
|
||||
try {
|
||||
$exif = @exif_read_data('data://image/jpeg;base64,' . base64_encode($binary), 'IFD0');
|
||||
if (!empty($exif['Orientation'])) $orientation = (int)$exif['Orientation'];
|
||||
} catch (Throwable $e) {
|
||||
return $img;
|
||||
}
|
||||
if ($orientation < 2 || $orientation > 8) return $img;
|
||||
|
||||
$canFlip = function_exists('imageflip');
|
||||
switch ($orientation) {
|
||||
case 2: if ($canFlip) imageflip($img, IMG_FLIP_HORIZONTAL); break;
|
||||
case 3: $img = imagerotate($img, 180, 0); break;
|
||||
case 4: if ($canFlip) imageflip($img, IMG_FLIP_VERTICAL); break;
|
||||
case 5: $img = imagerotate($img, 90, 0); if ($canFlip) imageflip($img, IMG_FLIP_HORIZONTAL); break;
|
||||
case 6: $img = imagerotate($img, -90, 0); break;
|
||||
case 7: $img = imagerotate($img, -90, 0); if ($canFlip) imageflip($img, IMG_FLIP_HORIZONTAL); break;
|
||||
case 8: $img = imagerotate($img, 90, 0); break;
|
||||
}
|
||||
return $img;
|
||||
}
|
||||
|
||||
function save_image(string $binary, string $dir, string $folder): array
|
||||
{
|
||||
ensure_gd();
|
||||
|
||||
if (strlen($binary) > MEDIA_MAX_BYTES) {
|
||||
respond(['ok' => false, 'error' => 'Image exceeds 8MB limit.'], 400);
|
||||
}
|
||||
|
||||
$info = @getimagesizefromstring($binary);
|
||||
if (!$info || empty($info['mime'])) {
|
||||
respond(['ok' => false, 'error' => 'Unsupported or invalid image.'], 400);
|
||||
}
|
||||
|
||||
$mime = strtolower((string)$info['mime']);
|
||||
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
|
||||
respond(['ok' => false, 'error' => 'Only JPEG, PNG or WebP images are allowed.'], 400);
|
||||
}
|
||||
|
||||
$img = @imagecreatefromstring($binary);
|
||||
if (!$img) {
|
||||
respond(['ok' => false, 'error' => 'Unable to read the image data.'], 400);
|
||||
}
|
||||
|
||||
$img = normalize_orientation($img, $info, $binary);
|
||||
$width = imagesx($img);
|
||||
$height = imagesy($img);
|
||||
|
||||
$maxSide = MEDIA_MAX_SIDE;
|
||||
if ($width > $maxSide || $height > $maxSide) {
|
||||
$scale = $maxSide / max($width, $height);
|
||||
$newW = (int)max(1, floor($width * $scale));
|
||||
$newH = (int)max(1, floor($height * $scale));
|
||||
$dst = imagecreatetruecolor($newW, $newH);
|
||||
imagealphablending($dst, true);
|
||||
imagesavealpha($dst, true);
|
||||
imagecopyresampled($dst, $img, 0, 0, 0, 0, $newW, $newH, $width, $height);
|
||||
imagedestroy($img);
|
||||
$img = $dst;
|
||||
$width = $newW;
|
||||
$height = $newH;
|
||||
}
|
||||
|
||||
$ext = 'jpg';
|
||||
if ($mime === 'image/png') $ext = 'png';
|
||||
if ($mime === 'image/webp' && function_exists('imagewebp')) $ext = 'webp';
|
||||
|
||||
$filename = 'img-' . date('Ymd-His') . '-' . bin2hex(random_bytes(3)) . '.' . $ext;
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $filename;
|
||||
|
||||
switch ($ext) {
|
||||
case 'png':
|
||||
imagepng($img, $path, 6);
|
||||
break;
|
||||
case 'webp':
|
||||
imagewebp($img, $path, 85);
|
||||
break;
|
||||
default:
|
||||
imagejpeg($img, $path, 85);
|
||||
break;
|
||||
}
|
||||
imagedestroy($img);
|
||||
|
||||
$stat = @stat($path) ?: [];
|
||||
$dim2 = @getimagesize($path) ?: [];
|
||||
|
||||
return [
|
||||
'name' => $filename,
|
||||
'url' => project_media_url($folder, $filename),
|
||||
'size' => (int)($stat['size'] ?? 0),
|
||||
'width' => (int)($dim2[0] ?? $width),
|
||||
'height' => (int)($dim2[1] ?? $height),
|
||||
'modified' => (int)($stat['mtime'] ?? time()),
|
||||
];
|
||||
}
|
||||
|
||||
function read_uploaded_file(array $files, int $idx): string
|
||||
{
|
||||
$tmp = (string)($files['tmp_name'][$idx] ?? '');
|
||||
$size = (int)($files['size'][$idx] ?? 0);
|
||||
$err = (int)($files['error'][$idx] ?? UPLOAD_ERR_NO_FILE);
|
||||
|
||||
if ($err !== UPLOAD_ERR_OK) {
|
||||
throw new RuntimeException('Upload failed for one file.');
|
||||
}
|
||||
if (!is_uploaded_file($tmp)) {
|
||||
throw new RuntimeException('Suspicious upload rejected.');
|
||||
}
|
||||
if ($size > MEDIA_MAX_BYTES) {
|
||||
throw new RuntimeException('File is larger than 8MB.');
|
||||
}
|
||||
$data = file_get_contents($tmp);
|
||||
if ($data === false) {
|
||||
throw new RuntimeException('Unable to read upload.');
|
||||
}
|
||||
if (strlen($data) > MEDIA_MAX_BYTES) {
|
||||
throw new RuntimeException('File is larger than 8MB.');
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
function fetch_remote_image(string $url): string
|
||||
{
|
||||
$url = trim($url);
|
||||
if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
throw new RuntimeException('Please provide a valid image URL.');
|
||||
}
|
||||
if (!preg_match('#^https?://#i', $url)) {
|
||||
throw new RuntimeException('Only http/https URLs are allowed.');
|
||||
}
|
||||
|
||||
$data = '';
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_MAXREDIRS => 0,
|
||||
CURLOPT_USERAGENT => 'PortfolioMediaFetcher/1.0',
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_BUFFERSIZE => 102400,
|
||||
]);
|
||||
$data = (string)curl_exec($ch);
|
||||
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
if (curl_errno($ch) || $code >= 400 || $code === 0) {
|
||||
curl_close($ch);
|
||||
throw new RuntimeException('Failed to download image (HTTP ' . $code . ').');
|
||||
}
|
||||
curl_close($ch);
|
||||
} else {
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'timeout' => 10,
|
||||
'header' => "User-Agent: PortfolioMediaFetcher/1.0\r\n",
|
||||
],
|
||||
]);
|
||||
$fh = @fopen($url, 'rb', false, $ctx);
|
||||
if (!$fh) throw new RuntimeException('Could not open the URL.');
|
||||
while (!feof($fh) && strlen($data) <= MEDIA_MAX_BYTES) {
|
||||
$chunk = fread($fh, 8192);
|
||||
if ($chunk === false) break;
|
||||
$data .= $chunk;
|
||||
if (strlen($data) > MEDIA_MAX_BYTES) {
|
||||
fclose($fh);
|
||||
throw new RuntimeException('Remote image is larger than 8MB.');
|
||||
}
|
||||
}
|
||||
fclose($fh);
|
||||
}
|
||||
|
||||
if ($data === '' || strlen($data) === 0) {
|
||||
throw new RuntimeException('Image download returned empty data.');
|
||||
}
|
||||
if (strlen($data) > MEDIA_MAX_BYTES) {
|
||||
throw new RuntimeException('Remote image is larger than 8MB.');
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
// ----- Main routing -----
|
||||
$csrfToken = require_csrf();
|
||||
$projectId = (int)($_GET['project_id'] ?? $_POST['project_id'] ?? 0);
|
||||
if ($projectId <= 0) respond(['ok' => false, 'error' => 'Missing project id.'], 400);
|
||||
|
||||
$project = load_project($projectId);
|
||||
$folder = project_media_folder($project);
|
||||
$dir = project_media_dir($project);
|
||||
ensure_media_dir($dir);
|
||||
|
||||
$action = strtolower((string)($_REQUEST['action'] ?? ($_SERVER['REQUEST_METHOD'] === 'GET' ? 'list' : '')));
|
||||
|
||||
try {
|
||||
if ($action === 'list') {
|
||||
respond(['ok' => true, 'files' => list_media($dir, $folder)]);
|
||||
}
|
||||
|
||||
if ($action === 'upload') {
|
||||
$files = $_FILES['files'] ?? null;
|
||||
if (!$files || !isset($files['tmp_name']) || !is_array($files['tmp_name'])) {
|
||||
respond(['ok' => false, 'error' => 'No files received.'], 400);
|
||||
}
|
||||
|
||||
$saved = [];
|
||||
$total = count($files['tmp_name']);
|
||||
for ($i = 0; $i < $total; $i++) {
|
||||
$data = read_uploaded_file($files, $i);
|
||||
$saved[] = save_image($data, $dir, $folder);
|
||||
if (count($saved) >= 10) break; // prevent huge multi-upload bursts
|
||||
}
|
||||
|
||||
respond(['ok' => true, 'saved' => count($saved), 'files' => $saved]);
|
||||
}
|
||||
|
||||
if ($action === 'fetch_url') {
|
||||
$url = (string)($_POST['image_url'] ?? '');
|
||||
$data = fetch_remote_image($url);
|
||||
$file = save_image($data, $dir, $folder);
|
||||
respond(['ok' => true, 'saved' => 1, 'files' => [$file]]);
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$name = (string)($_POST['name'] ?? '');
|
||||
if ($name === '' || !preg_match('/^[a-zA-Z0-9._-]+$/', $name)) {
|
||||
respond(['ok' => false, 'error' => 'Bad filename.'], 400);
|
||||
}
|
||||
$path = realpath($dir . DIRECTORY_SEPARATOR . $name);
|
||||
$dirReal = realpath($dir);
|
||||
if (!$path || !$dirReal || strpos($path, $dirReal) !== 0 || !is_file($path)) {
|
||||
respond(['ok' => false, 'error' => 'File not found.'], 404);
|
||||
}
|
||||
@unlink($path);
|
||||
respond(['ok' => true]);
|
||||
}
|
||||
|
||||
respond(['ok' => false, 'error' => 'Unknown action.'], 400);
|
||||
} catch (Throwable $e) {
|
||||
respond(['ok' => false, 'error' => $e->getMessage()], 400);
|
||||
}
|
||||
275
public/admin/projects.php
Normal file
275
public/admin/projects.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/../../includes/bootstrap.php';
|
||||
|
||||
|
||||
|
||||
require_admin_login();
|
||||
// auto schema patch
|
||||
try {
|
||||
$cols = pdo()->query("SHOW COLUMNS FROM projects")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$colNames = array_map(static fn($c) => (string)($c['Field'] ?? ''), $cols);
|
||||
$alter = [];
|
||||
|
||||
if (!in_array('tech_json', $colNames, true)) $alter[] = "ADD COLUMN tech_json LONGTEXT NULL";
|
||||
if (!in_array('links_json', $colNames, true)) $alter[] = "ADD COLUMN links_json LONGTEXT NULL";
|
||||
if (!in_array('sort_order', $colNames, true)) $alter[] = "ADD COLUMN sort_order INT NOT NULL DEFAULT 0";
|
||||
if (!in_array('slug', $colNames, true)) $alter[] = "ADD COLUMN slug VARCHAR(120) NULL";
|
||||
if (!in_array('short_summary', $colNames, true)) $alter[] = "ADD COLUMN short_summary VARCHAR(240) NULL";
|
||||
|
||||
// Ensure summary can hold long text (avoid Data too long errors)
|
||||
foreach ($cols as $c) {
|
||||
if (($c['Field'] ?? '') !== 'summary') continue;
|
||||
$type = strtolower((string)($c['Type'] ?? ''));
|
||||
$null = strtoupper((string)($c['Null'] ?? '')) === 'YES' ? 'NULL' : 'NOT NULL';
|
||||
if (strpos($type, 'text') === false) $alter[] = "MODIFY summary TEXT {$null}";
|
||||
break;
|
||||
}
|
||||
|
||||
if ($alter) {
|
||||
pdo()->exec("ALTER TABLE projects " . implode(", ", $alter));
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// ignore (no permission, etc.)
|
||||
}
|
||||
|
||||
|
||||
if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(32));
|
||||
function csrf_ok($t): bool { return is_string($t) && hash_equals($_SESSION['csrf'], $t); }
|
||||
|
||||
if (!function_exists('flash_set')) {
|
||||
function flash_set(string $type, string $msg): void { $_SESSION['_flash'] = ['type'=>$type,'msg'=>$msg]; }
|
||||
}
|
||||
if (!function_exists('flash_get')) {
|
||||
function flash_get(): ?array { $f=$_SESSION['_flash']??null; unset($_SESSION['_flash']); return is_array($f)?$f:null; }
|
||||
}
|
||||
|
||||
$f = flash_get();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!csrf_ok((string)($_POST['csrf'] ?? ''))) {
|
||||
flash_set('danger', 'Bad CSRF token.');
|
||||
header('Location: /public/admin/projects.php'); exit;
|
||||
}
|
||||
|
||||
$action = (string)($_POST['action'] ?? '');
|
||||
|
||||
if ($action === 'create') {
|
||||
$title = trim((string)($_POST['title'] ?? ''));
|
||||
$tag = trim((string)($_POST['tag'] ?? ''));
|
||||
$year = trim((string)($_POST['year'] ?? ''));
|
||||
$summary = trim((string)($_POST['summary'] ?? ''));
|
||||
$shortSummary = trim((string)($_POST['short_summary'] ?? ''));
|
||||
$slug = trim((string)($_POST['slug'] ?? ''));
|
||||
$sort = (int)($_POST['sort_order'] ?? 0);
|
||||
|
||||
$techCsv = trim((string)($_POST['tech_csv'] ?? ''));
|
||||
$tech = array_values(array_filter(array_map('trim', explode(',', $techCsv))));
|
||||
$linksLines = preg_split("/\r\n|\n|\r/", (string)($_POST['links_lines'] ?? '')) ?: [];
|
||||
$links = [];
|
||||
foreach ($linksLines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '') continue;
|
||||
// format: Label | https://url
|
||||
$parts = array_map('trim', explode('|', $line, 2));
|
||||
$label = $parts[0] ?? '';
|
||||
$url = $parts[1] ?? '';
|
||||
if ($label !== '' && $url !== '') $links[] = ['label'=>$label,'url'=>$url];
|
||||
}
|
||||
|
||||
$slug = project_slugify($slug !== '' ? $slug : $title);
|
||||
if ($slug === '' && $title !== '') $slug = project_slugify($title);
|
||||
|
||||
if ($title === '') {
|
||||
flash_set('danger', 'Title is required.');
|
||||
} elseif ($slug === '' || !preg_match('/^[a-z0-9-]{2,120}$/', $slug)) {
|
||||
flash_set('danger', 'Slug must be letters, numbers, or hyphen (auto-derived from title).');
|
||||
} elseif ($summary === '' || mb_strlen($summary) > 5000) {
|
||||
flash_set('danger', 'Summary must be 1-5000 characters.');
|
||||
} elseif ($shortSummary !== '' && mb_strlen($shortSummary) > 240) {
|
||||
flash_set('danger', 'Card summary must be 240 characters or less.');
|
||||
} else {
|
||||
$techJson = json_encode($tech, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
|
||||
$linksJson = json_encode($links, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$st = pdo()->prepare("SELECT id FROM projects WHERE slug = ? LIMIT 1");
|
||||
$st->execute([$slug]);
|
||||
if ($st->fetch()) {
|
||||
flash_set('danger', 'Slug already in use.');
|
||||
} else {
|
||||
pdo()->prepare("INSERT INTO projects (slug,title,tag,year,summary,short_summary,tech_json,links_json,sort_order)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)")
|
||||
->execute([$slug,$title,$tag,$year,$summary,$shortSummary ?: null,$techJson,$linksJson,$sort]);
|
||||
|
||||
$newId = (int)pdo()->lastInsertId();
|
||||
flash_set('success', 'Project added. Upload screenshots in the Gallery tab.');
|
||||
$redirect = '/public/admin/project_edit.php';
|
||||
if ($newId > 0) $redirect .= '?id=' . $newId . '&tab=gallery#tabGallery';
|
||||
header('Location: ' . $redirect); exit;
|
||||
}
|
||||
}
|
||||
|
||||
header('Location: /public/admin/projects.php'); exit;
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
pdo()->prepare("DELETE FROM projects WHERE id=?")->execute([$id]);
|
||||
flash_set('success', 'Project deleted.');
|
||||
}
|
||||
header('Location: /public/admin/projects.php'); exit;
|
||||
}
|
||||
}
|
||||
|
||||
$projects = [];
|
||||
try {
|
||||
$projects = pdo()->query("SELECT id, slug, title, tag, year, summary, tech_json, links_json, sort_order
|
||||
FROM projects ORDER BY sort_order ASC, id DESC")->fetchAll() ?: [];
|
||||
} catch (Throwable $e) {}
|
||||
|
||||
$newParam = $_GET['new'] ?? null;
|
||||
$openNew = $newParam !== null;
|
||||
?>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Projects • Admin</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/public/css/admin.css">
|
||||
<link rel="stylesheet" href="/public/css/edit_project.css">
|
||||
</head>
|
||||
<body class="min-vh-100">
|
||||
<div class="container py-4" style="max-width: 1100px">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="h3 m-0">Projects</h1>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a class="btn btn-outline-light" href="/public/admin/dashboard.php">Dashboard</a>
|
||||
<a class="btn btn-outline-light" href="/public/admin/logout.php">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($f): ?>
|
||||
<div class="alert alert-<?= htmlspecialchars($f['type']) ?>"><?= htmlspecialchars($f['msg']) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card card-glass p-3 mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div class="text-white-50 small">Add New</div>
|
||||
<div class="fw-bold">Create a new project card</div>
|
||||
</div>
|
||||
<span class="text-white-50 small">Save a project, then use its Gallery button.</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3" id="newProject">
|
||||
<form method="post" class="row g-3">
|
||||
<input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
|
||||
<input type="hidden" name="action" value="create">
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label text-white-50">Title</label>
|
||||
<input class="form-control" name="title" required maxlength="120">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label text-white-50">Year</label>
|
||||
<input class="form-control" name="year" maxlength="10" placeholder="2025">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label text-white-50">Tag</label>
|
||||
<input class="form-control" name="tag" maxlength="40" placeholder="Concept / Live / Tool">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label text-white-50">Slug (optional)</label>
|
||||
<input class="form-control" name="slug" maxlength="60" placeholder="my-project-name">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label text-white-50">Sort order</label>
|
||||
<input class="form-control" name="sort_order" type="number" value="0">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label text-white-50">Summary</label>
|
||||
<textarea class="form-control" name="summary" rows="2" maxlength="5000"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label text-white-50">Card summary (optional, short preview)</label>
|
||||
<textarea class="form-control" name="short_summary" rows="2" maxlength="240"
|
||||
placeholder="If blank, the preview will auto-clamp the full summary."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label text-white-50">Tech (comma separated)</label>
|
||||
<input class="form-control" name="tech_csv" placeholder="PHP, MariaDB, PDO, AJAX, Tailwind">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label text-white-50">Links (one per line: Label | URL)</label>
|
||||
<textarea class="form-control" name="links_lines" rows="2" placeholder="GitHub | https://github.com/..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<button class="btn btn-violet">Save Project</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-glass p-0 overflow-hidden">
|
||||
<div class="p-3 border-bottom border-white/10 d-flex justify-content-between align-items-center">
|
||||
<div class="fw-bold">Existing Projects</div>
|
||||
<div class="text-white-50 small"><?= count($projects) ?> total</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0 align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:70px">ID</th>
|
||||
<th>Title</th>
|
||||
<th style="width:110px">Year</th>
|
||||
<th style="width:160px">Tag</th>
|
||||
<th style="width:120px">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($projects as $p): ?>
|
||||
<tr>
|
||||
<td class="text-white-50"><?= (int)$p['id'] ?></td>
|
||||
<td>
|
||||
<div class="fw-semibold"><?= htmlspecialchars((string)$p['title']) ?></div>
|
||||
<div class="text-white-50 small"><?= htmlspecialchars((string)$p['summary']) ?></div>
|
||||
</td>
|
||||
<td class="text-white-50"><?= htmlspecialchars((string)$p['year']) ?></td>
|
||||
<td><span class="badge text-bg-light"><?= htmlspecialchars((string)$p['tag']) ?></span></td>
|
||||
<td>
|
||||
<a class="btn btn-outline-light btn-sm" href="/public/admin/project_edit.php?id=<?= (int)$p['id'] ?>">Edit</a>
|
||||
<a class="btn btn-outline-light btn-sm" href="/public/admin/project_edit.php?id=<?= (int)$p['id'] ?>&tab=gallery#tabGallery">Gallery</a>
|
||||
<form method="post" class="d-inline">
|
||||
<input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="<?= (int)$p['id'] ?>">
|
||||
<button class="btn btn-outline-danger btn-sm" data-confirm="Delete this project?">Del</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$projects): ?>
|
||||
<tr><td colspan="5" class="text-center text-white-50 py-4">No projects yet.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
199
public/admin/signup.php
Normal file
199
public/admin/signup.php
Normal file
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/../../includes/bootstrap.php';
|
||||
|
||||
// already logged in? go dashboard
|
||||
if (admin_is_logged_in()) {
|
||||
header('Location: ' . url_path('/public/admin/dashboard.php'));
|
||||
exit;
|
||||
}
|
||||
|
||||
// allow registration only if there are no admin users yet (first-time setup)
|
||||
$cnt = (int)pdo()->query("SELECT COUNT(*) FROM admin_users")->fetchColumn();
|
||||
if ($cnt > 0) {
|
||||
header('Location: ' . url_path('/public/admin/login.php'));
|
||||
exit;
|
||||
}
|
||||
|
||||
$err = '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$csrf = (string)($_POST['csrf'] ?? '');
|
||||
$user = trim((string)($_POST['username'] ?? ''));
|
||||
$pass = (string)($_POST['password'] ?? '');
|
||||
$pass2 = (string)($_POST['password2'] ?? '');
|
||||
|
||||
if (!csrf_check($csrf)) {
|
||||
$err = 'Invalid session. Refresh and try again.';
|
||||
} elseif (!preg_match('/^[a-zA-Z0-9_]{3,32}$/', $user)) {
|
||||
$err = 'Username: 3-32 chars (letters, numbers, underscore).';
|
||||
} elseif (strlen($pass) < 10) {
|
||||
$err = 'Password must be at least 10 characters.';
|
||||
} elseif ($pass !== $pass2) {
|
||||
$err = 'Passwords do not match.';
|
||||
} else {
|
||||
// even though cnt==0, still check just in case (race condition)
|
||||
$st = pdo()->prepare("SELECT id FROM admin_users WHERE username=? LIMIT 1");
|
||||
$st->execute([$user]);
|
||||
if ($st->fetch()) {
|
||||
$err = 'Username already exists.';
|
||||
} else {
|
||||
$algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_DEFAULT;
|
||||
$hash = password_hash($pass, $algo, [
|
||||
'memory_cost' => 1 << 17, // 128MB
|
||||
'time_cost' => 4,
|
||||
'threads' => 2,
|
||||
]);
|
||||
|
||||
$ins = pdo()->prepare("INSERT INTO admin_users (username, pass_hash) VALUES (?, ?)");
|
||||
$ins->execute([$user, $hash]);
|
||||
|
||||
$id = (int)pdo()->lastInsertId();
|
||||
admin_login(['id' => $id, 'username' => $user]);
|
||||
|
||||
header('Location: ' . url_path('/public/admin/dashboard.php'));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Setup Admin</title>
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="<?= htmlspecialchars(url_path('/public/css/app.css')) ?>">
|
||||
<link href="https://cdn.jsdelivr.net/npm/remixicon@4.6.0/fonts/remixicon.css" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body class="min-vh-100 d-flex align-items-center text-white">
|
||||
|
||||
<div class="bg-aurora"></div>
|
||||
<div class="bg-grid"></div>
|
||||
|
||||
<div class="container" style="max-width: 720px;">
|
||||
<div class="card card-glass p-4 p-md-5">
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 flex-wrap mb-3">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="brand-badge">GM</div>
|
||||
<div>
|
||||
<div class="pill d-inline-flex align-items-center gap-2">
|
||||
<span class="dot"></span> First-time setup
|
||||
</div>
|
||||
<h1 class="h3 mb-0 mt-2 hero-gradient">Create Admin Account</h1>
|
||||
<div class="text-white-50 small">This page auto-disables after the first admin is created.</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="tag"><i class="ri-lock-2-line"></i> Secure</span>
|
||||
</div>
|
||||
|
||||
<?php if ($err): ?>
|
||||
<div class="alert alert-danger border-0" style="background: rgba(239,68,68,.15); color:#fff;">
|
||||
<i class="ri-error-warning-line"></i> <?= htmlspecialchars($err) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" autocomplete="off" class="mt-3">
|
||||
<input type="hidden" name="csrf" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label text-white-50">Username</label>
|
||||
<div class="position-relative">
|
||||
<input class="field" name="username" maxlength="32" required
|
||||
placeholder="georgi_admin"
|
||||
value="<?= htmlspecialchars((string)($_POST['username'] ?? '')) ?>">
|
||||
<span class="position-absolute top-50 translate-middle-y end-0 me-3 text-white-50">
|
||||
<i class="ri-user-3-line"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-text text-white-50">Allowed: letters, numbers, underscore. 3-32 chars.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label text-white-50">Password</label>
|
||||
<div class="position-relative">
|
||||
<input id="pw" class="field" type="password" name="password" required placeholder="Min 10 characters">
|
||||
<button type="button" class="btn btn-sm position-absolute top-50 translate-middle-y end-0 me-2"
|
||||
id="togglePw"
|
||||
style="background: rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.10); color:#fff; border-radius: 12px;">
|
||||
<i class="ri-eye-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<div class="bar"><div class="bar-fill" id="pwBar"></div></div>
|
||||
<div class="small text-white-50 mt-1" id="pwHint">Use 10+ chars, mix letters + numbers.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label text-white-50">Confirm password</label>
|
||||
<div class="position-relative">
|
||||
<input id="pw2" class="field" type="password" name="password2" required placeholder="Repeat password">
|
||||
<span class="position-absolute top-50 translate-middle-y end-0 me-3 text-white-50" id="matchIcon">
|
||||
<i class="ri-shield-check-line"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 mt-2">
|
||||
<button class="btn w-100 py-3"
|
||||
style="background: linear-gradient(135deg, rgba(139,92,246,.95), rgba(217,70,239,.85)); border:0; color:#0b0712; font-weight:800; border-radius: 16px;">
|
||||
<i class="ri-sparkling-2-line"></i> Create Admin
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="col-12 text-center">
|
||||
<div class="text-white-50 small">
|
||||
After setup, you will be redirected to the dashboard.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<script>
|
||||
$(function(){
|
||||
// toggle password
|
||||
$('#togglePw').on('click', function(){
|
||||
const $pw = $('#pw');
|
||||
const isPwd = $pw.attr('type') === 'password';
|
||||
$pw.attr('type', isPwd ? 'text' : 'password');
|
||||
$(this).find('i').attr('class', isPwd ? 'ri-eye-off-line' : 'ri-eye-line');
|
||||
});
|
||||
|
||||
function scorePassword(p){
|
||||
let s = 0;
|
||||
if (!p) return 0;
|
||||
if (p.length >= 10) s += 35;
|
||||
if (p.length >= 14) s += 15;
|
||||
if (/[a-z]/.test(p)) s += 15;
|
||||
if (/[A-Z]/.test(p)) s += 10;
|
||||
if (/[0-9]/.test(p)) s += 15;
|
||||
if (/[^a-zA-Z0-9]/.test(p)) s += 10;
|
||||
return Math.min(100, s);
|
||||
}
|
||||
|
||||
function updatePwUI(){
|
||||
const p = $('#pw').val() || '';
|
||||
const s = scorePassword(p);
|
||||
$('#pwBar').css('width', s + '%');
|
||||
|
||||
const p2 = $('#pw2').val() || '';
|
||||
const match = p.length > 0 && p === p2;
|
||||
$('#matchIcon i').attr('class', match ? 'ri-checkbox-circle-line' : 'ri-shield-check-line');
|
||||
$('#matchIcon').toggleClass('text-success', match).toggleClass('text-white-50', !match);
|
||||
}
|
||||
|
||||
$('#pw, #pw2').on('input', updatePwUI);
|
||||
updatePwUI();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
103
public/admin/skill_edit.php
Normal file
103
public/admin/skill_edit.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/../../includes/bootstrap.php';
|
||||
|
||||
|
||||
|
||||
require_admin_login();
|
||||
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
$pageTitle = $id ? 'Edit Skill • Admin' : 'New Skill • Admin';
|
||||
|
||||
$skill = ['label' => '', 'level' => 0, 'icon' => 'ri-code-s-slash-line'];
|
||||
|
||||
if ($id) {
|
||||
$st = db()->prepare("SELECT * FROM skills WHERE id = ? LIMIT 1");
|
||||
$st->execute([$id]);
|
||||
$row = $st->fetch();
|
||||
if (!$row) { flash_set('danger', 'Skill not found.'); header('Location: /public/admin/skills.php'); exit; }
|
||||
$skill = $row;
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!csrf_verify($_POST['csrf'] ?? null)) $errors[] = 'CSRF failed. Refresh and try again.';
|
||||
|
||||
$label = trim((string)($_POST['label'] ?? ''));
|
||||
$level = (int)($_POST['level'] ?? 0);
|
||||
$icon = trim((string)($_POST['icon'] ?? ''));
|
||||
|
||||
if ($label === '' || mb_strlen($label) > 100) $errors[] = 'Label must be 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';
|
||||
?>
|
||||
<div class="d-flex align-items-center justify-content-between mt-4 flex-wrap gap-2">
|
||||
<h1 class="h3 m-0 brand"><?= $id ? 'Edit Skill' : 'New Skill' ?></h1>
|
||||
<a class="btn btn-outline-light" href="/public/admin/skills.php">Back</a>
|
||||
</div>
|
||||
|
||||
<?php if ($errors): ?>
|
||||
<div class="alert alert-danger card-glass border-0 mt-3">
|
||||
<ul class="mb-0">
|
||||
<?php foreach ($errors as $e): ?><li><?= htmlspecialchars($e) ?></li><?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card card-glass border-0 mt-3 p-4">
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label text-white-50">Label</label>
|
||||
<input class="form-control" name="label" maxlength="100" required
|
||||
value="<?= htmlspecialchars((string)$skill['label']) ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label text-white-50">Level (0–100)</label>
|
||||
<input class="form-control" name="level" type="number" min="0" max="100" required
|
||||
value="<?= (int)$skill['level'] ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label text-white-50">Remixicon class</label>
|
||||
<input class="form-control" name="icon" maxlength="80" required
|
||||
value="<?= htmlspecialchars((string)$skill['icon']) ?>">
|
||||
<div class="form-text text-white-50">
|
||||
Example: <code>ri-database-2-line</code> / <code>ri-code-s-slash-line</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 d-flex gap-2">
|
||||
<button class="btn btn-violet"><?= $id ? 'Save Changes' : 'Create Skill' ?></button>
|
||||
<a class="btn btn-outline-light" href="/public/admin/skills.php">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<?php include __DIR__ . '/_bottom.php'; ?>
|
||||
521
public/admin/skills.php
Normal file
521
public/admin/skills.php
Normal file
@@ -0,0 +1,521 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/../../includes/bootstrap.php';
|
||||
|
||||
require_admin_login();
|
||||
|
||||
// ensure schema supports categories + parent relation
|
||||
try {
|
||||
$cols = pdo()->query("SHOW COLUMNS FROM skills")->fetchAll(PDO::FETCH_COLUMN, 0);
|
||||
$alter = [];
|
||||
if (!in_array('category', $cols, true)) $alter[] = "ADD COLUMN category VARCHAR(32) NOT NULL DEFAULT 'language'";
|
||||
if (!in_array('parent_id', $cols, true)) $alter[] = "ADD COLUMN parent_id INT NULL DEFAULT NULL";
|
||||
if ($alter) {
|
||||
pdo()->exec("ALTER TABLE skills " . implode(", ", $alter));
|
||||
}
|
||||
} catch (Throwable $e) {}
|
||||
|
||||
// ensure pivot table for multi-language frameworks
|
||||
try {
|
||||
pdo()->exec("
|
||||
CREATE TABLE IF NOT EXISTS skill_links (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
framework_id INT NOT NULL,
|
||||
language_id INT NOT NULL,
|
||||
UNIQUE KEY uniq_fw_lang (framework_id, language_id),
|
||||
KEY idx_fw (framework_id),
|
||||
KEY idx_lang (language_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
");
|
||||
// seed from legacy parent_id if any exist
|
||||
pdo()->exec("
|
||||
INSERT IGNORE INTO skill_links (framework_id, language_id)
|
||||
SELECT id AS framework_id, parent_id AS language_id
|
||||
FROM skills
|
||||
WHERE category='framework' AND parent_id IS NOT NULL AND parent_id > 0
|
||||
");
|
||||
} catch (Throwable $e) {}
|
||||
|
||||
if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(32));
|
||||
function csrf_ok($t): bool { return is_string($t) && hash_equals($_SESSION['csrf'], $t); }
|
||||
|
||||
if (!function_exists('flash_set')) {
|
||||
function flash_set(string $type, string $msg): void { $_SESSION['_flash'] = ['type'=>$type,'msg'=>$msg]; }
|
||||
}
|
||||
if (!function_exists('flash_get')) {
|
||||
function flash_get(): ?array { $f=$_SESSION['_flash']??null; unset($_SESSION['_flash']); return is_array($f)?$f:null; }
|
||||
}
|
||||
$f = flash_get();
|
||||
$allowedCategories = ['language','framework'];
|
||||
|
||||
function normalize_ids(array $ids): array {
|
||||
$out = [];
|
||||
foreach ($ids as $v) {
|
||||
$v = (int)$v;
|
||||
if ($v > 0) $out[$v] = $v;
|
||||
}
|
||||
return array_values($out);
|
||||
}
|
||||
|
||||
function fetch_language_ids(): array {
|
||||
try {
|
||||
$ids = pdo()->query("SELECT id FROM skills WHERE category='language'")->fetchAll(PDO::FETCH_COLUMN, 0);
|
||||
return array_map('intval', $ids ?: []);
|
||||
} catch (Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function sync_framework_languages(int $frameworkId, array $languageIds, array $validLanguageIds): void {
|
||||
$validSet = array_flip($validLanguageIds);
|
||||
$languageIds = array_values(array_filter(array_unique(array_map('intval', $languageIds)), function ($id) use ($validSet) {
|
||||
return $id > 0 && isset($validSet[$id]);
|
||||
}));
|
||||
|
||||
pdo()->prepare("DELETE FROM skill_links WHERE framework_id=?")->execute([$frameworkId]);
|
||||
if (!$languageIds) return;
|
||||
|
||||
$ins = pdo()->prepare("INSERT IGNORE INTO skill_links (framework_id, language_id) VALUES (?, ?)");
|
||||
foreach ($languageIds as $lid) {
|
||||
$ins->execute([$frameworkId, $lid]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!csrf_ok((string)($_POST['csrf'] ?? ''))) {
|
||||
flash_set('danger', 'Bad CSRF token.');
|
||||
header('Location: /public/admin/skills.php'); exit;
|
||||
}
|
||||
|
||||
// DELETE (handled first, so it never falls into update_many)
|
||||
if (isset($_POST['delete_id'])) {
|
||||
$id = (int)($_POST['delete_id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
pdo()->prepare("UPDATE skills SET parent_id=NULL WHERE parent_id=?")->execute([$id]);
|
||||
pdo()->prepare("DELETE FROM skill_links WHERE framework_id=? OR language_id=?")->execute([$id, $id]);
|
||||
pdo()->prepare("DELETE FROM skills WHERE id=?")->execute([$id]);
|
||||
flash_set('success', 'Skill deleted.');
|
||||
}
|
||||
header('Location: /public/admin/skills.php'); exit;
|
||||
}
|
||||
|
||||
$action = (string)($_POST['action'] ?? '');
|
||||
|
||||
if ($action === 'create') {
|
||||
$label = trim((string)($_POST['label'] ?? ''));
|
||||
$icon = trim((string)($_POST['icon'] ?? 'ri-code-s-slash-line'));
|
||||
$level = max(0, min(100, (int)($_POST['level'] ?? 0)));
|
||||
$sort = (int)($_POST['sort_order'] ?? 0);
|
||||
$category = strtolower(trim((string)($_POST['category'] ?? 'language')));
|
||||
if (!in_array($category, $allowedCategories, true)) $category = 'language';
|
||||
$parentIds = $category === 'framework' ? normalize_ids((array)($_POST['parent_id_new'] ?? [])) : [];
|
||||
$validLanguageIds = $category === 'framework' ? fetch_language_ids() : [];
|
||||
if ($parentIds && $validLanguageIds) {
|
||||
$parentIds = array_values(array_intersect($parentIds, $validLanguageIds));
|
||||
}
|
||||
|
||||
if ($label === '') {
|
||||
flash_set('danger', 'Label is required.');
|
||||
} else {
|
||||
if ($category === 'framework') {
|
||||
if (!$parentIds) {
|
||||
flash_set('danger', 'Select one or more parent languages for this framework.');
|
||||
header('Location: /public/admin/skills.php'); exit;
|
||||
}
|
||||
} else {
|
||||
$parentIds = [];
|
||||
}
|
||||
|
||||
$primaryParent = ($category === 'framework' && $parentIds) ? (int)$parentIds[0] : null;
|
||||
|
||||
pdo()->prepare("INSERT INTO skills (label, icon, level, sort_order, category, parent_id) VALUES (?,?,?,?,?,?)")
|
||||
->execute([$label, $icon, $level, $sort, $category, $primaryParent]);
|
||||
$newId = (int)pdo()->lastInsertId();
|
||||
|
||||
if ($category === 'framework' && $newId > 0) {
|
||||
sync_framework_languages($newId, $parentIds, $validLanguageIds);
|
||||
}
|
||||
flash_set('success', 'Skill added.');
|
||||
}
|
||||
header('Location: /public/admin/skills.php'); exit;
|
||||
}
|
||||
|
||||
if ($action === 'update_many') {
|
||||
$ids = $_POST['id'] ?? [];
|
||||
$categoriesMap = [];
|
||||
foreach ($ids as $i => $idRaw) {
|
||||
$cid = (int)$idRaw;
|
||||
$catRaw = strtolower(trim((string)($_POST['category'][$i] ?? 'language')));
|
||||
$categoriesMap[$cid] = in_array($catRaw, $allowedCategories, true) ? $catRaw : 'language';
|
||||
}
|
||||
// use DB-backed language ids to avoid losing links if a row is flipped to framework
|
||||
$validLanguageIds = fetch_language_ids();
|
||||
$parentSelections = (array)($_POST['parent_id'] ?? []);
|
||||
|
||||
foreach ($ids as $i => $idRaw) {
|
||||
$id = (int)$idRaw;
|
||||
if ($id <= 0) continue;
|
||||
|
||||
$label = trim((string)($_POST['label'][$i] ?? ''));
|
||||
$icon = trim((string)($_POST['icon'][$i] ?? 'ri-code-s-slash-line'));
|
||||
$level = max(0, min(100, (int)($_POST['level'][$i] ?? 0)));
|
||||
$sort = (int)($_POST['sort_order'][$i] ?? 0);
|
||||
$category = $categoriesMap[$id] ?? 'language';
|
||||
$selectedParents = ($category === 'framework') ? normalize_ids((array)($parentSelections[$id] ?? [])) : [];
|
||||
if ($selectedParents && $validLanguageIds) {
|
||||
$selectedParents = array_values(array_intersect($selectedParents, $validLanguageIds));
|
||||
}
|
||||
$primaryParent = ($category === 'framework' && $selectedParents) ? (int)$selectedParents[0] : null;
|
||||
|
||||
pdo()->prepare("UPDATE skills SET label=?, icon=?, level=?, sort_order=?, category=?, parent_id=? WHERE id=?")
|
||||
->execute([$label, $icon, $level, $sort, $category, $primaryParent, $id]);
|
||||
|
||||
if ($category === 'framework') {
|
||||
sync_framework_languages($id, $selectedParents, $validLanguageIds);
|
||||
// if this was previously a language, clear stale links where it was used as a parent
|
||||
pdo()->prepare("DELETE FROM skill_links WHERE language_id=? AND framework_id!=?")->execute([$id, $id]);
|
||||
} else {
|
||||
// if it is (or is now) a language, only remove links where it was the framework
|
||||
pdo()->prepare("DELETE FROM skill_links WHERE framework_id=?")->execute([$id]);
|
||||
}
|
||||
}
|
||||
flash_set('success', 'Skills updated.');
|
||||
header('Location: /public/admin/skills.php'); exit;
|
||||
}
|
||||
}
|
||||
|
||||
$skills = [];
|
||||
try {
|
||||
$skills = pdo()->query("SELECT id,label,icon,level,sort_order,category,parent_id FROM skills ORDER BY (category='language') DESC, sort_order ASC, id ASC")->fetchAll() ?: [];
|
||||
} catch (Throwable $e) {}
|
||||
$skillLinks = [];
|
||||
try {
|
||||
$skillLinks = pdo()->query("SELECT framework_id, language_id FROM skill_links")->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
} catch (Throwable $e) {}
|
||||
$frameworkParentMap = [];
|
||||
foreach ($skillLinks as $link) {
|
||||
$fw = (int)($link['framework_id'] ?? 0);
|
||||
$lang = (int)($link['language_id'] ?? 0);
|
||||
if ($fw > 0 && $lang > 0) {
|
||||
if (!isset($frameworkParentMap[$fw])) $frameworkParentMap[$fw] = [];
|
||||
$frameworkParentMap[$fw][$lang] = $lang;
|
||||
}
|
||||
}
|
||||
|
||||
$languageOptions = array_values(array_filter($skills, function ($s) {
|
||||
return strtolower((string)($s['category'] ?? 'language')) === 'language';
|
||||
}));
|
||||
$languageLabelMap = [];
|
||||
foreach ($languageOptions as $lang) {
|
||||
$languageLabelMap[(int)$lang['id']] = (string)$lang['label'];
|
||||
}
|
||||
// fallback to legacy parent_id if no links saved yet
|
||||
foreach ($skills as $s) {
|
||||
$cat = strtolower((string)($s['category'] ?? 'language'));
|
||||
if ($cat !== 'framework') continue;
|
||||
$fwId = (int)($s['id'] ?? 0);
|
||||
$pid = (int)($s['parent_id'] ?? 0);
|
||||
if ($fwId > 0 && $pid > 0 && empty($frameworkParentMap[$fwId])) {
|
||||
$frameworkParentMap[$fwId][$pid] = $pid;
|
||||
}
|
||||
}
|
||||
|
||||
$frameworkParentMap = array_map('array_values', $frameworkParentMap);
|
||||
|
||||
$openNew = isset($_GET['new']);
|
||||
?>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Skills • Admin</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/public/css/admin.css">
|
||||
<style>
|
||||
.lvl{width:120px}
|
||||
body { font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif; }
|
||||
.table-responsive { overflow: visible; }
|
||||
.parent-wrap { position: relative; }
|
||||
.new-skill-card { position: relative; z-index: 50; overflow: visible; }
|
||||
.parent-picker-btn {
|
||||
background: linear-gradient(120deg, rgba(255,255,255,.05), rgba(139,92,246,.08));
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
color: #f8fafc;
|
||||
transition: all .18s ease;
|
||||
}
|
||||
.parent-picker-btn:hover {
|
||||
border-color: rgba(167,139,250,.6);
|
||||
box-shadow: 0 12px 28px rgba(0,0,0,.2);
|
||||
}
|
||||
.parent-summary {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.parent-menu {
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 18px 38px rgba(0,0,0,.3);
|
||||
z-index: 5000;
|
||||
position: absolute;
|
||||
inset: auto auto auto 0;
|
||||
min-width: 100%;
|
||||
}
|
||||
.parent-option {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
transition: background .15s ease, border-color .15s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.parent-option:hover {
|
||||
background: rgba(255,255,255,.05);
|
||||
border-color: rgba(255,255,255,.1);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-vh-100">
|
||||
<div class="container py-4" style="max-width: 1100px">
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="h3 m-0">Tech Stack (%)</h1>
|
||||
<div class="text-white-50 small">Create languages and nest frameworks beneath them.</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a class="btn btn-outline-light" href="/public/admin/dashboard.php">Dashboard</a>
|
||||
<a class="btn btn-outline-light" href="/public/admin/logout.php">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($f): ?>
|
||||
<div class="alert alert-<?= htmlspecialchars($f['type']) ?>"><?= htmlspecialchars($f['msg']) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card card-glass p-3 mb-3 new-skill-card">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div class="text-white-50 small">Add New</div>
|
||||
<div class="fw-bold">Create a new bar on the homepage</div>
|
||||
</div>
|
||||
<button class="btn btn-violet" type="button" data-scroll-target="#newSkill">
|
||||
Add Skill
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-3" id="newSkill">
|
||||
<form method="post" class="row g-3">
|
||||
<input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
|
||||
<input type="hidden" name="action" value="create">
|
||||
|
||||
<div class="col-md-5">
|
||||
<label class="form-label text-white-50">Label</label>
|
||||
<input class="form-control" name="label" required maxlength="120" placeholder="PHP / Laravel">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label text-white-50">Icon (RemixIcon class)</label>
|
||||
<input class="form-control" name="icon" maxlength="80" value="ri-code-s-slash-line">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label text-white-50">%</label>
|
||||
<input class="form-control" name="level" type="number" min="0" max="100" placeholder="100">
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label text-white-50">Sort</label>
|
||||
<input class="form-control" name="sort_order" type="number" value="0">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label text-white-50">Category</label>
|
||||
<select class="form-select" name="category" data-parent-target="#parent-new-wrap">
|
||||
<option value="language">Language</option>
|
||||
<option value="framework">Framework</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="parent-wrap d-none" data-parent-wrap id="parent-new-wrap">
|
||||
<label class="form-label text-white-50">Parent languages (for frameworks)</label>
|
||||
<div class="dropdown w-100">
|
||||
<button class="btn parent-picker-btn w-100 d-flex justify-content-between align-items-center" type="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" data-bs-display="static">
|
||||
<span class="parent-summary" data-parent-summary>Select parent languages</span>
|
||||
<i class="ri-arrow-down-s-line ms-2"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-dark w-100 p-2 parent-menu">
|
||||
<?php foreach ($languageOptions as $lang): ?>
|
||||
<label class="parent-option d-flex align-items-center gap-2 text-white-75 small mb-1">
|
||||
<input class="form-check-input" type="checkbox" name="parent_id_new[]" value="<?= (int)$lang['id'] ?>" data-label="<?= htmlspecialchars((string)$lang['label']) ?>">
|
||||
<span><?= htmlspecialchars((string)$lang['label']) ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text text-white-50 mt-1">Choose one or more languages to nest its frameworks.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<button class="btn btn-violet">Save Skill</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-glass p-0 overflow-hidden">
|
||||
<div class="p-3 border-bottom border-white/10 d-flex justify-content-between align-items-center">
|
||||
<div class="fw-bold">Edit Skills</div>
|
||||
<div class="text-white-50 small"><?= count($skills) ?> total</div>
|
||||
</div>
|
||||
|
||||
<!-- ONE form only -->
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
|
||||
<input type="hidden" name="action" value="update_many">
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0 align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:70px">ID</th>
|
||||
<th>Label</th>
|
||||
<th style="width:200px">Icon</th>
|
||||
<th style="width:140px">Category</th>
|
||||
<th style="width:240px">Parents</th>
|
||||
<th class="lvl">%</th>
|
||||
<th style="width:90px">Sort</th>
|
||||
<th style="width:110px">Delete</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($skills as $s): ?>
|
||||
<tr>
|
||||
<td class="text-white-50">
|
||||
<?= (int)$s['id'] ?>
|
||||
<input type="hidden" name="id[]" value="<?= (int)$s['id'] ?>">
|
||||
</td>
|
||||
<td><input class="form-control" name="label[]" value="<?= htmlspecialchars((string)$s['label']) ?>" maxlength="120"></td>
|
||||
<td><input class="form-control" name="icon[]" value="<?= htmlspecialchars((string)$s['icon']) ?>" maxlength="80"></td>
|
||||
<td>
|
||||
<?php $cat = strtolower((string)($s['category'] ?? 'language')); ?>
|
||||
<select class="form-select" name="category[]" data-parent-target="#parent-<?= (int)$s['id'] ?>-wrap">
|
||||
<option value="language" <?= $cat === 'language' ? 'selected' : '' ?>>Language</option>
|
||||
<option value="framework" <?= $cat === 'framework' ? 'selected' : '' ?>>Framework</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<?php $selectedParents = $frameworkParentMap[(int)$s['id']] ?? []; ?>
|
||||
<?php
|
||||
$summaryText = 'Select parent languages';
|
||||
$labels = [];
|
||||
foreach ($selectedParents as $pid) {
|
||||
if (isset($languageLabelMap[$pid])) $labels[] = $languageLabelMap[$pid];
|
||||
}
|
||||
if ($labels) $summaryText = implode(', ', $labels);
|
||||
?>
|
||||
<div class="parent-wrap <?= $cat === 'framework' ? '' : 'd-none' ?>" data-parent-wrap id="parent-<?= (int)$s['id'] ?>-wrap">
|
||||
<div class="dropdown w-100">
|
||||
<button class="btn parent-picker-btn w-100 d-flex justify-content-between align-items-center" type="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" data-bs-display="static">
|
||||
<span class="parent-summary" data-parent-summary><?= htmlspecialchars($summaryText) ?></span>
|
||||
<i class="ri-arrow-down-s-line ms-2"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-dark w-100 p-2 parent-menu">
|
||||
<?php foreach ($languageOptions as $lang): ?>
|
||||
<?php if ((int)$lang['id'] === (int)$s['id']) continue; ?>
|
||||
<label class="parent-option d-flex align-items-center gap-2 text-white-75 small mb-1">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
name="parent_id[<?= (int)$s['id'] ?>][]"
|
||||
value="<?= (int)$lang['id'] ?>"
|
||||
data-label="<?= htmlspecialchars((string)$lang['label']) ?>"
|
||||
<?= in_array((int)$lang['id'], $selectedParents, true) ? 'checked' : '' ?>
|
||||
>
|
||||
<span><?= htmlspecialchars((string)$lang['label']) ?></span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><input class="form-control" name="level[]" type="number" min="0" max="100" value="<?= (int)$s['level'] ?>"></td>
|
||||
<td><input class="form-control" name="sort_order[]" type="number" value="<?= (int)$s['sort_order'] ?>"></td>
|
||||
<td>
|
||||
<!-- NO inner form -->
|
||||
<button
|
||||
class="btn btn-outline-danger btn-sm"
|
||||
type="submit"
|
||||
name="delete_id"
|
||||
value="<?= (int)$s['id'] ?>"
|
||||
data-confirm="Delete this skill?"
|
||||
>Del</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (!$skills): ?>
|
||||
<tr><td colspan="8" class="text-center text-white-50 py-4">No skills yet.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="p-3 border-top border-white/10">
|
||||
<button class="btn btn-violet">Save All Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
(function ($) {
|
||||
$(function () {
|
||||
$('[data-scroll-target]').on('click', function () {
|
||||
const target = $(this).data('scroll-target');
|
||||
if (!target) return;
|
||||
const $dest = $(target);
|
||||
if ($dest.length) {
|
||||
$('html, body').animate({ scrollTop: $dest.offset().top - 20 }, 250);
|
||||
}
|
||||
});
|
||||
|
||||
const syncSummary = ($wrap) => {
|
||||
const labels = $wrap
|
||||
.find('input[type="checkbox"]:checked')
|
||||
.map(function () { return $(this).data("label") || $(this).val(); })
|
||||
.get();
|
||||
$wrap.find('[data-parent-summary]').text(labels.length ? labels.join(', ') : 'Select parent languages');
|
||||
};
|
||||
|
||||
const updateParentVisibility = ($select) => {
|
||||
const targetSelector = $select.data('parent-target');
|
||||
if (!targetSelector) return;
|
||||
const $wrap = $(targetSelector);
|
||||
if (!$wrap.length) return;
|
||||
|
||||
const show = $select.val() === 'framework';
|
||||
$wrap.toggleClass('d-none', !show);
|
||||
if (!show) {
|
||||
$wrap.find('input[type="checkbox"]').prop('checked', false);
|
||||
$wrap.find('select').each(function () {
|
||||
$(this).find('option').prop('selected', false);
|
||||
});
|
||||
}
|
||||
syncSummary($wrap);
|
||||
};
|
||||
|
||||
$('[data-parent-target]').each(function () {
|
||||
const $select = $(this);
|
||||
updateParentVisibility($select);
|
||||
$select.on('change', () => updateParentVisibility($select));
|
||||
});
|
||||
|
||||
$('.parent-wrap').each(function () {
|
||||
const $wrap = $(this);
|
||||
$wrap.find('input[type="checkbox"]').on('change', () => syncSummary($wrap));
|
||||
syncSummary($wrap);
|
||||
});
|
||||
});
|
||||
})(jQuery);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
96
public/admin/spotify_callback.php
Normal file
96
public/admin/spotify_callback.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../../includes/bootstrap.php';
|
||||
require_admin_login();
|
||||
|
||||
function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }
|
||||
|
||||
if (!empty($_GET['error'])) {
|
||||
echo "<pre>Spotify error: " . h((string)$_GET['error']) . "</pre>";
|
||||
exit;
|
||||
}
|
||||
|
||||
$code = (string)($_GET['code'] ?? '');
|
||||
$state = (string)($_GET['state'] ?? '');
|
||||
|
||||
if ($code === '' || $state === '') {
|
||||
echo "<pre>Missing code/state.\n\nGET:\n"; print_r($_GET); echo "</pre>";
|
||||
exit;
|
||||
}
|
||||
|
||||
$expected = (string)($_SESSION['sp_state'] ?? '');
|
||||
unset($_SESSION['sp_state']);
|
||||
|
||||
if ($expected === '' || !hash_equals($expected, $state)) {
|
||||
echo "<pre>State mismatch.\nExpected: ".h($expected)."\nGot: ".h($state)."</pre>";
|
||||
echo "<p>Use the SAME host everywhere (127.0.0.1 vs localhost) or cookies won’t match.</p>";
|
||||
exit;
|
||||
}
|
||||
|
||||
if (SPOTIFY_CLIENT_ID === '' || SPOTIFY_CLIENT_SECRET === '' || SPOTIFY_REDIRECT_URI === '') {
|
||||
http_response_code(500);
|
||||
echo "<pre>Missing Spotify config.</pre>";
|
||||
exit;
|
||||
}
|
||||
|
||||
$ch = curl_init('https://accounts.spotify.com/api/token');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Basic ' . base64_encode(SPOTIFY_CLIENT_ID . ':' . SPOTIFY_CLIENT_SECRET),
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
],
|
||||
CURLOPT_POSTFIELDS => http_build_query([
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => SPOTIFY_REDIRECT_URI,
|
||||
]),
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
]);
|
||||
|
||||
$raw = curl_exec($ch);
|
||||
$http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$data = is_string($raw) ? json_decode($raw, true) : null;
|
||||
|
||||
if ($raw === false || $http < 200 || $http >= 300 || !is_array($data)) {
|
||||
echo "<pre>Token exchange failed.\nHTTP: {$http}\nCurl: ".h($err ?: 'none')."\nRaw:\n".h((string)$raw)."\n</pre>";
|
||||
exit;
|
||||
}
|
||||
|
||||
$access = (string)($data['access_token'] ?? '');
|
||||
$refresh = (string)($data['refresh_token'] ?? '');
|
||||
$expires = time() + (int)($data['expires_in'] ?? 3600);
|
||||
|
||||
if ($access === '') {
|
||||
echo "<pre>No access_token returned.\n"; print_r($data); echo "</pre>";
|
||||
exit;
|
||||
}
|
||||
|
||||
pdo()->exec("
|
||||
CREATE TABLE IF NOT EXISTS spotify_tokens (
|
||||
id INT PRIMARY KEY,
|
||||
refresh_token TEXT NULL,
|
||||
access_token TEXT NULL,
|
||||
access_expires INT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
");
|
||||
pdo()->exec("INSERT IGNORE INTO spotify_tokens (id) VALUES (1)");
|
||||
|
||||
if ($refresh !== '') {
|
||||
pdo()->prepare("UPDATE spotify_tokens SET refresh_token=?, access_token=?, access_expires=? WHERE id=1")
|
||||
->execute([$refresh, $access, $expires]);
|
||||
flash_set('success', 'Spotify connected. Refresh token saved.');
|
||||
} else {
|
||||
pdo()->prepare("UPDATE spotify_tokens SET access_token=?, access_expires=? WHERE id=1")
|
||||
->execute([$access, $expires]);
|
||||
flash_set('warning', 'Connected, but no refresh token returned (already authorized before). Revoke access in Spotify account settings and try again.');
|
||||
}
|
||||
|
||||
header('Location: ' . url_path('/public/admin/dashboard.php'));
|
||||
exit;
|
||||
28
public/admin/spotify_connect.php
Normal file
28
public/admin/spotify_connect.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../../includes/bootstrap.php';
|
||||
require_admin_login();
|
||||
|
||||
if (SPOTIFY_CLIENT_ID === '' || SPOTIFY_REDIRECT_URI === '') {
|
||||
http_response_code(500);
|
||||
echo "Missing Spotify client_id or redirect_uri";
|
||||
exit;
|
||||
}
|
||||
|
||||
$state = bin2hex(random_bytes(16));
|
||||
$_SESSION['sp_state'] = $state;
|
||||
|
||||
$scope = 'user-read-currently-playing user-read-recently-played';
|
||||
|
||||
$url = 'https://accounts.spotify.com/authorize?' . http_build_query([
|
||||
'response_type' => 'code',
|
||||
'client_id' => SPOTIFY_CLIENT_ID,
|
||||
'scope' => $scope,
|
||||
'redirect_uri' => SPOTIFY_REDIRECT_URI,
|
||||
'state' => $state,
|
||||
'show_dialog' => 'true',
|
||||
]);
|
||||
|
||||
header('Location: ' . $url);
|
||||
exit;
|
||||
105
public/admin/spotify_token.php
Normal file
105
public/admin/spotify_token.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/../../includes/bootstrap.php';
|
||||
|
||||
require_admin_login();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!csrf_check((string)($_POST['csrf'] ?? ''))) {
|
||||
flash_set('danger', 'Bad CSRF token.');
|
||||
header('Location: ' . url_path('/public/admin/dashboard.php'));
|
||||
exit;
|
||||
}
|
||||
|
||||
$token = trim((string)($_POST['access_token'] ?? ''));
|
||||
$ttl = (int)($_POST['ttl'] ?? 3600);
|
||||
if ($ttl < 300) $ttl = 300;
|
||||
|
||||
if ($token === '') {
|
||||
flash_set('danger', 'Token is empty.');
|
||||
header('Location: ' . url_path('/public/admin/spotify_token.php'));
|
||||
exit;
|
||||
}
|
||||
|
||||
$exp = time() + $ttl;
|
||||
|
||||
// ensure schema
|
||||
try { pdo()->exec("ALTER TABLE spotify_tokens ADD COLUMN access_token TEXT NULL"); } catch (Throwable $e) {}
|
||||
try { pdo()->exec("ALTER TABLE spotify_tokens ADD COLUMN access_expires INT NULL"); } catch (Throwable $e) {}
|
||||
try { pdo()->exec("INSERT IGNORE INTO spotify_tokens (id) VALUES (1)"); } catch (Throwable $e) {}
|
||||
|
||||
pdo()->prepare("UPDATE spotify_tokens SET access_token=?, access_expires=? WHERE id=1")
|
||||
->execute([$token, $exp]);
|
||||
|
||||
flash_set('success', 'Spotify access token saved (temporary).');
|
||||
header('Location: ' . url_path('/public/admin/dashboard.php'));
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Spotify Token • Admin</title>
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/remixicon@4.6.0/fonts/remixicon.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="<?= htmlspecialchars(url_path('/css/admin.css')) ?>">
|
||||
</head>
|
||||
<body class="min-vh-100">
|
||||
<div class="bg-aurora"></div>
|
||||
<div class="bg-grid"></div>
|
||||
|
||||
<div class="topbar-glass py-3 mb-4">
|
||||
<div class="container d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="brand-badge">GM</div>
|
||||
<div>
|
||||
<div class="text-white-50 small">Portfolio Admin</div>
|
||||
<div class="fw-bold hero-gradient">Spotify Token</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a class="navlink" href="<?= htmlspecialchars(url_path('/public/admin/dashboard.php')) ?>"><i class="ri-dashboard-line"></i><span>Dashboard</span></a>
|
||||
<a class="navlink" href="<?= htmlspecialchars(url_path('/public/admin/logout.php')) ?>"><i class="ri-logout-box-r-line"></i><span>Logout</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container pb-5" style="max-width: 900px;">
|
||||
<div class="card-glass p-4 p-md-5 rounded-4">
|
||||
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-2">
|
||||
<div class="pill d-inline-flex align-items-center gap-2">
|
||||
<span class="dot"></span> Temporary method
|
||||
</div>
|
||||
<span class="tag"><i class="ri-alert-line"></i> Expires</span>
|
||||
</div>
|
||||
|
||||
<h1 class="h3 m-0">Paste Spotify Access Token</h1>
|
||||
<div class="text-white-50 mt-2">
|
||||
Access tokens expire (usually ~1h). Best solution is storing a refresh token and letting <code>/api/spotify.php</code> refresh automatically.
|
||||
</div>
|
||||
|
||||
<form method="post" class="row g-3 mt-3">
|
||||
<input type="hidden" name="csrf" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label text-white-50">Access token</label>
|
||||
<textarea class="form-control" name="access_token" rows="5" required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label text-white-50">TTL seconds</label>
|
||||
<input class="form-control" name="ttl" type="number" value="3600" min="300">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<button class="btn btn-violet px-4 py-2"><i class="ri-save-3-line"></i> Save Token</button>
|
||||
<a class="btn btn-outline-light ms-2" href="<?= htmlspecialchars(url_path('/public/admin/dashboard.php')) ?>">Back</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
194
public/css/admin.css
Normal file
194
public/css/admin.css
Normal file
@@ -0,0 +1,194 @@
|
||||
:root{
|
||||
--ink:#07060a;
|
||||
--glass: rgba(16, 14, 26, .62);
|
||||
--glass2: rgba(18, 15, 28, .62);
|
||||
--b: rgba(255,255,255,.10);
|
||||
--muted: rgba(255,255,255,.72);
|
||||
--muted2: rgba(255,255,255,.50);
|
||||
--violet: rgba(139,92,246,.95);
|
||||
--fuchsia: rgba(217,70,239,.85);
|
||||
}
|
||||
|
||||
html,body{height:100%}
|
||||
body{background:var(--ink); color:#fff}
|
||||
|
||||
/* Helps native form controls (especially <select>) use dark UI */
|
||||
html{ color-scheme: dark; }
|
||||
|
||||
/* Background */
|
||||
.bg-aurora{
|
||||
position: fixed; inset: 0;
|
||||
background:
|
||||
radial-gradient(1000px 500px at 20% 20%, rgba(139,92,246,.30), transparent 60%),
|
||||
radial-gradient(900px 600px at 80% 30%, rgba(217,70,239,.22), transparent 60%),
|
||||
radial-gradient(900px 700px at 55% 90%, rgba(167,139,250,.18), transparent 60%);
|
||||
z-index:-2;
|
||||
}
|
||||
.bg-grid{
|
||||
position: fixed; inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
opacity:.12;
|
||||
mask-image: radial-gradient(circle at 50% 20%, #000 0%, transparent 65%);
|
||||
z-index:-1;
|
||||
}
|
||||
|
||||
/* Topbar */
|
||||
.topbar-glass{
|
||||
background: var(--glass2);
|
||||
border: 1px solid var(--b);
|
||||
backdrop-filter: blur(16px) saturate(150%);
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,.35);
|
||||
}
|
||||
.brand-badge{
|
||||
width: 40px; height: 40px;
|
||||
display:grid; place-items:center;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, var(--violet), var(--fuchsia));
|
||||
font-weight: 900;
|
||||
color:#0b0712;
|
||||
}
|
||||
.hero-gradient{
|
||||
background: linear-gradient(90deg, #a78bfa, #d946ef, #8b5cf6);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.navlink{
|
||||
display:inline-flex; align-items:center; gap:.55rem;
|
||||
padding:.55rem .85rem;
|
||||
border-radius: 14px;
|
||||
text-decoration:none;
|
||||
color: rgba(255,255,255,.78);
|
||||
transition: transform .12s ease, background .12s ease;
|
||||
}
|
||||
.navlink:hover{ background: rgba(255,255,255,.06); transform: translateY(-1px); }
|
||||
.navlink i{ font-size: 1.1rem; }
|
||||
|
||||
/* Cards */
|
||||
.card-glass{
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--b);
|
||||
backdrop-filter: blur(16px) saturate(150%);
|
||||
box-shadow: 0 12px 50px rgba(0,0,0,.35);
|
||||
}
|
||||
|
||||
/* Pills / tags */
|
||||
.pill{
|
||||
padding: .45rem .75rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,.06);
|
||||
border: 1px solid var(--b);
|
||||
color: rgba(255,255,255,.80);
|
||||
font-size: .9rem;
|
||||
}
|
||||
.dot{
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #34d399;
|
||||
box-shadow: 0 0 18px rgba(52,211,153,.55);
|
||||
}
|
||||
.tag{
|
||||
font-size: .75rem;
|
||||
padding: .25rem .55rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(139,92,246,.18);
|
||||
border: 1px solid rgba(139,92,246,.25);
|
||||
color: rgba(255,255,255,.85);
|
||||
}
|
||||
|
||||
/* Bootstrap overrides to match glass */
|
||||
.form-control, .form-select, textarea{
|
||||
border-radius: 14px !important;
|
||||
background: rgba(255,255,255,.06) !important;
|
||||
border: 1px solid rgba(255,255,255,.10) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.form-control:focus, textarea:focus, .form-select:focus{
|
||||
border-color: rgba(167,139,250,.55) !important;
|
||||
box-shadow: 0 0 0 4px rgba(139,92,246,.18) !important;
|
||||
}
|
||||
.form-control::placeholder, textarea::placeholder{
|
||||
color: rgba(255,255,255,.35) !important;
|
||||
}
|
||||
|
||||
/* Make <select> arrow + padding consistent and readable */
|
||||
.form-select{
|
||||
padding-right: 2.6rem !important;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24'%3E%3Cpath fill='%23a78bfa' d='M7 10l5 5l5-5z'/%3E%3C/svg%3E") !important;
|
||||
background-repeat: no-repeat !important;
|
||||
background-position: right 1rem center !important;
|
||||
background-size: 18px 18px !important;
|
||||
}
|
||||
|
||||
/* Dropdown list colors (works where browser allows styling) */
|
||||
.form-select option{
|
||||
background-color: #0c0a14;
|
||||
color: rgba(255,255,255,.92);
|
||||
}
|
||||
|
||||
/* Selected option contrast (supported in some browsers) */
|
||||
.form-select option:checked{
|
||||
background: linear-gradient(135deg, rgba(139,92,246,.55), rgba(217,70,239,.45));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-violet{
|
||||
border:0 !important;
|
||||
color:#0b0712 !important;
|
||||
font-weight:900 !important;
|
||||
border-radius: 14px !important;
|
||||
background: linear-gradient(135deg, var(--violet), var(--fuchsia)) !important;
|
||||
box-shadow: 0 0 0 1px rgba(167,139,250,.25), 0 18px 40px rgba(139,92,246,.18);
|
||||
}
|
||||
.btn-violet:hover{ filter: brightness(1.08); transform: translateY(-1px); }
|
||||
|
||||
.btn-outline-light{
|
||||
border-radius: 14px !important;
|
||||
border: 1px solid rgba(255,255,255,.12) !important;
|
||||
background: rgba(255,255,255,.06) !important;
|
||||
}
|
||||
.btn-outline-light:hover{ background: rgba(255,255,255,.10) !important; }
|
||||
|
||||
/* Optional: Bootstrap dropdown menus (if you use .dropdown anywhere) */
|
||||
.dropdown-menu{
|
||||
background: rgba(16,14,26,.88) !important;
|
||||
border: 1px solid rgba(255,255,255,.10) !important;
|
||||
backdrop-filter: blur(16px) saturate(150%);
|
||||
box-shadow: 0 18px 70px rgba(0,0,0,.45);
|
||||
border-radius: 14px;
|
||||
padding: .4rem;
|
||||
}
|
||||
.dropdown-item{
|
||||
color: rgba(255,255,255,.86) !important;
|
||||
border-radius: 10px;
|
||||
padding: .55rem .7rem;
|
||||
}
|
||||
.dropdown-item:hover,
|
||||
.dropdown-item:focus{
|
||||
background: rgba(167,139,250,.16) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.dropdown-item.active,
|
||||
.dropdown-item:active{
|
||||
background: linear-gradient(135deg, rgba(139,92,246,.95), rgba(217,70,239,.85)) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.table-dark{
|
||||
--bs-table-bg: transparent;
|
||||
--bs-table-striped-bg: rgba(255,255,255,.03);
|
||||
--bs-table-hover-bg: rgba(255,255,255,.05);
|
||||
--bs-table-border-color: rgba(255,255,255,.08);
|
||||
}
|
||||
.table thead th{
|
||||
color: rgba(255,255,255,.70);
|
||||
border-bottom-color: rgba(255,255,255,.10) !important;
|
||||
}
|
||||
306
public/css/app.css
Normal file
306
public/css/app.css
Normal file
@@ -0,0 +1,306 @@
|
||||
.topbar-glass{
|
||||
background: rgba(18, 15, 28, .62);
|
||||
border: 1px solid rgba(255,255,255,.10);
|
||||
backdrop-filter: blur(16px) saturate(150%);
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,.35);
|
||||
}
|
||||
|
||||
.brand-badge{
|
||||
width: 36px; height: 36px;
|
||||
display:grid; place-items:center;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, rgba(139,92,246,.95), rgba(217,70,239,.85));
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.navlink{
|
||||
display:inline-flex; align-items:center; gap:.5rem;
|
||||
padding:.55rem .85rem;
|
||||
border-radius: 14px;
|
||||
text-decoration:none;
|
||||
color: rgba(255,255,255,.78);
|
||||
transition: transform .12s ease, background .12s ease;
|
||||
}
|
||||
.navlink:hover{ background: rgba(255,255,255,.06); transform: translateY(-1px); }
|
||||
.navlink span{ font-size: .95rem; }
|
||||
|
||||
.bg-aurora{
|
||||
position: fixed; inset: 0;
|
||||
background:
|
||||
radial-gradient(1000px 500px at 20% 20%, rgba(139,92,246,.30), transparent 60%),
|
||||
radial-gradient(900px 600px at 80% 30%, rgba(217,70,239,.22), transparent 60%),
|
||||
radial-gradient(900px 700px at 55% 90%, rgba(167,139,250,.18), transparent 60%);
|
||||
filter: blur(0px);
|
||||
z-index:-2;
|
||||
}
|
||||
.bg-grid{
|
||||
position: fixed; inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
opacity:.12;
|
||||
mask-image: radial-gradient(circle at 50% 20%, #000 0%, transparent 65%);
|
||||
z-index:-1;
|
||||
}
|
||||
|
||||
.card-glass{
|
||||
background: rgba(16, 14, 26, .62);
|
||||
border: 1px solid rgba(255,255,255,.10);
|
||||
backdrop-filter: blur(16px) saturate(150%);
|
||||
box-shadow: 0 12px 50px rgba(0,0,0,.35);
|
||||
}
|
||||
|
||||
.pill{
|
||||
padding: .45rem .75rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,.06);
|
||||
border: 1px solid rgba(255,255,255,.10);
|
||||
color: rgba(255,255,255,.80);
|
||||
font-size: .9rem;
|
||||
}
|
||||
.dot{
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #34d399;
|
||||
box-shadow: 0 0 18px rgba(52,211,153,.55);
|
||||
}
|
||||
|
||||
.hero-gradient{
|
||||
background: linear-gradient(90deg, #a78bfa, #d946ef, #8b5cf6);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.iconlink{
|
||||
width: 42px; height: 42px;
|
||||
display:grid; place-items:center;
|
||||
border-radius: 14px;
|
||||
background: rgba(255,255,255,.06);
|
||||
border: 1px solid rgba(255,255,255,.10);
|
||||
text-decoration:none;
|
||||
transition: transform .12s ease;
|
||||
}
|
||||
.iconlink:hover{ transform: translateY(-2px); }
|
||||
|
||||
.tag{
|
||||
font-size: .75rem;
|
||||
padding: .25rem .55rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(139,92,246,.18);
|
||||
border: 1px solid rgba(139,92,246,.25);
|
||||
color: rgba(255,255,255,.85);
|
||||
}
|
||||
|
||||
.chip{
|
||||
padding: .45rem .7rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255,255,255,.12);
|
||||
background: rgba(255,255,255,.06);
|
||||
color: rgba(255,255,255,.75);
|
||||
}
|
||||
.chip.active{
|
||||
background: rgba(139,92,246,.30);
|
||||
border-color: rgba(139,92,246,.35);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.project-grid{
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.project-card{
|
||||
transition: transform .12s ease, border-color .12s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: start;
|
||||
height: auto;
|
||||
gap: 10px;
|
||||
}
|
||||
.project-card:hover{ transform: translateY(-2px); border-color: rgba(167,139,250,.35); }
|
||||
.project-card .summary-clamp{
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 3.6em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.project-card .project-footer{ margin-top: auto; }
|
||||
.project-card .tech-row{
|
||||
max-height: 64px;
|
||||
min-height: 28px;
|
||||
overflow: hidden;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.field{
|
||||
width:100%;
|
||||
padding: .9rem 1rem;
|
||||
border-radius: 14px;
|
||||
background: rgba(255,255,255,.06);
|
||||
border: 1px solid rgba(255,255,255,.10);
|
||||
outline: none;
|
||||
color: #fff;
|
||||
}
|
||||
.field:focus{
|
||||
border-color: rgba(167,139,250,.55);
|
||||
box-shadow: 0 0 0 4px rgba(139,92,246,.18);
|
||||
}
|
||||
|
||||
.bar{
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,.08);
|
||||
overflow:hidden;
|
||||
}
|
||||
.bar-fill{
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(139,92,246,.95), rgba(217,70,239,.85));
|
||||
transition: width 900ms cubic-bezier(.2,.9,.2,1);
|
||||
}
|
||||
|
||||
.social{
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
gap:.6rem;
|
||||
padding: 1rem;
|
||||
border-radius: 18px;
|
||||
text-decoration:none;
|
||||
background: rgba(255,255,255,.06);
|
||||
border: 1px solid rgba(255,255,255,.10);
|
||||
color:#fff;
|
||||
transition: transform .12s ease;
|
||||
}
|
||||
.social:hover{ transform: translateY(-2px); }
|
||||
|
||||
.pulse-bars{ display:flex; gap:4px; align-items:flex-end; }
|
||||
.pulse-bars span{
|
||||
width:4px; height:10px; border-radius: 4px;
|
||||
background: rgba(34,197,94,.9);
|
||||
animation: beat 900ms infinite ease-in-out alternate;
|
||||
}
|
||||
.pulse-bars span:nth-child(2){ animation-delay: 120ms; height: 14px; }
|
||||
.pulse-bars span:nth-child(3){ animation-delay: 240ms; height: 8px; }
|
||||
.pulse-bars span:nth-child(4){ animation-delay: 360ms; height: 12px; }
|
||||
@keyframes beat{ from{ transform: translateY(0); opacity:.6 } to{ transform: translateY(-4px); opacity:1 } }
|
||||
|
||||
#spotifyCard.is-playing #spArt { animation: spin 4s linear infinite; }
|
||||
@keyframes spin { from {transform:rotate(0)} to {transform:rotate(360deg)} }
|
||||
|
||||
/* Project modal gallery */
|
||||
.pm-gallery{
|
||||
display:grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.pm-gallery a{
|
||||
position: relative;
|
||||
display:block;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,.10);
|
||||
background: rgba(255,255,255,.04);
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,.30);
|
||||
}
|
||||
.pm-gallery img{
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
display:block;
|
||||
transition: transform .16s ease;
|
||||
}
|
||||
.pm-gallery a:hover img{ transform: scale(1.03); }
|
||||
|
||||
/* Animated PFP (neon ring) */
|
||||
|
||||
.pfp-wrap{
|
||||
position: relative;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: visible;
|
||||
transform: translateZ(0);
|
||||
animation: pfp-float 5.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.pfp-img{
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
border: 1px solid rgba(255,255,255,.12);
|
||||
box-shadow:
|
||||
0 18px 60px rgba(0,0,0,.55),
|
||||
0 0 0 6px rgba(255,255,255,.03);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.pfp-ring{
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 999px;
|
||||
background: conic-gradient(
|
||||
from 180deg,
|
||||
rgba(139,92,246,.0),
|
||||
rgba(139,92,246,.85),
|
||||
rgba(217,70,239,.85),
|
||||
rgba(167,139,250,.85),
|
||||
rgba(139,92,246,.0)
|
||||
);
|
||||
filter: drop-shadow(0 0 18px rgba(167,139,250,.22));
|
||||
animation: pfp-spin 4.2s linear infinite;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.pfp-ring::after{
|
||||
content:"";
|
||||
position:absolute;
|
||||
inset: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(7,6,10,.92);
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
}
|
||||
|
||||
.pfp-wrap::before{
|
||||
content:"";
|
||||
position:absolute;
|
||||
inset:-14px;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(circle, rgba(167,139,250,.18), transparent 60%);
|
||||
filter: blur(10px);
|
||||
opacity: .75;
|
||||
animation: pfp-pulse 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.pfp-wrap::after{
|
||||
content:"";
|
||||
position:absolute;
|
||||
inset: -30%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(115deg, transparent 40%, rgba(255,255,255,.16) 50%, transparent 60%);
|
||||
transform: translateX(-70%) rotate(12deg);
|
||||
animation: pfp-shine 3.8s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
mix-blend-mode: screen;
|
||||
opacity: .55;
|
||||
}
|
||||
|
||||
.pfp-wrap:hover{
|
||||
animation-play-state: paused;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.pfp-wrap:hover .pfp-ring{
|
||||
filter: drop-shadow(0 0 22px rgba(217,70,239,.26));
|
||||
}
|
||||
|
||||
@keyframes pfp-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes pfp-float { 0%,100%{ transform: translateY(0); } 50%{ transform: translateY(-8px); } }
|
||||
@keyframes pfp-pulse { 0%,100%{ opacity:.55; transform: scale(.98); } 50%{ opacity:.9; transform: scale(1.03); } }
|
||||
@keyframes pfp-shine { 0%{ transform: translateX(-70%) rotate(12deg); } 55%{ transform: translateX(70%) rotate(12deg); } 100%{ transform: translateX(70%) rotate(12deg); } }
|
||||
105
public/css/contacts.css
Normal file
105
public/css/contacts.css
Normal file
@@ -0,0 +1,105 @@
|
||||
:root{
|
||||
--ink:#07060a;
|
||||
--glass: rgba(16, 14, 26, .72);
|
||||
--glass2: rgba(18, 16, 30, .82);
|
||||
--b: rgba(255,255,255,.12);
|
||||
--muted: rgba(255,255,255,.78);
|
||||
--muted2: rgba(255,255,255,.55);
|
||||
--violet: rgba(139,92,246,.95);
|
||||
--fuchsia: rgba(217,70,239,.88);
|
||||
--table-border: rgba(255,255,255,.08);
|
||||
}
|
||||
|
||||
body{
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bg-aurora, .bg-grid{
|
||||
position: fixed; inset: 0; pointer-events: none;
|
||||
}
|
||||
.bg-aurora{
|
||||
background:
|
||||
radial-gradient(1100px 520px at 20% 20%, rgba(139,92,246,.32), transparent 60%),
|
||||
radial-gradient(900px 620px at 78% 24%, rgba(217,70,239,.22), transparent 60%),
|
||||
radial-gradient(980px 640px at 60% 86%, rgba(167,139,250,.20), transparent 60%);
|
||||
z-index:-2;
|
||||
}
|
||||
.bg-grid{
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
opacity:.12;
|
||||
mask-image: radial-gradient(circle at 50% 20%, #000 0%, transparent 70%);
|
||||
z-index:-1;
|
||||
}
|
||||
|
||||
.card-glass{
|
||||
background: linear-gradient(140deg, var(--glass), var(--glass2));
|
||||
border: 1px solid var(--b);
|
||||
backdrop-filter: blur(16px) saturate(150%);
|
||||
box-shadow: 0 16px 60px rgba(0,0,0,.45);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.pill{
|
||||
padding: .35rem .7rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,.06);
|
||||
border: 1px solid var(--b);
|
||||
color: var(--muted);
|
||||
font-size: .82rem;
|
||||
}
|
||||
|
||||
.table-dark{
|
||||
--bs-table-bg: transparent;
|
||||
--bs-table-striped-bg: rgba(255,255,255,.03);
|
||||
--bs-table-hover-bg: rgba(255,255,255,.07);
|
||||
--bs-table-border-color: var(--table-border);
|
||||
}
|
||||
.table-dark th{
|
||||
color: var(--muted);
|
||||
border-bottom-color: var(--table-border) !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
.table-dark td{
|
||||
color: #fff;
|
||||
border-top-color: var(--table-border) !important;
|
||||
}
|
||||
.table-hover tbody tr:hover{
|
||||
background: rgba(255,255,255,.03);
|
||||
}
|
||||
|
||||
.btn-violet{
|
||||
border:0 !important;
|
||||
color:#0b0712 !important;
|
||||
font-weight:900 !important;
|
||||
border-radius: 14px !important;
|
||||
background: linear-gradient(135deg, var(--violet), var(--fuchsia)) !important;
|
||||
box-shadow: 0 0 0 1px rgba(167,139,250,.25), 0 18px 40px rgba(139,92,246,.22);
|
||||
}
|
||||
.btn-outline-light{
|
||||
border-radius: 14px !important;
|
||||
border: 1px solid rgba(255,255,255,.16) !important;
|
||||
background: rgba(255,255,255,.07) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.btn-outline-light:hover{ background: rgba(255,255,255,.15) !important; }
|
||||
.btn-outline-secondary{
|
||||
border-radius: 14px !important;
|
||||
}
|
||||
|
||||
.badge.text-bg-light{
|
||||
color:#0b0712;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.table-message{
|
||||
color: #e6e6e6;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.alert{
|
||||
border-radius: 14px;
|
||||
}
|
||||
381
public/css/edit_project.css
Normal file
381
public/css/edit_project.css
Normal file
@@ -0,0 +1,381 @@
|
||||
/*
|
||||
Admin Glass Theme (Purple Aurora)
|
||||
- Keeps your gradient style
|
||||
- Fixes dropdowns/selects (no white background, readable states)
|
||||
*/
|
||||
|
||||
:root{
|
||||
/* Base */
|
||||
--ink: #07060a;
|
||||
--panel: rgba(16, 14, 26, .62);
|
||||
--panel-strong: rgba(16, 14, 26, .86);
|
||||
|
||||
/* Borders / text */
|
||||
--border: rgba(255,255,255,.10);
|
||||
--border-strong: rgba(255,255,255,.16);
|
||||
--text: rgba(255,255,255,.90);
|
||||
--muted: rgba(255,255,255,.65);
|
||||
|
||||
/* Accents */
|
||||
--violet: rgba(139,92,246,.95);
|
||||
--magenta: rgba(217,70,239,.85);
|
||||
--neon: rgba(167,139,250,.90);
|
||||
|
||||
/* Effects */
|
||||
--shadow: 0 12px 50px rgba(0,0,0,.35);
|
||||
--shadow-strong: 0 18px 70px rgba(0,0,0,.45);
|
||||
--ring: 0 0 0 4px rgba(139,92,246,.18);
|
||||
|
||||
/* Radii */
|
||||
--r-sm: 10px;
|
||||
--r-md: 14px;
|
||||
--r-lg: 18px;
|
||||
}
|
||||
|
||||
/*
|
||||
Base
|
||||
*/
|
||||
|
||||
html, body{ height: 100%; }
|
||||
body{
|
||||
background: var(--ink);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
a{ color: rgba(167,139,250,.95); }
|
||||
a:hover{ color: rgba(217,70,239,.95); }
|
||||
|
||||
::selection{
|
||||
background: rgba(167,139,250,.22);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.container{ max-width: 1100px; }
|
||||
|
||||
/*
|
||||
Background helpers
|
||||
*/
|
||||
|
||||
.bg-aurora{
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(1000px 500px at 20% 20%, rgba(139,92,246,.28), transparent 60%),
|
||||
radial-gradient(900px 600px at 80% 30%, rgba(217,70,239,.20), transparent 60%),
|
||||
radial-gradient(900px 700px at 55% 90%, rgba(167,139,250,.16), transparent 60%);
|
||||
z-index: -2;
|
||||
}
|
||||
|
||||
.bg-grid{
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
opacity: .12;
|
||||
mask-image: radial-gradient(circle at 50% 20%, #000 0%, transparent 65%);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/*
|
||||
Brand / Titles
|
||||
*/
|
||||
|
||||
h1.brand, .brand{
|
||||
font-weight: 800;
|
||||
letter-spacing: -.02em;
|
||||
}
|
||||
|
||||
.brand{
|
||||
background: linear-gradient(90deg, #a78bfa, #d946ef, #8b5cf6);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/*
|
||||
Glass cards
|
||||
*/
|
||||
|
||||
.card-glass,
|
||||
.card.card-glass{
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border) !important;
|
||||
backdrop-filter: blur(16px) saturate(150%);
|
||||
box-shadow: var(--shadow);
|
||||
border-radius: var(--r-lg);
|
||||
}
|
||||
|
||||
/*
|
||||
Alerts
|
||||
*/
|
||||
|
||||
.alert.card-glass{ color: var(--text); }
|
||||
|
||||
.alert-danger{
|
||||
background: rgba(220, 38, 38, .12) !important;
|
||||
border: 1px solid rgba(220, 38, 38, .25) !important;
|
||||
}
|
||||
.alert-success{
|
||||
background: rgba(34,197,94,.12) !important;
|
||||
border: 1px solid rgba(34,197,94,.22) !important;
|
||||
}
|
||||
.alert-warning{
|
||||
background: rgba(234,179,8,.12) !important;
|
||||
border: 1px solid rgba(234,179,8,.22) !important;
|
||||
}
|
||||
/* Optional: Bootstrap info alert used in your Gallery tab */
|
||||
.alert-info{
|
||||
background: rgba(59,130,246,.12) !important;
|
||||
border: 1px solid rgba(59,130,246,.22) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
/* nicer list spacing in errors */
|
||||
.alert ul{ padding-left: 1.25rem; }
|
||||
.alert li{ margin: .2rem 0; }
|
||||
|
||||
/*
|
||||
Tabs (Bootstrap nav-pills)
|
||||
*/
|
||||
|
||||
.nav-pills .nav-link{
|
||||
color: rgba(255,255,255,.78);
|
||||
background: rgba(255,255,255,.04);
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
border-radius: var(--r-md);
|
||||
padding: .55rem .85rem;
|
||||
}
|
||||
.nav-pills .nav-link:hover{
|
||||
background: rgba(255,255,255,.06);
|
||||
border-color: rgba(255,255,255,.12);
|
||||
color: rgba(255,255,255,.92);
|
||||
}
|
||||
.nav-pills .nav-link.active{
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, rgba(139,92,246,.95), rgba(217,70,239,.85));
|
||||
border-color: rgba(167,139,250,.30);
|
||||
box-shadow: 0 12px 30px rgba(139,92,246,.18);
|
||||
}
|
||||
|
||||
/*
|
||||
Forms
|
||||
*/
|
||||
|
||||
.form-label{ color: var(--muted) !important; font-size: .92rem; }
|
||||
.form-text{ color: rgba(255,255,255,.55) !important; }
|
||||
|
||||
/* Inputs / Textareas */
|
||||
.form-control,
|
||||
textarea.form-control{
|
||||
background: rgba(255,255,255,.06) !important;
|
||||
border: 1px solid rgba(255,255,255,.10) !important;
|
||||
color: #fff !important;
|
||||
border-radius: var(--r-md) !important;
|
||||
padding: .85rem 1rem !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
textarea.form-control{ line-height: 1.35; }
|
||||
|
||||
.form-control::placeholder{ color: rgba(255,255,255,.35); }
|
||||
|
||||
.form-control:focus,
|
||||
textarea.form-control:focus{
|
||||
border-color: rgba(167,139,250,.55) !important;
|
||||
box-shadow: var(--ring) !important;
|
||||
}
|
||||
|
||||
/* Disabled/readonly */
|
||||
.form-control:disabled,
|
||||
.form-control[readonly]{
|
||||
opacity: .75;
|
||||
background: rgba(255,255,255,.04) !important;
|
||||
}
|
||||
|
||||
/*
|
||||
SELECTS (Dropdown fields) — fixes “white background” issue
|
||||
Works for both:
|
||||
- Bootstrap 5: .form-select
|
||||
- Older Bootstrap: select.form-control
|
||||
*/
|
||||
|
||||
.form-select,
|
||||
select.form-control{
|
||||
background-color: rgba(255,255,255,.06) !important;
|
||||
border: 1px solid rgba(255,255,255,.10) !important;
|
||||
color: #fff !important;
|
||||
|
||||
border-radius: var(--r-md) !important;
|
||||
padding: .85rem 2.65rem .85rem 1rem !important;
|
||||
|
||||
/* Make sure OS arrow doesn't force ugly styles */
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
|
||||
/* Custom arrow */
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24'%3E%3Cpath fill='%23a78bfa' d='M7 10l5 5l5-5z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 1rem center;
|
||||
background-size: 18px 18px;
|
||||
}
|
||||
|
||||
.form-select:focus,
|
||||
select.form-control:focus{
|
||||
border-color: rgba(167,139,250,.55) !important;
|
||||
box-shadow: var(--ring) !important;
|
||||
}
|
||||
|
||||
/* Options: some browsers ignore this (native menu), but when supported it helps */
|
||||
.form-select option,
|
||||
select.form-control option{
|
||||
background: #0c0a14;
|
||||
color: rgba(255,255,255,.92);
|
||||
}
|
||||
|
||||
/*
|
||||
Bootstrap dropdown menus (the popup menus)
|
||||
*/
|
||||
|
||||
.dropdown-menu{
|
||||
background: var(--panel-strong) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
backdrop-filter: blur(18px) saturate(160%);
|
||||
box-shadow: var(--shadow-strong);
|
||||
border-radius: var(--r-md);
|
||||
padding: .4rem;
|
||||
}
|
||||
|
||||
.dropdown-divider{
|
||||
border-top-color: rgba(255,255,255,.12) !important;
|
||||
}
|
||||
|
||||
.dropdown-item{
|
||||
color: rgba(255,255,255,.86) !important;
|
||||
border-radius: var(--r-sm);
|
||||
padding: .55rem .7rem;
|
||||
}
|
||||
|
||||
.dropdown-item:hover,
|
||||
.dropdown-item:focus{
|
||||
background: rgba(167,139,250,.16) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.dropdown-item.active,
|
||||
.dropdown-item:active{
|
||||
background: linear-gradient(135deg, rgba(139,92,246,.95), rgba(217,70,239,.85)) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/*
|
||||
Buttons
|
||||
*/
|
||||
|
||||
.btn{
|
||||
border-radius: var(--r-md) !important;
|
||||
padding: .55rem .95rem !important;
|
||||
font-weight: 600 !important;
|
||||
transition: transform .12s ease, filter .12s ease, background .12s ease;
|
||||
}
|
||||
.btn:hover{ transform: translateY(-1px); }
|
||||
|
||||
.btn-violet{
|
||||
color: #fff !important;
|
||||
border: 1px solid rgba(167,139,250,.28) !important;
|
||||
background: linear-gradient(135deg, rgba(139,92,246,.95), rgba(217,70,239,.85)) !important;
|
||||
box-shadow: 0 12px 30px rgba(139,92,246,.18);
|
||||
}
|
||||
.btn-violet:hover{ filter: brightness(1.06); }
|
||||
|
||||
.btn-outline-light{
|
||||
color: rgba(255,255,255,.85) !important;
|
||||
border: 1px solid rgba(255,255,255,.18) !important;
|
||||
background: rgba(255,255,255,.06) !important;
|
||||
}
|
||||
.btn-outline-light:hover{ background: rgba(255,255,255,.10) !important; }
|
||||
|
||||
/*
|
||||
Code tag
|
||||
*/
|
||||
|
||||
code{
|
||||
color: rgba(167,139,250,.95);
|
||||
background: rgba(167,139,250,.10);
|
||||
border: 1px solid rgba(167,139,250,.18);
|
||||
padding: .15rem .35rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/*
|
||||
Media manager (drop zone / grid)
|
||||
*/
|
||||
|
||||
.media-drop{
|
||||
border: 1px dashed rgba(255,255,255,.25);
|
||||
background: rgba(255,255,255,.04);
|
||||
min-height: 170px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
cursor: pointer;
|
||||
transition: background .15s ease, border-color .15s ease, transform .12s ease;
|
||||
}
|
||||
.media-drop:hover{
|
||||
background: rgba(255,255,255,.07);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.media-drop.is-drag{
|
||||
border-color: rgba(167,139,250,.65);
|
||||
box-shadow: 0 0 0 2px rgba(167,139,250,.25);
|
||||
background: rgba(167,139,250,.08);
|
||||
}
|
||||
|
||||
.media-grid{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.media-card{
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
border-radius: var(--r-md);
|
||||
background: rgba(255,255,255,.04);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,.25);
|
||||
}
|
||||
|
||||
.media-card img{
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.media-meta{ padding: .65rem .8rem; }
|
||||
|
||||
.media-actions{
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
.media-actions .btn{
|
||||
padding: .3rem .6rem !important;
|
||||
border-radius: 10px !important;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
/*
|
||||
Mobile polish
|
||||
*/
|
||||
|
||||
@media (max-width: 576px){
|
||||
.btn{ width: 100%; }
|
||||
.mt-4.d-flex.gap-2{ flex-direction: column; }
|
||||
}
|
||||
9
public/js/admin.js
Normal file
9
public/js/admin.js
Normal file
@@ -0,0 +1,9 @@
|
||||
(function($){
|
||||
"use strict";
|
||||
$(function(){
|
||||
$(document).on("click","[data-confirm]",function(e){
|
||||
const msg = $(this).data("confirm") || "Are you sure?";
|
||||
if(!confirm(msg)) e.preventDefault();
|
||||
});
|
||||
});
|
||||
})(jQuery);
|
||||
383
public/js/app.js
Normal file
383
public/js/app.js
Normal file
@@ -0,0 +1,383 @@
|
||||
(function ($) {
|
||||
"use strict";
|
||||
|
||||
const state = {
|
||||
spTimer: null,
|
||||
spEnabled: true,
|
||||
activeId: null
|
||||
};
|
||||
|
||||
function showToast(msg) {
|
||||
const $toast = $("#appToast");
|
||||
if (!$toast.length) return;
|
||||
$("#toastMsg").text(msg);
|
||||
const t = bootstrap.Toast.getOrCreateInstance($toast[0], { delay: 2600 });
|
||||
t.show();
|
||||
}
|
||||
|
||||
function smoothScrollTo(hash) {
|
||||
const $target = $(hash);
|
||||
if (!$target.length) return;
|
||||
$("html, body").stop(true).animate(
|
||||
{ scrollTop: $target.offset().top - 90 },
|
||||
450
|
||||
);
|
||||
}
|
||||
|
||||
function openProjectModal(projectId) {
|
||||
const p = (window.PROJECTS || []).find(x => x.id === projectId);
|
||||
if (!p) return;
|
||||
|
||||
$("#pmTitle").text(p.title + " • " + p.year);
|
||||
$("#pmSummary").text(p.summary);
|
||||
|
||||
const $tech = $("#pmTech").empty();
|
||||
(p.tech || []).forEach(t => $("<span/>").addClass("tag").text(t).appendTo($tech));
|
||||
|
||||
const $links = $("#pmLinks").empty();
|
||||
if (p.links && p.links.length) {
|
||||
p.links.forEach(l => {
|
||||
$("<a/>", {
|
||||
class: "btn btn-light btn-sm",
|
||||
href: l.url,
|
||||
target: "_blank",
|
||||
rel: "noreferrer"
|
||||
}).text(l.label).appendTo($links);
|
||||
});
|
||||
} else {
|
||||
$("<span/>", { class: "text-white/50 text-sm" }).text("No public links yet.").appendTo($links);
|
||||
}
|
||||
|
||||
const $gallery = $("#pmGallery").empty();
|
||||
const imgs = Array.isArray(p.images) ? p.images : [];
|
||||
if (imgs.length) {
|
||||
imgs.forEach((url, idx) => {
|
||||
$("<a/>", {
|
||||
class: "pm-thumb",
|
||||
href: url,
|
||||
target: "_blank",
|
||||
rel: "noreferrer"
|
||||
}).append(
|
||||
$("<img/>", { src: url, alt: `${p.title} screenshot ${idx + 1}` })
|
||||
).appendTo($gallery);
|
||||
});
|
||||
} else {
|
||||
$("<div/>", { class: "text-white/50 small" }).text("No screenshots yet.").appendTo($gallery);
|
||||
}
|
||||
|
||||
const $modal = $("#projectModal");
|
||||
if ($modal.length) {
|
||||
const modal = bootstrap.Modal.getOrCreateInstance($modal[0]);
|
||||
modal.show();
|
||||
}
|
||||
}
|
||||
|
||||
function applyProjectFilter(tag) {
|
||||
tag = (tag || "all").toString().toLowerCase();
|
||||
$(".chip").removeClass("active");
|
||||
$(`.chip[data-filter="${tag}"]`).addClass("active");
|
||||
|
||||
$(".project-card").each(function () {
|
||||
const $c = $(this);
|
||||
const t = ($c.data("project-tag") || "").toString().toLowerCase();
|
||||
const show = (tag === "all") || (t === tag);
|
||||
if (show) $c.stop(true).fadeIn(140);
|
||||
else $c.stop(true).fadeOut(140);
|
||||
});
|
||||
}
|
||||
|
||||
function animateBarsInView() {
|
||||
const winTop = $(window).scrollTop();
|
||||
const winBottom = winTop + $(window).height();
|
||||
|
||||
$(".bar").each(function () {
|
||||
const $bar = $(this);
|
||||
if ($bar.data("done")) return;
|
||||
|
||||
const top = $bar.offset().top;
|
||||
if (top < winBottom - 80) {
|
||||
const level = parseInt($bar.data("level"), 10) || 0;
|
||||
$bar.find(".bar-fill").css("width", Math.max(0, Math.min(level, 100)) + "%");
|
||||
$bar.data("done", true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Scrollspy (active navlink)
|
||||
function setActiveNav(id) {
|
||||
if (!id || state.activeId === id) return;
|
||||
state.activeId = id;
|
||||
|
||||
$(".navlink").removeClass("is-active");
|
||||
$(`.navlink[href="#${id}"]`).addClass("is-active");
|
||||
}
|
||||
|
||||
function initScrollSpy() {
|
||||
const ids = ["about", "projects", "stack", "gaming", "contact"];
|
||||
const $sections = ids
|
||||
.map(id => $("#" + id))
|
||||
.filter($el => $el.length);
|
||||
|
||||
if (!$sections.length) return;
|
||||
|
||||
const updateActive = () => {
|
||||
const y = $(window).scrollTop() + 120;
|
||||
let bestId = null;
|
||||
|
||||
$sections.forEach($el => {
|
||||
const top = $el.offset().top;
|
||||
const bottom = top + $el.outerHeight();
|
||||
if (y >= top && y < bottom) bestId = $el.attr("id");
|
||||
});
|
||||
|
||||
if (!bestId) {
|
||||
$sections.forEach($el => {
|
||||
if (y >= $el.offset().top - 40) bestId = $el.attr("id");
|
||||
});
|
||||
}
|
||||
|
||||
if (bestId) setActiveNav(bestId);
|
||||
};
|
||||
|
||||
updateActive();
|
||||
$(window).on("scroll resize", updateActive);
|
||||
}
|
||||
|
||||
// Spotify
|
||||
function renderSpotify(payload) {
|
||||
const $card = $("#spotifyCard");
|
||||
|
||||
if (!payload || !payload.ok) {
|
||||
$("#spStatus").text("Spotify not configured");
|
||||
$(".pulse-bars").hide();
|
||||
$card.removeClass("is-playing");
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = payload.mode; // playing | recent | offline
|
||||
const t = payload.track || {};
|
||||
|
||||
$("#spTitle").text(t.title || "—");
|
||||
$("#spArtist").text(t.artist || "—");
|
||||
$("#spLink").attr("href", t.url || "#");
|
||||
|
||||
if (t.art) $("#spArt").attr("src", t.art).show();
|
||||
else $("#spArt").removeAttr("src").hide();
|
||||
|
||||
if (mode === "playing") {
|
||||
$("#spStatus").text("Now Playing");
|
||||
$(".pulse-bars").show();
|
||||
$card.addClass("is-playing");
|
||||
} else if (mode === "recent") {
|
||||
$("#spStatus").text("Last Played");
|
||||
$(".pulse-bars").hide();
|
||||
$card.removeClass("is-playing");
|
||||
} else {
|
||||
$("#spStatus").text("Offline");
|
||||
$(".pulse-bars").hide();
|
||||
$card.removeClass("is-playing");
|
||||
}
|
||||
}
|
||||
|
||||
function fetchSpotify() {
|
||||
if (!state.spEnabled) return;
|
||||
|
||||
$.ajax({
|
||||
url: "/api/spotify.php",
|
||||
method: "GET",
|
||||
dataType: "json",
|
||||
cache: false,
|
||||
timeout: 8000
|
||||
})
|
||||
.done(renderSpotify)
|
||||
.fail(function () {
|
||||
$("#spStatus").text("Spotify unavailable");
|
||||
$(".pulse-bars").hide();
|
||||
});
|
||||
}
|
||||
|
||||
// Spotify Widget
|
||||
var SpotifyWidget = (function ($) {
|
||||
var cfg = { endpoint: '/api/spotify.php' };
|
||||
|
||||
var $widget, $song, $artist, $status, $link, $visualizer;
|
||||
var $artEl; // could be <img> or <div>
|
||||
|
||||
function init() {
|
||||
$widget = $('#spotify-widget');
|
||||
$song = $('#spotify-song');
|
||||
$artist = $('#spotify-artist');
|
||||
$status = $('#spotify-status-text');
|
||||
$link = $('#spotify-link');
|
||||
$visualizer = $('.eq-visualizer');
|
||||
$artEl = $('#spotify-art');
|
||||
|
||||
// Replace the low-contrast logo with a badge (same idea as your JS)
|
||||
var $logoImg = $('.spotify-logo-icon');
|
||||
if ($logoImg.length) {
|
||||
var $badge = $('<span/>', {
|
||||
'class': 'spotify-badge',
|
||||
'role': 'img',
|
||||
'aria-label': 'Spotify',
|
||||
'text': 'Spotify'
|
||||
});
|
||||
$logoImg.replaceWith($badge);
|
||||
}
|
||||
|
||||
update();
|
||||
setInterval(update, 15000);
|
||||
}
|
||||
|
||||
function update() {
|
||||
$.ajax({
|
||||
url: cfg.endpoint,
|
||||
method: 'GET',
|
||||
dataType: 'json',
|
||||
cache: false
|
||||
})
|
||||
.done(function (data) {
|
||||
if (!data || data.ok !== true) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
if (data.mode === 'offline') {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
render(data);
|
||||
})
|
||||
.fail(function (xhr) {
|
||||
console.warn('Spotify widget API failed:', xhr.status, xhr.responseText);
|
||||
hide();
|
||||
});
|
||||
}
|
||||
|
||||
function hide() {
|
||||
$widget.addClass('hidden');
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
var t = (data.track || {});
|
||||
$widget.removeClass('hidden');
|
||||
|
||||
$song.text(t.title || 'Not Playing');
|
||||
$artist.text(t.artist || 'Spotify');
|
||||
$link.attr('href', t.url || '#');
|
||||
|
||||
// album art
|
||||
if (t.art) {
|
||||
if ($artEl.prop('tagName') === 'IMG') {
|
||||
$artEl.attr('src', t.art);
|
||||
$artEl.attr('alt', (t.title || 'Track') + ' album art');
|
||||
} else {
|
||||
$artEl.css({
|
||||
backgroundImage: "url('" + t.art + "')",
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center'
|
||||
});
|
||||
$artEl.find('i[data-feather]').remove();
|
||||
}
|
||||
}
|
||||
|
||||
if (data.mode === 'playing') {
|
||||
$status.text('Now Playing');
|
||||
$visualizer.css('display', 'flex');
|
||||
} else {
|
||||
$status.text('Last Played');
|
||||
$visualizer.css('display', 'none');
|
||||
}
|
||||
|
||||
// marquee if long
|
||||
if ((t.title || '').length > 25) {
|
||||
$song.css('animation', 'marquee 10s linear infinite');
|
||||
} else {
|
||||
$song.css('animation', 'none');
|
||||
}
|
||||
}
|
||||
|
||||
return { init: init };
|
||||
})(jQuery);
|
||||
|
||||
|
||||
// Contact form (AJAX)
|
||||
function bindContactForm() {
|
||||
const contactEndpoint = (window.CONTACT_ENDPOINT || "/api/contact.php");
|
||||
$("#contactForm").on("submit", function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const $form = $(this);
|
||||
const $btn = $form.find("button[type=submit]");
|
||||
$btn.prop("disabled", true);
|
||||
const payload = $form.serialize();
|
||||
|
||||
$.ajax({
|
||||
url: contactEndpoint,
|
||||
method: "POST",
|
||||
dataType: "json",
|
||||
data: payload,
|
||||
timeout: 12000
|
||||
})
|
||||
.done(handleRes)
|
||||
.fail(function () {
|
||||
showToast("Network error while sending.");
|
||||
})
|
||||
.always(function () {
|
||||
$btn.prop("disabled", false);
|
||||
});
|
||||
|
||||
function handleRes(res) {
|
||||
if (res && res.ok) {
|
||||
showToast("Message sent.");
|
||||
$form[0].reset();
|
||||
} else {
|
||||
showToast((res && res.error) ? res.error : "Could not send.");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Init
|
||||
$(function () {
|
||||
// Smooth scroll
|
||||
$(document).on("click", "a[data-scroll], a[href^='#']", function (e) {
|
||||
const href = $(this).attr("href");
|
||||
if (!href || href === "#") return;
|
||||
if (href.startsWith("#")) {
|
||||
e.preventDefault();
|
||||
smoothScrollTo(href);
|
||||
|
||||
// update nav immediately on click
|
||||
setActiveNav(href.replace("#", ""));
|
||||
|
||||
// close offcanvas if open
|
||||
const $off = $("#mobileNav");
|
||||
if ($off.length) bootstrap.Offcanvas.getOrCreateInstance($off[0]).hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Project card click
|
||||
$(".project-card").on("click", function () {
|
||||
openProjectModal($(this).data("project-id"));
|
||||
});
|
||||
|
||||
// Filter chips
|
||||
$(".chip").on("click", function () {
|
||||
applyProjectFilter($(this).data("filter"));
|
||||
});
|
||||
|
||||
// Bars
|
||||
animateBarsInView();
|
||||
$(window).on("scroll", animateBarsInView);
|
||||
|
||||
// Scrollspy active nav animation
|
||||
initScrollSpy();
|
||||
|
||||
// Contact
|
||||
bindContactForm();
|
||||
|
||||
// Spotify polling
|
||||
fetchSpotify();
|
||||
state.spTimer = setInterval(fetchSpotify, 20000);
|
||||
});
|
||||
|
||||
})(jQuery);
|
||||
Reference in New Issue
Block a user