Add Cloudinary-backed avatar system (#385)

This commit is contained in:
Chris Parsons 2026-05-06 14:47:37 -07:00 committed by GitHub
parent 05fe1493a3
commit a447ba54de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 8111 additions and 62 deletions

View file

@ -8,7 +8,9 @@ DATABASE_URL=""
PGBOUNCER=
DATABASE_DIRECT_URL=
BETTER_AUTH_SECRET=""
BETTER_AUTH_URL=""
# Canonical public app URL used by auth links, OAuth callbacks, and Cloudinary webhook callbacks.
# Examples: http://localhost:5173, https://brackt.com
APP_URL=""
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
DISCORD_CLIENT_ID=""
@ -18,4 +20,9 @@ SENTRY_AUTH_TOKEN=""
SQUIGGLE_CONTACT_EMAIL=""
RESEND_API_KEY=""
TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
# Cloudinary avatar uploads. API secret must stay server-only.
CLOUDINARY_CLOUD_NAME=""
CLOUDINARY_API_KEY=""
CLOUDINARY_API_SECRET=""

View file

@ -7,6 +7,7 @@ import {
import { DraftPickCell, type CoronaState } from "~/components/draft/DraftPickCell";
import { TeamAvatar } from "~/components/TeamAvatar";
import type { SeasonStatus } from "~/models/season";
import type { RawFlagConfig } from "~/lib/flag-types";
interface DraftCell {
pickNumber: number;
@ -23,6 +24,8 @@ interface DraftGridProps {
id: string;
name: string;
logoUrl?: string | null;
flagConfig?: RawFlagConfig | null;
avatarType?: string | null;
};
}>;
draftGrid: Array<Array<DraftCell | null>>;
@ -79,6 +82,8 @@ export function DraftGrid({
teamId={slot.team.id}
teamName={ownerMap[slot.team.id] || slot.team.name}
logoUrl={slot.team.logoUrl}
flagConfig={slot.team.flagConfig}
avatarType={slot.team.avatarType}
size="lg"
/>
</div>

View file

@ -43,6 +43,15 @@ export const WithLogo: Story = {
},
};
export const WithFlag: Story = {
args: {
teamId: "team-4",
teamName: "Flag Club",
avatarType: "flag",
flagConfig: { pattern: "nordic-cross", colors: ["#adf661", "#111827", "#ffffff"] },
},
};
export const SingleWordName: Story = {
args: {
teamId: "team-3",
@ -50,6 +59,29 @@ export const SingleWordName: Story = {
},
};
export const InheritingOwnerImage: Story = {
name: "Inheriting Owner Image (avatarType: owner)",
args: {
teamId: "team-5",
teamName: "Inherited Squad",
avatarType: "owner",
ownerAvatarData: { type: "image", url: "https://placehold.co/48x48/1a1a2e/adf661?text=OW" },
},
};
export const InheritingOwnerFlag: Story = {
name: "Inheriting Owner Flag (avatarType: owner)",
args: {
teamId: "team-6",
teamName: "Flag Inheritors",
avatarType: "owner",
ownerAvatarData: {
type: "flag",
config: { pattern: "diagonal", colors: ["#adf661", "#111827", "#ffffff"] },
},
},
};
export const AllSizes: Story = {
name: "All Sizes — Side by Side",
render: () => (
@ -66,6 +98,10 @@ export const AllSizes: Story = {
<TeamAvatar teamId="team-1" teamName="Thunder Hawks" size="lg" />
<span className="text-xs text-muted-foreground">lg</span>
</div>
<div className="flex flex-col items-center gap-1">
<TeamAvatar teamId="team-1" teamName="Thunder Hawks" size="xl" />
<span className="text-xs text-muted-foreground">xl</span>
</div>
</div>
),
};

View file

@ -1,25 +1,67 @@
import { avatarColor } from "~/lib/avatar-colors";
import { resolveTeamAvatarData } from "~/lib/avatar-data";
import type { AvatarData, RawFlagConfig } from "~/lib/flag-types";
import { FlagSvg } from "~/components/ui/FlagSvg";
import { cn } from "~/lib/utils";
import { cloudinaryAvatarUrl } from "~/lib/cloudinary-url";
interface TeamAvatarProps {
export interface TeamAvatarProps {
teamId: string;
teamName: string;
logoUrl?: string | null;
size?: "sm" | "md" | "lg";
flagConfig?: RawFlagConfig | null;
avatarType?: string | null;
ownerAvatarData?: AvatarData | null;
size?: "sm" | "md" | "lg" | "xl";
className?: string;
}
const sizeClasses = {
sm: "h-6 w-6 text-[10px]",
md: "h-9 w-9 text-sm",
lg: "h-12 w-12 text-base",
xl: "h-16 w-16 text-xl",
};
export function TeamAvatar({ teamId, teamName, logoUrl, size = "md" }: TeamAvatarProps) {
if (logoUrl) {
const pixelSizes = {
sm: 24,
md: 36,
lg: 48,
xl: 64,
};
export function TeamAvatar({
teamId,
teamName,
logoUrl,
flagConfig,
avatarType,
ownerAvatarData,
size = "md",
className,
}: TeamAvatarProps) {
const avatar = resolveTeamAvatarData(
{ id: teamId, logoUrl, flagConfig, avatarType },
ownerAvatarData
);
if (avatar.type === "image") {
return (
<img
src={logoUrl}
src={cloudinaryAvatarUrl(avatar.url, pixelSizes[size])}
alt={teamName}
className={`${sizeClasses[size]} object-cover shrink-0`}
className={cn(sizeClasses[size], "object-cover shrink-0", className)}
/>
);
}
if (avatar.type === "flag") {
return (
<FlagSvg
config={avatar.config}
size={pixelSizes[size]}
title={teamName}
className={cn(sizeClasses[size], className)}
/>
);
}
@ -37,7 +79,7 @@ export function TeamAvatar({ teamId, teamName, logoUrl, size = "md" }: TeamAvata
<div
role="img"
aria-label={teamName}
className={`${sizeClasses[size]} flex shrink-0 items-center justify-center font-bold`}
className={cn(sizeClasses[size], "flex shrink-0 items-center justify-center font-bold", className)}
style={{ backgroundColor: "#000", color }}
>
{initials}

View file

@ -2,20 +2,28 @@ import { Link, useNavigate } from "react-router";
import { authClient } from "~/lib/auth-client";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
import { Button } from "~/components/ui/button";
import { UserAvatar } from "~/components/ui/UserAvatar";
import type { RawFlagConfig } from "~/lib/flag-types";
interface UserMenuProps {
userId: string;
name: string | null;
email: string;
imageUrl: string | null;
customAvatarUrl?: string | null;
flagConfig?: RawFlagConfig | null;
avatarType?: string | null;
}
export function UserMenu({ name, email, imageUrl }: UserMenuProps) {
export function UserMenu({
userId,
name,
email,
customAvatarUrl,
flagConfig,
avatarType,
}: UserMenuProps) {
const navigate = useNavigate();
const initials = name
? name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2)
: email.slice(0, 2).toUpperCase();
async function handleSignOut() {
await authClient.signOut();
navigate("/");
@ -24,14 +32,15 @@ export function UserMenu({ name, email, imageUrl }: UserMenuProps) {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" className="relative h-9 w-9 rounded-full p-0 overflow-hidden">
{imageUrl ? (
<img src={imageUrl} alt={name ?? email} className="h-9 w-9 rounded-full object-cover" />
) : (
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-muted text-sm font-medium">
{initials}
</span>
)}
<Button variant="ghost" className="relative h-9 w-9 p-0 overflow-hidden">
<UserAvatar
userId={userId}
name={name}
email={email}
customAvatarUrl={customAvatarUrl}
flagConfig={flagConfig}
avatarType={avatarType}
/>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-48 p-1">

View file

@ -32,8 +32,8 @@ describe('DraftGrid Component', () => {
/>
);
expect(screen.getByText('Team 1')).toBeInTheDocument();
expect(screen.getByText('Team 2')).toBeInTheDocument();
expect(screen.getByText('Team 1', { selector: 'span' })).toBeInTheDocument();
expect(screen.getByText('Team 2', { selector: 'span' })).toBeInTheDocument();
});
it('should render draft grid with correct structure', () => {

View file

@ -1,5 +1,6 @@
import { memo, useMemo, useState } from "react";
import { TeamAvatar } from "~/components/TeamAvatar";
import type { RawFlagConfig } from "~/lib/flag-types";
import {
ContextMenu,
ContextMenuContent,
@ -31,6 +32,8 @@ interface DraftGridSectionProps {
id: string;
name: string;
logoUrl?: string | null;
flagConfig?: RawFlagConfig | null;
avatarType?: string | null;
};
}>;
draftGrid: Array<
@ -116,6 +119,8 @@ export const DraftGridSection = memo(function DraftGridSection({
teamId={slot.team.id}
teamName={ownerMap[slot.team.id] || slot.team.name}
logoUrl={slot.team.logoUrl}
flagConfig={slot.team.flagConfig}
avatarType={slot.team.avatarType}
size="md"
/>
</div>

View file

@ -1,9 +1,19 @@
import { memo, useMemo, Fragment } from "react";
import { TeamAvatar } from "~/components/TeamAvatar";
import { type DraftPick, groupPicksByTeamAndSport } from "./picks-utils";
import type { RawFlagConfig } from "~/lib/flag-types";
interface DraftSummaryViewProps {
draftSlots: Array<{ id: string; team: { id: string; name: string; logoUrl?: string | null } }>;
draftSlots: Array<{
id: string;
team: {
id: string;
name: string;
logoUrl?: string | null;
flagConfig?: RawFlagConfig | null;
avatarType?: string | null;
};
}>;
ownerMap?: Record<string, string>;
picks: DraftPick[];
sports: Array<{ id: string; name: string }>;
@ -87,6 +97,8 @@ export const DraftSummaryView = memo(function DraftSummaryView({
teamId={slot.team.id}
teamName={ownerName || slot.team.name}
logoUrl={slot.team.logoUrl}
flagConfig={slot.team.flagConfig}
avatarType={slot.team.avatarType}
size="md"
/>
<div className="text-xs font-semibold text-center truncate w-full">

View file

@ -10,6 +10,7 @@ import {
import { Button } from "~/components/ui/button";
import { authClient } from "~/lib/auth-client";
import { UserMenu } from "~/components/UserMenu";
import type { RawFlagConfig } from "~/lib/flag-types";
import { HelpCircle, MenuIcon, Settings } from "lucide-react";
const NAV_LINK_CLASS =
@ -27,20 +28,42 @@ function mobileNavLinkClass({ isActive }: { isActive: boolean }) {
interface NavbarProps {
isAdmin: boolean;
currentUser: {
id: string;
email: string;
username: string | null;
displayName: string | null;
customAvatarUrl: string | null;
flagConfig: RawFlagConfig | null;
avatarType: string | null;
} | null;
}
export function Navbar({ isAdmin }: NavbarProps) {
export function Navbar({ isAdmin, currentUser }: NavbarProps) {
const [open, setOpen] = useState(false);
const location = useLocation();
const { data: session } = authClient.useSession();
const signInHref = `/login?redirectTo=${encodeURIComponent(location.pathname)}`;
const authSection = session ? (
const menuUser = currentUser ?? (session ? {
id: session.user.id,
email: session.user.email,
username: null,
displayName: session.user.name ?? null,
customAvatarUrl: null,
flagConfig: null,
avatarType: null,
} : null);
const authSection = menuUser ? (
<UserMenu
name={session.user.name ?? null}
email={session.user.email}
imageUrl={session.user.image ?? null}
userId={menuUser.id}
name={menuUser.username ?? menuUser.displayName}
email={menuUser.email}
customAvatarUrl={menuUser.customAvatarUrl}
flagConfig={menuUser.flagConfig}
avatarType={menuUser.avatarType}
/>
) : (
<Link to={signInHref}>

View file

@ -0,0 +1,136 @@
import { useMemo, useState } from "react";
import { Save, User } from "lucide-react";
import { getDisplayFlagConfig } from "~/lib/avatar-data";
import type { AvatarData, FlagConfig, RawFlagConfig } from "~/lib/flag-types";
import { Button } from "~/components/ui/button";
import { FlagBuilder } from "~/components/ui/FlagBuilder";
import { FlagSvg } from "~/components/ui/FlagSvg";
import { AvatarUploader } from "~/components/ui/AvatarUploader";
import { cloudinaryAvatarUrl } from "~/lib/cloudinary-url";
import { cn } from "~/lib/utils";
interface AvatarEditorProps {
id: string;
flagConfig?: RawFlagConfig | null;
uploadedPhotoUrl?: string | null;
uploadUrl: string;
isTeam?: boolean;
currentAvatarType?: string | null;
ownerAvatarData?: AvatarData | null;
flagIntent?: string;
removePhotoIntent?: string;
useOwnerAvatarIntent?: string;
}
type Mode = "flag" | "upload" | "owner";
export function AvatarEditor({
id,
flagConfig,
uploadedPhotoUrl,
uploadUrl,
isTeam = false,
currentAvatarType,
ownerAvatarData,
flagIntent = "update-avatar-flag",
removePhotoIntent = "remove-avatar-photo",
useOwnerAvatarIntent = "use-owner-avatar",
}: AvatarEditorProps) {
const initialFlag = useMemo(() => getDisplayFlagConfig(id, flagConfig), [flagConfig, id]);
const [config, setConfig] = useState<FlagConfig>(initialFlag);
const showOwnerTab = isTeam && ownerAvatarData !== null && ownerAvatarData !== undefined;
const initialMode: Mode =
currentAvatarType === "owner" && showOwnerTab ? "owner" :
currentAvatarType === "uploaded" ? "upload" :
"flag";
const [mode, setMode] = useState<Mode>(initialMode);
const tabs = [
{ key: "flag" as const, label: "Flag" },
{ key: "upload" as const, label: "Upload Photo" },
...(showOwnerTab ? [{ key: "owner" as const, label: "My Avatar" }] : []),
];
return (
<div className="space-y-4">
<div className="flex overflow-hidden rounded-lg border border-border">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setMode(tab.key)}
className={cn(
"flex-1 py-2 text-sm font-medium transition-colors",
mode === tab.key
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent"
)}
>
{tab.label}
</button>
))}
</div>
{mode === "flag" && (
<form method="post" className="space-y-4">
<input type="hidden" name="intent" value={flagIntent} />
<input type="hidden" name="flagConfig" value={JSON.stringify(config)} />
<FlagBuilder value={config} seed={id} onChange={setConfig} />
<Button type="submit" className="w-full">
<Save className="mr-2 h-4 w-4" />
Save flag
</Button>
</form>
)}
{mode === "upload" && (
<AvatarUploader
uploadUrl={uploadUrl}
teamId={isTeam ? id : undefined}
savedPhotoUrl={uploadedPhotoUrl}
removePhotoIntent={removePhotoIntent}
/>
)}
{mode === "owner" && ownerAvatarData && (
<form method="post" className="space-y-4">
<input type="hidden" name="intent" value={useOwnerAvatarIntent} />
<div className="flex flex-col items-center gap-3 py-2">
<OwnerAvatarPreview avatarData={ownerAvatarData} />
<p className="text-sm text-muted-foreground">Your team will use your current profile avatar.</p>
</div>
<Button type="submit" className="w-full">
<User className="mr-2 h-4 w-4" />
Use my avatar
</Button>
</form>
)}
</div>
);
}
function OwnerAvatarPreview({ avatarData }: { avatarData: AvatarData }) {
if (avatarData.type === "image") {
return (
<img
src={cloudinaryAvatarUrl(avatarData.url, 64)}
alt="Your avatar"
className="h-16 w-16 rounded-full object-cover ring-2 ring-border"
/>
);
}
if (avatarData.type === "flag") {
return (
<FlagSvg
config={avatarData.config}
size={64}
title="Your avatar"
className="h-16 w-16 rounded-full ring-2 ring-border"
/>
);
}
return null;
}

View file

@ -0,0 +1,262 @@
import { useRef, useState } from "react";
import {
ReactCrop,
centerCrop,
convertToPixelCrop,
makeAspectCrop,
type Crop,
type PixelCrop,
} from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
import { ImageIcon, Trash2, Upload } from "lucide-react";
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
interface AvatarUploaderProps {
uploadUrl: string;
teamId?: string;
savedPhotoUrl?: string | null;
removePhotoIntent?: string;
}
const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
function centerSquareCrop(width: number, height: number): Crop {
return centerCrop(
makeAspectCrop({ unit: "%", width: 85 }, 1, width, height),
width,
height
);
}
async function cropImageToBlob(image: HTMLImageElement, crop: PixelCrop): Promise<Blob> {
const canvas = document.createElement("canvas");
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Canvas is not available");
}
ctx.drawImage(
image,
crop.x * scaleX,
crop.y * scaleY,
crop.width * scaleX,
crop.height * scaleY,
0,
0,
256,
256
);
return await new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error("Could not prepare image"));
}, "image/jpeg", 0.9);
});
}
export function AvatarUploader({
uploadUrl,
teamId,
savedPhotoUrl,
removePhotoIntent = "remove-avatar-photo",
}: AvatarUploaderProps) {
const imageRef = useRef<HTMLImageElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [shouldRemoveSavedPhoto, setShouldRemoveSavedPhoto] = useState(false);
const [fileName, setFileName] = useState("avatar.jpg");
const [crop, setCrop] = useState<Crop>();
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
const [status, setStatus] = useState<"idle" | "submitting" | "pending" | "error">("idle");
const [error, setError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
function prepareFile(file: File | undefined) {
setError(null);
setStatus("idle");
setShouldRemoveSavedPhoto(false);
if (!file) return;
if (!ACCEPTED_TYPES.includes(file.type)) {
setError("Choose a JPG, PNG, WebP, or GIF image.");
return;
}
if (file.size > 5 * 1024 * 1024) {
setError("Choose an image under 5 MB.");
return;
}
setFileName(file.name);
const reader = new FileReader();
reader.addEventListener("load", () => setImageSrc(String(reader.result)));
reader.readAsDataURL(file);
}
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
prepareFile(event.target.files?.[0]);
event.target.value = "";
}
function clearImage() {
setImageSrc(null);
setFileName("avatar.jpg");
setCrop(undefined);
setCompletedCrop(undefined);
setError(null);
setStatus("idle");
}
function handleRemoveSavedPhoto() {
setShouldRemoveSavedPhoto(true);
clearImage();
}
function handleDrop(event: React.DragEvent<HTMLButtonElement>) {
event.preventDefault();
setIsDragging(false);
prepareFile(event.dataTransfer.files[0]);
}
async function handleSubmit() {
const image = imageRef.current;
const cropToUse =
completedCrop ??
(image && crop ? convertToPixelCrop(crop, image.width, image.height) : undefined);
if (!image || !cropToUse) {
setError("Choose and crop an image first.");
return;
}
setStatus("submitting");
setError(null);
try {
const blob = await cropImageToBlob(image, cropToUse);
const formData = new FormData();
formData.append("file", blob, fileName.replace(/\.[^.]+$/, ".jpg"));
if (teamId) formData.append("teamId", teamId);
const response = await fetch(uploadUrl, { method: "POST", body: formData });
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(typeof result.error === "string" ? result.error : "Upload failed.");
}
setStatus("pending");
} catch (err) {
setStatus("error");
setError(err instanceof Error ? err.message : "Upload failed.");
}
}
return (
<div className="space-y-4">
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_TYPES.join(",")}
onChange={handleFileChange}
className="sr-only"
/>
{savedPhotoUrl && !shouldRemoveSavedPhoto && !imageSrc && (
<div className="space-y-3">
<div className="overflow-hidden rounded-md border border-border bg-background">
<img src={savedPhotoUrl} alt="Current avatar" className="max-h-[360px] w-full object-cover" />
</div>
<Button type="button" variant="outline" onClick={handleRemoveSavedPhoto} className="w-full">
<Trash2 className="mr-2 h-4 w-4" />
Remove photo
</Button>
</div>
)}
{(!savedPhotoUrl || shouldRemoveSavedPhoto) && !imageSrc && (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
onDragOver={(event) => {
event.preventDefault();
setIsDragging(true);
}}
onDragEnter={(event) => {
event.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
className={cn(
"flex min-h-40 w-full flex-col items-center justify-center rounded-lg border border-dashed bg-card/40 px-6 py-8 text-center transition-colors",
isDragging ? "border-[#adf661] bg-[#adf661]/10" : "border-border hover:border-muted-foreground hover:bg-accent/40"
)}
>
<ImageIcon className="mb-3 h-9 w-9 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">Drop a picture</span>
<span className="text-xs text-muted-foreground/80">or click to browse</span>
</button>
)}
{imageSrc && (
<>
<div className="max-w-sm overflow-hidden rounded-md border border-border bg-background">
<ReactCrop
crop={crop}
onChange={(_, percentCrop) => setCrop(percentCrop)}
onComplete={(pixelCrop) => setCompletedCrop(pixelCrop)}
aspect={1}
>
<img
ref={imageRef}
src={imageSrc}
alt=""
onLoad={(event) => {
const { width, height } = event.currentTarget;
setCrop(centerSquareCrop(width, height));
}}
className="max-h-[360px]"
/>
</ReactCrop>
</div>
<Button type="button" variant="outline" onClick={clearImage} className="w-full">
<Trash2 className="mr-2 h-4 w-4" />
Remove picture
</Button>
</>
)}
{status === "pending" && (
<p className="text-sm text-[#adf661]">Photo submitted for review. It will appear after approval.</p>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
{shouldRemoveSavedPhoto && !imageSrc ? (
<form method="post">
<input type="hidden" name="intent" value={removePhotoIntent} />
<Button type="submit" className="w-full">
Revert to flag avatar
</Button>
</form>
) : (
<Button
type="button"
onClick={handleSubmit}
disabled={!imageSrc || status === "submitting"}
className="w-full"
>
<Upload className="mr-2 h-4 w-4" />
{status === "submitting" ? "Submitting..." : "Submit photo"}
</Button>
)}
</div>
);
}

View file

@ -0,0 +1,23 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { generateFlagConfig } from "~/lib/flag-generator";
import type { FlagConfig } from "~/lib/flag-types";
import { FlagBuilder } from "./FlagBuilder";
const meta: Meta<typeof FlagBuilder> = {
title: "UI/FlagBuilder",
component: FlagBuilder,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof FlagBuilder>;
function FlagBuilderStory() {
const [config, setConfig] = useState<FlagConfig>(generateFlagConfig("story-user"));
return <FlagBuilder value={config} seed="story-user" onChange={setConfig} />;
}
export const Default: Story = {
render: () => <FlagBuilderStory />,
};

View file

@ -0,0 +1,183 @@
import { useMemo, useState } from "react";
import { Check } from "lucide-react";
import { AVATAR_COLORS } from "~/lib/avatar-colors";
import { generateFlagConfig } from "~/lib/flag-generator";
import { FLAG_PATTERNS, type FlagConfig, type FlagPattern } from "~/lib/flag-types";
import { cn } from "~/lib/utils";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { FlagSvg } from "~/components/ui/FlagSvg";
interface FlagBuilderProps {
value?: FlagConfig | null;
seed: string;
onChange: (config: FlagConfig) => void;
}
const SWATCHES = [
...AVATAR_COLORS,
"#22c55e",
"#14b8a6",
"#06b6d4",
"#0ea5e9",
"#6366f1",
"#a855f7",
"#d946ef",
"#ec4899",
"#f43f5e",
"#dc2626",
"#f97316",
"#eab308",
"#84cc16",
"#ffffff",
"#d1d5db",
"#6b7280",
"#111827",
"#000000",
];
const COLOR_SLOT_KEYS = ["primary", "secondary", "accent"];
const FALLBACK_COLORS = ["#adf661", "#111827", "#ffffff"];
const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
function normalizeColors(colors: string[]): string[] {
return [
colors[0] ?? FALLBACK_COLORS[0],
colors[1] ?? FALLBACK_COLORS[1],
colors[2] ?? FALLBACK_COLORS[2],
];
}
export function FlagBuilder({ value: configValue, seed, onChange }: FlagBuilderProps) {
const initial = useMemo(() => configValue ?? generateFlagConfig(seed), [seed, configValue]);
const [customInputs, setCustomInputs] = useState(() => normalizeColors(initial.colors));
const [selectedColorIndex, setSelectedColorIndex] = useState(0);
const colors = normalizeColors(initial.colors);
const config = { ...initial, colors };
const colorSlots = colors.map((color, index) => ({
key: COLOR_SLOT_KEYS[index],
label: `Color ${index + 1}`,
color,
index,
}));
const activeColorIndex = Math.min(selectedColorIndex, colors.length - 1);
const activeColor = colors[activeColorIndex];
const customColor = customInputs[activeColorIndex] ?? activeColor;
const isValidCustomColor = HEX_COLOR_RE.test(customColor);
function updatePattern(pattern: FlagPattern) {
onChange({ ...config, pattern });
}
function updateColor(index: number, color: string) {
const nextColors = [...colors];
nextColors[index] = color;
onChange({ ...config, colors: nextColors });
}
return (
<div className="space-y-5">
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{FLAG_PATTERNS.map((pattern) => {
const selected = config.pattern === pattern;
return (
<button
key={pattern}
type="button"
title={pattern}
aria-label={pattern}
aria-pressed={selected}
onClick={() => updatePattern(pattern)}
className={cn(
"relative flex aspect-square items-center justify-center rounded-md border bg-background p-1 transition-colors",
selected ? "border-[#adf661] ring-2 ring-[#adf661]/30" : "border-border hover:border-muted-foreground"
)}
>
<FlagSvg config={{ ...config, pattern }} size={60} className="h-full w-full" />
{selected && (
<span className="absolute right-1 top-1 rounded-full bg-[#adf661] p-0.5 text-black">
<Check className="h-3 w-3" />
</span>
)}
</button>
);
})}
</div>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{colorSlots.map((slot) => (
<button
key={slot.key}
type="button"
aria-pressed={activeColorIndex === slot.index}
onClick={() => setSelectedColorIndex(slot.index)}
className={cn(
"flex h-10 items-center gap-2 rounded-md border px-2 text-sm font-medium transition-colors",
activeColorIndex === slot.index
? "border-[#adf661] bg-[#adf661]/10"
: "border-border hover:border-muted-foreground"
)}
>
<span
className="h-5 w-5 shrink-0 rounded-sm border border-border"
style={{ backgroundColor: slot.color }}
/>
{slot.label}
</button>
))}
</div>
<div className="space-y-2">
<Label>{colorSlots[activeColorIndex]?.label ?? "Color"}</Label>
<div className="flex flex-wrap items-center gap-2">
{SWATCHES.map((swatch) => (
<button
key={swatch}
type="button"
title={swatch}
aria-label={`Use ${swatch}`}
onClick={() => updateColor(activeColorIndex, swatch)}
className={cn(
"h-8 w-8 rounded-md border transition-transform hover:scale-105",
activeColor.toLowerCase() === swatch.toLowerCase()
? "border-[#adf661] ring-2 ring-[#adf661]/30"
: "border-border"
)}
style={{ backgroundColor: swatch }}
/>
))}
<button
type="button"
title={isValidCustomColor ? customColor : "Custom color"}
aria-label={isValidCustomColor ? `Use custom color ${customColor}` : "Custom color"}
disabled={!isValidCustomColor}
onClick={() => {
if (isValidCustomColor) updateColor(activeColorIndex, customColor);
}}
className={cn(
"h-8 w-8 rounded-md border transition-transform",
isValidCustomColor ? "hover:scale-105" : "cursor-not-allowed opacity-40",
isValidCustomColor && activeColor.toLowerCase() === customColor.toLowerCase()
? "border-[#adf661] ring-2 ring-[#adf661]/30"
: "border-border"
)}
style={{ backgroundColor: isValidCustomColor ? customColor : "transparent" }}
/>
<Input
value={customInputs[activeColorIndex] ?? activeColor}
onChange={(event) => {
const inputValue = event.target.value;
const next = [...customInputs];
next[activeColorIndex] = inputValue;
setCustomInputs(next);
if (HEX_COLOR_RE.test(inputValue)) updateColor(activeColorIndex, inputValue);
}}
maxLength={7}
className="h-8 w-28"
/>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,29 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FLAG_PATTERNS } from "~/lib/flag-types";
import { FlagSvg } from "./FlagSvg";
const meta: Meta<typeof FlagSvg> = {
title: "UI/FlagSvg",
component: FlagSvg,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof FlagSvg>;
export const AllPatterns: Story = {
render: () => (
<div className="grid grid-cols-4 gap-4">
{FLAG_PATTERNS.map((pattern) => (
<div key={pattern} className="space-y-2">
<FlagSvg
config={{ pattern, colors: ["#adf661", "#111827", "#ffffff"] }}
size={64}
className="rounded-md border border-border"
/>
<p className="text-xs text-muted-foreground">{pattern}</p>
</div>
))}
</div>
),
};

View file

@ -0,0 +1,127 @@
import { cn } from "~/lib/utils";
import { getDisplayFlagConfig } from "~/lib/avatar-data";
import type { FlagConfig } from "~/lib/flag-types";
interface FlagSvgProps {
config: FlagConfig;
size?: number;
className?: string;
title?: string;
}
function colorAt(colors: string[], index: number): string {
return colors[index % colors.length] ?? "#adf661";
}
export function FlagSvg({ config, size = 48, className, title }: FlagSvgProps) {
const safeConfig = getDisplayFlagConfig(`${config.pattern}:${config.colors.join("-")}`, config);
const colors = safeConfig.colors;
const c0 = colorAt(colors, 0);
const c1 = colorAt(colors, 1);
const c2 = colorAt(colors, 2);
return (
<svg
viewBox="0 0 100 100"
width={size}
height={size}
role="img"
aria-label={title}
className={cn("block shrink-0 overflow-hidden", className)}
>
{title ? <title>{title}</title> : null}
{safeConfig.pattern === "triband-h" && (
<>
<rect width="100" height="34" fill={c0} />
<rect y="33" width="100" height="34" fill={c1} />
<rect y="66" width="100" height="34" fill={c2} />
</>
)}
{safeConfig.pattern === "triband-v" && (
<>
<rect width="34" height="100" fill={c0} />
<rect x="33" width="34" height="100" fill={c1} />
<rect x="66" width="34" height="100" fill={c2} />
</>
)}
{safeConfig.pattern === "diagonal" && (
<>
<rect width="100" height="100" fill={c0} />
<polygon points="0,0 100,0 0,100" fill={c1} />
<polygon points="100,100 100,28 28,100" fill={c2} />
</>
)}
{safeConfig.pattern === "nordic-cross" && (
<>
<rect width="100" height="100" fill={c0} />
<rect x="30" width="18" height="100" fill={c1} />
<rect y="40" width="100" height="18" fill={c1} />
<rect x="36" width="6" height="100" fill={c2} />
<rect y="46" width="100" height="6" fill={c2} />
</>
)}
{safeConfig.pattern === "cross" && (
<>
<rect width="100" height="100" fill={c0} />
<rect x="40" width="20" height="100" fill={c1} />
<rect y="40" width="100" height="20" fill={c1} />
<rect x="46" width="8" height="100" fill={c2} />
<rect y="46" width="100" height="8" fill={c2} />
</>
)}
{safeConfig.pattern === "canton" && (
<>
<rect width="100" height="100" fill={c0} />
<rect width="50" height="50" fill={c1} />
<rect x="12" y="12" width="26" height="26" fill={c2} />
</>
)}
{safeConfig.pattern === "triangle" && (
<>
<rect width="100" height="100" fill={c0} />
<polygon points="0,0 64,50 0,100" fill={c1} />
<polygon points="0,22 36,50 0,78" fill={c2} />
</>
)}
{safeConfig.pattern === "chevron" && (
<>
<rect width="100" height="100" fill={c0} />
<polygon points="0,0 56,50 0,100 28,100 84,50 28,0" fill={c1} />
<polygon points="0,22 32,50 0,78 0,62 14,50 0,38" fill={c2} />
</>
)}
{safeConfig.pattern === "quartered" && (
<>
<rect width="50" height="50" fill={c0} />
<rect x="50" width="50" height="50" fill={c1} />
<rect y="50" width="50" height="50" fill={c1} />
<rect x="50" y="50" width="50" height="50" fill={c2} />
</>
)}
{safeConfig.pattern === "bordered" && (
<>
<rect width="100" height="100" fill={c0} />
<rect x="12" y="12" width="76" height="76" fill={c1} />
<rect x="24" y="24" width="52" height="52" fill={c2} />
</>
)}
{safeConfig.pattern === "circle" && (
<>
<rect width="100" height="100" fill={c0} />
<circle cx="50" cy="50" r="30" fill={c1} />
<circle cx="50" cy="50" r="15" fill={c2} />
</>
)}
{safeConfig.pattern === "star" && (
<>
<rect width="100" height="100" fill={c0} />
<polygon
points="50,14 60,39 87,39 65,56 73,84 50,67 27,84 35,56 13,39 40,39"
fill={c1}
/>
<circle cx="50" cy="50" r="10" fill={c2} />
</>
)}
</svg>
);
}

View file

@ -0,0 +1,43 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { UserAvatar } from "./UserAvatar";
const meta: Meta<typeof UserAvatar> = {
title: "UI/UserAvatar",
component: UserAvatar,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof UserAvatar>;
export const Flag: Story = {
args: {
userId: "user-1",
name: "Chris Example",
email: "chris@example.com",
avatarType: "flag",
},
};
export const Uploaded: Story = {
args: {
userId: "user-2",
name: "Sam Example",
email: "sam@example.com",
avatarType: "uploaded",
customAvatarUrl: "https://placehold.co/96x96/111827/adf661?text=SE",
},
};
export const AllSizes: Story = {
render: () => (
<div className="flex items-end gap-4">
{(["xs", "sm", "md", "lg", "xl"] as const).map((size) => (
<div key={size} className="flex flex-col items-center gap-1">
<UserAvatar userId={`user-${size}`} name="Flag User" email="flag@example.com" avatarType="flag" size={size} />
<span className="text-xs text-muted-foreground">{size}</span>
</div>
))}
</div>
),
};

View file

@ -0,0 +1,65 @@
import { resolveUserAvatarData } from "~/lib/avatar-data";
import { cn } from "~/lib/utils";
import { FlagSvg } from "~/components/ui/FlagSvg";
import type { RawFlagConfig } from "~/lib/flag-types";
import { cloudinaryAvatarUrl } from "~/lib/cloudinary-url";
interface UserAvatarProps {
userId: string;
name?: string | null;
email?: string | null;
customAvatarUrl?: string | null;
flagConfig?: RawFlagConfig | null;
avatarType?: string | null;
size?: "xs" | "sm" | "md" | "lg" | "xl";
className?: string;
}
const sizeClasses = {
xs: "h-5 w-5 text-[9px]",
sm: "h-6 w-6 text-[10px]",
md: "h-9 w-9 text-sm",
lg: "h-12 w-12 text-base",
xl: "h-16 w-16 text-xl",
};
const pixelSizes = {
xs: 20,
sm: 24,
md: 36,
lg: 48,
xl: 64,
};
export function UserAvatar({
userId,
name,
email,
customAvatarUrl,
flagConfig,
avatarType,
size = "md",
className,
}: UserAvatarProps) {
const avatar = resolveUserAvatarData({ id: userId, customAvatarUrl, flagConfig, avatarType });
const label = name ?? email ?? "User avatar";
if (avatar.type === "image") {
return (
<img
src={cloudinaryAvatarUrl(avatar.url, pixelSizes[size])}
alt={label}
className={cn(sizeClasses[size], "object-cover shrink-0", className)}
/>
);
}
return (
<FlagSvg
config={avatar.config}
size={pixelSizes[size]}
title={label}
className={cn(sizeClasses[size], className)}
/>
);
}

View file

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { cloudinaryAvatarUrl } from "~/lib/cloudinary-url";
const BASE = "https://res.cloudinary.com/demo/image/upload/v1/avatars/user-1.jpg";
describe("cloudinaryAvatarUrl", () => {
it("inserts retina transformation into a Cloudinary URL", () => {
expect(cloudinaryAvatarUrl(BASE, 48)).toBe(
"https://res.cloudinary.com/demo/image/upload/w_96,h_96,c_fill,g_face,f_auto,q_auto/v1/avatars/user-1.jpg"
);
});
it("doubles the display size for retina", () => {
const result = cloudinaryAvatarUrl(BASE, 24);
expect(result).toContain("w_48,h_48");
});
it("passes non-Cloudinary URLs through unchanged", () => {
const url = "https://lh3.googleusercontent.com/photo.jpg";
expect(cloudinaryAvatarUrl(url, 48)).toBe(url);
});
it("passes null-ish strings through unchanged", () => {
expect(cloudinaryAvatarUrl("", 48)).toBe("");
});
it("is idempotent — calling twice returns the same URL", () => {
const once = cloudinaryAvatarUrl(BASE, 48);
expect(cloudinaryAvatarUrl(once, 48)).toBe(once);
});
});

View file

@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { cloudinaryPublicIdFromUrl } from "~/lib/cloudinary.server";
describe("cloudinaryPublicIdFromUrl", () => {
it("extracts public IDs from regular delivery URLs", () => {
expect(
cloudinaryPublicIdFromUrl("https://res.cloudinary.com/demo/image/upload/v1234567890/avatars/user-1.jpg")
).toBe("avatars/user-1");
});
it("extracts public IDs from transformed delivery URLs", () => {
expect(
cloudinaryPublicIdFromUrl("https://res.cloudinary.com/demo/image/upload/w_45,h_45,c_fill/v1234567890/avatars/team-2.png")
).toBe("avatars/team-2");
});
it("returns null for non-Cloudinary-like URLs", () => {
expect(cloudinaryPublicIdFromUrl("https://example.com/avatar.jpg")).toBeNull();
});
});

View file

@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { resolveTeamAvatarData, resolveUserAvatarData } from "~/lib/avatar-data";
import { generateFlagConfig } from "~/lib/flag-generator";
import { FLAG_PATTERNS, isFlagConfig } from "~/lib/flag-types";
describe("generateFlagConfig", () => {
it("generates the same flag for the same seed", () => {
expect(generateFlagConfig("user-1")).toEqual(generateFlagConfig("user-1"));
});
it("generates valid configs", () => {
const config = generateFlagConfig("team-9");
expect(FLAG_PATTERNS).toContain(config.pattern);
expect(config.colors).toHaveLength(3);
expect(isFlagConfig(config)).toBe(true);
});
});
describe("avatar data resolution", () => {
it("returns uploaded avatar when avatarType is uploaded", () => {
expect(resolveUserAvatarData({
id: "user-1",
avatarType: "uploaded",
customAvatarUrl: "https://example.com/avatar.jpg",
flagConfig: generateFlagConfig("user-1"),
})).toEqual({ type: "image", url: "https://example.com/avatar.jpg" });
});
it("returns flag when avatarType is uploaded but no customAvatarUrl", () => {
const avatar = resolveUserAvatarData({ id: "user-1", avatarType: "uploaded" });
expect(avatar.type).toBe("flag");
});
it("returns flag regardless of oauth imageUrl being present", () => {
const avatar = resolveUserAvatarData({ id: "user-2", avatarType: "flag" });
expect(avatar.type).toBe("flag");
if (avatar.type === "flag") {
expect(avatar.config).toEqual(generateFlagConfig("user-2"));
}
});
it("returns generated flag for legacy users with null avatarType", () => {
const avatar = resolveUserAvatarData({ id: "user-3", avatarType: null });
expect(avatar.type).toBe("flag");
});
it("falls back from teams to owner avatars", () => {
const ownerAvatar = { type: "image" as const, url: "https://example.com/owner.jpg" };
expect(resolveTeamAvatarData({ id: "team-1", avatarType: "owner" }, ownerAvatar)).toEqual(ownerAvatar);
});
it("prefers uploaded team logos over owner avatars", () => {
expect(resolveTeamAvatarData(
{ id: "team-1", avatarType: "uploaded", logoUrl: "https://example.com/team.jpg" },
{ type: "image", url: "https://example.com/owner.jpg" }
)).toEqual({ type: "image", url: "https://example.com/team.jpg" });
});
});

View file

@ -1,8 +1,10 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { eq } from "drizzle-orm";
import bcrypt from "bcrypt";
import { Resend } from "resend";
import { db } from "~/server/db";
import * as schema from "~/database/schema";
if (!process.env.BETTER_AUTH_SECRET) {
throw new Error("BETTER_AUTH_SECRET is required");
@ -18,11 +20,38 @@ const discordProvider = process.env.DISCORD_CLIENT_ID && process.env.DISCORD_CLI
? { discord: { clientId: process.env.DISCORD_CLIENT_ID, clientSecret: process.env.DISCORD_CLIENT_SECRET } }
: {};
const appBaseUrl = process.env.APP_URL ?? process.env.BETTER_AUTH_URL;
export const auth = betterAuth({
baseURL: appBaseUrl,
database: drizzleAdapter(db, {
provider: "pg",
usePlural: true,
}),
databaseHooks: {
user: {
update: {
before: async (data) => {
const incomingImage =
"imageUrl" in data ? data.imageUrl : "image" in data ? data.image : undefined;
const userId = typeof data.id === "string" ? data.id : null;
if (!incomingImage || !userId) return;
const existing = await db.query.users.findFirst({
where: eq(schema.users.id, userId),
});
if (existing?.avatarType === "uploaded" || existing?.avatarType === "flag") {
const next = { ...data };
delete next.imageUrl;
delete next.image;
return { data: next };
}
},
},
},
},
advanced: {
database: {
generateId: false,

48
app/lib/avatar-data.ts Normal file
View file

@ -0,0 +1,48 @@
import { generateFlagConfig } from "~/lib/flag-generator";
import {
parseFlagConfig,
type AvatarData,
type FlagConfig,
type RawFlagConfig,
} from "~/lib/flag-types";
interface UserAvatarSource {
id: string;
customAvatarUrl?: string | null;
avatarType?: string | null;
flagConfig?: RawFlagConfig | null;
}
interface TeamAvatarSource {
id: string;
logoUrl?: string | null;
avatarType?: string | null;
flagConfig?: RawFlagConfig | null;
}
export function getDisplayFlagConfig(id: string, flagConfig: RawFlagConfig | null | undefined): FlagConfig {
return parseFlagConfig(flagConfig) ?? generateFlagConfig(id);
}
export function resolveUserAvatarData(user: UserAvatarSource): AvatarData {
if (user.avatarType === "uploaded" && user.customAvatarUrl) {
return { type: "image", url: user.customAvatarUrl };
}
return { type: "flag", config: getDisplayFlagConfig(user.id, user.flagConfig) };
}
export function resolveTeamAvatarData(
team: TeamAvatarSource,
ownerAvatarData?: AvatarData | null
): AvatarData {
if (team.avatarType === "uploaded" && team.logoUrl) {
return { type: "image", url: team.logoUrl };
}
if (team.avatarType === "flag") {
return { type: "flag", config: getDisplayFlagConfig(team.id, team.flagConfig) };
}
return ownerAvatarData ?? { type: "flag", config: getDisplayFlagConfig(team.id, team.flagConfig) };
}

19
app/lib/cloudinary-url.ts Normal file
View file

@ -0,0 +1,19 @@
const CLOUDINARY_UPLOAD_SEGMENT = "/image/upload/";
function isCloudinaryUrl(url: string): boolean {
return url.includes("res.cloudinary.com") && url.includes(CLOUDINARY_UPLOAD_SEGMENT);
}
/**
* Returns a resized, format- and quality-optimized variant of a Cloudinary avatar URL.
* Doubles the requested pixel size for retina displays.
* Non-Cloudinary URLs are returned unchanged.
*/
export function cloudinaryAvatarUrl(url: string, displaySizePx: number): string {
if (!isCloudinaryUrl(url) || url.includes(",c_fill,")) return url;
const retinaSizePx = displaySizePx * 2;
const transform = `w_${retinaSizePx},h_${retinaSizePx},c_fill,g_face,f_auto,q_auto`;
return url.replace(CLOUDINARY_UPLOAD_SEGMENT, `${CLOUDINARY_UPLOAD_SEGMENT}${transform}/`);
}

View file

@ -0,0 +1,93 @@
import { v2 as cloudinary } from "cloudinary";
type AvatarTarget = "user" | "team";
interface UploadAvatarImageArgs {
buffer: Buffer;
contentType: string;
target: AvatarTarget;
entityId: string;
notificationUrl: string;
}
function requireCloudinaryConfig() {
const cloudName = process.env.CLOUDINARY_CLOUD_NAME;
const apiKey = process.env.CLOUDINARY_API_KEY;
const apiSecret = process.env.CLOUDINARY_API_SECRET;
if (!cloudName || !apiKey || !apiSecret) {
throw new Error("Cloudinary environment variables are not configured");
}
cloudinary.config({
cloud_name: cloudName,
api_key: apiKey,
api_secret: apiSecret,
secure: true,
});
}
function toDataUri(buffer: Buffer, contentType: string): string {
return `data:${contentType};base64,${buffer.toString("base64")}`;
}
export async function uploadAvatarImage({
buffer,
contentType,
target,
entityId,
notificationUrl,
}: UploadAvatarImageArgs) {
requireCloudinaryConfig();
return await cloudinary.uploader.upload(toDataUri(buffer, contentType), {
resource_type: "image",
folder: "avatars",
public_id: `${target}-${entityId}-${Date.now()}`,
overwrite: true,
moderation: "aws_rek",
notification_url: notificationUrl,
context: {
avatar_target: target,
entity_id: entityId,
},
transformation: [{ width: 256, height: 256, crop: "fill", gravity: "face" }],
});
}
export function cloudinaryPublicIdFromUrl(url: string | null | undefined): string | null {
if (!url) return null;
const match = url.match(/\/avatars\/([^/.]+)/);
return match ? `avatars/${match[1]}` : null;
}
export function buildWebhookUrl(request: Request): string {
const baseUrl = process.env.APP_URL ?? process.env.BETTER_AUTH_URL ?? new URL(request.url).origin;
return new URL("/api/webhooks/cloudinary", baseUrl).toString();
}
export async function deleteCloudinaryImageByUrl(url: string | null | undefined): Promise<void> {
const publicId = cloudinaryPublicIdFromUrl(url);
if (!publicId) return;
requireCloudinaryConfig();
await cloudinary.uploader.destroy(publicId, { resource_type: "image", invalidate: true });
}
export function verifyCloudinaryWebhookSignature({
body,
signature,
timestamp,
}: {
body: string;
signature: string | null;
timestamp: string | null;
}): boolean {
requireCloudinaryConfig();
if (!signature || !timestamp) return false;
const parsedTimestamp = Number(timestamp);
if (!Number.isFinite(parsedTimestamp)) return false;
return cloudinary.utils.verifyNotificationSignature(body, parsedTimestamp, signature);
}

28
app/lib/flag-generator.ts Normal file
View file

@ -0,0 +1,28 @@
import { AVATAR_COLORS, hashString } from "~/lib/avatar-colors";
import { FLAG_PATTERNS, type FlagConfig } from "~/lib/flag-types";
const EXTRA_COLORS = ["#ffffff", "#111827"];
const FLAG_COLORS = [...AVATAR_COLORS, ...EXTRA_COLORS];
function pickColor(seed: number, offset: number, used: Set<string>): string {
for (let i = 0; i < FLAG_COLORS.length; i++) {
const color = FLAG_COLORS[(seed + offset + i * 3) % FLAG_COLORS.length];
if (!used.has(color)) return color;
}
return FLAG_COLORS[(seed + offset) % FLAG_COLORS.length];
}
export function generateFlagConfig(seed: string): FlagConfig {
const hash = hashString(seed || "brackt");
const pattern = FLAG_PATTERNS[hash % FLAG_PATTERNS.length];
const used = new Set<string>();
const colors: string[] = [];
for (let i = 0; i < 3; i++) {
const color = pickColor(hash, i * 7 + Math.floor(hash / (i + 1)), used);
used.add(color);
colors.push(color);
}
return { pattern, colors };
}

52
app/lib/flag-types.ts Normal file
View file

@ -0,0 +1,52 @@
export type RawFlagConfig = { pattern: string; colors: string[] };
export const FLAG_PATTERNS = [
"triband-h",
"triband-v",
"diagonal",
"nordic-cross",
"cross",
"canton",
"triangle",
"chevron",
"quartered",
"bordered",
"circle",
"star",
] as const;
export type FlagPattern = (typeof FLAG_PATTERNS)[number];
export interface FlagConfig {
pattern: FlagPattern;
colors: string[];
}
export type UserAvatarType = "flag" | "uploaded";
export type TeamAvatarType = "owner" | "flag" | "uploaded";
export type AvatarData =
| { type: "image"; url: string }
| { type: "flag"; config: FlagConfig };
const FLAG_PATTERN_SET = new Set<string>(FLAG_PATTERNS);
const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
export function isFlagPattern(value: unknown): value is FlagPattern {
return typeof value === "string" && FLAG_PATTERN_SET.has(value);
}
export function isFlagConfig(value: unknown): value is FlagConfig {
if (!value || typeof value !== "object") return false;
const config = value as { pattern?: unknown; colors?: unknown };
return (
isFlagPattern(config.pattern) &&
Array.isArray(config.colors) &&
config.colors.length === 3 &&
config.colors.every((color) => typeof color === "string" && HEX_COLOR_RE.test(color))
);
}
export function parseFlagConfig(value: unknown): FlagConfig | null {
return isFlagConfig(value) ? value : null;
}

View file

@ -16,14 +16,15 @@ import { NavigationProgress } from "~/components/NavigationProgress";
import { Toaster } from "~/components/ui/sonner";
import { BracktGradients } from "~/components/ui/BracktGradients";
import { Footer } from "~/components/marketing/Footer";
import { isUserAdmin } from "~/models/user";
import { findUserById } from "~/models/user";
import { AlertCircle, FileQuestion, Lock, ShieldOff, ServerCrash } from "lucide-react";
import logoUrl from "../public/logo.svg?url";
export async function loader({ request }: Route.LoaderArgs) {
const session = await auth.api.getSession({ headers: request.headers });
const isAdmin = session ? await isUserAdmin(session.user.id) : false;
return { isAdmin };
const currentUser = session ? await findUserById(session.user.id) : null;
const isAdmin = currentUser?.isAdmin ?? false;
return { isAdmin, currentUser };
}
export const links: Route.LinksFunction = () => [
@ -70,7 +71,12 @@ export default function App({ loaderData }: Route.ComponentProps) {
return (
<>
<NavigationProgress />
{!isDraftRoute && <Navbar isAdmin={loaderData?.isAdmin ?? false} />}
{!isDraftRoute && (
<Navbar
isAdmin={loaderData?.isAdmin ?? false}
currentUser={loaderData?.currentUser ?? null}
/>
)}
{isDraftRoute ? (
<Outlet />
) : (
@ -178,4 +184,4 @@ function statusIcon(status: number) {
if (status === 404) return <FileQuestion className={cls} />;
if (status >= 500) return <ServerCrash className={cls} />;
return <AlertCircle className={cls} />;
}
}

View file

@ -50,6 +50,9 @@ export default [
route("api/autodraft/update", "routes/api/autodraft.update.ts"),
route("api/watchlist/toggle", "routes/api/watchlist.toggle.ts"),
route("api/user/timezone", "routes/api/user.timezone.ts"),
route("api/upload-avatar", "routes/api.upload-avatar.ts"),
route("api/upload-team-logo", "routes/api.upload-team-logo.ts"),
route("api/webhooks/cloudinary", "routes/api.webhooks.cloudinary.ts"),
route("api/seasons/:seasonId/draft", "routes/api/seasons.$seasonId.draft.ts"),
route("login", "routes/login.tsx"),
route("register", "routes/register.tsx"),

View file

@ -0,0 +1,195 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/lib/cloudinary.server", () => ({
verifyCloudinaryWebhookSignature: vi.fn(),
deleteCloudinaryImageByUrl: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("~/models/user", () => ({
findUserById: vi.fn(),
updateUser: vi.fn(),
}));
vi.mock("~/models/team", () => ({
findTeamById: vi.fn(),
updateTeam: vi.fn(),
}));
vi.mock("~/lib/logger", () => ({
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn() },
}));
import { action } from "../api.webhooks.cloudinary";
import {
verifyCloudinaryWebhookSignature,
deleteCloudinaryImageByUrl,
} from "~/lib/cloudinary.server";
import { findUserById, updateUser } from "~/models/user";
import { findTeamById, updateTeam } from "~/models/team";
function makeRequest(body: object) {
return new Request("http://test/api/webhooks/cloudinary", {
method: "POST",
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
"X-Cld-Signature": "sig",
"X-Cld-Timestamp": "1234567890",
},
});
}
function approvedUserPayload(overrides: object = {}) {
return {
secure_url: "https://res.cloudinary.com/demo/image/upload/v1/avatars/user-1.jpg",
moderation_status: "approved",
context: { avatar_target: "user", entity_id: "user-abc" },
...overrides,
};
}
function approvedTeamPayload(overrides: object = {}) {
return {
secure_url: "https://res.cloudinary.com/demo/image/upload/v1/avatars/team-1.jpg",
moderation_status: "approved",
context: { avatar_target: "team", entity_id: "team-xyz" },
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(verifyCloudinaryWebhookSignature).mockReturnValue(true);
vi.mocked(updateUser).mockResolvedValue({} as never);
vi.mocked(updateTeam).mockResolvedValue({} as never);
vi.mocked(findUserById).mockResolvedValue(null as never);
vi.mocked(findTeamById).mockResolvedValue(null as never);
});
describe("Cloudinary webhook — signature", () => {
it("returns 401 when signature is invalid", async () => {
vi.mocked(verifyCloudinaryWebhookSignature).mockReturnValue(false);
const res = await action({ request: makeRequest(approvedUserPayload()), params: {}, context: {} } as never);
expect(res.status).toBe(401);
});
});
describe("Cloudinary webhook — moderation", () => {
it("returns ignored when moderation_status is rejected", async () => {
const res = await action({
request: makeRequest({ ...approvedUserPayload(), moderation_status: "rejected" }),
params: {},
context: {},
} as never);
const body = await res.json();
expect(body).toEqual({ status: "ignored" });
});
it("returns ignored when moderation_status is pending", async () => {
const res = await action({
request: makeRequest({ ...approvedUserPayload(), moderation_status: "pending" }),
params: {},
context: {},
} as never);
const body = await res.json();
expect(body).toEqual({ status: "ignored" });
});
it("reads approval from nested moderation array", async () => {
vi.mocked(findUserById).mockResolvedValue({ customAvatarUrl: null } as never);
const payload = {
secure_url: "https://res.cloudinary.com/demo/image/upload/v1/avatars/user-1.jpg",
moderation: [{ status: "approved" }],
context: { avatar_target: "user", entity_id: "user-abc" },
};
const res = await action({ request: makeRequest(payload), params: {}, context: {} } as never);
const body = await res.json();
expect(body).toEqual({ status: "updated" });
});
});
describe("Cloudinary webhook — missing fields", () => {
it("returns 400 when secure_url is absent", async () => {
const res = await action({
request: makeRequest({ moderation_status: "approved", context: { avatar_target: "user", entity_id: "u1" } }),
params: {},
context: {},
} as never);
expect(res.status).toBe(400);
});
it("returns 400 when context is absent", async () => {
const res = await action({
request: makeRequest({ moderation_status: "approved", secure_url: "https://res.cloudinary.com/demo/image/upload/v1/avatars/x.jpg" }),
params: {},
context: {},
} as never);
expect(res.status).toBe(400);
});
it("returns 400 for unknown avatar_target", async () => {
const res = await action({
request: makeRequest({ ...approvedUserPayload(), context: { avatar_target: "league", entity_id: "l1" } }),
params: {},
context: {},
} as never);
expect(res.status).toBe(400);
});
});
describe("Cloudinary webhook — user update", () => {
it("sets customAvatarUrl and avatarType on the user", async () => {
vi.mocked(findUserById).mockResolvedValue({ customAvatarUrl: null } as never);
await action({ request: makeRequest(approvedUserPayload()), params: {}, context: {} } as never);
expect(updateUser).toHaveBeenCalledWith("user-abc", {
customAvatarUrl: "https://res.cloudinary.com/demo/image/upload/v1/avatars/user-1.jpg",
avatarType: "uploaded",
});
});
it("deletes the previous avatar when one exists", async () => {
const oldUrl = "https://res.cloudinary.com/demo/image/upload/v1/avatars/user-old.jpg";
vi.mocked(findUserById).mockResolvedValue({ customAvatarUrl: oldUrl } as never);
await action({ request: makeRequest(approvedUserPayload()), params: {}, context: {} } as never);
expect(deleteCloudinaryImageByUrl).toHaveBeenCalledWith(oldUrl);
});
it("does not call delete when there is no previous avatar", async () => {
vi.mocked(findUserById).mockResolvedValue({ customAvatarUrl: null } as never);
await action({ request: makeRequest(approvedUserPayload()), params: {}, context: {} } as never);
expect(deleteCloudinaryImageByUrl).not.toHaveBeenCalled();
});
it("does not delete when previous URL matches new URL", async () => {
const url = "https://res.cloudinary.com/demo/image/upload/v1/avatars/user-1.jpg";
vi.mocked(findUserById).mockResolvedValue({ customAvatarUrl: url } as never);
await action({ request: makeRequest(approvedUserPayload({ secure_url: url })), params: {}, context: {} } as never);
expect(deleteCloudinaryImageByUrl).not.toHaveBeenCalled();
});
});
describe("Cloudinary webhook — team update", () => {
it("sets logoUrl and avatarType on the team", async () => {
vi.mocked(findTeamById).mockResolvedValue({ logoUrl: null } as never);
await action({ request: makeRequest(approvedTeamPayload()), params: {}, context: {} } as never);
expect(updateTeam).toHaveBeenCalledWith("team-xyz", {
logoUrl: "https://res.cloudinary.com/demo/image/upload/v1/avatars/team-1.jpg",
avatarType: "uploaded",
});
});
it("deletes the previous team logo when one exists", async () => {
const oldUrl = "https://res.cloudinary.com/demo/image/upload/v1/avatars/team-old.jpg";
vi.mocked(findTeamById).mockResolvedValue({ logoUrl: oldUrl } as never);
await action({ request: makeRequest(approvedTeamPayload()), params: {}, context: {} } as never);
expect(deleteCloudinaryImageByUrl).toHaveBeenCalledWith(oldUrl);
});
it("also handles context in pipe-separated string format", async () => {
vi.mocked(findUserById).mockResolvedValue({ customAvatarUrl: null } as never);
const payload = {
secure_url: "https://res.cloudinary.com/demo/image/upload/v1/avatars/user-1.jpg",
moderation_status: "approved",
context: "avatar_target=user|entity_id=user-abc",
};
await action({ request: makeRequest(payload), params: {}, context: {} } as never);
expect(updateUser).toHaveBeenCalledWith("user-abc", expect.objectContaining({ avatarType: "uploaded" }));
});
});

View file

@ -0,0 +1,45 @@
import { auth } from "~/lib/auth.server";
import { buildWebhookUrl, uploadAvatarImage } from "~/lib/cloudinary.server";
import { logger } from "~/lib/logger";
import type { Route } from "./+types/api.upload-avatar";
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
const ACCEPTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
export async function action({ request }: Route.ActionArgs) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get("file");
if (!(file instanceof File)) {
return Response.json({ error: "Image file is required" }, { status: 400 });
}
if (!ACCEPTED_IMAGE_TYPES.has(file.type)) {
return Response.json({ error: "Unsupported image type" }, { status: 400 });
}
if (file.size > MAX_UPLOAD_BYTES) {
return Response.json({ error: "Image must be under 5 MB" }, { status: 400 });
}
try {
const buffer = Buffer.from(await file.arrayBuffer());
await uploadAvatarImage({
buffer,
contentType: file.type,
target: "user",
entityId: session.user.id,
notificationUrl: buildWebhookUrl(request),
});
return Response.json({ status: "pending" });
} catch (error) {
logger.error("Avatar upload failed:", error);
return Response.json({ error: "Upload failed" }, { status: 500 });
}
}

View file

@ -0,0 +1,60 @@
import { auth } from "~/lib/auth.server";
import { buildWebhookUrl, uploadAvatarImage } from "~/lib/cloudinary.server";
import { logger } from "~/lib/logger";
import { findTeamById } from "~/models/team";
import type { Route } from "./+types/api.upload-team-logo";
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
const ACCEPTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
export async function action({ request }: Route.ActionArgs) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await request.formData();
const teamId = formData.get("teamId");
const file = formData.get("file");
if (typeof teamId !== "string" || !teamId) {
return Response.json({ error: "Team ID is required" }, { status: 400 });
}
const team = await findTeamById(teamId);
if (!team) {
return Response.json({ error: "Team not found" }, { status: 404 });
}
if (team.ownerId !== session.user.id) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
if (!(file instanceof File)) {
return Response.json({ error: "Image file is required" }, { status: 400 });
}
if (!ACCEPTED_IMAGE_TYPES.has(file.type)) {
return Response.json({ error: "Unsupported image type" }, { status: 400 });
}
if (file.size > MAX_UPLOAD_BYTES) {
return Response.json({ error: "Image must be under 5 MB" }, { status: 400 });
}
try {
const buffer = Buffer.from(await file.arrayBuffer());
await uploadAvatarImage({
buffer,
contentType: file.type,
target: "team",
entityId: team.id,
notificationUrl: buildWebhookUrl(request),
});
return Response.json({ status: "pending" });
} catch (error) {
logger.error("Team avatar upload failed:", error);
return Response.json({ error: "Upload failed" }, { status: 500 });
}
}

View file

@ -0,0 +1,109 @@
import {
deleteCloudinaryImageByUrl,
verifyCloudinaryWebhookSignature,
} from "~/lib/cloudinary.server";
import { logger } from "~/lib/logger";
import { findTeamById, updateTeam } from "~/models/team";
import { findUserById, updateUser } from "~/models/user";
import type { Route } from "./+types/api.webhooks.cloudinary";
type CloudinaryContext = Record<string, unknown> | string | undefined;
interface CloudinaryWebhookPayload {
secure_url?: string;
moderation?: Array<{ status?: string }>;
moderation_status?: string;
info?: {
moderation?: { status?: string } | Array<{ status?: string }>;
};
context?: CloudinaryContext | { custom?: Record<string, unknown> };
}
function getContextValue(context: CloudinaryWebhookPayload["context"], key: string): string | null {
if (!context) return null;
if (typeof context === "string") {
const pairs = context.split("|").map((part) => part.split("="));
const found = pairs.find(([pairKey]) => pairKey === key);
return found?.[1] ?? null;
}
const custom = "custom" in context && context.custom && typeof context.custom === "object"
? context.custom
: context;
const value = (custom as Record<string, unknown>)[key];
return typeof value === "string" ? value : null;
}
function moderationStatus(payload: CloudinaryWebhookPayload): string | null {
const direct = payload.moderation?.[0]?.status ?? payload.moderation_status;
if (direct) return direct;
const infoModeration = payload.info?.moderation;
if (Array.isArray(infoModeration)) return infoModeration[0]?.status ?? null;
return infoModeration?.status ?? null;
}
export async function action({ request }: Route.ActionArgs) {
const body = await request.text();
const signature = request.headers.get("X-Cld-Signature");
const timestamp = request.headers.get("X-Cld-Timestamp");
if (!verifyCloudinaryWebhookSignature({ body, signature, timestamp })) {
return Response.json({ error: "Invalid signature" }, { status: 401 });
}
let payload: CloudinaryWebhookPayload;
try {
payload = JSON.parse(body) as CloudinaryWebhookPayload;
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const status = moderationStatus(payload);
if (status !== "approved") {
logger.info("Cloudinary avatar not approved:", { status });
return Response.json({ status: "ignored" });
}
if (!payload.secure_url) {
return Response.json({ error: "Missing secure_url" }, { status: 400 });
}
const target = getContextValue(payload.context, "avatar_target");
const entityId = getContextValue(payload.context, "entity_id");
if (!target || !entityId) {
return Response.json({ error: "Missing avatar context" }, { status: 400 });
}
if (target === "user") {
const existingUser = await findUserById(entityId);
await updateUser(entityId, {
customAvatarUrl: payload.secure_url,
avatarType: "uploaded",
});
if (existingUser?.customAvatarUrl && existingUser.customAvatarUrl !== payload.secure_url) {
deleteCloudinaryImageByUrl(existingUser.customAvatarUrl).catch((error) => {
logger.error("Failed to delete replaced user avatar from Cloudinary:", error);
});
}
return Response.json({ status: "updated" });
}
if (target === "team") {
const existingTeam = await findTeamById(entityId);
await updateTeam(entityId, {
logoUrl: payload.secure_url,
avatarType: "uploaded",
});
if (existingTeam?.logoUrl && existingTeam.logoUrl !== payload.secure_url) {
deleteCloudinaryImageByUrl(existingTeam.logoUrl).catch((error) => {
logger.error("Failed to delete replaced team avatar from Cloudinary:", error);
});
}
return Response.json({ status: "updated" });
}
return Response.json({ error: "Unknown avatar target" }, { status: 400 });
}

View file

@ -15,6 +15,8 @@ const createMockTeam = (overrides: {
seasonId: overrides.seasonId || 'season-1',
ownerId: overrides.ownerId,
logoUrl: null,
flagConfig: null,
avatarType: 'owner',
draftPosition: overrides.draftPosition || 1,
createdAt: new Date(),
updatedAt: new Date(),
@ -190,6 +192,9 @@ describe('Team Management - Admin User List', () => {
firstName: 'User',
lastName: 'One',
imageUrl: null,
flagConfig: null,
customAvatarUrl: null,
avatarType: 'flag',
isAdmin: false,
timezone: null,
createdAt: new Date(),
@ -205,6 +210,9 @@ describe('Team Management - Admin User List', () => {
firstName: 'User',
lastName: 'Two',
imageUrl: null,
flagConfig: null,
customAvatarUrl: null,
avatarType: 'flag',
isAdmin: false,
timezone: null,
createdAt: new Date(),
@ -241,6 +249,9 @@ describe('Team Management - Admin User List', () => {
firstName: 'User',
lastName: 'One',
imageUrl: null,
flagConfig: null,
customAvatarUrl: null,
avatarType: 'flag',
isAdmin: false,
timezone: null,
createdAt: new Date(),

View file

@ -8,6 +8,8 @@ import {
removeTeamOwner,
} from "~/models/team";
import { findSeasonById } from "~/models/season";
import { findUserById } from "~/models/user";
import { resolveUserAvatarData } from "~/lib/avatar-data";
import { Button } from "~/components/ui/button";
import {
Card,
@ -18,6 +20,10 @@ import {
} from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { AvatarEditor } from "~/components/ui/AvatarEditor";
import { parseFlagConfig } from "~/lib/flag-types";
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
import { logger } from "~/lib/logger";
import {
AlertDialog,
AlertDialogAction,
@ -62,7 +68,10 @@ export async function loader(args: Route.LoaderArgs) {
throw new Response("Season not found", { status: 404 });
}
return { team, season };
const owner = await findUserById(userId);
const ownerAvatarData = owner ? resolveUserAvatarData(owner) : null;
return { team, season, ownerAvatarData };
}
export async function action(args: Route.ActionArgs) {
@ -89,9 +98,58 @@ export async function action(args: Route.ActionArgs) {
const formData = await 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 updateTeam(teamId, {
flagConfig,
avatarType: "flag",
});
return { success: true };
}
if (intent === "remove-avatar-photo") {
await updateTeam(teamId, {
logoUrl: null,
avatarType: "owner",
});
if (team.logoUrl) {
deleteCloudinaryImageByUrl(team.logoUrl).catch((error) => {
logger.error("Failed to delete team avatar from Cloudinary:", error);
});
}
return { success: true };
}
if (intent === "use-owner-avatar") {
await updateTeam(teamId, { avatarType: "owner", flagConfig: null });
if (team.avatarType === "uploaded" && team.logoUrl) {
deleteCloudinaryImageByUrl(team.logoUrl).catch((error) => {
logger.error("Failed to delete replaced team avatar from Cloudinary:", error);
});
}
return { success: true };
}
if (intent === "update") {
const name = formData.get("name");
const logoUrl = formData.get("logoUrl");
if (!name || typeof name !== "string") {
return { error: "Team name is required" };
@ -99,7 +157,6 @@ export async function action(args: Route.ActionArgs) {
await updateTeam(teamId, {
name: name.trim(),
logoUrl: logoUrl && typeof logoUrl === "string" ? logoUrl.trim() : undefined,
});
await syncPrivateBracktParticipants(team.seasonId);
@ -115,7 +172,7 @@ export async function action(args: Route.ActionArgs) {
return { error: "Invalid action" };
}
export default function TeamSettings({ loaderData }: Route.ComponentProps) {
export default function TeamSettings({ loaderData, actionData }: Route.ComponentProps) {
const { team, season } = loaderData;
const navigate = useNavigate();
@ -138,11 +195,38 @@ export default function TeamSettings({ loaderData }: Route.ComponentProps) {
</div>
<div className="space-y-6">
{actionData && "success" in actionData && actionData.success && (
<p className="text-sm text-[#adf661]">Team settings updated.</p>
)}
{actionData && "error" in actionData && actionData.error && (
<p className="text-sm text-destructive">{actionData.error}</p>
)}
<Card>
<CardHeader>
<CardTitle>Team Avatar</CardTitle>
<CardDescription>
Update the flag or submit a photo for review
</CardDescription>
</CardHeader>
<CardContent>
<AvatarEditor
id={team.id}
isTeam
currentAvatarType={team.avatarType}
flagConfig={team.flagConfig}
uploadedPhotoUrl={team.avatarType === "uploaded" ? team.logoUrl : null}
uploadUrl="/api/upload-team-logo"
ownerAvatarData={loaderData.ownerAvatarData}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Team Information</CardTitle>
<CardDescription>
Update your team name and logo
Update your team name
</CardDescription>
</CardHeader>
<CardContent>
@ -161,21 +245,6 @@ export default function TeamSettings({ loaderData }: Route.ComponentProps) {
/>
</div>
<div className="space-y-2">
<Label htmlFor="logoUrl">Team Logo URL (optional)</Label>
<Input
id="logoUrl"
name="logoUrl"
type="url"
defaultValue={team.logoUrl || ""}
placeholder="https://example.com/logo.png"
maxLength={512}
/>
<p className="text-xs text-muted-foreground">
Enter a URL to an image for your team logo
</p>
</div>
<Button type="submit">Save Changes</Button>
</Form>
</CardContent>

View file

@ -8,6 +8,10 @@ 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))
@ -36,7 +40,54 @@ export async function action(args: Route.ActionArgs) {
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;
@ -85,6 +136,17 @@ export default function UserProfilePage({ loaderData, actionData }: Route.Compon
{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>

View file

@ -11,6 +11,9 @@ export const users = pgTable("users", {
firstName: varchar("first_name", { length: 255 }),
lastName: varchar("last_name", { length: 255 }),
imageUrl: varchar("image_url", { length: 512 }), // mapped as BetterAuth's "image" field
flagConfig: jsonb("flag_config").$type<{ pattern: string; colors: string[] }>(),
customAvatarUrl: varchar("custom_avatar_url", { length: 512 }),
avatarType: varchar("avatar_type", { length: 20 }).notNull().default("flag"),
isAdmin: boolean("is_admin").notNull().default(false),
timezone: varchar("timezone", { length: 50 }), // IANA timezone string, e.g. "America/Chicago"
createdAt: timestamp("created_at").defaultNow().notNull(),
@ -228,6 +231,8 @@ export const teams = pgTable("teams", {
.references(() => seasons.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
logoUrl: varchar("logo_url", { length: 512 }),
flagConfig: jsonb("flag_config").$type<{ pattern: string; colors: string[] }>(),
avatarType: varchar("avatar_type", { length: 20 }).notNull().default("owner"),
ownerId: varchar("owner_id", { length: 255 }), // UUID → users.id, nullable
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),

View file

@ -72,7 +72,7 @@ await requireLeagueAccess(args, { leagueId, seasonId, db });
| `/forgot-password` | Request a password reset email |
| `/reset-password` | Enter new password via reset token (link sent by email via Resend) |
Password reset emails are sent via Resend from `noreply@brackt.com`. The reset link points to `/reset-password?token=…` using `BETTER_AUTH_URL` as the base.
Password reset emails are sent via Resend from `noreply@brackt.com`. The reset link points to `/reset-password?token=…` using `APP_URL` as the base.
## OAuth Providers
@ -89,6 +89,6 @@ Account linking is enabled — users who sign in with OAuth get their accounts l
```
BETTER_AUTH_SECRET # required — random secret for signing sessions
BETTER_AUTH_URL # required — app base URL, e.g. https://brackt.com
APP_URL # required — app base URL, e.g. https://brackt.com
RESEND_API_KEY # required for password reset emails
```

View file

@ -0,0 +1,5 @@
ALTER TABLE "teams" ADD COLUMN "flag_config" jsonb;--> statement-breakpoint
ALTER TABLE "teams" ADD COLUMN "avatar_type" varchar(20) DEFAULT 'owner' NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "flag_config" jsonb;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "custom_avatar_url" varchar(512);--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "avatar_type" varchar(20) DEFAULT 'flag' NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -666,6 +666,13 @@
"when": 1777920441672,
"tag": "0094_curved_nick_fury",
"breakpoints": true
},
{
"idx": 95,
"version": "7",
"when": 1778043585280,
"tag": "0095_lucky_madrox",
"breakpoints": true
}
]
}

30
package-lock.json generated
View file

@ -32,6 +32,7 @@
"bcrypt": "^6.0.0",
"better-auth": "^1.6.9",
"class-variance-authority": "^0.7.1",
"cloudinary": "^2.10.0",
"clsx": "^2.1.1",
"compression": "^1.8.0",
"date-fns": "^4.1.0",
@ -46,6 +47,7 @@
"react": "^19.1.0",
"react-day-picker": "^9.11.1",
"react-dom": "^19.1.0",
"react-image-crop": "^11.0.10",
"react-is": "^19.2.5",
"react-router": "^7.7.1",
"recharts": "^3.4.1",
@ -8690,6 +8692,18 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/cloudinary": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.10.0.tgz",
"integrity": "sha512-sY09kYg7wprkndAOjZBAYqFZqwL+SxnEGcAvksOvFA+5upnFn949UjkEkHKNSwkBtW/xRDd0p6NgbSXZcxkI3w==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.23"
},
"engines": {
"node": ">=9"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -12614,10 +12628,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true,
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.once": {
@ -14506,6 +14519,15 @@
"react": "^19.2.4"
}
},
"node_modules/react-image-crop": {
"version": "11.0.10",
"resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.0.10.tgz",
"integrity": "sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==",
"license": "ISC",
"peerDependencies": {
"react": ">=16.13.1"
}
},
"node_modules/react-inspector": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-9.0.0.tgz",

View file

@ -54,6 +54,7 @@
"bcrypt": "^6.0.0",
"better-auth": "^1.6.9",
"class-variance-authority": "^0.7.1",
"cloudinary": "^2.10.0",
"clsx": "^2.1.1",
"compression": "^1.8.0",
"date-fns": "^4.1.0",
@ -68,6 +69,7 @@
"react": "^19.1.0",
"react-day-picker": "^9.11.1",
"react-dom": "^19.1.0",
"react-image-crop": "^11.0.10",
"react-is": "^19.2.5",
"react-router": "^7.7.1",
"recharts": "^3.4.1",

270
plans/avatar-system.md Normal file
View file

@ -0,0 +1,270 @@
# Avatar System: Flag Builder + Photo Upload
## Context
Users and teams currently show initials in a colored box when no avatar is set. OAuth users get their Google/Discord photo in `users.imageUrl`, but there's no custom upload path and no procedurally generated identity. This plan adds:
1. **Flag avatars** — SVG flags (12 patterns, 23 colors) as the default identity for every user and team, editable via a builder UI.
2. **Photo upload** — Cloudinary-hosted images with Rekognition NSFW moderation before display.
3. **Hierarchy** — teams inherit their owner's avatar by default; users and teams can independently override.
---
## Architecture Decisions
- **Image hosting**: Cloudinary. Store at 256×256, serve at 45×45 or 18×18 via URL-based transformations (e.g. `w_45,h_45,c_fill`). Transformations are generated once per unique image+size then CDN-cached; cost is ~2 credits/1k new uploads. At ~2 KB average delivered size, the free tier (25 GB bandwidth) covers ~80,000 active users. NSFW moderation via the AWS Rekognition add-on. Images are never shown until the webhook approves them.
- **Flag library**: None exists. Build a custom `FlagSvg` component (~300 lines). Pure SVG, no dependencies, SSR-safe.
- **DB**: Two new JSONB/varchar columns each on `users` and `teams`; keep existing `imageUrl` / `logoUrl` untouched.
- **OAuth protection**: BetterAuth's `updateUser` hook will be patched to skip writing `imageUrl` once a user has set a custom avatar.
---
## DB Schema Changes
**`database/schema.ts`** — add to `users`:
```ts
flagConfig: jsonb("flag_config"), // {pattern: string, colors: string[]}
customAvatarUrl: varchar("custom_avatar_url", { length: 512 }), // Cloudinary URL (post-approval)
avatarType: varchar("avatar_type", { length: 20 }).notNull().default("flag"),
// avatarType: 'flag' | 'uploaded'
```
Add to `teams`:
```ts
flagConfig: jsonb("flag_config"),
avatarType: varchar("avatar_type", { length: 20 }).notNull().default("owner"),
// avatarType: 'owner' | 'flag' | 'uploaded'
// existing logoUrl reused for uploaded team images
```
Run `npm run db:generate` then `npm run db:migrate`.
---
## Avatar Display Hierarchy
```
getUserAvatarData(user):
if avatarType === 'uploaded' && customAvatarUrl → {type: 'image', url: customAvatarUrl}
if flagConfig → {type: 'flag', config: flagConfig}
if imageUrl → {type: 'image', url: imageUrl} // OAuth fallback
→ {type: 'initials'}
getTeamAvatarData(team, owner):
if avatarType === 'uploaded' && logoUrl → {type: 'image', url: logoUrl}
if avatarType === 'flag' && flagConfig → {type: 'flag', config: flagConfig}
→ getUserAvatarData(owner) // 'owner' default + final fallback
```
---
## Phases
### Phase 1 — Flag SVG Engine
**New files:**
- `app/lib/flag-types.ts``FlagPattern` union type, `FlagConfig` interface
- `app/components/ui/FlagSvg.tsx` — renders `<svg>` from `{pattern, colors, size?}`
- `app/lib/flag-generator.ts``generateFlagConfig(seed: string): FlagConfig` (deterministic from hash)
**12 patterns** (pure SVG geometry, all square viewport):
| Pattern | SVG approach |
|---|---|
| `triband-h` | 3 stacked `<rect>` |
| `triband-v` | 3 side-by-side `<rect>` |
| `diagonal` | `<rect>` bg + `<polygon>` triangle |
| `nordic-cross` | bg + two crossing `<rect>` strips, cross offset left |
| `cross` | bg + two centered `<rect>` strips |
| `canton` | bg + `<rect>` overlay top-left quarter |
| `triangle` | bg + `<polygon>` left-pointing triangle |
| `chevron` | bg + `<polygon>` V pointing right |
| `quartered` | 4 `<rect>` in 2×2 |
| `bordered` | bg `<rect>` + inset `<rect>` with gap |
| `circle` | bg + `<circle>` center |
| `star` | bg + `<polygon>` 5-point star |
`generateFlagConfig` picks pattern + 2-3 colors deterministically using `hashString` from `app/lib/avatar-colors.ts` (reuse existing). Colors drawn from `AVATAR_COLORS` palette + white/black.
**Tests:** `app/lib/__tests__/flag-generator.test.ts`
---
### Phase 2 — Schema Migration + Backfill
1. Add columns to schema (`database/schema.ts`)
2. `npm run db:generate``npm run db:migrate`
3. Update model functions:
- `app/models/user.ts` — add `flagConfig`, `customAvatarUrl`, `avatarType` to select/update types
- `app/models/team.ts` — add `flagConfig`, `avatarType`
4. Backfill script: `scripts/backfill-flag-configs.ts`
- Queries all users/teams where `flagConfig IS NULL`
- Calls `generateFlagConfig(id)` for each
- Batch-updates. Run once manually: `npx tsx scripts/backfill-flag-configs.ts`
---
### Phase 3 — Avatar Display Components
**Modified files:**
- `app/components/TeamAvatar.tsx` — accept `flagConfig?` prop; render `<FlagSvg>` when present instead of initials
- `app/components/UserMenu.tsx` — use `customAvatarUrl ?? flagConfig ?? imageUrl` priority
- **New:** `app/components/ui/UserAvatar.tsx` — mirrors `TeamAvatar` for user contexts
`TeamAvatar` new props:
```ts
interface TeamAvatarProps {
teamId: string;
teamName: string;
logoUrl?: string | null;
flagConfig?: FlagConfig | null;
avatarType?: 'owner' | 'flag' | 'uploaded';
ownerAvatarData?: AvatarData; // resolved by parent from owner user
size?: 'sm' | 'md' | 'lg';
}
```
Add `xl` size (`h-16 w-16`) to both components for the editor preview.
---
### Phase 4 — Flag Builder UI
**New files:**
- `app/components/ui/FlagBuilder.tsx` — interactive editor
- Grid of 12 pattern thumbnails (4-col, using `<FlagSvg>` at ~60×60px)
- Selected pattern highlighted with brackt green border
- Color slot pickers: each slot shows the 6 brackt palette swatches + a custom hex `<input>`
- Live preview (large `<FlagSvg>` at ~180×180px)
- `onChange(config: FlagConfig)` callback
- `app/components/ui/AvatarEditor.tsx` — tab shell: "Flag" | "Upload Photo"
- Flag tab: `<FlagBuilder>`
- Upload tab: `<AvatarUploader>` (built in Phase 5)
- Save button calls appropriate action
**Modified:** `app/routes/user-profile.tsx`
- Add `AvatarEditor` section above existing profile fields
- New action intent `"update-avatar-flag"` — saves `flagConfig` + `avatarType: 'flag'`
- Update `app/models/user.ts` `updateUser` to accept these new fields
---
### Phase 5 — Cloudinary Photo Upload
**Setup:**
- `npm install cloudinary react-image-crop`
- Env vars: `CLOUDINARY_CLOUD_NAME`, `CLOUDINARY_API_KEY`, `CLOUDINARY_API_SECRET`, `CLOUDINARY_WEBHOOK_SECRET`
- One-time: enable AWS Rekognition add-on in Cloudinary dashboard; enable "block pending" mode via Cloudinary support ticket
**New server files:**
- `app/lib/cloudinary.server.ts` — Cloudinary SDK init + `uploadAvatarImage(buffer, userId)` helper
```ts
cloudinary.uploader.upload(dataUri, {
folder: 'avatars',
moderation: 'aws_rek',
context: `user_id=${userId}`,
// Store at 256×256; serve via URL transforms: w_45,h_45,c_fill or w_18,h_18,c_fill
transformation: [{ width: 256, height: 256, crop: 'fill', gravity: 'face' }],
})
```
- `app/routes/api.upload-avatar.ts` — POST multipart handler
- Auth required; max 5 MB; image types only
- Returns `{ status: 'pending' }`
- Registers in `app/routes.ts`
- `app/routes/api.upload-team-logo.ts` — same pattern but for teams, checks team ownership
- `app/routes/api.webhooks.cloudinary.ts` — POST webhook
- Validates `X-Cld-Signature` header
- On `approved`: reads `context.user_id` or `context.team_id`, updates `customAvatarUrl`/`logoUrl` + `avatarType: 'uploaded'`
- On `rejected`: no-op (log only; user keeps prior avatar)
- Register in `app/routes.ts`
**New client file:**
- `app/components/ui/AvatarUploader.tsx`
- Uses `react-image-crop` for square crop
- Previews crop before submit
- POST to `/api/upload-avatar` or `/api/upload-team-logo`
- Shows "Photo submitted for review — it'll appear within a few seconds" after submit
**OAuth protection** — `app/lib/auth.server.ts`:
```ts
// In BetterAuth user hooks, add:
databaseHooks: {
user: {
update: {
before: async (data) => {
if (data.image) {
const existing = await db.query.users.findFirst({ where: eq(users.id, data.id) });
if (existing?.avatarType === 'uploaded' || existing?.avatarType === 'flag') {
const { image, ...rest } = data; // drop the OAuth image update
return { data: rest };
}
}
return { data };
}
}
}
}
```
---
### Phase 6 — Team Avatar in League Settings
- Locate league settings route (recently refactored into per-section components — likely `app/routes/leagues/$leagueId.settings.tsx` or similar)
- Add a "Team Avatar" section (card) for each team the logged-in user owns in the league
- Reuse `<AvatarEditor>` with `teamId` prop instead of `userId`
- New action intent: `"update-team-avatar-flag"` and `"update-team-avatar-type"`
- `app/models/team.ts`: `updateTeam` accepts `flagConfig`, `avatarType`
---
## New Files Summary
| File | Purpose |
|---|---|
| `app/lib/flag-types.ts` | FlagPattern type, FlagConfig interface |
| `app/lib/flag-generator.ts` | Deterministic flag config from seed |
| `app/components/ui/FlagSvg.tsx` | SVG renderer |
| `app/components/ui/FlagBuilder.tsx` | Interactive editor |
| `app/components/ui/AvatarEditor.tsx` | Tab shell (Flag / Upload) |
| `app/components/ui/AvatarUploader.tsx` | Crop + upload UI |
| `app/components/ui/UserAvatar.tsx` | User avatar display component |
| `app/lib/cloudinary.server.ts` | Cloudinary SDK wrapper |
| `app/routes/api.upload-avatar.ts` | Upload endpoint (users) |
| `app/routes/api.upload-team-logo.ts` | Upload endpoint (teams) |
| `app/routes/api.webhooks.cloudinary.ts` | Cloudinary moderation webhook |
| `scripts/backfill-flag-configs.ts` | One-time backfill script |
| `app/lib/__tests__/flag-generator.test.ts` | Unit tests |
## Modified Files Summary
| File | Change |
|---|---|
| `database/schema.ts` | Add flagConfig/avatarType/customAvatarUrl to users; flagConfig/avatarType to teams |
| `app/models/user.ts` | New fields in types + updateUser |
| `app/models/team.ts` | New fields in types + updateTeam |
| `app/components/TeamAvatar.tsx` | Support flagConfig + ownerAvatarData |
| `app/components/UserMenu.tsx` | Use new avatar priority logic |
| `app/routes/user-profile.tsx` | Add AvatarEditor + new action intents |
| `app/lib/auth.server.ts` | BetterAuth databaseHook to protect imageUrl |
| `app/routes.ts` | Register 3 new API routes |
| League settings route | Add team avatar editing section |
---
## Verification
- **Phase 1**: `npm run test:run` passes flag-generator tests; `FlagSvg` renders all 12 patterns in browser
- **Phase 2**: `npm run typecheck` passes; backfill script runs without error; DB rows have flagConfig populated
- **Phase 3**: All avatar displays in the app (UserMenu, TeamAvatar in standings, draft room) show flags
- **Phase 4**: Flag builder is interactive; saving updates the DB; flag appears in UserMenu immediately
- **Phase 5**: Upload a test image → see "pending" message → Cloudinary webhook fires → avatar updates. Upload an explicit image → it should be rejected and not appear.
- **Phase 6**: League member can change their team's avatar from league settings
---
## Open Questions for Implementation
1. **Cloudinary account**: Need `CLOUD_NAME`, `API_KEY`, `API_SECRET`, and to enable the AWS Rekognition add-on + "block-pending" mode.
2. **League settings route path**: Confirm the exact file before Phase 6.
3. **Flag aspect ratio in builder UI**: `FlagSvg` will use `viewBox="0 0 100 100"` for square output. The builder preview will also be square.

View file

@ -0,0 +1,38 @@
import { eq, isNull } from "drizzle-orm";
import { db } from "~/server/db";
import * as schema from "~/database/schema";
import { generateFlagConfig } from "~/lib/flag-generator";
async function main() {
const users = await db.query.users.findMany({
where: isNull(schema.users.flagConfig),
});
const teams = await db.query.teams.findMany({
where: isNull(schema.teams.flagConfig),
});
for (const user of users) {
await db
.update(schema.users)
.set({ flagConfig: generateFlagConfig(user.id), updatedAt: new Date() })
.where(eq(schema.users.id, user.id));
}
for (const team of teams) {
await db
.update(schema.teams)
.set({ flagConfig: generateFlagConfig(team.id), updatedAt: new Date() })
.where(eq(schema.teams.id, team.id));
}
console.log(`Backfilled ${users.length} users and ${teams.length} teams.`);
}
main()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error(error);
process.exit(1);
});