Initial dev export (exclude uploads/runtime)
This commit is contained in:
1626
modules/admin/AdminController.php
Normal file
1626
modules/admin/AdminController.php
Normal file
File diff suppressed because it is too large
Load Diff
30
modules/admin/module.php
Normal file
30
modules/admin/module.php
Normal 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']);
|
||||
};
|
||||
45
modules/admin/views/account_new.php
Normal file
45
modules/admin/views/account_new.php
Normal 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';
|
||||
75
modules/admin/views/accounts.php
Normal file
75
modules/admin/views/accounts.php
Normal 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';
|
||||
87
modules/admin/views/crons.php
Normal file
87
modules/admin/views/crons.php
Normal 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';
|
||||
15
modules/admin/views/dashboard.php
Normal file
15
modules/admin/views/dashboard.php
Normal 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';
|
||||
185
modules/admin/views/installer.php
Normal file
185
modules/admin/views/installer.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?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();
|
||||
?>
|
||||
<section class="card" style="max-width:980px; margin:0 auto;">
|
||||
<div class="badge">Setup</div>
|
||||
<h1 style="margin:16px 0 6px; font-size:30px;">AudioCore V1.5 Installer</h1>
|
||||
<p style="margin:0; color:rgba(235,241,255,.75);">Deploy a fresh instance with validated SMTP and baseline health checks.</p>
|
||||
|
||||
<div style="display:flex; gap:10px; margin-top:18px; flex-wrap:wrap;">
|
||||
<div style="padding:8px 12px; border-radius:999px; border:1px solid rgba(255,255,255,.16); background:<?= $step === 1 ? 'rgba(57,244,179,.18)' : 'rgba(255,255,255,.03)' ?>;">
|
||||
<span style="font-size:11px; letter-spacing:.18em; text-transform:uppercase;">1. Core Setup</span>
|
||||
</div>
|
||||
<div style="padding:8px 12px; border-radius:999px; border:1px solid rgba(255,255,255,.16); background:<?= $step === 2 ? 'rgba(57,244,179,.18)' : 'rgba(255,255,255,.03)' ?>;">
|
||||
<span style="font-size:11px; letter-spacing:.18em; text-transform:uppercase;">2. Site + SMTP</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($error)): ?>
|
||||
<div style="margin-top:16px; border:1px solid rgba(255,124,124,.45); background:rgba(180,40,40,.18); border-radius:12px; padding:12px 14px; color:#ffd6d6;">
|
||||
<?= htmlspecialchars((string)$error, ENT_QUOTES, 'UTF-8') ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($success)): ?>
|
||||
<div style="margin-top:16px; border:1px solid rgba(57,244,179,.45); background:rgba(10,90,60,.22); border-radius:12px; padding:12px 14px; color:#b8ffe5;">
|
||||
<?= htmlspecialchars((string)$success, ENT_QUOTES, 'UTF-8') ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($step === 1): ?>
|
||||
<form method="post" action="/admin/install" style="margin-top:18px; display:grid; gap:14px;">
|
||||
<input type="hidden" name="installer_action" value="setup_core">
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px;">
|
||||
<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 style="margin-top:6px; padding-top:14px; border-top:1px solid rgba(255,255,255,.1); display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px;">
|
||||
<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 style="display:flex; justify-content:flex-end; margin-top:4px;">
|
||||
<button type="submit" class="button button-primary">Create Core Setup</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<form method="post" action="/admin/install" style="margin-top:18px; display:grid; gap:14px;">
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px;">
|
||||
<div>
|
||||
<label>Site Title *</label>
|
||||
<input class="input" name="site_title" value="<?= htmlspecialchars($val('site_title', 'AudioCore V1.5'), 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'), 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" style="resize:vertical;"><?= htmlspecialchars($val('seo_meta_description', ''), ENT_QUOTES, 'UTF-8') ?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding-top:12px; border-top:1px solid rgba(255,255,255,.1); display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px;">
|
||||
<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'), 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 style="border:1px solid <?= !empty($smtpResult['ok']) ? 'rgba(57,244,179,.45)' : 'rgba(255,124,124,.45)' ?>; background:<?= !empty($smtpResult['ok']) ? 'rgba(10,90,60,.22)' : 'rgba(180,40,40,.18)' ?>; border-radius:12px; padding:12px 14px;">
|
||||
<div style="font-weight:700; margin-bottom:4px; color:<?= !empty($smtpResult['ok']) ? '#b8ffe5' : '#ffd6d6' ?>">
|
||||
<?= !empty($smtpResult['ok']) ? 'SMTP test passed' : 'SMTP test failed' ?>
|
||||
</div>
|
||||
<div style="color:<?= !empty($smtpResult['ok']) ? '#b8ffe5' : '#ffd6d6' ?>;">
|
||||
<?= 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 style="padding:12px; border:1px solid rgba(255,255,255,.1); border-radius:12px; background:rgba(255,255,255,.02);">
|
||||
<div style="font-size:12px; text-transform:uppercase; letter-spacing:.16em; color:rgba(255,255,255,.65); margin-bottom:10px;">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']) ? '✓' : '!' ?>
|
||||
</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 style="display:flex; justify-content:flex-end; gap:10px; flex-wrap:wrap; margin-top:4px;">
|
||||
<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';
|
||||
638
modules/admin/views/layout.php
Normal file
638
modules/admin/views/layout.php
Normal file
@@ -0,0 +1,638 @@
|
||||
<?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">
|
||||
<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-duotone 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/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</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>
|
||||
26
modules/admin/views/login.php
Normal file
26
modules/admin/views/login.php
Normal 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';
|
||||
339
modules/admin/views/navigation.php
Normal file
339
modules/admin/views/navigation.php
Normal 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';
|
||||
682
modules/admin/views/settings.php
Normal file
682
modules/admin/views/settings.php
Normal file
@@ -0,0 +1,682 @@
|
||||
<?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="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">
|
||||
|
||||
<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 <i ...> 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">
|
||||
<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>
|
||||
<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">
|
||||
<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="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, '"') + '\">' +
|
||||
'<input class=\"input\" name=\"footer_link_url[]\" placeholder=\"/privacy\" value=\"' + (item.url || '').replace(/\"/g, '"') + '\">' +
|
||||
'<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, '"') + '" 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';
|
||||
114
modules/admin/views/shortcodes.php
Normal file
114
modules/admin/views/shortcodes.php
Normal 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';
|
||||
90
modules/admin/views/updates.php
Normal file
90
modules/admin/views/updates.php
Normal 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';
|
||||
31
modules/artists/ArtistsController.php
Normal file
31
modules/artists/ArtistsController.php
Normal 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',
|
||||
]));
|
||||
}
|
||||
}
|
||||
13
modules/artists/module.php
Normal file
13
modules/artists/module.php
Normal 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']);
|
||||
};
|
||||
12
modules/artists/views/site/index.php
Normal file
12
modules/artists/views/site/index.php
Normal 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';
|
||||
12
modules/artists/views/site/show.php
Normal file
12
modules/artists/views/site/show.php
Normal 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';
|
||||
316
modules/blog/BlogController.php
Normal file
316
modules/blog/BlogController.php
Normal 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
19
modules/blog/module.php
Normal 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']);
|
||||
};
|
||||
67
modules/blog/views/admin/edit.php
Normal file
67
modules/blog/views/admin/edit.php
Normal 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';
|
||||
54
modules/blog/views/admin/index.php
Normal file
54
modules/blog/views/admin/index.php
Normal 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';
|
||||
61
modules/blog/views/site/index.php
Normal file
61
modules/blog/views/site/index.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
$pageTitle = $title ?? 'News';
|
||||
$posts = $posts ?? [];
|
||||
$page = $page ?? null;
|
||||
ob_start();
|
||||
?>
|
||||
<section class="card">
|
||||
<div class="badge">News</div>
|
||||
<?php if ($page && !empty($page['content_html'])): ?>
|
||||
<div style="margin-top:12px; color:var(--muted); line-height:1.7;">
|
||||
<?= (string)$page['content_html'] ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<h1 style="margin-top:12px; font-size:30px;">Latest Updates</h1>
|
||||
<p style="color:var(--muted); margin-top:8px;">News, updates, and announcements.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div style="margin-top:18px; display:grid; gap:12px;">
|
||||
<?php if (!$posts): ?>
|
||||
<div style="color:var(--muted);">No posts yet.</div>
|
||||
<?php else: ?>
|
||||
<?php foreach ($posts as $post): ?>
|
||||
<article style="padding:14px 16px; border-radius:16px; border:1px solid rgba(255,255,255,0.12); background: rgba(0,0,0,0.25);">
|
||||
<div style="font-size:11px; text-transform:uppercase; letter-spacing:0.28em; color:var(--muted);">Post</div>
|
||||
<h2 style="margin:8px 0 6px; font-size:22px;"><?= htmlspecialchars((string)($post['title'] ?? ''), ENT_QUOTES, 'UTF-8') ?></h2>
|
||||
<?php if (!empty($post['featured_image_url'])): ?>
|
||||
<img src="<?= htmlspecialchars((string)$post['featured_image_url'], ENT_QUOTES, 'UTF-8') ?>" alt="" style="width:100%; border-radius:12px; margin:10px 0;">
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($post['published_at'])): ?>
|
||||
<div style="font-size:12px; color:var(--muted); margin-bottom:8px;"><?= htmlspecialchars((string)$post['published_at'], ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<?php endif; ?>
|
||||
<div style="font-size:12px; color:var(--muted); margin-bottom:8px;">
|
||||
<?php if (!empty($post['author_name'])): ?>
|
||||
<?= htmlspecialchars((string)$post['author_name'], ENT_QUOTES, 'UTF-8') ?>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($post['category'])): ?>
|
||||
<?php if (!empty($post['author_name'])): ?> · <?php endif; ?>
|
||||
<?= htmlspecialchars((string)$post['category'], ENT_QUOTES, 'UTF-8') ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<p style="color:var(--muted); line-height:1.6;">
|
||||
<?= htmlspecialchars((string)($post['excerpt'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
|
||||
</p>
|
||||
<?php if (!empty($post['tags'])): ?>
|
||||
<div style="margin-top:8px; display:flex; flex-wrap:wrap; gap:6px;">
|
||||
<?php foreach (array_filter(array_map('trim', explode(',', (string)$post['tags'] ?? ''))) as $tag): ?>
|
||||
<span style="font-size:11px; color:#c8ccd8; border:1px solid rgba(255,255,255,0.15); padding:4px 8px; border-radius:999px;">
|
||||
<?= htmlspecialchars((string)$tag, ENT_QUOTES, 'UTF-8') ?>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<a href="/news/<?= htmlspecialchars((string)($post['slug'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" style="display:inline-flex; margin-top:10px; font-size:12px; text-transform:uppercase; letter-spacing:0.2em; color:#9ad4ff;">Read more</a>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../../../views/site/layout.php';
|
||||
47
modules/blog/views/site/show.php
Normal file
47
modules/blog/views/site/show.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
$pageTitle = $title ?? 'Post';
|
||||
$contentHtml = $content_html ?? '';
|
||||
$publishedAt = $published_at ?? '';
|
||||
$featuredImage = $featured_image_url ?? '';
|
||||
$authorName = $author_name ?? '';
|
||||
$category = $category ?? '';
|
||||
$tags = $tags ?? '';
|
||||
ob_start();
|
||||
?>
|
||||
<section class="card">
|
||||
<div class="badge">News</div>
|
||||
<h1 style="margin-top:12px; font-size:30px;"><?= htmlspecialchars($pageTitle, ENT_QUOTES, 'UTF-8') ?></h1>
|
||||
<?php if ($publishedAt !== '' || $authorName !== '' || $category !== ''): ?>
|
||||
<div style="font-size:12px; color:var(--muted); margin-top:6px;">
|
||||
<?php if ($publishedAt !== ''): ?>
|
||||
<?= htmlspecialchars($publishedAt, ENT_QUOTES, 'UTF-8') ?>
|
||||
<?php endif; ?>
|
||||
<?php if ($authorName !== ''): ?>
|
||||
<?php if ($publishedAt !== ''): ?> · <?php endif; ?>
|
||||
<?= htmlspecialchars($authorName, ENT_QUOTES, 'UTF-8') ?>
|
||||
<?php endif; ?>
|
||||
<?php if ($category !== ''): ?>
|
||||
<?php if ($publishedAt !== '' || $authorName !== ''): ?> · <?php endif; ?>
|
||||
<?= htmlspecialchars($category, ENT_QUOTES, 'UTF-8') ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($featuredImage !== ''): ?>
|
||||
<img src="<?= htmlspecialchars($featuredImage, ENT_QUOTES, 'UTF-8') ?>" alt="" style="width:100%; border-radius:16px; margin-top:16px;">
|
||||
<?php endif; ?>
|
||||
<div style="margin-top:14px; color:var(--muted); line-height:1.8;">
|
||||
<?= $contentHtml ?>
|
||||
</div>
|
||||
<?php if ($tags !== ''): ?>
|
||||
<div style="margin-top:16px; display:flex; flex-wrap:wrap; gap:6px;">
|
||||
<?php foreach (array_filter(array_map('trim', explode(',', (string)$tags))) as $tag): ?>
|
||||
<span style="font-size:11px; color:#c8ccd8; border:1px solid rgba(255,255,255,0.15); padding:4px 8px; border-radius:999px;">
|
||||
<?= htmlspecialchars((string)$tag, ENT_QUOTES, 'UTF-8') ?>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../../../views/site/layout.php';
|
||||
218
modules/media/MediaController.php
Normal file
218
modules/media/MediaController.php
Normal 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
16
modules/media/module.php
Normal 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']);
|
||||
};
|
||||
114
modules/media/views/admin/index.php
Normal file
114
modules/media/views/admin/index.php
Normal 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 & 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';
|
||||
384
modules/newsletter/NewsletterController.php
Normal file
384
modules/newsletter/NewsletterController.php
Normal file
@@ -0,0 +1,384 @@
|
||||
<?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\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);
|
||||
}
|
||||
|
||||
$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.';
|
||||
|
||||
$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,
|
||||
]));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
80
modules/newsletter/module.php
Normal file
80
modules/newsletter/module.php
Normal 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']);
|
||||
};
|
||||
96
modules/newsletter/views/admin/edit.php
Normal file
96
modules/newsletter/views/admin/edit.php
Normal 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';
|
||||
259
modules/newsletter/views/admin/index.php
Normal file
259
modules/newsletter/views/admin/index.php
Normal 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';
|
||||
46
modules/newsletter/views/admin/subscribers.php
Normal file
46
modules/newsletter/views/admin/subscribers.php
Normal 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';
|
||||
23
modules/newsletter/views/site/unsubscribe.php
Normal file
23
modules/newsletter/views/site/unsubscribe.php
Normal 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';
|
||||
255
modules/pages/PagesController.php
Normal file
255
modules/pages/PagesController.php
Normal file
@@ -0,0 +1,255 @@
|
||||
<?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();
|
||||
}
|
||||
|
||||
return new Response($this->view->render('site/show.php', [
|
||||
'title' => (string)$page['title'],
|
||||
'content_html' => Shortcodes::render((string)$page['content_html'], [
|
||||
'page_slug' => $slug,
|
||||
'page_title' => (string)$page['title'],
|
||||
]),
|
||||
]));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
18
modules/pages/module.php
Normal file
18
modules/pages/module.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Http\Router;
|
||||
use Modules\Pages\PagesController;
|
||||
|
||||
require_once __DIR__ . '/PagesController.php';
|
||||
|
||||
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']);
|
||||
};
|
||||
122
modules/pages/views/admin/edit.php
Normal file
122
modules/pages/views/admin/edit.php
Normal 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';
|
||||
42
modules/pages/views/admin/home.php
Normal file
42
modules/pages/views/admin/home.php
Normal 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';
|
||||
58
modules/pages/views/admin/index.php
Normal file
58
modules/pages/views/admin/index.php
Normal 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';
|
||||
13
modules/pages/views/site/show.php
Normal file
13
modules/pages/views/site/show.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
$pageTitle = $title ?? 'Page';
|
||||
$contentHtml = $content_html ?? '';
|
||||
ob_start();
|
||||
?>
|
||||
<section class="card">
|
||||
<div style="margin-top:14px; color:var(--muted); line-height:1.8;">
|
||||
<?= $contentHtml ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../../../../views/site/layout.php';
|
||||
55
modules/plugins/PluginsController.php
Normal file
55
modules/plugins/PluginsController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
13
modules/plugins/module.php
Normal file
13
modules/plugins/module.php
Normal 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']);
|
||||
};
|
||||
55
modules/plugins/views/admin/index.php
Normal file
55
modules/plugins/views/admin/index.php
Normal 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';
|
||||
Reference in New Issue
Block a user