Files
EpicNext-Cms/src/app/admin/sounds/sounds-client.tsx
T
openhands 2e4ed76121
Local Build and Deploy / deploy (push) Successful in 49s
style: format code with prettier
2026-07-12 21:07:34 +02:00

585 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 &ldquo;Upload MP3&rdquo; 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 &ldquo;{deleting.name}&rdquo;?</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
&ldquo;nessun song disk&rdquo;.
{deleting.kind === "single" && <> Il file MP3 sarà rimosso dal disco.</>} L&apos;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&apos;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_&#123;id&#125;</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>
);
}