fix(hk): protect unsaved editors and await prefix save results
CI / check (push) Successful in 2m34s
CI / deploy (push) Successful in 19s
CI / publish-container (push) Successful in 1m24s

This commit is contained in:
Simo committed 2026-09-11 10:48:34 +02:00
1 parent 89af4eb1f6
commit d5091d1a73
11 files changed
+602 -231

No files matched your search

+184 -143
View File
@@ -1,9 +1,11 @@
"use client";
import { Save, X } from "lucide-react";
import { useEffect, useState } from "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,
@@ -18,7 +20,7 @@ export function PrefixDialog({
}: {
isOpen: boolean;
onClose: () => void;
onSave: (data: PrefixFormData, editId?: number) => Promise<void>;
onSave: (data: PrefixFormData, editId?: number) => Promise<boolean>;
editPrefix: UserPrefix | null;
}) {
const [form, setForm] = useState<PrefixFormData>({
@@ -30,9 +32,33 @@ export function PrefixDialog({
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 || "",
@@ -57,11 +83,21 @@ export function PrefixDialog({
if (!isOpen) return null;
const handleSave = async () => {
if (savingRef.current) return;
savingRef.current = true;
setSaveError(false);
setSaving(true);
try {
await onSave(form, editPrefix?.id);
if (!(await onSave(form, editPrefix?.id))) {
setSaveError(true);
return;
}
markSaved();
onClose();
} catch {
setSaveError(true);
} finally {
savingRef.current = false;
setSaving(false);
}
};
@@ -74,7 +110,7 @@ export function PrefixDialog({
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open && !saving) onClose();
if (!open) requestClose();
}}
disablePointerDismissal
>
@@ -82,157 +118,162 @@ export function PrefixDialog({
showCloseButton={false}
className="block max-h-[90dvh] overflow-y-auto sm:max-w-md space-y-4"
>
<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={onClose}
className="text-muted-foreground hover:text-foreground"
>
<span className="sr-only">Close prefix editor</span>
<X className="h-5 w-5" />
</button>
</div>
<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>
{/* 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>
)}
{!editPrefix && (
<div className="space-y-2">
<span className="text-sm font-medium">Username</span>
<span className="text-sm font-medium">Text</span>
<Input
placeholder="Username..."
aria-label="Username"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
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">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="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" }}
<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>
<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={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 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>
);