brackt/app/routes/user-profile.tsx
2026-05-09 09:18:50 -07:00

194 lines
6.3 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 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,
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="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&apos;s detected timezone.
</p>
)}
</div>
<Button type="submit" className="w-full">Save Changes</Button>
</Form>
</CardContent>
</Card>
</div>
);
}