Compare commits
1 Commits
dev
...
4da8ba5dad
| Author | SHA1 | Date | |
|---|---|---|---|
| 4da8ba5dad |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -11,6 +11,3 @@ function raf(time) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
requestAnimationFrame(raf);
|
requestAnimationFrame(raf);
|
||||||
|
|
||||||
// Expose lenis instance globally for nav controls
|
|
||||||
(window as any).__lenis = lenis;
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
/* Desktop navigation - visible on larger screens */
|
/* Desktop navigation - visible on medium screens and up */
|
||||||
.desktop-nav {
|
.desktop-nav {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1280px) {
|
@media (min-width: 768px) {
|
||||||
.desktop-nav {
|
.desktop-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile navigation - visible below larger screens */
|
/* Mobile navigation - visible below medium screens */
|
||||||
.mobile-nav {
|
.mobile-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1280px) {
|
@media (min-width: 768px) {
|
||||||
.mobile-nav {
|
.mobile-nav {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -43,7 +42,7 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1280px) {
|
@media (min-width: 768px) {
|
||||||
.mobile-menu-dropdown {
|
.mobile-menu-dropdown {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -57,14 +56,14 @@ nav {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1280px) {
|
@media (min-width: 768px) {
|
||||||
.desktop-nav {
|
.desktop-nav {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin-left: 2rem;
|
margin-left: 2rem;
|
||||||
gap: clamp(0.5rem, 1vw, 1rem);
|
gap: clamp(0.75rem, 1.5vw, 1.25rem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,84 +72,17 @@ nav {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: nowrap;
|
gap: clamp(0.75rem, 1.5vw, 1.25rem);
|
||||||
overflow: visible;
|
|
||||||
gap: clamp(0.25rem, 0.75vw, 0.75rem);
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 0.25rem;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop-nav-list li {
|
.desktop-nav-list li {
|
||||||
flex-shrink: 0;
|
flex-shrink: 1;
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-user-inline {
|
.nav-user-inline {
|
||||||
@@ -235,26 +167,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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,43 +5,11 @@
|
|||||||
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: #333; /* darker text */
|
||||||
--lrc-bg-color: rgba(0, 0, 0, 0.05);
|
--lrc-bg-color: rgba(0, 0, 0, 0.05);
|
||||||
--lrc-active-color: #000; /* bold black for active */
|
--lrc-active-color: #000; /* bold black for active */
|
||||||
--lrc-active-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); /* subtle shadow in light mode */
|
--lrc-active-shadow: none; /* no glow in light mode */
|
||||||
--lrc-hover-color: #005fcc; /* darker blue hover */
|
--lrc-hover-color: #005fcc; /* darker blue hover */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 {
|
||||||
@@ -438,7 +259,7 @@ body {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
max-height: 220px;
|
max-height: 125px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
@@ -456,9 +277,6 @@ body {
|
|||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #999 transparent;
|
scrollbar-color: #999 transparent;
|
||||||
scroll-padding-bottom: 1rem;
|
scroll-padding-bottom: 1rem;
|
||||||
overscroll-behavior: contain;
|
|
||||||
touch-action: pan-y;
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.lrc-text.empty {
|
.lrc-text.empty {
|
||||||
@@ -479,10 +297,9 @@ body {
|
|||||||
|
|
||||||
.lrc-line.active {
|
.lrc-line.active {
|
||||||
color: var(--lrc-active-color);
|
color: var(--lrc-active-color);
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
font-size: 0.95rem;
|
font-size: 0.8rem;
|
||||||
text-shadow: var(--lrc-active-shadow);
|
text-shadow: var(--lrc-active-shadow);
|
||||||
transform: scale(1.02);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.lrc-line:hover {
|
.lrc-line:hover {
|
||||||
|
|||||||
@@ -1,49 +1,27 @@
|
|||||||
import React, { Suspense, lazy, useState, useMemo, useEffect } from 'react';
|
import React, { Suspense, lazy, useState, useMemo, useEffect } from 'react';
|
||||||
import type { ComponentType } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
import Memes from './Memes.tsx';
|
import Memes from './Memes.jsx';
|
||||||
import Lighting from './Lighting.tsx';
|
import Lighting from './Lighting.jsx';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { JoyUIRootIsland } from './Components.tsx';
|
import { JoyUIRootIsland } from './Components.jsx';
|
||||||
import { PrimeReactProvider } from "primereact/api";
|
import { PrimeReactProvider } from "primereact/api";
|
||||||
import { usePrimeReactThemeSwitcher } from '@/hooks/usePrimeReactThemeSwitcher.tsx';
|
import { usePrimeReactThemeSwitcher } from '@/hooks/usePrimeReactThemeSwitcher.jsx';
|
||||||
import CustomToastContainer from '../components/ToastProvider.tsx';
|
import CustomToastContainer from '../components/ToastProvider.jsx';
|
||||||
import 'primereact/resources/themes/bootstrap4-light-blue/theme.css';
|
import 'primereact/resources/themes/bootstrap4-light-blue/theme.css';
|
||||||
import 'primereact/resources/primereact.min.css';
|
import 'primereact/resources/primereact.min.css';
|
||||||
import "primeicons/primeicons.css";
|
import "primeicons/primeicons.css";
|
||||||
|
|
||||||
const LoginPage = lazy(() => import('./Login.tsx'));
|
const LoginPage = lazy(() => import('./Login.jsx'));
|
||||||
const LyricSearch = lazy(() => import('./LyricSearch'));
|
const LyricSearch = lazy(() => import('./LyricSearch'));
|
||||||
const MediaRequestForm = lazy(() => import('./TRip/MediaRequestForm.tsx'));
|
const MediaRequestForm = lazy(() => import('./TRip/MediaRequestForm.jsx'));
|
||||||
const RequestManagement = lazy(() => import('./TRip/RequestManagement.tsx'));
|
const RequestManagement = lazy(() => import('./TRip/RequestManagement.jsx'));
|
||||||
const DiscordLogs = lazy(() => import('./DiscordLogs.tsx'));
|
const DiscordLogs = lazy(() => import('./DiscordLogs.jsx'));
|
||||||
// NOTE: Player is intentionally NOT imported at module initialization.
|
// NOTE: Player is intentionally NOT imported at module initialization.
|
||||||
// We create the lazy import inside the component at render-time only when
|
// We create the lazy import inside the component at render-time only when
|
||||||
// we are on the main site and the Player island should be rendered. This
|
// we are on the main site and the Player island should be rendered. This
|
||||||
// prevents bundling the player island into pages that are explicitly
|
// prevents bundling the player island into pages that are explicitly
|
||||||
// identified as subsites.
|
// identified as subsites.
|
||||||
const ReqForm = lazy(() => import('./req/ReqForm.tsx'));
|
const ReqForm = lazy(() => import('./req/ReqForm.jsx'));
|
||||||
|
|
||||||
// Simple error boundary for lazy islands
|
|
||||||
class LazyBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean }> {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.state = { hasError: false };
|
|
||||||
}
|
|
||||||
static getDerivedStateFromError() {
|
|
||||||
return { hasError: true };
|
|
||||||
}
|
|
||||||
componentDidCatch(err) {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.error('[AppLayout] lazy island error', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
render() {
|
|
||||||
if (this.state.hasError) {
|
|
||||||
return <div style={{ padding: '1rem', textAlign: 'center' }}>Something went wrong loading this module.</div>;
|
|
||||||
}
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -77,18 +55,15 @@ export default function Root({ child, user = undefined, ...props }: RootProps):
|
|||||||
// don't need to pass guards.
|
// don't need to pass guards.
|
||||||
const isSubsite = typeof document !== 'undefined' && document.documentElement.getAttribute('data-subsite') === 'true';
|
const isSubsite = typeof document !== 'undefined' && document.documentElement.getAttribute('data-subsite') === 'true';
|
||||||
// Log when the active child changes (DEV only)
|
// Log when the active child changes (DEV only)
|
||||||
const devLogsEnabled = import.meta.env.DEV && import.meta.env.VITE_DEV_LOGS === '1';
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!devLogsEnabled) return;
|
|
||||||
try {
|
try {
|
||||||
if (typeof console !== 'undefined' && typeof document !== 'undefined') {
|
if (typeof console !== 'undefined' && typeof document !== 'undefined' && import.meta.env.DEV) {
|
||||||
console.debug(`[AppLayout] child=${String(child)}, data-subsite=${document.documentElement.getAttribute('data-subsite')}`);
|
console.debug(`[AppLayout] child=${String(child)}, data-subsite=${document.documentElement.getAttribute('data-subsite')}`);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// no-op
|
// no-op
|
||||||
}
|
}
|
||||||
}, [child, devLogsEnabled]);
|
}, [child]);
|
||||||
|
|
||||||
// Only initialize the lazy player when this is NOT a subsite and the
|
// Only initialize the lazy player when this is NOT a subsite and the
|
||||||
// active child is the Player island. Placing the lazy() call here
|
// active child is the Player island. Placing the lazy() call here
|
||||||
@@ -107,7 +82,7 @@ export default function Root({ child, user = undefined, ...props }: RootProps):
|
|||||||
let mounted = true;
|
let mounted = true;
|
||||||
if (wantPlayer) {
|
if (wantPlayer) {
|
||||||
if (import.meta.env.DEV) { try { console.debug('[AppLayout] dynamic-import: requesting AudioPlayer'); } catch (e) { } }
|
if (import.meta.env.DEV) { try { console.debug('[AppLayout] dynamic-import: requesting AudioPlayer'); } catch (e) { } }
|
||||||
import('./Radio.tsx')
|
import('./Radio.js')
|
||||||
.then((mod) => {
|
.then((mod) => {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
// set the component factory
|
// set the component factory
|
||||||
@@ -139,59 +114,23 @@ export default function Root({ child, user = undefined, ...props }: RootProps):
|
|||||||
color="danger">
|
color="danger">
|
||||||
Work in progress... bugs are to be expected.
|
Work in progress... bugs are to be expected.
|
||||||
</Alert> */}
|
</Alert> */}
|
||||||
{child == "LoginPage" && (
|
{child == "LoginPage" && (<LoginPage {...props} loggedIn={loggedIn} />)}
|
||||||
<LazyBoundary>
|
{child == "LyricSearch" && (<LyricSearch />)}
|
||||||
<Suspense fallback={<div style={{ padding: '2rem', textAlign: 'center' }}>Loading...</div>}>
|
|
||||||
<LoginPage {...props} loggedIn={loggedIn} />
|
|
||||||
</Suspense>
|
|
||||||
</LazyBoundary>
|
|
||||||
)}
|
|
||||||
{child == "LyricSearch" && (
|
|
||||||
<LazyBoundary>
|
|
||||||
<Suspense fallback={<div style={{ padding: '2rem', textAlign: 'center' }}>Loading...</div>}>
|
|
||||||
<LyricSearch />
|
|
||||||
</Suspense>
|
|
||||||
</LazyBoundary>
|
|
||||||
)}
|
|
||||||
{child == "Player" && !isSubsite && PlayerComp && (
|
{child == "Player" && !isSubsite && PlayerComp && (
|
||||||
<LazyBoundary>
|
<Suspense fallback={null}>
|
||||||
<Suspense fallback={null}>
|
<PlayerComp user={user} />
|
||||||
<PlayerComp user={user} />
|
</Suspense>
|
||||||
</Suspense>
|
|
||||||
</LazyBoundary>
|
|
||||||
)}
|
|
||||||
{child == "Memes" && (
|
|
||||||
<LazyBoundary>
|
|
||||||
<Memes />
|
|
||||||
</LazyBoundary>
|
|
||||||
)}
|
)}
|
||||||
|
{child == "Memes" && <Memes />}
|
||||||
{child == "DiscordLogs" && (
|
{child == "DiscordLogs" && (
|
||||||
<LazyBoundary>
|
<Suspense fallback={<div style={{ padding: '2rem', textAlign: 'center' }}>Loading...</div>}>
|
||||||
<Suspense fallback={<div style={{ padding: '2rem', textAlign: 'center' }}>Loading...</div>}>
|
<DiscordLogs />
|
||||||
<DiscordLogs />
|
</Suspense>
|
||||||
</Suspense>
|
|
||||||
</LazyBoundary>
|
|
||||||
)}
|
|
||||||
{child == "qs2.MediaRequestForm" && (
|
|
||||||
<LazyBoundary>
|
|
||||||
<MediaRequestForm />
|
|
||||||
</LazyBoundary>
|
|
||||||
)}
|
|
||||||
{child == "qs2.RequestManagement" && (
|
|
||||||
<LazyBoundary>
|
|
||||||
<RequestManagement />
|
|
||||||
</LazyBoundary>
|
|
||||||
)}
|
|
||||||
{child == "ReqForm" && (
|
|
||||||
<LazyBoundary>
|
|
||||||
<ReqForm />
|
|
||||||
</LazyBoundary>
|
|
||||||
)}
|
|
||||||
{child == "Lighting" && (
|
|
||||||
<LazyBoundary>
|
|
||||||
<Lighting key={window.location.pathname + Math.random()} />
|
|
||||||
</LazyBoundary>
|
|
||||||
)}
|
)}
|
||||||
|
{child == "qs2.MediaRequestForm" && <MediaRequestForm />}
|
||||||
|
{child == "qs2.RequestManagement" && <RequestManagement />}
|
||||||
|
{child == "ReqForm" && <ReqForm />}
|
||||||
|
{child == "Lighting" && <Lighting key={window.location.pathname + Math.random()} />}
|
||||||
</JoyUIRootIsland>
|
</JoyUIRootIsland>
|
||||||
</PrimeReactProvider>
|
</PrimeReactProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -3290,14 +3284,9 @@ export default function DiscordLogs() {
|
|||||||
// Check if current channel is dds-archive
|
// Check if current channel is dds-archive
|
||||||
const isArchiveChannel = selectedChannel?.name === 'dds-archive';
|
const isArchiveChannel = selectedChannel?.name === 'dds-archive';
|
||||||
|
|
||||||
// Check if current channel is a thread (types 10, 11, 12)
|
|
||||||
const isThread = selectedChannel?.type === 10 || selectedChannel?.type === 11 || selectedChannel?.type === 12;
|
|
||||||
|
|
||||||
// Check if Havoc bot is in the channel's member list
|
// Check if Havoc bot is in the channel's member list
|
||||||
const HAVOC_BOT_ID = '1219636064608583770';
|
const HAVOC_BOT_ID = '1219636064608583770';
|
||||||
const isHavocInChannel = useMemo(() => {
|
const isHavocInChannel = useMemo(() => {
|
||||||
// Threads inherit access from parent channel - if we can see the thread, Havoc has access
|
|
||||||
if (isThread) return true;
|
|
||||||
if (!members?.groups) return true; // Assume present if members not loaded
|
if (!members?.groups) return true; // Assume present if members not loaded
|
||||||
for (const group of members.groups) {
|
for (const group of members.groups) {
|
||||||
if (group.members?.some(m => m.id === HAVOC_BOT_ID)) {
|
if (group.members?.some(m => m.id === HAVOC_BOT_ID)) {
|
||||||
@@ -3305,7 +3294,7 @@ export default function DiscordLogs() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}, [members, isThread]);
|
}, [members]);
|
||||||
|
|
||||||
// Group messages by author and time window (5 minutes)
|
// Group messages by author and time window (5 minutes)
|
||||||
// Messages are in ASC order (oldest first, newest at bottom), so we group accordingly
|
// Messages are in ASC order (oldest first, newest at bottom), so we group accordingly
|
||||||
|
|||||||
@@ -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) {
|
||||||
if (!iso) return;
|
const local = new Date(iso).toLocaleString(undefined, {
|
||||||
|
dateStyle: 'medium',
|
||||||
const local = new Date(iso).toLocaleString(undefined, {
|
timeStyle: 'short',
|
||||||
dateStyle: 'medium',
|
});
|
||||||
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');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
updateBuildTooltip();
|
||||||
bindApiStatusListener();
|
document.addEventListener('astro:page-load', updateBuildTooltip);
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initBuildTooltip);
|
|
||||||
} else {
|
|
||||||
initBuildTooltip();
|
|
||||||
}
|
|
||||||
document.addEventListener('astro:page-load', initBuildTooltip);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style is:global>
|
<style is:global>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -14,96 +14,6 @@ function clearCookie(name: string): void {
|
|||||||
document.cookie = `${name}=; Max-Age=0; path=/;`;
|
document.cookie = `${name}=; Max-Age=0; path=/;`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trusted domains for redirect validation
|
|
||||||
const TRUSTED_DOMAINS = ['codey.horse', 'boatson.boats'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse and decode the redirect URL from query params
|
|
||||||
*/
|
|
||||||
function getRedirectParam(): string | null {
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
let redirectParam = urlParams.get('redirect') || urlParams.get('returnUrl');
|
|
||||||
|
|
||||||
if (!redirectParam) return null;
|
|
||||||
|
|
||||||
// Handle double-encoding
|
|
||||||
if (redirectParam.includes('%')) {
|
|
||||||
try {
|
|
||||||
redirectParam = decodeURIComponent(redirectParam);
|
|
||||||
} catch {
|
|
||||||
// If decoding fails, use the original value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return redirectParam;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a URL is a valid trusted external URL
|
|
||||||
*/
|
|
||||||
function isTrustedExternalUrl(url: string): boolean {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
if (parsed.protocol !== 'https:') return false;
|
|
||||||
return TRUSTED_DOMAINS.some(domain =>
|
|
||||||
parsed.hostname === domain || parsed.hostname.endsWith('.' + domain)
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get validated redirect URL for POST-LOGIN redirect (after form submission)
|
|
||||||
* Allows: relative paths OR trusted external URLs
|
|
||||||
* This is safe because the user just authenticated fresh
|
|
||||||
*/
|
|
||||||
function getPostLoginRedirectUrl(): string {
|
|
||||||
const redirectParam = getRedirectParam();
|
|
||||||
if (!redirectParam) return '/';
|
|
||||||
|
|
||||||
// Relative path: must start with '/' but not '//' (protocol-relative)
|
|
||||||
if (redirectParam.startsWith('/') && !redirectParam.startsWith('//')) {
|
|
||||||
return redirectParam;
|
|
||||||
}
|
|
||||||
|
|
||||||
// External URL: must be https and trusted domain
|
|
||||||
if (isTrustedExternalUrl(redirectParam)) {
|
|
||||||
return redirectParam;
|
|
||||||
}
|
|
||||||
|
|
||||||
return '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get validated redirect URL for AUTO-REDIRECT (when already logged in)
|
|
||||||
* Only allows: relative paths
|
|
||||||
* External URLs are BLOCKED because if a logged-in user was redirected here
|
|
||||||
* from an external resource, nginx denied them access - redirecting back would loop
|
|
||||||
*/
|
|
||||||
function getAutoRedirectUrl(): string | null {
|
|
||||||
const redirectParam = getRedirectParam();
|
|
||||||
if (!redirectParam) return null;
|
|
||||||
|
|
||||||
// Only allow relative paths for auto-redirect
|
|
||||||
if (redirectParam.startsWith('/') && !redirectParam.startsWith('//')) {
|
|
||||||
return redirectParam;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block external URLs - would cause redirect loop with nginx
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the returnUrl is an external trusted domain
|
|
||||||
* Used to detect access denial from nginx-protected external vhosts
|
|
||||||
*/
|
|
||||||
function isExternalReturnUrl(): boolean {
|
|
||||||
const redirectParam = getRedirectParam();
|
|
||||||
if (!redirectParam) return false;
|
|
||||||
if (redirectParam.startsWith('/')) return false;
|
|
||||||
return isTrustedExternalUrl(redirectParam);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LoginPageProps {
|
interface LoginPageProps {
|
||||||
loggedIn?: boolean;
|
loggedIn?: boolean;
|
||||||
accessDenied?: boolean;
|
accessDenied?: boolean;
|
||||||
@@ -123,16 +33,6 @@ export default function LoginPage({ loggedIn = false, accessDenied = false, requ
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// If user is already logged in and not access denied, redirect them
|
|
||||||
// This handles the case where token was refreshed successfully
|
|
||||||
// NOTE: Only auto-redirect to relative URLs - external URLs mean nginx denied access
|
|
||||||
useEffect(() => {
|
|
||||||
if (loggedIn && !accessDenied) {
|
|
||||||
const returnTo = getAutoRedirectUrl() || '/';
|
|
||||||
window.location.href = returnTo;
|
|
||||||
}
|
|
||||||
}, [loggedIn, accessDenied]);
|
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent<HTMLFormElement>): Promise<void> {
|
async function handleSubmit(e: FormEvent<HTMLFormElement>): Promise<void> {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -153,7 +53,7 @@ export default function LoginPage({ loggedIn = false, accessDenied = false, requ
|
|||||||
password,
|
password,
|
||||||
grant_type: "password",
|
grant_type: "password",
|
||||||
scope: "",
|
scope: "",
|
||||||
client_id: "b8308cf47d424e66",
|
client_id: "",
|
||||||
client_secret: "",
|
client_secret: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -186,8 +86,11 @@ export default function LoginPage({ loggedIn = false, accessDenied = false, requ
|
|||||||
toast.success("Login successful!", {
|
toast.success("Login successful!", {
|
||||||
toastId: "login-success-toast",
|
toastId: "login-success-toast",
|
||||||
});
|
});
|
||||||
// After fresh login, allow redirect to external trusted URLs
|
// Check for returnUrl in query params
|
||||||
const returnTo = getPostLoginRedirectUrl();
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const returnUrl = urlParams.get('returnUrl');
|
||||||
|
// Validate returnUrl is a relative path (security: prevent open redirect)
|
||||||
|
const returnTo = (returnUrl && returnUrl.startsWith('/')) ? returnUrl : '/';
|
||||||
window.location.href = returnTo;
|
window.location.href = returnTo;
|
||||||
} else {
|
} else {
|
||||||
toast.error("Login failed: no access token received", {
|
toast.error("Login failed: no access token received", {
|
||||||
@@ -206,10 +109,6 @@ export default function LoginPage({ loggedIn = false, accessDenied = false, requ
|
|||||||
|
|
||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
const rolesList = Array.isArray(requiredRoles) ? requiredRoles : (requiredRoles ? requiredRoles.split(',') : []);
|
const rolesList = Array.isArray(requiredRoles) ? requiredRoles : (requiredRoles ? requiredRoles.split(',') : []);
|
||||||
// Check if this is an external resource denial (nginx redirect)
|
|
||||||
const isExternalDenial = rolesList.some(r => r === '(external resource)');
|
|
||||||
const displayRoles = rolesList.filter(r => r !== '(external resource)');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center px-4 py-16">
|
<div className="flex items-center justify-center px-4 py-16">
|
||||||
<div className="max-w-md w-full bg-white dark:bg-[#1a1a1a] rounded-2xl shadow-xl shadow-neutral-900/5 dark:shadow-black/30 border border-neutral-200/60 dark:border-neutral-800/60 px-10 py-8 text-center">
|
<div className="max-w-md w-full bg-white dark:bg-[#1a1a1a] rounded-2xl shadow-xl shadow-neutral-900/5 dark:shadow-black/30 border border-neutral-200/60 dark:border-neutral-800/60 px-10 py-8 text-center">
|
||||||
@@ -218,11 +117,11 @@ export default function LoginPage({ loggedIn = false, accessDenied = false, requ
|
|||||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||||
You don't have permission to access this resource.
|
You don't have permission to access this resource.
|
||||||
</p>
|
</p>
|
||||||
{displayRoles.length > 0 && (
|
{rolesList.length > 0 && (
|
||||||
<div className="mb-5 p-3 bg-neutral-100 dark:bg-neutral-800/50 rounded-xl border border-neutral-200/60 dark:border-neutral-700/60">
|
<div className="mb-5 p-3 bg-neutral-100 dark:bg-neutral-800/50 rounded-xl border border-neutral-200/60 dark:border-neutral-700/60">
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-500 mb-2 font-medium">Required role{displayRoles.length > 1 ? 's' : ''}:</p>
|
<p className="text-sm text-neutral-500 dark:text-neutral-500 mb-2 font-medium">Required role{rolesList.length > 1 ? 's' : ''}:</p>
|
||||||
<div className="flex flex-wrap justify-center gap-2">
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
{displayRoles.map((role, i) => (
|
{rolesList.map((role, i) => (
|
||||||
<span key={i} className="px-2.5 py-1 text-xs font-semibold bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 rounded-full">
|
<span key={i} className="px-2.5 py-1 text-xs font-semibold bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 rounded-full">
|
||||||
{role}
|
{role}
|
||||||
</span>
|
</span>
|
||||||
@@ -230,13 +129,6 @@ export default function LoginPage({ loggedIn = false, accessDenied = false, requ
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isExternalDenial && displayRoles.length === 0 && (
|
|
||||||
<div className="mb-5 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-xl border border-amber-200/60 dark:border-amber-700/40">
|
|
||||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
|
||||||
This resource requires elevated permissions.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-xs italic text-neutral-400 dark:text-neutral-500 mb-5">
|
<p className="text-xs italic text-neutral-400 dark:text-neutral-500 mb-5">
|
||||||
If you believe this is an error, scream at codey.
|
If you believe this is an error, scream at codey.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -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%' }}
|
||||||
|
|||||||
@@ -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
@@ -1,19 +1,19 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import Alert from "@mui/joy/Alert";
|
||||||
|
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
|
||||||
|
|
||||||
export default function RadioBanner(): React.ReactElement {
|
export default function RadioBanner(): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="radio-banner text-center mb-8">
|
<div>
|
||||||
<h2 className="text-[#333333] dark:text-[#D4D4D4] text-2xl font-bold mb-4">Radio</h2>
|
<h2 style={{ textAlign: 'center', marginBottom: '1rem' }}>Radio</h2>
|
||||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mx-auto max-w-md">
|
<Alert
|
||||||
<div className="flex items-center">
|
variant="soft"
|
||||||
<svg className="w-6 h-6 text-yellow-600 dark:text-yellow-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
color="danger"
|
||||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
sx={{ fontSize: "1.0rem", textAlign: "center", mt: 4, mb: 4, px: 3, py: 2 }}
|
||||||
</svg>
|
startDecorator={<ErrorOutlineIcon fontSize="large" />}
|
||||||
<p className="text-yellow-800 dark:text-yellow-200 font-medium">
|
>
|
||||||
Maintenance in progress. Please check back soon!
|
Maintenance in progress. Please check back soon!
|
||||||
</p>
|
</Alert>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } }));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
1349
src/components/TRip/MediaRequestForm.jsx
Normal file
1349
src/components/TRip/MediaRequestForm.jsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -279,7 +279,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
gap: 0.5rem; /* space between track and percent */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -293,7 +292,6 @@
|
|||||||
overflow: hidden; /* must clip when scaled */
|
overflow: hidden; /* must clip when scaled */
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
margin-right: 0; /* ensure neighbor percent isn't pushed inside */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rm-progress-track-lg {
|
.rm-progress-track-lg {
|
||||||
@@ -310,41 +308,13 @@
|
|||||||
transform: scaleX(var(--rm-progress, 0)); /* use custom property (0-1 range) */
|
transform: scaleX(var(--rm-progress, 0)); /* use custom property (0-1 range) */
|
||||||
border-top-left-radius: 999px;
|
border-top-left-radius: 999px;
|
||||||
border-bottom-left-radius: 999px;
|
border-bottom-left-radius: 999px;
|
||||||
transition: transform .5s cubic-bezier(.25,.8,.25,1), background-color .28s ease, border-radius .28s;
|
transition: transform 0.24s cubic-bezier(0.4,0,0.2,1), border-radius 0.24s;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
right: 0;
|
right: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
will-change: transform, background-color;
|
will-change: transform;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
z-index: 1; /* ensure fill sits beneath the percent text */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure percent label appears above the fill even when inside the track */
|
|
||||||
.rm-progress-text {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
flex: none; /* don't stretch */
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Finalizing pulse for near-100% jobs */
|
|
||||||
.rm-finalizing {
|
|
||||||
animation: rm-finalize-pulse 1.6s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes rm-finalize-pulse {
|
|
||||||
0% {
|
|
||||||
box-shadow: 0 0 0 0 rgba(255, 193, 7, 0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
box-shadow: 0 0 12px 4px rgba(255, 193, 7, 0.10);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
box-shadow: 0 0 0 0 rgba(255, 193, 7, 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fix for native audio progress bar (range input) */
|
/* Fix for native audio progress bar (range input) */
|
||||||
@@ -434,75 +404,15 @@
|
|||||||
.rm-progress-text {
|
.rm-progress-text {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure progress styles apply when rendered within a PrimeReact Dialog (portal) */
|
/* Ensure progress styles apply when rendered within a PrimeReact Dialog (portal) */
|
||||||
.p-dialog .rm-progress-container {
|
.p-dialog .rm-progress-container{display:flex;align-items:center;width:100%}
|
||||||
display: flex;
|
.p-dialog .rm-progress-track{position:relative;flex:1 1 0%;min-width:0;height:6px;background-color:#80808033;border-radius:999px;overflow:hidden;margin:0!important;padding:0!important}
|
||||||
align-items: center;
|
.p-dialog .rm-progress-track-lg{height:10px}
|
||||||
width: 100%;
|
.p-dialog .rm-progress-fill{position:absolute;left:0;top:0;height:100%;width:100%!important;transform-origin:left center;transform:scaleX(var(--rm-progress, 0));border-top-left-radius:999px;border-bottom-left-radius:999px;transition:transform .24s cubic-bezier(.4,0,.2,1),border-radius .24s;margin:0!important;padding:0!important;right:0;min-width:0;will-change:transform;box-sizing:border-box}
|
||||||
}
|
.p-dialog .rm-progress-text{font-size:.75rem;font-weight:600;min-width:2.5rem;text-align:right}
|
||||||
.p-dialog .rm-progress-track {
|
min-width: 2.5rem;
|
||||||
position: relative;
|
text-align: right;
|
||||||
flex: 1 1 0%;
|
|
||||||
min-width: 0;
|
|
||||||
height: 6px;
|
|
||||||
background-color: #80808033;
|
|
||||||
border-radius: 999px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin: 0 !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
.p-dialog .rm-progress-track-lg { height: 10px; }
|
|
||||||
.p-dialog .rm-progress-fill {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
height: 100%;
|
|
||||||
width: 100% !important;
|
|
||||||
transform-origin: left center;
|
|
||||||
transform: scaleX(var(--rm-progress, 0));
|
|
||||||
border-top-left-radius: 999px;
|
|
||||||
border-bottom-left-radius: 999px;
|
|
||||||
transition: transform .5s cubic-bezier(.25,.8,.25,1), background-color .28s ease, border-radius .28s;
|
|
||||||
margin: 0 !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
right: 0;
|
|
||||||
min-width: 0;
|
|
||||||
will-change: transform, background-color;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.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 */
|
||||||
@@ -622,25 +532,6 @@
|
|||||||
font-size: 0.85rem !important;
|
font-size: 0.85rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Track list scrollbar and status pill adjustments */
|
|
||||||
.rm-track-list {
|
|
||||||
/* Give room for overlay scrollbars so status pills don't overlap */
|
|
||||||
padding-inline-end: 1.25rem; /* ~20px */
|
|
||||||
}
|
|
||||||
|
|
||||||
.rm-track-status {
|
|
||||||
/* Ensure the status pill has extra right padding and sits visually clear of the scrollbar */
|
|
||||||
padding-right: 0.75rem !important;
|
|
||||||
margin-right: 0.25rem !important;
|
|
||||||
border-radius: 999px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Slightly reduce spacing on very small screens */
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.rm-track-list { padding-inline-end: 0.75rem; }
|
|
||||||
.rm-track-status { padding-right: 0.5rem !important; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Album header info stacks */
|
/* Album header info stacks */
|
||||||
.album-header-info {
|
.album-header-info {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -11,15 +11,6 @@ import BreadcrumbNav from "./BreadcrumbNav";
|
|||||||
import { API_URL } from "@/config";
|
import { API_URL } from "@/config";
|
||||||
import "./RequestManagement.css";
|
import "./RequestManagement.css";
|
||||||
|
|
||||||
interface TrackInfo {
|
|
||||||
title?: string;
|
|
||||||
artist?: string;
|
|
||||||
status?: string;
|
|
||||||
error?: string;
|
|
||||||
filename?: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RequestJob {
|
interface RequestJob {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
target: string;
|
target: string;
|
||||||
@@ -31,12 +22,11 @@ interface RequestJob {
|
|||||||
tarball?: string;
|
tarball?: string;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
track_list?: TrackInfo[];
|
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
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://codey.lol/m/m2"; // configurable prefix
|
||||||
|
|
||||||
export default function RequestManagement() {
|
export default function RequestManagement() {
|
||||||
const [requests, setRequests] = useState<RequestJob[]>([]);
|
const [requests, setRequests] = useState<RequestJob[]>([]);
|
||||||
@@ -48,28 +38,15 @@ export default function RequestManagement() {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const pollingDetailRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollingDetailRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
// Track finalizing job polls to actively refresh job status when progress hits 100% but status hasn't updated yet
|
|
||||||
const finalizingPollsRef = useRef<Record<string | number, ReturnType<typeof setInterval> | null>>({});
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
if (absPath.includes("/storage/music/TRIP/")) {
|
|
||||||
return isVideo
|
|
||||||
? `https://trip.codey.horse/videos/${filename}`
|
|
||||||
: `https://trip.codey.horse/${filename}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, assume /storage/music2/completed/{quality} format
|
|
||||||
return `${TAR_BASE_URL}/${quality}/${filename}`;
|
return `${TAR_BASE_URL}/${quality}/${filename}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -205,105 +182,30 @@ export default function RequestManagement() {
|
|||||||
|
|
||||||
const computePct = (p: unknown) => {
|
const computePct = (p: unknown) => {
|
||||||
if (p === null || p === undefined || p === "") return 0;
|
if (p === null || p === undefined || p === "") return 0;
|
||||||
// Handle "X / Y" format (e.g., "9 / 545") - note spaces around slash from backend
|
|
||||||
if (typeof p === 'string' && p.includes('/')) {
|
|
||||||
const parts = p.split('/').map(s => s.trim());
|
|
||||||
const current = parseFloat(parts[0]);
|
|
||||||
const total = parseFloat(parts[1]);
|
|
||||||
if (Number.isFinite(current) && Number.isFinite(total) && total > 0) {
|
|
||||||
return Math.min(100, Math.max(0, Math.round((current / total) * 100)));
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const num = Number(p);
|
const num = Number(p);
|
||||||
if (!Number.isFinite(num)) return 0;
|
if (!Number.isFinite(num)) return 0;
|
||||||
// Backend sends progress as 0-100 directly, so just clamp it
|
const normalized = num > 1 ? num : num * 100;
|
||||||
return Math.min(100, Math.max(0, Math.round(num)));
|
return Math.min(100, Math.max(0, Math.round(normalized)));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Visual pct used for display/fill. Prevent briefly showing 100% unless status is Finished
|
|
||||||
const displayPct = (p: unknown, status?: string) => {
|
|
||||||
const pct = computePct(p);
|
|
||||||
const statusNorm = String(status || "").trim();
|
|
||||||
// If the backend reports 100% but the job hasn't reached 'Finished', show 99 to avoid flash
|
|
||||||
if (pct >= 100 && statusNorm.toLowerCase() !== 'finished') return 99;
|
|
||||||
return pct;
|
|
||||||
};
|
|
||||||
const isFinalizingJob = (job: RequestJob | { progress?: unknown; status?: string }) => {
|
|
||||||
const pct = computePct(job.progress);
|
|
||||||
const statusNorm = String(job.status || "").trim().toLowerCase();
|
|
||||||
// Only treat as finalizing when status is explicitly "Compressing"
|
|
||||||
// This is set by the backend only when progress == 100 and tarball isn't ready yet
|
|
||||||
return statusNorm === 'compressing';
|
|
||||||
};
|
|
||||||
|
|
||||||
const startFinalizingPoll = (jobId: string | number) => {
|
|
||||||
if (finalizingPollsRef.current[jobId]) return; // already polling
|
|
||||||
|
|
||||||
let attempts = 0;
|
|
||||||
const iv = setInterval(async () => {
|
|
||||||
attempts += 1;
|
|
||||||
try {
|
|
||||||
const updated = await fetchJobDetail(jobId);
|
|
||||||
if (updated) {
|
|
||||||
// Merge the updated job into requests list so UI refreshes
|
|
||||||
setRequests((prev) => prev.map((r) => (r.id === updated.id ? updated : r)));
|
|
||||||
// If it's no longer finalizing, stop this poll
|
|
||||||
if (!isFinalizingJob(updated)) {
|
|
||||||
if (finalizingPollsRef.current[jobId]) {
|
|
||||||
clearInterval(finalizingPollsRef.current[jobId] as ReturnType<typeof setInterval>);
|
|
||||||
finalizingPollsRef.current[jobId] = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// ignore individual errors; we'll retry a few times
|
|
||||||
}
|
|
||||||
// safety cap: stop after ~20 attempts (~30s)
|
|
||||||
if (attempts >= 20) {
|
|
||||||
if (finalizingPollsRef.current[jobId]) {
|
|
||||||
clearInterval(finalizingPollsRef.current[jobId] as ReturnType<typeof setInterval>);
|
|
||||||
finalizingPollsRef.current[jobId] = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 1500);
|
|
||||||
|
|
||||||
finalizingPollsRef.current[jobId] = iv;
|
|
||||||
};
|
|
||||||
|
|
||||||
// stop all finalizing polls on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
Object.values(finalizingPollsRef.current).forEach((iv) => {
|
|
||||||
if (iv) clearInterval(iv);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const progressBarTemplate = (rowData: RequestJob) => {
|
const progressBarTemplate = (rowData: RequestJob) => {
|
||||||
const p = rowData.progress;
|
const p = rowData.progress;
|
||||||
if (p === null || p === undefined || p === "") return "—";
|
if (p === null || p === undefined || p === "") return "—";
|
||||||
const pctRaw = computePct(p);
|
const pct = computePct(p);
|
||||||
const isFinalizing = isFinalizingJob(rowData);
|
|
||||||
const pct = isFinalizing ? 99 : pctRaw;
|
|
||||||
|
|
||||||
const getProgressColor = () => {
|
const getProgressColor = () => {
|
||||||
if (rowData.status === "Failed") return "bg-red-500";
|
if (rowData.status === "Failed") return "bg-red-500";
|
||||||
if (rowData.status === "Finished") return "bg-green-500";
|
if (rowData.status === "Finished") return "bg-green-500";
|
||||||
if (isFinalizing) return "bg-yellow-500"; // finalizing indicator
|
if (pct < 30) return "bg-blue-400";
|
||||||
if (pctRaw < 30) return "bg-blue-400";
|
if (pct < 70) return "bg-blue-500";
|
||||||
if (pctRaw < 70) return "bg-blue-500";
|
|
||||||
return "bg-blue-600";
|
return "bg-blue-600";
|
||||||
};
|
};
|
||||||
|
|
||||||
// If this job appears to be finalizing, ensure a poll is active to get the real status
|
|
||||||
if (isFinalizing) startFinalizingPoll(rowData.id);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rm-progress-container">
|
<div className="rm-progress-container">
|
||||||
<div className="rm-progress-track" style={{ flex: 1, minWidth: 0 }}>
|
<div className="rm-progress-track" style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div
|
<div
|
||||||
className={`rm-progress-fill ${getProgressColor()} ${isFinalizing ? 'rm-finalizing' : ''}`}
|
className={`rm-progress-fill ${getProgressColor()}`}
|
||||||
style={{
|
style={{
|
||||||
// CSS custom property for progress animation
|
// CSS custom property for progress animation
|
||||||
['--rm-progress' as string]: (pct / 100).toString(),
|
['--rm-progress' as string]: (pct / 100).toString(),
|
||||||
@@ -311,12 +213,12 @@ export default function RequestManagement() {
|
|||||||
borderBottomRightRadius: pct === 100 ? '999px' : 0
|
borderBottomRightRadius: pct === 100 ? '999px' : 0
|
||||||
}}
|
}}
|
||||||
data-pct={pct}
|
data-pct={pct}
|
||||||
aria-valuenow={pctRaw}
|
aria-valuenow={pct}
|
||||||
aria-valuemin={0}
|
aria-valuemin={0}
|
||||||
aria-valuemax={100}
|
aria-valuemax={100}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="rm-progress-text" style={{ marginLeft: 8, flex: 'none' }}>{pct}%{isFinalizing ? ' (finalizing...)' : ''}</span>
|
<span className="rm-progress-text" style={{ marginLeft: 8, flex: 'none' }}>{pct}%</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -440,7 +342,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 +370,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">
|
||||||
@@ -510,34 +411,26 @@ export default function RequestManagement() {
|
|||||||
<div className="rm-progress-container mt-2">
|
<div className="rm-progress-container mt-2">
|
||||||
<div className="rm-progress-track rm-progress-track-lg">
|
<div className="rm-progress-track rm-progress-track-lg">
|
||||||
{(() => {
|
{(() => {
|
||||||
const pctRawDialog = computePct(selectedRequest.progress);
|
const pctDialog = computePct(selectedRequest.progress);
|
||||||
const isFinalizingDialog = isFinalizingJob(selectedRequest);
|
|
||||||
const pctDialog = isFinalizingDialog ? 99 : pctRawDialog;
|
|
||||||
const status = selectedRequest.status;
|
const status = selectedRequest.status;
|
||||||
const fillColor = status === "Failed" ? "bg-red-500" : status === "Finished" ? "bg-green-500" : isFinalizingDialog ? "bg-yellow-500" : "bg-blue-500";
|
const fillColor = status === "Failed" ? "bg-red-500" : status === "Finished" ? "bg-green-500" : "bg-blue-500";
|
||||||
|
|
||||||
// Ensure we poll for finalizing jobs to get the real status update
|
|
||||||
if (isFinalizingDialog) startFinalizingPoll(selectedRequest.id);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
<div
|
className={`rm-progress-fill ${fillColor}`}
|
||||||
className={`rm-progress-fill ${fillColor} ${isFinalizingDialog ? 'rm-finalizing' : ''}`}
|
style={{
|
||||||
style={{
|
['--rm-progress' as string]: (pctDialog / 100).toString(),
|
||||||
['--rm-progress' as string]: (pctDialog / 100).toString(),
|
borderTopRightRadius: pctDialog >= 100 ? '999px' : 0,
|
||||||
borderTopRightRadius: pctDialog >= 100 ? '999px' : 0,
|
borderBottomRightRadius: pctDialog >= 100 ? '999px' : 0
|
||||||
borderBottomRightRadius: pctDialog >= 100 ? '999px' : 0
|
}}
|
||||||
}}
|
data-pct={pctDialog}
|
||||||
data-pct={pctDialog}
|
aria-valuenow={pctDialog}
|
||||||
aria-valuenow={pctRawDialog}
|
aria-valuemin={0}
|
||||||
aria-valuemin={0}
|
aria-valuemax={100}
|
||||||
aria-valuemax={100}
|
/>
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<span className="rm-progress-text">{formatProgress(selectedRequest.progress)}{isFinalizingJob(selectedRequest) ? ' — finalizing' : ''}</span>
|
<span className="rm-progress-text">{formatProgress(selectedRequest.progress)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -557,78 +450,18 @@ 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
{/* --- Track List Card --- */}
|
|
||||||
{selectedRequest.track_list && selectedRequest.track_list.length > 0 && (
|
|
||||||
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md track-list-card">
|
|
||||||
<p className="mb-2 flex-shrink-0"><strong>Tracks ({selectedRequest.track_list.length}):</strong></p>
|
|
||||||
<div className="track-list-scrollable space-y-2" data-lenis-prevent>
|
|
||||||
{selectedRequest.track_list.map((track, idx) => {
|
|
||||||
const rawStatus = String(track.status || "pending");
|
|
||||||
const statusNorm = rawStatus.trim().toLowerCase();
|
|
||||||
|
|
||||||
const isError = statusNorm === "failed" || statusNorm === "error" || !!track.error;
|
|
||||||
const isSuccess = ["done", "success", "completed", "finished"].includes(statusNorm);
|
|
||||||
const isPending = ["pending", "queued"].includes(statusNorm);
|
|
||||||
const isDownloading = ["downloading", "in_progress", "started"].includes(statusNorm);
|
|
||||||
|
|
||||||
const statusBadgeClass = isError
|
|
||||||
? "bg-red-600 text-white"
|
|
||||||
: isSuccess
|
|
||||||
? "bg-green-600 text-white"
|
|
||||||
: isDownloading
|
|
||||||
? "bg-blue-600 text-white"
|
|
||||||
: isPending
|
|
||||||
? "bg-yellow-600 text-white"
|
|
||||||
: "bg-gray-500 text-white";
|
|
||||||
|
|
||||||
const trackTitle = track.title || track.filename || `Track ${idx + 1}`;
|
|
||||||
const trackArtist = track.artist;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className={`p-2 rounded border ${isError ? "border-red-500/50 bg-red-500/10" : "border-neutral-300 dark:border-neutral-600"}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium truncate" title={trackTitle}>
|
|
||||||
{trackTitle}
|
|
||||||
</p>
|
|
||||||
{trackArtist && (
|
|
||||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 truncate" title={trackArtist}>
|
|
||||||
{trackArtist}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className={`px-2 py-0.5 pr-3 mr-2 rounded text-xs font-semibold whitespace-nowrap ${statusBadgeClass} rm-track-status`} title={rawStatus}>
|
|
||||||
{rawStatus}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{track.error && (
|
|
||||||
<p className="mt-1 text-xs text-red-400 break-words">
|
|
||||||
<i className="pi pi-exclamation-triangle mr-1" />
|
|
||||||
{track.error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div >
|
</div >
|
||||||
) : (
|
) : (
|
||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -277,7 +296,7 @@ export default function ReqForm() {
|
|||||||
/>
|
/>
|
||||||
{selectedItem && selectedTypeLabel && (
|
{selectedItem && selectedTypeLabel && (
|
||||||
<div className="text-xs font-medium uppercase text-neutral-500 dark:text-neutral-400 tracking-wide">
|
<div className="text-xs font-medium uppercase text-neutral-500 dark:text-neutral-400 tracking-wide">
|
||||||
Type: <span className="font-bold text-neutral-700 dark:text-neutral-200">{selectedTypeLabel}</span>
|
Selected type: <span className="font-bold text-neutral-700 dark:text-neutral-200">{selectedTypeLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -315,19 +334,18 @@ export default function ReqForm() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="year" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
<label htmlFor="year" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
Year
|
Year <span className="text-neutral-400">(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<InputText
|
<InputText
|
||||||
id="year"
|
id="year"
|
||||||
value={year}
|
value={year}
|
||||||
placeholder=""
|
onChange={(e) => setYear(e.target.value)}
|
||||||
readOnly
|
placeholder="e.g. 2023"
|
||||||
disabled
|
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-xl px-4 py-3 bg-white dark:bg-neutral-900/50 focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 transition-all outline-none"
|
||||||
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-xl px-4 py-3 bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400 cursor-not-allowed transition-all outline-none"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* <div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="requester" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
<label htmlFor="requester" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
Your Name <span className="text-neutral-400">(optional)</span>
|
Your Name <span className="text-neutral-400">(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -338,7 +356,7 @@ export default function ReqForm() {
|
|||||||
placeholder="Who is requesting this?"
|
placeholder="Who is requesting this?"
|
||||||
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-xl px-4 py-3 bg-white dark:bg-neutral-900/50 focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 transition-all outline-none"
|
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-xl px-4 py-3 bg-white dark:bg-neutral-900/50 focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 transition-all outline-none"
|
||||||
/>
|
/>
|
||||||
</div> */}
|
</div>
|
||||||
|
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -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.6"
|
||||||
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";
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -2,121 +2,46 @@
|
|||||||
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: "Lighting", href: "/lighting", auth: true },
|
||||||
label: "Admin",
|
{ label: "Status", href: "https://status.boatson.boats", icon: "external" },
|
||||||
href: "javascript:void(0)",
|
|
||||||
auth: true,
|
|
||||||
adminOnly: true,
|
|
||||||
children: [
|
|
||||||
{ 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: "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.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 +61,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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -259,8 +155,7 @@ const isPathActiveExact = (href: string) => {
|
|||||||
<!-- Mobile Navigation Menu -->
|
<!-- Mobile Navigation Menu -->
|
||||||
<div
|
<div
|
||||||
id="mobile-menu"
|
id="mobile-menu"
|
||||||
class="mobile-menu-dropdown xl:hidden"
|
class="mobile-menu-dropdown md: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 +165,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 +190,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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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'",
|
||||||
|
|||||||
@@ -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'",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface SubmitRequestBody {
|
|||||||
title?: string;
|
title?: string;
|
||||||
year?: string;
|
year?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
// requester?: string;
|
requester?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ export async function POST({ request }: APIContext): Promise<Response> {
|
|||||||
try {
|
try {
|
||||||
// Use pre-parsed body data
|
// Use pre-parsed body data
|
||||||
const extendedRequest = request as ExtendedRequest;
|
const extendedRequest = request as ExtendedRequest;
|
||||||
const { title, year, type } = extendedRequest.bodyData || {};
|
const { title, year, type, requester } = extendedRequest.bodyData || {};
|
||||||
|
|
||||||
// Input validation
|
// Input validation
|
||||||
if (!title || typeof title !== 'string' || !title.trim()) {
|
if (!title || typeof title !== 'string' || !title.trim()) {
|
||||||
@@ -174,16 +174,16 @@ export async function POST({ request }: APIContext): Promise<Response> {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// if (requester && (typeof requester !== 'string' || requester.length > 500)) {
|
if (requester && (typeof requester !== 'string' || requester.length > 500)) {
|
||||||
// const response = new Response(JSON.stringify({ error: 'Requester name must be 500 characters or less if provided' }), {
|
const response = new Response(JSON.stringify({ error: 'Requester name must be 500 characters or less if provided' }), {
|
||||||
// status: 400,
|
status: 400,
|
||||||
// headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
// });
|
});
|
||||||
// if (!hadCookie) {
|
if (!hadCookie) {
|
||||||
// response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
response.headers.set('Set-Cookie', createNonceCookie(cookieId));
|
||||||
// }
|
}
|
||||||
// return response;
|
return response;
|
||||||
// }
|
}
|
||||||
|
|
||||||
// Fetch synopsis and IMDb ID from TMDB
|
// Fetch synopsis and IMDb ID from TMDB
|
||||||
let synopsis = '';
|
let synopsis = '';
|
||||||
@@ -258,13 +258,13 @@ export async function POST({ request }: APIContext): Promise<Response> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// if (requester && requester.trim()) {
|
if (requester && requester.trim()) {
|
||||||
// fields.push({
|
fields.push({
|
||||||
// name: "Requested by",
|
name: "Requested by",
|
||||||
// value: requester.trim(),
|
value: requester.trim(),
|
||||||
// inline: false
|
inline: false
|
||||||
// });
|
});
|
||||||
// }
|
}
|
||||||
|
|
||||||
interface DiscordEmbed {
|
interface DiscordEmbed {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -5,97 +5,8 @@ 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 accessDenied = Astro.locals.accessDenied || false;
|
||||||
const EXTERNAL_RETURN_COOKIE = 'ext_return';
|
const requiredRoles = Astro.locals.requiredRoles || [];
|
||||||
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)
|
|
||||||
// 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 trustedDomains = ['codey.horse', 'boatson.boats'];
|
|
||||||
let isExternalReturn = false;
|
|
||||||
let externalHostname = '';
|
|
||||||
|
|
||||||
if (returnUrl && !returnUrl.startsWith('/')) {
|
|
||||||
try {
|
|
||||||
const url = new URL(returnUrl);
|
|
||||||
if (url.protocol === 'https:') {
|
|
||||||
const isTrusted = trustedDomains.some(domain =>
|
|
||||||
url.hostname === domain || url.hostname.endsWith('.' + domain)
|
|
||||||
);
|
|
||||||
if (isTrusted) {
|
|
||||||
isExternalReturn = true;
|
|
||||||
externalHostname = url.hostname;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Invalid URL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
let externalReturnAttempted = false;
|
|
||||||
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)'] : []);
|
|
||||||
|
|
||||||
// If user is authenticated and arrived with a returnUrl, redirect them there
|
|
||||||
// This handles the case where access_token expired but refresh_token was still valid
|
|
||||||
// BUT only for relative URLs - external URLs that sent us here mean access was denied
|
|
||||||
if (isLoggedIn && !accessDenied) {
|
|
||||||
if (returnUrl) {
|
|
||||||
// Validate the return URL for security
|
|
||||||
let isValidRedirect = false;
|
|
||||||
|
|
||||||
// Only allow relative paths for auto-redirect
|
|
||||||
// External URLs arriving here mean nginx denied access, so don't redirect back
|
|
||||||
if (returnUrl.startsWith('/') && !returnUrl.startsWith('//')) {
|
|
||||||
isValidRedirect = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isValidRedirect) {
|
|
||||||
return Astro.redirect(returnUrl, 302);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// No returnUrl or external URL (access denied case handled above) - redirect to home
|
|
||||||
if (!isExternalReturn) {
|
|
||||||
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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
203
src/utils/jwt.ts
203
src/utils/jwt.ts
@@ -1,27 +1,26 @@
|
|||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import crypto from 'crypto';
|
import fs from 'fs';
|
||||||
import { API_URL } from '../config';
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
|
||||||
// JWKS endpoint for fetching RSA public keys
|
// JWT keys location - can be configured via environment variable
|
||||||
const JWKS_URL = `${API_URL}/auth/jwks`;
|
// In production, prefer using a secret management service (Vault, AWS Secrets Manager, etc.)
|
||||||
|
const secretFilePath = import.meta.env.JWT_KEYS_PATH || path.join(
|
||||||
|
os.homedir(),
|
||||||
|
'.config',
|
||||||
|
'api_jwt_keys.json'
|
||||||
|
);
|
||||||
|
|
||||||
// Cache for JWKS keys
|
// Warn if using default location in production
|
||||||
interface JwkKey {
|
if (!import.meta.env.JWT_KEYS_PATH && !import.meta.env.DEV) {
|
||||||
kty: string;
|
console.warn(
|
||||||
use?: string;
|
'[SECURITY WARNING] JWT_KEYS_PATH not set. Using default location ~/.config/api_jwt_keys.json. ' +
|
||||||
kid: string;
|
'Consider using a secret management service in production.'
|
||||||
alg?: string;
|
);
|
||||||
n: string;
|
|
||||||
e: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface JwksResponse {
|
interface JwtKeyFile {
|
||||||
keys: JwkKey[];
|
keys: Record<string, string>;
|
||||||
}
|
|
||||||
|
|
||||||
interface CachedKeys {
|
|
||||||
keys: Map<string, string>; // kid -> PEM public key
|
|
||||||
fetchedAt: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
@@ -32,98 +31,16 @@ export interface JwtPayload {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache TTL: 5 minutes (keys are refreshed on cache miss or verification failure)
|
// Load and parse keys JSON once at startup
|
||||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
let keyFileData: JwtKeyFile;
|
||||||
|
try {
|
||||||
let cachedKeys: CachedKeys | null = null;
|
keyFileData = JSON.parse(fs.readFileSync(secretFilePath, 'utf-8'));
|
||||||
|
} catch (err) {
|
||||||
/**
|
console.error(`[CRITICAL] Failed to load JWT keys from ${secretFilePath}:`, (err as Error).message);
|
||||||
* Convert a JWK RSA public key to PEM format
|
throw new Error('JWT keys file not found or invalid. Set JWT_KEYS_PATH environment variable.');
|
||||||
*/
|
|
||||||
function jwkToPem(jwk: JwkKey): string {
|
|
||||||
if (jwk.kty !== 'RSA') {
|
|
||||||
throw new Error(`Unsupported key type: ${jwk.kty}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode base64url-encoded modulus and exponent
|
|
||||||
const n = Buffer.from(jwk.n, 'base64url');
|
|
||||||
const e = Buffer.from(jwk.e, 'base64url');
|
|
||||||
|
|
||||||
// Build the RSA public key using Node's crypto
|
|
||||||
const publicKey = crypto.createPublicKey({
|
|
||||||
key: {
|
|
||||||
kty: 'RSA',
|
|
||||||
n: jwk.n,
|
|
||||||
e: jwk.e,
|
|
||||||
},
|
|
||||||
format: 'jwk',
|
|
||||||
});
|
|
||||||
|
|
||||||
return publicKey.export({ type: 'spki', format: 'pem' }) as string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function verifyToken(token: string | null | undefined): JwtPayload | null {
|
||||||
* Fetch JWKS from the API and cache the keys
|
|
||||||
*/
|
|
||||||
async function fetchJwks(): Promise<Map<string, string>> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(JWKS_URL, {
|
|
||||||
headers: { 'Accept': 'application/json' },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`JWKS fetch failed: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const jwks: JwksResponse = await response.json();
|
|
||||||
const keys = new Map<string, string>();
|
|
||||||
|
|
||||||
for (const jwk of jwks.keys) {
|
|
||||||
if (jwk.kty === 'RSA' && jwk.kid) {
|
|
||||||
try {
|
|
||||||
const pem = jwkToPem(jwk);
|
|
||||||
keys.set(jwk.kid, pem);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`Failed to convert JWK ${jwk.kid} to PEM:`, (err as Error).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keys.size === 0) {
|
|
||||||
throw new Error('No valid RSA keys found in JWKS');
|
|
||||||
}
|
|
||||||
|
|
||||||
cachedKeys = {
|
|
||||||
keys,
|
|
||||||
fetchedAt: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(`[JWT] Fetched ${keys.size} RSA public key(s) from JWKS`);
|
|
||||||
return keys;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[JWT] Failed to fetch JWKS:', (error as Error).message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cached keys or fetch fresh ones
|
|
||||||
*/
|
|
||||||
async function getKeys(forceRefresh = false): Promise<Map<string, string>> {
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
if (!forceRefresh && cachedKeys && (now - cachedKeys.fetchedAt) < CACHE_TTL_MS) {
|
|
||||||
return cachedKeys.keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetchJwks();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify a JWT token using RS256 with keys from JWKS
|
|
||||||
*/
|
|
||||||
export async function verifyToken(token: string | null | undefined): Promise<JwtPayload | null> {
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -135,22 +52,14 @@ export async function verifyToken(token: string | null | undefined): Promise<Jwt
|
|||||||
}
|
}
|
||||||
|
|
||||||
const kid = decoded.header.kid;
|
const kid = decoded.header.kid;
|
||||||
let keys = await getKeys();
|
const key = keyFileData.keys[kid];
|
||||||
let publicKey = keys.get(kid);
|
|
||||||
|
|
||||||
// If key not found, try refreshing JWKS (handles key rotation)
|
if (!key) {
|
||||||
if (!publicKey) {
|
throw new Error(`Unknown kid: ${kid}`);
|
||||||
console.log(`[JWT] Key ${kid} not in cache, refreshing JWKS...`);
|
|
||||||
keys = await getKeys(true);
|
|
||||||
publicKey = keys.get(kid);
|
|
||||||
|
|
||||||
if (!publicKey) {
|
|
||||||
throw new Error(`Unknown kid: ${kid}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify using RS256
|
// Verify using the correct key and HS256 algo
|
||||||
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] }) as JwtPayload;
|
const payload = jwt.verify(token, key, { algorithms: ['HS256'] }) as JwtPayload;
|
||||||
return payload;
|
return payload;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -158,53 +67,3 @@ export async function verifyToken(token: string | null | undefined): Promise<Jwt
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Synchronous verification - uses cached keys only (for middleware compatibility)
|
|
||||||
* Falls back to null if cache is empty; caller should handle async verification
|
|
||||||
*/
|
|
||||||
export function verifyTokenSync(token: string | null | undefined): JwtPayload | null {
|
|
||||||
if (!token) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cachedKeys || cachedKeys.keys.size === 0) {
|
|
||||||
// No cached keys - caller needs to use async version
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const decoded = jwt.decode(token, { complete: true });
|
|
||||||
if (!decoded?.header?.kid) {
|
|
||||||
throw new Error('No kid in token header');
|
|
||||||
}
|
|
||||||
|
|
||||||
const kid = decoded.header.kid;
|
|
||||||
const publicKey = cachedKeys.keys.get(kid);
|
|
||||||
|
|
||||||
if (!publicKey) {
|
|
||||||
// Key not found - might need refresh, return null to signal async verification needed
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify using RS256
|
|
||||||
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] }) as JwtPayload;
|
|
||||||
return payload;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('JWT verification failed:', (error as Error).message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pre-warm the JWKS cache at startup
|
|
||||||
*/
|
|
||||||
export async function initializeJwks(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await fetchJwks();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[JWT] Failed to pre-warm JWKS cache:', (error as Error).message);
|
|
||||||
// Don't throw - allow lazy initialization on first verification
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
Reference in New Issue
Block a user