source: frontend/src/components/PlaylistDropdown.tsx@ ce45c7a

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

add song to playlist

  • Property mode set to 100644
File size: 7.7 KB
Line 
1import { useEffect, useRef, useState } from "react";
2import { createPortal } from "react-dom";
3import axiosInstance from "../api/axiosInstance";
4import { useCreatedPlaylists } from "../context/playlistContext";
5
6interface PlaylistDropdownProps {
7 songId: number;
8 isOpen: boolean;
9 onClose: () => void;
10 position?: { top: number; left: number };
11
12 usePortal?: boolean;
13
14 direction?: "above" | "below";
15
16 onCreateNewPlaylist?: () => void;
17}
18
19const PlaylistDropdown = ({
20 songId,
21 isOpen,
22 onClose,
23 position,
24 usePortal = false,
25 direction = "above",
26 onCreateNewPlaylist,
27}: PlaylistDropdownProps) => {
28 const { createdPlaylists, refreshPlaylists } = useCreatedPlaylists();
29 const [containingPlaylistIds, setContainingPlaylistIds] = useState<number[]>(
30 [],
31 );
32 const [loading, setLoading] = useState(false);
33 const [processingPlaylistId, setProcessingPlaylistId] = useState<
34 number | null
35 >(null);
36 const dropdownRef = useRef<HTMLDivElement>(null);
37
38 useEffect(() => {
39 if (isOpen && songId) {
40 const fetchPlaylistIds = async () => {
41 setLoading(true);
42 setContainingPlaylistIds([]);
43 try {
44 const response = await axiosInstance.get<number[]>(
45 `/playlists/song/${songId}`,
46 );
47 setContainingPlaylistIds(response.data);
48 } catch (error) {
49 console.error("Failed to fetch song presence:", error);
50 } finally {
51 setLoading(false);
52 }
53 };
54 fetchPlaylistIds();
55 } else {
56 setContainingPlaylistIds([]);
57 }
58 }, [isOpen, songId]);
59
60 useEffect(() => {
61 const handleClickOutside = (event: MouseEvent) => {
62 if (
63 dropdownRef.current &&
64 !dropdownRef.current.contains(event.target as Node)
65 ) {
66 onClose();
67 }
68 };
69
70 if (isOpen) {
71 document.addEventListener("mousedown", handleClickOutside);
72 }
73 return () => document.removeEventListener("mousedown", handleClickOutside);
74 }, [isOpen, onClose]);
75
76 const handleTogglePlaylist = async (playlistId: number, songId: number) => {
77 if (processingPlaylistId !== null) return;
78
79 setProcessingPlaylistId(playlistId);
80
81 try {
82 const response = await axiosInstance.post<{
83 playlistId: number;
84 isSongAddedToPlaylist: boolean;
85 }>(`/playlists/${playlistId}/song/${songId}`);
86
87 const { playlistId: returnedPlaylistId, isSongAddedToPlaylist } =
88 response.data;
89
90 if (isSongAddedToPlaylist) {
91 setContainingPlaylistIds((prev) =>
92 prev.includes(returnedPlaylistId)
93 ? prev
94 : [...prev, returnedPlaylistId],
95 );
96 } else {
97 setContainingPlaylistIds((prev) =>
98 prev.filter((id) => id !== returnedPlaylistId),
99 );
100 }
101 } catch (error) {
102 console.error("Failed to toggle playlist:", error);
103 } finally {
104 refreshPlaylists(true);
105 setTimeout(() => {
106 setProcessingPlaylistId(null);
107 }, 500);
108 }
109 };
110
111 const handleCreateNew = () => {
112 onCreateNewPlaylist?.();
113 onClose();
114 };
115
116 if (!isOpen) return null;
117
118 const inlinePositionClass =
119 direction === "below"
120 ? "absolute left-0 top-full mt-2"
121 : "absolute right-0 bottom-full mb-2";
122
123 const dropdownContent = (
124 <div
125 ref={dropdownRef}
126 className={`${usePortal ? "fixed" : inlinePositionClass} w-56 bg-[#282828] rounded-lg shadow-2xl py-2 z-[9999] border border-white/10 max-h-60 overflow-y-auto custom-scrollbar`}
127 style={
128 usePortal && position
129 ? {
130 top: position.top,
131 left: position.left,
132 transform:
133 direction === "below" ? undefined : "translateY(-100%)",
134 }
135 : undefined
136 }
137 onClick={(e) => e.stopPropagation()}
138 >
139 <div className="px-4 py-2 text-xs font-bold text-gray-400 uppercase tracking-wider border-b border-white/5 mb-1">
140 Add to playlist
141 </div>
142
143 {loading ? (
144 <div className="flex items-center justify-center py-4">
145 <div className="w-5 h-5 border-2 border-white/10 border-t-[#1db954] rounded-full animate-spin"></div>
146 </div>
147 ) : createdPlaylists && createdPlaylists.length > 0 ? (
148 createdPlaylists.map((playlist) => {
149 const isProcessing = processingPlaylistId === playlist.id;
150 const isChecked = containingPlaylistIds.includes(playlist.id);
151
152 return (
153 <label
154 key={playlist.id}
155 className={`flex items-center px-4 py-2 hover:bg-white/10 transition-colors group/item ${
156 isProcessing ? "pointer-events-none" : "cursor-pointer"
157 }`}
158 onClick={(e) => e.stopPropagation()}
159 >
160 <div className="relative flex items-center justify-center">
161 <input
162 type="checkbox"
163 className="peer sr-only"
164 checked={isChecked}
165 disabled={processingPlaylistId !== null}
166 onChange={() => handleTogglePlaylist(playlist.id, songId)}
167 />
168 <div
169 className={`w-5 h-5 border-2 rounded bg-[#181818] transition-all ${
170 isProcessing
171 ? "border-[#1db954] animate-pulse"
172 : isChecked
173 ? "bg-[#1db954] border-[#1db954]"
174 : "border-gray-500"
175 }`}
176 >
177 {isProcessing && (
178 <div className="absolute inset-0 flex items-center justify-center">
179 <div className="w-3 h-3 border-2 border-transparent border-t-[#1db954] rounded-full animate-spin"></div>
180 </div>
181 )}
182 </div>
183 <svg
184 className={`absolute w-3 h-3 transition-opacity ${
185 isChecked && !isProcessing ? "opacity-100" : "opacity-0"
186 }`}
187 fill="none"
188 stroke="currentColor"
189 strokeWidth="3"
190 viewBox="0 0 24 24"
191 >
192 <path
193 strokeLinecap="round"
194 strokeLinejoin="round"
195 d="M5 13l4 4L19 7"
196 />
197 </svg>
198 </div>
199 <span
200 className={`ml-3 text-sm truncate transition-all ${
201 isProcessing
202 ? "text-[#1db954] animate-pulse"
203 : "text-gray-200 group-hover/item:text-white"
204 }`}
205 >
206 {playlist.name}
207 </span>
208 {isProcessing && (
209 <span className="ml-auto text-xs text-[#1db954] animate-pulse">
210 •••
211 </span>
212 )}
213 </label>
214 );
215 })
216 ) : (
217 <div className="px-4 py-3 text-sm text-gray-500 italic">
218 No playlists created
219 </div>
220 )}
221
222 <button
223 onClick={(e) => {
224 e.stopPropagation();
225 handleCreateNew();
226 }}
227 className="w-full text-left px-4 py-2 mt-1 text-sm text-[#1db954] hover:bg-white/5 transition-colors border-t border-white/5 flex items-center gap-2 font-medium cursor-pointer"
228 >
229 <svg
230 className="w-4 h-4"
231 fill="none"
232 stroke="currentColor"
233 viewBox="0 0 24 24"
234 >
235 <path
236 strokeLinecap="round"
237 strokeLinejoin="round"
238 strokeWidth={2}
239 d="M12 4v16m8-8H4"
240 />
241 </svg>
242 Create new playlist
243 </button>
244 </div>
245 );
246
247 return usePortal
248 ? createPortal(dropdownContent, document.body)
249 : dropdownContent;
250};
251
252export default PlaylistDropdown;
Note: See TracBrowser for help on using the repository browser.