216 lines
7.1 KiB
TypeScript
216 lines
7.1 KiB
TypeScript
import { redirect, Form } from "react-router";
|
|
import { useEffect, useState } from "react";
|
|
import { auth } from "~/lib/auth.server";
|
|
import { findUserById, updateUser, isUserInActiveDraft } from "~/models/user";
|
|
import type { Route } from "./+types/user-profile";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Input } from "~/components/ui/input";
|
|
import { Label } from "~/components/ui/label";
|
|
import { TimezoneSelect, TIMEZONE_OPTIONS } from "~/components/league/TimezoneSelect";
|
|
import { AvatarEditor } from "~/components/ui/AvatarEditor";
|
|
import { parseFlagConfig } from "~/lib/flag-types";
|
|
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
|
|
import { logger } from "~/lib/logger";
|
|
|
|
const VALID_TIMEZONES = new Set(
|
|
TIMEZONE_OPTIONS.flatMap((g) => g.zones.map((z) => z.value))
|
|
);
|
|
|
|
export function meta(): Route.MetaDescriptors {
|
|
return [{ title: "Profile - Brackt" }];
|
|
}
|
|
|
|
export async function loader(args: Route.LoaderArgs) {
|
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
|
if (!session) {
|
|
return redirect("/login?redirectTo=/user-profile");
|
|
}
|
|
const user = await findUserById(session.user.id);
|
|
if (!user) {
|
|
return redirect("/");
|
|
}
|
|
const isInActiveDraft = await isUserInActiveDraft(session.user.id);
|
|
return { user, isInActiveDraft };
|
|
}
|
|
|
|
export async function action(args: Route.ActionArgs) {
|
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
|
if (!session) {
|
|
return redirect("/login?redirectTo=/user-profile");
|
|
}
|
|
|
|
const user = await findUserById(session.user.id);
|
|
if (!user) {
|
|
return redirect("/");
|
|
}
|
|
|
|
const formData = await args.request.formData();
|
|
const intent = formData.get("intent");
|
|
|
|
if (intent === "update-avatar-flag") {
|
|
const rawConfig = formData.get("flagConfig");
|
|
if (typeof rawConfig !== "string") {
|
|
return { error: "Flag config is required." };
|
|
}
|
|
|
|
let parsedConfig: unknown;
|
|
try {
|
|
parsedConfig = JSON.parse(rawConfig);
|
|
} catch {
|
|
return { error: "Invalid flag config." };
|
|
}
|
|
|
|
const flagConfig = parseFlagConfig(parsedConfig);
|
|
if (!flagConfig) {
|
|
return { error: "Invalid flag config." };
|
|
}
|
|
|
|
await updateUser(session.user.id, {
|
|
flagConfig,
|
|
avatarType: "flag",
|
|
});
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
if (intent === "remove-avatar-photo") {
|
|
await updateUser(session.user.id, {
|
|
customAvatarUrl: null,
|
|
avatarType: "flag",
|
|
});
|
|
if (user.customAvatarUrl) {
|
|
deleteCloudinaryImageByUrl(user.customAvatarUrl).catch((error) => {
|
|
logger.error("Failed to delete user avatar from Cloudinary:", error);
|
|
});
|
|
}
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
const displayName = formData.get("displayName") as string;
|
|
const username = formData.get("username") as string | null;
|
|
const firstName = formData.get("firstName") as string | null;
|
|
const lastName = formData.get("lastName") as string | null;
|
|
const timezone = formData.get("timezone") as string | null;
|
|
|
|
const timezoneValue =
|
|
timezone && VALID_TIMEZONES.has(timezone) ? timezone : undefined;
|
|
|
|
await updateUser(session.user.id, {
|
|
displayName: displayName || undefined,
|
|
username: username || undefined,
|
|
firstName: firstName || undefined,
|
|
lastName: lastName || undefined,
|
|
timezone: timezoneValue,
|
|
});
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
export default function UserProfilePage({ loaderData, actionData }: Route.ComponentProps) {
|
|
const { user, isInActiveDraft } = loaderData;
|
|
const [timezone, setTimezone] = useState(user.timezone ?? "");
|
|
|
|
// Auto-detect browser timezone if not already set
|
|
useEffect(() => {
|
|
if (!user.timezone) {
|
|
try {
|
|
const detected = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
if (detected && VALID_TIMEZONES.has(detected)) {
|
|
setTimezone(detected);
|
|
}
|
|
} catch {
|
|
// Intl not available — leave blank
|
|
}
|
|
}
|
|
}, [user.timezone]);
|
|
|
|
return (
|
|
<div className="container mx-auto py-8 px-4 max-w-lg">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Your Profile</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{actionData?.success && (
|
|
<p className="text-sm text-green-500 mb-4">Profile updated successfully.</p>
|
|
)}
|
|
{actionData && "error" in actionData && actionData.error && (
|
|
<p className="text-sm text-destructive mb-4">{actionData.error}</p>
|
|
)}
|
|
<div className="mb-6 border-b pb-6">
|
|
<AvatarEditor
|
|
id={user.id}
|
|
flagConfig={user.flagConfig}
|
|
uploadedPhotoUrl={user.avatarType === "uploaded" ? user.customAvatarUrl : null}
|
|
uploadUrl="/api/upload-avatar"
|
|
/>
|
|
</div>
|
|
<Form method="post" className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="displayName">Display Name</Label>
|
|
<Input
|
|
id="displayName"
|
|
name="displayName"
|
|
defaultValue={user.displayName ?? ""}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="username">Username</Label>
|
|
<Input
|
|
id="username"
|
|
name="username"
|
|
defaultValue={user.username ?? ""}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="firstName">First Name</Label>
|
|
<Input
|
|
id="firstName"
|
|
name="firstName"
|
|
defaultValue={user.firstName ?? ""}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="lastName">Last Name</Label>
|
|
<Input
|
|
id="lastName"
|
|
name="lastName"
|
|
defaultValue={user.lastName ?? ""}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>Email</Label>
|
|
<p className="text-sm text-muted-foreground">{user.email}</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="timezone">Timezone</Label>
|
|
{/* Hidden input carries the controlled value into the form submission */}
|
|
<input type="hidden" name="timezone" value={timezone} />
|
|
<TimezoneSelect
|
|
value={timezone}
|
|
onChange={setTimezone}
|
|
disabled={isInActiveDraft}
|
|
/>
|
|
{isInActiveDraft ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
Timezone cannot be changed while you are in an active draft.
|
|
</p>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground">
|
|
Used for overnight pick protection. We pre-filled your browser's detected timezone.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<Button type="submit" className="w-full">Save Changes</Button>
|
|
</Form>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|