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

86
public/styles/player.css Normal file
View 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 */
}

View File

@@ -1,36 +1,6 @@
/* Universal box-sizing for consistency */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* player.css moved to /public/styles/player.css — kept empty here to avoid
accidental inclusion by the build tool during development. */
:root {
--lrc-text-color: #333; /* darker text */
--lrc-bg-color: rgba(0, 0, 0, 0.05);
--lrc-active-color: #000; /* bold black for active */
--lrc-active-shadow: none; /* no glow in light mode */
--lrc-hover-color: #005fcc; /* darker blue hover */
}
[data-theme="dark"] {
--lrc-text-color: #ccc; /* original gray */
--lrc-bg-color: rgba(255, 255, 255, 0.02);
--lrc-active-color: #fff; /* bright white for active */
--lrc-active-shadow: 0 0 4px rgba(212, 175, 55, 0.6); /* gold glow */
--lrc-hover-color: #4fa2ff; /* original blue hover */
}
body {
font-family: sans-serif;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
min-width: 100vw;
min-height: 100vh;
/* background: linear-gradient(-45deg, #FFCDD2 50%, #B2EBF2 50%); */
}
/* Container for the player and album cover */
.music-container {

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,26 +1051,24 @@ 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
e.preventDefault();
handleToggleAllAlbums();
}}
className="text-sm text-blue-600 hover:underline cursor-pointer"
className="text-sm text-blue-600 hover:underline cursor-pointer text-center sm:text-right"
>
Check / Uncheck All Albums
</a>
</div>
</div>
<Accordion
multiple
className="mt-4"
@@ -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,6 +1169,7 @@ export default function MediaRequestForm() {
return (
<li key={track.id} className="py-2">
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-3">
<input
type="checkbox"
@@ -1197,6 +1196,7 @@ export default function MediaRequestForm() {
<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>
);
}

View File

@@ -16,6 +16,24 @@ export const RADIO_API_URL = "https://radio-api.codey.lol";
export const socialLinks = {
};
export const MAJOR_VERSION = "0.3"
export const MAJOR_VERSION = "0.4"
export const RELEASE_FLAG = null;
export const ENVIRONMENT = import.meta.env.DEV ? "Dev" : "Prod";
// 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',
};

View File

@@ -6,10 +6,11 @@ interface Props {
}
import Themes from "astro-themes";
import { ViewTransitions } from "astro:transitions";
import BaseHead from "../components/BaseHead.astro";
import Navbar from "./Nav.astro";
import { metaData } from "../config";
import Nav from "./Nav.astro";
import SubNav from "./SubNav.astro";
import Footer from "../components/Footer.astro";
import "@fontsource/geist-sans/400.css";
@@ -19,21 +20,55 @@ import "@fontsource/geist-mono/600.css";
import "@styles/global.css";
import "@fonts/fonts.css";
const { title, description, image } = Astro.props;
const hostHeader = Astro.request?.headers?.get('host') || '';
const host = hostHeader.split(':')[0];
import { getSubsiteByHost, getSubsiteFromSignal } from '../utils/subsites.js';
// Determine if this request maps to a subsite (either via host or path)
// support legacy detection if path starts with /subsites/req — default host should match SUBSITES
// also support path-layout detection (e.g. /subsites/req)
import { getSubsiteByPath } from '../utils/subsites.js';
const detectedSubsite = getSubsiteByHost(host) ?? getSubsiteByPath(Astro.url.pathname) ?? null;
const isReq = detectedSubsite?.short === 'req';
const isReqSubdomain = host?.startsWith('req.');
import { WHITELABELS } from "../config";
// Accept forced whitelabel via query param, headers or request locals (set by middleware)
const forcedParam = Astro.url.searchParams.get('whitelabel') || Astro.request?.headers?.get('x-whitelabel') || (Astro.request as any)?.locals?.whitelabel || null;
let whitelabel: any = null;
if (forcedParam) {
const forced = getSubsiteFromSignal(forcedParam);
if (forced) whitelabel = WHITELABELS[forced.host] ?? null;
}
// fallback: by host mapping or legacy /subsites/req detection
if (!whitelabel) {
whitelabel = WHITELABELS[host] ?? (isReq ? WHITELABELS[detectedSubsite.host] : null);
}
// Determine whether we consider this request a subsite. Middleware will set
// request locals.isSubsite, which we trust here, but as a fallback also use
// the presence of a whitelabel mapping or a detected subsite path.
const isSubsite = (Astro.request as any)?.locals?.isSubsite ?? Boolean(whitelabel || detectedSubsite);
// Debug logging
console.log(`[Base.astro] host: ${host}, forcedParam: ${forcedParam}, isReq: ${isReq}, whitelabel: ${JSON.stringify(whitelabel)}`);
---
<html lang="en" class="scrollbar-hide lenis lenis-smooth">
<html lang="en" class="scrollbar-hide lenis lenis-smooth" data-subsite={isSubsite ? 'true' : 'false'}>
<head>
<ViewTransitions />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="googlebot"
content="index, follow, max-video-preview:-1, max-image-preview:large, max-snippet:-1"
/>
<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>
import "@scripts/lenisSmoothScroll.js";
import "@scripts/main.jsx";
@@ -41,19 +76,23 @@ const { title, description, image } = Astro.props;
</head>
<body
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
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>
<div style="background: #f44336; color: white; padding: 1em; text-align: center;">
This site requires JavaScript to function. Please enable JavaScript in your browser.
</div>
</noscript>
<Navbar />
{whitelabel ? <SubNav whitelabel={whitelabel} subsite={detectedSubsite} /> : <Nav />}
<slot />
<Footer />
</main>
<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 */
.scrollbar-hide::-webkit-scrollbar {
display: none;
@@ -64,5 +103,20 @@ const { title, description, image } = Astro.props;
scrollbar-width: none;
}
</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>
</html>

View File

@@ -6,6 +6,12 @@ import { padlockIconSvg, userIconSvg, externalLinkIconSvg } from "@/utils/navAss
import "@/assets/styles/nav.css";
const user = await requireAuthHook(Astro);
const hostHeader = Astro.request?.headers?.get('host') || '';
const host = hostHeader.split(':')[0];
import { getSubsiteByHost } from '../utils/subsites.js';
import { getSubsiteByPath } from '../utils/subsites.js';
const isReq = getSubsiteByHost(host)?.short === 'req' || getSubsiteByPath(Astro.url.pathname)?.short === 'req';
// Nav is the standard site navigation — whitelabel logic belongs in SubNav
const isLoggedIn = Boolean(user);
const userDisplayName = user?.user ?? null;
@@ -45,8 +51,7 @@ const currentPath = Astro.url.pathname;
<!-- Logo/Brand -->
<a
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}
</a>
@@ -70,9 +75,10 @@ const currentPath = Astro.url.pathname;
<a
href={item.href}
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"
}
style={isActive ? `background: #111827` : undefined}
target={isExternal ? "_blank" : undefined}
rel={(isExternal || isAuthedPath) ? "external" : undefined}
onclick={item.onclick}
@@ -174,9 +180,10 @@ const currentPath = Astro.url.pathname;
<a
href={item.href}
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"
}
style={isActive ? `background: #111827` : undefined}
target={isExternal ? "_blank" : undefined}
rel={(isExternal || isAuthedPath) ? "external" : undefined}
onclick={item.onclick}

11
src/layouts/SubNav.astro Normal file
View 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} />}

View 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;

View 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>

View 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>

View File

@@ -1,16 +1,122 @@
import { defineMiddleware } from 'astro:middleware';
import { SUBSITES } from './config.js';
import { getSubsiteByHost, getSubsiteFromSignal } from './utils/subsites.js';
export const onRequest = defineMiddleware(async (context, next) => {
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
const response = await next();
// 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} -> /`);
return context.redirect('/', 302);
}
// no post-processing of response
return response;
} catch (error) {
// Handle any middleware errors by redirecting to home

112
src/pages/api/search.js Normal file
View 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
View 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
View 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>

View File

@@ -2,15 +2,35 @@
import Base from "../layouts/Base.astro";
import Root from "../components/AppLayout.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>
{whitelabel ? (
<section>
<div class="prose prose-neutral dark:prose-invert">
<Root child="ReqForm" client:only="react">
</Root>
</div>
</section>
) : (
<section>
<div class="prose prose-neutral dark:prose-invert">
<Root
child="LyricSearch"
client:only="react"
/>
</div>
</section>
)}
</Base>

View File

@@ -10,7 +10,7 @@ const isLoggedIn = Boolean(user);
<Base>
<section>
<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>
</section>
</Base>

View File

@@ -1,6 +1,7 @@
---
import Base from "../layouts/Base.astro";
import Root from "../components/AppLayout.jsx";
// The Base layout exposes runtime subsite state — no per-page detection needed
import { requireAuthHook } from "@/hooks/requireAuthHook";
const user = await requireAuthHook(Astro);
---

View 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>

View 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
View 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,
};