this repo has no description
at main 617 lines 22 kB view raw
1"use client"; 2 3import { useRouter } from "next/navigation"; 4import { useEffect, useRef, useState } from "react"; 5 6interface Artist { 7 name: string; 8 did?: string; 9 artist?: { 10 $type?: string; 11 name?: string; 12 }; 13} 14 15interface Recording { 16 id: string; 17 title: string; 18 song?: any; 19 artists?: any[]; 20 isrc?: string; 21 masterOwner?: any; 22 duration?: number; 23} 24 25interface Release { 26 id: string; 27 title: string; 28 artists: any[]; 29 gtin?: string; 30 releaseDate?: string; 31 recordings?: any[]; 32} 33 34interface HandleSuggestion { 35 did: string; 36 handle: string; 37 displayName?: string; 38} 39 40interface PartyLookupState { 41 handleQuery: string; 42 suggestions: HandleSuggestion[]; 43 searching: boolean; 44 lookupLoading: boolean; 45 message: string | null; 46} 47 48const emptyLookupState = (): PartyLookupState => ({ 49 handleQuery: "", 50 suggestions: [], 51 searching: false, 52 lookupLoading: false, 53 message: null, 54}); 55 56export function ReleaseForm({ 57 editingRelease, 58 onReleaseSaved, 59}: { 60 editingRelease?: Release; 61 onReleaseSaved?: () => void; 62}) { 63 const router = useRouter(); 64 const [title, setTitle] = useState(editingRelease?.title || ""); 65 const [gtin, setGtin] = useState(editingRelease?.gtin || ""); 66 const [releaseDate, setReleaseDate] = useState( 67 editingRelease?.releaseDate ? editingRelease.releaseDate.split("T")[0] : "" 68 ); 69 const [artists, setArtists] = useState<Artist[]>( 70 editingRelease && editingRelease.artists?.length > 0 ? editingRelease.artists : [{ name: "" }] 71 ); 72 const [artistLookups, setArtistLookups] = useState<PartyLookupState[]>( 73 editingRelease && editingRelease.artists?.length > 0 74 ? editingRelease.artists.map(() => emptyLookupState()) 75 : [emptyLookupState()] 76 ); 77 const [recordings, setRecordings] = useState<Recording[]>([]); 78 const [allRecordings, setAllRecordings] = useState<Recording[]>([]); 79 const [selectedRecordings, setSelectedRecordings] = useState<Recording[]>( 80 editingRelease && editingRelease.recordings 81 ? editingRelease.recordings.map((r: any) => { 82 // If it's a URI string, we need to fetch the full object later 83 if (typeof r === "string") { 84 return { id: r, title: "Unknown" }; 85 } 86 // Otherwise it's already a full object 87 return r as Recording; 88 }) 89 : [] 90 ); 91 const [showRecordingDropdown, setShowRecordingDropdown] = useState(false); 92 const [recordingSearchQuery, setRecordingSearchQuery] = useState(""); 93 const [isSaving, setIsSaving] = useState(false); 94 const [error, setError] = useState<string | null>(null); 95 const [draggedIndex, setDraggedIndex] = useState<number | null>(null); 96 const lookupTimersRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({}); 97 98 useEffect(() => { 99 return () => { 100 Object.values(lookupTimersRef.current).forEach((timer) => clearTimeout(timer)); 101 }; 102 }, []); 103 104 useEffect(() => { 105 const fetchRecordings = async () => { 106 try { 107 const res = await fetch("/api/recording"); 108 if (!res.ok) throw new Error("Failed to fetch recordings"); 109 const data = await res.json(); 110 setAllRecordings(Array.isArray(data.recordings) ? data.recordings : []); 111 } catch (err) { 112 console.error("Failed to fetch recordings:", err); 113 } 114 }; 115 void fetchRecordings(); 116 }, []); 117 118 const updateArtist = <K extends keyof Artist>(index: number, field: K, value: Artist[K]) => { 119 setArtists((prev) => { 120 const updated = [...prev]; 121 const current = updated[index] || { name: "" }; 122 updated[index] = { ...current, [field]: value }; 123 return updated; 124 }); 125 }; 126 127 const updateArtistLookup = (index: number, updates: Partial<PartyLookupState>) => { 128 setArtistLookups((prev) => { 129 const updated = [...prev]; 130 const current = updated[index] || emptyLookupState(); 131 updated[index] = { ...current, ...updates }; 132 return updated; 133 }); 134 }; 135 136 const searchHandleSuggestions = async (index: number, query: string) => { 137 updateArtistLookup(index, { searching: true, message: null }); 138 139 try { 140 const res = await fetch(`/api/actor-search?q=${encodeURIComponent(query)}`); 141 if (!res.ok) throw new Error("Search failed"); 142 143 const data = await res.json(); 144 const suggestions: HandleSuggestion[] = Array.isArray(data.actors) 145 ? data.actors 146 .filter((actor: any) => typeof actor?.did === "string" && typeof actor?.handle === "string") 147 .map((actor: any) => ({ 148 did: actor.did, 149 handle: actor.handle, 150 displayName: typeof actor.displayName === "string" ? actor.displayName : undefined, 151 })) 152 : []; 153 154 updateArtistLookup(index, { suggestions }); 155 } catch (err) { 156 console.error("Failed to search handles:", err); 157 updateArtistLookup(index, { 158 suggestions: [], 159 message: "Handle search failed. Try again.", 160 }); 161 } finally { 162 updateArtistLookup(index, { searching: false }); 163 } 164 }; 165 166 const fetchArtistForDid = async (index: number, did: string) => { 167 updateArtistLookup(index, { 168 lookupLoading: true, 169 message: "Looking up artist record...", 170 }); 171 172 try { 173 const res = await fetch(`/api/artist?did=${encodeURIComponent(did)}`); 174 if (!res.ok) throw new Error("Lookup failed"); 175 176 const data = await res.json(); 177 const record = data.artist || null; 178 179 setArtists((prev) => { 180 const updated = [...prev]; 181 const current = updated[index]; 182 if (!current) return prev; 183 184 const nextArtist: Artist = { 185 ...current, 186 did, 187 }; 188 189 if (record?.name && !nextArtist.name) { 190 nextArtist.name = record.name; 191 } 192 193 if (record) { 194 nextArtist.artist = { 195 ...record, 196 $type: record.$type || "ch.indiemusi.alpha.actor.artist", 197 }; 198 } else { 199 delete nextArtist.artist; 200 } 201 202 updated[index] = nextArtist; 203 return updated; 204 }); 205 206 if (record) { 207 updateArtistLookup(index, { message: "Artist record linked." }); 208 } else { 209 updateArtistLookup(index, { 210 message: "No artist record found for this DID. You can still type a name.", 211 }); 212 } 213 } catch (err) { 214 console.error("Failed to fetch artist for DID:", err); 215 updateArtistLookup(index, { 216 message: "Could not load artist record for this DID.", 217 }); 218 } finally { 219 updateArtistLookup(index, { lookupLoading: false }); 220 } 221 }; 222 223 const onArtistHandleInputChange = (index: number, value: string) => { 224 const existingTimer = lookupTimersRef.current[index]; 225 if (existingTimer) clearTimeout(existingTimer); 226 227 updateArtistLookup(index, { 228 handleQuery: value, 229 suggestions: [], 230 searching: false, 231 message: null, 232 }); 233 234 updateArtist(index, "did", undefined); 235 updateArtist(index, "artist", undefined); 236 237 const query = value.trim(); 238 if (query.length < 2) { 239 return; 240 } 241 242 lookupTimersRef.current[index] = setTimeout(() => { 243 void searchHandleSuggestions(index, query); 244 }, 250); 245 }; 246 247 const onSelectArtistHandle = async (index: number, suggestion: HandleSuggestion) => { 248 const existingTimer = lookupTimersRef.current[index]; 249 if (existingTimer) clearTimeout(existingTimer); 250 251 updateArtistLookup(index, { 252 handleQuery: suggestion.handle, 253 suggestions: [], 254 searching: false, 255 message: null, 256 }); 257 258 updateArtist(index, "did", suggestion.did); 259 await fetchArtistForDid(index, suggestion.did); 260 }; 261 262 const addArtist = () => { 263 setArtists((prev) => [...prev, { name: "" }]); 264 setArtistLookups((prev) => [...prev, emptyLookupState()]); 265 }; 266 267 const removeArtist = (index: number) => { 268 const existingTimer = lookupTimersRef.current[index]; 269 if (existingTimer) clearTimeout(existingTimer); 270 271 const reindexedTimers: Record<number, ReturnType<typeof setTimeout>> = {}; 272 Object.entries(lookupTimersRef.current).forEach(([key, timer]) => { 273 const timerIndex = Number(key); 274 if (timerIndex < index) reindexedTimers[timerIndex] = timer; 275 if (timerIndex > index) reindexedTimers[timerIndex - 1] = timer; 276 }); 277 lookupTimersRef.current = reindexedTimers; 278 279 setArtists((prev) => prev.filter((_, i) => i !== index)); 280 setArtistLookups((prev) => prev.filter((_, i) => i !== index)); 281 }; 282 283 const addRecording = (recording: Recording) => { 284 if (!selectedRecordings.find((r) => r.id === recording.id)) { 285 setSelectedRecordings((prev) => [...prev, recording]); 286 setRecordingSearchQuery(""); 287 setShowRecordingDropdown(false); 288 } 289 }; 290 291 const removeRecording = (recordingId: string) => { 292 setSelectedRecordings((prev) => prev.filter((r) => r.id !== recordingId)); 293 }; 294 295 const handleRecordingDragStart = (index: number) => { 296 setDraggedIndex(index); 297 }; 298 299 const handleRecordingDragOver = (e: React.DragEvent<HTMLDivElement>) => { 300 e.preventDefault(); 301 e.dataTransfer.dropEffect = "move"; 302 }; 303 304 const handleRecordingDrop = (dropIndex: number) => { 305 if (draggedIndex === null || draggedIndex === dropIndex) { 306 setDraggedIndex(null); 307 return; 308 } 309 310 setSelectedRecordings((prev) => { 311 const updated = [...prev]; 312 const [draggedItem] = updated.splice(draggedIndex, 1); 313 updated.splice(dropIndex, 0, draggedItem); 314 return updated; 315 }); 316 317 setDraggedIndex(null); 318 }; 319 320 const filteredRecordings = allRecordings.filter( 321 (rec) => 322 !selectedRecordings.find((s) => s.id === rec.id) && 323 (rec.title.toLowerCase().includes(recordingSearchQuery.toLowerCase()) || 324 rec.isrc?.toLowerCase().includes(recordingSearchQuery.toLowerCase())) 325 ); 326 327 const handleSaveRelease = async () => { 328 if (!title.trim() || artists.some((a) => !a.name.trim()) || selectedRecordings.length === 0) { 329 setError("Please fill in all required fields: title, at least one artist, and at least one recording."); 330 return; 331 } 332 333 setIsSaving(true); 334 setError(null); 335 336 try { 337 const releaseData = { 338 ...(editingRelease && { uri: editingRelease.id }), 339 title: title.trim(), 340 gtin: gtin.trim() || undefined, 341 releaseDate: releaseDate ? `${releaseDate}T00:00:00Z` : undefined, 342 artists: artists.map((a) => ({ 343 name: a.name, 344 did: a.did, 345 artist: a.artist, 346 })), 347 recordings: selectedRecordings.map((r) => { 348 const { id: _id, ...recordingRecord } = r as any; 349 return { 350 ...recordingRecord, 351 $type: "ch.indiemusi.alpha.recording", 352 }; 353 }), 354 }; 355 356 const method = editingRelease ? "PUT" : "POST"; 357 const res = await fetch("/api/release", { 358 method, 359 headers: { "Content-Type": "application/json" }, 360 body: JSON.stringify(releaseData), 361 }); 362 363 if (!res.ok) { 364 const error = await res.json(); 365 throw new Error(error.error || "Failed to save release"); 366 } 367 368 setTitle(""); 369 setGtin(""); 370 setReleaseDate(""); 371 setArtists([{ name: "" }]); 372 setArtistLookups([emptyLookupState()]); 373 setSelectedRecordings([]); 374 if (onReleaseSaved) onReleaseSaved(); 375 router.refresh(); 376 } catch (err) { 377 console.error("Failed to save release:", err); 378 setError(err instanceof Error ? err.message : "Failed to save release"); 379 } finally { 380 setIsSaving(false); 381 } 382 }; 383 384 return ( 385 <form className="space-y-4" onSubmit={(e) => e.preventDefault()}> 386 <h2 className="mb-4 text-lg font-semibold text-zinc-900 dark:text-zinc-100"> 387 {editingRelease ? "Edit Release" : "New Release"} 388 </h2> 389 390 <div> 391 <label className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"> 392 Title * 393 </label> 394 <input 395 type="text" 396 value={title} 397 onChange={(e) => setTitle(e.target.value)} 398 className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100" 399 placeholder="Release title" 400 /> 401 </div> 402 403 <div> 404 <label className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"> 405 GTIN 406 </label> 407 <input 408 type="text" 409 value={gtin} 410 onChange={(e) => setGtin(e.target.value)} 411 className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100" 412 placeholder="e.g. 0123456789012" 413 /> 414 </div> 415 416 <div> 417 <label className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"> 418 Release Date 419 </label> 420 <input 421 type="date" 422 value={releaseDate} 423 onChange={(e) => setReleaseDate(e.target.value)} 424 className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100" 425 /> 426 </div> 427 428 <div className="space-y-3 pt-2"> 429 <label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"> 430 Artists * <span className="text-xs text-zinc-500">(at least one required)</span> 431 </label> 432 433 {artists.map((artist, index) => { 434 const lookup = artistLookups[index] || emptyLookupState(); 435 return ( 436 <div 437 key={index} 438 className="space-y-2 rounded-lg border border-zinc-300 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800/50" 439 > 440 <div className="relative"> 441 <label className="mb-1 block text-xs text-zinc-600 dark:text-zinc-400">Handle</label> 442 <input 443 type="text" 444 value={lookup.handleQuery} 445 onChange={(e) => onArtistHandleInputChange(index, e.target.value)} 446 className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm dark:border-zinc-700 dark:bg-zinc-800" 447 placeholder="Type a handle, e.g. alice.bsky.social" 448 /> 449 450 {lookup.suggestions.length > 0 && ( 451 <div className="absolute z-10 mt-1 w-full rounded border border-zinc-300 bg-white shadow dark:border-zinc-700 dark:bg-zinc-900"> 452 {lookup.suggestions.map((suggestion) => ( 453 <button 454 key={suggestion.did} 455 type="button" 456 onClick={() => { 457 void onSelectArtistHandle(index, suggestion); 458 }} 459 className="w-full px-2 py-2 text-left hover:bg-zinc-100 dark:hover:bg-zinc-800" 460 > 461 <div className="text-sm text-zinc-900 dark:text-zinc-100">{suggestion.handle}</div> 462 {suggestion.displayName && ( 463 <div className="text-xs text-zinc-500 dark:text-zinc-400">{suggestion.displayName}</div> 464 )} 465 </button> 466 ))} 467 </div> 468 )} 469 </div> 470 471 <div> 472 <label className="mb-1 block text-xs text-zinc-600 dark:text-zinc-400">DID</label> 473 <input 474 type="text" 475 value={artist.did || ""} 476 readOnly 477 className="w-full rounded border border-zinc-300 bg-zinc-100 px-2 py-1 text-xs text-zinc-700 dark:border-zinc-700 dark:bg-zinc-700 dark:text-zinc-300" 478 /> 479 </div> 480 481 <div> 482 <label className="mb-1 block text-xs text-zinc-600 dark:text-zinc-400">Name *</label> 483 <input 484 type="text" 485 value={artist.name} 486 onChange={(e) => updateArtist(index, "name", e.target.value)} 487 className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm dark:border-zinc-700 dark:bg-zinc-800" 488 /> 489 </div> 490 491 {lookup.message && ( 492 <p className="text-xs text-zinc-500 dark:text-zinc-400"> 493 {lookup.lookupLoading ? "Fetching record..." : lookup.message} 494 </p> 495 )} 496 497 {index > 0 && ( 498 <button 499 type="button" 500 onClick={() => removeArtist(index)} 501 className="text-xs text-red-600 hover:text-red-700 dark:text-red-400" 502 > 503 Remove 504 </button> 505 )} 506 </div> 507 ); 508 })} 509 510 <button 511 type="button" 512 onClick={addArtist} 513 className="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400" 514 > 515 + Add Artist 516 </button> 517 </div> 518 519 <div className="space-y-3 pt-2"> 520 <label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"> 521 Recordings * <span className="text-xs text-zinc-500">(at least one required)</span> 522 </label> 523 524 {selectedRecordings.length > 0 && ( 525 <div className="space-y-2"> 526 {selectedRecordings.map((recording, index) => ( 527 <div 528 key={recording.id || `${recording.title || "recording"}-${index}`} 529 draggable 530 onDragStart={() => handleRecordingDragStart(index)} 531 onDragOver={handleRecordingDragOver} 532 onDrop={() => handleRecordingDrop(index)} 533 onDragLeave={() => {}} 534 className={`flex items-center justify-between rounded-lg border-2 p-3 transition-all ${ 535 draggedIndex === index 536 ? "border-blue-400 bg-blue-50 opacity-50 dark:border-blue-600 dark:bg-blue-950/30" 537 : draggedIndex !== null 538 ? "border-zinc-300 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800/50" 539 : "border-zinc-300 bg-zinc-50 cursor-move hover:border-blue-300 dark:border-zinc-700 dark:bg-zinc-800/50 dark:hover:border-blue-700" 540 }`} 541 > 542 <div className="flex-1"> 543 <p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{recording.title}</p> 544 {recording.isrc && ( 545 <p className="text-xs text-zinc-500 dark:text-zinc-400">ISRC: {recording.isrc}</p> 546 )} 547 </div> 548 <button 549 type="button" 550 onClick={() => removeRecording(recording.id)} 551 className="text-sm text-red-600 hover:text-red-700 dark:text-red-400" 552 > 553 Remove 554 </button> 555 </div> 556 ))} 557 </div> 558 )} 559 560 <div className="relative"> 561 <input 562 type="text" 563 value={recordingSearchQuery} 564 onChange={(e) => { 565 setRecordingSearchQuery(e.target.value); 566 setShowRecordingDropdown(true); 567 }} 568 onFocus={() => setShowRecordingDropdown(true)} 569 placeholder="Search recordings by title or ISRC..." 570 className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-800" 571 /> 572 573 {showRecordingDropdown && filteredRecordings.length > 0 && ( 574 <div className="absolute z-10 mt-1 w-full rounded-lg border border-zinc-300 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-900"> 575 {filteredRecordings.slice(0, 5).map((recording) => ( 576 <button 577 key={recording.id} 578 type="button" 579 onClick={() => addRecording(recording)} 580 className="w-full px-3 py-2 text-left hover:bg-zinc-100 dark:hover:bg-zinc-800" 581 > 582 <p className="text-sm text-zinc-900 dark:text-zinc-100">{recording.title}</p> 583 {recording.isrc && ( 584 <p className="text-xs text-zinc-500 dark:text-zinc-400">ISRC: {recording.isrc}</p> 585 )} 586 </button> 587 ))} 588 </div> 589 )} 590 591 {showRecordingDropdown && allRecordings.length > 0 && filteredRecordings.length === 0 && ( 592 <div className="absolute z-10 mt-1 w-full rounded-lg border border-zinc-300 bg-white p-2 text-center text-sm text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-400"> 593 No recordings available 594 </div> 595 )} 596 </div> 597 </div> 598 599 {error && ( 600 <div className="rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-900 dark:border-red-700 dark:bg-red-950/30 dark:text-red-200"> 601 {error} 602 </div> 603 )} 604 605 <div className="flex items-center gap-2"> 606 <button 607 type="submit" 608 onClick={handleSaveRelease} 609 disabled={isSaving || title.trim() === "" || artists.some((a) => !a.name.trim()) || selectedRecordings.length === 0} 610 className="inline-flex rounded-lg bg-blue-600 px-4 py-2 text-white disabled:opacity-50" 611 > 612 {isSaving ? "Saving..." : editingRelease ? "Update Release" : "Save Release"} 613 </button> 614 </div> 615 </form> 616 ); 617}