Files
EpicNext-Cms/src/app/admin/prefixes/prefixes-client.tsx
T
Simo b1ddda66ff
CI / check (push) Successful in 27s
CI / release (push) Skipped
CI / deploy (push) Successful in 43s
Revert "Merge pull request 'Complete Housekeeping migration and /ase cutover' (#52) from codex/housekeeping-complete into main"
This reverts commit 488b6e57c4, reversing
changes made to b506b4499a.
2026-08-30 21:31:34 +02:00

800 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 { 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 { useConfirmDialog } from "@/components/admin/confirm-dialog";
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: "None" },
{ id: "glow", label: "✨ Glow" },
{ id: "shadow", label: "🌑 Shadow" },
{ id: "italic", label: "𝑰 Italic" },
{ id: "outline", label: "🔲 Outline" },
{ id: "pulse", label: "💫 Pulse" },
{ id: "bold-glow", label: "💡 Neon" },
];
const PRESET_ICONS = [
{ emoji: "", label: "None" },
{ emoji: "👑", label: "Crown" },
{ emoji: "⭐", label: "Star" },
{ emoji: "❤️", label: "Heart" },
{ emoji: "💎", label: "Diamond" },
{ emoji: "🔥", label: "Fire" },
{ emoji: "⚡", label: "Lightning" },
{ emoji: "🛡️", label: "Shield" },
{ emoji: "💀", label: "Skull" },
{ emoji: "🎵", label: "Music" },
{ emoji: "💠", label: "Gem" },
{ emoji: "⚔️", label: "Swords" },
{ emoji: "🏆", label: "Trophy" },
{ emoji: "🚀", label: "Rocket" },
{ emoji: "👻", label: "Ghost" },
{ emoji: "🐾", label: "Paw" },
{ emoji: "🍃", label: "Leaf" },
{ emoji: "😎", label: "Cool" },
{ emoji: "🐺", label: "Wolf" },
{ emoji: "🦁", label: "Lion" },
{ emoji: "🐉", label: "Dragon" },
{ emoji: "🦄", label: "Unicorn" },
{ emoji: "🌙", label: "Moon" },
{ emoji: "☀️", label: "Sun" },
{ emoji: "❄️", label: "Snow" },
{ emoji: "🌹", label: "Rose" },
{ emoji: "🎮", label: "Controller" },
{ emoji: "💰", label: "Money" },
{ emoji: "🔮", label: "Orb" },
{ emoji: "🏴‍☠️", label: "Pirate" },
];
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 ? "Edit Prefix" : "Add Prefix"}
</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) => ({ char, n: i }))
.map(({ char, n }) => (
<span
key={`char-${n}`}
style={{ color: colors[Math.min(n, 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="Username..."
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
/>
</div>
)}
<div className="space-y-2">
<span className="text-sm font-medium">Text</span>
<Input
maxLength={15}
placeholder="Prefix text..."
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">
Color (HEX or 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">Icon</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">Effect</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">
Active
</label>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
disabled={
saving ||
!form.text.trim() ||
(!editPrefix && !form.username.trim())
}
onClick={handleSave}
>
<Save className="h-4 w-4 mr-1" />
{saving ? "Saving..." : "Save"}
</Button>
</div>
</div>
</div>
);
}
export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
const { run, isPending } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
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}`);
if (!res.ok) return;
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");
if (!res.ok) return;
const data = await res.json();
if (data.ok) setBlacklist(data.words);
}, []);
const fetchSettings = useCallback(async () => {
const res = await fetch("/api/admin/prefixes/settings");
if (!res.ok) return;
const data = await res.json();
if (data.ok) setSettings(data.settings);
}, []);
useEffect(() => {
fetchPrefixes();
}, [fetchPrefixes]);
useEffect(() => {
fetchBlacklist();
fetchSettings();
}, [fetchBlacklist, fetchSettings]);
const handleDeletePrefix = async (id: number) => {
const ok = await confirm({
title: "Delete prefix",
description: "Are you sure you want to delete this prefix?",
confirmLabel: "Delete",
cancelLabel: "Cancel",
});
if (!ok) return;
run(() => deletePrefixAction({ id }), {
successMessage: "Prefix deleted!",
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: "Prefix updated!",
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: "Prefix created!",
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: "Word added to blacklist!",
onSuccess: () => {
setNewWord("");
fetchBlacklist();
},
});
};
const handleRemoveBlacklistWord = async (id: number) => {
run(() => removeBlacklistWordAction({ id }), {
successMessage: "Word removed from blacklist!",
onSuccess: () => {
fetchBlacklist();
},
});
};
const saveSettings = async () => {
run(
() =>
updatePrefixSettings({
settings: settings as unknown as Record<string, string>,
}),
{
successMessage: "Settings saved!",
},
);
};
return (
<>
{confirmDialog}
<PrefixDialog
editPrefix={editPrefix}
isOpen={dialogOpen}
onClose={() => setDialogOpen(false)}
onSave={handleSavePrefix}
/>
<Tabs defaultValue="prefixes">
<TabsList>
<TabsTrigger value="prefixes">User Prefixes</TabsTrigger>
<TabsTrigger value="blacklist">Blacklist</TabsTrigger>
<TabsTrigger value="settings">Settings</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="Search by user or text..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setPage(1);
}}
/>
</div>
<Button variant="outline" onClick={fetchPrefixes}>
Search
</Button>
{canEdit && (
<Button onClick={openAddDialog}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
)}
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>User</TableHead>
<TableHead>Prefix</TableHead>
<TableHead>Color</TableHead>
<TableHead>Icon</TableHead>
<TableHead>Effect</TableHead>
<TableHead>Active</TableHead>
<TableHead>Date</TableHead>
{canEdit && <TableHead>Actions</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={`char-${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={`color-${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 ? "Yes" : "No"}</TableCell>
<TableCell className="text-xs">
{prefix.created_at
? 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}
>
No prefixes found
</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)}
>
Previous
</Button>
<span className="text-sm">
Page {page} of {totalPages}
</span>
<Button
disabled={page >= totalPages}
size="sm"
variant="outline"
onClick={() => setPage(page + 1)}
>
Next
</Button>
</div>
)}
</TabsContent>
<TabsContent value="blacklist" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Forbidden Words</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{canEdit && (
<div className="flex items-center gap-2">
<Input
placeholder="Add word..."
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" />
Add
</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">
No blacklist words
</p>
)}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Prefix Settings</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">Max Length</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">Minimum Rank</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">Price (Credits)</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">Price (Points)</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">Points Type</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, etc.
</p>
</div>
</div>
{canEdit && (
<Button disabled={isPending} onClick={saveSettings}>
<Save className="h-4 w-4 mr-1" />
Save Settings
</Button>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</>
);
}