diff --git a/src/components/LyricSearch.tsx b/src/components/LyricSearch.tsx index 29fbe1a..57650f1 100644 --- a/src/components/LyricSearch.tsx +++ b/src/components/LyricSearch.tsx @@ -21,6 +21,7 @@ import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; import RemoveRoundedIcon from '@mui/icons-material/RemoveRounded'; import { AutoComplete } from 'primereact/autocomplete/autocomplete.esm.js'; import { API_URL } from '../config'; +import { useAutoCompleteScrollFix } from '@/hooks/useAutoCompleteScrollFix'; // Type definitions interface YouTubeVideo { @@ -126,6 +127,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }: LyricS const autoCompleteInputRef = useRef(null); const searchButtonRef = useRef(null); const [theme, setTheme] = useState(document.documentElement.getAttribute("data-theme") || "light"); + const { attachScrollFix: handlePanelShow, cleanupScrollFix: handlePanelHide } = useAutoCompleteScrollFix(); const statusLabels = { hint: "Format: Artist - Song", error: "Artist and song required", @@ -234,36 +236,7 @@ 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 trimmed = searchValue?.trim() || ""; @@ -487,6 +460,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }: LyricS onChange={(e) => setValue(e.target.value)} onKeyDown={handleKeyDown} onShow={handlePanelShow} + onHide={handlePanelHide} placeholder={placeholder} autoFocus style={{ width: '100%' }} diff --git a/src/components/Radio.tsx b/src/components/Radio.tsx index 3beefa9..2890b0b 100644 --- a/src/components/Radio.tsx +++ b/src/components/Radio.tsx @@ -15,6 +15,7 @@ import { API_URL } from "@/config"; import { authFetch } from "@/utils/authFetch"; import { requireAuthHook } from "@/hooks/requireAuthHook"; import { useHtmlThemeAttr } from "@/hooks/useHtmlThemeAttr"; +import { useAutoCompleteScrollFix } from "@/hooks/useAutoCompleteScrollFix"; interface LyricLine { timestamp: number; @@ -148,6 +149,7 @@ export default function Player({ user }: PlayerProps) { const [requestInputArtist, setRequestInputArtist] = useState(""); const [requestInputSong, setRequestInputSong] = useState(""); const [requestInputUuid, setRequestInputUuid] = useState(""); + const { attachScrollFix: handleTypeaheadShow, cleanupScrollFix: handleTypeaheadHide } = useAutoCompleteScrollFix(); const audioElement = useRef(null); const hlsInstance = useRef(null); @@ -941,30 +943,8 @@ export default function Player({ user }: PlayerProps) { onChange={e => { setRequestInput(e.target.value ?? ''); }} - onShow={() => { - 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); - }} + onShow={handleTypeaheadShow} + onHide={handleTypeaheadHide} placeholder="Request a song..." inputStyle={{ width: '24rem', diff --git a/src/components/TRip/MediaRequestForm.tsx b/src/components/TRip/MediaRequestForm.tsx index 366f910..62bc5c8 100644 --- a/src/components/TRip/MediaRequestForm.tsx +++ b/src/components/TRip/MediaRequestForm.tsx @@ -6,6 +6,7 @@ import { Button } from "@mui/joy"; import { Accordion, AccordionTab } from "primereact/accordion"; import { AutoComplete } from "primereact/autocomplete"; import { authFetch } from "@/utils/authFetch"; +import { useAutoCompleteScrollFix } from '@/hooks/useAutoCompleteScrollFix'; import BreadcrumbNav from "./BreadcrumbNav"; import { API_URL, ENVIRONMENT } from "@/config"; import "./RequestManagement.css"; @@ -88,6 +89,8 @@ export default function MediaRequestForm() { const [audioProgress, setAudioProgress] = useState({ current: 0, duration: 0 }); const [diskSpace, setDiskSpace] = useState(null); + const { attachScrollFix, cleanupScrollFix } = useAutoCompleteScrollFix(); + const debounceTimeout = useRef | null>(null); const autoCompleteRef = useRef(null); const metadataFetchToastId = useRef(null); @@ -951,30 +954,7 @@ export default function MediaRequestForm() { }); }; - // Attach scroll fix for autocomplete panel - const attachScrollFix = () => { - setTimeout(() => { - const panel = document.querySelector(".p-autocomplete-panel"); - const items = panel?.querySelector(".p-autocomplete-items") as HTMLElement | null; - 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); - }; + // Submit request handler with progress indicator const handleSubmitRequest = async () => { @@ -1139,6 +1119,7 @@ export default function MediaRequestForm() { className="w-full" inputClassName="w-full px-3 py-2 rounded border border-neutral-300 dark:border-neutral-600 text-black dark:text-white dark:bg-neutral-800" onShow={attachScrollFix} + onHide={cleanupScrollFix} itemTemplate={artistItemTemplate} /> diff --git a/src/components/TRip/RequestManagement.tsx b/src/components/TRip/RequestManagement.tsx index 9534617..d798da6 100644 --- a/src/components/TRip/RequestManagement.tsx +++ b/src/components/TRip/RequestManagement.tsx @@ -62,7 +62,7 @@ export default function RequestManagement() { // Check if path is /storage/music/TRIP if (absPath.includes("/storage/music/TRIP/")) { - return `https://music.boatson.boats/TRIP/${filename}`; + return `https://_music.codey.lol/TRIP/${filename}`; } // Otherwise, assume /storage/music2/completed/{quality} format diff --git a/src/components/req/ReqForm.tsx b/src/components/req/ReqForm.tsx index 261d372..ae57abc 100644 --- a/src/components/req/ReqForm.tsx +++ b/src/components/req/ReqForm.tsx @@ -4,6 +4,7 @@ import { Button } from "@mui/joy"; // Dropdown not used in this form; removed to avoid unused-import warnings import { AutoComplete } from "primereact/autocomplete"; import { InputText } from "primereact/inputtext"; +import { useAutoCompleteScrollFix } from "@/hooks/useAutoCompleteScrollFix"; declare global { interface Window { @@ -45,6 +46,7 @@ export default function ReqForm() { const [posterLoading, setPosterLoading] = useState(true); const [submittedRequest, setSubmittedRequest] = useState(null); // Track successful submission const [csrfToken, setCsrfToken] = useState(null); + const { attachScrollFix, cleanupScrollFix } = useAutoCompleteScrollFix(); // Get CSRF token from window global on mount useEffect(() => { @@ -151,29 +153,7 @@ export default function ReqForm() { // Token was already refreshed from the submit response }; - const attachScrollFix = () => { - setTimeout(() => { - const panel = document.querySelector(".p-autocomplete-panel"); - const items = panel?.querySelector(".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) => { if (!mediaTypeValue) return ""; @@ -286,6 +266,7 @@ export default function ReqForm() { field="label" autoComplete="off" onShow={attachScrollFix} + onHide={cleanupScrollFix} itemTemplate={(item) => (
{item.label} diff --git a/src/hooks/useAutoCompleteScrollFix.ts b/src/hooks/useAutoCompleteScrollFix.ts new file mode 100644 index 0000000..f250861 --- /dev/null +++ b/src/hooks/useAutoCompleteScrollFix.ts @@ -0,0 +1,168 @@ +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(); + * + * + * ``` + */ +export function useAutoCompleteScrollFix(maxHeight = '200px') { + const observerRef = useRef(null); + const wheelHandlerRef = useRef<((e: WheelEvent) => void) | null>(null); + const itemsRef = useRef(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); + } + }; +} diff --git a/src/pages/login.astro b/src/pages/login.astro index 53fa75f..197d0a4 100644 --- a/src/pages/login.astro +++ b/src/pages/login.astro @@ -5,6 +5,21 @@ import Root from "@/components/AppLayout.jsx"; import { requireAuthHook } from '@/hooks/requireAuthHook'; const user = await requireAuthHook(Astro); const isLoggedIn = Boolean(user); +const cookieHeader = Astro.request.headers.get('cookie') ?? ''; +const EXTERNAL_RETURN_COOKIE = 'ext_return'; +const EXTERNAL_RETURN_WINDOW_MS = 15000; + +const getCookie = (name: string): string | null => { + if (!cookieHeader) return null; + const parts = cookieHeader.split(';'); + for (const part of parts) { + const [rawKey, ...rawVal] = part.trim().split('='); + if (rawKey === name) { + return rawVal.join('=') || null; + } + } + return null; +}; // Detect if returnUrl is external (nginx-protected vhost redirect) // 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'); @@ -31,7 +46,19 @@ if (returnUrl && !returnUrl.startsWith('/')) { // 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 -const accessDenied = Astro.locals.accessDenied || (isLoggedIn && isExternalReturn); +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 @@ -53,7 +80,21 @@ if (isLoggedIn && !accessDenied) { } } // No returnUrl or external URL (access denied case handled above) - redirect to home - return Astro.redirect('/', 302); + 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 }); } ---