Add Cloudinary-backed avatar system (#385)
This commit is contained in:
parent
05fe1493a3
commit
a447ba54de
44 changed files with 8111 additions and 62 deletions
11
.env.example
11
.env.example
|
|
@ -8,7 +8,9 @@ DATABASE_URL=""
|
||||||
PGBOUNCER=
|
PGBOUNCER=
|
||||||
DATABASE_DIRECT_URL=
|
DATABASE_DIRECT_URL=
|
||||||
BETTER_AUTH_SECRET=""
|
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_ID=""
|
||||||
GOOGLE_CLIENT_SECRET=""
|
GOOGLE_CLIENT_SECRET=""
|
||||||
DISCORD_CLIENT_ID=""
|
DISCORD_CLIENT_ID=""
|
||||||
|
|
@ -18,4 +20,9 @@ SENTRY_AUTH_TOKEN=""
|
||||||
SQUIGGLE_CONTACT_EMAIL=""
|
SQUIGGLE_CONTACT_EMAIL=""
|
||||||
RESEND_API_KEY=""
|
RESEND_API_KEY=""
|
||||||
TURNSTILE_SITE_KEY=1x00000000000000000000AA
|
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=""
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
import { DraftPickCell, type CoronaState } from "~/components/draft/DraftPickCell";
|
import { DraftPickCell, type CoronaState } from "~/components/draft/DraftPickCell";
|
||||||
import { TeamAvatar } from "~/components/TeamAvatar";
|
import { TeamAvatar } from "~/components/TeamAvatar";
|
||||||
import type { SeasonStatus } from "~/models/season";
|
import type { SeasonStatus } from "~/models/season";
|
||||||
|
import type { RawFlagConfig } from "~/lib/flag-types";
|
||||||
|
|
||||||
interface DraftCell {
|
interface DraftCell {
|
||||||
pickNumber: number;
|
pickNumber: number;
|
||||||
|
|
@ -23,6 +24,8 @@ interface DraftGridProps {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
logoUrl?: string | null;
|
logoUrl?: string | null;
|
||||||
|
flagConfig?: RawFlagConfig | null;
|
||||||
|
avatarType?: string | null;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
draftGrid: Array<Array<DraftCell | null>>;
|
draftGrid: Array<Array<DraftCell | null>>;
|
||||||
|
|
@ -79,6 +82,8 @@ export function DraftGrid({
|
||||||
teamId={slot.team.id}
|
teamId={slot.team.id}
|
||||||
teamName={ownerMap[slot.team.id] || slot.team.name}
|
teamName={ownerMap[slot.team.id] || slot.team.name}
|
||||||
logoUrl={slot.team.logoUrl}
|
logoUrl={slot.team.logoUrl}
|
||||||
|
flagConfig={slot.team.flagConfig}
|
||||||
|
avatarType={slot.team.avatarType}
|
||||||
size="lg"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
export const SingleWordName: Story = {
|
||||||
args: {
|
args: {
|
||||||
teamId: "team-3",
|
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 = {
|
export const AllSizes: Story = {
|
||||||
name: "All Sizes — Side by Side",
|
name: "All Sizes — Side by Side",
|
||||||
render: () => (
|
render: () => (
|
||||||
|
|
@ -66,6 +98,10 @@ export const AllSizes: Story = {
|
||||||
<TeamAvatar teamId="team-1" teamName="Thunder Hawks" size="lg" />
|
<TeamAvatar teamId="team-1" teamName="Thunder Hawks" size="lg" />
|
||||||
<span className="text-xs text-muted-foreground">lg</span>
|
<span className="text-xs text-muted-foreground">lg</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,67 @@
|
||||||
import { avatarColor } from "~/lib/avatar-colors";
|
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;
|
teamId: string;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
logoUrl?: string | null;
|
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 = {
|
const sizeClasses = {
|
||||||
sm: "h-6 w-6 text-[10px]",
|
sm: "h-6 w-6 text-[10px]",
|
||||||
md: "h-9 w-9 text-sm",
|
md: "h-9 w-9 text-sm",
|
||||||
lg: "h-12 w-12 text-base",
|
lg: "h-12 w-12 text-base",
|
||||||
|
xl: "h-16 w-16 text-xl",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TeamAvatar({ teamId, teamName, logoUrl, size = "md" }: TeamAvatarProps) {
|
const pixelSizes = {
|
||||||
if (logoUrl) {
|
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 (
|
return (
|
||||||
<img
|
<img
|
||||||
src={logoUrl}
|
src={cloudinaryAvatarUrl(avatar.url, pixelSizes[size])}
|
||||||
alt={teamName}
|
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
|
<div
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={teamName}
|
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 }}
|
style={{ backgroundColor: "#000", color }}
|
||||||
>
|
>
|
||||||
{initials}
|
{initials}
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,28 @@ import { Link, useNavigate } from "react-router";
|
||||||
import { authClient } from "~/lib/auth-client";
|
import { authClient } from "~/lib/auth-client";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { UserAvatar } from "~/components/ui/UserAvatar";
|
||||||
|
import type { RawFlagConfig } from "~/lib/flag-types";
|
||||||
|
|
||||||
interface UserMenuProps {
|
interface UserMenuProps {
|
||||||
|
userId: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
email: string;
|
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 navigate = useNavigate();
|
||||||
|
|
||||||
const initials = name
|
|
||||||
? name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2)
|
|
||||||
: email.slice(0, 2).toUpperCase();
|
|
||||||
|
|
||||||
async function handleSignOut() {
|
async function handleSignOut() {
|
||||||
await authClient.signOut();
|
await authClient.signOut();
|
||||||
navigate("/");
|
navigate("/");
|
||||||
|
|
@ -24,14 +32,15 @@ export function UserMenu({ name, email, imageUrl }: UserMenuProps) {
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="ghost" className="relative h-9 w-9 rounded-full p-0 overflow-hidden">
|
<Button variant="ghost" className="relative h-9 w-9 p-0 overflow-hidden">
|
||||||
{imageUrl ? (
|
<UserAvatar
|
||||||
<img src={imageUrl} alt={name ?? email} className="h-9 w-9 rounded-full object-cover" />
|
userId={userId}
|
||||||
) : (
|
name={name}
|
||||||
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-muted text-sm font-medium">
|
email={email}
|
||||||
{initials}
|
customAvatarUrl={customAvatarUrl}
|
||||||
</span>
|
flagConfig={flagConfig}
|
||||||
)}
|
avatarType={avatarType}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent align="end" className="w-48 p-1">
|
<PopoverContent align="end" className="w-48 p-1">
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,8 @@ describe('DraftGrid Component', () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText('Team 1')).toBeInTheDocument();
|
expect(screen.getByText('Team 1', { selector: 'span' })).toBeInTheDocument();
|
||||||
expect(screen.getByText('Team 2')).toBeInTheDocument();
|
expect(screen.getByText('Team 2', { selector: 'span' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render draft grid with correct structure', () => {
|
it('should render draft grid with correct structure', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { memo, useMemo, useState } from "react";
|
import { memo, useMemo, useState } from "react";
|
||||||
import { TeamAvatar } from "~/components/TeamAvatar";
|
import { TeamAvatar } from "~/components/TeamAvatar";
|
||||||
|
import type { RawFlagConfig } from "~/lib/flag-types";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
|
|
@ -31,6 +32,8 @@ interface DraftGridSectionProps {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
logoUrl?: string | null;
|
logoUrl?: string | null;
|
||||||
|
flagConfig?: RawFlagConfig | null;
|
||||||
|
avatarType?: string | null;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
draftGrid: Array<
|
draftGrid: Array<
|
||||||
|
|
@ -116,6 +119,8 @@ export const DraftGridSection = memo(function DraftGridSection({
|
||||||
teamId={slot.team.id}
|
teamId={slot.team.id}
|
||||||
teamName={ownerMap[slot.team.id] || slot.team.name}
|
teamName={ownerMap[slot.team.id] || slot.team.name}
|
||||||
logoUrl={slot.team.logoUrl}
|
logoUrl={slot.team.logoUrl}
|
||||||
|
flagConfig={slot.team.flagConfig}
|
||||||
|
avatarType={slot.team.avatarType}
|
||||||
size="md"
|
size="md"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,19 @@
|
||||||
import { memo, useMemo, Fragment } from "react";
|
import { memo, useMemo, Fragment } from "react";
|
||||||
import { TeamAvatar } from "~/components/TeamAvatar";
|
import { TeamAvatar } from "~/components/TeamAvatar";
|
||||||
import { type DraftPick, groupPicksByTeamAndSport } from "./picks-utils";
|
import { type DraftPick, groupPicksByTeamAndSport } from "./picks-utils";
|
||||||
|
import type { RawFlagConfig } from "~/lib/flag-types";
|
||||||
|
|
||||||
interface DraftSummaryViewProps {
|
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>;
|
ownerMap?: Record<string, string>;
|
||||||
picks: DraftPick[];
|
picks: DraftPick[];
|
||||||
sports: Array<{ id: string; name: string }>;
|
sports: Array<{ id: string; name: string }>;
|
||||||
|
|
@ -87,6 +97,8 @@ export const DraftSummaryView = memo(function DraftSummaryView({
|
||||||
teamId={slot.team.id}
|
teamId={slot.team.id}
|
||||||
teamName={ownerName || slot.team.name}
|
teamName={ownerName || slot.team.name}
|
||||||
logoUrl={slot.team.logoUrl}
|
logoUrl={slot.team.logoUrl}
|
||||||
|
flagConfig={slot.team.flagConfig}
|
||||||
|
avatarType={slot.team.avatarType}
|
||||||
size="md"
|
size="md"
|
||||||
/>
|
/>
|
||||||
<div className="text-xs font-semibold text-center truncate w-full">
|
<div className="text-xs font-semibold text-center truncate w-full">
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { authClient } from "~/lib/auth-client";
|
import { authClient } from "~/lib/auth-client";
|
||||||
import { UserMenu } from "~/components/UserMenu";
|
import { UserMenu } from "~/components/UserMenu";
|
||||||
|
import type { RawFlagConfig } from "~/lib/flag-types";
|
||||||
import { HelpCircle, MenuIcon, Settings } from "lucide-react";
|
import { HelpCircle, MenuIcon, Settings } from "lucide-react";
|
||||||
|
|
||||||
const NAV_LINK_CLASS =
|
const NAV_LINK_CLASS =
|
||||||
|
|
@ -27,20 +28,42 @@ function mobileNavLinkClass({ isActive }: { isActive: boolean }) {
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
isAdmin: boolean;
|
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 [open, setOpen] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { data: session } = authClient.useSession();
|
const { data: session } = authClient.useSession();
|
||||||
|
|
||||||
const signInHref = `/login?redirectTo=${encodeURIComponent(location.pathname)}`;
|
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
|
<UserMenu
|
||||||
name={session.user.name ?? null}
|
userId={menuUser.id}
|
||||||
email={session.user.email}
|
name={menuUser.username ?? menuUser.displayName}
|
||||||
imageUrl={session.user.image ?? null}
|
email={menuUser.email}
|
||||||
|
customAvatarUrl={menuUser.customAvatarUrl}
|
||||||
|
flagConfig={menuUser.flagConfig}
|
||||||
|
avatarType={menuUser.avatarType}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Link to={signInHref}>
|
<Link to={signInHref}>
|
||||||
|
|
|
||||||
136
app/components/ui/AvatarEditor.tsx
Normal file
136
app/components/ui/AvatarEditor.tsx
Normal 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;
|
||||||
|
}
|
||||||
262
app/components/ui/AvatarUploader.tsx
Normal file
262
app/components/ui/AvatarUploader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
app/components/ui/FlagBuilder.stories.tsx
Normal file
23
app/components/ui/FlagBuilder.stories.tsx
Normal 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 />,
|
||||||
|
};
|
||||||
183
app/components/ui/FlagBuilder.tsx
Normal file
183
app/components/ui/FlagBuilder.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
app/components/ui/FlagSvg.stories.tsx
Normal file
29
app/components/ui/FlagSvg.stories.tsx
Normal 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>
|
||||||
|
),
|
||||||
|
};
|
||||||
127
app/components/ui/FlagSvg.tsx
Normal file
127
app/components/ui/FlagSvg.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
app/components/ui/UserAvatar.stories.tsx
Normal file
43
app/components/ui/UserAvatar.stories.tsx
Normal 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>
|
||||||
|
),
|
||||||
|
};
|
||||||
65
app/components/ui/UserAvatar.tsx
Normal file
65
app/components/ui/UserAvatar.tsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
app/lib/__tests__/cloudinary-url.test.ts
Normal file
31
app/lib/__tests__/cloudinary-url.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
20
app/lib/__tests__/cloudinary.server.test.ts
Normal file
20
app/lib/__tests__/cloudinary.server.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
58
app/lib/__tests__/flag-generator.test.ts
Normal file
58
app/lib/__tests__/flag-generator.test.ts
Normal 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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
|
import * as schema from "~/database/schema";
|
||||||
|
|
||||||
if (!process.env.BETTER_AUTH_SECRET) {
|
if (!process.env.BETTER_AUTH_SECRET) {
|
||||||
throw new Error("BETTER_AUTH_SECRET is required");
|
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 } }
|
? { 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({
|
export const auth = betterAuth({
|
||||||
|
baseURL: appBaseUrl,
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
provider: "pg",
|
provider: "pg",
|
||||||
usePlural: true,
|
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: {
|
advanced: {
|
||||||
database: {
|
database: {
|
||||||
generateId: false,
|
generateId: false,
|
||||||
|
|
|
||||||
48
app/lib/avatar-data.ts
Normal file
48
app/lib/avatar-data.ts
Normal 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
19
app/lib/cloudinary-url.ts
Normal 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}/`);
|
||||||
|
}
|
||||||
93
app/lib/cloudinary.server.ts
Normal file
93
app/lib/cloudinary.server.ts
Normal 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
28
app/lib/flag-generator.ts
Normal 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
52
app/lib/flag-types.ts
Normal 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;
|
||||||
|
}
|
||||||
16
app/root.tsx
16
app/root.tsx
|
|
@ -16,14 +16,15 @@ import { NavigationProgress } from "~/components/NavigationProgress";
|
||||||
import { Toaster } from "~/components/ui/sonner";
|
import { Toaster } from "~/components/ui/sonner";
|
||||||
import { BracktGradients } from "~/components/ui/BracktGradients";
|
import { BracktGradients } from "~/components/ui/BracktGradients";
|
||||||
import { Footer } from "~/components/marketing/Footer";
|
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 { AlertCircle, FileQuestion, Lock, ShieldOff, ServerCrash } from "lucide-react";
|
||||||
import logoUrl from "../public/logo.svg?url";
|
import logoUrl from "../public/logo.svg?url";
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const session = await auth.api.getSession({ headers: request.headers });
|
const session = await auth.api.getSession({ headers: request.headers });
|
||||||
const isAdmin = session ? await isUserAdmin(session.user.id) : false;
|
const currentUser = session ? await findUserById(session.user.id) : null;
|
||||||
return { isAdmin };
|
const isAdmin = currentUser?.isAdmin ?? false;
|
||||||
|
return { isAdmin, currentUser };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const links: Route.LinksFunction = () => [
|
export const links: Route.LinksFunction = () => [
|
||||||
|
|
@ -70,7 +71,12 @@ export default function App({ loaderData }: Route.ComponentProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NavigationProgress />
|
<NavigationProgress />
|
||||||
{!isDraftRoute && <Navbar isAdmin={loaderData?.isAdmin ?? false} />}
|
{!isDraftRoute && (
|
||||||
|
<Navbar
|
||||||
|
isAdmin={loaderData?.isAdmin ?? false}
|
||||||
|
currentUser={loaderData?.currentUser ?? null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isDraftRoute ? (
|
{isDraftRoute ? (
|
||||||
<Outlet />
|
<Outlet />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -178,4 +184,4 @@ function statusIcon(status: number) {
|
||||||
if (status === 404) return <FileQuestion className={cls} />;
|
if (status === 404) return <FileQuestion className={cls} />;
|
||||||
if (status >= 500) return <ServerCrash className={cls} />;
|
if (status >= 500) return <ServerCrash className={cls} />;
|
||||||
return <AlertCircle className={cls} />;
|
return <AlertCircle className={cls} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,9 @@ export default [
|
||||||
route("api/autodraft/update", "routes/api/autodraft.update.ts"),
|
route("api/autodraft/update", "routes/api/autodraft.update.ts"),
|
||||||
route("api/watchlist/toggle", "routes/api/watchlist.toggle.ts"),
|
route("api/watchlist/toggle", "routes/api/watchlist.toggle.ts"),
|
||||||
route("api/user/timezone", "routes/api/user.timezone.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("api/seasons/:seasonId/draft", "routes/api/seasons.$seasonId.draft.ts"),
|
||||||
route("login", "routes/login.tsx"),
|
route("login", "routes/login.tsx"),
|
||||||
route("register", "routes/register.tsx"),
|
route("register", "routes/register.tsx"),
|
||||||
|
|
|
||||||
195
app/routes/__tests__/api.webhooks.cloudinary.test.ts
Normal file
195
app/routes/__tests__/api.webhooks.cloudinary.test.ts
Normal 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" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
45
app/routes/api.upload-avatar.ts
Normal file
45
app/routes/api.upload-avatar.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/routes/api.upload-team-logo.ts
Normal file
60
app/routes/api.upload-team-logo.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
109
app/routes/api.webhooks.cloudinary.ts
Normal file
109
app/routes/api.webhooks.cloudinary.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,8 @@ const createMockTeam = (overrides: {
|
||||||
seasonId: overrides.seasonId || 'season-1',
|
seasonId: overrides.seasonId || 'season-1',
|
||||||
ownerId: overrides.ownerId,
|
ownerId: overrides.ownerId,
|
||||||
logoUrl: null,
|
logoUrl: null,
|
||||||
|
flagConfig: null,
|
||||||
|
avatarType: 'owner',
|
||||||
draftPosition: overrides.draftPosition || 1,
|
draftPosition: overrides.draftPosition || 1,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
|
@ -190,6 +192,9 @@ describe('Team Management - Admin User List', () => {
|
||||||
firstName: 'User',
|
firstName: 'User',
|
||||||
lastName: 'One',
|
lastName: 'One',
|
||||||
imageUrl: null,
|
imageUrl: null,
|
||||||
|
flagConfig: null,
|
||||||
|
customAvatarUrl: null,
|
||||||
|
avatarType: 'flag',
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
timezone: null,
|
timezone: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
|
@ -205,6 +210,9 @@ describe('Team Management - Admin User List', () => {
|
||||||
firstName: 'User',
|
firstName: 'User',
|
||||||
lastName: 'Two',
|
lastName: 'Two',
|
||||||
imageUrl: null,
|
imageUrl: null,
|
||||||
|
flagConfig: null,
|
||||||
|
customAvatarUrl: null,
|
||||||
|
avatarType: 'flag',
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
timezone: null,
|
timezone: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
|
@ -241,6 +249,9 @@ describe('Team Management - Admin User List', () => {
|
||||||
firstName: 'User',
|
firstName: 'User',
|
||||||
lastName: 'One',
|
lastName: 'One',
|
||||||
imageUrl: null,
|
imageUrl: null,
|
||||||
|
flagConfig: null,
|
||||||
|
customAvatarUrl: null,
|
||||||
|
avatarType: 'flag',
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
timezone: null,
|
timezone: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import {
|
||||||
removeTeamOwner,
|
removeTeamOwner,
|
||||||
} from "~/models/team";
|
} from "~/models/team";
|
||||||
import { findSeasonById } from "~/models/season";
|
import { findSeasonById } from "~/models/season";
|
||||||
|
import { findUserById } from "~/models/user";
|
||||||
|
import { resolveUserAvatarData } from "~/lib/avatar-data";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -18,6 +20,10 @@ import {
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
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 {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -62,7 +68,10 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
throw new Response("Season not found", { status: 404 });
|
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) {
|
export async function action(args: Route.ActionArgs) {
|
||||||
|
|
@ -89,9 +98,58 @@ export async function action(args: Route.ActionArgs) {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const intent = formData.get("intent");
|
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") {
|
if (intent === "update") {
|
||||||
const name = formData.get("name");
|
const name = formData.get("name");
|
||||||
const logoUrl = formData.get("logoUrl");
|
|
||||||
|
|
||||||
if (!name || typeof name !== "string") {
|
if (!name || typeof name !== "string") {
|
||||||
return { error: "Team name is required" };
|
return { error: "Team name is required" };
|
||||||
|
|
@ -99,7 +157,6 @@ export async function action(args: Route.ActionArgs) {
|
||||||
|
|
||||||
await updateTeam(teamId, {
|
await updateTeam(teamId, {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
logoUrl: logoUrl && typeof logoUrl === "string" ? logoUrl.trim() : undefined,
|
|
||||||
});
|
});
|
||||||
await syncPrivateBracktParticipants(team.seasonId);
|
await syncPrivateBracktParticipants(team.seasonId);
|
||||||
|
|
||||||
|
|
@ -115,7 +172,7 @@ export async function action(args: Route.ActionArgs) {
|
||||||
return { error: "Invalid action" };
|
return { error: "Invalid action" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TeamSettings({ loaderData }: Route.ComponentProps) {
|
export default function TeamSettings({ loaderData, actionData }: Route.ComponentProps) {
|
||||||
const { team, season } = loaderData;
|
const { team, season } = loaderData;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
|
@ -138,11 +195,38 @@ export default function TeamSettings({ loaderData }: Route.ComponentProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Team Information</CardTitle>
|
<CardTitle>Team Information</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Update your team name and logo
|
Update your team name
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
@ -161,21 +245,6 @@ export default function TeamSettings({ loaderData }: Route.ComponentProps) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<Button type="submit">Save Changes</Button>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ import { Button } from "~/components/ui/button";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { TimezoneSelect, TIMEZONE_OPTIONS } from "~/components/league/TimezoneSelect";
|
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(
|
const VALID_TIMEZONES = new Set(
|
||||||
TIMEZONE_OPTIONS.flatMap((g) => g.zones.map((z) => z.value))
|
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");
|
return redirect("/login?redirectTo=/user-profile");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const user = await findUserById(session.user.id);
|
||||||
|
if (!user) {
|
||||||
|
return redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
const formData = await args.request.formData();
|
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 displayName = formData.get("displayName") as string;
|
||||||
const username = formData.get("username") as string | null;
|
const username = formData.get("username") as string | null;
|
||||||
const firstName = formData.get("firstName") 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 && (
|
{actionData?.success && (
|
||||||
<p className="text-sm text-green-500 mb-4">Profile updated successfully.</p>
|
<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">
|
<Form method="post" className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="displayName">Display Name</Label>
|
<Label htmlFor="displayName">Display Name</Label>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ export const users = pgTable("users", {
|
||||||
firstName: varchar("first_name", { length: 255 }),
|
firstName: varchar("first_name", { length: 255 }),
|
||||||
lastName: varchar("last_name", { length: 255 }),
|
lastName: varchar("last_name", { length: 255 }),
|
||||||
imageUrl: varchar("image_url", { length: 512 }), // mapped as BetterAuth's "image" field
|
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),
|
isAdmin: boolean("is_admin").notNull().default(false),
|
||||||
timezone: varchar("timezone", { length: 50 }), // IANA timezone string, e.g. "America/Chicago"
|
timezone: varchar("timezone", { length: 50 }), // IANA timezone string, e.g. "America/Chicago"
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
|
@ -228,6 +231,8 @@ export const teams = pgTable("teams", {
|
||||||
.references(() => seasons.id, { onDelete: "cascade" }),
|
.references(() => seasons.id, { onDelete: "cascade" }),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
logoUrl: varchar("logo_url", { length: 512 }),
|
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
|
ownerId: varchar("owner_id", { length: 255 }), // UUID → users.id, nullable
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ await requireLeagueAccess(args, { leagueId, seasonId, db });
|
||||||
| `/forgot-password` | Request a password reset email |
|
| `/forgot-password` | Request a password reset email |
|
||||||
| `/reset-password` | Enter new password via reset token (link sent by email via Resend) |
|
| `/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
|
## 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_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
|
RESEND_API_KEY # required for password reset emails
|
||||||
```
|
```
|
||||||
|
|
|
||||||
5
drizzle/0095_lucky_madrox.sql
Normal file
5
drizzle/0095_lucky_madrox.sql
Normal 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;
|
||||||
5755
drizzle/meta/0095_snapshot.json
Normal file
5755
drizzle/meta/0095_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -666,6 +666,13 @@
|
||||||
"when": 1777920441672,
|
"when": 1777920441672,
|
||||||
"tag": "0094_curved_nick_fury",
|
"tag": "0094_curved_nick_fury",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 95,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778043585280,
|
||||||
|
"tag": "0095_lucky_madrox",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
30
package-lock.json
generated
30
package-lock.json
generated
|
|
@ -32,6 +32,7 @@
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-auth": "^1.6.9",
|
"better-auth": "^1.6.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"cloudinary": "^2.10.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"compression": "^1.8.0",
|
"compression": "^1.8.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
|
@ -46,6 +47,7 @@
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "^9.11.1",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-image-crop": "^11.0.10",
|
||||||
"react-is": "^19.2.5",
|
"react-is": "^19.2.5",
|
||||||
"react-router": "^7.7.1",
|
"react-router": "^7.7.1",
|
||||||
"recharts": "^3.4.1",
|
"recharts": "^3.4.1",
|
||||||
|
|
@ -8690,6 +8692,18 @@
|
||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"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": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
|
@ -12614,10 +12628,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.once": {
|
"node_modules/lodash.once": {
|
||||||
|
|
@ -14506,6 +14519,15 @@
|
||||||
"react": "^19.2.4"
|
"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": {
|
"node_modules/react-inspector": {
|
||||||
"version": "9.0.0",
|
"version": "9.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-9.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-9.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-auth": "^1.6.9",
|
"better-auth": "^1.6.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"cloudinary": "^2.10.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"compression": "^1.8.0",
|
"compression": "^1.8.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
|
@ -68,6 +69,7 @@
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "^9.11.1",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-image-crop": "^11.0.10",
|
||||||
"react-is": "^19.2.5",
|
"react-is": "^19.2.5",
|
||||||
"react-router": "^7.7.1",
|
"react-router": "^7.7.1",
|
||||||
"recharts": "^3.4.1",
|
"recharts": "^3.4.1",
|
||||||
|
|
|
||||||
270
plans/avatar-system.md
Normal file
270
plans/avatar-system.md
Normal 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, 2–3 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.
|
||||||
38
scripts/backfill-flag-configs.ts
Normal file
38
scripts/backfill-flag-configs.ts
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue