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

main
Last change on this file since db5fb23 was 1579b4f, checked in by Filip Gavrilovski <filipgavrilovski28@…>, 5 months ago

refactor artist, collection, listener components to have similar style as other components; refactor some dto-s

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