312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState, type FormEvent } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Loader2Icon } from "lucide-react";
|
|
import { updatePreferences } from "@/app/(frontend)/account/actions";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Label } from "@/components/ui/label";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { cn } from "@/lib/utils";
|
|
import { MUTEABLE_NOTIFICATION_TYPES } from "@/lib/notifications/notificationTypes";
|
|
|
|
interface PreferencesFormProps {
|
|
readonly mutedAll: boolean;
|
|
readonly mutedTypes: readonly string[];
|
|
readonly showCallsign: boolean;
|
|
readonly showVersionOverlay: boolean;
|
|
readonly showBackgroundTexture: boolean;
|
|
readonly leadOrActual: "lead" | "actual";
|
|
readonly discordEnabled: boolean;
|
|
readonly discordMutedTypes: readonly string[];
|
|
readonly xpDisplayMini: boolean;
|
|
}
|
|
|
|
export function PreferencesForm({
|
|
mutedAll: initialMutedAll,
|
|
mutedTypes: initialMutedTypes,
|
|
showCallsign: initialShowCallsign,
|
|
showVersionOverlay: initialShowVersionOverlay,
|
|
showBackgroundTexture: initialShowBackgroundTexture,
|
|
leadOrActual: initialLeadOrActual,
|
|
discordEnabled: initialDiscordEnabled,
|
|
discordMutedTypes: initialDiscordMutedTypes,
|
|
xpDisplayMini: initialXPDisplayMini,
|
|
}: PreferencesFormProps) {
|
|
const router = useRouter();
|
|
const [mutedAll, setMutedAll] = useState(initialMutedAll);
|
|
const [mutedTypes, setMutedTypes] = useState<string[]>(() => [...initialMutedTypes]);
|
|
const [showCallsign, setShowCallsign] = useState(initialShowCallsign);
|
|
const [showVersionOverlay, setShowVersionOverlay] = useState(initialShowVersionOverlay);
|
|
const [showBackgroundTexture, setShowBackgroundTexture] = useState(initialShowBackgroundTexture);
|
|
const [leadOrActual, setLeadOrActual] = useState<"lead" | "actual">(initialLeadOrActual);
|
|
const [discordEnabled, setDiscordEnabled] = useState(initialDiscordEnabled);
|
|
const [discordMutedTypes, setDiscordMutedTypes] = useState<string[]>(() => [
|
|
...initialDiscordMutedTypes,
|
|
]);
|
|
const [xpDisplayMini, setXPDisplayMini] = useState(initialXPDisplayMini);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [success, setSuccess] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const toggleType = (value: string, checked: boolean) => {
|
|
setMutedTypes((previous) =>
|
|
checked ? [...new Set([...previous, value])] : previous.filter((type) => type !== value),
|
|
);
|
|
};
|
|
|
|
const toggleDiscordType = (value: string, checked: boolean) => {
|
|
setDiscordMutedTypes((previous) =>
|
|
checked ? [...new Set([...previous, value])] : previous.filter((type) => type !== value),
|
|
);
|
|
};
|
|
|
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
setLoading(true);
|
|
setError(null);
|
|
setSuccess(null);
|
|
|
|
try {
|
|
const result = await updatePreferences({
|
|
mutedAll,
|
|
mutedTypes,
|
|
showCallsign,
|
|
showVersionOverlay,
|
|
showBackgroundTexture,
|
|
leadOrActual,
|
|
discordEnabled,
|
|
discordMutedTypes,
|
|
xpDisplayMini,
|
|
});
|
|
|
|
if (!result.success) {
|
|
setError(result.error ?? "Could not save preferences.");
|
|
return;
|
|
}
|
|
|
|
setSuccess("Preferences updated.");
|
|
router.refresh();
|
|
} catch (caughtError) {
|
|
setError(
|
|
caughtError instanceof Error
|
|
? caughtError.message
|
|
: "Could not save preferences. Please try again.",
|
|
);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Display</CardTitle>
|
|
<CardDescription>Choose what appears in the app shell and roster views.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-4">
|
|
<Label htmlFor="showCallsign" className="flex items-center justify-between gap-4 text-sm">
|
|
<span className="flex min-w-0 flex-col gap-1">
|
|
<span>Show callsign instead of display name</span>
|
|
<span className="text-sm font-normal text-muted-foreground">
|
|
Use your username in the sidebar and app shell.
|
|
</span>
|
|
</span>
|
|
<Switch
|
|
id="showCallsign"
|
|
checked={showCallsign}
|
|
onCheckedChange={setShowCallsign}
|
|
aria-label="Show callsign instead of display name"
|
|
/>
|
|
</Label>
|
|
|
|
<Label
|
|
htmlFor="xpDisplayMini"
|
|
className="flex items-center justify-between gap-4 text-sm"
|
|
>
|
|
<span className="flex min-w-0 flex-col gap-1">
|
|
<span>Use mini mode for XP display</span>
|
|
<span className="text-sm font-normal text-muted-foreground">
|
|
Shows level name and XP instead of progress bar.
|
|
</span>
|
|
</span>
|
|
<Switch
|
|
id="xpDisplayMini"
|
|
checked={xpDisplayMini}
|
|
onCheckedChange={setXPDisplayMini}
|
|
aria-label="Use mini mode for XP display"
|
|
/>
|
|
</Label>
|
|
|
|
<Label
|
|
htmlFor="showBackgroundTexture"
|
|
className="flex items-center justify-between gap-4 text-sm"
|
|
>
|
|
<span className="flex min-w-0 flex-col gap-1">
|
|
<span>Show diagonal background texture</span>
|
|
<span className="text-sm font-normal text-muted-foreground">
|
|
Adds the subtle tactical line pattern behind page content.
|
|
</span>
|
|
</span>
|
|
<Switch
|
|
id="showBackgroundTexture"
|
|
checked={showBackgroundTexture}
|
|
onCheckedChange={setShowBackgroundTexture}
|
|
aria-label="Show diagonal background texture"
|
|
/>
|
|
</Label>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-sm font-medium">Roster leader label</span>
|
|
<span className="text-sm text-muted-foreground">
|
|
Choose the label used when reviewing the unit roster.
|
|
</span>
|
|
</div>
|
|
<RadioGroup
|
|
value={leadOrActual}
|
|
onValueChange={(value) => {
|
|
if (value === "lead" || value === "actual") setLeadOrActual(value);
|
|
}}
|
|
className="flex flex-wrap gap-4"
|
|
aria-label="Roster leader label"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<RadioGroupItem id="leadOrActual-lead" value="lead" />
|
|
<Label htmlFor="leadOrActual-lead">Lead</Label>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<RadioGroupItem id="leadOrActual-actual" value="actual" />
|
|
<Label htmlFor="leadOrActual-actual">Actual</Label>
|
|
</div>
|
|
</RadioGroup>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Notifications</CardTitle>
|
|
<CardDescription>Control the in-app alerts that appear in your inbox.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-4">
|
|
<Label htmlFor="mutedAll" className="flex items-center justify-between gap-4 text-sm">
|
|
<span className="flex min-w-0 flex-col gap-1">
|
|
<span>Mute all in-app notifications</span>
|
|
<span className="text-sm font-normal text-muted-foreground">
|
|
Pause every in-app notification listed below.
|
|
</span>
|
|
</span>
|
|
<Switch
|
|
id="mutedAll"
|
|
checked={mutedAll}
|
|
onCheckedChange={setMutedAll}
|
|
aria-label="Mute all in-app notifications"
|
|
/>
|
|
</Label>
|
|
|
|
<div
|
|
className={cn("flex flex-col gap-3 pl-6", mutedAll && "opacity-50 pointer-events-none")}
|
|
aria-disabled={mutedAll}
|
|
>
|
|
{MUTEABLE_NOTIFICATION_TYPES.map((notificationType) => {
|
|
const id = `notification-${notificationType.value.replace(":", "-")}`;
|
|
return (
|
|
<Label
|
|
key={notificationType.value}
|
|
htmlFor={id}
|
|
className="flex items-center justify-between gap-4 text-sm font-normal"
|
|
>
|
|
<span>{notificationType.label}</span>
|
|
<Switch
|
|
id={id}
|
|
checked={mutedTypes.includes(notificationType.value)}
|
|
onCheckedChange={(checked) => toggleType(notificationType.value, checked)}
|
|
disabled={mutedAll}
|
|
aria-label={`Mute ${notificationType.label.toLowerCase()}`}
|
|
/>
|
|
</Label>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Discord</CardTitle>
|
|
<CardDescription>Choose whether the bot can send you direct messages.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-4">
|
|
<Label
|
|
htmlFor="discordEnabled"
|
|
className="flex items-center justify-between gap-4 text-sm"
|
|
>
|
|
<span className="flex min-w-0 flex-col gap-1">
|
|
<span>Enable Discord notifications</span>
|
|
<span className="text-sm font-normal text-muted-foreground">
|
|
Receive direct messages about app events.
|
|
</span>
|
|
</span>
|
|
<Switch
|
|
id="discordEnabled"
|
|
checked={discordEnabled}
|
|
onCheckedChange={setDiscordEnabled}
|
|
aria-label="Enable Discord notifications"
|
|
/>
|
|
</Label>
|
|
|
|
<div
|
|
className={cn(
|
|
"flex flex-col gap-3 pl-6",
|
|
!discordEnabled && "opacity-50 pointer-events-none",
|
|
)}
|
|
aria-disabled={!discordEnabled}
|
|
>
|
|
{MUTEABLE_NOTIFICATION_TYPES.map((notificationType) => {
|
|
const id = `discord-${notificationType.value.replace(":", "-")}`;
|
|
return (
|
|
<Label
|
|
key={notificationType.value}
|
|
htmlFor={id}
|
|
className="flex items-center justify-between gap-4 text-sm font-normal"
|
|
>
|
|
<span>Mute {notificationType.label.toLowerCase()}</span>
|
|
<Switch
|
|
id={id}
|
|
checked={discordMutedTypes.includes(notificationType.value)}
|
|
onCheckedChange={(checked) =>
|
|
toggleDiscordType(notificationType.value, checked)
|
|
}
|
|
disabled={!discordEnabled}
|
|
aria-label={`Mute ${notificationType.label.toLowerCase()}`}
|
|
/>
|
|
</Label>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{error && (
|
|
<p role="alert" className="text-sm text-destructive">
|
|
{error}
|
|
</p>
|
|
)}
|
|
{success && (
|
|
<p aria-live="polite" className="text-sm text-emerald-500">
|
|
{success}
|
|
</p>
|
|
)}
|
|
<div className="sticky bottom-0 bg-card p-6 w-full -m-6">
|
|
<Button type="submit" disabled={loading}>
|
|
{loading && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
|
|
{loading ? "Saving..." : "Save preferences"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|