source: frontend/src/pages/PublishSong.tsx@ ed8f998

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

add endpoint for publishing songs and albums

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