Compare commits

22 Commits

Author SHA1 Message Date
AudioCore Bot
9deabe1ec9 Release v1.5.1 2026-04-01 14:12:17 +00:00
AudioCore Bot
dc53051358 Improve track sample generator UI and metadata-based sample filenames 2026-03-09 00:03:25 +00:00
AudioCore Bot
4f50b611b0 Refine homepage shortcode layout and rendering cleanup 2026-03-05 20:15:48 +00:00
AudioCore Bot
8d12c68c0c Home shortcode layout: two-column composition and section headers 2026-03-05 18:25:21 +00:00
AudioCore Bot
bfdeecade1 Frontend shortcode polish: spacing, card sizing, newsletter styles 2026-03-05 17:55:10 +00:00
AudioCore Bot
d33c16a20f Shortcode grids: constrain card width so single items do not render huge 2026-03-05 17:36:16 +00:00
AudioCore Bot
2eaa61654b Fix custom CSS injection so base styles always load 2026-03-05 17:25:09 +00:00
AudioCore Bot
af6bb638ac Shortcodes: add home blocks and global custom CSS setting 2026-03-05 17:09:01 +00:00
AudioCore Bot
93e829bd19 Store: enforce configurable timezone for store timestamps 2026-03-05 15:28:50 +00:00
20a8582928 Update README.md 2026-03-05 14:38:47 +00:00
AudioCore Bot
fd03d276f1 Hide future-dated releases in artist profile latest releases 2026-03-05 14:30:52 +00:00
AudioCore Bot
c5302c3b44 Schedule releases by release_date and block unreleased store purchases 2026-03-05 14:18:20 +00:00
AudioCore Bot
89b4b1eefd Installer: auto-create storage directory and seed settings/db files 2026-03-04 22:40:59 +00:00
AudioCore Bot
2e92b9f421 Refine installer UI styles and normalize form controls 2026-03-04 22:01:54 +00:00
72b76397ef Update README.md 2026-03-04 21:43:18 +00:00
d5677b2457 Update README.md 2026-03-04 21:42:58 +00:00
9793d340cd Add README.md 2026-03-04 21:42:44 +00:00
d15470de67 Update update.json 2026-03-04 21:36:41 +00:00
AudioCore Bot
d00e6ff092 Add update manifest for stable/beta channels 2026-03-04 21:23:22 +00:00
AudioCore Bot
72a919009b Merge remote main bootstrap history 2026-03-04 21:16:32 +00:00
AudioCore Bot
6e0fc4a8da Exclude local storage config/cache/logs from release export 2026-03-04 20:49:46 +00:00
AudioCore Bot
b2afadd539 Initial dev export (exclude uploads/runtime) 2026-03-04 20:46:11 +00:00
131 changed files with 27031 additions and 0 deletions

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Runtime/storage
storage/*.log
storage/*.cache
storage/*.sqlite
storage/mail_debug/
storage/sessions/
storage/cache/
!storage/cache/
!storage/cache/.gitkeep
# Uploads / media / binaries
uploads/
public/uploads/
*.mp3
*.wav
*.flac
*.zip
*.tar.gz
*.mov
*.mp4
*.psd
*.ai
# OS/editor noise
.DS_Store
Thumbs.db
# Local config/secrets
storage/db.php
storage/settings.php
storage/update_cache.json
storage/logs/
# Local config/secrets
storage/db.php
storage/settings.php
storage/update_cache.json
storage/logs/

6
.htaccess Normal file
View File

@@ -0,0 +1,6 @@
Options -MultiViews
RewriteEngine On
RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . index.php [L]

5
.user.ini Normal file
View File

@@ -0,0 +1,5 @@
upload_max_filesize=1024M
post_max_size=1024M
memory_limit=1024M
max_execution_time = 300
max_input_time = 300

46
README.md Normal file
View File

@@ -0,0 +1,46 @@
# AudioCore V1.5.1
AudioCore V1.5.1 is a modular CMS for labels, artists, releases, storefront sales, and support workflows.
It is built around a plugin-first architecture so core stays clean while features can be enabled/disabled per site.
## Key features
- Modular plugin system (enable/disable from admin)
- Artists plugin (profiles, socials, artist pages)
- Releases plugin (catalog pages, release detail, track metadata, samples)
- Store plugin (cart, checkout, PayPal, orders, customers, discounts, downloads)
- Support plugin (ticketing, IMAP reply sync, admin replies)
- Newsletter module (subscriber management + templates)
- Media library (upload and reuse media across modules)
- Page builder + shortcode support
- Maintenance/coming-soon controls
- Update checker from Gitea manifest
## Tech overview
- PHP 8.3+
- MySQL/MariaDB
- OpenLiteSpeed/Apache compatible routing via `.htaccess`
- Server cron integration for background jobs (IMAP sync, chart refresh)
## Install (high level)
1. Upload project files.
2. Point web root to `dev/` (or deploy repo to desired root).
3. Open `/admin` to run installer.
4. Enter database + admin details.
5. Configure SMTP and run test email.
6. Enable required plugins and create their tables.
## Recommended production setup
- Keep private download storage outside public web root.
- Use HTTPS only.
- Configure SMTP correctly for transactional mail.
- Configure cron jobs in Admin > Cron Jobs.
- Keep `storage/` writable and secured.
## Release notes source
Updates are controlled via `update.json` in this repository and checked from admin Updates page.

4
admin/.htaccess Normal file
View File

@@ -0,0 +1,4 @@
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ ../index.php [L]

2
admin/index.php Normal file
View File

@@ -0,0 +1,2 @@
<?php
require_once __DIR__ . '/../index.php';

47
core/bootstrap.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/http/Response.php';
require_once __DIR__ . '/http/Router.php';
require_once __DIR__ . '/views/View.php';
require_once __DIR__ . '/services/Database.php';
require_once __DIR__ . '/services/Auth.php';
require_once __DIR__ . '/services/Csrf.php';
require_once __DIR__ . '/services/Settings.php';
require_once __DIR__ . '/services/Audit.php';
require_once __DIR__ . '/services/ApiLayer.php';
require_once __DIR__ . '/services/Permissions.php';
require_once __DIR__ . '/services/Shortcodes.php';
require_once __DIR__ . '/services/Nav.php';
require_once __DIR__ . '/services/Mailer.php';
require_once __DIR__ . '/services/RateLimiter.php';
require_once __DIR__ . '/services/Plugins.php';
require_once __DIR__ . '/services/Updater.php';
Core\Services\Auth::init();
Core\Services\Settings::init(__DIR__ . '/../storage/settings.php');
Core\Services\Plugins::init(__DIR__ . '/../plugins');
Core\Services\Audit::ensureTable();
if (!function_exists('csrf_token')) {
function csrf_token(): string
{
return \Core\Services\Csrf::token();
}
}
if (!function_exists('csrf_input')) {
function csrf_input(): string
{
$token = htmlspecialchars(\Core\Services\Csrf::token(), ENT_QUOTES, 'UTF-8');
return '<input type="hidden" name="csrf_token" value="' . $token . '">';
}
}
if (!function_exists('csrf_meta')) {
function csrf_meta(): string
{
$token = htmlspecialchars(\Core\Services\Csrf::token(), ENT_QUOTES, 'UTF-8');
return '<meta name="csrf-token" content="' . $token . '">';
}
}

27
core/http/Response.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Core\Http;
class Response
{
private string $body;
private int $status;
private array $headers;
public function __construct(string $body = '', int $status = 200, array $headers = [])
{
$this->body = $body;
$this->status = $status;
$this->headers = $headers;
}
public function send(): void
{
http_response_code($this->status);
foreach ($this->headers as $name => $value) {
header($name . ': ' . $value);
}
echo $this->body;
}
}

197
core/http/Router.php Normal file
View File

@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace Core\Http;
use Core\Services\Auth;
use Core\Services\Database;
use Core\Services\Csrf;
use Core\Services\Permissions;
use Core\Services\Shortcodes;
use Core\Views\View;
use PDO;
use Throwable;
class Router
{
private array $routes = [];
public function get(string $path, callable $handler): void
{
$this->routes['GET'][$path] = $handler;
}
public function post(string $path, callable $handler): void
{
$this->routes['POST'][$path] = $handler;
}
public function registerModules(string $modulesPath): void
{
foreach (glob($modulesPath . '/*/module.php') as $moduleFile) {
$module = require $moduleFile;
if (is_callable($module)) {
$module($this);
}
}
}
public function dispatch(string $uri, string $method): Response
{
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
if ($path !== '/' && str_ends_with($path, '/')) {
$path = rtrim($path, '/');
}
$method = strtoupper($method);
if ($method === 'GET') {
$redirect = $this->matchRedirect($path);
if ($redirect !== null) {
return $redirect;
}
}
if (str_starts_with($path, '/admin')) {
$permission = Permissions::routePermission($path);
if ($permission !== null && Auth::check() && !Auth::can($permission)) {
return new Response('', 302, ['Location' => '/admin?denied=1']);
}
}
if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true) && !$this->isCsrfExempt($path) && !Csrf::verifyRequest()) {
return $this->csrfFailureResponse($path);
}
if (isset($this->routes[$method][$path])) {
$handler = $this->routes[$method][$path];
return $handler();
}
if ($method === 'GET') {
if (str_starts_with($path, '/news/')) {
$postSlug = trim(substr($path, strlen('/news/')));
if ($postSlug !== '' && strpos($postSlug, '/') === false) {
$db = Database::get();
if ($db instanceof PDO) {
try {
$stmt = $db->prepare("
SELECT title, content_html, published_at, featured_image_url, author_name, category, tags
FROM ac_posts
WHERE slug = :slug AND is_published = 1
LIMIT 1
");
$stmt->execute([':slug' => $postSlug]);
$post = $stmt->fetch(PDO::FETCH_ASSOC);
if ($post) {
$view = new View(__DIR__ . '/../../modules/blog/views');
return new Response($view->render('site/show.php', [
'title' => (string)$post['title'],
'content_html' => (string)$post['content_html'],
'published_at' => (string)($post['published_at'] ?? ''),
'featured_image_url' => (string)($post['featured_image_url'] ?? ''),
'author_name' => (string)($post['author_name'] ?? ''),
'category' => (string)($post['category'] ?? ''),
'tags' => (string)($post['tags'] ?? ''),
]));
}
} catch (Throwable $e) {
}
}
}
}
$slug = ltrim($path, '/');
if ($slug !== '' && strpos($slug, '/') === false) {
$db = Database::get();
if ($db instanceof PDO) {
try {
$stmt = $db->prepare("SELECT title, content_html FROM ac_pages WHERE slug = :slug AND is_published = 1 LIMIT 1");
$stmt->execute([':slug' => $slug]);
$page = $stmt->fetch(PDO::FETCH_ASSOC);
if ($page) {
$view = new View(__DIR__ . '/../../modules/pages/views');
return new Response($view->render('site/show.php', [
'title' => (string)$page['title'],
'content_html' => Shortcodes::render((string)$page['content_html'], [
'page_slug' => (string)$slug,
]),
]));
}
} catch (Throwable $e) {
}
}
}
}
$view = new View();
return new Response($view->render('site/404.php', [
'title' => 'Not Found',
'message' => 'Page not found.',
]), 404);
}
private function isCsrfExempt(string $path): bool
{
return str_starts_with($path, '/api/');
}
private function csrfFailureResponse(string $path): Response
{
$accept = (string)($_SERVER['HTTP_ACCEPT'] ?? '');
$contentType = (string)($_SERVER['CONTENT_TYPE'] ?? '');
if (stripos($accept, 'application/json') !== false || stripos($contentType, 'application/json') !== false) {
return new Response(json_encode(['ok' => false, 'error' => 'Invalid CSRF token.']), 419, ['Content-Type' => 'application/json']);
}
$target = (string)($_SERVER['HTTP_REFERER'] ?? $path);
if ($target === '') {
$target = '/';
}
$separator = str_contains($target, '?') ? '&' : '?';
return new Response('', 302, ['Location' => $target . $separator . 'error=csrf']);
}
private function matchRedirect(string $path): ?Response
{
$db = Database::get();
if (!($db instanceof PDO)) {
return null;
}
try {
$db->exec("
CREATE TABLE IF NOT EXISTS ac_redirects (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
source_path VARCHAR(255) NOT NULL UNIQUE,
target_url VARCHAR(1000) NOT NULL,
status_code SMALLINT NOT NULL DEFAULT 301,
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;
");
$stmt = $db->prepare("
SELECT target_url, status_code
FROM ac_redirects
WHERE source_path = :path AND is_active = 1
LIMIT 1
");
$stmt->execute([':path' => $path]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return null;
}
$code = (int)($row['status_code'] ?? 301);
if (!in_array($code, [301, 302, 307, 308], true)) {
$code = 301;
}
$target = trim((string)($row['target_url'] ?? ''));
if ($target === '' || $target === $path) {
return null;
}
return new Response('', $code, ['Location' => $target]);
} catch (Throwable $e) {
return null;
}
}
}

560
core/services/ApiLayer.php Normal file
View File

@@ -0,0 +1,560 @@
<?php
declare(strict_types=1);
namespace Core\Services;
use PDO;
use Throwable;
class ApiLayer
{
private static bool $schemaEnsured = false;
private static bool $schemaEnsuring = false;
public static function ensureSchema(?PDO $db = null): void
{
if (self::$schemaEnsured || self::$schemaEnsuring) {
return;
}
$db = $db instanceof PDO ? $db : Database::get();
if (!($db instanceof PDO)) {
return;
}
self::$schemaEnsuring = true;
try {
try {
$db->exec("
CREATE TABLE IF NOT EXISTS ac_api_clients (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(150) NOT NULL,
api_key_hash CHAR(64) NOT NULL UNIQUE,
api_key_prefix VARCHAR(24) NOT NULL,
webhook_url VARCHAR(1000) NULL,
webhook_secret VARCHAR(128) NULL,
scopes_json TEXT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
last_used_at DATETIME NULL,
last_used_ip VARCHAR(64) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_api_clients_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
} catch (Throwable $e) {
}
try {
$db->exec("
CREATE TABLE IF NOT EXISTS ac_store_order_item_allocations (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_id INT UNSIGNED NOT NULL,
order_item_id INT UNSIGNED NOT NULL,
artist_id INT UNSIGNED NULL,
release_id INT UNSIGNED NULL,
track_id INT UNSIGNED NULL,
source_item_type ENUM('track','release','bundle') NOT NULL,
source_item_id INT UNSIGNED NOT NULL,
title_snapshot VARCHAR(255) NOT NULL,
qty INT UNSIGNED NOT NULL DEFAULT 1,
gross_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
currency_snapshot CHAR(3) NOT NULL DEFAULT 'GBP',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
KEY idx_store_allocations_order (order_id),
KEY idx_store_allocations_item (order_item_id),
KEY idx_store_allocations_artist (artist_id),
KEY idx_store_allocations_release (release_id),
KEY idx_store_allocations_track (track_id),
KEY idx_store_allocations_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
} catch (Throwable $e) {
}
try {
$db->exec("ALTER TABLE ac_releases ADD COLUMN artist_id INT UNSIGNED NULL AFTER slug");
} catch (Throwable $e) {
}
try {
$db->exec("ALTER TABLE ac_store_order_items ADD COLUMN artist_id INT UNSIGNED NULL AFTER item_id");
} catch (Throwable $e) {
}
try {
$db->exec("ALTER TABLE ac_releases ADD KEY idx_releases_artist_id (artist_id)");
} catch (Throwable $e) {
}
try {
$db->exec("ALTER TABLE ac_store_order_items ADD KEY idx_store_order_items_artist_id (artist_id)");
} catch (Throwable $e) {
}
self::backfillReleaseArtists($db);
self::backfillOrderItemArtists($db);
self::backfillAllocations($db);
self::$schemaEnsured = true;
} finally {
self::$schemaEnsuring = false;
}
}
public static function issueClient(PDO $db, string $name, string $webhookUrl = '', string $scopesJson = '[]'): string
{
self::ensureSchema($db);
$plainKey = 'ac_' . bin2hex(random_bytes(24));
$stmt = $db->prepare("
INSERT INTO ac_api_clients (name, api_key_hash, api_key_prefix, webhook_url, scopes_json, is_active)
VALUES (:name, :hash, :prefix, :webhook_url, :scopes_json, 1)
");
$stmt->execute([
':name' => $name,
':hash' => hash('sha256', $plainKey),
':prefix' => substr($plainKey, 0, 16),
':webhook_url' => $webhookUrl !== '' ? $webhookUrl : null,
':scopes_json' => $scopesJson,
]);
return $plainKey;
}
public static function verifyRequest(?PDO $db = null): ?array
{
$db = $db instanceof PDO ? $db : Database::get();
if (!($db instanceof PDO)) {
return null;
}
self::ensureSchema($db);
$key = self::extractApiKey();
if ($key === '') {
return null;
}
try {
$stmt = $db->prepare("
SELECT id, name, api_key_prefix, webhook_url, webhook_secret, scopes_json, is_active
FROM ac_api_clients
WHERE api_key_hash = :hash
AND is_active = 1
LIMIT 1
");
$stmt->execute([':hash' => hash('sha256', $key)]);
$client = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$client) {
return null;
}
$touch = $db->prepare("
UPDATE ac_api_clients
SET last_used_at = NOW(), last_used_ip = :ip
WHERE id = :id
");
$touch->execute([
':ip' => self::clientIp(),
':id' => (int)$client['id'],
]);
return $client;
} catch (Throwable $e) {
return null;
}
}
public static function syncOrderItemAllocations(PDO $db, int $orderId, int $orderItemId): void
{
self::ensureSchema($db);
if ($orderId <= 0 || $orderItemId <= 0) {
return;
}
try {
$stmt = $db->prepare("
SELECT id, order_id, item_type, item_id, artist_id, title_snapshot, qty, line_total, currency_snapshot
FROM ac_store_order_items
WHERE id = :id AND order_id = :order_id
LIMIT 1
");
$stmt->execute([
':id' => $orderItemId,
':order_id' => $orderId,
]);
$item = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$item) {
return;
}
$db->prepare("DELETE FROM ac_store_order_item_allocations WHERE order_item_id = :order_item_id")
->execute([':order_item_id' => $orderItemId]);
$type = (string)($item['item_type'] ?? '');
$sourceId = (int)($item['item_id'] ?? 0);
$qty = max(1, (int)($item['qty'] ?? 1));
$lineTotal = (float)($item['line_total'] ?? 0);
$currency = strtoupper(trim((string)($item['currency_snapshot'] ?? 'GBP')));
if (!preg_match('/^[A-Z]{3}$/', $currency)) {
$currency = 'GBP';
}
$rows = [];
if ($type === 'track') {
$track = self::loadTrackArtistContext($db, $sourceId);
if ($track) {
$rows[] = [
'artist_id' => (int)($track['artist_id'] ?? 0),
'release_id' => (int)($track['release_id'] ?? 0),
'track_id' => $sourceId,
'title_snapshot' => (string)($track['title'] ?? $item['title_snapshot'] ?? 'Track'),
'gross_amount' => $lineTotal,
];
}
} elseif ($type === 'release') {
$release = self::loadReleaseArtistContext($db, $sourceId);
if ($release) {
$rows[] = [
'artist_id' => (int)($release['artist_id'] ?? 0),
'release_id' => $sourceId,
'track_id' => null,
'title_snapshot' => (string)($release['title'] ?? $item['title_snapshot'] ?? 'Release'),
'gross_amount' => $lineTotal,
];
}
} elseif ($type === 'bundle') {
$rows = self::buildBundleAllocationRows($db, $sourceId, $lineTotal);
}
if (!$rows) {
return;
}
$insert = $db->prepare("
INSERT INTO ac_store_order_item_allocations
(order_id, order_item_id, artist_id, release_id, track_id, source_item_type, source_item_id, title_snapshot, qty, gross_amount, currency_snapshot, created_at)
VALUES (:order_id, :order_item_id, :artist_id, :release_id, :track_id, :source_item_type, :source_item_id, :title_snapshot, :qty, :gross_amount, :currency_snapshot, NOW())
");
foreach ($rows as $row) {
$artistId = (int)($row['artist_id'] ?? 0);
$releaseId = (int)($row['release_id'] ?? 0);
$trackId = (int)($row['track_id'] ?? 0);
$insert->execute([
':order_id' => $orderId,
':order_item_id' => $orderItemId,
':artist_id' => $artistId > 0 ? $artistId : null,
':release_id' => $releaseId > 0 ? $releaseId : null,
':track_id' => $trackId > 0 ? $trackId : null,
':source_item_type' => $type,
':source_item_id' => $sourceId,
':title_snapshot' => (string)($row['title_snapshot'] ?? $item['title_snapshot'] ?? 'Item'),
':qty' => $qty,
':gross_amount' => (float)($row['gross_amount'] ?? 0),
':currency_snapshot' => $currency,
]);
}
} catch (Throwable $e) {
}
}
public static function dispatchSaleWebhooksForOrder(int $orderId): void
{
$db = Database::get();
if (!($db instanceof PDO) || $orderId <= 0) {
return;
}
self::ensureSchema($db);
try {
$stmt = $db->query("
SELECT id, name, webhook_url, webhook_secret
FROM ac_api_clients
WHERE is_active = 1
AND webhook_url IS NOT NULL
AND webhook_url <> ''
ORDER BY id ASC
");
$clients = $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : [];
if (!$clients) {
return;
}
$payload = self::buildOrderWebhookPayload($db, $orderId);
if (!$payload) {
return;
}
foreach ($clients as $client) {
$url = trim((string)($client['webhook_url'] ?? ''));
if ($url === '') {
continue;
}
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
if (!is_string($json) || $json === '') {
continue;
}
$headers = [
'Content-Type: application/json',
'Content-Length: ' . strlen($json),
'X-AudioCore-Event: sale.paid',
'X-AudioCore-Client: ' . (string)($client['name'] ?? 'AudioCore API'),
];
$secret = trim((string)($client['webhook_secret'] ?? ''));
if ($secret !== '') {
$headers[] = 'X-AudioCore-Signature: sha256=' . hash_hmac('sha256', $json, $secret);
}
self::postJson($url, $json, $headers);
}
} catch (Throwable $e) {
}
}
private static function buildOrderWebhookPayload(PDO $db, int $orderId): array
{
try {
$orderStmt = $db->prepare("
SELECT id, order_no, email, status, currency, subtotal, total, discount_code, discount_amount, payment_provider, payment_ref, created_at, updated_at
FROM ac_store_orders
WHERE id = :id
LIMIT 1
");
$orderStmt->execute([':id' => $orderId]);
$order = $orderStmt->fetch(PDO::FETCH_ASSOC);
if (!$order) {
return [];
}
$allocStmt = $db->prepare("
SELECT id, artist_id, release_id, track_id, source_item_type, source_item_id, title_snapshot, qty, gross_amount, currency_snapshot, created_at
FROM ac_store_order_item_allocations
WHERE order_id = :order_id
ORDER BY id ASC
");
$allocStmt->execute([':order_id' => $orderId]);
$allocations = $allocStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
return [
'event' => 'sale.paid',
'order' => $order,
'allocations' => $allocations,
];
} catch (Throwable $e) {
return [];
}
}
private static function backfillReleaseArtists(PDO $db): void
{
try {
$db->exec("
UPDATE ac_releases r
JOIN ac_artists a ON a.name = r.artist_name
SET r.artist_id = a.id
WHERE r.artist_id IS NULL
AND r.artist_name IS NOT NULL
AND r.artist_name <> ''
");
} catch (Throwable $e) {
}
}
private static function backfillOrderItemArtists(PDO $db): void
{
try {
$db->exec("
UPDATE ac_store_order_items oi
JOIN ac_release_tracks t ON oi.item_type = 'track' AND oi.item_id = t.id
JOIN ac_releases r ON r.id = t.release_id
SET oi.artist_id = r.artist_id
WHERE oi.artist_id IS NULL
");
} catch (Throwable $e) {
}
try {
$db->exec("
UPDATE ac_store_order_items oi
JOIN ac_releases r ON oi.item_type = 'release' AND oi.item_id = r.id
SET oi.artist_id = r.artist_id
WHERE oi.artist_id IS NULL
");
} catch (Throwable $e) {
}
}
private static function backfillAllocations(PDO $db): void
{
try {
$stmt = $db->query("
SELECT oi.id, oi.order_id
FROM ac_store_order_items oi
LEFT JOIN ac_store_order_item_allocations a ON a.order_item_id = oi.id
WHERE a.id IS NULL
ORDER BY oi.id ASC
LIMIT 5000
");
$rows = $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : [];
foreach ($rows as $row) {
self::syncOrderItemAllocations($db, (int)($row['order_id'] ?? 0), (int)($row['id'] ?? 0));
}
} catch (Throwable $e) {
}
}
private static function loadReleaseArtistContext(PDO $db, int $releaseId): ?array
{
if ($releaseId <= 0) {
return null;
}
try {
$stmt = $db->prepare("
SELECT r.id, r.title, r.artist_id
FROM ac_releases r
WHERE r.id = :id
LIMIT 1
");
$stmt->execute([':id' => $releaseId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
} catch (Throwable $e) {
return null;
}
}
private static function loadTrackArtistContext(PDO $db, int $trackId): ?array
{
if ($trackId <= 0) {
return null;
}
try {
$stmt = $db->prepare("
SELECT t.id, t.title, t.release_id, r.artist_id
FROM ac_release_tracks t
JOIN ac_releases r ON r.id = t.release_id
WHERE t.id = :id
LIMIT 1
");
$stmt->execute([':id' => $trackId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
} catch (Throwable $e) {
return null;
}
}
private static function buildBundleAllocationRows(PDO $db, int $bundleId, float $lineTotal): array
{
if ($bundleId <= 0) {
return [];
}
try {
$stmt = $db->prepare("
SELECT
r.id AS release_id,
r.title,
r.artist_id,
COALESCE(SUM(CASE WHEN sp.is_enabled = 1 THEN sp.track_price ELSE 0 END), 0) AS weight
FROM ac_store_bundle_items bi
JOIN ac_releases r ON r.id = bi.release_id
LEFT JOIN ac_release_tracks t ON t.release_id = r.id
LEFT JOIN ac_store_track_products sp ON sp.release_track_id = t.id
WHERE bi.bundle_id = :bundle_id
GROUP BY r.id, r.title, r.artist_id, bi.sort_order, bi.id
ORDER BY bi.sort_order ASC, bi.id ASC
");
$stmt->execute([':bundle_id' => $bundleId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
if (!$rows) {
return [];
}
$weights = [];
$totalWeight = 0.0;
foreach ($rows as $index => $row) {
$weight = max(0.0, (float)($row['weight'] ?? 0));
if ($weight <= 0) {
$weight = 1.0;
}
$weights[$index] = $weight;
$totalWeight += $weight;
}
if ($totalWeight <= 0) {
$totalWeight = (float)count($rows);
}
$result = [];
$allocated = 0.0;
$lastIndex = count($rows) - 1;
foreach ($rows as $index => $row) {
if ($index === $lastIndex) {
$amount = round($lineTotal - $allocated, 2);
} else {
$amount = round($lineTotal * ($weights[$index] / $totalWeight), 2);
$allocated += $amount;
}
$result[] = [
'artist_id' => (int)($row['artist_id'] ?? 0),
'release_id' => (int)($row['release_id'] ?? 0),
'track_id' => null,
'title_snapshot' => (string)($row['title'] ?? 'Bundle release'),
'gross_amount' => max(0.0, $amount),
];
}
return $result;
} catch (Throwable $e) {
return [];
}
}
private static function extractApiKey(): string
{
$auth = trim((string)($_SERVER['HTTP_AUTHORIZATION'] ?? ''));
if (stripos($auth, 'Bearer ') === 0) {
return trim(substr($auth, 7));
}
$header = trim((string)($_SERVER['HTTP_X_API_KEY'] ?? ''));
if ($header !== '') {
return $header;
}
return '';
}
private static function clientIp(): string
{
$forwarded = trim((string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''));
if ($forwarded !== '') {
$parts = explode(',', $forwarded);
return trim((string)($parts[0] ?? ''));
}
return trim((string)($_SERVER['REMOTE_ADDR'] ?? ''));
}
private static function postJson(string $url, string $json, array $headers): void
{
if (function_exists('curl_init')) {
$ch = curl_init($url);
if ($ch === false) {
return;
}
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $json,
CURLOPT_TIMEOUT => 8,
CURLOPT_CONNECTTIMEOUT => 5,
]);
curl_exec($ch);
curl_close($ch);
return;
}
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => implode("\r\n", $headers),
'content' => $json,
'timeout' => 8,
'ignore_errors' => true,
],
]);
@file_get_contents($url, false, $context);
}
}

105
core/services/Audit.php Normal file
View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Core\Services;
use PDO;
use Throwable;
class Audit
{
public static function ensureTable(): void
{
$db = Database::get();
if (!($db instanceof PDO)) {
return;
}
try {
$db->exec("
CREATE TABLE IF NOT EXISTS ac_audit_logs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
actor_id INT UNSIGNED NULL,
actor_name VARCHAR(120) NULL,
actor_role VARCHAR(40) NULL,
action VARCHAR(120) NOT NULL,
context_json MEDIUMTEXT NULL,
ip_address VARCHAR(45) NULL,
user_agent VARCHAR(255) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
} catch (Throwable $e) {
return;
}
}
public static function log(string $action, array $context = []): void
{
$db = Database::get();
if (!($db instanceof PDO)) {
return;
}
self::ensureTable();
try {
$stmt = $db->prepare("
INSERT INTO ac_audit_logs
(actor_id, actor_name, actor_role, action, context_json, ip_address, user_agent)
VALUES
(:actor_id, :actor_name, :actor_role, :action, :context_json, :ip_address, :user_agent)
");
$stmt->execute([
':actor_id' => Auth::id() > 0 ? Auth::id() : null,
':actor_name' => Auth::name() !== '' ? Auth::name() : null,
':actor_role' => Auth::role() !== '' ? Auth::role() : null,
':action' => $action,
':context_json' => $context ? json_encode($context, JSON_UNESCAPED_SLASHES) : null,
':ip_address' => self::ip(),
':user_agent' => mb_substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
]);
} catch (Throwable $e) {
return;
}
}
public static function latest(int $limit = 100): array
{
$db = Database::get();
if (!($db instanceof PDO)) {
return [];
}
self::ensureTable();
$limit = max(1, min(500, $limit));
try {
$stmt = $db->prepare("
SELECT id, actor_name, actor_role, action, context_json, ip_address, created_at
FROM ac_audit_logs
ORDER BY id DESC
LIMIT :limit
");
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
} catch (Throwable $e) {
return [];
}
}
private static function ip(): ?string
{
$candidates = [
(string)($_SERVER['HTTP_CF_CONNECTING_IP'] ?? ''),
(string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''),
(string)($_SERVER['REMOTE_ADDR'] ?? ''),
];
foreach ($candidates as $candidate) {
if ($candidate === '') {
continue;
}
$first = trim(explode(',', $candidate)[0] ?? '');
if ($first !== '' && filter_var($first, FILTER_VALIDATE_IP)) {
return $first;
}
}
return null;
}
}

79
core/services/Auth.php Normal file
View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Core\Services;
class Auth
{
private const SESSION_KEY = 'admin_id';
private const SESSION_ROLE_KEY = 'admin_role';
private const SESSION_NAME_KEY = 'admin_name';
public static function init(): void
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
}
public static function check(): bool
{
self::init();
return isset($_SESSION[self::SESSION_KEY]);
}
public static function id(): int
{
self::init();
return (int)($_SESSION[self::SESSION_KEY] ?? 0);
}
public static function login(int $adminId, string $role = 'admin', string $name = ''): void
{
self::init();
session_regenerate_id(true);
$_SESSION[self::SESSION_KEY] = $adminId;
$_SESSION[self::SESSION_ROLE_KEY] = $role;
if ($name !== '') {
$_SESSION[self::SESSION_NAME_KEY] = $name;
}
}
public static function logout(): void
{
self::init();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], (bool)$params['secure'], (bool)$params['httponly']);
}
session_destroy();
session_start();
session_regenerate_id(true);
}
public static function role(): string
{
self::init();
return (string)($_SESSION[self::SESSION_ROLE_KEY] ?? 'admin');
}
public static function name(): string
{
self::init();
return (string)($_SESSION[self::SESSION_NAME_KEY] ?? 'Admin');
}
public static function hasRole(array $roles): bool
{
return in_array(self::role(), $roles, true);
}
public static function can(string $permission): bool
{
if (!self::check()) {
return false;
}
return Permissions::can(self::role(), $permission);
}
}

48
core/services/Csrf.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Core\Services;
class Csrf
{
private const SESSION_KEY = '_csrf_token';
public static function token(): string
{
if (session_status() !== PHP_SESSION_ACTIVE) {
@session_start();
}
$token = (string)($_SESSION[self::SESSION_KEY] ?? '');
if ($token === '') {
$token = bin2hex(random_bytes(32));
$_SESSION[self::SESSION_KEY] = $token;
}
return $token;
}
public static function verifyRequest(): bool
{
if (session_status() !== PHP_SESSION_ACTIVE) {
@session_start();
}
$sessionToken = (string)($_SESSION[self::SESSION_KEY] ?? '');
if ($sessionToken === '') {
$sessionToken = self::token();
}
$provided = '';
if (isset($_POST['csrf_token'])) {
$provided = (string)$_POST['csrf_token'];
} elseif (isset($_SERVER['HTTP_X_CSRF_TOKEN'])) {
$provided = (string)$_SERVER['HTTP_X_CSRF_TOKEN'];
}
if ($provided === '') {
return false;
}
return hash_equals($sessionToken, $provided);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Core\Services;
use PDO;
use PDOException;
class Database
{
private static ?PDO $pdo = null;
public static function get(): ?PDO
{
if (self::$pdo instanceof PDO) {
return self::$pdo;
}
$configPath = __DIR__ . '/../../storage/db.php';
if (!is_file($configPath)) {
return null;
}
$config = require $configPath;
if (!is_array($config)) {
return null;
}
$host = (string)($config['host'] ?? 'localhost');
$db = (string)($config['database'] ?? '');
$user = (string)($config['user'] ?? '');
$pass = (string)($config['pass'] ?? '');
$port = (int)($config['port'] ?? 3306);
if ($db == '' || $user == '') {
return null;
}
$dsn = "mysql:host={$host};dbname={$db};port={$port};charset=utf8mb4";
try {
self::$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
} catch (PDOException $e) {
return null;
}
return self::$pdo;
}
}

226
core/services/Mailer.php Normal file
View File

@@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
namespace Core\Services;
class Mailer
{
public static function send(string $to, string $subject, string $html, array $settings): array
{
$host = (string)($settings['smtp_host'] ?? '');
$port = (int)($settings['smtp_port'] ?? 587);
$user = (string)($settings['smtp_user'] ?? '');
$pass = (string)($settings['smtp_pass'] ?? '');
$encryption = strtolower((string)($settings['smtp_encryption'] ?? 'tls'));
$fromEmail = (string)($settings['smtp_from_email'] ?? '');
$fromName = (string)($settings['smtp_from_name'] ?? '');
if ($fromEmail === '') {
$fromEmail = $user !== '' ? $user : 'no-reply@localhost';
}
$fromHeader = $fromName !== '' ? "{$fromName} <{$fromEmail}>" : $fromEmail;
if ($host === '') {
$headers = [
'MIME-Version: 1.0',
'Content-type: text/html; charset=utf-8',
"From: {$fromHeader}",
];
$ok = mail($to, $subject, $html, implode("\r\n", $headers));
return ['ok' => $ok, 'error' => $ok ? '' : 'mail() failed', 'debug' => 'transport=mail()'];
}
$remote = $encryption === 'ssl' ? "ssl://{$host}:{$port}" : "{$host}:{$port}";
$socket = stream_socket_client($remote, $errno, $errstr, 10);
if (!$socket) {
return ['ok' => false, 'error' => "SMTP connect failed: {$errstr}", 'debug' => "connect={$remote} errno={$errno} err={$errstr}"];
}
$debug = [];
$read = function () use ($socket): string {
$data = '';
while (!feof($socket)) {
$line = fgets($socket, 515);
if ($line === false) {
break;
}
$data .= $line;
if (isset($line[3]) && $line[3] === ' ') {
break;
}
}
return $data;
};
$send = function (string $command) use ($socket, $read): string {
fwrite($socket, $command . "\r\n");
return $read();
};
$resp = $read();
$debug[] = 'S: ' . trim($resp);
if (!self::isOkResponse($resp)) {
fclose($socket);
return ['ok' => false, 'error' => 'SMTP greeting failed', 'debug' => implode("\n", $debug)];
}
$resp = $send("EHLO localhost");
$debug[] = 'C: EHLO localhost';
$debug[] = 'S: ' . trim($resp);
if (!self::isOkResponse($resp)) {
fclose($socket);
return ['ok' => false, 'error' => 'SMTP EHLO failed', 'debug' => implode("\n", $debug)];
}
$authCaps = self::parseAuthCapabilities($resp);
if ($encryption === 'tls') {
$resp = $send("STARTTLS");
$debug[] = 'C: STARTTLS';
$debug[] = 'S: ' . trim($resp);
if (substr(trim($resp), 0, 3) !== '220') {
fclose($socket);
return ['ok' => false, 'error' => 'SMTP STARTTLS failed', 'debug' => implode("\n", $debug)];
}
if (!stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
fclose($socket);
return ['ok' => false, 'error' => 'SMTP TLS negotiation failed', 'debug' => implode("\n", $debug)];
}
$resp = $send("EHLO localhost");
$debug[] = 'C: EHLO localhost';
$debug[] = 'S: ' . trim($resp);
if (!self::isOkResponse($resp)) {
fclose($socket);
return ['ok' => false, 'error' => 'SMTP EHLO after TLS failed', 'debug' => implode("\n", $debug)];
}
$authCaps = self::parseAuthCapabilities($resp);
}
if ($user !== '' && $pass !== '') {
$authOk = false;
$authErrors = [];
// Prefer advertised AUTH mechanisms when available.
if (in_array('PLAIN', $authCaps, true)) {
$authResp = $send("AUTH PLAIN " . base64_encode("\0{$user}\0{$pass}"));
$debug[] = 'C: AUTH PLAIN [credentials]';
$debug[] = 'S: ' . trim($authResp);
if (substr(trim($authResp), 0, 3) === '235') {
$authOk = true;
} else {
$authErrors[] = 'PLAIN rejected';
}
}
if (!$authOk && (in_array('LOGIN', $authCaps, true) || !$authCaps)) {
$resp = $send("AUTH LOGIN");
$debug[] = 'C: AUTH LOGIN';
$debug[] = 'S: ' . trim($resp);
if (substr(trim($resp), 0, 3) === '334') {
$resp = $send(base64_encode($user));
$debug[] = 'C: [username]';
$debug[] = 'S: ' . trim($resp);
if (substr(trim($resp), 0, 3) === '334') {
$resp = $send(base64_encode($pass));
$debug[] = 'C: [password]';
$debug[] = 'S: ' . trim($resp);
if (substr(trim($resp), 0, 3) === '235') {
$authOk = true;
} else {
$authErrors[] = 'LOGIN password rejected';
}
} else {
$authErrors[] = 'LOGIN username rejected';
}
} else {
$authErrors[] = 'LOGIN command rejected';
}
}
if (!$authOk) {
fclose($socket);
$err = $authErrors ? implode(', ', $authErrors) : 'No supported AUTH method';
return ['ok' => false, 'error' => 'SMTP authentication failed: ' . $err, 'debug' => implode("\n", $debug)];
}
}
$resp = $send("MAIL FROM:<{$fromEmail}>");
$debug[] = "C: MAIL FROM:<{$fromEmail}>";
$debug[] = 'S: ' . trim($resp);
if (!self::isOkResponse($resp)) {
fclose($socket);
return ['ok' => false, 'error' => 'SMTP MAIL FROM failed', 'debug' => implode("\n", $debug)];
}
$resp = $send("RCPT TO:<{$to}>");
$debug[] = "C: RCPT TO:<{$to}>";
$debug[] = 'S: ' . trim($resp);
if (!self::isOkResponse($resp)) {
fclose($socket);
return ['ok' => false, 'error' => 'SMTP RCPT TO failed', 'debug' => implode("\n", $debug)];
}
$resp = $send("DATA");
$debug[] = 'C: DATA';
$debug[] = 'S: ' . trim($resp);
if (substr(trim($resp), 0, 3) !== '354') {
fclose($socket);
return ['ok' => false, 'error' => 'SMTP DATA failed', 'debug' => implode("\n", $debug)];
}
$headers = [
"From: {$fromHeader}",
"To: {$to}",
"Subject: {$subject}",
"MIME-Version: 1.0",
"Content-Type: text/html; charset=utf-8",
];
$message = implode("\r\n", $headers) . "\r\n\r\n" . $html . "\r\n.";
fwrite($socket, $message . "\r\n");
$resp = $read();
$debug[] = 'C: [message body]';
$debug[] = 'S: ' . trim($resp);
if (!self::isOkResponse($resp)) {
fclose($socket);
return ['ok' => false, 'error' => 'SMTP message rejected', 'debug' => implode("\n", $debug)];
}
$resp = $send("QUIT");
$debug[] = 'C: QUIT';
$debug[] = 'S: ' . trim($resp);
fclose($socket);
return ['ok' => true, 'error' => '', 'debug' => implode("\n", $debug)];
}
private static function isOkResponse(string $response): bool
{
$code = substr(trim($response), 0, 3);
if ($code === '') {
return false;
}
return $code[0] === '2' || $code[0] === '3';
}
/**
* @return string[]
*/
private static function parseAuthCapabilities(string $ehloResponse): array
{
$caps = [];
foreach (preg_split('/\r?\n/', $ehloResponse) as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
$line = preg_replace('/^\d{3}[ -]/', '', $line) ?? $line;
if (stripos($line, 'AUTH ') === 0) {
$parts = preg_split('/\s+/', substr($line, 5)) ?: [];
foreach ($parts as $p) {
$p = strtoupper(trim($p));
if ($p !== '') {
$caps[] = $p;
}
}
}
}
return array_values(array_unique($caps));
}
}

25
core/services/Nav.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Core\Services;
use PDO;
use Throwable;
class Nav
{
public static function links(): array
{
$db = Database::get();
if (!$db instanceof PDO) {
return [];
}
try {
$stmt = $db->query("SELECT id, label, url, sort_order, is_active FROM ac_nav_links ORDER BY sort_order ASC, id ASC");
$rows = $stmt->fetchAll();
} catch (Throwable $e) {
return [];
}
return $rows ?: [];
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Core\Services;
class Permissions
{
public static function definitions(): array
{
$defs = [
['key' => 'core.dashboard', 'label' => 'Dashboard', 'group' => 'Core'],
['key' => 'core.settings', 'label' => 'Settings', 'group' => 'Core'],
['key' => 'core.navigation', 'label' => 'Navigation', 'group' => 'Core'],
['key' => 'core.accounts', 'label' => 'Accounts', 'group' => 'Core'],
['key' => 'core.plugins', 'label' => 'Plugins', 'group' => 'Core'],
['key' => 'module.pages', 'label' => 'Pages', 'group' => 'Modules'],
['key' => 'module.shortcodes', 'label' => 'Shortcodes', 'group' => 'Modules'],
['key' => 'module.blog', 'label' => 'Blog', 'group' => 'Modules'],
['key' => 'module.media', 'label' => 'Media', 'group' => 'Modules'],
['key' => 'module.newsletter', 'label' => 'Newsletter', 'group' => 'Modules'],
];
foreach (Plugins::all() as $plugin) {
$slug = trim((string)($plugin['slug'] ?? ''));
if ($slug === '') {
continue;
}
$defs[] = [
'key' => 'plugin.' . $slug,
'label' => (string)($plugin['name'] ?? ucfirst($slug)),
'group' => 'Plugins',
];
}
return $defs;
}
public static function matrix(): array
{
$default = self::defaultMatrix();
$raw = Settings::get('role_permissions_json', '');
if ($raw === '') {
return $default;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return $default;
}
foreach ($default as $perm => $roles) {
$row = $decoded[$perm] ?? null;
if (!is_array($row)) {
continue;
}
foreach (['admin', 'manager', 'editor'] as $role) {
if (array_key_exists($role, $row)) {
$default[$perm][$role] = self::toBool($row[$role]);
}
}
}
return $default;
}
public static function saveMatrix(array $posted): void
{
$current = self::matrix();
foreach ($current as $permission => $roles) {
foreach ($roles as $role => $allowed) {
$current[$permission][$role] = isset($posted[$permission][$role]);
}
}
Settings::set('role_permissions_json', json_encode($current, JSON_UNESCAPED_SLASHES));
}
public static function can(string $role, string $permission): bool
{
$matrix = self::matrix();
if (!isset($matrix[$permission])) {
return true;
}
return (bool)($matrix[$permission][$role] ?? false);
}
public static function routePermission(string $path): ?string
{
if ($path === '/admin') {
return 'core.dashboard';
}
if (!str_starts_with($path, '/admin/')) {
return null;
}
$slug = trim((string)explode('/', trim(substr($path, strlen('/admin/')), '/'))[0]);
if ($slug === '' || in_array($slug, ['login', 'logout', 'install', 'installer'], true)) {
return null;
}
$coreMap = [
'settings' => 'core.settings',
'navigation' => 'core.navigation',
'accounts' => 'core.accounts',
'plugins' => 'core.plugins',
'pages' => 'module.pages',
'shortcodes' => 'module.shortcodes',
'blog' => 'module.blog',
'media' => 'module.media',
'newsletter' => 'module.newsletter',
];
if (isset($coreMap[$slug])) {
return $coreMap[$slug];
}
return 'plugin.' . $slug;
}
private static function defaultMatrix(): array
{
$matrix = [];
foreach (self::definitions() as $def) {
$key = (string)$def['key'];
$matrix[$key] = [
'admin' => true,
'manager' => true,
'editor' => false,
];
}
foreach (['module.pages', 'module.shortcodes', 'module.blog'] as $editorAllowed) {
if (isset($matrix[$editorAllowed])) {
$matrix[$editorAllowed]['editor'] = true;
}
}
return $matrix;
}
private static function toBool(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_int($value) || is_float($value)) {
return ((int)$value) === 1;
}
$v = strtolower(trim((string)$value));
return in_array($v, ['1', 'true', 'yes', 'on'], true);
}
}

276
core/services/Plugins.php Normal file
View File

@@ -0,0 +1,276 @@
<?php
declare(strict_types=1);
namespace Core\Services;
use Core\Http\Router;
use PDO;
use Throwable;
class Plugins
{
private static string $path = '';
private static array $plugins = [];
public static function init(string $path): void
{
self::$path = rtrim($path, '/');
self::sync();
}
public static function all(): array
{
return self::$plugins;
}
public static function enabled(): array
{
return array_values(array_filter(self::$plugins, static function (array $plugin): bool {
return (bool)($plugin['is_enabled'] ?? false);
}));
}
public static function isEnabled(string $slug): bool
{
foreach (self::$plugins as $plugin) {
if ((string)($plugin['slug'] ?? '') === $slug) {
return (bool)($plugin['is_enabled'] ?? false);
}
}
return false;
}
public static function adminNav(): array
{
$items = [];
foreach (self::enabled() as $plugin) {
$nav = $plugin['admin_nav'] ?? null;
if (!is_array($nav)) {
continue;
}
$items[] = [
'label' => (string)($nav['label'] ?? ''),
'url' => (string)($nav['url'] ?? ''),
'roles' => array_values(array_filter((array)($nav['roles'] ?? []))),
'icon' => (string)($nav['icon'] ?? ''),
'slug' => (string)($plugin['slug'] ?? ''),
];
}
$items = array_values(array_filter($items, static function (array $item): bool {
if ($item['label'] === '' || $item['url'] === '') {
return false;
}
$slug = trim((string)($item['slug'] ?? ''));
if ($slug !== '' && Auth::check() && !Permissions::can(Auth::role(), 'plugin.' . $slug)) {
return false;
}
return true;
}));
$order = [
'artists' => 10,
'releases' => 20,
'store' => 30,
'advanced-reporting' => 40,
'support' => 50,
];
usort($items, static function (array $a, array $b) use ($order): int {
$aSlug = (string)($a['slug'] ?? '');
$bSlug = (string)($b['slug'] ?? '');
$aOrder = $order[$aSlug] ?? 1000;
$bOrder = $order[$bSlug] ?? 1000;
if ($aOrder === $bOrder) {
return strcasecmp((string)($a['label'] ?? ''), (string)($b['label'] ?? ''));
}
return $aOrder <=> $bOrder;
});
return $items;
}
public static function register(Router $router): void
{
foreach (self::enabled() as $plugin) {
$entry = (string)($plugin['entry'] ?? 'plugin.php');
$entryPath = rtrim((string)($plugin['path'] ?? ''), '/') . '/' . $entry;
if (!is_file($entryPath)) {
continue;
}
$handler = require $entryPath;
if (is_callable($handler)) {
$handler($router);
}
}
}
public static function toggle(string $slug, bool $enabled): void
{
$db = Database::get();
if (!$db instanceof PDO) {
return;
}
try {
$stmt = $db->prepare("UPDATE ac_plugins SET is_enabled = :enabled, updated_at = NOW() WHERE slug = :slug");
$stmt->execute([
':enabled' => $enabled ? 1 : 0,
':slug' => $slug,
]);
} catch (Throwable $e) {
}
self::sync();
if (!$enabled) {
$plugin = null;
foreach (self::$plugins as $item) {
if (($item['slug'] ?? '') === $slug) {
$plugin = $item;
break;
}
}
if ($plugin && !empty($plugin['pages'])) {
self::removeNavLinks($db, (array)$plugin['pages']);
}
}
}
public static function sync(): void
{
$filesystem = self::scanFilesystem();
$db = Database::get();
$dbRows = [];
if ($db instanceof PDO) {
try {
$db->exec("
CREATE TABLE IF NOT EXISTS ac_plugins (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(120) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
version VARCHAR(50) NOT NULL DEFAULT '0.0.0',
is_enabled TINYINT(1) NOT NULL DEFAULT 0,
installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
$stmt = $db->query("SELECT slug, is_enabled FROM ac_plugins");
$dbRows = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
} catch (Throwable $e) {
$dbRows = [];
}
}
$dbMap = [];
foreach ($dbRows as $row) {
$dbMap[(string)$row['slug']] = (int)$row['is_enabled'];
}
foreach ($filesystem as $slug => $plugin) {
$plugin['is_enabled'] = (bool)($dbMap[$slug] ?? ($plugin['default_enabled'] ?? false));
$filesystem[$slug] = $plugin;
if ($db instanceof PDO && !isset($dbMap[$slug])) {
try {
$stmt = $db->prepare("
INSERT INTO ac_plugins (slug, name, version, is_enabled)
VALUES (:slug, :name, :version, :enabled)
");
$stmt->execute([
':slug' => $slug,
':name' => (string)($plugin['name'] ?? $slug),
':version' => (string)($plugin['version'] ?? '0.0.0'),
':enabled' => $plugin['is_enabled'] ? 1 : 0,
]);
} catch (Throwable $e) {
}
}
if ($db instanceof PDO && $plugin['is_enabled']) {
$thisPages = $plugin['pages'] ?? [];
if (is_array($thisPages) && $thisPages) {
self::ensurePages($db, $thisPages);
}
}
}
self::$plugins = array_values($filesystem);
}
private static function scanFilesystem(): array
{
if (self::$path === '' || !is_dir(self::$path)) {
return [];
}
$plugins = [];
foreach (glob(self::$path . '/*/plugin.json') as $manifestPath) {
$dir = dirname($manifestPath);
$slug = basename($dir);
$raw = file_get_contents($manifestPath);
$decoded = json_decode($raw ?: '', true);
if (!is_array($decoded)) {
$decoded = [];
}
$plugins[$slug] = [
'slug' => $slug,
'name' => (string)($decoded['name'] ?? $slug),
'version' => (string)($decoded['version'] ?? '0.0.0'),
'description' => (string)($decoded['description'] ?? ''),
'author' => (string)($decoded['author'] ?? ''),
'admin_nav' => is_array($decoded['admin_nav'] ?? null) ? $decoded['admin_nav'] : null,
'pages' => is_array($decoded['pages'] ?? null) ? $decoded['pages'] : [],
'entry' => (string)($decoded['entry'] ?? 'plugin.php'),
'default_enabled' => (bool)($decoded['default_enabled'] ?? false),
'path' => $dir,
];
}
return $plugins;
}
private static function ensurePages(PDO $db, array $pages): void
{
foreach ($pages as $page) {
if (!is_array($page)) {
continue;
}
$slug = trim((string)($page['slug'] ?? ''));
$title = trim((string)($page['title'] ?? ''));
$content = (string)($page['content_html'] ?? '');
if ($slug === '' || $title === '') {
continue;
}
try {
$stmt = $db->prepare("SELECT id FROM ac_pages WHERE slug = :slug LIMIT 1");
$stmt->execute([':slug' => $slug]);
if ($stmt->fetch()) {
continue;
}
$insert = $db->prepare("
INSERT INTO ac_pages (title, slug, content_html, is_published, is_home, is_blog_index)
VALUES (:title, :slug, :content_html, 1, 0, 0)
");
$insert->execute([
':title' => $title,
':slug' => $slug,
':content_html' => $content,
]);
} catch (Throwable $e) {
}
}
}
private static function removeNavLinks(PDO $db, array $pages): void
{
foreach ($pages as $page) {
if (!is_array($page)) {
continue;
}
$slug = trim((string)($page['slug'] ?? ''));
if ($slug === '') {
continue;
}
$url = '/' . ltrim($slug, '/');
try {
$stmt = $db->prepare("DELETE FROM ac_nav_links WHERE url = :url");
$stmt->execute([':url' => $url]);
} catch (Throwable $e) {
}
}
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Core\Services;
use PDO;
use Throwable;
class RateLimiter
{
private static bool $tableEnsured = false;
public static function tooMany(string $action, string $subjectKey, int $limit, int $windowSeconds): bool
{
if ($limit < 1 || $windowSeconds < 1) {
return false;
}
$db = Database::get();
if (!($db instanceof PDO)) {
return false;
}
self::ensureTable($db);
try {
$cutoff = date('Y-m-d H:i:s', time() - $windowSeconds);
$countStmt = $db->prepare("
SELECT COUNT(*) AS c
FROM ac_rate_limits
WHERE action_name = :action_name
AND subject_key = :subject_key
AND created_at >= :cutoff
");
$countStmt->execute([
':action_name' => $action,
':subject_key' => $subjectKey,
':cutoff' => $cutoff,
]);
$count = (int)(($countStmt->fetch(PDO::FETCH_ASSOC)['c'] ?? 0));
if ($count >= $limit) {
return true;
}
$insertStmt = $db->prepare("
INSERT INTO ac_rate_limits (action_name, subject_key, created_at)
VALUES (:action_name, :subject_key, NOW())
");
$insertStmt->execute([
':action_name' => $action,
':subject_key' => $subjectKey,
]);
$db->exec("DELETE FROM ac_rate_limits WHERE created_at < (NOW() - INTERVAL 2 DAY)");
} catch (Throwable $e) {
return false;
}
return false;
}
private static function ensureTable(PDO $db): void
{
if (self::$tableEnsured) {
return;
}
$db->exec("
CREATE TABLE IF NOT EXISTS ac_rate_limits (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
action_name VARCHAR(80) NOT NULL,
subject_key VARCHAR(191) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_rate_limits_lookup (action_name, subject_key, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
self::$tableEnsured = true;
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Core\Services;
use Throwable;
class Settings
{
private static array $data = [];
private static bool $loaded = false;
public static function init(string $path): void
{
if (self::$loaded) {
return;
}
if (is_file($path)) {
$loaded = require $path;
if (is_array($loaded)) {
self::$data = $loaded;
}
}
self::reload();
self::$loaded = true;
}
public static function get(string $key, string $default = ''): string
{
return array_key_exists($key, self::$data) ? (string)self::$data[$key] : $default;
}
public static function reload(): void
{
$db = Database::get();
if (!$db) {
return;
}
try {
$rows = $db->query("SELECT setting_key, setting_value FROM ac_settings")->fetchAll();
foreach ($rows as $row) {
$k = (string)($row['setting_key'] ?? '');
$v = (string)($row['setting_value'] ?? '');
if ($k !== '') {
self::$data[$k] = $v;
}
}
} catch (Throwable $e) {
return;
}
}
public static function set(string $key, string $value): void
{
self::$data[$key] = $value;
$db = Database::get();
if (!$db) {
return;
}
try {
$stmt = $db->prepare("REPLACE INTO ac_settings (setting_key, setting_value) VALUES (:k, :v)");
$stmt->execute([':k' => $key, ':v' => $value]);
} catch (Throwable $e) {
return;
}
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Core\Services;
class Shortcodes
{
/** @var array<string, callable> */
private static array $handlers = [];
public static function register(string $name, callable $handler): void
{
$key = strtolower(trim($name));
if ($key === '') {
return;
}
self::$handlers[$key] = $handler;
}
public static function render(string $content, array $context = []): string
{
if ($content === '' || !self::$handlers) {
return $content;
}
return preg_replace_callback('/\[([a-zA-Z0-9_-]+)([^\]]*)\]/', function (array $m) use ($context): string {
$tag = strtolower((string)($m[1] ?? ''));
if (!isset(self::$handlers[$tag])) {
return (string)$m[0];
}
$attrs = self::parseAttrs((string)($m[2] ?? ''));
try {
$out = (self::$handlers[$tag])($attrs, $context);
return is_string($out) ? $out : '';
} catch (\Throwable $e) {
return '';
}
}, $content) ?? $content;
}
private static function parseAttrs(string $raw): array
{
$attrs = [];
if ($raw === '') {
return $attrs;
}
if (preg_match_all('/([a-zA-Z0-9_-]+)\s*=\s*"([^"]*)"/', $raw, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$attrs[strtolower((string)$match[1])] = (string)$match[2];
}
}
return $attrs;
}
}

231
core/services/Updater.php Normal file
View File

@@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace Core\Services;
use Throwable;
class Updater
{
private const CACHE_TTL_SECONDS = 21600;
private const MANIFEST_URL = 'https://lab.codemunkeh.io/codemunkeh/AudioCore/raw/branch/main/update.json';
public static function currentVersion(): string
{
$data = require __DIR__ . '/../version.php';
if (is_array($data) && !empty($data['version'])) {
return (string)$data['version'];
}
return '0.0.0';
}
public static function getStatus(bool $force = false): array
{
$current = self::currentVersion();
$manifestUrl = self::MANIFEST_URL;
$channel = trim(Settings::get('update_channel', 'stable'));
if ($channel === '') {
$channel = 'stable';
}
$cache = self::readCache();
if (
!$force
&& is_array($cache)
&& (string)($cache['manifest_url'] ?? '') === $manifestUrl
&& (string)($cache['channel'] ?? '') === $channel
&& ((int)($cache['fetched_at'] ?? 0) + self::CACHE_TTL_SECONDS) > time()
) {
return $cache;
}
$status = [
'ok' => false,
'configured' => true,
'current_version' => $current,
'latest_version' => $current,
'update_available' => false,
'channel' => $channel,
'manifest_url' => $manifestUrl,
'error' => '',
'checked_at' => gmdate('c'),
'download_url' => '',
'changelog_url' => '',
'notes' => '',
'fetched_at' => time(),
];
try {
$raw = self::fetchManifest($manifestUrl);
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
throw new \RuntimeException('Manifest JSON is invalid.');
}
$release = self::pickReleaseForChannel($decoded, $channel);
$latest = trim((string)($release['version'] ?? ''));
if ($latest === '') {
throw new \RuntimeException('Manifest does not include a version for selected channel.');
}
$status['ok'] = true;
$status['latest_version'] = $latest;
$status['download_url'] = (string)($release['download_url'] ?? '');
$status['changelog_url'] = (string)($release['changelog_url'] ?? '');
$status['notes'] = (string)($release['notes'] ?? '');
$status['update_available'] = version_compare($latest, $current, '>');
} catch (Throwable $e) {
$status['error'] = $e->getMessage();
}
self::writeCache($status);
return $status;
}
private static function pickReleaseForChannel(array $manifest, string $channel): array
{
if (isset($manifest['channels']) && is_array($manifest['channels'])) {
$channels = $manifest['channels'];
if (isset($channels[$channel]) && is_array($channels[$channel])) {
return $channels[$channel];
}
if (isset($channels['stable']) && is_array($channels['stable'])) {
return $channels['stable'];
}
}
return $manifest;
}
private static function cachePath(): string
{
return __DIR__ . '/../../storage/update_cache.json';
}
private static function readCache(): ?array
{
try {
$db = Database::get();
if ($db) {
$stmt = $db->query("
SELECT checked_at, channel, manifest_url, current_version, latest_version,
is_update_available, ok, error_text, payload_json
FROM ac_update_checks
ORDER BY id DESC
LIMIT 1
");
$row = $stmt ? $stmt->fetch() : null;
if (is_array($row)) {
$payload = json_decode((string)($row['payload_json'] ?? ''), true);
if (is_array($payload)) {
$payload['channel'] = (string)($row['channel'] ?? ($payload['channel'] ?? 'stable'));
$payload['manifest_url'] = (string)($row['manifest_url'] ?? ($payload['manifest_url'] ?? ''));
$payload['current_version'] = (string)($row['current_version'] ?? ($payload['current_version'] ?? '0.0.0'));
$payload['latest_version'] = (string)($row['latest_version'] ?? ($payload['latest_version'] ?? '0.0.0'));
$payload['update_available'] = ((int)($row['is_update_available'] ?? 0) === 1);
$payload['ok'] = ((int)($row['ok'] ?? 0) === 1);
$payload['error'] = (string)($row['error_text'] ?? ($payload['error'] ?? ''));
$checkedAt = (string)($row['checked_at'] ?? '');
if ($checkedAt !== '') {
$payload['checked_at'] = $checkedAt;
$payload['fetched_at'] = strtotime($checkedAt) ?: ($payload['fetched_at'] ?? 0);
}
return $payload;
}
}
}
} catch (Throwable $e) {
}
$path = self::cachePath();
if (!is_file($path)) {
return null;
}
$raw = @file_get_contents($path);
if ($raw === false) {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
}
private static function writeCache(array $data): void
{
try {
$db = Database::get();
if ($db) {
$stmt = $db->prepare("
INSERT INTO ac_update_checks
(checked_at, channel, manifest_url, current_version, latest_version, is_update_available, ok, error_text, payload_json)
VALUES (NOW(), :channel, :manifest_url, :current_version, :latest_version, :is_update_available, :ok, :error_text, :payload_json)
");
$stmt->execute([
':channel' => (string)($data['channel'] ?? 'stable'),
':manifest_url' => (string)($data['manifest_url'] ?? ''),
':current_version' => (string)($data['current_version'] ?? '0.0.0'),
':latest_version' => (string)($data['latest_version'] ?? '0.0.0'),
':is_update_available' => !empty($data['update_available']) ? 1 : 0,
':ok' => !empty($data['ok']) ? 1 : 0,
':error_text' => (string)($data['error'] ?? ''),
':payload_json' => json_encode($data, JSON_UNESCAPED_SLASHES),
]);
}
} catch (Throwable $e) {
}
$path = self::cachePath();
@file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
}
private static function fetchManifest(string $manifestUrl): string
{
$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 10,
'header' => "User-Agent: AudioCore-Updater/1.0\r\nAccept: application/json\r\n",
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$raw = @file_get_contents($manifestUrl, false, $ctx);
if (is_string($raw) && $raw !== '') {
return $raw;
}
if (!function_exists('curl_init')) {
throw new \RuntimeException('Unable to fetch manifest (file_get_contents failed and cURL is unavailable).');
}
$ch = curl_init($manifestUrl);
if ($ch === false) {
throw new \RuntimeException('Unable to fetch manifest (failed to initialize cURL).');
}
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'User-Agent: AudioCore-Updater/1.0',
'Accept: application/json',
]);
$body = curl_exec($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if (!is_string($body) || $body === '') {
$detail = $err !== '' ? $err : ('HTTP ' . $httpCode);
throw new \RuntimeException('Unable to fetch manifest via cURL: ' . $detail);
}
if ($httpCode >= 400) {
throw new \RuntimeException('Manifest request failed with HTTP ' . $httpCode . '.');
}
return $body;
}
}

6
core/version.php Normal file
View File

@@ -0,0 +1,6 @@
<?php
declare(strict_types=1);
return [
'version' => '1.5.1',
];

55
core/views/View.php Normal file
View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Core\Views;
use Core\Services\Csrf;
class View
{
private string $basePath;
public function __construct(string $basePath = '')
{
$this->basePath = $basePath !== '' ? rtrim($basePath, '/') : __DIR__ . '/../../views';
}
public function render(string $template, array $vars = []): string
{
$path = $this->basePath !== '' ? $this->basePath . '/' . ltrim($template, '/') : $template;
if (!is_file($path)) {
error_log('AC View missing: ' . $path);
return '';
}
if ($vars) {
extract($vars, EXTR_SKIP);
}
ob_start();
require $path;
$html = ob_get_clean() ?: '';
return $this->injectCsrfTokens($html);
}
private function injectCsrfTokens(string $html): string
{
if ($html === '' || stripos($html, '<form') === false) {
return $html;
}
$tokenField = '<input type="hidden" name="csrf_token" value="' . htmlspecialchars(Csrf::token(), ENT_QUOTES, 'UTF-8') . '">';
return (string)preg_replace_callback(
'~<form\b[^>]*>~i',
static function (array $matches) use ($tokenField): string {
$tag = $matches[0];
if (!preg_match('~\bmethod\s*=\s*([\"\']?)post\1~i', $tag)) {
return $tag;
}
return $tag . $tokenField;
},
$html
);
}
}

7
index.php Normal file
View File

@@ -0,0 +1,7 @@
<?php
ini_set('display_errors', '0');
ini_set('display_startup_errors', '0');
ini_set('log_errors', '1');
error_reporting(E_ALL);
require_once __DIR__ . '/public/index.php';

1
limits.php Normal file
View File

@@ -0,0 +1 @@
<?php echo "upload_max_filesize: " . ini_get("upload_max_filesize"); ?>

File diff suppressed because it is too large Load Diff

30
modules/admin/module.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Core\Http\Router;
use Modules\Admin\AdminController;
require_once __DIR__ . '/AdminController.php';
return function (Router $router): void {
$controller = new AdminController();
$router->get('/admin', [$controller, 'index']);
$router->get('/admin/login', [$controller, 'loginForm']);
$router->get('/admin/logout', [$controller, 'logout']);
$router->get('/admin/settings', [$controller, 'settingsForm']);
$router->get('/admin/navigation', [$controller, 'navigationForm']);
$router->get('/admin/accounts', [$controller, 'accountsIndex']);
$router->get('/admin/accounts/new', [$controller, 'accountsNew']);
$router->get('/admin/updates', [$controller, 'updatesForm']);
$router->get('/admin/installer', [$controller, 'installer']);
$router->get('/admin/shortcodes', [$controller, 'shortcodesIndex']);
$router->get('/admin/shortcodes/preview', [$controller, 'shortcodesPreview']);
$router->post('/admin/install', [$controller, 'install']);
$router->post('/admin/login', [$controller, 'login']);
$router->post('/admin/settings', [$controller, 'saveSettings']);
$router->post('/admin/navigation', [$controller, 'saveNavigation']);
$router->post('/admin/accounts/save', [$controller, 'accountsSave']);
$router->post('/admin/accounts/delete', [$controller, 'accountsDelete']);
$router->post('/admin/updates', [$controller, 'updatesSave']);
};

View File

@@ -0,0 +1,45 @@
<?php
$pageTitle = 'New Account';
$error = $error ?? '';
ob_start();
?>
<section class="admin-card">
<div class="badge">Accounts</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
<div>
<h1 style="font-size:28px; margin:0;">New Account</h1>
<p style="color: var(--muted); margin-top:6px;">Create an admin, manager, or editor account.</p>
</div>
<a href="/admin/accounts" class="btn outline small">Back</a>
</div>
<?php if ($error): ?>
<div style="margin-top:16px; color:#f3b0b0; font-size:13px;"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<form method="post" action="/admin/accounts/save" style="margin-top:18px; display:grid; gap:16px;">
<div class="admin-card" style="padding:16px;">
<div style="display:grid; gap:12px;">
<label class="label">Name</label>
<input class="input" name="name" placeholder="Name">
<label class="label">Email</label>
<input class="input" name="email" placeholder="name@example.com">
<label class="label">Password</label>
<input class="input" type="password" name="password" placeholder="Password">
<label class="label">Role</label>
<select class="input" name="role">
<option value="admin">Admin</option>
<option value="manager">Manager</option>
<option value="editor">Editor</option>
</select>
</div>
</div>
<div style="display:flex; justify-content:flex-end;">
<button type="submit" class="btn">Create account</button>
</div>
</form>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/layout.php';

View File

@@ -0,0 +1,75 @@
<?php
$pageTitle = 'Accounts';
$users = $users ?? [];
$error = $error ?? '';
ob_start();
?>
<section class="admin-card">
<div class="badge">Accounts</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
<div>
<h1 style="font-size:28px; margin:0;">Accounts</h1>
<p style="color: var(--muted); margin-top:6px;">Manage admin access and roles.</p>
</div>
<a href="/admin/accounts/new" class="btn small">New Account</a>
</div>
<?php if ($error): ?>
<div style="margin-top:16px; color:#f3b0b0; font-size:13px;"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<div style="margin-top:18px; display:grid; gap:12px;">
<div class="badge" style="opacity:0.7;">Permissions</div>
<div style="display:grid; grid-template-columns: 1.2fr repeat(3, 1fr); gap:12px; font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<div>Capability</div>
<div>Admin</div>
<div>Manager</div>
<div>Editor</div>
</div>
<div style="display:grid; grid-template-columns: 1.2fr repeat(3, 1fr); gap:12px; align-items:center; padding:10px 12px; border-radius:16px; border:1px solid var(--stroke); background: rgba(14,14,16,0.9);">
<div>Full access</div>
<div style="color:var(--accent-2); font-weight:600;">✓</div>
<div style="color:#f3b0b0;">✕</div>
<div style="color:#f3b0b0;">✕</div>
</div>
<div style="display:grid; grid-template-columns: 1.2fr repeat(3, 1fr); gap:12px; align-items:center; padding:10px 12px; border-radius:16px; border:1px solid var(--stroke); background: rgba(14,14,16,0.9);">
<div>Restricted modules</div>
<div style="color:var(--accent-2); font-weight:600;">✓</div>
<div style="color:var(--accent-2); font-weight:600;">✓</div>
<div style="color:#f3b0b0;">✕</div>
</div>
<div style="display:grid; grid-template-columns: 1.2fr repeat(3, 1fr); gap:12px; align-items:center; padding:10px 12px; border-radius:16px; border:1px solid var(--stroke); background: rgba(14,14,16,0.9);">
<div>Edit pages</div>
<div style="color:var(--accent-2); font-weight:600;">✓</div>
<div style="color:var(--accent-2); font-weight:600;">✓</div>
<div style="color:var(--accent-2); font-weight:600;">✓</div>
</div>
</div>
<div style="margin-top:24px; display:grid; gap:10px;">
<div style="display:grid; grid-template-columns: 1.4fr 1.2fr 160px 140px; gap:12px; font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<div>Name</div>
<div>Email</div>
<div>Role</div>
<div>Actions</div>
</div>
<?php if (!$users): ?>
<div style="color: var(--muted); font-size:13px;">No accounts yet.</div>
<?php else: ?>
<?php foreach ($users as $user): ?>
<div style="display:grid; grid-template-columns: 1.4fr 1.2fr 160px 140px; gap:12px; align-items:center; padding:10px 12px; border-radius:16px; border:1px solid var(--stroke); background: rgba(14,14,16,0.9);">
<div style="font-weight:600;"><?= htmlspecialchars((string)($user['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:12px; color:var(--muted);"><?= htmlspecialchars((string)($user['email'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
<div style="text-transform:uppercase; font-size:12px; color:var(--accent);"><?= htmlspecialchars((string)($user['role'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
<form method="post" action="/admin/accounts/delete" onsubmit="return confirm('Delete this account?');">
<input type="hidden" name="id" value="<?= (int)($user['id'] ?? 0) ?>">
<button type="submit" class="btn outline small">Delete</button>
</form>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/layout.php';

View File

@@ -0,0 +1,87 @@
<?php
$pageTitle = 'Cron Jobs';
$storeEnabled = (bool)($store_enabled ?? false);
$supportEnabled = (bool)($support_enabled ?? false);
$storeCron = is_array($store_cron ?? null) ? $store_cron : null;
$supportCron = is_array($support_cron ?? null) ? $support_cron : null;
ob_start();
?>
<section class="admin-card">
<div class="badge">Automation</div>
<div style="display:flex; align-items:flex-end; justify-content:space-between; gap:14px; margin-top:14px;">
<div>
<h1 style="margin:0; font-size:30px;">Cron Jobs</h1>
<p style="margin:8px 0 0; color:var(--muted); max-width:780px;">Cron jobs run server tasks in the background. Use them when tasks must run reliably without waiting for page visits.</p>
</div>
</div>
<div class="admin-card" style="margin-top:14px; padding:12px 14px; border-radius:12px;">
<div style="display:grid; gap:6px; font-size:13px; color:var(--muted);">
<div><strong style="color:#f5f7ff;">Support IMAP Sync:</strong> <span style="color:#ffcf9a;">Required</span> if you want email replies imported into tickets.</div>
<div><strong style="color:#f5f7ff;">Store Sales Chart:</strong> <span style="color:#9ff8d8;">Recommended</span> for predictable chart refresh and lower request-time work.</div>
</div>
</div>
<?php if (!$storeEnabled && !$supportEnabled): ?>
<article class="admin-card" style="margin-top:14px; padding:12px 14px; border-radius:12px;">
<div style="font-size:13px; color:var(--muted);">Enable Store and/or Support plugin to show cron commands here.</div>
</article>
<?php else: ?>
<div style="display:grid; gap:12px; margin-top:14px;">
<?php foreach ([$storeCron, $supportCron] as $job): ?>
<?php if (!is_array($job)) { continue; } ?>
<article class="admin-card" style="padding:12px 14px; border-radius:12px; display:grid; gap:10px;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px;">
<div>
<div style="font-size:11px; letter-spacing:0.2em; text-transform:uppercase; color:var(--muted);"><?= htmlspecialchars((string)($job['title'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
<div style="color:var(--muted); font-size:13px; margin-top:6px;"><?= htmlspecialchars((string)($job['description'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
</div>
<span class="pill"><?= htmlspecialchars((string)($job['interval'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
</div>
<div style="display:grid; grid-template-columns:1fr auto; gap:8px; align-items:center;">
<input class="input" value="<?= htmlspecialchars((string)($job['key'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" readonly>
<form method="post" action="<?= htmlspecialchars((string)($job['regen_action'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<?php foreach ((array)($job['regen_fields'] ?? []) as $k => $v): ?>
<input type="hidden" name="<?= htmlspecialchars((string)$k, ENT_QUOTES, 'UTF-8') ?>" value="<?= htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8') ?>">
<?php endforeach; ?>
<button class="btn outline small" type="submit">Regenerate Key</button>
</form>
</div>
<input class="input cron-url" value="<?= htmlspecialchars((string)($job['url'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" readonly>
<div style="display:grid; grid-template-columns:1fr auto; gap:8px; align-items:start;">
<textarea class="input cron-command" rows="2" style="resize:vertical; font-family:'IBM Plex Mono', monospace; font-size:12px; line-height:1.5;" readonly><?= htmlspecialchars((string)($job['command'] ?? ''), ENT_QUOTES, 'UTF-8') ?></textarea>
<button class="btn outline small copy-cron-btn" type="button">Copy</button>
</div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<script>
(function(){
const buttons = Array.from(document.querySelectorAll('.copy-cron-btn'));
buttons.forEach((btn) => {
btn.addEventListener('click', async () => {
const wrap = btn.closest('article');
const input = wrap ? wrap.querySelector('.cron-command') : null;
if (!input) return;
try {
await navigator.clipboard.writeText(input.value);
const prev = btn.textContent;
btn.textContent = 'Copied';
setTimeout(() => { btn.textContent = prev; }, 1200);
} catch (e) {
input.focus();
input.select();
}
});
});
})();
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/layout.php';

View File

@@ -0,0 +1,15 @@
<?php
$pageTitle = 'Admin';
ob_start();
?>
<section class="card">
<div class="badge">Admin</div>
<h1 style="margin-top:16px; font-size:28px;">Welcome</h1>
<p style="color:var(--muted);">Admin module is live. Use Settings to update the footer text.</p>
<div style="margin-top:16px;">
<a href="/admin/settings" class="btn">Settings</a>
</div>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/layout.php';

View File

@@ -0,0 +1,298 @@
<?php
$pageTitle = 'Installer';
$step = (int)($step ?? 1);
$values = is_array($values ?? null) ? $values : [];
$smtpResult = is_array($smtp_result ?? null) ? $smtp_result : [];
$checks = is_array($checks ?? null) ? $checks : [];
$val = static function (string $key, string $default = '') use ($values): string {
return (string)($values[$key] ?? $default);
};
ob_start();
?>
<style>
.ac-installer { max-width: 980px; margin: 0 auto; }
.ac-installer-title { margin: 16px 0 6px; font-size: 42px; line-height: 1.05; }
.ac-installer-intro { margin: 0; color: rgba(235,241,255,.75); }
.ac-installer-steps { display: flex; gap: 10px; margin-top: 18px; flex-wrap: wrap; }
.ac-step-pill {
padding: 8px 12px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,.16);
background: rgba(255,255,255,.03);
font-size: 11px;
letter-spacing: .18em;
text-transform: uppercase;
font-family: 'IBM Plex Mono', monospace;
}
.ac-step-pill.is-active { background: rgba(57,244,179,.18); }
.ac-installer-form { margin-top: 18px; display: grid; gap: 14px; }
.ac-installer-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.ac-installer-section {
margin-top: 6px;
padding-top: 14px;
border-top: 1px solid rgba(255,255,255,.10);
}
.ac-installer label {
display: inline-block;
margin: 0 0 8px;
font-size: 12px;
color: rgba(238,244,255,.78);
text-transform: uppercase;
letter-spacing: .16em;
font-family: 'IBM Plex Mono', monospace;
}
.ac-installer .input,
.ac-installer textarea.input,
.ac-installer select.input {
width: 100%;
height: 46px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,.16);
background: rgba(12,16,24,.58);
color: #edf2ff;
padding: 0 14px;
font-size: 15px;
outline: none;
}
.ac-installer textarea.input {
height: auto;
min-height: 92px;
padding: 12px 14px;
resize: vertical;
}
.ac-installer .input:focus {
border-color: rgba(57,244,179,.55);
box-shadow: 0 0 0 3px rgba(57,244,179,.14);
}
.ac-installer-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
margin-top: 6px;
}
.ac-installer .button {
height: 44px;
border-radius: 999px;
padding: 0 18px;
border: 1px solid rgba(255,255,255,.2);
background: rgba(255,255,255,.04);
color: #eef4ff;
text-transform: uppercase;
letter-spacing: .16em;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.ac-installer .button:hover { background: rgba(255,255,255,.1); }
.ac-installer .button-primary {
background: linear-gradient(135deg, #25afff, #2bd8ff);
border-color: rgba(40,185,255,.75);
color: #041019;
}
.ac-installer .button-primary:hover { filter: brightness(1.05); }
.ac-installer-alert {
margin-top: 16px;
border-radius: 12px;
padding: 12px 14px;
}
.ac-installer-alert.error {
border: 1px solid rgba(255,124,124,.45);
background: rgba(180,40,40,.18);
color: #ffd6d6;
}
.ac-installer-alert.success {
border: 1px solid rgba(57,244,179,.45);
background: rgba(10,90,60,.22);
color: #b8ffe5;
}
.ac-installer-checks {
padding: 12px;
border: 1px solid rgba(255,255,255,.1);
border-radius: 12px;
background: rgba(255,255,255,.02);
}
.ac-installer-checks-title {
font-size: 12px;
text-transform: uppercase;
letter-spacing: .16em;
color: rgba(255,255,255,.65);
margin-bottom: 10px;
font-family: 'IBM Plex Mono', monospace;
}
</style>
<section class="card ac-installer">
<div class="badge">Setup</div>
<h1 class="ac-installer-title">AudioCore V1.5.1 Installer</h1>
<p class="ac-installer-intro">Deploy a fresh instance with validated SMTP and baseline health checks.</p>
<div class="ac-installer-steps">
<div class="ac-step-pill <?= $step === 1 ? 'is-active' : '' ?>">1. Core Setup</div>
<div class="ac-step-pill <?= $step === 2 ? 'is-active' : '' ?>">2. Site + SMTP</div>
</div>
<?php if (!empty($error)): ?>
<div class="ac-installer-alert error"><?= htmlspecialchars((string)$error, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<?php if (!empty($success)): ?>
<div class="ac-installer-alert success"><?= htmlspecialchars((string)$success, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<?php if ($step === 1): ?>
<form method="post" action="/admin/install" class="ac-installer-form">
<input type="hidden" name="installer_action" value="setup_core">
<div class="ac-installer-grid">
<div>
<label>DB Host *</label>
<input class="input" name="db_host" value="<?= htmlspecialchars($val('db_host', 'localhost'), ENT_QUOTES, 'UTF-8') ?>">
</div>
<div>
<label>DB Port *</label>
<input class="input" name="db_port" value="<?= htmlspecialchars($val('db_port', '3306'), ENT_QUOTES, 'UTF-8') ?>">
</div>
<div>
<label>DB Name *</label>
<input class="input" name="db_name" value="<?= htmlspecialchars($val('db_name', ''), ENT_QUOTES, 'UTF-8') ?>">
</div>
<div>
<label>DB User *</label>
<input class="input" name="db_user" value="<?= htmlspecialchars($val('db_user', ''), ENT_QUOTES, 'UTF-8') ?>">
</div>
<div style="grid-column:1/-1;">
<label>DB Password</label>
<input class="input" type="password" name="db_pass" value="">
</div>
</div>
<div class="ac-installer-section ac-installer-grid">
<div>
<label>Admin Name *</label>
<input class="input" name="admin_name" value="<?= htmlspecialchars($val('admin_name', 'Admin'), ENT_QUOTES, 'UTF-8') ?>">
</div>
<div>
<label>Admin Email *</label>
<input class="input" type="email" name="admin_email" value="<?= htmlspecialchars($val('admin_email', ''), ENT_QUOTES, 'UTF-8') ?>">
</div>
<div>
<label>Admin Password *</label>
<input class="input" type="password" name="admin_password" value="" placeholder="Minimum 8 characters">
</div>
</div>
<div class="ac-installer-actions">
<button type="submit" class="button button-primary">Create Core Setup</button>
</div>
</form>
<?php else: ?>
<form method="post" action="/admin/install" class="ac-installer-form">
<div class="ac-installer-grid">
<div>
<label>Site Title *</label>
<input class="input" name="site_title" value="<?= htmlspecialchars($val('site_title', 'AudioCore V1.5.1'), ENT_QUOTES, 'UTF-8') ?>">
</div>
<div>
<label>Site Tagline</label>
<input class="input" name="site_tagline" value="<?= htmlspecialchars($val('site_tagline', 'Core CMS for DJs & Producers'), ENT_QUOTES, 'UTF-8') ?>">
</div>
<div>
<label>SEO Title Suffix</label>
<input class="input" name="seo_title_suffix" value="<?= htmlspecialchars($val('seo_title_suffix', 'AudioCore V1.5.1'), ENT_QUOTES, 'UTF-8') ?>">
</div>
<div style="grid-column:1/-1;">
<label>SEO Meta Description</label>
<textarea class="input" name="seo_meta_description" rows="3"><?= htmlspecialchars($val('seo_meta_description', ''), ENT_QUOTES, 'UTF-8') ?></textarea>
</div>
</div>
<div class="ac-installer-section ac-installer-grid">
<div>
<label>SMTP Host *</label>
<input class="input" name="smtp_host" value="<?= htmlspecialchars($val('smtp_host', ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="smtp.example.com">
</div>
<div>
<label>SMTP Port *</label>
<input class="input" name="smtp_port" value="<?= htmlspecialchars($val('smtp_port', '587'), ENT_QUOTES, 'UTF-8') ?>">
</div>
<div>
<label>SMTP User</label>
<input class="input" name="smtp_user" value="<?= htmlspecialchars($val('smtp_user', ''), ENT_QUOTES, 'UTF-8') ?>">
</div>
<div>
<label>SMTP Password</label>
<input class="input" type="password" name="smtp_pass" value="<?= htmlspecialchars($val('smtp_pass', ''), ENT_QUOTES, 'UTF-8') ?>">
</div>
<div>
<label>SMTP Encryption</label>
<input class="input" name="smtp_encryption" value="<?= htmlspecialchars($val('smtp_encryption', 'tls'), ENT_QUOTES, 'UTF-8') ?>" placeholder="tls or ssl">
</div>
<div>
<label>SMTP From Email *</label>
<input class="input" type="email" name="smtp_from_email" value="<?= htmlspecialchars($val('smtp_from_email', ''), ENT_QUOTES, 'UTF-8') ?>">
</div>
<div>
<label>SMTP From Name</label>
<input class="input" name="smtp_from_name" value="<?= htmlspecialchars($val('smtp_from_name', 'AudioCore V1.5.1'), ENT_QUOTES, 'UTF-8') ?>">
</div>
<div>
<label>Test Recipient Email *</label>
<input class="input" type="email" name="smtp_test_email" value="<?= htmlspecialchars($val('smtp_test_email', ''), ENT_QUOTES, 'UTF-8') ?>">
</div>
</div>
<?php if (!empty($smtpResult)): ?>
<div class="ac-installer-alert <?= !empty($smtpResult['ok']) ? 'success' : 'error' ?>" style="margin-top:0;">
<div style="font-weight:700; margin-bottom:4px;">
<?= !empty($smtpResult['ok']) ? 'SMTP test passed' : 'SMTP test failed' ?>
</div>
<div><?= htmlspecialchars((string)($smtpResult['message'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
<?php if (!empty($smtpResult['debug'])): ?>
<details style="margin-top:8px;">
<summary style="cursor:pointer;">Debug output</summary>
<pre style="white-space:pre-wrap; margin-top:8px; font-size:12px; color:#cfd6f5;"><?= htmlspecialchars((string)$smtpResult['debug'], ENT_QUOTES, 'UTF-8') ?></pre>
</details>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (!empty($checks)): ?>
<div class="ac-installer-checks">
<div class="ac-installer-checks-title">Installer Health Checks</div>
<div style="display:grid; gap:8px;">
<?php foreach ($checks as $check): ?>
<div style="display:flex; gap:10px; align-items:flex-start;">
<span style="display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px; border-radius:999px; font-size:12px; margin-top:2px; background:<?= !empty($check['ok']) ? 'rgba(57,244,179,.2)' : 'rgba(255,124,124,.2)' ?>; color:<?= !empty($check['ok']) ? '#9ff8d8' : '#ffb7b7' ?>;">
<?= !empty($check['ok']) ? '&#10003;' : '!' ?>
</span>
<div>
<div style="font-weight:600;"><?= htmlspecialchars((string)($check['label'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
<div style="color:rgba(235,241,255,.65); font-size:13px;"><?= htmlspecialchars((string)($check['detail'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<div class="ac-installer-actions">
<button type="submit" name="installer_action" value="test_smtp" class="button">Send Test Email + Run Checks</button>
<button type="submit" name="installer_action" value="finish_install" class="button button-primary">Finish Installation</button>
</div>
</form>
<?php endif; ?>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../views/site/layout.php';

View File

@@ -0,0 +1,643 @@
<?php
/** @var string $pageTitle */
/** @var string $content */
use Core\Services\Auth;
use Core\Services\Settings;
use Core\Services\Plugins;
use Core\Services\Updater;
$faUrl = Settings::get('fontawesome_pro_url', '');
if ($faUrl === '') {
$faUrl = Settings::get('fontawesome_url', '');
}
$faEnabled = $faUrl !== '';
$role = Auth::role();
$isAuthed = Auth::check();
$pluginNav = Plugins::adminNav();
$userName = Auth::name();
$updateStatus = ['ok' => false, 'update_available' => false];
if ($isAuthed) {
try {
$updateStatus = Updater::getStatus(false);
} catch (Throwable $e) {
$updateStatus = ['ok' => false, 'update_available' => false];
}
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<?= csrf_meta() ?>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($pageTitle ?? 'Admin', ENT_QUOTES, 'UTF-8') ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@500;600;700&family=IBM+Plex+Mono:wght@400;600&display=swap" rel="stylesheet">
<?php if ($faEnabled): ?>
<link rel="stylesheet" href="<?= htmlspecialchars($faUrl, ENT_QUOTES, 'UTF-8') ?>">
<?php endif; ?>
<style>
:root {
color-scheme: dark;
--bg: #0b0b0c;
--panel: #1b1c1f;
--panel-2: #232427;
--text: #f3f4f8;
--muted: #a2a7b3;
--accent: #22a7ff;
--accent-2: #22f2a5;
--stroke: rgba(255,255,255,0.1);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: 'Syne', sans-serif;
background: var(--bg);
color: var(--text);
}
a { color: inherit; text-decoration: none; }
.shell { max-width: 1200px; margin: 0 auto; padding: 0 24px; }
.admin-wrapper {
max-width: 1280px;
margin: 24px auto;
padding: 0 18px;
}
.admin-shell {
display: grid;
grid-template-columns: 240px 1fr;
min-height: 100vh;
background: rgba(14,14,16,0.98);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 24px;
overflow: hidden;
box-shadow: 0 30px 70px rgba(0,0,0,0.45);
}
.auth-shell {
display: grid;
grid-template-columns: 1fr;
min-height: 70vh;
align-items: center;
}
.sidebar {
border-right: 1px solid var(--stroke);
background: rgba(10,10,12,0.98);
padding: 24px 18px;
display: flex;
flex-direction: column;
gap: 18px;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.sidebar-user {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 6px 16px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.sidebar-user .icon {
width: 40px;
height: 40px;
border-radius: 12px;
background: rgba(255,255,255,0.06);
display: grid;
place-items: center;
font-size: 18px;
color: var(--accent-2);
}
.sidebar-user .hello {
font-size: 12px;
color: rgba(255,255,255,0.55);
text-transform: uppercase;
letter-spacing: 0.2em;
}
.sidebar-user .name {
font-size: 14px;
font-weight: 600;
}
.sidebar-section {
display: grid;
gap: 8px;
}
.sidebar-title {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.3em;
color: rgba(255,255,255,0.4);
margin-top: 10px;
}
.sidebar a {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
background: rgba(255,255,255,0.03);
border: 1px solid var(--stroke);
color: var(--muted);
}
.sidebar a.active,
.sidebar a:hover {
color: var(--text);
background: rgba(255,255,255,0.08);
}
.brand {
display: flex;
align-items: center;
gap: 12px;
font-weight: 600;
font-size: 18px;
}
.brand-badge {
width: 48px;
height: 48px;
border-radius: 14px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
display: grid;
place-items: center;
color: #071016;
font-weight: 700;
}
.badge {
font-family: 'IBM Plex Mono', monospace;
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.3em;
color: rgba(255,255,255,0.5);
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-radius: 999px;
background: var(--accent);
color: #041018;
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.25em;
border: none;
cursor: pointer;
}
.btn.outline {
background: transparent;
color: var(--text);
border: 1px solid rgba(255,255,255,0.18);
}
.btn.small {
padding: 4px 10px;
font-size: 10px;
letter-spacing: 0.18em;
border-radius: 999px;
line-height: 1.4;
min-height: 26px;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
display: none;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 50;
}
.modal {
background: #17181d;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 18px;
padding: 16px;
width: min(900px, 92vw);
max-height: 80vh;
overflow: hidden;
display: grid;
gap: 12px;
}
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 10px;
overflow: auto;
padding-right: 4px;
}
.media-thumb {
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(0,0,0,0.35);
padding: 8px;
display: grid;
gap: 6px;
cursor: pointer;
}
.media-thumb img {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 8px;
}
.media-meta {
font-size: 11px;
color: var(--muted);
word-break: break-word;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.modal-title {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.2em;
}
.modal-actions {
display: flex;
gap: 10px;
align-items: center;
flex: 1;
justify-content: flex-end;
}
.media-empty {
color: var(--muted);
font-size: 13px;
padding: 12px;
}
.input {
width: 100%;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--stroke);
background: rgba(12,12,14,0.9);
color: var(--text);
}
.label {
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.2em;
}
.admin-header {
border-bottom: 1px solid var(--stroke);
background: rgba(10,10,12,0.9);
backdrop-filter: blur(6px);
}
.admin-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 18px 0;
}
.admin-actions {
display: flex;
gap: 10px;
align-items: center;
}
.admin-content {
padding: 28px 0 64px;
}
.admin-main {
display: flex;
flex-direction: column;
min-width: 0;
}
.admin-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 28px;
width: 100%;
}
.admin-card {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 20px;
padding: 20px;
box-shadow: 0 18px 40px rgba(0,0,0,0.35);
}
.pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.2em;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(255,255,255,0.04);
color: var(--muted);
}
@media (max-width: 1200px) {
.admin-container { max-width: 1040px; }
}
@media (max-width: 1024px) {
.admin-shell { grid-template-columns: 210px 1fr; }
.admin-container { padding: 0 20px; }
}
@media (max-width: 880px) {
.admin-wrapper { margin: 16px auto; }
.admin-shell { grid-template-columns: 1fr; min-height: auto; }
.sidebar {
position: relative;
height: auto;
border-right: none;
border-bottom: 1px solid var(--stroke);
}
}
</style>
</head>
<body>
<div class="admin-wrapper">
<div class="<?= $isAuthed ? 'admin-shell' : 'auth-shell' ?>">
<?php if ($isAuthed): ?>
<aside class="sidebar">
<div class="sidebar-user">
<div class="icon">
<?php if ($faEnabled): ?>
<i class="fa-solid fa-head-side-headphones"></i>
<?php else: ?>
AC
<?php endif; ?>
</div>
<div>
<div class="hello">Hello</div>
<div class="name"><?= htmlspecialchars($userName, ENT_QUOTES, 'UTF-8') ?></div>
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-title">Core</div>
<a href="/admin" class="<?= ($pageTitle ?? '') === 'Admin' ? 'active' : '' ?>">
<?php if ($faEnabled): ?><i class="fa-solid fa-house"></i><?php endif; ?>
Dashboard
</a>
<?php if (in_array($role, ['admin', 'manager', 'editor'], true)): ?>
<a href="/admin/pages" class="<?= ($pageTitle ?? '') === 'Pages' ? 'active' : '' ?>">
<?php if ($faEnabled): ?><i class="fa-solid fa-file-lines"></i><?php endif; ?>
Pages
</a>
<?php endif; ?>
<?php if (in_array($role, ['admin', 'manager'], true)): ?>
<a href="/admin/media" class="<?= ($pageTitle ?? '') === 'Media' ? 'active' : '' ?>">
<?php if ($faEnabled): ?><i class="fa-solid fa-photo-film"></i><?php endif; ?>
Media
</a>
<?php endif; ?>
<?php if (in_array($role, ['admin', 'manager'], true)): ?>
<a href="/admin/navigation" class="<?= ($pageTitle ?? '') === 'Navigation' ? 'active' : '' ?>">
<?php if ($faEnabled): ?><i class="fa-solid fa-sitemap"></i><?php endif; ?>
Navigation
</a>
<?php endif; ?>
</div>
<?php if (in_array($role, ['admin', 'manager'], true)): ?>
<div class="sidebar-section">
<div class="sidebar-title">Content</div>
<a href="/admin/posts" class="<?= ($pageTitle ?? '') === 'Posts' ? 'active' : '' ?>">
<?php if ($faEnabled): ?><i class="fa-solid fa-newspaper"></i><?php endif; ?>
Blog
</a>
<a href="/admin/newsletter" class="<?= ($pageTitle ?? '') === 'Newsletter' ? 'active' : '' ?>">
<?php if ($faEnabled): ?><i class="fa-solid fa-envelope"></i><?php endif; ?>
Newsletter
</a>
<a href="/admin/shortcodes" class="<?= ($pageTitle ?? '') === 'Shortcodes' ? 'active' : '' ?>">
<?php if ($faEnabled): ?><i class="fa-solid fa-code"></i><?php endif; ?>
Shortcodes
</a>
<a href="/admin/updates" class="<?= ($pageTitle ?? '') === 'Updates' ? 'active' : '' ?>">
<?php if ($faEnabled): ?><i class="fa-solid fa-download"></i><?php endif; ?>
Updates
<?php if (!empty($updateStatus['update_available'])): ?>
<span style="margin-left:auto; width:8px; height:8px; border-radius:999px; background:#22f2a5;"></span>
<?php endif; ?>
</a>
</div>
<?php endif; ?>
<?php if ($pluginNav): ?>
<div class="sidebar-section">
<div class="sidebar-title">Plugins</div>
<?php foreach ($pluginNav as $item): ?>
<?php
$roles = $item['roles'] ?? [];
if ($roles && !in_array($role, $roles, true)) {
continue;
}
$itemUrl = (string)$item['url'];
$itemLabel = (string)$item['label'];
$itemIcon = (string)$item['icon'];
$activeClass = (($pageTitle ?? '') === $itemLabel) ? 'active' : '';
?>
<a href="<?= htmlspecialchars($itemUrl, ENT_QUOTES, 'UTF-8') ?>" class="<?= $activeClass ?>">
<?php if ($faEnabled && $itemIcon): ?><i class="<?= htmlspecialchars($itemIcon, ENT_QUOTES, 'UTF-8') ?>"></i><?php endif; ?>
<?= htmlspecialchars($itemLabel, ENT_QUOTES, 'UTF-8') ?>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($role === 'admin'): ?>
<div class="sidebar-section">
<div class="sidebar-title">Admin</div>
<a href="/admin/accounts" class="<?= ($pageTitle ?? '') === 'Accounts' ? 'active' : '' ?>">
<?php if ($faEnabled): ?><i class="fa-solid fa-user-shield"></i><?php endif; ?>
Accounts
</a>
<a href="/admin/api" class="<?= ($pageTitle ?? '') === 'API' ? 'active' : '' ?>">
<?php if ($faEnabled): ?><i class="fa-solid fa-plug-circle-bolt"></i><?php endif; ?>
API
</a>
<a href="/admin/plugins" class="<?= ($pageTitle ?? '') === 'Plugins' ? 'active' : '' ?>">
<?php if ($faEnabled): ?><i class="fa-solid fa-plug"></i><?php endif; ?>
Plugins
</a>
</div>
<?php endif; ?>
</aside>
<?php endif; ?>
<div class="admin-main">
<?php if ($isAuthed): ?>
<header class="admin-header">
<div class="admin-container">
<div class="admin-top">
<div class="brand">
<div class="brand-badge">AC</div>
<div>
<div>AudioCore Admin</div>
<div class="badge">V1.5.1</div>
</div>
</div>
<div class="admin-actions">
<a href="/admin/updates" class="btn outline" style="<?= !empty($updateStatus['update_available']) ? 'border-color: rgba(34,242,165,.6); color:#9ff8d8;' : '' ?>">
<?php if ($faEnabled): ?><i class="fa-solid fa-download"></i><?php endif; ?>
<?= !empty($updateStatus['update_available']) ? 'Update Available' : 'Updates' ?>
</a>
<a href="/admin/settings" class="btn outline">
<?php if ($faEnabled): ?><i class="fa-solid fa-gear"></i><?php endif; ?>
Settings
</a>
<a href="/admin/logout" class="btn outline">
<?php if ($faEnabled): ?><i class="fa-solid fa-right-from-bracket"></i><?php endif; ?>
Logout
</a>
</div>
</div>
</div>
</header>
<?php endif; ?>
<main class="admin-content">
<div class="admin-container">
<?= $content ?? '' ?>
</div>
</main>
</div>
</div>
</div>
<div id="mediaPickerBackdrop" class="modal-backdrop" aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true" aria-label="Media picker">
<div class="modal-header">
<div class="modal-title">Media Picker</div>
<div class="modal-actions">
<input id="mediaPickerSearch" class="input" placeholder="Search media..." style="max-width: 320px;">
<button type="button" class="btn outline small" id="mediaPickerClose">Close</button>
</div>
</div>
<div id="mediaPickerGrid" class="media-grid"></div>
<div id="mediaPickerEmpty" class="media-empty" style="display:none;">No images available.</div>
</div>
</div>
<script>
(function () {
const backdrop = document.getElementById('mediaPickerBackdrop');
const grid = document.getElementById('mediaPickerGrid');
const empty = document.getElementById('mediaPickerEmpty');
const search = document.getElementById('mediaPickerSearch');
const closeBtn = document.getElementById('mediaPickerClose');
if (!backdrop || !grid || !search || !closeBtn) {
return;
}
let items = [];
let activeTarget = null;
function insertAtCursor(textarea, text) {
const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || 0;
const value = textarea.value || '';
textarea.value = value.slice(0, start) + text + value.slice(end);
const next = start + text.length;
textarea.setSelectionRange(next, next);
textarea.focus();
}
function renderGrid(filterText) {
const q = (filterText || '').toLowerCase();
const filtered = items.filter(item => {
const name = String(item.file_name || '').toLowerCase();
const isImage = String(item.file_type || '').startsWith('image/');
return isImage && (!q || name.includes(q));
});
grid.innerHTML = '';
if (!filtered.length) {
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
filtered.forEach(item => {
const card = document.createElement('button');
card.type = 'button';
card.className = 'media-thumb';
card.innerHTML = `
<img src="${item.file_url}" alt="">
<div class="media-meta">${item.file_name || ''}</div>
`;
card.addEventListener('click', function () {
const target = activeTarget ? document.getElementById(activeTarget) : null;
if (!target) {
return;
}
if (activeMode === 'url') {
insertAtCursor(target, item.file_url);
} else {
insertAtCursor(target, '<img src="' + item.file_url + '" alt="">');
}
closePicker();
});
grid.appendChild(card);
});
}
let activeMode = 'html';
function openPicker(targetId, mode) {
activeTarget = targetId;
activeMode = mode || 'html';
backdrop.style.display = 'flex';
backdrop.setAttribute('aria-hidden', 'false');
search.value = '';
fetch('/admin/media/picker', { credentials: 'same-origin' })
.then(resp => resp.ok ? resp.json() : Promise.reject())
.then(data => {
items = Array.isArray(data.items) ? data.items : [];
renderGrid('');
})
.catch(() => {
items = [];
renderGrid('');
});
}
function closePicker() {
backdrop.style.display = 'none';
backdrop.setAttribute('aria-hidden', 'true');
activeTarget = null;
}
document.addEventListener('click', function (event) {
const btn = event.target.closest('[data-media-picker]');
if (!btn) {
return;
}
const targetId = btn.getAttribute('data-media-picker');
const mode = btn.getAttribute('data-media-picker-mode');
if (targetId) {
openPicker(targetId, mode);
}
});
search.addEventListener('input', function () {
renderGrid(search.value);
});
closeBtn.addEventListener('click', closePicker);
backdrop.addEventListener('click', function (event) {
if (event.target === backdrop) {
closePicker();
}
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && backdrop.style.display === 'flex') {
closePicker();
}
});
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,26 @@
<?php
$pageTitle = 'Admin Login';
ob_start();
?>
<section class="admin-card" style="max-width:520px; margin:0 auto;">
<div class="badge">Admin</div>
<h1 style="margin-top:16px; font-size:28px;">Admin Login</h1>
<p style="color: var(--muted); margin-top:6px;">Sign in to manage AudioCore.</p>
<?php if (!empty($error)): ?>
<p style="color:#ff9d9d; font-size:13px; margin-top:12px;"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>
<form method="post" action="/admin/login" style="margin-top:18px; display:grid; gap:16px;">
<div style="display:grid; gap:8px;">
<label class="label">Email</label>
<input class="input" name="email" autocomplete="username">
</div>
<div style="display:grid; gap:8px;">
<label class="label">Password</label>
<input class="input" name="password" type="password" autocomplete="current-password">
</div>
<button type="submit" class="btn">Login</button>
</form>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../modules/admin/views/layout.php';

View File

@@ -0,0 +1,339 @@
<?php
$pageTitle = 'Navigation';
$links = $links ?? [];
$pages = $pages ?? [];
$error = $error ?? '';
$saved = ($saved ?? '') === '1';
ob_start();
?>
<section class="admin-card">
<div class="badge">Navigation</div>
<h1 style="margin-top:16px; font-size:28px;">Site Navigation</h1>
<p style="color: var(--muted); margin-top:8px;">Build your main menu. Add items on the left, then drag to reorder.</p>
<?php if ($error): ?>
<div style="margin-top:16px; color: #f3b0b0; font-size:13px;"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
<?php elseif ($saved): ?>
<div style="margin-top:16px; color: var(--accent); font-size:13px;">Navigation saved.</div>
<?php endif; ?>
<style>
.nav-grid { display: grid; grid-template-columns: 320px 1fr; gap: 18px; }
.nav-list { display: grid; gap: 10px; }
.nav-item {
display: grid;
grid-template-columns: 34px 1.3fr 2fr 90px 90px 90px;
gap: 12px;
align-items: center;
padding: 10px 12px;
border-radius: 16px;
border: 1px solid var(--stroke);
background: rgba(14,14,16,0.9);
}
.nav-item.dragging { opacity: 0.6; }
.drag-handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(255,255,255,0.04);
font-size: 12px;
color: var(--muted);
cursor: grab;
user-select: none;
}
.nav-meta {
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.2em;
}
.nav-item input[readonly] { opacity: 0.7; }
@media (max-width: 980px) {
.nav-grid { grid-template-columns: 1fr; }
.nav-item { grid-template-columns: 28px 1fr; grid-auto-rows: auto; }
.nav-item > *:nth-child(n+3) { grid-column: 2 / -1; }
}
</style>
<form method="post" action="/admin/navigation" style="margin-top:20px; display:grid; gap:18px;">
<div class="nav-grid">
<aside class="admin-card" style="padding:16px;">
<div class="badge" style="opacity:0.7;">Add menu items</div>
<div style="margin-top:12px; display:grid; gap:12px;">
<label class="label">Page picker</label>
<select id="navPageSelect" class="input" style="text-transform:none;">
<option value="" data-label="" data-url="">Select page</option>
<option value="home" data-label="Home" data-url="/">Home</option>
<option value="artists" data-label="Artists" data-url="/artists">Artists</option>
<option value="releases" data-label="Releases" data-url="/releases">Releases</option>
<option value="store" data-label="Store" data-url="/store">Store</option>
<option value="contact" data-label="Contact" data-url="/contact">Contact</option>
<?php foreach ($pages as $page): ?>
<option value="page-<?= htmlspecialchars((string)($page['slug'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"
data-label="<?= htmlspecialchars((string)($page['title'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"
data-url="/<?= htmlspecialchars((string)($page['slug'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<?= htmlspecialchars((string)($page['title'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</option>
<?php endforeach; ?>
<option value="custom" data-label="" data-url="">Custom link</option>
</select>
<label style="display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<input id="navActiveInput" type="checkbox" checked>
Active
</label>
<button type="button" id="navAddButton" class="btn" style="padding:8px 14px;">Add to menu</button>
<div style="font-size:12px; color:var(--muted);">Use Custom for external links or anchors.</div>
</div>
</aside>
<section class="admin-card" style="padding:16px;">
<div class="badge" style="opacity:0.7;">Menu structure</div>
<div style="margin-top:12px; display:grid; gap:12px;">
<div class="nav-item" style="background: transparent; border: none; padding: 0;">
<div></div>
<div class="nav-meta">Label</div>
<div class="nav-meta">URL</div>
<div class="nav-meta">Order</div>
<div class="nav-meta">Active</div>
<div class="nav-meta">Delete</div>
</div>
<div id="navMenuEmpty" style="color: var(--muted); font-size:13px; display: <?= $links ? 'none' : 'block' ?>;">No navigation links yet.</div>
<div id="navMenuList" class="nav-list">
<?php foreach ($links as $link): ?>
<div class="nav-item" draggable="true">
<div class="drag-handle" title="Drag to reorder">||</div>
<input class="input" name="items[<?= (int)$link['id'] ?>][label]" value="<?= htmlspecialchars((string)($link['label'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<input class="input" name="items[<?= (int)$link['id'] ?>][url]" value="<?= htmlspecialchars((string)($link['url'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<input class="input" name="items[<?= (int)$link['id'] ?>][sort_order]" data-sort-input value="<?= htmlspecialchars((string)($link['sort_order'] ?? 0), ENT_QUOTES, 'UTF-8') ?>" readonly>
<label style="display:flex; justify-content:center;">
<input type="checkbox" name="items[<?= (int)$link['id'] ?>][is_active]" value="1" <?= ((int)($link['is_active'] ?? 0) === 1) ? 'checked' : '' ?>>
</label>
<label style="display:flex; justify-content:center;">
<input type="checkbox" name="delete_ids[]" value="<?= (int)$link['id'] ?>">
</label>
</div>
<?php endforeach; ?>
</div>
</div>
</section>
</div>
<div style="display:flex; justify-content:flex-end;">
<button type="submit" class="btn">Save navigation</button>
</div>
</form>
</section>
<div id="navModal" style="position:fixed; inset:0; background:rgba(0,0,0,0.55); display:none; align-items:center; justify-content:center; padding:24px; z-index:40;">
<div class="admin-card" style="max-width:520px; width:100%; position:relative;">
<div class="badge">Custom link</div>
<h2 style="margin-top:12px; font-size:24px;">Add custom link</h2>
<div style="display:grid; gap:14px; margin-top:16px;">
<div>
<label class="label">Label</label>
<input id="modalLabel" class="input" placeholder="Press Kit">
</div>
<div>
<label class="label">URL</label>
<input id="modalUrl" class="input" placeholder="https://example.com/press">
</div>
<label style="display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<input id="modalActive" type="checkbox" checked>
Active
</label>
<div style="display:flex; gap:12px; justify-content:flex-end;">
<button type="button" id="modalCancel" class="btn" style="background:transparent; color:var(--text); border:1px solid rgba(255,255,255,0.2);">Cancel</button>
<button type="button" id="modalAdd" class="btn">Add link</button>
</div>
</div>
</div>
</div>
<script>
(function () {
const menuList = document.getElementById('navMenuList');
const menuEmpty = document.getElementById('navMenuEmpty');
const addButton = document.getElementById('navAddButton');
const pageSelect = document.getElementById('navPageSelect');
const activeInput = document.getElementById('navActiveInput');
const modal = document.getElementById('navModal');
const modalLabel = document.getElementById('modalLabel');
const modalUrl = document.getElementById('modalUrl');
const modalActive = document.getElementById('modalActive');
const modalAdd = document.getElementById('modalAdd');
const modalCancel = document.getElementById('modalCancel');
let stagedIndex = 0;
function showModal() {
modal.style.display = 'flex';
modalLabel.value = '';
modalUrl.value = '';
modalActive.checked = true;
modalLabel.focus();
}
function hideModal() {
modal.style.display = 'none';
}
function updateOrder() {
const items = menuList.querySelectorAll('.nav-item');
items.forEach((item, index) => {
const sortInput = item.querySelector('[data-sort-input]');
if (sortInput) {
sortInput.value = String(index + 1);
}
});
menuEmpty.style.display = items.length ? 'none' : 'block';
}
function addStagedLink(label, url, isActive) {
if (!label || !url) {
return;
}
stagedIndex += 1;
const row = document.createElement('div');
row.className = 'nav-item';
row.setAttribute('draggable', 'true');
const handle = document.createElement('div');
handle.className = 'drag-handle';
handle.textContent = '||';
handle.title = 'Drag to reorder';
const labelInput = document.createElement('input');
labelInput.className = 'input';
labelInput.name = `new[${stagedIndex}][label]`;
labelInput.value = label;
const urlInput = document.createElement('input');
urlInput.className = 'input';
urlInput.name = `new[${stagedIndex}][url]`;
urlInput.value = url;
const orderInputEl = document.createElement('input');
orderInputEl.className = 'input';
orderInputEl.name = `new[${stagedIndex}][sort_order]`;
orderInputEl.value = '0';
orderInputEl.setAttribute('data-sort-input', '');
orderInputEl.readOnly = true;
const activeLabel = document.createElement('label');
activeLabel.style.display = 'flex';
activeLabel.style.justifyContent = 'center';
const activeCheckbox = document.createElement('input');
activeCheckbox.type = 'checkbox';
activeCheckbox.name = `new[${stagedIndex}][is_active]`;
activeCheckbox.value = '1';
activeCheckbox.checked = !!isActive;
activeLabel.appendChild(activeCheckbox);
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'btn';
removeButton.textContent = 'Remove';
removeButton.style.padding = '6px 12px';
removeButton.style.background = 'transparent';
removeButton.style.color = 'var(--text)';
removeButton.style.border = '1px solid rgba(255,255,255,0.2)';
row.appendChild(handle);
row.appendChild(labelInput);
row.appendChild(urlInput);
row.appendChild(orderInputEl);
row.appendChild(activeLabel);
row.appendChild(removeButton);
removeButton.addEventListener('click', () => {
row.remove();
updateOrder();
});
menuList.appendChild(row);
enableDrag(row);
updateOrder();
}
addButton.addEventListener('click', () => {
const selected = pageSelect.options[pageSelect.selectedIndex];
if (!selected || !selected.value) {
return;
}
if (selected.value === 'custom') {
showModal();
return;
}
const label = selected.getAttribute('data-label') || '';
const url = selected.getAttribute('data-url') || '';
const isActive = activeInput.checked;
addStagedLink(label, url, isActive);
pageSelect.value = '';
activeInput.checked = true;
});
modalAdd.addEventListener('click', () => {
const label = modalLabel.value.trim();
const url = modalUrl.value.trim();
const isActive = modalActive.checked;
addStagedLink(label, url, isActive);
hideModal();
});
modalCancel.addEventListener('click', hideModal);
modal.addEventListener('click', (event) => {
if (event.target === modal) {
hideModal();
}
});
function getDragAfterElement(container, y) {
const elements = [...container.querySelectorAll('.nav-item:not(.dragging)')];
return elements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset, element: child };
}
return closest;
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
function enableDrag(item) {
item.addEventListener('dragstart', () => {
item.classList.add('dragging');
});
item.addEventListener('dragend', () => {
item.classList.remove('dragging');
updateOrder();
});
}
menuList.addEventListener('dragover', (event) => {
event.preventDefault();
const dragging = menuList.querySelector('.dragging');
if (!dragging) {
return;
}
const afterElement = getDragAfterElement(menuList, event.clientY);
if (afterElement == null) {
menuList.appendChild(dragging);
} else {
menuList.insertBefore(dragging, afterElement);
}
});
menuList.querySelectorAll('.nav-item').forEach(enableDrag);
updateOrder();
})();
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/layout.php';

View File

@@ -0,0 +1,705 @@
<?php
$pageTitle = 'Settings';
ob_start();
?>
<section class="admin-card settings-shell">
<div class="badge">Settings</div>
<h1 style="margin-top:16px; font-size:28px;">Site Settings</h1>
<p style="color: var(--muted); margin-top:8px;">Configure branding, maintenance, icons, and integrations.</p>
<?php if (!empty($status_message ?? '')): ?>
<div class="settings-status <?= (($status ?? '') === 'ok') ? 'is-ok' : 'is-error' ?>">
<?= htmlspecialchars((string)$status_message, ENT_QUOTES, 'UTF-8') ?>
</div>
<?php endif; ?>
<form method="post" action="/admin/settings" enctype="multipart/form-data" style="margin-top:20px; display:grid; gap:16px;">
<div class="settings-tabs" role="tablist" aria-label="Settings sections">
<button type="button" class="settings-tab is-active" data-tab="branding" role="tab" aria-selected="true">Branding</button>
<button type="button" class="settings-tab" data-tab="footer" role="tab" aria-selected="false">Footer</button>
<button type="button" class="settings-tab" data-tab="maintenance" role="tab" aria-selected="false">Maintenance</button>
<button type="button" class="settings-tab" data-tab="icons" role="tab" aria-selected="false">Icons</button>
<button type="button" class="settings-tab" data-tab="smtp" role="tab" aria-selected="false">SMTP</button>
<button type="button" class="settings-tab" data-tab="mailchimp" role="tab" aria-selected="false">Mailchimp</button>
<button type="button" class="settings-tab" data-tab="seo" role="tab" aria-selected="false">SEO</button>
<button type="button" class="settings-tab" data-tab="custom_css" role="tab" aria-selected="false">Custom CSS</button>
<button type="button" class="settings-tab" data-tab="redirects" role="tab" aria-selected="false">Redirects</button>
<button type="button" class="settings-tab" data-tab="permissions" role="tab" aria-selected="false">Permissions</button>
<button type="button" class="settings-tab" data-tab="audit" role="tab" aria-selected="false">Audit Log</button>
</div>
<div class="settings-panel is-active" data-panel="branding" role="tabpanel">
<div class="admin-card" style="padding:16px;">
<div class="badge" style="opacity:0.7;">Branding</div>
<div style="margin-top:12px; display:grid; gap:12px;">
<label class="label">Header Title</label>
<input class="input" name="site_header_title" value="<?= htmlspecialchars($site_header_title ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="AudioCore V1.5.1">
<label class="label">Header Tagline</label>
<input class="input" name="site_header_tagline" value="<?= htmlspecialchars($site_header_tagline ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="Core CMS for DJs & Producers">
<label class="label">Header Badge Text (right side)</label>
<input class="input" name="site_header_badge_text" value="<?= htmlspecialchars($site_header_badge_text ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="Independent catalog">
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
<div>
<label class="label">Header Left Mode</label>
<select class="input" name="site_header_brand_mode">
<option value="default" <?= (($site_header_brand_mode ?? 'default') === 'default') ? 'selected' : '' ?>>Text + mark</option>
<option value="logo_only" <?= (($site_header_brand_mode ?? '') === 'logo_only') ? 'selected' : '' ?>>Logo only</option>
</select>
</div>
<div>
<label class="label">Mark Content</label>
<select class="input" name="site_header_mark_mode">
<option value="text" <?= (($site_header_mark_mode ?? 'text') === 'text') ? 'selected' : '' ?>>Text</option>
<option value="icon" <?= (($site_header_mark_mode ?? '') === 'icon') ? 'selected' : '' ?>>Font Awesome icon</option>
<option value="logo" <?= (($site_header_mark_mode ?? '') === 'logo') ? 'selected' : '' ?>>Logo in mark</option>
</select>
</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
<div>
<label class="label">Mark Text</label>
<input class="input" name="site_header_mark_text" value="<?= htmlspecialchars($site_header_mark_text ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="AC">
</div>
<div>
<label class="label">Mark Icon Class</label>
<input class="input" name="site_header_mark_icon" value="<?= htmlspecialchars($site_header_mark_icon ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="fa-solid fa-music">
<div style="font-size:12px; color:var(--muted); margin-top:6px;">Use class only (or paste full &lt;i ...&gt; and it will be normalized).</div>
</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
<div>
<label class="label">Mark Gradient Start</label>
<input class="input" name="site_header_mark_bg_start" value="<?= htmlspecialchars($site_header_mark_bg_start ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="#22f2a5">
</div>
<div>
<label class="label">Mark Gradient End</label>
<input class="input" name="site_header_mark_bg_end" value="<?= htmlspecialchars($site_header_mark_bg_end ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="#10252e">
</div>
</div>
<label class="label">Logo URL (for logo-only or mark logo mode)</label>
<input class="input" name="site_header_logo_url" value="<?= htmlspecialchars($site_header_logo_url ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="https://example.com/logo.png">
<div class="settings-logo-tools">
<div class="settings-logo-preview">
<?php if (!empty($site_header_logo_url ?? '')): ?>
<img src="<?= htmlspecialchars((string)$site_header_logo_url, ENT_QUOTES, 'UTF-8') ?>" alt="">
<?php else: ?>
<span>No logo set</span>
<?php endif; ?>
</div>
<div class="settings-logo-actions">
<button type="button" class="btn outline" id="openLogoMediaPicker">Use from Media</button>
<button type="submit" class="btn outline danger" name="settings_action" value="remove_logo">Remove current logo</button>
</div>
</div>
<div class="admin-card" style="padding:12px; margin-top:4px;">
<div class="label" style="margin-bottom:8px;">Upload Logo</div>
<label class="settings-upload-dropzone" for="headerLogoFile">
<input id="headerLogoFile" class="settings-file-input" type="file" name="header_logo_file" accept="image/*,.svg">
<div class="settings-upload-text">
<div style="font-size:11px; letter-spacing:0.2em; text-transform:uppercase; color:rgba(255,255,255,0.6);">Drag & Drop</div>
<div style="font-size:14px; color:var(--text);">or click to upload</div>
<div class="settings-file-name" id="headerLogoFileName">No file selected</div>
</div>
</label>
<div style="display:flex; justify-content:flex-end; margin-top:10px;">
<button type="submit" class="btn" name="settings_action" value="upload_logo">Upload logo</button>
</div>
</div>
</div>
</div>
</div>
<div class="settings-panel" data-panel="footer" role="tabpanel" hidden>
<div class="admin-card" style="padding:16px;">
<div class="badge" style="opacity:0.7;">Footer</div>
<div style="margin-top:12px; display:grid; gap:12px;">
<label class="label">Footer Text</label>
<input class="input" name="footer_text" value="<?= htmlspecialchars($footer_text ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="AudioCore V1.5.1">
<div style="font-size:12px; color:var(--muted);">Shown in the site footer.</div>
<div class="label">Footer Links</div>
<div class="admin-card" style="padding:12px;">
<div id="footerLinksList" style="display:grid; gap:8px;"></div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:10px; gap:8px; flex-wrap:wrap;">
<button type="button" class="btn outline" id="addFooterLinkRow">Add footer link</button>
<div style="font-size:12px; color:var(--muted);">Examples: Privacy, Terms, Refund Policy.</div>
</div>
</div>
<input type="hidden" name="footer_links_json" id="footerLinksJson" value="">
</div>
</div>
</div>
<div class="settings-panel" data-panel="maintenance" role="tabpanel" hidden>
<div class="admin-card" style="padding:16px;">
<div class="badge" style="opacity:0.7;">Coming Soon / Maintenance</div>
<div style="margin-top:12px; display:grid; gap:12px;">
<label style="display:inline-flex; align-items:center; gap:8px; font-size:13px;">
<input type="checkbox" name="site_maintenance_enabled" value="1" <?= (($site_maintenance_enabled ?? '0') === '1') ? 'checked' : '' ?>>
Enable maintenance mode for visitors (admins still see full site when logged in)
</label>
<label class="label">Title</label>
<input class="input" name="site_maintenance_title" value="<?= htmlspecialchars($site_maintenance_title ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="Coming Soon">
<label class="label">Message</label>
<textarea class="input" name="site_maintenance_message" rows="3" style="resize:vertical;"><?= htmlspecialchars($site_maintenance_message ?? '', ENT_QUOTES, 'UTF-8') ?></textarea>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
<div>
<label class="label">Button Label (optional)</label>
<input class="input" name="site_maintenance_button_label" value="<?= htmlspecialchars($site_maintenance_button_label ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="Admin Login">
</div>
<div>
<label class="label">Button URL (optional)</label>
<input class="input" name="site_maintenance_button_url" value="<?= htmlspecialchars($site_maintenance_button_url ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="/admin/login">
</div>
</div>
<div style="display:grid; gap:10px; padding:14px; border:1px solid rgba(255,255,255,.08); border-radius:16px; background:rgba(255,255,255,.02);">
<div class="badge" style="opacity:.7;">Visitor Access Password</div>
<div style="font-size:12px; color:var(--muted);">
Set a password to let non-admin users unlock the site during maintenance mode. Leave blank to keep the current password.
</div>
<input class="input" type="password" name="site_maintenance_access_password" placeholder="<?= (($site_maintenance_access_password_enabled ?? '0') === '1') ? 'Password already set - enter a new one to replace it' : 'Set an access password' ?>">
<label style="display:inline-flex; align-items:center; gap:8px; font-size:13px;">
<input type="checkbox" name="site_maintenance_access_password_clear" value="1">
Clear the current access password
</label>
</div>
<label class="label">Custom HTML (optional, overrides title/message layout)</label>
<textarea class="input" name="site_maintenance_html" rows="6" style="resize:vertical; font-family:'IBM Plex Mono', monospace;"><?= htmlspecialchars($site_maintenance_html ?? '', ENT_QUOTES, 'UTF-8') ?></textarea>
</div>
</div>
</div>
<div class="settings-panel" data-panel="icons" role="tabpanel" hidden>
<div class="admin-card" style="padding:16px;">
<div class="badge" style="opacity:0.7;">Icons</div>
<div style="margin-top:12px; display:grid; gap:12px;">
<label class="label">Font Awesome Pro URL</label>
<input class="input" name="fontawesome_pro_url" value="<?= htmlspecialchars($fontawesome_pro_url ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="https://kit.fontawesome.com/your-kit-id.css">
<div style="font-size:12px; color:var(--muted);">Use your Pro kit URL to enable duotone icons.</div>
<label class="label">Font Awesome URL (Fallback)</label>
<input class="input" name="fontawesome_url" value="<?= htmlspecialchars($fontawesome_url ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
<div style="font-size:12px; color:var(--muted);">Used if Pro URL is empty.</div>
</div>
</div>
</div>
<div class="settings-panel" data-panel="smtp" role="tabpanel" hidden>
<div class="admin-card" style="padding:16px;">
<div class="badge" style="opacity:0.7;">SMTP</div>
<div style="margin-top:12px; display:grid; gap:12px;">
<label class="label">SMTP Host</label>
<input class="input" name="smtp_host" value="<?= htmlspecialchars($smtp_host ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="smtp.example.com">
<label class="label">SMTP Port</label>
<input class="input" name="smtp_port" value="<?= htmlspecialchars($smtp_port ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="587">
<label class="label">SMTP User</label>
<input class="input" name="smtp_user" value="<?= htmlspecialchars($smtp_user ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="user@example.com">
<label class="label">SMTP Password</label>
<input class="input" type="password" name="smtp_pass" value="<?= htmlspecialchars($smtp_pass ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="password">
<label class="label">SMTP Encryption</label>
<input class="input" name="smtp_encryption" value="<?= htmlspecialchars($smtp_encryption ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="tls">
<label class="label">From Email</label>
<input class="input" name="smtp_from_email" value="<?= htmlspecialchars($smtp_from_email ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="no-reply@example.com">
<label class="label">From Name</label>
<input class="input" name="smtp_from_name" value="<?= htmlspecialchars($smtp_from_name ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="AudioCore">
</div>
</div>
</div>
<div class="settings-panel" data-panel="mailchimp" role="tabpanel" hidden>
<div class="admin-card" style="padding:16px;">
<div class="badge" style="opacity:0.7;">Mailchimp</div>
<div style="margin-top:12px; display:grid; gap:12px;">
<label class="label">API Key</label>
<input class="input" name="mailchimp_api_key" value="<?= htmlspecialchars($mailchimp_api_key ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="xxxx-us1">
<label class="label">List ID</label>
<input class="input" name="mailchimp_list_id" value="<?= htmlspecialchars($mailchimp_list_id ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="abcd1234">
<div style="font-size:12px; color:var(--muted);">Used for syncing subscriber signups.</div>
</div>
</div>
</div>
<div class="settings-panel" data-panel="seo" role="tabpanel" hidden>
<div class="admin-card" style="padding:16px;">
<div class="badge" style="opacity:0.7;">Global SEO</div>
<div style="margin-top:12px; display:grid; gap:12px;">
<label class="label">Title Suffix</label>
<input class="input" name="seo_title_suffix" value="<?= htmlspecialchars($seo_title_suffix ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="AudioCore V1.5.1">
<label class="label">Default Meta Description</label>
<textarea class="input" name="seo_meta_description" rows="3" style="resize:vertical;"><?= htmlspecialchars($seo_meta_description ?? '', ENT_QUOTES, 'UTF-8') ?></textarea>
<label class="label">Open Graph Image URL</label>
<input class="input" name="seo_og_image" value="<?= htmlspecialchars($seo_og_image ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="https://example.com/og-image.jpg">
<div style="display:flex; gap:20px; flex-wrap:wrap;">
<label style="display:inline-flex; align-items:center; gap:8px; font-size:13px;">
<input type="checkbox" name="seo_robots_index" value="1" <?= (($seo_robots_index ?? '1') === '1') ? 'checked' : '' ?>>
Allow indexing
</label>
<label style="display:inline-flex; align-items:center; gap:8px; font-size:13px;">
<input type="checkbox" name="seo_robots_follow" value="1" <?= (($seo_robots_follow ?? '1') === '1') ? 'checked' : '' ?>>
Allow link following
</label>
</div>
</div>
</div>
</div>
<div class="settings-panel" data-panel="custom_css" role="tabpanel" hidden>
<div class="admin-card" style="padding:16px;">
<div class="badge" style="opacity:0.7;">Custom CSS</div>
<div style="margin-top:12px; display:grid; gap:12px;">
<label class="label">Site-wide Custom CSS</label>
<textarea class="input" name="site_custom_css" rows="14" style="resize:vertical; font-family:'IBM Plex Mono', monospace;" placeholder=".my-custom-class { color: #22f2a5; }"><?= htmlspecialchars($site_custom_css ?? '', ENT_QUOTES, 'UTF-8') ?></textarea>
<div style="font-size:12px; color:var(--muted);">Applied on all frontend pages after theme styles.</div>
</div>
</div>
</div>
<div class="settings-panel" data-panel="redirects" role="tabpanel" hidden>
<div class="admin-card" style="padding:16px; display:grid; gap:12px;">
<div class="badge" style="opacity:0.7;">Redirects Manager</div>
<div style="font-size:12px; color:var(--muted);">Exact-path redirects. Example source <code>/old-page</code> → target <code>/new-page</code>.</div>
<div style="display:grid; gap:10px;">
<input class="input" name="redirect_source_path" placeholder="/old-url">
<input class="input" name="redirect_target_url" placeholder="/new-url or https://external.site/path">
<div style="display:grid; grid-template-columns: 180px 1fr auto; gap:10px; align-items:center;">
<select class="input" name="redirect_status_code">
<option value="301">301 Permanent</option>
<option value="302">302 Temporary</option>
<option value="307">307 Temporary</option>
<option value="308">308 Permanent</option>
</select>
<label style="display:inline-flex; align-items:center; gap:8px; font-size:13px;">
<input type="checkbox" name="redirect_is_active" value="1" checked>
Active
</label>
<button type="submit" class="btn" name="settings_action" value="save_redirect">Save redirect</button>
</div>
</div>
<div class="admin-card" style="padding:12px;">
<div class="badge" style="margin-bottom:8px;">Existing Redirects</div>
<?php if (empty($redirects ?? [])): ?>
<div style="color:var(--muted); font-size:13px;">No redirects configured.</div>
<?php else: ?>
<div style="display:grid; gap:8px;">
<?php foreach (($redirects ?? []) as $redirect): ?>
<div style="display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto auto auto; gap:10px; align-items:center; border:1px solid rgba(255,255,255,0.1); border-radius:10px; padding:8px 10px;">
<div style="font-family:'IBM Plex Mono', monospace; font-size:12px;"><?= htmlspecialchars((string)$redirect['source_path'], ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-family:'IBM Plex Mono', monospace; font-size:12px; color:var(--muted);"><?= htmlspecialchars((string)$redirect['target_url'], ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:12px; color:var(--muted);"><?= (int)$redirect['status_code'] ?></div>
<div style="font-size:12px; color:<?= ((int)($redirect['is_active'] ?? 0) === 1) ? '#9df6d3' : '#ffb7c2' ?>;"><?= ((int)($redirect['is_active'] ?? 0) === 1) ? 'active' : 'inactive' ?></div>
<button type="submit" class="btn outline danger" name="settings_action" value="delete_redirect" onclick="document.getElementById('redirectDeleteId').value='<?= (int)$redirect['id'] ?>';">Delete</button>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<input type="hidden" name="redirect_id" id="redirectDeleteId" value="0">
</div>
</div>
</div>
<div class="settings-panel" data-panel="permissions" role="tabpanel" hidden>
<div class="admin-card" style="padding:16px;">
<div class="badge" style="opacity:0.7;">Role Permissions Matrix</div>
<div style="margin-top:10px; font-size:12px; color:var(--muted);">Plugin/module-level restrictions by admin role.</div>
<div style="margin-top:12px; overflow:auto;">
<table style="width:100%; border-collapse:collapse; min-width:640px;">
<thead>
<tr style="text-align:left; border-bottom:1px solid rgba(255,255,255,0.12);">
<th style="padding:10px 8px; font-size:12px; letter-spacing:0.14em; text-transform:uppercase;">Permission</th>
<th style="padding:10px 8px; font-size:12px; letter-spacing:0.14em; text-transform:uppercase;">Admin</th>
<th style="padding:10px 8px; font-size:12px; letter-spacing:0.14em; text-transform:uppercase;">Manager</th>
<th style="padding:10px 8px; font-size:12px; letter-spacing:0.14em; text-transform:uppercase;">Editor</th>
</tr>
</thead>
<tbody>
<?php $currentGroup = ''; ?>
<?php foreach (($permission_definitions ?? []) as $permission): ?>
<?php
$pKey = (string)($permission['key'] ?? '');
$pLabel = (string)($permission['label'] ?? $pKey);
$pGroup = (string)($permission['group'] ?? 'Other');
$row = $permission_matrix[$pKey] ?? ['admin' => true, 'manager' => false, 'editor' => false];
?>
<?php if ($pGroup !== $currentGroup): $currentGroup = $pGroup; ?>
<tr><td colspan="4" style="padding:12px 8px 6px; color:var(--muted); font-size:11px; letter-spacing:0.22em; text-transform:uppercase;"><?= htmlspecialchars($pGroup, ENT_QUOTES, 'UTF-8') ?></td></tr>
<?php endif; ?>
<tr style="border-bottom:1px solid rgba(255,255,255,0.08);">
<td style="padding:10px 8px;"><?= htmlspecialchars($pLabel, ENT_QUOTES, 'UTF-8') ?></td>
<td style="padding:10px 8px;"><input type="checkbox" name="permissions[<?= htmlspecialchars($pKey, ENT_QUOTES, 'UTF-8') ?>][admin]" value="1" <?= !empty($row['admin']) ? 'checked' : '' ?>></td>
<td style="padding:10px 8px;"><input type="checkbox" name="permissions[<?= htmlspecialchars($pKey, ENT_QUOTES, 'UTF-8') ?>][manager]" value="1" <?= !empty($row['manager']) ? 'checked' : '' ?>></td>
<td style="padding:10px 8px;"><input type="checkbox" name="permissions[<?= htmlspecialchars($pKey, ENT_QUOTES, 'UTF-8') ?>][editor]" value="1" <?= !empty($row['editor']) ? 'checked' : '' ?>></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div style="margin-top:12px; display:flex; justify-content:flex-end;">
<button type="submit" class="btn" name="settings_action" value="save_permissions">Save permissions</button>
</div>
</div>
</div>
<div class="settings-panel" data-panel="audit" role="tabpanel" hidden>
<div class="admin-card" style="padding:16px;">
<div class="badge" style="opacity:0.7;">Audit Log</div>
<div style="margin-top:10px; max-height:460px; overflow:auto; border:1px solid rgba(255,255,255,0.12); border-radius:12px;">
<?php if (empty($audit_logs ?? [])): ?>
<div style="padding:12px; color:var(--muted); font-size:13px;">No audit events yet.</div>
<?php else: ?>
<table style="width:100%; border-collapse:collapse; min-width:820px;">
<thead>
<tr style="text-align:left; border-bottom:1px solid rgba(255,255,255,0.12);">
<th style="padding:10px 8px;">Time</th>
<th style="padding:10px 8px;">Actor</th>
<th style="padding:10px 8px;">Action</th>
<th style="padding:10px 8px;">IP</th>
<th style="padding:10px 8px;">Context</th>
</tr>
</thead>
<tbody>
<?php foreach (($audit_logs ?? []) as $log): ?>
<tr style="border-bottom:1px solid rgba(255,255,255,0.08);">
<td style="padding:9px 8px; font-family:'IBM Plex Mono', monospace; font-size:11px;"><?= htmlspecialchars((string)($log['created_at'] ?? ''), ENT_QUOTES, 'UTF-8') ?></td>
<td style="padding:9px 8px;"><?= htmlspecialchars(trim((string)($log['actor_name'] ?? 'System') . ' (' . (string)($log['actor_role'] ?? '-') . ')'), ENT_QUOTES, 'UTF-8') ?></td>
<td style="padding:9px 8px; font-family:'IBM Plex Mono', monospace; font-size:11px;"><?= htmlspecialchars((string)($log['action'] ?? ''), ENT_QUOTES, 'UTF-8') ?></td>
<td style="padding:9px 8px; font-family:'IBM Plex Mono', monospace; font-size:11px;"><?= htmlspecialchars((string)($log['ip_address'] ?? ''), ENT_QUOTES, 'UTF-8') ?></td>
<td style="padding:9px 8px; font-family:'IBM Plex Mono', monospace; font-size:11px; color:var(--muted);"><?= htmlspecialchars((string)($log['context_json'] ?? ''), ENT_QUOTES, 'UTF-8') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
</div>
<div style="display:flex; justify-content:flex-end;">
<button type="submit" class="btn">Save settings</button>
</div>
</form>
</section>
<style>
.settings-status {
margin-top: 14px;
border-radius: 12px;
padding: 10px 12px;
font-size: 13px;
border: 1px solid rgba(255,255,255,0.14);
}
.settings-status.is-ok {
border-color: rgba(34,242,165,0.35);
color: #baf8e3;
background: rgba(34,242,165,0.10);
}
.settings-status.is-error {
border-color: rgba(255,100,120,0.35);
color: #ffc9d2;
background: rgba(255,100,120,0.10);
}
.settings-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
border-bottom: 1px solid rgba(255,255,255,0.08);
padding-bottom: 12px;
}
.settings-tab {
height: 34px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.03);
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 10px;
font-family: 'IBM Plex Mono', monospace;
cursor: pointer;
}
.settings-tab.is-active {
border-color: rgba(34,242,165,0.4);
color: #89f3cc;
background: rgba(34,242,165,0.1);
}
.settings-panel {
display: none;
}
.settings-panel.is-active {
display: block;
}
.settings-upload-dropzone {
border: 1px dashed rgba(255,255,255,0.22);
border-radius: 12px;
min-height: 108px;
padding: 12px;
display: grid;
place-items: center;
background: rgba(255,255,255,0.02);
cursor: pointer;
text-align: center;
}
.settings-upload-dropzone:hover {
border-color: rgba(34,242,165,0.45);
background: rgba(34,242,165,0.06);
}
.settings-logo-tools {
display: grid;
gap: 10px;
margin-top: 8px;
}
.settings-logo-preview {
min-height: 78px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(255,255,255,0.02);
display: grid;
place-items: center;
overflow: hidden;
padding: 8px;
}
.settings-logo-preview img {
max-height: 62px;
max-width: 100%;
object-fit: contain;
display: block;
}
.settings-logo-preview span {
color: var(--muted);
font-size: 12px;
}
.settings-logo-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.settings-media-modal {
position: fixed;
inset: 0;
background: rgba(3,4,8,0.75);
z-index: 2000;
display: none;
align-items: center;
justify-content: center;
padding: 20px;
}
.settings-media-modal.is-open {
display: flex;
}
.settings-media-panel {
width: min(980px, 100%);
max-height: 80vh;
overflow: auto;
border-radius: 16px;
border: 1px solid rgba(255,255,255,0.12);
background: #12151f;
padding: 16px;
}
.settings-media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 10px;
margin-top: 12px;
}
.settings-media-item {
border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px;
overflow: hidden;
background: rgba(255,255,255,0.03);
cursor: pointer;
}
.settings-media-item img {
width: 100%;
height: 104px;
object-fit: cover;
display: block;
}
.settings-media-item div {
padding: 8px;
font-size: 11px;
color: var(--muted);
word-break: break-word;
}
.settings-file-input {
display: none;
}
.settings-upload-text {
display: grid;
gap: 6px;
}
.settings-file-name {
font-size: 12px;
color: var(--muted);
}
@media (max-width: 760px) {
.settings-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
}
.settings-tab {
width: 100%;
justify-self: stretch;
}
}
</style>
<script>
(function () {
const tabs = Array.from(document.querySelectorAll('.settings-tab'));
const panels = Array.from(document.querySelectorAll('.settings-panel'));
if (!tabs.length || !panels.length) return;
function activate(tabName) {
tabs.forEach((tab) => {
const isActive = tab.dataset.tab === tabName;
tab.classList.toggle('is-active', isActive);
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
});
panels.forEach((panel) => {
const isActive = panel.dataset.panel === tabName;
panel.classList.toggle('is-active', isActive);
panel.hidden = !isActive;
});
try { localStorage.setItem('ac_settings_tab', tabName); } catch (e) {}
}
tabs.forEach((tab) => {
tab.addEventListener('click', () => activate(tab.dataset.tab));
});
let start = 'branding';
try {
const saved = localStorage.getItem('ac_settings_tab');
if (saved && tabs.some((tab) => tab.dataset.tab === saved)) {
start = saved;
}
} catch (e) {}
activate(start);
})();
(function () {
const list = document.getElementById('footerLinksList');
const hidden = document.getElementById('footerLinksJson');
const addBtn = document.getElementById('addFooterLinkRow');
if (!list || !hidden || !addBtn) return;
const initial = <?= json_encode(is_array($footer_links ?? null) ? $footer_links : [], JSON_UNESCAPED_SLASHES) ?>;
function syncHidden() {
const rows = Array.from(list.querySelectorAll('.footer-link-row'));
const data = rows.map((row) => ({
label: (row.querySelector('input[name=\"footer_link_label[]\"]')?.value || '').trim(),
url: (row.querySelector('input[name=\"footer_link_url[]\"]')?.value || '').trim(),
})).filter((item) => item.label !== '' && item.url !== '');
hidden.value = JSON.stringify(data);
}
function createRow(item) {
const row = document.createElement('div');
row.className = 'footer-link-row';
row.style.display = 'grid';
row.style.gridTemplateColumns = '1fr 1fr auto';
row.style.gap = '8px';
row.innerHTML = '' +
'<input class=\"input\" name=\"footer_link_label[]\" placeholder=\"Label\" value=\"' + (item.label || '').replace(/\"/g, '&quot;') + '\">' +
'<input class=\"input\" name=\"footer_link_url[]\" placeholder=\"/privacy\" value=\"' + (item.url || '').replace(/\"/g, '&quot;') + '\">' +
'<button type=\"button\" class=\"btn outline danger\">Remove</button>';
row.querySelectorAll('input').forEach((inp) => inp.addEventListener('input', syncHidden));
row.querySelector('button').addEventListener('click', () => {
row.remove();
syncHidden();
});
return row;
}
if (initial.length) {
initial.forEach((item) => list.appendChild(createRow(item)));
} else {
list.appendChild(createRow({ label: '', url: '' }));
}
syncHidden();
addBtn.addEventListener('click', () => {
list.appendChild(createRow({ label: '', url: '' }));
syncHidden();
});
document.querySelector('form[action=\"/admin/settings\"]')?.addEventListener('submit', syncHidden);
})();
(function () {
const input = document.getElementById('headerLogoFile');
const label = document.getElementById('headerLogoFileName');
if (!input || !label) return;
input.addEventListener('change', function () {
const file = input.files && input.files.length ? input.files[0].name : 'No file selected';
label.textContent = file;
});
})();
(function () {
const openBtn = document.getElementById('openLogoMediaPicker');
const logoInput = document.querySelector('input[name="site_header_logo_url"]');
if (!openBtn || !logoInput) return;
const modal = document.createElement('div');
modal.className = 'settings-media-modal';
modal.innerHTML = '' +
'<div class="settings-media-panel">' +
' <div style="display:flex; justify-content:space-between; align-items:center; gap:10px;">' +
' <div class="badge">Media Library</div>' +
' <button type="button" class="btn outline" id="closeLogoMediaPicker">Close</button>' +
' </div>' +
' <div id="settingsMediaList" class="settings-media-grid"></div>' +
'</div>';
document.body.appendChild(modal);
const list = modal.querySelector('#settingsMediaList');
const closeBtn = modal.querySelector('#closeLogoMediaPicker');
function closeModal() { modal.classList.remove('is-open'); }
closeBtn.addEventListener('click', closeModal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
openBtn.addEventListener('click', async function () {
modal.classList.add('is-open');
list.innerHTML = '<div style="color:var(--muted);">Loading media...</div>';
try {
const res = await fetch('/admin/media/picker', { credentials: 'same-origin' });
const data = await res.json();
const items = Array.isArray(data.items) ? data.items : [];
if (!items.length) {
list.innerHTML = '<div style="color:var(--muted);">No media found.</div>';
return;
}
list.innerHTML = '';
items.forEach((item) => {
const url = (item.file_url || '').toString();
const type = (item.file_type || '').toString().toLowerCase();
const isImage = type.startsWith('image/');
const node = document.createElement('button');
node.type = 'button';
node.className = 'settings-media-item';
node.innerHTML = isImage
? '<img src="' + url.replace(/"/g, '&quot;') + '" alt="">' + '<div>' + (item.file_name || url) + '</div>'
: '<div style="height:104px;display:grid;place-items:center;">' + (item.file_type || 'FILE') + '</div><div>' + (item.file_name || url) + '</div>';
node.addEventListener('click', () => {
logoInput.value = url;
closeModal();
});
list.appendChild(node);
});
} catch (err) {
list.innerHTML = '<div style="color:#ffb7c2;">Failed to load media picker.</div>';
}
});
})();
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/layout.php';

View File

@@ -0,0 +1,114 @@
<?php
$pageTitle = 'Shortcodes';
$codes = is_array($codes ?? null) ? $codes : [];
$enabledCodes = array_values(array_filter($codes, static fn(array $c): bool => !empty($c['enabled'])));
$disabledCodes = array_values(array_filter($codes, static fn(array $c): bool => empty($c['enabled'])));
ob_start();
?>
<section class="admin-card">
<div class="badge">Content</div>
<div style="display:flex; align-items:flex-end; justify-content:space-between; gap:14px; margin-top:14px;">
<div>
<h1 style="margin:0; font-size:30px;">Shortcodes</h1>
<p style="margin:8px 0 0; color:var(--muted);">Use these in page HTML to render dynamic blocks from modules/plugins.</p>
</div>
<a href="/admin/pages" class="btn outline">Back to pages</a>
</div>
<div style="display:grid; gap:16px; margin-top:18px;">
<article class="admin-card" style="padding:14px; border-radius:14px;">
<div class="label" style="font-size:11px;">Active Shortcodes</div>
<?php if (!$enabledCodes): ?>
<div style="margin-top:10px; color:var(--muted); font-size:13px;">No active shortcodes found.</div>
<?php else: ?>
<div style="display:grid; gap:12px; margin-top:10px;">
<?php foreach ($enabledCodes as $code): ?>
<article class="admin-card" style="padding:14px; border-radius:12px; box-shadow:none;">
<div style="display:flex; flex-wrap:wrap; gap:8px; align-items:center; justify-content:space-between;">
<div style="font-family:'IBM Plex Mono', monospace; font-size:13px; letter-spacing:0.08em;">
<?= htmlspecialchars((string)($code['tag'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</div>
<span class="pill" style="padding:5px 10px; font-size:10px; letter-spacing:0.14em; border-color:rgba(115,255,198,0.4); color:#9ff8d8;">Enabled</span>
</div>
<div style="margin-top:8px; color:var(--muted); font-size:13px;">
<?= htmlspecialchars((string)($code['description'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</div>
<div style="margin-top:10px; display:grid; grid-template-columns:1fr auto; gap:10px; align-items:center;">
<code style="padding:9px 11px; border-radius:10px; border:1px solid rgba(255,255,255,0.12); background:rgba(255,255,255,0.03); font-family:'IBM Plex Mono', monospace; font-size:12px; overflow:auto; white-space:nowrap;">
<?= htmlspecialchars((string)($code['example'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</code>
<div style="display:flex; gap:8px; align-items:center;">
<button class="btn outline previewShortcodeBtn" type="button" data-code="<?= htmlspecialchars((string)($code['example'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">Preview</button>
<button class="btn outline" type="button" onclick="navigator.clipboard.writeText('<?= htmlspecialchars((string)($code['example'] ?? ''), ENT_QUOTES, 'UTF-8') ?>')">Copy</button>
</div>
</div>
<div style="margin-top:8px; font-size:12px; color:var(--muted);">Source: <?= htmlspecialchars((string)($code['source'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</article>
<?php if ($disabledCodes): ?>
<article class="admin-card" style="padding:14px; border-radius:14px;">
<div class="label" style="font-size:11px;">Disabled (plugin/module unavailable)</div>
<div style="display:grid; gap:10px; margin-top:10px;">
<?php foreach ($disabledCodes as $code): ?>
<div style="padding:10px 12px; border:1px dashed rgba(255,255,255,0.16); border-radius:10px; color:var(--muted); font-size:13px; display:flex; align-items:center; justify-content:space-between; gap:10px;">
<span style="font-family:'IBM Plex Mono', monospace; font-size:12px; color:#b7bcc8;"><?= htmlspecialchars((string)($code['tag'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
<span style="font-size:12px; color:#ffbecc;"><?= htmlspecialchars((string)($code['source'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
</div>
<?php endforeach; ?>
</div>
</article>
<?php endif; ?>
</div>
</section>
<div id="shortcodePreviewModal" style="position:fixed;inset:0;background:rgba(2,3,8,0.72);display:none;align-items:center;justify-content:center;z-index:4000;padding:18px;">
<div style="width:min(1100px,100%);height:min(760px,85vh);border-radius:16px;border:1px solid rgba(255,255,255,0.14);background:#11141c;display:grid;grid-template-rows:auto 1fr;overflow:hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 12px;border-bottom:1px solid rgba(255,255,255,0.12);">
<div class="badge">Shortcode Preview</div>
<div style="display:flex;gap:8px;align-items:center;">
<a href="#" id="shortcodePreviewPopout" target="_blank" class="btn outline">Popout</a>
<button type="button" id="shortcodePreviewClose" class="btn outline">Close</button>
</div>
</div>
<iframe id="shortcodePreviewFrame" src="about:blank" style="width:100%;height:100%;border:0;background:#0d1016;"></iframe>
</div>
</div>
<script>
(function () {
const modal = document.getElementById('shortcodePreviewModal');
const frame = document.getElementById('shortcodePreviewFrame');
const popout = document.getElementById('shortcodePreviewPopout');
const closeBtn = document.getElementById('shortcodePreviewClose');
const buttons = Array.from(document.querySelectorAll('.previewShortcodeBtn'));
if (!modal || !frame || !popout || !closeBtn || !buttons.length) return;
function closeModal() {
modal.style.display = 'none';
frame.src = 'about:blank';
popout.href = '#';
}
closeBtn.addEventListener('click', closeModal);
modal.addEventListener('click', function (e) {
if (e.target === modal) closeModal();
});
buttons.forEach((btn) => {
btn.addEventListener('click', function () {
const code = btn.getAttribute('data-code') || '';
const url = '/admin/shortcodes/preview?code=' + encodeURIComponent(code);
frame.src = url;
popout.href = url;
modal.style.display = 'flex';
});
});
})();
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/layout.php';

View File

@@ -0,0 +1,90 @@
<?php
$pageTitle = 'Updates';
$status = is_array($status ?? null) ? $status : [];
$channel = (string)($channel ?? 'stable');
$message = (string)($message ?? '');
$messageType = (string)($message_type ?? '');
ob_start();
?>
<section class="admin-card" style="display:grid; gap:18px;">
<div class="badge">System</div>
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:16px; flex-wrap:wrap;">
<div>
<h1 style="margin:0; font-size:42px; line-height:1;">Updates</h1>
<p style="margin:10px 0 0; color:var(--muted);">Check for new AudioCore releases from your Gitea manifest.</p>
</div>
<form method="post" action="/admin/updates">
<input type="hidden" name="updates_action" value="check_now">
<button class="btn" type="submit">Check Now</button>
</form>
</div>
<?php if ($message !== ''): ?>
<div style="padding:12px 14px; border-radius:12px; border:1px solid <?= $messageType === 'error' ? 'rgba(255,124,124,.45)' : 'rgba(57,244,179,.45)' ?>; background:<?= $messageType === 'error' ? 'rgba(180,40,40,.18)' : 'rgba(10,90,60,.22)' ?>;">
<?= htmlspecialchars($message, ENT_QUOTES, 'UTF-8') ?>
</div>
<?php endif; ?>
<div class="admin-card" style="padding:16px; background:rgba(255,255,255,.03); box-shadow:none;">
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px;">
<div>
<div class="badge" style="margin-bottom:6px;">Installed</div>
<div style="font-size:26px; font-weight:700;"><?= htmlspecialchars((string)($status['current_version'] ?? '0.0.0'), ENT_QUOTES, 'UTF-8') ?></div>
</div>
<div>
<div class="badge" style="margin-bottom:6px;">Latest</div>
<div style="font-size:26px; font-weight:700;"><?= htmlspecialchars((string)($status['latest_version'] ?? '-'), ENT_QUOTES, 'UTF-8') ?></div>
</div>
<div>
<div class="badge" style="margin-bottom:6px;">Status</div>
<?php if (!empty($status['ok']) && !empty($status['update_available'])): ?>
<div style="font-size:20px; font-weight:700; color:#9ff8d8;">Update available</div>
<?php elseif (!empty($status['ok'])): ?>
<div style="font-size:20px; font-weight:700; color:#9ff8d8;">Up to date</div>
<?php else: ?>
<div style="font-size:20px; font-weight:700; color:#ffb7b7;">Check failed</div>
<?php endif; ?>
</div>
<div>
<div class="badge" style="margin-bottom:6px;">Channel</div>
<div style="font-size:18px; font-weight:700; text-transform:uppercase;"><?= htmlspecialchars((string)($status['channel'] ?? 'stable'), ENT_QUOTES, 'UTF-8') ?></div>
</div>
</div>
<?php if (!empty($status['error'])): ?>
<div style="margin-top:12px; color:#ffb7b7;"><?= htmlspecialchars((string)$status['error'], ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<div style="margin-top:10px; color:var(--muted); font-size:13px;">
Last checked: <?= htmlspecialchars((string)($status['checked_at'] ?? 'never'), ENT_QUOTES, 'UTF-8') ?>
</div>
<?php if (!empty($status['changelog_url'])): ?>
<div style="margin-top:8px;">
<a href="<?= htmlspecialchars((string)$status['changelog_url'], ENT_QUOTES, 'UTF-8') ?>" target="_blank" rel="noopener" style="color:#9ff8d8;">View changelog</a>
</div>
<?php endif; ?>
</div>
<form method="post" action="/admin/updates" class="admin-card" style="padding:16px; background:rgba(255,255,255,.03); box-shadow:none; display:grid; gap:12px;">
<input type="hidden" name="updates_action" value="save_config">
<div class="badge">Update Source</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
<label style="display:grid; gap:6px;">
<span class="label">Channel</span>
<select class="input" name="update_channel">
<option value="stable" <?= $channel === 'stable' ? 'selected' : '' ?>>Stable</option>
<option value="beta" <?= $channel === 'beta' ? 'selected' : '' ?>>Beta</option>
</select>
</label>
<label style="display:grid; gap:6px;">
<span class="label">Manifest Source</span>
<input class="input" type="text" value="<?= htmlspecialchars((string)($status['manifest_url'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" readonly>
</label>
</div>
<div style="display:flex; justify-content:flex-end;">
<button class="btn" type="submit">Save Update Settings</button>
</div>
</form>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/layout.php';

View File

@@ -0,0 +1,760 @@
<?php
declare(strict_types=1);
namespace Modules\Api;
use Core\Http\Response;
use Core\Services\ApiLayer;
use Core\Services\Auth;
use Core\Services\Database;
use Core\Views\View;
use PDO;
use Throwable;
class ApiController
{
private View $view;
public function __construct()
{
$this->view = new View(__DIR__ . '/views');
}
public function adminIndex(): Response
{
if (!Auth::check()) {
return new Response('', 302, ['Location' => '/admin/login']);
}
if (!Auth::hasRole(['admin'])) {
return new Response('', 302, ['Location' => '/admin']);
}
$db = Database::get();
$clients = [];
$createdKey = (string)($_SESSION['ac_api_created_key'] ?? '');
unset($_SESSION['ac_api_created_key']);
if ($db instanceof PDO) {
ApiLayer::ensureSchema($db);
try {
$stmt = $db->query("
SELECT id, name, api_key_prefix, webhook_url, is_active, last_used_at, last_used_ip, created_at
FROM ac_api_clients
ORDER BY created_at DESC, id DESC
");
$clients = $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : [];
} catch (Throwable $e) {
$clients = [];
}
}
return new Response($this->view->render('admin/index.php', [
'title' => 'API',
'clients' => $clients,
'created_key' => $createdKey,
'status' => trim((string)($_GET['status'] ?? '')),
'message' => trim((string)($_GET['message'] ?? '')),
'base_url' => $this->baseUrl(),
]));
}
public function adminCreateClient(): Response
{
if ($guard = $this->guardAdmin()) {
return $guard;
}
$name = trim((string)($_POST['name'] ?? ''));
$webhookUrl = trim((string)($_POST['webhook_url'] ?? ''));
if ($name === '') {
return $this->adminRedirect('error', 'Client name is required.');
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->adminRedirect('error', 'Database unavailable.');
}
try {
$plainKey = ApiLayer::issueClient($db, $name, $webhookUrl);
$_SESSION['ac_api_created_key'] = $plainKey;
return $this->adminRedirect('ok', 'API client created. Copy the key now.');
} catch (Throwable $e) {
return $this->adminRedirect('error', 'Unable to create API client.');
}
}
public function adminToggleClient(): Response
{
if ($guard = $this->guardAdmin()) {
return $guard;
}
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) {
return $this->adminRedirect('error', 'Invalid client.');
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->adminRedirect('error', 'Database unavailable.');
}
try {
$stmt = $db->prepare("
UPDATE ac_api_clients
SET is_active = CASE WHEN is_active = 1 THEN 0 ELSE 1 END
WHERE id = :id
");
$stmt->execute([':id' => $id]);
return $this->adminRedirect('ok', 'API client updated.');
} catch (Throwable $e) {
return $this->adminRedirect('error', 'Unable to update API client.');
}
}
public function adminDeleteClient(): Response
{
if ($guard = $this->guardAdmin()) {
return $guard;
}
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) {
return $this->adminRedirect('error', 'Invalid client.');
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->adminRedirect('error', 'Database unavailable.');
}
try {
$stmt = $db->prepare("DELETE FROM ac_api_clients WHERE id = :id LIMIT 1");
$stmt->execute([':id' => $id]);
return $this->adminRedirect('ok', 'API client deleted.');
} catch (Throwable $e) {
return $this->adminRedirect('error', 'Unable to delete API client.');
}
}
public function authVerify(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
return $this->json([
'ok' => true,
'client' => [
'id' => (int)($client['id'] ?? 0),
'name' => (string)($client['name'] ?? ''),
'prefix' => (string)($client['api_key_prefix'] ?? ''),
],
]);
}
public function artistSales(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$artistId = (int)($_GET['artist_id'] ?? 0);
if ($artistId <= 0) {
return $this->json(['ok' => false, 'error' => 'artist_id is required.'], 422);
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
ApiLayer::ensureSchema($db);
try {
$stmt = $db->prepare("
SELECT
a.id,
a.order_id,
o.order_no,
a.order_item_id,
a.source_item_type,
a.source_item_id,
a.release_id,
a.track_id,
a.title_snapshot,
a.qty,
a.gross_amount,
a.currency_snapshot,
a.created_at,
o.email AS customer_email,
o.payment_provider,
o.payment_ref
FROM ac_store_order_item_allocations a
JOIN ac_store_orders o ON o.id = a.order_id
WHERE a.artist_id = :artist_id
AND o.status = 'paid'
ORDER BY a.id DESC
LIMIT 500
");
$stmt->execute([':artist_id' => $artistId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
return $this->json([
'ok' => true,
'artist_id' => $artistId,
'count' => count($rows),
'rows' => $rows,
]);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to load artist sales.'], 500);
}
}
public function salesSince(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$artistId = (int)($_GET['artist_id'] ?? 0);
$afterId = (int)($_GET['after_id'] ?? 0);
$since = trim((string)($_GET['since'] ?? ''));
if ($artistId <= 0) {
return $this->json(['ok' => false, 'error' => 'artist_id is required.'], 422);
}
if ($afterId <= 0 && $since === '') {
return $this->json(['ok' => false, 'error' => 'after_id or since is required.'], 422);
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
ApiLayer::ensureSchema($db);
try {
$where = [
'a.artist_id = :artist_id',
"o.status = 'paid'",
];
$params = [':artist_id' => $artistId];
if ($afterId > 0) {
$where[] = 'a.id > :after_id';
$params[':after_id'] = $afterId;
}
if ($since !== '') {
$where[] = 'a.created_at >= :since';
$params[':since'] = $since;
}
$stmt = $db->prepare("
SELECT
a.id,
a.order_id,
o.order_no,
a.order_item_id,
a.source_item_type,
a.source_item_id,
a.release_id,
a.track_id,
a.title_snapshot,
a.qty,
a.gross_amount,
a.currency_snapshot,
a.created_at
FROM ac_store_order_item_allocations a
JOIN ac_store_orders o ON o.id = a.order_id
WHERE " . implode(' AND ', $where) . "
ORDER BY a.id ASC
LIMIT 500
");
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
return $this->json([
'ok' => true,
'artist_id' => $artistId,
'count' => count($rows),
'rows' => $rows,
]);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to load incremental sales.'], 500);
}
}
public function artistTracks(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$artistId = (int)($_GET['artist_id'] ?? 0);
if ($artistId <= 0) {
return $this->json(['ok' => false, 'error' => 'artist_id is required.'], 422);
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
ApiLayer::ensureSchema($db);
try {
$stmt = $db->prepare("
SELECT
t.id,
t.release_id,
r.title AS release_title,
r.slug AS release_slug,
r.catalog_no,
r.release_date,
t.track_no,
t.title,
t.mix_name,
t.duration,
t.bpm,
t.key_signature,
t.sample_url,
sp.is_enabled AS store_enabled,
sp.track_price,
sp.currency
FROM ac_release_tracks t
JOIN ac_releases r ON r.id = t.release_id
LEFT JOIN ac_store_track_products sp ON sp.release_track_id = t.id
WHERE r.artist_id = :artist_id
ORDER BY r.release_date DESC, r.id DESC, t.track_no ASC, t.id ASC
");
$stmt->execute([':artist_id' => $artistId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
return $this->json([
'ok' => true,
'artist_id' => $artistId,
'count' => count($rows),
'tracks' => $rows,
]);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to load artist tracks.'], 500);
}
}
public function submitRelease(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
ApiLayer::ensureSchema($db);
$data = $this->requestData();
$title = trim((string)($data['title'] ?? ''));
if ($title === '') {
return $this->json(['ok' => false, 'error' => 'title is required.'], 422);
}
$artistId = (int)($data['artist_id'] ?? 0);
$artistName = trim((string)($data['artist_name'] ?? ''));
if ($artistId > 0) {
$artistName = $this->artistNameById($db, $artistId) ?: $artistName;
}
$slug = $this->slugify((string)($data['slug'] ?? $title));
try {
$stmt = $db->prepare("
INSERT INTO ac_releases
(title, slug, artist_id, artist_name, description, credits, catalog_no, release_date, cover_url, sample_url, is_published)
VALUES (:title, :slug, :artist_id, :artist_name, :description, :credits, :catalog_no, :release_date, :cover_url, :sample_url, :is_published)
");
$stmt->execute([
':title' => $title,
':slug' => $slug,
':artist_id' => $artistId > 0 ? $artistId : null,
':artist_name' => $artistName !== '' ? $artistName : null,
':description' => $this->nullableText($data['description'] ?? null),
':credits' => $this->nullableText($data['credits'] ?? null),
':catalog_no' => $this->nullableText($data['catalog_no'] ?? null),
':release_date' => $this->nullableText($data['release_date'] ?? null),
':cover_url' => $this->nullableText($data['cover_url'] ?? null),
':sample_url' => $this->nullableText($data['sample_url'] ?? null),
':is_published' => !empty($data['is_published']) ? 1 : 0,
]);
$releaseId = (int)$db->lastInsertId();
return $this->json([
'ok' => true,
'release_id' => $releaseId,
'slug' => $slug,
], 201);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to create release.'], 500);
}
}
public function submitTracks(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
ApiLayer::ensureSchema($db);
$data = $this->requestData();
$releaseId = (int)($data['release_id'] ?? 0);
$tracks = is_array($data['tracks'] ?? null) ? $data['tracks'] : [];
if ($releaseId <= 0 || !$tracks) {
return $this->json(['ok' => false, 'error' => 'release_id and tracks are required.'], 422);
}
$created = [];
try {
foreach ($tracks as $track) {
if (!is_array($track)) {
continue;
}
$trackId = (int)($track['id'] ?? 0);
$title = trim((string)($track['title'] ?? ''));
if ($title === '') {
continue;
}
if ($trackId > 0) {
$stmt = $db->prepare("
UPDATE ac_release_tracks
SET track_no = :track_no, title = :title, mix_name = :mix_name, duration = :duration,
bpm = :bpm, key_signature = :key_signature, sample_url = :sample_url
WHERE id = :id AND release_id = :release_id
");
$stmt->execute([
':track_no' => isset($track['track_no']) ? (int)$track['track_no'] : null,
':title' => $title,
':mix_name' => $this->nullableText($track['mix_name'] ?? null),
':duration' => $this->nullableText($track['duration'] ?? null),
':bpm' => isset($track['bpm']) && $track['bpm'] !== '' ? (int)$track['bpm'] : null,
':key_signature' => $this->nullableText($track['key_signature'] ?? null),
':sample_url' => $this->nullableText($track['sample_url'] ?? null),
':id' => $trackId,
':release_id' => $releaseId,
]);
$created[] = ['id' => $trackId, 'updated' => true];
} else {
$stmt = $db->prepare("
INSERT INTO ac_release_tracks
(release_id, track_no, title, mix_name, duration, bpm, key_signature, sample_url)
VALUES (:release_id, :track_no, :title, :mix_name, :duration, :bpm, :key_signature, :sample_url)
");
$stmt->execute([
':release_id' => $releaseId,
':track_no' => isset($track['track_no']) ? (int)$track['track_no'] : null,
':title' => $title,
':mix_name' => $this->nullableText($track['mix_name'] ?? null),
':duration' => $this->nullableText($track['duration'] ?? null),
':bpm' => isset($track['bpm']) && $track['bpm'] !== '' ? (int)$track['bpm'] : null,
':key_signature' => $this->nullableText($track['key_signature'] ?? null),
':sample_url' => $this->nullableText($track['sample_url'] ?? null),
]);
$created[] = ['id' => (int)$db->lastInsertId(), 'created' => true];
}
}
return $this->json([
'ok' => true,
'release_id' => $releaseId,
'tracks' => $created,
], 201);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to submit tracks.'], 500);
}
}
public function updateRelease(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
ApiLayer::ensureSchema($db);
$data = $this->requestData();
$releaseId = (int)($data['release_id'] ?? 0);
if ($releaseId <= 0) {
return $this->json(['ok' => false, 'error' => 'release_id is required.'], 422);
}
$fields = [];
$params = [':id' => $releaseId];
$map = [
'title' => 'title',
'description' => 'description',
'credits' => 'credits',
'catalog_no' => 'catalog_no',
'release_date' => 'release_date',
'cover_url' => 'cover_url',
'sample_url' => 'sample_url',
'is_published' => 'is_published',
];
foreach ($map as $input => $column) {
if (!array_key_exists($input, $data)) {
continue;
}
$fields[] = "{$column} = :{$input}";
$params[":{$input}"] = $input === 'is_published'
? (!empty($data[$input]) ? 1 : 0)
: $this->nullableText($data[$input]);
}
if (array_key_exists('slug', $data)) {
$fields[] = "slug = :slug";
$params[':slug'] = $this->slugify((string)$data['slug']);
}
if (array_key_exists('artist_id', $data) || array_key_exists('artist_name', $data)) {
$artistId = (int)($data['artist_id'] ?? 0);
$artistName = trim((string)($data['artist_name'] ?? ''));
if ($artistId > 0) {
$artistName = $this->artistNameById($db, $artistId) ?: $artistName;
}
$fields[] = "artist_id = :artist_id";
$fields[] = "artist_name = :artist_name";
$params[':artist_id'] = $artistId > 0 ? $artistId : null;
$params[':artist_name'] = $artistName !== '' ? $artistName : null;
}
if (!$fields) {
return $this->json(['ok' => false, 'error' => 'No fields to update.'], 422);
}
try {
$stmt = $db->prepare("UPDATE ac_releases SET " . implode(', ', $fields) . " WHERE id = :id");
$stmt->execute($params);
return $this->json(['ok' => true, 'release_id' => $releaseId]);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to update release.'], 500);
}
}
public function updateTrack(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
$data = $this->requestData();
$trackId = (int)($data['track_id'] ?? 0);
if ($trackId <= 0) {
return $this->json(['ok' => false, 'error' => 'track_id is required.'], 422);
}
$fields = [];
$params = [':id' => $trackId];
$map = [
'track_no' => 'track_no',
'title' => 'title',
'mix_name' => 'mix_name',
'duration' => 'duration',
'bpm' => 'bpm',
'key_signature' => 'key_signature',
'sample_url' => 'sample_url',
];
foreach ($map as $input => $column) {
if (!array_key_exists($input, $data)) {
continue;
}
$fields[] = "{$column} = :{$input}";
if ($input === 'bpm' || $input === 'track_no') {
$params[":{$input}"] = $data[$input] !== '' ? (int)$data[$input] : null;
} else {
$params[":{$input}"] = $this->nullableText($data[$input]);
}
}
if (!$fields) {
return $this->json(['ok' => false, 'error' => 'No fields to update.'], 422);
}
try {
$stmt = $db->prepare("UPDATE ac_release_tracks SET " . implode(', ', $fields) . " WHERE id = :id");
$stmt->execute($params);
return $this->json(['ok' => true, 'track_id' => $trackId]);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to update track.'], 500);
}
}
public function orderItemData(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
ApiLayer::ensureSchema($db);
$artistId = (int)($_GET['artist_id'] ?? 0);
$orderId = (int)($_GET['order_id'] ?? 0);
$afterId = (int)($_GET['after_id'] ?? 0);
$since = trim((string)($_GET['since'] ?? ''));
try {
$where = ['1=1'];
$params = [];
if ($artistId > 0) {
$where[] = 'a.artist_id = :artist_id';
$params[':artist_id'] = $artistId;
}
if ($orderId > 0) {
$where[] = 'a.order_id = :order_id';
$params[':order_id'] = $orderId;
}
if ($afterId > 0) {
$where[] = 'a.id > :after_id';
$params[':after_id'] = $afterId;
}
if ($since !== '') {
$where[] = 'a.created_at >= :since';
$params[':since'] = $since;
}
$stmt = $db->prepare("
SELECT
a.id,
a.order_id,
o.order_no,
o.status AS order_status,
o.email AS customer_email,
a.order_item_id,
a.artist_id,
a.release_id,
a.track_id,
a.source_item_type,
a.source_item_id,
a.title_snapshot,
a.qty,
a.gross_amount,
a.currency_snapshot,
a.created_at
FROM ac_store_order_item_allocations a
JOIN ac_store_orders o ON o.id = a.order_id
WHERE " . implode(' AND ', $where) . "
ORDER BY a.id DESC
LIMIT 1000
");
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
return $this->json([
'ok' => true,
'count' => count($rows),
'rows' => $rows,
]);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to load order item data.'], 500);
}
}
private function requestData(): array
{
$contentType = strtolower(trim((string)($_SERVER['CONTENT_TYPE'] ?? '')));
if (str_contains($contentType, 'application/json')) {
$raw = file_get_contents('php://input');
$decoded = json_decode(is_string($raw) ? $raw : '', true);
return is_array($decoded) ? $decoded : [];
}
return is_array($_POST) ? $_POST : [];
}
private function requireClient(): array|Response
{
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
$client = ApiLayer::verifyRequest($db);
if (!$client) {
return $this->json(['ok' => false, 'error' => 'Invalid API key.'], 401);
}
return $client;
}
private function json(array $data, int $status = 200): Response
{
$json = json_encode($data, JSON_UNESCAPED_SLASHES);
return new Response(
is_string($json) ? $json : '{"ok":false,"error":"json_encode_failed"}',
$status,
['Content-Type' => 'application/json; charset=utf-8']
);
}
private function slugify(string $value): string
{
$value = strtolower(trim($value));
$value = preg_replace('~[^a-z0-9]+~', '-', $value) ?? $value;
$value = trim($value, '-');
return $value !== '' ? $value : 'release';
}
private function nullableText(mixed $value): ?string
{
$text = trim((string)$value);
return $text !== '' ? $text : null;
}
private function artistNameById(PDO $db, int $artistId): string
{
if ($artistId <= 0) {
return '';
}
try {
$stmt = $db->prepare("SELECT name FROM ac_artists WHERE id = :id LIMIT 1");
$stmt->execute([':id' => $artistId]);
return trim((string)($stmt->fetchColumn() ?: ''));
} catch (Throwable $e) {
return '';
}
}
private function adminRedirect(string $status, string $message): Response
{
return new Response('', 302, [
'Location' => '/admin/api?status=' . rawurlencode($status) . '&message=' . rawurlencode($message),
]);
}
private function baseUrl(): string
{
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| ((string)($_SERVER['SERVER_PORT'] ?? '') === '443');
$scheme = $https ? 'https' : 'http';
$host = trim((string)($_SERVER['HTTP_HOST'] ?? ''));
return $host !== '' ? ($scheme . '://' . $host) : '';
}
private function guardAdmin(): ?Response
{
if (!Auth::check()) {
return new Response('', 302, ['Location' => '/admin/login']);
}
if (!Auth::hasRole(['admin'])) {
return new Response('', 302, ['Location' => '/admin']);
}
return null;
}
}

26
modules/api/module.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use Core\Http\Router;
use Modules\Api\ApiController;
require_once __DIR__ . '/ApiController.php';
return function (Router $router): void {
$controller = new ApiController();
$router->get('/admin/api', [$controller, 'adminIndex']);
$router->post('/admin/api/clients/create', [$controller, 'adminCreateClient']);
$router->post('/admin/api/clients/toggle', [$controller, 'adminToggleClient']);
$router->post('/admin/api/clients/delete', [$controller, 'adminDeleteClient']);
$router->get('/api/v1/auth/verify', [$controller, 'authVerify']);
$router->get('/api/v1/sales', [$controller, 'artistSales']);
$router->get('/api/v1/sales/since', [$controller, 'salesSince']);
$router->get('/api/v1/tracks', [$controller, 'artistTracks']);
$router->get('/api/v1/order-items', [$controller, 'orderItemData']);
$router->post('/api/v1/releases', [$controller, 'submitRelease']);
$router->post('/api/v1/tracks', [$controller, 'submitTracks']);
$router->post('/api/v1/releases/update', [$controller, 'updateRelease']);
$router->post('/api/v1/tracks/update', [$controller, 'updateTrack']);
};

View File

@@ -0,0 +1,189 @@
<?php
$pageTitle = 'API';
$clients = is_array($clients ?? null) ? $clients : [];
$createdKey = (string)($created_key ?? '');
$status = trim((string)($status ?? ''));
$message = trim((string)($message ?? ''));
$baseUrl = rtrim((string)($base_url ?? ''), '/');
$endpointRows = [
[
'title' => 'Verify key',
'method' => 'GET',
'path' => $baseUrl . '/api/v1/auth/verify',
'note' => 'Quick auth check for AMS bootstrapping.',
],
[
'title' => 'Artist sales',
'method' => 'GET',
'path' => $baseUrl . '/api/v1/sales?artist_id=123',
'note' => 'Detailed paid sales rows for one artist.',
],
[
'title' => 'Sales since sync',
'method' => 'GET',
'path' => $baseUrl . '/api/v1/sales/since?artist_id=123&after_id=500',
'note' => 'Incremental sync using after_id or timestamp.',
],
[
'title' => 'Artist tracks',
'method' => 'GET',
'path' => $baseUrl . '/api/v1/tracks?artist_id=123',
'note' => 'Track list tied to one artist account.',
],
[
'title' => 'Order item detail',
'method' => 'GET',
'path' => $baseUrl . '/api/v1/order-items?artist_id=123',
'note' => 'Granular order transparency for AMS reporting.',
],
[
'title' => 'Submit release / tracks',
'method' => 'POST',
'path' => $baseUrl . '/api/v1/releases + /api/v1/tracks',
'note' => 'AMS pushes approved releases and tracks into AudioCore.',
],
];
ob_start();
?>
<section class="admin-card">
<div class="badge">Integration</div>
<div style="margin-top:14px; max-width:860px;">
<h1 style="margin:0; font-size:30px;">API</h1>
<p style="margin:8px 0 0; color:var(--muted);">AMS integration endpoints, API keys, and sales sync access live here. Keep this page operational rather than decorative: create clients, issue keys, and hand exact endpoints to the AMS.</p>
</div>
<?php if ($message !== ''): ?>
<div class="admin-card" style="margin-top:16px; padding:14px; border-color:<?= $status === 'ok' ? 'rgba(34,242,165,.25)' : 'rgba(255,120,120,.22)' ?>; color:<?= $status === 'ok' ? '#9ff8d8' : '#ffb0b0' ?>;">
<?= htmlspecialchars($message, ENT_QUOTES, 'UTF-8') ?>
</div>
<?php endif; ?>
<?php if ($createdKey !== ''): ?>
<div class="admin-card" style="margin-top:16px; padding:16px;">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:16px; flex-wrap:wrap;">
<div>
<div class="label">New API Key</div>
<div style="margin-top:8px; font-size:13px; color:#ffdfad;">Copy this now. It is only shown once.</div>
</div>
<button type="button" class="btn outline small" onclick="navigator.clipboard.writeText('<?= htmlspecialchars($createdKey, ENT_QUOTES, 'UTF-8') ?>')">Copy key</button>
</div>
<code style="display:block; margin-top:12px; padding:14px 16px; border-radius:14px; border:1px solid rgba(255,255,255,0.12); background:rgba(255,255,255,0.03); font-family:'IBM Plex Mono', monospace; font-size:13px; overflow:auto; white-space:nowrap;"><?= htmlspecialchars($createdKey, ENT_QUOTES, 'UTF-8') ?></code>
</div>
<?php endif; ?>
<div style="display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; margin-top:18px;">
<article class="admin-card" style="padding:16px;">
<div class="label">Clients</div>
<div style="margin-top:10px; font-size:28px; font-weight:700;"><?= count($clients) ?></div>
<div style="margin-top:6px; color:var(--muted); font-size:13px;">Configured external systems</div>
</article>
<article class="admin-card" style="padding:16px;">
<div class="label">Auth</div>
<div style="margin-top:10px; font-size:28px; font-weight:700;">Bearer</div>
<div style="margin-top:6px; color:var(--muted); font-size:13px;">Also accepts <code>X-API-Key</code></div>
</article>
<article class="admin-card" style="padding:16px;">
<div class="label">Webhook</div>
<div style="margin-top:10px; font-size:28px; font-weight:700;">sale.paid</div>
<div style="margin-top:6px; color:var(--muted); font-size:13px;">Outbound sale notifications for AMS sync</div>
</article>
</div>
<article class="admin-card" style="padding:18px; margin-top:16px;">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:16px; flex-wrap:wrap;">
<div>
<div class="label">Create API Client</div>
<div style="margin-top:8px; color:var(--muted); font-size:13px; max-width:680px;">Use one client per AMS install or per integration target. That keeps revocation clean and usage attribution obvious.</div>
</div>
</div>
<form method="post" action="/admin/api/clients/create" style="display:grid; gap:14px; margin-top:16px;">
<div style="display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto; gap:12px; align-items:end;">
<label style="display:grid; gap:6px;">
<span class="label">Client Name</span>
<input class="input" type="text" name="name" placeholder="AudioCore AMS">
</label>
<label style="display:grid; gap:6px;">
<span class="label">Webhook URL (optional)</span>
<input class="input" type="url" name="webhook_url" placeholder="https://ams.example.com/webhooks/audiocore">
</label>
<button class="btn" type="submit">Create Key</button>
</div>
</form>
</article>
<article class="admin-card" style="padding:18px; margin-top:16px;">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:16px; flex-wrap:wrap;">
<div>
<div class="label">Endpoint Reference</div>
<div style="margin-top:8px; color:var(--muted); font-size:13px;">Keep this as an operator reference. The layout is stacked because this panel needs readability more than compression.</div>
</div>
<div style="color:var(--muted); font-size:12px;">Use <strong>Authorization: Bearer &lt;api-key&gt;</strong> or <strong>X-API-Key</strong>.</div>
</div>
<div style="display:grid; gap:10px; margin-top:16px;">
<?php foreach ($endpointRows as $row): ?>
<div class="admin-card" style="padding:14px 16px; display:grid; grid-template-columns:140px 96px minmax(0,1fr); gap:14px; align-items:start;">
<div>
<div style="font-weight:700;"><?= htmlspecialchars($row['title'], ENT_QUOTES, 'UTF-8') ?></div>
<div style="margin-top:5px; color:var(--muted); font-size:12px; line-height:1.45;"><?= htmlspecialchars($row['note'], ENT_QUOTES, 'UTF-8') ?></div>
</div>
<div>
<span class="pill" style="padding:6px 10px; font-size:10px; letter-spacing:0.14em; border-color:rgba(115,255,198,0.25); color:#9ff8d8;"><?= htmlspecialchars($row['method'], ENT_QUOTES, 'UTF-8') ?></span>
</div>
<code style="display:block; padding:10px 12px; border-radius:10px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.1); font-size:12px; overflow:auto; white-space:nowrap;"><?= htmlspecialchars($row['path'], ENT_QUOTES, 'UTF-8') ?></code>
</div>
<?php endforeach; ?>
</div>
</article>
<article class="admin-card" style="padding:18px; margin-top:16px;">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:16px; flex-wrap:wrap;">
<div>
<div class="label">Active Clients</div>
<div style="margin-top:8px; color:var(--muted); font-size:13px;">Disable a client to cut access immediately. Delete only when you do not need audit visibility anymore.</div>
</div>
</div>
<?php if (!$clients): ?>
<div style="margin-top:14px; color:var(--muted); font-size:13px;">No API clients created yet.</div>
<?php else: ?>
<div style="display:grid; gap:10px; margin-top:14px;">
<?php foreach ($clients as $client): ?>
<div class="admin-card" style="padding:16px; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:16px; align-items:center;">
<div>
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
<strong><?= htmlspecialchars((string)($client['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?></strong>
<span class="pill" style="padding:5px 10px; font-size:10px; letter-spacing:0.14em; border-color:<?= (int)($client['is_active'] ?? 0) === 1 ? 'rgba(115,255,198,0.35)' : 'rgba(255,255,255,0.16)' ?>; color:<?= (int)($client['is_active'] ?? 0) === 1 ? '#9ff8d8' : '#a7adba' ?>;">
<?= (int)($client['is_active'] ?? 0) === 1 ? 'Active' : 'Disabled' ?>
</span>
</div>
<div style="display:grid; grid-template-columns:repeat(3,minmax(0,auto)); gap:18px; margin-top:10px; color:var(--muted); font-size:12px;">
<div><span class="label" style="display:block; margin-bottom:4px;">Key prefix</span><?= htmlspecialchars((string)($client['api_key_prefix'] ?? ''), ENT_QUOTES, 'UTF-8') ?>...</div>
<div><span class="label" style="display:block; margin-bottom:4px;">Last used</span><?= htmlspecialchars((string)($client['last_used_at'] ?? 'Never'), ENT_QUOTES, 'UTF-8') ?></div>
<div><span class="label" style="display:block; margin-bottom:4px;">Last IP</span><?= htmlspecialchars((string)($client['last_used_ip'] ?? '—'), ENT_QUOTES, 'UTF-8') ?></div>
</div>
<?php if (!empty($client['webhook_url'])): ?>
<div style="margin-top:8px; color:var(--muted); font-size:12px;">
Webhook: <?= htmlspecialchars((string)$client['webhook_url'], ENT_QUOTES, 'UTF-8') ?>
</div>
<?php endif; ?>
</div>
<div style="display:flex; gap:8px; align-items:center; justify-content:flex-end;">
<form method="post" action="/admin/api/clients/toggle">
<input type="hidden" name="id" value="<?= (int)($client['id'] ?? 0) ?>">
<button type="submit" class="btn outline small"><?= (int)($client['is_active'] ?? 0) === 1 ? 'Disable' : 'Enable' ?></button>
</form>
<form method="post" action="/admin/api/clients/delete" onsubmit="return confirm('Delete this API client?');">
<input type="hidden" name="id" value="<?= (int)($client['id'] ?? 0) ?>">
<button type="submit" class="btn outline small" style="border-color:rgba(255,120,120,.35); color:#ffb0b0;">Delete</button>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</article>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../admin/views/layout.php';

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Modules\Artists;
use Core\Http\Response;
use Core\Views\View;
class ArtistsController
{
private View $view;
public function __construct()
{
$this->view = new View(__DIR__ . '/views');
}
public function index(): Response
{
return new Response($this->view->render('site/index.php', [
'title' => 'Artists',
]));
}
public function show(): Response
{
return new Response($this->view->render('site/show.php', [
'title' => 'Artist Profile',
]));
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
use Core\Http\Router;
use Modules\Artists\ArtistsController;
require_once __DIR__ . '/ArtistsController.php';
return function (Router $router): void {
$controller = new ArtistsController();
$router->get('/artists', [$controller, 'index']);
$router->get('/artist', [$controller, 'show']);
};

View File

View File

@@ -0,0 +1,12 @@
<?php
$pageTitle = 'Artists';
ob_start();
?>
<section class="card">
<div class="badge">Artists</div>
<h1 style="margin-top:16px; font-size:28px;">Artists</h1>
<p style="color:var(--muted);">Artist module placeholder.</p>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../views/site/layout.php';

View File

@@ -0,0 +1,12 @@
<?php
$pageTitle = 'Artist';
ob_start();
?>
<section class="card">
<div class="badge">Artist</div>
<h1 style="margin-top:16px; font-size:28px;">Artist profile</h1>
<p style="color:var(--muted);">Artist profile placeholder.</p>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../views/site/layout.php';

View File

@@ -0,0 +1,316 @@
<?php
declare(strict_types=1);
namespace Modules\Blog;
use Core\Http\Response;
use Core\Services\Auth;
use Core\Services\Database;
use Core\Views\View;
use DateTime;
use PDO;
use Throwable;
class BlogController
{
private View $view;
public function __construct()
{
$this->view = new View(__DIR__ . '/views');
}
public function index(): Response
{
$db = Database::get();
$posts = [];
$page = null;
if ($db instanceof PDO) {
$pageStmt = $db->prepare("SELECT title, content_html FROM ac_pages WHERE is_blog_index = 1 AND is_published = 1 LIMIT 1");
$pageStmt->execute();
$page = $pageStmt->fetch(PDO::FETCH_ASSOC) ?: null;
$stmt = $db->prepare("
SELECT title, slug, excerpt, published_at, featured_image_url, author_name, category, tags
FROM ac_posts
WHERE is_published = 1
ORDER BY COALESCE(published_at, created_at) DESC
");
$stmt->execute();
$posts = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
return new Response($this->view->render('site/index.php', [
'title' => 'News',
'posts' => $posts,
'page' => $page,
]));
}
public function show(): Response
{
$slug = trim((string)($_GET['slug'] ?? ''));
if ($slug === '') {
return $this->notFound();
}
$db = Database::get();
if (!$db instanceof PDO) {
return $this->notFound();
}
$stmt = $db->prepare("
SELECT title, content_html, published_at, featured_image_url, author_name, category, tags
FROM ac_posts
WHERE slug = :slug AND is_published = 1
LIMIT 1
");
$stmt->execute([':slug' => $slug]);
$post = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$post) {
return $this->notFound();
}
return new Response($this->view->render('site/show.php', [
'title' => (string)$post['title'],
'content_html' => (string)$post['content_html'],
'published_at' => (string)($post['published_at'] ?? ''),
'featured_image_url' => (string)($post['featured_image_url'] ?? ''),
'author_name' => (string)($post['author_name'] ?? ''),
'category' => (string)($post['category'] ?? ''),
'tags' => (string)($post['tags'] ?? ''),
]));
}
public function adminIndex(): Response
{
if ($guard = $this->guard(['admin', 'manager'])) {
return $guard;
}
$db = Database::get();
$posts = [];
if ($db instanceof PDO) {
$stmt = $db->query("SELECT id, title, slug, author_name, is_published, published_at, updated_at FROM ac_posts ORDER BY updated_at DESC");
$posts = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
}
return new Response($this->view->render('admin/index.php', [
'title' => 'Posts',
'posts' => $posts,
]));
}
public function adminEdit(): Response
{
if ($guard = $this->guard(['admin', 'manager'])) {
return $guard;
}
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$post = [
'id' => 0,
'title' => '',
'slug' => '',
'excerpt' => '',
'featured_image_url' => '',
'author_name' => '',
'category' => '',
'tags' => '',
'content_html' => '',
'is_published' => 0,
'published_at' => '',
];
$db = Database::get();
if ($id > 0 && $db instanceof PDO) {
$stmt = $db->prepare("SELECT * FROM ac_posts WHERE id = :id LIMIT 1");
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row) {
$post = $row;
}
}
return new Response($this->view->render('admin/edit.php', [
'title' => $id > 0 ? 'Edit Post' : 'New Post',
'post' => $post,
'error' => '',
]));
}
public function adminSave(): Response
{
if ($guard = $this->guard(['admin', 'manager'])) {
return $guard;
}
$db = Database::get();
if (!$db instanceof PDO) {
return new Response('', 302, ['Location' => '/admin/posts']);
}
$id = (int)($_POST['id'] ?? 0);
$title = trim((string)($_POST['title'] ?? ''));
$slug = trim((string)($_POST['slug'] ?? ''));
$excerpt = trim((string)($_POST['excerpt'] ?? ''));
$featuredImage = trim((string)($_POST['featured_image_url'] ?? ''));
$authorName = trim((string)($_POST['author_name'] ?? ''));
$category = trim((string)($_POST['category'] ?? ''));
$tags = trim((string)($_POST['tags'] ?? ''));
$content = (string)($_POST['content_html'] ?? '');
$isPublished = isset($_POST['is_published']) ? 1 : 0;
$publishedAt = trim((string)($_POST['published_at'] ?? ''));
if ($title === '') {
return $this->renderEditError($id, $title, $slug, $excerpt, $featuredImage, $authorName, $category, $tags, $content, $isPublished, $publishedAt, 'Title is required.');
}
if ($slug === '') {
$slug = $this->slugify($title);
} else {
$slug = $this->slugify($slug);
}
if ($publishedAt !== '') {
try {
$dt = new DateTime($publishedAt);
$publishedAt = $dt->format('Y-m-d H:i:s');
} catch (Throwable $e) {
$publishedAt = '';
}
}
try {
if ($id > 0) {
$chk = $db->prepare("SELECT id FROM ac_posts WHERE slug = :slug AND id != :id LIMIT 1");
$chk->execute([':slug' => $slug, ':id' => $id]);
} else {
$chk = $db->prepare("SELECT id FROM ac_posts WHERE slug = :slug LIMIT 1");
$chk->execute([':slug' => $slug]);
}
if ($chk->fetch()) {
return $this->renderEditError($id, $title, $slug, $excerpt, $featuredImage, $authorName, $category, $tags, $content, $isPublished, $publishedAt, 'Slug already exists.');
}
if ($id > 0) {
$stmt = $db->prepare("
UPDATE ac_posts
SET title = :title, slug = :slug, excerpt = :excerpt,
featured_image_url = :featured_image_url, author_name = :author_name,
category = :category, tags = :tags, content_html = :content,
is_published = :published, published_at = :published_at
WHERE id = :id
");
$stmt->execute([
':title' => $title,
':slug' => $slug,
':excerpt' => $excerpt !== '' ? $excerpt : null,
':featured_image_url' => $featuredImage !== '' ? $featuredImage : null,
':author_name' => $authorName !== '' ? $authorName : null,
':category' => $category !== '' ? $category : null,
':tags' => $tags !== '' ? $tags : null,
':content' => $content,
':published' => $isPublished,
':published_at' => $publishedAt !== '' ? $publishedAt : null,
':id' => $id,
]);
} else {
$stmt = $db->prepare("
INSERT INTO ac_posts (title, slug, excerpt, featured_image_url, author_name, category, tags, content_html, is_published, published_at)
VALUES (:title, :slug, :excerpt, :featured_image_url, :author_name, :category, :tags, :content, :published, :published_at)
");
$stmt->execute([
':title' => $title,
':slug' => $slug,
':excerpt' => $excerpt !== '' ? $excerpt : null,
':featured_image_url' => $featuredImage !== '' ? $featuredImage : null,
':author_name' => $authorName !== '' ? $authorName : null,
':category' => $category !== '' ? $category : null,
':tags' => $tags !== '' ? $tags : null,
':content' => $content,
':published' => $isPublished,
':published_at' => $publishedAt !== '' ? $publishedAt : null,
]);
}
} catch (Throwable $e) {
return $this->renderEditError($id, $title, $slug, $excerpt, $featuredImage, $authorName, $category, $tags, $content, $isPublished, $publishedAt, 'Unable to save post.');
}
return new Response('', 302, ['Location' => '/admin/posts']);
}
public function adminDelete(): Response
{
if ($guard = $this->guard(['admin', 'manager'])) {
return $guard;
}
$db = Database::get();
if (!$db instanceof PDO) {
return new Response('', 302, ['Location' => '/admin/posts']);
}
$id = (int)($_POST['id'] ?? 0);
if ($id > 0) {
$stmt = $db->prepare("DELETE FROM ac_posts WHERE id = :id");
$stmt->execute([':id' => $id]);
}
return new Response('', 302, ['Location' => '/admin/posts']);
}
private function renderEditError(
int $id,
string $title,
string $slug,
string $excerpt,
string $featuredImage,
string $authorName,
string $category,
string $tags,
string $content,
int $isPublished,
string $publishedAt,
string $error
): Response {
$post = [
'id' => $id,
'title' => $title,
'slug' => $slug,
'excerpt' => $excerpt,
'featured_image_url' => $featuredImage,
'author_name' => $authorName,
'category' => $category,
'tags' => $tags,
'content_html' => $content,
'is_published' => $isPublished,
'published_at' => $publishedAt,
];
return new Response($this->view->render('admin/edit.php', [
'title' => $id > 0 ? 'Edit Post' : 'New Post',
'post' => $post,
'error' => $error,
]));
}
private function notFound(): Response
{
$view = new View();
return new Response($view->render('site/404.php', [
'title' => 'Not Found',
'message' => 'Post not found.',
]), 404);
}
private function slugify(string $value): string
{
$value = strtolower(trim($value));
$value = preg_replace('~[^a-z0-9]+~', '-', $value) ?? $value;
$value = trim($value, '-');
return $value !== '' ? $value : 'post';
}
private function guard(array $roles): ?Response
{
if (!Auth::check()) {
return new Response('', 302, ['Location' => '/admin/login']);
}
if (!Auth::hasRole($roles)) {
return new Response('', 302, ['Location' => '/admin']);
}
return null;
}
}

19
modules/blog/module.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use Core\Http\Router;
use Modules\Blog\BlogController;
require_once __DIR__ . '/BlogController.php';
return function (Router $router): void {
$controller = new BlogController();
$router->get('/news', [$controller, 'index']);
$router->get('/news/post', [$controller, 'show']);
$router->get('/admin/posts', [$controller, 'adminIndex']);
$router->get('/admin/posts/new', [$controller, 'adminEdit']);
$router->get('/admin/posts/edit', [$controller, 'adminEdit']);
$router->post('/admin/posts/save', [$controller, 'adminSave']);
$router->post('/admin/posts/delete', [$controller, 'adminDelete']);
};

View File

@@ -0,0 +1,67 @@
<?php
$pageTitle = $title ?? 'Edit Post';
$post = $post ?? [];
$error = $error ?? '';
ob_start();
?>
<section class="admin-card">
<div class="badge">Blog</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
<div>
<h1 style="font-size:28px; margin:0;"><?= htmlspecialchars($pageTitle, ENT_QUOTES, 'UTF-8') ?></h1>
<p style="color: var(--muted); margin-top:6px;">Write a news post or update.</p>
</div>
<a href="/admin/posts" class="btn outline">Back</a>
</div>
<?php if ($error): ?>
<div style="margin-top:16px; color:#f3b0b0; font-size:13px;"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<form method="post" action="/admin/posts/save" style="margin-top:18px; display:grid; gap:16px;">
<input type="hidden" name="id" value="<?= (int)($post['id'] ?? 0) ?>">
<div class="admin-card" style="padding:16px;">
<div style="display:grid; gap:12px;">
<label class="label">Title</label>
<input class="input" name="title" value="<?= htmlspecialchars((string)($post['title'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="Post title">
<label class="label">Slug</label>
<input class="input" name="slug" value="<?= htmlspecialchars((string)($post['slug'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="post-title">
<label class="label">Excerpt</label>
<textarea class="input" name="excerpt" rows="3" style="resize:vertical;"><?= htmlspecialchars((string)($post['excerpt'] ?? ''), ENT_QUOTES, 'UTF-8') ?></textarea>
<label class="label">Featured Image URL</label>
<input class="input" name="featured_image_url" value="<?= htmlspecialchars((string)($post['featured_image_url'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="https://example.com/cover.jpg">
<label class="label">Author</label>
<input class="input" name="author_name" value="<?= htmlspecialchars((string)($post['author_name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="AudioCore Team">
<label class="label">Category</label>
<input class="input" name="category" value="<?= htmlspecialchars((string)($post['category'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="News">
<label class="label">Tags (comma separated)</label>
<input class="input" name="tags" value="<?= htmlspecialchars((string)($post['tags'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="release, label, update">
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<label class="label" style="margin:0;">Content (HTML)</label>
<button type="button" class="btn outline small" data-media-picker="blog_content_html">Insert Media</button>
</div>
<textarea class="input" id="blog_content_html" name="content_html" rows="16" style="resize:vertical; font-family:'IBM Plex Mono', monospace; font-size:13px; line-height:1.6;"><?= htmlspecialchars((string)($post['content_html'] ?? ''), ENT_QUOTES, 'UTF-8') ?></textarea>
<label class="label">Published At</label>
<input class="input" name="published_at" value="<?= htmlspecialchars((string)($post['published_at'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="2026-01-25 18:30:00">
<label style="display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<input type="checkbox" name="is_published" value="1" <?= ((int)($post['is_published'] ?? 0) === 1) ? 'checked' : '' ?>>
Published
</label>
</div>
</div>
<div style="display:flex; justify-content:flex-end; gap:12px; align-items:center;">
<button type="submit" class="btn">Save post</button>
</div>
</form>
<?php if (!empty($post['id'])): ?>
<form method="post" action="/admin/posts/delete" onsubmit="return confirm('Delete this post?');" style="margin-top:12px;">
<input type="hidden" name="id" value="<?= (int)($post['id'] ?? 0) ?>">
<button type="submit" class="btn outline">Delete</button>
</form>
<?php endif; ?>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../admin/views/layout.php';

View File

@@ -0,0 +1,54 @@
<?php
$pageTitle = 'Posts';
$posts = $posts ?? [];
ob_start();
?>
<section class="admin-card">
<div class="badge">Blog</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
<div>
<h1 style="font-size:28px; margin:0;">Posts</h1>
<p style="color: var(--muted); margin-top:6px;">Publish news updates and announcements.</p>
</div>
<a href="/admin/posts/new" class="btn small">New Post</a>
</div>
<div style="margin-top:18px; display:grid; gap:10px;">
<div style="display:grid; grid-template-columns: 2fr 1fr 140px 140px 160px 120px; gap:12px; font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<div>Title</div>
<div>Slug</div>
<div>Author</div>
<div>Status</div>
<div>Published</div>
<div>Actions</div>
</div>
<?php if (!$posts): ?>
<div style="color: var(--muted); font-size:13px;">No posts yet.</div>
<?php else: ?>
<?php foreach ($posts as $post): ?>
<div style="display:grid; grid-template-columns: 2fr 1fr 140px 140px 160px 120px; gap:12px; align-items:center; padding:10px 12px; border-radius:16px; border:1px solid var(--stroke); background: rgba(14,14,16,0.9);">
<div style="font-weight:600;"><?= htmlspecialchars((string)($post['title'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:12px; color:var(--muted); font-family: 'IBM Plex Mono', monospace;">
<?= htmlspecialchars((string)($post['slug'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</div>
<div style="font-size:12px; color:var(--muted);">
<?= htmlspecialchars((string)($post['author_name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</div>
<div style="font-size:12px; color:<?= ((int)($post['is_published'] ?? 0) === 1) ? 'var(--accent-2)' : 'var(--muted)' ?>;">
<?= ((int)($post['is_published'] ?? 0) === 1) ? 'Published' : 'Draft' ?>
</div>
<div style="font-size:12px; color:var(--muted);">
<?= htmlspecialchars((string)($post['published_at'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</div>
<div style="display:flex; gap:8px;">
<a href="/admin/posts/edit?id=<?= (int)$post['id'] ?>" class="btn outline small">Edit</a>
<a href="/news/<?= htmlspecialchars((string)($post['slug'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" class="btn outline small">View</a>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../admin/views/layout.php';

View File

@@ -0,0 +1,171 @@
<?php
$posts = is_array($posts ?? null) ? $posts : [];
$page = is_array($page ?? null) ? $page : null;
$pageContentHtml = trim((string)($page['content_html'] ?? ''));
$formatDate = static function (?string $value): string {
if ($value === null || trim($value) === '') {
return '';
}
try {
return (new DateTime($value))->format('d M Y');
} catch (Throwable $e) {
return $value;
}
};
$renderTags = static function (string $tags): array {
return array_values(array_filter(array_map('trim', explode(',', $tags))));
};
ob_start();
?>
<section class="card news-list-shell-minimal">
<?php if ($pageContentHtml !== ''): ?>
<div class="blog-page-content-box">
<?= $pageContentHtml ?>
</div>
<?php endif; ?>
<?php if ($posts): ?>
<div class="news-list-grid-minimal">
<?php foreach ($posts as $post): ?>
<a class="news-card-minimal" href="/news/<?= htmlspecialchars((string)$post['slug'], ENT_QUOTES, 'UTF-8') ?>">
<?php if (!empty($post['featured_image_url'])): ?>
<div class="news-card-media-minimal">
<img src="<?= htmlspecialchars((string)$post['featured_image_url'], ENT_QUOTES, 'UTF-8') ?>" alt="">
</div>
<?php endif; ?>
<div class="news-card-copy-minimal">
<div class="news-card-meta-minimal">
<?php $published = $formatDate((string)($post['published_at'] ?? '')); ?>
<?php if ($published !== ''): ?><span><?= htmlspecialchars($published, ENT_QUOTES, 'UTF-8') ?></span><?php endif; ?>
<?php if (!empty($post['author_name'])): ?><span><?= htmlspecialchars((string)$post['author_name'], ENT_QUOTES, 'UTF-8') ?></span><?php endif; ?>
</div>
<h2><?= htmlspecialchars((string)$post['title'], ENT_QUOTES, 'UTF-8') ?></h2>
<?php if (!empty($post['excerpt'])): ?>
<p><?= htmlspecialchars((string)$post['excerpt'], ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>
<?php $tags = $renderTags((string)($post['tags'] ?? '')); ?>
<?php if ($tags): ?>
<div class="news-card-tags-minimal">
<?php foreach (array_slice($tags, 0, 3) as $tag): ?>
<span><?= htmlspecialchars($tag, ENT_QUOTES, 'UTF-8') ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</a>
<?php endforeach; ?>
</div>
<?php elseif ($pageContentHtml === ''): ?>
<div class="news-empty-minimal">No published posts yet.</div>
<?php endif; ?>
</section>
<style>
.news-list-shell-minimal { display: grid; gap: 18px; }
.blog-page-content-box {
border-radius: 22px;
border: 1px solid rgba(255,255,255,.1);
background: rgba(12,15,21,.84);
padding: 28px;
overflow: hidden;
}
.blog-page-content-box img {
max-width: 100%;
height: auto;
display: block;
}
.news-list-grid-minimal {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.news-card-minimal {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 0;
min-height: 180px;
border-radius: 22px;
overflow: hidden;
border: 1px solid rgba(255,255,255,.1);
background: rgba(12,15,21,.84);
text-decoration: none;
color: inherit;
transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease;
}
.news-card-minimal:hover {
transform: translateY(-2px);
border-color: rgba(34,242,165,.34);
box-shadow: 0 18px 32px rgba(0,0,0,.2);
}
.news-card-media-minimal { background: rgba(255,255,255,.03); }
.news-card-media-minimal img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.news-card-copy-minimal {
display: grid;
align-content: start;
gap: 12px;
padding: 20px;
}
.news-card-meta-minimal {
display: flex;
flex-wrap: wrap;
gap: 12px;
color: rgba(255,255,255,.58);
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
letter-spacing: .16em;
text-transform: uppercase;
}
.news-card-copy-minimal h2 {
margin: 0;
font-size: 34px;
line-height: .96;
}
.news-card-copy-minimal p {
margin: 0;
color: #c4cede;
line-height: 1.65;
font-size: 15px;
}
.news-card-tags-minimal {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.news-card-tags-minimal span {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.04);
color: #dce5f6;
font-size: 11px;
}
.news-empty-minimal {
border-radius: 18px;
border: 1px dashed rgba(255,255,255,.16);
padding: 24px;
color: #afb8ca;
background: rgba(255,255,255,.02);
}
@media (max-width: 980px) {
.news-list-grid-minimal { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.blog-page-content-box { padding: 20px; }
.news-card-minimal {
grid-template-columns: 1fr;
min-height: 0;
}
.news-card-media-minimal { min-height: 220px; }
.news-card-copy-minimal h2 { font-size: 28px; }
}
</style>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../views/site/layout.php';

View File

@@ -0,0 +1,257 @@
<?php
$pageTitle = $title ?? 'Post';
$contentHtml = (string)($content_html ?? '');
$publishedAt = (string)($published_at ?? '');
$authorName = (string)($author_name ?? '');
$category = (string)($category ?? '');
$tags = (string)($tags ?? '');
$tagList = array_filter(array_map('trim', explode(',', $tags)));
$publishedDisplay = '';
if ($publishedAt !== '') {
try {
$publishedDisplay = (new DateTime($publishedAt))->format('d M Y');
} catch (Throwable $e) {
$publishedDisplay = $publishedAt;
}
}
ob_start();
?>
<section class="card article-shell-fluid">
<div class="article-fluid-grid">
<aside class="article-fluid-meta">
<div class="badge">News</div>
<h1 class="article-fluid-title"><?= htmlspecialchars($pageTitle, ENT_QUOTES, 'UTF-8') ?></h1>
<div class="article-fluid-meta-list">
<?php if ($publishedDisplay !== ''): ?>
<div class="article-fluid-meta-item">
<span>Date</span>
<strong><?= htmlspecialchars($publishedDisplay, ENT_QUOTES, 'UTF-8') ?></strong>
</div>
<?php endif; ?>
<?php if ($authorName !== ''): ?>
<div class="article-fluid-meta-item">
<span>Author</span>
<strong><?= htmlspecialchars($authorName, ENT_QUOTES, 'UTF-8') ?></strong>
</div>
<?php endif; ?>
<?php if ($category !== ''): ?>
<div class="article-fluid-meta-item">
<span>Category</span>
<strong><?= htmlspecialchars($category, ENT_QUOTES, 'UTF-8') ?></strong>
</div>
<?php endif; ?>
</div>
<?php if ($tagList): ?>
<div class="article-fluid-tags">
<?php foreach ($tagList as $tag): ?>
<span><?= htmlspecialchars((string)$tag, ENT_QUOTES, 'UTF-8') ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<a href="/news" class="article-fluid-back">Back to news</a>
</aside>
<div class="article-fluid-content-shell">
<div class="article-fluid-post-box">
<?= $contentHtml ?>
</div>
</div>
</div>
</section>
<style>
.article-shell-fluid {
padding: 0;
}
.article-fluid-grid {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 0;
align-items: start;
min-height: 100%;
}
.article-fluid-meta {
display: grid;
gap: 18px;
align-content: start;
min-height: 100%;
padding: 32px 24px;
}
.article-fluid-title {
margin: 0;
color: #f5f7ff;
font-size: clamp(34px, 3vw, 56px);
line-height: .94;
word-break: break-word;
}
.article-fluid-meta-list {
display: grid;
gap: 14px;
}
.article-fluid-meta-item span {
display: block;
margin-bottom: 6px;
color: rgba(255,255,255,.56);
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
letter-spacing: .18em;
text-transform: uppercase;
}
.article-fluid-meta-item strong {
color: #e8eefc;
font-size: 15px;
font-weight: 600;
}
.article-fluid-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.article-fluid-tags span {
display: inline-flex;
align-items: center;
padding: 7px 10px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.04);
color: #dce5f6;
font-size: 11px;
}
.article-fluid-back {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 42px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,.14);
background: rgba(255,255,255,.04);
color: #eef4ff;
text-decoration: none;
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
letter-spacing: .16em;
text-transform: uppercase;
}
.article-fluid-back:hover {
border-color: rgba(34,242,165,.36);
color: #9ff8d8;
}
.article-fluid-content-shell {
min-width: 0;
min-height: 100%;
margin: 0;
padding: 40px 44px;
border-radius: 0 28px 28px 0;
background: #f4f1ec;
box-shadow: none;
}
.article-fluid-post-box {
min-width: 0;
padding: 0;
color: #26272d;
}
.article-fluid-post-box,
.article-fluid-post-box p,
.article-fluid-post-box li,
.article-fluid-post-box blockquote,
.article-fluid-post-box figcaption,
.article-fluid-post-box td,
.article-fluid-post-box th {
color: #3a3d45;
}
.article-fluid-post-box h1,
.article-fluid-post-box h2,
.article-fluid-post-box h3,
.article-fluid-post-box h4,
.article-fluid-post-box h5,
.article-fluid-post-box h6,
.article-fluid-post-box strong {
color: #202228;
}
.article-fluid-post-box,
.article-fluid-post-box > *,
.article-fluid-post-box > * > *,
.article-fluid-post-box > * > * > * {
max-width: none !important;
}
.article-fluid-post-box > * {
width: 100% !important;
margin: 0 !important;
}
.article-fluid-post-box > div,
.article-fluid-post-box > section,
.article-fluid-post-box > article,
.article-fluid-post-box > main,
.article-fluid-post-box > figure {
width: 100% !important;
max-width: none !important;
margin: 0 !important;
}
.article-fluid-post-box * {
margin-left: 0 !important;
margin-right: 0 !important;
}
.article-fluid-post-box [style*="max-width"],
.article-fluid-post-box [style*="width"],
.article-fluid-post-box [style*="margin: auto"],
.article-fluid-post-box [style*="margin-left:auto"],
.article-fluid-post-box [style*="margin-right:auto"] {
max-width: none !important;
width: 100% !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
.article-fluid-post-box > div,
.article-fluid-post-box > section,
.article-fluid-post-box > article,
.article-fluid-post-box > main,
.article-fluid-post-box > figure,
.article-fluid-post-box > div > div:first-child {
background: transparent !important;
box-shadow: none !important;
border: 0 !important;
border-radius: 0 !important;
}
.article-fluid-post-box img {
max-width: 100%;
height: auto;
display: block;
}
.article-fluid-post-box iframe,
.article-fluid-post-box video,
.article-fluid-post-box table,
.article-fluid-post-box pre {
max-width: 100%;
}
@media (max-width: 980px) {
.article-fluid-grid {
grid-template-columns: 1fr;
gap: 20px;
}
.article-fluid-meta {
padding: 24px 24px 8px;
}
.article-fluid-content-shell {
margin: 0;
border-radius: 0 0 28px 28px;
}
}
@media (max-width: 720px) {
.article-shell-fluid {
padding: 0;
}
.article-fluid-content-shell {
padding: 28px 24px;
}
.article-fluid-post-box {
padding: 0;
}
.article-fluid-title {
font-size: 42px;
}
}
</style>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../views/site/layout.php';

View File

@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
namespace Modules\Media;
use Core\Http\Response;
use Core\Services\Auth;
use Core\Services\Database;
use Core\Views\View;
use PDO;
use Throwable;
class MediaController
{
private View $view;
public function __construct()
{
$this->view = new View(__DIR__ . '/views');
}
public function index(): 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();
$items = [];
$folders = [];
$folderId = isset($_GET['folder']) ? (int)$_GET['folder'] : 0;
if ($db instanceof PDO) {
$folderStmt = $db->query("SELECT id, name FROM ac_media_folders ORDER BY name ASC");
$folders = $folderStmt ? $folderStmt->fetchAll(PDO::FETCH_ASSOC) : [];
if ($folderId > 0) {
$stmt = $db->prepare("SELECT id, file_name, file_url, file_type, file_size, created_at FROM ac_media WHERE folder_id = :folder_id ORDER BY created_at DESC");
$stmt->execute([':folder_id' => $folderId]);
} else {
$stmt = $db->query("SELECT id, file_name, file_url, file_type, file_size, created_at FROM ac_media WHERE folder_id IS NULL ORDER BY created_at DESC");
}
$items = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
}
$error = (string)($_GET['error'] ?? '');
$success = (string)($_GET['success'] ?? '');
return new Response($this->view->render('admin/index.php', [
'title' => 'Media',
'items' => $items,
'folders' => $folders,
'active_folder' => $folderId,
'error' => $error,
'success' => $success,
]));
}
public function picker(): Response
{
if (!Auth::check()) {
return new Response('', 302, ['Location' => '/admin/login']);
}
if (!Auth::hasRole(['admin', 'manager', 'editor'])) {
return new Response('', 302, ['Location' => '/admin']);
}
$db = Database::get();
$items = [];
if ($db instanceof PDO) {
$stmt = $db->query("SELECT id, file_name, file_url, file_type FROM ac_media ORDER BY created_at DESC");
$items = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
}
return new Response(json_encode(['items' => $items]), 200, ['Content-Type' => 'application/json']);
}
public function upload(): Response
{
if (!Auth::check()) {
return new Response('', 302, ['Location' => '/admin/login']);
}
if (!Auth::hasRole(['admin', 'manager'])) {
return new Response('', 302, ['Location' => '/admin']);
}
$file = $_FILES['media_file'] ?? null;
$folderId = isset($_POST['folder_id']) ? (int)$_POST['folder_id'] : 0;
if (!$file || !isset($file['tmp_name'])) {
return $this->uploadError('No file uploaded.', $folderId);
}
if ($file['error'] !== UPLOAD_ERR_OK) {
return $this->uploadError($this->uploadErrorMessage((int)$file['error']), $folderId);
}
$tmp = (string)$file['tmp_name'];
if ($tmp === '' || !is_uploaded_file($tmp)) {
return new Response('', 302, ['Location' => '/admin/media']);
}
$ext = strtolower(pathinfo((string)$file['name'], PATHINFO_EXTENSION));
if ($ext === '') {
$ext = 'bin';
}
$uploadDir = __DIR__ . '/../../uploads/media';
if (!is_dir($uploadDir)) {
if (!mkdir($uploadDir, 0755, true)) {
return $this->uploadError('Upload directory could not be created.', $folderId);
}
}
if (!is_writable($uploadDir)) {
return $this->uploadError('Upload directory is not writable.', $folderId);
}
$baseName = preg_replace('~[^a-z0-9]+~', '-', strtolower((string)$file['name'])) ?? 'file';
$baseName = trim($baseName, '-');
$fileName = ($baseName !== '' ? $baseName : 'file') . '-' . date('YmdHis') . '.' . $ext;
$dest = $uploadDir . '/' . $fileName;
if (!move_uploaded_file($tmp, $dest)) {
return $this->uploadError('Upload failed. Check server permissions.', $folderId);
}
$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, :folder_id)
");
$stmt->execute([
':name' => (string)$file['name'],
':url' => $fileUrl,
':type' => $fileType,
':size' => $fileSize,
':folder_id' => $folderId > 0 ? $folderId : null,
]);
} catch (Throwable $e) {
return $this->uploadError('Database insert failed.', $folderId);
}
}
$redirect = $folderId > 0 ? '/admin/media?folder=' . $folderId . '&success=1' : '/admin/media?success=1';
return new Response('', 302, ['Location' => $redirect]);
}
public function delete(): 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("SELECT file_url FROM ac_media WHERE id = :id");
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row && !empty($row['file_url'])) {
$path = __DIR__ . '/../../..' . (string)$row['file_url'];
if (is_file($path)) {
@unlink($path);
}
}
$db->prepare("DELETE FROM ac_media WHERE id = :id")->execute([':id' => $id]);
}
return new Response('', 302, ['Location' => '/admin/media']);
}
public function createFolder(): Response
{
if (!Auth::check()) {
return new Response('', 302, ['Location' => '/admin/login']);
}
if (!Auth::hasRole(['admin', 'manager'])) {
return new Response('', 302, ['Location' => '/admin']);
}
$name = trim((string)($_POST['name'] ?? ''));
if ($name === '') {
return new Response('', 302, ['Location' => '/admin/media']);
}
$db = Database::get();
if ($db instanceof PDO) {
$stmt = $db->prepare("INSERT INTO ac_media_folders (name) VALUES (:name)");
$stmt->execute([':name' => $name]);
}
return new Response('', 302, ['Location' => '/admin/media']);
}
private function uploadError(string $message, int $folderId): Response
{
$target = $folderId > 0 ? '/admin/media?folder=' . $folderId : '/admin/media';
$target .= '&error=' . rawurlencode($message);
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.';
}
}

16
modules/media/module.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
use Core\Http\Router;
use Modules\Media\MediaController;
require_once __DIR__ . '/MediaController.php';
return function (Router $router): void {
$controller = new MediaController();
$router->get('/admin/media', [$controller, 'index']);
$router->get('/admin/media/picker', [$controller, 'picker']);
$router->post('/admin/media/upload', [$controller, 'upload']);
$router->post('/admin/media/delete', [$controller, 'delete']);
$router->post('/admin/media/folders', [$controller, 'createFolder']);
};

View File

@@ -0,0 +1,114 @@
<?php
$pageTitle = 'Media';
$items = $items ?? [];
$folders = $folders ?? [];
$activeFolder = (int)($active_folder ?? 0);
$error = (string)($error ?? '');
$success = (string)($success ?? '');
ob_start();
?>
<section class="admin-card">
<div class="badge">Media</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
<div>
<h1 style="font-size:28px; margin:0;">Media Library</h1>
<p style="color: var(--muted); margin-top:6px;">Upload and reuse images across pages, posts, and newsletters.</p>
</div>
</div>
<?php if ($error !== ''): ?>
<div style="margin-top:12px; color:#f3b0b0; font-size:13px;"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
<?php elseif ($success !== ''): ?>
<div style="margin-top:12px; color:var(--accent-2); font-size:13px;">Upload complete.</div>
<?php endif; ?>
<div style="margin-top:18px; display:grid; gap:12px;">
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<a href="/admin/media" class="btn outline small" style="<?= $activeFolder === 0 ? 'border-color: var(--accent); color: var(--text);' : '' ?>">All</a>
<?php foreach ($folders as $folder): ?>
<a href="/admin/media?folder=<?= (int)$folder['id'] ?>" class="btn outline small" style="<?= $activeFolder === (int)$folder['id'] ? 'border-color: var(--accent); color: var(--text);' : '' ?>">
<?= htmlspecialchars((string)$folder['name'], ENT_QUOTES, 'UTF-8') ?>
</a>
<?php endforeach; ?>
</div>
<form method="post" action="/admin/media/folders" style="display:flex; gap:10px; flex-wrap:wrap;">
<input class="input" name="name" placeholder="New folder name" style="max-width:280px;">
<button type="submit" class="btn outline small">Create Folder</button>
</form>
<form method="post" action="/admin/media/upload" enctype="multipart/form-data" id="mediaUploadForm">
<input type="hidden" name="folder_id" value="<?= $activeFolder > 0 ? $activeFolder : 0 ?>">
<label for="mediaFileInput" id="mediaDropzone" style="display:flex; flex-direction:column; gap:8px; align-items:center; justify-content:center; padding:24px; border-radius:16px; border:1px dashed rgba(255,255,255,0.2); background: rgba(0,0,0,0.2); cursor:pointer;">
<div style="font-size:12px; text-transform:uppercase; letter-spacing:0.2em; color:var(--muted);">Drag &amp; Drop</div>
<div style="font-size:14px; color:var(--text);">or click to upload</div>
<div id="mediaFileName" style="font-size:12px; color:var(--muted);">No file selected</div>
</label>
<input class="input" type="file" id="mediaFileInput" name="media_file" accept="image/*" style="display:none;">
<div style="margin-top:10px; display:flex; justify-content:flex-end;">
<button type="submit" class="btn small">Upload</button>
</div>
</form>
</div>
<div style="margin-top:18px; display:grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap:12px;">
<?php if (!$items): ?>
<div style="color: var(--muted); font-size:13px;">No media uploaded yet.</div>
<?php else: ?>
<?php foreach ($items as $item): ?>
<div style="border-radius:16px; border:1px solid var(--stroke); background: rgba(14,14,16,0.9); padding:10px;">
<div style="aspect-ratio: 1 / 1; border-radius:12px; overflow:hidden; background:#0b0c10; display:flex; align-items:center; justify-content:center;">
<?php if (str_starts_with((string)($item['file_type'] ?? ''), 'image/')): ?>
<img src="<?= htmlspecialchars((string)$item['file_url'], ENT_QUOTES, 'UTF-8') ?>" alt="" style="width:100%; height:100%; object-fit:cover;">
<?php else: ?>
<div style="color:var(--muted); font-size:12px;">File</div>
<?php endif; ?>
</div>
<div style="margin-top:8px; font-size:12px; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
<?= htmlspecialchars((string)($item['file_name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</div>
<input class="input" readonly value="<?= htmlspecialchars((string)($item['file_url'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" style="margin-top:6px; font-size:11px;">
<form method="post" action="/admin/media/delete" onsubmit="return confirm('Delete this file?');" style="margin-top:8px;">
<input type="hidden" name="id" value="<?= (int)($item['id'] ?? 0) ?>">
<button type="submit" class="btn outline small">Delete</button>
</form>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</section>
<script>
(function () {
const dropzone = document.getElementById('mediaDropzone');
const fileInput = document.getElementById('mediaFileInput');
const fileName = document.getElementById('mediaFileName');
if (!dropzone || !fileInput || !fileName) {
return;
}
dropzone.addEventListener('dragover', (event) => {
event.preventDefault();
dropzone.style.borderColor = 'var(--accent)';
});
dropzone.addEventListener('dragleave', () => {
dropzone.style.borderColor = 'rgba(255,255,255,0.2)';
});
dropzone.addEventListener('drop', (event) => {
event.preventDefault();
dropzone.style.borderColor = 'rgba(255,255,255,0.2)';
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length) {
fileInput.files = event.dataTransfer.files;
fileName.textContent = event.dataTransfer.files[0].name;
}
});
fileInput.addEventListener('change', () => {
fileName.textContent = fileInput.files.length ? fileInput.files[0].name : 'No file selected';
});
})();
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../admin/views/layout.php';

View File

@@ -0,0 +1,420 @@
<?php
declare(strict_types=1);
namespace Modules\Newsletter;
use Core\Http\Response;
use Core\Services\Auth;
use Core\Services\Database;
use Core\Services\Mailer;
use Core\Services\RateLimiter;
use Core\Services\Settings;
use Core\Views\View;
use PDO;
use Throwable;
class NewsletterController
{
private View $view;
public function __construct()
{
$this->view = new View(__DIR__ . '/views');
}
public function subscribe(): Response
{
$email = trim((string)($_POST['email'] ?? ''));
$name = trim((string)($_POST['name'] ?? ''));
if ($email === '') {
return new Response('Missing email', 400);
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return new Response('Invalid email', 400);
}
$limitKey = sha1(strtolower($email) . '|' . $this->clientIp());
if (RateLimiter::tooMany('newsletter_subscribe', $limitKey, 8, 600)) {
return new Response('Too many requests. Please wait 10 minutes and try again.', 429);
}
$db = Database::get();
if ($db instanceof PDO) {
$stmt = $db->prepare("
INSERT INTO ac_newsletter_subscribers (email, name, status, source)
VALUES (:email, :name, 'subscribed', 'form')
ON DUPLICATE KEY UPDATE name = VALUES(name), status = 'subscribed', unsubscribed_at = NULL
");
$stmt->execute([
':email' => $email,
':name' => $name !== '' ? $name : null,
]);
}
$this->syncMailchimp($email, $name);
return new Response('Subscribed', 200);
}
public function unsubscribeForm(): Response
{
$email = trim((string)($_GET['email'] ?? ''));
return new Response($this->view->render('site/unsubscribe.php', [
'title' => 'Unsubscribe',
'email' => $email,
'status' => '',
]));
}
public function unsubscribe(): Response
{
$email = trim((string)($_POST['email'] ?? ''));
$status = 'Email is required.';
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$limitKey = sha1(strtolower($email) . '|' . $this->clientIp());
if (RateLimiter::tooMany('newsletter_unsubscribe', $limitKey, 8, 600)) {
return new Response($this->view->render('site/unsubscribe.php', [
'title' => 'Unsubscribe',
'email' => $email,
'status' => 'Too many unsubscribe attempts. Please wait 10 minutes.',
]), 429);
}
}
$db = Database::get();
if ($db instanceof PDO && $email !== '') {
$stmt = $db->prepare("UPDATE ac_newsletter_subscribers SET status = 'unsubscribed', unsubscribed_at = NOW() WHERE email = :email");
$stmt->execute([':email' => $email]);
$status = 'You have been unsubscribed.';
}
return new Response($this->view->render('site/unsubscribe.php', [
'title' => 'Unsubscribe',
'email' => $email,
'status' => $status,
]));
}
private function clientIp(): string
{
foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'] as $key) {
$value = trim((string)($_SERVER[$key] ?? ''));
if ($value === '') {
continue;
}
if ($key === 'HTTP_X_FORWARDED_FOR') {
$parts = array_map('trim', explode(',', $value));
$value = (string)($parts[0] ?? '');
}
if ($value !== '') {
return substr($value, 0, 64);
}
}
return '0.0.0.0';
}
public function adminIndex(): Response
{
if ($guard = $this->guard(['admin', 'manager'])) {
return $guard;
}
$db = Database::get();
$campaigns = [];
if ($db instanceof PDO) {
$stmt = $db->query("SELECT id, title, subject, status, sent_at, scheduled_at FROM ac_newsletter_campaigns ORDER BY created_at DESC");
$campaigns = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
}
return new Response($this->view->render('admin/index.php', [
'title' => 'Newsletter',
'campaigns' => $campaigns,
]));
}
public function adminSubscribers(): Response
{
if ($guard = $this->guard(['admin', 'manager'])) {
return $guard;
}
$db = Database::get();
$subscribers = [];
if ($db instanceof PDO) {
$stmt = $db->query("SELECT id, email, name, status, created_at FROM ac_newsletter_subscribers ORDER BY created_at DESC");
$subscribers = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
}
return new Response($this->view->render('admin/subscribers.php', [
'title' => 'Newsletter',
'subscribers' => $subscribers,
]));
}
public function adminEdit(): Response
{
if ($guard = $this->guard(['admin', 'manager'])) {
return $guard;
}
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$campaign = [
'id' => 0,
'title' => '',
'subject' => '',
'content_html' => '',
'status' => 'draft',
'scheduled_at' => '',
];
$db = Database::get();
if ($id > 0 && $db instanceof PDO) {
$stmt = $db->prepare("SELECT * FROM ac_newsletter_campaigns WHERE id = :id LIMIT 1");
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row) {
$campaign = $row;
}
}
return new Response($this->view->render('admin/edit.php', [
'title' => $id > 0 ? 'Edit Campaign' : 'New Campaign',
'campaign' => $campaign,
'error' => '',
]));
}
public function adminSave(): Response
{
if ($guard = $this->guard(['admin', 'manager'])) {
return $guard;
}
$db = Database::get();
if (!$db instanceof PDO) {
return new Response('', 302, ['Location' => '/admin/newsletter']);
}
$id = (int)($_POST['id'] ?? 0);
$title = trim((string)($_POST['title'] ?? ''));
$subject = trim((string)($_POST['subject'] ?? ''));
$content = (string)($_POST['content_html'] ?? '');
$scheduledAt = trim((string)($_POST['scheduled_at'] ?? ''));
if ($title === '' || $subject === '') {
return $this->renderEditError($id, $title, $subject, $content, 'Title and subject are required.');
}
try {
$status = $scheduledAt !== '' ? 'scheduled' : 'draft';
if ($id > 0) {
$stmt = $db->prepare("
UPDATE ac_newsletter_campaigns
SET title = :title, subject = :subject, content_html = :content,
status = :status, scheduled_at = :scheduled_at
WHERE id = :id
");
$stmt->execute([
':title' => $title,
':subject' => $subject,
':content' => $content,
':status' => $status,
':scheduled_at' => $scheduledAt !== '' ? $scheduledAt : null,
':id' => $id,
]);
} else {
$stmt = $db->prepare("
INSERT INTO ac_newsletter_campaigns (title, subject, content_html, status, scheduled_at)
VALUES (:title, :subject, :content, :status, :scheduled_at)
");
$stmt->execute([
':title' => $title,
':subject' => $subject,
':content' => $content,
':status' => $status,
':scheduled_at' => $scheduledAt !== '' ? $scheduledAt : null,
]);
}
} catch (Throwable $e) {
return $this->renderEditError($id, $title, $subject, $content, 'Unable to save campaign.');
}
return new Response('', 302, ['Location' => '/admin/newsletter']);
}
public function adminSend(): Response
{
if ($guard = $this->guard(['admin', 'manager'])) {
return $guard;
}
$db = Database::get();
if (!$db instanceof PDO) {
return new Response('', 302, ['Location' => '/admin/newsletter']);
}
$id = (int)($_POST['id'] ?? 0);
$stmt = $db->prepare("SELECT id, subject, content_html FROM ac_newsletter_campaigns WHERE id = :id LIMIT 1");
$stmt->execute([':id' => $id]);
$campaign = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$campaign) {
return new Response('', 302, ['Location' => '/admin/newsletter']);
}
$subStmt = $db->query("SELECT id, email FROM ac_newsletter_subscribers WHERE status = 'subscribed'");
$subs = $subStmt ? $subStmt->fetchAll(PDO::FETCH_ASSOC) : [];
$settings = [
'smtp_host' => Settings::get('smtp_host'),
'smtp_port' => Settings::get('smtp_port'),
'smtp_user' => Settings::get('smtp_user'),
'smtp_pass' => Settings::get('smtp_pass'),
'smtp_encryption' => Settings::get('smtp_encryption'),
'smtp_from_email' => Settings::get('smtp_from_email'),
'smtp_from_name' => Settings::get('smtp_from_name'),
];
foreach ($subs as $sub) {
$result = Mailer::send((string)$sub['email'], (string)$campaign['subject'], (string)$campaign['content_html'], $settings);
$sendStmt = $db->prepare("
INSERT INTO ac_newsletter_sends (campaign_id, subscriber_id, status, sent_at, error_text)
VALUES (:campaign_id, :subscriber_id, :status, NOW(), :error_text)
");
$sendStmt->execute([
':campaign_id' => (int)$campaign['id'],
':subscriber_id' => (int)$sub['id'],
':status' => $result['ok'] ? 'sent' : 'failed',
':error_text' => $result['ok'] ? null : (string)$result['error'],
]);
}
$db->prepare("UPDATE ac_newsletter_campaigns SET status = 'sent', sent_at = NOW() WHERE id = :id")
->execute([':id' => $id]);
return new Response('', 302, ['Location' => '/admin/newsletter']);
}
public function adminTestSend(): Response
{
if ($guard = $this->guard(['admin', 'manager'])) {
return $guard;
}
$db = Database::get();
if (!$db instanceof PDO) {
return new Response('', 302, ['Location' => '/admin/newsletter']);
}
$id = (int)($_POST['id'] ?? 0);
$email = trim((string)($_POST['test_email'] ?? ''));
if ($id <= 0 || $email === '') {
return new Response('', 302, ['Location' => '/admin/newsletter/campaigns/edit?id=' . $id]);
}
$stmt = $db->prepare("SELECT subject, content_html FROM ac_newsletter_campaigns WHERE id = :id LIMIT 1");
$stmt->execute([':id' => $id]);
$campaign = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$campaign) {
return new Response('', 302, ['Location' => '/admin/newsletter']);
}
$settings = [
'smtp_host' => Settings::get('smtp_host'),
'smtp_port' => Settings::get('smtp_port'),
'smtp_user' => Settings::get('smtp_user'),
'smtp_pass' => Settings::get('smtp_pass'),
'smtp_encryption' => Settings::get('smtp_encryption'),
'smtp_from_email' => Settings::get('smtp_from_email'),
'smtp_from_name' => Settings::get('smtp_from_name'),
];
Mailer::send($email, (string)$campaign['subject'], (string)$campaign['content_html'], $settings);
return new Response('', 302, ['Location' => '/admin/newsletter/campaigns/edit?id=' . $id]);
}
public function adminProcessQueue(): Response
{
if ($guard = $this->guard(['admin', 'manager'])) {
return $guard;
}
$db = Database::get();
if (!$db instanceof PDO) {
return new Response('', 302, ['Location' => '/admin/newsletter']);
}
$stmt = $db->prepare("
SELECT id FROM ac_newsletter_campaigns
WHERE status = 'scheduled' AND scheduled_at IS NOT NULL AND scheduled_at <= NOW()
");
$stmt->execute();
$campaigns = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($campaigns as $campaign) {
$_POST['id'] = (int)$campaign['id'];
$this->adminSend();
}
return new Response('', 302, ['Location' => '/admin/newsletter']);
}
public function adminDeleteSubscriber(): Response
{
if ($guard = $this->guard(['admin', 'manager'])) {
return $guard;
}
$id = (int)($_POST['id'] ?? 0);
$db = Database::get();
if ($db instanceof PDO && $id > 0) {
$db->prepare("DELETE FROM ac_newsletter_subscribers WHERE id = :id")->execute([':id' => $id]);
}
return new Response('', 302, ['Location' => '/admin/newsletter/subscribers']);
}
private function renderEditError(int $id, string $title, string $subject, string $content, string $error): Response
{
$campaign = [
'id' => $id,
'title' => $title,
'subject' => $subject,
'content_html' => $content,
];
return new Response($this->view->render('admin/edit.php', [
'title' => $id > 0 ? 'Edit Campaign' : 'New Campaign',
'campaign' => $campaign,
'error' => $error,
]));
}
private function guard(array $roles): ?Response
{
if (!Auth::check()) {
return new Response('', 302, ['Location' => '/admin/login']);
}
if (!Auth::hasRole($roles)) {
return new Response('', 302, ['Location' => '/admin']);
}
return null;
}
private function syncMailchimp(string $email, string $name): void
{
$apiKey = Settings::get('mailchimp_api_key');
$listId = Settings::get('mailchimp_list_id');
if ($apiKey === '' || $listId === '') {
return;
}
$parts = explode('-', $apiKey);
$dc = $parts[1] ?? '';
if ($dc === '') {
return;
}
$subscriberHash = md5(strtolower($email));
$url = "https://{$dc}.api.mailchimp.com/3.0/lists/{$listId}/members/{$subscriberHash}";
$payload = json_encode([
'email_address' => $email,
'status' => 'subscribed',
'merge_fields' => [
'FNAME' => $name,
],
]);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_USERPWD, 'user:' . $apiKey);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_exec($ch);
curl_close($ch);
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
use Core\Http\Router;
use Core\Services\Shortcodes;
use Modules\Newsletter\NewsletterController;
require_once __DIR__ . '/NewsletterController.php';
Shortcodes::register('newsletter-signup', static function (array $attrs = []): string {
$title = trim((string)($attrs['title'] ?? 'Join the newsletter'));
$button = trim((string)($attrs['button'] ?? 'Subscribe'));
$placeholder = trim((string)($attrs['placeholder'] ?? 'you@example.com'));
if ($title === '') {
$title = 'Join the newsletter';
}
if ($button === '') {
$button = 'Subscribe';
}
if ($placeholder === '') {
$placeholder = 'you@example.com';
}
return '<form method="post" action="/newsletter/subscribe" class="ac-shortcode-newsletter-form">'
. '<div class="ac-shortcode-newsletter-title">' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '</div>'
. '<div class="ac-shortcode-newsletter-row">'
. '<input type="email" name="email" required class="ac-shortcode-newsletter-input" placeholder="' . htmlspecialchars($placeholder, ENT_QUOTES, 'UTF-8') . '">'
. '<button type="submit" class="ac-shortcode-newsletter-btn">' . htmlspecialchars($button, ENT_QUOTES, 'UTF-8') . '</button>'
. '</div>'
. '</form>';
});
Shortcodes::register('newsletter-unsubscribe', static function (array $attrs = []): string {
$label = trim((string)($attrs['label'] ?? 'Unsubscribe'));
if ($label === '') {
$label = 'Unsubscribe';
}
$token = trim((string)($attrs['token'] ?? ''));
$href = '/newsletter/unsubscribe';
if ($token !== '') {
$href .= '?token=' . rawurlencode($token);
}
return '<a class="ac-shortcode-link ac-shortcode-newsletter-unsubscribe" href="' . htmlspecialchars($href, ENT_QUOTES, 'UTF-8') . '">' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '</a>';
});
Shortcodes::register('newsletter-unsubscribe-form', static function (array $attrs = []): string {
$title = trim((string)($attrs['title'] ?? 'Unsubscribe from newsletter'));
$button = trim((string)($attrs['button'] ?? 'Unsubscribe'));
if ($title === '') {
$title = 'Unsubscribe from newsletter';
}
if ($button === '') {
$button = 'Unsubscribe';
}
return '<form method="post" action="/newsletter/unsubscribe" class="ac-shortcode-newsletter-form">'
. '<div class="ac-shortcode-newsletter-title">' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '</div>'
. '<div class="ac-shortcode-newsletter-row">'
. '<input type="email" name="email" required class="ac-shortcode-newsletter-input" placeholder="you@example.com">'
. '<button type="submit" class="ac-shortcode-newsletter-btn">' . htmlspecialchars($button, ENT_QUOTES, 'UTF-8') . '</button>'
. '</div>'
. '</form>';
});
return function (Router $router): void {
$controller = new NewsletterController();
$router->post('/newsletter/subscribe', [$controller, 'subscribe']);
$router->get('/newsletter/unsubscribe', [$controller, 'unsubscribeForm']);
$router->post('/newsletter/unsubscribe', [$controller, 'unsubscribe']);
$router->get('/admin/newsletter', [$controller, 'adminIndex']);
$router->get('/admin/newsletter/campaigns/new', [$controller, 'adminEdit']);
$router->get('/admin/newsletter/campaigns/edit', [$controller, 'adminEdit']);
$router->post('/admin/newsletter/campaigns/save', [$controller, 'adminSave']);
$router->post('/admin/newsletter/campaigns/send', [$controller, 'adminSend']);
$router->post('/admin/newsletter/campaigns/test', [$controller, 'adminTestSend']);
$router->post('/admin/newsletter/campaigns/process', [$controller, 'adminProcessQueue']);
$router->get('/admin/newsletter/subscribers', [$controller, 'adminSubscribers']);
$router->post('/admin/newsletter/subscribers/delete', [$controller, 'adminDeleteSubscriber']);
};

View File

@@ -0,0 +1,96 @@
<?php
$pageTitle = $title ?? 'Edit Campaign';
$campaign = $campaign ?? [];
$error = $error ?? '';
ob_start();
?>
<section class="admin-card">
<div class="badge">Newsletter</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
<div>
<h1 style="font-size:28px; margin:0;"><?= htmlspecialchars($pageTitle, ENT_QUOTES, 'UTF-8') ?></h1>
<p style="color: var(--muted); margin-top:6px;">Write a full HTML campaign.</p>
</div>
<a href="/admin/newsletter" class="btn outline">Back</a>
</div>
<?php if ($error): ?>
<div style="margin-top:16px; color:#f3b0b0; font-size:13px;"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<form method="post" action="/admin/newsletter/campaigns/save" style="margin-top:18px; display:grid; gap:16px;">
<input type="hidden" name="id" value="<?= (int)($campaign['id'] ?? 0) ?>">
<div class="admin-card" style="padding:16px;">
<div style="display:grid; gap:12px;">
<label class="label">Title</label>
<input class="input" name="title" value="<?= htmlspecialchars((string)($campaign['title'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="Monthly Update">
<label class="label">Subject</label>
<input class="input" name="subject" value="<?= htmlspecialchars((string)($campaign['subject'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="AudioCore Newsletter">
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<label class="label" style="margin:0;">Content (HTML)</label>
<button type="button" class="btn outline small" data-media-picker="newsletter_content_html">Insert Media</button>
</div>
<textarea class="input" id="newsletter_content_html" name="content_html" rows="18" style="resize:vertical; font-family:'IBM Plex Mono', monospace; font-size:13px; line-height:1.6;"><?= htmlspecialchars((string)($campaign['content_html'] ?? ''), ENT_QUOTES, 'UTF-8') ?></textarea>
<label class="label">Schedule Send (optional)</label>
<input class="input" name="scheduled_at" value="<?= htmlspecialchars((string)($campaign['scheduled_at'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="2026-01-25 18:30:00">
</div>
</div>
<div style="display:flex; justify-content:flex-end; gap:12px; align-items:center;">
<button type="button" id="previewNewsletter" class="btn outline">Preview</button>
<button type="submit" class="btn">Save campaign</button>
</div>
</form>
<?php if (!empty($campaign['id'])): ?>
<form method="post" action="/admin/newsletter/campaigns/test" style="margin-top:12px; display:flex; gap:10px; align-items:center;">
<input type="hidden" name="id" value="<?= (int)($campaign['id'] ?? 0) ?>">
<input class="input" name="test_email" placeholder="test@example.com" style="max-width:280px;">
<button type="submit" class="btn outline small">Send Test</button>
</form>
<?php endif; ?>
</section>
<script>
(function () {
const previewBtn = document.getElementById('previewNewsletter');
const contentEl = document.querySelector('textarea[name="content_html"]');
if (!previewBtn || !contentEl) {
return;
}
previewBtn.addEventListener('click', function () {
const previewWindow = window.open('', 'newsletterPreview', 'width=1000,height=800');
if (!previewWindow) {
return;
}
const html = contentEl.value || '';
const doc = previewWindow.document;
doc.open();
doc.write(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Newsletter Preview</title>
<style>
body { margin: 0; background: #f0f2f5; font-family: Arial, sans-serif; }
.wrap { padding: 24px; display: flex; justify-content: center; }
.frame { max-width: 680px; width: 100%; background: #ffffff; border-radius: 12px; padding: 24px; box-shadow: 0 12px 30px rgba(0,0,0,0.15); }
img { max-width: 100%; height: auto; }
</style>
</head>
<body>
<div class="wrap">
<div class="frame">
${html}
</div>
</div>
</body>
</html>`);
doc.close();
});
})();
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../admin/views/layout.php';

View File

@@ -0,0 +1,259 @@
<?php
$pageTitle = 'Newsletter';
$campaigns = $campaigns ?? [];
ob_start();
?>
<section class="admin-card">
<div class="badge">Newsletter</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
<div>
<h1 style="font-size:28px; margin:0;">Campaigns</h1>
<p style="color: var(--muted); margin-top:6px;">Build and send HTML newsletters.</p>
</div>
<div style="display:flex; gap:10px;">
<a href="/admin/newsletter/subscribers" class="btn outline small">Subscribers</a>
<form method="post" action="/admin/newsletter/campaigns/process" style="display:inline;">
<button type="submit" class="btn outline small">Process Queue</button>
</form>
<a href="/admin/newsletter/campaigns/new" class="btn small">New Campaign</a>
</div>
</div>
<div style="margin-top:18px; display:grid; gap:10px;">
<div style="display:grid; grid-template-columns: 2fr 1.4fr 120px 140px 140px 140px; gap:12px; font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<div>Title</div>
<div>Subject</div>
<div>Status</div>
<div>Scheduled</div>
<div>Sent At</div>
<div>Actions</div>
</div>
<?php if (!$campaigns): ?>
<div style="color: var(--muted); font-size:13px;">No campaigns yet.</div>
<?php else: ?>
<?php foreach ($campaigns as $campaign): ?>
<div style="display:grid; grid-template-columns: 2fr 1.4fr 120px 140px 140px 140px; gap:12px; align-items:center; padding:10px 12px; border-radius:16px; border:1px solid var(--stroke); background: rgba(14,14,16,0.9);">
<div style="font-weight:600;"><?= htmlspecialchars((string)($campaign['title'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:12px; color:var(--muted);"><?= htmlspecialchars((string)($campaign['subject'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:12px; color:<?= ((string)($campaign['status'] ?? '') === 'sent') ? 'var(--accent-2)' : 'var(--muted)' ?>;">
<?= htmlspecialchars((string)($campaign['status'] ?? 'draft'), ENT_QUOTES, 'UTF-8') ?>
</div>
<div style="font-size:12px; color:var(--muted);"><?= htmlspecialchars((string)($campaign['scheduled_at'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:12px; color:var(--muted);"><?= htmlspecialchars((string)($campaign['sent_at'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
<div style="display:flex; gap:8px;">
<a href="/admin/newsletter/campaigns/edit?id=<?= (int)$campaign['id'] ?>" class="btn outline small">Edit</a>
<form method="post" action="/admin/newsletter/campaigns/send" onsubmit="return confirm('Send this campaign now?');">
<input type="hidden" name="id" value="<?= (int)$campaign['id'] ?>">
<button type="submit" class="btn small">Send</button>
</form>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</section>
<section class="admin-card" style="margin-top:16px;">
<div class="badge">Signup Form</div>
<p style="color: var(--muted); margin-top:10px;">Choose a signup form template to paste into any custom page.</p>
<div style="margin-top:12px; display:grid; gap:12px;">
<select id="signupTemplateSelect" class="input" style="text-transform:none;">
<option value="">Select template</option>
<option value="signup-compact">Compact Inline</option>
<option value="signup-card">Card Form</option>
<option value="signup-minimal">Minimal</option>
<option value="signup-banner">Banner CTA</option>
</select>
<div id="signupTemplatePreview" style="border:1px solid rgba(255,255,255,0.12); border-radius:14px; padding:12px; background: rgba(0,0,0,0.2); min-height:120px;"></div>
<div style="display:flex; gap:10px; justify-content:flex-end;">
<button type="button" id="copySignupTemplate" class="btn outline small">Copy HTML</button>
</div>
</div>
</section>
<section class="admin-card" style="margin-top:16px;">
<div class="badge">Template Starter</div>
<p style="color: var(--muted); margin-top:10px;">Pick a campaign template, preview it, then copy HTML.</p>
<div style="margin-top:12px; display:grid; gap:12px;">
<select id="newsletterTemplateSelect" class="input" style="text-transform:none;">
<option value="">Select template</option>
<option value="email-minimal">Minimal Update</option>
<option value="email-feature">Feature Promo</option>
<option value="email-digest">Weekly Digest</option>
<option value="email-event">Event Invite</option>
</select>
<div id="newsletterTemplatePreview" style="border:1px solid rgba(255,255,255,0.12); border-radius:14px; padding:12px; background: rgba(0,0,0,0.2); min-height:140px;"></div>
<div style="display:flex; gap:10px; justify-content:flex-end;">
<button type="button" id="copyNewsletterTemplate" class="btn outline small">Copy HTML</button>
</div>
</div>
</section>
<script>
(function () {
const signupTemplates = {
'signup-compact': {
html: `<form method="post" action="/newsletter/subscribe" style="display:flex; gap:8px; flex-wrap:wrap;">
<input type="text" name="name" placeholder="Name" style="padding:10px 12px; border-radius:10px; border:1px solid #e5e7eb;">
<input type="email" name="email" placeholder="Email" required style="padding:10px 12px; border-radius:10px; border:1px solid #e5e7eb;">
<button type="submit" style="padding:10px 16px; border-radius:999px; border:none; background:#22f2a5; color:#071016;">Subscribe</button>
</form>`,
preview: `<div style="background:#0f1117; padding:12px; border-radius:12px;">
<div style="color:#c9cbd4; font-size:12px; margin-bottom:8px;">Compact Inline</div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<div style="flex:1; min-width:120px; height:34px; border-radius:8px; background:#1b1e26;"></div>
<div style="flex:1; min-width:120px; height:34px; border-radius:8px; background:#1b1e26;"></div>
<div style="width:110px; height:34px; border-radius:999px; background:#22f2a5;"></div>
</div>
</div>`
},
'signup-card': {
html: `<div style="padding:18px; border:1px solid #e5e7eb; border-radius:12px;">
<h3 style="margin:0 0 8px;">Join the newsletter</h3>
<p style="margin:0 0 12px;">Monthly updates and releases.</p>
<form method="post" action="/newsletter/subscribe" style="display:grid; gap:8px;">
<input type="text" name="name" placeholder="Name" style="padding:10px 12px; border-radius:10px; border:1px solid #e5e7eb;">
<input type="email" name="email" placeholder="Email" required style="padding:10px 12px; border-radius:10px; border:1px solid #e5e7eb;">
<button type="submit" style="padding:10px 16px; border-radius:999px; border:none; background:#22f2a5; color:#071016;">Subscribe</button>
</form>
</div>`,
preview: `<div style="background:#0f1117; padding:12px; border-radius:12px;">
<div style="height:10px; width:120px; background:#2a2e3a; border-radius:6px; margin-bottom:10px;"></div>
<div style="height:8px; width:180px; background:#1b1e26; border-radius:6px; margin-bottom:12px;"></div>
<div style="display:grid; gap:8px;">
<div style="height:34px; border-radius:8px; background:#1b1e26;"></div>
<div style="height:34px; border-radius:8px; background:#1b1e26;"></div>
<div style="height:34px; width:120px; border-radius:999px; background:#22f2a5;"></div>
</div>
</div>`
},
'signup-minimal': {
html: `<form method="post" action="/newsletter/subscribe">
<input type="email" name="email" placeholder="Email" required style="padding:10px 12px; border-radius:10px; border:1px solid #e5e7eb; width:100%; max-width:320px;">
<button type="submit" style="margin-top:8px; padding:8px 14px; border-radius:999px; border:1px solid #111; background:#111; color:#fff;">Subscribe</button>
</form>`,
preview: `<div style="background:#0f1117; padding:12px; border-radius:12px;">
<div style="height:34px; width:240px; border-radius:8px; background:#1b1e26;"></div>
<div style="height:30px; width:110px; border-radius:999px; background:#2a2e3a; margin-top:8px;"></div>
</div>`
},
'signup-banner': {
html: `<div style="padding:16px; border-radius:12px; background:#0f172a; color:#fff; display:flex; flex-wrap:wrap; gap:12px; align-items:center;">
<div style="flex:1; min-width:180px;">
<strong>Get updates</strong><br>
New releases, events, and drops.
</div>
<form method="post" action="/newsletter/subscribe" style="display:flex; gap:8px; flex-wrap:wrap;">
<input type="email" name="email" placeholder="Email" required style="padding:10px 12px; border-radius:10px; border:1px solid #1f2937;">
<button type="submit" style="padding:10px 16px; border-radius:999px; border:none; background:#22f2a5; color:#071016;">Subscribe</button>
</form>
</div>`,
preview: `<div style="background:#0f1117; padding:12px; border-radius:12px;">
<div style="height:16px; width:160px; background:#1b1e26; border-radius:6px; margin-bottom:8px;"></div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<div style="height:34px; width:160px; border-radius:8px; background:#1b1e26;"></div>
<div style="height:34px; width:110px; border-radius:999px; background:#22f2a5;"></div>
</div>
</div>`
}
};
const emailTemplates = {
'email-minimal': {
html: `<div style="font-family:Arial, sans-serif; color:#111; line-height:1.6;">
<h1 style="margin:0 0 8px;">AudioCore Update</h1>
<p style="margin:0 0 16px;">Latest releases, news, and announcements.</p>
<p style="margin:0;">Thanks for listening.</p>
</div>`,
preview: `<div style="background:#fff; color:#111; padding:12px; border-radius:8px;">
<div style="height:16px; width:180px; background:#e5e7eb; border-radius:6px; margin-bottom:8px;"></div>
<div style="height:10px; width:240px; background:#f3f4f6; border-radius:6px; margin-bottom:10px;"></div>
<div style="height:10px; width:120px; background:#f3f4f6; border-radius:6px;"></div>
</div>`
},
'email-feature': {
html: `<div style="font-family:Arial, sans-serif; color:#111; line-height:1.6;">
<h1 style="margin:0 0 8px;">Featured Release</h1>
<img src="https://placehold.co/600x360/111827/ffffff?text=Cover" alt="" style="width:100%; border-radius:10px; margin:8px 0;">
<p style="margin:0 0 12px;">REC008 Night Drive EP now available.</p>
<a href="#" style="display:inline-block; padding:10px 16px; border-radius:999px; background:#22f2a5; color:#071016; text-decoration:none;">Listen now</a>
</div>`,
preview: `<div style="background:#fff; color:#111; padding:12px; border-radius:8px;">
<div style="height:16px; width:160px; background:#e5e7eb; border-radius:6px; margin-bottom:8px;"></div>
<div style="height:120px; background:#e5e7eb; border-radius:8px; margin-bottom:10px;"></div>
<div style="height:10px; width:200px; background:#f3f4f6; border-radius:6px; margin-bottom:12px;"></div>
<div style="height:30px; width:120px; background:#22f2a5; border-radius:999px;"></div>
</div>`
},
'email-digest': {
html: `<div style="font-family:Arial, sans-serif; color:#111; line-height:1.6;">
<h1 style="margin:0 0 12px;">Weekly Digest</h1>
<ul style="padding-left:18px; margin:0 0 12px;">
<li>New release: REC009 Twilight Runner</li>
<li>Label spotlight: Neon District</li>
<li>Playlist update: Midnight Circuit</li>
</ul>
<p style="margin:0;">See the full catalog for more.</p>
</div>`,
preview: `<div style="background:#fff; color:#111; padding:12px; border-radius:8px;">
<div style="height:16px; width:160px; background:#e5e7eb; border-radius:6px; margin-bottom:10px;"></div>
<div style="height:10px; width:240px; background:#f3f4f6; border-radius:6px; margin-bottom:6px;"></div>
<div style="height:10px; width:200px; background:#f3f4f6; border-radius:6px; margin-bottom:6px;"></div>
<div style="height:10px; width:180px; background:#f3f4f6; border-radius:6px; margin-bottom:12px;"></div>
<div style="height:10px; width:200px; background:#f3f4f6; border-radius:6px;"></div>
</div>`
},
'email-event': {
html: `<div style="font-family:Arial, sans-serif; color:#111; line-height:1.6;">
<h1 style="margin:0 0 8px;">Live Showcase</h1>
<p style="margin:0 0 12px;">Friday, 8PM · Warehouse 12</p>
<a href="#" style="display:inline-block; padding:10px 16px; border-radius:999px; background:#111; color:#fff; text-decoration:none;">RSVP</a>
</div>`,
preview: `<div style="background:#fff; color:#111; padding:12px; border-radius:8px;">
<div style="height:16px; width:140px; background:#e5e7eb; border-radius:6px; margin-bottom:8px;"></div>
<div style="height:10px; width:160px; background:#f3f4f6; border-radius:6px; margin-bottom:12px;"></div>
<div style="height:30px; width:80px; background:#111; border-radius:999px;"></div>
</div>`
}
};
function wirePicker(selectId, previewId, copyId, templates) {
const select = document.getElementById(selectId);
const preview = document.getElementById(previewId);
const copyBtn = document.getElementById(copyId);
if (!select || !preview || !copyBtn) {
return;
}
function renderPreview() {
const key = select.value;
preview.innerHTML = key && templates[key] ? templates[key].preview : '';
}
select.addEventListener('change', renderPreview);
copyBtn.addEventListener('click', async function () {
const key = select.value;
if (!key || !templates[key]) {
return;
}
const html = templates[key].html;
try {
await navigator.clipboard.writeText(html);
copyBtn.textContent = 'Copied';
setTimeout(() => (copyBtn.textContent = 'Copy HTML'), 1200);
} catch (err) {
copyBtn.textContent = 'Copy failed';
setTimeout(() => (copyBtn.textContent = 'Copy HTML'), 1200);
}
});
renderPreview();
}
wirePicker('signupTemplateSelect', 'signupTemplatePreview', 'copySignupTemplate', signupTemplates);
wirePicker('newsletterTemplateSelect', 'newsletterTemplatePreview', 'copyNewsletterTemplate', emailTemplates);
})();
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../admin/views/layout.php';

View File

@@ -0,0 +1,46 @@
<?php
$pageTitle = 'Newsletter';
$subscribers = $subscribers ?? [];
ob_start();
?>
<section class="admin-card">
<div class="badge">Newsletter</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
<div>
<h1 style="font-size:28px; margin:0;">Subscribers</h1>
<p style="color: var(--muted); margin-top:6px;">People on your newsletter list.</p>
</div>
<a href="/admin/newsletter" class="btn outline small">Back to Campaigns</a>
</div>
<div style="margin-top:18px; display:grid; gap:10px;">
<div style="display:grid; grid-template-columns: 1.5fr 1.5fr 120px 160px 120px; gap:12px; font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<div>Email</div>
<div>Name</div>
<div>Status</div>
<div>Joined</div>
<div>Actions</div>
</div>
<?php if (!$subscribers): ?>
<div style="color: var(--muted); font-size:13px;">No subscribers yet.</div>
<?php else: ?>
<?php foreach ($subscribers as $sub): ?>
<div style="display:grid; grid-template-columns: 1.5fr 1.5fr 120px 160px 120px; gap:12px; align-items:center; padding:10px 12px; border-radius:16px; border:1px solid var(--stroke); background: rgba(14,14,16,0.9);">
<div style="font-size:12px; color:var(--muted);"><?= htmlspecialchars((string)($sub['email'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:12px; color:var(--muted);"><?= htmlspecialchars((string)($sub['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:12px; color:<?= ((string)($sub['status'] ?? '') === 'subscribed') ? 'var(--accent-2)' : 'var(--muted)' ?>;">
<?= htmlspecialchars((string)($sub['status'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</div>
<div style="font-size:12px; color:var(--muted);"><?= htmlspecialchars((string)($sub['created_at'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
<form method="post" action="/admin/newsletter/subscribers/delete" onsubmit="return confirm('Delete this subscriber?');">
<input type="hidden" name="id" value="<?= (int)($sub['id'] ?? 0) ?>">
<button type="submit" class="btn outline small">Delete</button>
</form>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../admin/views/layout.php';

View File

@@ -0,0 +1,23 @@
<?php
$pageTitle = $title ?? 'Unsubscribe';
$email = $email ?? '';
$status = $status ?? '';
ob_start();
?>
<section class="card">
<div class="badge">Newsletter</div>
<h1 style="margin-top:12px; font-size:30px;">Unsubscribe</h1>
<p style="color:var(--muted); margin-top:8px;">Remove your email from the newsletter list.</p>
<?php if ($status !== ''): ?>
<div style="margin-top:12px; color:var(--muted);"><?= htmlspecialchars($status, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<form method="post" action="/newsletter/unsubscribe" style="margin-top:14px; display:grid; gap:10px;">
<input class="input" name="email" value="<?= htmlspecialchars((string)$email, ENT_QUOTES, 'UTF-8') ?>" placeholder="you@example.com">
<button type="submit" class="btn" style="width:max-content;">Unsubscribe</button>
</form>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../views/site/layout.php';

View File

@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
namespace Modules\Pages;
use Core\Http\Response;
use Core\Services\Auth;
use Core\Services\Database;
use Core\Services\Shortcodes;
use Core\Views\View;
use PDO;
use Throwable;
class PagesController
{
private View $view;
public function __construct()
{
$this->view = new View(__DIR__ . '/views');
}
public function show(): Response
{
$slug = trim((string)($_GET['slug'] ?? ''));
if ($slug === '') {
return $this->notFound();
}
$db = Database::get();
if (!$db instanceof PDO) {
return $this->notFound();
}
$stmt = $db->prepare("SELECT title, content_html FROM ac_pages WHERE slug = :slug AND is_published = 1 LIMIT 1");
$stmt->execute([':slug' => $slug]);
$page = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$page) {
return $this->notFound();
}
$rendered = Shortcodes::render((string)$page['content_html'], [
'page_slug' => $slug,
'page_title' => (string)$page['title'],
]);
// WYSIWYG editors often wrap shortcode blocks in <p>, which breaks grid placement.
$rendered = preg_replace(
'~<p>\s*(<(?:section|div|form|a)[^>]*class="[^"]*ac-shortcode[^"]*"[^>]*>.*?</(?:section|div|form|a)>)\s*</p>~is',
'$1',
$rendered
) ?? $rendered;
$rendered = preg_replace('~(<(?:section|div|form|a)[^>]*class="[^"]*ac-shortcode[^"]*"[^>]*>)\s*<br\s*/?>~i', '$1', $rendered) ?? $rendered;
$rendered = preg_replace('~<br\s*/?>\s*(</(?:section|div|form|a)>)~i', '$1', $rendered) ?? $rendered;
return new Response($this->view->render('site/show.php', [
'title' => (string)$page['title'],
'content_html' => $rendered,
]));
}
public function adminIndex(): Response
{
if ($guard = $this->guard(['admin', 'manager', 'editor'])) {
return $guard;
}
$db = Database::get();
$pages = [];
if ($db instanceof PDO) {
try {
$stmt = $db->query("SELECT id, title, slug, is_published, is_home, is_blog_index, updated_at FROM ac_pages ORDER BY updated_at DESC");
$pages = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
} catch (Throwable $e) {
$pages = [];
}
}
return new Response($this->view->render('admin/index.php', [
'title' => 'Pages',
'pages' => $pages,
]));
}
public function adminEdit(): Response
{
if ($guard = $this->guard(['admin', 'manager', 'editor'])) {
return $guard;
}
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$page = [
'id' => 0,
'title' => '',
'slug' => '',
'content_html' => '',
'is_published' => 0,
'is_home' => 0,
'is_blog_index' => 0,
];
$db = Database::get();
if ($id > 0 && $db instanceof PDO) {
$stmt = $db->prepare("SELECT id, title, slug, content_html, is_published, is_home, is_blog_index FROM ac_pages WHERE id = :id LIMIT 1");
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row) {
$page = $row;
}
}
return new Response($this->view->render('admin/edit.php', [
'title' => $id > 0 ? 'Edit Page' : 'New Page',
'page' => $page,
'error' => '',
]));
}
public function adminSave(): Response
{
if ($guard = $this->guard(['admin', 'manager', 'editor'])) {
return $guard;
}
$db = Database::get();
if (!$db instanceof PDO) {
return new Response('', 302, ['Location' => '/admin/pages']);
}
$id = (int)($_POST['id'] ?? 0);
$title = trim((string)($_POST['title'] ?? ''));
$slug = trim((string)($_POST['slug'] ?? ''));
$content = (string)($_POST['content_html'] ?? '');
$isPublished = isset($_POST['is_published']) ? 1 : 0;
$isHome = isset($_POST['is_home']) ? 1 : 0;
$isBlogIndex = isset($_POST['is_blog_index']) ? 1 : 0;
if ($title === '') {
return $this->renderEditError($id, $title, $slug, $content, $isPublished, $isHome, $isBlogIndex, 'Title is required.');
}
if ($slug === '') {
$slug = $this->slugify($title);
} else {
$slug = $this->slugify($slug);
}
try {
if ($id > 0) {
$chk = $db->prepare("SELECT id FROM ac_pages WHERE slug = :slug AND id != :id LIMIT 1");
$chk->execute([':slug' => $slug, ':id' => $id]);
} else {
$chk = $db->prepare("SELECT id FROM ac_pages WHERE slug = :slug LIMIT 1");
$chk->execute([':slug' => $slug]);
}
if ($chk->fetch()) {
return $this->renderEditError($id, $title, $slug, $content, $isPublished, $isHome, $isBlogIndex, 'Slug already exists.');
}
if ($isHome === 1) {
$db->exec("UPDATE ac_pages SET is_home = 0");
}
if ($isBlogIndex === 1) {
$db->exec("UPDATE ac_pages SET is_blog_index = 0");
}
if ($id > 0) {
$stmt = $db->prepare("
UPDATE ac_pages
SET title = :title, slug = :slug, content_html = :content,
is_published = :published, is_home = :is_home, is_blog_index = :is_blog_index
WHERE id = :id
");
$stmt->execute([
':title' => $title,
':slug' => $slug,
':content' => $content,
':published' => $isPublished,
':is_home' => $isHome,
':is_blog_index' => $isBlogIndex,
':id' => $id,
]);
} else {
$stmt = $db->prepare("
INSERT INTO ac_pages (title, slug, content_html, is_published, is_home, is_blog_index)
VALUES (:title, :slug, :content, :published, :is_home, :is_blog_index)
");
$stmt->execute([
':title' => $title,
':slug' => $slug,
':content' => $content,
':published' => $isPublished,
':is_home' => $isHome,
':is_blog_index' => $isBlogIndex,
]);
}
} catch (Throwable $e) {
return $this->renderEditError($id, $title, $slug, $content, $isPublished, $isHome, $isBlogIndex, 'Unable to save page.');
}
return new Response('', 302, ['Location' => '/admin/pages']);
}
public function adminDelete(): Response
{
if ($guard = $this->guard(['admin', 'manager'])) {
return $guard;
}
$db = Database::get();
if (!$db instanceof PDO) {
return new Response('', 302, ['Location' => '/admin/pages']);
}
$id = (int)($_POST['id'] ?? 0);
if ($id > 0) {
$stmt = $db->prepare("DELETE FROM ac_pages WHERE id = :id");
$stmt->execute([':id' => $id]);
}
return new Response('', 302, ['Location' => '/admin/pages']);
}
private function renderEditError(int $id, string $title, string $slug, string $content, int $isPublished, int $isHome, int $isBlogIndex, string $error): Response
{
$page = [
'id' => $id,
'title' => $title,
'slug' => $slug,
'content_html' => $content,
'is_published' => $isPublished,
'is_home' => $isHome,
'is_blog_index' => $isBlogIndex,
];
return new Response($this->view->render('admin/edit.php', [
'title' => $id > 0 ? 'Edit Page' : 'New Page',
'page' => $page,
'error' => $error,
]));
}
private function notFound(): Response
{
$view = new View();
return new Response($view->render('site/404.php', [
'title' => 'Not Found',
'message' => 'Page not found.',
]), 404);
}
private function slugify(string $value): string
{
$value = strtolower(trim($value));
$value = preg_replace('~[^a-z0-9]+~', '-', $value) ?? $value;
$value = trim($value, '-');
return $value !== '' ? $value : 'page';
}
private function guard(array $roles): ?Response
{
if (!Auth::check()) {
return new Response('', 302, ['Location' => '/admin/login']);
}
if (!Auth::hasRole($roles)) {
return new Response('', 302, ['Location' => '/admin']);
}
return null;
}
}

65
modules/pages/module.php Normal file
View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
use Core\Http\Router;
use Core\Services\Shortcodes;
use Modules\Pages\PagesController;
require_once __DIR__ . '/PagesController.php';
Shortcodes::register('hero', static function (array $attrs = []): string {
$eyebrow = trim((string)($attrs['eyebrow'] ?? 'Catalog Focus'));
$title = trim((string)($attrs['title'] ?? 'Latest Drops'));
$subtitle = trim((string)($attrs['subtitle'] ?? 'Discover fresh releases, artists, and top-selling tracks.'));
$ctaText = trim((string)($attrs['cta_text'] ?? 'Browse Releases'));
$ctaUrl = trim((string)($attrs['cta_url'] ?? '/releases'));
$secondaryText = trim((string)($attrs['secondary_text'] ?? 'Meet the Roster'));
$secondaryUrl = trim((string)($attrs['secondary_url'] ?? '/artists'));
$html = '<section class="ac-shortcode-hero">';
if ($eyebrow !== '') {
$html .= '<div class="ac-shortcode-hero-eyebrow">' . htmlspecialchars($eyebrow, ENT_QUOTES, 'UTF-8') . '</div>';
}
$html .= '<h2 class="ac-shortcode-hero-title">' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '</h2>';
if ($subtitle !== '') {
$html .= '<p class="ac-shortcode-hero-subtitle">' . htmlspecialchars($subtitle, ENT_QUOTES, 'UTF-8') . '</p>';
}
$html .= '<div class="ac-shortcode-hero-actions">'
. '<a class="ac-shortcode-hero-btn primary" href="' . htmlspecialchars($ctaUrl, ENT_QUOTES, 'UTF-8') . '">' . htmlspecialchars($ctaText, ENT_QUOTES, 'UTF-8') . '</a>';
if ($secondaryText !== '' && $secondaryUrl !== '') {
$html .= '<a class="ac-shortcode-hero-btn" href="' . htmlspecialchars($secondaryUrl, ENT_QUOTES, 'UTF-8') . '">' . htmlspecialchars($secondaryText, ENT_QUOTES, 'UTF-8') . '</a>';
}
$html .= '</div></section>';
return $html;
});
Shortcodes::register('home-catalog', static function (array $attrs = []): string {
$releaseLimit = max(1, min(12, (int)($attrs['release_limit'] ?? 8)));
$artistLimit = max(1, min(10, (int)($attrs['artist_limit'] ?? 6)));
$chartLimit = max(1, min(20, (int)($attrs['chart_limit'] ?? 10)));
$hero = Shortcodes::render('[hero eyebrow="Catalog Focus" title="Latest Drops" subtitle="Fresh releases, artists, and best sellers." cta_text="Browse Releases" cta_url="/releases" secondary_text="Meet Artists" secondary_url="/artists"]');
$releases = Shortcodes::render('[latest-releases limit="' . $releaseLimit . '"]');
$artists = Shortcodes::render('[new-artists limit="' . $artistLimit . '"]');
$chart = Shortcodes::render('[sale-chart type="tracks" window="latest" limit="' . $chartLimit . '"]');
$newsletter = Shortcodes::render('[newsletter-signup title="Join the list" button="Subscribe"]');
return '<section class="ac-home-catalog">'
. $hero
. '<div class="ac-home-columns">'
. '<div class="ac-home-main">' . $releases . $chart . '</div>'
. '<aside class="ac-home-side">' . $artists . $newsletter . '</aside>'
. '</div>'
. '</section>';
});
return function (Router $router): void {
$controller = new PagesController();
$router->get('/page', [$controller, 'show']);
$router->get('/admin/pages', [$controller, 'adminIndex']);
$router->get('/admin/pages/new', [$controller, 'adminEdit']);
$router->get('/admin/pages/edit', [$controller, 'adminEdit']);
$router->post('/admin/pages/save', [$controller, 'adminSave']);
$router->post('/admin/pages/delete', [$controller, 'adminDelete']);
};

View File

@@ -0,0 +1,122 @@
<?php
$pageTitle = $title ?? 'Edit Page';
$page = $page ?? [];
$error = $error ?? '';
ob_start();
?>
<section class="admin-card">
<div class="badge">Pages</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
<div>
<h1 style="font-size:28px; margin:0;"><?= htmlspecialchars($pageTitle, ENT_QUOTES, 'UTF-8') ?></h1>
<p style="color: var(--muted); margin-top:6px;">Create a custom page for the site.</p>
</div>
<a href="/admin/pages" class="btn outline">Back</a>
</div>
<?php if ($error): ?>
<div style="margin-top:16px; color:#f3b0b0; font-size:13px;"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<form method="post" action="/admin/pages/save" style="margin-top:18px; display:grid; gap:16px;">
<input type="hidden" name="id" value="<?= (int)($page['id'] ?? 0) ?>">
<div class="admin-card" style="padding:16px;">
<div style="display:grid; gap:12px;">
<label class="label">Title</label>
<input class="input" name="title" value="<?= htmlspecialchars((string)($page['title'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="About">
<label class="label">Slug</label>
<input class="input" name="slug" value="<?= htmlspecialchars((string)($page['slug'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="about">
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<label class="label" style="margin:0;">Content (HTML)</label>
<button type="button" class="btn outline small" data-media-picker="content_html">Insert Media</button>
</div>
<textarea class="input" name="content_html" id="content_html" rows="18" style="resize:vertical; font-family:'IBM Plex Mono', monospace; font-size:13px; line-height:1.6;"></textarea>
<label style="display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<input type="checkbox" name="is_published" value="1" <?= ((int)($page['is_published'] ?? 0) === 1) ? 'checked' : '' ?>>
Published
</label>
<label style="display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<input type="checkbox" name="is_home" value="1" <?= ((int)($page['is_home'] ?? 0) === 1) ? 'checked' : '' ?>>
Set as Home Page
</label>
<label style="display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<input type="checkbox" name="is_blog_index" value="1" <?= ((int)($page['is_blog_index'] ?? 0) === 1) ? 'checked' : '' ?>>
Set as Blog Page
</label>
</div>
</div>
<div style="display:flex; justify-content:flex-end; gap:12px; align-items:center;">
<button type="button" id="previewPage" class="btn outline">Preview</button>
<button type="submit" class="btn">Save page</button>
</div>
</form>
<?php if (!empty($page['id'])): ?>
<form method="post" action="/admin/pages/delete" onsubmit="return confirm('Delete this page?');" style="margin-top:12px;">
<input type="hidden" name="id" value="<?= (int)($page['id'] ?? 0) ?>">
<button type="submit" class="btn outline">Delete</button>
</form>
<?php endif; ?>
</section>
<script>
(function () {
const inputEl = document.getElementById('content_html');
const previewBtn = document.getElementById('previewPage');
if (!inputEl || !previewBtn) {
return;
}
const rawHtml = <?=
json_encode((string)($page['content_html'] ?? ''), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)
?>;
inputEl.value = rawHtml || '';
previewBtn.addEventListener('click', function () {
const previewWindow = window.open('', 'pagePreview', 'width=1200,height=800');
if (!previewWindow) {
return;
}
const html = inputEl.value || '';
const doc = previewWindow.document;
doc.open();
doc.write(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Page Preview</title>
<style>
body {
margin: 0;
font-family: 'Syne', sans-serif;
background-color: #14151a;
color: #f5f7ff;
}
.shell { max-width: 1080px; margin: 0 auto; padding: 32px 24px 64px; }
.card {
border-radius: 24px;
background: rgba(20, 22, 28, 0.75);
border: 1px solid rgba(255,255,255,0.12);
box-shadow: 0 20px 50px rgba(0,0,0,0.3);
padding: 28px;
}
a { color: #9ad4ff; }
</style>
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@500;600;700&family=IBM+Plex+Mono:wght@400;600&display=swap" rel="stylesheet">
</head>
<body>
<main class="shell">
<section class="card">
${html}
</section>
</main>
</body>
</html>`);
doc.close();
});
})();
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../admin/views/layout.php';

View File

@@ -0,0 +1,42 @@
<?php
$pageTitle = 'Home Page';
$page = $page ?? [];
$saved = ($saved ?? '') === '1';
ob_start();
?>
<section class="admin-card">
<div class="badge">Home</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
<div>
<h1 style="font-size:28px; margin:0;">Home Page</h1>
<p style="color: var(--muted); margin-top:6px;">Edit the front page content. Leave title blank to hide it.</p>
</div>
<a href="/" class="btn outline small">View site</a>
</div>
<?php if ($saved): ?>
<div style="margin-top:16px; color:var(--accent-2); font-size:13px;">Home page updated.</div>
<?php endif; ?>
<form method="post" action="/admin/home/save" style="margin-top:18px; display:grid; gap:16px;">
<div class="admin-card" style="padding:16px;">
<div style="display:grid; gap:12px;">
<label class="label">Title</label>
<input class="input" name="title" value="<?= htmlspecialchars((string)($page['title'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="AudioCore">
<label class="label">Content (HTML)</label>
<textarea class="input" name="content_html" rows="12" style="resize:vertical;"><?= htmlspecialchars((string)($page['content_html'] ?? ''), ENT_QUOTES, 'UTF-8') ?></textarea>
<label style="display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<input type="checkbox" name="is_published" value="1" <?= ((int)($page['is_published'] ?? 0) === 1) ? 'checked' : '' ?>>
Published
</label>
</div>
</div>
<div style="display:flex; justify-content:flex-end;">
<button type="submit" class="btn">Save home page</button>
</div>
</form>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../admin/views/layout.php';

View File

@@ -0,0 +1,58 @@
<?php
$pageTitle = 'Pages';
$pages = $pages ?? [];
ob_start();
?>
<section class="admin-card">
<div class="badge">Pages</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
<div>
<h1 style="font-size:28px; margin:0;">Pages</h1>
<p style="color: var(--muted); margin-top:6px;">Manage custom content pages.</p>
</div>
<a href="/admin/pages/new" class="btn small">New Page</a>
</div>
<div style="margin-top:18px; display:grid; gap:10px;">
<div style="display:grid; grid-template-columns: 2fr 1fr 120px 120px 120px 160px 120px; gap:12px; font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<div>Title</div>
<div>Slug</div>
<div>Status</div>
<div>Home</div>
<div>Blog</div>
<div>Updated</div>
<div>Actions</div>
</div>
<?php if (!$pages): ?>
<div style="color: var(--muted); font-size:13px;">No pages yet.</div>
<?php else: ?>
<?php foreach ($pages as $page): ?>
<div style="display:grid; grid-template-columns: 2fr 1fr 120px 120px 120px 160px 120px; gap:12px; align-items:center; padding:10px 12px; border-radius:16px; border:1px solid var(--stroke); background: rgba(14,14,16,0.9);">
<div style="font-weight:600;"><?= htmlspecialchars((string)($page['title'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:12px; color:var(--muted); font-family: 'IBM Plex Mono', monospace;">
<?= htmlspecialchars((string)($page['slug'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</div>
<div style="font-size:12px; color:<?= ((int)($page['is_published'] ?? 0) === 1) ? 'var(--accent-2)' : 'var(--muted)' ?>;">
<?= ((int)($page['is_published'] ?? 0) === 1) ? 'Published' : 'Draft' ?>
</div>
<div style="font-size:12px; color:<?= ((int)($page['is_home'] ?? 0) === 1) ? 'var(--accent-2)' : 'var(--muted)' ?>;">
<?= ((int)($page['is_home'] ?? 0) === 1) ? 'Yes' : 'No' ?>
</div>
<div style="font-size:12px; color:<?= ((int)($page['is_blog_index'] ?? 0) === 1) ? 'var(--accent-2)' : 'var(--muted)' ?>;">
<?= ((int)($page['is_blog_index'] ?? 0) === 1) ? 'Yes' : 'No' ?>
</div>
<div style="font-size:12px; color:var(--muted);">
<?= htmlspecialchars((string)($page['updated_at'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</div>
<div style="display:flex; gap:8px;">
<a href="/admin/pages/edit?id=<?= (int)$page['id'] ?>" class="btn outline small">Edit</a>
<a href="/<?= htmlspecialchars((string)($page['slug'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" class="btn outline small">View</a>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../admin/views/layout.php';

View File

@@ -0,0 +1,13 @@
<?php
$pageTitle = $title ?? 'Page';
$contentHtml = $content_html ?? '';
ob_start();
?>
<section class="card">
<div class="page-content" style="margin-top:14px; color:var(--muted);">
<?= $contentHtml ?>
</div>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../views/site/layout.php';

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Modules\Plugins;
use Core\Http\Response;
use Core\Services\Auth;
use Core\Services\Plugins;
use Core\Views\View;
class PluginsController
{
private View $view;
public function __construct()
{
$this->view = new View(__DIR__ . '/views');
}
public function index(): Response
{
if ($guard = $this->guard()) {
return $guard;
}
Plugins::sync();
return new Response($this->view->render('admin/index.php', [
'title' => 'Plugins',
'plugins' => Plugins::all(),
]));
}
public function toggle(): Response
{
if ($guard = $this->guard()) {
return $guard;
}
$slug = trim((string)($_POST['slug'] ?? ''));
$enabled = isset($_POST['enabled']) && $_POST['enabled'] === '1';
if ($slug !== '') {
Plugins::toggle($slug, $enabled);
}
return new Response('', 302, ['Location' => '/admin/plugins']);
}
private function guard(): ?Response
{
if (!Auth::check()) {
return new Response('', 302, ['Location' => '/admin/login']);
}
if (!Auth::hasRole(['admin'])) {
return new Response('', 302, ['Location' => '/admin']);
}
return null;
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
use Core\Http\Router;
use Modules\Plugins\PluginsController;
require_once __DIR__ . '/PluginsController.php';
return function (Router $router): void {
$controller = new PluginsController();
$router->get('/admin/plugins', [$controller, 'index']);
$router->post('/admin/plugins/toggle', [$controller, 'toggle']);
};

View File

@@ -0,0 +1,55 @@
<?php
$pageTitle = $title ?? 'Plugins';
$plugins = $plugins ?? [];
ob_start();
?>
<section class="admin-card">
<div class="badge">Plugins</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
<div>
<h1 style="font-size:28px; margin:0;">Plugins</h1>
<p style="color: var(--muted); margin-top:6px;">Enable or disable optional features.</p>
</div>
</div>
<?php if (!$plugins): ?>
<div style="margin-top:18px; color: var(--muted); font-size:13px;">No plugins found in <code>/dev/plugins</code>.</div>
<?php else: ?>
<div style="margin-top:18px; display:grid; gap:12px;">
<?php foreach ($plugins as $plugin): ?>
<div class="admin-card" style="padding:16px; display:grid; gap:10px;">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:16px;">
<div>
<div style="font-size:18px; font-weight:600;">
<?= htmlspecialchars((string)($plugin['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</div>
<div style="font-size:12px; color: var(--muted); margin-top:4px;">
<?= htmlspecialchars((string)($plugin['description'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</div>
<div style="margin-top:8px; font-size:12px; color: var(--muted); display:flex; gap:14px; flex-wrap:wrap;">
<span>Slug: <?= htmlspecialchars((string)($plugin['slug'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
<span>Version: <?= htmlspecialchars((string)($plugin['version'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
<?php if (!empty($plugin['author'])): ?>
<span>Author: <?= htmlspecialchars((string)$plugin['author'], ENT_QUOTES, 'UTF-8') ?></span>
<?php endif; ?>
</div>
</div>
<form method="post" action="/admin/plugins/toggle" style="display:flex; align-items:center; gap:10px;">
<input type="hidden" name="slug" value="<?= htmlspecialchars((string)($plugin['slug'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<?php if (!empty($plugin['is_enabled'])): ?>
<input type="hidden" name="enabled" value="0">
<button type="submit" class="btn outline small">Disable</button>
<?php else: ?>
<input type="hidden" name="enabled" value="1">
<button type="submit" class="btn small">Enable</button>
<?php endif; ?>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../admin/views/layout.php';

1
plugins/.gitkeep Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,536 @@
<?php
declare(strict_types=1);
namespace Plugins\AdvancedReporting;
use Core\Http\Response;
use Core\Services\ApiLayer;
use Core\Services\Auth;
use Core\Services\Database;
use Core\Services\Settings;
use Core\Views\View;
use PDO;
use Throwable;
class ReportsController
{
private View $view;
private array $releaseTrackCache = [];
private array $trackDownloadCountCache = [];
public function __construct()
{
$this->view = new View(__DIR__ . '/views');
}
public function adminIndex(): Response
{
if ($guard = $this->guard()) {
return $guard;
}
$db = Database::get();
if (!($db instanceof PDO)) {
return new Response($this->view->render('admin/index.php', [
'title' => 'Sales Reports',
'tab' => 'overview',
'filters' => $this->filters(),
'overview' => [],
'artist_rows' => [],
'track_rows' => [],
'artist_options' => [],
'tables_ready' => false,
'currency' => Settings::get('store_currency', 'GBP'),
]));
}
ApiLayer::ensureSchema($db);
$filters = $this->filters();
$tab = $filters['tab'];
return new Response($this->view->render('admin/index.php', [
'title' => 'Sales Reports',
'tab' => $tab,
'filters' => $filters,
'overview' => $this->overviewPayload($db, $filters),
'artist_rows' => $tab === 'artists' ? $this->artistRows($db, $filters) : [],
'track_rows' => $tab === 'tracks' ? $this->trackRows($db, $filters) : [],
'artist_options' => $this->artistOptions($db),
'tables_ready' => $this->tablesReady($db),
'currency' => strtoupper(trim((string)Settings::get('store_currency', 'GBP'))),
]));
}
public function adminExport(): Response
{
if ($guard = $this->guard()) {
return $guard;
}
$db = Database::get();
if (!($db instanceof PDO)) {
return new Response('Database unavailable', 500);
}
ApiLayer::ensureSchema($db);
$filters = $this->filters();
$type = strtolower(trim((string)($_GET['type'] ?? 'artists')));
if (!in_array($type, ['artists', 'tracks'], true)) {
$type = 'artists';
}
$rows = $type === 'tracks' ? $this->trackRows($db, $filters) : $this->artistRows($db, $filters);
$stream = fopen('php://temp', 'w+');
if ($stream === false) {
return new Response('Unable to create export', 500);
}
if ($type === 'tracks') {
fputcsv($stream, ['Artist', 'Track', 'Release', 'Catalog', 'Units Sold', 'Units Refunded', 'Gross Revenue', 'Refunded Revenue', 'Net Revenue', 'PayPal Fees', 'Net After Fees', 'Downloads']);
foreach ($rows as $row) {
fputcsv($stream, [
(string)($row['artist_name'] ?? ''),
(string)($row['track_display'] ?? ''),
(string)($row['release_title'] ?? ''),
(string)($row['catalog_no'] ?? ''),
(int)($row['units_sold'] ?? 0),
(int)($row['units_refunded'] ?? 0),
number_format((float)($row['gross_revenue'] ?? 0), 2, '.', ''),
number_format((float)($row['refunded_revenue'] ?? 0), 2, '.', ''),
number_format((float)($row['net_revenue'] ?? 0), 2, '.', ''),
number_format((float)($row['payment_fees'] ?? 0), 2, '.', ''),
number_format((float)($row['net_after_fees'] ?? 0), 2, '.', ''),
(int)($row['download_count'] ?? 0),
]);
}
} else {
fputcsv($stream, ['Artist', 'Paid Orders', 'Units Sold', 'Units Refunded', 'Gross Revenue', 'Refunded Revenue', 'Net Revenue', 'PayPal Fees', 'Net After Fees', 'Releases', 'Tracks']);
foreach ($rows as $row) {
fputcsv($stream, [
(string)($row['artist_name'] ?? ''),
(int)($row['paid_orders'] ?? 0),
(int)($row['units_sold'] ?? 0),
(int)($row['units_refunded'] ?? 0),
number_format((float)($row['gross_revenue'] ?? 0), 2, '.', ''),
number_format((float)($row['refunded_revenue'] ?? 0), 2, '.', ''),
number_format((float)($row['net_revenue'] ?? 0), 2, '.', ''),
number_format((float)($row['payment_fees'] ?? 0), 2, '.', ''),
number_format((float)($row['net_after_fees'] ?? 0), 2, '.', ''),
(int)($row['release_count'] ?? 0),
(int)($row['track_count'] ?? 0),
]);
}
}
rewind($stream);
$csv = stream_get_contents($stream);
fclose($stream);
return new Response((string)$csv, 200, [
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => 'attachment; filename="sales-report-' . $type . '-' . gmdate('Ymd-His') . '.csv"',
]);
}
private function guard(): ?Response
{
if (!Auth::check()) {
return new Response('', 302, ['Location' => '/admin/login']);
}
if (!Auth::hasRole(['admin', 'manager'])) {
return new Response('', 302, ['Location' => '/admin']);
}
return null;
}
private function filters(): array
{
$tab = strtolower(trim((string)($_GET['tab'] ?? 'overview')));
if (!in_array($tab, ['overview', 'artists', 'tracks'], true)) {
$tab = 'overview';
}
return [
'tab' => $tab,
'from' => trim((string)($_GET['from'] ?? '')),
'to' => trim((string)($_GET['to'] ?? '')),
'q' => trim((string)($_GET['q'] ?? '')),
'artist_id' => max(0, (int)($_GET['artist_id'] ?? 0)),
];
}
private function tablesReady(PDO $db): bool
{
try {
$probe = $db->query("SHOW TABLES LIKE 'ac_store_order_item_allocations'");
return (bool)($probe && $probe->fetch(PDO::FETCH_NUM));
} catch (Throwable $e) {
return false;
}
}
private function overviewPayload(PDO $db, array $filters): array
{
$stats = [
'gross_revenue' => 0.0,
'refunded_revenue' => 0.0,
'net_revenue' => 0.0,
'payment_fees' => 0.0,
'net_after_fees' => 0.0,
'paid_orders' => 0,
'refunded_orders' => 0,
'units_sold' => 0,
'units_refunded' => 0,
'top_artists' => [],
'top_tracks' => [],
];
try {
[$whereSql, $params] = $this->dateWhere($filters, 'o');
$stmt = $db->prepare("
SELECT
COALESCE(SUM(CASE WHEN o.status = 'paid' THEN o.total ELSE 0 END), 0) AS gross_revenue,
COALESCE(SUM(CASE WHEN o.status = 'refunded' THEN o.total ELSE 0 END), 0) AS refunded_revenue,
COALESCE(SUM(CASE WHEN o.status = 'paid' THEN COALESCE(o.payment_fee, 0) ELSE 0 END), 0) AS payment_fees,
COUNT(DISTINCT CASE WHEN o.status = 'paid' THEN o.id END) AS paid_orders,
COUNT(DISTINCT CASE WHEN o.status = 'refunded' THEN o.id END) AS refunded_orders
FROM ac_store_orders o
{$whereSql}
");
$stmt->execute($params);
$row = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
$stats['gross_revenue'] = (float)($row['gross_revenue'] ?? 0);
$stats['refunded_revenue'] = (float)($row['refunded_revenue'] ?? 0);
$stats['payment_fees'] = (float)($row['payment_fees'] ?? 0);
$stats['net_revenue'] = $stats['gross_revenue'] - $stats['refunded_revenue'];
$stats['net_after_fees'] = $stats['net_revenue'] - $stats['payment_fees'];
$stats['paid_orders'] = (int)($row['paid_orders'] ?? 0);
$stats['refunded_orders'] = (int)($row['refunded_orders'] ?? 0);
} catch (Throwable $e) {
}
try {
[$whereSql, $params] = $this->dateWhere($filters, 'o');
$stmt = $db->prepare("
SELECT
COALESCE(SUM(CASE WHEN o.status = 'paid' THEN a.qty ELSE 0 END), 0) AS units_sold,
COALESCE(SUM(CASE WHEN o.status = 'refunded' THEN a.qty ELSE 0 END), 0) AS units_refunded
FROM ac_store_order_item_allocations a
JOIN ac_store_orders o ON o.id = a.order_id
{$whereSql}
");
$stmt->execute($params);
$row = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
$stats['units_sold'] = (int)($row['units_sold'] ?? 0);
$stats['units_refunded'] = (int)($row['units_refunded'] ?? 0);
} catch (Throwable $e) {
}
$topFilters = $filters;
$topFilters['limit'] = 5;
$stats['top_artists'] = $this->artistRows($db, $topFilters);
$stats['top_tracks'] = $this->trackRows($db, $topFilters);
return $stats;
}
private function artistRows(PDO $db, array $filters): array
{
[$whereSql, $params] = $this->dateWhere($filters, 'o');
if (!empty($filters['artist_id'])) {
$whereSql .= ($whereSql === '' ? ' WHERE ' : ' AND ') . 'alloc.artist_id = :artist_id';
$params[':artist_id'] = (int)$filters['artist_id'];
}
$q = trim((string)($filters['q'] ?? ''));
if ($q !== '') {
$whereSql .= ($whereSql === '' ? ' WHERE ' : ' AND ') . " (ar.name LIKE :q OR r.artist_name LIKE :q OR alloc.title_snapshot LIKE :q)";
$params[':q'] = '%' . $q . '%';
}
$limitSql = !empty($filters['limit']) ? ' LIMIT ' . max(1, (int)$filters['limit']) : '';
try {
$stmt = $db->prepare("
SELECT
COALESCE(alloc.artist_id, 0) AS artist_id,
COALESCE(NULLIF(MAX(ar.name), ''), NULLIF(MAX(r.artist_name), ''), 'Unknown Artist') AS artist_name,
COUNT(DISTINCT CASE WHEN o.status = 'paid' THEN alloc.order_id END) AS paid_orders,
COALESCE(SUM(CASE WHEN o.status = 'paid' THEN alloc.qty ELSE 0 END), 0) AS units_sold,
COALESCE(SUM(CASE WHEN o.status = 'refunded' THEN alloc.qty ELSE 0 END), 0) AS units_refunded,
COALESCE(SUM(CASE WHEN o.status = 'paid' THEN alloc.gross_amount ELSE 0 END), 0) AS gross_revenue,
COALESCE(SUM(CASE WHEN o.status = 'refunded' THEN alloc.gross_amount ELSE 0 END), 0) AS refunded_revenue,
COALESCE(SUM(CASE WHEN o.status = 'paid' AND COALESCE(NULLIF(o.payment_gross, 0), NULLIF(o.total, 0), 0) > 0 THEN COALESCE(o.payment_fee, 0) * (alloc.gross_amount / COALESCE(NULLIF(o.payment_gross, 0), NULLIF(o.total, 0))) ELSE 0 END), 0) AS payment_fees,
COUNT(DISTINCT CASE WHEN o.status = 'paid' AND alloc.release_id IS NOT NULL THEN alloc.release_id END) AS release_count,
COUNT(DISTINCT CASE WHEN o.status = 'paid' AND alloc.track_id IS NOT NULL THEN alloc.track_id END) AS track_count
FROM ac_store_order_item_allocations alloc
JOIN ac_store_orders o ON o.id = alloc.order_id
LEFT JOIN ac_artists ar ON ar.id = alloc.artist_id
LEFT JOIN ac_releases r ON r.id = alloc.release_id
{$whereSql}
GROUP BY COALESCE(alloc.artist_id, 0)
ORDER BY
((COALESCE(SUM(CASE WHEN o.status = 'paid' THEN alloc.gross_amount ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN o.status = 'refunded' THEN alloc.gross_amount ELSE 0 END), 0)) - COALESCE(SUM(CASE WHEN o.status = 'paid' AND COALESCE(NULLIF(o.payment_gross, 0), NULLIF(o.total, 0), 0) > 0 THEN COALESCE(o.payment_fee, 0) * (alloc.gross_amount / COALESCE(NULLIF(o.payment_gross, 0), NULLIF(o.total, 0))) ELSE 0 END), 0)) DESC,
COALESCE(SUM(CASE WHEN o.status = 'paid' THEN alloc.gross_amount ELSE 0 END), 0) DESC,
artist_name ASC
{$limitSql}
");
$stmt->execute($params);
$rows = array_values(array_filter($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [], static function (array $row): bool {
return (float)($row['gross_revenue'] ?? 0) > 0 || (float)($row['refunded_revenue'] ?? 0) > 0;
}));
foreach ($rows as &$row) {
$row['payment_fees'] = (float)($row['payment_fees'] ?? 0);
$row['net_revenue'] = (float)($row['gross_revenue'] ?? 0) - (float)($row['refunded_revenue'] ?? 0);
$row['net_after_fees'] = $row['net_revenue'] - $row['payment_fees'];
}
unset($row);
return $rows;
} catch (Throwable $e) {
return [];
}
}
private function trackRows(PDO $db, array $filters): array
{
[$whereSql, $params] = $this->dateWhere($filters, 'o');
if (!empty($filters['artist_id'])) {
$whereSql .= ($whereSql === '' ? ' WHERE ' : ' AND ') . 'COALESCE(alloc.artist_id, r.artist_id) = :artist_id';
$params[':artist_id'] = (int)$filters['artist_id'];
}
$q = trim((string)($filters['q'] ?? ''));
if ($q !== '') {
$whereSql .= ($whereSql === '' ? ' WHERE ' : ' AND ') . "(t.title LIKE :q OR t.mix_name LIKE :q OR ar.name LIKE :q OR r.title LIKE :q OR r.catalog_no LIKE :q)";
$params[':q'] = '%' . $q . '%';
}
$limitSql = !empty($filters['limit']) ? ' LIMIT ' . max(1, (int)$filters['limit']) : '';
try {
$stmt = $db->prepare("
SELECT
alloc.id,
alloc.order_id,
alloc.track_id,
alloc.release_id,
alloc.artist_id,
alloc.qty,
alloc.gross_amount,
o.status,
COALESCE(o.payment_fee, 0) AS payment_fee,
COALESCE(o.payment_gross, 0) AS payment_gross,
COALESCE(o.total, 0) AS order_total,
COALESCE(NULLIF(MAX(t.title), ''), MAX(alloc.title_snapshot), 'Track') AS track_title,
COALESCE(NULLIF(MAX(t.mix_name), ''), '') AS mix_name,
COALESCE(NULLIF(MAX(ar.name), ''), NULLIF(MAX(r.artist_name), ''), 'Unknown Artist') AS artist_name,
COALESCE(NULLIF(MAX(r.title), ''), 'Release') AS release_title,
COALESCE(NULLIF(MAX(r.catalog_no), ''), '') AS catalog_no
FROM ac_store_order_item_allocations alloc
JOIN ac_store_orders o ON o.id = alloc.order_id
LEFT JOIN ac_release_tracks t ON t.id = alloc.track_id
LEFT JOIN ac_releases r ON r.id = COALESCE(alloc.release_id, t.release_id)
LEFT JOIN ac_artists ar ON ar.id = COALESCE(alloc.artist_id, r.artist_id)
{$whereSql}
GROUP BY alloc.id
ORDER BY alloc.id DESC
");
$stmt->execute($params);
$allocations = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
$rows = [];
foreach ($allocations as $alloc) {
$releaseId = (int)($alloc['release_id'] ?? 0);
$trackId = (int)($alloc['track_id'] ?? 0);
if ($trackId > 0) {
$targets = [[
'track_id' => $trackId,
'artist_name' => (string)($alloc['artist_name'] ?? 'Unknown Artist'),
'release_title' => (string)($alloc['release_title'] ?? 'Release'),
'catalog_no' => (string)($alloc['catalog_no'] ?? ''),
'track_title' => (string)($alloc['track_title'] ?? 'Track'),
'mix_name' => (string)($alloc['mix_name'] ?? ''),
'weight' => 1.0,
]];
} elseif ($releaseId > 0) {
$targets = $this->releaseTracks($db, $releaseId, (string)($alloc['artist_name'] ?? 'Unknown Artist'), (string)($alloc['release_title'] ?? 'Release'), (string)($alloc['catalog_no'] ?? ''));
if (!$targets) {
continue;
}
} else {
continue;
}
$totalWeight = 0.0;
foreach ($targets as $target) {
$totalWeight += max(0.0, (float)($target['weight'] ?? 0));
}
if ($totalWeight <= 0) {
$totalWeight = (float)count($targets);
foreach ($targets as &$target) {
$target['weight'] = 1.0;
}
unset($target);
}
foreach ($targets as $target) {
$key = (int)($target['track_id'] ?? 0);
if ($key <= 0) {
continue;
}
if (!isset($rows[$key])) {
$trackTitle = trim((string)($target['track_title'] ?? 'Track'));
$mixName = trim((string)($target['mix_name'] ?? ''));
$rows[$key] = [
'track_id' => $key,
'artist_name' => (string)($target['artist_name'] ?? 'Unknown Artist'),
'release_title' => (string)($target['release_title'] ?? 'Release'),
'catalog_no' => (string)($target['catalog_no'] ?? ''),
'track_title' => $trackTitle,
'mix_name' => $mixName,
'track_display' => $mixName !== '' ? $trackTitle . ' (' . $mixName . ')' : $trackTitle,
'units_sold' => 0,
'units_refunded' => 0,
'gross_revenue' => 0.0,
'refunded_revenue' => 0.0,
'download_count' => $this->trackDownloadCount($db, $key),
'payment_fees' => 0.0,
'net_after_fees' => 0.0,
];
}
$share = max(0.0, (float)($target['weight'] ?? 0)) / $totalWeight;
$amount = (float)($alloc['gross_amount'] ?? 0) * $share;
$qty = (int)($alloc['qty'] ?? 0);
$feeBase = (float)($alloc['payment_gross'] ?? 0);
if ($feeBase <= 0) {
$feeBase = (float)($alloc['order_total'] ?? 0);
}
$feeShare = ((string)($alloc['status'] ?? '') === 'paid' && $feeBase > 0)
? ((float)($alloc['payment_fee'] ?? 0) * (((float)($alloc['gross_amount'] ?? 0)) / $feeBase) * $share)
: 0.0;
if ((string)($alloc['status'] ?? '') === 'paid') {
$rows[$key]['units_sold'] += $qty;
$rows[$key]['gross_revenue'] += $amount;
$rows[$key]['payment_fees'] += $feeShare;
} elseif ((string)($alloc['status'] ?? '') === 'refunded') {
$rows[$key]['units_refunded'] += $qty;
$rows[$key]['refunded_revenue'] += $amount;
}
}
}
$rows = array_values(array_filter($rows, static function (array $row): bool {
return (float)($row['gross_revenue'] ?? 0) > 0 || (float)($row['refunded_revenue'] ?? 0) > 0;
}));
foreach ($rows as &$row) {
$row['payment_fees'] = (float)($row['payment_fees'] ?? 0);
$row['net_revenue'] = (float)($row['gross_revenue'] ?? 0) - (float)($row['refunded_revenue'] ?? 0);
$row['net_after_fees'] = $row['net_revenue'] - $row['payment_fees'];
}
unset($row);
usort($rows, static function (array $a, array $b): int {
$cmp = ((float)($b['net_revenue'] ?? 0)) <=> ((float)($a['net_revenue'] ?? 0));
if ($cmp !== 0) {
return $cmp;
}
$cmp = ((float)($b['net_after_fees'] ?? 0)) <=> ((float)($a['net_after_fees'] ?? 0));
if ($cmp !== 0) {
return $cmp;
}
return strcasecmp((string)($a['track_display'] ?? ''), (string)($b['track_display'] ?? ''));
});
if (!empty($filters['limit'])) {
$rows = array_slice($rows, 0, max(1, (int)$filters['limit']));
}
return $rows;
} catch (Throwable $e) {
return [];
}
}
private function releaseTracks(PDO $db, int $releaseId, string $artistName, string $releaseTitle, string $catalogNo): array
{
if ($releaseId <= 0) {
return [];
}
if (isset($this->releaseTrackCache[$releaseId])) {
return $this->releaseTrackCache[$releaseId];
}
try {
$stmt = $db->prepare("
SELECT
t.id,
t.title,
COALESCE(t.mix_name, '') AS mix_name,
COALESCE(sp.track_price, 0) AS track_price
FROM ac_release_tracks t
LEFT JOIN ac_store_track_products sp ON sp.release_track_id = t.id AND sp.is_enabled = 1
WHERE t.release_id = :release_id
ORDER BY t.track_no ASC, t.id ASC
");
$stmt->execute([':release_id' => $releaseId]);
$tracks = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
$targets = [];
foreach ($tracks as $track) {
$targets[] = [
'track_id' => (int)($track['id'] ?? 0),
'artist_name' => $artistName,
'release_title' => $releaseTitle,
'catalog_no' => $catalogNo,
'track_title' => (string)($track['title'] ?? 'Track'),
'mix_name' => (string)($track['mix_name'] ?? ''),
'weight' => max(0.0, (float)($track['track_price'] ?? 0)),
];
}
$this->releaseTrackCache[$releaseId] = $targets;
return $targets;
} catch (Throwable $e) {
return [];
}
}
private function trackDownloadCount(PDO $db, int $trackId): int
{
if ($trackId <= 0) {
return 0;
}
if (isset($this->trackDownloadCountCache[$trackId])) {
return $this->trackDownloadCountCache[$trackId];
}
try {
$stmt = $db->prepare("
SELECT COUNT(e.id) AS download_count
FROM ac_store_files f
LEFT JOIN ac_store_download_events e ON e.file_id = f.id
WHERE f.scope_type = 'track' AND f.scope_id = :track_id
");
$stmt->execute([':track_id' => $trackId]);
$count = (int)(($stmt->fetch(PDO::FETCH_ASSOC) ?: [])['download_count'] ?? 0);
$this->trackDownloadCountCache[$trackId] = $count;
return $count;
} catch (Throwable $e) {
return 0;
}
}
private function artistOptions(PDO $db): array
{
try {
$stmt = $db->query("SELECT id, name FROM ac_artists WHERE name IS NOT NULL AND name <> '' ORDER BY name ASC");
return $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : [];
} catch (Throwable $e) {
return [];
}
}
private function dateWhere(array $filters, string $alias): array
{
$where = [];
$params = [];
$from = trim((string)($filters['from'] ?? ''));
$to = trim((string)($filters['to'] ?? ''));
if ($from !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) {
$where[] = $alias . '.created_at >= :from_date';
$params[':from_date'] = $from . ' 00:00:00';
}
if ($to !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) {
$where[] = $alias . '.created_at <= :to_date';
$params[':to_date'] = $to . ' 23:59:59';
}
return [$where ? ' WHERE ' . implode(' AND ', $where) : '', $params];
}
}

View File

@@ -0,0 +1,14 @@
{
"name": "Advanced Reporting",
"version": "0.1.0",
"description": "Expanded sales reporting for the store layer.",
"author": "AudioCore",
"admin_nav": {
"label": "Sales Reports",
"url": "/admin/store/reports",
"roles": ["admin", "manager"],
"icon": "fa-solid fa-chart-line"
},
"entry": "plugin.php",
"default_enabled": false
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
use Core\Http\Router;
use Plugins\AdvancedReporting\ReportsController;
require_once __DIR__ . '/ReportsController.php';
return function (Router $router): void {
$controller = new ReportsController();
$router->get('/admin/store/reports', [$controller, 'adminIndex']);
$router->get('/admin/store/reports/export', [$controller, 'adminExport']);
};

View File

@@ -0,0 +1,387 @@
<?php
$pageTitle = $title ?? 'Sales Reports';
$tab = (string)($tab ?? 'overview');
$filters = is_array($filters ?? null) ? $filters : [];
$overview = is_array($overview ?? null) ? $overview : [];
$artistRows = is_array($artist_rows ?? null) ? $artist_rows : [];
$trackRows = is_array($track_rows ?? null) ? $track_rows : [];
$artistOptions = is_array($artist_options ?? null) ? $artist_options : [];
$tablesReady = (bool)($tables_ready ?? false);
$currency = (string)($currency ?? 'GBP');
$tabLabels = [
'overview' => 'Overview',
'artists' => 'Artists',
'tracks' => 'Tracks',
];
$activeTabLabel = $tabLabels[$tab] ?? 'Overview';
$overviewGross = (float)($overview['gross_revenue'] ?? 0);
$overviewRefunded = (float)($overview['refunded_revenue'] ?? 0);
$overviewNet = (float)($overview['net_revenue'] ?? 0);
$overviewNetAfterFees = (float)($overview['net_after_fees'] ?? 0);
$selectedFrom = (string)($filters['from'] ?? '');
$selectedTo = (string)($filters['to'] ?? '');
$selectedArtistId = (int)($filters['artist_id'] ?? 0);
$selectedQuery = (string)($filters['q'] ?? '');
$today = new DateTimeImmutable('today');
$monthStart = $today->modify('first day of this month');
$monthEnd = $today->modify('last day of this month');
$lastMonthStart = $monthStart->modify('-1 month');
$lastMonthEnd = $monthStart->modify('-1 day');
$quarter = (int)floor((((int)$today->format('n')) - 1) / 3);
$quarterStartMonth = ($quarter * 3) + 1;
$quarterStart = new DateTimeImmutable($today->format('Y') . '-' . str_pad((string)$quarterStartMonth, 2, '0', STR_PAD_LEFT) . '-01');
$lastQuarterEnd = $quarterStart->modify('-1 day');
$lastQuarterStartMonth = (int)$lastQuarterEnd->format('n') - (((int)$lastQuarterEnd->format('n') - 1) % 3);
$lastQuarterStart = new DateTimeImmutable($lastQuarterEnd->format('Y') . '-' . str_pad((string)$lastQuarterStartMonth, 2, '0', STR_PAD_LEFT) . '-01');
$quickRanges = [
'7d' => ['label' => 'Last 7 days', 'from' => $today->modify('-6 days')->format('Y-m-d'), 'to' => $today->format('Y-m-d')],
'30d' => ['label' => 'Last 30 days', 'from' => $today->modify('-29 days')->format('Y-m-d'), 'to' => $today->format('Y-m-d')],
'month' => ['label' => 'This month', 'from' => $monthStart->format('Y-m-d'), 'to' => $monthEnd->format('Y-m-d')],
'last-month' => ['label' => 'Last month', 'from' => $lastMonthStart->format('Y-m-d'), 'to' => $lastMonthEnd->format('Y-m-d')],
'quarter' => ['label' => 'This quarter', 'from' => $quarterStart->format('Y-m-d'), 'to' => $today->format('Y-m-d')],
'last-quarter' => ['label' => 'Last quarter', 'from' => $lastQuarterStart->format('Y-m-d'), 'to' => $lastQuarterEnd->format('Y-m-d')],
'ytd' => ['label' => 'Year to date', 'from' => $today->format('Y') . '-01-01', 'to' => $today->format('Y-m-d')],
];
$activeRangeLabel = 'Custom range';
foreach ($quickRanges as $range) {
if ($selectedFrom === $range['from'] && $selectedTo === $range['to']) {
$activeRangeLabel = $range['label'];
break;
}
}
$artistExportUrl = '/admin/store/reports/export?type=artists'
. '&from=' . rawurlencode($selectedFrom)
. '&to=' . rawurlencode($selectedTo)
. '&artist_id=' . $selectedArtistId
. '&q=' . rawurlencode($selectedQuery);
$trackExportUrl = '/admin/store/reports/export?type=tracks'
. '&from=' . rawurlencode($selectedFrom)
. '&to=' . rawurlencode($selectedTo)
. '&artist_id=' . $selectedArtistId
. '&q=' . rawurlencode($selectedQuery);
$exportUrl = '/admin/store/reports/export?type=' . ($tab === 'tracks' ? 'tracks' : 'artists')
. '&from=' . rawurlencode($selectedFrom)
. '&to=' . rawurlencode($selectedTo)
. '&artist_id=' . $selectedArtistId
. '&q=' . rawurlencode($selectedQuery);
ob_start();
?>
<section class="admin-card reports-page reports-shell">
<div class="reports-hero">
<div class="reports-hero-copy">
<div class="badge">Store Analytics</div>
<h1>Sales Reports</h1>
<p>Revenue, allocations, and performance reporting across orders, artists, releases, and tracks.</p>
</div>
<div class="reports-hero-actions">
<div class="reports-status-card">
<span class="reports-status-label">Current View</span>
<strong><?= htmlspecialchars($activeTabLabel, ENT_QUOTES, 'UTF-8') ?></strong>
<small><?= $tablesReady ? 'Live allocation data ready' : 'Waiting for reporting tables' ?></small>
</div>
<a href="/admin/store" class="btn outline">Back to Store</a>
</div>
</div>
<?php if (!$tablesReady): ?>
<div class="reports-empty-state">
<strong>Reporting tables are not ready.</strong>
<span>Initialize the Store plugin first so allocation and reporting tables exist.</span>
</div>
<?php else: ?>
<div class="reports-toolbar">
<div class="reports-tabset">
<a href="/admin/store/reports?tab=overview" class="reports-tab <?= $tab === 'overview' ? 'is-active' : '' ?>">Overview</a>
<a href="/admin/store/reports?tab=artists" class="reports-tab <?= $tab === 'artists' ? 'is-active' : '' ?>">Artists</a>
<a href="/admin/store/reports?tab=tracks" class="reports-tab <?= $tab === 'tracks' ? 'is-active' : '' ?>">Tracks</a>
</div>
<div class="reports-export-actions">
<a href="<?= htmlspecialchars($artistExportUrl, ENT_QUOTES, 'UTF-8') ?>" class="btn outline small">Export Artists</a>
<a href="<?= htmlspecialchars($trackExportUrl, ENT_QUOTES, 'UTF-8') ?>" class="btn outline small">Export Tracks</a>
</div>
</div>
<div class="reports-range-bar">
<div class="reports-range-copy">
<span class="reports-range-label">Reporting Period</span>
<strong><?= htmlspecialchars($activeRangeLabel, ENT_QUOTES, 'UTF-8') ?></strong>
<small><?= $selectedFrom !== '' || $selectedTo !== '' ? htmlspecialchars(trim(($selectedFrom !== '' ? $selectedFrom : 'Beginning') . ' → ' . ($selectedTo !== '' ? $selectedTo : 'Today')), ENT_QUOTES, 'UTF-8') : 'No explicit date filter applied' ?></small>
</div>
<div class="reports-range-chips">
<?php foreach ($quickRanges as $key => $range): ?>
<?php $rangeUrl = '/admin/store/reports?tab=' . rawurlencode($tab) . '&from=' . rawurlencode($range['from']) . '&to=' . rawurlencode($range['to']) . '&artist_id=' . $selectedArtistId . '&q=' . rawurlencode($selectedQuery); ?>
<a href="<?= htmlspecialchars($rangeUrl, ENT_QUOTES, 'UTF-8') ?>" class="reports-chip <?= $activeRangeLabel === $range['label'] ? 'is-active' : '' ?>"><?= htmlspecialchars($range['label'], ENT_QUOTES, 'UTF-8') ?></a>
<?php endforeach; ?>
</div>
</div>
<form method="get" action="/admin/store/reports" class="reports-filter-bar">
<input type="hidden" name="tab" value="<?= htmlspecialchars($tab, ENT_QUOTES, 'UTF-8') ?>">
<label class="reports-filter-field reports-filter-wide">
<span>Search</span>
<input class="input" type="text" name="q" value="<?= htmlspecialchars($selectedQuery, ENT_QUOTES, 'UTF-8') ?>" placeholder="Artist, track, release, catalog">
</label>
<label class="reports-filter-field">
<span>From</span>
<input class="input" type="date" name="from" value="<?= htmlspecialchars($selectedFrom, ENT_QUOTES, 'UTF-8') ?>">
</label>
<label class="reports-filter-field">
<span>To</span>
<input class="input" type="date" name="to" value="<?= htmlspecialchars($selectedTo, ENT_QUOTES, 'UTF-8') ?>">
</label>
<label class="reports-filter-field">
<span>Artist</span>
<select class="input" name="artist_id">
<option value="0">All artists</option>
<?php foreach ($artistOptions as $artist): ?>
<option value="<?= (int)($artist['id'] ?? 0) ?>" <?= $selectedArtistId === (int)($artist['id'] ?? 0) ? 'selected' : '' ?>><?= htmlspecialchars((string)($artist['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach; ?>
</select>
</label>
<div class="reports-filter-actions">
<button type="submit" class="btn small">Apply</button>
<a href="/admin/store/reports?tab=<?= rawurlencode($tab) ?>" class="btn outline small">Reset</a>
</div>
</form>
<?php if ($tab === 'overview'): ?>
<div class="reports-kpi-grid reports-kpi-grid-minimal">
<article class="reports-metric reports-metric-primary reports-metric-focus">
<span>Before Fees</span>
<strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($overviewNet, 2) ?></strong>
<small>Total sales before processor fees.</small>
</article>
<article class="reports-metric reports-metric-primary reports-metric-focus">
<span>After Fees</span>
<strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($overviewNetAfterFees, 2) ?></strong>
<small>Total sales after captured PayPal fees.</small>
</article>
</div>
<div class="reports-showcase-grid">
<section class="reports-panel reports-ranking-panel">
<div class="reports-panel-head">
<div>
<h2>Top Artists</h2>
<p>Best performing artist allocations for the selected period.</p>
</div>
<span>Net After Fees</span>
</div>
<?php if (!$overview['top_artists']): ?>
<div class="reports-empty-state compact">
<strong>No artist sales in this period.</strong>
<span>Try widening the date range or clearing the artist filter.</span>
</div>
<?php else: ?>
<div class="reports-ranking-list">
<?php foreach ($overview['top_artists'] as $index => $row): ?>
<article class="reports-ranking-row">
<div class="reports-rank"><?= $index + 1 ?></div>
<div class="reports-ranking-copy">
<strong><?= htmlspecialchars((string)($row['artist_name'] ?? 'Unknown Artist'), ENT_QUOTES, 'UTF-8') ?></strong>
<span><?= (int)($row['units_sold'] ?? 0) ?> sold / <?= (int)($row['paid_orders'] ?? 0) ?> paid orders / <?= (int)($row['release_count'] ?? 0) ?> releases</span>
</div>
<div class="reports-ranking-value"><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($row['net_after_fees'] ?? 0), 2) ?></div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<section class="reports-panel reports-ranking-panel">
<div class="reports-panel-head">
<div>
<h2>Top Tracks</h2>
<p>Derived track performance from direct, release, and bundle allocations.</p>
</div>
<span>Net After Fees</span>
</div>
<?php if (!$overview['top_tracks']): ?>
<div class="reports-empty-state compact">
<strong>No track sales in this period.</strong>
<span>Track-level allocations appear once paid orders exist in the selected range.</span>
</div>
<?php else: ?>
<div class="reports-ranking-list">
<?php foreach ($overview['top_tracks'] as $index => $row): ?>
<article class="reports-ranking-row reports-ranking-row-track">
<div class="reports-rank"><?= $index + 1 ?></div>
<div class="reports-ranking-copy">
<strong><?= htmlspecialchars((string)($row['track_display'] ?? 'Track'), ENT_QUOTES, 'UTF-8') ?></strong>
<span><?= htmlspecialchars((string)($row['artist_name'] ?? 'Unknown Artist'), ENT_QUOTES, 'UTF-8') ?> / <?= htmlspecialchars((string)($row['catalog_no'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
</div>
<div class="reports-ranking-value"><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($row['net_after_fees'] ?? 0), 2) ?></div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
</div>
<?php elseif ($tab === 'artists'): ?>
<section class="reports-panel reports-ledger-panel">
<div class="reports-panel-head reports-panel-head-large">
<div>
<h2>Artist Ledger</h2>
<p>Grouped artist performance with paid orders, units, refunds, captured fees, and net after fees.</p>
</div>
<span><?= count($artistRows) ?> rows</span>
</div>
<?php if (!$artistRows): ?>
<div class="reports-empty-state compact">
<strong>No artist sales found.</strong>
<span>Try widening the date range or clearing the artist filter.</span>
</div>
<?php else: ?>
<div class="reports-ledger-list">
<?php foreach ($artistRows as $row): ?>
<article class="reports-ledger-row">
<div class="reports-ledger-main">
<strong><?= htmlspecialchars((string)($row['artist_name'] ?? 'Unknown Artist'), ENT_QUOTES, 'UTF-8') ?></strong>
<span><?= (int)($row['release_count'] ?? 0) ?> releases / <?= (int)($row['track_count'] ?? 0) ?> direct tracks</span>
</div>
<div class="reports-ledger-metrics">
<div class="reports-stat-chip"><small>Paid Orders</small><strong><?= (int)($row['paid_orders'] ?? 0) ?></strong></div>
<div class="reports-stat-chip"><small>Units</small><strong><?= (int)($row['units_sold'] ?? 0) ?></strong></div>
<div class="reports-stat-chip muted"><small>Refunded Units</small><strong><?= (int)($row['units_refunded'] ?? 0) ?></strong></div>
<div class="reports-money-stack"><small>Gross</small><strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($row['gross_revenue'] ?? 0), 2) ?></strong></div>
<div class="reports-money-stack muted"><small>Refunded</small><strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($row['refunded_revenue'] ?? 0), 2) ?></strong></div>
<div class="reports-money-stack muted"><small>PayPal Fees</small><strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($row['payment_fees'] ?? 0), 2) ?></strong></div>
<div class="reports-money-stack emphasis"><small>Net After Fees</small><strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($row['net_after_fees'] ?? 0), 2) ?></strong></div>
</div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php else: ?>
<section class="reports-panel reports-ledger-panel">
<div class="reports-panel-head reports-panel-head-large">
<div>
<h2>Track Ledger</h2>
<p>Track-level breakdown derived from direct sales, release sales, bundle allocations, and captured PayPal fees.</p>
</div>
<span><?= count($trackRows) ?> rows</span>
</div>
<?php if (!$trackRows): ?>
<div class="reports-empty-state compact">
<strong>No track sales found.</strong>
<span>Track metrics will populate once paid orders exist in the selected range.</span>
</div>
<?php else: ?>
<div class="reports-ledger-list reports-ledger-list-tracks">
<?php foreach ($trackRows as $row): ?>
<article class="reports-ledger-row reports-ledger-row-track">
<div class="reports-ledger-main">
<strong><?= htmlspecialchars((string)($row['track_display'] ?? 'Track'), ENT_QUOTES, 'UTF-8') ?></strong>
<span><?= htmlspecialchars((string)($row['artist_name'] ?? 'Unknown Artist'), ENT_QUOTES, 'UTF-8') ?> / <?= htmlspecialchars((string)($row['release_title'] ?? ''), ENT_QUOTES, 'UTF-8') ?><?= !empty($row['catalog_no']) ? ' / ' . htmlspecialchars((string)$row['catalog_no'], ENT_QUOTES, 'UTF-8') : '' ?></span>
</div>
<div class="reports-ledger-metrics">
<div class="reports-stat-chip"><small>Sold</small><strong><?= (int)($row['units_sold'] ?? 0) ?></strong></div>
<div class="reports-stat-chip muted"><small>Refunded Units</small><strong><?= (int)($row['units_refunded'] ?? 0) ?></strong></div>
<div class="reports-stat-chip"><small>Downloads</small><strong><?= (int)($row['download_count'] ?? 0) ?></strong></div>
<div class="reports-money-stack"><small>Gross</small><strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($row['gross_revenue'] ?? 0), 2) ?></strong></div>
<div class="reports-money-stack muted"><small>Refunded</small><strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($row['refunded_revenue'] ?? 0), 2) ?></strong></div>
<div class="reports-money-stack muted"><small>PayPal Fees</small><strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($row['payment_fees'] ?? 0), 2) ?></strong></div>
<div class="reports-money-stack emphasis"><small>Net After Fees</small><strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($row['net_after_fees'] ?? 0), 2) ?></strong></div>
</div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php endif; ?>
<?php endif; ?>
</section>
<style>
.reports-shell { display:grid; gap:18px; }
.reports-hero { display:grid; grid-template-columns:minmax(0,1fr) 280px; gap:18px; align-items:start; }
.reports-hero-copy { display:grid; gap:10px; }
.reports-hero-copy h1 { margin:0; font-size:34px; line-height:1; }
.reports-hero-copy p { margin:0; max-width:760px; color:var(--muted); font-size:15px; }
.reports-hero-actions { display:grid; gap:12px; justify-items:stretch; }
.reports-status-card { padding:16px; border-radius:16px; border:1px solid rgba(255,255,255,.08); background:linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.015)); box-shadow:inset 0 1px 0 rgba(255,255,255,.04); }
.reports-status-card strong { display:block; font-size:20px; margin-top:6px; }
.reports-status-card small, .reports-status-label { color:var(--muted); display:block; }
.reports-status-label { font-size:11px; text-transform:uppercase; letter-spacing:.16em; }
.reports-toolbar { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; }
.reports-tabset { display:flex; flex-wrap:wrap; gap:8px; }
.reports-export-actions { display:flex; gap:8px; flex-wrap:wrap; }
.reports-tab { padding:10px 14px; border-radius:999px; border:1px solid rgba(255,255,255,.08); color:var(--muted); text-decoration:none; font-size:12px; letter-spacing:.16em; text-transform:uppercase; background:rgba(255,255,255,.02); }
.reports-tab.is-active { color:#0b1015; background:linear-gradient(135deg, #48d3ff, #31f0a8); border-color:transparent; box-shadow:0 12px 30px rgba(49,240,168,.18); }
.reports-range-bar { display:grid; grid-template-columns:minmax(220px,.9fr) minmax(0,2.1fr); gap:16px; align-items:start; padding:16px; border-radius:18px; border:1px solid rgba(255,255,255,.08); background:linear-gradient(180deg, rgba(255,255,255,.035), rgba(255,255,255,.012)); }
.reports-range-copy { display:grid; gap:4px; }
.reports-range-label { color:var(--muted); font-size:11px; letter-spacing:.16em; text-transform:uppercase; }
.reports-range-copy strong { font-size:22px; line-height:1.1; }
.reports-range-copy small { color:var(--muted); }
.reports-range-chips { display:flex; gap:8px; flex-wrap:wrap; justify-content:flex-start; align-content:flex-start; }
.reports-chip { padding:9px 12px; border-radius:999px; border:1px solid rgba(255,255,255,.08); color:var(--muted); text-decoration:none; font-size:11px; letter-spacing:.12em; text-transform:uppercase; background:rgba(255,255,255,.02); }
.reports-chip.is-active { color:#edf6ff; border-color:rgba(72,211,255,.28); background:rgba(72,211,255,.12); }
.reports-filter-bar { display:grid; grid-template-columns:minmax(280px,1.6fr) repeat(3, minmax(160px, .8fr)) auto; gap:12px; align-items:end; padding:16px; border-radius:18px; border:1px solid rgba(255,255,255,.08); background:linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.015)); }
.reports-filter-field { display:grid; gap:8px; }
.reports-filter-field span { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.14em; }
.reports-filter-actions { display:flex; gap:8px; align-items:center; justify-content:flex-end; flex-wrap:wrap; }
.reports-kpi-grid { display:grid; grid-template-columns:repeat(7, minmax(0,1fr)); gap:12px; }
.reports-kpi-grid-minimal { grid-template-columns:repeat(2, minmax(0,1fr)); }
.reports-metric { padding:18px; border-radius:18px; border:1px solid rgba(255,255,255,.08); background:linear-gradient(180deg, rgba(255,255,255,.045), rgba(255,255,255,.015)); display:grid; gap:10px; }
.reports-metric span { color:var(--muted); font-size:11px; letter-spacing:.16em; text-transform:uppercase; }
.reports-metric strong { font-size:28px; line-height:1; }
.reports-metric small { color:var(--muted); font-size:12px; }
.reports-metric-primary strong { font-size:32px; }
.reports-metric-focus { min-height:150px; }
.reports-showcase-grid { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
.reports-panel { padding:18px; border-radius:20px; border:1px solid rgba(255,255,255,.08); background:linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.015)); }
.reports-panel-head { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:14px; }
.reports-panel-head h2 { margin:0; font-size:22px; }
.reports-panel-head p { margin:6px 0 0; color:var(--muted); max-width:560px; }
.reports-panel-head span { color:var(--muted); font-size:11px; letter-spacing:.16em; text-transform:uppercase; white-space:nowrap; }
.reports-panel-head-large { margin-bottom:18px; }
.reports-ranking-list, .reports-ledger-list { display:grid; gap:10px; }
.reports-ranking-row { display:grid; grid-template-columns:46px minmax(0,1fr) auto; gap:14px; align-items:center; padding:14px 16px; border-radius:16px; border:1px solid rgba(255,255,255,.07); background:rgba(255,255,255,.025); }
.reports-rank { width:46px; height:46px; border-radius:14px; display:grid; place-items:center; font-size:20px; font-weight:800; color:#d8eef9; background:linear-gradient(180deg, rgba(72,211,255,.18), rgba(49,240,168,.08)); border:1px solid rgba(72,211,255,.18); }
.reports-ranking-copy { display:grid; gap:4px; min-width:0; }
.reports-ranking-copy strong { font-size:16px; }
.reports-ranking-copy span { color:var(--muted); font-size:13px; }
.reports-ranking-value { font-size:20px; font-weight:800; white-space:nowrap; }
.reports-ledger-panel { display:grid; gap:12px; }
.reports-ledger-row { display:grid; grid-template-columns:minmax(220px,1.15fr) minmax(0,2.2fr); gap:14px; align-items:start; padding:16px; border-radius:18px; border:1px solid rgba(255,255,255,.07); background:linear-gradient(180deg, rgba(255,255,255,.03), rgba(255,255,255,.012)); }
.reports-ledger-row-track { grid-template-columns:minmax(280px,1.25fr) minmax(0,2.3fr); }
.reports-ledger-main { display:grid; gap:5px; align-content:center; min-width:0; }
.reports-ledger-main strong { font-size:18px; }
.reports-ledger-main span { color:var(--muted); font-size:13px; }
.reports-ledger-metrics { display:grid; grid-template-columns:repeat(4, minmax(0,1fr)); gap:10px; }
.reports-stat-chip, .reports-money-stack { padding:12px 14px; border-radius:14px; background:rgba(255,255,255,.03); border:1px solid rgba(255,255,255,.05); display:grid; gap:6px; align-content:center; }
.reports-stat-chip small, .reports-money-stack small { color:var(--muted); font-size:10px; letter-spacing:.14em; text-transform:uppercase; }
.reports-stat-chip strong, .reports-money-stack strong { font-size:20px; line-height:1; }
.reports-money-stack.emphasis { background:linear-gradient(180deg, rgba(72,211,255,.12), rgba(49,240,168,.08)); border-color:rgba(72,211,255,.18); }
.reports-stat-chip.muted, .reports-money-stack.muted { opacity:.78; }
.reports-empty-state { padding:16px 18px; border-radius:16px; border:1px dashed rgba(255,255,255,.12); background:rgba(255,255,255,.015); display:grid; gap:6px; color:var(--muted); }
.reports-empty-state strong { color:#edf6ff; font-size:15px; }
.reports-empty-state.compact { min-height:132px; align-content:center; }
@media (max-width: 1380px) {
.reports-hero { grid-template-columns:1fr; }
.reports-kpi-grid { grid-template-columns:repeat(3, minmax(0,1fr)); }
.reports-range-bar { grid-template-columns:1fr; }
.reports-filter-bar { grid-template-columns:repeat(2, minmax(0,1fr)); }
.reports-filter-wide, .reports-filter-actions { grid-column:1 / -1; }
.reports-filter-actions { justify-content:flex-start; }
.reports-showcase-grid { grid-template-columns:1fr; }
.reports-ledger-row, .reports-ledger-row-track { grid-template-columns:1fr; }
}
@media (max-width: 920px) {
.reports-kpi-grid { grid-template-columns:repeat(2, minmax(0,1fr)); }
.reports-ranking-row { grid-template-columns:40px minmax(0,1fr); }
.reports-ranking-value { grid-column:2; }
.reports-ledger-metrics { grid-template-columns:repeat(2, minmax(0,1fr)); }
}
@media (max-width: 640px) {
.reports-kpi-grid, .reports-filter-bar, .reports-ledger-row, .reports-ledger-row-track, .reports-ledger-metrics { grid-template-columns:1fr; }
.reports-toolbar { align-items:flex-start; }
.reports-range-chips, .reports-export-actions { width:100%; }
.reports-ranking-row { grid-template-columns:1fr; }
.reports-rank { width:38px; height:38px; }
.reports-ranking-value { grid-column:auto; }
}
</style>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

View File

@@ -0,0 +1,544 @@
<?php
declare(strict_types=1);
namespace Plugins\Artists;
use Core\Http\Response;
use Core\Services\Auth;
use Core\Views\View;
use Core\Services\Database;
use PDO;
use Throwable;
class ArtistsController
{
private View $view;
public function __construct()
{
$this->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;
}
}

View File

@@ -0,0 +1,21 @@
{
"name": "Artists",
"version": "0.1.0",
"description": "Public artist listings and profiles.",
"author": "AudioCore",
"admin_nav": {
"label": "Artists",
"url": "/admin/artists",
"roles": ["admin", "manager"],
"icon": "fa-solid fa-user"
},
"pages": [
{
"slug": "artists",
"title": "Artists",
"content_html": "<section class=\"card\"><div class=\"badge\">Artists</div><h1 style=\"margin-top:16px; font-size:28px;\">Artists</h1><p style=\"color:var(--muted);\">Add your artist roster here.</p></section>"
}
],
"entry": "plugin.php",
"default_enabled": false
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
use Core\Http\Router;
use Core\Services\Database;
use Core\Services\Shortcodes;
use Plugins\Artists\ArtistsController;
require_once __DIR__ . '/ArtistsController.php';
Shortcodes::register('new-artists', static function (array $attrs = []): string {
$limit = max(1, min(20, (int)($attrs['limit'] ?? 6)));
$db = Database::get();
if (!($db instanceof \PDO)) {
return '';
}
try {
$stmt = $db->prepare("
SELECT name, slug, country, avatar_url
FROM ac_artists
WHERE is_active = 1
ORDER BY created_at DESC, id DESC
LIMIT :limit
");
$stmt->bindValue(':limit', $limit, \PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
} catch (\Throwable $e) {
return '';
}
if (!$rows) {
return '<div class="ac-shortcode-empty">No artists available yet.</div>';
}
$cards = '';
foreach ($rows as $row) {
$name = htmlspecialchars((string)($row['name'] ?? ''), ENT_QUOTES, 'UTF-8');
$slug = rawurlencode((string)($row['slug'] ?? ''));
$country = htmlspecialchars(trim((string)($row['country'] ?? '')), ENT_QUOTES, 'UTF-8');
$avatar = trim((string)($row['avatar_url'] ?? ''));
$avatarHtml = $avatar !== ''
? '<img src="' . htmlspecialchars($avatar, ENT_QUOTES, 'UTF-8') . '" alt="" loading="lazy">'
: '<div class="ac-shortcode-cover-fallback">AC</div>';
$cards .= '<a class="ac-shortcode-artist-card" href="/artist?slug=' . $slug . '">'
. '<div class="ac-shortcode-artist-avatar">' . $avatarHtml . '</div>'
. '<div class="ac-shortcode-artist-meta">'
. '<div class="ac-shortcode-artist-name">' . $name . '</div>'
. ($country !== '' ? '<div class="ac-shortcode-artist-country">' . $country . '</div>' : '')
. '</div>'
. '</a>';
}
return '<section class="ac-shortcode-artists">'
. '<div class="ac-shortcode-block-head">New Artists</div>'
. '<div class="ac-shortcode-artists-grid">' . $cards . '</div>'
. '</section>';
});
return function (Router $router): void {
$controller = new ArtistsController();
$router->get('/artists', [$controller, 'index']);
$router->get('/artist', [$controller, 'show']);
$router->get('/admin/artists', [$controller, 'adminIndex']);
$router->post('/admin/artists/install', [$controller, 'adminInstall']);
$router->get('/admin/artists/new', [$controller, 'adminNew']);
$router->get('/admin/artists/edit', function () use ($controller): Core\Http\Response {
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
return $controller->adminEdit($id);
});
$router->post('/admin/artists/upload', [$controller, 'adminUpload']);
$router->post('/admin/artists/save', [$controller, 'adminSave']);
$router->post('/admin/artists/delete', [$controller, 'adminDelete']);
};

View File

@@ -0,0 +1,129 @@
<?php
$pageTitle = $title ?? 'Edit Artist';
$artist = $artist ?? [];
$error = $error ?? '';
$uploadError = (string)($_GET['upload_error'] ?? '');
$socialLinks = [];
if (!empty($artist['social_links'])) {
$decoded = json_decode((string)$artist['social_links'], true);
if (is_array($decoded)) {
$socialLinks = $decoded;
}
}
ob_start();
?>
<section class="admin-card">
<div class="badge">Artists</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
<div>
<h1 style="font-size:28px; margin:0;"><?= htmlspecialchars($pageTitle, ENT_QUOTES, 'UTF-8') ?></h1>
<p style="color: var(--muted); margin-top:6px;">Create or update an artist profile.</p>
</div>
<a href="/admin/artists" class="btn outline">Back</a>
</div>
<?php if ($error): ?>
<div style="margin-top:16px; color:#f3b0b0; font-size:13px;"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<?php if ($uploadError !== ''): ?>
<div style="margin-top:12px; color:#f3b0b0; font-size:13px;"><?= htmlspecialchars($uploadError, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<form method="post" action="/admin/artists/upload" enctype="multipart/form-data" id="artistAvatarUpload" style="margin-top:18px;">
<div class="admin-card" style="padding:14px; background: rgba(10,10,12,0.6);">
<div class="label">Upload avatar</div>
<input type="hidden" name="artist_id" value="<?= (int)($artist['id'] ?? 0) ?>">
<label for="artistAvatarFile" id="artistAvatarDropzone" style="display:flex; flex-direction:column; gap:8px; align-items:center; justify-content:center; padding:18px; border-radius:14px; border:1px dashed rgba(255,255,255,0.2); background: rgba(0,0,0,0.2); cursor:pointer;">
<div style="font-size:11px; text-transform:uppercase; letter-spacing:0.2em; color:var(--muted);">Drag &amp; Drop</div>
<div style="font-size:13px; color:var(--text);">or click to upload</div>
<div id="artistAvatarFileName" style="font-size:11px; color:var(--muted);">No file selected</div>
</label>
<input class="input" type="file" id="artistAvatarFile" name="artist_avatar" accept="image/*" style="display:none;">
<div style="margin-top:10px; display:flex; justify-content:flex-end;">
<button type="submit" class="btn small">Upload</button>
</div>
</div>
</form>
<form method="post" action="/admin/artists/save" style="margin-top:16px; display:grid; gap:16px;">
<input type="hidden" name="id" value="<?= (int)($artist['id'] ?? 0) ?>">
<div class="admin-card" style="padding:16px;">
<div style="display:grid; gap:12px;">
<label class="label">Name</label>
<input class="input" name="name" value="<?= htmlspecialchars((string)($artist['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="Artist name">
<label class="label">Slug</label>
<input class="input" name="slug" value="<?= htmlspecialchars((string)($artist['slug'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="artist-name">
<label class="label">Country</label>
<input class="input" name="country" value="<?= htmlspecialchars((string)($artist['country'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="UK">
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<label class="label" style="margin:0;">Avatar URL</label>
<button type="button" class="btn outline small" data-media-picker="artist_avatar_url" data-media-picker-mode="url">Pick from Media</button>
</div>
<input class="input" id="artist_avatar_url" name="avatar_url" value="<?= htmlspecialchars((string)($artist['avatar_url'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="https://...">
<label class="label">Bio</label>
<textarea class="input" name="bio" rows="6" style="resize:vertical; font-family:'IBM Plex Mono', monospace; font-size:13px; line-height:1.6;"><?= htmlspecialchars((string)($artist['bio'] ?? ''), ENT_QUOTES, 'UTF-8') ?></textarea>
<label class="label">Artist Credits</label>
<textarea class="input" name="credits" rows="4" style="resize:vertical; font-family:'IBM Plex Mono', monospace; font-size:13px; line-height:1.6;" placeholder="Written by..., Vocals by..., Produced by..."><?= htmlspecialchars((string)($artist['credits'] ?? ''), ENT_QUOTES, 'UTF-8') ?></textarea>
<div class="admin-card" style="padding:14px; background: rgba(10,10,12,0.6);">
<div class="label">Social Links</div>
<div style="margin-top:10px; display:grid; gap:10px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));">
<input class="input" name="social_website" placeholder="Website URL" value="<?= htmlspecialchars((string)($socialLinks['website'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<input class="input" name="social_instagram" placeholder="Instagram URL" value="<?= htmlspecialchars((string)($socialLinks['instagram'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<input class="input" name="social_soundcloud" placeholder="SoundCloud URL" value="<?= htmlspecialchars((string)($socialLinks['soundcloud'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<input class="input" name="social_spotify" placeholder="Spotify URL" value="<?= htmlspecialchars((string)($socialLinks['spotify'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<input class="input" name="social_youtube" placeholder="YouTube URL" value="<?= htmlspecialchars((string)($socialLinks['youtube'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<input class="input" name="social_tiktok" placeholder="TikTok URL" value="<?= htmlspecialchars((string)($socialLinks['tiktok'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<input class="input" name="social_bandcamp" placeholder="Bandcamp URL" value="<?= htmlspecialchars((string)($socialLinks['bandcamp'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<input class="input" name="social_beatport" placeholder="Beatport URL" value="<?= htmlspecialchars((string)($socialLinks['beatport'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<input class="input" name="social_facebook" placeholder="Facebook URL" value="<?= htmlspecialchars((string)($socialLinks['facebook'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<input class="input" name="social_x" placeholder="X / Twitter URL" value="<?= htmlspecialchars((string)($socialLinks['x'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
</div>
</div>
<label style="display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<input type="checkbox" name="is_active" value="1" <?= ((int)($artist['is_active'] ?? 1) === 1) ? 'checked' : '' ?>>
Active
</label>
</div>
</div>
<div style="display:flex; justify-content:flex-end; gap:12px; align-items:center;">
<button type="submit" class="btn">Save artist</button>
</div>
</form>
</section>
<script>
(function () {
const dropzone = document.getElementById('artistAvatarDropzone');
const fileInput = document.getElementById('artistAvatarFile');
const fileName = document.getElementById('artistAvatarFileName');
if (!dropzone || !fileInput || !fileName) {
return;
}
dropzone.addEventListener('dragover', (event) => {
event.preventDefault();
dropzone.style.borderColor = 'var(--accent)';
});
dropzone.addEventListener('dragleave', () => {
dropzone.style.borderColor = 'rgba(255,255,255,0.2)';
});
dropzone.addEventListener('drop', (event) => {
event.preventDefault();
dropzone.style.borderColor = 'rgba(255,255,255,0.2)';
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length) {
fileInput.files = event.dataTransfer.files;
fileName.textContent = event.dataTransfer.files[0].name;
}
});
fileInput.addEventListener('change', () => {
fileName.textContent = fileInput.files.length ? fileInput.files[0].name : 'No file selected';
});
})();
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

View File

@@ -0,0 +1,97 @@
<?php
$pageTitle = 'Artists';
$tableReady = $table_ready ?? false;
$artists = $artists ?? [];
$pageId = (int)($page_id ?? 0);
$pagePublished = (int)($page_published ?? 0);
$socialReady = (bool)($social_ready ?? false);
ob_start();
?>
<section class="admin-card">
<div class="badge">Artists</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
<div>
<h1 style="font-size:28px; margin:0;">Artists</h1>
<p style="color: var(--muted); margin-top:6px;">Artists plugin admin placeholder.</p>
</div>
<a href="/admin/artists/new" class="btn">New Artist</a>
</div>
<?php if (!$tableReady): ?>
<div class="admin-card" style="margin-top:16px; padding:16px; display:flex; align-items:center; justify-content:space-between; gap:16px;">
<div>
<div style="font-weight:600;">Database not initialized</div>
<div style="color: var(--muted); font-size:13px; margin-top:4px;">Create the artists table before adding records.</div>
</div>
<form method="post" action="/admin/artists/install">
<button type="submit" class="btn small">Create Tables</button>
</form>
</div>
<?php else: ?>
<?php if (!$socialReady): ?>
<div class="admin-card" style="margin-top:16px; padding:14px; display:flex; align-items:center; justify-content:space-between; gap:16px;">
<div>
<div style="font-weight:600;">Social links not enabled</div>
<div style="color: var(--muted); font-size:12px; margin-top:4px;">Click create tables to add the social links column.</div>
</div>
<form method="post" action="/admin/artists/install">
<button type="submit" class="btn small">Update Table</button>
</form>
</div>
<?php endif; ?>
<div class="admin-card" style="margin-top:16px; padding:14px; display:flex; align-items:center; justify-content:space-between; gap:16px;">
<div>
<div style="font-weight:600;">Artists page</div>
<div style="color: var(--muted); font-size:12px; margin-top:4px;">
Slug: <code>artists</code>
<?php if ($pageId > 0): ?>
· Status: <?= $pagePublished === 1 ? 'Published' : 'Draft' ?>
<?php else: ?>
· Not created
<?php endif; ?>
</div>
</div>
<?php if ($pageId > 0): ?>
<a href="/admin/pages/edit?id=<?= $pageId ?>" class="btn outline small">Edit Page Content</a>
<?php else: ?>
<span class="pill">Re-enable plugin to create</span>
<?php endif; ?>
</div>
<?php if (!$artists): ?>
<div style="margin-top:18px; color: var(--muted); font-size:13px;">No artists yet.</div>
<?php else: ?>
<div style="margin-top:18px; display:grid; gap:12px;">
<?php foreach ($artists as $artist): ?>
<div class="admin-card" style="padding:14px; display:flex; align-items:center; justify-content:space-between; gap:16px;">
<div style="display:flex; gap:12px; align-items:center;">
<div style="width:44px; height:44px; border-radius:12px; overflow:hidden; background:rgba(255,255,255,0.06); display:grid; place-items:center;">
<?php if (!empty($artist['avatar_url'])): ?>
<img src="<?= htmlspecialchars((string)$artist['avatar_url'], ENT_QUOTES, 'UTF-8') ?>" alt="" style="width:100%; height:100%; object-fit:cover;">
<?php else: ?>
<span style="font-size:12px; color:var(--muted);">N/A</span>
<?php endif; ?>
</div>
<div>
<div style="font-weight:600;"><?= htmlspecialchars((string)$artist['name'], ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:12px; color:var(--muted);"><?= htmlspecialchars((string)$artist['slug'], ENT_QUOTES, 'UTF-8') ?></div>
</div>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<?php if ((int)$artist['is_active'] !== 1): ?>
<span class="pill">Inactive</span>
<?php endif; ?>
<a href="/admin/artists/edit?id=<?= (int)$artist['id'] ?>" class="btn outline small">Edit</a>
<form method="post" action="/admin/artists/delete" onsubmit="return confirm('Delete this artist?');">
<input type="hidden" name="id" value="<?= (int)$artist['id'] ?>">
<button type="submit" class="btn outline small">Delete</button>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endif; ?>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

View File

@@ -0,0 +1,236 @@
<?php
$pageTitle = $title ?? 'Artists';
$artists = $artists ?? [];
function ac_country_code(string $country): string {
$code = strtoupper(trim($country));
if ($code === '') {
return '';
}
if ($code === 'UK') {
$code = 'GB';
}
if (!preg_match('/^[A-Z]{2}$/', $code)) {
return '';
}
return strtolower($code);
}
ob_start();
?>
<section class="card" style="display:grid; gap:18px;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px;">
<div class="badge">Artists</div>
<div class="view-toggle" role="group" aria-label="View toggle">
<button type="button" class="view-btn active" id="artistGridBtn" aria-label="Grid view">
<i class="fa-duotone fa-grid-round-2"></i>
<span>Grid</span>
</button>
<button type="button" class="view-btn" id="artistListBtn" aria-label="List view">
<i class="fa-duotone fa-list-ol"></i>
<span>List</span>
</button>
</div>
</div>
<?php if (!$artists): ?>
<div style="color:var(--muted); font-size:14px;">No artists published yet.</div>
<?php else: ?>
<div id="artistView" class="artist-grid">
<?php foreach ($artists as $artist): ?>
<a class="artist-card" href="/artist?slug=<?= htmlspecialchars((string)$artist['slug'], ENT_QUOTES, 'UTF-8') ?>">
<div class="artist-avatar">
<?php if (!empty($artist['avatar_url'])): ?>
<img src="<?= htmlspecialchars((string)$artist['avatar_url'], ENT_QUOTES, 'UTF-8') ?>" alt="">
<?php else: ?>
<div class="artist-avatar-placeholder">AC</div>
<?php endif; ?>
</div>
<div class="artist-info">
<div class="artist-name">
<?= htmlspecialchars((string)$artist['name'], ENT_QUOTES, 'UTF-8') ?>
</div>
<?php $flag = ac_country_code((string)($artist['country'] ?? '')); ?>
<?php if ($flag !== ''): ?>
<div class="artist-meta">
<span class="fi fi-<?= htmlspecialchars($flag, ENT_QUOTES, 'UTF-8') ?>"></span>
</div>
<?php endif; ?>
</div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<style>
.artist-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.artist-list {
display: grid;
gap: 10px;
}
.artist-card {
border-radius: 18px;
padding: 6px;
background: transparent;
display: grid;
gap: 10px;
color: inherit;
}
.artist-card:hover .artist-avatar {
box-shadow: 0 12px 30px rgba(0,0,0,0.35);
transform: scale(1.02);
}
.artist-card .artist-avatar {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.artist-avatar {
width: 140px;
height: 140px;
border-radius: 16px;
overflow: hidden;
background: rgba(255,255,255,0.06);
display: grid;
place-items: center;
}
.artist-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.artist-avatar-placeholder {
font-size: 12px;
color: var(--muted);
letter-spacing: 0.2em;
}
.artist-info {
display: none;
gap: 6px;
}
.artist-name {
font-weight: 600;
font-size: 15px;
display: inline-flex;
align-items: center;
gap: 8px;
}
.artist-meta {
font-size: 12px;
color: var(--muted);
display: inline-flex;
gap: 8px;
align-items: center;
}
.view-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px;
border-radius: 999px;
background: rgba(10,10,12,0.6);
border: 1px solid rgba(255,255,255,0.12);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.04);
}
.view-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-radius: 999px;
border: 1px solid transparent;
background: transparent;
color: rgba(255,255,255,0.7);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.2em;
cursor: pointer;
}
.view-btn i {
font-size: 12px;
}
.view-btn.active {
background: linear-gradient(135deg, rgba(34,242,165,0.2), rgba(34,167,255,0.12));
border-color: rgba(34,242,165,0.35);
color: #f3fff9;
box-shadow: 0 6px 16px rgba(34,242,165,0.2);
}
.view-btn:not(.active):hover {
color: rgba(255,255,255,0.95);
background: rgba(255,255,255,0.06);
}
.flag {
font-size: 14px;
}
.artist-list .artist-card {
grid-template-columns: 64px 1fr;
align-items: center;
gap: 16px;
padding: 10px 12px;
border: 1px solid rgba(255,255,255,0.08);
background: rgba(12,12,14,0.5);
}
.artist-list .artist-avatar {
width: 64px;
height: 64px;
}
.artist-list .artist-info {
display: grid;
}
@media (max-width: 700px) {
.artist-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.artist-card {
padding: 4px;
}
.artist-avatar {
width: 100%;
height: auto;
aspect-ratio: 1 / 1;
}
.view-btn span {
display: none;
}
.view-btn {
width: 34px;
height: 34px;
padding: 0;
justify-content: center;
}
.artist-list .artist-card {
grid-template-columns: 1fr;
align-items: flex-start;
}
}
</style>
<script>
(function () {
const gridBtn = document.getElementById('artistGridBtn');
const listBtn = document.getElementById('artistListBtn');
const view = document.getElementById('artistView');
if (!gridBtn || !listBtn || !view) {
return;
}
gridBtn.addEventListener('click', () => {
view.classList.add('artist-grid');
view.classList.remove('artist-list');
gridBtn.classList.add('active');
listBtn.classList.remove('active');
});
listBtn.addEventListener('click', () => {
view.classList.add('artist-list');
view.classList.remove('artist-grid');
listBtn.classList.add('active');
gridBtn.classList.remove('active');
});
})();
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../views/site/layout.php';

View File

@@ -0,0 +1,239 @@
<?php
use Core\Services\Settings;
$pageTitle = $title ?? 'Artist';
$artist = $artist ?? null;
$proUrl = Settings::get('fontawesome_pro_url', '');
$faUrl = $proUrl !== '' ? $proUrl : Settings::get('fontawesome_url', '');
$hasPro = $proUrl !== '';
$hasIcons = $faUrl !== '';
$artistReleases = is_array($artist_releases ?? null) ? $artist_releases : [];
$socialLinks = [];
if ($artist && !empty($artist['social_links'])) {
$decoded = json_decode((string)$artist['social_links'], true);
if (is_array($decoded)) {
$socialLinks = $decoded;
}
}
$iconMap = [
'website' => ['label' => 'Website', 'icon' => 'fa-duotone fa-globe-pointer'],
'instagram' => ['label' => 'Instagram', 'icon' => 'fa-brands fa-instagram'],
'soundcloud' => ['label' => 'SoundCloud', 'icon' => 'fa-brands fa-soundcloud'],
'spotify' => ['label' => 'Spotify', 'icon' => 'fa-brands fa-spotify'],
'youtube' => ['label' => 'YouTube', 'icon' => 'fa-brands fa-youtube'],
'tiktok' => ['label' => 'TikTok', 'icon' => 'fa-brands fa-tiktok'],
'bandcamp' => ['label' => 'Bandcamp', 'icon' => 'fa-brands fa-bandcamp'],
'beatport' => ['label' => 'Beatport', 'icon' => 'fa-solid fa-music'],
'facebook' => ['label' => 'Facebook', 'icon' => 'fa-brands fa-facebook'],
'x' => ['label' => 'X', 'icon' => 'fa-brands fa-x-twitter'],
];
function ac_normalize_url(string $value): string {
if ($value === '') {
return '';
}
if (preg_match('~^(https?://)~i', $value)) {
return $value;
}
return 'https://' . $value;
}
ob_start();
?>
<section class="card" style="display:grid; gap:16px;">
<div class="badge">Artist</div>
<?php if (!$artist): ?>
<h1 style="margin:0; font-size:28px;">Artist not found</h1>
<p style="color:var(--muted);">This profile is unavailable.</p>
<?php else: ?>
<div style="display:grid; gap:18px;">
<div style="display:flex; align-items:center; gap:20px;">
<div style="width:160px; height:160px; border-radius:24px; overflow:hidden; background:rgba(255,255,255,0.06); display:grid; place-items:center;">
<?php if (!empty($artist['avatar_url'])): ?>
<img src="<?= htmlspecialchars((string)$artist['avatar_url'], ENT_QUOTES, 'UTF-8') ?>" alt="" style="width:100%; height:100%; object-fit:cover;">
<?php else: ?>
<span style="font-size:12px; color:var(--muted); letter-spacing:0.2em;">AC</span>
<?php endif; ?>
</div>
<div>
<div class="badge">Profile</div>
<h1 style="margin:6px 0 0; font-size:34px;"><?= htmlspecialchars((string)$artist['name'], ENT_QUOTES, 'UTF-8') ?></h1>
<?php if (!empty($artist['country'])): ?>
<?php
$code = strtolower((string)$artist['country']);
if ($code === 'uk') { $code = 'gb'; }
?>
<div style="margin-top:8px; color:var(--muted); font-size:13px;">
<span class="fi fi-<?= htmlspecialchars($code, ENT_QUOTES, 'UTF-8') ?>"></span>
</div>
<?php endif; ?>
</div>
</div>
<?php if (!empty($artist['bio'])): ?>
<div style="color:var(--muted); line-height:1.7;">
<?= nl2br(htmlspecialchars((string)$artist['bio'], ENT_QUOTES, 'UTF-8')) ?>
</div>
<?php endif; ?>
<?php if (!empty($artist['credits'])): ?>
<div style="padding:14px; border-radius:18px; border:1px solid rgba(255,255,255,0.08); background: rgba(0,0,0,0.25);">
<div class="badge">Credits</div>
<div style="margin-top:8px; color:var(--muted); line-height:1.7;">
<?= nl2br(htmlspecialchars((string)$artist['credits'], ENT_QUOTES, 'UTF-8')) ?>
</div>
</div>
<?php endif; ?>
<?php if ($socialLinks): ?>
<div style="display:flex; flex-wrap:wrap; gap:10px;">
<?php foreach ($socialLinks as $key => $url): ?>
<?php
$meta = $iconMap[$key] ?? ['label' => ucfirst($key), 'icon' => 'fa-solid fa-link'];
$label = $meta['label'];
$icon = $meta['icon'];
$normalized = ac_normalize_url((string)$url);
$safeUrl = htmlspecialchars($normalized, ENT_QUOTES, 'UTF-8');
?>
<?php if ($hasIcons && $icon !== ''): ?>
<a class="pill social-icon" href="<?= $safeUrl ?>" target="_blank" rel="noopener" aria-label="<?= htmlspecialchars($label, ENT_QUOTES, 'UTF-8') ?>">
<i class="<?= htmlspecialchars($icon, ENT_QUOTES, 'UTF-8') ?>"></i>
</a>
<?php else: ?>
<a class="pill" href="<?= $safeUrl ?>" target="_blank" rel="noopener">
<?= htmlspecialchars($label, ENT_QUOTES, 'UTF-8') ?>
</a>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($artistReleases): ?>
<div class="artist-release-panel">
<div class="artist-release-panel-head">
<div class="badge">Latest Releases</div>
<a href="/releases?artist=<?= urlencode((string)($artist['name'] ?? '')) ?>" class="badge artist-view-all-link">View all</a>
</div>
<div class="artist-release-grid">
<?php foreach ($artistReleases as $release): ?>
<a class="artist-release-card" href="/release?slug=<?= htmlspecialchars((string)($release['slug'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<div class="artist-release-cover">
<?php if (!empty($release['cover_url'])): ?>
<img src="<?= htmlspecialchars((string)$release['cover_url'], ENT_QUOTES, 'UTF-8') ?>" alt="">
<?php else: ?>
<span>AC</span>
<?php endif; ?>
</div>
<div class="artist-release-meta">
<div class="artist-release-title"><?= htmlspecialchars((string)($release['title'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
<?php if (!empty($release['release_date'])): ?>
<div class="artist-release-date"><?= htmlspecialchars((string)$release['release_date'], ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
</div>
</a>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
</section>
<style>
.social-icon {
color: var(--text);
}
.social-icon i {
font-size: 14px;
}
.artist-release-panel {
display: grid;
gap: 12px;
margin-top: 6px;
padding: 14px;
border-radius: 18px;
border: 1px solid rgba(255,255,255,0.08);
background: rgba(0,0,0,0.24);
}
.artist-release-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.artist-view-all-link {
text-decoration: none;
color: rgba(255,255,255,0.72);
transition: color .18s ease;
}
.artist-view-all-link:hover {
color: #ffffff;
}
.artist-release-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.artist-release-card {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
color: inherit;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.08);
background: rgba(0,0,0,0.22);
padding: 8px;
transition: transform .18s ease, border-color .18s ease, background .18s ease;
}
.artist-release-card:hover {
transform: translateY(-2px);
border-color: rgba(255,255,255,0.18);
background: rgba(0,0,0,0.28);
}
.artist-release-cover {
width: 58px;
height: 58px;
flex: 0 0 58px;
border-radius: 9px;
overflow: hidden;
background: rgba(255,255,255,0.06);
display: grid;
place-items: center;
color: var(--muted);
font-size: 12px;
letter-spacing: .14em;
}
.artist-release-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.artist-release-title {
font-size: 12px;
font-weight: 600;
line-height: 1.25;
}
.artist-release-date {
margin-top: 4px;
font-size: 10px;
color: var(--muted);
}
.artist-release-meta {
min-width: 0;
}
@media (max-width: 900px) {
.artist-release-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 520px) {
.artist-release-grid {
grid-template-columns: 1fr;
}
.artist-release-card {
padding: 7px;
}
}
</style>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../views/site/layout.php';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
{
"name": "Releases",
"version": "0.1.0",
"description": "Release listings and detail pages.",
"author": "AudioCore",
"admin_nav": {
"label": "Releases",
"url": "/admin/releases",
"roles": ["admin", "manager"],
"icon": "fa-solid fa-compact-disc"
},
"pages": [
{
"slug": "releases",
"title": "Releases",
"content_html": "<section class=\"card\"><div class=\"badge\">Releases</div><h1 style=\"margin-top:16px; font-size:28px;\">Releases</h1><p style=\"color:var(--muted);\">Latest drops from the label.</p></section>"
}
],
"entry": "plugin.php",
"default_enabled": false
}

120
plugins/releases/plugin.php Normal file
View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
use Core\Http\Router;
use Core\Services\Database;
use Core\Services\Shortcodes;
use Plugins\Releases\ReleasesController;
require_once __DIR__ . '/ReleasesController.php';
Shortcodes::register('releases', static function (array $attrs = []): string {
$limit = max(1, min(20, (int)($attrs['limit'] ?? 8)));
$db = Database::get();
if (!($db instanceof \PDO)) {
return '';
}
try {
$artistJoinReady = false;
try {
$probe = $db->query("SHOW COLUMNS FROM ac_releases LIKE 'artist_id'");
$artistJoinReady = (bool)($probe && $probe->fetch(\PDO::FETCH_ASSOC));
} catch (\Throwable $e) {
$artistJoinReady = false;
}
if ($artistJoinReady) {
$stmt = $db->prepare("
SELECT r.title, r.slug, r.release_date, r.cover_url, COALESCE(r.artist_name, a.name) AS artist_name
FROM ac_releases r
LEFT JOIN ac_artists a ON a.id = r.artist_id
WHERE r.is_published = 1
AND (r.release_date IS NULL OR r.release_date <= CURDATE())
ORDER BY r.release_date DESC, r.created_at DESC
LIMIT :limit
");
} else {
$stmt = $db->prepare("
SELECT title, slug, release_date, cover_url, artist_name
FROM ac_releases
WHERE is_published = 1
AND (release_date IS NULL OR release_date <= CURDATE())
ORDER BY release_date DESC, created_at DESC
LIMIT :limit
");
}
$stmt->bindValue(':limit', $limit, \PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
} catch (\Throwable $e) {
return '';
}
if (!$rows) {
return '<div class="ac-shortcode-empty">No releases published yet.</div>';
}
$cards = '';
foreach ($rows as $row) {
$title = htmlspecialchars((string)($row['title'] ?? ''), ENT_QUOTES, 'UTF-8');
$slug = rawurlencode((string)($row['slug'] ?? ''));
$artist = htmlspecialchars(trim((string)($row['artist_name'] ?? '')), ENT_QUOTES, 'UTF-8');
$date = htmlspecialchars((string)($row['release_date'] ?? ''), ENT_QUOTES, 'UTF-8');
$cover = trim((string)($row['cover_url'] ?? ''));
$coverHtml = $cover !== ''
? '<img src="' . htmlspecialchars($cover, ENT_QUOTES, 'UTF-8') . '" alt="" loading="lazy">'
: '<div class="ac-shortcode-cover-fallback">AC</div>';
$cards .= '<a class="ac-shortcode-release-card" href="/release?slug=' . $slug . '">'
. '<div class="ac-shortcode-release-cover">' . $coverHtml . '</div>'
. '<div class="ac-shortcode-release-meta">'
. '<div class="ac-shortcode-release-title">' . $title . '</div>'
. ($artist !== '' ? '<div class="ac-shortcode-release-artist">' . $artist . '</div>' : '')
. ($date !== '' ? '<div class="ac-shortcode-release-date">' . $date . '</div>' : '')
. '</div>'
. '</a>';
}
return '<section class="ac-shortcode-releases">'
. '<div class="ac-shortcode-block-head">Latest Releases</div>'
. '<div class="ac-shortcode-release-grid">' . $cards . '</div>'
. '</section>';
});
Shortcodes::register('latest-releases', static function (array $attrs = []): string {
return Shortcodes::render('[releases limit="' . max(1, min(20, (int)($attrs['limit'] ?? 8))) . '"]');
});
return function (Router $router): void {
$controller = new ReleasesController();
$router->get('/releases', [$controller, 'index']);
$router->get('/release', [$controller, 'show']);
$router->get('/admin/releases', [$controller, 'adminIndex']);
$router->post('/admin/releases/install', [$controller, 'adminInstall']);
$router->get('/admin/releases/new', [$controller, 'adminNew']);
$router->get('/admin/releases/edit', function () use ($controller): Core\Http\Response {
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
return $controller->adminEdit($id);
});
$router->get('/admin/releases/tracks', function () use ($controller): Core\Http\Response {
$releaseId = isset($_GET['release_id']) ? (int)$_GET['release_id'] : 0;
return $controller->adminTracks($releaseId);
});
$router->get('/admin/releases/tracks/new', function () use ($controller): Core\Http\Response {
$releaseId = isset($_GET['release_id']) ? (int)$_GET['release_id'] : 0;
return $controller->adminTrackEdit(0, $releaseId);
});
$router->get('/admin/releases/tracks/edit', function () use ($controller): Core\Http\Response {
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$releaseId = isset($_GET['release_id']) ? (int)$_GET['release_id'] : 0;
return $controller->adminTrackEdit($id, $releaseId);
});
$router->post('/admin/releases/save', [$controller, 'adminSave']);
$router->post('/admin/releases/delete', [$controller, 'adminDelete']);
$router->post('/admin/releases/upload', [$controller, 'adminUpload']);
$router->post('/admin/releases/tracks/save', [$controller, 'adminTrackSave']);
$router->post('/admin/releases/tracks/delete', [$controller, 'adminTrackDelete']);
$router->post('/admin/releases/tracks/upload', [$controller, 'adminTrackUpload']);
$router->post('/admin/releases/tracks/sample/generate', [$controller, 'adminTrackGenerateSample']);
$router->get('/admin/releases/tracks/source', [$controller, 'adminTrackSource']);
};

View File

@@ -0,0 +1,129 @@
<?php
$pageTitle = $title ?? 'Edit Release';
$release = $release ?? [];
$error = $error ?? '';
$uploadError = (string)($_GET['upload_error'] ?? '');
$storePluginEnabled = (bool)($store_plugin_enabled ?? false);
ob_start();
?>
<section class="admin-card">
<div class="badge">Releases</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
<div>
<h1 style="font-size:28px; margin:0;"><?= htmlspecialchars($pageTitle, ENT_QUOTES, 'UTF-8') ?></h1>
<p style="color: var(--muted); margin-top:6px;">Create or update a release.</p>
</div>
<div style="display:flex; gap:10px; align-items:center;">
<?php if ((int)($release['id'] ?? 0) > 0): ?>
<a href="/admin/releases/tracks?release_id=<?= (int)$release['id'] ?>" class="btn outline">Manage Tracks</a>
<?php endif; ?>
<a href="/admin/releases" class="btn outline">Back</a>
</div>
</div>
<?php if ($error): ?>
<div style="margin-top:16px; color:#f3b0b0; font-size:13px;"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<?php if ($uploadError !== ''): ?>
<div style="margin-top:12px; color:#f3b0b0; font-size:13px;"><?= htmlspecialchars($uploadError, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<form method="post" action="/admin/releases/save" enctype="multipart/form-data" style="margin-top:16px; display:grid; gap:16px;">
<input type="hidden" name="id" value="<?= (int)($release['id'] ?? 0) ?>">
<div class="admin-card" style="padding:16px;">
<div style="display:grid; gap:12px;">
<label class="label">Title</label>
<input class="input" name="title" value="<?= htmlspecialchars((string)($release['title'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="Release title">
<label class="label">Artist</label>
<input class="input" name="artist_name" value="<?= htmlspecialchars((string)($release['artist_name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="Artist name">
<label class="label">Slug</label>
<input class="input" name="slug" value="<?= htmlspecialchars((string)($release['slug'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="release-title">
<label class="label">Release Date</label>
<input class="input" type="date" name="release_date" value="<?= htmlspecialchars((string)($release['release_date'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<label class="label">Catalog Number</label>
<input class="input" name="catalog_no" value="<?= htmlspecialchars((string)($release['catalog_no'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="CAT-001">
<div class="admin-card" style="padding:14px; background: rgba(10,10,12,0.6);">
<div class="label">Upload cover</div>
<input type="hidden" name="release_id" value="<?= (int)($release['id'] ?? 0) ?>">
<label for="releaseCoverFile" id="releaseCoverDropzone" style="display:flex; flex-direction:column; gap:8px; align-items:center; justify-content:center; padding:18px; border-radius:14px; border:1px dashed rgba(255,255,255,0.2); background: rgba(0,0,0,0.2); cursor:pointer;">
<div style="font-size:11px; text-transform:uppercase; letter-spacing:0.2em; color:var(--muted);">Drag &amp; Drop</div>
<div style="font-size:13px; color:var(--text);">or click to upload</div>
<div id="releaseCoverFileName" style="font-size:11px; color:var(--muted);">No file selected</div>
</label>
<input class="input" type="file" id="releaseCoverFile" name="release_cover" accept="image/*" style="display:none;">
<div style="margin-top:10px; display:flex; justify-content:flex-end;">
<button type="submit" class="btn small" formaction="/admin/releases/upload" formmethod="post" formenctype="multipart/form-data" name="upload_type" value="cover">Upload</button>
</div>
</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<label class="label" style="margin:0;">Cover URL</label>
<button type="button" class="btn outline small" data-media-picker="release_cover_url" data-media-picker-mode="url">Pick from Media</button>
</div>
<input class="input" id="release_cover_url" name="cover_url" value="<?= htmlspecialchars((string)($release['cover_url'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="https://...">
<label class="label">Description</label>
<textarea class="input" name="description" rows="6" style="resize:vertical; font-family:'IBM Plex Mono', monospace; font-size:13px; line-height:1.6;"><?= htmlspecialchars((string)($release['description'] ?? ''), ENT_QUOTES, 'UTF-8') ?></textarea>
<label class="label">Release Credits</label>
<textarea class="input" name="credits" rows="4" style="resize:vertical; font-family:'IBM Plex Mono', monospace; font-size:13px; line-height:1.6;" placeholder="Written by..., Produced by..., Mastered by..."><?= htmlspecialchars((string)($release['credits'] ?? ''), ENT_QUOTES, 'UTF-8') ?></textarea>
<?php if ($storePluginEnabled): ?>
<div class="admin-card" style="padding:14px; background: rgba(10,10,12,0.6);">
<div class="label" style="margin-bottom:10px;">Store Options</div>
<label style="display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<input type="checkbox" name="store_enabled" value="1" <?= ((int)($release['store_enabled'] ?? 0) === 1) ? 'checked' : '' ?>>
Enable release purchase
</label>
<div style="display:grid; grid-template-columns:1fr 120px; gap:10px; margin-top:10px;">
<div>
<label class="label">Bundle Price</label>
<input class="input" name="bundle_price" value="<?= htmlspecialchars((string)($release['bundle_price'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="3.99">
</div>
<div>
<label class="label">Currency</label>
<input class="input" name="store_currency" value="<?= htmlspecialchars((string)($release['store_currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?>" placeholder="GBP">
</div>
</div>
<label class="label" style="margin-top:10px;">Button Label (optional)</label>
<input class="input" name="purchase_label" value="<?= htmlspecialchars((string)($release['purchase_label'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="Buy Release">
</div>
<?php endif; ?>
<label style="display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<input type="checkbox" name="is_published" value="1" <?= ((int)($release['is_published'] ?? 1) === 1) ? 'checked' : '' ?>>
Published
</label>
</div>
</div>
<div style="display:flex; justify-content:flex-end; gap:12px; align-items:center;">
<button type="submit" class="btn">Save release</button>
</div>
</form>
</section>
<script>
(function () {
const coverDrop = document.getElementById('releaseCoverDropzone');
const coverFile = document.getElementById('releaseCoverFile');
const coverName = document.getElementById('releaseCoverFileName');
if (coverDrop && coverFile && coverName) {
coverDrop.addEventListener('dragover', (event) => {
event.preventDefault();
coverDrop.style.borderColor = 'var(--accent)';
});
coverDrop.addEventListener('dragleave', () => {
coverDrop.style.borderColor = 'rgba(255,255,255,0.2)';
});
coverDrop.addEventListener('drop', (event) => {
event.preventDefault();
coverDrop.style.borderColor = 'rgba(255,255,255,0.2)';
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length) {
coverFile.files = event.dataTransfer.files;
coverName.textContent = event.dataTransfer.files[0].name;
}
});
coverFile.addEventListener('change', () => {
coverName.textContent = coverFile.files.length ? coverFile.files[0].name : 'No file selected';
});
}
})();
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

View File

@@ -0,0 +1,87 @@
<?php
$pageTitle = 'Releases';
$releases = $releases ?? [];
$tableReady = $table_ready ?? false;
$pageId = (int)($page_id ?? 0);
$pagePublished = (int)($page_published ?? 0);
ob_start();
?>
<section class="admin-card">
<div class="badge">Releases</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
<div>
<h1 style="font-size:28px; margin:0;">Releases</h1>
<p style="color: var(--muted); margin-top:6px;">Manage singles, EPs, and albums.</p>
</div>
<a href="/admin/releases/new" class="btn">New Release</a>
</div>
<?php if (!$tableReady): ?>
<div class="admin-card" style="margin-top:16px; padding:16px; display:flex; align-items:center; justify-content:space-between; gap:16px;">
<div>
<div style="font-weight:600;">Database not initialized</div>
<div style="color: var(--muted); font-size:13px; margin-top:4px;">Create the releases table before adding records.</div>
</div>
<form method="post" action="/admin/releases/install">
<button type="submit" class="btn small">Create Tables</button>
</form>
</div>
<?php else: ?>
<div class="admin-card" style="margin-top:16px; padding:14px; display:flex; align-items:center; justify-content:space-between; gap:16px;">
<div>
<div style="font-weight:600;">Releases page</div>
<div style="color: var(--muted); font-size:12px; margin-top:4px;">
Slug: <code>releases</code>
<?php if ($pageId > 0): ?>
- Status: <?= $pagePublished === 1 ? 'Published' : 'Draft' ?>
<?php else: ?>
- Not created
<?php endif; ?>
</div>
</div>
<?php if ($pageId > 0): ?>
<a href="/admin/pages/edit?id=<?= $pageId ?>" class="btn outline small">Edit Page Content</a>
<?php else: ?>
<span class="pill">Re-enable plugin to create</span>
<?php endif; ?>
</div>
<?php if (!$releases): ?>
<div style="margin-top:18px; color: var(--muted); font-size:13px;">No releases yet.</div>
<?php else: ?>
<div style="margin-top:18px; display:grid; gap:12px;">
<?php foreach ($releases as $release): ?>
<div class="admin-card" style="padding:14px; display:flex; align-items:center; justify-content:space-between; gap:16px;">
<div style="display:flex; gap:12px; align-items:center;">
<div style="width:44px; height:44px; border-radius:12px; overflow:hidden; background:rgba(255,255,255,0.06); display:grid; place-items:center;">
<?php if (!empty($release['cover_url'])): ?>
<img src="<?= htmlspecialchars((string)$release['cover_url'], ENT_QUOTES, 'UTF-8') ?>" alt="" style="width:100%; height:100%; object-fit:cover;">
<?php else: ?>
<span style="font-size:12px; color:var(--muted);">N/A</span>
<?php endif; ?>
</div>
<div>
<div style="font-weight:600;"><?= htmlspecialchars((string)$release['title'], ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:12px; color:var(--muted);"><?= htmlspecialchars((string)$release['slug'], ENT_QUOTES, 'UTF-8') ?></div>
</div>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<?php if ((int)$release['is_published'] !== 1): ?>
<span class="pill">Draft</span>
<?php endif; ?>
<a href="/admin/releases/tracks?release_id=<?= (int)$release['id'] ?>" class="btn outline small">Tracks</a>
<a href="/admin/releases/edit?id=<?= (int)$release['id'] ?>" class="btn outline small">Edit</a>
<form method="post" action="/admin/releases/delete" onsubmit="return confirm('Delete this release?');">
<input type="hidden" name="id" value="<?= (int)$release['id'] ?>">
<button type="submit" class="btn outline small">Delete</button>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endif; ?>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

View File

@@ -0,0 +1,295 @@
<?php
$pageTitle = $title ?? 'Edit Track';
$track = $track ?? [];
$release = $release ?? null;
$error = $error ?? '';
$uploadError = (string)($_GET['upload_error'] ?? '');
$storePluginEnabled = (bool)($store_plugin_enabled ?? false);
$trackId = (int)($track['id'] ?? 0);
$fullSourceUrl = $trackId > 0 ? '/admin/releases/tracks/source?track_id=' . $trackId : '';
ob_start();
?>
<section class="admin-card">
<div class="badge">Releases</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
<div>
<h1 style="font-size:28px; margin:0;"><?= htmlspecialchars($pageTitle, ENT_QUOTES, 'UTF-8') ?></h1>
<p style="color: var(--muted); margin-top:6px;">
<?= $release ? htmlspecialchars((string)$release['title'], ENT_QUOTES, 'UTF-8') : 'Track details' ?>
</p>
</div>
<a href="/admin/releases/tracks?release_id=<?= (int)($track['release_id'] ?? 0) ?>" class="btn outline">Back</a>
</div>
<?php if ($error): ?>
<div style="margin-top:16px; color:#f3b0b0; font-size:13px;"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<?php if ($uploadError !== ''): ?>
<div style="margin-top:12px; color:#f3b0b0; font-size:13px;"><?= htmlspecialchars($uploadError, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<form method="post" action="/admin/releases/tracks/save" enctype="multipart/form-data" style="margin-top:16px; display:grid; gap:16px;">
<input type="hidden" name="id" value="<?= (int)($track['id'] ?? 0) ?>">
<input type="hidden" name="track_id" value="<?= (int)($track['id'] ?? 0) ?>">
<input type="hidden" name="release_id" value="<?= (int)($track['release_id'] ?? 0) ?>">
<div class="admin-card" style="padding:16px;">
<div style="display:grid; gap:12px;">
<label class="label">Track #</label>
<input class="input" name="track_no" value="<?= htmlspecialchars((string)($track['track_no'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="1">
<label class="label">Title</label>
<input class="input" name="title" value="<?= htmlspecialchars((string)($track['title'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="Track title">
<label class="label">Mix Name</label>
<input class="input" name="mix_name" value="<?= htmlspecialchars((string)($track['mix_name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="Extended Mix">
<label class="label">Duration</label>
<input class="input" name="duration" value="<?= htmlspecialchars((string)($track['duration'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="6:12">
<label class="label">BPM</label>
<input class="input" name="bpm" value="<?= htmlspecialchars((string)($track['bpm'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="140">
<label class="label">Key</label>
<input class="input" name="key_signature" value="<?= htmlspecialchars((string)($track['key_signature'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="B Minor">
<div class="admin-card" style="padding:14px; background: rgba(10,10,12,0.6);">
<div class="label">Upload sample (MP3)</div>
<label for="trackSampleFile" id="trackSampleDropzone" style="display:flex; flex-direction:column; gap:8px; align-items:center; justify-content:center; padding:18px; border-radius:14px; border:1px dashed rgba(255,255,255,0.2); background: rgba(0,0,0,0.2); cursor:pointer;">
<div style="font-size:11px; text-transform:uppercase; letter-spacing:0.2em; color:var(--muted);">Drag &amp; Drop</div>
<div style="font-size:13px; color:var(--text);">or click to upload</div>
<div id="trackSampleFileName" style="font-size:11px; color:var(--muted);">No file selected</div>
</label>
<input class="input" type="file" id="trackSampleFile" name="track_sample" accept=".mp3,audio/mpeg,audio/mp3" style="display:none;">
<div style="margin-top:10px; display:flex; justify-content:flex-end;">
<button type="submit" class="btn small" formaction="/admin/releases/tracks/upload" formmethod="post" formenctype="multipart/form-data" name="upload_kind" value="sample">Upload</button>
</div>
</div>
<label class="label">Sample URL (MP3)</label>
<input class="input" name="sample_url" value="<?= htmlspecialchars((string)($track['sample_url'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="https://...">
<?php if ($storePluginEnabled): ?>
<div class="admin-card" style="padding:14px; background: rgba(10,10,12,0.6);">
<div class="label">Upload full track (MP3)</div>
<label for="trackFullFile" id="trackFullDropzone" style="display:flex; flex-direction:column; gap:8px; align-items:center; justify-content:center; padding:18px; border-radius:14px; border:1px dashed rgba(255,255,255,0.2); background: rgba(0,0,0,0.2); cursor:pointer;">
<div style="font-size:11px; text-transform:uppercase; letter-spacing:0.2em; color:var(--muted);">Drag &amp; Drop</div>
<div style="font-size:13px; color:var(--text);">or click to upload</div>
<div id="trackFullFileName" style="font-size:11px; color:var(--muted);">No file selected</div>
</label>
<input class="input" type="file" id="trackFullFile" name="track_sample" accept=".mp3,audio/mpeg,audio/mp3" style="display:none;">
<div style="margin-top:10px; display:flex; justify-content:flex-end;">
<button type="submit" class="btn small" formaction="/admin/releases/tracks/upload" formmethod="post" formenctype="multipart/form-data" name="upload_kind" value="full">Upload Full</button>
</div>
</div>
<?php if ($trackId > 0 && (string)($track['full_file_url'] ?? '') !== ''): ?>
<div class="admin-card" style="padding:14px; background: rgba(10,10,12,0.6);">
<div class="label">Generate sample from full track</div>
<div id="trackWaveform" style="margin-top:8px; height:96px; border-radius:12px; border:1px solid rgba(255,255,255,.12); background:rgba(0,0,0,.25);"></div>
<div style="display:grid; gap:10px; margin-top:10px;">
<input type="hidden" id="sampleStart" name="sample_start" value="0">
<input type="hidden" id="sampleDuration" name="sample_duration" value="90">
<div style="display:flex; flex-wrap:wrap; align-items:center; gap:10px;">
<button type="button" class="btn outline small" id="previewSampleRegionBtn">Preview</button>
<div class="input" style="display:flex; align-items:center; gap:10px; min-height:42px; width:clamp(240px,42vw,440px);">
<span style="font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:var(--muted);">Range</span>
<span id="sampleRangeText" style="font-family:'IBM Plex Mono',monospace; color:var(--text);">0s -> 90s</span>
<span id="sampleDurationText" style="margin-left:auto; font-family:'IBM Plex Mono',monospace; color:var(--muted);">90s</span>
</div>
<div style="margin-left:auto; display:flex; gap:10px;">
<button type="button" class="btn outline small" id="resetSampleRegionBtn">Reset range</button>
<button type="submit" class="btn small" formaction="/admin/releases/tracks/sample/generate" formmethod="post">Generate sample</button>
</div>
</div>
</div>
</div>
<?php endif; ?>
<label class="label">Full File URL (Store Download)</label>
<input class="input" name="full_file_url" value="<?= htmlspecialchars((string)($track['full_file_url'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="/uploads/media/track-full.mp3">
<div class="admin-card" style="padding:14px; background: rgba(10,10,12,0.6);">
<div class="label" style="margin-bottom:10px;">Store Options</div>
<label style="display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<input type="checkbox" name="store_enabled" value="1" <?= ((int)($track['store_enabled'] ?? 0) === 1) ? 'checked' : '' ?>>
Enable track purchase
</label>
<div style="display:grid; grid-template-columns:1fr 120px; gap:10px; margin-top:10px;">
<div>
<label class="label">Track Price</label>
<input class="input" name="track_price" value="<?= htmlspecialchars((string)($track['track_price'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="1.49">
</div>
<div>
<label class="label">Currency</label>
<input class="input" name="store_currency" value="<?= htmlspecialchars((string)($track['store_currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?>" placeholder="GBP">
</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
<div style="display:flex; justify-content:flex-end; gap:12px; align-items:center;">
<button type="submit" class="btn">Save track</button>
</div>
</form>
</section>
<script src="https://unpkg.com/wavesurfer.js@7/dist/wavesurfer.min.js"></script>
<script src="https://unpkg.com/wavesurfer.js@7/dist/plugins/regions.min.js"></script>
<script>
(function () {
const drop = document.getElementById('trackSampleDropzone');
const file = document.getElementById('trackSampleFile');
const name = document.getElementById('trackSampleFileName');
if (drop && file && name) {
drop.addEventListener('dragover', (event) => {
event.preventDefault();
drop.style.borderColor = 'var(--accent)';
});
drop.addEventListener('dragleave', () => {
drop.style.borderColor = 'rgba(255,255,255,0.2)';
});
drop.addEventListener('drop', (event) => {
event.preventDefault();
drop.style.borderColor = 'rgba(255,255,255,0.2)';
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length) {
file.files = event.dataTransfer.files;
name.textContent = event.dataTransfer.files[0].name;
}
});
file.addEventListener('change', () => {
name.textContent = file.files.length ? file.files[0].name : 'No file selected';
});
}
const fullDrop = document.getElementById('trackFullDropzone');
const fullFile = document.getElementById('trackFullFile');
const fullName = document.getElementById('trackFullFileName');
if (fullDrop && fullFile && fullName) {
fullDrop.addEventListener('dragover', (event) => {
event.preventDefault();
fullDrop.style.borderColor = 'var(--accent)';
});
fullDrop.addEventListener('dragleave', () => {
fullDrop.style.borderColor = 'rgba(255,255,255,0.2)';
});
fullDrop.addEventListener('drop', (event) => {
event.preventDefault();
fullDrop.style.borderColor = 'rgba(255,255,255,0.2)';
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length) {
fullFile.files = event.dataTransfer.files;
fullName.textContent = event.dataTransfer.files[0].name;
}
});
fullFile.addEventListener('change', () => {
fullName.textContent = fullFile.files.length ? fullFile.files[0].name : 'No file selected';
});
}
const waveWrap = document.getElementById('trackWaveform');
const previewRegionBtn = document.getElementById('previewSampleRegionBtn');
const startInput = document.getElementById('sampleStart');
const durationInput = document.getElementById('sampleDuration');
const rangeText = document.getElementById('sampleRangeText');
const durationText = document.getElementById('sampleDurationText');
const resetRegionBtn = document.getElementById('resetSampleRegionBtn');
if (waveWrap && startInput && durationInput && window.WaveSurfer) {
let fixedDuration = 90;
let regionRef = null;
let isPreviewPlaying = false;
const ws = WaveSurfer.create({
container: waveWrap,
waveColor: 'rgba(175,190,220,0.35)',
progressColor: '#22f2a5',
cursorColor: '#22f2a5',
barWidth: 2,
barGap: 2,
height: 92,
});
const regions = ws.registerPlugin(window.WaveSurfer.Regions.create());
ws.load('<?= htmlspecialchars($fullSourceUrl, ENT_QUOTES, 'UTF-8') ?>');
function syncRange(startSec) {
const start = Math.max(0, Math.floor(startSec));
const end = Math.max(start, Math.floor(start + fixedDuration));
startInput.value = String(start);
durationInput.value = String(fixedDuration);
if (rangeText) rangeText.textContent = start + 's → ' + end + 's';
if (durationText) durationText.textContent = fixedDuration + 's';
}
ws.on('ready', () => {
const total = Math.floor(ws.getDuration() || 0);
fixedDuration = Math.max(10, Math.min(90, total > 0 ? total : 90));
syncRange(0);
regionRef = regions.addRegion({
start: 0,
end: fixedDuration,
drag: true,
resize: false,
color: 'rgba(34,242,165,0.20)',
});
const updateRegion = () => {
if (!regionRef) return;
let start = regionRef.start || 0;
const maxStart = Math.max(0, (ws.getDuration() || fixedDuration) - fixedDuration);
if (start < 0) start = 0;
if (start > maxStart) start = maxStart;
regionRef.setOptions({ start: start, end: start + fixedDuration });
syncRange(start);
};
regionRef.on('update-end', updateRegion);
});
ws.on('interaction', () => {});
ws.on('audioprocess', () => {
if (!isPreviewPlaying || !regionRef) return;
const t = ws.getCurrentTime() || 0;
if (t >= regionRef.end) {
ws.pause();
ws.setTime(regionRef.start);
isPreviewPlaying = false;
if (previewRegionBtn) previewRegionBtn.textContent = 'Preview';
}
});
ws.on('pause', () => {
if (isPreviewPlaying) {
isPreviewPlaying = false;
if (previewRegionBtn) previewRegionBtn.textContent = 'Preview';
}
});
if (resetRegionBtn) {
resetRegionBtn.addEventListener('click', () => {
if (!regionRef) return;
regionRef.setOptions({ start: 0, end: fixedDuration });
syncRange(0);
});
}
if (previewRegionBtn) {
previewRegionBtn.addEventListener('click', () => {
if (!regionRef) return;
if (isPreviewPlaying) {
ws.pause();
isPreviewPlaying = false;
previewRegionBtn.textContent = 'Preview';
return;
}
ws.setTime(regionRef.start);
ws.play();
isPreviewPlaying = true;
previewRegionBtn.textContent = 'Stop preview';
});
}
} else if (resetRegionBtn) {
resetRegionBtn.addEventListener('click', () => {
if (startInput) startInput.value = '0';
});
}
})();
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

View File

@@ -0,0 +1,69 @@
<?php
$pageTitle = 'Release Tracks';
$release = $release ?? null;
$tracks = $tracks ?? [];
$tableReady = $table_ready ?? false;
$releaseId = (int)($release_id ?? 0);
ob_start();
?>
<section class="admin-card">
<div class="badge">Releases</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
<div>
<h1 style="font-size:28px; margin:0;">Tracks</h1>
<p style="color: var(--muted); margin-top:6px;">
<?= $release ? htmlspecialchars((string)$release['title'], ENT_QUOTES, 'UTF-8') : 'Select a release to manage tracks.' ?>
</p>
</div>
<div style="display:flex; gap:10px; align-items:center;">
<a href="/admin/releases" class="btn outline">Back</a>
<?php if ($releaseId > 0): ?>
<a href="/admin/releases/tracks/new?release_id=<?= $releaseId ?>" class="btn">New Track</a>
<?php endif; ?>
</div>
</div>
<?php if (!$tableReady): ?>
<div style="margin-top:18px; color: var(--muted); font-size:13px;">Tracks table is not available. Run Releases ? Create Tables.</div>
<?php elseif (!$release): ?>
<div style="margin-top:18px; color: var(--muted); font-size:13px;">Release not found.</div>
<?php elseif (!$tracks): ?>
<div style="margin-top:18px; color: var(--muted); font-size:13px;">No tracks yet.</div>
<?php else: ?>
<div style="margin-top:18px; display:grid; gap:12px;">
<?php foreach ($tracks as $track): ?>
<div class="admin-card" style="padding:14px; display:flex; align-items:center; justify-content:space-between; gap:16px;">
<div style="display:flex; gap:12px; align-items:center;">
<div style="width:38px; height:38px; border-radius:10px; background:rgba(255,255,255,0.06); display:grid; place-items:center; font-size:12px; color:var(--muted);">
<?= (int)($track['track_no'] ?? 0) > 0 ? (int)$track['track_no'] : '<27>' ?>
</div>
<div>
<div style="font-weight:600;">
<?= htmlspecialchars((string)$track['title'], ENT_QUOTES, 'UTF-8') ?>
<?php if (!empty($track['mix_name'])): ?>
<span style="color:var(--muted); font-weight:400;">(<?= htmlspecialchars((string)$track['mix_name'], ENT_QUOTES, 'UTF-8') ?>)</span>
<?php endif; ?>
</div>
<div style="font-size:12px; color:var(--muted);">
<?= htmlspecialchars((string)($track['duration'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
<?= !empty($track['bpm']) ? ' <20> ' . htmlspecialchars((string)$track['bpm'], ENT_QUOTES, 'UTF-8') . ' BPM' : '' ?>
<?= !empty($track['key_signature']) ? ' <20> ' . htmlspecialchars((string)$track['key_signature'], ENT_QUOTES, 'UTF-8') : '' ?>
</div>
</div>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<a href="/admin/releases/tracks/edit?release_id=<?= $releaseId ?>&id=<?= (int)$track['id'] ?>" class="btn outline small">Edit</a>
<form method="post" action="/admin/releases/tracks/delete" onsubmit="return confirm('Delete this track?');">
<input type="hidden" name="id" value="<?= (int)$track['id'] ?>">
<input type="hidden" name="release_id" value="<?= $releaseId ?>">
<button type="submit" class="btn outline small">Delete</button>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

View File

@@ -0,0 +1,366 @@
<?php
$pageTitle = $title ?? 'Releases';
$releases = is_array($releases ?? null) ? $releases : [];
$releaseCount = (int)($total_releases ?? count($releases));
$artistFilter = trim((string)($artist_filter ?? ''));
$artistOptions = is_array($artist_options ?? null) ? $artist_options : [];
$search = trim((string)($search ?? ''));
$sort = trim((string)($sort ?? 'newest'));
$currentPage = max(1, (int)($current_page ?? 1));
$totalPages = max(1, (int)($total_pages ?? 1));
$buildReleaseUrl = static function (int $page) use ($search, $artistFilter, $sort): string {
$params = [];
if ($search !== '') {
$params['q'] = $search;
}
if ($artistFilter !== '') {
$params['artist'] = $artistFilter;
}
if ($sort !== 'newest') {
$params['sort'] = $sort;
}
if ($page > 1) {
$params['p'] = $page;
}
$qs = http_build_query($params);
return '/releases' . ($qs !== '' ? ('?' . $qs) : '');
};
ob_start();
?>
<div class="ac-releases-page">
<section class="card ac-releases-shell">
<div class="ac-releases-header">
<div class="badge">Releases</div>
<h1>Latest Drops</h1>
<p>Singles, EPs, and albums from the AudioCore catalog.</p>
</div>
<form method="get" action="/releases" class="ac-release-controls">
<div class="ac-search-wrap">
<span class="ac-search-icon"><i class="fa-solid fa-magnifying-glass"></i></span>
<input class="ac-search-input" type="text" name="q" value="<?= htmlspecialchars($search, ENT_QUOTES, 'UTF-8') ?>" placeholder="Search releases, artists, catalog number">
</div>
<select class="ac-control-input" name="artist">
<option value="">All artists</option>
<?php foreach ($artistOptions as $artist): ?>
<option value="<?= htmlspecialchars((string)$artist, ENT_QUOTES, 'UTF-8') ?>" <?= $artistFilter === (string)$artist ? 'selected' : '' ?>>
<?= htmlspecialchars((string)$artist, ENT_QUOTES, 'UTF-8') ?>
</option>
<?php endforeach; ?>
</select>
<select class="ac-control-input" name="sort">
<option value="newest" <?= $sort === 'newest' ? 'selected' : '' ?>>Newest first</option>
<option value="oldest" <?= $sort === 'oldest' ? 'selected' : '' ?>>Oldest first</option>
<option value="title_asc" <?= $sort === 'title_asc' ? 'selected' : '' ?>>Title A-Z</option>
<option value="title_desc" <?= $sort === 'title_desc' ? 'selected' : '' ?>>Title Z-A</option>
</select>
<button type="submit" class="ac-btn ac-btn-primary">Apply</button>
<a href="/releases" class="ac-btn ac-btn-ghost">Reset</a>
</form>
<?php if ($artistFilter !== '' || $search !== '' || $sort !== 'newest'): ?>
<div class="ac-active-filters">
<?php if ($artistFilter !== ''): ?><div class="ac-chip">Artist: <?= htmlspecialchars($artistFilter, ENT_QUOTES, 'UTF-8') ?></div><?php endif; ?>
<?php if ($search !== ''): ?><div class="ac-chip">Search: <?= htmlspecialchars($search, ENT_QUOTES, 'UTF-8') ?></div><?php endif; ?>
<?php if ($sort !== 'newest'): ?><div class="ac-chip">Sort: <?= htmlspecialchars(str_replace('_', ' ', $sort), ENT_QUOTES, 'UTF-8') ?></div><?php endif; ?>
</div>
<?php endif; ?>
<?php if (!$releases): ?>
<div class="ac-empty">No releases published yet.</div>
<?php else: ?>
<div class="ac-release-grid">
<?php foreach ($releases as $release): ?>
<a class="ac-release-card" href="/release?slug=<?= htmlspecialchars((string)$release['slug'], ENT_QUOTES, 'UTF-8') ?>">
<div class="ac-release-cover">
<?php if (!empty($release['cover_url'])): ?>
<img src="<?= htmlspecialchars((string)$release['cover_url'], ENT_QUOTES, 'UTF-8') ?>" alt="">
<?php else: ?>
<div class="ac-release-placeholder">AC</div>
<?php endif; ?>
<?php if (!empty($release['release_date'])): ?>
<span class="ac-release-date"><?= htmlspecialchars((string)$release['release_date'], ENT_QUOTES, 'UTF-8') ?></span>
<?php endif; ?>
</div>
<div class="ac-release-meta">
<div class="ac-release-title"><?= htmlspecialchars((string)$release['title'], ENT_QUOTES, 'UTF-8') ?></div>
<?php if (!empty($release['artist_name'])): ?>
<div class="ac-release-artist"><?= htmlspecialchars((string)$release['artist_name'], ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
</div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($totalPages > 1): ?>
<nav class="ac-release-pagination">
<?php $prevPage = max(1, $currentPage - 1); ?>
<?php $nextPage = min($totalPages, $currentPage + 1); ?>
<a class="ac-btn ac-btn-ghost<?= $currentPage <= 1 ? ' is-disabled' : '' ?>" href="<?= $currentPage <= 1 ? '#' : htmlspecialchars($buildReleaseUrl($prevPage), ENT_QUOTES, 'UTF-8') ?>">Prev</a>
<div class="ac-pagination-meta">Page <?= $currentPage ?> of <?= $totalPages ?> · <?= $releaseCount ?> total</div>
<a class="ac-btn ac-btn-ghost<?= $currentPage >= $totalPages ? ' is-disabled' : '' ?>" href="<?= $currentPage >= $totalPages ? '#' : htmlspecialchars($buildReleaseUrl($nextPage), ENT_QUOTES, 'UTF-8') ?>">Next</a>
</nav>
<?php endif; ?>
</section>
</div>
<style>
.ac-releases-page .ac-releases-shell {
margin-top: 14px;
display: grid;
gap: 14px;
}
.ac-releases-page .ac-releases-header {
border-bottom: 1px solid rgba(255,255,255,0.06);
padding-bottom: 10px;
}
.ac-releases-page .ac-releases-header h1 {
margin: 8px 0 0;
font-size: 52px;
line-height: 1.05;
letter-spacing: -0.02em;
}
.ac-releases-page .ac-releases-header p {
margin: 8px 0 0;
color: var(--muted);
font-size: 14px;
}
.ac-releases-page .ac-release-controls {
display: grid;
grid-template-columns: minmax(260px, 1fr) 180px 180px auto auto;
gap: 10px;
align-items: center;
padding: 8px;
border: 1px solid rgba(255,255,255,0.07);
border-radius: 14px;
background: rgba(8, 12, 19, 0.16);
}
.ac-releases-page .ac-search-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
border: 1px solid rgba(255,255,255,0.07);
border-radius: 10px;
background: rgba(7,10,16,0.10);
height: 36px;
padding: 0 10px;
}
.ac-releases-page .ac-search-icon {
color: rgba(255,255,255,0.72);
font-size: 12px;
width: 16px;
display: inline-flex;
justify-content: center;
}
.ac-releases-page .ac-search-input,
.ac-releases-page .ac-control-input {
width: 100%;
height: 36px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.07);
background: rgba(7,10,16,0.10);
color: rgba(233,237,247,0.94);
padding: 0 10px;
font-size: 13px;
outline: none;
}
.ac-releases-page .ac-search-input {
border: 0;
background: transparent;
padding: 0;
min-width: 0;
}
.ac-releases-page .ac-search-input::placeholder {
color: rgba(220,228,245,.45);
}
.ac-releases-page .ac-search-input:focus,
.ac-releases-page .ac-control-input:focus {
box-shadow: 0 0 0 2px rgba(34,242,165,.12);
border-color: rgba(34,242,165,.38);
}
.ac-releases-page .ac-btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
padding: 0 14px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,.10);
text-decoration: none;
font-size: 10px;
text-transform: uppercase;
letter-spacing: .12em;
font-weight: 700;
cursor: pointer;
transition: all .2s ease;
}
.ac-releases-page .ac-btn-primary {
background: rgba(34,242,165,.14);
color: #87f2cb;
border-color: rgba(34,242,165,.34);
}
.ac-releases-page .ac-btn-primary:hover {
background: rgba(34,242,165,.20);
}
.ac-releases-page .ac-btn-ghost {
color: #a1acc4;
background: transparent;
border-color: rgba(255,255,255,.10);
}
.ac-releases-page .ac-btn-ghost:hover {
color: #e5ebf7;
border-color: rgba(255,255,255,.18);
}
.ac-releases-page .ac-btn.is-disabled {
opacity: .45;
pointer-events: none;
}
.ac-releases-page .ac-active-filters {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ac-releases-page .ac-chip {
border-radius: 999px;
padding: 6px 10px;
border: 1px solid rgba(255,255,255,.12);
font-size: 10px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
font-family: 'IBM Plex Mono', monospace;
}
.ac-releases-page .ac-release-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 240px));
justify-content: start;
gap: 16px;
}
.ac-releases-page .ac-release-card {
display: grid;
gap: 10px;
color: inherit;
text-decoration: none;
padding: 10px;
border-radius: 18px;
border: 1px solid rgba(255,255,255,.08);
background: rgba(0,0,0,.14);
transition: border-color .2s ease, transform .2s ease;
}
.ac-releases-page .ac-release-card:hover {
border-color: rgba(255,255,255,.18);
transform: translateY(-2px);
}
.ac-releases-page .ac-release-cover {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 16px;
overflow: hidden;
position: relative;
background: rgba(255,255,255,.03);
}
.ac-releases-page .ac-release-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.ac-releases-page .ac-release-date {
position: absolute;
left: 10px;
bottom: 10px;
border-radius: 999px;
padding: 6px 10px;
font-size: 11px;
color: #fff;
background: rgba(0,0,0,.56);
border: 1px solid rgba(255,255,255,.18);
}
.ac-releases-page .ac-release-placeholder {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: var(--muted);
letter-spacing: .16em;
}
.ac-releases-page .ac-release-meta {
display: grid;
gap: 6px;
}
.ac-releases-page .ac-release-title {
font-weight: 600;
font-size: 18px;
line-height: 1.2;
}
.ac-releases-page .ac-release-artist {
font-size: 13px;
color: var(--muted);
}
.ac-releases-page .ac-empty {
padding: 16px;
border: 1px solid rgba(255,255,255,.08);
border-radius: 12px;
color: var(--muted);
}
.ac-releases-page .ac-release-pagination {
margin-top: 2px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
}
.ac-releases-page .ac-pagination-meta {
font-size: 12px;
color: var(--muted);
}
@media (max-width: 1180px) {
.ac-releases-page .ac-release-controls {
grid-template-columns: 1fr 160px 160px auto auto;
}
.ac-releases-page .ac-release-grid {
grid-template-columns: repeat(auto-fill, minmax(210px, 230px));
}
}
@media (max-width: 900px) {
.ac-releases-page .ac-release-controls {
grid-template-columns: 1fr 1fr 1fr;
}
.ac-releases-page .ac-release-controls .ac-search-wrap {
grid-column: 1 / -1;
}
}
@media (max-width: 760px) {
.ac-releases-page .ac-releases-header h1 {
font-size: 40px;
}
.ac-releases-page .ac-release-controls {
grid-template-columns: 1fr;
}
.ac-releases-page .ac-release-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
justify-content: stretch;
}
}
@media (max-width: 520px) {
.ac-releases-page .ac-release-grid {
grid-template-columns: 1fr;
}
}
</style>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../views/site/layout.php';

View File

@@ -0,0 +1,397 @@
<?php
declare(strict_types=1);
$pageTitle = $title ?? 'Release';
$release = $release ?? null;
$tracks = $tracks ?? [];
$bundles = is_array($bundles ?? null) ? $bundles : [];
$storePluginEnabled = (bool)($store_plugin_enabled ?? false);
$releaseCover = (string)($release['cover_url'] ?? '');
$returnUrl = (string)($_SERVER['REQUEST_URI'] ?? '/releases');
$releaseStoreEnabled = (int)($release['store_enabled'] ?? 0) === 1;
$bundlePrice = (float)($release['bundle_price'] ?? 0);
$bundleCurrency = (string)($release['store_currency'] ?? 'GBP');
$bundleLabel = trim((string)($release['purchase_label'] ?? ''));
$bundleLabel = $bundleLabel !== '' ? $bundleLabel : 'Buy Release';
ob_start();
?>
<section class="card" style="display:grid; gap:18px; padding-bottom:110px;">
<div class="badge">Release</div>
<?php if (!$release): ?>
<h1 style="margin:0; font-size:28px;">Release not found</h1>
<p style="color:var(--muted);">This release is unavailable.</p>
<?php else: ?>
<div class="release-wrap" style="display:grid; gap:18px;">
<div class="release-hero" style="display:grid; grid-template-columns:minmax(0,1fr) 360px; gap:22px; align-items:start;">
<div class="release-meta" style="display:grid; gap:14px;">
<h1 style="margin:0; font-size:46px; line-height:1.06;"><?= htmlspecialchars((string)$release['title'], ENT_QUOTES, 'UTF-8') ?></h1>
<?php if (!empty($release['artist_name'])): ?>
<div style="font-size:14px; color:var(--muted);">
By
<a href="/releases?artist=<?= rawurlencode((string)$release['artist_name']) ?>" style="color:#dfe7fb; text-decoration:none; border-bottom:1px solid rgba(223,231,251,.35);">
<?= htmlspecialchars((string)$release['artist_name'], ENT_QUOTES, 'UTF-8') ?>
</a>
</div>
<?php endif; ?>
<div style="display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px; max-width:640px;">
<?php if (!empty($release['catalog_no'])): ?>
<div style="padding:10px 12px; border-radius:12px; border:1px solid rgba(255,255,255,0.08); background:rgba(0,0,0,0.22);">
<div class="badge" style="font-size:9px;">Catalog</div>
<div style="margin-top:6px; font-size:14px;"><?= htmlspecialchars((string)$release['catalog_no'], ENT_QUOTES, 'UTF-8') ?></div>
</div>
<?php endif; ?>
<?php if (!empty($release['release_date'])): ?>
<div style="padding:10px 12px; border-radius:12px; border:1px solid rgba(255,255,255,0.08); background:rgba(0,0,0,0.22);">
<div class="badge" style="font-size:9px;">Release Date</div>
<div style="margin-top:6px; font-size:14px;"><?= htmlspecialchars((string)$release['release_date'], ENT_QUOTES, 'UTF-8') ?></div>
</div>
<?php endif; ?>
</div>
<?php if (!empty($release['description'])): ?>
<div style="color:var(--muted); line-height:1.75; max-width:760px;">
<?= nl2br(htmlspecialchars((string)$release['description'], ENT_QUOTES, 'UTF-8')) ?>
</div>
<?php endif; ?>
<?php if ($storePluginEnabled && $releaseStoreEnabled && $bundlePrice > 0): ?>
<div style="margin-top:4px;">
<form method="post" action="/store/cart/add" style="margin:0;">
<input type="hidden" name="item_type" value="release">
<input type="hidden" name="item_id" value="<?= (int)($release['id'] ?? 0) ?>">
<input type="hidden" name="title" value="<?= htmlspecialchars((string)$release['title'], ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="cover_url" value="<?= htmlspecialchars($releaseCover, ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="currency" value="<?= htmlspecialchars($bundleCurrency, ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="price" value="<?= htmlspecialchars(number_format($bundlePrice, 2, '.', ''), ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="qty" value="1">
<input type="hidden" name="return_url" value="<?= htmlspecialchars($returnUrl, ENT_QUOTES, 'UTF-8') ?>">
<button type="submit" class="track-buy-btn">
<i class="fa-solid fa-cart-plus"></i>
<span><?= htmlspecialchars($bundleLabel, ENT_QUOTES, 'UTF-8') ?> <?= htmlspecialchars($bundleCurrency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($bundlePrice, 2) ?></span>
</button>
</form>
</div>
<?php endif; ?>
</div>
<div class="release-cover-box" style="border-radius:20px; overflow:hidden; background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.1); aspect-ratio:1/1;">
<?php if ($releaseCover !== ''): ?>
<img id="releaseCoverMain" src="<?= htmlspecialchars($releaseCover, ENT_QUOTES, 'UTF-8') ?>" alt="" style="width:100%; height:100%; object-fit:cover;">
<?php else: ?>
<div id="releaseCoverMain" style="height:100%; display:grid; place-items:center; color:var(--muted); letter-spacing:0.3em; font-size:12px;">AUDIOCORE</div>
<?php endif; ?>
</div>
</div>
<?php if ($tracks): ?>
<div style="padding:16px; border-radius:18px; border:1px solid rgba(255,255,255,0.08); background:rgba(0,0,0,0.22); display:grid; gap:10px;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<div class="badge">Tracklist</div>
<div style="display:flex; align-items:center; gap:10px;">
<div id="releasePlayerNow" style="font-size:12px; color:var(--muted);">Select a track to play sample</div>
</div>
</div>
<div style="display:grid; gap:8px;">
<?php foreach ($tracks as $track): ?>
<?php
$sample = (string)($track['sample_url'] ?? '');
$trackTitle = (string)($track['title'] ?? 'Track');
$mix = (string)($track['mix_name'] ?? '');
$fullTitle = $mix !== '' ? ($trackTitle . ' (' . $mix . ')') : $trackTitle;
?>
<div class="track-row" style="display:grid; grid-template-columns:92px minmax(0,1fr) auto; gap:12px; align-items:center; padding:10px 12px; border-radius:12px; border:1px solid rgba(255,255,255,0.08); background:rgba(0,0,0,0.26);">
<button type="button"
class="track-play-btn"
data-src="<?= htmlspecialchars($sample, ENT_QUOTES, 'UTF-8') ?>"
data-title="<?= htmlspecialchars($fullTitle, ENT_QUOTES, 'UTF-8') ?>"
data-cover="<?= htmlspecialchars($releaseCover, ENT_QUOTES, 'UTF-8') ?>"
<?= $sample === '' ? 'disabled' : '' ?>>
<i class="fa-solid fa-play"></i> <span>Play</span>
</button>
<div style="min-width:0;">
<div style="font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
<?= htmlspecialchars($trackTitle, ENT_QUOTES, 'UTF-8') ?>
<?php if ($mix !== ''): ?>
<span style="color:var(--muted); font-weight:400;">(<?= htmlspecialchars($mix, ENT_QUOTES, 'UTF-8') ?>)</span>
<?php endif; ?>
</div>
<div style="font-size:12px; color:var(--muted); margin-top:4px;">
<?= (int)($track['track_no'] ?? 0) > 0 ? '#' . (int)$track['track_no'] : 'Track' ?>
<?= !empty($track['duration']) ? ' - ' . htmlspecialchars((string)$track['duration'], ENT_QUOTES, 'UTF-8') : '' ?>
<?= !empty($track['bpm']) ? ' - ' . htmlspecialchars((string)$track['bpm'], ENT_QUOTES, 'UTF-8') . ' BPM' : '' ?>
<?= !empty($track['key_signature']) ? ' - ' . htmlspecialchars((string)$track['key_signature'], ENT_QUOTES, 'UTF-8') : '' ?>
</div>
</div>
<div style="display:flex; align-items:center; gap:8px;">
<?php
$trackStoreEnabled = (int)($track['store_enabled'] ?? 0) === 1;
$trackPrice = (float)($track['track_price'] ?? 0);
$trackCurrency = (string)($track['store_currency'] ?? 'GBP');
?>
<?php if ($storePluginEnabled && $trackStoreEnabled && $trackPrice > 0): ?>
<form method="post" action="/store/cart/add" style="margin:0;">
<input type="hidden" name="item_type" value="track">
<input type="hidden" name="item_id" value="<?= (int)($track['id'] ?? 0) ?>">
<input type="hidden" name="title" value="<?= htmlspecialchars($fullTitle, ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="cover_url" value="<?= htmlspecialchars($releaseCover, ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="currency" value="<?= htmlspecialchars($trackCurrency, ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="price" value="<?= htmlspecialchars(number_format($trackPrice, 2, '.', ''), ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="qty" value="1">
<input type="hidden" name="return_url" value="<?= htmlspecialchars($returnUrl, ENT_QUOTES, 'UTF-8') ?>">
<button type="submit" class="track-buy-btn">
<i class="fa-solid fa-cart-plus"></i>
<span>Buy <?= htmlspecialchars($trackCurrency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($trackPrice, 2) ?></span>
</button>
</form>
<?php else: ?>
<div style="font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:0.18em;">
<?= $sample !== '' ? 'Sample' : 'No sample' ?>
</div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php if ($storePluginEnabled && $bundles): ?>
<div class="bundle-zone" style="display:grid; gap:10px;">
<div class="badge">Bundle Deals</div>
<div style="display:grid; gap:10px;">
<?php foreach ($bundles as $bundle): ?>
<?php
$bundleId = (int)($bundle['id'] ?? 0);
$bundleName = trim((string)($bundle['name'] ?? 'Bundle'));
$bundlePriceRow = (float)($bundle['bundle_price'] ?? 0);
$bundleCurrencyRow = (string)($bundle['currency'] ?? 'GBP');
$bundleLabelRow = trim((string)($bundle['purchase_label'] ?? ''));
$bundleLabelRow = $bundleLabelRow !== '' ? $bundleLabelRow : 'Buy Bundle';
$bundleCovers = is_array($bundle['covers'] ?? null) ? $bundle['covers'] : [];
$bundleCount = (int)($bundle['release_count'] ?? 0);
$regularTotal = (float)($bundle['regular_total'] ?? 0);
$saving = max(0, $regularTotal - $bundlePriceRow);
?>
<div class="bundle-card">
<div class="bundle-stack" style="--cover-count:<?= max(1, min(5, count($bundleCovers))) ?>;" aria-hidden="true">
<?php if ($bundleCovers): ?>
<?php foreach (array_slice($bundleCovers, 0, 5) as $i => $cover): ?>
<span class="bundle-cover" style="--i:<?= (int)$i ?>;">
<img src="<?= htmlspecialchars((string)$cover, ENT_QUOTES, 'UTF-8') ?>" alt="">
</span>
<?php endforeach; ?>
<?php else: ?>
<span class="bundle-cover bundle-fallback">AC</span>
<?php endif; ?>
</div>
<div class="bundle-copy">
<div class="bundle-title"><?= htmlspecialchars($bundleName, ENT_QUOTES, 'UTF-8') ?></div>
<div class="bundle-meta">
<?= $bundleCount > 0 ? $bundleCount . ' releases' : 'Multi-release bundle' ?>
<?php if ($saving > 0): ?>
<span class="bundle-save">Save <?= htmlspecialchars($bundleCurrencyRow, ENT_QUOTES, 'UTF-8') ?> <?= number_format($saving, 2) ?></span>
<?php endif; ?>
</div>
</div>
<form method="post" action="/store/cart/add" style="margin:0;">
<input type="hidden" name="item_type" value="bundle">
<input type="hidden" name="item_id" value="<?= $bundleId ?>">
<input type="hidden" name="title" value="<?= htmlspecialchars($bundleName, ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="cover_url" value="<?= htmlspecialchars((string)($bundleCovers[0] ?? $releaseCover), ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="currency" value="<?= htmlspecialchars($bundleCurrencyRow, ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="price" value="<?= htmlspecialchars(number_format($bundlePriceRow, 2, '.', ''), ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="qty" value="1">
<input type="hidden" name="return_url" value="<?= htmlspecialchars($returnUrl, ENT_QUOTES, 'UTF-8') ?>">
<button type="submit" class="track-buy-btn">
<i class="fa-solid fa-cart-plus"></i>
<span><?= htmlspecialchars($bundleLabelRow, ENT_QUOTES, 'UTF-8') ?> <?= htmlspecialchars($bundleCurrencyRow, ENT_QUOTES, 'UTF-8') ?> <?= number_format($bundlePriceRow, 2) ?></span>
</button>
</form>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php if (!empty($release['credits'])): ?>
<div style="padding:12px 14px; border-radius:14px; border:1px solid rgba(255,255,255,0.08); background:rgba(0,0,0,0.2);">
<div class="badge" style="font-size:9px;">Credits</div>
<div style="margin-top:6px; color:var(--muted); line-height:1.55; font-size:13px;">
<?= nl2br(htmlspecialchars((string)$release['credits'], ENT_QUOTES, 'UTF-8')) ?>
</div>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
</section>
<div id="acDock" class="ac-dock" hidden>
<div class="ac-dock-inner">
<div class="ac-dock-meta">
<div id="acDockArt" class="ac-dock-art">AC</div>
<div class="ac-dock-title-wrap">
<div class="badge" style="font-size:9px;">Now Playing</div>
<div id="acDockTitle" class="ac-dock-title">Track sample</div>
</div>
</div>
<button type="button" id="acDockToggle" class="ac-dock-toggle"><i class="fa-solid fa-play"></i> <span>Play</span></button>
<input id="acDockSeek" class="ac-seek" type="range" min="0" max="100" value="0" step="0.1">
<div class="ac-dock-time"><span id="acDockCurrent">0:00</span> / <span id="acDockDuration">0:00</span></div>
<input id="acDockVolume" class="ac-volume" type="range" min="0" max="1" value="1" step="0.01">
<button type="button" id="acDockClose" class="ac-dock-close" aria-label="Close player">X</button>
<audio id="acDockAudio" preload="none"></audio>
</div>
</div>
<style>
.release-wrap {
min-width: 0;
}
.track-row.is-active { border-color: rgba(34,242,165,.45)!important; background: rgba(34,242,165,.08)!important; }
.track-play-btn,.ac-dock-toggle{height:34px;border:1px solid rgba(34,242,165,.35);border-radius:999px;background:rgba(34,242,165,.12);color:#bffff0;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 14px}
.track-buy-btn{height:34px;border:1px solid rgba(255,255,255,.18);border-radius:999px;background:rgba(255,255,255,.08);color:#e9eefc;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 14px;font-size:12px;white-space:nowrap}
.track-buy-btn:hover{background:rgba(255,255,255,.14)}
.track-play-btn[disabled]{border-color:rgba(255,255,255,.2);background:rgba(255,255,255,.08);color:var(--muted);cursor:not-allowed}
.bundle-card{display:grid;grid-template-columns:max-content minmax(0,1fr) auto;gap:14px;align-items:center;padding:12px;border-radius:14px;border:1px solid rgba(255,255,255,.1);background:linear-gradient(180deg,rgba(255,255,255,.03),rgba(255,255,255,.01))}
.bundle-stack{position:relative;height:70px;width:calc(70px + (max(0, var(--cover-count, 1) - 1) * 16px));}
.bundle-cover{position:absolute;left:calc(var(--i,0) * 16px);top:0;width:70px;height:70px;border-radius:10px;overflow:hidden;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.04);box-shadow:0 8px 20px rgba(0,0,0,.35)}
.bundle-cover img{width:100%;height:100%;object-fit:cover}
.bundle-fallback{display:grid;place-items:center;font-size:11px;color:var(--muted)}
.bundle-copy{min-width:0}
.bundle-title{font-size:17px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bundle-meta{margin-top:6px;font-size:12px;color:var(--muted);display:flex;flex-wrap:wrap;gap:10px;align-items:center}
.bundle-save{color:#9ff7d5;font-weight:700;letter-spacing:.08em;text-transform:uppercase;font-size:11px}
.ac-dock{position:fixed;left:14px;right:14px;bottom:14px;z-index:50}
.ac-dock-inner{display:grid;grid-template-columns:minmax(210px,280px) 96px minmax(160px,1fr) 110px 110px 44px;gap:10px;align-items:center;padding:12px;border-radius:14px;border:1px solid rgba(255,255,255,.14);background:rgba(10,11,15,.95)}
.ac-dock-meta{display:grid;grid-template-columns:44px minmax(0,1fr);align-items:center;gap:10px;min-width:0}
.ac-dock-art{width:44px;height:44px;border-radius:10px;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.06);overflow:hidden;display:grid;place-items:center;font-size:10px;color:var(--muted)}
.ac-dock-art img{width:100%;height:100%;object-fit:cover;display:block}
.ac-dock-title-wrap{min-width:0}.ac-dock-title{margin-top:4px;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ac-dock-time{font-size:12px;color:var(--muted);font-family:'IBM Plex Mono',monospace}
.ac-dock-close{height:34px;border-radius:10px;border:1px solid rgba(255,255,255,.2);background:rgba(255,255,255,.05);color:var(--muted);cursor:pointer}
.ac-seek,.ac-volume{-webkit-appearance:none;appearance:none;height:6px;border-radius:999px;background:rgba(255,255,255,.2);outline:none}
.ac-seek::-webkit-slider-thumb,.ac-volume::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;border-radius:50%;background:#22f2a5;border:1px solid rgba(0,0,0,.45)}
@media (max-width: 980px) {
.release-hero {
grid-template-columns: 1fr !important;
gap: 16px !important;
}
.release-cover-box {
max-width: 420px;
}
}
@media (max-width: 700px) {
.release-meta h1 {
font-size: 32px !important;
line-height: 1.12 !important;
}
.track-row {
grid-template-columns: 88px minmax(0,1fr) !important;
gap: 10px !important;
}
.track-buy-btn { font-size: 11px; padding: 0 10px; }
.bundle-card{grid-template-columns:1fr;gap:10px}
.bundle-stack{height:62px}
.bundle-cover{width:62px;height:62px}
.ac-dock {
left: 8px;
right: 8px;
bottom: 8px;
}
.ac-dock-inner {
grid-template-columns: minmax(0,1fr) 96px auto 44px !important;
grid-template-areas:
"meta toggle time close"
"seek seek seek seek";
row-gap: 8px;
padding: 10px;
border-radius: 12px;
}
.ac-dock-meta { grid-area: meta; gap: 8px; }
.ac-dock-art { width: 38px; height: 38px; border-radius: 8px; }
.ac-dock-title { font-size: 12px; }
.ac-dock-close { grid-area: close; width: 44px; justify-self: end; }
.ac-dock-toggle {
grid-area: toggle;
height: 32px;
padding: 0 8px;
border-radius: 999px;
justify-self: center;
width: 96px;
min-width: 96px;
font-size: 12px;
}
.ac-dock-time {
grid-area: time;
text-align: right;
white-space: nowrap;
font-size: 12px;
align-self: center;
}
.ac-seek { grid-area: seek; margin-top: 0; align-self: center; }
.ac-volume { display: none; }
}
</style>
<script>
(function () {
const dock = document.getElementById('acDock');
const audio = document.getElementById('acDockAudio');
const toggle = document.getElementById('acDockToggle');
const seek = document.getElementById('acDockSeek');
const volume = document.getElementById('acDockVolume');
const current = document.getElementById('acDockCurrent');
const duration = document.getElementById('acDockDuration');
const titleEl = document.getElementById('acDockTitle');
const dockArt = document.getElementById('acDockArt');
const closeBtn = document.getElementById('acDockClose');
const status = document.getElementById('releasePlayerNow');
const mainCover = document.getElementById('releaseCoverMain');
if (!dock || !audio || !toggle || !seek || !volume || !current || !duration || !titleEl || !closeBtn) return;
const mainCoverSrc = mainCover && mainCover.tagName === 'IMG' ? (mainCover.getAttribute('src') || '') : '';
const defaultCover = mainCoverSrc || <?= json_encode($releaseCover, JSON_UNESCAPED_SLASHES) ?> || '';
let activeBtn = null;
function fmt(sec){ if(!isFinite(sec)||sec<0) return '0:00'; const m=Math.floor(sec/60), s=Math.floor(sec%60); return m+':'+String(s).padStart(2,'0'); }
function setPlayState(btn,playing){ if(!btn) return; btn.innerHTML = playing ? '<i class="fa-solid fa-pause"></i> <span>Pause</span>' : '<i class="fa-solid fa-play"></i> <span>Play</span>'; }
function setDockArt(src){ if(!dockArt) return; const finalSrc = src || defaultCover; dockArt.innerHTML = finalSrc ? ('<img src="'+finalSrc.replace(/"/g,'&quot;')+'" alt="">') : 'AC'; }
function setActive(btn,title){
document.querySelectorAll('.track-play-btn').forEach((b)=>{ setPlayState(b,false); const row=b.closest('.track-row'); if(row) row.classList.remove('is-active'); });
activeBtn = btn;
if(btn){ setPlayState(btn,true); const row=btn.closest('.track-row'); if(row) row.classList.add('is-active'); }
titleEl.textContent = title || 'Track sample';
if(status) status.textContent = title ? ('Now Playing: '+title) : 'Select a track to play sample';
}
function openAndPlay(src,title,btn,cover){
if(!src) return;
if(dock.hidden) dock.hidden = false;
setDockArt(cover || '');
if(audio.getAttribute('src') === src){ if(audio.paused) audio.play().catch(()=>{}); else audio.pause(); return; }
audio.setAttribute('src', src);
setActive(btn,title);
audio.play().catch(()=>{ if(btn) setPlayState(btn,false); });
}
document.querySelectorAll('.track-play-btn').forEach((btn)=>{
btn.addEventListener('click', ()=>{
openAndPlay(btn.getAttribute('data-src') || '', btn.getAttribute('data-title') || 'Track sample', btn, btn.getAttribute('data-cover') || '');
});
});
toggle.addEventListener('click', ()=>{ if(!audio.getAttribute('src')) return; if(audio.paused) audio.play().catch(()=>{}); else audio.pause(); });
closeBtn.addEventListener('click', ()=>{ audio.pause(); audio.removeAttribute('src'); seek.value='0'; current.textContent='0:00'; duration.textContent='0:00'; setPlayState(toggle,false); setActive(null,''); setDockArt(''); dock.hidden=true; });
volume.addEventListener('input', ()=>{ audio.volume = Number(volume.value); });
seek.addEventListener('input', ()=>{ if(!isFinite(audio.duration)||audio.duration<=0) return; audio.currentTime = (Number(seek.value)/100)*audio.duration; });
audio.addEventListener('loadedmetadata', ()=>{ duration.textContent = fmt(audio.duration); });
audio.addEventListener('timeupdate', ()=>{ current.textContent = fmt(audio.currentTime); if(isFinite(audio.duration)&&audio.duration>0){ seek.value = String((audio.currentTime/audio.duration)*100); } });
audio.addEventListener('play', ()=>{ setPlayState(toggle,true); if(activeBtn) setPlayState(activeBtn,true); });
audio.addEventListener('pause', ()=>{ setPlayState(toggle,false); if(activeBtn) setPlayState(activeBtn,false); });
audio.addEventListener('ended', ()=>{ setPlayState(toggle,false); if(activeBtn) setPlayState(activeBtn,false); seek.value='0'; current.textContent='0:00'; });
})();
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../views/site/layout.php';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
<?php
$pageTitle = $title ?? 'Store Customers';
$customers = $customers ?? [];
ob_start();
?>
<section class="admin-card">
<div class="badge">Store</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
<div>
<h1 style="font-size:28px; margin:0;">Customers</h1>
<p style="color: var(--muted); margin-top:6px;">Registered and purchasing customers.</p>
</div>
<a href="/admin/store" class="btn outline">Back</a>
</div>
<div style="display:flex; flex-wrap:wrap; gap:8px; margin-top:12px;">
<a href="/admin/store" class="btn outline small">Overview</a>
<a href="/admin/store/settings" class="btn outline small">Settings</a>
<a href="/admin/store/orders" class="btn outline small">Orders</a>
<a href="/admin/store/customers" class="btn outline small">Customers</a>
</div>
<?php if (!$customers): ?>
<div style="margin-top:16px; color:var(--muted); font-size:13px;">No customers yet.</div>
<?php else: ?>
<div style="margin-top:16px; display:grid; gap:10px;">
<?php foreach ($customers as $customer): ?>
<div class="admin-card" style="padding:14px; display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:10px; align-items:center;">
<div>
<div style="font-weight:600;"><?= htmlspecialchars((string)($customer['email'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:12px; color:var(--muted); margin-top:2px;">
<?= htmlspecialchars((string)($customer['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</div>
</div>
<div class="pill"><?= ((int)($customer['is_active'] ?? 1) === 1) ? 'Active' : 'Inactive' ?></div>
<div style="font-size:12px; color:var(--muted);"><?= htmlspecialchars((string)($customer['created_at'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Plugins\Store\Gateways;
interface GatewayInterface
{
public function key(): string;
public function label(): string;
public function isEnabled(array $settings): bool;
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Plugins\Store\Gateways;
final class Gateways
{
/**
* @return GatewayInterface[]
*/
public static function all(): array
{
return [
new PaypalGateway(),
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Plugins\Store\Gateways;
class PaypalGateway implements GatewayInterface
{
public function key(): string
{
return 'paypal';
}
public function label(): string
{
return 'PayPal';
}
public function isEnabled(array $settings): bool
{
return (string)($settings['store_paypal_enabled'] ?? '0') === '1';
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Plugins\Store\Gateways;
class StripeGateway implements GatewayInterface
{
public function key(): string
{
return 'stripe';
}
public function label(): string
{
return 'Stripe';
}
public function isEnabled(array $settings): bool
{
return (string)($settings['store_stripe_enabled'] ?? '0') === '1';
}
}

52
plugins/store/index.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
$pageTitle = $title ?? 'Store';
$tablesReady = (bool)($tables_ready ?? false);
$privateRoot = (string)($private_root ?? '');
$privateRootReady = (bool)($private_root_ready ?? false);
ob_start();
?>
<section class="admin-card">
<div class="badge">Store</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
<div>
<h1 style="font-size:28px; margin:0;">Store</h1>
<p style="color: var(--muted); margin-top:6px;">Commerce layer for releases/tracks.</p>
</div>
<div style="display:flex; gap:10px; align-items:center;">
<a href="/admin/store/settings" class="btn outline">Settings</a>
<a href="/admin/store/orders" class="btn outline">Orders</a>
<a href="/admin/store/customers" class="btn outline">Customers</a>
</div>
</div>
<div style="display:flex; flex-wrap:wrap; gap:8px; margin-top:12px;">
<a href="/admin/store" class="btn outline small">Overview</a>
<a href="/admin/store/settings" class="btn outline small">Settings</a>
<a href="/admin/store/orders" class="btn outline small">Orders</a>
<a href="/admin/store/customers" class="btn outline small">Customers</a>
</div>
<?php if (!$tablesReady): ?>
<div class="admin-card" style="margin-top:16px; padding:16px; display:flex; align-items:center; justify-content:space-between; gap:16px;">
<div>
<div style="font-weight:600;">Store tables not initialized</div>
<div style="color: var(--muted); font-size:13px; margin-top:4px;">Create store tables before configuring products and checkout.</div>
</div>
<form method="post" action="/admin/store/install">
<button type="submit" class="btn small">Create Tables</button>
</form>
</div>
<?php endif; ?>
<div class="admin-card" style="margin-top:16px; padding:16px;">
<div style="font-weight:600;">Private download root</div>
<div style="color: var(--muted); font-size:13px; margin-top:4px; font-family:'IBM Plex Mono', monospace;">
<?= htmlspecialchars($privateRoot, ENT_QUOTES, 'UTF-8') ?>
</div>
<div style="margin-top:8px; font-size:12px; color: <?= $privateRootReady ? '#9be7c6' : '#f3b0b0' ?>;">
<?= $privateRootReady ? 'Ready' : 'Missing or not writable' ?>
</div>
</div>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

45
plugins/store/orders.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
$pageTitle = $title ?? 'Store Orders';
$orders = $orders ?? [];
ob_start();
?>
<section class="admin-card">
<div class="badge">Store</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
<div>
<h1 style="font-size:28px; margin:0;">Orders</h1>
<p style="color: var(--muted); margin-top:6px;">Order queue and payment status.</p>
</div>
<a href="/admin/store" class="btn outline">Back</a>
</div>
<div style="display:flex; flex-wrap:wrap; gap:8px; margin-top:12px;">
<a href="/admin/store" class="btn outline small">Overview</a>
<a href="/admin/store/settings" class="btn outline small">Settings</a>
<a href="/admin/store/orders" class="btn outline small">Orders</a>
<a href="/admin/store/customers" class="btn outline small">Customers</a>
</div>
<?php if (!$orders): ?>
<div style="margin-top:16px; color:var(--muted); font-size:13px;">No orders yet.</div>
<?php else: ?>
<div style="margin-top:16px; display:grid; gap:10px;">
<?php foreach ($orders as $order): ?>
<div class="admin-card" style="padding:14px; display:grid; grid-template-columns:minmax(0,1fr) auto auto auto; gap:10px; align-items:center;">
<div>
<div style="font-weight:600;"><?= htmlspecialchars((string)($order['order_no'] ?? ('#' . (int)($order['id'] ?? 0))), ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:12px; color:var(--muted); margin-top:2px;"><?= htmlspecialchars((string)($order['email'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
</div>
<div class="pill"><?= htmlspecialchars((string)($order['status'] ?? 'pending'), ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:13px; color:var(--text);">
<?= htmlspecialchars((string)($order['currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?>
<?= number_format((float)($order['total'] ?? 0), 2) ?>
</div>
<div style="font-size:12px; color:var(--muted);"><?= htmlspecialchars((string)($order['created_at'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

Some files were not shown because too many files have changed in this diff Show More