various changes
This commit is contained in:
483
src/components/TRip/MediaRequestForm.jsx
Normal file
483
src/components/TRip/MediaRequestForm.jsx
Normal file
@@ -0,0 +1,483 @@
|
||||
import React, { useState, useEffect, useRef, Suspense, lazy } from "react";
|
||||
import { toast } from 'react-toastify';
|
||||
import { Button } from "@mui/joy";
|
||||
import { Accordion, AccordionTab } from "primereact/accordion";
|
||||
import { AutoComplete } from "primereact/autocomplete";
|
||||
import BreadcrumbNav from "./BreadcrumbNav";
|
||||
|
||||
|
||||
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 [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 debounceTimeout = useRef(null);
|
||||
const autoCompleteRef = useRef(null);
|
||||
|
||||
// Helper fetch wrapper that includes cookies automatically
|
||||
const authFetch = async (url, options = {}) => {
|
||||
const opts = {
|
||||
...options,
|
||||
credentials: "include", // <--- send HttpOnly cookies with requests
|
||||
};
|
||||
return fetch(url, opts);
|
||||
};
|
||||
|
||||
|
||||
// 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 {
|
||||
const res = await authFetch(
|
||||
`https://api.codey.lol/trip/get_artists_by_name?artist=${encodeURIComponent(
|
||||
query
|
||||
)}`,
|
||||
);
|
||||
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([]);
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
//helpers for string truncation
|
||||
const truncate = (text, maxLen) =>
|
||||
maxLen <= 3
|
||||
? text.slice(0, maxLen)
|
||||
: text.length <= maxLen
|
||||
? text
|
||||
: text.slice(0, maxLen - 3) + '...';
|
||||
|
||||
const artistItemTemplate = (artist) => {
|
||||
if (!artist) return null;
|
||||
return <div>{truncate(artist.artist, 58)}</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 () => {
|
||||
setIsSearching(true);
|
||||
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(
|
||||
`https://api.codey.lol/trip/get_albums_by_artist_id/${selectedArtist.id}`
|
||||
);
|
||||
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({});
|
||||
|
||||
// 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(`https://api.codey.lol/trip/get_track_by_id/${trackId}`);
|
||||
if (!res.ok) throw new Error("Failed to fetch track URL");
|
||||
const data = await res.json();
|
||||
|
||||
if (data.stream_url) {
|
||||
const fileResponse = await authFetch(data.stream_url);
|
||||
if (!fileResponse.ok) throw new Error("Failed to fetch track file");
|
||||
|
||||
const blob = await fileResponse.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
|
||||
// Sanitize filename (remove / or other illegal chars)
|
||||
const sanitize = (str) => str.replace(/[\\/:*?"<>|]/g, "_");
|
||||
const filename = `${sanitize(artist)} - ${sanitize(title)}.flac`;
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// 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 () => {
|
||||
for (const album of albumsToFetch) {
|
||||
if (isCancelled) break;
|
||||
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`https://api.codey.lol/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) => String(t.id)),
|
||||
}));
|
||||
} catch (err) {
|
||||
toast.error(`Failed to fetch tracks for album ${album.album}.`);
|
||||
setTracksByAlbum((prev) => ({ ...prev, [album.id]: [] }));
|
||||
setSelectedTracks((prev) => ({ ...prev, [album.id]: [] }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Example: simulate submission delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
toast.success("Request submitted!");
|
||||
} catch (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>{`
|
||||
.p-accordion-tab {
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
[data-theme="dark"] .p-accordion-tab {
|
||||
background-color: #1e1e1e;
|
||||
color: #ffffff;
|
||||
}
|
||||
[data-theme="dark"] .p-accordion-header .p-accordion-header-link {
|
||||
background-color: #1e1e1e !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
[data-theme="dark"] .p-accordion-content {
|
||||
background-color: #2a2a2a;
|
||||
color: #ffffff;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<BreadcrumbNav currentPage="request" />
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
value="artist"
|
||||
checked={type === "artist"}
|
||||
onChange={() => setType("artist")}
|
||||
/>
|
||||
Artist
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
value="album"
|
||||
checked={type === "album"}
|
||||
onChange={() => setType("album")}
|
||||
/>
|
||||
Album
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
value="track"
|
||||
checked={type === "track"}
|
||||
onChange={() => setType("track")}
|
||||
/>
|
||||
Track
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<AutoComplete
|
||||
ref={autoCompleteRef}
|
||||
value={selectedArtist || artistInput}
|
||||
suggestions={artistSuggestions}
|
||||
field="artist"
|
||||
completeMethod={searchArtists}
|
||||
onChange={handleArtistChange}
|
||||
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"}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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 && (
|
||||
<>
|
||||
<Accordion multiple className="mt-4">
|
||||
{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">
|
||||
<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>{album}</span>
|
||||
<small className="ml-2 text-neutral-500 dark:text-neutral-400">({release_date})</small>
|
||||
</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}`}
|
||||
>
|
||||
{track.title}
|
||||
</button>
|
||||
<span className="text-xs text-neutral-500">{track.audioQuality}</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user