diff --git a/src/assets/scripts/lenisSmoothScroll.js b/src/assets/scripts/lenisSmoothScroll.ts similarity index 100% rename from src/assets/scripts/lenisSmoothScroll.js rename to src/assets/scripts/lenisSmoothScroll.ts diff --git a/src/assets/scripts/main.jsx b/src/assets/scripts/main.tsx similarity index 100% rename from src/assets/scripts/main.jsx rename to src/assets/scripts/main.tsx diff --git a/src/components/AppLayout.jsx b/src/components/AppLayout.tsx similarity index 90% rename from src/components/AppLayout.jsx rename to src/components/AppLayout.tsx index bd299a4..705f314 100644 --- a/src/components/AppLayout.jsx +++ b/src/components/AppLayout.tsx @@ -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 | 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 diff --git a/src/components/AudioPlayer.jsx b/src/components/AudioPlayer.tsx similarity index 94% rename from src/components/AudioPlayer.jsx rename to src/components/AudioPlayer.tsx index c5f11bb..6929de0 100644 --- a/src/components/AudioPlayer.jsx +++ b/src/components/AudioPlayer.tsx @@ -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([]); 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(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 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); 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" diff --git a/src/components/Components.jsx b/src/components/Components.tsx similarity index 58% rename from src/components/Components.jsx rename to src/components/Components.tsx index c41d2e4..8fdcbe0 100644 --- a/src/components/Components.jsx +++ b/src/components/Components.tsx @@ -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(null); if (!cache.current) { cache.current = createCache({ key: "joy" }); } React.useEffect(() => { const current = cache.current; - return () => current.sheet.flush(); + return () => current?.sheet.flush(); }, []); return ( - + {children} ); diff --git a/src/components/DiscordLogs.jsx b/src/components/DiscordLogs.tsx similarity index 93% rename from src/components/DiscordLogs.jsx rename to src/components/DiscordLogs.tsx index e27f4b1..0522c3e 100644 --- a/src/components/DiscordLogs.jsx +++ b/src/components/DiscordLogs.tsx @@ -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 | Record; +} + +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; + usersMap?: Record; + rolesMap?: Map | Record; + emojiCache?: Record; + onChannelClick?: (channelId: string) => void; +} + +interface MessageProps { + message: DiscordMessage; + isFirstInGroup: boolean; + showTimestamp: boolean; + previewCache: Map; + onPreviewLoad: (url: string, preview: LinkPreviewData) => void; + channelMap: Map; + usersMap: Record; + emojiCache: Record; + 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, 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).get === 'function') { + role = (rolesMap as Map).get(roleId); + } else if ((rolesMap as Record)[roleId]) { + role = (rolesMap as Record)[roleId]; + } else if ((rolesMap as Record)[String(roleId)]) { + role = (rolesMap as Record)[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(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'} /> ); @@ -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(null); + const animationRef = useRef(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({
- + {pollAuthor?.displayName || pollAuthor?.username || 'Unknown'} ’s poll {pollTitle} has closed @@ -1380,7 +1659,7 @@ const DiscordMessage = memo(function DiscordMessage({ {getSystemIcon()}
- + {displayAuthor?.displayName || displayAuthor?.username || 'Unknown'} {' '} @@ -1478,7 +1757,7 @@ const DiscordMessage = memo(function DiscordMessage({ )} {displayAuthor?.displayName || displayAuthor?.username || 'Unknown User'} @@ -1566,35 +1845,35 @@ const DiscordMessage = memo(function DiscordMessage({
{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 ( -
onPollVoterClick?.(e, answer)} style={{ cursor: answer.voters?.length ? 'pointer' : 'default' }}> +
onPollVoterClick?.(e, answer)} style={{ cursor: answer.voters?.length ? 'pointer' : 'default' }}>
- {answer.emoji && ( - answer.emoji.id ? ( + {(answer.emoji ?? answer.poll_media?.emoji) && ( + (answer.emoji ?? answer.poll_media?.emoji)?.id ? ( {answer.emoji.name} ) : ( - {answer.emoji.name} + {(answer.emoji ?? answer.poll_media?.emoji)?.name} ) )} - {answer.text} + {answer.text ?? answer.poll_media?.text} - {answer.voteCount} ({percentage}%) + {answer.voteCount ?? 0} ({percentage}%)
- {answer.voters?.length > 0 && ( + {(answer.voters?.length ?? 0) > 0 && (
- {answer.voters.slice(0, 10).map((voter) => ( + {answer.voters!.slice(0, 10).map((voter) => (
))} - {answer.voters.length > 10 && ( + {(answer.voters?.length ?? 0) > 10 && ( - +{answer.voters.length - 10} + +{(answer.voters?.length ?? 0) - 10} )}
@@ -1675,9 +1954,9 @@ const DiscordMessage = memo(function DiscordMessage({ /> )} {/* Embed fields */} - {embed.fields?.length > 0 && ( + {(embed.fields?.length ?? 0) > 0 && (
- {embed.fields.map((field, fieldIdx) => ( + {embed.fields!.map((field, fieldIdx) => (
e.target.style.display = 'none'} + onError={(e) => (e.target as HTMLElement).style.display = 'none'} /> )}
@@ -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(null); + const pendingTargetMessageRef = useRef(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([]); + 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 [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(null); const [searchQuery, setSearchQuery] = useState(''); - const [searchResults, setSearchResults] = useState(null); // Server-side search results + const [searchResults, setSearchResults] = useState(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>({}); + 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 [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>({}); + const handleCategoryToggle = useCallback((catName: string) => { setCollapsedCategories(prev => ({ ...prev, [catName]: !prev[catName] })); }, []); diff --git a/src/components/Lighting.jsx b/src/components/Lighting.tsx similarity index 93% rename from src/components/Lighting.jsx rename to src/components/Lighting.tsx index 6c507bf..386bc7c 100644 --- a/src/components/Lighting.jsx +++ b/src/components/Lighting.tsx @@ -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({ 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 | null>(null); useEffect(() => { authFetch(`${API_URL}/lighting/state`) @@ -127,22 +135,22 @@ export default function Lighting() { overflow: 'hidden' }}> { 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 }); }} diff --git a/src/components/Login.jsx b/src/components/Login.tsx similarity index 93% rename from src/components/Login.jsx rename to src/components/Login.tsx index 7929008..1f934b0 100644 --- a/src/components/Login.jsx +++ b/src/components/Login.tsx @@ -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(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + + const passwordRef = useRef(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): Promise { e.preventDefault(); setLoading(true); diff --git a/src/components/LyricSearch.jsx b/src/components/LyricSearch.tsx similarity index 88% rename from src/components/LyricSearch.jsx rename to src/components/LyricSearch.tsx index 48df126..29fbe1a 100644 --- a/src/components/LyricSearch.jsx +++ b/src/components/LyricSearch.tsx @@ -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([]); const [isLoading, setIsLoading] = useState(false); - const [excludedSources, setExcludedSources] = useState([]); - const [lyricsResult, setLyricsResult] = useState(null); + const [excludedSources, setExcludedSources] = useState([]); + const [lyricsResult, setLyricsResult] = useState(null); const [textSize, setTextSize] = useState("normal"); const [inputStatus, setInputStatus] = useState("hint"); - const [youtubeVideo, setYoutubeVideo] = useState(null); // { video_id, title, channel, duration } + const [youtubeVideo, setYoutubeVideo] = useState(null); const [youtubeLoading, setYoutubeLoading] = useState(false); - const [highlightedVerse, setHighlightedVerse] = useState(null); + const [highlightedVerse, setHighlightedVerse] = useState(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(null); + const autoCompleteRef = useRef(null); + const autoCompleteInputRef = useRef(null); + const searchButtonRef = useRef(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(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; 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 (
diff --git a/src/components/Memes.jsx b/src/components/Memes.tsx similarity index 87% rename from src/components/Memes.jsx rename to src/components/Memes.tsx index 55a915a..f7ca60b 100644 --- a/src/components/Memes.jsx +++ b/src/components/Memes.tsx @@ -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; + items: MemeImage[]; +} + +const Memes: React.FC = () => { + 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(null); + const touchStartRef = useRef(null); + const touchEndRef = useRef(null); + const theme = document.documentElement.getAttribute("data-theme") + const cacheRef = useRef({ 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 => { if (loading || !hasMore || cacheRef.current.pagesLoaded.has(pageNum)) return; setLoading(true); try { diff --git a/src/components/RadioBanner.tsx b/src/components/RadioBanner.tsx new file mode 100644 index 0000000..d503af7 --- /dev/null +++ b/src/components/RadioBanner.tsx @@ -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 ( +
+

Radio

+ } + > + Maintenance in progress. Please check back soon! + +
+ ); +} diff --git a/src/components/RandomMsg.jsx b/src/components/RandomMsg.tsx similarity index 88% rename from src/components/RandomMsg.jsx rename to src/components/RandomMsg.tsx index d89a0ad..13bb8d2 100644 --- a/src/components/RandomMsg.jsx +++ b/src/components/RandomMsg.tsx @@ -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(""); + const [responseTime, setResponseTime] = useState(null); + const [showResponseTime, setShowResponseTime] = useState(false); + + const getRandomMsg = async (): Promise => { try { const start = performance.now(); const response = await fetch(`${API_URL}/randmsg`, { diff --git a/src/components/SubsiteAppLayout.jsx b/src/components/SubsiteAppLayout.tsx similarity index 79% rename from src/components/SubsiteAppLayout.jsx rename to src/components/SubsiteAppLayout.tsx index 10fe9d3..43b248f 100644 --- a/src/components/SubsiteAppLayout.jsx +++ b/src/components/SubsiteAppLayout.tsx @@ -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(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; } diff --git a/src/components/TRip/BreadcrumbNav.jsx b/src/components/TRip/BreadcrumbNav.tsx similarity index 84% rename from src/components/TRip/BreadcrumbNav.jsx rename to src/components/TRip/BreadcrumbNav.tsx index 83106d1..e2778ea 100644 --- a/src/components/TRip/BreadcrumbNav.jsx +++ b/src/components/TRip/BreadcrumbNav.tsx @@ -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" }, ]; diff --git a/src/components/TRip/MediaRequestForm.jsx b/src/components/TRip/MediaRequestForm.tsx similarity index 90% rename from src/components/TRip/MediaRequestForm.jsx rename to src/components/TRip/MediaRequestForm.tsx index d1bbcaf..aac50a3 100644 --- a/src/components/TRip/MediaRequestForm.jsx +++ b/src/components/TRip/MediaRequestForm.tsx @@ -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; + 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(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(null); + const [albums, setAlbums] = useState([]); + const [tracksByAlbum, setTracksByAlbum] = useState>({}); + const [selectedTracks, setSelectedTracks] = useState>({}); + const [artistSuggestions, setArtistSuggestions] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [isSearching, setIsSearching] = useState(false); - const [loadingAlbumId, setLoadingAlbumId] = useState(null); - const [expandedAlbums, setExpandedAlbums] = useState([]); + const [loadingAlbumId, setLoadingAlbumId] = useState(null); + const [expandedAlbums, setExpandedAlbums] = useState([]); const [isFetching, setIsFetching] = useState(false); - const [currentTrackId, setCurrentTrackId] = useState(null); + const [currentTrackId, setCurrentTrackId] = useState(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(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 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 | null>(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 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() { ); - const ShuffleIcon = ({ active }) => ( + const ShuffleIcon = ({ active }: { active: boolean }) => ( ); - const shuffleArray = (arr) => { + const shuffleArray = (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 => { 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() { {/* Disk Space Indicator - always visible */} - {diskSpace && ( + {diskSpace && diskSpace.usedPercent !== undefined && (
@@ -1047,7 +1107,7 @@ export default function MediaRequestForm() {
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() {
- - {truncate(album, 32)} + + {truncate(album || '', 32)} {loadingAlbumId === id && } - ({release_date}) + ({release_date || 'Unknown'}) {typeof tracksByAlbum[id] === 'undefined' ? ( loadingAlbumId === id ? 'Loading...' : '...' @@ -1277,7 +1337,6 @@ export default function MediaRequestForm() {