refactor: add SubNav layout and per-subsite nav placeholders; switch Base to use SubNav
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
215
src/components/req/ReqForm.jsx
Normal file
215
src/components/req/ReqForm.jsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { Button } from "@mui/joy";
|
||||
import { Dropdown } from "primereact/dropdown/dropdown.esm.js";
|
||||
import { AutoComplete } from "primereact/autocomplete/autocomplete.esm.js";
|
||||
import { InputText } from "primereact/inputtext/inputtext.esm.js";
|
||||
|
||||
export default function ReqForm() {
|
||||
const [type, setType] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [year, setYear] = useState("");
|
||||
const [requester, setRequester] = useState("");
|
||||
const [selectedItem, setSelectedItem] = useState(null);
|
||||
const [selectedOverview, setSelectedOverview] = useState("");
|
||||
const [selectedTitle, setSelectedTitle] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (title !== selectedTitle && selectedOverview) {
|
||||
setSelectedOverview("");
|
||||
setSelectedTitle("");
|
||||
}
|
||||
}, [title, selectedTitle, selectedOverview]);
|
||||
|
||||
const searchTitles = async (event) => {
|
||||
const query = event.query;
|
||||
if (query.length < 3) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
setSuggestions(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching suggestions:', error);
|
||||
setSuggestions([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) {
|
||||
toast.error("Please fill in the required fields.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const response = await fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ title, year, type, requester }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Submission failed');
|
||||
}
|
||||
toast.success("Request submitted successfully!");
|
||||
// Reset form
|
||||
setType("");
|
||||
setTitle("");
|
||||
setYear("");
|
||||
setRequester("");
|
||||
setSelectedOverview("");
|
||||
setSelectedTitle("");
|
||||
setSelectedItem(null);
|
||||
} catch (error) {
|
||||
console.error('Submission error:', error);
|
||||
toast.error("Failed to submit request. Please try again.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const attachScrollFix = () => {
|
||||
setTimeout(() => {
|
||||
const panel = document.querySelector(".p-autocomplete-panel");
|
||||
const items = panel?.querySelector(".p-autocomplete-items");
|
||||
if (items) {
|
||||
items.style.maxHeight = "200px";
|
||||
items.style.overflowY = "auto";
|
||||
items.style.overscrollBehavior = "contain";
|
||||
const wheelHandler = (e) => {
|
||||
const delta = e.deltaY;
|
||||
const atTop = items.scrollTop === 0;
|
||||
const atBottom = items.scrollTop + items.clientHeight >= items.scrollHeight;
|
||||
if ((delta < 0 && atTop) || (delta > 0 && atBottom)) {
|
||||
e.preventDefault();
|
||||
} else {
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
items.removeEventListener("wheel", wheelHandler);
|
||||
items.addEventListener("wheel", wheelHandler, { passive: false });
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh] p-4">
|
||||
<div className="w-full max-w-lg p-8 bg-white dark:bg-[#1E1E1E] rounded-3xl shadow-2xl border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-800 dark:text-white mb-2">
|
||||
Request Movies/TV
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Submit your request for review
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="title" className="block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Title <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<AutoComplete
|
||||
id="title"
|
||||
value={title}
|
||||
suggestions={suggestions}
|
||||
completeMethod={searchTitles}
|
||||
onChange={(e) => setTitle(typeof e.value === 'string' ? e.value : e.value.label)}
|
||||
onSelect={(e) => {
|
||||
setType(e.value.mediaType === 'tv' ? 'tv' : 'movie');
|
||||
setTitle(e.value.label);
|
||||
setSelectedTitle(e.value.label);
|
||||
setSelectedItem(e.value);
|
||||
if (e.value.year) setYear(e.value.year);
|
||||
setSelectedOverview(e.value.overview || "");
|
||||
}}
|
||||
placeholder="Enter movie or TV show title"
|
||||
className="w-full"
|
||||
inputClassName="w-full border-2 border-gray-200 dark:border-gray-600 rounded-xl px-4 py-3 focus:border-[#12f8f4] transition-colors"
|
||||
panelClassName="border-2 border-gray-200 dark:border-gray-600 rounded-xl"
|
||||
field="label"
|
||||
onShow={attachScrollFix}
|
||||
itemTemplate={(item) => (
|
||||
<div className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded">
|
||||
<span className="font-medium">{item.label}</span>
|
||||
{item.year && <span className="text-sm text-gray-500 ml-2">({item.year})</span>}
|
||||
<span className="text-xs text-gray-400 ml-2 uppercase">{item.mediaType === 'tv' ? 'TV' : 'Movie'}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedOverview && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Synopsis
|
||||
</label>
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-600">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
{selectedOverview}
|
||||
</p>
|
||||
</div>
|
||||
{selectedItem?.poster_path && (
|
||||
<img
|
||||
src={`https://image.tmdb.org/t/p/w200${selectedItem.poster_path}`}
|
||||
alt="Poster"
|
||||
className="w-24 sm:w-32 md:w-40 h-auto rounded-lg border border-gray-200 dark:border-gray-600"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="year" className="block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Year <span className="text-gray-500">(optional)</span>
|
||||
</label>
|
||||
<InputText
|
||||
id="year"
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
placeholder="e.g. 2023"
|
||||
className="w-full border-2 border-gray-200 dark:border-gray-600 rounded-xl px-4 py-3 focus:border-[#12f8f4] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="requester" className="block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Your Name <span className="text-gray-500">(optional)</span>
|
||||
</label>
|
||||
<InputText
|
||||
id="requester"
|
||||
value={requester}
|
||||
onChange={(e) => setRequester(e.target.value)}
|
||||
placeholder="Who is requesting this?"
|
||||
className="w-full border-2 border-gray-200 dark:border-gray-600 rounded-xl px-4 py-3 focus:border-[#12f8f4] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-3 px-6 bg-[#12f8f4] text-gray-900 font-semibold rounded-xl disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? "Submitting..." : "Submit Request"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user