Compare commits

..

2 Commits

Author SHA1 Message Date
433dbadec3 Merge pull request 'dev' (#4) from dev into master
Reviewed-on: #4
2026-02-07 21:23:11 -05:00
4da8ba5dad Merge pull request 'dev' (#3) from dev into master
Reviewed-on: #3
2025-12-24 07:50:59 -05:00
35 changed files with 556 additions and 5328 deletions

View File

@@ -1,5 +1,5 @@
# codey.horse # codey.lol
codey.horse is a web app built with Astro, TypeScript, Tailwind CSS, React, and Vite. codey.lol is a web app built with Astro, TypeScript, Tailwind CSS, React, and Vite.
## Pages Overview ## Pages Overview

View File

@@ -30,16 +30,6 @@
} }
}; };
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");
@@ -61,12 +51,10 @@
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);
} }
}); });
@@ -76,7 +64,6 @@
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);
}); });
}); });
@@ -86,7 +73,6 @@
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);
} }
} }
}; };
@@ -95,93 +81,7 @@
document.addEventListener("click", closeHandler); document.addEventListener("click", closeHandler);
} }
function initDropdownExternalLinks() { const ready = () => initMobileMenu();
// 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 });
@@ -189,10 +89,5 @@
ready(); ready();
} }
document.addEventListener("astro:page-load", () => { document.addEventListener("astro:page-load", initMobileMenu);
initMobileMenu();
initDropdownExternalLinks();
initDesktopDropdownToggle();
initMobileSubmenus();
});
})(); })();

View File

@@ -11,6 +11,3 @@ function raf(time) {
} }
requestAnimationFrame(raf); requestAnimationFrame(raf);
// Expose lenis instance globally for nav controls
(window as any).__lenis = lenis;

View File

@@ -178,8 +178,9 @@ blockquote p:first-of-type::after {
text-wrap: balance; text-wrap: balance;
} }
/* Removed .hidden { display: none; } - Tailwind already provides this utility .hidden {
and the custom rule was breaking responsive variants like sm:flex */ display: none;
}
[data-theme="dark"] .astro-code, [data-theme="dark"] .astro-code,
[data-theme="dark"] .astro-code span { [data-theme="dark"] .astro-code span {

View File

@@ -29,9 +29,8 @@
} }
.mobile-menu-dropdown.open { .mobile-menu-dropdown.open {
max-height: calc(100vh - 4rem); max-height: none;
overflow-y: auto; overflow: visible;
overscroll-behavior: contain;
padding-bottom: 0.75rem; padding-bottom: 0.75rem;
opacity: 1; opacity: 1;
} }
@@ -84,70 +83,6 @@ 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;
@@ -235,26 +170,3 @@ 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);
}

View File

@@ -5,38 +5,6 @@
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);
@@ -74,7 +42,6 @@ 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;
@@ -82,11 +49,6 @@ 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;
@@ -251,172 +213,31 @@ 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: 0 0 auto !important; flex: none !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: 0 auto !important; margin-bottom: 1rem !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: 1 / 1 !important; aspect-ratio: unset !important;
height: auto !important; height: auto !important;
width: 60% !important; width: 100% !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: 0 0 auto !important; flex: none !important;
width: 100% !important; width: 100% !important;
max-width: 100% !important; max-width: 100% !important;
height: auto !important; height: auto !important;
padding: 0 1rem !important; padding: 0 !important;
min-width: 0 !important; min-width: 0 !important;
text-align: center !important; flex-shrink: 0 !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 {

View File

@@ -2,7 +2,6 @@ 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
@@ -666,7 +665,7 @@ const ARCHIVE_USERS = {
id: '456226577798135808', id: '456226577798135808',
username: 'slip', username: 'slip',
displayName: 'poopboy', displayName: 'poopboy',
avatar: 'https://codey.horse/images/456226577798135808.png', avatar: 'https://codey.lol/images/456226577798135808.png',
color: null, color: null,
}, },
}; };
@@ -982,12 +981,7 @@ 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');
// Final sanitization pass with DOMPurify to prevent XSS return parsed;
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

View File

@@ -21,81 +21,29 @@ const envBadge = ENVIRONMENT === 'Dev' ? 'DEV' : null;
{!whitelabel && <RandomMsg client:only="react" />} {!whitelabel && <RandomMsg client:only="react" />}
<div class="footer-version" data-build-time={buildTime} title={`Built: ${buildTime}`}> <div class="footer-version" data-build-time={buildTime} title={`Built: ${buildTime}`}>
<span class="version-pill"> <span class="version-pill">
{envBadge && <span class="env-dot api-status-dot" title="Development build"></span>} {envBadge && <span class="env-dot" title="Development build"></span>}
{!envBadge && <span class="version-dot api-status-dot"></span>} {!envBadge && <span class="version-dot"></span>}
<span class="version-text">{versionDisplay}:{buildNumber}</span> {versionDisplay}:{buildNumber}
<span class="build-time-text" aria-hidden="true"></span>
</span> </span>
</div> </div>
</div> </div>
<script> <script>
function applyApiStatus(reachable) { function updateBuildTooltip() {
const dot = document.querySelector('.api-status-dot');
if (!dot) return;
dot.classList.toggle('api-offline', reachable === false);
}
function bindApiStatusListener() {
const guard = window as any;
if (guard.__footerApiStatusBound) return;
guard.__footerApiStatusBound = true;
// Restore last known state across Astro client navigation
try {
const cached = sessionStorage.getItem('api-reachable');
if (cached === '0') applyApiStatus(false);
if (cached === '1') applyApiStatus(true);
} catch {}
window.addEventListener('api:status', (event) => {
const customEvent = event as CustomEvent<{ reachable?: boolean }>;
const reachable = Boolean(customEvent?.detail?.reachable);
applyApiStatus(reachable);
try {
sessionStorage.setItem('api-reachable', reachable ? '1' : '0');
} catch {}
});
}
function initBuildTooltip() {
const el = document.querySelector('.footer-version[data-build-time]'); const el = document.querySelector('.footer-version[data-build-time]');
if (!el) return; if (el) {
const iso = el.getAttribute('data-build-time'); const iso = el.getAttribute('data-build-time');
if (!iso) return; if (iso) {
const local = new Date(iso).toLocaleString(undefined, { const local = new Date(iso).toLocaleString(undefined, {
dateStyle: 'medium', dateStyle: 'medium',
timeStyle: 'short', timeStyle: 'short',
}); });
el.setAttribute('title', `Built: ${local}`); 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');
});
} }
} }
bindApiStatusListener();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initBuildTooltip);
} else {
initBuildTooltip();
} }
document.addEventListener('astro:page-load', initBuildTooltip); updateBuildTooltip();
document.addEventListener('astro:page-load', updateBuildTooltip);
</script> </script>
<style is:global> <style is:global>
@@ -125,24 +73,6 @@ 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 {
@@ -174,11 +104,6 @@ const envBadge = ENVIRONMENT === 'Dev' ? 'DEV' : null;
box-shadow: 0 0 4px rgba(34, 197, 94, 0.4); box-shadow: 0 0 4px rgba(34, 197, 94, 0.4);
} }
.api-status-dot.api-offline {
background: #ef4444 !important;
box-shadow: 0 0 4px rgba(239, 68, 68, 0.45) !important;
}
.env-dot { .env-dot {
width: 6px; width: 6px;
height: 6px; height: 6px;

View File

@@ -15,7 +15,7 @@ function clearCookie(name: string): void {
} }
// Trusted domains for redirect validation // Trusted domains for redirect validation
const TRUSTED_DOMAINS = ['codey.horse', 'boatson.boats']; const TRUSTED_DOMAINS = ['codey.lol', 'boatson.boats'];
/** /**
* Parse and decode the redirect URL from query params * Parse and decode the redirect URL from query params

View File

@@ -21,7 +21,6 @@ import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import RemoveRoundedIcon from '@mui/icons-material/RemoveRounded'; import RemoveRoundedIcon from '@mui/icons-material/RemoveRounded';
import { AutoComplete } from 'primereact/autocomplete/autocomplete.esm.js'; import { AutoComplete } from 'primereact/autocomplete/autocomplete.esm.js';
import { API_URL } from '../config'; import { API_URL } from '../config';
import { useAutoCompleteScrollFix } from '@/hooks/useAutoCompleteScrollFix';
// Type definitions // Type definitions
interface YouTubeVideo { interface YouTubeVideo {
@@ -127,7 +126,6 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }: LyricS
const autoCompleteInputRef = useRef<HTMLInputElement | null>(null); const autoCompleteInputRef = useRef<HTMLInputElement | null>(null);
const searchButtonRef = useRef<HTMLButtonElement | null>(null); const searchButtonRef = useRef<HTMLButtonElement | null>(null);
const [theme, setTheme] = useState(document.documentElement.getAttribute("data-theme") || "light"); const [theme, setTheme] = useState(document.documentElement.getAttribute("data-theme") || "light");
const { attachScrollFix: handlePanelShow, cleanupScrollFix: handlePanelHide } = useAutoCompleteScrollFix();
const statusLabels = { const statusLabels = {
hint: "Format: Artist - Song", hint: "Format: Artist - Song",
error: "Artist and song required", error: "Artist and song required",
@@ -236,7 +234,36 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }: LyricS
}; };
// Show scrollable dropdown panel with mouse wheel handling
const handlePanelShow = () => {
setTimeout(() => {
const panel = document.querySelector(".p-autocomplete-panel");
const items = panel?.querySelector(".p-autocomplete-items");
if (items) {
(items as HTMLElement).style.maxHeight = "200px";
(items as HTMLElement).style.overflowY = "auto";
(items as HTMLElement).style.overscrollBehavior = "contain";
const wheelHandler: EventListener = (e) => {
const wheelEvent = e as WheelEvent;
const delta = wheelEvent.deltaY;
const atTop = items.scrollTop === 0;
const atBottom =
items.scrollTop + items.clientHeight >= items.scrollHeight;
if ((delta < 0 && atTop) || (delta > 0 && atBottom)) {
e.preventDefault();
} else {
e.stopPropagation();
}
};
items.removeEventListener("wheel", wheelHandler);
items.addEventListener("wheel", wheelHandler, { passive: false });
}
}, 0);
};
const evaluateSearchValue = useCallback((searchValue: string, shouldUpdate = true) => { const evaluateSearchValue = useCallback((searchValue: string, shouldUpdate = true) => {
const trimmed = searchValue?.trim() || ""; const trimmed = searchValue?.trim() || "";
@@ -460,7 +487,6 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }: LyricS
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onShow={handlePanelShow} onShow={handlePanelShow}
onHide={handlePanelHide}
placeholder={placeholder} placeholder={placeholder}
autoFocus autoFocus
style={{ width: '100%' }} style={{ width: '100%' }}

View File

@@ -8,7 +8,7 @@ import { toast } from 'react-toastify';
import { API_URL } from '../config'; import { API_URL } from '../config';
const MEME_API_URL = `${API_URL}/memes/list_memes`; const MEME_API_URL = `${API_URL}/memes/list_memes`;
const BASE_IMAGE_URL = "https://codey.horse/meme"; const BASE_IMAGE_URL = "https://codey.lol/meme";
interface MemeImage { interface MemeImage {
id: string; id: string;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -22,15 +22,9 @@ export default function RandomMsg(): React.ReactElement {
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json(); const data = await response.json();
if (data?.msg) setRandomMsg(data.msg.replace(/<br\s*\/?\>/gi, "\n")); if (data?.msg) setRandomMsg(data.msg.replace(/<br\s*\/?\>/gi, "\n"));
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("api:status", { detail: { reachable: true } }));
}
} catch (err) { } catch (err) {
console.error("Failed to fetch random message:", err); console.error("Failed to fetch random message:", err);
setResponseTime(null); setResponseTime(null);
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("api:status", { detail: { reachable: false } }));
}
} }
}; };

View File

@@ -24,6 +24,7 @@ export default function BreadcrumbNav({ currentPage }: BreadcrumbNavProps): Reac
<React.Fragment key={key}> <React.Fragment key={key}>
<a <a
href={href} href={href}
data-astro-reload
className={`px-3 py-1.5 rounded-full transition-colors ${isActive className={`px-3 py-1.5 rounded-full transition-colors ${isActive
? "bg-neutral-200 dark:bg-neutral-700 font-semibold text-neutral-900 dark:text-white" ? "bg-neutral-200 dark:bg-neutral-700 font-semibold text-neutral-900 dark:text-white"
: "text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800" : "text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800"

File diff suppressed because it is too large Load Diff

View File

@@ -475,35 +475,6 @@
} }
.p-dialog .rm-progress-text { font-size: .75rem; font-weight: 600; color: #e5e7eb !important; margin-left: 0.5rem; white-space: nowrap; } .p-dialog .rm-progress-text { font-size: .75rem; font-weight: 600; color: #e5e7eb !important; margin-left: 0.5rem; white-space: nowrap; }
/* Request Details Dialog - Responsive Track List */
.request-details-dialog .p-dialog-content {
display: flex;
flex-direction: column;
max-height: 80vh;
overflow: hidden;
}
.request-details-content {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
overflow: hidden;
}
.request-details-content > .track-list-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.track-list-scrollable {
flex: 1;
min-height: 120px;
overflow-y: auto;
}
/* Container Styles */ /* Container Styles */
.trip-management-container { .trip-management-container {

View File

@@ -36,7 +36,7 @@ interface RequestJob {
} }
const STATUS_OPTIONS = ["Queued", "Started", "Compressing", "Finished", "Failed"]; const STATUS_OPTIONS = ["Queued", "Started", "Compressing", "Finished", "Failed"];
const TAR_BASE_URL = "https://kr.codey.horse"; // configurable prefix const TAR_BASE_URL = "https://kr.codey.lol"; // configurable prefix
export default function RequestManagement() { export default function RequestManagement() {
const [requests, setRequests] = useState<RequestJob[]>([]); const [requests, setRequests] = useState<RequestJob[]>([]);
@@ -54,19 +54,15 @@ export default function RequestManagement() {
const resolveTarballPath = (job: RequestJob) => job.tarball; const resolveTarballPath = (job: RequestJob) => job.tarball;
const tarballUrl = (absPath: string | undefined, quality: string, jobType?: string) => { const tarballUrl = (absPath: string | undefined, quality: string) => {
if (!absPath) return null; if (!absPath) return null;
const filename = absPath.split("/").pop(); // get "SOMETHING.tar.gz" const filename = absPath.split("/").pop(); // get "SOMETHING.tar.gz"
// If the backend already stores a fully qualified URL, return as-is // If the backend already stores a fully qualified URL, return as-is
if (/^https?:\/\//i.test(absPath)) return absPath; if (/^https?:\/\//i.test(absPath)) return absPath;
const isVideo = jobType === "video" || absPath.includes("/videos/");
// Check if path is /storage/music/TRIP // Check if path is /storage/music/TRIP
if (absPath.includes("/storage/music/TRIP/")) { if (absPath.includes("/storage/music/TRIP/")) {
return isVideo return `https://music.boatson.boats/TRIP/${filename}`;
? `https://trip.codey.horse/videos/${filename}`
: `https://trip.codey.horse/${filename}`;
} }
// Otherwise, assume /storage/music2/completed/{quality} format // Otherwise, assume /storage/music2/completed/{quality} format
@@ -440,7 +436,7 @@ export default function RequestManagement() {
</span> </span>
} }
body={(row: RequestJob) => { body={(row: RequestJob) => {
const url = tarballUrl(resolveTarballPath(row as RequestJob), row.quality || "FLAC", row.type); const url = tarballUrl(resolveTarballPath(row as RequestJob), row.quality || "FLAC");
if (!url) return "—"; if (!url) return "—";
const encodedURL = encodeURI(url); const encodedURL = encodeURI(url);
@@ -468,16 +464,15 @@ export default function RequestManagement() {
<Dialog <Dialog
header="Request Details" header="Request Details"
visible={isDialogVisible} visible={isDialogVisible}
style={{ width: "500px", maxHeight: "90vh" }} style={{ width: "500px" }}
onHide={() => setIsDialogVisible(false)} onHide={() => setIsDialogVisible(false)}
breakpoints={{ "960px": "95vw" }} breakpoints={{ "960px": "95vw" }}
modal modal
dismissableMask dismissableMask
maximizable className="dark:bg-neutral-900 dark:text-neutral-100"
className="dark:bg-neutral-900 dark:text-neutral-100 request-details-dialog"
> >
{selectedRequest ? ( {selectedRequest ? (
<div className="request-details-content space-y-4 text-sm"> <div className="space-y-4 text-sm">
{/* --- Metadata Card --- */} {/* --- Metadata Card --- */}
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -557,12 +552,12 @@ export default function RequestManagement() {
<p> <p>
<strong>Tarball:</strong>{" "} <strong>Tarball:</strong>{" "}
<a <a
href={encodeURI(tarballUrl(resolveTarballPath(selectedRequest), selectedRequest.quality, selectedRequest.type) || "")} href={encodeURI(tarballUrl(resolveTarballPath(selectedRequest), selectedRequest.quality) || "")}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-500 hover:underline" className="text-blue-500 hover:underline"
> >
{tarballUrl(resolveTarballPath(selectedRequest), selectedRequest.quality, selectedRequest.type)?.split("/").pop()} {tarballUrl(resolveTarballPath(selectedRequest), selectedRequest.quality)?.split("/").pop()}
</a> </a>
</p> </p>
</div> </div>
@@ -571,9 +566,9 @@ export default function RequestManagement() {
{/* --- Track List Card --- */} {/* --- Track List Card --- */}
{selectedRequest.track_list && selectedRequest.track_list.length > 0 && ( {selectedRequest.track_list && selectedRequest.track_list.length > 0 && (
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md track-list-card"> <div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md">
<p className="mb-2 flex-shrink-0"><strong>Tracks ({selectedRequest.track_list.length}):</strong></p> <p className="mb-2"><strong>Tracks ({selectedRequest.track_list.length}):</strong></p>
<div className="track-list-scrollable space-y-2" data-lenis-prevent> <div className="max-h-60 overflow-y-auto space-y-2" data-lenis-prevent>
{selectedRequest.track_list.map((track, idx) => { {selectedRequest.track_list.map((track, idx) => {
const rawStatus = String(track.status || "pending"); const rawStatus = String(track.status || "pending");
const statusNorm = rawStatus.trim().toLowerCase(); const statusNorm = rawStatus.trim().toLowerCase();

View File

@@ -4,7 +4,6 @@ import { Button } from "@mui/joy";
// Dropdown not used in this form; removed to avoid unused-import warnings // Dropdown not used in this form; removed to avoid unused-import warnings
import { AutoComplete } from "primereact/autocomplete"; import { AutoComplete } from "primereact/autocomplete";
import { InputText } from "primereact/inputtext"; import { InputText } from "primereact/inputtext";
import { useAutoCompleteScrollFix } from "@/hooks/useAutoCompleteScrollFix";
declare global { declare global {
interface Window { interface Window {
@@ -46,7 +45,6 @@ export default function ReqForm() {
const [posterLoading, setPosterLoading] = useState<boolean>(true); const [posterLoading, setPosterLoading] = useState<boolean>(true);
const [submittedRequest, setSubmittedRequest] = useState<SubmittedRequest | null>(null); // Track successful submission const [submittedRequest, setSubmittedRequest] = useState<SubmittedRequest | null>(null); // Track successful submission
const [csrfToken, setCsrfToken] = useState<string | null>(null); const [csrfToken, setCsrfToken] = useState<string | null>(null);
const { attachScrollFix, cleanupScrollFix } = useAutoCompleteScrollFix();
// Get CSRF token from window global on mount // Get CSRF token from window global on mount
useEffect(() => { useEffect(() => {
@@ -153,7 +151,29 @@ export default function ReqForm() {
// Token was already refreshed from the submit response // Token was already refreshed from the submit response
}; };
const attachScrollFix = () => {
setTimeout(() => {
const panel = document.querySelector<HTMLElement>(".p-autocomplete-panel");
const items = panel?.querySelector<HTMLElement>(".p-autocomplete-items");
if (items) {
items.style.maxHeight = "200px";
items.style.overflowY = "auto";
items.style.overscrollBehavior = "contain";
const wheelHandler = (e: WheelEvent) => {
const delta = e.deltaY;
const atTop = items.scrollTop === 0;
const atBottom = items.scrollTop + items.clientHeight >= items.scrollHeight;
if ((delta < 0 && atTop) || (delta > 0 && atBottom)) {
e.preventDefault();
} else {
e.stopPropagation();
}
};
items.removeEventListener("wheel", wheelHandler);
items.addEventListener("wheel", wheelHandler, { passive: false });
}
}, 0);
};
const formatMediaType = (mediaTypeValue: MediaType | undefined) => { const formatMediaType = (mediaTypeValue: MediaType | undefined) => {
if (!mediaTypeValue) return ""; if (!mediaTypeValue) return "";
@@ -266,7 +286,6 @@ export default function ReqForm() {
field="label" field="label"
autoComplete="off" autoComplete="off"
onShow={attachScrollFix} onShow={attachScrollFix}
onHide={cleanupScrollFix}
itemTemplate={(item) => ( itemTemplate={(item) => (
<div className="p-2 rounded"> <div className="p-2 rounded">
<span className="font-medium">{item.label}</span> <span className="font-medium">{item.label}</span>

View File

@@ -39,9 +39,9 @@ export interface ProtectedRoute {
} }
export const metaData: MetaData = { export const metaData: MetaData = {
baseUrl: "https://codey.horse/", baseUrl: "https://codey.lol/",
title: "CODEY STUFF", title: "CODEY STUFF",
name: "codey.horse", name: "codey.lol",
owner: "codey", owner: "codey",
ogImage: "/images/favicon.png", ogImage: "/images/favicon.png",
description: "CODEY STUFF!", description: "CODEY STUFF!",
@@ -57,13 +57,13 @@ export const metaData: MetaData = {
], ],
}; };
export const API_URL: string = "https://api.codey.horse"; export const API_URL: string = "https://api.codey.lol";
export const RADIO_API_URL: string = "https://radio-api.codey.horse"; export const RADIO_API_URL: string = "https://radio-api.codey.lol";
export const socialLinks: Record<string, string> = { export const socialLinks: Record<string, string> = {
}; };
export const MAJOR_VERSION: string = "1.1" export const MAJOR_VERSION: string = "0.7"
export const RELEASE_FLAG: string | null = null; export const RELEASE_FLAG: string | null = null;
export const ENVIRONMENT: "Dev" | "Prod" = import.meta.env.DEV ? "Dev" : "Prod"; export const ENVIRONMENT: "Dev" | "Prod" = import.meta.env.DEV ? "Dev" : "Prod";

View File

@@ -1,168 +0,0 @@
import { useCallback, useRef } from 'react';
/**
* Hook to fix scrolling issues in PrimeReact AutoComplete dropdown panels.
*
* Fixes two issues:
* 1. Mouse wheel scrolling not working properly within the dropdown
* 2. Keyboard navigation (arrow keys) not scrolling the highlighted item into view
*
* Usage:
* ```tsx
* const { attachScrollFix, cleanupScrollFix } = useAutoCompleteScrollFix();
*
* <AutoComplete
* onShow={attachScrollFix}
* onHide={cleanupScrollFix}
* ...
* />
* ```
*/
export function useAutoCompleteScrollFix(maxHeight = '200px') {
const observerRef = useRef<MutationObserver | null>(null);
const wheelHandlerRef = useRef<((e: WheelEvent) => void) | null>(null);
const itemsRef = useRef<HTMLElement | null>(null);
const cleanupScrollFix = useCallback(() => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
if (itemsRef.current && wheelHandlerRef.current) {
itemsRef.current.removeEventListener('wheel', wheelHandlerRef.current as EventListener);
}
wheelHandlerRef.current = null;
itemsRef.current = null;
}, []);
const attachScrollFix = useCallback(() => {
// Clean up any existing handlers first
cleanupScrollFix();
setTimeout(() => {
const panel = document.querySelector('.p-autocomplete-panel');
const items = panel?.querySelector('.p-autocomplete-items') as HTMLElement | null;
if (!items) return;
itemsRef.current = items;
// Apply scrollable styles
items.style.maxHeight = maxHeight;
items.style.overflowY = 'auto';
items.style.overscrollBehavior = 'contain';
// Mouse wheel scroll handler - prevent page scroll when at boundaries
const wheelHandler = (e: WheelEvent) => {
const delta = e.deltaY;
const atTop = items.scrollTop === 0;
const atBottom = items.scrollTop + items.clientHeight >= items.scrollHeight;
if ((delta < 0 && atTop) || (delta > 0 && atBottom)) {
e.preventDefault();
} else {
e.stopPropagation();
}
};
wheelHandlerRef.current = wheelHandler;
items.addEventListener('wheel', wheelHandler, { passive: false });
// MutationObserver to watch for highlighted item changes (keyboard navigation)
// This ensures the highlighted item scrolls into view when using arrow keys
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const target = mutation.target as HTMLElement;
if (target.classList.contains('p-highlight')) {
// Scroll the highlighted item into view
target.scrollIntoView({
block: 'nearest',
behavior: 'smooth'
});
break;
}
}
}
});
// Observe all autocomplete items for class changes
const allItems = items.querySelectorAll('.p-autocomplete-item');
allItems.forEach((item) => {
observer.observe(item, { attributes: true, attributeFilter: ['class'] });
});
observerRef.current = observer;
}, 0);
}, [maxHeight, cleanupScrollFix]);
return { attachScrollFix, cleanupScrollFix };
}
/**
* Standalone function for components that don't use hooks (e.g., in event handlers).
* Returns a cleanup function that should be called when the panel hides.
*/
export function attachAutoCompleteScrollFix(maxHeight = '200px'): () => void {
let observer: MutationObserver | null = null;
let wheelHandler: ((e: WheelEvent) => void) | null = null;
let items: HTMLElement | null = null;
setTimeout(() => {
const panel = document.querySelector('.p-autocomplete-panel');
items = panel?.querySelector('.p-autocomplete-items') as HTMLElement | null;
if (!items) return;
// Apply scrollable styles
items.style.maxHeight = maxHeight;
items.style.overflowY = 'auto';
items.style.overscrollBehavior = 'contain';
// Mouse wheel scroll handler
wheelHandler = (e: WheelEvent) => {
const delta = e.deltaY;
const atTop = items!.scrollTop === 0;
const atBottom = items!.scrollTop + items!.clientHeight >= items!.scrollHeight;
if ((delta < 0 && atTop) || (delta > 0 && atBottom)) {
e.preventDefault();
} else {
e.stopPropagation();
}
};
items.addEventListener('wheel', wheelHandler, { passive: false });
// MutationObserver for keyboard navigation scroll
observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const target = mutation.target as HTMLElement;
if (target.classList.contains('p-highlight')) {
target.scrollIntoView({
block: 'nearest',
behavior: 'smooth'
});
break;
}
}
}
});
const allItems = items.querySelectorAll('.p-autocomplete-item');
allItems.forEach((item) => {
observer!.observe(item, { attributes: true, attributeFilter: ['class'] });
});
}, 0);
// Return cleanup function
return () => {
if (observer) {
observer.disconnect();
}
if (items && wheelHandler) {
items.removeEventListener('wheel', wheelHandler as EventListener);
}
};
}

View File

@@ -13,7 +13,6 @@ import { metaData } from "../config";
import Nav from "./Nav.astro"; import Nav from "./Nav.astro";
import SubNav from "./SubNav.astro"; import SubNav from "./SubNav.astro";
import Footer from "../components/Footer.astro"; import Footer from "../components/Footer.astro";
import MiniRadioPlayer from "../components/MiniRadioPlayer.tsx";
import "@fontsource/ibm-plex-sans/500.css"; import "@fontsource/ibm-plex-sans/500.css";
import "@fontsource/ibm-plex-sans/600.css"; import "@fontsource/ibm-plex-sans/600.css";
@@ -58,8 +57,6 @@ if (!whitelabel) {
// request locals.isSubsite, which we trust here, but as a fallback also use // request locals.isSubsite, which we trust here, but as a fallback also use
// the presence of a whitelabel mapping or a detected subsite path. // the presence of a whitelabel mapping or a detected subsite path.
const isSubsite = (Astro.request as any)?.locals?.isSubsite ?? Boolean(whitelabel || detectedSubsite); const isSubsite = (Astro.request as any)?.locals?.isSubsite ?? Boolean(whitelabel || detectedSubsite);
const normalizedPath = (Astro.url.pathname || '/').replace(/\/+$/, '') || '/';
const isRadioPage = normalizedPath === '/radio';
// Debug logging // Debug logging
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
@@ -97,7 +94,7 @@ if (import.meta.env.DEV) {
</div> </div>
<main <main
class="flex-auto min-w-0 mt-6 md:mt-8 flex flex-col px-4 sm:px-6 md:px-0 max-w-3xl w-full pb-28 md:pb-24"> class="flex-auto min-w-0 mt-6 md:mt-8 flex flex-col px-4 sm:px-6 md:px-0 max-w-3xl w-full pb-8">
<noscript> <noscript>
<div style="background: #f44336; color: white; padding: 1em; text-align: center;"> <div style="background: #f44336; color: white; padding: 1em; text-align: center;">
This site requires JavaScript to function. Please enable JavaScript in your browser. This site requires JavaScript to function. Please enable JavaScript in your browser.
@@ -106,7 +103,6 @@ if (import.meta.env.DEV) {
<slot /> <slot />
{!hideFooter && <Footer />} {!hideFooter && <Footer />}
</main> </main>
{!isSubsite && !isRadioPage && <MiniRadioPlayer client:only="react" />}
<style> <style>
/* CSS rules for the page scrollbar */ /* CSS rules for the page scrollbar */
.scrollbar-hide::-webkit-scrollbar { .scrollbar-hide::-webkit-scrollbar {

View File

@@ -2,121 +2,60 @@
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 { userIconSvg, externalLinkIconSvg } from "@/utils/navAssets"; import { padlockIconSvg, 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;
type NavItem = { const navItems = [
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: "TRip",
href: "/TRip",
auth: true,
icon: "pirate",
children: [
{ label: "Submit Request", href: "/TRip", auth: true },
{ label: "Manage Requests", href: "/TRip/requests", auth: true },
],
},
{ label: "Discord Logs", href: "/discord-logs", auth: true }, { label: "Discord Logs", href: "/discord-logs", auth: true },
{
label: "Admin",
href: "javascript:void(0)",
auth: true,
adminOnly: true,
children: [
{ label: "Lighting", href: "/lighting", auth: true, adminOnly: true }, { label: "Lighting", href: "/lighting", 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: "Git", href: "https://kode.boatson.boats", icon: "external" }, { label: "Git", href: "https://kode.boatson.boats", icon: "external" },
{ label: "Glances", href: "https://_gl.codey.lol", auth: true, icon: "external",
adminOnly: true },
{ label: "PSQL", href: "https://_pg.codey.lol", auth: true, icon: "external",
adminOnly: true },
{ label: "qBitTorrent", href: "https://_qb.codey.lol", auth: true, icon: "external",
adminOnly: true },
{ label: "RQ", href: "https://_rq.codey.lol", auth: true, icon: "external",
adminOnly: true },
{ label: "RI", href: "https://_r0.codey.lol", auth: true, icon: "external",
adminOnly: true },
// { label: "Status", href: "https://status.boatson.boats", icon: "external" },
{ 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()" }] : []),
]; ];
// Fold any adminOnly root items into the Admin group automatically const visibleNavItems = navItems.filter((item) => {
const adminContainerIndex = baseNavItems.findIndex((item) => item.label === "Admin"); if (item.auth && !isLoggedIn) {
const adminContainer: NavItem = adminContainerIndex >= 0 return false;
? baseNavItems[adminContainerIndex]
: { label: "Admin", href: "#admin", auth: true, adminOnly: true, children: [] };
const adminChildren: NavItem[] = [...(adminContainer.children ?? [])];
const groupedNavItems: NavItem[] = [];
baseNavItems.forEach((item, idx) => {
if (item.label === "Admin") return; // defer insertion
if (item.adminOnly) {
adminChildren.push(item);
} else {
groupedNavItems.push(item);
} }
if (item.adminOnly && !isAdmin) {
return false;
}
if (item.guestOnly && isLoggedIn) {
return false;
}
return true;
}); });
if (adminChildren.length > 0) { const currentPath = Astro.url.pathname;
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;
};
--- ---
@@ -136,69 +75,40 @@ const isPathActiveExact = (href: string) => {
<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 childActive = hasChildren && item.children?.some((child) => isPathActive(child.href)); const normalize = (url) => (url || '/').replace(/\/+$/, '') || '/';
const isActive = childActive || (!isExternal && isPathActive(item.href)); const normalizedCurrent = normalize(currentPath);
const normalizedHref = normalize(item.href);
const isActive = !isExternal && (
normalizedHref === '/'
? normalizedCurrent === '/'
: normalizedCurrent === normalizedHref || normalizedCurrent.startsWith(normalizedHref + '/')
);
return ( return (
<li class:list={["nav-item", hasChildren && "nav-item--has-children"]}> <li>
<a <a
href={item.href} href={item.href}
class={isActive class={isActive
? "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-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-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-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]"
} }
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>
); );
})} })}
@@ -260,7 +170,6 @@ const isPathActiveExact = (href: string) => {
<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}`}>
@@ -270,11 +179,16 @@ const isPathActiveExact = (href: string) => {
)} )}
<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 childActive = hasChildren && item.children?.some((child) => isPathActive(child.href)); const normalize = (url) => (url || '/').replace(/\/+$/, '') || '/';
const isActive = childActive || (!isExternal && isPathActive(item.href)); const normalizedCurrent = normalize(currentPath);
const normalizedHref = normalize(item.href);
const isActive = !isExternal && (
normalizedHref === '/'
? normalizedCurrent === '/'
: normalizedCurrent === normalizedHref || normalizedCurrent.startsWith(normalizedHref + '/')
);
return ( return (
<li> <li>
@@ -290,46 +204,16 @@ const isPathActiveExact = (href: string) => {
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>
); );
})} })}

View File

@@ -26,7 +26,7 @@ if (typeof globalThis.Headers !== 'undefined' && typeof globalThis.Headers.proto
} }
} }
const API_URL = "https://api.codey.horse"; const API_URL = "https://api.codey.lol";
const AUTH_TIMEOUT_MS = 3000; // 3 second timeout for auth requests const AUTH_TIMEOUT_MS = 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
@@ -392,7 +392,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
} }
} }
// Block /subsites/req/* on main domain (codey.horse, local.codey.horse, etc) // Block /subsites/req/* on main domain (codey.lol, local.codey.lol, etc)
const isMainDomain = !wantsSubsite; const isMainDomain = !wantsSubsite;
if (isMainDomain && Object.values(subsites || {}).some(p => context.url.pathname.startsWith(p))) { if (isMainDomain && Object.values(subsites || {}).some(p => context.url.pathname.startsWith(p))) {
// Immediately return a 404 for /req on the main domain // Immediately return a 404 for /req on the main domain
@@ -460,7 +460,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
"font-src 'self' https://fonts.gstatic.com data:", "font-src 'self' https://fonts.gstatic.com data:",
"img-src 'self' data: blob: https: http:", "img-src 'self' data: blob: https: http:",
"media-src 'self' blob: https:", "media-src 'self' blob: https:",
"connect-src 'self' https://api.codey.horse https://*.codey.horse https://*.audio.tidal.com wss:", "connect-src 'self' https://api.codey.lol https://*.codey.lol https://*.audio.tidal.com wss:",
// Allow YouTube for video embeds and Cloudflare for challenges/Turnstile // Allow YouTube for video embeds and Cloudflare for challenges/Turnstile
"frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com https://challenges.cloudflare.com", "frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com https://challenges.cloudflare.com",
"object-src 'none'", "object-src 'none'",

View File

@@ -1,5 +1,5 @@
import { defineMiddleware } from 'astro:middleware'; import { defineMiddleware } from 'astro:middleware';
import { SUBSITES, PROTECTED_ROUTES, PUBLIC_ROUTES, API_URL, type ProtectedRoute } from './config.ts'; import { SUBSITES, PROTECTED_ROUTES, PUBLIC_ROUTES, 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,6 +64,7 @@ if (typeof globalThis.Headers !== 'undefined' && typeof (globalThis.Headers.prot
} }
} }
const API_URL: string = "https://api.codey.lol";
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
@@ -429,7 +430,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
} }
} }
// Block /subsites/req/* on main domain (codey.horse, local.codey.horse, etc) // Block /subsites/req/* on main domain (codey.lol, local.codey.lol, etc)
const isMainDomain = !wantsSubsite; const isMainDomain = !wantsSubsite;
if (isMainDomain && Object.values(subsites || {}).some(p => context.url.pathname.startsWith(p))) { if (isMainDomain && Object.values(subsites || {}).some(p => context.url.pathname.startsWith(p))) {
// Immediately return a 404 for /req on the main domain // Immediately return a 404 for /req on the main domain
@@ -500,7 +501,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
"font-src 'self' https://fonts.gstatic.com data:", "font-src 'self' https://fonts.gstatic.com data:",
"img-src 'self' data: blob: https: http:", "img-src 'self' data: blob: https: http:",
"media-src 'self' blob: https:", "media-src 'self' blob: https:",
"connect-src 'self' https://api.codey.horse https://*.codey.horse https://*.audio.tidal.com wss:", "connect-src 'self' https://api.codey.lol https://*.codey.lol https://*.audio.tidal.com wss:",
// Allow YouTube for video embeds and Cloudflare for challenges/Turnstile // Allow YouTube for video embeds and Cloudflare for challenges/Turnstile
"frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com https://challenges.cloudflare.com", "frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com https://challenges.cloudflare.com",
"object-src 'none'", "object-src 'none'",

View File

@@ -14,9 +14,7 @@ const user = Astro.locals.user as any;
<style is:global> <style is:global>
/* Override main container width for TRip pages */ /* Override main container width for TRip pages */
html:has(.trip-section) main { body:has(.trip-section) main {
max-width: 1400px !important; max-width: 1400px !important;
width: 100% !important;
padding-bottom: 7rem !important;
} }
</style> </style>

View File

@@ -8,7 +8,7 @@ const user = Astro.locals.user as any;
--- ---
<Base title="TRip Requests" description="TRip Requests / Status"> <Base title="TRip Requests" description="TRip Requests / Status">
<section class="page-section trip-section" transition:animate="none"> <section class="page-section trip-section" transition:animate="none">
<Root child="qs2.RequestManagement" client:only="react" /> <Root child="qs2.RequestManagement" client:only="react" transition:persist />
</section> </section>
</Base> </Base>
@@ -17,6 +17,5 @@ const user = Astro.locals.user as any;
html:has(.trip-section) main { html:has(.trip-section) main {
max-width: 1400px !important; max-width: 1400px !important;
width: 100% !important; width: 100% !important;
padding-bottom: 7rem !important;
} }
</style> </style>

View File

@@ -15,10 +15,8 @@ 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 && import.meta.env.PROD) { if (!IMAGE_CACHE_SECRET) {
throw new Error('CRITICAL: IMAGE_CACHE_SECRET environment variable is not set in production!'); console.error('CRITICAL: IMAGE_CACHE_SECRET environment variable is not set!');
} else if (!IMAGE_CACHE_SECRET) {
console.error('WARNING: IMAGE_CACHE_SECRET environment variable is not set!');
} }
/** /**
@@ -32,21 +30,7 @@ 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, 32); // 128-bit signature return hmac.digest('hex').substring(0, 16); // Short signature is sufficient
}
/**
* 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
} }
/** /**
@@ -66,23 +50,6 @@ 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, {
@@ -131,11 +98,8 @@ export async function GET({ request }: APIContext): Promise<Response> {
WHERE image_id = ${imageId} WHERE image_id = ${imageId}
`; `;
image = result[0]; image = result[0];
} else if (sourceUrl) { } else {
// Require signature for URL-based lookups to prevent enumeration // Look up by source_url - no signature needed as URL itself is the identifier
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

View File

@@ -11,9 +11,7 @@ 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 && import.meta.env.PROD) { if (!VIDEO_CACHE_SECRET) {
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');
} }
@@ -21,14 +19,7 @@ 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, 32); // 128-bit signature return hmac.digest('hex').substring(0, 16);
}
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 {
@@ -41,16 +32,6 @@ 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;
@@ -106,11 +87,7 @@ 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 if (sourceUrl) { } else {
// 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];
} }

View File

@@ -14,10 +14,8 @@ 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 && import.meta.env.PROD) { if (!SIGNING_SECRET) {
throw new Error('CRITICAL: IMAGE_PROXY_SECRET environment variable is not set in production!'); console.error('CRITICAL: IMAGE_PROXY_SECRET environment variable is not set!');
} 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)

View File

@@ -5,25 +5,10 @@ import Root from "@/components/AppLayout.jsx";
import { requireAuthHook } from '@/hooks/requireAuthHook'; import { requireAuthHook } from '@/hooks/requireAuthHook';
const user = await requireAuthHook(Astro); const user = await requireAuthHook(Astro);
const isLoggedIn = Boolean(user); const isLoggedIn = Boolean(user);
const cookieHeader = Astro.request.headers.get('cookie') ?? '';
const EXTERNAL_RETURN_COOKIE = 'ext_return';
const EXTERNAL_RETURN_WINDOW_MS = 15000;
const getCookie = (name: string): string | null => {
if (!cookieHeader) return null;
const parts = cookieHeader.split(';');
for (const part of parts) {
const [rawKey, ...rawVal] = part.trim().split('=');
if (rawKey === name) {
return rawVal.join('=') || null;
}
}
return null;
};
// Detect if returnUrl is external (nginx-protected vhost redirect) // Detect if returnUrl is external (nginx-protected vhost redirect)
// If logged-in user arrives with external returnUrl, nginx denied them - show access denied // If logged-in user arrives with external returnUrl, nginx denied them - show access denied
const returnUrl = Astro.url.searchParams.get('returnUrl') || Astro.url.searchParams.get('redirect'); const returnUrl = Astro.url.searchParams.get('returnUrl') || Astro.url.searchParams.get('redirect');
const trustedDomains = ['codey.horse', 'boatson.boats']; const trustedDomains = ['codey.lol', 'boatson.boats'];
let isExternalReturn = false; let isExternalReturn = false;
let externalHostname = ''; let externalHostname = '';
@@ -46,19 +31,7 @@ if (returnUrl && !returnUrl.startsWith('/')) {
// If logged in and redirected from an external nginx-protected resource, // If logged in and redirected from an external nginx-protected resource,
// nginx denied access (user lacks role) - treat as access denied to prevent redirect loop // nginx denied access (user lacks role) - treat as access denied to prevent redirect loop
let externalReturnAttempted = false; const accessDenied = Astro.locals.accessDenied || (isLoggedIn && isExternalReturn);
if (isExternalReturn && returnUrl) {
const cookieVal = getCookie(EXTERNAL_RETURN_COOKIE);
if (cookieVal) {
const [encodedUrl, ts] = cookieVal.split('|');
const lastTs = Number(ts || 0);
if (encodedUrl === encodeURIComponent(returnUrl) && lastTs && (Date.now() - lastTs) < EXTERNAL_RETURN_WINDOW_MS) {
externalReturnAttempted = true;
}
}
}
const accessDenied = Astro.locals.accessDenied || (isLoggedIn && isExternalReturn && externalReturnAttempted);
const requiredRoles = Astro.locals.requiredRoles || (isExternalReturn ? ['(external resource)'] : []); const requiredRoles = Astro.locals.requiredRoles || (isExternalReturn ? ['(external resource)'] : []);
// If user is authenticated and arrived with a returnUrl, redirect them there // If user is authenticated and arrived with a returnUrl, redirect them there
@@ -80,22 +53,8 @@ if (isLoggedIn && !accessDenied) {
} }
} }
// No returnUrl or external URL (access denied case handled above) - redirect to home // No returnUrl or external URL (access denied case handled above) - redirect to home
if (!isExternalReturn) {
return Astro.redirect('/', 302); return Astro.redirect('/', 302);
} }
}
// External return: allow a single redirect attempt, then show access denied if bounced back
if (isLoggedIn && isExternalReturn && !accessDenied && returnUrl) {
const cookieValue = `${encodeURIComponent(returnUrl)}|${Date.now()}`;
const headers = new Headers();
headers.set('Location', returnUrl);
headers.append(
'Set-Cookie',
`${EXTERNAL_RETURN_COOKIE}=${cookieValue}; Path=/; Max-Age=15; SameSite=Lax`
);
return new Response(null, { status: 302, headers });
}
--- ---
<Base title="Login"> <Base title="Login">

View File

@@ -39,7 +39,7 @@ export const authFetch = async (
}; };
// Centralized refresh function that handles deduplication properly // Centralized refresh function that handles deduplication properly
export async function doRefresh(): Promise<boolean> { async function doRefresh(): Promise<boolean> {
const now = Date.now(); const now = Date.now();
// If a refresh just succeeded recently, assume we're good // If a refresh just succeeded recently, assume we're good

View File

@@ -1,38 +0,0 @@
import type { HlsConfig } from "hls.js";
// Shared HLS.js tuning for all players.
// Source profile is 0.5s segments with 5 live segments (Liquidsoap),
// so we keep live sync close while allowing enough headroom for network jitter.
export const SHARED_HLS_CONFIG: Partial<HlsConfig> = {
// Live latency behavior
lowLatencyMode: false,
// Keep slightly more headroom so HLS doesn't need audible rate correction.
liveSyncDurationCount: 2,
// Keep a wider cushion before catch-up behavior is triggered.
liveMaxLatencyDurationCount: 6,
// Avoid "sped up" sounding playback during live edge catch-up.
maxLiveSyncPlaybackRate: 1,
// Buffer behavior (audio-only stream; keep lean for quicker station swaps)
maxBufferLength: 10,
maxMaxBufferLength: 20,
backBufferLength: 30,
// ABR smoothing for quick but stable quality adaptation
abrEwmaFastLive: 2,
abrEwmaSlowLive: 7,
abrBandWidthFactor: 0.9,
abrBandWidthUpFactor: 0.65,
// Loading / retry policy
manifestLoadingTimeOut: 8000,
levelLoadingTimeOut: 8000,
fragLoadingTimeOut: 12000,
manifestLoadingMaxRetry: 4,
levelLoadingMaxRetry: 4,
fragLoadingMaxRetry: 4,
// Runtime
startLevel: -1,
enableWorker: true,
};

View File

@@ -1,134 +0,0 @@
import type Hls from "hls.js";
const CATCHUP_MARKER = "__seriousfm_oneShotCatchupInstalled";
type HlsWithMarker = Hls & {
[CATCHUP_MARKER]?: boolean;
};
type CatchupController = {
trigger: () => void;
cleanup: () => void;
};
const controllers = new WeakMap<Hls, CatchupController>();
/**
* Apply a subtle one-shot live catch-up for a newly created HLS instance.
*
* Why: continuous live-edge catch-up can sound "sped up". We keep HLS auto catch-up
* disabled and do a very small temporary rate bump once, then return to 1.0.
*/
export function installOneShotLiveCatchup(hls: Hls, media: HTMLMediaElement): () => void {
const existing = controllers.get(hls);
if (existing) {
return existing.cleanup;
}
const tagged = hls as HlsWithMarker;
if (tagged[CATCHUP_MARKER]) {
return () => {};
}
tagged[CATCHUP_MARKER] = true;
let intervalId: ReturnType<typeof setInterval> | null = null;
let stopTimerId: ReturnType<typeof setTimeout> | null = null;
let running = false;
const setRate = (rate: number) => {
// Keep pitch stable where supported.
(media as any).preservesPitch = true;
(media as any).mozPreservesPitch = true;
(media as any).webkitPreservesPitch = true;
media.playbackRate = rate;
};
const resetRate = () => {
setRate(1);
};
const getExcessLatency = (): number | null => {
const latency = (hls as any).latency;
const targetLatency = (hls as any).targetLatency;
if (typeof latency !== "number" || typeof targetLatency !== "number") {
return null;
}
return latency - targetLatency;
};
const stopCatchup = () => {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
if (stopTimerId) {
clearTimeout(stopTimerId);
stopTimerId = null;
}
running = false;
resetRate();
};
const tick = () => {
// If user paused/stopped, just stop the one-shot session.
if (media.paused || media.ended) {
stopCatchup();
return;
}
const excess = getExcessLatency();
if (excess === null) {
// Metric unavailable yet, keep waiting inside the bounded one-shot window.
return;
}
// Close enough to target live latency; stop and return to normal speed.
if (excess <= 0.35) {
stopCatchup();
return;
}
// Imperceptible speed-up profile.
if (excess > 6) {
setRate(1.03);
} else if (excess > 3) {
setRate(1.02);
} else {
setRate(1.01);
}
};
const trigger = () => {
if (running || media.paused || media.ended) return;
running = true;
// Slight delay so HLS latency metrics are populated after track transition.
stopTimerId = setTimeout(() => {
tick();
intervalId = setInterval(tick, 1500);
// Hard stop so this is always a bounded "one-time" correction.
stopTimerId = setTimeout(() => {
stopCatchup();
}, 35_000);
}, 900);
};
const cleanup = () => {
stopCatchup();
};
controllers.set(hls, { trigger, cleanup });
return cleanup;
}
/**
* Trigger subtle one-shot live catch-up for the current track transition.
*/
export function triggerOneShotLiveCatchup(hls: Hls | null, media: HTMLMediaElement | null): void {
if (!hls || !media) return;
const controller = controllers.get(hls);
if (!controller) return;
controller.trigger();
}

View File

@@ -1,577 +0,0 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GLOBAL RADIO STATE SINGLETON
// Shared between MiniRadioPlayer and the full Radio page to ensure
// playback continuity when navigating between pages.
// ═══════════════════════════════════════════════════════════════════════════════
import type Hls from "hls.js";
export interface QualityLevel {
index: number;
bitrate: number;
name: string;
isLossless?: boolean;
}
export interface GlobalRadioState {
audio: HTMLAudioElement | null;
video: HTMLVideoElement | null;
hls: Hls | null;
isPlaying: boolean;
station: string;
qualityLevel: number;
qualityLevels: QualityLevel[];
// Track metadata for seamless handoff
trackTitle: string;
trackArtist: string;
trackAlbum: string;
coverArt: string;
trackUuid: string | null;
}
// Station type helpers
export const VIDEO_STATIONS = ["videos"] as const;
export type VideoStation = typeof VIDEO_STATIONS[number];
export function isVideoStation(station: string): station is VideoStation {
return VIDEO_STATIONS.includes(station as VideoStation);
}
export function getStreamUrl(station: string): string {
if (isVideoStation(station)) {
return `https://stream.codey.horse/hls/videos/videos.m3u8`;
}
return `https://stream.codey.horse/hls/${station}/${station}.m3u8`;
}
const GLOBAL_KEY = "__seriousFmRadio";
const RADIO_SHARED_STATE_KEY = "__seriousFmRadioSharedState";
const RADIO_SYNC_PULSE_KEY = "__seriousFmRadioSyncPulse";
const RADIO_SYNC_CHANNEL = "seriousfm-radio-sync-v1";
const TAB_ID = typeof window === "undefined"
? "server"
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
type RadioSyncMessage = {
type: "PLAYBACK" | "STATE";
tabId: string;
at: number;
payload: Record<string, any>;
};
let syncInitialized = false;
let syncChannel: BroadcastChannel | null = null;
const handledSyncMessageKeys: string[] = [];
function buildSyncMessageKey(message: RadioSyncMessage): string {
return `${message.tabId}|${message.type}|${message.at}|${JSON.stringify(message.payload || {})}`;
}
function markSyncMessageHandled(message: RadioSyncMessage): boolean {
const key = buildSyncMessageKey(message);
if (handledSyncMessageKeys.includes(key)) {
return false;
}
handledSyncMessageKeys.push(key);
if (handledSyncMessageKeys.length > 200) {
handledSyncMessageKeys.shift();
}
return true;
}
function readSharedSnapshot(): Partial<GlobalRadioState> | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(RADIO_SHARED_STATE_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
function writeSharedSnapshot(state: GlobalRadioState): void {
if (typeof window === "undefined") return;
try {
const existingOwner = readPlaybackOwnerTabId();
const snapshot = {
station: state.station,
qualityLevel: state.qualityLevel,
qualityLevels: state.qualityLevels,
isPlaying: state.isPlaying,
playbackOwnerTabId: existingOwner,
trackTitle: state.trackTitle,
trackArtist: state.trackArtist,
trackAlbum: state.trackAlbum,
coverArt: state.coverArt,
trackUuid: state.trackUuid,
at: Date.now(),
};
localStorage.setItem(RADIO_SHARED_STATE_KEY, JSON.stringify(snapshot));
} catch {
// ignore
}
}
function readPlaybackOwnerTabId(): string | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(RADIO_SHARED_STATE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
return typeof parsed?.playbackOwnerTabId === "string" ? parsed.playbackOwnerTabId : null;
} catch {
return null;
}
}
function writePlaybackOwnerTabId(ownerTabId: string | null): void {
if (typeof window === "undefined") return;
try {
const raw = localStorage.getItem(RADIO_SHARED_STATE_KEY);
const parsed = raw ? JSON.parse(raw) : {};
parsed.playbackOwnerTabId = ownerTabId;
parsed.at = Date.now();
localStorage.setItem(RADIO_SHARED_STATE_KEY, JSON.stringify(parsed));
} catch {
// ignore
}
}
function postSyncMessage(message: RadioSyncMessage): void {
if (typeof window === "undefined") return;
try {
if (syncChannel) {
syncChannel.postMessage(message);
}
} catch {
// ignore
}
// Fallback pulse for browsers/environments without BroadcastChannel support.
try {
localStorage.setItem(RADIO_SYNC_PULSE_KEY, JSON.stringify(message));
} catch {
// ignore
}
}
function applyRemoteState(payload: Record<string, any>): void {
const state = getGlobalRadioState();
const prevStation = state.station;
const prevQuality = state.qualityLevel;
const prevTrackUuid = state.trackUuid;
if (typeof payload.station === "string") {
state.station = payload.station;
try {
localStorage.setItem("radioStation", payload.station);
} catch {
// ignore
}
}
if (typeof payload.qualityLevel === "number") {
state.qualityLevel = payload.qualityLevel;
try {
localStorage.setItem("radioQuality", String(payload.qualityLevel));
} catch {
// ignore
}
}
if (Array.isArray(payload.qualityLevels)) state.qualityLevels = payload.qualityLevels;
if (typeof payload.isPlaying === "boolean") {
state.isPlaying = payload.isPlaying;
emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: payload.isPlaying });
}
if (typeof payload.trackTitle === "string") state.trackTitle = payload.trackTitle;
if (typeof payload.trackArtist === "string") state.trackArtist = payload.trackArtist;
if (typeof payload.trackAlbum === "string") state.trackAlbum = payload.trackAlbum;
if (typeof payload.coverArt === "string") state.coverArt = payload.coverArt;
if (typeof payload.trackUuid === "string" || payload.trackUuid === null) state.trackUuid = payload.trackUuid;
if (state.station !== prevStation) {
emitRadioEvent(RADIO_EVENTS.STATION_CHANGED, { station: state.station });
}
if (state.qualityLevel !== prevQuality) {
emitRadioEvent(RADIO_EVENTS.QUALITY_CHANGED, { quality: state.qualityLevel });
}
if (state.trackUuid !== prevTrackUuid) {
emitRadioEvent(RADIO_EVENTS.TRACK_CHANGED, {
title: state.trackTitle,
artist: state.trackArtist,
album: state.trackAlbum,
coverArt: state.coverArt,
uuid: state.trackUuid,
});
}
}
function handleSyncMessage(message: RadioSyncMessage): void {
if (!message || message.tabId === TAB_ID) return;
if (!markSyncMessageHandled(message)) return;
if (message.type === "PLAYBACK") {
const shouldBePlaying = !!message.payload?.isPlaying;
const state = getGlobalRadioState();
const ownerTabId = typeof message.payload?.ownerTabId === "string" ? message.payload.ownerTabId : null;
const mediaList = [state.audio, state.video].filter(Boolean) as Array<HTMLMediaElement>;
if (shouldBePlaying) {
if (ownerTabId && ownerTabId === TAB_ID) {
const media = getActiveMediaElement();
if (media) {
try {
media.playbackRate = 1;
const playPromise = media.play();
if (playPromise && typeof (playPromise as Promise<void>).catch === "function") {
(playPromise as Promise<void>).catch(() => {
// ignore autoplay/user-gesture restrictions
});
}
} catch {
// ignore
}
}
state.isPlaying = true;
writePlaybackOwnerTabId(ownerTabId);
emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: true });
return;
}
for (const media of mediaList) {
if (media.paused || media.ended) continue;
try {
media.pause();
} catch {
// ignore
}
}
// Reflect that radio is playing in another tab while keeping this tab paused.
state.isPlaying = true;
if (ownerTabId) {
writePlaybackOwnerTabId(ownerTabId);
}
emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: true });
return;
}
// Global pause request: pause any local media in this tab as well.
for (const media of mediaList) {
if (media.paused || media.ended) continue;
try {
media.pause();
} catch {
// ignore
}
}
if (ownerTabId) {
writePlaybackOwnerTabId(ownerTabId);
}
state.isPlaying = false;
emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: false });
return;
}
if (message.type === "STATE") {
applyRemoteState(message.payload || {});
}
}
function ensureCrossTabSync(): void {
if (typeof window === "undefined" || syncInitialized) return;
syncInitialized = true;
if (typeof BroadcastChannel !== "undefined") {
try {
syncChannel = new BroadcastChannel(RADIO_SYNC_CHANNEL);
syncChannel.onmessage = (event: MessageEvent<RadioSyncMessage>) => {
handleSyncMessage(event.data);
};
} catch {
syncChannel = null;
}
}
window.addEventListener("storage", (event) => {
if (!event.key) return;
if (event.key === RADIO_SYNC_PULSE_KEY && event.newValue) {
try {
handleSyncMessage(JSON.parse(event.newValue));
} catch {
// ignore
}
return;
}
if (event.key === RADIO_SHARED_STATE_KEY && event.newValue) {
try {
const snapshot = JSON.parse(event.newValue);
// Live playback state is coordinated via PLAYBACK messages; do not override
// it opportunistically from snapshot writes that can come from passive tabs.
const { isPlaying: _ignored, playbackOwnerTabId: _ignoredOwner, ...rest } = snapshot || {};
applyRemoteState(rest);
} catch {
// ignore
}
}
});
}
function syncStateAcrossTabs(partial: Record<string, any>): void {
if (typeof window === "undefined") return;
postSyncMessage({
type: "STATE",
tabId: TAB_ID,
at: Date.now(),
payload: partial,
});
}
function syncPlaybackAcrossTabs(isPlaying: boolean, ownerTabIdOverride?: string | null): void {
if (typeof window === "undefined") return;
const ownerTabId = ownerTabIdOverride !== undefined
? ownerTabIdOverride
: (isPlaying ? TAB_ID : null);
postSyncMessage({
type: "PLAYBACK",
tabId: TAB_ID,
at: Date.now(),
payload: { isPlaying, ownerTabId },
});
}
export function getGlobalRadioState(): GlobalRadioState {
if (typeof window === "undefined") {
return {
audio: null,
video: null,
hls: null,
isPlaying: false,
station: "main",
qualityLevel: -1,
qualityLevels: [],
trackTitle: "",
trackArtist: "",
trackAlbum: "",
coverArt: "/images/radio_art_default.jpg",
trackUuid: null,
};
}
const w = window as any;
if (!w[GLOBAL_KEY]) {
const snapshot = readSharedSnapshot();
w[GLOBAL_KEY] = {
audio: null,
video: null,
hls: null,
isPlaying: typeof snapshot?.isPlaying === "boolean" ? snapshot.isPlaying : false,
station: snapshot?.station || localStorage.getItem("radioStation") || "main",
qualityLevel: typeof snapshot?.qualityLevel === "number"
? snapshot.qualityLevel
: parseInt(localStorage.getItem("radioQuality") || "-1", 10),
qualityLevels: Array.isArray(snapshot?.qualityLevels) ? snapshot.qualityLevels : [],
trackTitle: snapshot?.trackTitle || "",
trackArtist: snapshot?.trackArtist || "",
trackAlbum: snapshot?.trackAlbum || "",
coverArt: snapshot?.coverArt || "/images/radio_art_default.jpg",
trackUuid: snapshot?.trackUuid ?? null,
};
}
ensureCrossTabSync();
return w[GLOBAL_KEY];
}
export function getOrCreateAudio(): HTMLAudioElement {
const state = getGlobalRadioState();
if (!state.audio) {
state.audio = document.createElement("audio");
state.audio.preload = "none";
state.audio.crossOrigin = "anonymous";
state.audio.setAttribute("playsinline", "true");
state.audio.setAttribute("webkit-playsinline", "true");
}
return state.audio;
}
export function getOrCreateVideo(): HTMLVideoElement {
const state = getGlobalRadioState();
if (!state.video) {
state.video = document.createElement("video");
state.video.preload = "none";
state.video.crossOrigin = "anonymous";
state.video.setAttribute("playsinline", "true");
state.video.setAttribute("webkit-playsinline", "true");
state.video.muted = false;
}
return state.video;
}
/** Returns the current media element (audio or video) based on station */
export function getActiveMediaElement(): HTMLAudioElement | HTMLVideoElement | null {
const state = getGlobalRadioState();
if (isVideoStation(state.station)) {
return state.video;
}
return state.audio;
}
export function updateGlobalStation(station: string): void {
const state = getGlobalRadioState();
state.station = station;
// Reset metadata on station switch to avoid stale info bleeding across stations.
state.trackTitle = "";
state.trackArtist = "";
state.trackAlbum = "";
state.coverArt = "/images/radio_art_default.jpg";
state.trackUuid = null;
localStorage.setItem("radioStation", station);
writeSharedSnapshot(state);
syncStateAcrossTabs({
station,
trackTitle: "",
trackArtist: "",
trackAlbum: "",
coverArt: "/images/radio_art_default.jpg",
trackUuid: null,
});
}
export function updateGlobalQuality(qualityLevel: number): void {
const state = getGlobalRadioState();
state.qualityLevel = qualityLevel;
localStorage.setItem("radioQuality", qualityLevel.toString());
writeSharedSnapshot(state);
syncStateAcrossTabs({ qualityLevel });
}
export function updateGlobalPlayingState(isPlaying: boolean): void {
const state = getGlobalRadioState();
// Prevent passive tabs from stomping shared playing state owned by another tab.
if (!isPlaying) {
const currentOwner = readPlaybackOwnerTabId();
if (currentOwner && currentOwner !== TAB_ID) {
return;
}
}
state.isPlaying = isPlaying;
writeSharedSnapshot(state);
writePlaybackOwnerTabId(isPlaying ? TAB_ID : null);
syncPlaybackAcrossTabs(isPlaying);
}
/**
* Force pause radio across all tabs/windows.
* Useful when a passive tab (not current owner) requests pause.
*/
export function requestGlobalPauseAll(): void {
const state = getGlobalRadioState();
const previousOwner = readPlaybackOwnerTabId();
state.isPlaying = false;
writeSharedSnapshot(state);
// Preserve previous owner so resume can target the same tab/media element.
writePlaybackOwnerTabId(previousOwner);
syncPlaybackAcrossTabs(false, previousOwner);
emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: false });
}
/**
* If another tab owns playback, request that owner tab to resume.
* Returns true when a remote-owner resume request was sent.
*/
export function requestOwnerTabResume(): boolean {
const ownerTabId = readPlaybackOwnerTabId();
if (!ownerTabId || ownerTabId === TAB_ID) return false;
const state = getGlobalRadioState();
state.isPlaying = true;
writeSharedSnapshot(state);
writePlaybackOwnerTabId(ownerTabId);
syncPlaybackAcrossTabs(true, ownerTabId);
emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: true });
return true;
}
export function updateGlobalTrackInfo(info: {
title?: string;
artist?: string;
album?: string;
coverArt?: string;
uuid?: string | null;
}): void {
const state = getGlobalRadioState();
if (info.title !== undefined) state.trackTitle = info.title;
if (info.artist !== undefined) state.trackArtist = info.artist;
if (info.album !== undefined) state.trackAlbum = info.album;
if (info.coverArt !== undefined) state.coverArt = info.coverArt;
if (info.uuid !== undefined) state.trackUuid = info.uuid;
writeSharedSnapshot(state);
syncStateAcrossTabs({
trackTitle: state.trackTitle,
trackArtist: state.trackArtist,
trackAlbum: state.trackAlbum,
coverArt: state.coverArt,
trackUuid: state.trackUuid,
});
}
export function destroyGlobalHls(): void {
const state = getGlobalRadioState();
if (state.hls) {
state.hls.destroy();
state.hls = null;
}
}
export function setGlobalHls(hls: Hls | null): void {
const state = getGlobalRadioState();
state.hls = hls;
}
export function setGlobalQualityLevels(levels: QualityLevel[]): void {
const state = getGlobalRadioState();
state.qualityLevels = levels;
writeSharedSnapshot(state);
syncStateAcrossTabs({ qualityLevels: levels });
}
// Event system for cross-component communication
type RadioEventCallback = (data: any) => void;
const listeners: Map<string, Set<RadioEventCallback>> = new Map();
export function onRadioEvent(event: string, callback: RadioEventCallback): () => void {
if (!listeners.has(event)) {
listeners.set(event, new Set());
}
listeners.get(event)!.add(callback);
// Return unsubscribe function
return () => {
listeners.get(event)?.delete(callback);
};
}
export function emitRadioEvent(event: string, data?: any): void {
listeners.get(event)?.forEach(callback => {
try {
callback(data);
} catch (e) {
console.error("Radio event listener error:", e);
}
});
}
// Event names
export const RADIO_EVENTS = {
STATION_CHANGED: "station_changed",
QUALITY_CHANGED: "quality_changed",
PLAYBACK_CHANGED: "playback_changed",
TRACK_CHANGED: "track_changed",
PLAYER_TAKEOVER: "player_takeover", // Full player taking over from mini
} as const;