Files
EpicNext-Cms/src/app/admin/prefixes/prefixes-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

704 lines
24 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 { Pencil, Plus, Save, Search, Trash2, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import {
addBlacklistWord as addBlacklistWordAction,
createPrefix,
deletePrefix as deletePrefixAction,
removeBlacklistWord as removeBlacklistWordAction,
updatePrefix,
updatePrefixSettings,
} from "@/actions/prefixes";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useServerAction } from "@/hooks/use-server-action";
interface UserPrefix {
id: number;
user_id: number;
text: string;
color: string;
icon: string;
effect: string;
active: boolean;
created_at: string;
username?: string;
}
interface BlacklistWord {
id: number;
word: string;
created_at: string;
}
interface PrefixSettings {
max_length: string;
min_rank_to_buy: string;
price_credits: string;
price_points: string;
points_type: string;
}
interface PrefixFormData {
username: string;
text: string;
color: string;
icon: string;
effect: string;
active: boolean;
}
const PRESET_EFFECTS = [
{ id: "", label: "Nessuno" },
{ id: "glow", label: "✨ Bagliore" },
{ id: "shadow", label: "🌑 Ombra" },
{ id: "italic", label: "𝑰 Corsivo" },
{ id: "outline", label: "🔲 Contorno" },
{ id: "pulse", label: "💫 Pulsante" },
{ id: "bold-glow", label: "💡 Neon" },
];
const PRESET_ICONS = [
{ emoji: "", label: "Nessuna" },
{ emoji: "👑", label: "Corona" },
{ emoji: "⭐", label: "Stella" },
{ emoji: "❤️", label: "Cuore" },
{ emoji: "💎", label: "Diamante" },
{ emoji: "🔥", label: "Fuoco" },
{ emoji: "⚡", label: "Fulmine" },
{ emoji: "🛡️", label: "Scudo" },
{ emoji: "💀", label: "Teschio" },
{ emoji: "🎵", label: "Musica" },
{ emoji: "💠", label: "Gemma" },
{ emoji: "⚔️", label: "Spade" },
{ emoji: "🏆", label: "Trofeo" },
{ emoji: "🚀", label: "Razzo" },
{ emoji: "👻", label: "Fantasma" },
{ emoji: "🐾", label: "Zampa" },
{ emoji: "🍃", label: "Foglia" },
{ emoji: "😎", label: "Cool" },
{ emoji: "🐺", label: "Lupo" },
{ emoji: "🦁", label: "Leone" },
{ emoji: "🐉", label: "Drago" },
{ emoji: "🦄", label: "Unicorno" },
{ emoji: "🌙", label: "Luna" },
{ emoji: "☀️", label: "Sole" },
{ emoji: "❄️", label: "Neve" },
{ emoji: "🌹", label: "Rosa" },
{ emoji: "🎮", label: "Controller" },
{ emoji: "💰", label: "Soldi" },
{ emoji: "🔮", label: "Sfera" },
{ emoji: "🏴‍☠️", label: "Pirata" },
];
function PrefixDialog({
isOpen,
onClose,
onSave,
editPrefix,
}: {
isOpen: boolean;
onClose: () => void;
onSave: (data: PrefixFormData, editId?: number) => Promise<void>;
editPrefix: UserPrefix | null;
}) {
const [form, setForm] = useState<PrefixFormData>({
username: "",
text: "",
color: "#FFFFFF",
icon: "",
effect: "",
active: false,
});
const [saving, setSaving] = useState(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (editPrefix) {
setForm({
username: editPrefix.username || "",
text: editPrefix.text,
color: editPrefix.color,
icon: editPrefix.icon || "",
effect: editPrefix.effect || "",
active: editPrefix.active,
});
} else {
setForm({ username: "", text: "", color: "#FFFFFF", icon: "", effect: "", active: false });
}
}, [editPrefix, isOpen]);
if (!isOpen) return null;
const handleSave = async () => {
setSaving(true);
try {
await onSave(form, editPrefix?.id);
onClose();
} finally {
setSaving(false);
}
};
// Parse multi-color for preview
const colors = form.color.split(",");
const hasMultiColor = colors.length > 1 && new Set(colors).size > 1;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background rounded-lg shadow-lg w-full max-w-md p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">{editPrefix ? "Modifica Prefisso" : "Aggiungi Prefisso"}</h3>
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="h-5 w-5" />
</button>
</div>
{/* Preview */}
<div className="flex items-center justify-center p-3 rounded-lg bg-muted min-h-[40px]">
<span className="text-lg font-bold">
{form.icon && <span className="mr-0.5">{form.icon}</span>}
{hasMultiColor ? (
<span>
{"{"}
{[...(form.text || "...")].map((char, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: char position drives color slot
<span key={i} style={{ color: colors[Math.min(i, colors.length - 1)] }}>
{char}
</span>
))}
{"}"}
</span>
) : (
<span style={{ color: colors[0] || "#FFFFFF" }}>
{"{"}
{form.text || "..."}
{"}"}
</span>
)}
</span>
<span className="ml-2 text-lg text-muted-foreground">{form.username || "Username"}</span>
</div>
{!editPrefix && (
<div className="space-y-2">
<span className="text-sm font-medium">Username</span>
<Input
placeholder="Nome utente..."
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
/>
</div>
)}
<div className="space-y-2">
<span className="text-sm font-medium">Testo</span>
<Input
maxLength={15}
placeholder="Testo del prefix..."
value={form.text}
onChange={(e) => setForm({ ...form, text: e.target.value })}
/>
<span className="text-xs text-muted-foreground">{form.text.length}/15</span>
</div>
<div className="space-y-2">
<span className="text-sm font-medium">Colore (HEX o multi: #FF0000,#00FF00)</span>
<div className="flex items-center gap-2">
<Input
placeholder="#FFFFFF o #FF0000,#00FF00,..."
value={form.color}
onChange={(e) => setForm({ ...form, color: e.target.value })}
/>
<div
className="w-8 h-8 rounded border shrink-0"
style={{ backgroundColor: colors[0] || "#FFFFFF" }}
/>
</div>
</div>
<div className="space-y-2">
<span className="text-sm font-medium">Icona</span>
<select
className="w-full px-3 py-2 rounded border bg-background text-sm"
value={form.icon}
onChange={(e) => setForm({ ...form, icon: e.target.value })}
>
{PRESET_ICONS.map((icon) => (
<option key={icon.label} value={icon.emoji}>
{icon.emoji ? `${icon.emoji} ${icon.label}` : icon.label}
</option>
))}
</select>
</div>
<div className="space-y-2">
<span className="text-sm font-medium">Effetto</span>
<select
className="w-full px-3 py-2 rounded border bg-background text-sm"
value={form.effect}
onChange={(e) => setForm({ ...form, effect: e.target.value })}
>
{PRESET_EFFECTS.map((fx) => (
<option key={fx.id} value={fx.id}>
{fx.label}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<input
checked={form.active}
id="prefix-active"
type="checkbox"
onChange={(e) => setForm({ ...form, active: e.target.checked })}
/>
<label className="text-sm" htmlFor="prefix-active">
Attivo
</label>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={onClose}>
Annulla
</Button>
<Button
disabled={saving || !form.text.trim() || (!editPrefix && !form.username.trim())}
onClick={handleSave}
>
<Save className="h-4 w-4 mr-1" />
{saving ? "Salvando..." : "Salva"}
</Button>
</div>
</div>
</div>
);
}
export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
const { run, isPending } = useServerAction();
const [prefixes, setPrefixes] = useState<UserPrefix[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [blacklist, setBlacklist] = useState<BlacklistWord[]>([]);
const [newWord, setNewWord] = useState("");
const [settings, setSettings] = useState<PrefixSettings>({
max_length: "15",
min_rank_to_buy: "1",
price_credits: "5",
price_points: "0",
points_type: "0",
});
const [dialogOpen, setDialogOpen] = useState(false);
const [editPrefix, setEditPrefix] = useState<UserPrefix | null>(null);
const fetchPrefixes = useCallback(async () => {
const params = new URLSearchParams({ page: String(page) });
if (searchQuery) params.set("q", searchQuery);
const res = await fetch(`/api/admin/prefixes?${params}`);
const data = await res.json();
if (data.ok) {
setPrefixes(data.prefixes);
setTotalPages(data.pages);
}
}, [page, searchQuery]);
const fetchBlacklist = useCallback(async () => {
const res = await fetch("/api/admin/prefixes/blacklist");
const data = await res.json();
if (data.ok) setBlacklist(data.words);
}, []);
const fetchSettings = useCallback(async () => {
const res = await fetch("/api/admin/prefixes/settings");
const data = await res.json();
if (data.ok) setSettings(data.settings);
}, []);
useEffect(() => {
fetchPrefixes();
}, [fetchPrefixes]);
useEffect(() => {
fetchBlacklist();
fetchSettings();
}, [fetchBlacklist, fetchSettings]);
const handleDeletePrefix = async (id: number) => {
if (!confirm("Sei sicuro di voler eliminare questo prefisso?")) return;
run(() => deletePrefixAction({ id }), {
successMessage: "Prefisso eliminato!",
onSuccess: () => {
fetchPrefixes();
},
});
};
const handleSavePrefix = async (data: PrefixFormData, editId?: number) => {
if (editId) {
run(
() =>
updatePrefix({
id: editId,
text: data.text,
color: data.color,
icon: data.icon,
effect: data.effect,
active: data.active ? 1 : 0,
}),
{
successMessage: "Prefisso aggiornato!",
onSuccess: () => {
fetchPrefixes();
},
},
);
} else {
run(
() =>
createPrefix({
username: data.username,
text: data.text,
color: data.color,
icon: data.icon,
effect: data.effect,
active: data.active ? 1 : 0,
}),
{
successMessage: "Prefisso creato!",
onSuccess: () => {
fetchPrefixes();
},
},
);
}
};
const openAddDialog = () => {
setEditPrefix(null);
setDialogOpen(true);
};
const openEditDialog = (prefix: UserPrefix) => {
setEditPrefix(prefix);
setDialogOpen(true);
};
const handleAddBlacklistWord = async () => {
if (!newWord.trim()) return;
run(() => addBlacklistWordAction({ word: newWord.trim() }), {
successMessage: "Parola aggiunta alla blacklist!",
onSuccess: () => {
setNewWord("");
fetchBlacklist();
},
});
};
const handleRemoveBlacklistWord = async (id: number) => {
run(() => removeBlacklistWordAction({ id }), {
successMessage: "Parola rimossa dalla blacklist!",
onSuccess: () => {
fetchBlacklist();
},
});
};
const saveSettings = async () => {
run(() => updatePrefixSettings({ settings: settings as unknown as Record<string, string> }), {
successMessage: "Impostazioni salvate!",
});
};
return (
<>
<PrefixDialog
editPrefix={editPrefix}
isOpen={dialogOpen}
onClose={() => setDialogOpen(false)}
onSave={handleSavePrefix}
/>
<Tabs defaultValue="prefixes">
<TabsList>
<TabsTrigger value="prefixes">Prefissi Utenti</TabsTrigger>
<TabsTrigger value="blacklist">Blacklist</TabsTrigger>
<TabsTrigger value="settings">Impostazioni</TabsTrigger>
</TabsList>
<TabsContent value="prefixes" className="space-y-4">
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
className="pl-8"
placeholder="Cerca per utente o testo..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setPage(1);
}}
/>
</div>
<Button variant="outline" onClick={fetchPrefixes}>
Cerca
</Button>
{canEdit && (
<Button onClick={openAddDialog}>
<Plus className="h-4 w-4 mr-1" />
Aggiungi
</Button>
)}
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Utente</TableHead>
<TableHead>Prefix</TableHead>
<TableHead>Colore</TableHead>
<TableHead>Icona</TableHead>
<TableHead>Effetto</TableHead>
<TableHead>Attivo</TableHead>
<TableHead>Data</TableHead>
{canEdit && <TableHead>Azioni</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{prefixes.map((prefix) => {
const colors = prefix.color.split(",");
const hasMultiColor = colors.length > 1 && new Set(colors).size > 1;
const prefixIcon = prefix.icon || "";
return (
<TableRow key={prefix.id}>
<TableCell>{prefix.id}</TableCell>
<TableCell>{prefix.username || `#${prefix.user_id}`}</TableCell>
<TableCell>
<span style={{ fontWeight: "bold" }}>
{hasMultiColor ? (
<>
{"{"}
{[...prefix.text].map((char, i) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: char position drives color slot
key={i}
style={{
color: colors[Math.min(i, colors.length - 1)],
}}
>
{char}
</span>
))}
{"}"}
</>
) : (
<span style={{ color: colors[0] || "#FFFFFF" }}>
{"{"}
{prefix.text}
{"}"}
</span>
)}
</span>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{colors.slice(0, 5).map((c, i) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: color preview slots, position is identity
key={i}
className="w-4 h-4 rounded border"
style={{ backgroundColor: c }}
title={c}
/>
))}
{colors.length > 5 && (
<span className="text-xs text-muted-foreground">+{colors.length - 5}</span>
)}
</div>
</TableCell>
<TableCell className="text-center text-lg">{prefixIcon || "-"}</TableCell>
<TableCell className="text-xs">
{prefix.effect
? PRESET_EFFECTS.find((fx) => fx.id === prefix.effect)?.label || prefix.effect
: "-"}
</TableCell>
<TableCell>{prefix.active ? "Si" : "No"}</TableCell>
<TableCell className="text-xs">
{new Date(prefix.created_at).toLocaleDateString()}
</TableCell>
{canEdit && (
<TableCell>
<div className="flex items-center gap-1">
<Button size="icon" variant="outline" onClick={() => openEditDialog(prefix)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="destructive"
disabled={isPending}
onClick={() => handleDeletePrefix(prefix.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
)}
</TableRow>
);
})}
{prefixes.length === 0 && (
<TableRow>
<TableCell className="text-center text-muted-foreground" colSpan={9}>
Nessun prefisso trovato
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button disabled={page <= 1} size="sm" variant="outline" onClick={() => setPage(page - 1)}>
Precedente
</Button>
<span className="text-sm">
Pagina {page} di {totalPages}
</span>
<Button
disabled={page >= totalPages}
size="sm"
variant="outline"
onClick={() => setPage(page + 1)}
>
Successiva
</Button>
</div>
)}
</TabsContent>
<TabsContent value="blacklist" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Parole Vietate</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{canEdit && (
<div className="flex items-center gap-2">
<Input
placeholder="Aggiungi parola..."
value={newWord}
onChange={(e) => setNewWord(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddBlacklistWord()}
/>
<Button disabled={isPending} onClick={handleAddBlacklistWord}>
<Plus className="h-4 w-4 mr-1" />
Aggiungi
</Button>
</div>
)}
<div className="flex flex-wrap gap-2">
{blacklist.map((word) => (
<div
key={word.id}
className="flex items-center gap-1 px-3 py-1 rounded-full bg-destructive/10 text-destructive text-sm"
>
<span>{word.word}</span>
{canEdit && (
<button
type="button"
className="ml-1 hover:text-destructive/80"
onClick={() => handleRemoveBlacklistWord(word.id)}
>
&times;
</button>
)}
</div>
))}
{blacklist.length === 0 && (
<p className="text-muted-foreground text-sm">Nessuna parola in blacklist</p>
)}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Impostazioni Prefissi</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<span className="text-sm font-medium">Lunghezza Massima</span>
<Input
disabled={!canEdit}
type="number"
value={settings.max_length}
onChange={(e) => setSettings({ ...settings, max_length: e.target.value })}
/>
</div>
<div className="space-y-2">
<span className="text-sm font-medium">Rank Minimo</span>
<Input
disabled={!canEdit}
type="number"
value={settings.min_rank_to_buy}
onChange={(e) => setSettings({ ...settings, min_rank_to_buy: e.target.value })}
/>
</div>
<div className="space-y-2">
<span className="text-sm font-medium">Prezzo (Crediti)</span>
<Input
disabled={!canEdit}
type="number"
value={settings.price_credits}
onChange={(e) => setSettings({ ...settings, price_credits: e.target.value })}
/>
</div>
<div className="space-y-2">
<span className="text-sm font-medium">Prezzo (Punti)</span>
<Input
disabled={!canEdit}
type="number"
value={settings.price_points}
onChange={(e) => setSettings({ ...settings, price_points: e.target.value })}
/>
</div>
<div className="space-y-2">
<span className="text-sm font-medium">Tipo Punti</span>
<Input
disabled={!canEdit}
type="number"
value={settings.points_type}
onChange={(e) => setSettings({ ...settings, points_type: e.target.value })}
/>
<p className="text-xs text-muted-foreground">0 = Duckets, 5 = Diamonds, ecc.</p>
</div>
</div>
{canEdit && (
<Button disabled={isPending} onClick={saveSettings}>
<Save className="h-4 w-4 mr-1" />
Salva Impostazioni
</Button>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</>
);
}