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