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
This commit is contained in:
@@ -30,6 +30,16 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function setLenisEnabled(enabled) {
|
||||||
|
const lenis = window.__lenis;
|
||||||
|
if (!lenis) return;
|
||||||
|
if (enabled) {
|
||||||
|
lenis.start();
|
||||||
|
} else {
|
||||||
|
lenis.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initMobileMenu() {
|
function initMobileMenu() {
|
||||||
const menuBtn = document.getElementById("mobile-menu-btn");
|
const menuBtn = document.getElementById("mobile-menu-btn");
|
||||||
const mobileMenu = document.getElementById("mobile-menu");
|
const mobileMenu = document.getElementById("mobile-menu");
|
||||||
@@ -51,10 +61,12 @@
|
|||||||
mobileMenu.classList.remove("open");
|
mobileMenu.classList.remove("open");
|
||||||
menuIcon.style.display = "block";
|
menuIcon.style.display = "block";
|
||||||
closeIcon.style.display = "none";
|
closeIcon.style.display = "none";
|
||||||
|
setLenisEnabled(true);
|
||||||
} else {
|
} else {
|
||||||
mobileMenu.classList.add("open");
|
mobileMenu.classList.add("open");
|
||||||
menuIcon.style.display = "none";
|
menuIcon.style.display = "none";
|
||||||
closeIcon.style.display = "block";
|
closeIcon.style.display = "block";
|
||||||
|
setLenisEnabled(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,6 +76,7 @@
|
|||||||
mobileMenu.classList.remove("open");
|
mobileMenu.classList.remove("open");
|
||||||
menuIcon.style.display = "block";
|
menuIcon.style.display = "block";
|
||||||
closeIcon.style.display = "none";
|
closeIcon.style.display = "none";
|
||||||
|
setLenisEnabled(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,6 +86,7 @@
|
|||||||
mobileMenu.classList.remove("open");
|
mobileMenu.classList.remove("open");
|
||||||
menuIcon.style.display = "block";
|
menuIcon.style.display = "block";
|
||||||
closeIcon.style.display = "none";
|
closeIcon.style.display = "none";
|
||||||
|
setLenisEnabled(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -81,7 +95,93 @@
|
|||||||
document.addEventListener("click", closeHandler);
|
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") {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener("DOMContentLoaded", ready, { once: true });
|
document.addEventListener("DOMContentLoaded", ready, { once: true });
|
||||||
@@ -89,5 +189,10 @@
|
|||||||
ready();
|
ready();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("astro:page-load", initMobileMenu);
|
document.addEventListener("astro:page-load", () => {
|
||||||
|
initMobileMenu();
|
||||||
|
initDropdownExternalLinks();
|
||||||
|
initDesktopDropdownToggle();
|
||||||
|
initMobileSubmenus();
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -11,3 +11,6 @@ function raf(time) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
requestAnimationFrame(raf);
|
requestAnimationFrame(raf);
|
||||||
|
|
||||||
|
// Expose lenis instance globally for nav controls
|
||||||
|
(window as any).__lenis = lenis;
|
||||||
|
|||||||
@@ -29,8 +29,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mobile-menu-dropdown.open {
|
.mobile-menu-dropdown.open {
|
||||||
max-height: none;
|
max-height: calc(100vh - 4rem);
|
||||||
overflow: visible;
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
padding-bottom: 0.75rem;
|
padding-bottom: 0.75rem;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -83,6 +84,70 @@ nav {
|
|||||||
flex-shrink: 0;
|
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 {
|
.desktop-nav-list a {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
padding: 0.375rem 0.625rem;
|
padding: 0.375rem 0.625rem;
|
||||||
@@ -170,3 +235,26 @@ nav {
|
|||||||
border-color: rgba(248, 113, 113, 0.6);
|
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);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,38 @@
|
|||||||
box-sizing: border-box;
|
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 {
|
:root {
|
||||||
--lrc-text-color: #666; /* muted text for inactive lines */
|
--lrc-text-color: #666; /* muted text for inactive lines */
|
||||||
--lrc-bg-color: rgba(0, 0, 0, 0.05);
|
--lrc-bg-color: rgba(0, 0, 0, 0.05);
|
||||||
@@ -42,6 +74,7 @@ body {
|
|||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -49,6 +82,11 @@ body {
|
|||||||
box-shadow: 1px 1px 5px 0 rgba(0, 0, 0, 0.3);
|
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 section */
|
||||||
.album-cover {
|
.album-cover {
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
@@ -213,31 +251,172 @@ body {
|
|||||||
margin-bottom: 50px !important;
|
margin-bottom: 50px !important;
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
padding: 1rem !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 {
|
.album-cover {
|
||||||
flex: none !important;
|
flex: 0 0 auto !important;
|
||||||
|
aspect-ratio: unset !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
margin-bottom: 1rem !important;
|
margin: 0 auto !important;
|
||||||
min-width: 0 !important;
|
min-width: 0 !important;
|
||||||
|
padding: 0 1rem !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.album-cover > img {
|
.album-cover > img {
|
||||||
aspect-ratio: unset !important;
|
aspect-ratio: 1 / 1 !important;
|
||||||
height: auto !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 {
|
.music-player {
|
||||||
flex: none !important;
|
flex: 0 0 auto !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
padding: 0 !important;
|
padding: 0 1rem !important;
|
||||||
min-width: 0 !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 {
|
.progress-bar-container {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useLayoutEffect, useMemo, useCallback, memo
|
|||||||
import type { AnimationItem } from 'lottie-web';
|
import type { AnimationItem } from 'lottie-web';
|
||||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||||
import { authFetch } from '@/utils/authFetch';
|
import { authFetch } from '@/utils/authFetch';
|
||||||
|
import DOMPurify from 'isomorphic-dompurify';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Type Definitions
|
// Type Definitions
|
||||||
@@ -981,7 +982,12 @@ function parseDiscordMarkdown(text: string | null | undefined, options: ParseOpt
|
|||||||
// Must be done after all markdown processing
|
// Must be done after all markdown processing
|
||||||
parsed = parsed.replace(/\\([_*~`|\\])/g, '$1');
|
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) {
|
} catch (err) {
|
||||||
try { console.error('parseDiscordMarkdown failed', err); } catch (e) { /* ignore logging errors */ }
|
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
|
// Fallback: return a safely-escaped version of the input to avoid crashing the UI
|
||||||
|
|||||||
@@ -23,27 +23,49 @@ const envBadge = ENVIRONMENT === 'Dev' ? 'DEV' : null;
|
|||||||
<span class="version-pill">
|
<span class="version-pill">
|
||||||
{envBadge && <span class="env-dot" title="Development build"></span>}
|
{envBadge && <span class="env-dot" title="Development build"></span>}
|
||||||
{!envBadge && <span class="version-dot"></span>}
|
{!envBadge && <span class="version-dot"></span>}
|
||||||
{versionDisplay}:{buildNumber}
|
<span class="version-text">{versionDisplay}:{buildNumber}</span>
|
||||||
|
<span class="build-time-text" aria-hidden="true"></span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function updateBuildTooltip() {
|
function initBuildTooltip() {
|
||||||
const el = document.querySelector('.footer-version[data-build-time]');
|
const el = document.querySelector('.footer-version[data-build-time]');
|
||||||
if (el) {
|
if (!el) return;
|
||||||
const iso = el.getAttribute('data-build-time');
|
|
||||||
if (iso) {
|
const iso = el.getAttribute('data-build-time');
|
||||||
const local = new Date(iso).toLocaleString(undefined, {
|
if (!iso) return;
|
||||||
dateStyle: 'medium',
|
|
||||||
timeStyle: 'short',
|
const local = new Date(iso).toLocaleString(undefined, {
|
||||||
});
|
dateStyle: 'medium',
|
||||||
el.setAttribute('title', `Built: ${local}`);
|
timeStyle: 'short',
|
||||||
}
|
});
|
||||||
|
el.setAttribute('title', `Built: ${local}`);
|
||||||
|
|
||||||
|
// Set build time text for mobile tap display
|
||||||
|
const buildTimeText = el.querySelector('.build-time-text');
|
||||||
|
if (buildTimeText) {
|
||||||
|
buildTimeText.textContent = local;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tap on mobile to toggle build time display
|
||||||
|
const pill = el.querySelector('.version-pill') as HTMLElement | null;
|
||||||
|
if (pill && !pill.dataset.initialized) {
|
||||||
|
pill.dataset.initialized = 'true';
|
||||||
|
pill.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
pill.classList.toggle('show-build-time');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateBuildTooltip();
|
|
||||||
document.addEventListener('astro:page-load', updateBuildTooltip);
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initBuildTooltip);
|
||||||
|
} else {
|
||||||
|
initBuildTooltip();
|
||||||
|
}
|
||||||
|
document.addEventListener('astro:page-load', initBuildTooltip);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style is:global>
|
<style is:global>
|
||||||
@@ -73,6 +95,24 @@ const envBadge = ENVIRONMENT === 'Dev' ? 'DEV' : null;
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06), inset 0 1px 0 rgba(255,255,255,0.8);
|
box-shadow: 0 1px 3px rgba(0,0,0,0.06), inset 0 1px 0 rgba(255,255,255,0.8);
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.build-time-text {
|
||||||
|
display: none;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
padding-left: 0.4rem;
|
||||||
|
border-left: 1px solid rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-pill.show-build-time .build-time-text {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .build-time-text {
|
||||||
|
border-left-color: rgba(255, 255, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-pill:hover {
|
.version-pill:hover {
|
||||||
|
|||||||
@@ -40,6 +40,39 @@ const STATIONS = {
|
|||||||
pop: { label: "Pop" },
|
pop: { label: "Pop" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Marquee component for long text that scrolls on mobile
|
||||||
|
function MarqueeText({ text, className = "" }: { text: string; className?: string }) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const textRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const [shouldScroll, setShouldScroll] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkOverflow = () => {
|
||||||
|
if (containerRef.current && textRef.current) {
|
||||||
|
const containerWidth = containerRef.current.offsetWidth;
|
||||||
|
const textWidth = textRef.current.scrollWidth;
|
||||||
|
setShouldScroll(textWidth > containerWidth);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkOverflow();
|
||||||
|
window.addEventListener('resize', checkOverflow);
|
||||||
|
return () => window.removeEventListener('resize', checkOverflow);
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={`marquee-wrapper ${className}`}>
|
||||||
|
<span
|
||||||
|
ref={textRef}
|
||||||
|
className={`marquee-content ${shouldScroll ? 'scrolling' : ''}`}
|
||||||
|
data-text={shouldScroll ? text : ''}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function Player({ user }: PlayerProps) {
|
export default function Player({ user }: PlayerProps) {
|
||||||
// Global CSS now contains the paginator / dialog datatable dark rules.
|
// Global CSS now contains the paginator / dialog datatable dark rules.
|
||||||
@@ -119,6 +152,7 @@ export default function Player({ user }: PlayerProps) {
|
|||||||
const [trackArtist, setTrackArtist] = useState("");
|
const [trackArtist, setTrackArtist] = useState("");
|
||||||
const [trackGenre, setTrackGenre] = useState("");
|
const [trackGenre, setTrackGenre] = useState("");
|
||||||
const [trackAlbum, setTrackAlbum] = useState("");
|
const [trackAlbum, setTrackAlbum] = useState("");
|
||||||
|
const [nextTrack, setNextTrack] = useState<{ artist: string; song: string } | null>(null);
|
||||||
const [coverArt, setCoverArt] = useState("/images/radio_art_default.jpg");
|
const [coverArt, setCoverArt] = useState("/images/radio_art_default.jpg");
|
||||||
const [lyrics, setLyrics] = useState<LyricLine[]>([]);
|
const [lyrics, setLyrics] = useState<LyricLine[]>([]);
|
||||||
const [currentLyricIndex, setCurrentLyricIndex] = useState(0);
|
const [currentLyricIndex, setCurrentLyricIndex] = useState(0);
|
||||||
@@ -142,6 +176,10 @@ export default function Player({ user }: PlayerProps) {
|
|||||||
const [queueTotalRecords, setQueueTotalRecords] = useState(0);
|
const [queueTotalRecords, setQueueTotalRecords] = useState(0);
|
||||||
const [queuePage, setQueuePage] = useState(0);
|
const [queuePage, setQueuePage] = useState(0);
|
||||||
const [queueRows, setQueueRows] = useState(20);
|
const [queueRows, setQueueRows] = useState(20);
|
||||||
|
|
||||||
|
// Refs to avoid stale closure issues in callbacks
|
||||||
|
const queuePageRef = useRef(queuePage);
|
||||||
|
const queueRowsRef = useRef(queueRows);
|
||||||
|
|
||||||
// DJ controls state
|
// DJ controls state
|
||||||
const [requestInput, setRequestInput] = useState("");
|
const [requestInput, setRequestInput] = useState("");
|
||||||
@@ -162,6 +200,13 @@ export default function Player({ user }: PlayerProps) {
|
|||||||
const wsConnectionCheckTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
const wsConnectionCheckTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const wsLastMessageTime = useRef<number>(Date.now());
|
const wsLastMessageTime = useRef<number>(Date.now());
|
||||||
const [isStreamReady, setIsStreamReady] = useState(false);
|
const [isStreamReady, setIsStreamReady] = useState(false);
|
||||||
|
|
||||||
|
// Quality selection state
|
||||||
|
const [qualityLevels, setQualityLevels] = useState<{index: number, bitrate: number, name: string, isLossless?: boolean}[]>([]);
|
||||||
|
const [selectedQuality, setSelectedQuality] = useState<number>(-1); // -1 = auto
|
||||||
|
const [currentBitrate, setCurrentBitrate] = useState<number | null>(null); // Stream's declared bitrate
|
||||||
|
const [liveDownloadRate, setLiveDownloadRate] = useState<number | null>(null); // Actual download speed
|
||||||
|
const [actualPlayingLevel, setActualPlayingLevel] = useState<number>(-1); // Actual HLS.js level being played
|
||||||
|
|
||||||
const formatTime = (seconds: number): string => {
|
const formatTime = (seconds: number): string => {
|
||||||
if (!seconds || isNaN(seconds) || seconds < 0) return "00:00";
|
if (!seconds || isNaN(seconds) || seconds < 0) return "00:00";
|
||||||
@@ -170,6 +215,24 @@ export default function Player({ user }: PlayerProps) {
|
|||||||
return `${mins}:${secs}`;
|
return `${mins}:${secs}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Change stream quality
|
||||||
|
const handleQualityChange = (levelIndex: number) => {
|
||||||
|
setSelectedQuality(levelIndex);
|
||||||
|
localStorage.setItem('radioQuality', levelIndex.toString());
|
||||||
|
if (hlsInstance.current) {
|
||||||
|
hlsInstance.current.currentLevel = levelIndex; // -1 = auto
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format live bitrate for display (shows actual download rate)
|
||||||
|
const formatBitrate = (rawBitrate: number): string => {
|
||||||
|
const kbps = rawBitrate / 1000;
|
||||||
|
if (kbps >= 1000) {
|
||||||
|
return `${(kbps / 1000).toFixed(1)} Mbps`;
|
||||||
|
}
|
||||||
|
return `${Math.round(kbps)} kbps`;
|
||||||
|
};
|
||||||
|
|
||||||
// Set page title based on current track and station
|
// Set page title based on current track and station
|
||||||
const setPageTitle = (artist, song) => {
|
const setPageTitle = (artist, song) => {
|
||||||
document.title = `${metaData.title} - Radio - ${artist} - ${song} [${activeStationRef.current}]`;
|
document.title = `${metaData.title} - Radio - ${artist} - ${song} [${activeStationRef.current}]`;
|
||||||
@@ -239,12 +302,104 @@ export default function Player({ user }: PlayerProps) {
|
|||||||
hlsInstance.current = hls;
|
hlsInstance.current = hls;
|
||||||
hls.attachMedia(audio);
|
hls.attachMedia(audio);
|
||||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => hls.loadSource(streamUrl));
|
hls.on(Hls.Events.MEDIA_ATTACHED, () => hls.loadSource(streamUrl));
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
hls.on(Hls.Events.MANIFEST_PARSED, (_event, data) => {
|
||||||
|
// Capture available quality levels (use friendly bitrate names)
|
||||||
|
const standards = [64, 96, 128, 160, 192, 256, 320];
|
||||||
|
const levels = data.levels.map((level, index) => {
|
||||||
|
const kbps = (level.bitrate || 0) / 1000;
|
||||||
|
const codec = level.audioCodec || level.codecs || '';
|
||||||
|
|
||||||
|
// Check if this is a lossless codec
|
||||||
|
const isLossless = codec.toLowerCase().includes('flac') ||
|
||||||
|
codec.toLowerCase().includes('alac') ||
|
||||||
|
kbps > 500; // Likely lossless if >500kbps
|
||||||
|
|
||||||
|
let name: string;
|
||||||
|
if (isLossless) {
|
||||||
|
name = 'Lossless';
|
||||||
|
} else if (level.bitrate) {
|
||||||
|
const friendly = standards.reduce((prev, curr) =>
|
||||||
|
Math.abs(curr - kbps) < Math.abs(prev - kbps) ? curr : prev
|
||||||
|
);
|
||||||
|
name = `${friendly} kbps`;
|
||||||
|
} else {
|
||||||
|
name = `Level ${index + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
bitrate: level.bitrate || 0,
|
||||||
|
name,
|
||||||
|
isLossless,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort: lossless first, then by bitrate descending
|
||||||
|
levels.sort((a, b) => {
|
||||||
|
if (a.isLossless && !b.isLossless) return -1;
|
||||||
|
if (!a.isLossless && b.isLossless) return 1;
|
||||||
|
return b.bitrate - a.bitrate;
|
||||||
|
});
|
||||||
|
|
||||||
|
setQualityLevels(levels);
|
||||||
|
console.log('HLS quality levels:', levels, data.levels); // Debug log
|
||||||
|
|
||||||
|
// Apply saved quality preference, or default to Lossless if network supports it
|
||||||
|
const savedQuality = localStorage.getItem('radioQuality');
|
||||||
|
if (savedQuality !== null) {
|
||||||
|
const qualityIndex = parseInt(savedQuality, 10);
|
||||||
|
hls.currentLevel = qualityIndex;
|
||||||
|
setSelectedQuality(qualityIndex);
|
||||||
|
} else {
|
||||||
|
// Check if network can support lossless (~2 Mbps needed)
|
||||||
|
const connection = (navigator as any).connection;
|
||||||
|
const downlink = connection?.downlink; // Mbps
|
||||||
|
const effectiveType = connection?.effectiveType; // '4g', '3g', etc.
|
||||||
|
|
||||||
|
// Default to Lossless only if:
|
||||||
|
// - downlink >= 2 Mbps, OR
|
||||||
|
// - effectiveType is '4g' and no downlink info, OR
|
||||||
|
// - no connection info available (assume good connection)
|
||||||
|
const canSupportLossless = !connection ||
|
||||||
|
(downlink && downlink >= 2) ||
|
||||||
|
(!downlink && effectiveType === '4g');
|
||||||
|
|
||||||
|
const losslessLevel = levels.find(l => l.isLossless);
|
||||||
|
if (losslessLevel && canSupportLossless) {
|
||||||
|
hls.currentLevel = losslessLevel.index;
|
||||||
|
setSelectedQuality(losslessLevel.index);
|
||||||
|
} else {
|
||||||
|
setSelectedQuality(-1); // Auto - let HLS.js pick based on bandwidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setIsStreamReady(true);
|
setIsStreamReady(true);
|
||||||
audio.play().then(() => setIsPlaying(true)).catch(() => {
|
audio.play().then(() => setIsPlaying(true)).catch(() => {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Track level switches - update current bitrate and actual playing level
|
||||||
|
hls.on(Hls.Events.LEVEL_SWITCHED, (_event, data) => {
|
||||||
|
const level = hls.levels[data.level];
|
||||||
|
if (level?.bitrate) {
|
||||||
|
setCurrentBitrate(level.bitrate);
|
||||||
|
}
|
||||||
|
setActualPlayingLevel(data.level);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track actual live download bandwidth from fragment downloads
|
||||||
|
hls.on(Hls.Events.FRAG_LOADED, (_event, data) => {
|
||||||
|
const stats = data.frag.stats;
|
||||||
|
if (stats && stats.total && stats.loading) {
|
||||||
|
const downloadTime = stats.loading.end - stats.loading.start;
|
||||||
|
if (downloadTime > 0) {
|
||||||
|
const liveBps = (stats.total * 8 * 1000) / downloadTime;
|
||||||
|
setLiveDownloadRate(liveBps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let mediaRecoveryAttempts = 0;
|
let mediaRecoveryAttempts = 0;
|
||||||
const MAX_RECOVERY_ATTEMPTS = 3;
|
const MAX_RECOVERY_ATTEMPTS = 3;
|
||||||
|
|
||||||
@@ -392,6 +547,21 @@ export default function Player({ user }: PlayerProps) {
|
|||||||
|
|
||||||
// Update page title to reflect the new station
|
// Update page title to reflect the new station
|
||||||
document.title = `${metaData.title} - Radio [${activeStation}]`;
|
document.title = `${metaData.title} - Radio [${activeStation}]`;
|
||||||
|
|
||||||
|
// Cleanup on unmount or station change
|
||||||
|
return () => {
|
||||||
|
// Destroy HLS instance to stop network requests
|
||||||
|
if (hlsInstance.current) {
|
||||||
|
hlsInstance.current.destroy();
|
||||||
|
hlsInstance.current = null;
|
||||||
|
}
|
||||||
|
// Stop and clear audio element
|
||||||
|
if (audioElement.current) {
|
||||||
|
audioElement.current.pause();
|
||||||
|
audioElement.current.removeAttribute('src');
|
||||||
|
audioElement.current.load();
|
||||||
|
}
|
||||||
|
};
|
||||||
}, [activeStation]);
|
}, [activeStation]);
|
||||||
|
|
||||||
const parseLrcString = useCallback((lrcString: string | undefined | null): LyricLine[] => {
|
const parseLrcString = useCallback((lrcString: string | undefined | null): LyricLine[] => {
|
||||||
@@ -443,8 +613,9 @@ export default function Player({ user }: PlayerProps) {
|
|||||||
setLyrics([]);
|
setLyrics([]);
|
||||||
setCurrentLyricIndex(0);
|
setCurrentLyricIndex(0);
|
||||||
|
|
||||||
// Refresh queue when track changes
|
// Refresh queue and next track when track changes
|
||||||
fetchQueue();
|
fetchQueue();
|
||||||
|
fetchNextTrack();
|
||||||
} else {
|
} else {
|
||||||
// Same track - update duration and elapsed time
|
// Same track - update duration and elapsed time
|
||||||
setTrackDuration(trackData.duration || 0);
|
setTrackDuration(trackData.duration || 0);
|
||||||
@@ -804,8 +975,9 @@ export default function Player({ user }: PlayerProps) {
|
|||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!data.err) {
|
if (!data.err) {
|
||||||
toast.success("OK!");
|
toast.success("Removed!");
|
||||||
fetchQueue();
|
fetchQueue();
|
||||||
|
fetchNextTrack();
|
||||||
} else {
|
} else {
|
||||||
toast.error("Remove from queue failed.");
|
toast.error("Remove from queue failed.");
|
||||||
}
|
}
|
||||||
@@ -815,9 +987,122 @@ export default function Player({ user }: PlayerProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Reorder a queue item to a new position
|
||||||
|
const handleQueueReorder = async (uuid: string, newPosition: number) => {
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${API_URL}/radio/queue_reorder`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
station: activeStation,
|
||||||
|
uuid,
|
||||||
|
newPosition,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.err) {
|
||||||
|
fetchQueue();
|
||||||
|
fetchNextTrack();
|
||||||
|
} else {
|
||||||
|
toast.error("Reorder failed.");
|
||||||
|
fetchQueue(); // Refresh to get correct state
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error reordering queue:", error);
|
||||||
|
toast.error("Reorder failed.");
|
||||||
|
fetchQueue();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Move queue item up one position
|
||||||
|
const handleMoveUp = (uuid: string, currentPos: number) => {
|
||||||
|
if (currentPos <= 0) return;
|
||||||
|
handleQueueReorder(uuid, currentPos - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Move queue item down one position
|
||||||
|
const handleMoveDown = (uuid: string, currentPos: number) => {
|
||||||
|
handleQueueReorder(uuid, currentPos + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Move queue item to top (position 0)
|
||||||
|
const handleMoveToTop = (uuid: string) => {
|
||||||
|
handleQueueReorder(uuid, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drag and drop state
|
||||||
|
const [draggedItem, setDraggedItem] = useState<{uuid: string, pos: number} | null>(null);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const handleDragStart = (e: React.DragEvent, uuid: string, pos: number) => {
|
||||||
|
setDraggedItem({ uuid, pos });
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', uuid); // Required for Firefox
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
setDragOverIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent, targetPos: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (draggedItem && draggedItem.pos !== targetPos) {
|
||||||
|
handleQueueReorder(draggedItem.uuid, targetPos);
|
||||||
|
}
|
||||||
|
setDraggedItem(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDraggedItem(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
const [queueSearch, setQueueSearch] = useState("");
|
const [queueSearch, setQueueSearch] = useState("");
|
||||||
const fetchQueue = async (page = queuePage, rows = queueRows, search = queueSearch) => {
|
const queueSearchRef = useRef(queueSearch);
|
||||||
const start = page * rows;
|
|
||||||
|
// Keep refs in sync with state
|
||||||
|
useEffect(() => { queuePageRef.current = queuePage; }, [queuePage]);
|
||||||
|
useEffect(() => { queueRowsRef.current = queueRows; }, [queueRows]);
|
||||||
|
useEffect(() => { queueSearchRef.current = queueSearch; }, [queueSearch]);
|
||||||
|
|
||||||
|
// Fetch just the next track for display (lightweight call)
|
||||||
|
const fetchNextTrack = async () => {
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${API_URL}/radio/get_queue`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
station: activeStationRef.current,
|
||||||
|
start: 0,
|
||||||
|
length: 1,
|
||||||
|
search: "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.items && data.items.length > 0) {
|
||||||
|
setNextTrack({ artist: data.items[0].artist, song: data.items[0].song });
|
||||||
|
} else {
|
||||||
|
setNextTrack(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching next track:", error);
|
||||||
|
setNextTrack(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchQueue = async (page?: number, rows?: number, search?: string) => {
|
||||||
|
// Use refs for default values to avoid stale closures
|
||||||
|
const actualPage = page ?? queuePageRef.current;
|
||||||
|
const actualRows = rows ?? queueRowsRef.current;
|
||||||
|
const actualSearch = search ?? queueSearchRef.current;
|
||||||
|
const start = actualPage * actualRows;
|
||||||
// console.log("Fetching queue for station (ref):", activeStationRef.current);
|
// console.log("Fetching queue for station (ref):", activeStationRef.current);
|
||||||
try {
|
try {
|
||||||
const response = await authFetch(`${API_URL}/radio/get_queue`, {
|
const response = await authFetch(`${API_URL}/radio/get_queue`, {
|
||||||
@@ -826,14 +1111,14 @@ export default function Player({ user }: PlayerProps) {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
station: activeStationRef.current, // Use ref to ensure latest value
|
station: activeStationRef.current, // Use ref to ensure latest value
|
||||||
start,
|
start,
|
||||||
length: rows,
|
length: actualRows,
|
||||||
search,
|
search: actualSearch,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setQueueData(data.items || []);
|
setQueueData(data.items || []);
|
||||||
setQueueTotalRecords(
|
setQueueTotalRecords(
|
||||||
typeof search === 'string' && search.length > 0
|
typeof actualSearch === 'string' && actualSearch.length > 0
|
||||||
? data.recordsFiltered ?? data.recordsTotal ?? 0
|
? data.recordsFiltered ?? data.recordsTotal ?? 0
|
||||||
: data.recordsTotal ?? 0
|
: data.recordsTotal ?? 0
|
||||||
);
|
);
|
||||||
@@ -854,6 +1139,15 @@ export default function Player({ user }: PlayerProps) {
|
|||||||
if (isQueueVisible) {
|
if (isQueueVisible) {
|
||||||
fetchQueue(queuePage, queueRows, queueSearch);
|
fetchQueue(queuePage, queueRows, queueSearch);
|
||||||
}
|
}
|
||||||
|
// Always fetch next track when station changes
|
||||||
|
fetchNextTrack();
|
||||||
|
}, [activeStation]);
|
||||||
|
|
||||||
|
// Poll next track every 10 seconds (queue can change at any time)
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNextTrack();
|
||||||
|
const interval = setInterval(fetchNextTrack, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
}, [activeStation]);
|
}, [activeStation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -918,6 +1212,10 @@ export default function Player({ user }: PlayerProps) {
|
|||||||
{/* {user && <span className="text-lg font-semibold">Hello, {user.user}</span>} */}
|
{/* {user && <span className="text-lg font-semibold">Hello, {user.user}</span>} */}
|
||||||
|
|
||||||
<div className="music-container mt-8">
|
<div className="music-container mt-8">
|
||||||
|
{/* Header - always first */}
|
||||||
|
<h1 className="music-player__header text-2xl font-bold" title={trackGenre ? `Genre: ${trackGenre}` : undefined}>serious.FM</h1>
|
||||||
|
|
||||||
|
{/* Album cover section */}
|
||||||
<section className="album-cover">
|
<section className="album-cover">
|
||||||
<div className="music-player__album" title="Album">
|
<div className="music-player__album" title="Album">
|
||||||
{trackAlbum}
|
{trackAlbum}
|
||||||
@@ -925,11 +1223,17 @@ export default function Player({ user }: PlayerProps) {
|
|||||||
<img src={coverArt} className="cover rounded-lg shadow-md" alt="Cover Art" />
|
<img src={coverArt} className="cover rounded-lg shadow-md" alt="Cover Art" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Track info section */}
|
||||||
<section className="music-player">
|
<section className="music-player">
|
||||||
<h1 className="music-player__header text-2xl font-bold">serious.FM</h1>
|
<MarqueeText text={trackTitle} className="music-player__title text-xl font-semibold" />
|
||||||
<h1 className="music-player__title text-xl font-semibold">{trackTitle}</h1>
|
<MarqueeText text={trackArtist} className="music-player__author text-lg font-medium" />
|
||||||
<h2 className="music-player__author text-lg font-medium">{trackArtist}</h2>
|
|
||||||
{trackGenre && <h2 className="music-player__genre text-md italic">{trackGenre}</h2>}
|
{/* Next track preview */}
|
||||||
|
{nextTrack && (
|
||||||
|
<div className="next-track text-xs text-neutral-500 dark:text-neutral-400 mt-1 px-2 py-1 bg-neutral-100 dark:bg-neutral-800 rounded-full inline-block">
|
||||||
|
<span className="font-medium">Up next:</span> {nextTrack.artist} - {nextTrack.song}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="music-time flex justify-between items-center mt-4">
|
<div className="music-time flex justify-between items-center mt-4">
|
||||||
<p className="music-time__current text-sm">{formatTime(elapsedTime)}</p>
|
<p className="music-time__current text-sm">{formatTime(elapsedTime)}</p>
|
||||||
@@ -943,6 +1247,31 @@ export default function Player({ user }: PlayerProps) {
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Quality selector */}
|
||||||
|
{qualityLevels.length > 0 && (
|
||||||
|
<div className="quality-selector mt-2 flex justify-center items-center gap-3 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-neutral-500 dark:text-neutral-400">Quality:</span>
|
||||||
|
<select
|
||||||
|
value={selectedQuality}
|
||||||
|
onChange={(e) => handleQualityChange(parseInt(e.target.value, 10))}
|
||||||
|
className="px-2 py-1 rounded bg-neutral-200 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-800 dark:text-neutral-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value={-1}>Auto</option>
|
||||||
|
{qualityLevels.map((level) => (
|
||||||
|
<option key={level.index} value={level.index}>
|
||||||
|
{level.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<span className="text-green-600 dark:text-green-400 font-medium">
|
||||||
|
⬇ {qualityLevels.find(l => l.index === actualPlayingLevel)?.name || '...'}
|
||||||
|
{liveDownloadRate && <span className="text-neutral-500 dark:text-neutral-400 ml-1">@ {formatBitrate(liveDownloadRate)}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={lrcContainerRef}
|
ref={lrcContainerRef}
|
||||||
className={`lrc-text mt-4 p-4 rounded-lg bg-neutral-100 dark:bg-neutral-800 ${lyrics.length === 0 ? "empty" : ""}`}
|
className={`lrc-text mt-4 p-4 rounded-lg bg-neutral-100 dark:bg-neutral-800 ${lyrics.length === 0 ? "empty" : ""}`}
|
||||||
@@ -1073,119 +1402,189 @@ export default function Player({ user }: PlayerProps) {
|
|||||||
<audio ref={audioElement} preload="none" />
|
<audio ref={audioElement} preload="none" />
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
header={`Queue - ${activeStation}`}
|
header={
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span>Queue - {STATIONS[activeStation]?.label || activeStation}</span>
|
||||||
|
<span className="text-xs text-neutral-500 dark:text-neutral-400 font-normal">
|
||||||
|
({queueTotalRecords} tracks)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
visible={isQueueVisible}
|
visible={isQueueVisible}
|
||||||
style={{ width: "80vw", maxWidth: "1200px", height: "auto", maxHeight: "90vh" }}
|
style={{ width: "90vw", maxWidth: "1000px", height: "auto", maxHeight: "90vh" }}
|
||||||
footer={queueFooter}
|
footer={queueFooter}
|
||||||
onHide={() => setQueueVisible(false)}
|
onHide={() => setQueueVisible(false)}
|
||||||
// Use the same dark class used by other dialog styles (CSS escapes the colon)
|
|
||||||
className={theme === "dark" ? "dark:bg-neutral-900" : "light-theme"}
|
className={theme === "dark" ? "dark:bg-neutral-900" : "light-theme"}
|
||||||
dismissableMask={true}
|
dismissableMask={true}
|
||||||
|
contentStyle={{ overflow: 'auto', maxHeight: 'calc(90vh - 140px)' }}
|
||||||
>
|
>
|
||||||
<div style={{ maxHeight: "calc(90vh - 100px)", overflow: "visible" }}>
|
<div>
|
||||||
<div className="mb-2 flex justify-end">
|
{/* Search bar */}
|
||||||
|
<div className="mb-3 flex justify-between items-center gap-2 flex-wrap">
|
||||||
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{isDJ ? "Drag to reorder or use arrows" : "View upcoming tracks"}
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={queueSearch}
|
value={queueSearch}
|
||||||
onChange={e => setQueueSearch(e.target.value)}
|
onChange={e => setQueueSearch(e.target.value)}
|
||||||
placeholder="Search..."
|
placeholder="Search queue..."
|
||||||
className="px-2 py-1 rounded border border-neutral-300 dark:border-neutral-700 text-sm"
|
className="px-3 py-1.5 rounded-full border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||||
style={{ minWidth: 180 }}
|
style={{ minWidth: 200 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DataTable
|
|
||||||
value={queueData}
|
{/* Queue list */}
|
||||||
paginator
|
<div className="queue-list space-y-1">
|
||||||
alwaysShowPaginator={true}
|
{queueData.map((item: any, index: number) => (
|
||||||
rows={queueRows}
|
<div
|
||||||
first={queuePage * queueRows}
|
key={item.uuid}
|
||||||
totalRecords={queueTotalRecords}
|
draggable={isDJ}
|
||||||
onPage={(e) => {
|
onDragStart={(e) => isDJ && handleDragStart(e, item.uuid, item.pos)}
|
||||||
setQueuePage(e.page ?? 0);
|
onDragOver={(e) => isDJ && handleDragOver(e, item.pos)}
|
||||||
fetchQueue(e.page ?? 0, queueRows, queueSearch);
|
onDragLeave={handleDragLeave}
|
||||||
}}
|
onDrop={(e) => isDJ && handleDrop(e, item.pos)}
|
||||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport"
|
onDragEnd={handleDragEnd}
|
||||||
currentPageReportTemplate="Showing {first} to {last} of {totalRecords} tracks"
|
className={`queue-item flex items-center gap-2 p-2 rounded-lg transition-colors ${isDJ ? 'cursor-grab active:cursor-grabbing' : ''} ${
|
||||||
paginatorClassName="queue-paginator !bg-neutral-50 dark:!bg-neutral-800 !text-neutral-900 dark:!text-neutral-100 !border-neutral-200 dark:!border-neutral-700"
|
dragOverIndex === item.pos
|
||||||
className="p-datatable-gridlines rounded-lg shadow-md border-t border-neutral-300 dark:border-neutral-700"
|
? 'bg-blue-100 dark:bg-blue-800/50 border-2 border-dashed border-blue-400'
|
||||||
style={{ minHeight: 'auto', height: 'auto', tableLayout: 'fixed', width: '100%' }}
|
: index === 0
|
||||||
lazy
|
? 'bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800'
|
||||||
>
|
: 'bg-neutral-50 dark:bg-neutral-800/50 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
||||||
<Column
|
} ${draggedItem?.uuid === item.uuid ? 'opacity-50' : ''}`}
|
||||||
field="pos"
|
>
|
||||||
header="Position"
|
{/* Drag Handle & Position */}
|
||||||
body={(rowData) => rowData.pos + 1}
|
<div className="flex flex-col items-center gap-0.5 min-w-[50px]">
|
||||||
sortable
|
{isDJ && (
|
||||||
headerClassName="bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 font-bold font-sans"
|
<div className="text-neutral-400 mb-1" title="Drag to reorder">
|
||||||
style={{ width: '50px', textAlign: 'center' }}
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
></Column>
|
<path d="M8 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm0 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm0 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm8-12a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm0 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm0 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0z" />
|
||||||
<Column
|
</svg>
|
||||||
field="artistsong"
|
</div>
|
||||||
header="Track"
|
)}
|
||||||
sortable
|
{isDJ && item.pos > 0 && (
|
||||||
headerClassName="bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 font-bold font-sans"
|
|
||||||
style={{ width: '300px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
|
||||||
body={(rowData) => <span title={`${rowData.artist} - ${rowData.song}`} style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '300px' }}>{rowData.artist} - {rowData.song}</span>}
|
|
||||||
></Column>
|
|
||||||
<Column
|
|
||||||
field="album"
|
|
||||||
header="Album"
|
|
||||||
sortable
|
|
||||||
headerClassName="bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 font-bold font-sans"
|
|
||||||
style={{ width: '220px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
|
||||||
body={(rowData) => <span title={rowData.album} style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '220px' }}>{rowData.album}</span>}
|
|
||||||
></Column>
|
|
||||||
<Column
|
|
||||||
field="genre"
|
|
||||||
header="Genre"
|
|
||||||
sortable
|
|
||||||
headerClassName="bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 font-bold font-sans"
|
|
||||||
style={{ width: '120px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
|
||||||
body={(rowData) => <span title={rowData.genre} style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '120px' }}>{rowData.genre}</span>}
|
|
||||||
></Column>
|
|
||||||
{isDJ && (
|
|
||||||
<Column
|
|
||||||
field="actions"
|
|
||||||
header="Actions"
|
|
||||||
body={(rowData) => (
|
|
||||||
<div className="flex gap-1 flex-nowrap" style={{ minWidth: '220px', justifyContent: 'center' }}>
|
|
||||||
<button
|
<button
|
||||||
className="px-2 py-1 rounded bg-blue-400 text-neutral-900 hover:bg-blue-500 text-xs shadow-md cursor-pointer font-bold font-sans"
|
onClick={() => handleMoveUp(item.uuid, item.pos)}
|
||||||
onClick={() => handleSkip(rowData.uuid)}
|
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200 p-0.5"
|
||||||
title="Skip To"
|
title="Move up"
|
||||||
>
|
>
|
||||||
Skip To
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
<span className={`text-sm font-bold ${index === 0 ? 'text-blue-600 dark:text-blue-400' : 'text-neutral-500'}`}>
|
||||||
|
#{item.pos + 1}
|
||||||
|
</span>
|
||||||
|
{isDJ && (
|
||||||
<button
|
<button
|
||||||
className="px-2 py-1 rounded bg-yellow-400 text-neutral-900 hover:bg-yellow-500 text-xs shadow-md cursor-pointer font-bold font-sans"
|
onClick={() => handleMoveDown(item.uuid, item.pos)}
|
||||||
onClick={() => handleQueueShift(rowData.uuid, false)}
|
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200 p-0.5"
|
||||||
title="Play"
|
title="Move down"
|
||||||
>
|
>
|
||||||
Play
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Track Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-semibold text-sm text-neutral-900 dark:text-neutral-100 truncate" title={`${item.artist} - ${item.song}`}>
|
||||||
|
{item.artist} - {item.song}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-500 dark:text-neutral-400 truncate" title={item.album}>
|
||||||
|
{item.album}
|
||||||
|
{item.genre && item.genre !== 'N/A' && (
|
||||||
|
<span className="ml-2 px-1.5 py-0.5 bg-neutral-200 dark:bg-neutral-700 rounded text-[10px]">
|
||||||
|
{item.genre}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{isDJ && (
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
{item.pos > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleMoveToTop(item.uuid)}
|
||||||
|
className="px-2.5 py-1.5 rounded-md bg-purple-500 text-white hover:bg-purple-600 text-xs font-bold shadow-sm flex items-center gap-1"
|
||||||
|
title="Move to top"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 11l7-7 7 7M5 19l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
Top
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="px-2 py-1 rounded bg-green-400 text-neutral-900 hover:bg-green-500 text-xs shadow-md cursor-pointer font-bold font-sans"
|
onClick={() => handleQueueShift(item.uuid, true)}
|
||||||
onClick={() => handleQueueShift(rowData.uuid, true)}
|
className="px-2.5 py-1.5 rounded-md bg-green-500 text-white hover:bg-green-600 text-xs font-bold shadow-sm"
|
||||||
title="Play Next"
|
title="Play Next"
|
||||||
>
|
>
|
||||||
Play Next
|
Play Next
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="px-2 py-1 rounded bg-red-400 text-neutral-900 hover:bg-red-500 text-xs shadow-md cursor-pointer font-bold font-sans"
|
onClick={() => handleSkip(item.uuid)}
|
||||||
onClick={() => {
|
className="px-2.5 py-1.5 rounded-md bg-blue-500 text-white hover:bg-blue-600 text-xs font-bold shadow-sm"
|
||||||
handleRemoveFromQueue(rowData.uuid);
|
title="Skip to this track"
|
||||||
}}
|
|
||||||
title="Remove"
|
|
||||||
>
|
>
|
||||||
|
Skip To
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveFromQueue(item.uuid)}
|
||||||
|
className="px-2.5 py-1.5 rounded-md bg-red-500 text-white hover:bg-red-600 text-xs font-bold shadow-sm flex items-center gap-1"
|
||||||
|
title="Remove from queue"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
headerClassName="bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 font-bold font-sans"
|
</div>
|
||||||
style={{ width: '220px', textAlign: 'center' }}
|
))}
|
||||||
></Column>
|
</div>
|
||||||
)}
|
|
||||||
</DataTable>
|
{/* Pagination */}
|
||||||
|
{queueTotalRecords > queueRows && (
|
||||||
|
<div className="mt-4 flex justify-center items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { setQueuePage(0); fetchQueue(0, queueRows, queueSearch); }}
|
||||||
|
disabled={queuePage === 0}
|
||||||
|
className="px-2 py-1 rounded bg-neutral-200 dark:bg-neutral-700 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
First
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { const p = Math.max(0, queuePage - 1); setQueuePage(p); fetchQueue(p, queueRows, queueSearch); }}
|
||||||
|
disabled={queuePage === 0}
|
||||||
|
className="px-2 py-1 rounded bg-neutral-200 dark:bg-neutral-700 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-neutral-600 dark:text-neutral-400 px-2">
|
||||||
|
Page {queuePage + 1} of {Math.ceil(queueTotalRecords / queueRows)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => { const p = queuePage + 1; setQueuePage(p); fetchQueue(p, queueRows, queueSearch); }}
|
||||||
|
disabled={(queuePage + 1) * queueRows >= queueTotalRecords}
|
||||||
|
className="px-2 py-1 rounded bg-neutral-200 dark:bg-neutral-700 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { const p = Math.ceil(queueTotalRecords / queueRows) - 1; setQueuePage(p); fetchQueue(p, queueRows, queueSearch); }}
|
||||||
|
disabled={(queuePage + 1) * queueRows >= queueTotalRecords}
|
||||||
|
className="px-2 py-1 rounded bg-neutral-200 dark:bg-neutral-700 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
Last
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,60 +2,121 @@
|
|||||||
import { metaData, API_URL } from "../config";
|
import { metaData, API_URL } from "../config";
|
||||||
import { Icon } from "astro-icon/components";
|
import { Icon } from "astro-icon/components";
|
||||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||||
import { padlockIconSvg, userIconSvg, externalLinkIconSvg } from "@/utils/navAssets";
|
import { userIconSvg, externalLinkIconSvg } from "@/utils/navAssets";
|
||||||
import "@/assets/styles/nav.css";
|
import "@/assets/styles/nav.css";
|
||||||
|
|
||||||
const user = await requireAuthHook(Astro);
|
const user = await requireAuthHook(Astro);
|
||||||
const hostHeader = Astro.request?.headers?.get('host') || '';
|
|
||||||
const host = hostHeader.split(':')[0];
|
|
||||||
import { getSubsiteByHost } from '../utils/subsites.js';
|
|
||||||
import { getSubsiteByPath } from '../utils/subsites.js';
|
|
||||||
const isReq = getSubsiteByHost(host)?.short === 'req' || getSubsiteByPath(Astro.url.pathname)?.short === 'req';
|
|
||||||
// Nav is the standard site navigation — whitelabel logic belongs in SubNav
|
// Nav is the standard site navigation — whitelabel logic belongs in SubNav
|
||||||
const isLoggedIn = Boolean(user);
|
const isLoggedIn = Boolean(user);
|
||||||
const userDisplayName = user?.user ?? null;
|
const userDisplayName = user?.user ?? null;
|
||||||
const isAdmin = user?.roles?.includes('admin') ?? false;
|
const isAdmin = user?.roles?.includes('admin') ?? false;
|
||||||
|
|
||||||
const navItems = [
|
type NavItem = {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
icon?: string;
|
||||||
|
auth?: boolean;
|
||||||
|
adminOnly?: boolean;
|
||||||
|
guestOnly?: boolean;
|
||||||
|
onclick?: string;
|
||||||
|
children?: NavItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseNavItems: NavItem[] = [
|
||||||
{ label: "Home", href: "/" },
|
{ label: "Home", href: "/" },
|
||||||
{ label: "Radio", href: "/radio" },
|
{ label: "Radio", href: "/radio" },
|
||||||
{ label: "Memes", href: "/memes" },
|
{ label: "Memes", href: "/memes" },
|
||||||
{ label: "TRip", href: "/TRip", auth: true, icon: "pirate" },
|
|
||||||
{ label: "Discord Logs", href: "/discord-logs", auth: true },
|
|
||||||
{ label: "Lighting", href: "/lighting", auth: true, adminOnly: true },
|
|
||||||
{ label: "Git", href: "https://kode.boatson.boats", icon: "external" },
|
{ label: "Git", href: "https://kode.boatson.boats", icon: "external" },
|
||||||
{ label: "Glances", href: "https://_gl.codey.horse", auth: true, icon: "external",
|
{
|
||||||
adminOnly: true },
|
label: "TRip",
|
||||||
{ label: "PSQL", href: "https://_pg.codey.horse", auth: true, icon: "external",
|
href: "/TRip",
|
||||||
adminOnly: true },
|
auth: true,
|
||||||
{ label: "qBitTorrent", href: "https://_qb.codey.horse", auth: true, icon: "external",
|
icon: "pirate",
|
||||||
adminOnly: true },
|
children: [
|
||||||
{ label: "RQ", href: "https://_rq.codey.horse", auth: true, icon: "external",
|
{ label: "Submit Request", href: "/TRip", auth: true },
|
||||||
adminOnly: true },
|
{ label: "Manage Requests", href: "/TRip/requests", auth: true },
|
||||||
{ label: "RI", href: "https://_r0.codey.horse", auth: true, icon: "external",
|
],
|
||||||
adminOnly: true },
|
},
|
||||||
// { label: "Status", href: "https://status.boatson.boats", icon: "external" },
|
{
|
||||||
|
label: "Admin",
|
||||||
|
href: "javascript:void(0)",
|
||||||
|
auth: true,
|
||||||
|
adminOnly: true,
|
||||||
|
children: [
|
||||||
|
{ label: "Lighting", href: "/lighting", auth: true, adminOnly: true },
|
||||||
|
{ label: "Discord Logs", href: "/discord-logs", auth: true, adminOnly: true },
|
||||||
|
{ label: "Glances", href: "https://_gl.codey.horse", auth: true, icon: "external", adminOnly: true },
|
||||||
|
{ label: "PSQL", href: "https://_pg.codey.horse", auth: true, icon: "external", adminOnly: true },
|
||||||
|
{ label: "SQLite", href: "https://_sqlite.codey.horse", auth: true, icon: "external", adminOnly: true },
|
||||||
|
{ label: "qBitTorrent", href: "https://_qb.codey.horse", auth: true, icon: "external", adminOnly: true },
|
||||||
|
{ label: "RQ", href: "https://_rq.codey.horse", auth: true, icon: "external", adminOnly: true },
|
||||||
|
{ label: "RI", href: "https://_r0.codey.horse", auth: true, icon: "external", adminOnly: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ label: "Login", href: "/login", guestOnly: true },
|
{ label: "Login", href: "/login", guestOnly: true },
|
||||||
...(isLoggedIn ? [{ label: "Logout", href: "#logout", onclick: "handleLogout()" }] : []),
|
...(isLoggedIn ? [{ label: "Logout", href: "#logout", onclick: "handleLogout()" }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const visibleNavItems = navItems.filter((item) => {
|
// Fold any adminOnly root items into the Admin group automatically
|
||||||
if (item.auth && !isLoggedIn) {
|
const adminContainerIndex = baseNavItems.findIndex((item) => item.label === "Admin");
|
||||||
return false;
|
const adminContainer: NavItem = adminContainerIndex >= 0
|
||||||
}
|
? baseNavItems[adminContainerIndex]
|
||||||
|
: { label: "Admin", href: "#admin", auth: true, adminOnly: true, children: [] };
|
||||||
|
|
||||||
if (item.adminOnly && !isAdmin) {
|
const adminChildren: NavItem[] = [...(adminContainer.children ?? [])];
|
||||||
return false;
|
const groupedNavItems: NavItem[] = [];
|
||||||
}
|
|
||||||
|
|
||||||
if (item.guestOnly && isLoggedIn) {
|
baseNavItems.forEach((item, idx) => {
|
||||||
return false;
|
if (item.label === "Admin") return; // defer insertion
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
if (item.adminOnly) {
|
||||||
|
adminChildren.push(item);
|
||||||
|
} else {
|
||||||
|
groupedNavItems.push(item);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentPath = Astro.url.pathname;
|
if (adminChildren.length > 0) {
|
||||||
|
const adminItem: NavItem = { ...adminContainer, children: adminChildren };
|
||||||
|
// Insert Admin before Login/Logout (which are always last)
|
||||||
|
const loginIdx = groupedNavItems.findIndex((item) => item.label === "Login" || item.label === "Logout");
|
||||||
|
const insertAt = loginIdx >= 0 ? loginIdx : groupedNavItems.length;
|
||||||
|
groupedNavItems.splice(insertAt, 0, adminItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isItemVisible = (item: NavItem): boolean => {
|
||||||
|
if (item.auth && !isLoggedIn) return false;
|
||||||
|
if (item.adminOnly && !isAdmin) return false;
|
||||||
|
if (item.guestOnly && isLoggedIn) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibleNavItems = groupedNavItems
|
||||||
|
.filter(isItemVisible)
|
||||||
|
.map((item): NavItem => {
|
||||||
|
const visibleChildren = item.children?.filter(isItemVisible);
|
||||||
|
return visibleChildren ? { ...item, children: visibleChildren } : item;
|
||||||
|
})
|
||||||
|
.filter((item) => !item.children || item.children.length > 0);
|
||||||
|
|
||||||
|
const normalize = (url: string) => (url || '/').replace(/\/+$/, '') || '/';
|
||||||
|
const normalizedCurrent = normalize(Astro.url.pathname);
|
||||||
|
|
||||||
|
// For parent items: active if exact match OR if path starts with href (prefix matching)
|
||||||
|
const isPathActive = (href: string) => {
|
||||||
|
const normalizedHref = normalize(href);
|
||||||
|
if (!href || href.startsWith('http')) return false;
|
||||||
|
return normalizedHref === '/'
|
||||||
|
? normalizedCurrent === '/'
|
||||||
|
: normalizedCurrent === normalizedHref || normalizedCurrent.startsWith(`${normalizedHref}/`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// For dropdown children: active only on exact match (no prefix matching)
|
||||||
|
const isPathActiveExact = (href: string) => {
|
||||||
|
const normalizedHref = normalize(href);
|
||||||
|
if (!href || href.startsWith('http')) return false;
|
||||||
|
return normalizedCurrent === normalizedHref;
|
||||||
|
};
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,40 +136,69 @@ const currentPath = Astro.url.pathname;
|
|||||||
<div class="desktop-nav flex items-center">
|
<div class="desktop-nav flex items-center">
|
||||||
<ul class="desktop-nav-list">
|
<ul class="desktop-nav-list">
|
||||||
{visibleNavItems.map((item) => {
|
{visibleNavItems.map((item) => {
|
||||||
|
const hasChildren = Array.isArray(item.children) && item.children.length > 0;
|
||||||
const isExternal = item.href?.startsWith("http");
|
const isExternal = item.href?.startsWith("http");
|
||||||
const isAuthedPath = item.auth ?? false;
|
const isAuthedPath = item.auth ?? false;
|
||||||
const normalize = (url) => (url || '/').replace(/\/+$/, '') || '/';
|
const childActive = hasChildren && item.children?.some((child) => isPathActive(child.href));
|
||||||
const normalizedCurrent = normalize(currentPath);
|
const isActive = childActive || (!isExternal && isPathActive(item.href));
|
||||||
const normalizedHref = normalize(item.href);
|
|
||||||
const isActive = !isExternal && (
|
|
||||||
normalizedHref === '/'
|
|
||||||
? normalizedCurrent === '/'
|
|
||||||
: normalizedCurrent === normalizedHref || normalizedCurrent.startsWith(normalizedHref + '/')
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li>
|
<li class:list={["nav-item", hasChildren && "nav-item--has-children"]}>
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class={isActive
|
class={isActive
|
||||||
? "flex items-center gap-0.5 px-3 py-1.5 rounded-lg text-[13px] font-semibold transition-all duration-200 text-white bg-neutral-900 dark:bg-white dark:text-neutral-900 shadow-sm font-['IBM_Plex_Sans',sans-serif]"
|
? "flex items-center gap-1 px-3 py-1.5 rounded-lg text-[13px] font-semibold transition-all duration-200 text-white bg-neutral-900 dark:bg-white dark:text-neutral-900 shadow-sm font-['IBM_Plex_Sans',sans-serif]"
|
||||||
: "flex items-center gap-0.5 px-3 py-1.5 rounded-lg text-[13px] font-medium transition-all duration-200 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800/60 font-['IBM_Plex_Sans',sans-serif]"
|
: "flex items-center gap-1 px-3 py-1.5 rounded-lg text-[13px] font-medium transition-all duration-200 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800/60 font-['IBM_Plex_Sans',sans-serif]"
|
||||||
}
|
}
|
||||||
target={isExternal ? "_blank" : undefined}
|
target={isExternal ? "_blank" : undefined}
|
||||||
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
||||||
onclick={item.onclick}
|
onclick={item.onclick}
|
||||||
|
aria-haspopup={hasChildren ? "true" : undefined}
|
||||||
|
aria-expanded={hasChildren ? isActive : undefined}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
{hasChildren && <span class="nav-caret" aria-hidden="true">▼</span>}
|
||||||
{item.icon === "external" && (
|
{item.icon === "external" && (
|
||||||
<span class="inline-flex ml-0.5" aria-hidden="true" set:html={externalLinkIconSvg}></span>
|
<span class="inline-flex ml-0.5" aria-hidden="true" set:html={externalLinkIconSvg}></span>
|
||||||
)}
|
)}
|
||||||
{item.icon === "padlock" && (
|
|
||||||
<span class="inline-flex" aria-hidden="true" set:html={padlockIconSvg}></span>
|
|
||||||
)}
|
|
||||||
{item.icon === "pirate" && (
|
{item.icon === "pirate" && (
|
||||||
<span class="inline-flex ml-1" role="img" aria-label="Pirate flag">🏴☠️</span>
|
<span class="inline-flex ml-1" role="img" aria-label="Pirate flag">🏴☠️</span>
|
||||||
)}
|
)}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{hasChildren && (
|
||||||
|
<div class="nav-dropdown" role="menu">
|
||||||
|
<ul>
|
||||||
|
{item.children?.map((child) => {
|
||||||
|
const childExternal = child.href?.startsWith("http");
|
||||||
|
const childAuthedPath = child.auth ?? false;
|
||||||
|
const childIsActive = !childExternal && isPathActiveExact(child.href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={child.href}
|
||||||
|
class={childIsActive
|
||||||
|
? "flex items-center gap-1 px-3 py-2 rounded-md text-sm font-semibold transition-all duration-150 text-white bg-neutral-900 dark:text-neutral-900 dark:bg-white"
|
||||||
|
: "flex items-center gap-1 px-3 py-2 rounded-md text-sm font-medium transition-all duration-150 text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800/80"}
|
||||||
|
target={childExternal ? "_blank" : undefined}
|
||||||
|
rel={(childExternal || childAuthedPath) ? "external" : undefined}
|
||||||
|
onclick={childIsActive ? "event.preventDefault()" : undefined}
|
||||||
|
>
|
||||||
|
{child.label}
|
||||||
|
{child.icon === "external" && (
|
||||||
|
<span class="inline-flex ml-0.5" aria-hidden="true" set:html={externalLinkIconSvg}></span>
|
||||||
|
)}
|
||||||
|
{child.icon === "pirate" && (
|
||||||
|
<span class="inline-flex ml-1" role="img" aria-label="Pirate flag">🏴☠️</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -170,6 +260,7 @@ const currentPath = Astro.url.pathname;
|
|||||||
<div
|
<div
|
||||||
id="mobile-menu"
|
id="mobile-menu"
|
||||||
class="mobile-menu-dropdown xl:hidden"
|
class="mobile-menu-dropdown xl:hidden"
|
||||||
|
data-lenis-prevent
|
||||||
>
|
>
|
||||||
{isLoggedIn && userDisplayName && (
|
{isLoggedIn && userDisplayName && (
|
||||||
<div class:list={['nav-user-inline', 'nav-user-inline--mobile', isAdmin && 'nav-user-inline--admin']} title={`Logged in as ${userDisplayName}`}>
|
<div class:list={['nav-user-inline', 'nav-user-inline--mobile', isAdmin && 'nav-user-inline--admin']} title={`Logged in as ${userDisplayName}`}>
|
||||||
@@ -179,16 +270,11 @@ const currentPath = Astro.url.pathname;
|
|||||||
)}
|
)}
|
||||||
<ul class="flex flex-col gap-1 py-4">
|
<ul class="flex flex-col gap-1 py-4">
|
||||||
{visibleNavItems.map((item) => {
|
{visibleNavItems.map((item) => {
|
||||||
|
const hasChildren = Array.isArray(item.children) && item.children.length > 0;
|
||||||
const isExternal = item.href?.startsWith("http");
|
const isExternal = item.href?.startsWith("http");
|
||||||
const isAuthedPath = item.auth ?? false;
|
const isAuthedPath = item.auth ?? false;
|
||||||
const normalize = (url) => (url || '/').replace(/\/+$/, '') || '/';
|
const childActive = hasChildren && item.children?.some((child) => isPathActive(child.href));
|
||||||
const normalizedCurrent = normalize(currentPath);
|
const isActive = childActive || (!isExternal && isPathActive(item.href));
|
||||||
const normalizedHref = normalize(item.href);
|
|
||||||
const isActive = !isExternal && (
|
|
||||||
normalizedHref === '/'
|
|
||||||
? normalizedCurrent === '/'
|
|
||||||
: normalizedCurrent === normalizedHref || normalizedCurrent.startsWith(normalizedHref + '/')
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li>
|
<li>
|
||||||
@@ -204,16 +290,46 @@ const currentPath = Astro.url.pathname;
|
|||||||
onclick={item.onclick}
|
onclick={item.onclick}
|
||||||
>
|
>
|
||||||
<span style="color:inherit;">{item.label}</span>
|
<span style="color:inherit;">{item.label}</span>
|
||||||
|
{hasChildren && <span class="mobile-caret" aria-hidden="true">›</span>}
|
||||||
{item.icon === "external" && (
|
{item.icon === "external" && (
|
||||||
<span class="inline-flex ml-0.5" aria-hidden="true" set:html={externalLinkIconSvg}></span>
|
<span class="inline-flex ml-0.5" aria-hidden="true" set:html={externalLinkIconSvg}></span>
|
||||||
)}
|
)}
|
||||||
{item.icon === "padlock" && (
|
|
||||||
<span class="inline-flex" aria-hidden="true" set:html={padlockIconSvg}></span>
|
|
||||||
)}
|
|
||||||
{item.icon === "pirate" && (
|
{item.icon === "pirate" && (
|
||||||
<span class="inline-flex ml-1" role="img" aria-label="Pirate flag">🏴☠️</span>
|
<span class="inline-flex ml-1" role="img" aria-label="Pirate flag">🏴☠️</span>
|
||||||
)}
|
)}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{hasChildren && (
|
||||||
|
<ul class="mobile-subnav" role="menu">
|
||||||
|
{item.children?.map((child) => {
|
||||||
|
const childExternal = child.href?.startsWith("http");
|
||||||
|
const childAuthedPath = child.auth ?? false;
|
||||||
|
const childIsActive = !childExternal && isPathActiveExact(child.href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={child.href}
|
||||||
|
class={childIsActive
|
||||||
|
? "flex items-center gap-1 px-6 py-2 rounded-lg text-base font-semibold transition-all duration-200 text-white bg-neutral-900 dark:bg-white dark:text-neutral-900"
|
||||||
|
: "flex items-center gap-1 px-6 py-2 rounded-lg text-base font-medium transition-all duration-200 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"}
|
||||||
|
target={childExternal ? "_blank" : undefined}
|
||||||
|
rel={(childExternal || childAuthedPath) ? "external" : undefined}
|
||||||
|
onclick={childIsActive ? "event.preventDefault()" : undefined}
|
||||||
|
>
|
||||||
|
<span style="color:inherit;">{child.label}</span>
|
||||||
|
{child.icon === "external" && (
|
||||||
|
<span class="inline-flex ml-0.5" aria-hidden="true" set:html={externalLinkIconSvg}></span>
|
||||||
|
)}
|
||||||
|
{child.icon === "pirate" && (
|
||||||
|
<span class="inline-flex ml-1" role="img" aria-label="Pirate flag">🏴☠️</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineMiddleware } from 'astro:middleware';
|
import { defineMiddleware } from 'astro:middleware';
|
||||||
import { SUBSITES, PROTECTED_ROUTES, PUBLIC_ROUTES, type ProtectedRoute } from './config.ts';
|
import { SUBSITES, PROTECTED_ROUTES, PUBLIC_ROUTES, API_URL, type ProtectedRoute } from './config.ts';
|
||||||
import { getSubsiteByHost, getSubsiteFromSignal, type SubsiteInfo } from './utils/subsites.ts';
|
import { getSubsiteByHost, getSubsiteFromSignal, type SubsiteInfo } from './utils/subsites.ts';
|
||||||
|
|
||||||
declare module 'astro' {
|
declare module 'astro' {
|
||||||
@@ -64,7 +64,6 @@ if (typeof globalThis.Headers !== 'undefined' && typeof (globalThis.Headers.prot
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_URL: string = "https://api.codey.horse";
|
|
||||||
const AUTH_TIMEOUT_MS: number = 3000; // 3 second timeout for auth requests
|
const AUTH_TIMEOUT_MS: number = 3000; // 3 second timeout for auth requests
|
||||||
|
|
||||||
// Deduplication for concurrent refresh requests (prevents race condition where
|
// Deduplication for concurrent refresh requests (prevents race condition where
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ import type { APIContext } from 'astro';
|
|||||||
|
|
||||||
// Secret for signing image IDs - prevents enumeration attacks
|
// Secret for signing image IDs - prevents enumeration attacks
|
||||||
const IMAGE_CACHE_SECRET = import.meta.env.IMAGE_CACHE_SECRET;
|
const IMAGE_CACHE_SECRET = import.meta.env.IMAGE_CACHE_SECRET;
|
||||||
if (!IMAGE_CACHE_SECRET) {
|
if (!IMAGE_CACHE_SECRET && import.meta.env.PROD) {
|
||||||
console.error('CRITICAL: IMAGE_CACHE_SECRET environment variable is not set!');
|
throw new Error('CRITICAL: IMAGE_CACHE_SECRET environment variable is not set in production!');
|
||||||
|
} else if (!IMAGE_CACHE_SECRET) {
|
||||||
|
console.error('WARNING: IMAGE_CACHE_SECRET environment variable is not set!');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,7 +32,21 @@ export function signImageId(imageId: string | number): string {
|
|||||||
}
|
}
|
||||||
const hmac = crypto.createHmac('sha256', IMAGE_CACHE_SECRET);
|
const hmac = crypto.createHmac('sha256', IMAGE_CACHE_SECRET);
|
||||||
hmac.update(String(imageId));
|
hmac.update(String(imageId));
|
||||||
return hmac.digest('hex').substring(0, 16); // Short signature is sufficient
|
return hmac.digest('hex').substring(0, 32); // 128-bit signature
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate HMAC signature for a source URL
|
||||||
|
* @param sourceUrl - The URL to sign
|
||||||
|
* @returns The hex signature
|
||||||
|
*/
|
||||||
|
export function signSourceUrl(sourceUrl: string): string {
|
||||||
|
if (!IMAGE_CACHE_SECRET) {
|
||||||
|
throw new Error('IMAGE_CACHE_SECRET not configured');
|
||||||
|
}
|
||||||
|
const hmac = crypto.createHmac('sha256', IMAGE_CACHE_SECRET);
|
||||||
|
hmac.update(sourceUrl);
|
||||||
|
return hmac.digest('hex').substring(0, 32); // 128-bit signature
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,6 +66,23 @@ function verifyImageSignature(imageId: string | number, signature: string | null
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify HMAC signature for a source URL
|
||||||
|
* @param sourceUrl - The URL
|
||||||
|
* @param signature - The signature to verify
|
||||||
|
* @returns Whether signature is valid
|
||||||
|
*/
|
||||||
|
function verifyUrlSignature(sourceUrl: string, signature: string | null): boolean {
|
||||||
|
if (!IMAGE_CACHE_SECRET || !signature) return false;
|
||||||
|
const expected = signSourceUrl(sourceUrl);
|
||||||
|
// Timing-safe comparison
|
||||||
|
try {
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET({ request }: APIContext): Promise<Response> {
|
export async function GET({ request }: APIContext): Promise<Response> {
|
||||||
// Rate limit check - higher limit for images but still protected
|
// Rate limit check - higher limit for images but still protected
|
||||||
const rateCheck = checkRateLimit(request, {
|
const rateCheck = checkRateLimit(request, {
|
||||||
@@ -98,8 +131,11 @@ export async function GET({ request }: APIContext): Promise<Response> {
|
|||||||
WHERE image_id = ${imageId}
|
WHERE image_id = ${imageId}
|
||||||
`;
|
`;
|
||||||
image = result[0];
|
image = result[0];
|
||||||
} else {
|
} else if (sourceUrl) {
|
||||||
// Look up by source_url - no signature needed as URL itself is the identifier
|
// Require signature for URL-based lookups to prevent enumeration
|
||||||
|
if (!verifyUrlSignature(sourceUrl, signature)) {
|
||||||
|
return new Response('Invalid or missing signature for URL lookup', { status: 403 });
|
||||||
|
}
|
||||||
const result = await sql`
|
const result = await sql`
|
||||||
SELECT image_data, content_type, source_url
|
SELECT image_data, content_type, source_url
|
||||||
FROM image_cache
|
FROM image_cache
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import { checkRateLimit, recordRequest } from '../../../utils/rateLimit.ts';
|
|||||||
import type { APIContext } from 'astro';
|
import type { APIContext } from 'astro';
|
||||||
|
|
||||||
const VIDEO_CACHE_SECRET = import.meta.env.IMAGE_CACHE_SECRET; // share same secret for simplicity
|
const VIDEO_CACHE_SECRET = import.meta.env.IMAGE_CACHE_SECRET; // share same secret for simplicity
|
||||||
if (!VIDEO_CACHE_SECRET) {
|
if (!VIDEO_CACHE_SECRET && import.meta.env.PROD) {
|
||||||
|
throw new Error('CRITICAL: IMAGE_CACHE_SECRET environment variable is not set in production!');
|
||||||
|
} else if (!VIDEO_CACHE_SECRET) {
|
||||||
console.error('WARNING: IMAGE_CACHE_SECRET not set, video signing may be unavailable');
|
console.error('WARNING: IMAGE_CACHE_SECRET not set, video signing may be unavailable');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +21,14 @@ export function signVideoId(videoId: string | number): string {
|
|||||||
if (!VIDEO_CACHE_SECRET) throw new Error('VIDEO_CACHE_SECRET not configured');
|
if (!VIDEO_CACHE_SECRET) throw new Error('VIDEO_CACHE_SECRET not configured');
|
||||||
const hmac = crypto.createHmac('sha256', VIDEO_CACHE_SECRET);
|
const hmac = crypto.createHmac('sha256', VIDEO_CACHE_SECRET);
|
||||||
hmac.update(String(videoId));
|
hmac.update(String(videoId));
|
||||||
return hmac.digest('hex').substring(0, 16);
|
return hmac.digest('hex').substring(0, 32); // 128-bit signature
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signVideoUrl(sourceUrl: string): string {
|
||||||
|
if (!VIDEO_CACHE_SECRET) throw new Error('VIDEO_CACHE_SECRET not configured');
|
||||||
|
const hmac = crypto.createHmac('sha256', VIDEO_CACHE_SECRET);
|
||||||
|
hmac.update(sourceUrl);
|
||||||
|
return hmac.digest('hex').substring(0, 32); // 128-bit signature
|
||||||
}
|
}
|
||||||
|
|
||||||
function verifySignature(id: string | number, signature: string | null): boolean {
|
function verifySignature(id: string | number, signature: string | null): boolean {
|
||||||
@@ -32,6 +41,16 @@ function verifySignature(id: string | number, signature: string | null): boolean
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function verifyUrlSignature(sourceUrl: string, signature: string | null): boolean {
|
||||||
|
if (!VIDEO_CACHE_SECRET || !signature) return false;
|
||||||
|
const expected = signVideoUrl(sourceUrl);
|
||||||
|
try {
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface StreamResult {
|
interface StreamResult {
|
||||||
status: number;
|
status: number;
|
||||||
stream?: ReadableStream;
|
stream?: ReadableStream;
|
||||||
@@ -87,7 +106,11 @@ export async function GET({ request }: APIContext): Promise<Response> {
|
|||||||
if (id) {
|
if (id) {
|
||||||
const r = await sql`SELECT video_id, file_path, content_type, file_size FROM video_cache WHERE video_id = ${id}`;
|
const r = await sql`SELECT video_id, file_path, content_type, file_size FROM video_cache WHERE video_id = ${id}`;
|
||||||
row = r[0];
|
row = r[0];
|
||||||
} else {
|
} else if (sourceUrl) {
|
||||||
|
// Require signature for URL-based lookups to prevent enumeration
|
||||||
|
if (!verifyUrlSignature(sourceUrl, signature)) {
|
||||||
|
return new Response('Invalid or missing signature for URL lookup', { status: 403 });
|
||||||
|
}
|
||||||
const r = await sql`SELECT video_id, file_path, content_type, file_size FROM video_cache WHERE source_url = ${sourceUrl} LIMIT 1`;
|
const r = await sql`SELECT video_id, file_path, content_type, file_size FROM video_cache WHERE source_url = ${sourceUrl} LIMIT 1`;
|
||||||
row = r[0];
|
row = r[0];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import {
|
|||||||
|
|
||||||
// Secret key for signing URLs - MUST be set in production
|
// Secret key for signing URLs - MUST be set in production
|
||||||
const SIGNING_SECRET = import.meta.env.IMAGE_PROXY_SECRET;
|
const SIGNING_SECRET = import.meta.env.IMAGE_PROXY_SECRET;
|
||||||
if (!SIGNING_SECRET) {
|
if (!SIGNING_SECRET && import.meta.env.PROD) {
|
||||||
console.error('CRITICAL: IMAGE_PROXY_SECRET environment variable is not set!');
|
throw new Error('CRITICAL: IMAGE_PROXY_SECRET environment variable is not set in production!');
|
||||||
|
} else if (!SIGNING_SECRET) {
|
||||||
|
console.error('WARNING: IMAGE_PROXY_SECRET environment variable is not set!');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Private IP ranges to block (SSRF protection)
|
// Private IP ranges to block (SSRF protection)
|
||||||
|
|||||||
Reference in New Issue
Block a user