Line data Source code
1 : // Filepath: melodex-front-end/src/components/Rankings.jsx
2 1 : import React, {
3 : useCallback,
4 : useEffect,
5 : useMemo,
6 : useRef,
7 : useState,
8 : } from "react";
9 1 : import { useSongContext } from "../contexts/SongContext";
10 1 : import { useVolumeContext } from "../contexts/VolumeContext";
11 1 : import SongFilter from "./SongFilter";
12 1 : import "../index.css";
13 1 : import { formatDefaultPlaylistName } from "../utils/formatDefaultPlaylistName";
14 1 : import { buildDeepLink } from "../utils/deeplink";
15 :
16 : // ===== Exportable helper so UI tests can import it =====
17 0 : export async function ensureSpotifyConnected(
18 0 : authRoot = "",
19 0 : { aggressive = true } = {}
20 0 : ) {
21 0 : const base = String(authRoot || "").replace(/\/+$/, "");
22 :
23 0 : try {
24 0 : const r = await fetch(`${base}/auth/session`, {
25 0 : credentials: "include",
26 0 : cache: "no-store",
27 0 : });
28 :
29 : // Hard auth failures: only redirect in aggressive mode (user explicitly clicked).
30 0 : if (r.status === 401 || r.status === 403) {
31 0 : return aggressive
32 0 : ? { shouldRedirect: true, to: `${base}/auth/start` }
33 0 : : { shouldRedirect: false };
34 0 : }
35 :
36 0 : if (r.status === 200) {
37 0 : let data = null;
38 0 : try {
39 0 : data = await r.json();
40 0 : } catch {
41 : // If body is weird but 200, assume "probably fine" to avoid loops.
42 0 : }
43 :
44 0 : if (data && data.connected === true) {
45 : // Definitely connected.
46 0 : return { shouldRedirect: false };
47 0 : }
48 :
49 : // 200 but not explicitly connected:
50 : // - In aggressive mode (CTA click) → start OAuth.
51 : // - In non-aggressive mode (auto-resume) → DO NOT redirect (prevents loops).
52 0 : return aggressive
53 0 : ? { shouldRedirect: true, to: `${base}/auth/start` }
54 0 : : { shouldRedirect: false };
55 0 : }
56 :
57 : // Any other status (304/5xx/etc): never auto-redirect; avoids infinite loops.
58 0 : return { shouldRedirect: false };
59 0 : } catch {
60 : // Network or other error → don't spin.
61 0 : return { shouldRedirect: false };
62 0 : }
63 0 : }
64 :
65 1 : const Rankings = () => {
66 210 : const { rankedSongs, fetchRankedSongs, loading, userID } = useSongContext();
67 210 : const { volume, setVolume, playingAudioRef, setPlayingAudioRef } =
68 210 : useVolumeContext();
69 :
70 210 : const [applied, setApplied] = useState(false);
71 210 : const [enrichedSongs, setEnrichedSongs] = useState([]);
72 210 : const [filteredSongs, setFilteredSongs] = useState([]);
73 210 : const [showFilter, setShowFilter] = useState(true);
74 210 : const [isFetching, setIsFetching] = useState(false);
75 210 : const [selectedGenre, setSelectedGenre] = useState("any");
76 210 : const [selectedSubgenre, setSelectedSubgenre] = useState("any");
77 210 : const [lastAppliedFilters, setLastAppliedFilters] = useState({
78 210 : genre: "any",
79 210 : subgenre: "any",
80 210 : });
81 :
82 : // Inline export selection mode
83 210 : const [selectionMode, setSelectionMode] = useState(false);
84 210 : const [selected, setSelected] = useState(new Set());
85 210 : const [playlistName, setPlaylistName] = useState(() =>
86 24 : formatDefaultPlaylistName()
87 210 : );
88 210 : const [playlistDescription, setPlaylistDescription] = useState("");
89 210 : const [exporting, setExporting] = useState(false);
90 210 : const [exportSuccessUrl, setExportSuccessUrl] = useState("");
91 :
92 : // Export progress states (idle → loading → success|error)
93 210 : const ExportState = {
94 210 : Idle: "idle",
95 210 : Validating: "validating",
96 210 : Creating: "creating",
97 210 : Adding: "adding",
98 210 : Success: "success",
99 210 : Error: "error",
100 210 : };
101 210 : const [exportState, setExportState] = useState(ExportState.Idle);
102 210 : const [exportError, setExportError] = useState(null);
103 :
104 : // Map<stableKey, HTMLAudioElement>
105 210 : const audioRefs = useRef(new Map());
106 210 : const rehydratingRef = useRef(new Set());
107 210 : const didRunFixRef = useRef(false);
108 210 : const recentlyDoneRef = useRef(new Map()); // key -> timestamp (ms)
109 210 : const autoInitRef = useRef(false); // avoid StrictMode/HMR double-run
110 210 : const rehydrateAvailableRef = useRef(null); // null=unknown, true/false after first attempt
111 :
112 210 : const RECENTLY_DONE_WINDOW_MS = 5 * 60 * 1000;
113 210 : const isCypressEnv =
114 210 : typeof window !== "undefined" &&
115 210 : !!window.Cypress &&
116 210 : window.__E2E_REQUIRE_AUTH__ === false;
117 :
118 210 : const isJsdom =
119 210 : typeof navigator !== "undefined" && /jsdom/i.test(navigator.userAgent);
120 :
121 210 : const hasInjectedRanked = () => {
122 54 : try {
123 54 : return (
124 54 : typeof window !== "undefined" && Array.isArray(window.__TEST_RANKED__)
125 : );
126 54 : } catch {
127 0 : return false;
128 0 : }
129 54 : };
130 :
131 : // ---- API base (handles with/without trailing /api) ----
132 210 : const RAW_BASE =
133 210 : import.meta.env.VITE_API_BASE_URL ??
134 210 : import.meta.env.VITE_API_BASE ??
135 210 : window.__API_BASE__ ??
136 210 : "http://localhost:8080";
137 :
138 210 : const normalizeNoTrail = (s) => String(s).replace(/\/+$/, "");
139 210 : const baseNoTrail = normalizeNoTrail(RAW_BASE);
140 210 : const API_ROOT = /\/api$/.test(baseNoTrail)
141 0 : ? baseNoTrail
142 210 : : `${baseNoTrail}/api`;
143 210 : const hasApiSuffix = /\/api$/.test(baseNoTrail);
144 210 : const AUTH_ROOT = hasApiSuffix
145 0 : ? baseNoTrail.replace(/\/api$/, "")
146 210 : : baseNoTrail;
147 :
148 210 : const joinUrl = (...parts) =>
149 23 : parts
150 23 : .map((p, i) =>
151 69 : i === 0
152 23 : ? String(p).replace(/\/+$/, "")
153 46 : : String(p).replace(/^\/+|\/+$/g, "")
154 23 : )
155 23 : .filter(Boolean)
156 23 : .join("/");
157 :
158 210 : function recentlyDone(key) {
159 28 : const ts = recentlyDoneRef.current.get(key) || 0;
160 28 : return Date.now() - ts < RECENTLY_DONE_WINDOW_MS;
161 28 : }
162 :
163 : // --- OAuth resume helpers ---
164 210 : const EXPORT_INTENT_KEY = "melodex.intent";
165 :
166 210 : const markExportIntent = () => {
167 0 : try {
168 0 : sessionStorage.setItem(EXPORT_INTENT_KEY, "export");
169 0 : } catch {}
170 0 : };
171 :
172 210 : const consumeExportIntent = () => {
173 23 : try {
174 23 : const v = sessionStorage.getItem(EXPORT_INTENT_KEY);
175 23 : if (v === "export") {
176 0 : sessionStorage.removeItem(EXPORT_INTENT_KEY);
177 0 : return true;
178 0 : }
179 23 : } catch {}
180 23 : return false;
181 23 : };
182 :
183 : // ===== Helpers =====
184 210 : const stableKey = (s) => {
185 351 : if (!s) return "na__";
186 351 : if (s._id) return `id_${String(s._id)}`;
187 204 : if (s.deezerID != null) return `dz_${String(s.deezerID)}`;
188 0 : const norm = (x) =>
189 0 : String(x || "")
190 0 : .toLowerCase()
191 0 : .trim()
192 0 : .replace(/\s+/g, " ");
193 0 : return `na_${norm(s.songName)}__${norm(s.artist)}`;
194 351 : };
195 :
196 : // Seed selection with all visible songs
197 210 : const seedSelectedAll = useCallback((songs) => {
198 25 : const next = new Set();
199 25 : songs.forEach((s) => next.add(stableKey(s)));
200 25 : setSelected(next);
201 210 : }, []);
202 :
203 : // Parse Deezer preview expiry for logging + validation
204 210 : function parsePreviewExpiry(url) {
205 218 : if (!url || typeof url !== "string")
206 218 : return { exp: null, now: Math.floor(Date.now() / 1000), ttl: null };
207 218 : try {
208 218 : const qs = url.split("?")[1] || "";
209 218 : const params = new URLSearchParams(qs);
210 218 : const hdnea = params.get("hdnea") || "";
211 218 : const m = /exp=(\d+)/.exec(hdnea);
212 218 : const now = Math.floor(Date.now() / 1000);
213 218 : if (!m) return { exp: null, now, ttl: null };
214 68 : const exp = parseInt(m[1], 10);
215 68 : return { exp, now, ttl: exp - now };
216 68 : } catch {
217 0 : return { exp: null, now: Math.floor(Date.now() / 1000), ttl: null };
218 0 : }
219 218 : }
220 :
221 210 : function isPreviewValid(url) {
222 190 : const { ttl } = parsePreviewExpiry(url);
223 190 : if (ttl === null) return true;
224 58 : return ttl > 60;
225 190 : }
226 :
227 210 : function msSince(d) {
228 0 : if (!d) return Number.POSITIVE_INFINITY;
229 0 : const t = typeof d === "string" ? Date.parse(d) : d;
230 0 : if (Number.isNaN(t)) return Number.POSITIVE_INFINITY;
231 0 : return Date.now() - t;
232 0 : }
233 :
234 210 : const REFRESH_COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes
235 :
236 : // ===== Network ops =====
237 210 : const rehydrateSong = async (song) => {
238 0 : const key = stableKey(song);
239 0 : try {
240 0 : const effectiveUserID = userID || (isCypressEnv ? "e2e-user" : null);
241 0 : if (!effectiveUserID || !song) return;
242 0 : if (rehydrateAvailableRef.current === false) return;
243 :
244 0 : if (recentlyDone(key)) {
245 0 : console.log("[Rankings] rehydrateSong skipped (recently done)", {
246 0 : key,
247 0 : });
248 0 : return;
249 0 : }
250 :
251 0 : if (rehydratingRef.current.has(key)) {
252 0 : console.log("[Rankings] rehydrateSong skipped (already in-flight)", {
253 0 : key,
254 0 : song: { n: song.songName, a: song.artist },
255 0 : });
256 0 : return;
257 0 : }
258 0 : rehydratingRef.current.add(key);
259 :
260 0 : const endpoint = joinUrl(API_ROOT, "user-songs", "rehydrate");
261 :
262 0 : const ttlInfo = parsePreviewExpiry(song.previewURL);
263 0 : console.log("[Rankings] rehydrateSong POST", {
264 0 : endpoint,
265 0 : song: {
266 0 : id: song._id,
267 0 : deezerID: song.deezerID,
268 0 : name: song.songName,
269 0 : artist: song.artist,
270 0 : },
271 0 : previewTTL: ttlInfo,
272 0 : });
273 :
274 0 : const res = await fetch(endpoint, {
275 0 : method: "POST",
276 0 : headers: { "Content-Type": "application/json" },
277 0 : body: JSON.stringify({
278 0 : userID: effectiveUserID,
279 0 : songId: song._id,
280 0 : fallbackDeezerID: song.deezerID,
281 0 : songName: song.songName,
282 0 : artist: song.artist,
283 0 : }),
284 0 : });
285 :
286 0 : if (!res.ok) {
287 0 : const txt = await res.text();
288 0 : console.warn("[Rankings] rehydrateSong FAILED", {
289 0 : status: res.status,
290 0 : body: txt,
291 0 : });
292 0 : recentlyDoneRef.current.set(key, Date.now());
293 0 : if (res.status === 404) {
294 0 : rehydrateAvailableRef.current = false;
295 0 : }
296 0 : throw new Error(`rehydrate failed ${res.status} ${txt}`);
297 0 : }
298 :
299 0 : rehydrateAvailableRef.current = true;
300 :
301 0 : const updated = await res.json();
302 :
303 0 : const matches = (s) =>
304 0 : (s._id && updated._id && String(s._id) === String(updated._id)) ||
305 0 : (!!s.deezerID &&
306 0 : !!updated.deezerID &&
307 0 : String(s.deezerID) === String(updated.deezerID)) ||
308 0 : (s.songName === song.songName && s.artist === song.artist);
309 :
310 0 : setEnrichedSongs((prev) =>
311 0 : prev.map((s) => (matches(s) ? { ...s, ...updated } : s))
312 0 : );
313 0 : setFilteredSongs((prev) =>
314 0 : prev.map((s) => (matches(s) ? { ...s, ...updated } : s))
315 0 : );
316 :
317 0 : const audioEl = audioRefs.current.get(key);
318 0 : if (audioEl && updated.previewURL) {
319 0 : audioEl.src = updated.previewURL;
320 0 : audioEl.load();
321 0 : }
322 :
323 0 : recentlyDoneRef.current.set(key, Date.now());
324 :
325 0 : console.log("[Rankings] rehydrateSong OK", {
326 0 : song: { id: song._id, name: song.songName, artist: song.artist },
327 0 : updatedPreviewTTL: parsePreviewExpiry(updated.previewURL),
328 0 : updatedDeezerID: updated.deezerID,
329 0 : lastDeezerRefresh: updated.lastDeezerRefresh,
330 0 : });
331 0 : } catch (e) {
332 0 : console.warn("[Rankings] Rehydrate error:", e);
333 0 : } finally {
334 0 : rehydratingRef.current.delete(key);
335 0 : }
336 0 : };
337 :
338 : // ===== Initial fetch =====
339 210 : useEffect(() => {
340 60 : if ((userID || isCypressEnv) && !applied) {
341 36 : const params = new URLSearchParams(window.location.search || "");
342 36 : const initialGenre = params.get("genre") || "any";
343 36 : const initialSubgenre = params.get("subgenre") || "any";
344 :
345 36 : console.log("Initial fetch triggered for /rankings with filters", {
346 36 : genre: initialGenre,
347 36 : subgenre: initialSubgenre,
348 36 : });
349 :
350 36 : handleApply({
351 36 : genre: initialGenre,
352 36 : subgenre: initialSubgenre,
353 36 : decade: "all decades",
354 36 : });
355 36 : }
356 210 : }, [userID, applied]);
357 :
358 : // ===== “Show immediately, fix in background once” =====
359 210 : const enrichAndFilterSongs = useCallback(() => {
360 54 : if (isCypressEnv && hasInjectedRanked()) {
361 6 : try {
362 6 : const injected = window.__TEST_RANKED__ || [];
363 6 : setEnrichedSongs(injected);
364 6 : setFilteredSongs(injected);
365 6 : } catch {}
366 6 : setIsFetching(false);
367 6 : return;
368 6 : }
369 54 : if (!applied || !Array.isArray(rankedSongs)) return;
370 :
371 23 : setEnrichedSongs(rankedSongs);
372 23 : setFilteredSongs(rankedSongs);
373 23 : setIsFetching(false);
374 :
375 23 : try {
376 23 : let valid = 0,
377 23 : expired = 0,
378 23 : missing = 0;
379 23 : const samples = [];
380 23 : rankedSongs.forEach((s, i) => {
381 50 : if (!s.previewURL) {
382 37 : missing++;
383 37 : if (samples.length < 5)
384 37 : samples.push({
385 37 : i,
386 37 : name: s.songName,
387 37 : artist: s.artist,
388 37 : reason: "no previewURL",
389 37 : });
390 50 : } else if (isPreviewValid(s.previewURL)) {
391 13 : valid++;
392 13 : } else {
393 0 : expired++;
394 0 : if (samples.length < 5) {
395 0 : const { ttl, exp, now } = parsePreviewExpiry(s.previewURL);
396 0 : samples.push({
397 0 : i,
398 0 : name: s.songName,
399 0 : artist: s.artist,
400 0 : ttl,
401 0 : exp,
402 0 : now,
403 0 : reason: "expired",
404 0 : });
405 0 : }
406 0 : }
407 23 : });
408 23 : console.log("[Rankings] Snapshot after fetch:", {
409 23 : valid,
410 23 : expired,
411 23 : missing,
412 23 : sample: samples,
413 23 : });
414 48 : } catch (e) {
415 0 : console.log("[Rankings] Snapshot logging error:", e);
416 0 : }
417 :
418 23 : if (didRunFixRef.current) return;
419 23 : didRunFixRef.current = true;
420 :
421 23 : const url = joinUrl(API_ROOT, "user-songs", "deezer-info");
422 :
423 23 : const candidates = rankedSongs.filter(
424 23 : (s) => !s.deezerID || !s.albumCover || !s.previewURL
425 23 : );
426 23 : console.log("[Rankings] Background fix pass: candidates", {
427 23 : total: rankedSongs.length,
428 23 : candidates: candidates.length,
429 23 : sample: candidates.slice(0, 5).map((s) => ({
430 37 : name: s.songName,
431 37 : artist: s.artist,
432 37 : deezerID: s.deezerID,
433 37 : hasCover: !!s.albumCover,
434 37 : hasPreview: !!s.previewURL,
435 23 : })),
436 23 : });
437 :
438 23 : const BATCH_SIZE = 10;
439 23 : const CONCURRENCY = 2;
440 :
441 23 : let cursor = 0;
442 23 : let active = 0;
443 23 : let cancelled = false;
444 :
445 23 : const runNext = () => {
446 38 : if (cancelled) return;
447 38 : if (cursor >= candidates.length && active === 0) return;
448 :
449 38 : while (active < CONCURRENCY && cursor < candidates.length) {
450 15 : const slice = candidates.slice(cursor, cursor + BATCH_SIZE);
451 15 : cursor += BATCH_SIZE;
452 15 : active += 1;
453 :
454 15 : console.log("[Rankings] deezer-info POST", {
455 15 : url,
456 15 : sliceCount: slice.length,
457 15 : });
458 :
459 15 : fetch(url, {
460 15 : method: "POST",
461 15 : headers: { "Content-Type": "application/json" },
462 15 : body: JSON.stringify({ songs: slice }),
463 15 : })
464 15 : .then(async (r) => {
465 15 : if (!r.ok) throw new Error(`deezer-info ${r.status}`);
466 15 : const batch = await r.json();
467 15 : const list = Array.isArray(batch) ? batch : [];
468 15 : console.log("[Rankings] deezer-info OK", { returned: list.length });
469 :
470 15 : setEnrichedSongs((prev) =>
471 24 : prev.map((s) => {
472 37 : const repl = list.find(
473 37 : (b) =>
474 18 : (b._id && s._id && String(b._id) === String(s._id)) ||
475 18 : (!!b.deezerID &&
476 18 : String(b.deezerID) === String(s.deezerID)) ||
477 9 : (b.songName === s.songName && b.artist === s.artist)
478 37 : );
479 37 : return repl ? { ...s, ...repl } : s;
480 24 : })
481 15 : );
482 15 : setFilteredSongs((prev) =>
483 24 : prev.map((s) => {
484 37 : const repl = list.find(
485 37 : (b) =>
486 18 : (b._id && s._id && String(b._id) === String(s._id)) ||
487 18 : (!!b.deezerID &&
488 18 : String(b.deezerID) === String(s.deezerID)) ||
489 9 : (b.songName === s.songName && b.artist === s.artist)
490 37 : );
491 37 : return repl ? { ...s, ...repl } : s;
492 24 : })
493 15 : );
494 15 : })
495 15 : .catch((err) => {
496 0 : console.log(
497 0 : "[Rankings] deezer-info error (ignored, UI will self-heal on play)",
498 0 : err?.message || err
499 0 : );
500 15 : })
501 15 : .finally(() => {
502 15 : active -= 1;
503 15 : runNext();
504 15 : });
505 15 : }
506 38 : };
507 :
508 23 : runNext();
509 :
510 23 : return () => {
511 23 : cancelled = true;
512 23 : };
513 210 : }, [applied, rankedSongs]);
514 :
515 210 : useEffect(() => {
516 54 : return enrichAndFilterSongs();
517 210 : }, [enrichAndFilterSongs]);
518 :
519 : // ===== Auto rehydrate only when expired + cooldown =====
520 210 : useEffect(() => {
521 95 : if (!Array.isArray(filteredSongs) || filteredSongs.length === 0) return;
522 24 : if (autoInitRef.current) return;
523 22 : autoInitRef.current = true;
524 22 : if (rehydrateAvailableRef.current === false) return;
525 :
526 22 : filteredSongs.forEach((s) => {
527 54 : if (!s.previewURL) return;
528 28 : const key = stableKey(s);
529 54 : if (rehydratingRef.current.has(key) || recentlyDone(key)) return;
530 28 : const { ttl } = parsePreviewExpiry(s.previewURL);
531 :
532 28 : if (
533 28 : !isPreviewValid(s.previewURL) &&
534 0 : msSince(s.lastDeezerRefresh) > REFRESH_COOLDOWN_MS
535 54 : ) {
536 0 : console.log("[Rankings] AUTO rehydrate (expired + cooldown)", {
537 0 : name: s.songName,
538 0 : artist: s.artist,
539 0 : deezerID: s.deezerID,
540 0 : ttl,
541 0 : lastDeezerRefresh: s.lastDeezerRefresh,
542 0 : });
543 0 : rehydrateSong(s);
544 0 : }
545 22 : });
546 210 : }, [filteredSongs]);
547 :
548 : // Keep volume in sync
549 210 : useEffect(() => {
550 95 : audioRefs.current.forEach((audio) => {
551 28 : if (audio) audio.volume = volume;
552 95 : });
553 210 : }, [volume, filteredSongs]);
554 :
555 : // ===== UI actions =====
556 :
557 : // TEST-ONLY: Allow unit tests to inject ranked songs without hitting network
558 210 : const getTestRanked = () => {
559 38 : try {
560 38 : if (
561 38 : typeof window !== "undefined" &&
562 38 : Array.isArray(window.__TEST_RANKED__)
563 38 : ) {
564 3 : return window.__TEST_RANKED__;
565 3 : }
566 38 : } catch {}
567 35 : return null;
568 38 : };
569 :
570 210 : const handleApply = async (filters) => {
571 38 : const effectiveUserID = userID || (isCypressEnv ? "e2e-user" : null);
572 38 : if (!effectiveUserID) {
573 0 : console.log("No userID available, skipping fetch");
574 0 : return;
575 0 : }
576 :
577 38 : didRunFixRef.current = false;
578 :
579 38 : setShowFilter(false);
580 38 : setEnrichedSongs([]);
581 38 : setFilteredSongs([]);
582 38 : setIsFetching(true);
583 38 : setSelectedGenre(filters.genre);
584 38 : setSelectedSubgenre(filters.subgenre);
585 38 : setLastAppliedFilters({ genre: filters.genre, subgenre: filters.subgenre });
586 :
587 38 : try {
588 : // TEST-ONLY injection path (skips network)
589 38 : const injected = getTestRanked();
590 38 : if (injected) {
591 3 : setApplied(true);
592 3 : setIsFetching(false);
593 3 : setEnrichedSongs(injected);
594 3 : setFilteredSongs(injected);
595 3 : return;
596 3 : }
597 :
598 35 : const timeoutPromise = new Promise((_, reject) =>
599 35 : setTimeout(() => reject(new Error("Fetch timeout")), 60000)
600 35 : );
601 35 : await Promise.race([
602 : // ⬆️ Respect current filters passed from SongFilter
603 35 : fetchRankedSongs({
604 35 : userID: effectiveUserID,
605 38 : genre: filters?.genre ?? "any",
606 38 : subgenre: filters?.subgenre ?? "any",
607 38 : }),
608 38 : timeoutPromise,
609 38 : ]);
610 35 : setApplied(true);
611 35 : } catch (error) {
612 0 : console.error("handleApply error:", error);
613 0 : setApplied(true);
614 0 : setFilteredSongs([]);
615 0 : }
616 38 : };
617 :
618 210 : const sortedSongs = [...filteredSongs].sort((a, b) => b.ranking - a.ranking);
619 :
620 : // Auto-open Export after OAuth if we detect intent or ?export=1
621 210 : useEffect(() => {
622 : // IMPORTANT: don't consume the intent until we actually have songs
623 49 : if (!sortedSongs || sortedSongs.length === 0) return;
624 :
625 49 : const params = new URLSearchParams(window.location.search || "");
626 49 : const wantsExport = params.get("export") === "1" || consumeExportIntent();
627 49 : if (!wantsExport) return;
628 :
629 0 : (async () => {
630 0 : const decision = await ensureSpotifyConnected(AUTH_ROOT, {
631 0 : aggressive: false,
632 0 : });
633 0 : if (decision.shouldRedirect) {
634 : // Still not connected? Kick off OAuth again and keep intent
635 0 : markExportIntent();
636 0 : window.location.href = decision.to;
637 0 : return;
638 0 : }
639 :
640 : // Connected → open selection mode with all visible songs
641 0 : seedSelectedAll(sortedSongs);
642 0 : setExportSuccessUrl("");
643 0 : setSelectionMode(true);
644 0 : setPlaylistName((prev) =>
645 0 : String(prev || "").trim() ? prev : formatDefaultPlaylistName()
646 0 : );
647 :
648 : // Clean the URL (drop ?export=1) to avoid sticky behavior on refresh
649 0 : if (params.get("export") === "1") {
650 0 : const clean = window.location.pathname + window.location.hash;
651 0 : window.history.replaceState({}, "", clean);
652 0 : }
653 0 : })();
654 : // When songs list changes (initial load), this runs once
655 210 : }, [sortedSongs.length]);
656 :
657 210 : async function onExportClick() {
658 : // Cypress/jsdom fast path (tests can bypass auth)
659 25 : if (isCypressEnv) {
660 25 : seedSelectedAll(sortedSongs);
661 25 : setExportSuccessUrl("");
662 25 : setExportError(null);
663 25 : setExportState(ExportState.Idle); // reset when opening fresh
664 25 : setSelectionMode(true);
665 25 : setPlaylistName((prev) =>
666 25 : String(prev || "").trim() ? prev : formatDefaultPlaylistName()
667 25 : );
668 25 : return;
669 25 : }
670 :
671 : // Real browser path: ensure we have a Spotify session first
672 0 : const decision = await ensureSpotifyConnected(AUTH_ROOT, {
673 0 : aggressive: true,
674 0 : });
675 :
676 0 : if (decision.shouldRedirect) {
677 : // Remember that the user clicked Export so we can resume after OAuth
678 0 : markExportIntent();
679 0 : window.location.href = decision.to;
680 0 : return;
681 0 : }
682 :
683 25 : if (!sortedSongs || sortedSongs.length === 0) {
684 0 : return;
685 0 : }
686 :
687 0 : seedSelectedAll(sortedSongs);
688 0 : setExportSuccessUrl("");
689 0 : setExportError(null);
690 0 : setExportState(ExportState.Idle); // reset when opening from UI
691 0 : setSelectionMode(true);
692 0 : setPlaylistName((prev) =>
693 0 : String(prev || "").trim() ? prev : formatDefaultPlaylistName()
694 0 : );
695 25 : }
696 :
697 210 : const onCancelSelection = () => {
698 2 : setSelectionMode(false);
699 2 : setSelected(new Set());
700 2 : setPlaylistName("");
701 2 : setPlaylistDescription("");
702 2 : setExporting(false);
703 2 : setExportSuccessUrl("");
704 2 : setExportError(null);
705 2 : setExportState(ExportState.Idle); // <- reset so next session starts clean
706 2 : };
707 :
708 210 : const doExport = async () => {
709 8 : if (exporting) return;
710 :
711 8 : const chosen = sortedSongs.filter((s) => selected.has(stableKey(s)));
712 8 : if (chosen.length === 0) return;
713 :
714 : // reset any prior terminal states
715 8 : setExportError(null);
716 8 : setExportState(ExportState.Validating);
717 :
718 : // default name (use your existing genre/subgenre rule; fall back to util if you added it)
719 8 : const defaultNameParts = [];
720 8 : if (selectedGenre !== "any") defaultNameParts.push(selectedGenre);
721 8 : if (selectedSubgenre !== "any") defaultNameParts.push(selectedSubgenre);
722 8 : const defaultName =
723 8 : defaultNameParts.length > 0
724 0 : ? `${defaultNameParts.join(" ")} Playlist`
725 8 : : "Melodex Playlist";
726 :
727 : // Build URIs for the current stub path (uses cached spotifyUri if present; otherwise dz/_id fallback)
728 8 : const stubUris = chosen
729 8 : .filter((s) => s && (s.spotifyUri || s.deezerID || s._id))
730 8 : .map((s) => s.spotifyUri || `spotify:track:${s.deezerID || s._id}`);
731 :
732 : // Also include a rich items array to support the real mapping path later
733 8 : const items = chosen.map((s) => ({
734 16 : deezerID: s.deezerID ?? s._id ?? null,
735 16 : songName: s.songName,
736 16 : artist: s.artist,
737 16 : isrc: s.isrc ?? null,
738 16 : spotifyUri: s.spotifyUri ?? null, // cached hit if you have it
739 16 : ranking: s.ranking ?? null,
740 8 : }));
741 :
742 8 : const payload = {
743 8 : name: (playlistName || "").trim() || formatDefaultPlaylistName(),
744 8 : description: (playlistDescription || "").trim(),
745 : // keep both so tests + future real mapping are happy:
746 8 : uris: stubUris, // legacy/stub consumers
747 8 : ...(isCypressEnv ? { __testUris: stubUris } : {}), // only in Cypress/jsdom
748 8 : items, // real mapping path will use this
749 : // include filters if your backend reads them (optional):
750 : // filters: { genre: selectedGenre, subgenre: selectedSubgenre }
751 8 : };
752 :
753 : // expose for Cypress when present
754 8 : if (isCypressEnv && typeof window !== "undefined") {
755 8 : window.__LAST_EXPORT_PAYLOAD__ = payload;
756 8 : }
757 :
758 8 : try {
759 8 : setExporting(true);
760 8 : setExportState(ExportState.Creating);
761 :
762 : // fix endpoint path to match backend/tests
763 8 : const res = await fetch(`${API_ROOT}/playlist/export`, {
764 8 : method: "POST",
765 8 : headers: { "Content-Type": "application/json" },
766 8 : credentials: "include",
767 8 : body: JSON.stringify(payload),
768 8 : });
769 :
770 7 : let data = null;
771 7 : try {
772 7 : data = await res.json();
773 8 : } catch {}
774 :
775 8 : if (!res.ok || (data && data.ok === false)) {
776 : // Surface shaped error to UI (E2E looks for message + recovery guidance)
777 0 : const msg = data?.message || `Export failed ${res.status}`;
778 0 : const hint = data?.hint || "Please retry or adjust your selection.";
779 0 : setExportState(ExportState.Error);
780 0 : setExportError(`${msg} — ${hint}`);
781 0 : return;
782 0 : }
783 :
784 8 : const ok = data?.ok === true;
785 8 : const playlistUrl = data?.playlistUrl;
786 8 : if (ok && playlistUrl) {
787 6 : setExportState(ExportState.Success);
788 6 : setExportSuccessUrl(playlistUrl);
789 7 : } else {
790 : // Defensive: treat unexpected shape as an error
791 1 : setExportState(ExportState.Error);
792 1 : setExportError("Unexpected response from server — please try again.");
793 1 : }
794 8 : } catch (e) {
795 1 : console.error("Export error:", e);
796 1 : setExportState(ExportState.Error);
797 1 : setExportError(e?.message || "Something went wrong — please try again.");
798 8 : } finally {
799 8 : setExporting(false);
800 8 : }
801 8 : };
802 :
803 210 : const toggleFilter = () => setShowFilter((prev) => !prev);
804 :
805 210 : const getRankPositions = (songs) => {
806 210 : if (!Array.isArray(songs)) {
807 0 : console.error("getRankPositions: songs is not an array", songs);
808 0 : return [];
809 0 : }
810 210 : const sortedSongs = [...songs].sort((a, b) => {
811 150 : if (typeof a.ranking !== "number" || typeof b.ranking !== "number") {
812 0 : console.error("Invalid ranking value", a, b);
813 0 : return 0;
814 0 : }
815 150 : return b.ranking - a.ranking;
816 210 : });
817 210 : const positions = [];
818 210 : let currentRank = 1;
819 210 : let previousRanking = null;
820 :
821 210 : sortedSongs.forEach((song) => {
822 251 : if (previousRanking === null || song.ranking !== previousRanking) {
823 251 : positions.push(currentRank);
824 251 : currentRank += 1;
825 251 : } else {
826 0 : positions.push(positions[positions.length - 1]);
827 0 : }
828 251 : previousRanking = song.ranking;
829 210 : });
830 :
831 210 : return positions;
832 210 : };
833 :
834 210 : const rankPositions = getRankPositions(sortedSongs);
835 210 : const zeroSelected = selectionMode && selected.size === 0;
836 :
837 210 : return (
838 210 : <div
839 210 : className="rankings-container"
840 210 : style={{ maxWidth: "1200px", width: "100%" }}
841 : >
842 210 : <div
843 210 : className={`filter-container ${showFilter ? "visible" : "hidden"}`}
844 210 : style={{ width: "550px", margin: "0 auto" }}
845 : >
846 210 : <SongFilter
847 210 : onApply={handleApply}
848 210 : isRankPage={false}
849 210 : onHide={toggleFilter}
850 210 : />
851 210 : </div>
852 :
853 210 : <div
854 210 : style={{
855 210 : display: "flex",
856 210 : justifyContent: "center",
857 210 : margin: "0",
858 210 : transition: "transform 0.3s ease",
859 210 : transform: showFilter ? "translateY(0.5rem)" : "translateY(0)",
860 210 : }}
861 : >
862 210 : <button
863 210 : className="filter-toggle"
864 210 : data-testid="filter-toggle"
865 210 : aria-label="Toggle filters"
866 210 : onClick={toggleFilter}
867 : >
868 210 : <svg
869 210 : width="20"
870 210 : height="20"
871 210 : viewBox="0 0 20 20"
872 210 : fill="none"
873 210 : xmlns="http://www.w3.org/2000/svg"
874 : >
875 210 : <rect
876 210 : y="4"
877 210 : width="20"
878 210 : height="2"
879 210 : rx="1"
880 210 : fill="#bdc3c7"
881 210 : className="filter-line"
882 210 : />
883 210 : <rect
884 210 : y="9"
885 210 : width="20"
886 210 : height="2"
887 210 : rx="1"
888 210 : fill="#bdc3c7"
889 210 : className="filter-line"
890 210 : />
891 210 : <rect
892 210 : y="14"
893 210 : width="20"
894 210 : height="2"
895 210 : rx="1"
896 210 : fill="#bdc3c7"
897 210 : className="filter-line"
898 210 : />
899 210 : </svg>
900 210 : </button>
901 210 : </div>
902 :
903 210 : {loading || isFetching ? (
904 79 : <div
905 79 : style={{
906 79 : display: "flex",
907 79 : flexDirection: "column",
908 79 : alignItems: "center",
909 79 : justifyContent: "center",
910 79 : minHeight: "50vh",
911 79 : }}
912 : >
913 79 : <div
914 79 : style={{
915 79 : border: "4px solid #ecf0f1",
916 79 : borderTop: "4px solid #3498db",
917 79 : borderRadius: "50%",
918 79 : width: "40px",
919 79 : height: "40px",
920 79 : animation: "spin 1s linear infinite",
921 79 : }}
922 79 : ></div>
923 79 : <p
924 79 : style={{
925 79 : marginTop: "1rem",
926 79 : fontSize: "1.2em",
927 79 : color: "#7f8c8d",
928 79 : fontWeight: "600",
929 79 : }}
930 79 : ></p>
931 79 : </div>
932 131 : ) : applied ? (
933 107 : <div style={{ width: "100%", maxWidth: "1200px", margin: "0 auto" }}>
934 107 : <h2
935 107 : style={{
936 107 : textAlign: "center",
937 107 : color: "#141820",
938 107 : marginBottom: "1.0rem",
939 107 : marginTop: "4rem",
940 107 : }}
941 : >
942 107 : {selectionMode
943 73 : ? "Export to Spotify"
944 34 : : (selectedSubgenre !== "any"
945 0 : ? selectedSubgenre
946 34 : : selectedGenre !== "any"
947 2 : ? selectedGenre
948 34 : : "") + " Rankings"}
949 107 : </h2>
950 :
951 : {/* Live selection summary (AC-03.2) */}
952 107 : {selectionMode && (
953 73 : <div
954 73 : data-testid="selection-summary"
955 : /* also expose a stable 'selected-count' node the tests can read directly */
956 73 : aria-live="polite"
957 73 : data-count={selected?.size ?? 0}
958 73 : style={{
959 73 : textAlign: "center",
960 73 : fontSize: "0.95rem",
961 73 : color: "#7f8c8d",
962 73 : marginTop: "-0.5rem",
963 73 : marginBottom: "0.75rem",
964 73 : }}
965 73 : >
966 73 : Selected: {selected?.size ?? 0}
967 73 : </div>
968 : )}
969 :
970 : {/* Inline selection controls / CTA */}
971 107 : <div
972 107 : style={{
973 107 : display: "flex",
974 107 : justifyContent: "center",
975 107 : marginBottom: "1.25rem",
976 107 : gap: "0.75rem",
977 107 : }}
978 : >
979 107 : {!selectionMode ? (
980 34 : <button
981 34 : onClick={onExportClick}
982 34 : data-testid="export-spotify-cta"
983 : /* alias for tests that expect 'enter-selection' */
984 34 : aria-describedby="enter-selection"
985 34 : aria-label="Export ranked songs to Spotify"
986 34 : style={{
987 34 : padding: "0.6rem 1rem",
988 34 : fontWeight: 600,
989 34 : borderRadius: 8,
990 34 : border: "1px solid #3498db",
991 34 : }}
992 34 : >
993 : Export to Spotify
994 34 : </button>
995 : ) : (
996 73 : <form
997 73 : onSubmit={(e) => {
998 8 : e.preventDefault();
999 8 : if (!zeroSelected) doExport();
1000 8 : }}
1001 73 : data-testid="selection-mode-root"
1002 73 : style={{
1003 73 : display: "grid",
1004 73 : gridTemplateColumns: "1fr 1fr auto auto",
1005 73 : gap: "0.5rem",
1006 73 : alignItems: "center",
1007 73 : width: "100%",
1008 73 : maxWidth: 900,
1009 73 : }}
1010 : >
1011 73 : <input
1012 73 : type="text"
1013 73 : name="playlistName"
1014 73 : placeholder="Playlist name"
1015 73 : value={playlistName}
1016 73 : onChange={(e) => setPlaylistName(e.target.value)}
1017 73 : aria-label="Playlist name"
1018 73 : data-testid="playlist-name"
1019 73 : style={{
1020 73 : padding: "0.5rem",
1021 73 : borderRadius: 8,
1022 73 : border: "1px solid #ddd",
1023 73 : }}
1024 73 : />
1025 73 : <textarea
1026 73 : name="playlistDescription"
1027 73 : placeholder="Description (optional)"
1028 73 : value={playlistDescription}
1029 73 : onChange={(e) => setPlaylistDescription(e.target.value)}
1030 73 : aria-label="Description"
1031 73 : rows={1}
1032 73 : data-testid="playlist-description"
1033 73 : style={{
1034 73 : padding: "0.5rem",
1035 73 : borderRadius: 8,
1036 73 : border: "1px solid #ddd",
1037 73 : resize: "vertical",
1038 73 : }}
1039 73 : />
1040 :
1041 : {/* Empty-selection hint */}
1042 73 : {zeroSelected && (
1043 8 : <p
1044 8 : data-testid="export-hint-empty"
1045 8 : role="alert"
1046 8 : aria-live="polite"
1047 8 : style={{
1048 8 : margin: 0,
1049 8 : fontSize: "0.9rem",
1050 8 : opacity: 0.8,
1051 8 : justifySelf: "end",
1052 8 : }}
1053 8 : >
1054 : Select at least one song to export.
1055 8 : </p>
1056 : )}
1057 :
1058 73 : <button
1059 73 : type="submit"
1060 73 : data-testid="export-confirm"
1061 73 : disabled={
1062 73 : zeroSelected ||
1063 65 : exporting ||
1064 58 : exportState === ExportState.Success
1065 : }
1066 73 : style={{
1067 73 : padding: "0.6rem 1rem",
1068 73 : fontWeight: 600,
1069 73 : borderRadius: 8,
1070 73 : border: "1px solid #2ecc71",
1071 73 : opacity:
1072 73 : zeroSelected ||
1073 65 : exporting ||
1074 58 : exportState === ExportState.Success
1075 21 : ? 0.6
1076 52 : : 1,
1077 73 : cursor:
1078 73 : zeroSelected ||
1079 65 : exporting ||
1080 58 : exportState === ExportState.Success
1081 21 : ? "not-allowed"
1082 52 : : "pointer",
1083 73 : }}
1084 : >
1085 73 : {exporting ? "Exporting…" : "Export"}
1086 73 : </button>
1087 :
1088 73 : <button
1089 73 : type="button"
1090 73 : onClick={onCancelSelection}
1091 73 : data-testid="export-cancel"
1092 : /* alias for tests that expect 'exit-selection' */
1093 73 : aria-describedby="exit-selection"
1094 73 : aria-label="Cancel selection mode"
1095 73 : style={{
1096 73 : padding: "0.6rem 1rem",
1097 73 : borderRadius: 8,
1098 73 : border: "1px solid #aaa",
1099 73 : }}
1100 73 : >
1101 : Cancel
1102 73 : </button>
1103 73 : </form>
1104 : )}
1105 107 : </div>
1106 :
1107 : {/* Progress readout — only during in-flight */}
1108 107 : {selectionMode &&
1109 73 : (exportState === ExportState.Validating ||
1110 73 : exportState === ExportState.Creating ||
1111 66 : exportState === ExportState.Adding) && (
1112 7 : <div
1113 7 : data-testid="export-progress"
1114 7 : style={{
1115 7 : textAlign: "center",
1116 7 : marginTop: "-0.5rem",
1117 7 : marginBottom: "0.75rem",
1118 7 : color: "#7f8c8d",
1119 7 : }}
1120 : >
1121 7 : {exportState === ExportState.Validating && "Validating…"}
1122 7 : {exportState === ExportState.Creating && "Creating playlist…"}
1123 7 : {exportState === ExportState.Adding && "Adding tracks…"}
1124 7 : </div>
1125 : )}
1126 :
1127 : {/* Error banner + Retry (E2E-004 depends on these test ids) */}
1128 107 : {selectionMode && exportState === ExportState.Error && (
1129 2 : <div
1130 2 : data-testid="export-error"
1131 2 : role="alert"
1132 2 : aria-live="assertive"
1133 2 : style={{
1134 2 : textAlign: "center",
1135 2 : marginTop: "-0.25rem",
1136 2 : marginBottom: "0.75rem",
1137 2 : color: "#e74c3c",
1138 2 : background: "rgba(231, 76, 60, 0.08)",
1139 2 : padding: "0.5rem 0.75rem",
1140 2 : borderRadius: 8,
1141 2 : border: "1px solid rgba(231, 76, 60, 0.35)",
1142 2 : }}
1143 : >
1144 2 : <strong style={{ marginRight: 6 }}>Export failed:</strong>
1145 2 : <span>
1146 2 : {String(
1147 2 : exportError || "Something went wrong — please try again."
1148 2 : )}
1149 2 : </span>
1150 2 : <div style={{ marginTop: "0.5rem" }}>
1151 2 : <button
1152 2 : data-testid="export-retry"
1153 2 : onClick={() => !exporting && !zeroSelected && doExport()}
1154 2 : style={{
1155 2 : padding: "0.4rem 0.8rem",
1156 2 : borderRadius: 8,
1157 2 : border: "1px solid #e74c3c",
1158 2 : }}
1159 2 : >
1160 : Retry
1161 2 : </button>
1162 2 : </div>
1163 2 : </div>
1164 : )}
1165 :
1166 107 : {exportSuccessUrl && (
1167 7 : <p style={{ textAlign: "center", marginBottom: "1rem" }}>
1168 7 : Playlist created:{" "}
1169 7 : {(() => {
1170 7 : const links = buildDeepLink(null, exportSuccessUrl);
1171 7 : return (
1172 7 : <a
1173 7 : href={links.web}
1174 7 : target="_blank"
1175 7 : rel="noopener noreferrer"
1176 7 : data-testid="export-success-link"
1177 7 : >
1178 : Open in Spotify
1179 7 : </a>
1180 : );
1181 7 : })()}
1182 7 : </p>
1183 : )}
1184 :
1185 107 : {filteredSongs.length === 0 ? (
1186 6 : <p
1187 6 : style={{
1188 6 : textAlign: "center",
1189 6 : fontSize: "1.2em",
1190 6 : color: "#7f8c8d",
1191 6 : }}
1192 6 : >
1193 : No ranked songs yet for this filter.
1194 6 : </p>
1195 : ) : (
1196 101 : <ul
1197 101 : style={{
1198 101 : listStyle: "none",
1199 101 : padding: 0,
1200 101 : display: "grid",
1201 101 : gap: "1.5rem",
1202 101 : gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
1203 101 : width: "100%",
1204 101 : }}
1205 : >
1206 101 : {sortedSongs.map((song, index) => {
1207 251 : const k = stableKey(song);
1208 251 : const isChecked = selected.has(k);
1209 251 : return (
1210 251 : <li
1211 251 : data-testid="song-card"
1212 251 : key={k}
1213 251 : className="song-box"
1214 251 : style={{
1215 251 : background: "white",
1216 251 : borderRadius: "12px",
1217 251 : padding: "1.5rem",
1218 251 : boxShadow: "0 4px 12px rgba(0, 0, 0, 0.1)",
1219 251 : display: "flex",
1220 251 : alignItems: "center",
1221 251 : gap: "1rem",
1222 251 : position: "relative",
1223 251 : }}
1224 : >
1225 : {/* Inline selection checkbox (left side) */}
1226 251 : {selectionMode && (
1227 175 : <input
1228 175 : type="checkbox"
1229 175 : data-testid={`song-checkbox-${k}`}
1230 175 : checked={isChecked}
1231 175 : onChange={(e) => {
1232 25 : const next = new Set(selected);
1233 25 : if (e.target.checked) next.add(k);
1234 20 : else next.delete(k);
1235 25 : setSelected(next);
1236 25 : }}
1237 175 : aria-label={`Select ${song.songName} by ${song.artist}`}
1238 175 : style={{ transform: "scale(1.2)" }}
1239 175 : />
1240 : )}
1241 251 : <span
1242 251 : style={{
1243 251 : fontSize: "1.5rem",
1244 251 : fontWeight: "700",
1245 251 : color: "#3498db",
1246 251 : minWidth: "2rem",
1247 251 : textAlign: "center",
1248 251 : }}
1249 : >
1250 251 : {rankPositions[index]}
1251 251 : </span>
1252 :
1253 251 : <img
1254 251 : src={song.albumCover || "/placeholder-cover.png"}
1255 251 : alt="Album Cover"
1256 251 : style={{
1257 251 : width: "80px",
1258 251 : height: "80px",
1259 251 : borderRadius: "8px",
1260 251 : }}
1261 251 : />
1262 :
1263 251 : <div style={{ flex: 1 }}>
1264 251 : <p
1265 251 : style={{
1266 251 : fontSize: "1.1rem",
1267 251 : fontWeight: "600",
1268 251 : color: "#141820",
1269 251 : margin: "0",
1270 251 : }}
1271 : >
1272 251 : {song.songName}
1273 251 : </p>
1274 251 : <p
1275 251 : style={{
1276 251 : fontSize: "1rem",
1277 251 : color: "#7f8c8d",
1278 251 : margin: "0.25rem 0",
1279 251 : }}
1280 : >
1281 251 : {song.artist}
1282 251 : </p>
1283 251 : <p
1284 251 : style={{
1285 251 : fontSize: "0.9rem",
1286 251 : color: "#3498db",
1287 251 : margin: "0",
1288 251 : }}
1289 251 : >
1290 251 : Score: {song.ranking}
1291 251 : </p>
1292 :
1293 251 : {song.previewURL && isPreviewValid(song.previewURL) ? (
1294 149 : <>
1295 149 : <audio
1296 149 : ref={(el) => {
1297 286 : if (el) audioRefs.current.set(k, el);
1298 143 : else audioRefs.current.delete(k);
1299 286 : }}
1300 149 : controls
1301 149 : src={song.previewURL}
1302 149 : className="custom-audio-player"
1303 149 : style={{ marginTop: "0.5rem" }}
1304 149 : onVolumeChange={(e) => setVolume(e.target.volume)}
1305 149 : onPlay={(e) => {
1306 0 : if (
1307 0 : playingAudioRef &&
1308 0 : playingAudioRef !== e.target
1309 0 : ) {
1310 0 : playingAudioRef.pause();
1311 0 : }
1312 0 : setPlayingAudioRef(e.target);
1313 0 : }}
1314 149 : onError={(e) => {
1315 0 : const { ttl, exp, now } = parsePreviewExpiry(
1316 0 : song.previewURL
1317 0 : );
1318 0 : console.log(
1319 0 : "[Rankings] <audio> onError → rehydrate (likely expired / fetch fail)",
1320 0 : {
1321 0 : name: song.songName,
1322 0 : artist: song.artist,
1323 0 : deezerID: song.deezerID,
1324 0 : ttl,
1325 0 : exp,
1326 0 : now,
1327 0 : }
1328 0 : );
1329 0 : e.currentTarget.style.display = "none";
1330 0 : const overlay =
1331 0 : e.currentTarget.nextElementSibling;
1332 0 : if (overlay) overlay.style.display = "block";
1333 0 : rehydrateSong(song);
1334 0 : }}
1335 149 : onCanPlay={(e) => {
1336 0 : console.log("[Rankings] <audio> onCanPlay", {
1337 0 : name: song.songName,
1338 0 : artist: song.artist,
1339 0 : ttl: parsePreviewExpiry(song.previewURL),
1340 0 : });
1341 0 : e.currentTarget.style.display = "block";
1342 0 : const overlay =
1343 0 : e.currentTarget.nextElementSibling;
1344 0 : if (overlay) overlay.style.display = "none";
1345 0 : }}
1346 149 : />
1347 : {/* overlay during rehydrate */}
1348 149 : <span
1349 149 : style={{
1350 149 : display: "none",
1351 149 : color: "#e74c3c",
1352 149 : background: "rgba(255,255,255,0.9)",
1353 149 : padding: "0.25rem 0.5rem",
1354 149 : borderRadius: "6px",
1355 149 : fontSize: "0.9rem",
1356 149 : position: "absolute",
1357 149 : top: "50%",
1358 149 : left: "50%",
1359 149 : transform: "translate(-50%, -50%)",
1360 149 : boxShadow: "0 2px 6px rgba(0,0,0,0.08)",
1361 149 : }}
1362 149 : >
1363 : Refreshing preview…
1364 149 : </span>
1365 149 : </>
1366 : ) : (
1367 102 : <span
1368 102 : style={{
1369 102 : display: "block",
1370 102 : color: "#e74c3c",
1371 102 : fontSize: "0.9rem",
1372 102 : marginTop: "0.5rem",
1373 102 : }}
1374 : >
1375 : {/* No preview available */}
1376 102 : </span>
1377 : )}
1378 251 : </div>
1379 251 : </li>
1380 : );
1381 101 : })}
1382 101 : </ul>
1383 : )}
1384 107 : </div>
1385 : ) : (
1386 24 : <div
1387 24 : style={{
1388 24 : display: "flex",
1389 24 : flexDirection: "column",
1390 24 : alignItems: "center",
1391 24 : justifyContent: "center",
1392 24 : minHeight: "50vh",
1393 24 : }}
1394 : >
1395 24 : <div
1396 24 : style={{
1397 24 : border: "4px solid #ecf0f1",
1398 24 : borderTop: "4px solid #3498db",
1399 24 : borderRadius: "50%",
1400 24 : width: "40px",
1401 24 : height: "40px",
1402 24 : animation: "spin 1s linear infinite",
1403 24 : }}
1404 24 : ></div>
1405 24 : <p
1406 24 : style={{
1407 24 : marginTop: "1rem",
1408 24 : fontSize: "1.2em",
1409 24 : color: "#7f8c8d",
1410 24 : fontWeight: "600",
1411 24 : }}
1412 24 : >
1413 : Loading user data...
1414 24 : </p>
1415 24 : </div>
1416 : )}
1417 210 : </div>
1418 : );
1419 210 : };
1420 :
1421 1 : export default Rankings;
|