source: frontend/src/pages/PublishSong.tsx@ 27660af

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

update form for publishing music - replace mock search with api calls

  • Property mode set to 100644
File size: 31.2 KB
Line 
1import { useCallback, useEffect, useRef, useState } from "react";
2import { Link, useNavigate } from "react-router-dom";
3import { toast } from "react-toastify";
4import axiosInstance from "../api/axiosInstance";
5import { useAuth } from "../context/authContext";
6import { GENRE_OPTIONS, ROLE_OPTIONS } from "../utils/consts";
7import type {
8 ArtistSearchResult,
9 Contributor,
10 SongEntry,
11} from "../utils/types";
12
13const MAX_CONTRIBUTORS = 5;
14const MAX_ALBUM_SONGS = 20;
15
16const PublishSong = () => {
17 const { user } = useAuth();
18 const navigate = useNavigate();
19
20 // Form state
21 const [releaseType, setReleaseType] = useState<"single" | "album">("single");
22 const [title, setTitle] = useState("");
23 const [genre, setGenre] = useState("");
24 const [link, setLink] = useState("");
25 const [_coverFile, setCoverFile] = useState<File | null>(null); // TODO: Use in API call
26 const [coverPreview, setCoverPreview] = useState<string | null>(null);
27 const [contributors, setContributors] = useState<Contributor[]>([]);
28 const [isSubmitting, setIsSubmitting] = useState(false);
29
30 // Album-specific state
31 const [albumSongs, setAlbumSongs] = useState<SongEntry[]>([
32 { title: "", link: "", contributors: [] },
33 ]);
34
35 // Contributor search state
36 const [contributorSearch, setContributorSearch] = useState("");
37 const [searchResults, setSearchResults] = useState<ArtistSearchResult[]>([]);
38 const [isSearching, setIsSearching] = useState(false);
39 const [showSearchDropdown, setShowSearchDropdown] = useState(false);
40 const [selectedContributorRole, setSelectedContributorRole] = useState(
41 ROLE_OPTIONS[0].value,
42 );
43 const searchDropdownRef = useRef<HTMLDivElement>(null);
44 const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
45
46 // Album song contributor search state
47 const [albumSongContributorSearch, setAlbumSongContributorSearch] = useState<{
48 [key: number]: string;
49 }>({});
50 const [albumSongSearchResults, setAlbumSongSearchResults] = useState<{
51 [key: number]: ArtistSearchResult[];
52 }>({});
53 const [albumSongShowDropdown, setAlbumSongShowDropdown] = useState<{
54 [key: number]: boolean;
55 }>({});
56 const [albumSongSelectedRole, setAlbumSongSelectedRole] = useState<{
57 [key: number]: string;
58 }>({});
59
60 // Cleanup cover preview URL on unmount
61 useEffect(() => {
62 return () => {
63 if (coverPreview) {
64 URL.revokeObjectURL(coverPreview);
65 }
66 };
67 }, [coverPreview]);
68
69 // Click outside handler for search dropdown
70 useEffect(() => {
71 const handleClickOutside = (event: MouseEvent) => {
72 if (
73 searchDropdownRef.current &&
74 !searchDropdownRef.current.contains(event.target as Node)
75 ) {
76 setShowSearchDropdown(false);
77 }
78 };
79 document.addEventListener("mousedown", handleClickOutside);
80 return () => document.removeEventListener("mousedown", handleClickOutside);
81 }, []);
82
83 // Search for artists (mock implementation)
84 const searchArtists = useCallback(async (query: string) => {
85 if (!query.trim()) {
86 setSearchResults([]);
87 return;
88 }
89
90 setIsSearching(true);
91 try {
92 const response = await axiosInstance.get(
93 `/users/search?type=ARTIST&q=${encodeURIComponent(query)}&limit=5`,
94 );
95
96 const filtered = response.data.filter(
97 (artist: ArtistSearchResult) =>
98 artist.fullName.toLowerCase().includes(query.toLowerCase()) ||
99 artist.username.toLowerCase().includes(query.toLowerCase()),
100 );
101 setSearchResults(filtered);
102 } catch (error) {
103 console.error("Error searching artists:", error);
104 setSearchResults([]);
105 } finally {
106 setIsSearching(false);
107 }
108 }, []);
109
110 // Debounced search
111 const handleContributorSearchChange = (value: string) => {
112 setContributorSearch(value);
113 setShowSearchDropdown(true);
114
115 if (searchTimeoutRef.current) {
116 clearTimeout(searchTimeoutRef.current);
117 }
118
119 searchTimeoutRef.current = setTimeout(() => {
120 searchArtists(value);
121 }, 300);
122 };
123
124 // Add contributor
125 const handleAddContributor = (artist: ArtistSearchResult) => {
126 if (contributors.length >= MAX_CONTRIBUTORS) {
127 toast.error(`Maximum ${MAX_CONTRIBUTORS} contributors allowed`);
128 return;
129 }
130
131 if (contributors.some((c) => c.username === artist.username)) {
132 toast.error("This contributor is already added");
133 return;
134 }
135
136 setContributors([
137 ...contributors,
138 {
139 username: artist.username,
140 fullName: artist.fullName,
141 role: selectedContributorRole,
142 },
143 ]);
144 setContributorSearch("");
145 setSearchResults([]);
146 setShowSearchDropdown(false);
147 };
148
149 // Remove contributor
150 const handleRemoveContributor = (username: string) => {
151 setContributors(contributors.filter((c) => c.username !== username));
152 };
153
154 // Handle cover file
155 const handleCoverChange = (e: React.ChangeEvent<HTMLInputElement>) => {
156 const file = e.target.files?.[0];
157 if (file) {
158 if (file.size > 2 * 1024 * 1024) {
159 toast.error("Max file size is 5MB");
160 return;
161 }
162 if (!file.type.startsWith("image/")) {
163 toast.error("Only images allowed");
164 return;
165 }
166 if (coverPreview) {
167 URL.revokeObjectURL(coverPreview);
168 }
169 setCoverFile(file);
170 setCoverPreview(URL.createObjectURL(file));
171 }
172 };
173
174 const handleRemoveCover = () => {
175 if (coverPreview) {
176 URL.revokeObjectURL(coverPreview);
177 }
178 setCoverFile(null);
179 setCoverPreview(null);
180 };
181
182 // Album song handlers
183 const handleAddAlbumSong = () => {
184 if (albumSongs.length >= MAX_ALBUM_SONGS) {
185 toast.error(`Maximum ${MAX_ALBUM_SONGS} songs per album`);
186 return;
187 }
188 setAlbumSongs([...albumSongs, { title: "", link: "", contributors: [] }]);
189 };
190
191 const handleRemoveAlbumSong = (index: number) => {
192 if (albumSongs.length === 1) {
193 toast.error("Album must have at least one song");
194 return;
195 }
196 setAlbumSongs(albumSongs.filter((_, i) => i !== index));
197 };
198
199 const handleAlbumSongChange = (
200 index: number,
201 field: keyof SongEntry,
202 value: string | Contributor[],
203 ) => {
204 const updated = [...albumSongs];
205 updated[index] = { ...updated[index], [field]: value };
206 setAlbumSongs(updated);
207 };
208
209 // Album song contributor search
210 const handleAlbumSongContributorSearch = useCallback(
211 async (index: number, query: string) => {
212 setAlbumSongContributorSearch((prev) => ({ ...prev, [index]: query }));
213 setAlbumSongShowDropdown((prev) => ({ ...prev, [index]: true }));
214
215 if (!query.trim()) {
216 setAlbumSongSearchResults((prev) => ({ ...prev, [index]: [] }));
217 return;
218 }
219
220 // TODO: replace with api call
221 const response = await axiosInstance.get(
222 `/users/search?type=ARTIST&q=${encodeURIComponent(query)}&limit=5`,
223 );
224 const filtered = response.data.filter(
225 (artist: ArtistSearchResult) =>
226 artist.fullName.toLowerCase().includes(query.toLowerCase()) ||
227 artist.username.toLowerCase().includes(query.toLowerCase()),
228 );
229 setAlbumSongSearchResults((prev) => ({ ...prev, [index]: filtered }));
230 },
231 [],
232 );
233
234 const handleAddAlbumSongContributor = (
235 songIndex: number,
236 artist: ArtistSearchResult,
237 ) => {
238 const song = albumSongs[songIndex];
239 if (song.contributors.length >= MAX_CONTRIBUTORS) {
240 toast.error(`Maximum ${MAX_CONTRIBUTORS} contributors per song`);
241 return;
242 }
243
244 if (song.contributors.some((c) => c.username === artist.username)) {
245 toast.error("This contributor is already added");
246 return;
247 }
248
249 const role = albumSongSelectedRole[songIndex] || ROLE_OPTIONS[0].value;
250 const newContributor: Contributor = {
251 username: artist.username,
252 fullName: artist.fullName,
253 role,
254 };
255
256 handleAlbumSongChange(songIndex, "contributors", [
257 ...song.contributors,
258 newContributor,
259 ]);
260 setAlbumSongContributorSearch((prev) => ({ ...prev, [songIndex]: "" }));
261 setAlbumSongSearchResults((prev) => ({ ...prev, [songIndex]: [] }));
262 setAlbumSongShowDropdown((prev) => ({ ...prev, [songIndex]: false }));
263 };
264
265 const handleRemoveAlbumSongContributor = (
266 songIndex: number,
267 username: string,
268 ) => {
269 const song = albumSongs[songIndex];
270 handleAlbumSongChange(
271 songIndex,
272 "contributors",
273 song.contributors.filter((c) => c.username !== username),
274 );
275 };
276
277 // Form submission
278 const handleSubmit = async (e: React.FormEvent) => {
279 e.preventDefault();
280
281 // Validation
282 if (!title.trim()) {
283 toast.error("Please enter a title");
284 return;
285 }
286 if (!genre) {
287 toast.error("Please select a genre");
288 return;
289 }
290
291 if (releaseType === "single") {
292 if (!link.trim()) {
293 toast.error("Please enter a song link");
294 return;
295 }
296 } else {
297 // Album validation
298 for (let i = 0; i < albumSongs.length; i++) {
299 if (!albumSongs[i].title.trim()) {
300 toast.error(`Please enter a title for song ${i + 1}`);
301 return;
302 }
303 if (!albumSongs[i].link.trim()) {
304 toast.error(`Please enter a link for song ${i + 1}`);
305 return;
306 }
307 }
308 }
309
310 setIsSubmitting(true);
311
312 try {
313 // TODO: replace with API call
314 // const formData = new FormData();
315 // formData.append('title', title);
316 // formData.append('genre', genre);
317 // if (coverFile) formData.append('cover', coverFile);
318 // ...
319
320 // Simulate API call
321 await new Promise((resolve) => setTimeout(resolve, 1000));
322
323 toast.success(
324 releaseType === "single"
325 ? "Song published successfully!"
326 : "Album published successfully!",
327 );
328 navigate("/my-songs");
329 } catch (error) {
330 console.error("Error publishing:", error);
331 toast.error("Failed to publish. Please try again.");
332 } finally {
333 setIsSubmitting(false);
334 }
335 };
336
337 if (!user?.isArtist) {
338 return (
339 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] flex items-center justify-center">
340 <div className="text-center">
341 <p className="text-red-400 text-xl mb-4">
342 You need to be an artist to publish music.
343 </p>
344 <Link to="/" className="text-[#1db954] hover:underline text-sm">
345 ← Back to Home
346 </Link>
347 </div>
348 </div>
349 );
350 }
351
352 return (
353 <div className="min-h-screen bg-linear-to-br from-[#1e1e2e] to-[#0f0f1e] text-white">
354 <div className="max-w-3xl mx-auto px-6 py-10">
355 {/* Back link */}
356 <Link
357 to="/my-songs"
358 className="inline-flex items-center gap-1 text-gray-400 hover:text-white text-sm mb-8 transition-colors"
359 >
360 <svg
361 className="w-4 h-4"
362 fill="none"
363 stroke="currentColor"
364 viewBox="0 0 24 24"
365 >
366 <path
367 strokeLinecap="round"
368 strokeLinejoin="round"
369 strokeWidth={2}
370 d="M15 19l-7-7 7-7"
371 />
372 </svg>
373 Back to My Songs
374 </Link>
375
376 {/* Header */}
377 <h1 className="text-4xl font-extrabold mb-8">Publish New Music</h1>
378
379 <form onSubmit={handleSubmit} className="space-y-8">
380 {/* Release Type Selection */}
381 <div className="bg-[#181818] rounded-xl p-6">
382 <h2 className="text-lg font-semibold mb-4">
383 What are you releasing?
384 </h2>
385 <div className="flex gap-4">
386 <label
387 className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-lg border-2 cursor-pointer transition-all ${
388 releaseType === "single"
389 ? "border-[#1db954] bg-[#1db954]/10"
390 : "border-white/10 hover:border-white/20"
391 }`}
392 >
393 <input
394 type="radio"
395 name="releaseType"
396 value="single"
397 checked={releaseType === "single"}
398 onChange={() => setReleaseType("single")}
399 className="hidden"
400 />
401 <svg
402 className={`w-8 h-8 ${releaseType === "single" ? "text-[#1db954]" : "text-gray-400"}`}
403 fill="none"
404 stroke="currentColor"
405 viewBox="0 0 24 24"
406 >
407 <path
408 strokeLinecap="round"
409 strokeLinejoin="round"
410 strokeWidth={1.5}
411 d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z"
412 />
413 </svg>
414 <div>
415 <p className="font-medium">Single</p>
416 <p className="text-sm text-gray-400">One song</p>
417 </div>
418 </label>
419
420 <label
421 className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-lg border-2 cursor-pointer transition-all ${
422 releaseType === "album"
423 ? "border-[#1db954] bg-[#1db954]/10"
424 : "border-white/10 hover:border-white/20"
425 }`}
426 >
427 <input
428 type="radio"
429 name="releaseType"
430 value="album"
431 checked={releaseType === "album"}
432 onChange={() => setReleaseType("album")}
433 className="hidden"
434 />
435 <svg
436 className={`w-8 h-8 ${releaseType === "album" ? "text-[#1db954]" : "text-gray-400"}`}
437 fill="none"
438 stroke="currentColor"
439 viewBox="0 0 24 24"
440 >
441 <path
442 strokeLinecap="round"
443 strokeLinejoin="round"
444 strokeWidth={1.5}
445 d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
446 />
447 </svg>
448 <div>
449 <p className="font-medium">Album</p>
450 <p className="text-sm text-gray-400">Multiple songs</p>
451 </div>
452 </label>
453 </div>
454 </div>
455
456 {/* Basic Info */}
457 <div className="bg-[#181818] rounded-xl p-6 space-y-6">
458 <h2 className="text-lg font-semibold">
459 {releaseType === "single" ? "Song Details" : "Album Details"}
460 </h2>
461
462 {/* Title */}
463 <div>
464 <label
465 htmlFor="title"
466 className="block text-sm font-medium text-gray-300 mb-2"
467 >
468 {releaseType === "single" ? "Song Title" : "Album Name"} *
469 </label>
470 <input
471 type="text"
472 id="title"
473 value={title}
474 onChange={(e) => setTitle(e.target.value)}
475 placeholder={
476 releaseType === "single"
477 ? "Enter song title"
478 : "Enter album name"
479 }
480 className="w-full bg-[#282828] border border-white/10 rounded-lg py-3 px-4 text-white placeholder-gray-500 focus:outline-none focus:border-[#1db954] focus:ring-1 focus:ring-[#1db954] transition-all"
481 />
482 </div>
483
484 {/* Genre */}
485 <div>
486 <label
487 htmlFor="genre"
488 className="block text-sm font-medium text-gray-300 mb-2"
489 >
490 Genre *
491 </label>
492 <select
493 id="genre"
494 value={genre}
495 onChange={(e) => setGenre(e.target.value)}
496 className="w-full bg-[#282828] border border-white/10 rounded-lg py-3 px-4 text-white focus:outline-none focus:border-[#1db954] focus:ring-1 focus:ring-[#1db954] transition-all appearance-none cursor-pointer"
497 >
498 <option value="">Select a genre</option>
499 {GENRE_OPTIONS.map((g) => (
500 <option key={g} value={g}>
501 {g}
502 </option>
503 ))}
504 </select>
505 </div>
506
507 {/* Cover Art */}
508 <div>
509 <label className="block text-sm font-medium text-gray-300 mb-2">
510 Cover Art (optional)
511 </label>
512 <div className="flex items-start gap-4">
513 {coverPreview ? (
514 <div className="relative">
515 <img
516 src={coverPreview}
517 alt="Cover preview"
518 className="w-32 h-32 rounded-lg object-cover"
519 />
520 <button
521 type="button"
522 onClick={handleRemoveCover}
523 className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center text-white hover:bg-red-600 transition-colors"
524 >
525 <svg
526 className="w-4 h-4"
527 fill="none"
528 stroke="currentColor"
529 viewBox="0 0 24 24"
530 >
531 <path
532 strokeLinecap="round"
533 strokeLinejoin="round"
534 strokeWidth={2}
535 d="M6 18L18 6M6 6l12 12"
536 />
537 </svg>
538 </button>
539 </div>
540 ) : (
541 <label
542 htmlFor="cover"
543 className="w-32 h-32 rounded-lg border-2 border-dashed border-white/20 flex flex-col items-center justify-center cursor-pointer hover:border-[#1db954] transition-colors"
544 >
545 <svg
546 className="w-8 h-8 text-gray-400 mb-2"
547 fill="none"
548 stroke="currentColor"
549 viewBox="0 0 24 24"
550 >
551 <path
552 strokeLinecap="round"
553 strokeLinejoin="round"
554 strokeWidth={1.5}
555 d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
556 />
557 </svg>
558 <span className="text-xs text-gray-400">Upload</span>
559 </label>
560 )}
561 <input
562 type="file"
563 id="cover"
564 accept="image/*"
565 onChange={handleCoverChange}
566 className="hidden"
567 />
568 <div className="text-sm text-gray-400">
569 <p>Recommended: 1400x1400px</p>
570 <p>Max size: 5MB</p>
571 <p>JPG, PNG, or WebP</p>
572 </div>
573 </div>
574 </div>
575
576 {/* Single: Link */}
577 {releaseType === "single" && (
578 <div>
579 <label
580 htmlFor="link"
581 className="block text-sm font-medium text-gray-300 mb-2"
582 >
583 Song Link (YouTube) *
584 </label>
585 <input
586 type="url"
587 id="link"
588 value={link}
589 onChange={(e) => setLink(e.target.value)}
590 placeholder="https://www.youtube.com/watch?v=..."
591 className="w-full bg-[#282828] border border-white/10 rounded-lg py-3 px-4 text-white placeholder-gray-500 focus:outline-none focus:border-[#1db954] focus:ring-1 focus:ring-[#1db954] transition-all"
592 />
593 </div>
594 )}
595 </div>
596
597 {/* Single: Contributors */}
598 {releaseType === "single" && (
599 <div className="bg-[#181818] rounded-xl p-6 space-y-4">
600 <div className="flex items-center justify-between">
601 <h2 className="text-lg font-semibold">
602 Contributors (optional)
603 </h2>
604 <span className="text-sm text-gray-400">
605 {contributors.length}/{MAX_CONTRIBUTORS}
606 </span>
607 </div>
608
609 {/* Added contributors */}
610 {contributors.length > 0 && (
611 <div className="flex flex-wrap gap-2 mb-4">
612 {contributors.map((contributor) => (
613 <div
614 key={contributor.username}
615 className="flex items-center gap-2 bg-[#282828] rounded-full py-1.5 px-3"
616 >
617 <span className="text-sm">{contributor.fullName}</span>
618 <span className="text-xs text-gray-400">
619 (
620 {
621 ROLE_OPTIONS.find((r) => r.value === contributor.role)
622 ?.label
623 }
624 )
625 </span>
626 <button
627 type="button"
628 onClick={() =>
629 handleRemoveContributor(contributor.username)
630 }
631 className="text-gray-400 hover:text-red-400 transition-colors"
632 >
633 <svg
634 className="w-4 h-4"
635 fill="none"
636 stroke="currentColor"
637 viewBox="0 0 24 24"
638 >
639 <path
640 strokeLinecap="round"
641 strokeLinejoin="round"
642 strokeWidth={2}
643 d="M6 18L18 6M6 6l12 12"
644 />
645 </svg>
646 </button>
647 </div>
648 ))}
649 </div>
650 )}
651
652 {/* Add contributor */}
653 {contributors.length < MAX_CONTRIBUTORS && (
654 <div className="flex gap-3">
655 <select
656 value={selectedContributorRole}
657 onChange={(e) => setSelectedContributorRole(e.target.value)}
658 className="bg-[#282828] border border-white/10 rounded-lg py-2.5 px-3 text-white text-sm focus:outline-none focus:border-[#1db954] appearance-none cursor-pointer"
659 >
660 {ROLE_OPTIONS.map((role) => (
661 <option key={role.value} value={role.value}>
662 {role.label}
663 </option>
664 ))}
665 </select>
666
667 <div className="flex-1 relative" ref={searchDropdownRef}>
668 <input
669 type="text"
670 value={contributorSearch}
671 onChange={(e) =>
672 handleContributorSearchChange(e.target.value)
673 }
674 onFocus={() => setShowSearchDropdown(true)}
675 placeholder="Search for an artist..."
676 className="w-full bg-[#282828] border border-white/10 rounded-lg py-2.5 px-4 text-white placeholder-gray-500 focus:outline-none focus:border-[#1db954] focus:ring-1 focus:ring-[#1db954] transition-all"
677 />
678
679 {/* Search dropdown */}
680 {showSearchDropdown && contributorSearch && (
681 <div className="absolute top-full left-0 right-0 mt-1 bg-[#282828] border border-white/10 rounded-lg shadow-xl z-10 max-h-48 overflow-y-auto">
682 {isSearching ? (
683 <div className="p-3 text-center text-gray-400">
684 <div className="w-5 h-5 border-2 border-white/10 border-t-[#1db954] rounded-full animate-spin mx-auto" />
685 </div>
686 ) : searchResults.length > 0 ? (
687 searchResults.map((artist) => (
688 <button
689 key={artist.username}
690 type="button"
691 onClick={() => handleAddContributor(artist)}
692 className="w-full text-left px-4 py-2.5 hover:bg-white/10 transition-colors flex items-center gap-3"
693 >
694 <div className="w-8 h-8 bg-gray-600 rounded-full flex items-center justify-center">
695 {artist.profilePhoto ? (
696 <img
697 src={artist.profilePhoto}
698 alt={artist.fullName}
699 className="w-full h-full rounded-full object-cover"
700 />
701 ) : (
702 <span className="text-xs">
703 {artist.fullName.charAt(0).toUpperCase()}
704 </span>
705 )}
706 </div>
707 <div>
708 <p className="text-sm font-medium">
709 {artist.fullName}
710 </p>
711 <p className="text-xs text-gray-400">
712 @{artist.username}
713 </p>
714 </div>
715 </button>
716 ))
717 ) : (
718 <div className="p-3 text-center text-gray-400 text-sm">
719 No artists found
720 </div>
721 )}
722 </div>
723 )}
724 </div>
725 </div>
726 )}
727 </div>
728 )}
729
730 {/* Album: Songs */}
731 {releaseType === "album" && (
732 <div className="bg-[#181818] rounded-xl p-6 space-y-6">
733 <div className="flex items-center justify-between">
734 <h2 className="text-lg font-semibold">Album Songs</h2>
735 <span className="text-sm text-gray-400">
736 {albumSongs.length}/{MAX_ALBUM_SONGS} songs
737 </span>
738 </div>
739
740 {albumSongs.map((song, index) => (
741 <div
742 key={index}
743 className="bg-[#282828] rounded-lg p-4 space-y-4"
744 >
745 <div className="flex items-center justify-between">
746 <h3 className="font-medium text-gray-300">
747 Song {index + 1}
748 </h3>
749 {albumSongs.length > 1 && (
750 <button
751 type="button"
752 onClick={() => handleRemoveAlbumSong(index)}
753 className="text-gray-400 hover:text-red-400 transition-colors"
754 >
755 <svg
756 className="w-5 h-5"
757 fill="none"
758 stroke="currentColor"
759 viewBox="0 0 24 24"
760 >
761 <path
762 strokeLinecap="round"
763 strokeLinejoin="round"
764 strokeWidth={2}
765 d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
766 />
767 </svg>
768 </button>
769 )}
770 </div>
771
772 {/* Song title */}
773 <input
774 type="text"
775 value={song.title}
776 onChange={(e) =>
777 handleAlbumSongChange(index, "title", e.target.value)
778 }
779 placeholder="Song title"
780 className="w-full bg-[#1a1a2e] border border-white/10 rounded-lg py-2.5 px-4 text-white placeholder-gray-500 focus:outline-none focus:border-[#1db954] focus:ring-1 focus:ring-[#1db954] transition-all"
781 />
782
783 {/* Song link */}
784 <input
785 type="url"
786 value={song.link}
787 onChange={(e) =>
788 handleAlbumSongChange(index, "link", e.target.value)
789 }
790 placeholder="Song link (YouTube)"
791 className="w-full bg-[#1a1a2e] border border-white/10 rounded-lg py-2.5 px-4 text-white placeholder-gray-500 focus:outline-none focus:border-[#1db954] focus:ring-1 focus:ring-[#1db954] transition-all"
792 />
793
794 {/* Contributors */}
795 <div>
796 <div className="flex items-center justify-between mb-2">
797 <label className="text-sm text-gray-400">
798 Contributors (optional)
799 </label>
800 <span className="text-xs text-gray-500">
801 {song.contributors.length}/{MAX_CONTRIBUTORS}
802 </span>
803 </div>
804
805 {/* Added contributors */}
806 {song.contributors.length > 0 && (
807 <div className="flex flex-wrap gap-2 mb-3">
808 {song.contributors.map((contributor) => (
809 <div
810 key={contributor.username}
811 className="flex items-center gap-2 bg-[#1a1a2e] rounded-full py-1 px-2.5 text-sm"
812 >
813 <span>{contributor.fullName}</span>
814 <span className="text-xs text-gray-400">
815 (
816 {
817 ROLE_OPTIONS.find(
818 (r) => r.value === contributor.role,
819 )?.label
820 }
821 )
822 </span>
823 <button
824 type="button"
825 onClick={() =>
826 handleRemoveAlbumSongContributor(
827 index,
828 contributor.username,
829 )
830 }
831 className="text-gray-400 hover:text-red-400 transition-colors"
832 >
833 <svg
834 className="w-3.5 h-3.5"
835 fill="none"
836 stroke="currentColor"
837 viewBox="0 0 24 24"
838 >
839 <path
840 strokeLinecap="round"
841 strokeLinejoin="round"
842 strokeWidth={2}
843 d="M6 18L18 6M6 6l12 12"
844 />
845 </svg>
846 </button>
847 </div>
848 ))}
849 </div>
850 )}
851
852 {/* Add contributor */}
853 {song.contributors.length < MAX_CONTRIBUTORS && (
854 <div className="flex gap-2">
855 <select
856 value={
857 albumSongSelectedRole[index] ||
858 ROLE_OPTIONS[0].value
859 }
860 onChange={(e) =>
861 setAlbumSongSelectedRole((prev) => ({
862 ...prev,
863 [index]: e.target.value,
864 }))
865 }
866 className="bg-[#1a1a2e] border border-white/10 rounded-lg py-2 px-2.5 text-white text-sm focus:outline-none focus:border-[#1db954] appearance-none cursor-pointer"
867 >
868 {ROLE_OPTIONS.map((role) => (
869 <option key={role.value} value={role.value}>
870 {role.label}
871 </option>
872 ))}
873 </select>
874
875 <div className="flex-1 relative">
876 <input
877 type="text"
878 value={albumSongContributorSearch[index] || ""}
879 onChange={(e) =>
880 handleAlbumSongContributorSearch(
881 index,
882 e.target.value,
883 )
884 }
885 onFocus={() =>
886 setAlbumSongShowDropdown((prev) => ({
887 ...prev,
888 [index]: true,
889 }))
890 }
891 placeholder="Search artist..."
892 className="w-full bg-[#1a1a2e] border border-white/10 rounded-lg py-2 px-3 text-white text-sm placeholder-gray-500 focus:outline-none focus:border-[#1db954] transition-all"
893 />
894
895 {/* Dropdown */}
896 {albumSongShowDropdown[index] &&
897 albumSongContributorSearch[index] && (
898 <div className="absolute top-full left-0 right-0 mt-1 bg-[#1a1a2e] border border-white/10 rounded-lg shadow-xl z-10 max-h-40 overflow-y-auto">
899 {(albumSongSearchResults[index] || []).length >
900 0 ? (
901 albumSongSearchResults[index].map(
902 (artist) => (
903 <button
904 key={artist.username}
905 type="button"
906 onClick={() =>
907 handleAddAlbumSongContributor(
908 index,
909 artist,
910 )
911 }
912 className="w-full text-left px-3 py-2 hover:bg-white/10 transition-colors flex items-center gap-2"
913 >
914 <div className="w-6 h-6 bg-gray-600 rounded-full flex items-center justify-center text-xs">
915 {artist.fullName
916 .charAt(0)
917 .toUpperCase()}
918 </div>
919 <span className="text-sm">
920 {artist.fullName}
921 </span>
922 </button>
923 ),
924 )
925 ) : (
926 <div className="p-2 text-center text-gray-400 text-sm">
927 No artists found
928 </div>
929 )}
930 </div>
931 )}
932 </div>
933 </div>
934 )}
935 </div>
936 </div>
937 ))}
938
939 {/* Add song button */}
940 {albumSongs.length < MAX_ALBUM_SONGS && (
941 <button
942 type="button"
943 onClick={handleAddAlbumSong}
944 className="w-full py-3 border-2 border-dashed border-white/20 rounded-lg text-gray-400 hover:text-white hover:border-[#1db954] transition-all flex items-center justify-center gap-2"
945 >
946 <svg
947 className="w-5 h-5"
948 fill="none"
949 stroke="currentColor"
950 viewBox="0 0 24 24"
951 >
952 <path
953 strokeLinecap="round"
954 strokeLinejoin="round"
955 strokeWidth={2}
956 d="M12 4v16m8-8H4"
957 />
958 </svg>
959 Add Another Song
960 </button>
961 )}
962 </div>
963 )}
964
965 {/* Submit */}
966 <div className="flex gap-4">
967 <button
968 type="button"
969 onClick={() => navigate("/my-songs")}
970 className="flex-1 py-3 px-6 border border-white/20 rounded-full text-white font-semibold hover:bg-white/5 transition-colors cursor-pointer"
971 >
972 Cancel
973 </button>
974 <button
975 type="submit"
976 disabled={isSubmitting}
977 className="flex-1 py-3 px-6 bg-[#1db954] rounded-full text-black font-semibold hover:bg-[#1ed760] transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer flex items-center justify-center gap-2"
978 >
979 {isSubmitting ? (
980 <>
981 <div className="w-5 h-5 border-2 border-black/30 border-t-black rounded-full animate-spin" />
982 Publishing...
983 </>
984 ) : (
985 <>
986 <svg
987 className="w-5 h-5"
988 fill="none"
989 stroke="currentColor"
990 viewBox="0 0 24 24"
991 >
992 <path
993 strokeLinecap="round"
994 strokeLinejoin="round"
995 strokeWidth={2}
996 d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
997 />
998 </svg>
999 Publish {releaseType === "single" ? "Song" : "Album"}
1000 </>
1001 )}
1002 </button>
1003 </div>
1004 </form>
1005 </div>
1006 </div>
1007 );
1008};
1009
1010export default PublishSong;
Note: See TracBrowser for help on using the repository browser.