begin js(x) to ts(x)

This commit is contained in:
2025-12-19 11:59:00 -05:00
parent 564bfefa4a
commit 823c8b52b3
51 changed files with 1342 additions and 584 deletions

View File

@@ -1,4 +1,5 @@
import React, { Suspense, lazy, useState, useMemo, useEffect } from 'react';
import type { ComponentType } from 'react';
import Memes from './Memes.jsx';
import Lighting from './Lighting.jsx';
import { toast } from 'react-toastify';
@@ -22,7 +23,28 @@ const DiscordLogs = lazy(() => import('./DiscordLogs.jsx'));
// identified as subsites.
const ReqForm = lazy(() => import('./req/ReqForm.jsx'));
export default function Root({ child, user = undefined, ...props }) {
declare global {
interface Window {
toast: typeof toast;
__IS_SUBSITE?: boolean;
}
}
export interface User {
id?: string;
username?: string;
roles?: string[];
[key: string]: unknown;
}
export interface RootProps {
child: string;
user?: User;
loggedIn?: boolean;
[key: string]: unknown;
}
export default function Root({ child, user = undefined, ...props }: RootProps): React.ReactElement {
window.toast = toast;
const theme = document.documentElement.getAttribute("data-theme")
const loggedIn = props.loggedIn ?? Boolean(user);
@@ -54,7 +76,7 @@ export default function Root({ child, user = undefined, ...props }) {
// Use dynamic import+state on the client to avoid React.lazy identity
// churn and to surface any import-time errors. Since Root is used via
// client:only, this code runs in the browser and can safely import.
const [PlayerComp, setPlayerComp] = useState(null);
const [PlayerComp, setPlayerComp] = useState<ComponentType<{ user?: User }> | null>(null);
useEffect(() => {
let mounted = true;
if (wantPlayer) {
@@ -68,7 +90,7 @@ export default function Root({ child, user = undefined, ...props }) {
})
.catch((err) => {
console.error('[AppLayout] AudioPlayer import failed', err);
if (mounted) setPlayerComp(() => null);
if (mounted) setPlayerComp(null);
});
} else {
// unload if we no longer want the player

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, Suspense, lazy, useMemo, useCallback } from "react";
import type Hls from "hls.js";
import "@styles/player.css";
import "@/components/TRip/RequestManagement.css";
import { metaData } from "../config";
@@ -15,6 +16,20 @@ import { authFetch } from "@/utils/authFetch";
import { requireAuthHook } from "@/hooks/requireAuthHook";
import { useHtmlThemeAttr } from "@/hooks/useHtmlThemeAttr";
interface LyricLine {
timestamp: number;
line: string;
}
interface PlayerProps {
user?: {
id?: string;
username?: string;
roles?: string[];
[key: string]: unknown;
};
}
const STATIONS = {
main: { label: "Main" },
@@ -25,7 +40,7 @@ const STATIONS = {
};
export default function Player({ user }) {
export default function Player({ user }: PlayerProps) {
// Global CSS now contains the paginator / dialog datatable dark rules.
const [isQueueVisible, setQueueVisible] = useState(false);
@@ -42,19 +57,19 @@ export default function Player({ user }) {
'.p-dialog .p-datatable-tbody'
];
const elements = selectors
const elements: Element[] = selectors
.map(selector => document.querySelector(selector))
.filter(Boolean);
.filter((el): el is Element => el !== null);
// Also allow scrolling on the entire dialog
const dialog = document.querySelector('.p-dialog');
if (dialog) elements.push(dialog);
elements.forEach(element => {
element.style.overflowY = 'auto';
element.style.overscrollBehavior = 'contain';
(element as HTMLElement).style.overflowY = 'auto';
(element as HTMLElement).style.overscrollBehavior = 'contain';
const wheelHandler = (e) => {
const wheelHandler: EventListener = (e) => {
e.stopPropagation();
// Allow normal scrolling within the modal
};
@@ -99,7 +114,7 @@ export default function Player({ user }) {
const [trackGenre, setTrackGenre] = useState("");
const [trackAlbum, setTrackAlbum] = useState("");
const [coverArt, setCoverArt] = useState("/images/radio_art_default.jpg");
const [lyrics, setLyrics] = useState([]);
const [lyrics, setLyrics] = useState<LyricLine[]>([]);
const [currentLyricIndex, setCurrentLyricIndex] = useState(0);
const [elapsedTime, setElapsedTime] = useState(0);
// Update currentLyricIndex as song progresses
@@ -129,15 +144,15 @@ export default function Player({ user }) {
const [requestInputSong, setRequestInputSong] = useState("");
const [requestInputUuid, setRequestInputUuid] = useState("");
const audioElement = useRef(null);
const hlsInstance = useRef(null);
const currentTrackUuid = useRef(null);
const baseTrackElapsed = useRef(0);
const lastUpdateTimestamp = useRef(Date.now());
const activeStationRef = useRef(activeStation);
const wsInstance = useRef(null);
const audioElement = useRef<HTMLAudioElement | null>(null);
const hlsInstance = useRef<Hls | null>(null);
const currentTrackUuid = useRef<string | null>(null);
const baseTrackElapsed = useRef<number>(0);
const lastUpdateTimestamp = useRef<number>(Date.now());
const activeStationRef = useRef<string>(activeStation);
const wsInstance = useRef<WebSocket | null>(null);
const formatTime = (seconds) => {
const formatTime = (seconds: number): string => {
if (!seconds || isNaN(seconds) || seconds < 0) return "00:00";
const mins = String(Math.floor(seconds / 60)).padStart(2, "0");
const secs = String(Math.floor(seconds % 60)).padStart(2, "0");
@@ -187,16 +202,16 @@ export default function Player({ user }) {
const hls = new Hls({
lowLatencyMode: false,
abrEnabled: false,
// abrEnabled: false, // ABR not in current HLS.js types
liveSyncDuration: 0.6, // seconds behind live edge target
liveMaxLatencyDuration: 3.0, // max allowed latency before catchup
liveCatchUpPlaybackRate: 1.02,
// liveCatchUpPlaybackRate: 1.02, // Not in current HLS.js types
// maxBufferLength: 30,
// maxMaxBufferLength: 60,
// maxBufferHole: 2.0,
// manifestLoadingTimeOut: 5000,
// fragLoadingTimeOut: 10000, // playback speed when catching up
});
} as Partial<typeof Hls.DefaultConfig>);
hlsInstance.current = hls;
hls.attachMedia(audio);
@@ -238,8 +253,8 @@ export default function Player({ user }) {
const activeElement = document.querySelector('.lrc-line.active');
const lyricsContainer = document.querySelector('.lrc-text');
if (activeElement && lyricsContainer) {
lyricsContainer.style.maxHeight = '220px';
lyricsContainer.style.overflowY = 'auto';
(lyricsContainer as HTMLElement).style.maxHeight = '220px';
(lyricsContainer as HTMLElement).style.overflowY = 'auto';
activeElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 0);
@@ -268,11 +283,11 @@ export default function Player({ user }) {
document.title = `${metaData.title} - Radio [${activeStation}]`;
}, [activeStation]);
const parseLrcString = useCallback((lrcString) => {
const parseLrcString = useCallback((lrcString: string | undefined | null): LyricLine[] => {
if (!lrcString || typeof lrcString !== 'string') return [];
const lines = lrcString.split('\n').filter(line => line.trim());
const parsedLyrics = [];
const parsedLyrics: LyricLine[] = [];
for (const line of lines) {
const match = line.match(/\[(\d{2}):(\d{2}\.\d{2})\]\s*(.+)/);
@@ -752,18 +767,19 @@ export default function Player({ user }) {
fetchTypeahead(e.query);
}}
onChange={e => {
setRequestInput(e.target.value);
setRequestInput(e.target.value ?? '');
}}
onShow={() => {
setTimeout(() => {
const panel = document.querySelector('.p-autocomplete-panel');
const items = panel?.querySelector('.p-autocomplete-items');
if (items) {
items.style.maxHeight = '200px';
items.style.overflowY = 'auto';
items.style.overscrollBehavior = 'contain';
const wheelHandler = (e) => {
const delta = e.deltaY;
(items as HTMLElement).style.maxHeight = '200px';
(items as HTMLElement).style.overflowY = 'auto';
(items as HTMLElement).style.overscrollBehavior = 'contain';
const wheelHandler: EventListener = (e) => {
const wheelEvent = e as WheelEvent;
const delta = wheelEvent.deltaY;
const atTop = items.scrollTop === 0;
const atBottom = items.scrollTop + items.clientHeight >= items.scrollHeight;
if ((delta < 0 && atTop) || (delta > 0 && atBottom)) {
@@ -865,8 +881,8 @@ export default function Player({ user }) {
first={queuePage * queueRows}
totalRecords={queueTotalRecords}
onPage={(e) => {
setQueuePage(e.page);
fetchQueue(e.page, queueRows, queueSearch);
setQueuePage(e.page ?? 0);
fetchQueue(e.page ?? 0, queueRows, queueSearch);
}}
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport"
currentPageReportTemplate="Showing {first} to {last} of {totalRecords} entries"

View File

@@ -5,22 +5,28 @@ import React, {
useEffect,
useState,
} from "react";
import type { ReactNode } from "react";
import { CssVarsProvider, CssBaseline } from "@mui/joy";
import type { EmotionCache } from "@emotion/react";
import { CacheProvider } from "@emotion/react";
import createCache from "@emotion/cache";
import { default as $ } from "jquery";
export function JoyUIRootIsland({ children }) {
const cache = React.useRef();
interface JoyUIRootIslandProps {
children: ReactNode;
}
export function JoyUIRootIsland({ children }: JoyUIRootIslandProps): React.ReactElement {
const cache = React.useRef<EmotionCache | null>(null);
if (!cache.current) {
cache.current = createCache({ key: "joy" });
}
React.useEffect(() => {
const current = cache.current;
return () => current.sheet.flush();
return () => current?.sheet.flush();
}, []);
return (
<CacheProvider value={cache.current}>
<CacheProvider value={cache.current!}>
<CssVarsProvider>{children}</CssVarsProvider>
</CacheProvider>
);

View File

@@ -1,7 +1,264 @@
import React, { useState, useEffect, useLayoutEffect, useMemo, useCallback, memo, createContext, useContext, useRef } from 'react';
import type { AnimationItem } from 'lottie-web';
import { ProgressSpinner } from 'primereact/progressspinner';
import { authFetch } from '@/utils/authFetch';
// ============================================================================
// Type Definitions
// ============================================================================
interface DiscordUser {
id: string;
username: string;
displayName?: string;
avatar?: string;
avatarUrl?: string;
color?: string | number;
isArchiveResolved?: boolean;
bot?: boolean;
isWebhook?: boolean;
isServerForwarded?: boolean;
}
interface DiscordRole {
id: string;
name: string;
color?: string | number;
}
interface DiscordChannel {
id: string;
name: string;
type?: number;
guildId?: string;
guildName?: string;
guildIcon?: string;
parentId?: string;
position?: number;
topic?: string;
messageCount?: number;
threads?: DiscordChannel[];
}
interface DiscordEmoji {
id: string;
name: string;
url?: string;
animated?: boolean;
}
interface DiscordAttachment {
id: string;
filename?: string;
url: string;
size?: number;
content_type?: string;
width?: number;
height?: number;
originalUrl?: string;
}
interface DiscordEmbed {
url?: string;
title?: string;
description?: string;
color?: number;
author?: { name?: string; url?: string; icon_url?: string };
thumbnail?: { url?: string; width?: number; height?: number };
image?: { url?: string; width?: number; height?: number };
video?: { url?: string; width?: number; height?: number };
footer?: { text?: string; icon_url?: string };
fields?: Array<{ name: string; value: string; inline?: boolean }>;
timestamp?: string;
provider?: { name?: string; url?: string };
type?: string;
}
interface DiscordReaction {
emoji: { id?: string; name: string; animated?: boolean; url?: string };
count: number;
me?: boolean;
}
interface DiscordPollAnswer {
answer_id: number;
poll_media: { text?: string; emoji?: DiscordEmoji };
voteCount?: number;
// Runtime convenience properties
id?: number;
text?: string;
emoji?: DiscordEmoji;
voters?: Array<{ id: string; username?: string; displayName?: string; avatar?: string }>;
}
interface DiscordPoll {
question: { text: string; emoji?: DiscordEmoji };
question_text?: string;
answers: DiscordPollAnswer[];
expiry?: string;
allow_multiselect?: boolean;
allowMultiselect?: boolean;
isFinalized?: boolean;
totalVotes?: number;
results?: {
answer_counts: Array<{ id: number; count: number; me_voted?: boolean }>;
};
}
interface DiscordSticker {
id: string;
name: string;
format_type: number;
formatType?: number;
data?: unknown;
lottieData?: unknown;
url?: string;
}
interface DiscordMessage {
id: string;
author: DiscordUser;
content: string;
timestamp: string;
edited_timestamp?: string;
attachments?: DiscordAttachment[];
embeds?: DiscordEmbed[];
stickers?: DiscordSticker[];
reactions?: DiscordReaction[];
poll?: DiscordPoll | null;
referenced_message?: DiscordMessage | null;
type: number;
mentions?: DiscordUser[];
mention_roles?: string[];
pinned?: boolean;
tts?: boolean;
}
interface MemberGroup {
role?: DiscordRole;
members?: Array<{ id: string; color?: string | number }>;
}
interface MembersData {
groups?: MemberGroup[];
roles?: Map<string, DiscordRole> | Record<string, DiscordRole>;
}
interface DiscordGuild {
id: string;
name: string;
guildId?: string;
guildName?: string;
guildIcon?: string;
icon?: string;
roles?: DiscordRole[];
}
interface LinkPreviewData {
url: string;
type?: string;
title?: string;
description?: string;
image?: string;
video?: string;
videoId?: string;
siteName?: string;
trusted?: boolean;
themeColor?: string;
thumbnail?: string;
}
interface ParseOptions {
channelMap?: Map<string, DiscordChannel>;
usersMap?: Record<string, DiscordUser>;
rolesMap?: Map<string, DiscordRole> | Record<string, DiscordRole>;
emojiCache?: Record<string, DiscordEmoji>;
onChannelClick?: (channelId: string) => void;
}
interface MessageProps {
message: DiscordMessage;
isFirstInGroup: boolean;
showTimestamp: boolean;
previewCache: Map<string, LinkPreviewData>;
onPreviewLoad: (url: string, preview: LinkPreviewData) => void;
channelMap: Map<string, DiscordChannel>;
usersMap: Record<string, DiscordUser>;
emojiCache: Record<string, DiscordEmoji>;
members?: MembersData;
onChannelSelect?: (channelId: string | DiscordChannel) => void;
channelName?: string;
onReactionClick?: (e: React.MouseEvent, messageId: string, reaction: DiscordReaction) => void;
onPollVoterClick?: (e: React.MouseEvent, answer: DiscordPollAnswer) => void;
onContextMenu?: (e: React.MouseEvent, messageId: string, content: string) => void;
isSearchResult?: boolean;
onJumpToMessage?: (messageId: string, channelId?: string) => void;
}
interface AttachmentProps {
attachment: DiscordAttachment;
}
interface LinkPreviewProps {
url: string;
cachedPreview?: LinkPreviewData | null;
onPreviewLoad?: (url: string, preview: LinkPreviewData) => void;
}
interface LottieStickerProps {
data: unknown;
name: string;
}
interface ArchivedMessageResult {
originalAuthor: string;
originalTimestamp: string | null;
originalContent: string;
topic?: string;
}
// State types for popups
interface ReactionPopupState {
x: number;
y: number;
emoji: { id?: string; name: string; animated?: boolean };
users: Array<{ id: string; username?: string; displayName?: string; avatar?: string }>;
loading?: boolean;
}
interface PollVoterPopupState {
x: number;
y: number;
answer: DiscordPollAnswer;
voters: Array<{ id: string; username?: string; displayName?: string; avatar?: string }>;
}
interface ContextMenuState {
x: number;
y: number;
messageId: string;
content: string;
}
interface ChannelContextMenuState {
x: number;
y: number;
channel: DiscordChannel;
}
interface ImageModalState {
url: string;
alt?: string;
}
interface SearchResult {
id: string;
content: string;
author: DiscordUser;
timestamp: string;
channel_id?: string;
}
// ============================================================================
// Discord Message Type Constants
// https://discord.com/developers/docs/resources/channel#message-object-message-types
@@ -39,7 +296,11 @@ const MESSAGE_TYPE_GUILD_APPLICATION_PREMIUM_SUBSCRIPTION = 32;
const MESSAGE_TYPE_POLL_RESULT = 46;
// Image modal context for child components to trigger modal
const ImageModalContext = createContext(null);
interface ImageModalData {
url: string;
alt?: string;
}
const ImageModalContext = createContext<((data: ImageModalData) => void) | null>(null);
// Trusted domains that can be embedded directly without server-side proxy
const TRUSTED_DOMAINS = new Set([
@@ -79,7 +340,7 @@ const AUDIO_EXTENSIONS = /\.(mp3|wav|ogg|flac|m4a|aac)(\?.*)?$/i;
/**
* Check if URL is from a trusted domain
*/
function isTrustedDomain(url) {
function isTrustedDomain(url: string): boolean {
try {
const parsed = new URL(url);
return TRUSTED_DOMAINS.has(parsed.hostname);
@@ -91,7 +352,7 @@ function isTrustedDomain(url) {
/**
* Check if a URL is already a proxy URL (from our server)
*/
function isProxyUrl(url) {
function isProxyUrl(url: string | undefined | null): boolean {
if (!url) return false;
return url.startsWith('/api/image-proxy');
}
@@ -99,14 +360,14 @@ function isProxyUrl(url) {
/**
* Extract YouTube video ID
*/
function getYouTubeId(url) {
function getYouTubeId(url: string): string | null {
try {
const parsed = new URL(url);
if (parsed.hostname === 'youtu.be') {
return parsed.pathname.slice(1);
}
if (parsed.hostname.includes('youtube.com')) {
return parsed.searchParams.get('v') || parsed.pathname.split('/').pop();
return parsed.searchParams.get('v') || parsed.pathname.split('/').pop() || null;
}
} catch {
return null;
@@ -114,10 +375,20 @@ function getYouTubeId(url) {
return null;
}
/**
* Convert color (string or number) to CSS color string
*/
function toColorString(color: string | number | undefined): string | undefined {
if (color === undefined || color === null) return undefined;
if (typeof color === 'string') return color;
// Discord integer colors are decimal RGB values
return `#${color.toString(16).padStart(6, '0')}`;
}
/**
* Format file size
*/
function formatFileSize(bytes) {
function formatFileSize(bytes: number | undefined): string {
if (!bytes) return '';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
@@ -132,11 +403,11 @@ function formatFileSize(bytes) {
/**
* Format Discord timestamp
*/
function formatTimestamp(timestamp, format = 'full') {
function formatTimestamp(timestamp: string | Date, format: 'full' | 'time' | 'short' = 'full'): string {
const date = new Date(timestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const isYesterday = new Date(now - 86400000).toDateString() === date.toDateString();
const isYesterday = new Date(now.getTime() - 86400000).toDateString() === date.toDateString();
const time = date.toLocaleTimeString(undefined, {
hour: 'numeric',
@@ -145,6 +416,14 @@ function formatTimestamp(timestamp, format = 'full') {
});
if (format === 'time') return time;
if (format === 'short') {
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
});
}
if (isToday) return `Today at ${time}`;
if (isYesterday) return `Yesterday at ${time}`;
@@ -159,7 +438,7 @@ function formatTimestamp(timestamp, format = 'full') {
/**
* Format date for divider
*/
function formatDateDivider(timestamp) {
function formatDateDivider(timestamp: string | Date): string {
const date = new Date(timestamp);
return date.toLocaleDateString(undefined, {
weekday: 'long',
@@ -173,8 +452,8 @@ function formatDateDivider(timestamp) {
* Decode HTML entities safely. Uses the DOM if available (client-side), otherwise
* falls back to a robust regex for SSR environments.
*/
function decodeHtmlEntities(str) {
if (!str) return str;
function decodeHtmlEntities(str: string | null | undefined): string {
if (!str) return '';
try {
if (typeof document !== 'undefined') {
const tx = document.createElement('textarea');
@@ -205,7 +484,7 @@ function decodeHtmlEntities(str) {
*
* Returns { originalAuthor, originalTimestamp, originalContent, topic } or null if not parseable
*/
function parseArchivedMessage(content, depth = 0) {
function parseArchivedMessage(content: string | null | undefined, depth: number = 0): ArchivedMessageResult | null {
if (!content || depth > 3) return null; // Prevent infinite recursion
// The format is:
@@ -384,7 +663,7 @@ const ARCHIVE_USERS = {
* If members data is provided, looks up color from there
* Returns user data if found, otherwise returns a basic object with just the username
*/
function resolveArchivedUser(archivedUsername, usersMap, members) {
function resolveArchivedUser(archivedUsername: string, usersMap?: Record<string, DiscordUser>, members?: MembersData): DiscordUser {
// First check hardcoded archive users
if (ARCHIVE_USERS[archivedUsername]) {
const archivedUser = ARCHIVE_USERS[archivedUsername];
@@ -450,7 +729,7 @@ function resolveArchivedUser(archivedUsername, usersMap, members) {
* @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 = {}) {
function parseDiscordMarkdown(text: string | null | undefined, options: ParseOptions = {}): string {
if (!text) return '';
try {
@@ -632,14 +911,14 @@ function parseDiscordMarkdown(text, options = {}) {
// Role mentions (<@&123456789>) - robust lookup to avoid errors when rolesMap is missing or malformed
parsed = parsed.replace(/&lt;@&amp;(\d+)&gt;/g, (_, roleId) => {
try {
let role = null;
let role: DiscordRole | null | undefined = 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)];
if (typeof (rolesMap as Map<string, DiscordRole>).get === 'function') {
role = (rolesMap as Map<string, DiscordRole>).get(roleId);
} else if ((rolesMap as Record<string, DiscordRole>)[roleId]) {
role = (rolesMap as Record<string, DiscordRole>)[roleId];
} else if ((rolesMap as Record<string, DiscordRole>)[String(roleId)]) {
role = (rolesMap as Record<string, DiscordRole>)[String(roleId)];
}
}
const roleName = role?.name || 'role';
@@ -704,7 +983,7 @@ function parseDiscordMarkdown(text, options = {}) {
/**
* Extract URLs from text
*/
function extractUrls(text) {
function extractUrls(text: string | null | undefined): string[] {
if (!text) return [];
const urlRegex = /(https?:\/\/[^\s<]+)/g;
const matches = text.match(urlRegex);
@@ -715,8 +994,8 @@ function extractUrls(text) {
// Link Preview Component
// ============================================================================
const LinkPreview = memo(function LinkPreview({ url, cachedPreview, onPreviewLoad }) {
const [preview, setPreview] = useState(cachedPreview || null);
const LinkPreview = memo(function LinkPreview({ url, cachedPreview, onPreviewLoad }: LinkPreviewProps) {
const [preview, setPreview] = useState<LinkPreviewData | null>(cachedPreview || null);
const [loading, setLoading] = useState(!cachedPreview);
const [error, setError] = useState(false);
const [imageError, setImageError] = useState(false);
@@ -873,7 +1152,7 @@ const LinkPreview = memo(function LinkPreview({ url, cachedPreview, onPreviewLoa
alt="Linked image"
className="discord-attachment-image"
loading="lazy"
onError={(e) => e.target.style.display = 'none'}
onError={(e) => (e.target as HTMLElement).style.display = 'none'}
/>
</div>
);
@@ -938,9 +1217,9 @@ const LinkPreview = memo(function LinkPreview({ url, cachedPreview, onPreviewLoa
// Lottie Sticker Component
// ============================================================================
const LottieSticker = memo(function LottieSticker({ data, name }) {
const containerRef = useRef(null);
const animationRef = useRef(null);
const LottieSticker = memo(function LottieSticker({ data, name }: LottieStickerProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const animationRef = useRef<AnimationItem | null>(null);
useEffect(() => {
if (!containerRef.current || !data) return;
@@ -953,7 +1232,7 @@ const LottieSticker = memo(function LottieSticker({ data, name }) {
}
animationRef.current = lottie.default.loadAnimation({
container: containerRef.current,
container: containerRef.current!,
renderer: 'svg',
loop: true,
autoplay: true,
@@ -992,7 +1271,7 @@ const LottieSticker = memo(function LottieSticker({ data, name }) {
// Attachment Component
// ============================================================================
const Attachment = memo(function Attachment({ attachment }) {
const Attachment = memo(function Attachment({ attachment }: AttachmentProps) {
const { filename, url, size, content_type, width, height } = attachment;
const openImageModal = useContext(ImageModalContext);
@@ -1098,7 +1377,7 @@ const DiscordMessage = memo(function DiscordMessage({
onContextMenu,
isSearchResult,
onJumpToMessage
}) {
}: MessageProps) {
const {
id,
author,
@@ -1252,7 +1531,7 @@ const DiscordMessage = memo(function DiscordMessage({
</div>
<div className="discord-poll-result-content">
<div className="discord-poll-result-header">
<span className="discord-username" style={pollAuthor?.color ? { color: pollAuthor.color } : undefined}>
<span className="discord-username" style={pollAuthor?.color ? { color: toColorString(pollAuthor.color) } : undefined}>
{pollAuthor?.displayName || pollAuthor?.username || 'Unknown'}
</span>
<span className="discord-poll-title">s poll <strong>{pollTitle}</strong> has closed</span>
@@ -1380,7 +1659,7 @@ const DiscordMessage = memo(function DiscordMessage({
{getSystemIcon()}
</div>
<span className="discord-system-content">
<span className="discord-username" style={displayAuthor?.color ? { color: displayAuthor.color } : undefined}>
<span className="discord-username" style={displayAuthor?.color ? { color: toColorString(displayAuthor.color) } : undefined}>
{displayAuthor?.displayName || displayAuthor?.username || 'Unknown'}
</span>
{' '}
@@ -1478,7 +1757,7 @@ const DiscordMessage = memo(function DiscordMessage({
)}
<span
className={`discord-username ${(displayAuthor?.bot || displayAuthor?.isWebhook) ? 'discord-bot-user' : ''}`}
style={displayAuthor?.color ? { color: displayAuthor.color } : undefined}
style={displayAuthor?.color ? { color: toColorString(displayAuthor.color) } : undefined}
>
{displayAuthor?.displayName || displayAuthor?.username || 'Unknown User'}
</span>
@@ -1566,35 +1845,35 @@ const DiscordMessage = memo(function DiscordMessage({
</div>
<div className="discord-poll-answers">
{poll.answers.map((answer) => {
const percentage = poll.totalVotes > 0
? Math.round((answer.voteCount / poll.totalVotes) * 100)
const percentage = (poll.totalVotes ?? 0) > 0
? Math.round(((answer.voteCount ?? 0) / (poll.totalVotes ?? 1)) * 100)
: 0;
return (
<div key={answer.id} className="discord-poll-answer" onClick={(e) => onPollVoterClick?.(e, answer)} style={{ cursor: answer.voters?.length ? 'pointer' : 'default' }}>
<div key={answer.id ?? answer.answer_id} className="discord-poll-answer" onClick={(e) => onPollVoterClick?.(e, answer)} style={{ cursor: answer.voters?.length ? 'pointer' : 'default' }}>
<div
className="discord-poll-answer-bar"
style={{ width: `${percentage}%` }}
/>
<div className="discord-poll-answer-content">
{answer.emoji && (
answer.emoji.id ? (
{(answer.emoji ?? answer.poll_media?.emoji) && (
(answer.emoji ?? answer.poll_media?.emoji)?.id ? (
<img
src={emojiCache[answer.emoji.id]?.url || `https://cdn.discordapp.com/emojis/${answer.emoji.id}.${answer.emoji.animated ? 'gif' : 'png'}`}
alt={answer.emoji.name}
src={emojiCache[(answer.emoji ?? answer.poll_media?.emoji)!.id!]?.url || `https://cdn.discordapp.com/emojis/${(answer.emoji ?? answer.poll_media?.emoji)!.id}.${(answer.emoji ?? answer.poll_media?.emoji)!.animated ? 'gif' : 'png'}`}
alt={(answer.emoji ?? answer.poll_media?.emoji)?.name}
className="discord-poll-answer-emoji"
/>
) : (
<span className="discord-poll-answer-emoji">{answer.emoji.name}</span>
<span className="discord-poll-answer-emoji">{(answer.emoji ?? answer.poll_media?.emoji)?.name}</span>
)
)}
<span className="discord-poll-answer-text">{answer.text}</span>
<span className="discord-poll-answer-text">{answer.text ?? answer.poll_media?.text}</span>
<span className="discord-poll-answer-votes">
{answer.voteCount} ({percentage}%)
{answer.voteCount ?? 0} ({percentage}%)
</span>
</div>
{answer.voters?.length > 0 && (
{(answer.voters?.length ?? 0) > 0 && (
<div className="discord-poll-voters">
{answer.voters.slice(0, 10).map((voter) => (
{answer.voters!.slice(0, 10).map((voter) => (
<div
key={voter.id}
className="discord-poll-voter"
@@ -1608,9 +1887,9 @@ const DiscordMessage = memo(function DiscordMessage({
)}
</div>
))}
{answer.voters.length > 10 && (
{(answer.voters?.length ?? 0) > 10 && (
<span className="discord-poll-voters-more">
+{answer.voters.length - 10}
+{(answer.voters?.length ?? 0) - 10}
</span>
)}
</div>
@@ -1675,9 +1954,9 @@ const DiscordMessage = memo(function DiscordMessage({
/>
)}
{/* Embed fields */}
{embed.fields?.length > 0 && (
{(embed.fields?.length ?? 0) > 0 && (
<div className="discord-embed-fields">
{embed.fields.map((field, fieldIdx) => (
{embed.fields!.map((field, fieldIdx) => (
<div
key={fieldIdx}
className={`discord-embed-field ${field.inline ? 'inline' : ''}`}
@@ -1703,7 +1982,7 @@ const DiscordMessage = memo(function DiscordMessage({
src={embed.thumbnail.url}
alt=""
className="discord-embed-thumbnail"
onError={(e) => e.target.style.display = 'none'}
onError={(e) => (e.target as HTMLElement).style.display = 'none'}
/>
)}
</div>
@@ -1814,18 +2093,18 @@ const DiscordMessage = memo(function DiscordMessage({
// ============================================================================
export default function DiscordLogs() {
const messagesContainerRef = useRef(null);
const pendingTargetMessageRef = useRef(null); // For deep-linking - used in fetch, cleared after scroll
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
const pendingTargetMessageRef = useRef<string | null>(null); // For deep-linking - used in fetch, cleared after scroll
const scrollToBottomRef = useRef(false); // Flag to trigger scroll to bottom after initial load
const [channels, setChannels] = useState([]);
const [channelsByGuild, setChannelsByGuild] = useState({});
const [guilds, setGuilds] = useState([]);
const [selectedGuild, setSelectedGuild] = useState(null);
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 [channels, setChannels] = useState<DiscordChannel[]>([]);
const [channelsByGuild, setChannelsByGuild] = useState<Record<string, DiscordChannel[]>>({});
const [guilds, setGuilds] = useState<DiscordGuild[]>([]);
const [selectedGuild, setSelectedGuild] = useState<DiscordGuild | null>(null);
const [selectedChannel, setSelectedChannel] = useState<DiscordChannel | null>(null);
const [messages, setMessages] = useState<DiscordMessage[]>([]);
const [usersMap, setUsersMap] = useState<Record<string, DiscordUser>>({}); // Map of user ID -> { displayName, username, color }
const [emojiCache, setEmojiCache] = useState<Record<string, DiscordEmoji>>({}); // Map of emoji ID -> { url, animated }
const [members, setMembers] = useState<MembersData | null>(null); // { groups: [...], roles: [...] }
const [loadingMembers, setLoadingMembers] = useState(false);
const [memberListExpanded, setMemberListExpanded] = useState(false); // Default to collapsed
const [linkCopied, setLinkCopied] = useState(false);
@@ -1835,23 +2114,23 @@ export default function DiscordLogs() {
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 [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState(null); // Server-side search results
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null); // Server-side search results
const [searchLoading, setSearchLoading] = useState(false);
const [previewCache, setPreviewCache] = useState({});
const [imageModal, setImageModal] = useState(null); // { url, alt }
const [reactionPopup, setReactionPopup] = useState(null); // { x, y, emoji, users, loading }
const [pollVoterPopup, setPollVoterPopup] = useState(null); // { x, y, answer, voters }
const [contextMenu, setContextMenu] = useState(null); // { x, y, messageId, content }
const [channelContextMenu, setChannelContextMenu] = useState(null); // { x, y, channel }
const [previewCache, setPreviewCache] = useState<Record<string, LinkPreviewData>>({});
const [imageModal, setImageModal] = useState<ImageModalState | null>(null); // { url, alt }
const [reactionPopup, setReactionPopup] = useState<ReactionPopupState | null>(null); // { x, y, emoji, users, loading }
const [pollVoterPopup, setPollVoterPopup] = useState<PollVoterPopupState | null>(null); // { x, y, answer, voters }
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null); // { x, y, messageId, content }
const [channelContextMenu, setChannelContextMenu] = useState<ChannelContextMenuState | null>(null); // { x, y, channel }
const [topicExpanded, setTopicExpanded] = useState(false); // Show full channel topic
const [refetchCounter, setRefetchCounter] = useState(0); // Counter to force re-fetch messages
const lastPollTimeRef = useRef(new Date().toISOString()); // Track last poll time for edit detection
// Collapsible categories in sidebar
const [collapsedCategories, setCollapsedCategories] = useState({});
const handleCategoryToggle = useCallback((catName) => {
const [collapsedCategories, setCollapsedCategories] = useState<Record<string, boolean>>({});
const handleCategoryToggle = useCallback((catName: string) => {
setCollapsedCategories(prev => ({ ...prev, [catName]: !prev[catName] }));
}, []);

View File

@@ -1,15 +1,23 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { API_URL } from '../config.js';
import { authFetch } from '../utils/authFetch.js';
import { API_URL } from '../config.ts';
import { authFetch } from '../utils/authFetch.ts';
import Wheel from '@uiw/react-color-wheel';
interface LightingState {
power: string;
red: number;
blue: number;
green: number;
brightness: number;
}
export default function Lighting() {
const [state, setState] = useState({ power: '', red: 0, blue: 0, green: 0, brightness: 100 });
const [state, setState] = useState<LightingState>({ power: '', red: 0, blue: 0, green: 0, brightness: 100 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [pending, setPending] = useState(false);
const debounceRef = useRef(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
authFetch(`${API_URL}/lighting/state`)
@@ -127,22 +135,22 @@ export default function Lighting() {
overflow: 'hidden'
}}>
<Wheel
hsva={{ h: 0, s: 0, v: 100, a: 1 }}
color={{ h: 0, s: 0, v: 100, a: 1 }}
onChange={(color) => {
const { h, s, v } = color.hsva;
// Convert percentages to decimals and handle undefined v
const rgb = hsvToRgb(
const { red, green, blue } = hsvToRgb(
h / 360, // hue: 0-360 -> 0-1
s / 100, // saturation: 0-100 -> 0-1
(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);
if (import.meta.env.DEV) console.debug('Converting color:', color.hsva, 'to RGB:', { red, green, blue });
// Auto power on when changing color
updateLighting({
...state,
red: rgb.red,
green: rgb.green,
blue: rgb.blue,
red,
green,
blue,
power: state.power === 'off' ? 'on' : state.power
});
}}

View File

@@ -1,25 +1,31 @@
import React, { useState, useRef, useEffect } from "react";
import React, { useState, useRef, useEffect, type FormEvent } from "react";
import Button from "@mui/joy/Button";
import { toast } from "react-toastify";
import { API_URL } from "@/config";
function getCookie(name) {
function getCookie(name: string): string | null {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return decodeURIComponent(parts.pop().split(';').shift());
if (parts.length === 2) return decodeURIComponent(parts.pop()!.split(';').shift()!);
return null;
}
function clearCookie(name) {
function clearCookie(name: string): void {
document.cookie = `${name}=; Max-Age=0; path=/;`;
}
export default function LoginPage({ loggedIn = false, accessDenied = false, requiredRoles = [] }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
interface LoginPageProps {
loggedIn?: boolean;
accessDenied?: boolean;
requiredRoles?: string[] | string;
}
const passwordRef = useRef();
export default function LoginPage({ loggedIn = false, accessDenied = false, requiredRoles = [] }: LoginPageProps): React.ReactElement {
const [username, setUsername] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const passwordRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (passwordRef.current && password === "") {
@@ -27,7 +33,7 @@ export default function LoginPage({ loggedIn = false, accessDenied = false, requ
}
}, []);
async function handleSubmit(e) {
async function handleSubmit(e: FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
setLoading(true);

View File

@@ -7,7 +7,8 @@ import React, {
useState,
useCallback,
} from "react";
import { toast } from 'react-toastify';
import type { RefObject } from "react";
import { toast, type Id } from 'react-toastify';
import DOMPurify from 'isomorphic-dompurify';
import Box from '@mui/joy/Box';
import Button from "@mui/joy/Button";
@@ -21,6 +22,49 @@ import RemoveRoundedIcon from '@mui/icons-material/RemoveRounded';
import { AutoComplete } from 'primereact/autocomplete/autocomplete.esm.js';
import { API_URL } from '../config';
// Type definitions
interface YouTubeVideo {
video_id: string;
title: string;
channel: string;
duration: string;
thumbnail?: string | null;
}
interface LyricsResult {
lyrics: string;
artist: string;
song: string;
source?: string;
src?: string;
[key: string]: unknown;
}
interface UICheckboxProps {
id: string;
label: string;
value?: string;
onToggle?: (source: string) => void;
}
interface UICheckboxHandle {
setChecked: (val: boolean) => void;
checked: boolean;
}
interface Suggestion {
label: string;
value: string;
artist?: string;
song?: string;
}
interface LyricSearchInputFieldProps {
id: string;
placeholder: string;
setShowLyrics: (show: boolean) => void;
}
// Sanitize HTML from external sources to prevent XSS
const sanitizeHtml = (html) => {
if (!html) return '';
@@ -65,22 +109,22 @@ export default function LyricSearch() {
export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
export function LyricSearchInputField({ id, placeholder, setShowLyrics }: LyricSearchInputFieldProps) {
const [value, setValue] = useState("");
const [suggestions, setSuggestions] = useState([]);
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [excludedSources, setExcludedSources] = useState([]);
const [lyricsResult, setLyricsResult] = useState(null);
const [excludedSources, setExcludedSources] = useState<string[]>([]);
const [lyricsResult, setLyricsResult] = useState<LyricsResult | null>(null);
const [textSize, setTextSize] = useState("normal");
const [inputStatus, setInputStatus] = useState("hint");
const [youtubeVideo, setYoutubeVideo] = useState(null); // { video_id, title, channel, duration }
const [youtubeVideo, setYoutubeVideo] = useState<YouTubeVideo | null>(null);
const [youtubeLoading, setYoutubeLoading] = useState(false);
const [highlightedVerse, setHighlightedVerse] = useState(null);
const [highlightedVerse, setHighlightedVerse] = useState<string | null>(null);
const [isLyricsVisible, setIsLyricsVisible] = useState(false);
const searchToastRef = useRef(null);
const autoCompleteRef = useRef(null);
const autoCompleteInputRef = useRef(null);
const searchButtonRef = useRef(null);
const searchToastRef = useRef<Id | null>(null);
const autoCompleteRef = useRef<any>(null);
const autoCompleteInputRef = useRef<HTMLInputElement | null>(null);
const searchButtonRef = useRef<HTMLButtonElement | null>(null);
const [theme, setTheme] = useState(document.documentElement.getAttribute("data-theme") || "light");
const statusLabels = {
hint: "Format: Artist - Song",
@@ -180,7 +224,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
}, []);
// Toggle exclusion state for checkboxes
const toggleExclusion = (source) => {
const toggleExclusion = (source: string) => {
const lower = source.toLowerCase();
setExcludedSources((prev) =>
prev.includes(lower)
@@ -197,12 +241,13 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
const items = panel?.querySelector(".p-autocomplete-items");
if (items) {
items.style.maxHeight = "200px";
items.style.overflowY = "auto";
items.style.overscrollBehavior = "contain";
(items as HTMLElement).style.maxHeight = "200px";
(items as HTMLElement).style.overflowY = "auto";
(items as HTMLElement).style.overscrollBehavior = "contain";
const wheelHandler = (e) => {
const delta = e.deltaY;
const wheelHandler: EventListener = (e) => {
const wheelEvent = e as WheelEvent;
const delta = wheelEvent.deltaY;
const atTop = items.scrollTop === 0;
const atBottom =
items.scrollTop + items.clientHeight >= items.scrollHeight;
@@ -220,7 +265,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
}, 0);
};
const evaluateSearchValue = useCallback((searchValue, shouldUpdate = true) => {
const evaluateSearchValue = useCallback((searchValue: string, shouldUpdate = true) => {
const trimmed = searchValue?.trim() || "";
let status = "hint";
@@ -269,10 +314,10 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
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';
(panel as HTMLElement).style.display = 'none';
(panel as HTMLElement).style.opacity = '0';
(panel as HTMLElement).style.visibility = 'hidden';
(panel as HTMLElement).style.pointerEvents = 'none';
}
}, 10);
}, []);
@@ -355,9 +400,9 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
autoClose: 2500,
toastId: "lyrics-success-toast",
});
} catch (error) {
} catch (error: unknown) {
dismissSearchToast();
toast.error(error.message, {
toast.error((error as Error)?.message || 'An error occurred', {
icon: () => "😕",
autoClose: 5000,
toastId: "lyrics-error-toast",
@@ -371,7 +416,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
}
};
const handleKeyDown = (e) => {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
hideAutocompletePanel();
@@ -595,7 +640,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
}
export const UICheckbox = forwardRef(function UICheckbox(props = {}, ref) {
export const UICheckbox = forwardRef<UICheckboxHandle, UICheckboxProps>(function UICheckbox(props, ref) {
const [checked, setChecked] = useState(false);
useImperativeHandle(ref, () => ({
@@ -604,7 +649,7 @@ export const UICheckbox = forwardRef(function UICheckbox(props = {}, ref) {
}));
const verifyExclusions = () => {
const checkboxes = document.querySelectorAll(".exclude-chip");
const checkboxes = document.querySelectorAll(".exclude-chip") as NodeListOf<HTMLElement>;
const checkedCount = [...checkboxes].filter(cb => cb.dataset.checked === 'true').length;
if (checkedCount === 3) {
@@ -643,7 +688,12 @@ export const UICheckbox = forwardRef(function UICheckbox(props = {}, ref) {
});
export function LyricResultBox(opts = {}) {
interface LyricResultBoxOpts {
theme?: string;
}
export function LyricResultBox(opts: LyricResultBoxOpts = {}) {
const theme = opts.theme || 'dark';
return (
<div>
<Box className={`lyrics-card lyrics-card-${theme}`} sx={{ p: 2 }}>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useRef, useCallback } from "react";
import { useEffect, useState, useRef, useCallback, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { ProgressSpinner } from 'primereact/progressspinner';
import { Dialog } from 'primereact/dialog';
import { Image } from 'primereact/image';
@@ -10,27 +10,40 @@ import { API_URL } from '../config';
const MEME_API_URL = `${API_URL}/memes/list_memes`;
const BASE_IMAGE_URL = "https://codey.lol/meme";
const Memes = () => {
const [images, setImages] = useState([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
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: [] });
interface MemeImage {
id: string;
url: string;
filename?: string;
timestamp?: string;
[key: string]: unknown;
}
const prefetchImage = useCallback((img) => {
interface MemeCache {
pagesLoaded: Set<number>;
items: MemeImage[];
}
const Memes: React.FC = () => {
const [images, setImages] = useState<MemeImage[]>([]);
const [page, setPage] = useState<number>(1);
const [loading, setLoading] = useState<boolean>(false);
const [hasMore, setHasMore] = useState<boolean>(true);
const [selectedImage, setSelectedImage] = useState<MemeImage | null>(null);
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
const [imageLoading, setImageLoading] = useState<Record<string, boolean>>({});
const observerRef = useRef<IntersectionObserver | null>(null);
const touchStartRef = useRef<number | null>(null);
const touchEndRef = useRef<number | null>(null);
const theme = document.documentElement.getAttribute("data-theme")
const cacheRef = useRef<MemeCache>({ pagesLoaded: new Set(), items: [] });
const prefetchImage = useCallback((img: MemeImage | undefined): void => {
if (!img || typeof window === "undefined") return;
const preload = new window.Image();
preload.src = img.url;
}, []);
const handleNavigate = useCallback((direction) => {
const handleNavigate = useCallback((direction: number): void => {
setSelectedIndex((prev) => {
const newIndex = prev + direction;
if (newIndex < 0 || newIndex >= images.length) {
@@ -47,7 +60,7 @@ const Memes = () => {
if (!selectedImage) {
return;
}
const handleKeyDown = (event) => {
const handleKeyDown = (event: globalThis.KeyboardEvent): void => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
handleNavigate(-1);
@@ -79,7 +92,7 @@ const Memes = () => {
}
}
}, []);
const persistCache = useCallback((nextPage) => {
const persistCache = useCallback((nextPage: number): void => {
sessionStorage.setItem("memes-cache", JSON.stringify({
items: cacheRef.current.items,
pagesLoaded: Array.from(cacheRef.current.pagesLoaded),
@@ -88,7 +101,7 @@ const Memes = () => {
}));
}, [hasMore]);
const loadImages = async (pageNum, attempt = 0) => {
const loadImages = async (pageNum: number, attempt: number = 0): Promise<void> => {
if (loading || !hasMore || cacheRef.current.pagesLoaded.has(pageNum)) return;
setLoading(true);
try {

View File

@@ -0,0 +1,19 @@
import React from "react";
import Alert from "@mui/joy/Alert";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
export default function RadioBanner(): React.ReactElement {
return (
<div>
<h2 style={{ textAlign: 'center', marginBottom: '1rem' }}>Radio</h2>
<Alert
variant="soft"
color="danger"
sx={{ fontSize: "1.0rem", textAlign: "center", mt: 4, mb: 4, px: 3, py: 2 }}
startDecorator={<ErrorOutlineIcon fontSize="large" />}
>
Maintenance in progress. Please check back soon!
</Alert>
</div>
);
}

View File

@@ -1,12 +1,16 @@
import { useState, useEffect } from "react";
import { API_URL } from "../config";
export default function RandomMsg() {
const [randomMsg, setRandomMsg] = useState("");
const [responseTime, setResponseTime] = useState(null);
const [showResponseTime, setShowResponseTime] = useState(false);
interface RandomMsgResponse {
msg?: string;
}
const getRandomMsg = async () => {
export default function RandomMsg(): React.ReactElement {
const [randomMsg, setRandomMsg] = useState<string>("");
const [responseTime, setResponseTime] = useState<number | null>(null);
const [showResponseTime, setShowResponseTime] = useState<boolean>(false);
const getRandomMsg = async (): Promise<void> => {
try {
const start = performance.now();
const response = await fetch(`${API_URL}/randmsg`, {

View File

@@ -4,10 +4,10 @@
* 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 React, { Suspense, lazy, ReactNode } from 'react';
import { ToastContainer, toast } from 'react-toastify';
import { CssVarsProvider } from "@mui/joy";
import { CacheProvider } from "@emotion/react";
import { CacheProvider, EmotionCache } from "@emotion/react";
import createCache from "@emotion/cache";
import { PrimeReactProvider } from "primereact/api";
@@ -17,9 +17,13 @@ import 'primereact/resources/primereact.min.css';
const ReqForm = lazy(() => import('./req/ReqForm.jsx'));
interface MinimalJoyWrapperProps {
children: ReactNode;
}
// Inline minimal JoyUI wrapper to avoid importing Components.jsx
function MinimalJoyWrapper({ children }) {
const cache = React.useRef();
function MinimalJoyWrapper({ children }: MinimalJoyWrapperProps): React.ReactElement {
const cache = React.useRef<EmotionCache | null>(null);
if (!cache.current) {
cache.current = createCache({ key: "joy-sub" });
}
@@ -30,7 +34,12 @@ function MinimalJoyWrapper({ children }) {
);
}
export default function SubsiteRoot({ child, ...props }) {
interface SubsiteRootProps {
child: string;
[key: string]: unknown;
}
export default function SubsiteRoot({ child, ...props }: SubsiteRootProps): React.ReactElement {
if (typeof window !== 'undefined') {
window.toast = toast;
}

View File

@@ -1,7 +1,17 @@
import React from "react";
export default function BreadcrumbNav({ currentPage }) {
const pages = [
interface BreadcrumbNavProps {
currentPage: string;
}
interface PageLink {
key: string;
label: string;
href: string;
}
export default function BreadcrumbNav({ currentPage }: BreadcrumbNavProps): React.ReactElement {
const pages: PageLink[] = [
{ key: "request", label: "Request Media", href: "/TRip" },
{ key: "management", label: "Manage Requests", href: "/TRip/requests" },
];

View File

@@ -1,4 +1,6 @@
import React, { useState, useEffect, useRef, Suspense, lazy, useMemo } from "react";
import type { RefObject } from "react";
import type { Id } from "react-toastify";
import { toast } from "react-toastify";
import { Button } from "@mui/joy";
import { Accordion, AccordionTab } from "primereact/accordion";
@@ -8,50 +10,100 @@ import BreadcrumbNav from "./BreadcrumbNav";
import { API_URL, ENVIRONMENT } from "@/config";
import "./RequestManagement.css";
interface Artist {
id: string | number;
name: string;
artist?: string;
}
interface Album {
id: string | number;
title: string;
album?: string;
release_date?: string;
cover_image?: string;
cover_small?: string;
tracks?: Track[];
}
interface FetchTracksSequentiallyFn {
(): Promise<void>;
lastCall?: number;
}
interface Track {
id: string | number;
title: string;
artist?: string;
duration?: number | string;
album_id?: string | number;
track_number?: number;
version?: string;
}
interface DiskSpaceInfo {
total?: number;
used?: number;
available?: number;
percent?: number;
usedPercent?: number;
availableFormatted?: string;
}
interface AudioProgress {
current: number;
duration: number;
}
interface SearchArtistsFn {
(e: { query: string }): void;
lastCall?: number;
}
export default function MediaRequestForm() {
const [type, setType] = useState("artist");
const [selectedArtist, setSelectedArtist] = useState(null);
const [selectedArtist, setSelectedArtist] = useState<Artist | null>(null);
const [artistInput, setArtistInput] = useState("");
const [albumInput, setAlbumInput] = useState("");
const [trackInput, setTrackInput] = useState("");
const [quality, setQuality] = useState("FLAC"); // default FLAC
const [selectedItem, setSelectedItem] = useState(null);
const [albums, setAlbums] = useState([]);
const [tracksByAlbum, setTracksByAlbum] = useState({});
const [selectedTracks, setSelectedTracks] = useState({});
const [artistSuggestions, setArtistSuggestions] = useState([]);
const [selectedItem, setSelectedItem] = useState<Artist | Album | Track | null>(null);
const [albums, setAlbums] = useState<Album[]>([]);
const [tracksByAlbum, setTracksByAlbum] = useState<Record<string | number, Track[]>>({});
const [selectedTracks, setSelectedTracks] = useState<Record<string | number, string[] | null>>({});
const [artistSuggestions, setArtistSuggestions] = useState<Artist[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [loadingAlbumId, setLoadingAlbumId] = useState(null);
const [expandedAlbums, setExpandedAlbums] = useState([]);
const [loadingAlbumId, setLoadingAlbumId] = useState<string | number | null>(null);
const [expandedAlbums, setExpandedAlbums] = useState<number[]>([]);
const [isFetching, setIsFetching] = useState(false);
const [currentTrackId, setCurrentTrackId] = useState(null);
const [currentTrackId, setCurrentTrackId] = useState<string | number | null>(null);
const [isAudioPlaying, setIsAudioPlaying] = useState(false);
const [audioLoadingTrackId, setAudioLoadingTrackId] = useState(null);
const [playbackQueue, setPlaybackQueue] = useState([]);
const [queueIndex, setQueueIndex] = useState(null);
const [queueAlbumId, setQueueAlbumId] = useState(null);
const [albumPlaybackLoadingId, setAlbumPlaybackLoadingId] = useState(null);
const [shuffleAlbums, setShuffleAlbums] = useState({});
const [audioProgress, setAudioProgress] = useState({ current: 0, duration: 0 });
const [diskSpace, setDiskSpace] = useState(null);
const [audioLoadingTrackId, setAudioLoadingTrackId] = useState<string | number | null>(null);
const [playbackQueue, setPlaybackQueue] = useState<Track[]>([]);
const [queueIndex, setQueueIndex] = useState<number | null>(null);
const [queueAlbumId, setQueueAlbumId] = useState<string | number | null>(null);
const [albumPlaybackLoadingId, setAlbumPlaybackLoadingId] = useState<string | number | null>(null);
const [shuffleAlbums, setShuffleAlbums] = useState<Record<string | number, boolean>>({});
const [audioProgress, setAudioProgress] = useState<AudioProgress>({ current: 0, duration: 0 });
const [diskSpace, setDiskSpace] = useState<DiskSpaceInfo | null>(null);
const debounceTimeout = useRef(null);
const autoCompleteRef = useRef(null);
const metadataFetchToastId = useRef(null);
const audioRef = useRef(null);
const audioSourcesRef = useRef({});
const pendingTrackFetchesRef = useRef({});
const playbackQueueRef = useRef([]);
const queueIndexRef = useRef(null);
const queueAlbumIdRef = useRef(null);
const albumHeaderRefs = useRef({});
const debounceTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoCompleteRef = useRef<any>(null);
const metadataFetchToastId = useRef<Id | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const audioSourcesRef = useRef<Record<string | number, string>>({});
const pendingTrackFetchesRef = useRef<Record<string | number, Promise<string>>>({});
const playbackQueueRef = useRef<Track[]>([]);
const queueIndexRef = useRef<number | null>(null);
const queueAlbumIdRef = useRef<string | number | null>(null);
const albumHeaderRefs = useRef<Record<string | number, HTMLElement | null>>({});
const suppressHashRef = useRef(false);
const lastUrlRef = useRef("");
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // Helper for delays
const sanitizeFilename = (text) => (text || "").replace(/[\\/:*?"<>|]/g, "_") || "track";
const formatTime = (seconds) => {
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // Helper for delays
const sanitizeFilename = (text: string) => (text || "").replace(/[\\/:*?"<>|]/g, "_") || "track";
const formatTime = (seconds: number) => {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60)
@@ -130,7 +182,7 @@ export default function MediaRequestForm() {
</svg>
);
const ShuffleIcon = ({ active }) => (
const ShuffleIcon = ({ active }: { active: boolean }) => (
<svg
viewBox="0 0 24 24"
role="presentation"
@@ -144,7 +196,7 @@ export default function MediaRequestForm() {
</svg>
);
const shuffleArray = (arr) => {
const shuffleArray = <T,>(arr: T[]): T[] => {
const clone = [...arr];
for (let i = clone.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
@@ -153,7 +205,7 @@ export default function MediaRequestForm() {
return clone;
};
const ensureAlbumExpanded = (albumIndex) => {
const ensureAlbumExpanded = (albumIndex: number) => {
if (typeof albumIndex !== "number") return;
let albumAdded = false;
setExpandedAlbums((prev) => {
@@ -188,7 +240,7 @@ export default function MediaRequestForm() {
setAudioProgress({ current: 0, duration: 0 });
};
const getTrackSource = async (trackId) => {
const getTrackSource = async (trackId: string | number): Promise<string> => {
if (audioSourcesRef.current[trackId]) {
return audioSourcesRef.current[trackId];
}
@@ -230,8 +282,12 @@ export default function MediaRequestForm() {
return pendingTrackFetchesRef.current[trackId];
};
const prefetchTrack = async (track) => {
if (!track || audioSourcesRef.current[track.id] || pendingTrackFetchesRef.current[track.id]) {
const prefetchTrack = async (track: Track | null) => {
if (!track || audioSourcesRef.current[track.id]) {
return;
}
// Check if already being fetched (returns a truthy promise)
if (track.id in pendingTrackFetchesRef.current) {
return;
}
try {
@@ -241,7 +297,7 @@ export default function MediaRequestForm() {
}
};
const playTrack = async (track, { fromQueue = false } = {}) => {
const playTrack = async (track: Track, { fromQueue = false } = {}) => {
const audio = audioRef.current;
if (!audio || !track) return;
@@ -272,7 +328,7 @@ export default function MediaRequestForm() {
};
// Fetch artist suggestions for autocomplete
const searchArtists = (e) => {
const searchArtists: SearchArtistsFn = (e) => {
const query = e.query.trim();
if (!query) {
setArtistSuggestions([]);
@@ -307,14 +363,14 @@ export default function MediaRequestForm() {
};
const truncate = (text, maxLen) =>
const truncate = (text: string, maxLen: number) =>
maxLen <= 3
? text.slice(0, maxLen)
: text.length <= maxLen
? text
: text.slice(0, maxLen - 3) + '...';
const selectArtist = (artist) => {
const selectArtist = (artist: Artist) => {
// artist may be a grouped item or an alternate object
const value = artist.artist || artist.name || "";
setSelectedArtist(artist);
@@ -334,7 +390,7 @@ export default function MediaRequestForm() {
// If panel still exists, hide it via style (safer than removing the node)
setTimeout(() => {
const panel = document.querySelector('.p-autocomplete-panel');
const panel = document.querySelector('.p-autocomplete-panel') as HTMLElement | null;
if (panel) {
panel.style.display = 'none';
panel.style.opacity = '0';
@@ -344,7 +400,7 @@ export default function MediaRequestForm() {
}, 10);
// blur the input element if present
const inputEl = ac?.getInput ? ac.getInput() : document.querySelector('.p-autocomplete-input');
const inputEl = ac?.getInput ? ac.getInput() : document.querySelector('.p-autocomplete-input') as HTMLInputElement | null;
if (inputEl && typeof inputEl.blur === 'function') inputEl.blur();
} catch (innerErr) {
// Ignore inner errors
@@ -357,11 +413,11 @@ export default function MediaRequestForm() {
const totalAlbums = albums.length;
const totalTracks = useMemo(() =>
Object.values(tracksByAlbum).reduce((sum, arr) => (Array.isArray(arr) ? sum + arr.length : sum), 0),
Object.values(tracksByAlbum).reduce((sum: number, arr) => (Array.isArray(arr) ? sum + arr.length : sum), 0),
[tracksByAlbum]
);
const artistItemTemplate = (artist) => {
const artistItemTemplate = (artist: Artist & { alternatives?: Artist[]; alternates?: Artist[] }) => {
if (!artist) return null;
const alts = artist.alternatives || artist.alternates || [];
@@ -401,13 +457,13 @@ export default function MediaRequestForm() {
// Handle autocomplete input changes (typing/selecting)
const handleArtistChange = (e) => {
const handleArtistChange = (e: { value: string | Artist }) => {
if (typeof e.value === "string") {
setArtistInput(e.value);
setSelectedArtist(null);
} else if (e.value && typeof e.value === "object") {
setSelectedArtist(e.value);
setArtistInput(e.value.artist);
setArtistInput(e.value.artist || e.value.name || "");
} else {
setArtistInput("");
setSelectedArtist(null);
@@ -446,7 +502,7 @@ export default function MediaRequestForm() {
return;
}
setSelectedItem(selectedArtist.artist);
setSelectedItem({ ...selectedArtist, name: selectedArtist.artist || selectedArtist.name || "" });
try {
const res = await authFetch(
@@ -483,7 +539,7 @@ export default function MediaRequestForm() {
setIsSearching(false);
return;
}
setSelectedItem(`${artistInput} - ${albumInput}`);
setSelectedItem({ id: 0, name: `${artistInput} - ${albumInput}` });
setAlbums([]);
setTracksByAlbum({});
setSelectedTracks({});
@@ -493,7 +549,7 @@ export default function MediaRequestForm() {
setIsSearching(false);
return;
}
setSelectedItem(`${artistInput} - ${trackInput}`);
setSelectedItem({ id: 0, name: `${artistInput} - ${trackInput}` });
setAlbums([]);
setTracksByAlbum({});
setSelectedTracks({});
@@ -501,7 +557,7 @@ export default function MediaRequestForm() {
setIsSearching(false);
};
const handleTrackPlayPause = async (track, albumId = null, albumIndex = null) => {
const handleTrackPlayPause = async (track: Track, albumId: string | number | null = null, albumIndex: number | null = null) => {
const audio = audioRef.current;
if (!audio) return;
@@ -609,9 +665,9 @@ export default function MediaRequestForm() {
const blob = await fileResponse.blob();
const url = URL.createObjectURL(blob);
const artistName = track.artist || selectedArtist?.artist || "Unknown Artist";
const artistName = track.artist || selectedArtist?.artist || selectedArtist?.name || "Unknown Artist";
const urlPath = new URL(data.stream_url).pathname;
const extension = urlPath.split(".").pop().split("?")[0] || "flac";
const extension = urlPath.split(".").pop()?.split("?")[0] || "flac";
const filename = `${sanitizeFilename(artistName)} - ${sanitizeFilename(track.title)}.${extension}`;
const link = document.createElement("a");
@@ -791,7 +847,7 @@ export default function MediaRequestForm() {
const albumsToFetch = albums.filter((a) => !tracksByAlbum[a.id]);
if (albumsToFetch.length === 0) return;
const fetchTracksSequentially = async () => {
const fetchTracksSequentially: FetchTracksSequentiallyFn = async () => {
const minDelay = 650; // ms between API requests
setIsFetching(true);
@@ -829,22 +885,26 @@ export default function MediaRequestForm() {
}
// Update progress toast
toast.update(metadataFetchToastId.current, {
progress: (index + 1) / totalAlbums,
render: `Retrieving metadata... (${index + 1} / ${totalAlbums})`,
});
if (metadataFetchToastId.current !== null) {
toast.update(metadataFetchToastId.current, {
progress: (index + 1) / totalAlbums,
render: `Retrieving metadata... (${index + 1} / ${totalAlbums})`,
});
}
}
setLoadingAlbumId(null);
setIsFetching(false);
// Finish the toast
toast.update(metadataFetchToastId.current, {
render: "Metadata retrieved!",
type: "success",
progress: 1,
autoClose: 1500,
});
if (metadataFetchToastId.current !== null) {
toast.update(metadataFetchToastId.current, {
render: "Metadata retrieved!",
type: "success",
progress: 1,
autoClose: 1500,
});
}
};
fetchTracksSequentially();
@@ -857,7 +917,7 @@ export default function MediaRequestForm() {
// Toggle individual track checkbox
const toggleTrack = (albumId, trackId) => {
const toggleTrack = (albumId: string | number, trackId: string | number) => {
setSelectedTracks((prev) => {
const current = new Set(prev[albumId] || []);
if (current.has(String(trackId))) current.delete(String(trackId));
@@ -867,11 +927,11 @@ export default function MediaRequestForm() {
};
// Toggle album checkbox (select/deselect all tracks in album)
const toggleAlbum = (albumId) => {
const toggleAlbum = (albumId: string | number) => {
const allTracks = tracksByAlbum[albumId]?.map((t) => String(t.id)) || [];
setSelectedTracks((prev) => {
const current = prev[albumId] || [];
const allSelected = current.length === allTracks.length;
const allSelected = current?.length === allTracks.length;
return {
...prev,
[albumId]: allSelected ? [] : [...allTracks],
@@ -883,12 +943,12 @@ export default function MediaRequestForm() {
const attachScrollFix = () => {
setTimeout(() => {
const panel = document.querySelector(".p-autocomplete-panel");
const items = panel?.querySelector(".p-autocomplete-items");
const items = panel?.querySelector(".p-autocomplete-items") as HTMLElement | null;
if (items) {
items.style.maxHeight = "200px";
items.style.overflowY = "auto";
items.style.overscrollBehavior = "contain";
const wheelHandler = (e) => {
const wheelHandler = (e: WheelEvent) => {
const delta = e.deltaY;
const atTop = items.scrollTop === 0;
const atBottom = items.scrollTop + items.clientHeight >= items.scrollHeight;
@@ -923,7 +983,7 @@ export default function MediaRequestForm() {
},
body: JSON.stringify({
track_ids: allSelectedIds,
target: selectedArtist.artist,
target: selectedArtist?.artist || selectedArtist?.name || "",
quality: quality,
}),
});
@@ -1018,7 +1078,7 @@ export default function MediaRequestForm() {
<BreadcrumbNav currentPage="request" />
{/* Disk Space Indicator - always visible */}
{diskSpace && (
{diskSpace && diskSpace.usedPercent !== undefined && (
<div className="mb-4 flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2 1 3 3 3h10c2 0 3-1 3-3V7c0-2-1-3-3-3H7c-2 0-3 1-3 3z" />
@@ -1047,7 +1107,7 @@ export default function MediaRequestForm() {
<div className="flex flex-col gap-4">
<label htmlFor="artistInput">Artist: </label>
<AutoComplete
id={artistInput}
id="artistInput"
ref={autoCompleteRef}
value={selectedArtist || artistInput}
suggestions={artistSuggestions}
@@ -1125,7 +1185,7 @@ export default function MediaRequestForm() {
multiple
className="mt-4"
activeIndex={expandedAlbums}
onTabChange={(e) => setExpandedAlbums(e.index)}
onTabChange={(e) => setExpandedAlbums(Array.isArray(e.index) ? e.index : [e.index])}
>
{albums.map(({ album, id, release_date }, albumIndex) => {
const allTracks = tracksByAlbum[id] || [];
@@ -1193,11 +1253,11 @@ export default function MediaRequestForm() {
<ShuffleIcon active={!!shuffleAlbums[id]} />
</button>
</div>
<span className="flex items-center" title={album}>
{truncate(album, 32)}
<span className="flex items-center" title={album || ''}>
{truncate(album || '', 32)}
{loadingAlbumId === id && <Spinner />}
</span>
<small className="ml-2 text-neutral-500 dark:text-neutral-400">({release_date})</small>
<small className="ml-2 text-neutral-500 dark:text-neutral-400">({release_date || 'Unknown'})</small>
<span className="ml-0 w-full text-xs text-neutral-500 sm:ml-auto sm:w-auto">
{typeof tracksByAlbum[id] === 'undefined' ? (
loadingAlbumId === id ? 'Loading...' : '...'
@@ -1277,7 +1337,6 @@ 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

@@ -11,22 +11,36 @@ import BreadcrumbNav from "./BreadcrumbNav";
import { API_URL } from "@/config";
import "./RequestManagement.css";
interface RequestJob {
id: string | number;
target: string;
tracks: number;
quality: string;
status: string;
progress: number;
type?: string;
tarball_path?: string;
created_at?: string;
updated_at?: string;
[key: string]: unknown;
}
const STATUS_OPTIONS = ["Queued", "Started", "Compressing", "Finished", "Failed"];
const TAR_BASE_URL = "https://codey.lol/m/m2"; // configurable prefix
export default function RequestManagement() {
const [requests, setRequests] = useState([]);
const [filterType, setFilterType] = useState(null);
const [filterStatus, setFilterStatus] = useState(null);
const [filteredRequests, setFilteredRequests] = useState([]);
const [selectedRequest, setSelectedRequest] = useState(null);
const [requests, setRequests] = useState<RequestJob[]>([]);
const [filterType, setFilterType] = useState<string | null>(null);
const [filterStatus, setFilterStatus] = useState<string | null>(null);
const [filteredRequests, setFilteredRequests] = useState<RequestJob[]>([]);
const [selectedRequest, setSelectedRequest] = useState<RequestJob | null>(null);
const [isDialogVisible, setIsDialogVisible] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const pollingRef = useRef(null);
const pollingDetailRef = useRef(null);
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollingDetailRef = useRef<ReturnType<typeof setInterval> | null>(null);
const tarballUrl = (absPath, quality) => {
const tarballUrl = (absPath: string | undefined, quality: string) => {
if (!absPath) return null;
const filename = absPath.split("/").pop(); // get "SOMETHING.tar.gz"
return `${TAR_BASE_URL}/${quality}/${filename}`;
@@ -37,7 +51,7 @@ export default function RequestManagement() {
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();
const data = await res.json() as { jobs?: RequestJob[] };
setRequests(Array.isArray(data.jobs) ? data.jobs : []);
} catch (err) {
console.error(err);
@@ -51,11 +65,11 @@ export default function RequestManagement() {
}
};
const fetchJobDetail = async (jobId) => {
const fetchJobDetail = async (jobId: string | number): Promise<RequestJob | null> => {
try {
const res = await authFetch(`${API_URL}/trip/job/${jobId}`);
if (!res.ok) throw new Error("Failed to fetch job details");
return await res.json();
return await res.json() as RequestJob;
} catch (err) {
console.error(err);
if (!toast.isActive('fetch-job-fail-toast')) {
@@ -108,7 +122,7 @@ export default function RequestManagement() {
}, [filterType, filterStatus, requests]);
const getStatusColorClass = (status) => {
const getStatusColorClass = (status: string) => {
switch (status) {
case "Queued": return "bg-yellow-700 text-white";
case "Started": return "bg-blue-700 text-white";
@@ -119,7 +133,7 @@ export default function RequestManagement() {
}
};
const getQualityColorClass = (quality) => {
const getQualityColorClass = (quality: string) => {
switch (quality) {
case "FLAC": return "bg-green-700 text-white";
case "Lossy": return "bg-yellow-700 text-white";
@@ -128,25 +142,25 @@ export default function RequestManagement() {
};
const statusBodyTemplate = (rowData) => (
const statusBodyTemplate = (rowData: RequestJob) => (
<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) => (
const qualityBodyTemplate = (rowData: RequestJob) => (
<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>
);
const safeText = (val) => (val === 0 ? "0" : val || "—");
const textWithEllipsis = (val, width = "12rem") => (
const safeText = (val: unknown) => (val === 0 ? "0" : val || "—");
const textWithEllipsis = (val: string | undefined | null, width = "12rem") => (
<span className="truncate block" style={{ maxWidth: width }} title={val || ""}>{val || "—"}</span>
);
const truncate = (text, maxLen) =>
const truncate = (text: string, maxLen: number) =>
maxLen <= 3
? text.slice(0, maxLen)
: text.length <= maxLen
@@ -154,9 +168,9 @@ export default function RequestManagement() {
: text.slice(0, maxLen - 3) + '...';
const basename = (p) => (typeof p === "string" ? p.split("/").pop() : "");
const basename = (p: string | undefined) => (typeof p === "string" ? p.split("/").pop() : "");
const formatProgress = (p) => {
const formatProgress = (p: unknown) => {
if (p === null || p === undefined || p === "") return "—";
const num = Number(p);
if (Number.isNaN(num)) return "—";
@@ -164,16 +178,16 @@ export default function RequestManagement() {
return `${pct}%`;
};
const computePct = (p) => {
const computePct = (p: unknown) => {
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 progressBarTemplate = (rowData: RequestJob) => {
const p = rowData.progress;
if (p === null || p === undefined || p === "") return "—";
if (p === null || p === undefined || p === 0) return "—";
const num = Number(p);
if (Number.isNaN(num)) return "—";
const pct = computePct(p);
@@ -192,7 +206,8 @@ export default function RequestManagement() {
<div
className={`rm-progress-fill ${getProgressColor()}`}
style={{
'--rm-progress': (pct / 100).toString(),
// CSS custom property for progress animation
['--rm-progress' as string]: (pct / 100).toString(),
borderTopRightRadius: pct === 100 ? '999px' : 0,
borderBottomRightRadius: pct === 100 ? '999px' : 0
}}
@@ -207,7 +222,7 @@ export default function RequestManagement() {
);
};
const confirmDelete = (requestId) => {
const confirmDelete = (requestId: string | number) => {
confirmDialog({
message: "Are you sure you want to delete this request?",
header: "Confirm Delete",
@@ -216,12 +231,12 @@ export default function RequestManagement() {
});
};
const deleteRequest = (requestId) => {
const deleteRequest = (requestId: string | number) => {
setRequests((prev) => prev.filter((r) => r.id !== requestId));
toast.success("Request deleted");
};
const actionBodyTemplate = (rowData) => (
const actionBodyTemplate = (rowData: RequestJob) => (
<Button
color="neutral"
variant="outlined"
@@ -237,8 +252,9 @@ export default function RequestManagement() {
</Button>
);
const handleRowClick = async (e) => {
const detail = await fetchJobDetail(e.data.id);
const handleRowClick = async (e: { data: unknown }) => {
const rowData = e.data as RequestJob;
const detail = await fetchJobDetail(rowData.id);
if (detail) { setSelectedRequest(detail); setIsDialogVisible(true); }
};
@@ -300,14 +316,14 @@ export default function RequestManagement() {
<Column
field="id"
header="ID"
body={(row) => (
<span title={row.id}>
{row.id.split("-").slice(-1)[0]}
body={(row: RequestJob) => (
<span title={String(row.id)}>
{String(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="target" header="Target" sortable body={(row: RequestJob) => textWithEllipsis(row.target, "100%")} />
<Column field="tracks" header="# Tracks" body={(row: RequestJob) => 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
@@ -324,12 +340,12 @@ export default function RequestManagement() {
Tarball
</span>
}
body={(row) => {
const url = tarballUrl(row.tarball, row.quality || "FLAC");
const encodedURL = encodeURI(url);
body={(row: RequestJob) => {
const url = tarballUrl(row.tarball_path, row.quality || "FLAC");
if (!url) return "—";
const encodedURL = encodeURI(url);
const fileName = url.split("/").pop();
const fileName = url.split("/").pop() || "";
return (
<a
@@ -365,7 +381,7 @@ export default function RequestManagement() {
{/* --- Metadata Card --- */}
<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.id && <p className="col-span-2 break-all"><strong>ID:</strong> {String(selectedRequest.id)}</p>}
{selectedRequest.target && <p><strong>Target:</strong> {selectedRequest.target}</p>}
{selectedRequest.tracks && <p><strong># Tracks:</strong> {selectedRequest.tracks}</p>}
{selectedRequest.quality && (
@@ -396,7 +412,7 @@ export default function RequestManagement() {
<div
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(),
['--rm-progress' as string]: (computePct(selectedRequest.progress) / 100).toString(),
borderTopRightRadius: computePct(selectedRequest.progress) >= 100 ? '999px' : 0,
borderBottomRightRadius: computePct(selectedRequest.progress) >= 100 ? '999px' : 0
}}
@@ -414,24 +430,24 @@ export default function RequestManagement() {
{/* --- Timestamps Card --- */}
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-1 gap-2">
{selectedRequest.enqueued_at && <p><strong>Enqueued:</strong> {new Date(selectedRequest.enqueued_at).toLocaleString()}</p>}
{selectedRequest.started_at && <p><strong>Started:</strong> {new Date(selectedRequest.started_at).toLocaleString()}</p>}
{selectedRequest.ended_at && <p><strong>Ended:</strong> {new Date(selectedRequest.ended_at).toLocaleString()}</p>}
{selectedRequest.created_at && <p><strong>Enqueued:</strong> {new Date(selectedRequest.created_at).toLocaleString()}</p>}
{(selectedRequest as RequestJob & { started_at?: string }).started_at && <p><strong>Started:</strong> {new Date((selectedRequest as RequestJob & { started_at: string }).started_at).toLocaleString()}</p>}
{(selectedRequest as RequestJob & { ended_at?: string }).ended_at && <p><strong>Ended:</strong> {new Date((selectedRequest as RequestJob & { ended_at: string }).ended_at).toLocaleString()}</p>}
</div>
{/* --- Tarball Card --- */}
{
selectedRequest.tarball && (
selectedRequest.tarball_path && (
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md">
<p>
<strong>Tarball:</strong>{" "}
<a
href={encodeURI(tarballUrl(selectedRequest.tarball, selectedRequest.quality))}
href={encodeURI(tarballUrl(selectedRequest.tarball_path, selectedRequest.quality) || "")}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
{tarballUrl(selectedRequest.tarball, selectedRequest.quality).split("/").pop()}
{tarballUrl(selectedRequest.tarball_path, selectedRequest.quality)?.split("/").pop()}
</a>
</p>
</div>

View File

@@ -2,7 +2,17 @@ import React from 'react';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
const CustomToastContainer = ({ theme = 'light', newestOnTop = false, closeOnClick = true }) => {
interface CustomToastContainerProps {
theme?: 'light' | 'dark' | string;
newestOnTop?: boolean;
closeOnClick?: boolean;
}
const CustomToastContainer: React.FC<CustomToastContainerProps> = ({
theme = 'light',
newestOnTop = false,
closeOnClick = true
}) => {
// Map data-theme values to react-toastify theme
const toastTheme = theme === 'dark' ? 'dark' : 'light';