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}
+