this repo has no description
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}