diff --git a/admin.php b/admin.php new file mode 100644 index 0000000..8df9c59 --- /dev/null +++ b/admin.php @@ -0,0 +1,257 @@ + $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'] ?? ''; +?> + + + + + + Admin · <?= h(project_name()) ?> + + + + + + + + + + + + + + + + + + +
+
+
+
+ +

Gestiona shows, DJs, redes y mensajes

+
+

Primera versión funcional: listados por tipo, detalle editable y revisión básica de mensajes recibidos.

+
+ +
+
Programas
+
DJs
+
Redes
+
Mensajes
+
+ +
+ + +
+
+
+
+ +

+

+
+
+ +
+ + + + +
+ + > +
+
+ + +
+ +
+ + +
+
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+ + + +
+ + +
+
+ + +
+ +
+ + +
+ + +
+ + +
+ + +
+
+
+
+
+
+ + +
+
+
+
+ + +
+ +
+
+
+ + + + + + diff --git a/app.php b/app.php new file mode 100644 index 0000000..7e4ca92 --- /dev/null +++ b/app.php @@ -0,0 +1,363 @@ + $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(); diff --git a/assets/css/custom.css b/assets/css/custom.css index 789132e..74ccf9e 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -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; -} \ No newline at end of file +.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; + } +} diff --git a/assets/js/main.js b/assets/js/main.js index d349598..faafe36 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -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'); + }); }); }); diff --git a/healthz.php b/healthz.php new file mode 100644 index 0000000..e852548 --- /dev/null +++ b/healthz.php @@ -0,0 +1,21 @@ +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); +} diff --git a/index.php b/index.php index 7205f3d..a52ebdf 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,387 @@ 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()); ?> - + - - - New Style - + + + <?= h($title) ?> + + - - - - - - + + - - - - + + - - + + + -
-
-

Analyzing your requirements and generating your website…

-
- Loading… -
-

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — UTC

+
- + + + +
+
+
+
+
+
+ Señal online activa + Web app inicial MVP +
+

Tu radio lista para reproducir, descubrir shows y recibir pedidos musicales.

+

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.

+ +
+
+
+ Estado + En línea +
+
+
+
+ Shows + +
+
+
+
+ DJs + +
+
+
+
+ Mensajes + +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+ +

Play inmediato desde móvil o desktop

+
+

El reproductor queda visible de inmediato y el contexto editorial acompaña la escucha.

+
+
+
+
+
+ +
+
+
+
+
+
+ +

Información de emisión

+
    +
  • + Señal + Player embebido activo +
  • +
  • + Horario visible + UTC · editable +
  • +
  • + Contacto fan + Formulario conectado +
  • +
+
+
+ Consejo +

Si quieres, en la siguiente iteración puedo conectar metadata real del stream para reemplazar el “Ahora suena” basado en parrilla.

+
+
+
+
+
+
+ +
+
+
+
+ +

Parrilla semanal lista para editar

+
+

Los bloques publicados alimentan la vista pública y el estado editorial de la estación.

+
+
+ $items): ?> +
+
+
+

+ show +
+
+ +
+
+
+ + · +

+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+ +

Caras visibles de la emisora

+
+

Cada perfil puede enlazar a redes o biografía corta desde el mismo backend.

+
+
+ +
+ +
+ +
+
+
+ +
+
+
+
+ +

Pide una canción o manda un saludo

+
+

El formulario guarda el pedido en base de datos y lo deja listo para revisión desde el panel admin.

+
+
+
+
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Respuesta esperada: el mensaje se guarda con estado inicial new. +
+
+
+
+
+
+
+ +

Canales activos

+
+ + + +
+ Admin simple incluido +

Desde el panel puedes crear programas, DJs y enlaces, además de revisar el detalle de cada mensaje recibido.

+
+
+
+
+
+
+
+ + + + +
+
+
+
+ + +
+ +
+
+
+ + + +