This commit is contained in:
2025-12-17 13:33:31 -05:00
parent e18aa3f42c
commit c49bfe5a3d
38 changed files with 2436 additions and 436 deletions

View File

@@ -106,7 +106,7 @@ export default function Root({ child, user = undefined, ...props }) {
)}
{child == "qs2.MediaRequestForm" && <MediaRequestForm client:only="react" />}
{child == "qs2.RequestManagement" && <RequestManagement client:only="react" />}
{child == "ReqForm" && <ReqForm client:only="react" />}
{child == "ReqForm" && <ReqForm {...props} client:only="react" />}
{child == "Lighting" && <Lighting key={window.location.pathname + Math.random()} client:only="react" />}
</JoyUIRootIsland>
</PrimeReactProvider>

View File

@@ -1,9 +1,9 @@
import React, { useState, useEffect, useRef, Suspense, lazy, useMemo, useCallback } from "react";
import "@styles/player.css";
import "@/components/TRip/RequestManagement.css";
import { metaData } from "../config";
import Play from "@mui/icons-material/PlayArrow";
import Pause from "@mui/icons-material/Pause";
import "@styles/player.css";
import { Dialog } from "primereact/dialog";
import { AutoComplete } from "primereact/autocomplete";
import { DataTable } from "primereact/datatable";
@@ -14,30 +14,7 @@ import { API_URL } from "@/config";
import { authFetch } from "@/utils/authFetch";
import { requireAuthHook } from "@/hooks/requireAuthHook";
import { useHtmlThemeAttr } from "@/hooks/useHtmlThemeAttr";
import "@/components/TRip/RequestManagement.css";
// Custom styles for paginator
const paginatorStyles = `
.queue-paginator .p-paginator-current {
background: transparent !important;
color: inherit !important;
border: none !important;
}
.dark .queue-paginator .p-paginator-current {
color: rgb(212 212 212) !important;
background: transparent !important;
}
.queue-paginator .p-paginator-bottom {
padding: 16px !important;
border-top: 1px solid rgb(229 229 229) !important;
}
.dark .queue-paginator .p-paginator-bottom {
border-top-color: rgb(82 82 82) !important;
}
`;
const STATIONS = {
main: { label: "Main" },
@@ -49,16 +26,7 @@ const STATIONS = {
export default function Player({ user }) {
// Inject custom paginator styles
useEffect(() => {
const styleId = 'queue-paginator-styles';
if (!document.getElementById(styleId)) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = paginatorStyles;
document.head.appendChild(style);
}
}, []);
// Global CSS now contains the paginator / dialog datatable dark rules.
const [isQueueVisible, setQueueVisible] = useState(false);
// Mouse wheel scroll fix for queue modal
@@ -749,7 +717,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">
@@ -870,7 +838,8 @@ export default function Player({ user }) {
style={{ width: "80vw", maxWidth: "1200px", height: "auto", maxHeight: "90vh" }}
footer={queueFooter}
onHide={() => setQueueVisible(false)}
className={theme === "dark" ? "dark-theme" : "light-theme"}
// Use the same dark class used by other dialog styles (CSS escapes the colon)
className={theme === "dark" ? "dark:bg-neutral-900" : "light-theme"}
dismissableMask={true}
>
<div style={{ maxHeight: "calc(90vh - 100px)", overflow: "visible" }}>

View File

@@ -3,6 +3,13 @@ import { ProgressSpinner } from 'primereact/progressspinner';
import { authFetch } from '@/utils/authFetch';
// ============================================================================
// Discord Message Type Constants
// https://discord.com/developers/docs/resources/channel#message-object-message-types
const MESSAGE_TYPE_DEFAULT = 0;
const MESSAGE_TYPE_REPLY = 19;
const MESSAGE_TYPE_CHAT_INPUT_COMMAND = 20;
const MESSAGE_TYPE_CONTEXT_MENU_COMMAND = 23;
const MESSAGE_TYPE_POLL_RESULT = 46;
// Image modal context for child components to trigger modal
const ImageModalContext = createContext(null);
@@ -413,26 +420,28 @@ function resolveArchivedUser(archivedUsername, usersMap, members) {
* @param {Map} options.channelMap - Map of channel IDs to channel objects
* @param {Object} options.usersMap - Map of user IDs to user objects { displayName, username, color }
* @param {Object} options.rolesMap - Map of role IDs to role objects { name, color }
* @param {Object} options.emojiCache - Map of emoji IDs to cached emoji objects { url, animated }
* @param {Function} options.onChannelClick - Callback when channel is clicked
*/
function parseDiscordMarkdown(text, options = {}) {
if (!text) return '';
// Normalize HTML entities that sometimes make it into messages/embed fields
// We decode before we escape so strings like "A &amp; B" become "A & B"
// and avoid double-encoding when we later run an escape pass.
try {
// Normalize HTML entities that sometimes make it into messages/embed fields
// We decode before we escape so strings like "A &amp; B" become "A & B"
// and avoid double-encoding when we later run an escape pass.
const { channelMap = new Map(), usersMap = {}, rolesMap = new Map(), onChannelClick } = options;
const { channelMap = new Map(), usersMap = {}, rolesMap = new Map(), emojiCache = {}, onChannelClick } = options;
// Normalize entities then escape HTML to avoid XSS while ensuring
// already-encoded entities don't become double-encoded in the UI.
const normalized = decodeHtmlEntities(text);
// Normalize entities then escape HTML to avoid XSS while ensuring
// already-encoded entities don't become double-encoded in the UI.
const normalized = decodeHtmlEntities(text);
// Escape HTML first
let parsed = normalized
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Escape HTML first
let parsed = normalized
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Code blocks (``` ```) - add data-lenis-prevent for independent scrolling
// Must be processed first to prevent other formatting inside code
@@ -593,22 +602,39 @@ function parseDiscordMarkdown(text, options = {}) {
return `<span class="discord-mention">#${channelName}</span>`;
});
// Role mentions (<@&123456789>)
// Role mentions (<@&123456789>) - robust lookup to avoid errors when rolesMap is missing or malformed
parsed = parsed.replace(/&lt;@&amp;(\d+)&gt;/g, (_, roleId) => {
const role = rolesMap?.get?.(roleId) || rolesMap?.[roleId];
const roleName = role?.name || 'role';
const roleColor = role?.color || null;
const style = roleColor ? ` style="color: ${roleColor}; background-color: ${roleColor}20;"` : '';
return `<span class="discord-mention discord-role-mention"${style}>@${roleName}</span>`;
try {
let role = null;
if (rolesMap) {
if (typeof rolesMap.get === 'function') {
role = rolesMap.get(roleId);
} else if (rolesMap[roleId]) {
role = rolesMap[roleId];
} else if (rolesMap[String(roleId)]) {
role = rolesMap[String(roleId)];
}
}
const roleName = role?.name || 'role';
const roleColor = role?.color || null;
const style = roleColor ? ` style="color: ${roleColor}; background-color: ${roleColor}20;"` : '';
return `<span class="discord-mention discord-role-mention"${style}>@${roleName}</span>`;
} catch (err) {
// Defensive: log for telemetry/debug and return safe fallback
try { console.error('parseDiscordMarkdown: role mention parse failed', { roleId, err, rolesMapType: rolesMap && typeof rolesMap }); } catch (e) { /* ignore logging errors */ }
return `<span class="discord-mention discord-role-mention">@role</span>`;
}
});
// Slash command mentions (</command:123456789>)
parsed = parsed.replace(/&lt;\/([^:]+):(\d+)&gt;/g, '<span class="discord-slash-command">/$1</span>');
// Custom emoji (<:name:123456789> or <a:name:123456789>)
// Use cached emoji URL if available, otherwise fall back to Discord CDN
parsed = parsed.replace(/&lt;(a)?:(\w+):(\d+)&gt;/g, (_, animated, name, id) => {
const ext = animated ? 'gif' : 'png';
return `<img class="discord-emoji" src="https://cdn.discordapp.com/emojis/${id}.${ext}" alt=":${name}:" title=":${name}:">`;
const cached = emojiCache[id];
const url = cached?.url || `https://cdn.discordapp.com/emojis/${id}.${animated ? 'gif' : 'png'}`;
return `<img class="discord-emoji" src="${url}" alt=":${name}:" title=":${name}:">`;
});
// Unicode emoji (keep as-is, they render natively)
@@ -632,11 +658,20 @@ function parseDiscordMarkdown(text, options = {}) {
// Newlines
parsed = parsed.replace(/\n/g, '<br>');
// Unescape Discord markdown escape sequences (\_ \* \~ \` \| \\)
// Unescape Discord markdown escape sequences (\\_ \\* \\~ \\` \\| \\\\)
// Must be done after all markdown processing
parsed = parsed.replace(/\\([_*~`|\\])/g, '$1');
return parsed;
} catch (err) {
try { console.error('parseDiscordMarkdown failed', err); } catch (e) { /* ignore logging errors */ }
// Fallback: return a safely-escaped version of the input to avoid crashing the UI
const safe = String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return safe;
}
}
/**
@@ -743,27 +778,42 @@ const LinkPreview = memo(function LinkPreview({ url, cachedPreview, onPreviewLoa
return null; // Don't show anything for failed previews
}
// YouTube embed - trusted, use iframe
// YouTube embed - use click-to-play thumbnail
if (preview.type === 'youtube' && preview.videoId) {
const thumbnailUrl = `https://img.youtube.com/vi/${preview.videoId}/maxresdefault.jpg`;
const watchUrl = `https://www.youtube.com/watch?v=${preview.videoId}`;
return (
<div className="discord-embed discord-embed-video" style={{ borderColor: preview.themeColor || '#FF0000' }}>
<div className="discord-embed-content">
<div className="discord-embed-provider">YouTube</div>
{preview.title && (
<a href={url} target="_blank" rel="noopener noreferrer" className="discord-embed-title">
<a href={watchUrl} target="_blank" rel="noopener noreferrer" className="discord-embed-title">
{decodeHtmlEntities(preview.title)}
</a>
)}
</div>
<div className="discord-embed-video-container">
<iframe
src={`https://www.youtube.com/embed/${preview.videoId}`}
title={decodeHtmlEntities(preview.title) || 'YouTube video'}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="discord-embed-iframe"
/>
</div>
<a
href={watchUrl}
target="_blank"
rel="noopener noreferrer"
className="discord-embed-video-thumbnail-link"
title="Watch on YouTube"
>
<div className="discord-embed-video-container discord-embed-video-thumbnail">
<img
src={thumbnailUrl}
alt={decodeHtmlEntities(preview.title) || 'YouTube video'}
className="discord-embed-video-thumbnail-img"
/>
<div className="discord-embed-video-play-overlay">
<svg viewBox="0 0 68 48" className="discord-embed-video-play-icon">
<path d="M66.52,7.74c-0.78-2.93-2.49-5.41-5.42-6.19C55.79,.13,34,0,34,0S12.21,.13,6.9,1.55 C3.97,2.33,2.27,4.81,1.48,7.74C0.06,13.05,0,24,0,24s0.06,10.95,1.48,16.26c0.78,2.93,2.49,5.41,5.42,6.19 C12.21,47.87,34,48,34,48s21.79-0.13,27.1-1.55c2.93-0.78,4.64-3.26,5.42-6.19C67.94,34.95,68,24,68,24S67.94,13.05,66.52,7.74z" fill="#f00"></path>
<path d="M 45,24 27,14 27,34" fill="#fff"></path>
</svg>
</div>
</div>
</a>
</div>
);
}
@@ -866,7 +916,7 @@ const Attachment = memo(function Attachment({ attachment }) {
const openImageModal = useContext(ImageModalContext);
const isImage = content_type?.startsWith('image/') || IMAGE_EXTENSIONS.test(filename || url);
const isVideo = content_type?.startsWith('video/') || VIDEO_EXTENSIONS.test(filename || url);
const isVideo = content_type?.startsWith('video/') || VIDEO_EXTENSIONS.test(filename || url) || (url && url.includes('/api/discord/cached-video'));
const isAudio = content_type?.startsWith('audio/') || AUDIO_EXTENSIONS.test(filename || url);
if (isImage) {
@@ -958,6 +1008,7 @@ const DiscordMessage = memo(function DiscordMessage({
onPreviewLoad,
channelMap,
usersMap,
emojiCache,
members,
onChannelSelect,
channelName,
@@ -1019,6 +1070,9 @@ const DiscordMessage = memo(function DiscordMessage({
}
});
// Exclude URLs that match any attachment's originalUrl
const attachmentOriginalUrls = new Set(attachments?.map(a => a.originalUrl).filter(Boolean));
return urls.filter(url => {
// Skip if exact URL match
if (embedUrls.has(url)) return false;
@@ -1027,18 +1081,23 @@ const DiscordMessage = memo(function DiscordMessage({
const ytId = getYouTubeId(url);
if (ytId && embedYouTubeIds.has(ytId)) return false;
// Skip if URL matches any attachment originalUrl
if (attachmentOriginalUrls.has(url)) return false;
return true;
});
}, [urls, embeds]);
}, [urls, embeds, attachments]);
// Build rolesMap from members data for role mention parsing
// Build rolesMap from members data for role mention parsing (defensive)
const rolesMap = useMemo(() => {
const map = new Map();
members?.roles?.forEach(role => map.set(role.id, role));
if (Array.isArray(members?.roles)) {
members.roles.forEach(role => map.set(role?.id, role));
}
return map;
}, [members?.roles]);
const parsedContent = useMemo(() => parseDiscordMarkdown(displayContent, { channelMap, usersMap, rolesMap }), [displayContent, channelMap, usersMap, rolesMap]);
const parsedContent = useMemo(() => parseDiscordMarkdown(displayContent, { channelMap, usersMap, rolesMap, emojiCache }), [displayContent, channelMap, usersMap, rolesMap, emojiCache]);
// Handle channel link clicks
const handleContentClick = useCallback((e) => {
@@ -1069,9 +1128,15 @@ const DiscordMessage = memo(function DiscordMessage({
// System messages (join, boost, etc.)
// Type 0 = default, 19 = reply, 20 = chat input command, 23 = context menu command
// Types 20 and 23 are app/bot command messages and should render normally
if (type && type !== 0 && type !== 19 && type !== 20 && type !== 23) {
if (
type &&
type !== MESSAGE_TYPE_DEFAULT &&
type !== MESSAGE_TYPE_REPLY &&
type !== MESSAGE_TYPE_CHAT_INPUT_COMMAND &&
type !== MESSAGE_TYPE_CONTEXT_MENU_COMMAND
) {
// Special handling for poll result system messages (type 46)
if (type === 46) {
if (type === MESSAGE_TYPE_POLL_RESULT) {
// Find the poll_result embed
const pollResultEmbed = embeds?.find(e => e.type === 'poll_result');
let pollFields = {};
@@ -1163,7 +1228,15 @@ const DiscordMessage = memo(function DiscordMessage({
<>
{/* Reply context */}
{referenced_message && (
<div className="discord-reply-context">
<div
className="discord-reply-context"
onClick={() => {
if (referenced_message.id && onJumpToMessage) {
onJumpToMessage(referenced_message.id);
}
}}
style={{ cursor: referenced_message.id ? 'pointer' : 'default' }}
>
<img
src={referenced_message.author?.avatar
? (referenced_message.author.avatar.startsWith('http')
@@ -1182,11 +1255,12 @@ const DiscordMessage = memo(function DiscordMessage({
dangerouslySetInnerHTML={{
__html: referenced_message.content
? parseDiscordMarkdown(
referenced_message.content.length > 100
(referenced_message.content.length > 100
? referenced_message.content.slice(0, 100) + '...'
: referenced_message.content,
{ channelMap, usersMap, rolesMap }
)
: referenced_message.content
).replace(/\n/g, ' '),
{ channelMap, usersMap, rolesMap, emojiCache }
).replace(/<br\s*\/?>/gi, ' ')
: 'Click to see attachment'
}}
/>
@@ -1303,7 +1377,7 @@ const DiscordMessage = memo(function DiscordMessage({
{poll.question.emoji && (
poll.question.emoji.id ? (
<img
src={`https://cdn.discordapp.com/emojis/${poll.question.emoji.id}.${poll.question.emoji.animated ? 'gif' : 'png'}`}
src={emojiCache[poll.question.emoji.id]?.url || `https://cdn.discordapp.com/emojis/${poll.question.emoji.id}.${poll.question.emoji.animated ? 'gif' : 'png'}`}
alt={poll.question.emoji.name}
className="discord-poll-emoji"
/>
@@ -1331,7 +1405,7 @@ const DiscordMessage = memo(function DiscordMessage({
{answer.emoji && (
answer.emoji.id ? (
<img
src={`https://cdn.discordapp.com/emojis/${answer.emoji.id}.${answer.emoji.animated ? 'gif' : 'png'}`}
src={emojiCache[answer.emoji.id]?.url || `https://cdn.discordapp.com/emojis/${answer.emoji.id}.${answer.emoji.animated ? 'gif' : 'png'}`}
alt={answer.emoji.name}
className="discord-poll-answer-emoji"
/>
@@ -1422,7 +1496,7 @@ const DiscordMessage = memo(function DiscordMessage({
<div
className="discord-embed-description"
dangerouslySetInnerHTML={{
__html: parseDiscordMarkdown(embed.description, { channelMap, usersMap, rolesMap })
__html: parseDiscordMarkdown(embed.description, { channelMap, usersMap, rolesMap, emojiCache })
}}
/>
)}
@@ -1441,7 +1515,7 @@ const DiscordMessage = memo(function DiscordMessage({
<div
className="discord-embed-field-value"
dangerouslySetInnerHTML={{
__html: parseDiscordMarkdown(field.value, { channelMap, usersMap, rolesMap })
__html: parseDiscordMarkdown(field.value, { channelMap, usersMap, rolesMap, emojiCache })
}}
/>
)}
@@ -1463,15 +1537,40 @@ const DiscordMessage = memo(function DiscordMessage({
<img src={embed.image.url} alt="" className="discord-embed-image" />
)}
{embed.video?.url && (
// Check if it's a YouTube embed URL - use iframe instead of video
// Check if it's a YouTube URL - use click-to-play thumbnail
embed.video.url.includes('youtube.com/embed/') || embed.video.url.includes('youtu.be') ? (
<iframe
src={embed.video.url}
title={embed.title || 'YouTube video'}
className="discord-embed-video-iframe"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
(() => {
// Extract video ID from embed URL
const videoId = embed.video.url.includes('youtube.com/embed/')
? embed.video.url.split('/embed/')[1]?.split('?')[0]
: embed.video.url.split('/').pop()?.split('?')[0];
const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
const watchUrl = `https://www.youtube.com/watch?v=${videoId}`;
return (
<a
href={watchUrl}
target="_blank"
rel="noopener noreferrer"
className="discord-embed-video-thumbnail-link"
title="Watch on YouTube"
>
<div className="discord-embed-video-thumbnail">
<img
src={thumbnailUrl}
alt={embed.title || 'YouTube video'}
className="discord-embed-video-thumbnail-img"
/>
<div className="discord-embed-video-play-overlay">
<svg viewBox="0 0 68 48" className="discord-embed-video-play-icon">
<path d="M66.52,7.74c-0.78-2.93-2.49-5.41-5.42-6.19C55.79,.13,34,0,34,0S12.21,.13,6.9,1.55 C3.97,2.33,2.27,4.81,1.48,7.74C0.06,13.05,0,24,0,24s0.06,10.95,1.48,16.26c0.78,2.93,2.49,5.41,5.42,6.19 C12.21,47.87,34,48,34,48s21.79-0.13,27.1-1.55c2.93-0.78,4.64-3.26,5.42-6.19C67.94,34.95,68,24,68,24S67.94,13.05,66.52,7.74z" fill="#f00"></path>
<path d="M 45,24 27,14 27,34" fill="#fff"></path>
</svg>
</div>
</div>
</a>
);
})()
) : (
<video src={embed.video.url} controls className="discord-embed-video-player" />
)
@@ -1518,7 +1617,7 @@ const DiscordMessage = memo(function DiscordMessage({
>
{reaction.emoji.id ? (
<img
src={`https://cdn.discordapp.com/emojis/${reaction.emoji.id}.${reaction.emoji.animated ? 'gif' : 'png'}`}
src={reaction.emoji.url || `https://cdn.discordapp.com/emojis/${reaction.emoji.id}.${reaction.emoji.animated ? 'gif' : 'png'}`}
alt={reaction.emoji.name}
className="discord-reaction-emoji"
/>
@@ -1551,6 +1650,7 @@ export default function DiscordLogs() {
const [selectedChannel, setSelectedChannel] = useState(null);
const [messages, setMessages] = useState([]);
const [usersMap, setUsersMap] = useState({}); // Map of user ID -> { displayName, username, color }
const [emojiCache, setEmojiCache] = useState({}); // Map of emoji ID -> { url, animated }
const [members, setMembers] = useState(null); // { groups: [...], roles: [...] }
const [loadingMembers, setLoadingMembers] = useState(false);
const [memberListExpanded, setMemberListExpanded] = useState(false); // Default to collapsed
@@ -1559,6 +1659,8 @@ export default function DiscordLogs() {
const [loadingMessages, setLoadingMessages] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMoreMessages, setHasMoreMessages] = useState(true);
const [hasNewerMessages, setHasNewerMessages] = useState(false); // When viewing historical messages
const [loadingNewer, setLoadingNewer] = useState(false);
const [error, setError] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState(null); // Server-side search results
@@ -1722,6 +1824,46 @@ export default function DiscordLogs() {
setContextMenu(null);
}, [contextMenu]);
// Helper to scroll to a message element with correction for layout shifts
const scrollToMessageElement = useCallback((element, highlightDuration = 2000) => {
if (!element) return;
// First scroll immediately to get close
element.scrollIntoView({ behavior: 'instant', block: 'center' });
// Add highlight effect
element.classList.add('discord-message-highlight');
setTimeout(() => element.classList.remove('discord-message-highlight'), highlightDuration);
// After a delay for images to start loading, scroll again to correct for layout shifts
setTimeout(() => {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 300);
}, []);
// Helper to wait for an element to exist in DOM then scroll to it
const waitForElementAndScroll = useCallback((messageId, highlightDuration = 2000, maxAttempts = 20) => {
let attempts = 0;
const tryScroll = () => {
const element = document.getElementById(`message-${messageId}`);
if (element) {
scrollToMessageElement(element, highlightDuration);
return true;
}
attempts++;
if (attempts < maxAttempts) {
requestAnimationFrame(tryScroll);
return false;
}
console.warn(`Could not find message-${messageId} after ${maxAttempts} attempts`);
return false;
};
requestAnimationFrame(tryScroll);
}, [scrollToMessageElement]);
// Jump to a specific message (used from search results and poll result view)
const jumpToMessage = useCallback((messageId) => {
if (!messageId) return;
@@ -1732,10 +1874,7 @@ export default function DiscordLogs() {
// Message is already loaded, just scroll to it
const element = document.getElementById(`message-${messageId}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Add a brief highlight effect
element.classList.add('discord-message-highlight');
setTimeout(() => element.classList.remove('discord-message-highlight'), 2000);
scrollToMessageElement(element);
return;
}
}
@@ -1749,7 +1888,7 @@ export default function DiscordLogs() {
// Trigger a re-fetch by incrementing the counter
setRefetchCounter(c => c + 1);
setLoadingMessages(true);
}, [messages]);
}, [messages, scrollToMessageElement]);
// Handle channel context menu (right-click)
const handleChannelContextMenu = useCallback((e, channel) => {
@@ -1856,10 +1995,12 @@ export default function DiscordLogs() {
return map;
}, [channels]);
// Create roles lookup map for role mentions
// Create roles lookup map for role mentions (defensive)
const rolesMap = useMemo(() => {
const map = new Map();
members?.roles?.forEach(role => map.set(role.id, role));
if (Array.isArray(members?.roles)) {
members.roles.forEach(role => map.set(role?.id, role));
}
return map;
}, [members?.roles]);
@@ -2073,7 +2214,9 @@ export default function DiscordLogs() {
async function fetchMessages() {
setMessages([]);
setUsersMap({});
setEmojiCache({});
setHasMoreMessages(true);
setHasNewerMessages(false); // Loading latest messages
// Capture target message ID from ref (for deep-linking)
const targetMessageId = pendingTargetMessageRef.current;
@@ -2089,9 +2232,10 @@ export default function DiscordLogs() {
if (!response.ok) throw new Error('Failed to fetch messages');
const data = await response.json();
// Handle new response format { messages, users }
// Handle new response format { messages, users, emojiCache }
const messagesData = data.messages || data;
const usersData = data.users || {};
const emojiCacheData = data.emojiCache || {};
// If we were looking for a specific message but got no results,
// fall back to loading the latest messages
@@ -2103,6 +2247,7 @@ export default function DiscordLogs() {
const fallbackData = await fallbackResponse.json();
const fallbackMessages = fallbackData.messages || fallbackData;
const fallbackUsers = fallbackData.users || {};
const fallbackEmojis = fallbackData.emojiCache || {};
const normalizedFallback = fallbackMessages.map(msg => ({
...msg,
referenced_message: msg.referencedMessage || msg.referenced_message,
@@ -2113,6 +2258,7 @@ export default function DiscordLogs() {
}));
setMessages(normalizedFallback.reverse());
setUsersMap(fallbackUsers);
setEmojiCache(fallbackEmojis);
setHasMoreMessages(fallbackMessages.length === 50);
scrollToBottomRef.current = true;
lastPollTimeRef.current = new Date().toISOString();
@@ -2138,6 +2284,7 @@ export default function DiscordLogs() {
setMessages(orderedMessages);
setUsersMap(usersData);
setEmojiCache(emojiCacheData);
setHasMoreMessages(messagesData.length === 50);
// Reset poll time for edit detection
@@ -2172,28 +2319,28 @@ export default function DiscordLogs() {
// Handle target message (deep-linking)
const targetMessageId = pendingTargetMessageRef.current;
if (targetMessageId) {
// Use requestAnimationFrame to ensure DOM is fully painted
requestAnimationFrame(() => {
const targetElement = document.getElementById(`message-${targetMessageId}`);
if (targetElement) {
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Highlight the message with pulse + fade animation
targetElement.classList.add('discord-message-highlight');
setTimeout(() => {
targetElement.classList.remove('discord-message-highlight');
}, 5000); // 3 pulses (1.5s) + 3s fade
}
pendingTargetMessageRef.current = null;
});
pendingTargetMessageRef.current = null;
waitForElementAndScroll(targetMessageId, 5000); // 3 pulses (1.5s) + 3s fade
return;
}
// Handle scroll to bottom on initial channel load
if (scrollToBottomRef.current) {
scrollToBottomRef.current = false;
container.scrollTop = container.scrollHeight;
// Wait for content to render, then scroll to bottom
const scrollToBottom = () => {
container.scrollTop = container.scrollHeight;
};
// Scroll immediately
scrollToBottom();
// Then scroll again after a delay to correct for any layout shifts
setTimeout(scrollToBottom, 300);
setTimeout(scrollToBottom, 600);
}
}, [messages]);
}, [messages, waitForElementAndScroll]);
// Load more messages (pagination)
const loadMoreMessages = useCallback(async () => {
@@ -2209,9 +2356,10 @@ export default function DiscordLogs() {
if (!response.ok) throw new Error('Failed to fetch more messages');
const data = await response.json();
// Handle new response format { messages, users }
// Handle new response format { messages, users, emojiCache }
const messagesData = data.messages || data;
const usersData = data.users || {};
const emojiCacheData = data.emojiCache || {};
const normalizedMessages = messagesData.map(msg => ({
...msg,
@@ -2226,6 +2374,8 @@ export default function DiscordLogs() {
setMessages(prev => [...normalizedMessages.reverse(), ...prev]);
// Merge new users into existing usersMap
setUsersMap(prev => ({ ...prev, ...usersData }));
// Merge new emojis into existing emojiCache
setEmojiCache(prev => ({ ...prev, ...emojiCacheData }));
setHasMoreMessages(messagesData.length === 50);
} catch (err) {
console.error('Failed to load more messages:', err);
@@ -2234,7 +2384,96 @@ export default function DiscordLogs() {
}
}, [loadingMore, hasMoreMessages, messages, selectedChannel]);
// Infinite scroll: load more when scrolling near the top
// Jump to first message in channel
const jumpToFirstMessage = useCallback(async () => {
if (!selectedChannel || loadingMessages) return;
setLoadingMessages(true);
setMessages([]);
try {
// Fetch oldest messages using oldest=true parameter
const response = await authFetch(
`/api/discord/messages?channelId=${selectedChannel.id}&limit=50&oldest=true`
);
if (!response.ok) throw new Error('Failed to fetch first messages');
const data = await response.json();
const messagesData = data.messages || data;
const usersData = data.users || {};
const emojiCacheData = data.emojiCache || {};
const normalizedMessages = messagesData.map(msg => ({
...msg,
referenced_message: msg.referencedMessage || msg.referenced_message,
attachments: (msg.attachments || []).map(att => ({
...att,
content_type: att.contentType || att.content_type,
})),
}));
setMessages(normalizedMessages);
setUsersMap(usersData);
setEmojiCache(emojiCacheData);
setHasMoreMessages(messagesData.length === 50);
setHasNewerMessages(true); // We're viewing oldest, so there are newer messages
// Scroll to top after messages load
requestAnimationFrame(() => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = 0;
}
});
} catch (err) {
console.error('Failed to jump to first message:', err);
} finally {
setLoadingMessages(false);
}
}, [selectedChannel, loadingMessages]);
// Load newer messages (when viewing historical/oldest messages)
const loadNewerMessages = useCallback(async () => {
if (loadingNewer || !hasNewerMessages || messages.length === 0) return;
setLoadingNewer(true);
try {
// Get the newest message ID currently loaded
const newestMessage = messages[messages.length - 1];
const response = await authFetch(
`/api/discord/messages?channelId=${selectedChannel.id}&limit=50&after=${newestMessage.id}`
);
if (!response.ok) throw new Error('Failed to fetch newer messages');
const data = await response.json();
const messagesData = data.messages || data;
const usersData = data.users || {};
const emojiCacheData = data.emojiCache || {};
const normalizedMessages = messagesData.map(msg => ({
...msg,
referenced_message: msg.referencedMessage || msg.referenced_message,
attachments: (msg.attachments || []).map(att => ({
...att,
content_type: att.contentType || att.content_type,
})),
}));
// Append newer messages
setMessages(prev => [...prev, ...normalizedMessages]);
setUsersMap(prev => ({ ...prev, ...usersData }));
setEmojiCache(prev => ({ ...prev, ...emojiCacheData }));
// If we got less than 50, we've reached the end (no more newer messages)
if (messagesData.length < 50) {
setHasNewerMessages(false);
}
} catch (err) {
console.error('Failed to load newer messages:', err);
} finally {
setLoadingNewer(false);
}
}, [loadingNewer, hasNewerMessages, messages, selectedChannel]);
// Infinite scroll: load more when scrolling near the top or bottom
useEffect(() => {
const container = messagesContainerRef.current;
if (!container) return;
@@ -2243,7 +2482,7 @@ export default function DiscordLogs() {
// Don't load more when viewing search results
if (searchQuery.trim().length >= 2 && searchResults !== null) return;
// Load more when within 200px of the top
// Load older messages when within 200px of the top
if (container.scrollTop < 200 && hasMoreMessages && !loadingMore) {
// Save scroll position before loading
const scrollHeightBefore = container.scrollHeight;
@@ -2255,17 +2494,25 @@ export default function DiscordLogs() {
});
});
}
// Load newer messages when within 200px of the bottom (when viewing historical)
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
if (distanceFromBottom < 200 && hasNewerMessages && !loadingNewer) {
loadNewerMessages();
}
};
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [hasMoreMessages, loadingMore, loadMoreMessages, searchQuery, searchResults]);
}, [hasMoreMessages, loadingMore, loadMoreMessages, hasNewerMessages, loadingNewer, loadNewerMessages, searchQuery, searchResults]);
// Poll for new messages and edits every 5 seconds
useEffect(() => {
if (!selectedChannel || loadingMessages || messages.length === 0) return;
// Don't poll when viewing search results - it would add messages that aren't in search
if (searchQuery.trim().length >= 2 && searchResults !== null) return;
// Don't poll when viewing historical messages (jumped to first)
if (hasNewerMessages) return;
const pollInterval = setInterval(async () => {
try {
@@ -2281,11 +2528,13 @@ export default function DiscordLogs() {
let newMessages = [];
let newUsersData = {};
let newEmojiCacheData = {};
if (newMsgsResponse.ok) {
const data = await newMsgsResponse.json();
const messagesData = data.messages || data;
newUsersData = data.users || {};
newEmojiCacheData = data.emojiCache || {};
if (messagesData.length > 0) {
newMessages = messagesData.map(msg => ({
@@ -2309,7 +2558,9 @@ export default function DiscordLogs() {
const editedData = await editedResponse.json();
const editedMessagesData = editedData.messages || editedData;
const editedUsersData = editedData.users || {};
const editedEmojiCacheData = editedData.emojiCache || {};
newUsersData = { ...newUsersData, ...editedUsersData };
newEmojiCacheData = { ...newEmojiCacheData, ...editedEmojiCacheData };
editedMessages = editedMessagesData.map(msg => ({
...msg,
@@ -2358,6 +2609,7 @@ export default function DiscordLogs() {
});
setUsersMap(prev => ({ ...prev, ...newUsersData }));
setEmojiCache(prev => ({ ...prev, ...newEmojiCacheData }));
}
// Auto-scroll to bottom if user was already near bottom and there are new messages
@@ -2372,7 +2624,7 @@ export default function DiscordLogs() {
}, 5000);
return () => clearInterval(pollInterval);
}, [selectedChannel, loadingMessages, messages, searchQuery, searchResults]); // Poll for channel/guild updates every 5 seconds
}, [selectedChannel, loadingMessages, messages, searchQuery, searchResults, hasNewerMessages]); // Poll for channel/guild updates every 5 seconds
useEffect(() => {
if (loading) return; // Don't poll during initial load
@@ -2677,7 +2929,7 @@ export default function DiscordLogs() {
onClick={() => setTopicExpanded(!topicExpanded)}
title={topicExpanded ? 'Click to collapse' : 'Click to expand'}
dangerouslySetInnerHTML={{
__html: parseDiscordMarkdown(selectedChannel.topic, { channelMap, usersMap, rolesMap })
__html: parseDiscordMarkdown(selectedChannel.topic, { channelMap, usersMap, rolesMap, emojiCache })
}}
/>
</>
@@ -2703,6 +2955,16 @@ export default function DiscordLogs() {
</svg>
)}
</button>
<button
className="discord-jump-first-btn"
onClick={jumpToFirstMessage}
title="Jump to first message"
disabled={loadingMessages}
>
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
<path d="M18.41 16.59L13.82 12l4.59-4.59L17 6l-6 6 6 6zM6 6h2v12H6z" />
</svg>
</button>
<button
className={`discord-member-toggle-btn ${memberListExpanded ? 'active' : ''}`}
onClick={() => setMemberListExpanded(!memberListExpanded)}
@@ -2908,6 +3170,7 @@ export default function DiscordLogs() {
onPreviewLoad={handlePreviewLoad}
channelMap={channelMap}
usersMap={usersMap}
emojiCache={emojiCache}
members={members}
onChannelSelect={handleChannelSelect}
channelName={selectedChannel?.name}
@@ -3016,7 +3279,7 @@ export default function DiscordLogs() {
<div className="discord-reaction-popup-header">
{reactionPopup.emoji.id ? (
<img
src={`https://cdn.discordapp.com/emojis/${reactionPopup.emoji.id}.${reactionPopup.emoji.animated ? 'gif' : 'png'}`}
src={reactionPopup.emoji.url || `https://cdn.discordapp.com/emojis/${reactionPopup.emoji.id}.${reactionPopup.emoji.animated ? 'gif' : 'png'}`}
alt={reactionPopup.emoji.name}
className="discord-reaction-popup-emoji"
/>

View File

@@ -14,7 +14,7 @@ function clearCookie(name) {
document.cookie = `${name}=; Max-Age=0; path=/;`;
}
export default function LoginPage({ loggedIn = false }) {
export default function LoginPage({ loggedIn = false, accessDenied = false, requiredRoles = [] }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
@@ -80,7 +80,11 @@ export default function LoginPage({ loggedIn = false }) {
toast.success("Login successful!", {
toastId: "login-success-toast",
});
const returnTo = "/";
// Check for returnUrl in query params
const urlParams = new URLSearchParams(window.location.search);
const returnUrl = urlParams.get('returnUrl');
// Validate returnUrl is a relative path (security: prevent open redirect)
const returnTo = (returnUrl && returnUrl.startsWith('/')) ? returnUrl : '/';
window.location.href = returnTo;
} else {
toast.error("Login failed: no access token received", {
@@ -98,18 +102,32 @@ export default function LoginPage({ loggedIn = false }) {
}
if (loggedIn) {
const rolesList = Array.isArray(requiredRoles) ? requiredRoles : (requiredRoles ? requiredRoles.split(',') : []);
return (
<div className="flex items-center justify-center px-4 py-16">
<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">But you do not have permission to access this resource.
<div className="max-w-md w-full bg-white dark:bg-[#1a1a1a] rounded-2xl shadow-xl shadow-neutral-900/5 dark:shadow-black/30 border border-neutral-200/60 dark:border-neutral-800/60 px-10 py-8 text-center">
<img className="logo-auth mx-auto mb-5" src="/images/zim.png" alt="Logo" />
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-3 tracking-tight">Access Denied</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
You don't 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.
{rolesList.length > 0 && (
<div className="mb-5 p-3 bg-neutral-100 dark:bg-neutral-800/50 rounded-xl border border-neutral-200/60 dark:border-neutral-700/60">
<p className="text-sm text-neutral-500 dark:text-neutral-500 mb-2 font-medium">Required role{rolesList.length > 1 ? 's' : ''}:</p>
<div className="flex flex-wrap justify-center gap-2">
{rolesList.map((role, i) => (
<span key={i} className="px-2.5 py-1 text-xs font-semibold bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 rounded-full">
{role}
</span>
))}
</div>
</div>
)}
<p className="text-xs italic text-neutral-400 dark:text-neutral-500 mb-5">
If you believe this is an error, scream at codey.
</p>
<Button
className="btn"
className="w-full py-2.5 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 transition-colors shadow-sm"
color="primary"
variant="solid"
onClick={() => (window.location.href = "/")}
@@ -122,16 +140,23 @@ export default function LoginPage({ loggedIn = false }) {
}
return (
<div className="flex items-start justify-center bg-gray-50 dark:bg-[#121212] px-4 pt-20 py-10">
<div className="max-w-md w-full bg-white dark:bg-[#1E1E1E] rounded-2xl shadow-xl px-10 pb-6">
<h2 className="flex flex-col items-center text-3xl font-semibold text-gray-900 dark:text-white mb-8 font-sans">
<img className="logo-auth mb-4" src="/images/zim.png" alt="Logo" />
Log In
</h2>
<div className="flex items-center justify-center px-4 py-16">
<div className="max-w-md w-full bg-white dark:bg-[#1a1a1a] rounded-2xl shadow-xl shadow-neutral-900/5 dark:shadow-black/30 border border-neutral-200/60 dark:border-neutral-800/60 px-10 py-8">
<div className="text-center mb-8">
<img className="logo-auth mx-auto mb-4" src="/images/zim.png" alt="Logo" />
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white tracking-tight">
Log In
</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1">
Sign in to continue
</p>
</div>
<form className="space-y-6 relative" onSubmit={handleSubmit} noValidate>
{/* Username */}
<div className="relative">
<form className="space-y-5" onSubmit={handleSubmit} noValidate>
<div className="space-y-2">
<label htmlFor="username" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Username
</label>
<input
type="text"
id="username"
@@ -141,20 +166,15 @@ export default function LoginPage({ loggedIn = false }) {
onChange={(e) => setUsername(e.target.value)}
required
disabled={loading}
className="peer block w-full px-4 pt-5 pb-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-transparent text-gray-900 dark:text-white placeholder-transparent focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-xl px-4 py-3 bg-white dark:bg-neutral-900/50 text-neutral-900 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 transition-all outline-none"
placeholder="Enter your username"
/>
<label
htmlFor="username"
className="absolute left-4 top-2 text-gray-500 dark:text-gray-400 text-sm transition-all
peer-placeholder-shown:top-5 peer-placeholder-shown:text-gray-400 peer-placeholder-shown:text-base
peer-focus:top-2 peer-focus:text-sm peer-focus:text-blue-500 dark:peer-focus:text-blue-400"
>
Username
</label>
</div>
{/* Password */}
<div className="relative">
<div className="space-y-2">
<label htmlFor="password" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Password
</label>
<input
type="password"
id="password"
@@ -166,26 +186,20 @@ export default function LoginPage({ loggedIn = false }) {
onChange={(e) => setPassword(e.target.value)}
required
disabled={loading}
className="peer block w-full px-4 pt-5 pb-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-transparent text-gray-900 dark:text-white placeholder-transparent focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
className="w-full border border-neutral-200 dark:border-neutral-700 rounded-xl px-4 py-3 bg-white dark:bg-neutral-900/50 text-neutral-900 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20 transition-all outline-none"
placeholder="Enter your password"
/>
<label
htmlFor="password"
className="absolute left-4 top-2 text-gray-500 dark:text-gray-400 text-sm transition-all
peer-placeholder-shown:top-5 peer-placeholder-shown:text-gray-400 peer-placeholder-shown:text-base
peer-focus:top-2 peer-focus:text-sm peer-focus:text-blue-500 dark:peer-focus:text-blue-400"
>
Password
</label>
</div>
<button
type="submit"
disabled={loading}
className={`w-full py-3 bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 text-white rounded-lg font-semibold shadow-md transition-colors ${loading ? "opacity-60 cursor-not-allowed" : ""
}`}
>
{loading ? "Signing In..." : "Sign In"}
</button>
<div className="pt-2">
<button
type="submit"
disabled={loading}
className={`w-full py-3 px-6 bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-500/30 text-white rounded-xl font-semibold shadow-sm transition-all ${loading ? "opacity-60 cursor-not-allowed" : ""}`}
>
{loading ? "Signing In..." : "Sign In"}
</button>
</div>
</form>
</div>
</div>

View File

@@ -34,10 +34,26 @@ export default function LyricSearch() {
const [showLyrics, setShowLyrics] = useState(false);
return (
<div className="lyric-search">
<h1 className="text-3xl font-bold mb-8 text-neutral-900 dark:text-white tracking-tight">
Lyric Search
</h1>
<div className="lyric-search w-full">
{/* Hero section */}
<div className="mt-8 mb-12 text-center flex flex-col items-center">
<div className="relative w-32 h-32 flex items-center justify-center mb-4">
<div
className="absolute inset-0 rounded-full"
style={{
background: 'radial-gradient(circle at 50% 50%, rgba(168,85,247,0.25) 0%, rgba(168,85,247,0.15) 30%, rgba(236,72,153,0.08) 60%, transparent 80%)',
}}
></div>
<span className="relative text-6xl" style={{ marginTop: '-4px' }}>🎤</span>
</div>
<h1 className="text-4xl font-bold mb-3 text-neutral-900 dark:text-white tracking-tight">
Lyric Search
</h1>
<p className="text-neutral-600 dark:text-neutral-400 text-base max-w-sm leading-relaxed">
Search millions of songs instantly.<br />
<span className="text-neutral-400 dark:text-neutral-500 text-sm">Powered by Genius, LRCLib & more</span>
</p>
</div>
<LyricSearchInputField
id="lyric-search-input"
placeholder="Artist - Song"
@@ -268,7 +284,9 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
const evaluation = evaluateSearchValue(searchValue);
if (!evaluation?.valid) {
const message = statusLabels[evaluation?.status || inputStatus] || "Please use Artist - Song";
toast.error(message);
if (!toast.isActive("lyrics-validation-error-toast")) {
toast.error(message, { toastId: "lyrics-validation-error-toast" });
}
return;
}
@@ -335,13 +353,14 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
dismissSearchToast();
toast.success(`Found! (Took ${duration}s)`, {
autoClose: 2500,
toastId: `lyrics-success-${Date.now()}`,
toastId: "lyrics-success-toast",
});
} catch (error) {
dismissSearchToast();
toast.error(error.message, {
icon: () => "😕",
autoClose: 5000,
toastId: "lyrics-error-toast",
});
} finally {
setIsLoading(false);
@@ -412,7 +431,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
}, [statusTitle]);
return (
<div>
<div className="w-full">
<div className="lyric-search-input-wrapper">
<AutoComplete
id={id}
@@ -446,7 +465,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
{statusTitle}
</span>
</div>
<div className="flex items-center gap-4 mt-5">
<div className="flex flex-wrap items-center justify-center gap-4 mt-5 mb-8">
<Button
onClick={() => handleSearch()}
className="search-btn"
@@ -454,12 +473,12 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
>
Search
</Button>
<div className="h-6 w-px bg-neutral-300 dark:bg-neutral-700" aria-hidden="true"></div>
<div className="h-6 w-px bg-neutral-300 dark:bg-neutral-700 hidden sm:block" 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" onToggle={toggleExclusion} />
<UICheckbox id="excl-Cache" label="Cache" onToggle={toggleExclusion} />
<span className="exclude-label hidden sm:inline">Exclude:</span>
<UICheckbox id="excl-Genius" label="Genius" value="Genius" onToggle={toggleExclusion} />
<UICheckbox id="excl-LRCLib-Cache" label="LRCLib" value="LRCLib-Cache" onToggle={toggleExclusion} />
<UICheckbox id="excl-Cache" label="Cache" value="Cache" onToggle={toggleExclusion} />
</div>
</div>
@@ -585,7 +604,7 @@ export const UICheckbox = forwardRef(function UICheckbox(props = {}, ref) {
const newChecked = !checked;
setChecked(newChecked);
if (props.onToggle) {
const source = props.label;
const source = props.value || props.label;
props.onToggle(source);
}
setTimeout(verifyExclusions, 0);

View File

@@ -3,18 +3,24 @@ import { API_URL } from "../config";
export default function RandomMsg() {
const [randomMsg, setRandomMsg] = useState("");
const [responseTime, setResponseTime] = useState(null);
const [showResponseTime, setShowResponseTime] = useState(false);
const getRandomMsg = async () => {
try {
const start = performance.now();
const response = await fetch(`${API_URL}/randmsg`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
});
const end = performance.now();
setResponseTime(Math.round(end - start));
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (data?.msg) setRandomMsg(data.msg.replace(/<br\s*\/?>/gi, "\n"));
if (data?.msg) setRandomMsg(data.msg.replace(/<br\s*\/?\>/gi, "\n"));
} catch (err) {
console.error("Failed to fetch random message:", err);
setResponseTime(null);
}
};
@@ -22,11 +28,43 @@ export default function RandomMsg() {
return (
<div className="random-msg-container">
<div className="random-msg">
<div className="random-msg" style={{ position: "relative", display: "inline-block" }}>
{randomMsg && (
<small>
<i>{randomMsg}</i>
</small>
<>
<small
style={{ cursor: responseTime !== null ? "pointer" : "default" }}
onClick={() => {
if (responseTime !== null) setShowResponseTime((v) => !v);
}}
tabIndex={0}
onBlur={() => setShowResponseTime(false)}
>
<i>{randomMsg}</i>
</small>
{showResponseTime && responseTime !== null && (
<div
style={{
position: "absolute",
left: "50%",
top: "100%",
transform: "translateX(-50%)",
marginTop: 4,
background: "#222",
color: "#fff",
fontSize: "0.75em",
padding: "2px 8px",
borderRadius: 6,
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
zIndex: 10,
whiteSpace: "nowrap"
}}
role="status"
aria-live="polite"
>
API response: {responseTime} ms
</div>
)}
</>
)}
</div>
{randomMsg && (

View File

@@ -0,0 +1,64 @@
/**
* SubsiteAppLayout - Lightweight app layout for subsites
*
* This is a minimal version that avoids importing from shared modules
* that would pull in heavy CSS (AppLayout, Components, etc.)
*/
import React, { Suspense, lazy } from 'react';
import { ToastContainer, toast } from 'react-toastify';
import { CssVarsProvider } from "@mui/joy";
import { CacheProvider } from "@emotion/react";
import createCache from "@emotion/cache";
import { PrimeReactProvider } from "primereact/api";
// Import only minimal CSS - no theme CSS, no primeicons
import 'react-toastify/dist/ReactToastify.css';
import 'primereact/resources/primereact.min.css';
const ReqForm = lazy(() => import('./req/ReqForm.jsx'));
// Inline minimal JoyUI wrapper to avoid importing Components.jsx
function MinimalJoyWrapper({ children }) {
const cache = React.useRef();
if (!cache.current) {
cache.current = createCache({ key: "joy-sub" });
}
return (
<CacheProvider value={cache.current}>
<CssVarsProvider>{children}</CssVarsProvider>
</CacheProvider>
);
}
export default function SubsiteRoot({ child, ...props }) {
if (typeof window !== 'undefined') {
window.toast = toast;
}
const theme = typeof document !== 'undefined'
? document.documentElement.getAttribute("data-theme")
: 'light';
return (
<PrimeReactProvider>
<ToastContainer
position="top-right"
autoClose={5000}
hideProgressBar={false}
newestOnTop={true}
closeOnClick={true}
rtl={false}
pauseOnFocusLoss={false}
draggable
pauseOnHover
theme={theme === 'dark' ? 'dark' : 'light'}
/>
<MinimalJoyWrapper>
{child === "ReqForm" && (
<Suspense fallback={<div style={{ padding: '2rem', textAlign: 'center' }}>Loading...</div>}>
<ReqForm {...props} />
</Suspense>
)}
</MinimalJoyWrapper>
</PrimeReactProvider>
);
}

View File

@@ -14,16 +14,17 @@ export default function BreadcrumbNav({ currentPage }) {
<React.Fragment key={key}>
<a
href={href}
data-astro-reload
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"
? "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>
<span className="text-neutral-400 dark:text-neutral-600 py-1.5 flex items-center" aria-hidden="true">/</span>
)}
</React.Fragment>
);

View File

@@ -240,8 +240,10 @@ export default function MediaRequestForm() {
audio.src = sourceUrl;
setAudioProgress({ current: 0, duration: 0 });
setCurrentTrackId(track.id);
setIsAudioPlaying(true);
await audio.play();
} catch (error) {
setIsAudioPlaying(false);
console.error(error);
toast.error("Failed to play track.");
if (!fromQueue) {
@@ -492,13 +494,16 @@ export default function MediaRequestForm() {
if (currentTrackId === track.id) {
if (audio.paused) {
setIsAudioPlaying(true);
try {
await audio.play();
} catch (error) {
setIsAudioPlaying(false);
console.error(error);
toast.error("Unable to resume playback.");
}
} else {
setIsAudioPlaying(false);
audio.pause();
}
return;
@@ -547,13 +552,16 @@ export default function MediaRequestForm() {
if (queueAlbumId === albumId && playbackQueue.length > 0) {
if (audio.paused) {
setIsAudioPlaying(true);
try {
await audio.play();
} catch (error) {
setIsAudioPlaying(false);
console.error(error);
toast.error("Unable to resume album playback.");
}
} else {
setIsAudioPlaying(false);
audio.pause();
}
return;
@@ -1182,7 +1190,21 @@ export default function MediaRequestForm() {
/>
<button
type="button"
onClick={() => handleTrackPlayPause(track, id, albumIndex)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleTrackPlayPause(track, id, albumIndex);
}}
onPointerDown={(e) => {
try {
if (e?.pointerType === "touch" || e.type === "touchstart") {
e.preventDefault();
}
} catch (err) {
// ignore
}
}}
style={{ touchAction: "manipulation" }}
className={`flex items-center justify-center w-8 h-8 rounded-full border text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${isCurrentTrack && isAudioPlaying
? "border-green-600 text-green-600"
: "border-neutral-400 text-neutral-600 hover:text-blue-600 hover:border-blue-600"}`}
@@ -1213,6 +1235,7 @@ export default function MediaRequestForm() {
<button
type="button"
onClick={() => handleTrackDownload(track)}
referrerPolicy="no-referrer"
className="text-neutral-500 hover:text-blue-600 underline whitespace-nowrap cursor-pointer"
aria-label={`Download ${track.title}`}
>

View File

@@ -1,6 +1,27 @@
/* Table and Dark Overrides */
.trip-management-container {
width: 100%;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif;
}
/* Improve DataTable font across headers, cells, paginator, and body */
.trip-management-container .p-datatable,
.trip-management-container .p-datatable th,
.trip-management-container .p-datatable td,
.trip-management-container .p-paginator,
.trip-management-container .p-datatable .p-datatable-header,
.trip-management-container .p-datatable .p-datatable-footer{
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif !important;
font-weight: 500 !important;
font-size: 0.95rem !important;
line-height: 1.25rem !important;
}
/* Keep monospace for any code or ident columns */
.trip-management-container .p-datatable td.code,
.trip-management-container .p-datatable td.mono {
font-family: ui-monospace, "JetBrains Mono", "Fira Code", "Roboto Mono", "Consolas", "Monaco", "Courier New", monospace !important;
font-weight: 400 !important;
}
.trip-management-container .table-wrapper {
@@ -37,7 +58,9 @@
/* Column widths - distribute across table */
.trip-management-container .p-datatable-thead > tr > th,
.trip-management-container .p-datatable-tbody > tr > td {
/* Default: auto distribute */
/* Default column distribution: uniform padding and vertical alignment */
padding: 0.65rem 0.5rem;
vertical-align: middle;
}
/* ID column - narrow */
@@ -252,34 +275,142 @@
}
/* Progress Bar Styles */
.progress-bar-container {
.rm-progress-container {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.progress-bar-track {
flex: 1;
.rm-progress-track {
position: relative;
flex: 1 1 0%;
min-width: 0;
height: 6px;
background-color: rgba(128, 128, 128, 0.2);
border-radius: 999px;
overflow: hidden;
border-radius: 999px; /* rounded track so fill edge is hidden when smaller */
overflow: hidden; /* must clip when scaled */
margin: 0 !important;
padding: 0 !important;
}
.progress-bar-track-lg {
.rm-progress-track-lg {
height: 10px;
}
.progress-bar-fill {
.rm-progress-fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
border-radius: 999px;
transition: width 0.3s ease;
width: 100% !important; /* full width; we scale via transform to avoid subpixel gaps */
transform-origin: left center;
transform: scaleX(var(--rm-progress, 0)); /* use custom property (0-1 range) */
border-top-left-radius: 999px;
border-bottom-left-radius: 999px;
transition: transform 0.24s cubic-bezier(0.4,0,0.2,1), border-radius 0.24s;
margin: 0 !important;
padding: 0 !important;
right: 0;
min-width: 0;
will-change: transform;
box-sizing: border-box;
}
.progress-bar-text {
/* Fix for native audio progress bar (range input) */
.audio-progress-range {
width: 100%;
height: 6px;
background: transparent;
appearance: none;
-webkit-appearance: none;
border-radius: 999px;
overflow: hidden;
margin: 0;
padding: 0;
}
.audio-progress-range::-webkit-slider-runnable-track {
height: 6px;
background: rgba(128, 128, 128, 0.2);
border-radius: 999px;
}
.audio-progress-range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #2563eb;
box-shadow: 0 0 2px rgba(0,0,0,0.2);
margin-top: -3px;
cursor: pointer;
border: none;
}
.audio-progress-range::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: #2563eb;
border: none;
cursor: pointer;
}
.audio-progress-range::-ms-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: #2563eb;
border: none;
cursor: pointer;
}
.audio-progress-range::-ms-fill-lower {
background: #2563eb;
border-radius: 999px;
}
.audio-progress-range::-ms-fill-upper {
background: rgba(128, 128, 128, 0.2);
border-radius: 999px;
}
.audio-progress-range::-webkit-slider-thumb {
box-shadow: 0 0 2px rgba(0,0,0,0.2);
}
.audio-progress-range:focus {
outline: none;
}
.audio-progress-range::-webkit-slider-runnable-track {
background: linear-gradient(to right, #2563eb 0%, #2563eb var(--progress, 0%), rgba(128,128,128,0.2) var(--progress, 0%), rgba(128,128,128,0.2) 100%);
}
.audio-progress-range::-moz-range-progress {
background-color: #2563eb;
border-radius: 999px;
}
.audio-progress-range::-moz-range-track {
background-color: rgba(128, 128, 128, 0.2);
border-radius: 999px;
}
.audio-progress-range::-ms-fill-lower {
background-color: #2563eb;
border-radius: 999px;
}
.audio-progress-range::-ms-fill-upper {
background-color: rgba(128, 128, 128, 0.2);
border-radius: 999px;
}
.audio-progress-range:focus::-webkit-slider-runnable-track {
background: linear-gradient(to right, #2563eb 0%, #2563eb var(--progress, 0%), rgba(128,128,128,0.2) var(--progress, 0%), rgba(128,128,128,0.2) 100%);
}
.audio-progress-range::-ms-tooltip {
display: none;
}
.rm-progress-text {
font-size: 0.75rem;
font-weight: 600;
/* Ensure progress styles apply when rendered within a PrimeReact Dialog (portal) */
.p-dialog .rm-progress-container{display:flex;align-items:center;width:100%}
.p-dialog .rm-progress-track{position:relative;flex:1 1 0%;min-width:0;height:6px;background-color:#80808033;border-radius:999px;overflow:hidden;margin:0!important;padding:0!important}
.p-dialog .rm-progress-track-lg{height:10px}
.p-dialog .rm-progress-fill{position:absolute;left:0;top:0;height:100%;width:100%!important;transform-origin:left center;transform:scaleX(var(--rm-progress, 0));border-top-left-radius:999px;border-bottom-left-radius:999px;transition:transform .24s cubic-bezier(.4,0,.2,1),border-radius .24s;margin:0!important;padding:0!important;right:0;min-width:0;will-change:transform;box-sizing:border-box}
.p-dialog .rm-progress-text{font-size:.75rem;font-weight:600;min-width:2.5rem;text-align:right}
min-width: 2.5rem;
text-align: right;
}
@@ -339,17 +470,17 @@
border-radius: 0.5rem;
}
.progress-bar-container {
.trip-management-container .rm-progress-container {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.progress-bar-track {
.trip-management-container .rm-progress-track {
width: 100%;
}
.progress-bar-text {
.trip-management-container .rm-progress-text {
text-align: left;
}
}
@@ -440,3 +571,147 @@
width: 100%;
}
}
/* ========================================
Mobile Card Layout for Request Table
======================================== */
@media (max-width: 768px) {
/* Hide table header on mobile */
.trip-management-container .p-datatable-thead {
display: none !important;
}
/* Convert table to block layout */
.trip-management-container .p-datatable-table {
display: block !important;
table-layout: auto !important;
}
.trip-management-container .p-datatable-tbody {
display: block !important;
}
/* Each row becomes a card */
.trip-management-container .p-datatable-tbody > tr {
display: flex !important;
flex-wrap: wrap !important;
padding: 1rem !important;
margin-bottom: 0.75rem !important;
border-radius: 0.5rem !important;
border: 1px solid rgba(128, 128, 128, 0.3) !important;
gap: 0.5rem 1rem;
align-items: flex-start;
}
/* Reset column widths */
.trip-management-container .p-datatable-tbody > tr > td {
width: auto !important;
padding: 0.25rem 0 !important;
border: none !important;
display: flex;
align-items: flex-start;
gap: 0.35rem;
flex: 1 1 48%;
min-width: 48%;
}
/* Add labels before each cell */
.trip-management-container .p-datatable-tbody > tr > td::before {
font-weight: 600;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.025em;
color: #9ca3af;
min-width: 60px;
line-height: 1.1;
display: inline-block;
}
/* Target takes full width */
.trip-management-container .p-datatable-tbody > tr > td:nth-child(2) {
width: 100% !important;
order: -1;
font-weight: 600;
font-size: 1rem;
padding-bottom: 0.5rem !important;
border-bottom: 1px solid rgba(128, 128, 128, 0.2) !important;
margin-bottom: 0.25rem;
flex: 1 1 100%;
}
.trip-management-container .p-datatable-tbody > tr > td:nth-child(2)::before {
display: none;
}
/* ID - small, muted */
.trip-management-container .p-datatable-tbody > tr > td:nth-child(1) {
font-size: 0.75rem;
color: #6b7280;
}
.trip-management-container .p-datatable-tbody > tr > td:nth-child(1)::before {
content: "ID";
}
/* Tracks */
.trip-management-container .p-datatable-tbody > tr > td:nth-child(3)::before {
content: "Tracks";
}
/* Status */
.trip-management-container .p-datatable-tbody > tr > td:nth-child(4)::before {
content: "Status";
}
/* Progress - full width */
.trip-management-container .p-datatable-tbody > tr > td:nth-child(5) {
width: 100% !important;
order: 10;
flex: 1 1 100%;
}
.trip-management-container .p-datatable-tbody > tr > td:nth-child(5)::before {
content: "Progress";
}
/* Quality */
.trip-management-container .p-datatable-tbody > tr > td:nth-child(6)::before {
content: "Quality";
}
/* Tarball - full width */
.trip-management-container .p-datatable-tbody > tr > td:nth-child(7) {
width: 100% !important;
order: 11;
flex: 1 1 100%;
}
.trip-management-container .p-datatable-tbody > tr > td:nth-child(7)::before {
content: "Download";
}
/* Progress bar adjustments for mobile */
.trip-management-container .rm-progress-container {
flex: 1;
}
/* Skeleton adjustments for mobile */
.table-skeleton .skeleton-row {
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
border-radius: 0.5rem;
border: 1px solid rgba(128, 128, 128, 0.2);
}
.table-skeleton .skeleton-cell {
width: 100% !important;
}
.table-skeleton .skeleton-bar {
width: 60%;
}
.table-skeleton .skeleton-cell:first-child .skeleton-bar {
width: 100%;
height: 1.25rem;
}
}

View File

@@ -163,12 +163,19 @@ export default function RequestManagement() {
return `${pct}%`;
};
const computePct = (p) => {
if (p === null || p === undefined || p === "") return 0;
const num = Number(p);
if (Number.isNaN(num)) return 0;
return Math.min(100, Math.max(0, num > 1 ? Math.round(num) : Math.round(num * 100)));
};
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 pct = computePct(p);
const getProgressColor = () => {
if (rowData.status === "Failed") return "bg-red-500";
@@ -179,14 +186,22 @@ export default function RequestManagement() {
};
return (
<div className="progress-bar-container">
<div className="progress-bar-track">
<div className="rm-progress-container">
<div className="rm-progress-track" style={{ flex: 1, minWidth: 0 }}>
<div
className={`progress-bar-fill ${getProgressColor()}`}
style={{ width: `${pct}%` }}
className={`rm-progress-fill ${getProgressColor()}`}
style={{
'--rm-progress': (pct / 100).toString(),
borderTopRightRadius: pct === 100 ? '999px' : 0,
borderBottomRightRadius: pct === 100 ? '999px' : 0
}}
data-pct={pct}
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
/>
</div>
<span className="progress-bar-text">{pct}%</span>
<span className="rm-progress-text" style={{ marginLeft: 8, flex: 'none' }}>{pct}%</span>
</div>
);
};
@@ -348,7 +363,7 @@ export default function RequestManagement() {
<div className="space-y-4 text-sm">
{/* --- Metadata Card --- */}
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-2 gap-4">
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-1 sm:grid-cols-2 gap-4">
{selectedRequest.id && <p className="col-span-2 break-all"><strong>ID:</strong> {selectedRequest.id}</p>}
{selectedRequest.target && <p><strong>Target:</strong> {selectedRequest.target}</p>}
{selectedRequest.tracks && <p><strong># Tracks:</strong> {selectedRequest.tracks}</p>}
@@ -363,7 +378,7 @@ export default function RequestManagement() {
</div>
{/* --- Status / Progress Card --- */}
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-2 gap-4">
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-1 sm:grid-cols-2 gap-4">
{selectedRequest.status && (
<p>
<strong>Status:</strong>{" "}
@@ -375,14 +390,22 @@ export default function RequestManagement() {
{selectedRequest.progress !== undefined && selectedRequest.progress !== null && (
<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="rm-progress-container mt-2">
<div className="rm-progress-track rm-progress-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))}%` }}
className={`rm-progress-fill ${selectedRequest.status === "Failed" ? "bg-red-500" : selectedRequest.status === "Finished" ? "bg-green-500" : "bg-blue-500"}`}
style={{
'--rm-progress': (computePct(selectedRequest.progress) / 100).toString(),
borderTopRightRadius: computePct(selectedRequest.progress) >= 100 ? '999px' : 0,
borderBottomRightRadius: computePct(selectedRequest.progress) >= 100 ? '999px' : 0
}}
data-pct={computePct(selectedRequest.progress)}
aria-valuenow={Math.min(100, Math.max(0, Number(selectedRequest.progress) > 1 ? Math.round(selectedRequest.progress) : selectedRequest.progress * 100))}
aria-valuemin={0}
aria-valuemax={100}
/>
</div>
<span className="progress-bar-text">{formatProgress(selectedRequest.progress)}</span>
<span className="rm-progress-text">{formatProgress(selectedRequest.progress)}</span>
</div>
</div>
)}

View File

@@ -2,8 +2,8 @@ import React, { useState, useEffect } from "react";
import { toast } from "react-toastify";
import { Button } from "@mui/joy";
// Dropdown not used in this form; removed to avoid unused-import warnings
import { AutoComplete } from "primereact/autocomplete/autocomplete.esm.js";
import { InputText } from "primereact/inputtext/inputtext.esm.js";
import { AutoComplete } from "primereact/autocomplete";
import { InputText } from "primereact/inputtext";
export default function ReqForm() {
const [type, setType] = useState("");
@@ -16,6 +16,15 @@ export default function ReqForm() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [suggestions, setSuggestions] = useState([]);
const [posterLoading, setPosterLoading] = useState(true);
const [submittedRequest, setSubmittedRequest] = useState(null); // Track successful submission
const [csrfToken, setCsrfToken] = useState(null);
// Get CSRF token from window global on mount
useEffect(() => {
if (typeof window !== 'undefined' && window._t) {
setCsrfToken(window._t);
}
}, []);
useEffect(() => {
if (title !== selectedTitle) {
@@ -54,6 +63,11 @@ export default function ReqForm() {
return;
}
if (!csrfToken) {
toast.error("Security token not loaded. Please refresh the page.");
return;
}
setIsSubmitting(true);
try {
const response = await fetch('/api/submit', {
@@ -61,20 +75,34 @@ export default function ReqForm() {
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ title, year, type, requester }),
body: JSON.stringify({ title, year, type, requester, csrfToken }),
});
const responseData = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error('Submission failed');
const errorMessage = responseData.error || 'Submission failed';
toast.error(errorMessage);
// If CSRF token error, require page reload
if (response.status === 403) {
toast.error('Please refresh the page and try again.');
}
return;
}
toast.success("Request submitted successfully!");
// Reset form
setType("");
setTitle("");
setYear("");
setRequester("");
setSelectedOverview("");
setSelectedTitle("");
setSelectedItem(null);
// Save the new CSRF token from the response for future submissions
if (responseData.csrfToken) {
setCsrfToken(responseData.csrfToken);
}
// Store submitted request info for success view
setSubmittedRequest({
title,
year,
type,
requester,
poster_path: selectedItem?.poster_path,
});
} catch (error) {
console.error('Submission error:', error);
toast.error("Failed to submit request. Please try again.");
@@ -83,6 +111,19 @@ export default function ReqForm() {
}
};
const resetForm = () => {
setType("");
setTitle("");
setYear("");
setRequester("");
setSelectedOverview("");
setSelectedTitle("");
setSelectedItem(null);
setSubmittedRequest(null);
setPosterLoading(true);
// Token was already refreshed from the submit response
};
const attachScrollFix = () => {
setTimeout(() => {
const panel = document.querySelector(".p-autocomplete-panel");
@@ -116,6 +157,63 @@ export default function ReqForm() {
const selectedTypeLabel = formatMediaType(selectedItem?.mediaType || type);
// Success view after submission
if (submittedRequest) {
const typeLabel = formatMediaType(submittedRequest.type);
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-[#141414] rounded-2xl shadow-lg shadow-neutral-900/5 dark:shadow-black/20 border border-neutral-200/60 dark:border-neutral-800/60 text-center">
<div className="mb-6">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<svg className="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2 tracking-tight font-['IBM_Plex_Sans',sans-serif]">
Request Submitted!
</h2>
<p className="text-neutral-500 dark:text-neutral-400 text-sm">
Your request has been received and is pending review.
</p>
</div>
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-xl border border-neutral-200/60 dark:border-neutral-700/60 mb-6">
<div className="flex items-center gap-4">
{submittedRequest.poster_path && (
<img
src={`https://image.tmdb.org/t/p/w92${submittedRequest.poster_path}`}
alt="Poster"
className="w-16 h-auto rounded-lg border border-neutral-200 dark:border-neutral-700"
/>
)}
<div className="flex-1 text-left">
<p className="font-semibold text-neutral-900 dark:text-white">
{submittedRequest.title}
</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{submittedRequest.year && `${submittedRequest.year} · `}
{typeLabel || 'Media'}
</p>
{submittedRequest.requester && (
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
Requested by: {submittedRequest.requester}
</p>
)}
</div>
</div>
</div>
<Button
onClick={resetForm}
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 transition-colors shadow-sm"
>
Submit Another Request
</Button>
</div>
</div>
);
}
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-[#141414] rounded-2xl shadow-lg shadow-neutral-900/5 dark:shadow-black/20 border border-neutral-200/60 dark:border-neutral-800/60">
@@ -138,7 +236,12 @@ export default function ReqForm() {
suggestions={suggestions}
completeMethod={searchTitles}
minLength={2}
onChange={(e) => setTitle(typeof e.value === 'string' ? e.value : e.value.label)}
delay={300}
onChange={(e) => {
// Handle both string input and object selection
const val = e.target?.value ?? e.value;
setTitle(typeof val === 'string' ? val : val?.label || '');
}}
onSelect={(e) => {
setType(e.value.mediaType === 'tv' ? 'tv' : 'movie');
setTitle(e.value.label);
@@ -153,6 +256,7 @@ export default function ReqForm() {
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"
autoComplete="off"
onShow={attachScrollFix}
itemTemplate={(item) => (
<div className="p-2 rounded">