begin js(x) to ts(x)

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

View File

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

View File

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

View File

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