From ef15b646cce050be6c36725e6188b0a784e7ac65 Mon Sep 17 00:00:00 2001 From: codey Date: Sun, 22 Feb 2026 13:53:43 -0500 Subject: [PATCH] feat(Nav): Refactor navigation structure to support nested items and improve visibility logic feat(Radio): - Redesigned Queue modal, added drag & drop capabilities - Added stream quality selector, currently offering: AAC @ 128kbps, AAC @ 320kbps & FLAC (lossless) fix(middleware): Import API_URL from config and remove hardcoded API_URL definition security(api): Enhance discord image and video caching with improved signature verification and error handling, updated image proxy to include production checks for signing secret --- public/scripts/nav-controls.js | 109 ++++- src/assets/scripts/lenisSmoothScroll.ts | 3 + src/assets/styles/nav.css | 92 +++- src/assets/styles/player.css | 193 +++++++- src/components/DiscordLogs.tsx | 8 +- src/components/Footer.astro | 66 ++- src/components/Radio.tsx | 591 ++++++++++++++++++++---- src/layouts/Nav.astro | 234 +++++++--- src/middleware.ts | 3 +- src/pages/api/discord/cached-image.ts | 46 +- src/pages/api/discord/cached-video.ts | 29 +- src/pages/api/image-proxy.ts | 6 +- 12 files changed, 1188 insertions(+), 192 deletions(-) diff --git a/public/scripts/nav-controls.js b/public/scripts/nav-controls.js index 67add06..062fb20 100644 --- a/public/scripts/nav-controls.js +++ b/public/scripts/nav-controls.js @@ -30,6 +30,16 @@ } }; + function setLenisEnabled(enabled) { + const lenis = window.__lenis; + if (!lenis) return; + if (enabled) { + lenis.start(); + } else { + lenis.stop(); + } + } + function initMobileMenu() { const menuBtn = document.getElementById("mobile-menu-btn"); const mobileMenu = document.getElementById("mobile-menu"); @@ -51,10 +61,12 @@ mobileMenu.classList.remove("open"); menuIcon.style.display = "block"; closeIcon.style.display = "none"; + setLenisEnabled(true); } else { mobileMenu.classList.add("open"); menuIcon.style.display = "none"; closeIcon.style.display = "block"; + setLenisEnabled(false); } }); @@ -64,6 +76,7 @@ mobileMenu.classList.remove("open"); menuIcon.style.display = "block"; closeIcon.style.display = "none"; + setLenisEnabled(true); }); }); @@ -73,6 +86,7 @@ mobileMenu.classList.remove("open"); menuIcon.style.display = "block"; closeIcon.style.display = "none"; + setLenisEnabled(true); } } }; @@ -81,7 +95,93 @@ document.addEventListener("click", closeHandler); } - const ready = () => initMobileMenu(); + function initDropdownExternalLinks() { + // Close desktop dropdown when external links are clicked + const dropdownLinks = document.querySelectorAll('.nav-dropdown a[target="_blank"]'); + dropdownLinks.forEach((link) => { + link.addEventListener("click", () => { + // Blur the parent nav item to close the CSS hover-based dropdown + const navItem = link.closest('.nav-item--has-children'); + if (navItem) { + const parentLink = navItem.querySelector(':scope > a'); + if (parentLink) { + parentLink.blur(); + } + // Force close by temporarily disabling pointer events + navItem.style.pointerEvents = 'none'; + setTimeout(() => { + navItem.style.pointerEvents = ''; + }, 100); + } + }); + }); + } + + function initDesktopDropdownToggle() { + // Desktop: click parent to toggle dropdown open/closed + const desktopNav = document.querySelector('nav .hidden.md\\:flex'); + if (!desktopNav) return; + + const parentItems = desktopNav.querySelectorAll('.nav-item--has-children'); + parentItems.forEach((navItem) => { + const parentLink = navItem.querySelector(':scope > a'); + if (!parentLink) return; + + parentLink.addEventListener('click', (e) => { + e.preventDefault(); + const isOpen = navItem.classList.contains('dropdown-open'); + + // Close any other open dropdowns + parentItems.forEach((item) => item.classList.remove('dropdown-open')); + + if (!isOpen) { + navItem.classList.add('dropdown-open'); + } + }); + }); + + // Close dropdown when clicking outside + document.addEventListener('click', (e) => { + if (!desktopNav.contains(e.target)) { + parentItems.forEach((item) => item.classList.remove('dropdown-open')); + } + }); + } + + function initMobileSubmenus() { + const mobileMenu = document.getElementById("mobile-menu"); + if (!mobileMenu) return; + + // Find all parent links that have a sibling mobile-subnav + const parentLinks = mobileMenu.querySelectorAll('a:has(+ .mobile-subnav)'); + + parentLinks.forEach((link) => { + const subnav = link.nextElementSibling; + const caret = link.querySelector('.mobile-caret'); + + if (!subnav || !subnav.classList.contains('mobile-subnav')) return; + + // Clone to remove existing listeners + const newLink = link.cloneNode(true); + link.parentNode.replaceChild(newLink, link); + + const newCaret = newLink.querySelector('.mobile-caret'); + + newLink.addEventListener('click', (e) => { + // Toggle subnav open/closed on click + e.preventDefault(); + subnav.classList.toggle('open'); + if (newCaret) newCaret.classList.toggle('open'); + }); + }); + } + + const ready = () => { + initMobileMenu(); + initDropdownExternalLinks(); + initDesktopDropdownToggle(); + initMobileSubmenus(); + }; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", ready, { once: true }); @@ -89,5 +189,10 @@ ready(); } - document.addEventListener("astro:page-load", initMobileMenu); + document.addEventListener("astro:page-load", () => { + initMobileMenu(); + initDropdownExternalLinks(); + initDesktopDropdownToggle(); + initMobileSubmenus(); + }); })(); diff --git a/src/assets/scripts/lenisSmoothScroll.ts b/src/assets/scripts/lenisSmoothScroll.ts index e7c43a9..7602110 100644 --- a/src/assets/scripts/lenisSmoothScroll.ts +++ b/src/assets/scripts/lenisSmoothScroll.ts @@ -11,3 +11,6 @@ function raf(time) { } requestAnimationFrame(raf); + +// Expose lenis instance globally for nav controls +(window as any).__lenis = lenis; diff --git a/src/assets/styles/nav.css b/src/assets/styles/nav.css index 834a90c..27aab25 100644 --- a/src/assets/styles/nav.css +++ b/src/assets/styles/nav.css @@ -29,8 +29,9 @@ } .mobile-menu-dropdown.open { - max-height: none; - overflow: visible; + max-height: calc(100vh - 4rem); + overflow-y: auto; + overscroll-behavior: contain; padding-bottom: 0.75rem; opacity: 1; } @@ -83,6 +84,70 @@ nav { flex-shrink: 0; } +.nav-item { + position: relative; +} + +.nav-item--has-children .nav-caret { + margin-left: 0.25rem; + font-size: 0.85rem; + opacity: 0.7; + transition: transform 0.15s ease; +} + +.nav-item--has-children:hover .nav-caret, +.nav-item--has-children:focus-within .nav-caret { + transform: translateY(1px) rotate(180deg); +} + +.nav-dropdown { + position: absolute; + top: 100%; + margin-top: 0.2rem; + left: 0; + min-width: 12.5rem; + background: rgba(255, 255, 255, 0.98); + border: 1px solid rgba(226, 232, 240, 0.9); + border-radius: 0.75rem; + box-shadow: 0 14px 45px rgba(0, 0, 0, 0.12), 0 10px 18px rgba(0, 0, 0, 0.08); + padding: 0.35rem; + opacity: 0; + transform: translateY(4px); + pointer-events: none; + transition: opacity 0.2s ease, transform 0.2s ease; + z-index: 30; +} + +.nav-item--has-children .nav-dropdown::before { + content: ""; + position: absolute; + top: -10px; + left: 0; + right: 0; + height: 12px; + background: transparent; +} + +[data-theme="dark"] .nav-dropdown { + background: rgba(17, 24, 39, 0.98); + border-color: rgba(75, 85, 99, 0.6); + box-shadow: 0 14px 45px rgba(0, 0, 0, 0.45), 0 10px 18px rgba(0, 0, 0, 0.4); +} + +.nav-dropdown ul { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.nav-item--has-children:hover .nav-dropdown, +.nav-item--has-children:focus-within .nav-dropdown, +.nav-item--has-children.dropdown-open .nav-dropdown { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + .desktop-nav-list a { white-space: nowrap; padding: 0.375rem 0.625rem; @@ -170,3 +235,26 @@ nav { border-color: rgba(248, 113, 113, 0.6); box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3), inset 0 1px 0 rgba(239, 68, 68, 0.1); } + +.mobile-subnav { + display: none; + flex-direction: column; + gap: 0.15rem; + margin: 0.2rem 0 0.35rem; + padding-left: 0.75rem; +} + +.mobile-subnav.open { + display: flex; +} + +.mobile-caret { + margin-left: auto; + font-size: 0.95rem; + opacity: 0.55; + transition: transform 0.2s ease; +} + +.mobile-caret.open { + transform: rotate(90deg); +} diff --git a/src/assets/styles/player.css b/src/assets/styles/player.css index aed13e3..7141b5b 100644 --- a/src/assets/styles/player.css +++ b/src/assets/styles/player.css @@ -5,6 +5,38 @@ box-sizing: border-box; } +/* Marquee animation for long text */ +@keyframes marquee { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-50%); + } +} + +.marquee-wrapper { + overflow: hidden; + position: relative; + width: 100%; +} + +.marquee-content { + display: inline-block; + white-space: nowrap; +} + +.marquee-content.scrolling { + animation: marquee 10s linear infinite; + padding-right: 2rem; +} + +/* Duplicate text for seamless loop */ +.marquee-content.scrolling::after { + content: attr(data-text); + padding-left: 2rem; +} + :root { --lrc-text-color: #666; /* muted text for inactive lines */ --lrc-bg-color: rgba(0, 0, 0, 0.05); @@ -42,6 +74,7 @@ body { overflow-y: hidden; position: relative; display: flex; + flex-wrap: wrap; justify-content: center; align-items: center; box-sizing: border-box; @@ -49,6 +82,11 @@ body { box-shadow: 1px 1px 5px 0 rgba(0, 0, 0, 0.3); } +/* Header - hidden on desktop (shown inline in music-player), visible on mobile */ +.music-container > .music-player__header { + display: none; +} + /* Album cover section */ .album-cover { aspect-ratio: 1 / 1; @@ -213,31 +251,172 @@ body { margin-bottom: 50px !important; overflow: visible !important; padding: 1rem !important; + gap: 0 !important; } + /* Show header on mobile - it's first in DOM order */ + .music-container > .music-player__header { + display: block !important; + width: 100% !important; + text-align: center !important; + font-size: 1.25rem !important; + margin-bottom: 0.5rem !important; + } + + /* Album cover below header */ .album-cover { - flex: none !important; + flex: 0 0 auto !important; + aspect-ratio: unset !important; width: 100% !important; max-width: 100% !important; height: auto !important; - margin-bottom: 1rem !important; + margin: 0 auto !important; min-width: 0 !important; + padding: 0 1rem !important; + box-sizing: border-box !important; } .album-cover > img { - aspect-ratio: unset !important; + aspect-ratio: 1 / 1 !important; height: auto !important; - width: 100% !important; + width: 60% !important; + max-width: 220px !important; + border-radius: 0.75rem !important; + display: block !important; + margin: 0 auto !important; + object-fit: cover !important; } + .music-player__album { + font-size: 0.75rem !important; + margin-bottom: 0.25rem !important; + text-align: center !important; + padding: 0 !important; + word-wrap: break-word !important; + overflow-wrap: break-word !important; + } + + /* Player info below album */ .music-player { - flex: none !important; + flex: 0 0 auto !important; width: 100% !important; max-width: 100% !important; height: auto !important; - padding: 0 !important; + padding: 0 1rem !important; min-width: 0 !important; - flex-shrink: 0 !important; + text-align: center !important; + margin-top: 0.25rem !important; + } + + /* Hide header inside music-player on mobile (using the one outside) */ + .music-player .music-player__header { + display: none !important; + } + + .music-player__title { + font-size: 1rem !important; + margin-bottom: 0.15rem !important; + padding: 0 1rem !important; + } + + .music-player__author { + font-size: 0.85rem !important; + margin-bottom: 0.35rem !important; + padding: 0 1rem !important; + } + + .music-player__genre { + font-size: 0.8rem !important; + margin-bottom: 0.35rem !important; + padding: 0 1rem !important; + } + + .music-player__album { + padding: 0 1rem !important; + } + + .music-time { + margin-top: 0.35rem !important; + font-size: 0.8rem !important; + } + + .music-control { + margin-top: 0.5rem !important; + } + + .music-control__play { + width: 2.5rem !important; + height: 2.5rem !important; + } + + /* Lyrics section */ + .lrc-text { + max-height: 150px !important; + margin-top: 0.5rem !important; + padding: 0.5rem !important; + font-size: 0.75rem !important; + } + + .lrc-line { + font-size: 0.75rem !important; + } + + .lrc-line.active { + font-size: 0.85rem !important; + } + + /* DJ controls */ + .dj-controls { + min-width: 0 !important; + width: 100% !important; + margin-top: 0.75rem !important; + } + + .dj-controls .flex-col { + min-width: 0 !important; + width: 100% !important; + } + + .dj-controls .typeahead-input input, + .dj-controls .p-autocomplete-input { + width: 100% !important; + min-width: 0 !important; + max-width: 100% !important; + } + + .dj-controls .p-autocomplete { + width: 100% !important; + } + + .dj-controls .flex-row { + min-width: 0 !important; + width: 100% !important; + justify-content: center !important; + } + + .station-tabs { + padding-top: 2% !important; + padding-bottom: 2% !important; + } + + /* Next track styling for mobile */ + .next-track { + font-size: 0.7rem !important; + padding: 0.25rem 0.5rem !important; + margin-top: 0.25rem !important; + } +} + +/* Extra small screens */ +@media all and (max-width: 400px) { + .album-cover { + width: 70% !important; + max-width: 200px !important; + } + + .station-tabs button { + padding: 0.35rem 0.6rem !important; + font-size: 0.8rem !important; } } .progress-bar-container { diff --git a/src/components/DiscordLogs.tsx b/src/components/DiscordLogs.tsx index f052118..f6f456b 100644 --- a/src/components/DiscordLogs.tsx +++ b/src/components/DiscordLogs.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useLayoutEffect, useMemo, useCallback, memo import type { AnimationItem } from 'lottie-web'; import { ProgressSpinner } from 'primereact/progressspinner'; import { authFetch } from '@/utils/authFetch'; +import DOMPurify from 'isomorphic-dompurify'; // ============================================================================ // Type Definitions @@ -981,7 +982,12 @@ function parseDiscordMarkdown(text: string | null | undefined, options: ParseOpt // Must be done after all markdown processing parsed = parsed.replace(/\\([_*~`|\\])/g, '$1'); - return parsed; + // Final sanitization pass with DOMPurify to prevent XSS + return DOMPurify.sanitize(parsed, { + ALLOWED_TAGS: ['strong', 'em', 'u', 's', 'span', 'code', 'pre', 'br', 'a', 'img', 'blockquote'], + ALLOWED_ATTR: ['class', 'href', 'target', 'rel', 'src', 'alt', 'title', 'style', 'data-lenis-prevent', 'data-channel-id', 'data-user-id', 'data-role-id'], + ALLOW_DATA_ATTR: true, + }); } catch (err) { try { console.error('parseDiscordMarkdown failed', err); } catch (e) { /* ignore logging errors */ } // Fallback: return a safely-escaped version of the input to avoid crashing the UI diff --git a/src/components/Footer.astro b/src/components/Footer.astro index 27f8581..b9a63f2 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -23,27 +23,49 @@ const envBadge = ENVIRONMENT === 'Dev' ? 'DEV' : null; {envBadge && } {!envBadge && } - {versionDisplay}:{buildNumber} + {versionDisplay}:{buildNumber} +