"use client"; import { Layers, Loader2, Music, Pencil, Plus, Search, Trash2, Upload } from "lucide-react"; import { useRouter } from "next/navigation"; import { useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { deleteSoundtrack, updateSoundtrack } from "@/actions/soundtracks"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; import { useServerAction } from "@/hooks/use-server-action"; import { invalidateSongPickerCache } from "@/lib/client-cache/song-picker-cache"; import { parseTraxChannels } from "@/lib/trax-format"; import { TraxPlayer } from "./trax-player"; interface SoundtrackRow { id: number; code: string; name: string; author: string; length: number; track: string; samples: number[]; kind: "single" | "composition"; usedBy: number; } function formatLength(seconds: number): string { if (!seconds || seconds <= 0) return "—"; const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m}:${String(s).padStart(2, "0")}`; } function formatSamples(samples: number[]): string { if (samples.length === 0) return "—"; if (samples.length <= 20) return samples.join(", "); return `${samples.slice(0, 20).join(", ")}, …(+${samples.length - 20})`; } export function SoundsClient({ soundtracks, canEdit, baseUrl, }: { soundtracks: SoundtrackRow[]; canEdit: boolean; baseUrl: string; }) { const router = useRouter(); const { run, isPending } = useServerAction(); const [query, setQuery] = useState(""); const [kindFilter, setKindFilter] = useState<"all" | "single" | "composition">("all"); const [uploadOpen, setUploadOpen] = useState(false); const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); const [playingId, setPlayingId] = useState(null); const counts = useMemo(() => { let single = 0; let composition = 0; for (const s of soundtracks) { if (s.kind === "composition") composition++; else single++; } return { single, composition }; }, [soundtracks]); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); return soundtracks.filter((s) => { if (kindFilter !== "all" && s.kind !== kindFilter) return false; if (!q) return true; return ( s.name.toLowerCase().includes(q) || s.author.toLowerCase().includes(q) || s.code.toLowerCase().includes(q) || String(s.id).includes(q) ); }); }, [soundtracks, query, kindFilter]); function handleDelete() { if (!deleting) return; const id = deleting.id; run(() => deleteSoundtrack({ id }), { successMessage: `Song disk #${id} eliminato.`, errorMessage: `Impossibile eliminare il song disk #${id}.`, onSuccess: () => { invalidateSongPickerCache(); setDeleting(null); router.refresh(); }, }); } return (
setQuery(e.target.value)} placeholder="Cerca per nome, autore, codice o id…" className="pl-8" />
{filtered.length} / {soundtracks.length}
{canEdit && ( )}
ID Type Code Name Author Length Used Preview / Samples {canEdit && Actions} {filtered.length === 0 ? ( {soundtracks.length === 0 ? (

Nessun song disk caricato.

{canEdit &&

Clicca “Upload MP3” per iniziare.

}
) : ( <>Nessun risultato per i filtri attivi. )}
) : ( filtered.map((s) => ( {s.id} {s.kind === "composition" ? ( Trax ) : ( MP3 )} {s.code} {s.name} {s.author || "—"} {formatLength(s.length)} {s.usedBy > 0 ? ( {s.usedBy} ) : ( 0 )} {s.kind === "composition" ? (
{s.samples.length} sample: {formatSamples(s.samples)}
) : playingId === s.id ? ( // biome-ignore lint/a11y/useMediaCaption: hotel soundtrack preview, no caption track available
{canEdit && (
)}
)) )}
{canEdit && ( { invalidateSongPickerCache(); router.refresh(); }} /> )} {canEdit && editing && ( !open && setEditing(null)} onSaved={() => { invalidateSongPickerCache(); setEditing(null); router.refresh(); }} /> )} {canEdit && deleting && ( !open && setDeleting(null)}> Eliminare “{deleting.name}”? {deleting.usedBy > 0 ? ( <> Questo song disk è attualmente usato da {deleting.usedBy} catalog item {deleting.usedBy === 1 ? "" : "s"}. Eliminandolo, quei catalog item verranno riportati a “nessun song disk”. {deleting.kind === "single" && <> Il file MP3 sarà rimosso dal disco.} L'azione è irreversibile. ) : ( <> {deleting.kind === "single" ? "Il file MP3 sarà rimosso dal disco e la riga dal database." : "La riga sarà rimossa dal database. Nessun file viene eliminato (una composition non ha MP3 dedicato)."}{" "} L'azione è irreversibile. )} )}
); } // ── Upload dialog ──────────────────────────────────────────────────── function UploadDialog({ open, onOpenChange, onUploaded, }: { open: boolean; onOpenChange: (open: boolean) => void; onUploaded: () => void; }) { const fileRef = useRef(null); const [name, setName] = useState(""); const [author, setAuthor] = useState(""); const [uploading, setUploading] = useState(false); function reset() { setName(""); setAuthor(""); if (fileRef.current) fileRef.current.value = ""; } async function handleSubmit(e: React.FormEvent) { e.preventDefault(); const file = fileRef.current?.files?.[0]; if (!file) { toast.error("Seleziona un file MP3."); return; } if (!name.trim()) { toast.error("Il nome è obbligatorio."); return; } setUploading(true); const fd = new FormData(); fd.append("file", file); fd.append("name", name.trim()); fd.append("author", author.trim()); try { const res = await fetch("/api/admin/sounds/upload", { method: "POST", body: fd }); const json = (await res.json()) as { ok?: boolean; error?: string; id?: number }; if (!res.ok || !json.ok) { toast.error(json.error || "Upload fallito."); return; } toast.success(`Song disk #${json.id} caricato.`); reset(); onOpenChange(false); onUploaded(); } catch (err) { console.error(err); toast.error("Errore di rete durante l'upload."); } finally { setUploading(false); } } return ( { if (!uploading) onOpenChange(o); }} > Upload Song Disk Carica un file MP3. Dimensione massima 10 MB. Il codice sarà generato automaticamente come{" "} song_{id}.
setName(e.target.value)} placeholder="Titolo del brano" maxLength={100} required />
setAuthor(e.target.value)} placeholder="Autore (opzionale)" maxLength={50} />
); } // ── Edit dialog ────────────────────────────────────────────────────── function EditDialog({ soundtrack, onOpenChange, onSaved, }: { soundtrack: SoundtrackRow; onOpenChange: (open: boolean) => void; onSaved: () => void; }) { const [name, setName] = useState(soundtrack.name); const [author, setAuthor] = useState(soundtrack.author); const [track, setTrack] = useState(soundtrack.track ?? ""); const [length, setLength] = useState(String(soundtrack.length ?? 0)); const { run, isPending } = useServerAction(); const isComposition = soundtrack.kind === "composition"; function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!name.trim()) { toast.error("Il nome è obbligatorio."); return; } const lenNum = Number.parseInt(length, 10); if (!Number.isFinite(lenNum) || lenNum < 0) { toast.error("La durata deve essere un numero ≥ 0."); return; } run( () => updateSoundtrack({ id: soundtrack.id, name: name.trim(), author: author.trim(), track: isComposition ? track : (soundtrack.track ?? ""), length: lenNum, }), { successMessage: "Song disk aggiornato.", errorMessage: "Impossibile aggiornare il song disk.", onSuccess: onSaved, }, ); } return ( !o && onOpenChange(false)}> Modifica {isComposition ? "Trax Composition" : "Song Disk"} {soundtrack.code} {isComposition ? ( <> {" "} — composition di {soundtrack.samples.length} sample. Modifica il campo track solo se sai cosa stai facendo. ) : ( <> — il file MP3 non può essere sostituito: elimina e ricarica se devi cambiare audio. )}
setName(e.target.value)} maxLength={100} required />
setAuthor(e.target.value)} maxLength={50} />
setLength(e.target.value)} />

{isComposition ? "Durata totale della composition. Lascia 0 se sconosciuta." : "Impostato automaticamente dall’upload. Modifica solo se errato."}

{isComposition && (