source: frontend/src/pages/PublishSong.tsx@ 8dd3e22

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

add client side form for publishing new music with mock data and api calls

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