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

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

hide actions from unauthenticated users

  • Property mode set to 100644
File size: 7.7 KB
Line 
1import { use, 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";
7import { useAuth } from "../context/authContext";
8
9export interface SongItemData {
10 id: number;
11 title: string;
12 cover?: string | null;
13 genre?: string;
14 link?: string | null;
15 releasedBy?: string;
16 isLikedByCurrentUser?: boolean;
17}
18
19interface SongItemProps {
20 song: SongItemData;
21 /** Optional label shown before the artist, e.g. "Song" for search results */
22 label?: string;
23 /** Optional role badge for artist contributions, e.g. "PERFORMER" */
24 role?: string;
25 /** Optional index number for playlist/collection views */
26 index?: number;
27 /** Callback when the like button is clicked */
28 onLikeToggle?: (songId: number) => void;
29 /** Controlled: whether the playlist dropdown is open */
30 isDropdownOpen?: boolean;
31 /** Controlled: callback when dropdown should open/close */
32 onDropdownToggle?: (songId: number | null) => void;
33}
34
35const ROLE_COLORS: { [key: string]: string } = {
36 COMPOSER: "bg-purple-500/20 text-purple-300",
37 PERFORMER: "bg-blue-500/20 text-blue-300",
38 PRODUCER: "bg-green-500/20 text-green-300",
39 MAIN_VOCAL: "bg-pink-500/20 text-pink-300",
40};
41
42const SongItem = ({
43 song,
44 label,
45 role,
46 index,
47 onLikeToggle,
48 isDropdownOpen,
49 onDropdownToggle,
50}: SongItemProps) => {
51 const navigate = useNavigate();
52 const { play, currentSong } = usePlayer();
53 const { user } = useAuth();
54 const [internalOpen, setInternalOpen] = useState(false);
55 const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
56 const [dropdownDirection, setDropdownDirection] = useState<"above" | "below">(
57 "below",
58 );
59 const buttonRef = useRef<HTMLButtonElement>(null);
60
61 const playlistOpen = isDropdownOpen ?? internalOpen;
62 const setPlaylistOpen = (open: boolean) => {
63 if (onDropdownToggle) {
64 onDropdownToggle(open ? song.id : null);
65 } else {
66 setInternalOpen(open);
67 }
68 };
69
70 const isPlaying = currentSong?.id === song.id;
71
72 const subtitleParts: string[] = [];
73 if (label) subtitleParts.push(label);
74 if (song.releasedBy) subtitleParts.push(song.releasedBy);
75 const subtitle = subtitleParts.join(" • ");
76
77 return (
78 <div
79 onClick={() => navigate(`/songs/${song.id}`)}
80 onMouseEnter={() => {
81 if (onDropdownToggle && !playlistOpen) {
82 onDropdownToggle(null);
83 }
84 }}
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 ? `${baseURL}/${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 {user && 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 {user && (
192 <div className="relative">
193 <button
194 ref={buttonRef}
195 onClick={(e) => {
196 e.stopPropagation();
197 if (playlistOpen) {
198 setPlaylistOpen(false);
199 } else {
200 if (buttonRef.current) {
201 const rect = buttonRef.current.getBoundingClientRect();
202 const spaceBelow = window.innerHeight - rect.bottom;
203 const dropdownHeight = 260;
204
205 if (spaceBelow < dropdownHeight) {
206 setDropdownDirection("above");
207 setDropdownPosition({
208 top: rect.top - 8,
209 left: rect.right - 224,
210 });
211 } else {
212 setDropdownDirection("below");
213 setDropdownPosition({
214 top: rect.bottom + 8,
215 left: rect.right - 224,
216 });
217 }
218 }
219 setPlaylistOpen(true);
220 }
221 }}
222 className="p-2 hover:bg-white/10 rounded-full transition-colors cursor-pointer text-gray-400 hover:text-white"
223 aria-label="More options"
224 >
225 <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
226 <circle cx="12" cy="5" r="1.5" />
227 <circle cx="12" cy="12" r="1.5" />
228 <circle cx="12" cy="19" r="1.5" />
229 </svg>
230 </button>
231
232 <PlaylistDropdown
233 songId={song.id}
234 isOpen={playlistOpen}
235 onClose={() => setPlaylistOpen(false)}
236 position={dropdownPosition}
237 usePortal={true}
238 direction={dropdownDirection}
239 />
240 </div>
241 )}
242
243 {/* Three-dot menu */}
244 </div>
245 );
246};
247
248export default SongItem;
Note: See TracBrowser for help on using the repository browser.