begin js(x) to ts(x)
This commit is contained in:
@@ -1,7 +1,17 @@
|
||||
import React from "react";
|
||||
|
||||
export default function BreadcrumbNav({ currentPage }) {
|
||||
const pages = [
|
||||
interface BreadcrumbNavProps {
|
||||
currentPage: string;
|
||||
}
|
||||
|
||||
interface PageLink {
|
||||
key: string;
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export default function BreadcrumbNav({ currentPage }: BreadcrumbNavProps): React.ReactElement {
|
||||
const pages: PageLink[] = [
|
||||
{ key: "request", label: "Request Media", href: "/TRip" },
|
||||
{ key: "management", label: "Manage Requests", href: "/TRip/requests" },
|
||||
];
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { useState, useEffect, useRef, Suspense, lazy, useMemo } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import type { Id } from "react-toastify";
|
||||
import { toast } from "react-toastify";
|
||||
import { Button } from "@mui/joy";
|
||||
import { Accordion, AccordionTab } from "primereact/accordion";
|
||||
@@ -8,50 +10,100 @@ import BreadcrumbNav from "./BreadcrumbNav";
|
||||
import { API_URL, ENVIRONMENT } from "@/config";
|
||||
import "./RequestManagement.css";
|
||||
|
||||
interface Artist {
|
||||
id: string | number;
|
||||
name: string;
|
||||
artist?: string;
|
||||
}
|
||||
|
||||
interface Album {
|
||||
id: string | number;
|
||||
title: string;
|
||||
album?: string;
|
||||
release_date?: string;
|
||||
cover_image?: string;
|
||||
cover_small?: string;
|
||||
tracks?: Track[];
|
||||
}
|
||||
|
||||
interface FetchTracksSequentiallyFn {
|
||||
(): Promise<void>;
|
||||
lastCall?: number;
|
||||
}
|
||||
|
||||
interface Track {
|
||||
id: string | number;
|
||||
title: string;
|
||||
artist?: string;
|
||||
duration?: number | string;
|
||||
album_id?: string | number;
|
||||
track_number?: number;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
interface DiskSpaceInfo {
|
||||
total?: number;
|
||||
used?: number;
|
||||
available?: number;
|
||||
percent?: number;
|
||||
usedPercent?: number;
|
||||
availableFormatted?: string;
|
||||
}
|
||||
|
||||
interface AudioProgress {
|
||||
current: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface SearchArtistsFn {
|
||||
(e: { query: string }): void;
|
||||
lastCall?: number;
|
||||
}
|
||||
|
||||
export default function MediaRequestForm() {
|
||||
const [type, setType] = useState("artist");
|
||||
const [selectedArtist, setSelectedArtist] = useState(null);
|
||||
const [selectedArtist, setSelectedArtist] = useState<Artist | null>(null);
|
||||
const [artistInput, setArtistInput] = useState("");
|
||||
const [albumInput, setAlbumInput] = useState("");
|
||||
const [trackInput, setTrackInput] = useState("");
|
||||
const [quality, setQuality] = useState("FLAC"); // default FLAC
|
||||
const [selectedItem, setSelectedItem] = useState(null);
|
||||
const [albums, setAlbums] = useState([]);
|
||||
const [tracksByAlbum, setTracksByAlbum] = useState({});
|
||||
const [selectedTracks, setSelectedTracks] = useState({});
|
||||
const [artistSuggestions, setArtistSuggestions] = useState([]);
|
||||
const [selectedItem, setSelectedItem] = useState<Artist | Album | Track | null>(null);
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [tracksByAlbum, setTracksByAlbum] = useState<Record<string | number, Track[]>>({});
|
||||
const [selectedTracks, setSelectedTracks] = useState<Record<string | number, string[] | null>>({});
|
||||
const [artistSuggestions, setArtistSuggestions] = useState<Artist[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [loadingAlbumId, setLoadingAlbumId] = useState(null);
|
||||
const [expandedAlbums, setExpandedAlbums] = useState([]);
|
||||
const [loadingAlbumId, setLoadingAlbumId] = useState<string | number | null>(null);
|
||||
const [expandedAlbums, setExpandedAlbums] = useState<number[]>([]);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [currentTrackId, setCurrentTrackId] = useState(null);
|
||||
const [currentTrackId, setCurrentTrackId] = useState<string | number | null>(null);
|
||||
const [isAudioPlaying, setIsAudioPlaying] = useState(false);
|
||||
const [audioLoadingTrackId, setAudioLoadingTrackId] = useState(null);
|
||||
const [playbackQueue, setPlaybackQueue] = useState([]);
|
||||
const [queueIndex, setQueueIndex] = useState(null);
|
||||
const [queueAlbumId, setQueueAlbumId] = useState(null);
|
||||
const [albumPlaybackLoadingId, setAlbumPlaybackLoadingId] = useState(null);
|
||||
const [shuffleAlbums, setShuffleAlbums] = useState({});
|
||||
const [audioProgress, setAudioProgress] = useState({ current: 0, duration: 0 });
|
||||
const [diskSpace, setDiskSpace] = useState(null);
|
||||
const [audioLoadingTrackId, setAudioLoadingTrackId] = useState<string | number | null>(null);
|
||||
const [playbackQueue, setPlaybackQueue] = useState<Track[]>([]);
|
||||
const [queueIndex, setQueueIndex] = useState<number | null>(null);
|
||||
const [queueAlbumId, setQueueAlbumId] = useState<string | number | null>(null);
|
||||
const [albumPlaybackLoadingId, setAlbumPlaybackLoadingId] = useState<string | number | null>(null);
|
||||
const [shuffleAlbums, setShuffleAlbums] = useState<Record<string | number, boolean>>({});
|
||||
const [audioProgress, setAudioProgress] = useState<AudioProgress>({ current: 0, duration: 0 });
|
||||
const [diskSpace, setDiskSpace] = useState<DiskSpaceInfo | null>(null);
|
||||
|
||||
const debounceTimeout = useRef(null);
|
||||
const autoCompleteRef = useRef(null);
|
||||
const metadataFetchToastId = useRef(null);
|
||||
const audioRef = useRef(null);
|
||||
const audioSourcesRef = useRef({});
|
||||
const pendingTrackFetchesRef = useRef({});
|
||||
const playbackQueueRef = useRef([]);
|
||||
const queueIndexRef = useRef(null);
|
||||
const queueAlbumIdRef = useRef(null);
|
||||
const albumHeaderRefs = useRef({});
|
||||
const debounceTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const autoCompleteRef = useRef<any>(null);
|
||||
const metadataFetchToastId = useRef<Id | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const audioSourcesRef = useRef<Record<string | number, string>>({});
|
||||
const pendingTrackFetchesRef = useRef<Record<string | number, Promise<string>>>({});
|
||||
const playbackQueueRef = useRef<Track[]>([]);
|
||||
const queueIndexRef = useRef<number | null>(null);
|
||||
const queueAlbumIdRef = useRef<string | number | null>(null);
|
||||
const albumHeaderRefs = useRef<Record<string | number, HTMLElement | null>>({});
|
||||
const suppressHashRef = useRef(false);
|
||||
const lastUrlRef = useRef("");
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // Helper for delays
|
||||
const sanitizeFilename = (text) => (text || "").replace(/[\\/:*?"<>|]/g, "_") || "track";
|
||||
const formatTime = (seconds) => {
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // Helper for delays
|
||||
const sanitizeFilename = (text: string) => (text || "").replace(/[\\/:*?"<>|]/g, "_") || "track";
|
||||
const formatTime = (seconds: number) => {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60)
|
||||
@@ -130,7 +182,7 @@ export default function MediaRequestForm() {
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ShuffleIcon = ({ active }) => (
|
||||
const ShuffleIcon = ({ active }: { active: boolean }) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
role="presentation"
|
||||
@@ -144,7 +196,7 @@ export default function MediaRequestForm() {
|
||||
</svg>
|
||||
);
|
||||
|
||||
const shuffleArray = (arr) => {
|
||||
const shuffleArray = <T,>(arr: T[]): T[] => {
|
||||
const clone = [...arr];
|
||||
for (let i = clone.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
@@ -153,7 +205,7 @@ export default function MediaRequestForm() {
|
||||
return clone;
|
||||
};
|
||||
|
||||
const ensureAlbumExpanded = (albumIndex) => {
|
||||
const ensureAlbumExpanded = (albumIndex: number) => {
|
||||
if (typeof albumIndex !== "number") return;
|
||||
let albumAdded = false;
|
||||
setExpandedAlbums((prev) => {
|
||||
@@ -188,7 +240,7 @@ export default function MediaRequestForm() {
|
||||
setAudioProgress({ current: 0, duration: 0 });
|
||||
};
|
||||
|
||||
const getTrackSource = async (trackId) => {
|
||||
const getTrackSource = async (trackId: string | number): Promise<string> => {
|
||||
if (audioSourcesRef.current[trackId]) {
|
||||
return audioSourcesRef.current[trackId];
|
||||
}
|
||||
@@ -230,8 +282,12 @@ export default function MediaRequestForm() {
|
||||
return pendingTrackFetchesRef.current[trackId];
|
||||
};
|
||||
|
||||
const prefetchTrack = async (track) => {
|
||||
if (!track || audioSourcesRef.current[track.id] || pendingTrackFetchesRef.current[track.id]) {
|
||||
const prefetchTrack = async (track: Track | null) => {
|
||||
if (!track || audioSourcesRef.current[track.id]) {
|
||||
return;
|
||||
}
|
||||
// Check if already being fetched (returns a truthy promise)
|
||||
if (track.id in pendingTrackFetchesRef.current) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -241,7 +297,7 @@ export default function MediaRequestForm() {
|
||||
}
|
||||
};
|
||||
|
||||
const playTrack = async (track, { fromQueue = false } = {}) => {
|
||||
const playTrack = async (track: Track, { fromQueue = false } = {}) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !track) return;
|
||||
|
||||
@@ -272,7 +328,7 @@ export default function MediaRequestForm() {
|
||||
};
|
||||
|
||||
// Fetch artist suggestions for autocomplete
|
||||
const searchArtists = (e) => {
|
||||
const searchArtists: SearchArtistsFn = (e) => {
|
||||
const query = e.query.trim();
|
||||
if (!query) {
|
||||
setArtistSuggestions([]);
|
||||
@@ -307,14 +363,14 @@ export default function MediaRequestForm() {
|
||||
};
|
||||
|
||||
|
||||
const truncate = (text, maxLen) =>
|
||||
const truncate = (text: string, maxLen: number) =>
|
||||
maxLen <= 3
|
||||
? text.slice(0, maxLen)
|
||||
: text.length <= maxLen
|
||||
? text
|
||||
: text.slice(0, maxLen - 3) + '...';
|
||||
|
||||
const selectArtist = (artist) => {
|
||||
const selectArtist = (artist: Artist) => {
|
||||
// artist may be a grouped item or an alternate object
|
||||
const value = artist.artist || artist.name || "";
|
||||
setSelectedArtist(artist);
|
||||
@@ -334,7 +390,7 @@ export default function MediaRequestForm() {
|
||||
|
||||
// If panel still exists, hide it via style (safer than removing the node)
|
||||
setTimeout(() => {
|
||||
const panel = document.querySelector('.p-autocomplete-panel');
|
||||
const panel = document.querySelector('.p-autocomplete-panel') as HTMLElement | null;
|
||||
if (panel) {
|
||||
panel.style.display = 'none';
|
||||
panel.style.opacity = '0';
|
||||
@@ -344,7 +400,7 @@ export default function MediaRequestForm() {
|
||||
}, 10);
|
||||
|
||||
// blur the input element if present
|
||||
const inputEl = ac?.getInput ? ac.getInput() : document.querySelector('.p-autocomplete-input');
|
||||
const inputEl = ac?.getInput ? ac.getInput() : document.querySelector('.p-autocomplete-input') as HTMLInputElement | null;
|
||||
if (inputEl && typeof inputEl.blur === 'function') inputEl.blur();
|
||||
} catch (innerErr) {
|
||||
// Ignore inner errors
|
||||
@@ -357,11 +413,11 @@ export default function MediaRequestForm() {
|
||||
|
||||
const totalAlbums = albums.length;
|
||||
const totalTracks = useMemo(() =>
|
||||
Object.values(tracksByAlbum).reduce((sum, arr) => (Array.isArray(arr) ? sum + arr.length : sum), 0),
|
||||
Object.values(tracksByAlbum).reduce((sum: number, arr) => (Array.isArray(arr) ? sum + arr.length : sum), 0),
|
||||
[tracksByAlbum]
|
||||
);
|
||||
|
||||
const artistItemTemplate = (artist) => {
|
||||
const artistItemTemplate = (artist: Artist & { alternatives?: Artist[]; alternates?: Artist[] }) => {
|
||||
if (!artist) return null;
|
||||
|
||||
const alts = artist.alternatives || artist.alternates || [];
|
||||
@@ -401,13 +457,13 @@ export default function MediaRequestForm() {
|
||||
|
||||
|
||||
// Handle autocomplete input changes (typing/selecting)
|
||||
const handleArtistChange = (e) => {
|
||||
const handleArtistChange = (e: { value: string | Artist }) => {
|
||||
if (typeof e.value === "string") {
|
||||
setArtistInput(e.value);
|
||||
setSelectedArtist(null);
|
||||
} else if (e.value && typeof e.value === "object") {
|
||||
setSelectedArtist(e.value);
|
||||
setArtistInput(e.value.artist);
|
||||
setArtistInput(e.value.artist || e.value.name || "");
|
||||
} else {
|
||||
setArtistInput("");
|
||||
setSelectedArtist(null);
|
||||
@@ -446,7 +502,7 @@ export default function MediaRequestForm() {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedItem(selectedArtist.artist);
|
||||
setSelectedItem({ ...selectedArtist, name: selectedArtist.artist || selectedArtist.name || "" });
|
||||
|
||||
try {
|
||||
const res = await authFetch(
|
||||
@@ -483,7 +539,7 @@ export default function MediaRequestForm() {
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
setSelectedItem(`${artistInput} - ${albumInput}`);
|
||||
setSelectedItem({ id: 0, name: `${artistInput} - ${albumInput}` });
|
||||
setAlbums([]);
|
||||
setTracksByAlbum({});
|
||||
setSelectedTracks({});
|
||||
@@ -493,7 +549,7 @@ export default function MediaRequestForm() {
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
setSelectedItem(`${artistInput} - ${trackInput}`);
|
||||
setSelectedItem({ id: 0, name: `${artistInput} - ${trackInput}` });
|
||||
setAlbums([]);
|
||||
setTracksByAlbum({});
|
||||
setSelectedTracks({});
|
||||
@@ -501,7 +557,7 @@ export default function MediaRequestForm() {
|
||||
setIsSearching(false);
|
||||
};
|
||||
|
||||
const handleTrackPlayPause = async (track, albumId = null, albumIndex = null) => {
|
||||
const handleTrackPlayPause = async (track: Track, albumId: string | number | null = null, albumIndex: number | null = null) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
@@ -609,9 +665,9 @@ export default function MediaRequestForm() {
|
||||
|
||||
const blob = await fileResponse.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const artistName = track.artist || selectedArtist?.artist || "Unknown Artist";
|
||||
const artistName = track.artist || selectedArtist?.artist || selectedArtist?.name || "Unknown Artist";
|
||||
const urlPath = new URL(data.stream_url).pathname;
|
||||
const extension = urlPath.split(".").pop().split("?")[0] || "flac";
|
||||
const extension = urlPath.split(".").pop()?.split("?")[0] || "flac";
|
||||
const filename = `${sanitizeFilename(artistName)} - ${sanitizeFilename(track.title)}.${extension}`;
|
||||
|
||||
const link = document.createElement("a");
|
||||
@@ -791,7 +847,7 @@ export default function MediaRequestForm() {
|
||||
const albumsToFetch = albums.filter((a) => !tracksByAlbum[a.id]);
|
||||
if (albumsToFetch.length === 0) return;
|
||||
|
||||
const fetchTracksSequentially = async () => {
|
||||
const fetchTracksSequentially: FetchTracksSequentiallyFn = async () => {
|
||||
const minDelay = 650; // ms between API requests
|
||||
setIsFetching(true);
|
||||
|
||||
@@ -829,22 +885,26 @@ export default function MediaRequestForm() {
|
||||
}
|
||||
|
||||
// Update progress toast
|
||||
toast.update(metadataFetchToastId.current, {
|
||||
progress: (index + 1) / totalAlbums,
|
||||
render: `Retrieving metadata... (${index + 1} / ${totalAlbums})`,
|
||||
});
|
||||
if (metadataFetchToastId.current !== null) {
|
||||
toast.update(metadataFetchToastId.current, {
|
||||
progress: (index + 1) / totalAlbums,
|
||||
render: `Retrieving metadata... (${index + 1} / ${totalAlbums})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setLoadingAlbumId(null);
|
||||
setIsFetching(false);
|
||||
|
||||
// Finish the toast
|
||||
toast.update(metadataFetchToastId.current, {
|
||||
render: "Metadata retrieved!",
|
||||
type: "success",
|
||||
progress: 1,
|
||||
autoClose: 1500,
|
||||
});
|
||||
if (metadataFetchToastId.current !== null) {
|
||||
toast.update(metadataFetchToastId.current, {
|
||||
render: "Metadata retrieved!",
|
||||
type: "success",
|
||||
progress: 1,
|
||||
autoClose: 1500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
fetchTracksSequentially();
|
||||
@@ -857,7 +917,7 @@ export default function MediaRequestForm() {
|
||||
|
||||
|
||||
// Toggle individual track checkbox
|
||||
const toggleTrack = (albumId, trackId) => {
|
||||
const toggleTrack = (albumId: string | number, trackId: string | number) => {
|
||||
setSelectedTracks((prev) => {
|
||||
const current = new Set(prev[albumId] || []);
|
||||
if (current.has(String(trackId))) current.delete(String(trackId));
|
||||
@@ -867,11 +927,11 @@ export default function MediaRequestForm() {
|
||||
};
|
||||
|
||||
// Toggle album checkbox (select/deselect all tracks in album)
|
||||
const toggleAlbum = (albumId) => {
|
||||
const toggleAlbum = (albumId: string | number) => {
|
||||
const allTracks = tracksByAlbum[albumId]?.map((t) => String(t.id)) || [];
|
||||
setSelectedTracks((prev) => {
|
||||
const current = prev[albumId] || [];
|
||||
const allSelected = current.length === allTracks.length;
|
||||
const allSelected = current?.length === allTracks.length;
|
||||
return {
|
||||
...prev,
|
||||
[albumId]: allSelected ? [] : [...allTracks],
|
||||
@@ -883,12 +943,12 @@ export default function MediaRequestForm() {
|
||||
const attachScrollFix = () => {
|
||||
setTimeout(() => {
|
||||
const panel = document.querySelector(".p-autocomplete-panel");
|
||||
const items = panel?.querySelector(".p-autocomplete-items");
|
||||
const items = panel?.querySelector(".p-autocomplete-items") as HTMLElement | null;
|
||||
if (items) {
|
||||
items.style.maxHeight = "200px";
|
||||
items.style.overflowY = "auto";
|
||||
items.style.overscrollBehavior = "contain";
|
||||
const wheelHandler = (e) => {
|
||||
const wheelHandler = (e: WheelEvent) => {
|
||||
const delta = e.deltaY;
|
||||
const atTop = items.scrollTop === 0;
|
||||
const atBottom = items.scrollTop + items.clientHeight >= items.scrollHeight;
|
||||
@@ -923,7 +983,7 @@ export default function MediaRequestForm() {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
track_ids: allSelectedIds,
|
||||
target: selectedArtist.artist,
|
||||
target: selectedArtist?.artist || selectedArtist?.name || "",
|
||||
quality: quality,
|
||||
}),
|
||||
});
|
||||
@@ -1018,7 +1078,7 @@ export default function MediaRequestForm() {
|
||||
<BreadcrumbNav currentPage="request" />
|
||||
|
||||
{/* Disk Space Indicator - always visible */}
|
||||
{diskSpace && (
|
||||
{diskSpace && diskSpace.usedPercent !== undefined && (
|
||||
<div className="mb-4 flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2 1 3 3 3h10c2 0 3-1 3-3V7c0-2-1-3-3-3H7c-2 0-3 1-3 3z" />
|
||||
@@ -1047,7 +1107,7 @@ export default function MediaRequestForm() {
|
||||
<div className="flex flex-col gap-4">
|
||||
<label htmlFor="artistInput">Artist: </label>
|
||||
<AutoComplete
|
||||
id={artistInput}
|
||||
id="artistInput"
|
||||
ref={autoCompleteRef}
|
||||
value={selectedArtist || artistInput}
|
||||
suggestions={artistSuggestions}
|
||||
@@ -1125,7 +1185,7 @@ export default function MediaRequestForm() {
|
||||
multiple
|
||||
className="mt-4"
|
||||
activeIndex={expandedAlbums}
|
||||
onTabChange={(e) => setExpandedAlbums(e.index)}
|
||||
onTabChange={(e) => setExpandedAlbums(Array.isArray(e.index) ? e.index : [e.index])}
|
||||
>
|
||||
{albums.map(({ album, id, release_date }, albumIndex) => {
|
||||
const allTracks = tracksByAlbum[id] || [];
|
||||
@@ -1193,11 +1253,11 @@ export default function MediaRequestForm() {
|
||||
<ShuffleIcon active={!!shuffleAlbums[id]} />
|
||||
</button>
|
||||
</div>
|
||||
<span className="flex items-center" title={album}>
|
||||
{truncate(album, 32)}
|
||||
<span className="flex items-center" title={album || ''}>
|
||||
{truncate(album || '', 32)}
|
||||
{loadingAlbumId === id && <Spinner />}
|
||||
</span>
|
||||
<small className="ml-2 text-neutral-500 dark:text-neutral-400">({release_date})</small>
|
||||
<small className="ml-2 text-neutral-500 dark:text-neutral-400">({release_date || 'Unknown'})</small>
|
||||
<span className="ml-0 w-full text-xs text-neutral-500 sm:ml-auto sm:w-auto">
|
||||
{typeof tracksByAlbum[id] === 'undefined' ? (
|
||||
loadingAlbumId === id ? 'Loading...' : '...'
|
||||
@@ -1277,7 +1337,6 @@ export default function MediaRequestForm() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTrackDownload(track)}
|
||||
referrerPolicy="no-referrer"
|
||||
className="text-neutral-500 hover:text-blue-600 underline whitespace-nowrap cursor-pointer"
|
||||
aria-label={`Download ${track.title}`}
|
||||
>
|
||||
@@ -11,22 +11,36 @@ import BreadcrumbNav from "./BreadcrumbNav";
|
||||
import { API_URL } from "@/config";
|
||||
import "./RequestManagement.css";
|
||||
|
||||
interface RequestJob {
|
||||
id: string | number;
|
||||
target: string;
|
||||
tracks: number;
|
||||
quality: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
type?: string;
|
||||
tarball_path?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = ["Queued", "Started", "Compressing", "Finished", "Failed"];
|
||||
const TAR_BASE_URL = "https://codey.lol/m/m2"; // configurable prefix
|
||||
|
||||
export default function RequestManagement() {
|
||||
const [requests, setRequests] = useState([]);
|
||||
const [filterType, setFilterType] = useState(null);
|
||||
const [filterStatus, setFilterStatus] = useState(null);
|
||||
const [filteredRequests, setFilteredRequests] = useState([]);
|
||||
const [selectedRequest, setSelectedRequest] = useState(null);
|
||||
const [requests, setRequests] = useState<RequestJob[]>([]);
|
||||
const [filterType, setFilterType] = useState<string | null>(null);
|
||||
const [filterStatus, setFilterStatus] = useState<string | null>(null);
|
||||
const [filteredRequests, setFilteredRequests] = useState<RequestJob[]>([]);
|
||||
const [selectedRequest, setSelectedRequest] = useState<RequestJob | null>(null);
|
||||
const [isDialogVisible, setIsDialogVisible] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const pollingRef = useRef(null);
|
||||
const pollingDetailRef = useRef(null);
|
||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const pollingDetailRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
|
||||
const tarballUrl = (absPath, quality) => {
|
||||
const tarballUrl = (absPath: string | undefined, quality: string) => {
|
||||
if (!absPath) return null;
|
||||
const filename = absPath.split("/").pop(); // get "SOMETHING.tar.gz"
|
||||
return `${TAR_BASE_URL}/${quality}/${filename}`;
|
||||
@@ -37,7 +51,7 @@ export default function RequestManagement() {
|
||||
if (showLoading) setIsLoading(true);
|
||||
const res = await authFetch(`${API_URL}/trip/jobs/list`);
|
||||
if (!res.ok) throw new Error("Failed to fetch jobs");
|
||||
const data = await res.json();
|
||||
const data = await res.json() as { jobs?: RequestJob[] };
|
||||
setRequests(Array.isArray(data.jobs) ? data.jobs : []);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -51,11 +65,11 @@ export default function RequestManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchJobDetail = async (jobId) => {
|
||||
const fetchJobDetail = async (jobId: string | number): Promise<RequestJob | null> => {
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/trip/job/${jobId}`);
|
||||
if (!res.ok) throw new Error("Failed to fetch job details");
|
||||
return await res.json();
|
||||
return await res.json() as RequestJob;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (!toast.isActive('fetch-job-fail-toast')) {
|
||||
@@ -108,7 +122,7 @@ export default function RequestManagement() {
|
||||
}, [filterType, filterStatus, requests]);
|
||||
|
||||
|
||||
const getStatusColorClass = (status) => {
|
||||
const getStatusColorClass = (status: string) => {
|
||||
switch (status) {
|
||||
case "Queued": return "bg-yellow-700 text-white";
|
||||
case "Started": return "bg-blue-700 text-white";
|
||||
@@ -119,7 +133,7 @@ export default function RequestManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
const getQualityColorClass = (quality) => {
|
||||
const getQualityColorClass = (quality: string) => {
|
||||
switch (quality) {
|
||||
case "FLAC": return "bg-green-700 text-white";
|
||||
case "Lossy": return "bg-yellow-700 text-white";
|
||||
@@ -128,25 +142,25 @@ export default function RequestManagement() {
|
||||
};
|
||||
|
||||
|
||||
const statusBodyTemplate = (rowData) => (
|
||||
const statusBodyTemplate = (rowData: RequestJob) => (
|
||||
<span className={`inline-flex items-center justify-center min-w-[90px] px-3 py-1 rounded-full font-semibold text-xs ${getStatusColorClass(rowData.status)}`}>
|
||||
{rowData.status}
|
||||
</span>
|
||||
);
|
||||
|
||||
const qualityBodyTemplate = (rowData) => (
|
||||
const qualityBodyTemplate = (rowData: RequestJob) => (
|
||||
<span className={`inline-flex items-center justify-center min-w-[50px] px-3 py-1 rounded-full font-semibold text-xs ${getQualityColorClass(rowData.quality)}`}>
|
||||
{rowData.quality}
|
||||
</span>
|
||||
);
|
||||
|
||||
|
||||
const safeText = (val) => (val === 0 ? "0" : val || "—");
|
||||
const textWithEllipsis = (val, width = "12rem") => (
|
||||
const safeText = (val: unknown) => (val === 0 ? "0" : val || "—");
|
||||
const textWithEllipsis = (val: string | undefined | null, width = "12rem") => (
|
||||
<span className="truncate block" style={{ maxWidth: width }} title={val || ""}>{val || "—"}</span>
|
||||
);
|
||||
|
||||
const truncate = (text, maxLen) =>
|
||||
const truncate = (text: string, maxLen: number) =>
|
||||
maxLen <= 3
|
||||
? text.slice(0, maxLen)
|
||||
: text.length <= maxLen
|
||||
@@ -154,9 +168,9 @@ export default function RequestManagement() {
|
||||
: text.slice(0, maxLen - 3) + '...';
|
||||
|
||||
|
||||
const basename = (p) => (typeof p === "string" ? p.split("/").pop() : "");
|
||||
const basename = (p: string | undefined) => (typeof p === "string" ? p.split("/").pop() : "");
|
||||
|
||||
const formatProgress = (p) => {
|
||||
const formatProgress = (p: unknown) => {
|
||||
if (p === null || p === undefined || p === "") return "—";
|
||||
const num = Number(p);
|
||||
if (Number.isNaN(num)) return "—";
|
||||
@@ -164,16 +178,16 @@ export default function RequestManagement() {
|
||||
return `${pct}%`;
|
||||
};
|
||||
|
||||
const computePct = (p) => {
|
||||
const computePct = (p: unknown) => {
|
||||
if (p === null || p === undefined || p === "") return 0;
|
||||
const num = Number(p);
|
||||
if (Number.isNaN(num)) return 0;
|
||||
return Math.min(100, Math.max(0, num > 1 ? Math.round(num) : Math.round(num * 100)));
|
||||
};
|
||||
|
||||
const progressBarTemplate = (rowData) => {
|
||||
const progressBarTemplate = (rowData: RequestJob) => {
|
||||
const p = rowData.progress;
|
||||
if (p === null || p === undefined || p === "") return "—";
|
||||
if (p === null || p === undefined || p === 0) return "—";
|
||||
const num = Number(p);
|
||||
if (Number.isNaN(num)) return "—";
|
||||
const pct = computePct(p);
|
||||
@@ -192,7 +206,8 @@ export default function RequestManagement() {
|
||||
<div
|
||||
className={`rm-progress-fill ${getProgressColor()}`}
|
||||
style={{
|
||||
'--rm-progress': (pct / 100).toString(),
|
||||
// CSS custom property for progress animation
|
||||
['--rm-progress' as string]: (pct / 100).toString(),
|
||||
borderTopRightRadius: pct === 100 ? '999px' : 0,
|
||||
borderBottomRightRadius: pct === 100 ? '999px' : 0
|
||||
}}
|
||||
@@ -207,7 +222,7 @@ export default function RequestManagement() {
|
||||
);
|
||||
};
|
||||
|
||||
const confirmDelete = (requestId) => {
|
||||
const confirmDelete = (requestId: string | number) => {
|
||||
confirmDialog({
|
||||
message: "Are you sure you want to delete this request?",
|
||||
header: "Confirm Delete",
|
||||
@@ -216,12 +231,12 @@ export default function RequestManagement() {
|
||||
});
|
||||
};
|
||||
|
||||
const deleteRequest = (requestId) => {
|
||||
const deleteRequest = (requestId: string | number) => {
|
||||
setRequests((prev) => prev.filter((r) => r.id !== requestId));
|
||||
toast.success("Request deleted");
|
||||
};
|
||||
|
||||
const actionBodyTemplate = (rowData) => (
|
||||
const actionBodyTemplate = (rowData: RequestJob) => (
|
||||
<Button
|
||||
color="neutral"
|
||||
variant="outlined"
|
||||
@@ -237,8 +252,9 @@ export default function RequestManagement() {
|
||||
</Button>
|
||||
);
|
||||
|
||||
const handleRowClick = async (e) => {
|
||||
const detail = await fetchJobDetail(e.data.id);
|
||||
const handleRowClick = async (e: { data: unknown }) => {
|
||||
const rowData = e.data as RequestJob;
|
||||
const detail = await fetchJobDetail(rowData.id);
|
||||
if (detail) { setSelectedRequest(detail); setIsDialogVisible(true); }
|
||||
};
|
||||
|
||||
@@ -300,14 +316,14 @@ export default function RequestManagement() {
|
||||
<Column
|
||||
field="id"
|
||||
header="ID"
|
||||
body={(row) => (
|
||||
<span title={row.id}>
|
||||
{row.id.split("-").slice(-1)[0]}
|
||||
body={(row: RequestJob) => (
|
||||
<span title={String(row.id)}>
|
||||
{String(row.id).split("-").slice(-1)[0]}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<Column field="target" header="Target" sortable body={(row) => textWithEllipsis(row.target, "100%")} />
|
||||
<Column field="tracks" header="# Tracks" body={(row) => row.tracks} />
|
||||
<Column field="target" header="Target" sortable body={(row: RequestJob) => textWithEllipsis(row.target, "100%")} />
|
||||
<Column field="tracks" header="# Tracks" body={(row: RequestJob) => row.tracks} />
|
||||
<Column field="status" header="Status" body={statusBodyTemplate} style={{ textAlign: "center" }} sortable />
|
||||
<Column field="progress" header="Progress" body={progressBarTemplate} style={{ textAlign: "center" }} sortable />
|
||||
<Column
|
||||
@@ -324,12 +340,12 @@ export default function RequestManagement() {
|
||||
Tarball
|
||||
</span>
|
||||
}
|
||||
body={(row) => {
|
||||
const url = tarballUrl(row.tarball, row.quality || "FLAC");
|
||||
const encodedURL = encodeURI(url);
|
||||
body={(row: RequestJob) => {
|
||||
const url = tarballUrl(row.tarball_path, row.quality || "FLAC");
|
||||
if (!url) return "—";
|
||||
const encodedURL = encodeURI(url);
|
||||
|
||||
const fileName = url.split("/").pop();
|
||||
const fileName = url.split("/").pop() || "";
|
||||
|
||||
return (
|
||||
<a
|
||||
@@ -365,7 +381,7 @@ export default function RequestManagement() {
|
||||
|
||||
{/* --- Metadata Card --- */}
|
||||
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{selectedRequest.id && <p className="col-span-2 break-all"><strong>ID:</strong> {selectedRequest.id}</p>}
|
||||
{selectedRequest.id && <p className="col-span-2 break-all"><strong>ID:</strong> {String(selectedRequest.id)}</p>}
|
||||
{selectedRequest.target && <p><strong>Target:</strong> {selectedRequest.target}</p>}
|
||||
{selectedRequest.tracks && <p><strong># Tracks:</strong> {selectedRequest.tracks}</p>}
|
||||
{selectedRequest.quality && (
|
||||
@@ -396,7 +412,7 @@ export default function RequestManagement() {
|
||||
<div
|
||||
className={`rm-progress-fill ${selectedRequest.status === "Failed" ? "bg-red-500" : selectedRequest.status === "Finished" ? "bg-green-500" : "bg-blue-500"}`}
|
||||
style={{
|
||||
'--rm-progress': (computePct(selectedRequest.progress) / 100).toString(),
|
||||
['--rm-progress' as string]: (computePct(selectedRequest.progress) / 100).toString(),
|
||||
borderTopRightRadius: computePct(selectedRequest.progress) >= 100 ? '999px' : 0,
|
||||
borderBottomRightRadius: computePct(selectedRequest.progress) >= 100 ? '999px' : 0
|
||||
}}
|
||||
@@ -414,24 +430,24 @@ export default function RequestManagement() {
|
||||
|
||||
{/* --- Timestamps Card --- */}
|
||||
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-1 gap-2">
|
||||
{selectedRequest.enqueued_at && <p><strong>Enqueued:</strong> {new Date(selectedRequest.enqueued_at).toLocaleString()}</p>}
|
||||
{selectedRequest.started_at && <p><strong>Started:</strong> {new Date(selectedRequest.started_at).toLocaleString()}</p>}
|
||||
{selectedRequest.ended_at && <p><strong>Ended:</strong> {new Date(selectedRequest.ended_at).toLocaleString()}</p>}
|
||||
{selectedRequest.created_at && <p><strong>Enqueued:</strong> {new Date(selectedRequest.created_at).toLocaleString()}</p>}
|
||||
{(selectedRequest as RequestJob & { started_at?: string }).started_at && <p><strong>Started:</strong> {new Date((selectedRequest as RequestJob & { started_at: string }).started_at).toLocaleString()}</p>}
|
||||
{(selectedRequest as RequestJob & { ended_at?: string }).ended_at && <p><strong>Ended:</strong> {new Date((selectedRequest as RequestJob & { ended_at: string }).ended_at).toLocaleString()}</p>}
|
||||
</div>
|
||||
|
||||
{/* --- Tarball Card --- */}
|
||||
{
|
||||
selectedRequest.tarball && (
|
||||
selectedRequest.tarball_path && (
|
||||
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md">
|
||||
<p>
|
||||
<strong>Tarball:</strong>{" "}
|
||||
<a
|
||||
href={encodeURI(tarballUrl(selectedRequest.tarball, selectedRequest.quality))}
|
||||
href={encodeURI(tarballUrl(selectedRequest.tarball_path, selectedRequest.quality) || "")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
{tarballUrl(selectedRequest.tarball, selectedRequest.quality).split("/").pop()}
|
||||
{tarballUrl(selectedRequest.tarball_path, selectedRequest.quality)?.split("/").pop()}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
Reference in New Issue
Block a user