585 lines
21 KiB
TypeScript
585 lines
21 KiB
TypeScript
"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<SoundtrackRow | null>(null);
|
||
const [deleting, setDeleting] = useState<SoundtrackRow | null>(null);
|
||
const [playingId, setPlayingId] = useState<number | null>(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 (
|
||
<div className="space-y-4">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="relative flex-1 min-w-[220px] max-w-sm">
|
||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder="Cerca per nome, autore, codice o id…"
|
||
className="pl-8"
|
||
/>
|
||
</div>
|
||
<Select value={kindFilter} onValueChange={(v) => setKindFilter(v as typeof kindFilter)}>
|
||
<SelectTrigger className="w-[180px]">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">Tutti ({soundtracks.length})</SelectItem>
|
||
<SelectItem value="single">Singoli MP3 ({counts.single})</SelectItem>
|
||
<SelectItem value="composition">Composition ({counts.composition})</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<div className="text-sm text-muted-foreground">
|
||
{filtered.length} / {soundtracks.length}
|
||
</div>
|
||
<div className="flex-1" />
|
||
{canEdit && (
|
||
<Button onClick={() => setUploadOpen(true)} disabled={isPending}>
|
||
<Plus className="h-4 w-4 mr-1" />
|
||
Upload MP3
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="rounded-md border">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="w-12">ID</TableHead>
|
||
<TableHead className="w-24">Type</TableHead>
|
||
<TableHead className="w-40">Code</TableHead>
|
||
<TableHead>Name</TableHead>
|
||
<TableHead>Author</TableHead>
|
||
<TableHead className="w-20">Length</TableHead>
|
||
<TableHead className="w-20">Used</TableHead>
|
||
<TableHead className="w-[300px]">Preview / Samples</TableHead>
|
||
{canEdit && <TableHead className="w-24 text-right">Actions</TableHead>}
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{filtered.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell colSpan={canEdit ? 9 : 8} className="text-center text-muted-foreground py-8">
|
||
{soundtracks.length === 0 ? (
|
||
<div className="flex flex-col items-center gap-2">
|
||
<Music className="h-8 w-8 opacity-50" />
|
||
<p>Nessun song disk caricato.</p>
|
||
{canEdit && <p className="text-xs">Clicca “Upload MP3” per iniziare.</p>}
|
||
</div>
|
||
) : (
|
||
<>Nessun risultato per i filtri attivi.</>
|
||
)}
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
filtered.map((s) => (
|
||
<TableRow key={s.id}>
|
||
<TableCell className="font-mono text-xs">{s.id}</TableCell>
|
||
<TableCell>
|
||
{s.kind === "composition" ? (
|
||
<Badge variant="outline" className="gap-1">
|
||
<Layers className="h-3 w-3" />
|
||
Trax
|
||
</Badge>
|
||
) : (
|
||
<Badge variant="secondary" className="gap-1">
|
||
<Music className="h-3 w-3" />
|
||
MP3
|
||
</Badge>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="font-mono text-xs break-all">{s.code}</TableCell>
|
||
<TableCell className="font-medium">{s.name}</TableCell>
|
||
<TableCell className="text-muted-foreground">{s.author || "—"}</TableCell>
|
||
<TableCell className="font-mono text-xs">{formatLength(s.length)}</TableCell>
|
||
<TableCell>
|
||
{s.usedBy > 0 ? (
|
||
<Badge variant="secondary">{s.usedBy}</Badge>
|
||
) : (
|
||
<span className="text-muted-foreground text-xs">0</span>
|
||
)}
|
||
</TableCell>
|
||
<TableCell>
|
||
{s.kind === "composition" ? (
|
||
<div className="space-y-1 max-w-[300px]">
|
||
<TraxPlayer
|
||
channels={parseTraxChannels(s.track)}
|
||
baseUrl={baseUrl}
|
||
expectedLength={s.length}
|
||
/>
|
||
<div
|
||
className="text-[10px] text-muted-foreground font-mono truncate"
|
||
title={`Sample usati (${s.samples.length}): ${formatSamples(s.samples)}`}
|
||
>
|
||
{s.samples.length} sample: {formatSamples(s.samples)}
|
||
</div>
|
||
</div>
|
||
) : playingId === s.id ? (
|
||
// biome-ignore lint/a11y/useMediaCaption: hotel soundtrack preview, no caption track available
|
||
<audio
|
||
controls
|
||
autoPlay
|
||
src={`${baseUrl}${encodeURIComponent(s.code)}.mp3`}
|
||
className="h-8 max-w-[280px]"
|
||
onEnded={() => setPlayingId(null)}
|
||
onError={() => toast.error(`Impossibile riprodurre "${s.code}.mp3" da ${baseUrl}.`)}
|
||
/>
|
||
) : (
|
||
<Button size="sm" variant="ghost" onClick={() => setPlayingId(s.id)}>
|
||
▶ Play
|
||
</Button>
|
||
)}
|
||
</TableCell>
|
||
{canEdit && (
|
||
<TableCell className="text-right">
|
||
<div className="inline-flex gap-1">
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-8 w-8"
|
||
onClick={() => setEditing(s)}
|
||
disabled={isPending}
|
||
title="Modifica"
|
||
>
|
||
<Pencil className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||
onClick={() => setDeleting(s)}
|
||
disabled={isPending}
|
||
title="Elimina"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
)}
|
||
</TableRow>
|
||
))
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
|
||
{canEdit && (
|
||
<UploadDialog
|
||
open={uploadOpen}
|
||
onOpenChange={setUploadOpen}
|
||
onUploaded={() => {
|
||
invalidateSongPickerCache();
|
||
router.refresh();
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{canEdit && editing && (
|
||
<EditDialog
|
||
soundtrack={editing}
|
||
onOpenChange={(open) => !open && setEditing(null)}
|
||
onSaved={() => {
|
||
invalidateSongPickerCache();
|
||
setEditing(null);
|
||
router.refresh();
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{canEdit && deleting && (
|
||
<Dialog open onOpenChange={(open) => !open && setDeleting(null)}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Eliminare “{deleting.name}”?</DialogTitle>
|
||
<DialogDescription>
|
||
{deleting.usedBy > 0 ? (
|
||
<>
|
||
Questo song disk è attualmente usato da <strong>{deleting.usedBy}</strong> 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.
|
||
</>
|
||
)}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setDeleting(null)} disabled={isPending}>
|
||
Annulla
|
||
</Button>
|
||
<Button variant="destructive" onClick={handleDelete} disabled={isPending}>
|
||
{isPending && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
|
||
Elimina
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Upload dialog ────────────────────────────────────────────────────
|
||
|
||
function UploadDialog({
|
||
open,
|
||
onOpenChange,
|
||
onUploaded,
|
||
}: {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
onUploaded: () => void;
|
||
}) {
|
||
const fileRef = useRef<HTMLInputElement>(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 (
|
||
<Dialog
|
||
open={open}
|
||
onOpenChange={(o) => {
|
||
if (!uploading) onOpenChange(o);
|
||
}}
|
||
>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Upload Song Disk</DialogTitle>
|
||
<DialogDescription>
|
||
Carica un file MP3. Dimensione massima 10 MB. Il codice sarà generato automaticamente come{" "}
|
||
<code>song_{id}</code>.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<form onSubmit={handleSubmit} className="space-y-4">
|
||
<div className="space-y-1">
|
||
<Label htmlFor="sound-file">File MP3</Label>
|
||
<Input id="sound-file" type="file" accept=".mp3,audio/mpeg" ref={fileRef} required />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label htmlFor="sound-name">Nome</Label>
|
||
<Input
|
||
id="sound-name"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder="Titolo del brano"
|
||
maxLength={100}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label htmlFor="sound-author">Autore</Label>
|
||
<Input
|
||
id="sound-author"
|
||
value={author}
|
||
onChange={(e) => setAuthor(e.target.value)}
|
||
placeholder="Autore (opzionale)"
|
||
maxLength={50}
|
||
/>
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={uploading}>
|
||
Annulla
|
||
</Button>
|
||
<Button type="submit" disabled={uploading}>
|
||
{uploading ? (
|
||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||
) : (
|
||
<Upload className="h-4 w-4 mr-1" />
|
||
)}
|
||
Carica
|
||
</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
// ── 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 (
|
||
<Dialog open onOpenChange={(o) => !o && onOpenChange(false)}>
|
||
<DialogContent className="max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>Modifica {isComposition ? "Trax Composition" : "Song Disk"}</DialogTitle>
|
||
<DialogDescription>
|
||
<span className="font-mono text-xs">{soundtrack.code}</span>
|
||
{isComposition ? (
|
||
<>
|
||
{" "}
|
||
— composition di {soundtrack.samples.length} sample. Modifica il campo <code>track</code> solo
|
||
se sai cosa stai facendo.
|
||
</>
|
||
) : (
|
||
<> — il file MP3 non può essere sostituito: elimina e ricarica se devi cambiare audio.</>
|
||
)}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<form onSubmit={handleSubmit} className="space-y-4">
|
||
<div className="space-y-1">
|
||
<Label htmlFor="edit-name">Nome</Label>
|
||
<Input
|
||
id="edit-name"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
maxLength={100}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label htmlFor="edit-author">Autore</Label>
|
||
<Input
|
||
id="edit-author"
|
||
value={author}
|
||
onChange={(e) => setAuthor(e.target.value)}
|
||
maxLength={50}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label htmlFor="edit-length">Durata (secondi)</Label>
|
||
<Input
|
||
id="edit-length"
|
||
type="number"
|
||
min={0}
|
||
value={length}
|
||
onChange={(e) => setLength(e.target.value)}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
{isComposition
|
||
? "Durata totale della composition. Lascia 0 se sconosciuta."
|
||
: "Impostato automaticamente dall’upload. Modifica solo se errato."}
|
||
</p>
|
||
</div>
|
||
|
||
{isComposition && (
|
||
<div className="space-y-1">
|
||
<Label htmlFor="edit-track">Track (Trax composition)</Label>
|
||
<Textarea
|
||
id="edit-track"
|
||
value={track}
|
||
onChange={(e) => setTrack(e.target.value)}
|
||
rows={5}
|
||
className="font-mono text-xs"
|
||
placeholder="1:371,8;374,4;378,4;0,4;…"
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
Formato: <code>canale:sample_id,durata;sample_id,durata;…</code> — <code>0</code> = silenzio.
|
||
Sample IDs rilevati: <span className="font-mono">{soundtrack.samples.join(", ") || "—"}</span>
|
||
.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<DialogFooter>
|
||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||
Annulla
|
||
</Button>
|
||
<Button type="submit" disabled={isPending}>
|
||
{isPending && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
|
||
Salva
|
||
</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|