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

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

create playlists from sidebar

  • Property mode set to 100644
File size: 7.7 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 handleCreateNewPlaylist = () => {
71 console.log(`Creating new playlist for song ${song.id}`);
72 // TODO: Implement actual playlist creation
73 setPlaylistOpen(false);
74 };
75
76 const subtitleParts: string[] = [];
77 if (label) subtitleParts.push(label);
78 if (song.releasedBy) subtitleParts.push(song.releasedBy);
79 const subtitle = subtitleParts.join(" • ");
80
81 return (
82 <div
83 onClick={() => navigate(`/songs/${song.id}`)}
84 onMouseEnter={() => {
85 if (onDropdownToggle && !playlistOpen) {
86 onDropdownToggle(null);
87 }
88 }}
89 className="flex items-center gap-4 p-3 rounded-lg hover:bg-white/5 cursor-pointer transition-colors group"
90 >
91 {/* Optional index */}
92 {index != null && (
93 <span className="text-gray-500 font-medium w-8 text-center text-sm shrink-0">
94 {index}
95 </span>
96 )}
97
98 {/* Cover */}
99 <img
100 src={song.cover ? `${baseURL}/${song.cover}` : "/favicon.png"}
101 alt={song.title}
102 className="w-12 h-12 rounded object-cover shrink-0"
103 onError={(e) => {
104 (e.target as HTMLImageElement).src = "/favicon.png";
105 }}
106 />
107
108 {/* Title & subtitle */}
109 <div className="flex-1 min-w-0">
110 <p
111 className={`font-medium truncate ${
112 isPlaying ? "text-[#1db954]" : "text-white"
113 }`}
114 >
115 {song.title}
116 </p>
117 {subtitle && (
118 <p className="text-sm text-gray-400 truncate">{subtitle}</p>
119 )}
120 </div>
121
122 {/* Role badge (artist contributions) */}
123 {role && (
124 <span
125 className={`px-3 py-1 rounded-full text-xs font-medium hidden sm:block ${
126 ROLE_COLORS[role] || "bg-white/10 text-gray-300"
127 }`}
128 >
129 {role.replace("_", " ")}
130 </span>
131 )}
132
133 {/* Genre */}
134 {song.genre && (
135 <span className="text-xs text-gray-500 uppercase tracking-wider mr-2 hidden sm:block">
136 {song.genre}
137 </span>
138 )}
139
140 {/* Play button */}
141 {song.link && (
142 <button
143 onClick={(e) => {
144 e.stopPropagation();
145 play({
146 id: song.id,
147 title: song.title,
148 artist: song.releasedBy || "",
149 cover: song.cover,
150 embedUrl: toEmbedUrl(song.link!),
151 });
152 }}
153 className={`p-2 rounded-full transition-all cursor-pointer ${
154 isPlaying
155 ? "bg-white text-[#1db954] opacity-100"
156 : "bg-[#1db954] text-black hover:scale-110 opacity-0 group-hover:opacity-100"
157 }`}
158 aria-label={isPlaying ? "Now playing" : "Play song"}
159 >
160 {isPlaying ? (
161 <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
162 <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
163 </svg>
164 ) : (
165 <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
166 <path d="M8 5v14l11-7z" />
167 </svg>
168 )}
169 </button>
170 )}
171
172 {/* Like button */}
173 {onLikeToggle && (
174 <button
175 onClick={(e) => {
176 e.stopPropagation();
177 onLikeToggle(song.id);
178 }}
179 className="p-2 hover:bg-white/10 rounded-full transition-colors cursor-pointer"
180 aria-label={song.isLikedByCurrentUser ? "Unlike" : "Like"}
181 >
182 <svg
183 className="w-5 h-5"
184 fill={song.isLikedByCurrentUser ? "#ef4444" : "none"}
185 stroke={song.isLikedByCurrentUser ? "#ef4444" : "#6b7280"}
186 strokeWidth="2"
187 viewBox="0 0 24 24"
188 >
189 <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" />
190 </svg>
191 </button>
192 )}
193
194 {/* Three-dot menu */}
195 <div className="relative">
196 <button
197 ref={buttonRef}
198 onClick={(e) => {
199 e.stopPropagation();
200 if (playlistOpen) {
201 setPlaylistOpen(false);
202 } else {
203 if (buttonRef.current) {
204 const rect = buttonRef.current.getBoundingClientRect();
205 const spaceBelow = window.innerHeight - rect.bottom;
206 const dropdownHeight = 260;
207
208 if (spaceBelow < dropdownHeight) {
209 setDropdownDirection("above");
210 setDropdownPosition({
211 top: rect.top - 8,
212 left: rect.right - 224,
213 });
214 } else {
215 setDropdownDirection("below");
216 setDropdownPosition({
217 top: rect.bottom + 8,
218 left: rect.right - 224,
219 });
220 }
221 }
222 setPlaylistOpen(true);
223 }
224 }}
225 className="p-2 hover:bg-white/10 rounded-full transition-colors cursor-pointer text-gray-400 hover:text-white"
226 aria-label="More options"
227 >
228 <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
229 <circle cx="12" cy="5" r="1.5" />
230 <circle cx="12" cy="12" r="1.5" />
231 <circle cx="12" cy="19" r="1.5" />
232 </svg>
233 </button>
234
235 <PlaylistDropdown
236 songId={song.id}
237 isOpen={playlistOpen}
238 onClose={() => setPlaylistOpen(false)}
239 position={dropdownPosition}
240 usePortal={true}
241 direction={dropdownDirection}
242 onCreateNewPlaylist={handleCreateNewPlaylist}
243 />
244 </div>
245 </div>
246 );
247};
248
249export default SongItem;
Note: See TracBrowser for help on using the repository browser.