diff --git a/public/styles/player.css b/public/styles/player.css new file mode 100644 index 0000000..c71ff3c --- /dev/null +++ b/public/styles/player.css @@ -0,0 +1,86 @@ +/* Universal box-sizing for consistency */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +:root { + --lrc-text-color: #333; /* darker text */ + --lrc-bg-color: rgba(0, 0, 0, 0.05); + --lrc-active-color: #000; /* bold black for active */ + --lrc-active-shadow: none; /* no glow in light mode */ + --lrc-hover-color: #005fcc; /* darker blue hover */ +} + +[data-theme="dark"] { + --lrc-text-color: #ccc; /* original gray */ + --lrc-bg-color: rgba(255, 255, 255, 0.02); + --lrc-active-color: #fff; /* bright white for active */ + --lrc-active-shadow: 0 0 4px rgba(212, 175, 55, 0.6); /* gold glow */ + --lrc-hover-color: #4fa2ff; /* original blue hover */ +} + +body { + font-family: sans-serif; + width: 100%; + height: 100%; + margin: 0; + padding: 0; + min-width: 100vw; + min-height: 100vh; + /* background: linear-gradient(-45deg, #FFCDD2 50%, #B2EBF2 50%); */ +} + +/* Container for the player and album cover */ +.music-container { + width: 800px; /* fixed desktop width */ + max-width: 90vw; /* prevent overflow on smaller screens */ + height: auto !important; + margin: 0 auto 120px auto; /* increased bottom margin */ + overflow-x: visible; /* allow horizontal overflow if needed */ + overflow-y: hidden; + position: relative; + display: flex; + justify-content: center; + align-items: center; + box-sizing: border-box; + padding: 1rem; + box-shadow: 1px 1px 5px 0 rgba(0, 0, 0, 0.3); +} + +/* Album cover section */ +.album-cover { + aspect-ratio: 1 / 1; + width: 100%; + max-width: 30%; + height: auto; +} + +.album-cover > img { + width: 100%; + height: 100%; + object-fit: cover; +} + + +.album-cover > img:hover { + opacity: 0.7; +} + +/* Player info and controls */ +.music-player { + flex: 1 1 70%; /* Take remaining ~70% */ + max-width: 70%; + width: auto; + height: auto !important; /* Match container height */ + padding: 1em; + text-align: center; + display: flex; + flex-direction: column; + justify-content: center; + background: inherit; + box-sizing: border-box; + min-width: 0; /* Fix flex overflow */ + flex-shrink: 0; /* Prevent shrinking that hides content */ +} diff --git a/src/assets/styles/player.css b/src/assets/styles/player.css index 72ae265..83a2bee 100644 --- a/src/assets/styles/player.css +++ b/src/assets/styles/player.css @@ -1,36 +1,6 @@ -/* Universal box-sizing for consistency */ -*, -*::before, -*::after { - box-sizing: border-box; -} +/* player.css moved to /public/styles/player.css — kept empty here to avoid + accidental inclusion by the build tool during development. */ -:root { - --lrc-text-color: #333; /* darker text */ - --lrc-bg-color: rgba(0, 0, 0, 0.05); - --lrc-active-color: #000; /* bold black for active */ - --lrc-active-shadow: none; /* no glow in light mode */ - --lrc-hover-color: #005fcc; /* darker blue hover */ -} - -[data-theme="dark"] { - --lrc-text-color: #ccc; /* original gray */ - --lrc-bg-color: rgba(255, 255, 255, 0.02); - --lrc-active-color: #fff; /* bright white for active */ - --lrc-active-shadow: 0 0 4px rgba(212, 175, 55, 0.6); /* gold glow */ - --lrc-hover-color: #4fa2ff; /* original blue hover */ -} - -body { - font-family: sans-serif; - width: 100%; - height: 100%; - margin: 0; - padding: 0; - min-width: 100vw; - min-height: 100vh; - /* background: linear-gradient(-45deg, #FFCDD2 50%, #B2EBF2 50%); */ -} /* Container for the player and album cover */ .music-container { diff --git a/src/components/AppLayout.jsx b/src/components/AppLayout.jsx index 9f0dbcc..0e73510 100644 --- a/src/components/AppLayout.jsx +++ b/src/components/AppLayout.jsx @@ -1,4 +1,4 @@ -import React, { Suspense, lazy } from 'react'; +import React, { Suspense, lazy, useState, useMemo, useEffect } from 'react'; import Memes from './Memes.jsx'; import Lighting from './Lighting.jsx'; import { toast } from 'react-toastify'; @@ -14,13 +14,69 @@ const LoginPage = lazy(() => import('./Login.jsx')); const LyricSearch = lazy(() => import('./LyricSearch')); const MediaRequestForm = lazy(() => import('./TRip/MediaRequestForm.jsx')); const RequestManagement = lazy(() => import('./TRip/RequestManagement.jsx')); -const Player = lazy(() => import('./AudioPlayer.jsx')); +// NOTE: Player is intentionally NOT imported at module initialization. +// 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 +// prevents bundling the player island into pages that are explicitly +// identified as subsites. +const ReqForm = lazy(() => import('./req/ReqForm.jsx')); export default function Root({ child, user = undefined, ...props }) { window.toast = toast; const theme = document.documentElement.getAttribute("data-theme") const loggedIn = props.loggedIn ?? Boolean(user); usePrimeReactThemeSwitcher(theme); + // Avoid adding the Player island for subsite requests. We expose a + // runtime flag `window.__IS_SUBSITE` from the server layout so pages + // don't need to pass guards. + const isSubsite = typeof document !== 'undefined' && document.documentElement.getAttribute('data-subsite') === 'true'; + // Helpful runtime debugging: only log when child changes so we don't spam + // the console on every render. Use an effect so output is stable. + useEffect(() => { + try { + if (typeof console !== 'undefined' && typeof document !== 'undefined') { + console.debug(`[AppLayout] child=${String(child)}, data-subsite=${document.documentElement.getAttribute('data-subsite')}`); + } + } catch (e) { + // no-op + } + }, [child]); + + // Only initialize the lazy player when this is NOT a subsite and the + // active child is the Player island. Placing the lazy() call here + // avoids creating a static dependency at module load time. + // Create the lazy component only when we actually need it. Using + // `useMemo` ensures we don't re-create the lazy factory on every render + // which would create a new component identity and cause mount/unmount + // loops and repeated log messages. + const wantPlayer = !isSubsite && child === "Player"; + + // Use dynamic import+state on the client to avoid React.lazy identity + // churn and to surface any import-time errors. Since Root is used via + // client:only, this code runs in the browser and can safely import. + const [PlayerComp, setPlayerComp] = useState(null); + useEffect(() => { + let mounted = true; + if (wantPlayer) { + try { console.debug('[AppLayout] dynamic-import: requesting AudioPlayer'); } catch (e) { } + import('./AudioPlayer.jsx') + .then((mod) => { + if (!mounted) return; + // set the component factory + setPlayerComp(() => mod.default ?? null); + try { console.debug('[AppLayout] AudioPlayer import succeeded'); } catch (e) { } + }) + .catch((err) => { + console.error('[AppLayout] AudioPlayer import failed', err); + if (mounted) setPlayerComp(() => null); + }); + } else { + // unload if we no longer want the player + setPlayerComp(() => null); + } + return () => { mounted = false; }; + }, [wantPlayer]); + return ( */} {child == "LoginPage" && ()} {child == "LyricSearch" && ()} - {child == "Player" && ()} + {child == "Player" && !isSubsite && PlayerComp && ( + Loading player...}> + + + )} {child == "Memes" && } {child == "qs2.MediaRequestForm" && } {child == "qs2.RequestManagement" && } + {child == "ReqForm" && } {child == "Lighting" && } diff --git a/src/components/AudioPlayer.jsx b/src/components/AudioPlayer.jsx index 7844f71..32da0be 100644 --- a/src/components/AudioPlayer.jsx +++ b/src/components/AudioPlayer.jsx @@ -2,7 +2,10 @@ import React, { useState, useEffect, useRef, Suspense, lazy, useMemo, useCallbac import { metaData } from "../config"; import Play from "@mui/icons-material/PlayArrow"; import Pause from "@mui/icons-material/Pause"; -import "@styles/player.css"; +// Load AudioPlayer CSS at runtime only when the player mounts on the client. +// This avoids including the stylesheet in pages where the AudioPlayer never +// gets loaded (subsidiary/whitelabel sites). We dynamically import the +// stylesheet inside a client-only effect below. import { Dialog } from "primereact/dialog"; import { AutoComplete } from "primereact/autocomplete"; import { DataTable } from "primereact/datatable"; @@ -48,6 +51,31 @@ const STATIONS = { export default function Player({ user }) { + // Log lifecycle so we can confirm the player mounted in the browser + useEffect(() => { + // Load the player stylesheet from /public/styles/player.css so it will be + // requested at runtime only when the AudioPlayer mounts. This prevents the + // CSS from being included as a route asset for subsites. + try { + if (typeof window !== 'undefined') { + if (!document.getElementById('audio-player-css')) { + const link = document.createElement('link'); + link.id = 'audio-player-css'; + link.rel = 'stylesheet'; + link.href = '/styles/player.css'; + link.onload = () => { try { console.debug('[AudioPlayer] CSS loaded (link)'); } catch (e) { } }; + link.onerror = (err) => { console.warn('[AudioPlayer] CSS link failed', err); }; + document.head.appendChild(link); + } + } + } catch (e) { + // ignore + } + try { console.debug('[AudioPlayer] mounted'); } catch (e) { } + return () => { + try { console.debug('[AudioPlayer] unmounted'); } catch (e) { } + }; + }, []); // Inject custom paginator styles useEffect(() => { const styleId = 'queue-paginator-styles'; diff --git a/src/components/BaseHead.astro b/src/components/BaseHead.astro index 8a5863a..022b496 100644 --- a/src/components/BaseHead.astro +++ b/src/components/BaseHead.astro @@ -3,6 +3,7 @@ interface Props { title?: string; description?: string; image?: string; + isWhitelabel?: boolean; } import { metaData } from "../config"; @@ -11,21 +12,23 @@ import { JoyUIRootIsland } from "./Components" import { useHtmlThemeAttr } from "../hooks/useHtmlThemeAttr"; import { usePrimeReactThemeSwitcher } from "../hooks/usePrimeReactThemeSwitcher"; -const { title, description, image } = Astro.props; +const { title, description, image, isWhitelabel } = Astro.props; const { url } = Astro; -const shareTitle = title ? `${title} | ${metaData.title}` : metaData.shareTitle ?? metaData.title; +const trimmedTitle = title?.trim(); +const seoTitle = trimmedTitle || metaData.title; +const shareTitle = isWhitelabel ? (trimmedTitle || (metaData.shareTitle ?? metaData.title)) : (trimmedTitle ? `${trimmedTitle} | ${metaData.title}` : (metaData.shareTitle ?? metaData.title)); +const seoTitleTemplate = isWhitelabel ? "%s" : (trimmedTitle ? `%s | ${metaData.title}` : "%s"); const shareDescription = description ?? metaData.shareDescription ?? metaData.description; const canonicalUrl = url?.href ?? metaData.baseUrl; const shareImage = new URL(image ?? metaData.ogImage, metaData.baseUrl).toString(); const shareImageAlt = metaData.shareImageAlt ?? metaData.shareTitle ?? metaData.title; --- - - + {!whitelabel && }
Build# {buildNumber}
diff --git a/src/components/LyricSearch.jsx b/src/components/LyricSearch.jsx index 0132b1a..395e37b 100644 --- a/src/components/LyricSearch.jsx +++ b/src/components/LyricSearch.jsx @@ -17,7 +17,7 @@ import LinkIcon from '@mui/icons-material/Link'; import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded'; import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; import RemoveRoundedIcon from '@mui/icons-material/RemoveRounded'; -import { AutoComplete } from 'primereact/autocomplete'; +import { AutoComplete } from 'primereact/autocomplete/autocomplete.esm.js'; import { API_URL } from '../config'; export default function LyricSearch() { diff --git a/src/components/TRip/MediaRequestForm.jsx b/src/components/TRip/MediaRequestForm.jsx index a0a7c28..519a83e 100644 --- a/src/components/TRip/MediaRequestForm.jsx +++ b/src/components/TRip/MediaRequestForm.jsx @@ -1051,25 +1051,23 @@ export default function MediaRequestForm() { {type === "artist" && albums.length > 0 && ( <> -
-
+ } ({release_date}) - + {typeof tracksByAlbum[id] === 'undefined' ? ( loadingAlbumId === id ? 'Loading...' : '...' ) : ( @@ -1171,32 +1169,34 @@ export default function MediaRequestForm() { return (
  • -
    - toggleTrack(id, track.id)} - className="trip-checkbox cursor-pointer" - aria-label={`Select track ${track.title} `} - /> - +
    +
    + toggleTrack(id, track.id)} + className="trip-checkbox cursor-pointer" + aria-label={`Select track ${track.title} `} + /> + +
    {truncate(track.title, 80)} @@ -1207,7 +1207,7 @@ export default function MediaRequestForm() { )}
    -
    +
    {showProgress && ( -
    +
    { + if (title !== selectedTitle && selectedOverview) { + setSelectedOverview(""); + setSelectedTitle(""); + } + }, [title, selectedTitle, selectedOverview]); + + const searchTitles = async (event) => { + const query = event.query; + if (query.length < 3) { + setSuggestions([]); + return; + } + + try { + const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + const data = await response.json(); + setSuggestions(data); + } catch (error) { + console.error('Error fetching suggestions:', error); + setSuggestions([]); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!title.trim()) { + toast.error("Please fill in the required fields."); + return; + } + + setIsSubmitting(true); + try { + const response = await fetch('/api/submit', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ title, year, type, requester }), + }); + if (!response.ok) { + throw new Error('Submission failed'); + } + toast.success("Request submitted successfully!"); + // Reset form + setType(""); + setTitle(""); + setYear(""); + setRequester(""); + setSelectedOverview(""); + setSelectedTitle(""); + setSelectedItem(null); + } catch (error) { + console.error('Submission error:', error); + toast.error("Failed to submit request. Please try again."); + } finally { + setIsSubmitting(false); + } + }; + + 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) => { + 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); + }; + + return ( +
    +
    +
    +

    + Request Movies/TV +

    +

    + Submit your request for review +

    +
    +
    +
    + + setTitle(typeof e.value === 'string' ? e.value : e.value.label)} + onSelect={(e) => { + setType(e.value.mediaType === 'tv' ? 'tv' : 'movie'); + setTitle(e.value.label); + setSelectedTitle(e.value.label); + setSelectedItem(e.value); + if (e.value.year) setYear(e.value.year); + setSelectedOverview(e.value.overview || ""); + }} + placeholder="Enter movie or TV show title" + className="w-full" + inputClassName="w-full border-2 border-gray-200 dark:border-gray-600 rounded-xl px-4 py-3 focus:border-[#12f8f4] transition-colors" + panelClassName="border-2 border-gray-200 dark:border-gray-600 rounded-xl" + field="label" + onShow={attachScrollFix} + itemTemplate={(item) => ( +
    + {item.label} + {item.year && ({item.year})} + {item.mediaType === 'tv' ? 'TV' : 'Movie'} +
    + )} + /> +
    + + {selectedOverview && ( +
    + +
    +
    +
    +

    + {selectedOverview} +

    +
    + {selectedItem?.poster_path && ( + Poster + )} +
    +
    +
    + )} + +
    + + setYear(e.target.value)} + placeholder="e.g. 2023" + className="w-full border-2 border-gray-200 dark:border-gray-600 rounded-xl px-4 py-3 focus:border-[#12f8f4] transition-colors" + /> +
    + +
    + + setRequester(e.target.value)} + placeholder="Who is requesting this?" + className="w-full border-2 border-gray-200 dark:border-gray-600 rounded-xl px-4 py-3 focus:border-[#12f8f4] transition-colors" + /> +
    + +
    + +
    +
    +
    +
    + ); +} \ No newline at end of file diff --git a/src/config.js b/src/config.js index 778fd03..4e487cc 100644 --- a/src/config.js +++ b/src/config.js @@ -16,6 +16,24 @@ export const RADIO_API_URL = "https://radio-api.codey.lol"; export const socialLinks = { }; -export const MAJOR_VERSION = "0.3" +export const MAJOR_VERSION = "0.4" export const RELEASE_FLAG = null; -export const ENVIRONMENT = import.meta.env.DEV ? "Dev" : "Prod"; \ No newline at end of file +export const ENVIRONMENT = import.meta.env.DEV ? "Dev" : "Prod"; + +// Whitelabel overrides +export const WHITELABELS = { + 'req.boatson.boats': { + title: 'Request Media', + name: 'REQ', + brandColor: '#12f8f4', + siteTitle: 'Request Media', + logoText: 'Request Media', + }, +}; + +// Subsite mapping: host -> site path +// Keep this in sync with your page layout (e.g. src/pages/subsites/req/ -> /subsites/req) +export const SUBSITES = { + 'req.boatson.boats': '/subsites/req', + 'subsite2.codey.lol': '/subsite2', +}; \ No newline at end of file diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index b47703f..5c1e8bd 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -6,10 +6,11 @@ interface Props { } import Themes from "astro-themes"; -import { ViewTransitions } from "astro:transitions"; import BaseHead from "../components/BaseHead.astro"; -import Navbar from "./Nav.astro"; +import { metaData } from "../config"; +import Nav from "./Nav.astro"; +import SubNav from "./SubNav.astro"; import Footer from "../components/Footer.astro"; import "@fontsource/geist-sans/400.css"; @@ -19,21 +20,55 @@ import "@fontsource/geist-mono/600.css"; import "@styles/global.css"; import "@fonts/fonts.css"; - - const { title, description, image } = Astro.props; +const hostHeader = Astro.request?.headers?.get('host') || ''; +const host = hostHeader.split(':')[0]; + +import { getSubsiteByHost, getSubsiteFromSignal } from '../utils/subsites.js'; + +// Determine if this request maps to a subsite (either via host or path) +// support legacy detection if path starts with /subsites/req — default host should match SUBSITES +// also support path-layout detection (e.g. /subsites/req) +import { getSubsiteByPath } from '../utils/subsites.js'; +const detectedSubsite = getSubsiteByHost(host) ?? getSubsiteByPath(Astro.url.pathname) ?? null; +const isReq = detectedSubsite?.short === 'req'; +const isReqSubdomain = host?.startsWith('req.'); + +import { WHITELABELS } from "../config"; + +// Accept forced whitelabel via query param, headers or request locals (set by middleware) +const forcedParam = Astro.url.searchParams.get('whitelabel') || Astro.request?.headers?.get('x-whitelabel') || (Astro.request as any)?.locals?.whitelabel || null; + +let whitelabel: any = null; +if (forcedParam) { + const forced = getSubsiteFromSignal(forcedParam); + if (forced) whitelabel = WHITELABELS[forced.host] ?? null; +} + +// fallback: by host mapping or legacy /subsites/req detection +if (!whitelabel) { + whitelabel = WHITELABELS[host] ?? (isReq ? WHITELABELS[detectedSubsite.host] : null); +} + +// Determine whether we consider this request a subsite. Middleware will set +// request locals.isSubsite, which we trust here, but as a fallback also use +// the presence of a whitelabel mapping or a detected subsite path. +const isSubsite = (Astro.request as any)?.locals?.isSubsite ?? Boolean(whitelabel || detectedSubsite); + +// Debug logging +console.log(`[Base.astro] host: ${host}, forcedParam: ${forcedParam}, isReq: ${isReq}, whitelabel: ${JSON.stringify(whitelabel)}`); --- - + - - + + diff --git a/src/layouts/Nav.astro b/src/layouts/Nav.astro index 265c1f7..92e0985 100644 --- a/src/layouts/Nav.astro +++ b/src/layouts/Nav.astro @@ -6,6 +6,12 @@ import { padlockIconSvg, userIconSvg, externalLinkIconSvg } from "@/utils/navAss import "@/assets/styles/nav.css"; 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 const isLoggedIn = Boolean(user); const userDisplayName = user?.user ?? null; @@ -44,11 +50,10 @@ const currentPath = Astro.url.pathname;