source: frontend/src/components/SongItem.tsx@ d1ee039

main
Last change on this file since d1ee039 was d1ee039, checked in by Dimitar Arsov <dimitararsov04@…>, 5 months ago

create new playlist, delete playlists and add song to playlist on playlist creation

  • Property mode set to 100644
File size: 7.4 KB
Line 
1import { useRef, useState } from "react";
2import { useNavigate } from "react-router-dom";
3import { baseURL } from "../api/axiosInstance";
4import { usePlayer } from "../context/playerContext";
5import { toEmbedUrl } from "../utils/utils";
6import PlaylistDropdown from "./playlist/PlaylistDropdown";
7
8export interface SongItemData {
9 id: number;
10 title: string;
11 cover?: string | null;
12 genre?: string;
13 link?: string | null;
14 releasedBy?: string;
15 isLikedByCurrentUser?: boolean;
16}
17
18interface SongItemProps {
19 song: SongItemData;
20 /** Optional label shown before the artist, e.g. "Song" for search results */
21 label?: string;
22 /** Optional role badge for artist contributions, e.g. "PERFORMER" */
23 role?: string;
24 /** Optional index number for playlist/collection views */
25 index?: number;
26 /** Callback when the like button is clicked */
27 onLikeToggle?: (songId: number) => void;
28 /** Controlled: whether the playlist dropdown is open */
29 isDropdownOpen?: boolean;
30 /** Controlled: callback when dropdown should open/close */
31 onDropdownToggle?: (songId: number | null) => void;
32}
33
34const ROLE_COLORS: { [key: string]: string } = {
35 COMPOSER: "bg-purple-500/20 text-purple-300",
36 PERFORMER: "bg-blue-500/20 text-blue-300",
37 PRODUCER: "bg-green-500/20 text-green-300",
38 MAIN_VOCAL: "bg-pink-500/20 text-pink-300",
39};
40
41const SongItem = ({
42 song,
43 label,
44 role,
45 index,
46 onLikeToggle,
47 isDropdownOpen,
48 onDropdownToggle,
49}: SongItemProps) => {
50 const navigate = useNavigate();
51 const { play, currentSong } = usePlayer();
52 const [internalOpen, setInternalOpen] = useState(false);
53 const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
54 const [dropdownDirection, setDropdownDirection] = useState<"above" | "below">(
55 "below",
56 );
57 const buttonRef = useRef<HTMLButtonElement>(null);
58
59 const playlistOpen = isDropdownOpen ?? internalOpen;
60 const setPlaylistOpen = (open: boolean) => {
61 if (onDropdownToggle) {
62 onDropdownToggle(open ? song.id : null);
63 } else {
64 setInternalOpen(open);
65 }
66 };
67
68 const isPlaying = currentSong?.id === song.id;
69
70 const subtitleParts: string[] = [];
71 if (label) subtitleParts.push(label);
72 if (song.releasedBy) subtitleParts.push(song.releasedBy);
73 const subtitle = subtitleParts.join(" • ");
74
75 return (
76 <div
77 onClick={() => navigate(`/songs/${song.id}`)}
78 onMouseEnter={() => {
79 if (onDropdownToggle && !playlistOpen) {
80 onDropdownToggle(null);
81 }
82 }}
83 className="flex items-center gap-4 p-3 rounded-lg hover:bg-white/5 cursor-pointer transition-colors group"
84 >
85 {/* Optional index */}
86 {index != null && (
87 <span className="text-gray-500 font-medium w-8 text-center text-sm shrink-0">
88 {index}
89 </span>
90 )}
91
92 {/* Cover */}
93 <img
94 src={song.cover ? `${baseURL}/${song.cover}` : "/favicon.png"}
95 alt={song.title}
96 className="w-12 h-12 rounded object-cover shrink-0"
97 onError={(e) => {
98 (e.target as HTMLImageElement).src = "/favicon.png";
99 }}
100 />
101
102 {/* Title & subtitle */}
103 <div className="flex-1 min-w-0">
104 <p
105 className={`font-medium truncate ${
106 isPlaying ? "text-[#1db954]" : "text-white"
107 }`}
108 >
109 {song.title}
110 </p>
111 {subtitle && (
112 <p className="text-sm text-gray-400 truncate">{subtitle}</p>
113 )}
114 </div>
115
116 {/* Role badge (artist contributions) */}
117 {role && (
118 <span
119 className={`px-3 py-1 rounded-full text-xs font-medium hidden sm:block ${
120 ROLE_COLORS[role] || "bg-white/10 text-gray-300"
121 }`}
122 >
123 {role.replace("_", " ")}
124 </span>
125 )}
126
127 {/* Genre */}
128 {song.genre && (
129 <span className="text-xs text-gray-500 uppercase tracking-wider mr-2 hidden sm:block">
130 {song.genre}
131 </span>
132 )}
133
134 {/* Play button */}
135 {song.link && (
136 <button
137 onClick={(e) => {
138 e.stopPropagation();
139 play({
140 id: song.id,
141 title: song.title,
142 artist: song.releasedBy || "",
143 cover: song.cover,
144 embedUrl: toEmbedUrl(song.link!),
145 });
146 }}
147 className={`p-2 rounded-full transition-all cursor-pointer ${
148 isPlaying
149 ? "bg-white text-[#1db954] opacity-100"
150 : "bg-[#1db954] text-black hover:scale-110 opacity-0 group-hover:opacity-100"
151 }`}
152 aria-label={isPlaying ? "Now playing" : "Play song"}
153 >
154 {isPlaying ? (
155 <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
156 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
157 </svg>
158 ) : (
159 <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
160 <path d="M8 5v14l11-7z" />
161 </svg>
162 )}
163 </button>
164 )}
165
166 {/* Like button */}
167 {onLikeToggle && (
168 <button
169 onClick={(e) => {
170 e.stopPropagation();
171 onLikeToggle(song.id);
172 }}
173 className="p-2 hover:bg-white/10 rounded-full transition-colors cursor-pointer"
174 aria-label={song.isLikedByCurrentUser ? "Unlike" : "Like"}
175 >
176 <svg
177 className="w-5 h-5"
178 fill={song.isLikedByCurrentUser ? "#ef4444" : "none"}
179 stroke={song.isLikedByCurrentUser ? "#ef4444" : "#6b7280"}
180 strokeWidth="2"
181 viewBox="0 0 24 24"
182 >
183 <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" />
184 </svg>
185 </button>
186 )}
187
188 {/* Three-dot menu */}
189 <div className="relative">
190 <button
191 ref={buttonRef}
192 onClick={(e) => {
193 e.stopPropagation();
194 if (playlistOpen) {
195 setPlaylistOpen(false);
196 } else {
197 if (buttonRef.current) {
198 const rect = buttonRef.current.getBoundingClientRect();
199 const spaceBelow = window.innerHeight - rect.bottom;
200 const dropdownHeight = 260;
201
202 if (spaceBelow < dropdownHeight) {
203 setDropdownDirection("above");
204 setDropdownPosition({
205 top: rect.top - 8,
206 left: rect.right - 224,
207 });
208 } else {
209 setDropdownDirection("below");
210 setDropdownPosition({
211 top: rect.bottom + 8,
212 left: rect.right - 224,
213 });
214 }
215 }
216 setPlaylistOpen(true);
217 }
218 }}
219 className="p-2 hover:bg-white/10 rounded-full transition-colors cursor-pointer text-gray-400 hover:text-white"
220 aria-label="More options"
221 >
222 <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
223 <circle cx="12" cy="5" r="1.5" />
224 <circle cx="12" cy="12" r="1.5" />
225 <circle cx="12" cy="19" r="1.5" />
226 </svg>
227 </button>
228
229 <PlaylistDropdown
230 songId={song.id}
231 isOpen={playlistOpen}
232 onClose={() => setPlaylistOpen(false)}
233 position={dropdownPosition}
234 usePortal={true}
235 direction={dropdownDirection}
236 />
237 </div>
238 </div>
239 );
240};
241
242export default SongItem;
Note: See TracBrowser for help on using the repository browser.