refactor: add SubNav layout and per-subsite nav placeholders; switch Base to use SubNav
This commit is contained in:
86
public/styles/player.css
Normal file
86
public/styles/player.css
Normal file
@@ -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 */
|
||||||
|
}
|
||||||
@@ -1,36 +1,6 @@
|
|||||||
/* Universal box-sizing for consistency */
|
/* player.css moved to /public/styles/player.css — kept empty here to avoid
|
||||||
*,
|
accidental inclusion by the build tool during development. */
|
||||||
*::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 */
|
/* Container for the player and album cover */
|
||||||
.music-container {
|
.music-container {
|
||||||
|
|||||||
@@ -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 Memes from './Memes.jsx';
|
||||||
import Lighting from './Lighting.jsx';
|
import Lighting from './Lighting.jsx';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
@@ -14,13 +14,69 @@ const LoginPage = lazy(() => import('./Login.jsx'));
|
|||||||
const LyricSearch = lazy(() => import('./LyricSearch'));
|
const LyricSearch = lazy(() => import('./LyricSearch'));
|
||||||
const MediaRequestForm = lazy(() => import('./TRip/MediaRequestForm.jsx'));
|
const MediaRequestForm = lazy(() => import('./TRip/MediaRequestForm.jsx'));
|
||||||
const RequestManagement = lazy(() => import('./TRip/RequestManagement.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 }) {
|
export default function Root({ child, user = undefined, ...props }) {
|
||||||
window.toast = toast;
|
window.toast = toast;
|
||||||
const theme = document.documentElement.getAttribute("data-theme")
|
const theme = document.documentElement.getAttribute("data-theme")
|
||||||
const loggedIn = props.loggedIn ?? Boolean(user);
|
const loggedIn = props.loggedIn ?? Boolean(user);
|
||||||
usePrimeReactThemeSwitcher(theme);
|
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 (
|
return (
|
||||||
<PrimeReactProvider>
|
<PrimeReactProvider>
|
||||||
<CustomToastContainer
|
<CustomToastContainer
|
||||||
@@ -37,10 +93,15 @@ export default function Root({ child, user = undefined, ...props }) {
|
|||||||
</Alert> */}
|
</Alert> */}
|
||||||
{child == "LoginPage" && (<LoginPage {...props} loggedIn={loggedIn} />)}
|
{child == "LoginPage" && (<LoginPage {...props} loggedIn={loggedIn} />)}
|
||||||
{child == "LyricSearch" && (<LyricSearch {...props} client:only="react" />)}
|
{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 == "Memes" && <Memes client:only="react" />}
|
||||||
{child == "qs2.MediaRequestForm" && <MediaRequestForm client:only="react" />}
|
{child == "qs2.MediaRequestForm" && <MediaRequestForm client:only="react" />}
|
||||||
{child == "qs2.RequestManagement" && <RequestManagement 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" />}
|
{child == "Lighting" && <Lighting key={window.location.pathname + Math.random()} client:only="react" />}
|
||||||
</JoyUIRootIsland>
|
</JoyUIRootIsland>
|
||||||
</PrimeReactProvider>
|
</PrimeReactProvider>
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import React, { useState, useEffect, useRef, Suspense, lazy, useMemo, useCallbac
|
|||||||
import { metaData } from "../config";
|
import { metaData } from "../config";
|
||||||
import Play from "@mui/icons-material/PlayArrow";
|
import Play from "@mui/icons-material/PlayArrow";
|
||||||
import Pause from "@mui/icons-material/Pause";
|
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 { Dialog } from "primereact/dialog";
|
||||||
import { AutoComplete } from "primereact/autocomplete";
|
import { AutoComplete } from "primereact/autocomplete";
|
||||||
import { DataTable } from "primereact/datatable";
|
import { DataTable } from "primereact/datatable";
|
||||||
@@ -48,6 +51,31 @@ const STATIONS = {
|
|||||||
|
|
||||||
|
|
||||||
export default function Player({ user }) {
|
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
|
// Inject custom paginator styles
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const styleId = 'queue-paginator-styles';
|
const styleId = 'queue-paginator-styles';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ interface Props {
|
|||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
|
isWhitelabel?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
import { metaData } from "../config";
|
import { metaData } from "../config";
|
||||||
@@ -11,21 +12,23 @@ import { JoyUIRootIsland } from "./Components"
|
|||||||
import { useHtmlThemeAttr } from "../hooks/useHtmlThemeAttr";
|
import { useHtmlThemeAttr } from "../hooks/useHtmlThemeAttr";
|
||||||
import { usePrimeReactThemeSwitcher } from "../hooks/usePrimeReactThemeSwitcher";
|
import { usePrimeReactThemeSwitcher } from "../hooks/usePrimeReactThemeSwitcher";
|
||||||
|
|
||||||
const { title, description, image } = Astro.props;
|
const { title, description, image, isWhitelabel } = Astro.props;
|
||||||
|
|
||||||
const { url } = Astro;
|
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 shareDescription = description ?? metaData.shareDescription ?? metaData.description;
|
||||||
const canonicalUrl = url?.href ?? metaData.baseUrl;
|
const canonicalUrl = url?.href ?? metaData.baseUrl;
|
||||||
const shareImage = new URL(image ?? metaData.ogImage, metaData.baseUrl).toString();
|
const shareImage = new URL(image ?? metaData.ogImage, metaData.baseUrl).toString();
|
||||||
const shareImageAlt = metaData.shareImageAlt ?? metaData.shareTitle ?? metaData.title;
|
const shareImageAlt = metaData.shareImageAlt ?? metaData.shareTitle ?? metaData.title;
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<SEO
|
<SEO
|
||||||
title={shareTitle}
|
title={seoTitle}
|
||||||
titleTemplate=`%s | ${metaData.title}`
|
titleTemplate={seoTitleTemplate}
|
||||||
titleDefault={metaData.title}
|
titleDefault={metaData.title}
|
||||||
description={shareDescription}
|
description={shareDescription}
|
||||||
charset="UTF-8"
|
charset="UTF-8"
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
---
|
---
|
||||||
import { metaData, ENVIRONMENT } from "../config";
|
import { metaData, ENVIRONMENT, WHITELABELS } from "../config";
|
||||||
import RandomMsg from "../components/RandomMsg";
|
import RandomMsg from "../components/RandomMsg";
|
||||||
import { buildTime, buildNumber } from '../utils/buildTime.js';
|
import { buildTime, buildNumber } from '../utils/buildTime.js';
|
||||||
|
|
||||||
const YEAR = new Date().getFullYear();
|
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">
|
<div class="footer">
|
||||||
<RandomMsg client:only="react" />
|
{!whitelabel && <RandomMsg client:only="react" />}
|
||||||
<div style="margin-top: 15px; bottom: 0%">
|
<div style="margin-top: 15px; bottom: 0%">
|
||||||
<small>Build# {buildNumber}
|
<small>Build# {buildNumber}
|
||||||
<br>
|
<br>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import LinkIcon from '@mui/icons-material/Link';
|
|||||||
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
|
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
|
||||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
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';
|
import { AutoComplete } from 'primereact/autocomplete/autocomplete.esm.js';
|
||||||
import { API_URL } from '../config';
|
import { API_URL } from '../config';
|
||||||
|
|
||||||
export default function LyricSearch() {
|
export default function LyricSearch() {
|
||||||
|
|||||||
@@ -1051,25 +1051,23 @@ export default function MediaRequestForm() {
|
|||||||
|
|
||||||
{type === "artist" && albums.length > 0 && (
|
{type === "artist" && albums.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between items-center mb-2">
|
<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">
|
<div className="text-sm text-neutral-600 dark:text-neutral-400 text-center sm:text-left">
|
||||||
<strong className="mr-2">Albums:</strong> {totalAlbums}
|
<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}
|
<strong className="mr-2">Tracks:</strong> {totalTracks}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<a
|
||||||
<a
|
href="#"
|
||||||
href="#"
|
role="button"
|
||||||
role="button"
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.preventDefault();
|
||||||
e.preventDefault(); // prevent page jump
|
handleToggleAllAlbums();
|
||||||
handleToggleAllAlbums();
|
}}
|
||||||
}}
|
className="text-sm text-blue-600 hover:underline cursor-pointer text-center sm:text-right"
|
||||||
className="text-sm text-blue-600 hover:underline cursor-pointer"
|
>
|
||||||
>
|
Check / Uncheck All Albums
|
||||||
Check / Uncheck All Albums
|
</a>
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Accordion
|
<Accordion
|
||||||
multiple
|
multiple
|
||||||
@@ -1148,7 +1146,7 @@ export default function MediaRequestForm() {
|
|||||||
{loadingAlbumId === id && <Spinner />}
|
{loadingAlbumId === id && <Spinner />}
|
||||||
</span>
|
</span>
|
||||||
<small className="ml-2 text-neutral-500 dark:text-neutral-400">({release_date})</small>
|
<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' ? (
|
{typeof tracksByAlbum[id] === 'undefined' ? (
|
||||||
loadingAlbumId === id ? 'Loading...' : '...'
|
loadingAlbumId === id ? 'Loading...' : '...'
|
||||||
) : (
|
) : (
|
||||||
@@ -1171,32 +1169,34 @@ export default function MediaRequestForm() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={track.id} className="py-2">
|
<li key={track.id} className="py-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<input
|
<div className="flex items-center gap-3">
|
||||||
type="checkbox"
|
<input
|
||||||
checked={selected?.includes(String(track.id))}
|
type="checkbox"
|
||||||
onChange={() => toggleTrack(id, track.id)}
|
checked={selected?.includes(String(track.id))}
|
||||||
className="trip-checkbox cursor-pointer"
|
onChange={() => toggleTrack(id, track.id)}
|
||||||
aria-label={`Select track ${track.title} `}
|
className="trip-checkbox cursor-pointer"
|
||||||
/>
|
aria-label={`Select track ${track.title} `}
|
||||||
<button
|
/>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => handleTrackPlayPause(track, id, albumIndex)}
|
type="button"
|
||||||
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
|
onClick={() => handleTrackPlayPause(track, id, albumIndex)}
|
||||||
? "border-green-600 text-green-600"
|
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-neutral-400 text-neutral-600 hover:text-blue-600 hover:border-blue-600"}`}
|
? "border-green-600 text-green-600"
|
||||||
aria-label={`${isCurrentTrack && isAudioPlaying ? "Pause" : "Play"} ${track.title}`}
|
: "border-neutral-400 text-neutral-600 hover:text-blue-600 hover:border-blue-600"}`}
|
||||||
aria-pressed={isCurrentTrack && isAudioPlaying}
|
aria-label={`${isCurrentTrack && isAudioPlaying ? "Pause" : "Play"} ${track.title}`}
|
||||||
disabled={audioLoadingTrackId === track.id}
|
aria-pressed={isCurrentTrack && isAudioPlaying}
|
||||||
>
|
disabled={audioLoadingTrackId === track.id}
|
||||||
{audioLoadingTrackId === track.id ? (
|
>
|
||||||
<InlineSpinner sizeClass="h-4 w-4" />
|
{audioLoadingTrackId === track.id ? (
|
||||||
) : isCurrentTrack && isAudioPlaying ? (
|
<InlineSpinner sizeClass="h-4 w-4" />
|
||||||
<PauseIcon />
|
) : isCurrentTrack && isAudioPlaying ? (
|
||||||
) : (
|
<PauseIcon />
|
||||||
<PlayIcon />
|
) : (
|
||||||
)}
|
<PlayIcon />
|
||||||
</button>
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="flex-1 min-w-0 text-left">
|
<div className="flex-1 min-w-0 text-left">
|
||||||
<span className="block truncate" title={track.title}>
|
<span className="block truncate" title={track.title}>
|
||||||
{truncate(track.title, 80)}
|
{truncate(track.title, 80)}
|
||||||
@@ -1207,7 +1207,7 @@ export default function MediaRequestForm() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleTrackDownload(track)}
|
onClick={() => handleTrackDownload(track)}
|
||||||
@@ -1223,7 +1223,7 @@ export default function MediaRequestForm() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showProgress && (
|
{showProgress && (
|
||||||
<div className="mt-2 pr-6 pl-16">
|
<div className="mt-2 pr-2 pl-4 sm:pr-6 sm:pl-16">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
|
|||||||
215
src/components/req/ReqForm.jsx
Normal file
215
src/components/req/ReqForm.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,24 @@ export const RADIO_API_URL = "https://radio-api.codey.lol";
|
|||||||
export const socialLinks = {
|
export const socialLinks = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MAJOR_VERSION = "0.3"
|
export const MAJOR_VERSION = "0.4"
|
||||||
export const RELEASE_FLAG = null;
|
export const RELEASE_FLAG = null;
|
||||||
export const ENVIRONMENT = import.meta.env.DEV ? "Dev" : "Prod";
|
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',
|
||||||
|
};
|
||||||
@@ -6,10 +6,11 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
import Themes from "astro-themes";
|
import Themes from "astro-themes";
|
||||||
import { ViewTransitions } from "astro:transitions";
|
|
||||||
|
|
||||||
import BaseHead from "../components/BaseHead.astro";
|
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 Footer from "../components/Footer.astro";
|
||||||
|
|
||||||
import "@fontsource/geist-sans/400.css";
|
import "@fontsource/geist-sans/400.css";
|
||||||
@@ -19,21 +20,55 @@ import "@fontsource/geist-mono/600.css";
|
|||||||
import "@styles/global.css";
|
import "@styles/global.css";
|
||||||
import "@fonts/fonts.css";
|
import "@fonts/fonts.css";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const { title, description, image } = Astro.props;
|
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)}`);
|
||||||
---
|
---
|
||||||
|
|
||||||
<html lang="en" class="scrollbar-hide lenis lenis-smooth">
|
<html lang="en" class="scrollbar-hide lenis lenis-smooth" data-subsite={isSubsite ? 'true' : 'false'}>
|
||||||
<head>
|
<head>
|
||||||
<ViewTransitions />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta
|
<meta
|
||||||
name="googlebot"
|
name="googlebot"
|
||||||
content="index, follow, max-video-preview:-1, max-image-preview:large, max-snippet:-1"
|
content="index, follow, max-video-preview:-1, max-image-preview:large, max-snippet:-1"
|
||||||
/>
|
/>
|
||||||
<Themes />
|
<Themes />
|
||||||
<BaseHead title={title} description={description} image={image} />
|
<BaseHead title={whitelabel?.siteTitle ?? title} description={description} image={image ?? metaData.ogImage} isWhitelabel={!!whitelabel} />
|
||||||
|
<!-- Subsite state is available on the html[data-subsite] attribute -->
|
||||||
<script>
|
<script>
|
||||||
import "@scripts/lenisSmoothScroll.js";
|
import "@scripts/lenisSmoothScroll.js";
|
||||||
import "@scripts/main.jsx";
|
import "@scripts/main.jsx";
|
||||||
@@ -41,19 +76,23 @@ const { title, description, image } = Astro.props;
|
|||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
class="antialiased flex flex-col items-center justify-center mx-auto mt-2 lg:mt-8 mb-20 lg:mb-40
|
class="antialiased flex flex-col items-center justify-center mx-auto mt-2 lg:mt-8 mb-20 lg:mb-40
|
||||||
scrollbar-hide">
|
scrollbar-hide"
|
||||||
|
style={`--brand-color: ${whitelabel?.brandColor ?? '#111827'}`}>
|
||||||
<main
|
<main
|
||||||
class="flex-auto min-w-0 mt-2 md:mt-6 flex flex-col px-6 sm:px-4 md:px-0 max-w-3xl w-full">
|
class="page-enter flex-auto min-w-0 mt-2 md:mt-6 flex flex-col px-6 sm:px-4 md:px-0 max-w-3xl w-full">
|
||||||
<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.
|
||||||
</div>
|
</div>
|
||||||
</noscript>
|
</noscript>
|
||||||
<Navbar />
|
{whitelabel ? <SubNav whitelabel={whitelabel} subsite={detectedSubsite} /> : <Nav />}
|
||||||
<slot />
|
<slot />
|
||||||
<Footer />
|
<Footer />
|
||||||
</main>
|
</main>
|
||||||
<style>
|
<style>
|
||||||
|
/* Minimal page transition to replace deprecated ViewTransitions */
|
||||||
|
.page-enter { opacity: 0; transform: translateY(6px); transition: opacity 220ms ease, transform 240ms ease; }
|
||||||
|
html.page-ready .page-enter { opacity: 1; transform: none; }
|
||||||
/* CSS rules for the page scrollbar */
|
/* CSS rules for the page scrollbar */
|
||||||
.scrollbar-hide::-webkit-scrollbar {
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -64,5 +103,20 @@ const { title, description, image } = Astro.props;
|
|||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Mark the page ready so CSS transitions run (replaces ViewTransitions).
|
||||||
|
// We don't rely on any Astro-specific API here — just a small client-side toggle.
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
// Add page-ready on the next animation frame so the transition always runs.
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
requestAnimationFrame(() => document.documentElement.classList.add('page-ready'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import { padlockIconSvg, userIconSvg, externalLinkIconSvg } from "@/utils/navAss
|
|||||||
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
|
||||||
const isLoggedIn = Boolean(user);
|
const isLoggedIn = Boolean(user);
|
||||||
const userDisplayName = user?.user ?? null;
|
const userDisplayName = user?.user ?? null;
|
||||||
|
|
||||||
@@ -44,11 +50,10 @@ const currentPath = Astro.url.pathname;
|
|||||||
<div class="nav-bar-row flex items-center gap-4 justify-between">
|
<div class="nav-bar-row flex items-center gap-4 justify-between">
|
||||||
<!-- Logo/Brand -->
|
<!-- Logo/Brand -->
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="text-xl sm:text-2xl font-semibold header-text whitespace-nowrap hover:opacity-80 transition-opacity"
|
class="text-xl sm:text-2xl font-semibold header-text whitespace-nowrap hover:opacity-80 transition-opacity">
|
||||||
>
|
{metaData.title}
|
||||||
{metaData.title}
|
</a>
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Desktop Navigation -->
|
<!-- Desktop Navigation -->
|
||||||
<div class="desktop-nav flex items-center">
|
<div class="desktop-nav flex items-center">
|
||||||
@@ -70,9 +75,10 @@ const currentPath = Astro.url.pathname;
|
|||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class={isActive
|
class={isActive
|
||||||
? "flex items-center gap-0 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all duration-200 bg-neutral-900 dark:bg-neutral-100 text-white dark:text-neutral-900"
|
? "flex items-center gap-0 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all duration-200 text-white"
|
||||||
: "flex items-center gap-0 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all duration-200 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
: "flex items-center gap-0 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all duration-200 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||||
}
|
}
|
||||||
|
style={isActive ? `background: #111827` : undefined}
|
||||||
target={isExternal ? "_blank" : undefined}
|
target={isExternal ? "_blank" : undefined}
|
||||||
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
||||||
onclick={item.onclick}
|
onclick={item.onclick}
|
||||||
@@ -174,9 +180,10 @@ const currentPath = Astro.url.pathname;
|
|||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class={isActive
|
class={isActive
|
||||||
? "flex items-center gap-0 px-4 py-3 rounded-lg text-base font-medium transition-all duration-200 bg-neutral-900 dark:bg-neutral-100 text-white dark:text-neutral-900"
|
? "flex items-center gap-0 px-4 py-3 rounded-lg text-base font-medium transition-all duration-200 text-white"
|
||||||
: "flex items-center gap-0 px-4 py-3 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"
|
: "flex items-center gap-0 px-4 py-3 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"
|
||||||
}
|
}
|
||||||
|
style={isActive ? `background: #111827` : undefined}
|
||||||
target={isExternal ? "_blank" : undefined}
|
target={isExternal ? "_blank" : undefined}
|
||||||
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
||||||
onclick={item.onclick}
|
onclick={item.onclick}
|
||||||
|
|||||||
11
src/layouts/SubNav.astro
Normal file
11
src/layouts/SubNav.astro
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
// Generic subsite navigation router — renders a per-subsite nav when available
|
||||||
|
// Props: whitelabel: object, subsite: { host, path, short }
|
||||||
|
const whitelabel = Astro.props?.whitelabel ?? null;
|
||||||
|
const subsite = Astro.props?.subsite ?? null;
|
||||||
|
|
||||||
|
import ReqSubNav from './subsites/reqNav.astro';
|
||||||
|
import DefaultSubNav from './subsites/defaultNav.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
{subsite?.short === 'req' ? <ReqSubNav whitelabel={whitelabel} /> : <DefaultSubNav whitelabel={whitelabel} />}
|
||||||
27
src/layouts/WhitelabelLayout.jsx
Normal file
27
src/layouts/WhitelabelLayout.jsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
const WhitelabelLayout = ({ children, header, footer, customStyles }) => {
|
||||||
|
return (
|
||||||
|
<div style={customStyles} className="whitelabel-layout">
|
||||||
|
{header && <header className="whitelabel-header">{header}</header>}
|
||||||
|
<main className="whitelabel-main">{children}</main>
|
||||||
|
{footer && <footer className="whitelabel-footer">{footer}</footer>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
WhitelabelLayout.propTypes = {
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
header: PropTypes.node,
|
||||||
|
footer: PropTypes.node,
|
||||||
|
customStyles: PropTypes.object,
|
||||||
|
};
|
||||||
|
|
||||||
|
WhitelabelLayout.defaultProps = {
|
||||||
|
header: null,
|
||||||
|
footer: null,
|
||||||
|
customStyles: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WhitelabelLayout;
|
||||||
18
src/layouts/subsites/defaultNav.astro
Normal file
18
src/layouts/subsites/defaultNav.astro
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
// Default subsite nav — used when a subsite exists but no specialized nav is available
|
||||||
|
const whitelabel = Astro.props?.whitelabel ?? null;
|
||||||
|
const currentPath = Astro.url.pathname;
|
||||||
|
---
|
||||||
|
|
||||||
|
<nav class="w-full px-4 sm:px-6 py-4 bg-transparent sticky top-0 z-50 backdrop-blur-sm bg-white/80 dark:bg-[#121212]/80 border-b border-neutral-200/50 dark:border-neutral-800/50">
|
||||||
|
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
||||||
|
<a href="/" class="text-xl sm:text-2xl font-semibold" style={`color: ${whitelabel?.brandColor ?? 'var(--brand-color)'}`}>
|
||||||
|
{whitelabel?.logoText ?? 'Subsite'}
|
||||||
|
</a>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<!-- placeholder for future global subsite nav items -->
|
||||||
|
<button aria-label="Toggle theme" type="button" class="flex items-center justify-center w-8 h-8 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors" onclick="toggleTheme()">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
26
src/layouts/subsites/reqNav.astro
Normal file
26
src/layouts/subsites/reqNav.astro
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
// Req specific subsite nav placeholder. Keeps markup minimal for now.
|
||||||
|
import { Icon } from "astro-icon/components";
|
||||||
|
const whitelabel = Astro.props?.whitelabel ?? null;
|
||||||
|
const currentPath = Astro.url.pathname;
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
// Add req-specific nav items here in future
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<nav class="w-full px-4 sm:px-6 py-4 bg-transparent sticky top-0 z-50 backdrop-blur-sm bg-white/80 dark:bg-[#121212]/80 border-b border-neutral-200/50 dark:border-neutral-800/50">
|
||||||
|
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
||||||
|
<a href="/" class="text-xl sm:text-2xl font-semibold" style={`color: ${whitelabel?.brandColor ?? 'var(--brand-color)'}`}>
|
||||||
|
{whitelabel?.logoText ?? 'REQ'}
|
||||||
|
</a>
|
||||||
|
<ul class="flex items-center gap-4">
|
||||||
|
<!-- currently empty; future subsite-specific links go here -->
|
||||||
|
<li>
|
||||||
|
<button aria-label="Toggle theme" type="button" class="flex items-center justify-center w-8 h-8 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors" onclick="toggleTheme()">
|
||||||
|
<Icon name="fa6-solid:circle-half-stroke" class="h-4 w-4 text-[#1c1c1c] dark:text-[#D4D4D4]" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
@@ -1,16 +1,122 @@
|
|||||||
import { defineMiddleware } from 'astro:middleware';
|
import { defineMiddleware } from 'astro:middleware';
|
||||||
|
import { SUBSITES } from './config.js';
|
||||||
|
import { getSubsiteByHost, getSubsiteFromSignal } from './utils/subsites.js';
|
||||||
|
|
||||||
export const onRequest = defineMiddleware(async (context, next) => {
|
export const onRequest = defineMiddleware(async (context, next) => {
|
||||||
try {
|
try {
|
||||||
|
// Check the Host header to differentiate subdomains
|
||||||
|
// Build a headers map safely because Headers.get(':authority') throws
|
||||||
|
const headersMap = {};
|
||||||
|
for (const [k, v] of context.request.headers) {
|
||||||
|
headersMap[k.toLowerCase()] = v;
|
||||||
|
}
|
||||||
|
const hostHeader = headersMap['host'] || '';
|
||||||
|
// Node/http2 might store host as :authority (pseudo header); it appears under iteration as ':authority'
|
||||||
|
const authorityHeader = headersMap[':authority'] || '';
|
||||||
|
// Fallback to context.url.hostname if available (some environments populate it)
|
||||||
|
const urlHost = context.url?.hostname || '';
|
||||||
|
const host = (hostHeader || authorityHeader || urlHost).split(':')[0]; // normalize remove port
|
||||||
|
|
||||||
|
// Debug info for incoming requests
|
||||||
|
console.log(`[middleware] incoming: host=${hostHeader} path=${context.url.pathname}`);
|
||||||
|
|
||||||
|
// When the host header is missing, log all headers for debugging and
|
||||||
|
// attempt to determine host from forwarded headers or a dev query header.
|
||||||
|
if (!host) {
|
||||||
|
console.log('[middleware] WARNING: Host header missing. Dumping headers:');
|
||||||
|
for (const [k, v] of context.request.headers) {
|
||||||
|
console.log(`[middleware] header: ${k}=${v}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if how the request says it wants the whitelabel
|
||||||
|
const forwardedHost = headersMap['x-forwarded-host'] || '';
|
||||||
|
const xWhitelabel = headersMap['x-whitelabel'] || '';
|
||||||
|
const forceWhitelabelQuery = context.url.searchParams.get('whitelabel');
|
||||||
|
|
||||||
|
// Make whitelabel detection dynamic based on `SUBSITES` mapping and incoming signals
|
||||||
|
// We'll detect by full host (host/forwarded/authority) or by short-name match
|
||||||
|
const subsiteHosts = Object.keys(SUBSITES || {});
|
||||||
|
|
||||||
|
const hostSubsite = getSubsiteByHost(host);
|
||||||
|
const forwardedSubsite = getSubsiteByHost(forwardedHost);
|
||||||
|
const authoritySubsite = getSubsiteByHost(authorityHeader);
|
||||||
|
const headerSignalSubsite = getSubsiteFromSignal(xWhitelabel);
|
||||||
|
const querySignalSubsite = getSubsiteFromSignal(forceWhitelabelQuery);
|
||||||
|
|
||||||
|
const wantsSubsite = Boolean(hostSubsite || forwardedSubsite || authoritySubsite || headerSignalSubsite || querySignalSubsite);
|
||||||
|
|
||||||
|
// Use central SUBSITES mapping
|
||||||
|
// import from config.js near top to ensure single source of truth
|
||||||
|
// (we import lazily here for compatibility with middleware runtime)
|
||||||
|
const subsites = SUBSITES || {};
|
||||||
|
|
||||||
|
// Check if the request matches a subsite
|
||||||
|
const subsitePath = subsites[host];
|
||||||
|
if (subsitePath) {
|
||||||
|
const skipPrefixes = ['/_astro', '/_', '/assets', '/scripts', '/favicon', '/api', '/robots.txt', '/_static'];
|
||||||
|
const shouldSkip = skipPrefixes.some((p) => context.url.pathname.startsWith(p));
|
||||||
|
|
||||||
|
if (!shouldSkip && !context.url.pathname.startsWith(subsitePath)) {
|
||||||
|
console.log(`[middleware] Rewriting ${host} ${context.url.pathname} -> ${subsitePath}${context.url.pathname}`);
|
||||||
|
context.url.pathname = `${subsitePath}${context.url.pathname}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If the path appears to be a subsite path (like /subsites/req) but the host isn't a subsite,
|
||||||
|
// block so the main site doesn't accidentally serve that content.
|
||||||
|
const allPaths = Object.values(subsites || {});
|
||||||
|
const pathLooksLikeSubsite = allPaths.some((p) => context.url.pathname.startsWith(p));
|
||||||
|
if (pathLooksLikeSubsite) {
|
||||||
|
console.log(`[middleware] Blocking subsite path on main domain: ${host}${context.url.pathname}`);
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block /subsites/req/* on main domain (codey.lol, local.codey.lol, etc)
|
||||||
|
const isMainDomain = !wantsSubsite;
|
||||||
|
if (isMainDomain && Object.values(subsites || {}).some(p => context.url.pathname.startsWith(p))) {
|
||||||
|
// Immediately return a 404 for /req on the main domain
|
||||||
|
if (Object.values(subsites || {}).includes(context.url.pathname)) {
|
||||||
|
console.log(`[middleware] Blocking subsite root on main domain: ${hostHeader}${context.url.pathname}`);
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
console.log(`[middleware] Blocking subsite wildcard path on main domain: ${hostHeader}${context.url.pathname}`);
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly handle the forceWhitelabelQuery to rewrite the path
|
||||||
|
if (forceWhitelabelQuery) {
|
||||||
|
const forced = getSubsiteFromSignal(forceWhitelabelQuery);
|
||||||
|
const subsitePath = forced?.path || (`/${forceWhitelabelQuery}`.startsWith('/') ? `/${forceWhitelabelQuery}` : `/${forceWhitelabelQuery}`);
|
||||||
|
console.log(`[middleware] Forcing whitelabel via query: ${forceWhitelabelQuery} -> rewrite to ${subsitePath}${context.url.pathname}`);
|
||||||
|
context.url.pathname = `${subsitePath}${context.url.pathname}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass the whitelabel value explicitly to context.locals
|
||||||
|
// set a normalized whitelabel short name if available
|
||||||
|
const chosen = querySignalSubsite?.short || headerSignalSubsite?.short || hostSubsite?.short || forwardedSubsite?.short || authoritySubsite?.short || null;
|
||||||
|
context.locals.whitelabel = chosen;
|
||||||
|
// Also make it explicit whether this request maps to a subsite (any configured SUBSITES value)
|
||||||
|
// Middleware already resolved `wantsSubsite` which is true if host/forwarded/authority/header/query indicate a subsite.
|
||||||
|
// Expose a simple boolean so server-rendered pages/layouts or components can opt-out of loading/hydrating
|
||||||
|
// heavyweight subsystems (like the AudioPlayer) when the request is for a subsite.
|
||||||
|
context.locals.isSubsite = Boolean(wantsSubsite);
|
||||||
|
console.log(`[middleware] Setting context.locals.whitelabel: ${context.locals.whitelabel}`);
|
||||||
|
|
||||||
|
// Final debug: show the final pathname we hand to Astro
|
||||||
|
console.log(`[middleware] Final pathname: ${context.url.pathname}`);
|
||||||
|
|
||||||
// Let Astro handle the request first
|
// Let Astro handle the request first
|
||||||
const response = await next();
|
const response = await next();
|
||||||
|
|
||||||
// If it's a 404, redirect to home
|
// If it's a 404, redirect to home
|
||||||
if (response.status === 404) {
|
if (response.status === 404 && !context.url.pathname.startsWith('/api/')) {
|
||||||
console.log(`404 redirect: ${context.url.pathname} -> /`);
|
console.log(`404 redirect: ${context.url.pathname} -> /`);
|
||||||
return context.redirect('/', 302);
|
return context.redirect('/', 302);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// no post-processing of response
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle any middleware errors by redirecting to home
|
// Handle any middleware errors by redirecting to home
|
||||||
|
|||||||
112
src/pages/api/search.js
Normal file
112
src/pages/api/search.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
const rateLimitMap = new Map();
|
||||||
|
|
||||||
|
function checkRateLimit(userId, limit = 5, windowMs = 1000) {
|
||||||
|
const now = Date.now();
|
||||||
|
const key = userId;
|
||||||
|
const entry = rateLimitMap.get(key);
|
||||||
|
|
||||||
|
if (!entry || now > entry.resetTime) {
|
||||||
|
rateLimitMap.set(key, { count: 1, resetTime: now + windowMs });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.count >= limit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
import { getSubsiteByHost } from '../../utils/subsites.js';
|
||||||
|
|
||||||
|
export async function GET({ request }) {
|
||||||
|
const host = request.headers.get('host');
|
||||||
|
const subsite = getSubsiteByHost(host);
|
||||||
|
|
||||||
|
if (!subsite || subsite.short !== 'req') {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let userId = request.headers.get('cookie')?.split(';').find(c => c.trim().startsWith('nonce='))?.split('=')[1];
|
||||||
|
const hadCookie = !!userId;
|
||||||
|
if (!userId) {
|
||||||
|
userId = crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkRateLimit(userId)) {
|
||||||
|
const response = new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
if (!hadCookie) {
|
||||||
|
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TMDB_API_KEY = import.meta.env.TMDB_API_KEY;
|
||||||
|
|
||||||
|
if (!TMDB_API_KEY) {
|
||||||
|
console.error('TMDB_API_KEY not set');
|
||||||
|
const response = new Response(JSON.stringify([]), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
if (!hadCookie) {
|
||||||
|
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const q = url.searchParams.get('q');
|
||||||
|
|
||||||
|
if (!q || typeof q !== 'string' || !q.trim()) {
|
||||||
|
const response = new Response(JSON.stringify([]), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
if (!hadCookie) {
|
||||||
|
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q.length > 100) {
|
||||||
|
const response = new Response(JSON.stringify([]), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
if (!hadCookie) {
|
||||||
|
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiResponse = await fetch(`https://api.themoviedb.org/3/search/multi?api_key=${TMDB_API_KEY}&query=${encodeURIComponent(q)}`);
|
||||||
|
if (!apiResponse.ok) {
|
||||||
|
throw new Error(`TMDB API error: ${apiResponse.status}`);
|
||||||
|
}
|
||||||
|
const data = await apiResponse.json();
|
||||||
|
const seen = new Set();
|
||||||
|
const filtered = data.results
|
||||||
|
.filter(item => {
|
||||||
|
if (item.media_type !== 'movie' && item.media_type !== 'tv') return false;
|
||||||
|
const key = `${item.media_type}-${item.title || item.name}-${item.release_date?.split('-')[0] || item.first_air_date?.split('-')[0] || ''}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.slice(0, 10) // Limit to 10 suggestions
|
||||||
|
.map(item => ({
|
||||||
|
label: item.title || item.name,
|
||||||
|
value: item.title || item.name,
|
||||||
|
year: item.release_date?.split('-')[0] || item.first_air_date?.split('-')[0],
|
||||||
|
mediaType: item.media_type,
|
||||||
|
overview: item.overview,
|
||||||
|
poster_path: item.poster_path,
|
||||||
|
}));
|
||||||
|
const response = new Response(JSON.stringify(filtered), { headers: { 'Content-Type': 'application/json' } });
|
||||||
|
if (!hadCookie) {
|
||||||
|
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching suggestions:', error);
|
||||||
|
const response = new Response(JSON.stringify([]), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
if (!hadCookie) {
|
||||||
|
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
234
src/pages/api/submit.js
Normal file
234
src/pages/api/submit.js
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
const rateLimitMap = new Map();
|
||||||
|
|
||||||
|
function checkRateLimit(userId, limit = 1, windowMs = 15000) { // 1 per 15 seconds for submits
|
||||||
|
const now = Date.now();
|
||||||
|
const key = userId;
|
||||||
|
const entry = rateLimitMap.get(key);
|
||||||
|
|
||||||
|
if (!entry || now > entry.resetTime) {
|
||||||
|
return true; // Allow, will be recorded later
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.count >= limit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true; // Allow, will be recorded later
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordRequest(userId, windowMs = 15000) {
|
||||||
|
const now = Date.now();
|
||||||
|
const key = userId;
|
||||||
|
const entry = rateLimitMap.get(key);
|
||||||
|
|
||||||
|
if (!entry || now > entry.resetTime) {
|
||||||
|
rateLimitMap.set(key, { count: 1, resetTime: now + windowMs });
|
||||||
|
} else {
|
||||||
|
entry.count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
import { getSubsiteByHost } from '../../utils/subsites.js';
|
||||||
|
|
||||||
|
export async function POST({ request }) {
|
||||||
|
const host = request.headers.get('host');
|
||||||
|
const subsite = getSubsiteByHost(host);
|
||||||
|
if (!subsite || subsite.short !== 'req') {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let userId = request.headers.get('cookie')?.split(';').find(c => c.trim().startsWith('nonce='))?.split('=')[1];
|
||||||
|
const hadCookie = !!userId;
|
||||||
|
if (!userId) {
|
||||||
|
userId = crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkRateLimit(userId)) {
|
||||||
|
const response = new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
if (!hadCookie) {
|
||||||
|
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DISCORD_WEBHOOK_URL = import.meta.env.BUDO_REQ_DISCORD_WEBHOOK_URL;
|
||||||
|
|
||||||
|
if (!DISCORD_WEBHOOK_URL) {
|
||||||
|
console.error('DISCORD_WEBHOOK_URL not set');
|
||||||
|
const response = new Response(JSON.stringify({ error: 'Webhook not configured' }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
if (!hadCookie) {
|
||||||
|
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { title, year, type, requester } = await request.json();
|
||||||
|
|
||||||
|
// Input validation
|
||||||
|
if (!title || typeof title !== 'string' || !title.trim()) {
|
||||||
|
const response = new Response(JSON.stringify({ error: 'Title is required and must be a non-empty string' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
if (!hadCookie) {
|
||||||
|
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title.length > 200) {
|
||||||
|
const response = new Response(JSON.stringify({ error: 'Title must be 200 characters or less' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
if (!hadCookie) {
|
||||||
|
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['movie', 'tv'].includes(type)) {
|
||||||
|
const response = new Response(JSON.stringify({ error: 'Type must be either "movie" or "tv"' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
if (!hadCookie) {
|
||||||
|
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (year && (typeof year !== 'string' || !/^\d{4}$/.test(year))) {
|
||||||
|
const response = new Response(JSON.stringify({ error: 'Year must be a 4-digit number if provided' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
if (!hadCookie) {
|
||||||
|
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
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' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
if (!hadCookie) {
|
||||||
|
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch synopsis and IMDb ID from TMDB
|
||||||
|
let synopsis = '';
|
||||||
|
let imdbId = '';
|
||||||
|
let matchingItem = null;
|
||||||
|
try {
|
||||||
|
const searchResponse = await fetch(`https://api.themoviedb.org/3/search/multi?api_key=${import.meta.env.TMDB_API_KEY}&query=${encodeURIComponent(title)}`);
|
||||||
|
if (searchResponse.ok) {
|
||||||
|
const searchData = await searchResponse.json();
|
||||||
|
matchingItem = searchData.results.find(item =>
|
||||||
|
item.media_type === type &&
|
||||||
|
(item.title || item.name) === title
|
||||||
|
);
|
||||||
|
if (matchingItem) {
|
||||||
|
synopsis = matchingItem.overview || '';
|
||||||
|
|
||||||
|
// Get detailed info for IMDb ID
|
||||||
|
const detailEndpoint = type === 'movie' ? `movie/${matchingItem.id}` : `tv/${matchingItem.id}`;
|
||||||
|
const detailResponse = await fetch(`https://api.themoviedb.org/3/${detailEndpoint}?api_key=${import.meta.env.TMDB_API_KEY}`);
|
||||||
|
if (detailResponse.ok) {
|
||||||
|
const detailData = await detailResponse.json();
|
||||||
|
imdbId = detailData.imdb_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching synopsis and IMDb:', error);
|
||||||
|
// Continue without synopsis
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = [
|
||||||
|
{
|
||||||
|
name: "Title",
|
||||||
|
value: title,
|
||||||
|
inline: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Type",
|
||||||
|
value: type === 'tv' ? 'TV Show' : 'Movie',
|
||||||
|
inline: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (year) {
|
||||||
|
fields.push({
|
||||||
|
name: "Year",
|
||||||
|
value: year,
|
||||||
|
inline: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (synopsis) {
|
||||||
|
fields.push({
|
||||||
|
name: "Synopsis",
|
||||||
|
value: synopsis,
|
||||||
|
inline: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imdbId) {
|
||||||
|
fields.push({
|
||||||
|
name: "IMDb",
|
||||||
|
value: `[View on IMDb](https://www.imdb.com/title/${imdbId}/)`,
|
||||||
|
inline: false
|
||||||
|
});
|
||||||
|
} else if (matchingItem) {
|
||||||
|
const tmdbUrl = `https://www.themoviedb.org/${type}/${matchingItem.id}`;
|
||||||
|
fields.push({
|
||||||
|
name: "TMDB",
|
||||||
|
value: `[View on TMDB](${tmdbUrl})`,
|
||||||
|
inline: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requester && requester.trim()) {
|
||||||
|
fields.push({
|
||||||
|
name: "Requested by",
|
||||||
|
value: requester.trim(),
|
||||||
|
inline: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = {
|
||||||
|
title: type === 'tv' ? "📺 New TV Show Request" : "🎥 New Movie Request",
|
||||||
|
color: type === 'tv' ? 0x4ecdc4 : 0xff6b6b,
|
||||||
|
fields: fields,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
footer: {
|
||||||
|
text: subsite.host || 'req.boatson.boats'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (matchingItem && matchingItem.poster_path) {
|
||||||
|
embed.image = { url: `https://image.tmdb.org/t/p/w780${matchingItem.poster_path}` };
|
||||||
|
} else {
|
||||||
|
// Test image
|
||||||
|
embed.image = { url: 'https://image.tmdb.org/t/p/w780/9O7gLzmreU0nGkIB6K3BsJbzvNv.jpg' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiResponse = await fetch(DISCORD_WEBHOOK_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ embeds: [embed] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!apiResponse.ok) {
|
||||||
|
throw new Error(`Discord webhook error: ${apiResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordRequest(userId);
|
||||||
|
|
||||||
|
const response = new Response(JSON.stringify({ success: true }), { headers: { 'Content-Type': 'application/json' } });
|
||||||
|
if (!hadCookie) {
|
||||||
|
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Webhook submission error:', error);
|
||||||
|
const response = new Response(JSON.stringify({ error: 'Failed to submit' }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
if (!hadCookie) {
|
||||||
|
response.headers.set('Set-Cookie', `nonce=${userId}; HttpOnly; Path=/; Max-Age=31536000`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/pages/debug.astro
Normal file
24
src/pages/debug.astro
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
---
|
||||||
|
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Debug</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Debug</h1>
|
||||||
|
<ul>
|
||||||
|
<li>Host: {Astro.request.headers.get('host')}</li>
|
||||||
|
<li>Path: {Astro.url.pathname}</li>
|
||||||
|
<li>Is dev: {String(import.meta.env.DEV)}</li>
|
||||||
|
</ul>
|
||||||
|
<h2>Headers</h2>
|
||||||
|
<ul>
|
||||||
|
{Array.from(Astro.request.headers.entries()).map(([k, v]) => (
|
||||||
|
<li>{k}: {v}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -2,15 +2,35 @@
|
|||||||
import Base from "../layouts/Base.astro";
|
import Base from "../layouts/Base.astro";
|
||||||
import Root from "../components/AppLayout.jsx";
|
import Root from "../components/AppLayout.jsx";
|
||||||
import LyricSearch from '../components/LyricSearch.jsx';
|
import LyricSearch from '../components/LyricSearch.jsx';
|
||||||
|
|
||||||
|
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' || getSubsiteByPath(Astro.url.pathname)?.short === 'req';
|
||||||
|
|
||||||
|
import { WHITELABELS } from "../config";
|
||||||
|
const whitelabel = WHITELABELS[host] ?? (detected ? WHITELABELS[detected.host] : null);
|
||||||
---
|
---
|
||||||
|
|
||||||
<Base>
|
<Base>
|
||||||
<section>
|
{whitelabel ? (
|
||||||
<div class="prose prose-neutral dark:prose-invert">
|
<section>
|
||||||
<Root
|
<div class="prose prose-neutral dark:prose-invert">
|
||||||
child="LyricSearch"
|
<Root child="ReqForm" client:only="react">
|
||||||
client:only="react"
|
</Root>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
) : (
|
||||||
|
<section>
|
||||||
|
<div class="prose prose-neutral dark:prose-invert">
|
||||||
|
<Root
|
||||||
|
child="LyricSearch"
|
||||||
|
client:only="react"
|
||||||
|
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const isLoggedIn = Boolean(user);
|
|||||||
<Base>
|
<Base>
|
||||||
<section>
|
<section>
|
||||||
<div class="prose prose-neutral dark:prose-invert">
|
<div class="prose prose-neutral dark:prose-invert">
|
||||||
<Root child="LoginPage" loggedIn={isLoggedIn} client:only="react" >
|
<Root child="LoginPage" loggedIn={isLoggedIn} client:only="react">
|
||||||
</Root>
|
</Root>
|
||||||
</section>
|
</section>
|
||||||
</Base>
|
</Base>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import Base from "../layouts/Base.astro";
|
import Base from "../layouts/Base.astro";
|
||||||
import Root from "../components/AppLayout.jsx";
|
import Root from "../components/AppLayout.jsx";
|
||||||
|
// The Base layout exposes runtime subsite state — no per-page detection needed
|
||||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||||
const user = await requireAuthHook(Astro);
|
const user = await requireAuthHook(Astro);
|
||||||
---
|
---
|
||||||
|
|||||||
8
src/pages/subsites/req/debug.astro
Normal file
8
src/pages/subsites/req/debug.astro
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
import Base from "../../../layouts/Base.astro";
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base>
|
||||||
|
<h2>Req subsite debug</h2>
|
||||||
|
<pre>{JSON.stringify({ headers: Object.fromEntries(Astro.request.headers.entries()), pathname: Astro.url.pathname }, null, 2)}</pre>
|
||||||
|
</Base>
|
||||||
8
src/pages/subsites/req/index.astro
Normal file
8
src/pages/subsites/req/index.astro
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
import Base from "../../../layouts/Base.astro";
|
||||||
|
import ReqForm from "../../../components/req/ReqForm.jsx";
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base>
|
||||||
|
<ReqForm client:load />
|
||||||
|
</Base>
|
||||||
62
src/utils/subsites.js
Normal file
62
src/utils/subsites.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { SUBSITES, WHITELABELS } from '../config.js';
|
||||||
|
|
||||||
|
// Returns normalized host (no port)
|
||||||
|
function normalizeHost(host = '') {
|
||||||
|
if (!host) return '';
|
||||||
|
return host.split(':')[0].toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOSTS = Object.keys(SUBSITES || {});
|
||||||
|
|
||||||
|
export function getSubsiteByHost(rawHost = '') {
|
||||||
|
const host = normalizeHost(rawHost || '');
|
||||||
|
if (!host) return null;
|
||||||
|
if (SUBSITES[host]) return { host, path: SUBSITES[host], short: host.split('.')[0] };
|
||||||
|
// fallback: if short-name match
|
||||||
|
const short = host.split('.')[0];
|
||||||
|
const hostKey = HOSTS.find(h => h.split('.')[0] === short);
|
||||||
|
if (hostKey) return { host: hostKey, path: SUBSITES[hostKey], short };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSubsiteFromSignal(signal = '') {
|
||||||
|
if (!signal) return null;
|
||||||
|
// signal can be 'req' or 'req.boatson.boats'
|
||||||
|
const val = signal.split(':')[0].split('?')[0];
|
||||||
|
const short = val.split('.')[0];
|
||||||
|
// direct host match
|
||||||
|
if (SUBSITES[val]) return { host: val, path: SUBSITES[val], short };
|
||||||
|
// short name match
|
||||||
|
const hostKey = HOSTS.find(h => h.split('.')[0] === short);
|
||||||
|
if (hostKey) return { host: hostKey, path: SUBSITES[hostKey], short };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSubsiteByPath(path = '') {
|
||||||
|
if (!path) return null;
|
||||||
|
// check if path starts with one of the SUBSITES values
|
||||||
|
const candidate = Object.entries(SUBSITES || {}).find(([, p]) => path.startsWith(p));
|
||||||
|
if (!candidate) return null;
|
||||||
|
const [hostKey, p] = candidate;
|
||||||
|
return { host: hostKey, path: p, short: hostKey.split('.')[0] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSubsiteHost(rawHost = '', shortName = '') {
|
||||||
|
const h = getSubsiteByHost(rawHost);
|
||||||
|
if (!h) return false;
|
||||||
|
if (!shortName) return true;
|
||||||
|
return h.short === shortName || h.host === shortName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWhitelabelForHost(rawHost = '') {
|
||||||
|
const info = getSubsiteByHost(rawHost);
|
||||||
|
if (!info) return null;
|
||||||
|
return WHITELABELS[info.host] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getSubsiteByHost,
|
||||||
|
getSubsiteFromSignal,
|
||||||
|
isSubsiteHost,
|
||||||
|
getWhitelabelForHost,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user