Auto commit: 2026-03-31T15:24:56.836Z

This commit is contained in:
Flatlogic Bot 2026-03-31 15:24:56 +00:00
parent f2b2fdd6dd
commit 37d557d231
6 changed files with 1491 additions and 533 deletions

257
admin.php Normal file
View File

@ -0,0 +1,257 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/app.php';
$type = normalize_entry_type((string) ($_GET['type'] ?? 'program'));
$mode = (string) ($_GET['mode'] ?? 'detail');
$selectedId = isset($_GET['id']) ? (int) $_GET['id'] : null;
$entry = $selectedId ? get_entry($selectedId) : null;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save_entry') {
$postedType = normalize_entry_type((string) ($_POST['entry_type'] ?? 'program'));
$saveId = isset($_POST['id']) && $_POST['id'] !== '' ? (int) $_POST['id'] : null;
try {
$savedId = save_entry([
'entry_type' => $postedType,
'title' => $_POST['title'] ?? '',
'subtitle' => $_POST['subtitle'] ?? '',
'body' => $_POST['body'] ?? '',
'meta_url' => $_POST['meta_url'] ?? '',
'meta_value' => $_POST['meta_value'] ?? '',
'weekday' => $_POST['weekday'] ?? null,
'start_time' => $_POST['start_time'] ?? null,
'end_time' => $_POST['end_time'] ?? null,
'status' => $_POST['status'] ?? 'published',
'sort_order' => $_POST['sort_order'] ?? 0,
], $saveId);
set_flash('success', $saveId ? 'Contenido actualizado.' : 'Nuevo contenido creado.');
header('Location: /admin.php?type=' . urlencode($postedType) . '&id=' . $savedId);
exit;
} catch (Throwable $e) {
set_flash('danger', $e->getMessage());
header('Location: /admin.php?type=' . urlencode($postedType) . ($saveId ? '&id=' . $saveId : '&mode=new'));
exit;
}
}
$flash = pull_flash();
$counts = get_entry_counts();
$programs = get_entries('program');
$djs = get_entries('dj');
$socials = get_entries('social');
$messages = get_entries('message');
if (!$entry && $selectedId) {
$type = 'program';
}
$entry = $entry && $entry['entry_type'] === $type ? $entry : ($mode === 'new' ? null : $entry);
$assetVersion = (string) max(@filemtime(__DIR__ . '/assets/css/custom.css') ?: time(), @filemtime(__DIR__ . '/assets/js/main.js') ?: time());
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? project_description();
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin · <?= h(project_name()) ?></title>
<meta name="description" content="<?= h($projectDescription) ?>">
<?php if ($projectDescription): ?>
<meta property="og:description" content="<?= h($projectDescription) ?>">
<meta property="twitter:description" content="<?= h($projectDescription) ?>">
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<meta property="og:image" content="<?= h($projectImageUrl) ?>">
<meta property="twitter:image" content="<?= h($projectImageUrl) ?>">
<?php endif; ?>
<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=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="/assets/css/custom.css?v=<?= h($assetVersion) ?>">
</head>
<body class="admin-body">
<nav class="navbar navbar-expand-lg navbar-dark sticky-top radio-nav border-bottom border-secondary-subtle">
<div class="container">
<a class="navbar-brand d-flex align-items-center gap-2" href="/">
<span class="brand-mark">LR</span>
<span>
<span class="d-block fw-semibold brand-title">Lili Records Radio</span>
<span class="brand-subtitle">Admin</span>
</span>
</a>
<div class="d-flex gap-2 ms-auto">
<a class="btn btn-sm btn-outline-light" href="/">Ver sitio</a>
<a class="btn btn-sm btn-light" href="/admin.php?type=program&mode=new">Nuevo programa</a>
</div>
</div>
</nav>
<main class="section-block admin-shell">
<div class="container">
<div class="section-heading d-flex flex-wrap align-items-end justify-content-between gap-3 mb-4">
<div>
<span class="section-label">Panel simple</span>
<h1 class="section-title mb-0">Gestiona shows, DJs, redes y mensajes</h1>
</div>
<p class="section-copy">Primera versión funcional: listados por tipo, detalle editable y revisión básica de mensajes recibidos.</p>
</div>
<div class="row g-4 mb-4">
<div class="col-6 col-lg-3"><div class="mini-stat"><span class="mini-label">Programas</span><strong><?= h((string) $counts['program']) ?></strong></div></div>
<div class="col-6 col-lg-3"><div class="mini-stat"><span class="mini-label">DJs</span><strong><?= h((string) $counts['dj']) ?></strong></div></div>
<div class="col-6 col-lg-3"><div class="mini-stat"><span class="mini-label">Redes</span><strong><?= h((string) $counts['social']) ?></strong></div></div>
<div class="col-6 col-lg-3"><div class="mini-stat"><span class="mini-label">Mensajes</span><strong><?= h((string) $counts['message']) ?></strong></div></div>
</div>
<div class="row g-4">
<div class="col-xl-5">
<div class="panel p-4 mb-4">
<div class="d-flex flex-wrap gap-2 mb-3">
<a class="btn btn-sm <?= $type === 'program' ? 'btn-light' : 'btn-outline-light' ?>" href="/admin.php?type=program">Programas</a>
<a class="btn btn-sm <?= $type === 'dj' ? 'btn-light' : 'btn-outline-light' ?>" href="/admin.php?type=dj">DJs</a>
<a class="btn btn-sm <?= $type === 'social' ? 'btn-light' : 'btn-outline-light' ?>" href="/admin.php?type=social">Redes</a>
<a class="btn btn-sm <?= $type === 'message' ? 'btn-light' : 'btn-outline-light' ?>" href="/admin.php?type=message">Mensajes</a>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h5 text-white mb-0">Listado</h2>
<?php if ($type !== 'message'): ?>
<a class="btn btn-sm btn-outline-light" href="/admin.php?type=<?= h($type) ?>&mode=new">Crear nuevo</a>
<?php endif; ?>
</div>
<?php $source = $type === 'program' ? $programs : ($type === 'dj' ? $djs : ($type === 'social' ? $socials : $messages)); ?>
<?php if ($source === []): ?>
<div class="empty-card">Sin registros todavía.</div>
<?php else: ?>
<div class="admin-list">
<?php foreach ($source as $item): ?>
<a class="admin-item <?= $selectedId === (int) $item['id'] ? 'active' : '' ?>" href="/admin.php?type=<?= h($type) ?>&id=<?= h((string) $item['id']) ?>">
<div class="d-flex justify-content-between gap-3 align-items-start">
<div>
<strong class="text-white d-block"><?= h($item['title']) ?></strong>
<span class="small text-secondary"><?= h($item['subtitle'] ?: ($type === 'program' ? weekday_name((int) $item['weekday']) . ' · ' . time_label($item['start_time']) : 'Sin subtítulo')) ?></span>
</div>
<span class="soft-badge"><?= h($item['status']) ?></span>
</div>
<?php if (!empty($item['meta_value']) && $type === 'message'): ?>
<p class="small text-secondary-emphasis mb-0 mt-2">Pedido: <?= h($item['meta_value']) ?></p>
<?php elseif (!empty($item['body'])): ?>
<p class="small text-secondary-emphasis mb-0 mt-2"><?= h(strlen($item['body']) > 120 ? substr($item['body'], 0, 117) . '…' : $item['body']) ?></p>
<?php endif; ?>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
<div class="col-xl-7">
<div class="panel p-4 p-lg-5">
<div class="d-flex justify-content-between align-items-start gap-3 mb-4">
<div>
<span class="section-label">Detalle</span>
<h2 class="h3 text-white mb-1"><?= $type === 'message' ? 'Revisar mensaje' : (($mode === 'new' || !$entry) ? 'Crear contenido' : 'Editar contenido') ?></h2>
<p class="text-secondary mb-0"><?= $type === 'message' ? 'Marca el estado del mensaje y consulta el pedido.' : 'Completa los campos mínimos para actualizar la experiencia pública.' ?></p>
</div>
</div>
<form method="post" class="row g-3">
<input type="hidden" name="action" value="save_entry">
<input type="hidden" name="entry_type" value="<?= h($type) ?>">
<input type="hidden" name="id" value="<?= h((string) ($entry['id'] ?? '')) ?>">
<div class="col-md-7">
<label class="form-label" for="title">Título</label>
<input class="form-control" id="title" name="title" maxlength="160" required value="<?= h($entry['title'] ?? '') ?>" <?= $type === 'message' ? '' : '' ?>>
</div>
<div class="col-md-5">
<label class="form-label" for="status">Estado</label>
<select class="form-select" id="status" name="status">
<?php foreach ($type === 'message' ? ['new', 'reviewed'] : ['published', 'draft'] as $status): ?>
<option value="<?= h($status) ?>" <?= ($entry['status'] ?? ($type === 'message' ? 'new' : 'published')) === $status ? 'selected' : '' ?>><?= h($status) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-6">
<label class="form-label" for="subtitle"><?= $type === 'message' ? 'Email' : 'Subtítulo' ?></label>
<input class="form-control" id="subtitle" name="subtitle" maxlength="160" value="<?= h($entry['subtitle'] ?? '') ?>">
</div>
<div class="col-md-6">
<label class="form-label" for="meta_value"><?= $type === 'message' ? 'Pedido musical' : 'Etiqueta interna' ?></label>
<input class="form-control" id="meta_value" name="meta_value" maxlength="255" value="<?= h($entry['meta_value'] ?? '') ?>">
</div>
<?php if ($type === 'program'): ?>
<div class="col-md-4">
<label class="form-label" for="weekday">Día</label>
<select class="form-select" id="weekday" name="weekday" required>
<option value="">Selecciona</option>
<?php for ($day = 1; $day <= 7; $day++): ?>
<option value="<?= $day ?>" <?= (string) ($entry['weekday'] ?? '') === (string) $day ? 'selected' : '' ?>><?= h(weekday_name($day)) ?></option>
<?php endfor; ?>
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="start_time">Inicio</label>
<input class="form-control" id="start_time" name="start_time" type="time" value="<?= h(!empty($entry['start_time']) ? substr((string) $entry['start_time'], 0, 5) : '') ?>" required>
</div>
<div class="col-md-4">
<label class="form-label" for="end_time">Fin</label>
<input class="form-control" id="end_time" name="end_time" type="time" value="<?= h(!empty($entry['end_time']) ? substr((string) $entry['end_time'], 0, 5) : '') ?>" required>
</div>
<?php endif; ?>
<?php if ($type !== 'message'): ?>
<div class="col-md-8">
<label class="form-label" for="meta_url">URL principal</label>
<input class="form-control" id="meta_url" name="meta_url" maxlength="255" placeholder="https://..." value="<?= h($entry['meta_url'] ?? '') ?>">
</div>
<div class="col-md-4">
<label class="form-label" for="sort_order">Orden</label>
<input class="form-control" id="sort_order" name="sort_order" type="number" value="<?= h((string) ($entry['sort_order'] ?? 0)) ?>">
</div>
<?php else: ?>
<div class="col-md-12">
<label class="form-label" for="sort_order">Orden</label>
<input class="form-control" id="sort_order" name="sort_order" type="number" value="<?= h((string) ($entry['sort_order'] ?? 0)) ?>">
</div>
<?php endif; ?>
<div class="col-12">
<label class="form-label" for="body"><?= $type === 'message' ? 'Mensaje completo' : 'Descripción' ?></label>
<textarea class="form-control" id="body" name="body" rows="6"><?= h($entry['body'] ?? '') ?></textarea>
</div>
<div class="col-12 d-flex flex-wrap gap-3 pt-2">
<button class="btn btn-light" type="submit"><?= $type === 'message' ? 'Guardar revisión' : 'Guardar cambios' ?></button>
<a class="btn btn-outline-light" href="/admin.php?type=<?= h($type) ?>&mode=new">Limpiar formulario</a>
</div>
</form>
</div>
</div>
</div>
</div>
</main>
<?php if ($flash): ?>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div class="toast radio-toast align-items-center text-bg-dark border-0" id="statusToast" role="status" aria-live="polite" aria-atomic="true" data-autoshow="true">
<div class="d-flex">
<div class="toast-body">
<span class="toast-indicator <?= h($flash['type']) ?>"></span>
<?= h($flash['message']) ?>
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Cerrar"></button>
</div>
</div>
</div>
<?php endif; ?>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="/assets/js/main.js?v=<?= h($assetVersion) ?>"></script>
</body>
</html>

363
app.php Normal file
View File

@ -0,0 +1,363 @@
<?php
declare(strict_types=1);
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once __DIR__ . '/db/config.php';
date_default_timezone_set('UTC');
function h(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function project_name(): string
{
$name = trim((string) ($_SERVER['PROJECT_NAME'] ?? ''));
return $name !== '' ? $name : 'Lili Records Radio';
}
function project_description(): string
{
$description = trim((string) ($_SERVER['PROJECT_DESCRIPTION'] ?? ''));
return $description !== ''
? $description
: 'Radio online para escuchar Lili Records Radio, revisar la programación y enviar mensajes o pedidos musicales.';
}
function set_flash(string $type, string $message): void
{
$_SESSION['flash'] = [
'type' => $type,
'message' => $message,
];
}
function pull_flash(): ?array
{
if (!isset($_SESSION['flash'])) {
return null;
}
$flash = $_SESSION['flash'];
unset($_SESSION['flash']);
return $flash;
}
function ensure_radio_schema(): void
{
db()->exec(
"CREATE TABLE IF NOT EXISTS station_entries (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
entry_type VARCHAR(20) NOT NULL,
title VARCHAR(160) NOT NULL,
subtitle VARCHAR(160) DEFAULT NULL,
body TEXT DEFAULT NULL,
meta_url VARCHAR(255) DEFAULT NULL,
meta_value VARCHAR(255) DEFAULT NULL,
weekday TINYINT UNSIGNED DEFAULT NULL,
start_time TIME DEFAULT NULL,
end_time TIME DEFAULT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'published',
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_type_status (entry_type, status),
INDEX idx_schedule (entry_type, weekday, start_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
function seed_radio_content(): void
{
$seedMap = [
'program' => [
[
'title' => 'Mañanas de Vinilo',
'subtitle' => 'DJ Lili',
'body' => 'Selección fresca de soul, funk y novedades independientes para arrancar el día.',
'weekday' => 1,
'start_time' => '09:00:00',
'end_time' => '11:00:00',
'sort_order' => 10,
],
[
'title' => 'Cabina Abierta',
'subtitle' => 'Mika Set',
'body' => 'Sesión con pedidos, mensajes de la audiencia y mezclas en vivo.',
'weekday' => 3,
'start_time' => '18:00:00',
'end_time' => '20:00:00',
'sort_order' => 20,
],
[
'title' => 'Noches Lili Records',
'subtitle' => 'Nora Waves',
'body' => 'Curaduría de house, afrobeat y lanzamientos del sello.',
'weekday' => 5,
'start_time' => '21:00:00',
'end_time' => '23:00:00',
'sort_order' => 30,
],
[
'title' => 'Domingo Replay',
'subtitle' => 'Equipo Lili',
'body' => 'Repetición de los mejores momentos y entrevistas de la semana.',
'weekday' => 7,
'start_time' => '16:00:00',
'end_time' => '18:00:00',
'sort_order' => 40,
],
],
'dj' => [
[
'title' => 'DJ Lili',
'subtitle' => 'Dirección artística',
'body' => 'Host principal de la estación. Mezcla clásicos del catálogo con estrenos y entrevistas cortas.',
'meta_url' => 'https://instagram.com/',
'sort_order' => 10,
],
[
'title' => 'Mika Set',
'subtitle' => 'Live selector',
'body' => 'Especialista en sesiones híbridas y curaduría para pedidos en vivo.',
'meta_url' => 'https://facebook.com/',
'sort_order' => 20,
],
[
'title' => 'Nora Waves',
'subtitle' => 'Guest & curator',
'body' => 'Explora sonidos nocturnos con foco en electrónica latina e invitados del sello.',
'meta_url' => 'https://youtube.com/',
'sort_order' => 30,
],
],
'social' => [
[
'title' => 'Instagram',
'subtitle' => '@lilirecordsradio',
'body' => 'Clips, detrás de cabina y anuncios de programación.',
'meta_url' => 'https://instagram.com/',
'sort_order' => 10,
],
[
'title' => 'Facebook',
'subtitle' => 'Lili Records Radio',
'body' => 'Comunidad, eventos y transmisiones compartidas.',
'meta_url' => 'https://facebook.com/',
'sort_order' => 20,
],
[
'title' => 'YouTube',
'subtitle' => 'Sesiones grabadas',
'body' => 'Entrevistas, especiales y sets destacados.',
'meta_url' => 'https://youtube.com/',
'sort_order' => 30,
],
],
];
$countStmt = db()->prepare('SELECT COUNT(*) FROM station_entries WHERE entry_type = :entry_type');
foreach ($seedMap as $type => $items) {
$countStmt->execute(['entry_type' => $type]);
if ((int) $countStmt->fetchColumn() > 0) {
continue;
}
foreach ($items as $item) {
save_entry(array_merge($item, [
'entry_type' => $type,
'status' => 'published',
'meta_value' => $item['meta_value'] ?? null,
]));
}
}
}
function normalize_entry_type(string $type): string
{
$allowed = ['program', 'dj', 'social', 'message'];
return in_array($type, $allowed, true) ? $type : 'program';
}
function allowed_statuses(): array
{
return ['published', 'draft', 'new', 'reviewed'];
}
function save_entry(array $data, ?int $id = null): int
{
$payload = [
'entry_type' => normalize_entry_type((string) ($data['entry_type'] ?? 'program')),
'title' => trim((string) ($data['title'] ?? '')),
'subtitle' => trim((string) ($data['subtitle'] ?? '')),
'body' => trim((string) ($data['body'] ?? '')),
'meta_url' => trim((string) ($data['meta_url'] ?? '')),
'meta_value' => trim((string) ($data['meta_value'] ?? '')),
'weekday' => array_key_exists('weekday', $data) && $data['weekday'] !== null && $data['weekday'] !== '' ? (int) $data['weekday'] : null,
'start_time' => array_key_exists('start_time', $data) && $data['start_time'] !== null && $data['start_time'] !== '' ? (string) $data['start_time'] : null,
'end_time' => array_key_exists('end_time', $data) && $data['end_time'] !== null && $data['end_time'] !== '' ? (string) $data['end_time'] : null,
'status' => in_array((string) ($data['status'] ?? 'published'), allowed_statuses(), true) ? (string) $data['status'] : 'published',
'sort_order' => (int) ($data['sort_order'] ?? 0),
];
if ($payload['title'] === '') {
throw new InvalidArgumentException('El título es obligatorio.');
}
if ($payload['meta_url'] !== '' && filter_var($payload['meta_url'], FILTER_VALIDATE_URL) === false) {
throw new InvalidArgumentException('El enlace debe ser una URL válida.');
}
if ($payload['entry_type'] === 'program') {
if ($payload['weekday'] === null || $payload['weekday'] < 1 || $payload['weekday'] > 7) {
throw new InvalidArgumentException('Selecciona un día válido para el programa.');
}
if ($payload['start_time'] === null || $payload['end_time'] === null) {
throw new InvalidArgumentException('Define hora de inicio y fin del programa.');
}
}
if ($payload['entry_type'] === 'message') {
$payload['status'] = in_array($payload['status'], ['new', 'reviewed'], true) ? $payload['status'] : 'new';
}
if ($id !== null) {
$sql = 'UPDATE station_entries
SET entry_type = :entry_type,
title = :title,
subtitle = :subtitle,
body = :body,
meta_url = :meta_url,
meta_value = :meta_value,
weekday = :weekday,
start_time = :start_time,
end_time = :end_time,
status = :status,
sort_order = :sort_order
WHERE id = :id';
$stmt = db()->prepare($sql);
$stmt->execute($payload + ['id' => $id]);
return $id;
}
$sql = 'INSERT INTO station_entries
(entry_type, title, subtitle, body, meta_url, meta_value, weekday, start_time, end_time, status, sort_order)
VALUES
(:entry_type, :title, :subtitle, :body, :meta_url, :meta_value, :weekday, :start_time, :end_time, :status, :sort_order)';
$stmt = db()->prepare($sql);
$stmt->execute($payload);
return (int) db()->lastInsertId();
}
function get_entry(int $id): ?array
{
$stmt = db()->prepare('SELECT * FROM station_entries WHERE id = :id LIMIT 1');
$stmt->execute(['id' => $id]);
$entry = $stmt->fetch();
return $entry ?: null;
}
function get_entries(string $type, array $statuses = []): array
{
$type = normalize_entry_type($type);
$sql = 'SELECT * FROM station_entries WHERE entry_type = :entry_type';
$params = ['entry_type' => $type];
if ($statuses !== []) {
$placeholders = [];
foreach (array_values($statuses) as $index => $status) {
$key = 'status_' . $index;
$placeholders[] = ':' . $key;
$params[$key] = $status;
}
$sql .= ' AND status IN (' . implode(', ', $placeholders) . ')';
}
$sql .= ' ORDER BY sort_order ASC, weekday ASC, start_time ASC, created_at DESC';
$stmt = db()->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
function get_entry_counts(): array
{
$rows = db()->query('SELECT entry_type, COUNT(*) AS total FROM station_entries GROUP BY entry_type')->fetchAll();
$counts = ['program' => 0, 'dj' => 0, 'social' => 0, 'message' => 0];
foreach ($rows as $row) {
$counts[$row['entry_type']] = (int) $row['total'];
}
return $counts;
}
function weekday_name(?int $weekday): string
{
$days = [1 => 'Lunes', 2 => 'Martes', 3 => 'Miércoles', 4 => 'Jueves', 5 => 'Viernes', 6 => 'Sábado', 7 => 'Domingo'];
return $days[$weekday ?? 0] ?? 'Sin día';
}
function time_label(?string $time): string
{
if (!$time) {
return '—';
}
return substr($time, 0, 5);
}
function current_program(array $programs): ?array
{
$weekday = (int) date('N');
$time = date('H:i:s');
foreach ($programs as $program) {
if ((int) $program['weekday'] !== $weekday) {
continue;
}
if ($program['start_time'] <= $time && $program['end_time'] >= $time) {
return $program;
}
}
return null;
}
function next_program(array $programs): ?array
{
$nowWeekday = (int) date('N');
$nowTime = date('H:i:s');
$best = null;
$bestWeight = null;
foreach ($programs as $program) {
$weekday = (int) $program['weekday'];
$weight = (($weekday - $nowWeekday + 7) % 7) * 1440;
$minutes = ((int) substr((string) $program['start_time'], 0, 2)) * 60 + (int) substr((string) $program['start_time'], 3, 2);
$nowMinutes = ((int) substr($nowTime, 0, 2)) * 60 + (int) substr($nowTime, 3, 2);
if ($weight === 0 && $minutes <= $nowMinutes) {
$weight = 7 * 1440 + $minutes;
} else {
$weight += $minutes;
}
if ($bestWeight === null || $weight < $bestWeight) {
$best = $program;
$bestWeight = $weight;
}
}
return $best;
}
function group_programs_by_day(array $programs): array
{
$grouped = [];
foreach ($programs as $program) {
$grouped[(int) $program['weekday']][] = $program;
}
ksort($grouped);
return $grouped;
}
ensure_radio_schema();
seed_radio_content();

View File

@ -1,403 +1,490 @@
body {
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
background-size: 400% 400%;
animation: gradient 15s ease infinite;
color: #212529;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
margin: 0;
min-height: 100vh;
:root {
--bg: #0b0d10;
--panel: #12161b;
--panel-strong: #171c22;
--surface: #0f1318;
--surface-muted: #0c1015;
--border: #232a33;
--border-strong: #303946;
--text: #f5f7fa;
--muted: #98a2b3;
--muted-strong: #c8d0db;
--accent: #e9ecef;
--success: #4ade80;
--danger: #f97373;
--shadow: 0 24px 64px rgba(0, 0, 0, 0.34);
--radius-sm: 10px;
--radius-md: 14px;
--radius-lg: 18px;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.5rem;
--space-6: 2rem;
}
.main-wrapper {
display: flex;
html {
scroll-behavior: smooth;
}
body {
background: var(--bg);
color: var(--text);
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
letter-spacing: -0.01em;
}
main {
display: block;
}
a {
color: inherit;
}
a:hover {
color: inherit;
}
.radio-nav {
backdrop-filter: blur(14px);
background: rgba(10, 12, 15, 0.9);
}
.navbar-brand {
text-decoration: none;
}
.brand-mark,
.avatar-mark {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 100vh;
width: 100%;
padding: 20px;
box-sizing: border-box;
position: relative;
z-index: 1;
}
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.chat-container {
width: 100%;
max-width: 600px;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 20px;
display: flex;
flex-direction: column;
height: 85vh;
box-shadow: 0 20px 40px rgba(0,0,0,0.2);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
overflow: hidden;
}
.chat-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
background: rgba(255, 255, 255, 0.5);
width: 2.5rem;
height: 2.5rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
background: #151a20;
color: var(--text);
font-weight: 700;
font-size: 1.1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
.message {
max-width: 85%;
padding: 0.85rem 1.1rem;
border-radius: 16px;
line-height: 1.5;
font-size: 0.95rem;
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.message.visitor {
align-self: flex-end;
background: linear-gradient(135deg, #212529 0%, #343a40 100%);
color: #fff;
border-bottom-right-radius: 4px;
}
.message.bot {
align-self: flex-start;
background: #ffffff;
color: #212529;
border-bottom-left-radius: 4px;
}
.chat-input-area {
padding: 1.25rem;
background: rgba(255, 255, 255, 0.5);
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.chat-input-area form {
display: flex;
gap: 0.75rem;
}
.chat-input-area input {
flex: 1;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
padding: 0.75rem 1rem;
outline: none;
background: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease;
}
.chat-input-area input:focus {
border-color: #23a6d5;
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2);
}
.chat-input-area button {
background: #212529;
color: #fff;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 12px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
}
.chat-input-area button:hover {
background: #000;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
}
/* Background Animations */
.bg-animations {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
overflow: hidden;
pointer-events: none;
}
.blob {
position: absolute;
width: 500px;
height: 500px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
filter: blur(80px);
animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1);
}
.blob-1 {
top: -10%;
left: -10%;
background: rgba(238, 119, 82, 0.4);
}
.blob-2 {
bottom: -10%;
right: -10%;
background: rgba(35, 166, 213, 0.4);
animation-delay: -7s;
width: 600px;
height: 600px;
}
.blob-3 {
top: 40%;
left: 30%;
background: rgba(231, 60, 126, 0.3);
animation-delay: -14s;
width: 450px;
height: 450px;
}
@keyframes move {
0% { transform: translate(0, 0) rotate(0deg) scale(1); }
33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); }
66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); }
100% { transform: translate(0, 0) rotate(360deg) scale(1); }
}
.header-link {
font-size: 14px;
color: #fff;
text-decoration: none;
background: rgba(0, 0, 0, 0.2);
padding: 0.5rem 1rem;
border-radius: 8px;
transition: all 0.3s ease;
}
.header-link:hover {
background: rgba(0, 0, 0, 0.4);
text-decoration: none;
}
/* Admin Styles */
.admin-container {
max-width: 900px;
margin: 3rem auto;
padding: 2.5rem;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 24px;
box-shadow: 0 20px 50px rgba(0,0,0,0.15);
border: 1px solid rgba(255, 255, 255, 0.4);
position: relative;
z-index: 1;
}
.admin-container h1 {
margin-top: 0;
color: #212529;
font-weight: 800;
}
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0 8px;
margin-top: 1.5rem;
}
.table th {
background: transparent;
border: none;
padding: 1rem;
color: #6c757d;
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 1px;
}
.table td {
background: #fff;
padding: 1rem;
border: none;
}
.table tr td:first-child { border-radius: 12px 0 0 12px; }
.table tr td:last-child { border-radius: 0 12px 12px 0; }
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
font-size: 0.9rem;
}
.form-control {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
background: #fff;
transition: all 0.3s ease;
box-sizing: border-box;
.brand-title {
line-height: 1.1;
}
.form-control:focus {
outline: none;
border-color: #23a6d5;
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
.brand-subtitle {
font-size: 0.72rem;
color: var(--muted);
line-height: 1.1;
}
.header-container {
display: flex;
justify-content: space-between;
.hero-section,
.section-block {
padding: 4.5rem 0;
}
.hero-section {
padding-top: 4rem;
}
.panel,
.mini-stat,
.note-card,
.social-row,
.empty-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
.panel {
position: relative;
overflow: hidden;
}
.hero-panel,
.hero-side {
min-height: 100%;
}
.eyebrow-pill,
.soft-badge,
.schedule-chip,
.live-dot {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid var(--border-strong);
border-radius: 999px;
background: #0f1419;
color: var(--muted-strong);
font-size: 0.78rem;
padding: 0.35rem 0.7rem;
}
.header-links {
.eyebrow-pill.muted,
.soft-badge {
color: var(--muted);
}
.display-5 {
letter-spacing: -0.03em;
}
.text-secondary,
.section-copy,
.detail-list span,
.form-label,
.form-text,
.note-card p,
.social-copy {
color: var(--muted) !important;
}
.text-secondary-emphasis {
color: var(--muted-strong) !important;
}
.btn {
border-radius: 12px;
padding: 0.75rem 1rem;
font-weight: 600;
}
.btn-lg {
padding: 0.85rem 1.15rem;
}
.btn-light {
background: var(--accent);
color: #101418;
border-color: var(--accent);
}
.btn-light:hover,
.btn-light:focus {
background: #ffffff;
color: #101418;
border-color: #ffffff;
}
.btn-outline-light {
border-color: var(--border-strong);
color: var(--text);
}
.btn-outline-light:hover,
.btn-outline-light:focus,
.btn-outline-light:active {
background: #171c22 !important;
border-color: #3b4655 !important;
color: var(--text) !important;
}
.stats-row .mini-stat,
.mini-stat {
padding: 1rem 1.05rem;
min-height: 100%;
}
.mini-label,
.stack-label,
.panel-kicker,
.section-label {
display: block;
color: var(--muted);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.3rem;
}
.mini-stat strong,
.stack-card strong {
font-size: 1.05rem;
font-weight: 700;
color: var(--text);
}
.stack-card {
padding: 1rem 1.1rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.stack-card span:last-child {
color: var(--muted);
font-size: 0.88rem;
}
.stack-card.muted {
background: var(--surface-muted);
}
.live-dot {
color: var(--text);
}
.live-dot::before {
content: '';
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 0 0.25rem rgba(74, 222, 128, 0.15);
}
.section-heading {
border-bottom: 1px solid rgba(48, 57, 70, 0.4);
padding-bottom: 1rem;
}
.section-title {
font-size: clamp(1.8rem, 4vw, 2.45rem);
margin: 0.15rem 0 0;
letter-spacing: -0.03em;
}
.radio-player-shell {
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
background: #080a0d;
}
.radio-player-shell iframe {
width: 100%;
height: 100%;
border: 0;
background: #080a0d;
}
.detail-list {
display: grid;
gap: 1rem;
}
.admin-card {
background: rgba(255, 255, 255, 0.6);
padding: 2rem;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.5);
margin-bottom: 2.5rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
.detail-list li {
display: flex;
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid rgba(48, 57, 70, 0.45);
padding-bottom: 0.85rem;
}
.admin-card h3 {
margin-top: 0;
margin-bottom: 1.5rem;
font-weight: 700;
}
.btn-delete {
background: #dc3545;
color: white;
border: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
}
.btn-add {
background: #212529;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
margin-top: 1rem;
}
.btn-save {
background: #0088cc;
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 12px;
cursor: pointer;
.detail-list strong {
color: var(--text);
font-weight: 600;
width: 100%;
transition: all 0.3s ease;
text-align: right;
}
.webhook-url {
font-size: 0.85em;
color: #555;
margin-top: 0.5rem;
.note-card {
padding: 1rem 1.1rem;
}
.history-table-container {
overflow-x: auto;
background: rgba(255, 255, 255, 0.4);
padding: 1rem;
.note-card strong {
display: block;
margin-bottom: 0.35rem;
}
.timeline-list {
display: grid;
gap: 1rem;
}
.timeline-item {
display: grid;
grid-template-columns: 62px 1fr;
gap: 1rem;
align-items: start;
padding-top: 0.2rem;
}
.timeline-item + .timeline-item {
border-top: 1px solid rgba(48, 57, 70, 0.4);
padding-top: 1rem;
}
.timeline-hour {
color: var(--muted-strong);
font-weight: 700;
font-size: 0.9rem;
}
.dj-card {
transition: transform 0.22s ease, border-color 0.22s ease;
}
.dj-card:hover,
.social-row:hover,
.admin-item:hover {
transform: translateY(-2px);
border-color: var(--border-strong);
}
.text-link {
color: var(--text);
text-decoration: none;
border-bottom: 1px solid var(--border-strong);
padding-bottom: 0.1rem;
}
.social-row {
display: flex;
justify-content: space-between;
gap: 1rem;
text-decoration: none;
padding: 1rem 1.1rem;
transition: transform 0.22s ease, border-color 0.22s ease;
}
.social-copy {
max-width: 12rem;
text-align: right;
font-size: 0.88rem;
}
.footer-block {
background: #090b0e;
}
.form-control,
.form-select {
min-height: 46px;
background: #0f1419;
border: 1px solid var(--border-strong);
color: var(--text);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 0.75rem 0.9rem;
}
.history-table {
width: 100%;
.form-control::placeholder {
color: #6f7a88;
}
.history-table-time {
width: 15%;
white-space: nowrap;
font-size: 0.85em;
color: #555;
.form-control:focus,
.form-select:focus,
.btn:focus,
.nav-link:focus,
.navbar-toggler:focus {
box-shadow: 0 0 0 0.2rem rgba(233, 236, 239, 0.18);
border-color: #98a2b3;
outline: none;
}
.history-table-user {
width: 35%;
background: rgba(255, 255, 255, 0.3);
border-radius: 8px;
padding: 8px;
.form-select option {
background: #0f1419;
color: var(--text);
}
.history-table-ai {
width: 50%;
background: rgba(255, 255, 255, 0.5);
border-radius: 8px;
padding: 8px;
.toast.radio-toast {
background: rgba(18, 22, 27, 0.96) !important;
border: 1px solid var(--border-strong) !important;
border-radius: 14px;
min-width: 320px;
}
.no-messages {
text-align: center;
color: #777;
}
.toast-body {
color: var(--text);
display: flex;
align-items: center;
gap: 0.75rem;
}
.toast-indicator {
display: inline-block;
width: 0.65rem;
height: 0.65rem;
border-radius: 999px;
background: var(--muted);
flex: 0 0 auto;
}
.toast-indicator.success {
background: var(--success);
}
.toast-indicator.danger {
background: var(--danger);
}
.admin-shell {
padding-top: 3rem;
}
.admin-list {
display: grid;
gap: 0.85rem;
}
.admin-item {
display: block;
text-decoration: none;
padding: 1rem 1.05rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
transition: transform 0.22s ease, border-color 0.22s ease;
}
.admin-item.active {
border-color: #546172;
background: #131920;
}
.empty-card {
padding: 1.2rem;
color: var(--muted);
}
@media (max-width: 991.98px) {
.hero-section,
.section-block {
padding: 3.5rem 0;
}
.social-row {
flex-direction: column;
}
.social-copy {
max-width: none;
text-align: left;
}
}
@media (max-width: 575.98px) {
.hero-section {
padding-top: 2rem;
}
.panel,
.mini-stat,
.note-card,
.social-row,
.empty-card {
border-radius: 16px;
}
.timeline-item {
grid-template-columns: 1fr;
gap: 0.45rem;
}
.detail-list li {
flex-direction: column;
align-items: flex-start;
}
.detail-list strong {
text-align: left;
}
}

View File

@ -1,39 +1,32 @@
document.addEventListener('DOMContentLoaded', () => {
const chatForm = document.getElementById('chat-form');
const chatInput = document.getElementById('chat-input');
const chatMessages = document.getElementById('chat-messages');
const toastElement = document.getElementById('statusToast');
if (toastElement && window.bootstrap) {
const toast = new bootstrap.Toast(toastElement, { delay: 4200 });
toast.show();
}
const appendMessage = (text, sender) => {
const msgDiv = document.createElement('div');
msgDiv.classList.add('message', sender);
msgDiv.textContent = text;
chatMessages.appendChild(msgDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
};
document.querySelectorAll('a[href^="#"]').forEach((link) => {
link.addEventListener('click', (event) => {
const targetId = link.getAttribute('href');
if (!targetId || targetId === '#') {
return;
}
const target = document.querySelector(targetId);
if (!target) {
return;
}
event.preventDefault();
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
});
chatForm.addEventListener('submit', async (e) => {
e.preventDefault();
const message = chatInput.value.trim();
if (!message) return;
appendMessage(message, 'visitor');
chatInput.value = '';
try {
const response = await fetch('api/chat.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
});
const data = await response.json();
// Artificial delay for realism
setTimeout(() => {
appendMessage(data.reply, 'bot');
}, 500);
} catch (error) {
console.error('Error:', error);
appendMessage("Sorry, something went wrong. Please try again.", 'bot');
}
document.querySelectorAll('.needs-validation').forEach((form) => {
form.addEventListener('submit', (event) => {
if (!form.checkValidity()) {
event.preventDefault();
event.stopPropagation();
}
form.classList.add('was-validated');
});
});
});

21
healthz.php Normal file
View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/app.php';
header('Content-Type: application/json; charset=utf-8');
try {
db()->query('SELECT 1');
echo json_encode([
'status' => 'ok',
'app' => project_name(),
'time' => gmdate('c'),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'status' => 'error',
'message' => 'database unavailable',
'time' => gmdate('c'),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}

503
index.php
View File

@ -1,150 +1,387 @@
<?php
declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
require_once __DIR__ . '/app.php';
$phpVersion = PHP_VERSION;
$now = date('Y-m-d H:i:s');
$playerUrl = 'https://player.foxradios.com/demo/1/index.html';
$counts = get_entry_counts();
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'fan_message') {
$name = trim((string) ($_POST['name'] ?? ''));
$email = trim((string) ($_POST['email'] ?? ''));
$song = trim((string) ($_POST['song_request'] ?? ''));
$message = trim((string) ($_POST['message'] ?? ''));
if ($name === '') {
$errors[] = 'Escribe tu nombre.';
}
if ($email === '' || filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$errors[] = 'Indica un correo válido para responderte.';
}
if ($message === '') {
$errors[] = 'Cuéntanos tu saludo o pedido musical.';
}
if (strlen($message) > 1200) {
$errors[] = 'El mensaje debe tener menos de 1200 caracteres.';
}
if ($errors === []) {
save_entry([
'entry_type' => 'message',
'title' => $name,
'subtitle' => $email,
'body' => $message,
'meta_value' => $song,
'status' => 'new',
'sort_order' => 0,
]);
set_flash('success', 'Tu mensaje ya quedó en cabina. El equipo de Lili Records Radio lo verá en el panel admin.');
header('Location: /#mensajes');
exit;
}
set_flash('danger', implode(' ', $errors));
header('Location: /#mensajes');
exit;
}
$flash = pull_flash();
$programs = get_entries('program', ['published']);
$djs = get_entries('dj', ['published']);
$socials = get_entries('social', ['published']);
$currentShow = current_program($programs);
$nextShow = next_program($programs);
$groupedPrograms = group_programs_by_day($programs);
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? project_description();
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
$title = project_name();
$metaDescription = project_description();
$assetVersion = (string) max(@filemtime(__DIR__ . '/assets/css/custom.css') ?: time(), @filemtime(__DIR__ . '/assets/js/main.js') ?: time());
?>
<!doctype html>
<html lang="en">
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>New Style</title>
<?php
// Read project preview data from environment
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= h($title) ?></title>
<meta name="description" content="<?= h($metaDescription) ?>">
<meta name="author" content="Lili Records Radio">
<?php if ($projectDescription): ?>
<!-- Meta description -->
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<meta property="og:description" content="<?= h($projectDescription) ?>">
<meta property="twitter:description" content="<?= h($projectDescription) ?>">
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<!-- Open Graph image -->
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<meta property="og:image" content="<?= h($projectImageUrl) ?>">
<meta property="twitter:image" content="<?= h($projectImageUrl) ?>">
<?php endif; ?>
<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=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
}
</style>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="/assets/css/custom.css?v=<?= h($assetVersion) ?>">
</head>
<body>
<main>
<div class="card">
<h1>Analyzing your requirements and generating your website…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
</div>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will update automatically as the plan is implemented.</p>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
<nav class="navbar navbar-expand-lg navbar-dark sticky-top radio-nav border-bottom border-secondary-subtle">
<div class="container">
<a class="navbar-brand d-flex align-items-center gap-2" href="/#top">
<span class="brand-mark">LR</span>
<span>
<span class="d-block fw-semibold brand-title">Lili Records Radio</span>
<span class="brand-subtitle">Online station</span>
</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Abrir navegación">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNav">
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
<li class="nav-item"><a class="nav-link" href="#escuchar">Escuchar</a></li>
<li class="nav-item"><a class="nav-link" href="#programacion">Programación</a></li>
<li class="nav-item"><a class="nav-link" href="#djs">DJs</a></li>
<li class="nav-item"><a class="nav-link" href="#mensajes">Mensajes</a></li>
<li class="nav-item ms-lg-2"><a class="btn btn-sm btn-outline-light" href="/admin.php">Admin</a></li>
</ul>
</div>
</main>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC)
</footer>
</div>
</nav>
<header class="hero-section" id="top">
<div class="container">
<div class="row g-4 align-items-stretch">
<div class="col-lg-7">
<div class="hero-panel panel p-4 p-lg-5 h-100">
<div class="d-flex flex-wrap gap-2 mb-4">
<span class="eyebrow-pill">Señal online activa</span>
<span class="eyebrow-pill muted">Web app inicial MVP</span>
</div>
<h1 class="display-5 fw-semibold text-white mb-3">Tu radio lista para reproducir, descubrir shows y recibir pedidos musicales.</h1>
<p class="lead text-secondary-emphasis mb-4">Lili Records Radio abre con play inmediato, estado de “Ahora suena”, parrilla semanal, perfiles de DJs y un flujo real para que los oyentes envíen mensajes que el admin puede revisar y actualizar.</p>
<div class="d-flex flex-wrap gap-3 mb-4">
<a class="btn btn-light btn-lg" href="#escuchar">Escuchar ahora</a>
<a class="btn btn-outline-light btn-lg" href="/admin.php">Entrar al panel</a>
</div>
<div class="row g-3 stats-row">
<div class="col-6 col-xl-3">
<div class="mini-stat">
<span class="mini-label">Estado</span>
<strong>En línea</strong>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="mini-stat">
<span class="mini-label">Shows</span>
<strong><?= h((string) $counts['program']) ?></strong>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="mini-stat">
<span class="mini-label">DJs</span>
<strong><?= h((string) $counts['dj']) ?></strong>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="mini-stat">
<span class="mini-label">Mensajes</span>
<strong><?= h((string) $counts['message']) ?></strong>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-5">
<aside class="panel hero-side p-4 h-100">
<div class="panel-header d-flex justify-content-between align-items-start mb-3">
<div>
<p class="panel-kicker">Ahora suena</p>
<h2 class="h4 text-white mb-1"><?= h($currentShow['title'] ?? 'Lili Records Live Mix') ?></h2>
<p class="text-secondary mb-0"><?= h($currentShow['subtitle'] ?? 'Playlist continua · sin show en vivo detectado') ?></p>
</div>
<span class="live-dot">LIVE</span>
</div>
<div class="schedule-chip mb-3">
<span><?= h($currentShow ? weekday_name((int) $currentShow['weekday']) : 'Disponible 24/7') ?></span>
<strong><?= h($currentShow ? time_label($currentShow['start_time']) . ' ' . time_label($currentShow['end_time']) : 'Streaming abierto') ?></strong>
</div>
<p class="text-secondary small mb-4"><?= h($currentShow['body'] ?? 'Usando el player embebido confirmado por el usuario. Puedes reemplazar la programación demo desde el panel admin.') ?></p>
<div class="stack-card mb-3">
<span class="stack-label">Siguiente bloque</span>
<strong><?= h($nextShow['title'] ?? 'Sin programación próxima') ?></strong>
<span><?= h($nextShow ? weekday_name((int) $nextShow['weekday']) . ' · ' . time_label($nextShow['start_time']) : 'Agrega shows en admin') ?></span>
</div>
<div class="stack-card muted">
<span class="stack-label">Operación</span>
<strong>Mensajes conectados</strong>
<span>Los pedidos entran al panel admin con estado “new”.</span>
</div>
</aside>
</div>
</div>
</div>
</header>
<main>
<section class="section-block" id="escuchar">
<div class="container">
<div class="section-heading d-flex flex-wrap align-items-end justify-content-between gap-3 mb-4">
<div>
<span class="section-label">Escuchar</span>
<h2 class="section-title">Play inmediato desde móvil o desktop</h2>
</div>
<p class="section-copy">El reproductor queda visible de inmediato y el contexto editorial acompaña la escucha.</p>
</div>
<div class="row g-4">
<div class="col-xl-8">
<div class="panel p-3 p-md-4">
<div class="ratio ratio-16x9 radio-player-shell">
<iframe src="<?= h($playerUrl) ?>" title="Reproductor de Lili Records Radio" loading="lazy" allow="autoplay; encrypted-media" referrerpolicy="strict-origin-when-cross-origin"></iframe>
</div>
</div>
</div>
<div class="col-xl-4">
<div class="panel p-4 h-100 d-flex flex-column justify-content-between">
<div>
<span class="section-label">Cabina</span>
<h3 class="h4 text-white mt-2 mb-3">Información de emisión</h3>
<ul class="list-unstyled detail-list mb-0">
<li>
<span>Señal</span>
<strong>Player embebido activo</strong>
</li>
<li>
<span>Horario visible</span>
<strong>UTC · editable</strong>
</li>
<li>
<span>Contacto fan</span>
<strong>Formulario conectado</strong>
</li>
</ul>
</div>
<div class="note-card mt-4">
<strong>Consejo</strong>
<p class="mb-0">Si quieres, en la siguiente iteración puedo conectar metadata real del stream para reemplazar el “Ahora suena” basado en parrilla.</p>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="section-block" id="programacion">
<div class="container">
<div class="section-heading d-flex flex-wrap align-items-end justify-content-between gap-3 mb-4">
<div>
<span class="section-label">Programación</span>
<h2 class="section-title">Parrilla semanal lista para editar</h2>
</div>
<p class="section-copy">Los bloques publicados alimentan la vista pública y el estado editorial de la estación.</p>
</div>
<div class="row g-3">
<?php foreach ($groupedPrograms as $weekday => $items): ?>
<div class="col-md-6 col-xl-4">
<article class="panel p-4 h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="h5 text-white mb-0"><?= h(weekday_name((int) $weekday)) ?></h3>
<span class="soft-badge"><?= h((string) count($items)) ?> show<?= count($items) === 1 ? '' : 's' ?></span>
</div>
<div class="timeline-list">
<?php foreach ($items as $item): ?>
<div class="timeline-item">
<div class="timeline-hour"><?= h(time_label($item['start_time'])) ?></div>
<div>
<strong class="d-block text-white"><?= h($item['title']) ?></strong>
<span class="text-secondary d-block small mb-1"><?= h($item['subtitle']) ?> · <?= h(time_label($item['end_time'])) ?></span>
<p class="small text-secondary-emphasis mb-0"><?= h($item['body']) ?></p>
</div>
</div>
<?php endforeach; ?>
</div>
</article>
</div>
<?php endforeach; ?>
</div>
</div>
</section>
<section class="section-block" id="djs">
<div class="container">
<div class="section-heading d-flex flex-wrap align-items-end justify-content-between gap-3 mb-4">
<div>
<span class="section-label">DJs & shows</span>
<h2 class="section-title">Caras visibles de la emisora</h2>
</div>
<p class="section-copy">Cada perfil puede enlazar a redes o biografía corta desde el mismo backend.</p>
</div>
<div class="row g-3">
<?php foreach ($djs as $dj): ?>
<div class="col-md-6 col-xl-4">
<article class="panel p-4 h-100 dj-card">
<div class="avatar-mark mb-4"><?= h(strtoupper(substr($dj['title'], 0, 1))) ?></div>
<h3 class="h4 text-white mb-1"><?= h($dj['title']) ?></h3>
<p class="text-secondary mb-3"><?= h($dj['subtitle']) ?></p>
<p class="text-secondary-emphasis mb-4"><?= h($dj['body']) ?></p>
<?php if (!empty($dj['meta_url'])): ?>
<a class="text-link" href="<?= h($dj['meta_url']) ?>" target="_blank" rel="noreferrer">Ver enlace principal</a>
<?php endif; ?>
</article>
</div>
<?php endforeach; ?>
</div>
</div>
</section>
<section class="section-block" id="mensajes">
<div class="container">
<div class="section-heading d-flex flex-wrap align-items-end justify-content-between gap-3 mb-4">
<div>
<span class="section-label">Mensajes</span>
<h2 class="section-title">Pide una canción o manda un saludo</h2>
</div>
<p class="section-copy">El formulario guarda el pedido en base de datos y lo deja listo para revisión desde el panel admin.</p>
</div>
<div class="row g-4">
<div class="col-lg-7">
<div class="panel p-4 p-lg-5">
<form method="post" action="/#mensajes" class="row g-3 needs-validation" novalidate>
<input type="hidden" name="action" value="fan_message">
<div class="col-md-6">
<label class="form-label" for="name">Nombre</label>
<input class="form-control" id="name" name="name" maxlength="120" required>
</div>
<div class="col-md-6">
<label class="form-label" for="email">Email</label>
<input class="form-control" id="email" name="email" type="email" maxlength="160" required>
</div>
<div class="col-12">
<label class="form-label" for="song_request">Pedido musical</label>
<input class="form-control" id="song_request" name="song_request" maxlength="160" placeholder="Ej. Un tema de Lili Records Sessions">
</div>
<div class="col-12">
<label class="form-label" for="message">Mensaje para cabina</label>
<textarea class="form-control" id="message" name="message" rows="5" maxlength="1200" required placeholder="Escribe tu saludo, dedicatoria o pedido."></textarea>
</div>
<div class="col-12 d-flex flex-wrap gap-3 align-items-center pt-2">
<button class="btn btn-light" type="submit">Enviar a cabina</button>
<span class="small text-secondary">Respuesta esperada: el mensaje se guarda con estado inicial <strong class="text-white">new</strong>.</span>
</div>
</form>
</div>
</div>
<div class="col-lg-5">
<div class="panel p-4 h-100 d-flex flex-column gap-3">
<div>
<span class="section-label">Redes</span>
<h3 class="h4 text-white mt-2 mb-3">Canales activos</h3>
</div>
<?php foreach ($socials as $social): ?>
<a class="social-row" href="<?= h($social['meta_url'] ?: '#') ?>" target="_blank" rel="noreferrer">
<span>
<strong class="d-block text-white"><?= h($social['title']) ?></strong>
<span class="small text-secondary"><?= h($social['subtitle']) ?></span>
</span>
<span class="social-copy"><?= h($social['body']) ?></span>
</a>
<?php endforeach; ?>
<div class="note-card mt-auto">
<strong>Admin simple incluido</strong>
<p class="mb-0">Desde el panel puedes crear programas, DJs y enlaces, además de revisar el detalle de cada mensaje recibido.</p>
</div>
</div>
</div>
</div>
</div>
</section>
</main>
<footer class="footer-block border-top border-secondary-subtle">
<div class="container d-flex flex-column flex-lg-row justify-content-between gap-3 py-4">
<div>
<strong class="d-block text-white mb-1">Lili Records Radio</strong>
<span class="text-secondary">Web app inicial con streaming, parrilla, DJs, formulario y panel admin.</span>
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<a class="btn btn-sm btn-outline-light" href="/#escuchar">Ir al player</a>
<a class="btn btn-sm btn-outline-light" href="/admin.php">Panel admin</a>
<a class="btn btn-sm btn-outline-light" href="/healthz.php">Health</a>
</div>
</div>
</footer>
<?php if ($flash): ?>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div class="toast radio-toast align-items-center text-bg-dark border-0" id="statusToast" role="status" aria-live="polite" aria-atomic="true" data-autoshow="true">
<div class="d-flex">
<div class="toast-body">
<span class="toast-indicator <?= h($flash['type']) ?>"></span>
<?= h($flash['message']) ?>
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Cerrar"></button>
</div>
</div>
</div>
<?php endif; ?>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="/assets/js/main.js?v=<?= h($assetVersion) ?>"></script>
</body>
</html>