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