Files
lidify/frontend/app/import/spotify/page.tsx
2025-12-25 18:58:06 -06:00

1064 lines
51 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
import {
ArrowLeft,
Check,
X,
Download,
Loader2,
ExternalLink,
ChevronDown,
ChevronUp,
} from "lucide-react";
import { api } from "@/lib/api";
import { useToast } from "@/lib/toast-context";
// Types for Spotify Import
interface SpotifyTrack {
spotifyId: string;
title: string;
artist: string;
artistId: string;
album: string;
albumId: string;
isrc: string | null;
durationMs: number;
trackNumber: number;
previewUrl: string | null;
coverUrl: string | null;
}
interface MatchedTrack {
spotifyTrack: SpotifyTrack;
localTrack: {
id: string;
title: string;
albumId: string;
albumTitle: string;
artistName: string;
} | null;
matchType: "exact" | "fuzzy" | "none";
matchConfidence: number;
}
interface AlbumToDownload {
spotifyAlbumId: string;
albumName: string;
artistName: string;
artistMbid: string | null;
albumMbid: string | null;
coverUrl: string | null;
trackCount: number;
tracksNeeded: SpotifyTrack[];
}
interface ImportPreview {
playlist: {
id: string;
name: string;
description: string | null;
owner: string;
imageUrl: string | null;
trackCount: number;
};
matchedTracks: MatchedTrack[];
albumsToDownload: AlbumToDownload[];
summary: {
total: number;
inLibrary: number;
downloadable: number;
notFound: number;
};
}
interface ImportJob {
id: string;
status:
| "pending"
| "downloading"
| "scanning"
| "creating_playlist"
| "matching_tracks"
| "completed"
| "failed"
| "cancelled";
progress: number;
albumsTotal: number;
albumsCompleted: number;
tracksMatched: number;
tracksTotal: number;
tracksDownloadable: number;
createdPlaylistId: string | null;
error: string | null;
}
type Step = "input" | "preview" | "importing" | "complete";
function SpotifyImportPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { toast } = useToast();
const hasAutoFetched = useRef(false);
// State
const [step, setStep] = useState<Step>("input");
const [url, setUrl] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [preview, setPreview] = useState<ImportPreview | null>(null);
const [selectedAlbums, setSelectedAlbums] = useState<Set<string>>(
new Set()
);
const [playlistName, setPlaylistName] = useState("");
const [importJob, setImportJob] = useState<ImportJob | null>(null);
const [refreshStatusMessage, setRefreshStatusMessage] = useState<
string | null
>(null);
const [expandedSection, setExpandedSection] = useState<
"matched" | "download" | "notfound" | null
>("matched");
// Auto-fetch preview if URL is provided in query params
useEffect(() => {
const urlParam = searchParams.get("url");
if (urlParam && !hasAutoFetched.current) {
hasAutoFetched.current = true;
setUrl(urlParam);
// Auto-trigger preview fetch
(async () => {
setIsLoading(true);
try {
const result = await api.post<ImportPreview>(
"/spotify/preview",
{
url: urlParam,
}
);
setPreview(result);
setPlaylistName(result.playlist.name);
// Auto-select all albums (Soulseek can search for any track, even without MBID)
const downloadableAlbumIds = result.albumsToDownload.map(
(a) => a.albumMbid || a.spotifyAlbumId
);
setSelectedAlbums(new Set(downloadableAlbumIds));
setStep("preview");
} catch (err) {
const message =
err instanceof Error
? err.message
: "Failed to fetch playlist";
toast.error(message);
} finally {
setIsLoading(false);
}
})();
}
}, [searchParams, toast]);
// Poll for import job status
useEffect(() => {
if (
!importJob ||
importJob.status === "completed" ||
importJob.status === "failed" ||
importJob.status === "cancelled"
) {
return;
}
const interval = setInterval(async () => {
try {
const job = await api.get<ImportJob>(
`/spotify/import/${importJob.id}/status`
);
setImportJob(job);
if (job.status === "completed") {
setStep("complete");
window.dispatchEvent(
new CustomEvent("notifications-changed")
);
window.dispatchEvent(new CustomEvent("playlist-created"));
} else if (job.status === "cancelled") {
setStep("complete");
window.dispatchEvent(
new CustomEvent("notifications-changed")
);
window.dispatchEvent(new CustomEvent("playlist-created"));
} else if (job.status === "failed") {
setStep("complete");
window.dispatchEvent(
new CustomEvent("notifications-changed")
);
}
} catch (err) {
console.error("Failed to poll job status:", err);
}
}, 2000);
return () => clearInterval(interval);
}, [importJob, toast]);
// Handle URL paste/change
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUrl(e.target.value);
};
// Fetch preview
const handleFetchPreview = async () => {
if (!url.trim()) {
toast.error("Please enter a playlist URL");
return;
}
setIsLoading(true);
try {
const result = await api.post<ImportPreview>("/spotify/preview", {
url,
});
setPreview(result);
setPlaylistName(result.playlist.name);
// Auto-select all albums (Soulseek can search for any track, even without MBID)
const downloadableAlbumIds = result.albumsToDownload.map(
(a) => a.albumMbid || a.spotifyAlbumId
);
setSelectedAlbums(new Set(downloadableAlbumIds));
setStep("preview");
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to fetch playlist";
toast.error(message);
} finally {
setIsLoading(false);
}
};
// Start import
const handleStartImport = async () => {
if (!preview) return;
setIsLoading(true);
setRefreshStatusMessage(null);
try {
const response = await api.post<{ jobId: string; status: string }>(
"/spotify/import",
{
spotifyPlaylistId: preview.playlist.id,
url,
playlistName: playlistName || preview.playlist.name,
albumMbidsToDownload: Array.from(selectedAlbums),
}
);
setImportJob({
id: response.jobId,
status: "pending",
progress: 0,
albumsTotal: selectedAlbums.size,
albumsCompleted: 0,
tracksMatched: preview.summary.inLibrary,
tracksTotal: preview.summary.total,
tracksDownloadable: preview.summary.downloadable,
createdPlaylistId: null,
error: null,
});
setStep("importing");
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to start import";
toast.error(message);
} finally {
setIsLoading(false);
}
};
// Toggle album selection
const toggleAlbum = (albumMbid: string) => {
setSelectedAlbums((prev) => {
const next = new Set(prev);
if (next.has(albumMbid)) {
next.delete(albumMbid);
} else {
next.add(albumMbid);
}
return next;
});
};
// Select/deselect all albums
const toggleAllAlbums = () => {
if (!preview) return;
// All albums are downloadable via Soulseek (even without MBID)
const allAlbumIds = preview.albumsToDownload.map(
(a) => a.albumMbid || a.spotifyAlbumId
);
if (selectedAlbums.size === allAlbumIds.length) {
setSelectedAlbums(new Set());
} else {
setSelectedAlbums(new Set(allAlbumIds));
}
};
// Cancel import
const [isCancelling, setIsCancelling] = useState(false);
const handleCancelImport = async () => {
if (!importJob) return;
setIsCancelling(true);
try {
const result = await api.post<{
message: string;
playlistId: string | null;
tracksMatched: number;
}>(`/spotify/import/${importJob.id}/cancel`, {});
setImportJob((prev) =>
prev
? {
...prev,
status: "cancelled",
createdPlaylistId: null,
tracksMatched: 0,
}
: prev
);
setStep("complete");
// Only dispatch notifications-changed, not playlist-created since no playlist was made
window.dispatchEvent(new CustomEvent("notifications-changed"));
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to cancel import";
toast.error(message);
} finally {
setIsCancelling(false);
}
};
// Format duration
const formatDuration = (ms: number) => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
return (
<div className="min-h-screen relative">
{/* Quick gradient fade - yellow to purple like home page */}
<div className="absolute inset-0 pointer-events-none">
<div
className="absolute inset-0 bg-linear-to-b from-[#ecb200]/15 via-purple-900/10 to-transparent"
style={{ height: "35vh" }}
/>
<div
className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,var(--tw-gradient-stops))] from-[#ecb200]/8 via-transparent to-transparent"
style={{ height: "25vh" }}
/>
</div>
<div className="relative max-w-3xl mx-auto px-6 py-6">
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<button
onClick={() => router.back()}
className="p-2 hover:bg-white/5 rounded-full transition-colors"
>
<ArrowLeft className="w-5 h-5 text-gray-400" />
</button>
<div>
<h1 className="text-2xl font-bold text-white">
Import Playlist
</h1>
<p className="text-sm text-gray-400">
Import from Spotify or Deezer and download missing
albums
</p>
</div>
</div>
{/* Browse Link */}
<div className="mb-6 p-4 bg-white/5 rounded-lg border border-white/10">
<p className="text-sm text-gray-300">
Looking for playlists to import?{" "}
<a
href="/browse/playlists"
className="text-[#ecb200] hover:underline font-medium"
>
Browse Deezer playlists & radio stations
</a>
</p>
</div>
{/* Step: Input */}
{step === "input" && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Playlist URL
</label>
<input
type="text"
value={url}
onChange={handleUrlChange}
placeholder="https://www.deezer.com/playlist/... or https://open.spotify.com/playlist/..."
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[#ecb200]/50 focus:border-[#ecb200] transition-colors"
onKeyDown={(e) =>
e.key === "Enter" && handleFetchPreview()
}
/>
<p className="text-xs text-gray-500 mt-2">
Paste a public{" "}
<span className="text-[#AD47FF]">Deezer</span>{" "}
or{" "}
<span className="text-[#1DB954]">Spotify</span>{" "}
playlist URL
</p>
</div>
<button
onClick={handleFetchPreview}
disabled={isLoading || !url.trim()}
className="w-full py-3 rounded-full font-medium bg-[#ecb200] text-black hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Loading...
</>
) : (
"Continue"
)}
</button>
</div>
)}
{/* Step: Preview */}
{step === "preview" && preview && (
<div className="space-y-4">
{/* Playlist Info */}
<div className="flex items-start gap-4 p-4 bg-white/5 rounded-lg">
{preview.playlist.imageUrl ? (
<img
src={preview.playlist.imageUrl}
alt={preview.playlist.name}
className="w-20 h-20 rounded-md object-cover"
/>
) : (
<div className="w-20 h-20 rounded-md bg-white/10 flex items-center justify-center">
<Image
src="/assets/images/SpotIcon.png"
alt="Spotify"
width={32}
height={32}
/>
</div>
)}
<div className="flex-1 min-w-0">
<h2 className="text-lg font-bold text-white truncate">
{preview.playlist.name}
</h2>
<p className="text-sm text-gray-400">
{preview.playlist.owner} ·{" "}
{preview.playlist.trackCount} songs
</p>
{preview.playlist.description && (
<p className="text-sm text-gray-500 mt-1 line-clamp-1">
{preview.playlist.description}
</p>
)}
</div>
<a
href={
url ||
`https://open.spotify.com/playlist/${preview.playlist.id}`
}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-[#1DB954] transition-colors"
>
<ExternalLink className="w-4 h-4" />
</a>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-4 gap-3">
<div className="text-center py-3 bg-white/5 rounded-lg">
<div className="text-xl font-bold text-white">
{preview.summary.total}
</div>
<div className="text-xs text-gray-500">
Total
</div>
</div>
<div className="text-center py-3 bg-green-500/10 rounded-lg">
<div className="text-xl font-bold text-green-400">
{preview.summary.inLibrary}
</div>
<div className="text-xs text-gray-500">
In Library
</div>
</div>
<div className="text-center py-3 bg-[#1DB954]/10 rounded-lg">
<div className="text-xl font-bold text-[#1DB954]">
{
preview.albumsToDownload.filter(
(a) => a.albumMbid
).length
}
</div>
<div className="text-xs text-gray-500">
To Download
</div>
</div>
{preview.summary.notFound > 0 ? (
<div className="text-center py-3 bg-red-500/10 rounded-lg">
<div className="text-xl font-bold text-red-400">
{preview.summary.notFound}
</div>
<div className="text-xs text-gray-500">
Not Found
</div>
</div>
) : (
<div className="text-center py-3 bg-green-500/10 rounded-lg">
<div className="text-xl font-bold text-green-400">
</div>
<div className="text-xs text-gray-500">
All Matched
</div>
</div>
)}
</div>
{/* Tracks already in library */}
{preview.summary.inLibrary > 0 && (
<div className="bg-white/5 rounded-lg overflow-hidden">
<button
onClick={() =>
setExpandedSection(
expandedSection === "matched"
? null
: "matched"
)
}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-400" />
<span className="text-sm font-medium text-white">
{preview.summary.inLibrary} songs in
your library
</span>
</div>
{expandedSection === "matched" ? (
<ChevronUp className="w-4 h-4 text-gray-500" />
) : (
<ChevronDown className="w-4 h-4 text-gray-500" />
)}
</button>
{expandedSection === "matched" && (
<div className="border-t border-white/5 max-h-48 overflow-y-auto">
{preview.matchedTracks
.filter((m) => m.localTrack)
.map((match, i) => (
<div
key={
match.spotifyTrack
.spotifyId
}
className="flex items-center gap-3 px-4 py-2 hover:bg-white/5"
>
<span className="text-xs text-gray-600 w-5 text-right">
{i + 1}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm text-white truncate">
{match.localTrack
?.title ||
match
.spotifyTrack
.title}
</div>
<div className="text-xs text-gray-500 truncate">
{match.localTrack
?.artistName ||
match
.spotifyTrack
.artist}
</div>
</div>
<span className="text-xs text-gray-600">
{formatDuration(
match.spotifyTrack
.durationMs
)}
</span>
</div>
))}
</div>
)}
</div>
)}
{/* Albums to download */}
{preview.albumsToDownload.filter((a) => a.albumMbid)
.length > 0 && (
<div className="bg-white/5 rounded-lg overflow-hidden">
<button
onClick={() =>
setExpandedSection(
expandedSection === "download"
? null
: "download"
)
}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-2">
<Download className="w-4 h-4 text-[#1DB954]" />
<span className="text-sm font-medium text-white">
{
preview.albumsToDownload.filter(
(a) =>
a.albumMbid ||
a.albumName ===
"Unknown Album"
).length
}{" "}
albums to download
</span>
</div>
{expandedSection === "download" ? (
<ChevronUp className="w-4 h-4 text-gray-500" />
) : (
<ChevronDown className="w-4 h-4 text-gray-500" />
)}
</button>
{expandedSection === "download" && (
<div className="border-t border-white/5">
<div className="flex items-center justify-between px-4 py-2 bg-black/20">
<button
onClick={toggleAllAlbums}
className="text-xs text-[#1DB954] hover:underline"
>
{selectedAlbums.size ===
preview.albumsToDownload.length
? "Deselect All"
: "Select All"}
</button>
<span className="text-xs text-gray-500">
{selectedAlbums.size} selected
</span>
</div>
<div className="max-h-48 overflow-y-auto">
{preview.albumsToDownload.map(
(album, index) => {
const albumKey =
album.albumMbid ||
album.spotifyAlbumId;
return (
<label
key={
albumKey ||
`album-${index}`
}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-white/5 cursor-pointer border-b border-white/5 last:border-0"
>
<input
type="checkbox"
checked={selectedAlbums.has(
albumKey
)}
onChange={() =>
toggleAlbum(
albumKey
)
}
className="w-4 h-4 rounded border-white/20 bg-transparent text-[#1DB954] focus:ring-[#1DB954] focus:ring-offset-0"
/>
{album.coverUrl && (
<img
src={
album.coverUrl
}
alt={
album.albumName
}
className="w-10 h-10 rounded object-cover"
/>
)}
<div className="flex-1 min-w-0">
<div className="text-sm text-white truncate">
{
album.albumName
}
</div>
<div className="text-xs text-gray-500 truncate">
{
album.artistName
}{" "}
·{" "}
{
album.trackCount
}{" "}
songs
</div>
</div>
</label>
);
}
)}
</div>
</div>
)}
</div>
)}
{/* Tracks not found */}
{preview.summary.notFound > 0 && (
<div className="bg-white/5 rounded-lg overflow-hidden">
<button
onClick={() =>
setExpandedSection(
expandedSection === "notfound"
? null
: "notfound"
)
}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-2">
<X className="w-4 h-4 text-red-400" />
<span className="text-sm font-medium text-white">
{preview.summary.notFound} songs not
found
</span>
</div>
{expandedSection === "notfound" ? (
<ChevronUp className="w-4 h-4 text-gray-500" />
) : (
<ChevronDown className="w-4 h-4 text-gray-500" />
)}
</button>
{expandedSection === "notfound" && (
<div className="border-t border-white/5 max-h-48 overflow-y-auto">
{preview.albumsToDownload
.filter(
(a) =>
!a.albumMbid &&
a.albumName !==
"Unknown Album"
)
.flatMap(
(album) => album.tracksNeeded
)
.map((track) => (
<div
key={track.spotifyId}
className="flex items-center gap-3 px-4 py-2 hover:bg-white/5"
>
<div className="flex-1 min-w-0">
<div className="text-sm text-gray-400 truncate">
{track.title}
</div>
<div className="text-xs text-gray-600 truncate">
{track.artist} ·{" "}
{track.album}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Playlist name input */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Playlist Name
</label>
<input
type="text"
value={playlistName}
onChange={(e) =>
setPlaylistName(e.target.value)
}
placeholder="Enter playlist name"
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[#1DB954]/50 focus:border-[#1DB954] transition-colors"
/>
</div>
{/* Action buttons */}
<div className="flex items-center gap-3 pt-2">
<button
onClick={() => {
setStep("input");
setPreview(null);
}}
className="px-6 py-3 rounded-full text-sm font-medium text-gray-300 hover:text-white hover:bg-white/5 transition-colors"
>
Back
</button>
<button
onClick={handleStartImport}
disabled={
isLoading ||
(preview.summary.inLibrary === 0 &&
selectedAlbums.size === 0)
}
className="flex-1 py-3 rounded-full font-medium bg-[#1DB954] text-black hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Starting...
</>
) : preview.summary.inLibrary > 0 &&
selectedAlbums.size > 0 ? (
`Import ${preview.summary.inLibrary} songs + Download ${selectedAlbums.size} albums`
) : preview.summary.inLibrary > 0 ? (
`Import ${preview.summary.inLibrary} songs`
) : selectedAlbums.size > 0 ? (
`Download ${selectedAlbums.size} albums`
) : (
"Select albums to download"
)}
</button>
</div>
</div>
)}
{/* Step: Importing */}
{step === "importing" && importJob && (
<div className="text-center py-12">
<Loader2 className="w-10 h-10 text-[#1DB954] animate-spin mx-auto mb-4" />
<h2 className="text-lg font-bold text-white mb-1">
{importJob.status === "downloading"
? "Queueing Album Downloads"
: importJob.status === "scanning"
? "Scanning Library"
: importJob.status === "creating_playlist" ||
importJob.status === "matching_tracks"
? "Creating Playlist"
: importJob.status === "pending"
? "Waiting for Downloads"
: "Starting Import"}
</h2>
<p className="text-sm text-gray-400 mb-6">
{importJob.status === "downloading" && (
<>
Queued {importJob.albumsCompleted} of{" "}
{importJob.albumsTotal} albums
</>
)}
{importJob.status === "pending" && (
<>
Waiting for{" "}
{importJob.albumsTotal -
importJob.albumsCompleted}{" "}
downloads to complete
</>
)}
{importJob.status === "scanning" && (
<>Importing downloaded files into library</>
)}
{(importJob.status === "creating_playlist" ||
importJob.status === "matching_tracks") && (
<>Adding {importJob.tracksMatched} songs</>
)}
</p>
<div className="w-full max-w-xs mx-auto bg-white/10 rounded-full h-1.5">
<div
className="bg-[#1DB954] h-1.5 rounded-full transition-all duration-500"
style={{ width: `${importJob.progress}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-3">
{importJob.progress}% complete downloads continue
in the background
</p>
{/* Cancel button */}
<button
onClick={handleCancelImport}
disabled={isCancelling}
className="mt-6 px-5 py-2 rounded-full text-sm font-medium text-gray-400 hover:text-white hover:bg-white/10 border border-white/10 transition-colors disabled:opacity-50"
>
{isCancelling ? (
<>
<Loader2 className="w-3 h-3 animate-spin inline mr-2" />
Cancelling...
</>
) : (
"Cancel Import"
)}
</button>
<p className="text-xs text-gray-600 mt-2">
Playlist will be created with tracks downloaded so
far
</p>
</div>
)}
{/* Step: Complete */}
{step === "complete" && importJob && (
<div className="text-center py-12">
<div
className={
"w-14 h-14 rounded-full flex items-center justify-center mx-auto mb-4 " +
(importJob.status === "failed"
? "bg-red-500"
: importJob.status === "cancelled"
? "bg-amber-500"
: "bg-[#1DB954]")
}
>
{importJob.status === "failed" || importJob.status === "cancelled" ? (
<X className="w-7 h-7 text-white" />
) : (
<Check className="w-7 h-7 text-black" />
)}
</div>
<h2 className="text-lg font-bold text-white mb-1">
{importJob.status === "failed"
? "Import Failed"
: importJob.status === "cancelled"
? "Import Cancelled"
: "Import Complete"}
</h2>
{importJob.status === "failed" ? (
<p className="text-sm text-gray-400">
{importJob.error ||
"Something went wrong while importing."}
</p>
) : importJob.status === "cancelled" ? (
<p className="text-sm text-gray-400">
Import was cancelled. No playlist was created.
</p>
) : (
<>
<p className="text-sm text-gray-400">
{importJob.tracksMatched > 0
? `Added ${importJob.tracksMatched} songs to your playlist`
: "Playlist created (songs still downloading)"}
</p>
{importJob.tracksDownloadable > 0 &&
importJob.tracksMatched < importJob.tracksTotal && (
<p className="text-sm text-amber-400 mt-2">
{importJob.tracksDownloadable} songs still
downloading
</p>
)}
</>
)}
<div className="flex items-center justify-center gap-3 mt-6">
<button
onClick={() => {
setStep("input");
setUrl("");
setPreview(null);
setImportJob(null);
setRefreshStatusMessage(null);
}}
className="px-5 py-2.5 rounded-full text-sm font-medium text-gray-300 hover:text-white hover:bg-white/5 transition-colors"
>
Import Another
</button>
{importJob.tracksDownloadable > 0 &&
importJob.tracksMatched <
importJob.tracksTotal && (
<button
onClick={async () => {
try {
setIsLoading(true);
setRefreshStatusMessage(null);
const result = await api.post<{
added: number;
total: number;
}>(
`/spotify/import/${importJob.id}/refresh`,
{}
);
if (result.added > 0) {
setRefreshStatusMessage(
`Added ${result.added} new song(s).`
);
setImportJob((prev) =>
prev
? {
...prev,
tracksMatched:
result.total,
}
: prev
);
} else {
setRefreshStatusMessage(
"Albums still downloading. Try again later."
);
}
} catch {
setRefreshStatusMessage(
"Failed to refresh."
);
} finally {
setIsLoading(false);
}
}}
disabled={isLoading}
className="px-5 py-2.5 rounded-full text-sm font-medium bg-#0a0a0a text-white hover:bg-white/20 disabled:opacity-50 transition-colors"
>
{isLoading
? "Refreshing..."
: "Refresh"}
</button>
)}
{refreshStatusMessage && (
<p className="text-xs text-gray-500 mt-3">
{refreshStatusMessage}
</p>
)}
{importJob.createdPlaylistId && (
<button
onClick={() =>
router.push(
`/playlist/${importJob.createdPlaylistId}`
)
}
className="px-5 py-2.5 rounded-full text-sm font-medium bg-[#1DB954] text-black hover:brightness-110 transition-all"
>
View Playlist
</button>
)}
</div>
</div>
)}
</div>
</div>
);
}
export default function SpotifyImportPage() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="w-8 h-8 text-[#ecb200] animate-spin" />
</div>
}
>
<SpotifyImportPageContent />
</Suspense>
);
}