feat(api): implement rate limiting and SSRF protection across endpoints

- Added rate limiting to `reaction-users`, `search`, and `image-proxy` APIs to prevent abuse.
- Introduced SSRF protection in `image-proxy` to block requests to private IP ranges.
- Enhanced `link-preview` to use `linkedom` for HTML parsing and improved meta tag extraction.
- Refactored authentication checks in various pages to utilize middleware for cleaner code.
- Improved JWT key loading with error handling and security warnings for production.
- Updated `authFetch` utility to handle token refresh more efficiently with deduplication.
- Enhanced rate limiting utility to trust proxy headers from known sources.
- Numerous layout / design changes
This commit is contained in:
2025-12-05 14:21:52 -05:00
parent 55e4c5ff0c
commit e18aa3f42c
44 changed files with 3512 additions and 892 deletions

View File

@@ -749,7 +749,7 @@ export default function Player({ user }) {
<div className="music-time flex justify-between items-center mt-4">
<p className="music-time__current text-sm">{formatTime(elapsedTime)}</p>
<p className="music-time__last text-sm">{formatTime(trackDuration - elapsedTime)}</p>
<p className="music-time__last text-sm">-{formatTime(trackDuration - elapsedTime)}</p>
</div>
<div className="progress-bar-container w-full h-2 rounded bg-neutral-300 dark:bg-neutral-700 overflow-hidden">

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef, Suspense, lazy } from 'react';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { API_URL } from '../config.js';
import { authFetch } from '../utils/authFetch.js';
import Wheel from '@uiw/react-color-wheel';
@@ -8,6 +8,8 @@ export default function Lighting() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [pending, setPending] = useState(false);
const debounceRef = useRef(null);
useEffect(() => {
authFetch(`${API_URL}/lighting/state`)
@@ -26,38 +28,44 @@ export default function Lighting() {
setState({ power: '', red: 0, blue: 0, green: 0, brightness: 100 });
}, []);
const handleColorChange = (color) => {
if (import.meta.env.DEV) console.debug('Handle color change:', color);
const { r, g, b } = color.rgb;
updateLighting({
...state,
red: r,
green: g,
blue: b,
});
};
// Cleanup debounce on unmount
useEffect(() => {
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, []);
const debounceRef = useRef();
const updateLighting = (newState) => {
// Consolidated debounced update function
const updateLighting = useCallback((newState) => {
setState(newState);
setPending(true);
setError('');
setSuccess(false);
// Clear any pending timeout
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
// Set new timeout for API call
// Set new timeout for API call (250ms for smoother drag experience)
debounceRef.current = setTimeout(() => {
authFetch(`${API_URL}/lighting/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newState),
})
.then(() => setSuccess(true))
.catch(() => setError('Failed to update lighting state'));
}, 100); // 100ms debounce for 25 req/2s rate limit
};
.then(() => {
setSuccess(true);
setPending(false);
})
.catch(() => {
setError('Failed to update lighting state');
setPending(false);
});
}, 250);
}, []);
const handleSubmit = (e) => {
e.preventDefault();
@@ -129,11 +137,13 @@ export default function Lighting() {
(v ?? 100) / 100 // value: 0-100 -> 0-1, default to 1 if undefined
);
if (import.meta.env.DEV) console.debug('Converting color:', color.hsva, 'to RGB:', rgb);
// Auto power on when changing color
updateLighting({
...state,
red: rgb.red,
green: rgb.green,
blue: rgb.blue
blue: rgb.blue,
power: state.power === 'off' ? 'on' : state.power
});
}}
width={180}
@@ -151,20 +161,18 @@ export default function Lighting() {
value={state.brightness}
onChange={e => {
const newValue = Number(e.target.value);
const newState = {
// Auto power off at 0, auto power on when > 0
let newPower = state.power;
if (newValue === 0) {
newPower = 'off';
} else if (state.power === 'off' && newValue > 0) {
newPower = 'on';
}
updateLighting({
...state,
brightness: newValue,
power: newValue === 0 ? 'off' : state.power
};
setState(newState);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
updateLighting(newState);
}, 100); // 100ms debounce for 25 req/2s rate limit
power: newPower
});
}}
className="w-full max-w-xs accent-yellow-500"
/>
@@ -186,7 +194,8 @@ export default function Lighting() {
</div>
{error && <div className="mb-4 text-red-500 text-center font-bold animate-pulse">{error}</div>}
{success && <div className="mb-4 text-green-600 text-center font-bold animate-bounce">Updated!</div>}
{success && !pending && <div className="mb-4 text-green-600 text-center font-bold">Updated!</div>}
{pending && <div className="mb-4 text-indigo-400 text-center text-sm">Syncing...</div>}
{loading && <div className="mb-4 text-indigo-500 text-center font-bold animate-pulse">Loading...</div>}
</form>
</div>

View File

@@ -103,7 +103,7 @@ export default function LoginPage({ loggedIn = false }) {
<div className="max-w-md w-full bg-white dark:bg-[#1E1E1E] rounded-2xl shadow-xl px-10 py-8 text-center">
<img className="logo-auth mx-auto mb-4" src="/images/zim.png" alt="Logo" />
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white mb-4">You're already logged in</h2>
<p className="text-sm text-gray-800 dark:text-gray-300 mb-4">You do not have permission to access this resource.
<p className="text-sm text-gray-800 dark:text-gray-300 mb-4">But you do not have permission to access this resource.
</p>
<p className="text-xs italic text-gray-800 dark:text-gray-300 mb-4">
If you feel you have received this message in error, scream at codey.

View File

@@ -8,37 +8,41 @@ import React, {
useCallback,
} from "react";
import { toast } from 'react-toastify';
import DOMPurify from 'isomorphic-dompurify';
import Box from '@mui/joy/Box';
import Button from "@mui/joy/Button";
import IconButton from "@mui/joy/IconButton";
import Checkbox from "@mui/joy/Checkbox";
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import LinkIcon from '@mui/icons-material/Link';
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
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/autocomplete.esm.js';
import { API_URL } from '../config';
// Sanitize HTML from external sources to prevent XSS
const sanitizeHtml = (html) => {
if (!html) return '';
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['br', 'p', 'b', 'i', 'em', 'strong', 'span', 'div'],
ALLOWED_ATTR: ['class'],
});
};
export default function LyricSearch() {
const [showLyrics, setShowLyrics] = useState(false);
return (
<div className="lyric-search">
<h2 className="title">
<span>Lyric Search</span>
</h2>
<div className="card-text my-4">
<label htmlFor="lyric-search-input">Search:</label>
<LyricSearchInputField
id="lyric-search-input"
placeholder="Artist - Song"
setShowLyrics={setShowLyrics}
/>
<div id="spinner" className="hidden">
<CircularProgress variant="plain" color="primary" size="md" />
</div>
</div>
<h1 className="text-3xl font-bold mb-8 text-neutral-900 dark:text-white tracking-tight">
Lyric Search
</h1>
<LyricSearchInputField
id="lyric-search-input"
placeholder="Artist - Song"
setShowLyrics={setShowLyrics}
/>
</div>
);
}
@@ -53,6 +57,10 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
const [lyricsResult, setLyricsResult] = useState(null);
const [textSize, setTextSize] = useState("normal");
const [inputStatus, setInputStatus] = useState("hint");
const [youtubeVideo, setYoutubeVideo] = useState(null); // { video_id, title, channel, duration }
const [youtubeLoading, setYoutubeLoading] = useState(false);
const [highlightedVerse, setHighlightedVerse] = useState(null);
const [isLyricsVisible, setIsLyricsVisible] = useState(false);
const searchToastRef = useRef(null);
const autoCompleteRef = useRef(null);
const autoCompleteInputRef = useRef(null);
@@ -122,6 +130,39 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
setSuggestions(json);
}, []);
// Fetch YouTube video for the song
const fetchYouTubeVideo = useCallback(async (artist, song) => {
setYoutubeLoading(true);
try {
const res = await fetch(`${API_URL}/yt/search`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ t: `${artist} - ${song}` }),
});
if (!res.ok) {
setYoutubeVideo(null);
setYoutubeLoading(false);
return;
}
const data = await res.json();
if (data?.video_id) {
setYoutubeVideo({
video_id: data.video_id,
title: data.extras?.title || `${artist} - ${song}`,
channel: data.extras?.channel || '',
duration: data.extras?.duration || '',
thumbnail: data.extras?.thumbnails?.[0] || null,
});
} else {
setYoutubeVideo(null);
}
} catch (err) {
console.error('YouTube search failed:', err);
setYoutubeVideo(null);
}
setYoutubeLoading(false);
}, []);
// Toggle exclusion state for checkboxes
const toggleExclusion = (source) => {
const lower = source.toLowerCase();
@@ -194,10 +235,34 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
evaluateSearchValue(value);
}, [value, evaluateSearchValue]);
const handleSearch = async (searchValue = value) => {
if (autoCompleteRef.current) {
autoCompleteRef.current.hide();
// Robustly hide autocomplete panel
const hideAutocompletePanel = useCallback(() => {
// Method 1: Use PrimeReact API
if (autoCompleteRef.current?.hide) {
try {
autoCompleteRef.current.hide();
} catch (e) {
// Ignore
}
}
// Method 2: Clear suggestions to ensure panel hides
setSuggestions([]);
// Method 3: Force hide via DOM after a tick (fallback)
setTimeout(() => {
const panel = document.querySelector('.p-autocomplete-panel');
if (panel) {
panel.style.display = 'none';
panel.style.opacity = '0';
panel.style.visibility = 'hidden';
panel.style.pointerEvents = 'none';
}
}, 10);
}, []);
const handleSearch = async (searchValue = value) => {
hideAutocompletePanel();
autoCompleteInputRef.current?.blur(); // blur early so the suggestion panel closes immediately
const evaluation = evaluateSearchValue(searchValue);
@@ -210,6 +275,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
const { artist, song } = evaluation;
setIsLoading(true);
setLyricsResult(null);
setYoutubeVideo(null); // Reset YouTube video
setShowLyrics(false);
const toastId = "lyrics-searching-toast";
@@ -251,12 +317,21 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
setTextSize("normal");
setLyricsResult({ artist: data.artist, song: data.song, lyrics: data.lyrics });
setHighlightedVerse(null);
setIsLyricsVisible(false);
// Trigger fade-in animation
requestAnimationFrame(() => {
requestAnimationFrame(() => setIsLyricsVisible(true));
});
setShowLyrics(true);
// Update URL hash with search parameters
const hash = `#${encodeURIComponent(data.artist)}/${encodeURIComponent(data.song)}`;
window.history.pushState(null, '', hash);
// Search for YouTube video (don't block on this)
fetchYouTubeVideo(data.artist, data.song);
dismissSearchToast();
toast.success(`Found! (Took ${duration}s)`, {
autoClose: 2500,
@@ -270,6 +345,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
});
} finally {
setIsLoading(false);
hideAutocompletePanel();
autoCompleteInputRef.current?.blur();
searchButtonRef.current?.blur();
searchToastRef.current = null;
@@ -279,6 +355,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
const handleKeyDown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
hideAutocompletePanel();
handleSearch();
}
};
@@ -348,8 +425,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
onShow={handlePanelShow}
placeholder={placeholder}
autoFocus
style={{ width: '100%', maxWidth: '900px' }}
inputStyle={{ width: '100%' }}
style={{ width: '100%' }}
className={`lyric-search-input ${inputStatus === "error" ? "has-error" : ""} ${inputStatus === "ready" ? "has-ready" : ""}`}
aria-invalid={inputStatus === "error"}
aria-label={`Lyric search input. ${statusTitle}`}
@@ -370,30 +446,31 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
{statusTitle}
</span>
</div>
<Button
onClick={() => handleSearch()}
className="btn"
ref={searchButtonRef}
>
Search
</Button>
<div className="mt-4">
Exclude:<br />
<div id="exclude-checkboxes">
<div className="flex items-center gap-4 mt-5">
<Button
onClick={() => handleSearch()}
className="search-btn"
ref={searchButtonRef}
>
Search
</Button>
<div className="h-6 w-px bg-neutral-300 dark:bg-neutral-700" aria-hidden="true"></div>
<div className="exclude-sources">
<span className="exclude-label">Exclude:</span>
<UICheckbox id="excl-Genius" label="Genius" onToggle={toggleExclusion} />
<UICheckbox id="excl-LRCLib-Cache" label="LRCLib-Cache" onToggle={toggleExclusion} />
<UICheckbox id="excl-LRCLib-Cache" label="LRCLib" onToggle={toggleExclusion} />
<UICheckbox id="excl-Cache" label="Cache" onToggle={toggleExclusion} />
</div>
</div>
{isLoading && (
<div className="mt-3">
<div className="mt-6 flex justify-center">
<CircularProgress variant="plain" color="primary" size="md" />
</div>
)}
{lyricsResult && (
<div className={`lyrics-card lyrics-card-${theme} mt-4 p-4 rounded-md shadow-md`}>
<div className={`lyrics-card lyrics-card-${theme} mt-6 p-5 rounded-xl shadow-lg lyrics-card-animate ${isLyricsVisible ? 'lyrics-card-visible' : ''}`}>
<div className="lyrics-toolbar">
<div className="lyrics-title">
{lyricsResult.artist} - {lyricsResult.song}
@@ -439,10 +516,40 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
>
<LinkIcon fontSize="small" />
</IconButton>
{youtubeLoading && (
<IconButton
size="sm"
variant="plain"
color="neutral"
aria-label="Loading YouTube video..."
title="Finding video..."
className="lyrics-action-button"
disabled
sx={{ opacity: 0.3 }}
>
<PlayCircleOutlineIcon fontSize="small" />
</IconButton>
)}
{!youtubeLoading && youtubeVideo && (
<IconButton
size="sm"
variant="plain"
color="neutral"
aria-label="Watch on YouTube"
title={`Watch: ${youtubeVideo.title}`}
className="lyrics-action-button"
component="a"
href={`https://www.youtube.com/watch?v=${youtubeVideo.video_id}`}
target="_blank"
rel="noopener noreferrer"
>
<PlayCircleOutlineIcon fontSize="small" />
</IconButton>
)}
</div>
</div>
<div className={`lyrics-content ${textSize === "large" ? "lyrics-content-large" : ""}`}>
<div dangerouslySetInnerHTML={{ __html: lyricsResult.lyrics }} />
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(lyricsResult.lyrics) }} />
</div>
</div>
)}
@@ -460,10 +567,11 @@ export const UICheckbox = forwardRef(function UICheckbox(props = {}, ref) {
}));
const verifyExclusions = () => {
const checkboxes = document.querySelectorAll("#exclude-checkboxes input[type=checkbox]");
const checkedCount = [...checkboxes].filter(cb => cb.checked).length;
const checkboxes = document.querySelectorAll(".exclude-chip");
const checkedCount = [...checkboxes].filter(cb => cb.dataset.checked === 'true').length;
if (checkedCount === 3) {
// Reset all by triggering clicks
checkboxes.forEach(cb => cb.click());
if (!toast.isActive("lyrics-exclusion-reset-toast")) {
toast.error("All sources were excluded; exclusions have been reset.",
@@ -473,27 +581,27 @@ export const UICheckbox = forwardRef(function UICheckbox(props = {}, ref) {
}
};
const handleChange = (e) => {
const newChecked = e.target.checked;
const handleClick = () => {
const newChecked = !checked;
setChecked(newChecked);
if (props.onToggle) {
const source = props.label; // Use label as source identifier
const source = props.label;
props.onToggle(source);
}
verifyExclusions();
setTimeout(verifyExclusions, 0);
};
return (
<div>
<Checkbox
id={props.id}
key={props.label}
checked={checked}
label={props.label}
style={{ color: "inherit" }}
onChange={handleChange}
/>
</div>
<button
type="button"
id={props.id}
className={`exclude-chip ${checked ? 'exclude-chip--active' : ''}`}
data-checked={checked}
onClick={handleClick}
aria-pressed={checked}
>
{props.label}
</button>
);
});

View File

@@ -17,7 +17,10 @@ const Memes = () => {
const [hasMore, setHasMore] = useState(true);
const [selectedImage, setSelectedImage] = useState(null);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [imageLoading, setImageLoading] = useState({});
const observerRef = useRef();
const touchStartRef = useRef(null);
const touchEndRef = useRef(null);
const theme = document.documentElement.getAttribute("data-theme")
const cacheRef = useRef({ pagesLoaded: new Set(), items: [] });
@@ -158,6 +161,43 @@ const Memes = () => {
setSelectedIndex(-1);
}, []);
// Touch swipe handlers for mobile navigation
const handleTouchStart = useCallback((e) => {
touchStartRef.current = e.touches[0].clientX;
touchEndRef.current = null;
}, []);
const handleTouchMove = useCallback((e) => {
touchEndRef.current = e.touches[0].clientX;
}, []);
const handleTouchEnd = useCallback(() => {
if (!touchStartRef.current || !touchEndRef.current) return;
const distance = touchStartRef.current - touchEndRef.current;
const minSwipeDistance = 50;
if (Math.abs(distance) > minSwipeDistance) {
if (distance > 0) {
// Swiped left -> next
handleNavigate(1);
} else {
// Swiped right -> prev
handleNavigate(-1);
}
}
touchStartRef.current = null;
touchEndRef.current = null;
}, [handleNavigate]);
// Track image loading state
const handleImageLoad = useCallback((id) => {
setImageLoading(prev => ({ ...prev, [id]: false }));
}, []);
const handleImageLoadStart = useCallback((id) => {
setImageLoading(prev => ({ ...prev, [id]: true }));
}, []);
const handleCopyImage = useCallback(async () => {
if (!selectedImage) return;
try {
@@ -181,6 +221,7 @@ const Memes = () => {
<div className="grid-container">
{images.map((img, i) => {
const isLast = i === images.length - 1;
const isLoading = imageLoading[img.id] !== false;
return (
<div
key={img.id}
@@ -191,13 +232,18 @@ const Memes = () => {
setSelectedIndex(i);
prefetchImage(images[i + 1]);
}}
style={{ cursor: 'pointer' }}
style={{ cursor: 'pointer', position: 'relative' }}
>
{isLoading && (
<div className="meme-skeleton" />
)}
<Image
src={img.url}
alt={`meme-${img.id}`}
imageClassName="meme-img"
imageClassName={`meme-img ${isLoading ? 'meme-img-loading' : ''}`}
loading="lazy"
onLoad={() => handleImageLoad(img.id)}
onLoadStart={() => handleImageLoadStart(img.id)}
/>
</div>
);
@@ -239,7 +285,12 @@ const Memes = () => {
dismissableMask={true}
>
{selectedImage && (
<div className="meme-dialog-body">
<div
className="meme-dialog-body"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<button
type="button"
className="meme-dialog-nav meme-dialog-nav-prev"

View File

@@ -7,26 +7,27 @@ export default function BreadcrumbNav({ currentPage }) {
];
return (
<div>
<nav aria-label="breadcrumb" className="mb-6 flex gap-4 text-sm font-medium text-blue-600 dark:text-blue-400">
{pages.map(({ key, label, href }, i) => {
return (
<React.Fragment key={key}>
<a
href={href}
className={`${currentPage === key
? "!font-bold underline" // active: always underlined + bold
: "hover:underline" // inactive: underline only on hover
}`}
aria-current={currentPage === key ? "page" : undefined}
>
{label}
</a>
{i < pages.length - 1 && <span aria-hidden="true">/</span>}
</React.Fragment>
);
})}
</nav >
</div>
<nav aria-label="breadcrumb" className="mb-8 flex items-center gap-2 text-sm">
{pages.map(({ key, label, href }, i) => {
const isActive = currentPage === key;
return (
<React.Fragment key={key}>
<a
href={href}
className={`px-3 py-1.5 rounded-full transition-colors ${isActive
? "bg-neutral-200 dark:bg-neutral-700 font-semibold text-neutral-900 dark:text-white"
: "text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800"
}`}
aria-current={isActive ? "page" : undefined}
>
{label}
</a>
{i < pages.length - 1 && (
<span className="text-neutral-400 dark:text-neutral-600" aria-hidden="true">/</span>
)}
</React.Fragment>
);
})}
</nav>
);
}

View File

@@ -6,6 +6,7 @@ import { AutoComplete } from "primereact/autocomplete";
import { authFetch } from "@/utils/authFetch";
import BreadcrumbNav from "./BreadcrumbNav";
import { API_URL, ENVIRONMENT } from "@/config";
import "./RequestManagement.css";
export default function MediaRequestForm() {
const [type, setType] = useState("artist");
@@ -918,7 +919,7 @@ export default function MediaRequestForm() {
return (
<div className="max-w-3xl mx-auto my-10 p-6 rounded-xl shadow-md bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700">
<div className="trip-request-form mx-auto my-10 p-6 rounded-xl shadow-md bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700">
<style>{`
/* Accordion tab backgrounds & text */
.p-accordion-tab {
@@ -990,7 +991,8 @@ export default function MediaRequestForm() {
}
`}</style>
<BreadcrumbNav currentPage="request" />
<h2 className="text-3xl font-semibold mt-0">New Request</h2>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight mb-2">New Request</h2>
<p className="text-neutral-500 dark:text-neutral-400 text-sm mb-6">Search for an artist to browse and select tracks for download.</p>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<label htmlFor="artistInput">Artist: </label>

View File

@@ -1,81 +1,442 @@
/* Table and Dark Overrides */
.p-datatable {
table-layout: fixed !important;
.trip-management-container {
width: 100%;
}
.p-datatable td span.truncate {
.trip-management-container .table-wrapper {
width: 100%;
}
.trip-management-container .p-datatable {
width: 100% !important;
display: block !important;
}
.trip-management-container .p-datatable-wrapper {
width: 100% !important;
overflow-x: auto;
}
.trip-management-container .p-datatable-table {
width: 100% !important;
table-layout: fixed !important;
min-width: 100% !important;
}
/* Force header and body rows to fill width */
.trip-management-container .p-datatable-thead,
.trip-management-container .p-datatable-tbody {
width: 100% !important;
}
.trip-management-container .p-datatable-thead > tr,
.trip-management-container .p-datatable-tbody > tr {
width: 100% !important;
}
/* Column widths - distribute across table */
.trip-management-container .p-datatable-thead > tr > th,
.trip-management-container .p-datatable-tbody > tr > td {
/* Default: auto distribute */
}
/* ID column - narrow */
.trip-management-container .p-datatable-thead > tr > th:nth-child(1),
.trip-management-container .p-datatable-tbody > tr > td:nth-child(1) {
width: 10% !important;
}
/* Target column - widest */
.trip-management-container .p-datatable-thead > tr > th:nth-child(2),
.trip-management-container .p-datatable-tbody > tr > td:nth-child(2) {
width: 22% !important;
}
/* Tracks column */
.trip-management-container .p-datatable-thead > tr > th:nth-child(3),
.trip-management-container .p-datatable-tbody > tr > td:nth-child(3) {
width: 10% !important;
}
/* Status column */
.trip-management-container .p-datatable-thead > tr > th:nth-child(4),
.trip-management-container .p-datatable-tbody > tr > td:nth-child(4) {
width: 12% !important;
text-align: center;
}
/* Progress column */
.trip-management-container .p-datatable-thead > tr > th:nth-child(5),
.trip-management-container .p-datatable-tbody > tr > td:nth-child(5) {
width: 16% !important;
text-align: center;
}
/* Quality column */
.trip-management-container .p-datatable-thead > tr > th:nth-child(6),
.trip-management-container .p-datatable-tbody > tr > td:nth-child(6) {
width: 10% !important;
text-align: center;
}
/* Tarball column - fills remaining */
.trip-management-container .p-datatable-thead > tr > th:nth-child(7),
.trip-management-container .p-datatable-tbody > tr > td:nth-child(7) {
width: 20% !important;
}
.trip-management-container .p-datatable td span.truncate {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Row hover cursor - indicate clickable */
.trip-management-container .p-datatable-tbody > tr {
cursor: pointer;
transition: background-color 0.15s ease;
}
/* Center-align headers for centered columns */
.trip-management-container .p-datatable-thead > tr > th:nth-child(4),
.trip-management-container .p-datatable-thead > tr > th:nth-child(5),
.trip-management-container .p-datatable-thead > tr > th:nth-child(6) {
text-align: center !important;
}
/* Skeleton loading styles */
.table-skeleton {
width: 100%;
border-radius: 0.5rem;
overflow: hidden;
}
.skeleton-row {
display: flex;
padding: 1rem 0.75rem;
border-bottom: 1px solid rgba(128, 128, 128, 0.2);
}
.skeleton-row:last-child {
border-bottom: none;
}
.skeleton-cell {
padding: 0 0.5rem;
}
.skeleton-bar {
height: 1rem;
background: linear-gradient(90deg, #2a2a2a 25%, #3a3a3a 50%, #2a2a2a 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 0.25rem;
width: 80%;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Light mode skeleton */
[data-theme="light"] .skeleton-bar {
background: linear-gradient(90deg, #e5e5e5 25%, #f0f0f0 50%, #e5e5e5 75%);
background-size: 200% 100%;
}
/* Empty state styles */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
text-align: center;
}
.empty-state-icon {
font-size: 3rem;
color: #6b7280;
margin-bottom: 1rem;
}
.empty-state-text {
font-size: 1.125rem;
font-weight: 600;
color: #9ca3af;
margin-bottom: 0.25rem;
}
.empty-state-subtext {
font-size: 0.875rem;
color: #6b7280;
}
/* Dark Mode for Table */
[data-theme="dark"] .p-datatable {
background-color: #121212 !important;
[data-theme="dark"] .trip-management-container .p-datatable {
color: #e5e7eb !important;
}
[data-theme="dark"] .p-datatable-thead > tr > th {
[data-theme="dark"] .trip-management-container .p-datatable-thead > tr > th {
background-color: #1f1f1f !important;
color: #e5e7eb !important;
border-bottom: 1px solid #374151;
}
[data-theme="dark"] .p-datatable-tbody > tr {
[data-theme="dark"] .trip-management-container .p-datatable-tbody > tr {
background-color: #1a1a1a !important;
border-bottom: 1px solid #374151;
color: #e5e7eb !important;
}
[data-theme="dark"] .p-datatable-tbody > tr:nth-child(odd) {
[data-theme="dark"] .trip-management-container .p-datatable-tbody > tr:nth-child(odd) {
background-color: #222 !important;
}
[data-theme="dark"] .p-datatable-tbody > tr:hover {
[data-theme="dark"] .trip-management-container .p-datatable-tbody > tr:hover {
background-color: #333 !important;
color: #fff !important;
}
/* Paginator Dark Mode */
[data-theme="dark"] .p-paginator {
[data-theme="dark"] .trip-management-container .p-paginator {
background-color: #121212 !important;
color: #e5e7eb !important;
border-top: 1px solid #374151 !important;
}
[data-theme="dark"] .p-paginator .p-paginator-page,
[data-theme="dark"] .p-paginator .p-paginator-next,
[data-theme="dark"] .p-paginator .p-paginator-prev,
[data-theme="dark"] .p-paginator .p-paginator-first,
[data-theme="dark"] .p-paginator .p-paginator-last {
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-page,
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-next,
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-prev,
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-first,
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-last {
color: #e5e7eb !important;
background: transparent !important;
border: none !important;
}
[data-theme="dark"] .p-paginator .p-paginator-page:hover,
[data-theme="dark"] .p-paginator .p-paginator-next:hover,
[data-theme="dark"] .p-paginator .p-paginator-prev:hover {
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-page:hover,
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-next:hover,
[data-theme="dark"] .trip-management-container .p-paginator .p-paginator-prev:hover {
background-color: #374151 !important;
color: #fff !important;
border-radius: 0.25rem;
}
[data-theme="dark"] .p-paginator .p-highlight {
[data-theme="dark"] .trip-management-container .p-paginator .p-highlight {
background-color: #6b7280 !important;
color: #fff !important;
border-radius: 0.25rem !important;
}
/* Dark Mode for PrimeReact Dialog */
[data-theme="dark"] .p-dialog {
/* Dark Mode for PrimeReact Dialog - rendered via portal so needs global selector */
[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 {
background-color: #1a1a1a !important;
color: #e5e7eb !important;
border-color: #374151 !important;
}
[data-theme="dark"] .p-dialog .p-dialog-header {
background-color: #121212 !important;
[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 .p-dialog-header {
background-color: #171717 !important;
color: #e5e7eb !important;
border-bottom: 1px solid #374151 !important;
}
[data-theme="dark"] .p-dialog .p-dialog-content {
[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 .p-dialog-header .p-dialog-header-icon {
color: #e5e7eb !important;
}
[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 .p-dialog-header .p-dialog-header-icon:hover {
background-color: #374151 !important;
color: #fff !important;
}
[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 .p-dialog-content {
background-color: #1a1a1a !important;
color: #e5e7eb !important;
}
[data-theme="dark"] .p-dialog .p-dialog-footer {
background-color: #121212 !important;
[data-theme="dark"] .p-dialog.dark\:bg-neutral-900 .p-dialog-footer {
background-color: #171717 !important;
border-top: 1px solid #374151 !important;
}
/* Progress Bar Styles */
.progress-bar-container {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.progress-bar-track {
flex: 1;
height: 6px;
background-color: rgba(128, 128, 128, 0.2);
border-radius: 999px;
overflow: hidden;
}
.progress-bar-track-lg {
height: 10px;
}
.progress-bar-fill {
height: 100%;
border-radius: 999px;
transition: width 0.3s ease;
}
.progress-bar-text {
font-size: 0.75rem;
font-weight: 600;
min-width: 2.5rem;
text-align: right;
}
/* Container Styles */
.trip-management-container {
width: 100%;
}
.trip-management-container .overflow-x-auto {
overflow-x: auto;
max-width: 100%;
}
.trip-request-form {
width: 100%;
}
@media (max-width: 768px) {
.trip-management-container {
padding: 1rem;
margin: 1rem 0;
}
.trip-management-container h2 {
font-size: 1.5rem;
}
/* Stack filters on mobile */
.trip-management-container .flex-wrap {
flex-direction: column;
gap: 0.75rem;
}
/* Make table horizontally scrollable */
.trip-management-container .overflow-x-auto {
margin: 0 -1rem;
padding: 0 1rem;
}
/* Reduce column widths on mobile */
.p-datatable-thead > tr > th,
.p-datatable-tbody > tr > td {
padding: 0.5rem 0.25rem !important;
font-size: 0.8rem !important;
}
/* Hide less important columns on small screens */
.p-datatable .hide-mobile {
display: none !important;
}
}
@media (max-width: 480px) {
.trip-management-container {
padding: 0.75rem;
border-radius: 0.5rem;
}
.progress-bar-container {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.progress-bar-track {
width: 100%;
}
.progress-bar-text {
text-align: left;
}
}
/* ===== MediaRequestForm Mobile Styles ===== */
/* Form container responsive */
.trip-request-form {
width: 100%;
max-width: 48rem;
margin: 2.5rem auto;
padding: 1.5rem;
}
@media (max-width: 768px) {
.trip-request-form {
margin: 1rem auto;
padding: 1rem;
border-radius: 0.75rem;
}
.trip-request-form h2 {
font-size: 1.5rem;
}
/* Quality buttons stack on mobile */
.trip-quality-buttons {
flex-direction: column;
gap: 0.5rem;
}
.trip-quality-buttons button {
width: 100%;
}
/* Accordion improvements for mobile */
.p-accordion-header .p-accordion-header-link {
padding: 0.75rem !important;
font-size: 0.9rem !important;
}
.p-accordion-content {
padding: 0.5rem !important;
}
/* Track list items more compact on mobile */
.p-accordion-content li {
padding: 0.5rem !important;
font-size: 0.85rem !important;
}
/* Album header info stacks */
.album-header-info {
flex-direction: column;
align-items: flex-start !important;
gap: 0.25rem;
}
/* Audio player controls smaller on mobile */
.track-audio-controls button {
padding: 0.25rem 0.5rem !important;
}
}
@media (max-width: 480px) {
.trip-request-form {
margin: 0.5rem;
padding: 0.75rem;
}
.trip-request-form h2 {
font-size: 1.25rem;
}
/* Input fields full width */
.p-autocomplete,
.p-autocomplete-input {
width: 100% !important;
}
/* Smaller text in track listings */
.p-accordion-content li span {
font-size: 0.75rem !important;
}
/* Submit button full width */
.trip-submit-button {
width: 100%;
}
}

View File

@@ -9,6 +9,7 @@ import { authFetch } from "@/utils/authFetch";
import { confirmDialog, ConfirmDialog } from "primereact/confirmdialog";
import BreadcrumbNav from "./BreadcrumbNav";
import { API_URL } from "@/config";
import "./RequestManagement.css";
const STATUS_OPTIONS = ["Queued", "Started", "Compressing", "Finished", "Failed"];
const TAR_BASE_URL = "https://codey.lol/m/m2"; // configurable prefix
@@ -20,6 +21,7 @@ export default function RequestManagement() {
const [filteredRequests, setFilteredRequests] = useState([]);
const [selectedRequest, setSelectedRequest] = useState(null);
const [isDialogVisible, setIsDialogVisible] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const pollingRef = useRef(null);
const pollingDetailRef = useRef(null);
@@ -30,8 +32,9 @@ export default function RequestManagement() {
return `${TAR_BASE_URL}/${quality}/${filename}`;
};
const fetchJobs = async () => {
const fetchJobs = async (showLoading = true) => {
try {
if (showLoading) setIsLoading(true);
const res = await authFetch(`${API_URL}/trip/jobs/list`);
if (!res.ok) throw new Error("Failed to fetch jobs");
const data = await res.json();
@@ -43,6 +46,8 @@ export default function RequestManagement() {
toastId: 'fetch-fail-toast',
});
}
} finally {
setIsLoading(false);
}
};
@@ -123,13 +128,13 @@ export default function RequestManagement() {
const statusBodyTemplate = (rowData) => (
<span className={`inline-block px-3 py-1 rounded-full font-semibold text-sm ${getStatusColorClass(rowData.status)}`}>
<span className={`inline-flex items-center justify-center min-w-[90px] px-3 py-1 rounded-full font-semibold text-xs ${getStatusColorClass(rowData.status)}`}>
{rowData.status}
</span>
);
const qualityBodyTemplate = (rowData) => (
<span className={`inline-block px-3 py-1 rounded-full font-semibold text-sm ${getQualityColorClass(rowData.quality)}`}>
<span className={`inline-flex items-center justify-center min-w-[50px] px-3 py-1 rounded-full font-semibold text-xs ${getQualityColorClass(rowData.quality)}`}>
{rowData.quality}
</span>
);
@@ -158,6 +163,34 @@ export default function RequestManagement() {
return `${pct}%`;
};
const progressBarTemplate = (rowData) => {
const p = rowData.progress;
if (p === null || p === undefined || p === "") return "—";
const num = Number(p);
if (Number.isNaN(num)) return "—";
const pct = Math.min(100, Math.max(0, num > 1 ? Math.round(num) : num * 100));
const getProgressColor = () => {
if (rowData.status === "Failed") return "bg-red-500";
if (rowData.status === "Finished") return "bg-green-500";
if (pct < 30) return "bg-blue-400";
if (pct < 70) return "bg-blue-500";
return "bg-blue-600";
};
return (
<div className="progress-bar-container">
<div className="progress-bar-track">
<div
className={`progress-bar-fill ${getProgressColor()}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="progress-bar-text">{pct}%</span>
</div>
);
};
const confirmDelete = (requestId) => {
confirmDialog({
message: "Are you sure you want to delete this request?",
@@ -195,100 +228,15 @@ export default function RequestManagement() {
return (
<div className="w-max my-10 p-6 rounded-xl shadow-md
<div className="trip-management-container my-10 p-4 sm:p-6 rounded-xl shadow-md
bg-white dark:bg-neutral-900
text-neutral-900 dark:text-neutral-100
border border-neutral-200 dark:border-neutral-700
sm:p-4 md:p-6">
<style>{`
/* Table and Dark Overrides */
.p-datatable {
table-layout: fixed !important;
}
.p-datatable td span.truncate {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-theme="dark"] .p-datatable {
background-color: #121212 !important;
color: #e5e7eb !important;
}
[data-theme="dark"] .p-datatable-thead > tr > th {
background-color: #1f1f1f !important;
color: #e5e7eb !important;
border-bottom: 1px solid #374151;
}
[data-theme="dark"] .p-datatable-tbody > tr {
background-color: #1a1a1a !important;
border-bottom: 1px solid #374151;
color: #e5e7eb !important;
}
[data-theme="dark"] .p-datatable-tbody > tr:nth-child(odd) {
background-color: #222 !important;
}
[data-theme="dark"] .p-datatable-tbody > tr:hover {
background-color: #333 !important;
color: #fff !important;
}
/* Paginator Dark Mode */
[data-theme="dark"] .p-paginator {
background-color: #121212 !important;
color: #e5e7eb !important;
border-top: 1px solid #374151 !important;
}
[data-theme="dark"] .p-paginator .p-paginator-page,
[data-theme="dark"] .p-paginator .p-paginator-next,
[data-theme="dark"] .p-paginator .p-paginator-prev,
[data-theme="dark"] .p-paginator .p-paginator-first,
[data-theme="dark"] .p-paginator .p-paginator-last {
color: #e5e7eb !important;
background: transparent !important;
border: none !important;
}
[data-theme="dark"] .p-paginator .p-paginator-page:hover,
[data-theme="dark"] .p-paginator .p-paginator-next:hover,
[data-theme="dark"] .p-paginator .p-paginator-prev:hover {
background-color: #374151 !important;
color: #fff !important;
border-radius: 0.25rem;
}
[data-theme="dark"] .p-paginator .p-highlight {
background-color: #6b7280 !important;
color: #fff !important;
border-radius: 0.25rem !important;
}
/* Dark mode for PrimeReact Dialog */
[data-theme="dark"] .p-dialog {
background-color: #1a1a1a !important;
color: #e5e7eb !important;
border-color: #374151 !important;
}
[data-theme="dark"] .p-dialog .p-dialog-header {
background-color: #121212 !important;
color: #e5e7eb !important;
border-bottom: 1px solid #374151 !important;
}
[data-theme="dark"] .p-dialog .p-dialog-content {
background-color: #1a1a1a !important;
color: #e5e7eb !important;
}
[data-theme="dark"] .p-dialog .p-dialog-footer {
background-color: #121212 !important;
border-top: 1px solid #374151 !important;
color: #e5e7eb !important;
}
`}</style>
border border-neutral-200 dark:border-neutral-700">
<BreadcrumbNav currentPage="management" />
<h2 className="text-3xl font-semibold mt-0">Media Request Management</h2>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight mb-6">Manage Requests</h2>
<div className="flex flex-wrap gap-6 mb-6">
<div className="flex flex-wrap items-center gap-4 mb-6">
<Dropdown
value={filterStatus}
options={[{ label: "All Statuses", value: "all" }, ...STATUS_OPTIONS.map((s) => ({ label: s, value: s }))]}
@@ -298,68 +246,91 @@ export default function RequestManagement() {
/>
</div>
<div className="w-max overflow-x-auto rounded-lg">
<DataTable
value={filteredRequests}
paginator
rows={10}
removableSort
sortMode="multiple"
emptyMessage="No requests found."
onRowClick={handleRowClick}
>
<Column
field="id"
header="ID"
style={{ width: "6rem" }}
body={(row) => (
<span title={row.id}>
{row.id.split("-").slice(-1)[0]}
</span>
)}
/>
<Column field="target" header="Target" sortable style={{ width: "12rem" }} body={(row) => textWithEllipsis(row.target, "10rem")} />
<Column field="tracks" header="# Tracks" style={{ width: "8rem" }} body={(row) => row.tracks} />
<Column field="status" header="Status" body={statusBodyTemplate} style={{ width: "10rem", textAlign: "center" }} sortable />
<Column field="progress" header="Progress" body={(row) => formatProgress(row.progress)} style={{ width: "8rem", textAlign: "center" }} sortable />
<Column
field="quality"
header="Quality"
body={qualityBodyTemplate}
style={{ width: "6rem", textAlign: "center" }}
sortable />
<Column
field="tarball"
header={
<span className="flex items-center">
<i className="pi pi-download mr-1" /> {/* download icon in header */}
Tarball
</span>
{isLoading ? (
<div className="table-skeleton">
{[...Array(5)].map((_, i) => (
<div key={i} className="skeleton-row">
<div className="skeleton-cell w-[10%]"><div className="skeleton-bar" /></div>
<div className="skeleton-cell w-[22%]"><div className="skeleton-bar" /></div>
<div className="skeleton-cell w-[10%]"><div className="skeleton-bar" /></div>
<div className="skeleton-cell w-[12%]"><div className="skeleton-bar" /></div>
<div className="skeleton-cell w-[16%]"><div className="skeleton-bar" /></div>
<div className="skeleton-cell w-[10%]"><div className="skeleton-bar" /></div>
<div className="skeleton-cell w-[20%]"><div className="skeleton-bar" /></div>
</div>
))}
</div>
) : (
<div className="table-wrapper w-full">
<DataTable
value={filteredRequests}
paginator
rows={10}
removableSort
sortMode="multiple"
emptyMessage={
<div className="empty-state">
<i className="pi pi-inbox empty-state-icon" />
<p className="empty-state-text">No requests found</p>
<p className="empty-state-subtext">Requests you submit will appear here</p>
</div>
}
body={(row) => {
const url = tarballUrl(row.tarball, row.quality || "FLAC");
const encodedURL = encodeURI(url);
if (!url) return "—";
onRowClick={handleRowClick}
resizableColumns={false}
className="w-full"
style={{ width: '100%' }}
>
const fileName = url.split("/").pop();
<Column
field="id"
header="ID"
body={(row) => (
<span title={row.id}>
{row.id.split("-").slice(-1)[0]}
</span>
)}
/>
<Column field="target" header="Target" sortable body={(row) => textWithEllipsis(row.target, "100%")} />
<Column field="tracks" header="# Tracks" body={(row) => row.tracks} />
<Column field="status" header="Status" body={statusBodyTemplate} style={{ textAlign: "center" }} sortable />
<Column field="progress" header="Progress" body={progressBarTemplate} style={{ textAlign: "center" }} sortable />
<Column
field="quality"
header="Quality"
body={qualityBodyTemplate}
style={{ textAlign: "center" }}
sortable />
<Column
field="tarball"
header={
<span className="flex items-center">
<i className="pi pi-download mr-1" />
Tarball
</span>
}
body={(row) => {
const url = tarballUrl(row.tarball, row.quality || "FLAC");
const encodedURL = encodeURI(url);
if (!url) return "—";
return (
<a
href={encodedURL}
target="_blank"
rel="noopener noreferrer"
className="truncate text-blue-500 hover:underline"
title={fileName}
>
{truncate(fileName, 16)}
</a>
);
}}
style={{ width: "10rem" }}
/>
</DataTable>
</div>
const fileName = url.split("/").pop();
return (
<a
href={encodedURL}
target="_blank"
rel="noopener noreferrer"
className="truncate text-blue-500 hover:underline"
title={fileName}
>
{truncate(fileName, 28)}
</a>
);
}}
/>
</DataTable>
</div>
)}
<ConfirmDialog />
@@ -402,7 +373,18 @@ export default function RequestManagement() {
</p>
)}
{selectedRequest.progress !== undefined && selectedRequest.progress !== null && (
<p><strong>Progress:</strong> {formatProgress(selectedRequest.progress)}</p>
<div className="col-span-2">
<strong>Progress:</strong>
<div className="progress-bar-container mt-2">
<div className="progress-bar-track progress-bar-track-lg">
<div
className={`progress-bar-fill ${selectedRequest.status === "Failed" ? "bg-red-500" : selectedRequest.status === "Finished" ? "bg-green-500" : "bg-blue-500"}`}
style={{ width: `${Math.min(100, Math.max(0, Number(selectedRequest.progress) > 1 ? Math.round(selectedRequest.progress) : selectedRequest.progress * 100))}%` }}
/>
</div>
<span className="progress-bar-text">{formatProgress(selectedRequest.progress)}</span>
</div>
</div>
)}
</div>

View File

@@ -1,19 +1,23 @@
import React, { lazy, Suspense } from 'react';
import React from 'react';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
const CustomToastContainer = () => {
const CustomToastContainer = ({ theme = 'light', newestOnTop = false, closeOnClick = true }) => {
// Map data-theme values to react-toastify theme
const toastTheme = theme === 'dark' ? 'dark' : 'light';
return (
<ToastContainer
position="top-right"
autoClose={5000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick
newestOnTop={newestOnTop}
closeOnClick={closeOnClick}
rtl={false}
pauseOnFocusLoss={false}
draggable
pauseOnHover
theme={toastTheme}
/>
);
};

View File

@@ -15,6 +15,7 @@ export default function ReqForm() {
const [selectedTitle, setSelectedTitle] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [suggestions, setSuggestions] = useState([]);
const [posterLoading, setPosterLoading] = useState(true);
useEffect(() => {
if (title !== selectedTitle) {
@@ -22,6 +23,7 @@ export default function ReqForm() {
if (selectedItem) setSelectedItem(null);
if (type) setType("");
if (selectedTitle) setSelectedTitle("");
setPosterLoading(true);
}
}, [title, selectedTitle, selectedOverview, selectedItem, type]);
@@ -116,18 +118,18 @@ export default function ReqForm() {
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="w-full max-w-lg p-8 bg-white dark:bg-[#141414] rounded-2xl shadow-lg shadow-neutral-900/5 dark:shadow-black/20 border border-neutral-200/60 dark:border-neutral-800/60">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-800 dark:text-white mb-2">
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white mb-2 tracking-tight font-['IBM_Plex_Sans',sans-serif]">
Request Movies/TV
</h1>
<p className="text-gray-600 dark:text-gray-400 text-sm">
<p className="text-neutral-500 dark:text-neutral-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">
<label htmlFor="title" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Title <span className="text-red-500">*</span>
</label>
<AutoComplete
@@ -148,43 +150,50 @@ export default function ReqForm() {
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"
inputClassName="w-full border border-neutral-200 dark:border-neutral-700 rounded-xl px-4 py-3 bg-white dark:bg-neutral-900/50 focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 transition-all outline-none"
panelClassName="rounded-xl overflow-hidden"
field="label"
onShow={attachScrollFix}
itemTemplate={(item) => (
<div className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded">
<div className="p-2 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>
{item.year && <span className="text-sm text-neutral-500 ml-2">({item.year})</span>}
<span className="text-xs text-neutral-400 ml-2 uppercase">{item.mediaType === 'tv' ? 'TV' : 'Movie'}</span>
</div>
)}
/>
{selectedItem && selectedTypeLabel && (
<div className="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">
Selected type: {selectedTypeLabel}
<div className="text-xs font-medium uppercase text-neutral-500 dark:text-neutral-400 tracking-wide">
Selected type: <span className="font-bold text-neutral-700 dark:text-neutral-200">{selectedTypeLabel}</span>
</div>
)}
</div>
{selectedOverview && (
<div className="space-y-2">
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-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="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-xl border border-neutral-200/60 dark:border-neutral-700/60">
<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">
<p className="text-sm text-neutral-600 dark:text-neutral-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 className="relative w-24 sm:w-32 md:w-40 flex-shrink-0 overflow-hidden rounded-lg">
{posterLoading && (
<div className="w-full bg-neutral-200 dark:bg-neutral-700 rounded-lg animate-pulse" style={{ aspectRatio: '2/3' }} />
)}
<img
src={`https://image.tmdb.org/t/p/w200${selectedItem.poster_path}`}
alt="Poster"
className={`w-full h-auto rounded-lg border border-neutral-200 dark:border-neutral-700 transition-opacity duration-300 ${posterLoading ? 'hidden' : 'block'}`}
onLoad={() => setPosterLoading(false)}
onError={() => setPosterLoading(false)}
/>
</div>
)}
</div>
</div>
@@ -192,28 +201,28 @@ export default function ReqForm() {
)}
<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 htmlFor="year" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Year <span className="text-neutral-400">(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"
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-xl px-4 py-3 bg-white dark:bg-neutral-900/50 focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 transition-all outline-none"
/>
</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 htmlFor="requester" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Your Name <span className="text-neutral-400">(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"
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-xl px-4 py-3 bg-white dark:bg-neutral-900/50 focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 transition-all outline-none"
/>
</div>
@@ -221,7 +230,7 @@ export default function ReqForm() {
<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"
className="w-full py-3 px-6 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 font-semibold rounded-xl hover:bg-neutral-800 dark:hover:bg-neutral-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
>
{isSubmitting ? "Submitting..." : "Submit Request"}
</Button>