Files
codey.lol/src/components/TRip/MediaRequestForm.jsx
codey d671fbc130 feat: Add user display in navigation
misc: styling/css cleanup
2025-11-26 10:43:34 -05:00

765 lines
34 KiB
JavaScript

import React, { useState, useEffect, useRef, Suspense, lazy, useMemo } from "react";
import { toast } from "react-toastify";
import { Button } from "@mui/joy";
import { Accordion, AccordionTab } from "primereact/accordion";
import { AutoComplete } from "primereact/autocomplete";
import { authFetch } from "@/utils/authFetch";
import BreadcrumbNav from "./BreadcrumbNav";
import { API_URL, ENVIRONMENT } from "@/config";
import "./MediaRequestForm.css";
export default function MediaRequestForm() {
const [type, setType] = useState("artist");
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 [isSubmitting, setIsSubmitting] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [loadingAlbumId, setLoadingAlbumId] = useState(null);
const [expandedAlbums, setExpandedAlbums] = useState([]);
const [isFetching, setIsFetching] = useState(false);
const debounceTimeout = useRef(null);
const autoCompleteRef = useRef(null);
const metadataFetchToastId = useRef(null);
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // Helper for delays
const Spinner = () => (
<span
className="inline-block ml-2 h-4 w-4 border-2 border-t-2 border-gray-300 border-t-blue-600 rounded-full animate-spin"
aria-label="Loading"
/>
);
// Fetch artist suggestions for autocomplete
const searchArtists = (e) => {
const query = e.query.trim();
if (!query) {
setArtistSuggestions([]);
setSelectedArtist(null);
return;
}
if (debounceTimeout.current) clearTimeout(debounceTimeout.current);
debounceTimeout.current = setTimeout(async () => {
try {
// Ensure at least 600ms between actual requests
const now = Date.now();
if (!searchArtists.lastCall) searchArtists.lastCall = 0;
const elapsed = now - searchArtists.lastCall;
const minDelay = 600; // ms
if (elapsed < minDelay) await delay(minDelay - elapsed);
searchArtists.lastCall = Date.now();
const res = await authFetch(
`${API_URL}/trip/get_artists_by_name?artist=${encodeURIComponent(query)}&group=true`
);
if (!res.ok) throw new Error("API error");
const data = await res.json();
setArtistSuggestions(data);
} catch (err) {
toast.error("Failed to fetch artist suggestions.");
setArtistSuggestions([]);
}
}, 500); // debounce 500ms
};
const truncate = (text, maxLen) =>
maxLen <= 3
? text.slice(0, maxLen)
: text.length <= maxLen
? text
: text.slice(0, maxLen - 3) + '...';
const selectArtist = (artist) => {
// artist may be a grouped item or an alternate object
const value = artist.artist || artist.name || "";
setSelectedArtist(artist);
setArtistInput(value);
setArtistSuggestions([]);
// Hide autocomplete panel and blur input to prevent leftover white box.
// Use a small timeout so PrimeReact internal handlers can finish first.
try {
const ac = autoCompleteRef.current;
// Give PrimeReact a tick to finish its events, then call hide().
setTimeout(() => {
try {
if (ac && typeof ac.hide === "function") {
ac.hide();
}
// If panel still exists, hide it via style (safer than removing the node)
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';
}
}, 10);
// blur the input element if present
const inputEl = ac?.getInput ? ac.getInput() : document.querySelector('.p-autocomplete-input');
if (inputEl && typeof inputEl.blur === 'function') inputEl.blur();
} catch (innerErr) {
// Ignore inner errors
}
}, 0);
} catch (err) {
// Ignore outer errors
}
};
const totalAlbums = albums.length;
const totalTracks = useMemo(() =>
Object.values(tracksByAlbum).reduce((sum, arr) => (Array.isArray(arr) ? sum + arr.length : sum), 0),
[tracksByAlbum]
);
const artistItemTemplate = (artist) => {
if (!artist) return null;
const alts = artist.alternatives || artist.alternates || [];
return (
<div className="py-1">
<div className="font-medium flex items-baseline gap-2">
<span>{truncate(artist.artist || artist.name, 58)}</span>
<span className="text-xs text-neutral-400">ID: {artist.id}</span>
</div>
{alts.length > 0 && (
<div className="text-xs text-neutral-500 mt-1">
<div className="flex flex-wrap gap-2 items-center">
<span className="mr-1">Alternates:</span>
{alts.map((alt, idx) => (
<span key={alt.id || idx} className="inline-flex items-center max-w-[200px] truncate">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
selectArtist({ ...alt, artist: alt.artist || alt.name });
}}
className="ml-1 mr-1 underline hover:text-blue-600 bg-transparent border-none p-0 cursor-pointer"
>
{truncate(alt.artist || alt.name, 26)}
</button>
<span className="text-xs text-neutral-400">ID: {alt.id}</span>
{idx < alts.length - 1 ? <span className="mx-1">,</span> : null}
</span>
))}
</div>
</div>
)}
</div>
);
};
// Handle autocomplete input changes (typing/selecting)
const handleArtistChange = (e) => {
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);
} else {
setArtistInput("");
setSelectedArtist(null);
}
};
// Search button click handler
const handleSearch = async () => {
toast.dismiss();
setIsSearching(true);
try {
if (metadataFetchToastId.current) toast.dismiss(metadataFetchToastId.current);
} catch (err) {
}
metadataFetchToastId.current = toast.info("Retrieving metadata...",
{
autoClose: false,
progress: 0,
closeOnClick: false,
}
);
if (type === "artist") {
if (!selectedArtist) {
toast.error("Please select a valid artist from suggestions.");
setIsSearching(false);
return;
}
setSelectedItem(selectedArtist.artist);
try {
const res = await authFetch(
`${API_URL}/trip/get_albums_by_artist_id/${selectedArtist.id}?quality=${quality}`
);
if (!res.ok) throw new Error("API error");
const data = await res.json();
data.sort((a, b) =>
(b.release_date || "").localeCompare(a.release_date || "")
);
setAlbums(data);
setTracksByAlbum({});
setExpandedAlbums([]);
// Set selectedTracks for all albums as null (means tracks loading/not loaded)
setSelectedTracks(
data.reduce((acc, album) => {
acc[album.id] = null;
return acc;
}, {})
);
} catch (err) {
toast.error("Failed to fetch albums for artist.");
setAlbums([]);
setTracksByAlbum({});
setSelectedTracks({});
}
} else if (type === "album") {
if (!artistInput.trim() || !albumInput.trim()) {
toast.error("Artist and Album are required.");
setIsSearching(false);
return;
}
setSelectedItem(`${artistInput} - ${albumInput}`);
setAlbums([]);
setTracksByAlbum({});
setSelectedTracks({});
} else if (type === "track") {
if (!artistInput.trim() || !trackInput.trim()) {
toast.error("Artist and Track are required.");
setIsSearching(false);
return;
}
setSelectedItem(`${artistInput} - ${trackInput}`);
setAlbums([]);
setTracksByAlbum({});
setSelectedTracks({});
}
setIsSearching(false);
};
const handleTrackClick = async (trackId, artist, title) => {
try {
const res = await authFetch(`${API_URL}/trip/get_track_by_id/${trackId}?quality=${quality}`);
if (!res.ok) throw new Error("Failed to fetch track URL");
const data = await res.json();
if (data.stream_url) {
// Use plain fetch for public resource
const fileResponse = await fetch(data.stream_url, {
method: "GET",
mode: "cors", // ensure cross-origin is allowed
credentials: "omit", // do NOT send cookies or auth
});
if (!fileResponse.ok) {
throw new Error(`Failed to fetch track file: ${fileResponse.status}`);
}
const blob = await fileResponse.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
const sanitize = (str) => str.replace(/[\\/:*?"<>|]/g, "_");
const urlPath = new URL(data.stream_url).pathname;
const extension = urlPath.split('.').pop().split('?')[0] || 'flac';
const filename = `${sanitize(artist)} - ${sanitize(title)}.${extension}`;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
} else {
toast.error("No stream URL returned for this track.");
}
} catch (error) {
toast.error("Failed to get track download URL.");
console.error(error);
}
};
const allTracksLoaded = albums.every(({ id }) => Array.isArray(tracksByAlbum[id]) && tracksByAlbum[id].length > 0);
const handleToggleAllAlbums = () => {
const allSelected = albums.every(({ id }) => {
const allTracks = tracksByAlbum[id] || [];
return selectedTracks[id]?.length === allTracks.length && allTracks.length > 0;
});
const newSelection = {};
albums.forEach(({ id }) => {
const allTracks = tracksByAlbum[id] || [];
if (allSelected) {
// Uncheck all
newSelection[id] = [];
} else {
// Check all tracks in the album
newSelection[id] = allTracks.map(track => String(track.id));
}
});
setSelectedTracks(newSelection);
};
<a
href="#"
role="button"
onClick={(e) => {
e.preventDefault();
if (!allTracksLoaded) return; // prevent clicking before data ready
handleToggleAllAlbums();
}}
className={`text-sm hover:underline cursor-pointer ${!allTracksLoaded ? "text-gray-400 dark:text-gray-500 pointer-events-none" : "text-blue-600"
}`}
>
Check / Uncheck All Albums
</a>
// Sequentially fetch tracks for albums not loaded yet
useEffect(() => {
if (type !== "artist" || albums.length === 0) return;
let isCancelled = false;
const albumsToFetch = albums.filter((a) => !tracksByAlbum[a.id]);
if (albumsToFetch.length === 0) return;
const fetchTracksSequentially = async () => {
const minDelay = 650; // ms between API requests
setIsFetching(true);
const totalAlbums = albumsToFetch.length;
for (let index = 0; index < totalAlbums; index++) {
const album = albumsToFetch[index];
if (isCancelled) break;
setLoadingAlbumId(album.id);
try {
const now = Date.now();
if (!fetchTracksSequentially.lastCall) fetchTracksSequentially.lastCall = 0;
const elapsed = now - fetchTracksSequentially.lastCall;
if (elapsed < minDelay) await delay(minDelay - elapsed);
fetchTracksSequentially.lastCall = Date.now();
const res = await authFetch(`${API_URL}/trip/get_tracks_by_album_id/${album.id}`);
if (!res.ok) throw new Error("API error");
const data = await res.json();
if (isCancelled) break;
setTracksByAlbum((prev) => ({ ...prev, [album.id]: data }));
setSelectedTracks((prev) => ({
...prev,
[album.id]: data.map((t) => t.id),
}));
} catch (err) {
toast.error(`Failed to fetch tracks for album ${album.album}.`);
setTracksByAlbum((prev) => ({ ...prev, [album.id]: [] }));
setSelectedTracks((prev) => ({ ...prev, [album.id]: [] }));
}
// Update progress toast
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,
});
};
fetchTracksSequentially();
return () => {
isCancelled = true;
};
}, [albums, type]);
// Toggle individual track checkbox
const toggleTrack = (albumId, trackId) => {
setSelectedTracks((prev) => {
const current = new Set(prev[albumId] || []);
if (current.has(String(trackId))) current.delete(String(trackId));
else current.add(String(trackId));
return { ...prev, [albumId]: Array.from(current) };
});
};
// Toggle album checkbox (select/deselect all tracks in album)
const toggleAlbum = (albumId) => {
const allTracks = tracksByAlbum[albumId]?.map((t) => String(t.id)) || [];
setSelectedTracks((prev) => {
const current = prev[albumId] || [];
const allSelected = current.length === allTracks.length;
return {
...prev,
[albumId]: allSelected ? [] : [...allTracks],
};
});
};
// Attach scroll fix for autocomplete panel
const attachScrollFix = () => {
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;
const atTop = items.scrollTop === 0;
const atBottom = items.scrollTop + items.clientHeight >= items.scrollHeight;
if ((delta < 0 && atTop) || (delta > 0 && atBottom)) {
e.preventDefault();
} else {
e.stopPropagation();
}
};
items.removeEventListener("wheel", wheelHandler);
items.addEventListener("wheel", wheelHandler, { passive: false });
}
}, 0);
};
// Submit request handler with progress indicator
const handleSubmitRequest = async () => {
if (isFetching) {
// tracks are not done being fetched
return toast.error("Still fetching track metadata, please wait a moment.");
}
setIsSubmitting(true);
try {
const allSelectedIds = Object.values(selectedTracks)
.filter(arr => Array.isArray(arr)) // skip null entries
.flat();
const response = await authFetch(`${API_URL}/trip/bulk_fetch`, {
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({
track_ids: allSelectedIds,
target: selectedArtist.artist,
quality: quality,
}),
});
if (!response.ok) {
throw new Error(`Server error: ${response.status}`);
}
const data = await response.json();
toast.success(`Request submitted! (${allSelectedIds.length} tracks)`);
} catch (err) {
console.error(err);
toast.error("Failed to submit request.");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="max-w-3xl mx-auto my-10 p-6 rounded-xl shadow-md bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700">
<style>{`
/* Accordion tab backgrounds & text */
.p-accordion-tab {
background-color: #ffffff;
color: #000000;
}
[data-theme="dark"] .p-accordion-tab {
background-color: #1e1e1e;
color: #ffffff;
}
/* Accordion header link */
.p-accordion-header .p-accordion-header-link {
background-color: #f9f9f9;
color: #000000;
}
[data-theme="dark"] .p-accordion-header .p-accordion-header-link {
background-color: #1e1e1e !important;
color: #ffffff !important;
}
/* Accordion content panel */
.p-accordion-content {
background-color: #fafafa;
color: #000000;
}
[data-theme="dark"] .p-accordion-content {
background-color: #2a2a2a;
color: #ffffff;
}
/* Track list UL/LI styling */
.p-accordion-content ul {
padding-left: 0;
margin: 0;
list-style: none;
}
[data-theme="dark"] .p-accordion-content ul {
color: #ffffff;
}
/* Track items */
.p-accordion-content li {
background-color: #fff;
border-bottom: 1px solid #e5e5e5;
}
[data-theme="dark"] .p-accordion-content li {
background-color: #2a2a2a;
border-bottom: 1px solid #444;
}
/* Checkboxes inside track list */
.p-accordion-content input[type="checkbox"] {
accent-color: #1d4ed8; /* optional for consistent dark mode styling */
}
/* Loading spinner (optional darker style) */
[data-theme="dark"] .animate-spin {
border-color: #555;
border-top-color: #1d4ed8;
}
/* Small text like audio quality, duration, version */
.p-accordion-content span {
color: #555;
}
[data-theme="dark"] .p-accordion-content span {
color: #aaa;
}
`}</style>
<BreadcrumbNav currentPage="request" />
<h2 className="text-3xl font-semibold mt-0">New Request</h2>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<label htmlFor="artistInput">Artist: </label>
<AutoComplete
id={artistInput}
ref={autoCompleteRef}
value={selectedArtist || artistInput}
suggestions={artistSuggestions}
field="artist"
completeMethod={searchArtists}
onChange={handleArtistChange}
minLength={3}
placeholder="Artist"
dropdown
className="w-full"
inputClassName="w-full px-3 py-2 rounded border border-neutral-300 dark:border-neutral-600 text-black dark:text-white dark:bg-neutral-800"
onShow={attachScrollFix}
itemTemplate={artistItemTemplate}
/>
{(type === "album" || type === "track") && (
<input
type="text"
className="w-full dark:bg-neutral-800 dark:text-white border border-neutral-300 dark:border-neutral-600 rounded px-3 py-2"
value={type === "album" ? albumInput : trackInput}
onChange={(e) =>
type === "album" ? setAlbumInput(e.target.value) : setTrackInput(e.target.value)
}
placeholder={type === "album" ? "Album" : "Track"}
/>
)}
<div className="flex items-center gap-4 justify-end mt-2">
<label htmlFor="qualitySelect" className="text-sm font-medium">
Quality:
</label>
<select
id="qualitySelect"
value={quality}
onChange={(e) => setQuality(e.target.value)}
className="border border-neutral-300 dark:border-neutral-600 rounded px-2 py-1 bg-white dark:bg-neutral-800 text-black dark:text-white"
>
<option value="FLAC">FLAC</option>
<option value="Lossy">Lossy</option>
</select>
</div>
<Button onClick={handleSearch} disabled={isSearching}>
{isSearching ? (
<span className="flex items-center gap-2">
<span className="animate-spin h-4 w-4 border-2 border-t-2 border-gray-200 border-t-primary rounded-full"></span>
Searching...
</span>
) : (
"Search"
)}
</Button>
</div>
{type === "artist" && albums.length > 0 && (
<>
<div className="flex justify-between items-center mb-2">
<div className="text-sm text-neutral-600 dark:text-neutral-400">
<strong className="mr-2">Albums:</strong> {totalAlbums}
<span className="mx-3">|</span>
<strong className="mr-2">Tracks:</strong> {totalTracks}
</div>
<div>
<a
href="#"
role="button"
onClick={(e) => {
e.preventDefault(); // prevent page jump
handleToggleAllAlbums();
}}
className="text-sm text-blue-600 hover:underline cursor-pointer"
>
Check / Uncheck All Albums
</a>
</div>
</div>
<Accordion
multiple
className="mt-4"
activeIndex={expandedAlbums}
onTabChange={(e) => setExpandedAlbums(e.index)}
>
{albums.map(({ album, id, release_date }) => {
const allTracks = tracksByAlbum[id] || [];
const selected = selectedTracks[id];
// Album checkbox is checked if tracks not loaded (selected === null)
// or all tracks loaded and all selected
const allChecked =
selected === null || (selected?.length === allTracks.length && allTracks.length > 0);
const someChecked =
selected !== null && selected.length > 0 && selected.length < allTracks.length;
return (
<AccordionTab
key={id}
header={
<div className="flex items-center gap-2 w-full">
<input
type="checkbox"
checked={allChecked}
ref={(el) => {
if (el) el.indeterminate = someChecked;
}}
onChange={() => toggleAlbum(id)}
onClick={(e) => e.stopPropagation()}
className="cursor-pointer"
aria-label={`Select all tracks for album ${album}`}
/>
<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>
<span className="ml-auto text-xs text-neutral-500">
{typeof tracksByAlbum[id] === 'undefined' ? (
loadingAlbumId === id ? 'Loading...' : '...'
) : (
`${allTracks.length} track${allTracks.length !== 1 ? 's' : ''}`
)}
</span>
</div>
}
>
{allTracks.length > 0 ? (
<ul className="text-sm">
{allTracks.map((track) => (
<li key={track.id} className="py-1 flex items-center gap-2">
<input
type="checkbox"
checked={selected?.includes(String(track.id))}
onChange={() => toggleTrack(id, track.id)}
className="cursor-pointer"
aria-label={`Select track ${track.title} `}
/>
<button
type="button"
onClick={() => handleTrackClick(track.id, selectedArtist.artist, track.title)}
className="font-medium text-blue-600 hover:underline cursor-pointer bg-transparent border-none p-0"
aria-label={`Download track ${track.title} `}
>
{truncate(track.title, 80)}
</button>
<span className="text-xs text-neutral-500">{quality}</span>
{track.version && (
<span className="text-xs text-neutral-400">({track.version})</span>
)}
{track.duration && (
<span className="text-xs text-neutral-500 ml-auto tabular-nums">{track.duration}</span>
)}
</li>
))}
</ul>
) : (
<div className="text-sm italic text-neutral-600 dark:text-neutral-400">
{tracksByAlbum[id] ? "No tracks found for this album." : "Loading tracks..."}
</div>
)}
</AccordionTab>
);
})}
</Accordion>
<div className="flex justify-end">
<Button onClick={handleSubmitRequest} color="primary" className="mt-4" disabled={isSubmitting}>
{isSubmitting ? (
<span className="flex items-center gap-2">
<span className="animate-spin h-4 w-4 border-2 border-t-2 border-gray-200 border-t-primary rounded-full"></span>
Submitting...
</span>
) : (
"Submit Request"
)}
</Button>
</div>
</>
)
}
</div >
</div >
);
}