begin js(x) to ts(x)
This commit is contained in:
@@ -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
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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(/<@&(\d+)>/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] }));
|
||||
}, []);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }}>
|
||||
@@ -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 {
|
||||
19
src/components/RadioBanner.tsx
Normal file
19
src/components/RadioBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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`, {
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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}`}
|
||||
>
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user