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:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user