this repo has no description
at main 716 lines 24 kB view raw
1"use client"; 2 3import { useEffect, useRef, useState } from "react"; 4import { useRouter } from "next/navigation"; 5import { cleanISRC, isValidISRC, ISRC_ERROR_MESSAGE } from "@/lib/validation"; 6 7interface Song { 8 id: string; 9 title: string; 10 iswc?: string; 11 interestedParties: any[]; 12} 13 14interface Artist { 15 name: string; 16 did?: string; 17 artist?: { 18 $type?: string; 19 name?: string; 20 }; 21} 22 23interface MasterOwnerInfo { 24 name?: string; 25 did?: string; 26 masterOwner?: { 27 $type?: string; 28 name?: string; 29 }; 30} 31 32interface HandleSuggestion { 33 did: string; 34 handle: string; 35 displayName?: string; 36} 37 38interface PartyLookupState { 39 handleQuery: string; 40 suggestions: HandleSuggestion[]; 41 searching: boolean; 42 lookupLoading: boolean; 43 message: string | null; 44} 45 46const emptyLookupState = (): PartyLookupState => ({ 47 handleQuery: "", 48 suggestions: [], 49 searching: false, 50 lookupLoading: false, 51 message: null, 52}); 53 54function formatDurationForInput(seconds?: number): string { 55 if (seconds == null || Number.isNaN(seconds)) return ""; 56 57 const minutes = Math.floor(seconds / 60); 58 const remainingSeconds = seconds % 60; 59 return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`; 60} 61 62function parseDurationInput(value: string): number | null { 63 const trimmed = value.trim(); 64 if (!trimmed) return null; 65 66 const match = trimmed.match(/^(\d+):(\d{2})$/); 67 if (!match) return null; 68 69 const minutes = Number(match[1]); 70 const seconds = Number(match[2]); 71 if (seconds >= 60) return null; 72 73 return minutes * 60 + seconds; 74} 75 76interface RecordingToEdit { 77 id: string; 78 title: string; 79 song?: { ref: string }; 80 artists: Artist[]; 81 isrc?: string; 82 masterOwner?: MasterOwnerInfo; 83 duration?: number; 84} 85 86export function RecordingForm({ 87 editingRecording, 88 onRecordingSaved, 89}: { 90 editingRecording?: RecordingToEdit; 91 onRecordingSaved?: () => void; 92}) { 93 const router = useRouter(); 94 const [title, setTitle] = useState(editingRecording?.title || ""); 95 const [songs, setSongs] = useState<Song[]>([]); 96 const [selectedSongId, setSelectedSongId] = useState(editingRecording?.song?.ref || ""); 97 const [artists, setArtists] = useState<Artist[]>( 98 editingRecording?.artists && editingRecording.artists.length > 0 99 ? editingRecording.artists 100 : [{ name: "" }], 101 ); 102 const [isrc, setIsrc] = useState(editingRecording?.isrc || ""); 103 const [masterOwner, setMasterOwner] = useState<MasterOwnerInfo>(editingRecording?.masterOwner || {}); 104 const [duration, setDuration] = useState(formatDurationForInput(editingRecording?.duration)); 105 const [loading, setLoading] = useState(false); 106 const [error, setError] = useState<string | null>(null); 107 const [artistLookups, setArtistLookups] = useState<PartyLookupState[]>( 108 editingRecording?.artists 109 ? editingRecording.artists.map(() => emptyLookupState()) 110 : [emptyLookupState()], 111 ); 112 const [masterOwnerLookup, setMasterOwnerLookup] = useState<PartyLookupState>(emptyLookupState()); 113 const lookupTimersRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({}); 114 115 useEffect(() => { 116 async function fetchSongs() { 117 try { 118 const res = await fetch("/api/song"); 119 if (!res.ok) throw new Error("Failed to fetch songs"); 120 const data = await res.json(); 121 setSongs(data.songs || []); 122 } catch (err) { 123 console.error("Failed to fetch songs:", err); 124 } 125 } 126 127 fetchSongs(); 128 }, []); 129 130 useEffect(() => { 131 return () => { 132 Object.values(lookupTimersRef.current).forEach((timer) => clearTimeout(timer)); 133 }; 134 }, []); 135 136 const updateArtist = <K extends keyof Artist>(index: number, field: K, value: Artist[K]) => { 137 setArtists((prev) => { 138 const updated = [...prev]; 139 const current = updated[index] || { name: "" }; 140 updated[index] = { ...current, [field]: value }; 141 return updated; 142 }); 143 }; 144 145 const updateArtistLookup = (index: number, updates: Partial<PartyLookupState>) => { 146 setArtistLookups((prev) => { 147 const updated = [...prev]; 148 const current = updated[index] || emptyLookupState(); 149 updated[index] = { ...current, ...updates }; 150 return updated; 151 }); 152 }; 153 154 const searchHandleSuggestions = async (index: number, query: string) => { 155 updateArtistLookup(index, { searching: true, message: null }); 156 157 try { 158 const res = await fetch(`/api/actor-search?q=${encodeURIComponent(query)}`); 159 if (!res.ok) throw new Error("Search failed"); 160 161 const data = await res.json(); 162 const suggestions: HandleSuggestion[] = Array.isArray(data.actors) 163 ? data.actors 164 .filter((actor: any) => typeof actor?.did === "string" && typeof actor?.handle === "string") 165 .map((actor: any) => ({ 166 did: actor.did, 167 handle: actor.handle, 168 displayName: typeof actor.displayName === "string" ? actor.displayName : undefined, 169 })) 170 : []; 171 172 updateArtistLookup(index, { suggestions }); 173 } catch (err) { 174 console.error("Failed to search handles:", err); 175 updateArtistLookup(index, { 176 suggestions: [], 177 message: "Handle search failed. Try again.", 178 }); 179 } finally { 180 updateArtistLookup(index, { searching: false }); 181 } 182 }; 183 184 const fetchArtistForDid = async (index: number, did: string) => { 185 updateArtistLookup(index, { 186 lookupLoading: true, 187 message: "Looking up artist record...", 188 }); 189 190 try { 191 const res = await fetch(`/api/artist?did=${encodeURIComponent(did)}`); 192 if (!res.ok) throw new Error("Lookup failed"); 193 194 const data = await res.json(); 195 const record = data.artist || null; 196 197 setArtists((prev) => { 198 const updated = [...prev]; 199 const current = updated[index]; 200 if (!current) return prev; 201 202 const nextArtist: Artist = { 203 ...current, 204 did, 205 }; 206 207 if (record?.name && !nextArtist.name) { 208 nextArtist.name = record.name; 209 } 210 211 if (record) { 212 nextArtist.artist = { 213 ...record, 214 $type: record.$type || "ch.indiemusi.alpha.actor.artist", 215 }; 216 } else { 217 delete nextArtist.artist; 218 } 219 220 updated[index] = nextArtist; 221 return updated; 222 }); 223 224 if (record) { 225 updateArtistLookup(index, { message: "Artist record linked." }); 226 } else { 227 updateArtistLookup(index, { 228 message: "No artist record found for this DID. You can still type a name.", 229 }); 230 } 231 } catch (err) { 232 console.error("Failed to fetch artist for DID:", err); 233 updateArtistLookup(index, { 234 message: "Could not load artist record for this DID.", 235 }); 236 } finally { 237 updateArtistLookup(index, { lookupLoading: false }); 238 } 239 }; 240 241 const onArtistHandleInputChange = (index: number, value: string) => { 242 const existingTimer = lookupTimersRef.current[index]; 243 if (existingTimer) clearTimeout(existingTimer); 244 245 updateArtistLookup(index, { 246 handleQuery: value, 247 suggestions: [], 248 searching: false, 249 message: null, 250 }); 251 252 updateArtist(index, "did", undefined); 253 updateArtist(index, "artist", undefined); 254 255 const query = value.trim(); 256 if (query.length < 2) { 257 return; 258 } 259 260 lookupTimersRef.current[index] = setTimeout(() => { 261 void searchHandleSuggestions(index, query); 262 }, 250); 263 }; 264 265 const onSelectArtistHandle = async (index: number, suggestion: HandleSuggestion) => { 266 const existingTimer = lookupTimersRef.current[index]; 267 if (existingTimer) clearTimeout(existingTimer); 268 269 updateArtistLookup(index, { 270 handleQuery: suggestion.handle, 271 suggestions: [], 272 searching: false, 273 message: null, 274 }); 275 276 updateArtist(index, "did", suggestion.did); 277 await fetchArtistForDid(index, suggestion.did); 278 }; 279 280 const addArtist = () => { 281 setArtists((prev) => [...prev, { name: "" }]); 282 setArtistLookups((prev) => [...prev, emptyLookupState()]); 283 }; 284 285 const removeArtist = (index: number) => { 286 const existingTimer = lookupTimersRef.current[index]; 287 if (existingTimer) clearTimeout(existingTimer); 288 289 const reindexedTimers: Record<number, ReturnType<typeof setTimeout>> = {}; 290 Object.entries(lookupTimersRef.current).forEach(([key, timer]) => { 291 const timerIndex = Number(key); 292 if (timerIndex < index) reindexedTimers[timerIndex] = timer; 293 if (timerIndex > index) reindexedTimers[timerIndex - 1] = timer; 294 }); 295 lookupTimersRef.current = reindexedTimers; 296 297 setArtists((prev) => prev.filter((_, i) => i !== index)); 298 setArtistLookups((prev) => prev.filter((_, i) => i !== index)); 299 }; 300 301 const onMasterOwnerHandleChange = (value: string) => { 302 setMasterOwnerLookup((prev) => ({ 303 ...prev, 304 handleQuery: value, 305 suggestions: [], 306 searching: false, 307 message: null, 308 })); 309 setMasterOwner((prev) => ({ ...prev, did: undefined, masterOwner: undefined })); 310 311 const query = value.trim(); 312 if (query.length < 2) return; 313 314 setMasterOwnerLookup((prev) => ({ ...prev, searching: true })); 315 setTimeout(() => { 316 fetch(`/api/actor-search?q=${encodeURIComponent(query)}`) 317 .then((res) => res.json()) 318 .then((data) => { 319 const suggestions: HandleSuggestion[] = Array.isArray(data.actors) 320 ? data.actors 321 .filter((actor: any) => typeof actor?.did === "string" && typeof actor?.handle === "string") 322 .map((actor: any) => ({ 323 did: actor.did, 324 handle: actor.handle, 325 displayName: typeof actor.displayName === "string" ? actor.displayName : undefined, 326 })) 327 : []; 328 setMasterOwnerLookup((prev) => ({ ...prev, suggestions, searching: false })); 329 }) 330 .catch(() => { 331 setMasterOwnerLookup((prev) => ({ 332 ...prev, 333 suggestions: [], 334 searching: false, 335 message: "Search failed", 336 })); 337 }); 338 }, 250); 339 }; 340 341 const onSelectMasterOwnerHandle = (suggestion: HandleSuggestion) => { 342 setMasterOwnerLookup((prev) => ({ 343 ...prev, 344 handleQuery: suggestion.handle, 345 suggestions: [], 346 lookupLoading: true, 347 message: null, 348 })); 349 setMasterOwner((prev) => ({ ...prev, did: suggestion.did })); 350 351 fetch(`/api/master-owner?did=${encodeURIComponent(suggestion.did)}`) 352 .then((res) => res.json()) 353 .then((data) => { 354 if (data.masterOwner) { 355 const record = data.masterOwner; 356 const name = record.name || ""; 357 setMasterOwner((prev) => ({ 358 ...prev, 359 name: prev.name || name, 360 masterOwner: { 361 ...record, 362 $type: record.$type || "ch.indiemusi.alpha.actor.masterOwner", 363 }, 364 })); 365 setMasterOwnerLookup((prev) => ({ 366 ...prev, 367 lookupLoading: false, 368 message: "Master owner record fetched", 369 })); 370 } else { 371 setMasterOwner((prev) => ({ ...prev, masterOwner: undefined })); 372 setMasterOwnerLookup((prev) => ({ 373 ...prev, 374 lookupLoading: false, 375 message: "No master owner record found for this DID.", 376 })); 377 } 378 }) 379 .catch(() => { 380 setMasterOwner((prev) => ({ ...prev, masterOwner: undefined })); 381 setMasterOwnerLookup((prev) => ({ 382 ...prev, 383 lookupLoading: false, 384 message: "DID selected", 385 })); 386 }); 387 }; 388 389 async function handleSubmit(e: React.FormEvent) { 390 e.preventDefault(); 391 setLoading(true); 392 setError(null); 393 394 if (artists.length === 0 || artists.some((a) => !a.name)) { 395 setError("All artists must have a name"); 396 setLoading(false); 397 return; 398 } 399 400 if (isrc && !isValidISRC(isrc)) { 401 setError(ISRC_ERROR_MESSAGE); 402 setLoading(false); 403 return; 404 } 405 406 const parsedDuration = parseDurationInput(duration); 407 if (duration && parsedDuration == null) { 408 setError("Duration must be in mm:ss format"); 409 setLoading(false); 410 return; 411 } 412 413 try { 414 const payload = { 415 title, 416 artists: artists.map((a) => { 417 const out: any = { name: a.name }; 418 if (a.did) out.did = a.did; 419 if (a.artist?.$type === "ch.indiemusi.alpha.actor.artist") { 420 out.artist = a.artist; 421 } 422 return out; 423 }), 424 }; 425 426 if (selectedSongId) { 427 const selectedSong = songs.find((s) => s.id === selectedSongId); 428 if (selectedSong) { 429 const { id: _id, ...songRecord } = selectedSong; 430 (payload as any).song = songRecord; 431 } 432 } 433 434 if (isrc) (payload as any).isrc = cleanISRC(isrc); 435 if (masterOwner?.did || masterOwner?.name || masterOwner?.masterOwner) { 436 const nextMasterOwner: any = {}; 437 if (masterOwner.name) nextMasterOwner.name = masterOwner.name; 438 if (masterOwner.did) nextMasterOwner.did = masterOwner.did; 439 if (masterOwner.masterOwner?.$type === "ch.indiemusi.alpha.actor.masterOwner") { 440 nextMasterOwner.masterOwner = masterOwner.masterOwner; 441 } 442 (payload as any).masterOwner = nextMasterOwner; 443 } 444 if (parsedDuration != null) (payload as any).duration = parsedDuration; 445 446 const isEditing = !!editingRecording; 447 const res = await fetch("/api/recording", { 448 method: isEditing ? "PUT" : "POST", 449 headers: { "Content-Type": "application/json" }, 450 body: JSON.stringify(isEditing ? { ...payload, uri: editingRecording.id } : payload), 451 }); 452 453 if (!res.ok) { 454 const data = await res.json(); 455 throw new Error(data.error || `Failed to ${isEditing ? "update" : "create"} recording`); 456 } 457 458 router.refresh(); 459 onRecordingSaved?.(); 460 } catch (err) { 461 console.error(`Failed to ${editingRecording ? "update" : "create"} recording:`, err); 462 setError((err as Error).message || "Failed to save recording"); 463 } finally { 464 setLoading(false); 465 } 466 } 467 468 return ( 469 <form onSubmit={handleSubmit} className="space-y-4"> 470 <h2 className="mb-4 text-lg font-semibold text-zinc-900 dark:text-zinc-100"> 471 {editingRecording ? "Edit Recording" : "New Recording"} 472 </h2> 473 474 <div> 475 <label className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">Song</label> 476 <select 477 value={selectedSongId} 478 onChange={(e) => { 479 const value = e.target.value; 480 setSelectedSongId(value); 481 482 if (!value) return; 483 484 const selectedSong = songs.find((song) => song.id === value); 485 if (selectedSong) { 486 setTitle(selectedSong.title); 487 } 488 }} 489 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" 490 disabled={loading} 491 > 492 <option value="">-- Select a song (optional) --</option> 493 {songs.map((song) => ( 494 <option key={song.id} value={song.id}> 495 {song.title} 496 </option> 497 ))} 498 </select> 499 </div> 500 501 <div> 502 <label className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"> 503 Title * 504 </label> 505 <input 506 type="text" 507 value={title} 508 onChange={(e) => setTitle(e.target.value)} 509 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" 510 disabled={loading} 511 required 512 /> 513 </div> 514 515 <div> 516 <label className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">ISRC Code</label> 517 <input 518 type="text" 519 value={isrc} 520 onChange={(e) => setIsrc(e.target.value)} 521 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" 522 disabled={loading} 523 placeholder="e.g., USSM12345678" 524 /> 525 </div> 526 527 <div> 528 <label className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"> 529 Duration (mm:ss) 530 </label> 531 <input 532 type="text" 533 value={duration} 534 onChange={(e) => setDuration(e.target.value)} 535 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" 536 disabled={loading} 537 placeholder="e.g., 03:00" 538 /> 539 </div> 540 541 <div className="space-y-3 pt-2"> 542 <label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"> 543 Artists * <span className="text-xs text-zinc-500">(at least one required)</span> 544 </label> 545 546 {artists.map((artist, index) => { 547 const lookup = artistLookups[index] || emptyLookupState(); 548 return ( 549 <div 550 key={index} 551 className="space-y-2 rounded-lg border border-zinc-300 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800/50" 552 > 553 <div className="relative"> 554 <label className="mb-1 block text-xs text-zinc-600 dark:text-zinc-400">Handle</label> 555 <input 556 type="text" 557 value={lookup.handleQuery} 558 onChange={(e) => onArtistHandleInputChange(index, e.target.value)} 559 className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm dark:border-zinc-700 dark:bg-zinc-800" 560 disabled={loading} 561 placeholder="Type a handle, e.g. alice.bsky.social" 562 /> 563 564 {lookup.suggestions.length > 0 && ( 565 <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"> 566 {lookup.suggestions.map((suggestion) => ( 567 <button 568 key={suggestion.did} 569 type="button" 570 onClick={() => { 571 void onSelectArtistHandle(index, suggestion); 572 }} 573 className="w-full px-2 py-2 text-left hover:bg-zinc-100 dark:hover:bg-zinc-800" 574 > 575 <div className="text-sm text-zinc-900 dark:text-zinc-100">{suggestion.handle}</div> 576 {suggestion.displayName && ( 577 <div className="text-xs text-zinc-500 dark:text-zinc-400">{suggestion.displayName}</div> 578 )} 579 </button> 580 ))} 581 </div> 582 )} 583 </div> 584 585 <div> 586 <label className="mb-1 block text-xs text-zinc-600 dark:text-zinc-400">DID</label> 587 <input 588 type="text" 589 value={artist.did || ""} 590 readOnly 591 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" 592 /> 593 </div> 594 595 <div> 596 <label className="mb-1 block text-xs text-zinc-600 dark:text-zinc-400">Name *</label> 597 <input 598 type="text" 599 value={artist.name} 600 onChange={(e) => updateArtist(index, "name", e.target.value)} 601 className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm dark:border-zinc-700 dark:bg-zinc-800" 602 disabled={loading} 603 /> 604 </div> 605 606 {lookup.message && ( 607 <p className="text-xs text-zinc-500 dark:text-zinc-400"> 608 {lookup.lookupLoading ? "Fetching record..." : lookup.message} 609 </p> 610 )} 611 612 {index > 0 && ( 613 <button 614 type="button" 615 onClick={() => removeArtist(index)} 616 className="text-xs text-red-600 hover:text-red-700 dark:text-red-400" 617 disabled={loading} 618 > 619 Remove 620 </button> 621 )} 622 </div> 623 ); 624 })} 625 626 <button 627 type="button" 628 onClick={addArtist} 629 className="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400" 630 disabled={loading} 631 > 632 + Add Artist 633 </button> 634 </div> 635 636 <div className="space-y-3 pt-2"> 637 <label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"> 638 Master Owner (optional) 639 </label> 640 641 <div className="space-y-2 rounded-lg border border-zinc-300 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800/50"> 642 <div className="relative"> 643 <label className="mb-1 block text-xs text-zinc-600 dark:text-zinc-400">Handle</label> 644 <input 645 type="text" 646 value={masterOwnerLookup.handleQuery} 647 onChange={(e) => onMasterOwnerHandleChange(e.target.value)} 648 className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm dark:border-zinc-700 dark:bg-zinc-800" 649 disabled={loading} 650 placeholder="Type a handle, e.g. alice.bsky.social" 651 /> 652 653 {masterOwnerLookup.suggestions.length > 0 && ( 654 <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"> 655 {masterOwnerLookup.suggestions.map((suggestion) => ( 656 <button 657 key={suggestion.did} 658 type="button" 659 onClick={() => { 660 onSelectMasterOwnerHandle(suggestion); 661 }} 662 className="w-full px-2 py-2 text-left hover:bg-zinc-100 dark:hover:bg-zinc-800" 663 > 664 <div className="text-sm text-zinc-900 dark:text-zinc-100">{suggestion.handle}</div> 665 {suggestion.displayName && ( 666 <div className="text-xs text-zinc-500 dark:text-zinc-400">{suggestion.displayName}</div> 667 )} 668 </button> 669 ))} 670 </div> 671 )} 672 </div> 673 674 <div> 675 <label className="mb-1 block text-xs text-zinc-600 dark:text-zinc-400">DID</label> 676 <input 677 type="text" 678 value={masterOwner.did || ""} 679 readOnly 680 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" 681 /> 682 </div> 683 684 <div> 685 <label className="mb-1 block text-xs text-zinc-600 dark:text-zinc-400">Name</label> 686 <input 687 type="text" 688 value={masterOwner.name || ""} 689 onChange={(e) => setMasterOwner((prev) => ({ ...prev, name: e.target.value }))} 690 className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm dark:border-zinc-700 dark:bg-zinc-800" 691 disabled={loading} 692 /> 693 </div> 694 695 {masterOwnerLookup.message && ( 696 <p className="text-xs text-zinc-500 dark:text-zinc-400"> 697 {masterOwnerLookup.lookupLoading ? "Fetching record..." : masterOwnerLookup.message} 698 </p> 699 )} 700 </div> 701 </div> 702 703 {error && <p className="text-sm text-red-500">{error}</p>} 704 705 <div className="flex items-center gap-2"> 706 <button 707 type="submit" 708 disabled={loading || !title || artists.length === 0} 709 className="inline-flex rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50" 710 > 711 {loading ? "Saving..." : editingRecording ? "Update Recording" : "Save Recording"} 712 </button> 713 </div> 714 </form> 715 ); 716}