view = new View(__DIR__ . '/views'); } public function index(): Response { $db = Database::get(); $page = null; $artists = []; if ($db instanceof PDO) { try { $stmt = $db->prepare("SELECT title, content_html FROM ac_pages WHERE slug = 'artists' AND is_published = 1 LIMIT 1"); $stmt->execute(); $page = $stmt->fetch(PDO::FETCH_ASSOC) ?: null; $listStmt = $db->query(" SELECT id, name, slug, country, avatar_url FROM ac_artists WHERE is_active = 1 ORDER BY created_at DESC "); $artists = $listStmt ? $listStmt->fetchAll(PDO::FETCH_ASSOC) : []; } catch (Throwable $e) { } } return new Response($this->view->render('site/index.php', [ 'title' => (string)($page['title'] ?? 'Artists'), 'content_html' => (string)($page['content_html'] ?? ''), 'artists' => $artists, ])); } public function show(): Response { $slug = trim((string)($_GET['slug'] ?? '')); $artist = null; $artistReleases = []; if ($slug !== '') { $db = Database::get(); if ($db instanceof PDO) { $stmt = $db->prepare("SELECT * FROM ac_artists WHERE slug = :slug AND is_active = 1 LIMIT 1"); $stmt->execute([':slug' => $slug]); $artist = $stmt->fetch(PDO::FETCH_ASSOC) ?: null; if ($artist) { try { $artistIdReady = false; $probe = $db->query("SHOW COLUMNS FROM ac_releases LIKE 'artist_id'"); if ($probe && $probe->fetch(PDO::FETCH_ASSOC)) { $artistIdReady = true; } if ($artistIdReady) { $relStmt = $db->prepare(" SELECT id, title, slug, release_date, cover_url, artist_name FROM ac_releases WHERE is_published = 1 AND (release_date IS NULL OR release_date <= :today) AND (artist_id = :artist_id OR artist_name = :artist_name) ORDER BY release_date DESC, created_at DESC LIMIT 2 "); $relStmt->execute([ ':today' => date('Y-m-d'), ':artist_id' => (int)($artist['id'] ?? 0), ':artist_name' => (string)($artist['name'] ?? ''), ]); } else { $relStmt = $db->prepare(" SELECT id, title, slug, release_date, cover_url, artist_name FROM ac_releases WHERE is_published = 1 AND (release_date IS NULL OR release_date <= :today) AND artist_name = :artist_name ORDER BY release_date DESC, created_at DESC LIMIT 2 "); $relStmt->execute([ ':today' => date('Y-m-d'), ':artist_name' => (string)($artist['name'] ?? ''), ]); } $artistReleases = $relStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; } catch (Throwable $e) { $artistReleases = []; } } } } return new Response($this->view->render('site/show.php', [ 'title' => $artist ? (string)$artist['name'] : 'Artist Profile', 'artist' => $artist, 'artist_releases' => $artistReleases, ])); } public function adminIndex(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } $tableReady = $this->artistsTableReady(); $socialReady = $this->socialColumnReady(); $artists = []; $pageId = 0; $pagePublished = 0; if ($tableReady) { $db = Database::get(); if ($db instanceof PDO) { $stmt = $db->query("SELECT id, name, slug, country, avatar_url, is_active FROM ac_artists ORDER BY created_at DESC"); $artists = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; $pageStmt = $db->prepare("SELECT id, is_published FROM ac_pages WHERE slug = 'artists' LIMIT 1"); $pageStmt->execute(); $pageRow = $pageStmt->fetch(PDO::FETCH_ASSOC); if ($pageRow) { $pageId = (int)($pageRow['id'] ?? 0); $pagePublished = (int)($pageRow['is_published'] ?? 0); } } } return new Response($this->view->render('admin/index.php', [ 'title' => 'Artists', 'table_ready' => $tableReady, 'social_ready' => $socialReady, 'artists' => $artists, 'page_id' => $pageId, 'page_published' => $pagePublished, ])); } public function adminNew(): Response { return $this->adminEdit(0); } public function adminEdit(int $id = 0): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } $artist = [ 'id' => 0, 'name' => '', 'slug' => '', 'bio' => '', 'credits' => '', 'country' => '', 'avatar_url' => '', 'social_links' => '', 'is_active' => 1, ]; if ($id > 0) { $db = Database::get(); if ($db instanceof PDO) { $stmt = $db->prepare("SELECT * FROM ac_artists WHERE id = :id LIMIT 1"); $stmt->execute([':id' => $id]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if ($row) { $artist = array_merge($artist, $row); } } } if (!empty($_GET['avatar_url']) && $artist['avatar_url'] === '') { $artist['avatar_url'] = (string)$_GET['avatar_url']; } return new Response($this->view->render('admin/edit.php', [ 'title' => $id > 0 ? 'Edit Artist' : 'New Artist', 'artist' => $artist, 'error' => '', ])); } public function adminSave(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } $id = (int)($_POST['id'] ?? 0); $name = trim((string)($_POST['name'] ?? '')); $slug = trim((string)($_POST['slug'] ?? '')); $bio = trim((string)($_POST['bio'] ?? '')); $credits = trim((string)($_POST['credits'] ?? '')); $country = trim((string)($_POST['country'] ?? '')); $avatarUrl = trim((string)($_POST['avatar_url'] ?? '')); $socialLinks = [ 'website' => $this->normalizeUrl(trim((string)($_POST['social_website'] ?? ''))), 'instagram' => $this->normalizeUrl(trim((string)($_POST['social_instagram'] ?? ''))), 'soundcloud' => $this->normalizeUrl(trim((string)($_POST['social_soundcloud'] ?? ''))), 'spotify' => $this->normalizeUrl(trim((string)($_POST['social_spotify'] ?? ''))), 'youtube' => $this->normalizeUrl(trim((string)($_POST['social_youtube'] ?? ''))), 'tiktok' => $this->normalizeUrl(trim((string)($_POST['social_tiktok'] ?? ''))), 'bandcamp' => $this->normalizeUrl(trim((string)($_POST['social_bandcamp'] ?? ''))), 'beatport' => $this->normalizeUrl(trim((string)($_POST['social_beatport'] ?? ''))), 'facebook' => $this->normalizeUrl(trim((string)($_POST['social_facebook'] ?? ''))), 'x' => $this->normalizeUrl(trim((string)($_POST['social_x'] ?? ''))), ]; $socialLinks = array_filter($socialLinks, static fn($value) => $value !== ''); $socialJson = $socialLinks ? json_encode($socialLinks, JSON_UNESCAPED_SLASHES) : null; $socialReady = $this->socialColumnReady(); $creditsReady = $this->creditsColumnReady(); $isActive = isset($_POST['is_active']) ? 1 : 0; if ($name === '') { return $this->saveError($id, 'Name is required.'); } $slug = $slug !== '' ? $this->slugify($slug) : $this->slugify($name); $db = Database::get(); if (!$db instanceof PDO) { return $this->saveError($id, 'Database unavailable.'); } $dupStmt = $id > 0 ? $db->prepare("SELECT id FROM ac_artists WHERE slug = :slug AND id != :id LIMIT 1") : $db->prepare("SELECT id FROM ac_artists WHERE slug = :slug LIMIT 1"); $params = $id > 0 ? [':slug' => $slug, ':id' => $id] : [':slug' => $slug]; $dupStmt->execute($params); if ($dupStmt->fetch()) { return $this->saveError($id, 'Slug already exists.'); } try { if ($id > 0) { $stmt = $db->prepare(" UPDATE ac_artists SET name = :name, slug = :slug, bio = :bio" . ($creditsReady ? ", credits = :credits" : "") . ", country = :country, avatar_url = :avatar_url" . ($socialReady ? ", social_links = :social_links" : "") . ", is_active = :is_active WHERE id = :id "); $params = [ ':name' => $name, ':slug' => $slug, ':bio' => $bio !== '' ? $bio : null, ':country' => $country !== '' ? $country : null, ':avatar_url' => $avatarUrl !== '' ? $avatarUrl : null, ':is_active' => $isActive, ':id' => $id, ]; if ($creditsReady) { $params[':credits'] = $credits !== '' ? $credits : null; } if ($socialReady) { $params[':social_links'] = $socialJson; } $stmt->execute($params); } else { $stmt = $db->prepare(" INSERT INTO ac_artists (name, slug, bio" . ($creditsReady ? ", credits" : "") . ", country, avatar_url" . ($socialReady ? ", social_links" : "") . ", is_active) VALUES (:name, :slug, :bio" . ($creditsReady ? ", :credits" : "") . ", :country, :avatar_url" . ($socialReady ? ", :social_links" : "") . ", :is_active) "); $params = [ ':name' => $name, ':slug' => $slug, ':bio' => $bio !== '' ? $bio : null, ':country' => $country !== '' ? $country : null, ':avatar_url' => $avatarUrl !== '' ? $avatarUrl : null, ':is_active' => $isActive, ]; if ($creditsReady) { $params[':credits'] = $credits !== '' ? $credits : null; } if ($socialReady) { $params[':social_links'] = $socialJson; } $stmt->execute($params); } } catch (Throwable $e) { error_log('AC artists save error: ' . $e->getMessage()); return $this->saveError($id, 'Unable to save artist. Check table columns and input.'); } return new Response('', 302, ['Location' => '/admin/artists']); } public function adminDelete(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } $id = (int)($_POST['id'] ?? 0); $db = Database::get(); if ($db instanceof PDO && $id > 0) { $stmt = $db->prepare("DELETE FROM ac_artists WHERE id = :id"); $stmt->execute([':id' => $id]); } return new Response('', 302, ['Location' => '/admin/artists']); } public function adminUpload(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } $file = $_FILES['artist_avatar'] ?? null; $artistId = (int)($_POST['artist_id'] ?? 0); if (!$file || !isset($file['tmp_name'])) { return $this->uploadRedirect($artistId); } if ($file['error'] !== UPLOAD_ERR_OK) { return $this->uploadRedirect($artistId, $this->uploadErrorMessage((int)$file['error'])); } $tmp = (string)$file['tmp_name']; if ($tmp === '' || !is_uploaded_file($tmp)) { return $this->uploadRedirect($artistId); } $info = getimagesize($tmp); if ($info === false) { return $this->uploadRedirect($artistId, 'Avatar must be an image.'); } $ext = image_type_to_extension($info[2], false); $allowed = ['jpg', 'jpeg', 'png', 'webp']; if (!in_array($ext, $allowed, true)) { return $this->uploadRedirect($artistId, 'Avatar must be JPG, PNG, or WEBP.'); } $uploadDir = __DIR__ . '/../../uploads/media'; if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) { return $this->uploadRedirect($artistId, 'Upload directory is not writable.'); } if (!is_writable($uploadDir)) { return $this->uploadRedirect($artistId, 'Upload directory is not writable.'); } $baseName = preg_replace('~[^a-z0-9]+~', '-', strtolower((string)$file['name'])) ?? 'avatar'; $baseName = trim($baseName, '-'); $fileName = ($baseName !== '' ? $baseName : 'avatar') . '-' . date('YmdHis') . '.' . $ext; $dest = $uploadDir . '/' . $fileName; if (!move_uploaded_file($tmp, $dest)) { return $this->uploadRedirect($artistId, 'Upload failed.'); } $fileUrl = '/uploads/media/' . $fileName; $fileType = (string)($file['type'] ?? ''); $fileSize = (int)($file['size'] ?? 0); $db = Database::get(); if ($db instanceof PDO) { try { $stmt = $db->prepare(" INSERT INTO ac_media (file_name, file_url, file_type, file_size, folder_id) VALUES (:name, :url, :type, :size, NULL) "); $stmt->execute([ ':name' => (string)$file['name'], ':url' => $fileUrl, ':type' => $fileType, ':size' => $fileSize, ]); } catch (Throwable $e) { } } if ($artistId > 0 && $db instanceof PDO) { $stmt = $db->prepare("UPDATE ac_artists SET avatar_url = :url WHERE id = :id"); $stmt->execute([':url' => $fileUrl, ':id' => $artistId]); } return $this->uploadRedirect($artistId, '', $fileUrl); } public function adminInstall(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } $db = Database::get(); if ($db instanceof PDO) { try { $db->exec(" CREATE TABLE IF NOT EXISTS ac_artists ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(200) NOT NULL, slug VARCHAR(200) NOT NULL UNIQUE, bio MEDIUMTEXT NULL, credits MEDIUMTEXT NULL, country VARCHAR(120) NULL, avatar_url VARCHAR(255) NULL, social_links TEXT NULL, is_active TINYINT(1) NOT NULL DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); $db->exec("ALTER TABLE ac_artists ADD COLUMN credits MEDIUMTEXT NULL"); $db->exec("ALTER TABLE ac_artists ADD COLUMN social_links TEXT NULL"); } catch (Throwable $e) { } } return new Response('', 302, ['Location' => '/admin/artists']); } private function artistsTableReady(): bool { $db = Database::get(); if (!$db instanceof PDO) { return false; } try { $stmt = $db->query("SELECT 1 FROM ac_artists LIMIT 1"); return $stmt !== false; } catch (Throwable $e) { return false; } } private function socialColumnReady(): bool { $db = Database::get(); if (!$db instanceof PDO) { return false; } try { $stmt = $db->query("SHOW COLUMNS FROM ac_artists LIKE 'social_links'"); $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null; return (bool)$row; } catch (Throwable $e) { return false; } } private function creditsColumnReady(): bool { $db = Database::get(); if (!$db instanceof PDO) { return false; } try { $stmt = $db->query("SHOW COLUMNS FROM ac_artists LIKE 'credits'"); $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null; return (bool)$row; } catch (Throwable $e) { return false; } } private function saveError(int $id, string $message): Response { return new Response($this->view->render('admin/edit.php', [ 'title' => $id > 0 ? 'Edit Artist' : 'New Artist', 'artist' => [ 'id' => $id, 'name' => (string)($_POST['name'] ?? ''), 'slug' => (string)($_POST['slug'] ?? ''), 'bio' => (string)($_POST['bio'] ?? ''), 'credits' => (string)($_POST['credits'] ?? ''), 'country' => (string)($_POST['country'] ?? ''), 'avatar_url' => (string)($_POST['avatar_url'] ?? ''), 'social_links' => '', 'is_active' => isset($_POST['is_active']) ? 1 : 0, ], 'error' => $message, ])); } private function slugify(string $value): string { $value = strtolower(trim($value)); $value = preg_replace('~[^a-z0-9]+~', '-', $value) ?? $value; $value = trim($value, '-'); return $value !== '' ? $value : 'artist'; } private function uploadRedirect(int $artistId, string $error = '', string $url = ''): Response { if ($artistId > 0) { $target = '/admin/artists/edit?id=' . $artistId; } else { $target = '/admin/artists/new'; } if ($url !== '') { $target .= '&avatar_url=' . rawurlencode($url); } if ($error !== '') { $target .= '&upload_error=' . rawurlencode($error); } return new Response('', 302, ['Location' => $target]); } private function uploadErrorMessage(int $code): string { $max = (string)ini_get('upload_max_filesize'); $map = [ UPLOAD_ERR_INI_SIZE => "File exceeds upload_max_filesize ({$max}).", UPLOAD_ERR_FORM_SIZE => 'File exceeds form limit.', UPLOAD_ERR_PARTIAL => 'File was only partially uploaded.', UPLOAD_ERR_NO_TMP_DIR => 'Missing temp upload directory.', UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk.', UPLOAD_ERR_EXTENSION => 'Upload stopped by a PHP extension.', UPLOAD_ERR_NO_FILE => 'No file uploaded.', ]; return $map[$code] ?? 'Upload failed.'; } private function normalizeUrl(string $value): string { if ($value === '') { return ''; } if (preg_match('~^(https?://)~i', $value)) { return $value; } return 'https://' . $value; } }