Compare commits
2 Commits
de50889b2c
...
ee33b86fe7
| Author | SHA1 | Date | |
|---|---|---|---|
| ee33b86fe7 | |||
| d8d6c5ec21 |
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,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,68 @@ 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';
|
||||
// Log when the active child changes (DEV only)
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (typeof console !== 'undefined' && typeof document !== 'undefined' && import.meta.env.DEV) {
|
||||
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) {
|
||||
if (import.meta.env.DEV) { 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);
|
||||
if (import.meta.env.DEV) { 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 +92,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={null}>
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, Suspense, lazy, useMemo, useCallback } from "react";
|
||||
import "@styles/player.css";
|
||||
import { metaData } from "../config";
|
||||
import Play from "@mui/icons-material/PlayArrow";
|
||||
import Pause from "@mui/icons-material/Pause";
|
||||
|
||||
@@ -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 shareDescription = description ?? metaData.shareDescription ?? metaData.description;
|
||||
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 = isWhitelabel ? "%s" : (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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function Lighting() {
|
||||
}, []);
|
||||
|
||||
const handleColorChange = (color) => {
|
||||
console.log('Handle color change:', color);
|
||||
if (import.meta.env.DEV) console.debug('Handle color change:', color);
|
||||
const { r, g, b } = color.rgb;
|
||||
updateLighting({
|
||||
...state,
|
||||
@@ -128,7 +128,7 @@ export default function Lighting() {
|
||||
s / 100, // saturation: 0-100 -> 0-1
|
||||
(v ?? 100) / 100 // value: 0-100 -> 0-1, default to 1 if undefined
|
||||
);
|
||||
console.log('Converting color:', color.hsva, 'to RGB:', rgb);
|
||||
if (import.meta.env.DEV) console.debug('Converting color:', color.hsva, 'to RGB:', rgb);
|
||||
updateLighting({
|
||||
...state,
|
||||
red: rgb.red,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1051,25 +1051,23 @@ export default function MediaRequestForm() {
|
||||
|
||||
{type === "artist" && albums.length > 0 && (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<div className="flex flex-col gap-2 mb-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-400 text-center sm:text-left">
|
||||
<strong className="mr-2">Albums:</strong> {totalAlbums}
|
||||
<span className="mx-3">|</span>
|
||||
<span className="mx-3 sm:inline">|</span>
|
||||
<strong className="mr-2">Tracks:</strong> {totalTracks}
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="#"
|
||||
role="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault(); // prevent page jump
|
||||
handleToggleAllAlbums();
|
||||
}}
|
||||
className="text-sm text-blue-600 hover:underline cursor-pointer"
|
||||
>
|
||||
Check / Uncheck All Albums
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
href="#"
|
||||
role="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleToggleAllAlbums();
|
||||
}}
|
||||
className="text-sm text-blue-600 hover:underline cursor-pointer text-center sm:text-right"
|
||||
>
|
||||
Check / Uncheck All Albums
|
||||
</a>
|
||||
</div>
|
||||
<Accordion
|
||||
multiple
|
||||
@@ -1148,7 +1146,7 @@ export default function MediaRequestForm() {
|
||||
{loadingAlbumId === id && <Spinner />}
|
||||
</span>
|
||||
<small className="ml-2 text-neutral-500 dark:text-neutral-400">({release_date})</small>
|
||||
<span className="ml-auto text-xs text-neutral-500">
|
||||
<span className="ml-0 w-full text-xs text-neutral-500 sm:ml-auto sm:w-auto">
|
||||
{typeof tracksByAlbum[id] === 'undefined' ? (
|
||||
loadingAlbumId === id ? 'Loading...' : '...'
|
||||
) : (
|
||||
@@ -1171,32 +1169,34 @@ export default function MediaRequestForm() {
|
||||
|
||||
return (
|
||||
<li key={track.id} className="py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected?.includes(String(track.id))}
|
||||
onChange={() => toggleTrack(id, track.id)}
|
||||
className="trip-checkbox cursor-pointer"
|
||||
aria-label={`Select track ${track.title} `}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTrackPlayPause(track, id, albumIndex)}
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full border text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${isCurrentTrack && isAudioPlaying
|
||||
? "border-green-600 text-green-600"
|
||||
: "border-neutral-400 text-neutral-600 hover:text-blue-600 hover:border-blue-600"}`}
|
||||
aria-label={`${isCurrentTrack && isAudioPlaying ? "Pause" : "Play"} ${track.title}`}
|
||||
aria-pressed={isCurrentTrack && isAudioPlaying}
|
||||
disabled={audioLoadingTrackId === track.id}
|
||||
>
|
||||
{audioLoadingTrackId === track.id ? (
|
||||
<InlineSpinner sizeClass="h-4 w-4" />
|
||||
) : isCurrentTrack && isAudioPlaying ? (
|
||||
<PauseIcon />
|
||||
) : (
|
||||
<PlayIcon />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected?.includes(String(track.id))}
|
||||
onChange={() => toggleTrack(id, track.id)}
|
||||
className="trip-checkbox cursor-pointer"
|
||||
aria-label={`Select track ${track.title} `}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTrackPlayPause(track, id, albumIndex)}
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full border text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${isCurrentTrack && isAudioPlaying
|
||||
? "border-green-600 text-green-600"
|
||||
: "border-neutral-400 text-neutral-600 hover:text-blue-600 hover:border-blue-600"}`}
|
||||
aria-label={`${isCurrentTrack && isAudioPlaying ? "Pause" : "Play"} ${track.title}`}
|
||||
aria-pressed={isCurrentTrack && isAudioPlaying}
|
||||
disabled={audioLoadingTrackId === track.id}
|
||||
>
|
||||
{audioLoadingTrackId === track.id ? (
|
||||
<InlineSpinner sizeClass="h-4 w-4" />
|
||||
) : isCurrentTrack && isAudioPlaying ? (
|
||||
<PauseIcon />
|
||||
) : (
|
||||
<PlayIcon />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<span className="block truncate" title={track.title}>
|
||||
{truncate(track.title, 80)}
|
||||
@@ -1207,7 +1207,7 @@ export default function MediaRequestForm() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 ml-auto text-xs text-neutral-500">
|
||||
<div className="flex items-center gap-3 text-xs text-neutral-500 w-full justify-between sm:w-auto sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTrackDownload(track)}
|
||||
@@ -1223,7 +1223,7 @@ export default function MediaRequestForm() {
|
||||
</div>
|
||||
</div>
|
||||
{showProgress && (
|
||||
<div className="mt-2 pr-6 pl-16">
|
||||
<div className="mt-2 pr-2 pl-4 sm:pr-6 sm:pl-16">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
|
||||
216
src/components/req/ReqForm.jsx
Normal file
216
src/components/req/ReqForm.jsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { Button } from "@mui/joy";
|
||||
// Dropdown not used in this form; removed to avoid unused-import warnings
|
||||
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 title"
|
||||
title="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,22 @@ 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";
|
||||
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
|
||||
export const SUBSITES = {
|
||||
'req.boatson.boats': '/subsites/req',
|
||||
};
|
||||
@@ -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,56 @@ 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
|
||||
if (import.meta.env.DEV) {
|
||||
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} />
|
||||
<script>
|
||||
import "@scripts/lenisSmoothScroll.js";
|
||||
import "@scripts/main.jsx";
|
||||
@@ -41,19 +77,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 +104,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>
|
||||
|
||||
@@ -6,6 +6,12 @@ import { padlockIconSvg, userIconSvg, externalLinkIconSvg } from "@/utils/navAss
|
||||
import "@/assets/styles/nav.css";
|
||||
|
||||
const user = await requireAuthHook(Astro);
|
||||
const hostHeader = Astro.request?.headers?.get('host') || '';
|
||||
const host = hostHeader.split(':')[0];
|
||||
import { getSubsiteByHost } from '../utils/subsites.js';
|
||||
import { getSubsiteByPath } from '../utils/subsites.js';
|
||||
const isReq = getSubsiteByHost(host)?.short === 'req' || getSubsiteByPath(Astro.url.pathname)?.short === 'req';
|
||||
// Nav is the standard site navigation — whitelabel logic belongs in SubNav
|
||||
const isLoggedIn = Boolean(user);
|
||||
const userDisplayName = user?.user ?? null;
|
||||
|
||||
@@ -44,11 +50,10 @@ const currentPath = Astro.url.pathname;
|
||||
<div class="nav-bar-row flex items-center gap-4 justify-between">
|
||||
<!-- Logo/Brand -->
|
||||
<a
|
||||
href="/"
|
||||
class="text-xl sm:text-2xl font-semibold header-text whitespace-nowrap hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{metaData.title}
|
||||
</a>
|
||||
href="/"
|
||||
class="text-xl sm:text-2xl font-semibold header-text whitespace-nowrap hover:opacity-80 transition-opacity">
|
||||
{metaData.title}
|
||||
</a>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="desktop-nav flex items-center">
|
||||
@@ -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
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;
|
||||
21
src/layouts/subsites/defaultNav.astro
Normal file
21
src/layouts/subsites/defaultNav.astro
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
// Default subsite nav — used when a subsite exists but no specialized nav is available
|
||||
import { API_URL } from "../../config";
|
||||
const whitelabel = Astro.props?.whitelabel ?? null;
|
||||
const currentPath = Astro.url.pathname;
|
||||
---
|
||||
|
||||
<script src="/scripts/nav-controls.js" defer data-api-url={API_URL}></script>
|
||||
|
||||
<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>
|
||||
28
src/layouts/subsites/reqNav.astro
Normal file
28
src/layouts/subsites/reqNav.astro
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
// Req specific subsite nav placeholder. Keeps markup minimal for now.
|
||||
import { Icon } from "astro-icon/components";
|
||||
import { API_URL } from "../../config";
|
||||
const whitelabel = Astro.props?.whitelabel ?? null;
|
||||
const currentPath = Astro.url.pathname;
|
||||
|
||||
const links = [
|
||||
// Add req-specific nav items here in future
|
||||
];
|
||||
---
|
||||
|
||||
<script src="/scripts/nav-controls.js" defer data-api-url={API_URL}></script>
|
||||
|
||||
<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">
|
||||
<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 { 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
|
||||
if (import.meta.env.DEV) 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) {
|
||||
if (import.meta.env.DEV) console.log('[middleware] WARNING: Host header missing. Dumping headers:');
|
||||
for (const [k, v] of context.request.headers) {
|
||||
if (import.meta.env.DEV) 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)) {
|
||||
if (import.meta.env.DEV) 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) {
|
||||
if (import.meta.env.DEV) 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)) {
|
||||
if (import.meta.env.DEV) console.log(`[middleware] Blocking subsite root on main domain: ${hostHeader}${context.url.pathname}`);
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
if (import.meta.env.DEV) 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}`);
|
||||
if (import.meta.env.DEV) 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);
|
||||
if (import.meta.env.DEV) console.log(`[middleware] Setting context.locals.whitelabel: ${context.locals.whitelabel}`);
|
||||
|
||||
// Final debug: show the final pathname we hand to Astro
|
||||
if (import.meta.env.DEV) 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) {
|
||||
console.log(`404 redirect: ${context.url.pathname} -> /`);
|
||||
if (response.status === 404 && !context.url.pathname.startsWith('/api/')) {
|
||||
if (import.meta.env.DEV) 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
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 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>
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root
|
||||
child="LyricSearch"
|
||||
client:only="react"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
---
|
||||
|
||||
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