Files
EpicNext-Cms/src/app/admin/prefixes/prefix-dialog.tsx
T
Simo d5091d1a73
CI / check (push) Successful in 2m34s
CI / deploy (push) Successful in 19s
CI / publish-container (push) Successful in 1m24s
fix(hk): protect unsaved editors and await prefix save results
2026-09-11 10:48:34 +02:00

281 lines
7.4 KiB
TypeScript

"use client";
import { Save, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
import {
PRESET_EFFECTS,
PRESET_ICONS,
type PrefixFormData,
type UserPrefix,
} from "./prefix-model";
export function PrefixDialog({
isOpen,
onClose,
onSave,
editPrefix,
}: {
isOpen: boolean;
onClose: () => void;
onSave: (data: PrefixFormData, editId?: number) => Promise<boolean>;
editPrefix: UserPrefix | null;
}) {
const [form, setForm] = useState<PrefixFormData>({
username: "",
text: "",
color: "#FFFFFF",
icon: "",
effect: "",
active: false,
});
const [saving, setSaving] = useState(false);
const savingRef = useRef(false);
const [saveError, setSaveError] = useState(false);
const tAction = useTranslations("pages.admin.actions");
const initial: PrefixFormData = {
username: editPrefix?.username || "",
text: editPrefix?.text || "",
color: editPrefix?.color || "#FFFFFF",
icon: editPrefix?.icon || "",
effect: editPrefix?.effect || "",
active: editPrefix?.active ?? false,
};
const dirty = Object.keys(initial).some(
(key) =>
form[key as keyof PrefixFormData] !==
initial[key as keyof PrefixFormData],
);
const { confirmLeave, markSaved } = useUnsavedChanges(
isOpen && dirty,
tAction("leaveUnsaved"),
);
function requestClose() {
if (!saving && confirmLeave()) onClose();
}
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
setSaveError(false);
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 () => {
if (savingRef.current) return;
savingRef.current = true;
setSaveError(false);
setSaving(true);
try {
if (!(await onSave(form, editPrefix?.id))) {
setSaveError(true);
return;
}
markSaved();
onClose();
} catch {
setSaveError(true);
} finally {
savingRef.current = false;
setSaving(false);
}
};
// Parse multi-color for preview
const colors = form.color.split(",");
const hasMultiColor = colors.length > 1 && new Set(colors).size > 1;
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) requestClose();
}}
disablePointerDismissal
>
<DialogContent
showCloseButton={false}
className="block max-h-[90dvh] overflow-y-auto sm:max-w-md space-y-4"
>
<fieldset disabled={saving} className="contents">
{saveError && <p role="alert">{tAction("saveError")}</p>}
<div className="flex items-center justify-between">
<DialogTitle className="text-lg font-semibold">
{editPrefix ? "Edit Prefix" : "Add Prefix"}
</DialogTitle>
<button
type="button"
disabled={saving}
onClick={requestClose}
className="text-muted-foreground hover:text-foreground"
>
<span className="sr-only">Close prefix editor</span>
<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..."
aria-label="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..."
aria-label="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,..."
aria-label="Prefix color"
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"
aria-label="Prefix icon"
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"
aria-label="Prefix effect"
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={requestClose}>
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>
</fieldset>
</DialogContent>
</Dialog>
);
}