| 1 | import { useEffect, useState } from "react";
|
|---|
| 2 | import { Link, useParams } from "react-router-dom";
|
|---|
| 3 | import axiosInstance, { baseURL } from "../api/axiosInstance";
|
|---|
| 4 | import SongItem from "../components/SongItem";
|
|---|
| 5 | import { getErrorMessage } from "../utils/error";
|
|---|
| 6 | import type { Album, Playlist, Song } from "../utils/types";
|
|---|
| 7 | interface CollectionView {
|
|---|
| 8 | id: number;
|
|---|
| 9 | title: string;
|
|---|
| 10 | cover?: string | null;
|
|---|
| 11 | genre?: string;
|
|---|
| 12 | type: string;
|
|---|
| 13 | releasedBy: string;
|
|---|
| 14 | isLikedByCurrentUser?: boolean;
|
|---|
| 15 | songs: Song[];
|
|---|
| 16 | }
|
|---|
| 17 |
|
|---|
| 18 | const MusicalCollection = () => {
|
|---|
| 19 | const { type, id } = useParams();
|
|---|
| 20 | const [collection, setCollection] = useState<CollectionView | null>(null);
|
|---|
| 21 | const [isLoading, setIsLoading] = useState(true);
|
|---|
| 22 | const [error, setError] = useState<string | null>(null);
|
|---|
| 23 | const [openDropdownSongId, setOpenDropdownSongId] = useState<number | null>(
|
|---|
| 24 | null,
|
|---|
| 25 | );
|
|---|
| 26 |
|
|---|
| 27 | const normalizeCollection = (
|
|---|
| 28 | data: Album | Playlist,
|
|---|
| 29 | type: string,
|
|---|
| 30 | ): CollectionView => {
|
|---|
| 31 | if (type === "album") {
|
|---|
| 32 | const album = data as Album;
|
|---|
| 33 | return {
|
|---|
| 34 | id: album.id,
|
|---|
| 35 | title: album.title,
|
|---|
| 36 | cover: album.cover,
|
|---|
| 37 | genre: album.genre,
|
|---|
| 38 | type: album.type,
|
|---|
| 39 | releasedBy: album.releasedBy,
|
|---|
| 40 | isLikedByCurrentUser: album.isLikedByCurrentUser,
|
|---|
| 41 | songs: album.songs,
|
|---|
| 42 | };
|
|---|
| 43 | } else {
|
|---|
| 44 | const playlist = data as Playlist;
|
|---|
| 45 | return {
|
|---|
| 46 | id: playlist.id,
|
|---|
| 47 | title: playlist.name,
|
|---|
| 48 | cover: playlist.cover,
|
|---|
| 49 | genre: undefined,
|
|---|
| 50 | type: "PLAYLIST",
|
|---|
| 51 | releasedBy: playlist.creatorName,
|
|---|
| 52 | isLikedByCurrentUser: undefined,
|
|---|
| 53 | songs: playlist.songsInPlaylist,
|
|---|
| 54 | };
|
|---|
| 55 | }
|
|---|
| 56 | };
|
|---|
| 57 |
|
|---|
| 58 | const toggleLike = async (songId: number) => {
|
|---|
| 59 | try {
|
|---|
| 60 | await axiosInstance.post(`/musical-entity/${songId}/like`);
|
|---|
| 61 | setCollection((prev) => {
|
|---|
| 62 | if (!prev) return null;
|
|---|
| 63 | return {
|
|---|
| 64 | ...prev,
|
|---|
| 65 | songs: prev.songs.map((s) =>
|
|---|
| 66 | s.id === songId
|
|---|
| 67 | ? { ...s, isLikedByCurrentUser: !s.isLikedByCurrentUser }
|
|---|
| 68 | : s,
|
|---|
| 69 | ),
|
|---|
| 70 | };
|
|---|
| 71 | });
|
|---|
| 72 | } catch (err) {
|
|---|
| 73 | console.error("Error toggling like:", err);
|
|---|
| 74 | }
|
|---|
| 75 | };
|
|---|
| 76 |
|
|---|
| 77 | const toggleCollectionLike = async () => {
|
|---|
| 78 | if (!collection) return;
|
|---|
| 79 | try {
|
|---|
| 80 | await axiosInstance.post(`/musical-entity/${collection.id}/like`);
|
|---|
| 81 | setCollection((prev) => {
|
|---|
| 82 | if (!prev) return null;
|
|---|
| 83 | return { ...prev, isLikedByCurrentUser: !prev.isLikedByCurrentUser };
|
|---|
| 84 | });
|
|---|
| 85 | } catch (err) {
|
|---|
| 86 | console.error("Error toggling collection like:", err);
|
|---|
| 87 | }
|
|---|
| 88 | };
|
|---|
| 89 |
|
|---|
| 90 | useEffect(() => {
|
|---|
| 91 | const fetchData = async () => {
|
|---|
| 92 | setIsLoading(true);
|
|---|
| 93 | setError(null);
|
|---|
| 94 | try {
|
|---|
| 95 | const endpoint =
|
|---|
| 96 | type === "album" ? `/albums/${id}` : `/playlists/${id}`;
|
|---|
| 97 | const response = await axiosInstance.get(endpoint);
|
|---|
| 98 |
|
|---|
| 99 | const normalized = normalizeCollection(response.data, type!);
|
|---|
| 100 | setCollection(normalized);
|
|---|
| 101 | } catch (err: any) {
|
|---|
| 102 | setError(getErrorMessage(err));
|
|---|
| 103 | } finally {
|
|---|
| 104 | setIsLoading(false);
|
|---|
| 105 | }
|
|---|
| 106 | };
|
|---|
| 107 | fetchData();
|
|---|
| 108 | }, [id, type]);
|
|---|
| 109 |
|
|---|
| 110 | if (isLoading) {
|
|---|
| 111 | return (
|
|---|
| 112 | <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center">
|
|---|
| 113 | <div className="flex flex-col items-center gap-4">
|
|---|
| 114 | <div className="w-12 h-12 border-4 border-white/10 border-t-[#1db954] rounded-full animate-spin" />
|
|---|
| 115 | <p className="text-gray-400 text-lg">Loading collection…</p>
|
|---|
| 116 | </div>
|
|---|
| 117 | </div>
|
|---|
| 118 | );
|
|---|
| 119 | }
|
|---|
| 120 |
|
|---|
| 121 | if (error) {
|
|---|
| 122 | return (
|
|---|
| 123 | <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center">
|
|---|
| 124 | <div className="text-center">
|
|---|
| 125 | <p className="text-red-400 text-xl mb-4">{error}</p>
|
|---|
| 126 | <Link to="/" className="text-[#1db954] hover:underline text-sm">
|
|---|
| 127 | ← Back to Home
|
|---|
| 128 | </Link>
|
|---|
| 129 | </div>
|
|---|
| 130 | </div>
|
|---|
| 131 | );
|
|---|
| 132 | }
|
|---|
| 133 |
|
|---|
| 134 | if (!collection) return null;
|
|---|
| 135 |
|
|---|
| 136 | return (
|
|---|
| 137 | <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] text-white">
|
|---|
| 138 | <div className="max-w-5xl mx-auto px-6 py-10">
|
|---|
| 139 | {/* Back link */}
|
|---|
| 140 | <Link
|
|---|
| 141 | to="/"
|
|---|
| 142 | className="inline-flex items-center gap-1 text-gray-400 hover:text-white text-sm mb-8 transition-colors"
|
|---|
| 143 | >
|
|---|
| 144 | <svg
|
|---|
| 145 | className="w-4 h-4"
|
|---|
| 146 | fill="none"
|
|---|
| 147 | stroke="currentColor"
|
|---|
| 148 | viewBox="0 0 24 24"
|
|---|
| 149 | >
|
|---|
| 150 | <path
|
|---|
| 151 | strokeLinecap="round"
|
|---|
| 152 | strokeLinejoin="round"
|
|---|
| 153 | strokeWidth={2}
|
|---|
| 154 | d="M15 19l-7-7 7-7"
|
|---|
| 155 | />
|
|---|
| 156 | </svg>
|
|---|
| 157 | Back to Home
|
|---|
| 158 | </Link>
|
|---|
| 159 |
|
|---|
| 160 | {/* Hero section */}
|
|---|
| 161 | <div className="flex flex-col md:flex-row gap-8 mb-10">
|
|---|
| 162 | {/* Cover art */}
|
|---|
| 163 | <div className="w-full md:w-72 shrink-0">
|
|---|
| 164 | <div className="relative w-full pt-[100%] rounded-xl overflow-hidden shadow-2xl bg-[#181818]">
|
|---|
| 165 | <img
|
|---|
| 166 | src={
|
|---|
| 167 | collection.cover
|
|---|
| 168 | ? `${baseURL}/${collection.cover}`
|
|---|
| 169 | : "/favicon.png"
|
|---|
| 170 | }
|
|---|
| 171 | alt={collection.title}
|
|---|
| 172 | className="absolute inset-0 w-full h-full object-cover"
|
|---|
| 173 | onError={(e) => {
|
|---|
| 174 | (e.target as HTMLImageElement).src = "/favicon.png";
|
|---|
| 175 | }}
|
|---|
| 176 | />
|
|---|
| 177 | </div>
|
|---|
| 178 | </div>
|
|---|
| 179 |
|
|---|
| 180 | {/* Collection info */}
|
|---|
| 181 | <div className="flex flex-col justify-end gap-3 min-w-0">
|
|---|
| 182 | <span className="text-xs uppercase tracking-widest text-gray-400 font-medium">
|
|---|
| 183 | {collection.genre ? `${collection.genre} • ` : ""}
|
|---|
| 184 | {collection.type === "PLAYLIST" ? "Playlist" : "Album"}
|
|---|
| 185 | </span>
|
|---|
| 186 | <h1 className="text-4xl md:text-5xl font-extrabold leading-tight truncate">
|
|---|
| 187 | {collection.title}
|
|---|
| 188 | </h1>
|
|---|
| 189 |
|
|---|
| 190 | <p className="text-xl text-gray-300 font-semibold">
|
|---|
| 191 | {collection.releasedBy}
|
|---|
| 192 | </p>
|
|---|
| 193 |
|
|---|
| 194 | {collection.songs && (
|
|---|
| 195 | <p className="text-sm text-gray-500">
|
|---|
| 196 | {collection.songs.length} song
|
|---|
| 197 | {collection.songs.length !== 1 ? "s" : ""}
|
|---|
| 198 | </p>
|
|---|
| 199 | )}
|
|---|
| 200 |
|
|---|
| 201 | {/* Action buttons */}
|
|---|
| 202 | <div className="flex items-center gap-3 mt-4">
|
|---|
| 203 | {type === "album" && (
|
|---|
| 204 | <button
|
|---|
| 205 | onClick={toggleCollectionLike}
|
|---|
| 206 | className={`flex items-center gap-2 px-5 py-3 rounded-full text-sm font-semibold transition-colors cursor-pointer ${
|
|---|
| 207 | collection.isLikedByCurrentUser
|
|---|
| 208 | ? "bg-[#1db954] text-black"
|
|---|
| 209 | : "bg-white/10 text-white hover:bg-white/20"
|
|---|
| 210 | }`}
|
|---|
| 211 | >
|
|---|
| 212 | <svg
|
|---|
| 213 | className="w-5 h-5"
|
|---|
| 214 | fill={
|
|---|
| 215 | collection.isLikedByCurrentUser ? "currentColor" : "none"
|
|---|
| 216 | }
|
|---|
| 217 | stroke="currentColor"
|
|---|
| 218 | viewBox="0 0 24 24"
|
|---|
| 219 | >
|
|---|
| 220 | <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
|---|
| 221 | </svg>
|
|---|
| 222 | {collection.isLikedByCurrentUser ? "Liked" : "Like"}
|
|---|
| 223 | </button>
|
|---|
| 224 | )}
|
|---|
| 225 | </div>
|
|---|
| 226 | </div>
|
|---|
| 227 | </div>
|
|---|
| 228 |
|
|---|
| 229 | {/* Songs list */}
|
|---|
| 230 | <div className="border-t border-white/10 pt-6">
|
|---|
| 231 | <h2 className="text-2xl font-bold mb-4">Songs</h2>
|
|---|
| 232 |
|
|---|
| 233 | {collection.songs && collection.songs.length > 0 ? (
|
|---|
| 234 | <div className="space-y-1">
|
|---|
| 235 | {collection.songs.map((song, index) => (
|
|---|
| 236 | <SongItem
|
|---|
| 237 | key={song.id}
|
|---|
| 238 | song={song}
|
|---|
| 239 | index={index + 1}
|
|---|
| 240 | onLikeToggle={() => toggleLike(song.id)}
|
|---|
| 241 | isDropdownOpen={openDropdownSongId === song.id}
|
|---|
| 242 | onDropdownToggle={setOpenDropdownSongId}
|
|---|
| 243 | />
|
|---|
| 244 | ))}
|
|---|
| 245 | </div>
|
|---|
| 246 | ) : (
|
|---|
| 247 | <div className="text-center py-12 text-gray-400">
|
|---|
| 248 | <p className="text-lg">No songs available</p>
|
|---|
| 249 | </div>
|
|---|
| 250 | )}
|
|---|
| 251 | </div>
|
|---|
| 252 | </div>
|
|---|
| 253 | </div>
|
|---|
| 254 | );
|
|---|
| 255 | };
|
|---|
| 256 |
|
|---|
| 257 | export default MusicalCollection;
|
|---|