feat(account): add self-service account page with profile, password, Discord request, and preferences
- Add /account page backed by server actions - Allow editing display name, changing password, and requesting a Discord username change - Add notification muting and callsign display preferences UI - Add shadcn card component and account event types
This commit is contained in:
parent
b8cbb85dc8
commit
c81036bd82
8 changed files with 764 additions and 0 deletions
234
src/app/(frontend)/account/actions.ts
Normal file
234
src/app/(frontend)/account/actions.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
"use server";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { notifyUser } from "@/lib/notifications";
|
||||
import {
|
||||
MUTEABLE_NOTIFICATION_VALUES,
|
||||
type MuteableNotificationType,
|
||||
} from "@/lib/notifications/notificationTypes";
|
||||
|
||||
export interface ActionResult<T = undefined> {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
async function authenticate() {
|
||||
const payloadConfig = await config;
|
||||
const payload = await getPayload({ config: payloadConfig });
|
||||
const { headers } = await import("next/headers");
|
||||
const hdrs = await headers();
|
||||
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
return { payload, user };
|
||||
}
|
||||
|
||||
export async function changePassword(input: {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const userId = user.id as number;
|
||||
|
||||
if (!input.currentPassword || !input.newPassword) {
|
||||
return { success: false, error: "Current and new password are required." };
|
||||
}
|
||||
if (input.newPassword.length < 8) {
|
||||
return { success: false, error: "New password must be at least 8 characters." };
|
||||
}
|
||||
|
||||
try {
|
||||
await payload.login({
|
||||
collection: "users",
|
||||
data: { username: user.username, password: input.currentPassword },
|
||||
});
|
||||
} catch {
|
||||
return { success: false, error: "Current password is incorrect." };
|
||||
}
|
||||
|
||||
await payload.update({
|
||||
collection: "users",
|
||||
id: userId,
|
||||
data: { password: input.newPassword },
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.AccountPasswordChange,
|
||||
message: "Password changed",
|
||||
actor: userId,
|
||||
targetCollection: "users",
|
||||
targetId: userId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return message === "Unauthorized"
|
||||
? { success: false, error: "You must be logged in." }
|
||||
: { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDisplayName(input: { displayName: string }): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const userId = user.id as number;
|
||||
|
||||
const trimmed = input.displayName.trim();
|
||||
if (!trimmed) {
|
||||
return { success: false, error: "Display name is required." };
|
||||
}
|
||||
if (trimmed.length > 60) {
|
||||
return { success: false, error: "Display name must be 60 characters or fewer." };
|
||||
}
|
||||
|
||||
await payload.update({
|
||||
collection: "users",
|
||||
id: userId,
|
||||
data: { displayName: trimmed },
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.AccountProfileUpdate,
|
||||
message: `Updated display name to "${trimmed}"`,
|
||||
actor: userId,
|
||||
targetCollection: "users",
|
||||
targetId: userId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return message === "Unauthorized"
|
||||
? { success: false, error: "You must be logged in." }
|
||||
: { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestDiscordUsernameChange(input: {
|
||||
desiredDiscordUsername: string;
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const userId = user.id as number;
|
||||
|
||||
const trimmed = input.desiredDiscordUsername.trim();
|
||||
if (!trimmed) {
|
||||
return { success: false, error: "Discord username is required." };
|
||||
}
|
||||
if (trimmed.length < 2 || trimmed.length > 32) {
|
||||
return { success: false, error: "Discord usernames must be between 2 and 32 characters." };
|
||||
}
|
||||
if (/\s/.test(trimmed)) {
|
||||
return { success: false, error: "Discord usernames cannot contain spaces." };
|
||||
}
|
||||
|
||||
const existingRes = await payload.find({
|
||||
collection: "users",
|
||||
where: { discordUsername: { equals: trimmed } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
if (existingRes.docs.some((doc) => doc.id !== userId)) {
|
||||
return { success: false, error: "That Discord username is already in use." };
|
||||
}
|
||||
|
||||
const staffRes = await payload.find({
|
||||
collection: "users",
|
||||
where: {
|
||||
or: [{ roles: { contains: "admin" } }, { roles: { contains: "developer" } }],
|
||||
},
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const staffIds = [...new Set(staffRes.docs.map((doc) => doc.id as number))].filter(
|
||||
(id) => id !== userId,
|
||||
);
|
||||
for (const staffId of staffIds) {
|
||||
await notifyUser(payload, {
|
||||
userId: staffId,
|
||||
type: "account:discord-request",
|
||||
title: `Discord username change requested by ${user.username}`,
|
||||
message: `wants to change their Discord username to "${trimmed}".`,
|
||||
link: `/admin/collections/users/${userId}`,
|
||||
});
|
||||
}
|
||||
|
||||
await notifyUser(payload, {
|
||||
userId,
|
||||
type: "account:discord-request",
|
||||
title: "Request submitted",
|
||||
message: `Your request to change your Discord username to "${trimmed}" has been sent to leadership.`,
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.AccountDiscordRequest,
|
||||
message: `Requested a Discord username change to "${trimmed}"`,
|
||||
actor: userId,
|
||||
targetCollection: "users",
|
||||
targetId: userId,
|
||||
data: { requestedDiscordUsername: trimmed },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return message === "Unauthorized"
|
||||
? { success: false, error: "You must be logged in." }
|
||||
: { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePreferences(input: {
|
||||
mutedAll: boolean;
|
||||
mutedTypes: string[];
|
||||
showCallsign: boolean;
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
const userId = user.id as number;
|
||||
|
||||
const mutedTypes = input.mutedTypes.filter((t): t is MuteableNotificationType =>
|
||||
(MUTEABLE_NOTIFICATION_VALUES as readonly string[]).includes(t),
|
||||
);
|
||||
|
||||
await payload.update({
|
||||
collection: "users",
|
||||
id: userId,
|
||||
data: {
|
||||
preferences: {
|
||||
notifications: { mutedAll: input.mutedAll, mutedTypes },
|
||||
display: { showCallsign: input.showCallsign },
|
||||
},
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.AccountPreferencesUpdate,
|
||||
message: "Updated preferences",
|
||||
actor: userId,
|
||||
targetCollection: "users",
|
||||
targetId: userId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return message === "Unauthorized"
|
||||
? { success: false, error: "You must be logged in." }
|
||||
: { success: false, error: message };
|
||||
}
|
||||
}
|
||||
107
src/app/(frontend)/account/page.tsx
Normal file
107
src/app/(frontend)/account/page.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { UserCogIcon } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { AccountProfileForm } from "@/components/frontend/account/AccountProfileForm";
|
||||
import { ChangePasswordForm } from "@/components/frontend/account/ChangePasswordForm";
|
||||
import { DiscordUsernameRequestForm } from "@/components/frontend/account/DiscordUsernameRequestForm";
|
||||
import { PreferencesForm } from "@/components/frontend/account/PreferencesForm";
|
||||
|
||||
export const metadata = {
|
||||
title: "Account — Polaris Task Force",
|
||||
};
|
||||
|
||||
export default async function AccountPage() {
|
||||
const payload = await getPayload({ config });
|
||||
const { user } = await payload.auth({
|
||||
headers: await nextHeaders(),
|
||||
canSetHeaders: false,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
const fullUser = await payload.findByID({
|
||||
collection: "users",
|
||||
id: user.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
const notifications = fullUser.preferences?.notifications;
|
||||
|
||||
return (
|
||||
<div className="p-5 flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserCogIcon className="size-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Account</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage your profile, password, and notification preferences.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex max-w-2xl flex-col gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile</CardTitle>
|
||||
<CardDescription>How your name appears across the platform.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AccountProfileForm displayName={fullUser.displayName ?? ""} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Password</CardTitle>
|
||||
<CardDescription>
|
||||
Change your password. You will stay logged in on this device.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChangePasswordForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Discord Username</CardTitle>
|
||||
<CardDescription>
|
||||
Your Discord username is managed by leadership. Request a change here.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DiscordUsernameRequestForm
|
||||
currentDiscordUsername={fullUser.discordUsername ?? ""}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preferences</CardTitle>
|
||||
<CardDescription>Control in-app notifications and display options.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PreferencesForm
|
||||
mutedAll={notifications?.mutedAll ?? false}
|
||||
mutedTypes={notifications?.mutedTypes ?? []}
|
||||
showCallsign={fullUser.preferences?.display?.showCallsign ?? false}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
src/components/frontend/account/AccountProfileForm.tsx
Normal file
62
src/components/frontend/account/AccountProfileForm.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { updateDisplayName } from "@/app/(frontend)/account/actions";
|
||||
|
||||
export function AccountProfileForm({ displayName }: { displayName: string }) {
|
||||
const router = useRouter();
|
||||
const [value, setValue] = useState(displayName);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const res = await updateDisplayName({ displayName: value });
|
||||
if (!res.success) {
|
||||
setError(res.error ?? "Failed to update display name.");
|
||||
return;
|
||||
}
|
||||
setSuccess("Display name updated.");
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Something went wrong. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="displayName">In-Game Name</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
name="displayName"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
maxLength={60}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{success && <p className="text-sm text-emerald-500">{success}</p>}
|
||||
<div>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Loader2Icon className="size-4 animate-spin" />}
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
98
src/components/frontend/account/ChangePasswordForm.tsx
Normal file
98
src/components/frontend/account/ChangePasswordForm.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { changePassword } from "@/app/(frontend)/account/actions";
|
||||
|
||||
export function ChangePasswordForm() {
|
||||
const router = useRouter();
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("New passwords do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await changePassword({ currentPassword, newPassword });
|
||||
if (!res.success) {
|
||||
setError(res.error ?? "Failed to change password.");
|
||||
return;
|
||||
}
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setSuccess("Password updated.");
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Something went wrong. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="currentPassword">Current password</Label>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="newPassword">New password</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirmPassword">Confirm new password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{success && <p className="text-sm text-emerald-500">{success}</p>}
|
||||
<div>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Loader2Icon className="size-4 animate-spin" />}
|
||||
{loading ? "Updating..." : "Change password"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { requestDiscordUsernameChange } from "@/app/(frontend)/account/actions";
|
||||
|
||||
export function DiscordUsernameRequestForm({
|
||||
currentDiscordUsername,
|
||||
}: {
|
||||
currentDiscordUsername: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [value, setValue] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const res = await requestDiscordUsernameChange({ desiredDiscordUsername: value });
|
||||
if (!res.success) {
|
||||
setError(res.error ?? "Failed to submit request.");
|
||||
return;
|
||||
}
|
||||
setValue("");
|
||||
setSuccess("Request submitted — leadership has been notified.");
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Something went wrong. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Current Discord username</Label>
|
||||
<p className="text-sm text-muted-foreground">{currentDiscordUsername || "Not set"}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="desiredDiscordUsername">New Discord username</Label>
|
||||
<Input
|
||||
id="desiredDiscordUsername"
|
||||
name="desiredDiscordUsername"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
maxLength={32}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leadership will be notified to approve this change.
|
||||
</p>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{success && <p className="text-sm text-emerald-500">{success}</p>}
|
||||
<div>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Loader2Icon className="size-4 animate-spin" />}
|
||||
{loading ? "Submitting..." : "Request change"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
108
src/components/frontend/account/PreferencesForm.tsx
Normal file
108
src/components/frontend/account/PreferencesForm.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { MUTEABLE_NOTIFICATION_TYPES } from "@/lib/notifications/notificationTypes";
|
||||
import { updatePreferences } from "@/app/(frontend)/account/actions";
|
||||
|
||||
interface PreferencesFormProps {
|
||||
mutedAll: boolean;
|
||||
mutedTypes: string[];
|
||||
showCallsign: boolean;
|
||||
}
|
||||
|
||||
export function PreferencesForm({
|
||||
mutedAll: initialMutedAll,
|
||||
mutedTypes: initialMutedTypes,
|
||||
showCallsign: initialShowCallsign,
|
||||
}: PreferencesFormProps) {
|
||||
const router = useRouter();
|
||||
const [mutedAll, setMutedAll] = useState(initialMutedAll);
|
||||
const [mutedTypes, setMutedTypes] = useState<string[]>(initialMutedTypes);
|
||||
const [showCallsign, setShowCallsign] = useState(initialShowCallsign);
|
||||
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((prev) =>
|
||||
checked ? [...new Set([...prev, value])] : prev.filter((t) => t !== value),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const res = await updatePreferences({ mutedAll, mutedTypes, showCallsign });
|
||||
if (!res.success) {
|
||||
setError(res.error ?? "Failed to save preferences.");
|
||||
return;
|
||||
}
|
||||
setSuccess("Preferences saved.");
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Something went wrong. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-medium">Notifications</h3>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={mutedAll}
|
||||
onCheckedChange={(c) => setMutedAll(c === true)}
|
||||
/>
|
||||
Mute all in-app notifications
|
||||
</label>
|
||||
<div
|
||||
className={
|
||||
mutedAll
|
||||
? "flex flex-col gap-3 pl-6 opacity-50 pointer-events-none"
|
||||
: "flex flex-col gap-3 pl-6"
|
||||
}
|
||||
>
|
||||
{MUTEABLE_NOTIFICATION_TYPES.map((t) => (
|
||||
<label key={t.value} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={mutedTypes.includes(t.value)}
|
||||
onCheckedChange={(c) => toggleType(t.value, c === true)}
|
||||
/>
|
||||
{t.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-medium">Display</h3>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={showCallsign}
|
||||
onCheckedChange={(c) => setShowCallsign(c === true)}
|
||||
/>
|
||||
Show callsign (username) instead of display name
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{success && <p className="text-sm text-emerald-500">{success}</p>}
|
||||
<div>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Loader2Icon className="size-4 animate-spin" />}
|
||||
{loading ? "Saving..." : "Save preferences"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
75
src/components/ui/card.tsx
Normal file
75
src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
|
||||
|
|
@ -75,6 +75,12 @@ export const EventTypes = {
|
|||
|
||||
// NPCs
|
||||
NpcGenerate: "npc:generate",
|
||||
|
||||
// Account
|
||||
AccountPasswordChange: "account:password-change",
|
||||
AccountProfileUpdate: "account:profile-update",
|
||||
AccountDiscordRequest: "account:discord-request",
|
||||
AccountPreferencesUpdate: "account:preferences-update",
|
||||
} as const;
|
||||
|
||||
export type EventType = (typeof EventTypes)[keyof typeof EventTypes];
|
||||
|
|
|
|||
Loading…
Reference in a new issue