refactor: add SubNav layout and per-subsite nav placeholders; switch Base to use SubNav

This commit is contained in:
2025-11-28 09:07:55 -05:00
parent de50889b2c
commit d8d6c5ec21
26 changed files with 1227 additions and 122 deletions

View File

@@ -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 (
<PrimeReactProvider>
<CustomToastContainer
@@ -37,10 +93,15 @@ export default function Root({ child, user = undefined, ...props }) {
</Alert> */}
{child == "LoginPage" && (<LoginPage {...props} loggedIn={loggedIn} />)}
{child == "LyricSearch" && (<LyricSearch {...props} client:only="react" />)}
{child == "Player" && (<Player client:only="react" user={user} />)}
{child == "Player" && !isSubsite && PlayerComp && (
<Suspense fallback={<div data-testid="player-fallback" className="p-4 text-center">Loading player...</div>}>
<PlayerComp client:only="react" user={user} />
</Suspense>
)}
{child == "Memes" && <Memes client:only="react" />}
{child == "qs2.MediaRequestForm" && <MediaRequestForm client:only="react" />}
{child == "qs2.RequestManagement" && <RequestManagement client:only="react" />}
{child == "ReqForm" && <ReqForm client:only="react" />}
{child == "Lighting" && <Lighting key={window.location.pathname + Math.random()} client:only="react" />}
</JoyUIRootIsland>
</PrimeReactProvider>

View File

@@ -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';

View File

@@ -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;
---
<SEO
title={shareTitle}
titleTemplate=`%s | ${metaData.title}`
title={seoTitle}
titleTemplate={seoTitleTemplate}
titleDefault={metaData.title}
description={shareDescription}
charset="UTF-8"

View File

@@ -1,15 +1,21 @@
---
import { metaData, ENVIRONMENT } from "../config";
import { metaData, ENVIRONMENT, WHITELABELS } from "../config";
import RandomMsg from "../components/RandomMsg";
import { buildTime, buildNumber } from '../utils/buildTime.js';
const YEAR = new Date().getFullYear();
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 detected = getSubsiteByHost(host) ?? getSubsiteByPath(Astro.url.pathname) ?? null;
const isReq = detected?.short === 'req';
const whitelabel = WHITELABELS[host] ?? (isReq ? WHITELABELS[detected.host] : null);
---
<div class="footer">
<RandomMsg client:only="react" />
{!whitelabel && <RandomMsg client:only="react" />}
<div style="margin-top: 15px; bottom: 0%">
<small>Build# {buildNumber}
<br>

View File

@@ -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() {

View File

@@ -1051,25 +1051,23 @@ export default function MediaRequestForm() {
{type === "artist" && albums.length > 0 && (
<>
<div className="flex justify-between items-center mb-2">
<div className="text-sm text-neutral-600 dark:text-neutral-400">
<div className="flex flex-col gap-2 mb-2 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-neutral-600 dark:text-neutral-400 text-center sm:text-left">
<strong className="mr-2">Albums:</strong> {totalAlbums}
<span className="mx-3">|</span>
<span className="mx-3 sm:inline">|</span>
<strong className="mr-2">Tracks:</strong> {totalTracks}
</div>
<div>
<a
href="#"
role="button"
onClick={(e) => {
e.preventDefault(); // prevent page jump
handleToggleAllAlbums();
}}
className="text-sm text-blue-600 hover:underline cursor-pointer"
>
Check / Uncheck All Albums
</a>
</div>
<a
href="#"
role="button"
onClick={(e) => {
e.preventDefault();
handleToggleAllAlbums();
}}
className="text-sm text-blue-600 hover:underline cursor-pointer text-center sm:text-right"
>
Check / Uncheck All Albums
</a>
</div>
<Accordion
multiple
@@ -1148,7 +1146,7 @@ export default function MediaRequestForm() {
{loadingAlbumId === id && <Spinner />}
</span>
<small className="ml-2 text-neutral-500 dark:text-neutral-400">({release_date})</small>
<span className="ml-auto text-xs text-neutral-500">
<span className="ml-0 w-full text-xs text-neutral-500 sm:ml-auto sm:w-auto">
{typeof tracksByAlbum[id] === 'undefined' ? (
loadingAlbumId === id ? 'Loading...' : '...'
) : (
@@ -1171,32 +1169,34 @@ export default function MediaRequestForm() {
return (
<li key={track.id} className="py-2">
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={selected?.includes(String(track.id))}
onChange={() => toggleTrack(id, track.id)}
className="trip-checkbox cursor-pointer"
aria-label={`Select track ${track.title} `}
/>
<button
type="button"
onClick={() => handleTrackPlayPause(track, id, albumIndex)}
className={`flex items-center justify-center w-8 h-8 rounded-full border text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${isCurrentTrack && isAudioPlaying
? "border-green-600 text-green-600"
: "border-neutral-400 text-neutral-600 hover:text-blue-600 hover:border-blue-600"}`}
aria-label={`${isCurrentTrack && isAudioPlaying ? "Pause" : "Play"} ${track.title}`}
aria-pressed={isCurrentTrack && isAudioPlaying}
disabled={audioLoadingTrackId === track.id}
>
{audioLoadingTrackId === track.id ? (
<InlineSpinner sizeClass="h-4 w-4" />
) : isCurrentTrack && isAudioPlaying ? (
<PauseIcon />
) : (
<PlayIcon />
)}
</button>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={selected?.includes(String(track.id))}
onChange={() => toggleTrack(id, track.id)}
className="trip-checkbox cursor-pointer"
aria-label={`Select track ${track.title} `}
/>
<button
type="button"
onClick={() => handleTrackPlayPause(track, id, albumIndex)}
className={`flex items-center justify-center w-8 h-8 rounded-full border text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${isCurrentTrack && isAudioPlaying
? "border-green-600 text-green-600"
: "border-neutral-400 text-neutral-600 hover:text-blue-600 hover:border-blue-600"}`}
aria-label={`${isCurrentTrack && isAudioPlaying ? "Pause" : "Play"} ${track.title}`}
aria-pressed={isCurrentTrack && isAudioPlaying}
disabled={audioLoadingTrackId === track.id}
>
{audioLoadingTrackId === track.id ? (
<InlineSpinner sizeClass="h-4 w-4" />
) : isCurrentTrack && isAudioPlaying ? (
<PauseIcon />
) : (
<PlayIcon />
)}
</button>
</div>
<div className="flex-1 min-w-0 text-left">
<span className="block truncate" title={track.title}>
{truncate(track.title, 80)}
@@ -1207,7 +1207,7 @@ export default function MediaRequestForm() {
</span>
)}
</div>
<div className="flex items-center gap-3 ml-auto text-xs text-neutral-500">
<div className="flex items-center gap-3 text-xs text-neutral-500 w-full justify-between sm:w-auto sm:justify-end">
<button
type="button"
onClick={() => handleTrackDownload(track)}
@@ -1223,7 +1223,7 @@ export default function MediaRequestForm() {
</div>
</div>
{showProgress && (
<div className="mt-2 pr-6 pl-16">
<div className="mt-2 pr-2 pl-4 sm:pr-6 sm:pl-16">
<input
type="range"
min="0"

View File

@@ -0,0 +1,215 @@
import React, { useState, useEffect } from "react";
import { toast } from "react-toastify";
import { Button } from "@mui/joy";
import { Dropdown } from "primereact/dropdown/dropdown.esm.js";
import { AutoComplete } from "primereact/autocomplete/autocomplete.esm.js";
import { InputText } from "primereact/inputtext/inputtext.esm.js";
export default function ReqForm() {
const [type, setType] = useState("");
const [title, setTitle] = useState("");
const [year, setYear] = useState("");
const [requester, setRequester] = useState("");
const [selectedItem, setSelectedItem] = useState(null);
const [selectedOverview, setSelectedOverview] = useState("");
const [selectedTitle, setSelectedTitle] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [suggestions, setSuggestions] = useState([]);
useEffect(() => {
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 (
<div className="flex items-center justify-center min-h-[60vh] p-4">
<div className="w-full max-w-lg p-8 bg-white dark:bg-[#1E1E1E] rounded-3xl shadow-2xl border border-gray-200 dark:border-gray-700">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-800 dark:text-white mb-2">
Request Movies/TV
</h1>
<p className="text-gray-600 dark:text-gray-400 text-sm">
Submit your request for review
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<label htmlFor="title" className="block text-sm font-semibold text-gray-700 dark:text-gray-300">
Title <span className="text-red-500">*</span>
</label>
<AutoComplete
id="title"
value={title}
suggestions={suggestions}
completeMethod={searchTitles}
onChange={(e) => 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) => (
<div className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded">
<span className="font-medium">{item.label}</span>
{item.year && <span className="text-sm text-gray-500 ml-2">({item.year})</span>}
<span className="text-xs text-gray-400 ml-2 uppercase">{item.mediaType === 'tv' ? 'TV' : 'Movie'}</span>
</div>
)}
/>
</div>
{selectedOverview && (
<div className="space-y-2">
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300">
Synopsis
</label>
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-600">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<p className="text-sm text-gray-600 dark:text-gray-400 leading-relaxed">
{selectedOverview}
</p>
</div>
{selectedItem?.poster_path && (
<img
src={`https://image.tmdb.org/t/p/w200${selectedItem.poster_path}`}
alt="Poster"
className="w-24 sm:w-32 md:w-40 h-auto rounded-lg border border-gray-200 dark:border-gray-600"
/>
)}
</div>
</div>
</div>
)}
<div className="space-y-2">
<label htmlFor="year" className="block text-sm font-semibold text-gray-700 dark:text-gray-300">
Year <span className="text-gray-500">(optional)</span>
</label>
<InputText
id="year"
value={year}
onChange={(e) => 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"
/>
</div>
<div className="space-y-2">
<label htmlFor="requester" className="block text-sm font-semibold text-gray-700 dark:text-gray-300">
Your Name <span className="text-gray-500">(optional)</span>
</label>
<InputText
id="requester"
value={requester}
onChange={(e) => 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"
/>
</div>
<div className="pt-4">
<Button
type="submit"
disabled={isSubmitting}
className="w-full py-3 px-6 bg-[#12f8f4] text-gray-900 font-semibold rounded-xl disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? "Submitting..." : "Submit Request"}
</Button>
</div>
</form>
</div>
</div>
);
}