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=
|
||||
DATABASE_DIRECT_URL=
|
||||
BETTER_AUTH_SECRET=""
|
||||
BETTER_AUTH_URL=""
|
||||
# Canonical public app URL used by auth links, OAuth callbacks, and Cloudinary webhook callbacks.
|
||||
# Examples: http://localhost:5173, https://brackt.com
|
||||
APP_URL=""
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GOOGLE_CLIENT_SECRET=""
|
||||
DISCORD_CLIENT_ID=""
|
||||
|
|
@ -18,4 +20,9 @@ SENTRY_AUTH_TOKEN=""
|
|||
SQUIGGLE_CONTACT_EMAIL=""
|
||||
RESEND_API_KEY=""
|
||||
TURNSTILE_SITE_KEY=1x00000000000000000000AA
|
||||
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
|
||||
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
|
||||
|
||||
# Cloudinary avatar uploads. API secret must stay server-only.
|
||||
CLOUDINARY_CLOUD_NAME=""
|
||||
CLOUDINARY_API_KEY=""
|
||||
CLOUDINARY_API_SECRET=""
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
import { DraftPickCell, type CoronaState } from "~/components/draft/DraftPickCell";
|
||||
import { TeamAvatar } from "~/components/TeamAvatar";
|
||||
import type { SeasonStatus } from "~/models/season";
|
||||
import type { RawFlagConfig } from "~/lib/flag-types";
|
||||
|
||||
interface DraftCell {
|
||||
pickNumber: number;
|
||||
|
|
@ -23,6 +24,8 @@ interface DraftGridProps {
|
|||
id: string;
|
||||
name: string;
|
||||
logoUrl?: string | null;
|
||||
flagConfig?: RawFlagConfig | null;
|
||||
avatarType?: string | null;
|
||||
};
|
||||
}>;
|
||||
draftGrid: Array<Array<DraftCell | null>>;
|
||||
|
|
@ -79,6 +82,8 @@ export function DraftGrid({
|
|||
teamId={slot.team.id}
|
||||
teamName={ownerMap[slot.team.id] || slot.team.name}
|
||||
logoUrl={slot.team.logoUrl}
|
||||
flagConfig={slot.team.flagConfig}
|
||||
avatarType={slot.team.avatarType}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -43,6 +43,15 @@ export const WithLogo: Story = {
|
|||
},
|
||||
};
|
||||
|
||||
export const WithFlag: Story = {
|
||||
args: {
|
||||
teamId: "team-4",
|
||||
teamName: "Flag Club",
|
||||
avatarType: "flag",
|
||||
flagConfig: { pattern: "nordic-cross", colors: ["#adf661", "#111827", "#ffffff"] },
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleWordName: Story = {
|
||||
args: {
|
||||
teamId: "team-3",
|
||||
|
|
@ -50,6 +59,29 @@ export const SingleWordName: Story = {
|
|||
},
|
||||
};
|
||||
|
||||
export const InheritingOwnerImage: Story = {
|
||||
name: "Inheriting Owner Image (avatarType: owner)",
|
||||
args: {
|
||||
teamId: "team-5",
|
||||
teamName: "Inherited Squad",
|
||||
avatarType: "owner",
|
||||
ownerAvatarData: { type: "image", url: "https://placehold.co/48x48/1a1a2e/adf661?text=OW" },
|
||||
},
|
||||
};
|
||||
|
||||
export const InheritingOwnerFlag: Story = {
|
||||
name: "Inheriting Owner Flag (avatarType: owner)",
|
||||
args: {
|
||||
teamId: "team-6",
|
||||
teamName: "Flag Inheritors",
|
||||
avatarType: "owner",
|
||||
ownerAvatarData: {
|
||||
type: "flag",
|
||||
config: { pattern: "diagonal", colors: ["#adf661", "#111827", "#ffffff"] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const AllSizes: Story = {
|
||||
name: "All Sizes — Side by Side",
|
||||
render: () => (
|
||||
|
|
@ -66,6 +98,10 @@ export const AllSizes: Story = {
|
|||
<TeamAvatar teamId="team-1" teamName="Thunder Hawks" size="lg" />
|
||||
<span className="text-xs text-muted-foreground">lg</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<TeamAvatar teamId="team-1" teamName="Thunder Hawks" size="xl" />
|
||||
<span className="text-xs text-muted-foreground">xl</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,25 +1,67 @@
|
|||
import { avatarColor } from "~/lib/avatar-colors";
|
||||
import { resolveTeamAvatarData } from "~/lib/avatar-data";
|
||||
import type { AvatarData, RawFlagConfig } from "~/lib/flag-types";
|
||||
import { FlagSvg } from "~/components/ui/FlagSvg";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { cloudinaryAvatarUrl } from "~/lib/cloudinary-url";
|
||||
|
||||
interface TeamAvatarProps {
|
||||
export interface TeamAvatarProps {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
logoUrl?: string | null;
|
||||
size?: "sm" | "md" | "lg";
|
||||
flagConfig?: RawFlagConfig | null;
|
||||
avatarType?: string | null;
|
||||
ownerAvatarData?: AvatarData | null;
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "h-6 w-6 text-[10px]",
|
||||
md: "h-9 w-9 text-sm",
|
||||
lg: "h-12 w-12 text-base",
|
||||
xl: "h-16 w-16 text-xl",
|
||||
};
|
||||
|
||||
export function TeamAvatar({ teamId, teamName, logoUrl, size = "md" }: TeamAvatarProps) {
|
||||
if (logoUrl) {
|
||||
const pixelSizes = {
|
||||
sm: 24,
|
||||
md: 36,
|
||||
lg: 48,
|
||||
xl: 64,
|
||||
};
|
||||
|
||||
export function TeamAvatar({
|
||||
teamId,
|
||||
teamName,
|
||||
logoUrl,
|
||||
flagConfig,
|
||||
avatarType,
|
||||
ownerAvatarData,
|
||||
size = "md",
|
||||
className,
|
||||
}: TeamAvatarProps) {
|
||||
const avatar = resolveTeamAvatarData(
|
||||
{ id: teamId, logoUrl, flagConfig, avatarType },
|
||||
ownerAvatarData
|
||||
);
|
||||
|
||||
if (avatar.type === "image") {
|
||||
return (
|
||||
<img
|
||||
src={logoUrl}
|
||||
src={cloudinaryAvatarUrl(avatar.url, pixelSizes[size])}
|
||||
alt={teamName}
|
||||
className={`${sizeClasses[size]} object-cover shrink-0`}
|
||||
className={cn(sizeClasses[size], "object-cover shrink-0", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (avatar.type === "flag") {
|
||||
return (
|
||||
<FlagSvg
|
||||
config={avatar.config}
|
||||
size={pixelSizes[size]}
|
||||
title={teamName}
|
||||
className={cn(sizeClasses[size], className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -37,7 +79,7 @@ export function TeamAvatar({ teamId, teamName, logoUrl, size = "md" }: TeamAvata
|
|||
<div
|
||||
role="img"
|
||||
aria-label={teamName}
|
||||
className={`${sizeClasses[size]} flex shrink-0 items-center justify-center font-bold`}
|
||||
className={cn(sizeClasses[size], "flex shrink-0 items-center justify-center font-bold", className)}
|
||||
style={{ backgroundColor: "#000", color }}
|
||||
>
|
||||
{initials}
|
||||
|
|
|
|||
|
|
@ -2,20 +2,28 @@ import { Link, useNavigate } from "react-router";
|
|||
import { authClient } from "~/lib/auth-client";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { UserAvatar } from "~/components/ui/UserAvatar";
|
||||
import type { RawFlagConfig } from "~/lib/flag-types";
|
||||
|
||||
interface UserMenuProps {
|
||||
userId: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
imageUrl: string | null;
|
||||
customAvatarUrl?: string | null;
|
||||
flagConfig?: RawFlagConfig | null;
|
||||
avatarType?: string | null;
|
||||
}
|
||||
|
||||
export function UserMenu({ name, email, imageUrl }: UserMenuProps) {
|
||||
export function UserMenu({
|
||||
userId,
|
||||
name,
|
||||
email,
|
||||
customAvatarUrl,
|
||||
flagConfig,
|
||||
avatarType,
|
||||
}: UserMenuProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const initials = name
|
||||
? name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2)
|
||||
: email.slice(0, 2).toUpperCase();
|
||||
|
||||
async function handleSignOut() {
|
||||
await authClient.signOut();
|
||||
navigate("/");
|
||||
|
|
@ -24,14 +32,15 @@ export function UserMenu({ name, email, imageUrl }: UserMenuProps) {
|
|||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-9 w-9 rounded-full p-0 overflow-hidden">
|
||||
{imageUrl ? (
|
||||
<img src={imageUrl} alt={name ?? email} className="h-9 w-9 rounded-full object-cover" />
|
||||
) : (
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-muted text-sm font-medium">
|
||||
{initials}
|
||||
</span>
|
||||
)}
|
||||
<Button variant="ghost" className="relative h-9 w-9 p-0 overflow-hidden">
|
||||
<UserAvatar
|
||||
userId={userId}
|
||||
name={name}
|
||||
email={email}
|
||||
customAvatarUrl={customAvatarUrl}
|
||||
flagConfig={flagConfig}
|
||||
avatarType={avatarType}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-48 p-1">
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ describe('DraftGrid Component', () => {
|
|||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Team 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Team 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Team 1', { selector: 'span' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Team 2', { selector: 'span' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render draft grid with correct structure', () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { memo, useMemo, useState } from "react";
|
||||
import { TeamAvatar } from "~/components/TeamAvatar";
|
||||
import type { RawFlagConfig } from "~/lib/flag-types";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
|
|
@ -31,6 +32,8 @@ interface DraftGridSectionProps {
|
|||
id: string;
|
||||
name: string;
|
||||
logoUrl?: string | null;
|
||||
flagConfig?: RawFlagConfig | null;
|
||||
avatarType?: string | null;
|
||||
};
|
||||
}>;
|
||||
draftGrid: Array<
|
||||
|
|
@ -116,6 +119,8 @@ export const DraftGridSection = memo(function DraftGridSection({
|
|||
teamId={slot.team.id}
|
||||
teamName={ownerMap[slot.team.id] || slot.team.name}
|
||||
logoUrl={slot.team.logoUrl}
|
||||
flagConfig={slot.team.flagConfig}
|
||||
avatarType={slot.team.avatarType}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,19 @@
|
|||
import { memo, useMemo, Fragment } from "react";
|
||||
import { TeamAvatar } from "~/components/TeamAvatar";
|
||||
import { type DraftPick, groupPicksByTeamAndSport } from "./picks-utils";
|
||||
import type { RawFlagConfig } from "~/lib/flag-types";
|
||||
|
||||
interface DraftSummaryViewProps {
|
||||
draftSlots: Array<{ id: string; team: { id: string; name: string; logoUrl?: string | null } }>;
|
||||
draftSlots: Array<{
|
||||
id: string;
|
||||
team: {
|
||||
id: string;
|
||||
name: string;
|
||||
logoUrl?: string | null;
|
||||
flagConfig?: RawFlagConfig | null;
|
||||
avatarType?: string | null;
|
||||
};
|
||||
}>;
|
||||
ownerMap?: Record<string, string>;
|
||||
picks: DraftPick[];
|
||||
sports: Array<{ id: string; name: string }>;
|
||||
|
|
@ -87,6 +97,8 @@ export const DraftSummaryView = memo(function DraftSummaryView({
|
|||
teamId={slot.team.id}
|
||||
teamName={ownerName || slot.team.name}
|
||||
logoUrl={slot.team.logoUrl}
|
||||
flagConfig={slot.team.flagConfig}
|
||||
avatarType={slot.team.avatarType}
|
||||
size="md"
|
||||
/>
|
||||
<div className="text-xs font-semibold text-center truncate w-full">
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { Button } from "~/components/ui/button";
|
||||
import { authClient } from "~/lib/auth-client";
|
||||
import { UserMenu } from "~/components/UserMenu";
|
||||
import type { RawFlagConfig } from "~/lib/flag-types";
|
||||
import { HelpCircle, MenuIcon, Settings } from "lucide-react";
|
||||
|
||||
const NAV_LINK_CLASS =
|
||||
|
|
@ -27,20 +28,42 @@ function mobileNavLinkClass({ isActive }: { isActive: boolean }) {
|
|||
|
||||
interface NavbarProps {
|
||||
isAdmin: boolean;
|
||||
currentUser: {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string | null;
|
||||
displayName: string | null;
|
||||
customAvatarUrl: string | null;
|
||||
flagConfig: RawFlagConfig | null;
|
||||
avatarType: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function Navbar({ isAdmin }: NavbarProps) {
|
||||
export function Navbar({ isAdmin, currentUser }: NavbarProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const signInHref = `/login?redirectTo=${encodeURIComponent(location.pathname)}`;
|
||||
|
||||
const authSection = session ? (
|
||||
const menuUser = currentUser ?? (session ? {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
username: null,
|
||||
displayName: session.user.name ?? null,
|
||||
customAvatarUrl: null,
|
||||
flagConfig: null,
|
||||
avatarType: null,
|
||||
} : null);
|
||||
|
||||
const authSection = menuUser ? (
|
||||
<UserMenu
|
||||
name={session.user.name ?? null}
|
||||
email={session.user.email}
|
||||
imageUrl={session.user.image ?? null}
|
||||
userId={menuUser.id}
|
||||
name={menuUser.username ?? menuUser.displayName}
|
||||
email={menuUser.email}
|
||||
customAvatarUrl={menuUser.customAvatarUrl}
|
||||
flagConfig={menuUser.flagConfig}
|
||||
avatarType={menuUser.avatarType}
|
||||
/>
|
||||
) : (
|
||||
<Link to={signInHref}>
|
||||
|
|
|
|||
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 { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { eq } from "drizzle-orm";
|
||||
import bcrypt from "bcrypt";
|
||||
import { Resend } from "resend";
|
||||
import { db } from "~/server/db";
|
||||
import * as schema from "~/database/schema";
|
||||
|
||||
if (!process.env.BETTER_AUTH_SECRET) {
|
||||
throw new Error("BETTER_AUTH_SECRET is required");
|
||||
|
|
@ -18,11 +20,38 @@ const discordProvider = process.env.DISCORD_CLIENT_ID && process.env.DISCORD_CLI
|
|||
? { discord: { clientId: process.env.DISCORD_CLIENT_ID, clientSecret: process.env.DISCORD_CLIENT_SECRET } }
|
||||
: {};
|
||||
|
||||
const appBaseUrl = process.env.APP_URL ?? process.env.BETTER_AUTH_URL;
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL: appBaseUrl,
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
usePlural: true,
|
||||
}),
|
||||
databaseHooks: {
|
||||
user: {
|
||||
update: {
|
||||
before: async (data) => {
|
||||
const incomingImage =
|
||||
"imageUrl" in data ? data.imageUrl : "image" in data ? data.image : undefined;
|
||||
const userId = typeof data.id === "string" ? data.id : null;
|
||||
|
||||
if (!incomingImage || !userId) return;
|
||||
|
||||
const existing = await db.query.users.findFirst({
|
||||
where: eq(schema.users.id, userId),
|
||||
});
|
||||
|
||||
if (existing?.avatarType === "uploaded" || existing?.avatarType === "flag") {
|
||||
const next = { ...data };
|
||||
delete next.imageUrl;
|
||||
delete next.image;
|
||||
return { data: next };
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
database: {
|
||||
generateId: false,
|
||||
|
|
|
|||
48
app/lib/avatar-data.ts
Normal file
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 { BracktGradients } from "~/components/ui/BracktGradients";
|
||||
import { Footer } from "~/components/marketing/Footer";
|
||||
import { isUserAdmin } from "~/models/user";
|
||||
import { findUserById } from "~/models/user";
|
||||
import { AlertCircle, FileQuestion, Lock, ShieldOff, ServerCrash } from "lucide-react";
|
||||
import logoUrl from "../public/logo.svg?url";
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
const isAdmin = session ? await isUserAdmin(session.user.id) : false;
|
||||
return { isAdmin };
|
||||
const currentUser = session ? await findUserById(session.user.id) : null;
|
||||
const isAdmin = currentUser?.isAdmin ?? false;
|
||||
return { isAdmin, currentUser };
|
||||
}
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
|
|
@ -70,7 +71,12 @@ export default function App({ loaderData }: Route.ComponentProps) {
|
|||
return (
|
||||
<>
|
||||
<NavigationProgress />
|
||||
{!isDraftRoute && <Navbar isAdmin={loaderData?.isAdmin ?? false} />}
|
||||
{!isDraftRoute && (
|
||||
<Navbar
|
||||
isAdmin={loaderData?.isAdmin ?? false}
|
||||
currentUser={loaderData?.currentUser ?? null}
|
||||
/>
|
||||
)}
|
||||
{isDraftRoute ? (
|
||||
<Outlet />
|
||||
) : (
|
||||
|
|
@ -178,4 +184,4 @@ function statusIcon(status: number) {
|
|||
if (status === 404) return <FileQuestion className={cls} />;
|
||||
if (status >= 500) return <ServerCrash className={cls} />;
|
||||
return <AlertCircle className={cls} />;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ export default [
|
|||
route("api/autodraft/update", "routes/api/autodraft.update.ts"),
|
||||
route("api/watchlist/toggle", "routes/api/watchlist.toggle.ts"),
|
||||
route("api/user/timezone", "routes/api/user.timezone.ts"),
|
||||
route("api/upload-avatar", "routes/api.upload-avatar.ts"),
|
||||
route("api/upload-team-logo", "routes/api.upload-team-logo.ts"),
|
||||
route("api/webhooks/cloudinary", "routes/api.webhooks.cloudinary.ts"),
|
||||
route("api/seasons/:seasonId/draft", "routes/api/seasons.$seasonId.draft.ts"),
|
||||
route("login", "routes/login.tsx"),
|
||||
route("register", "routes/register.tsx"),
|
||||
|
|
|
|||
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',
|
||||
ownerId: overrides.ownerId,
|
||||
logoUrl: null,
|
||||
flagConfig: null,
|
||||
avatarType: 'owner',
|
||||
draftPosition: overrides.draftPosition || 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
|
@ -190,6 +192,9 @@ describe('Team Management - Admin User List', () => {
|
|||
firstName: 'User',
|
||||
lastName: 'One',
|
||||
imageUrl: null,
|
||||
flagConfig: null,
|
||||
customAvatarUrl: null,
|
||||
avatarType: 'flag',
|
||||
isAdmin: false,
|
||||
timezone: null,
|
||||
createdAt: new Date(),
|
||||
|
|
@ -205,6 +210,9 @@ describe('Team Management - Admin User List', () => {
|
|||
firstName: 'User',
|
||||
lastName: 'Two',
|
||||
imageUrl: null,
|
||||
flagConfig: null,
|
||||
customAvatarUrl: null,
|
||||
avatarType: 'flag',
|
||||
isAdmin: false,
|
||||
timezone: null,
|
||||
createdAt: new Date(),
|
||||
|
|
@ -241,6 +249,9 @@ describe('Team Management - Admin User List', () => {
|
|||
firstName: 'User',
|
||||
lastName: 'One',
|
||||
imageUrl: null,
|
||||
flagConfig: null,
|
||||
customAvatarUrl: null,
|
||||
avatarType: 'flag',
|
||||
isAdmin: false,
|
||||
timezone: null,
|
||||
createdAt: new Date(),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import {
|
|||
removeTeamOwner,
|
||||
} from "~/models/team";
|
||||
import { findSeasonById } from "~/models/season";
|
||||
import { findUserById } from "~/models/user";
|
||||
import { resolveUserAvatarData } from "~/lib/avatar-data";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -18,6 +20,10 @@ import {
|
|||
} from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { AvatarEditor } from "~/components/ui/AvatarEditor";
|
||||
import { parseFlagConfig } from "~/lib/flag-types";
|
||||
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
|
||||
import { logger } from "~/lib/logger";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -62,7 +68,10 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
throw new Response("Season not found", { status: 404 });
|
||||
}
|
||||
|
||||
return { team, season };
|
||||
const owner = await findUserById(userId);
|
||||
const ownerAvatarData = owner ? resolveUserAvatarData(owner) : null;
|
||||
|
||||
return { team, season, ownerAvatarData };
|
||||
}
|
||||
|
||||
export async function action(args: Route.ActionArgs) {
|
||||
|
|
@ -89,9 +98,58 @@ export async function action(args: Route.ActionArgs) {
|
|||
const formData = await request.formData();
|
||||
const intent = formData.get("intent");
|
||||
|
||||
if (intent === "update-avatar-flag") {
|
||||
const rawConfig = formData.get("flagConfig");
|
||||
if (typeof rawConfig !== "string") {
|
||||
return { error: "Flag config is required" };
|
||||
}
|
||||
|
||||
let parsedConfig: unknown;
|
||||
try {
|
||||
parsedConfig = JSON.parse(rawConfig);
|
||||
} catch {
|
||||
return { error: "Invalid flag config" };
|
||||
}
|
||||
|
||||
const flagConfig = parseFlagConfig(parsedConfig);
|
||||
if (!flagConfig) {
|
||||
return { error: "Invalid flag config" };
|
||||
}
|
||||
|
||||
await updateTeam(teamId, {
|
||||
flagConfig,
|
||||
avatarType: "flag",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (intent === "remove-avatar-photo") {
|
||||
await updateTeam(teamId, {
|
||||
logoUrl: null,
|
||||
avatarType: "owner",
|
||||
});
|
||||
if (team.logoUrl) {
|
||||
deleteCloudinaryImageByUrl(team.logoUrl).catch((error) => {
|
||||
logger.error("Failed to delete team avatar from Cloudinary:", error);
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (intent === "use-owner-avatar") {
|
||||
await updateTeam(teamId, { avatarType: "owner", flagConfig: null });
|
||||
if (team.avatarType === "uploaded" && team.logoUrl) {
|
||||
deleteCloudinaryImageByUrl(team.logoUrl).catch((error) => {
|
||||
logger.error("Failed to delete replaced team avatar from Cloudinary:", error);
|
||||
});
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (intent === "update") {
|
||||
const name = formData.get("name");
|
||||
const logoUrl = formData.get("logoUrl");
|
||||
|
||||
if (!name || typeof name !== "string") {
|
||||
return { error: "Team name is required" };
|
||||
|
|
@ -99,7 +157,6 @@ export async function action(args: Route.ActionArgs) {
|
|||
|
||||
await updateTeam(teamId, {
|
||||
name: name.trim(),
|
||||
logoUrl: logoUrl && typeof logoUrl === "string" ? logoUrl.trim() : undefined,
|
||||
});
|
||||
await syncPrivateBracktParticipants(team.seasonId);
|
||||
|
||||
|
|
@ -115,7 +172,7 @@ export async function action(args: Route.ActionArgs) {
|
|||
return { error: "Invalid action" };
|
||||
}
|
||||
|
||||
export default function TeamSettings({ loaderData }: Route.ComponentProps) {
|
||||
export default function TeamSettings({ loaderData, actionData }: Route.ComponentProps) {
|
||||
const { team, season } = loaderData;
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
|
@ -138,11 +195,38 @@ export default function TeamSettings({ loaderData }: Route.ComponentProps) {
|
|||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{actionData && "success" in actionData && actionData.success && (
|
||||
<p className="text-sm text-[#adf661]">Team settings updated.</p>
|
||||
)}
|
||||
{actionData && "error" in actionData && actionData.error && (
|
||||
<p className="text-sm text-destructive">{actionData.error}</p>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Team Avatar</CardTitle>
|
||||
<CardDescription>
|
||||
Update the flag or submit a photo for review
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AvatarEditor
|
||||
id={team.id}
|
||||
isTeam
|
||||
currentAvatarType={team.avatarType}
|
||||
flagConfig={team.flagConfig}
|
||||
uploadedPhotoUrl={team.avatarType === "uploaded" ? team.logoUrl : null}
|
||||
uploadUrl="/api/upload-team-logo"
|
||||
ownerAvatarData={loaderData.ownerAvatarData}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Team Information</CardTitle>
|
||||
<CardDescription>
|
||||
Update your team name and logo
|
||||
Update your team name
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -161,21 +245,6 @@ export default function TeamSettings({ loaderData }: Route.ComponentProps) {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="logoUrl">Team Logo URL (optional)</Label>
|
||||
<Input
|
||||
id="logoUrl"
|
||||
name="logoUrl"
|
||||
type="url"
|
||||
defaultValue={team.logoUrl || ""}
|
||||
placeholder="https://example.com/logo.png"
|
||||
maxLength={512}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter a URL to an image for your team logo
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button type="submit">Save Changes</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import { Button } from "~/components/ui/button";
|
|||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { TimezoneSelect, TIMEZONE_OPTIONS } from "~/components/league/TimezoneSelect";
|
||||
import { AvatarEditor } from "~/components/ui/AvatarEditor";
|
||||
import { parseFlagConfig } from "~/lib/flag-types";
|
||||
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
|
||||
import { logger } from "~/lib/logger";
|
||||
|
||||
const VALID_TIMEZONES = new Set(
|
||||
TIMEZONE_OPTIONS.flatMap((g) => g.zones.map((z) => z.value))
|
||||
|
|
@ -36,7 +40,54 @@ export async function action(args: Route.ActionArgs) {
|
|||
return redirect("/login?redirectTo=/user-profile");
|
||||
}
|
||||
|
||||
const user = await findUserById(session.user.id);
|
||||
if (!user) {
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
const formData = await args.request.formData();
|
||||
const intent = formData.get("intent");
|
||||
|
||||
if (intent === "update-avatar-flag") {
|
||||
const rawConfig = formData.get("flagConfig");
|
||||
if (typeof rawConfig !== "string") {
|
||||
return { error: "Flag config is required." };
|
||||
}
|
||||
|
||||
let parsedConfig: unknown;
|
||||
try {
|
||||
parsedConfig = JSON.parse(rawConfig);
|
||||
} catch {
|
||||
return { error: "Invalid flag config." };
|
||||
}
|
||||
|
||||
const flagConfig = parseFlagConfig(parsedConfig);
|
||||
if (!flagConfig) {
|
||||
return { error: "Invalid flag config." };
|
||||
}
|
||||
|
||||
await updateUser(session.user.id, {
|
||||
flagConfig,
|
||||
avatarType: "flag",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (intent === "remove-avatar-photo") {
|
||||
await updateUser(session.user.id, {
|
||||
customAvatarUrl: null,
|
||||
avatarType: "flag",
|
||||
});
|
||||
if (user.customAvatarUrl) {
|
||||
deleteCloudinaryImageByUrl(user.customAvatarUrl).catch((error) => {
|
||||
logger.error("Failed to delete user avatar from Cloudinary:", error);
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const displayName = formData.get("displayName") as string;
|
||||
const username = formData.get("username") as string | null;
|
||||
const firstName = formData.get("firstName") as string | null;
|
||||
|
|
@ -85,6 +136,17 @@ export default function UserProfilePage({ loaderData, actionData }: Route.Compon
|
|||
{actionData?.success && (
|
||||
<p className="text-sm text-green-500 mb-4">Profile updated successfully.</p>
|
||||
)}
|
||||
{actionData && "error" in actionData && actionData.error && (
|
||||
<p className="text-sm text-destructive mb-4">{actionData.error}</p>
|
||||
)}
|
||||
<div className="mb-6 border-b pb-6">
|
||||
<AvatarEditor
|
||||
id={user.id}
|
||||
flagConfig={user.flagConfig}
|
||||
uploadedPhotoUrl={user.avatarType === "uploaded" ? user.customAvatarUrl : null}
|
||||
uploadUrl="/api/upload-avatar"
|
||||
/>
|
||||
</div>
|
||||
<Form method="post" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="displayName">Display Name</Label>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ export const users = pgTable("users", {
|
|||
firstName: varchar("first_name", { length: 255 }),
|
||||
lastName: varchar("last_name", { length: 255 }),
|
||||
imageUrl: varchar("image_url", { length: 512 }), // mapped as BetterAuth's "image" field
|
||||
flagConfig: jsonb("flag_config").$type<{ pattern: string; colors: string[] }>(),
|
||||
customAvatarUrl: varchar("custom_avatar_url", { length: 512 }),
|
||||
avatarType: varchar("avatar_type", { length: 20 }).notNull().default("flag"),
|
||||
isAdmin: boolean("is_admin").notNull().default(false),
|
||||
timezone: varchar("timezone", { length: 50 }), // IANA timezone string, e.g. "America/Chicago"
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
|
|
@ -228,6 +231,8 @@ export const teams = pgTable("teams", {
|
|||
.references(() => seasons.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
logoUrl: varchar("logo_url", { length: 512 }),
|
||||
flagConfig: jsonb("flag_config").$type<{ pattern: string; colors: string[] }>(),
|
||||
avatarType: varchar("avatar_type", { length: 20 }).notNull().default("owner"),
|
||||
ownerId: varchar("owner_id", { length: 255 }), // UUID → users.id, nullable
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ await requireLeagueAccess(args, { leagueId, seasonId, db });
|
|||
| `/forgot-password` | Request a password reset email |
|
||||
| `/reset-password` | Enter new password via reset token (link sent by email via Resend) |
|
||||
|
||||
Password reset emails are sent via Resend from `noreply@brackt.com`. The reset link points to `/reset-password?token=…` using `BETTER_AUTH_URL` as the base.
|
||||
Password reset emails are sent via Resend from `noreply@brackt.com`. The reset link points to `/reset-password?token=…` using `APP_URL` as the base.
|
||||
|
||||
## OAuth Providers
|
||||
|
||||
|
|
@ -89,6 +89,6 @@ Account linking is enabled — users who sign in with OAuth get their accounts l
|
|||
|
||||
```
|
||||
BETTER_AUTH_SECRET # required — random secret for signing sessions
|
||||
BETTER_AUTH_URL # required — app base URL, e.g. https://brackt.com
|
||||
APP_URL # required — app base URL, e.g. https://brackt.com
|
||||
RESEND_API_KEY # required for password reset emails
|
||||
```
|
||||
|
|
|
|||
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,
|
||||
"tag": "0094_curved_nick_fury",
|
||||
"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",
|
||||
"better-auth": "^1.6.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cloudinary": "^2.10.0",
|
||||
"clsx": "^2.1.1",
|
||||
"compression": "^1.8.0",
|
||||
"date-fns": "^4.1.0",
|
||||
|
|
@ -46,6 +47,7 @@
|
|||
"react": "^19.1.0",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-image-crop": "^11.0.10",
|
||||
"react-is": "^19.2.5",
|
||||
"react-router": "^7.7.1",
|
||||
"recharts": "^3.4.1",
|
||||
|
|
@ -8690,6 +8692,18 @@
|
|||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cloudinary": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.10.0.tgz",
|
||||
"integrity": "sha512-sY09kYg7wprkndAOjZBAYqFZqwL+SxnEGcAvksOvFA+5upnFn949UjkEkHKNSwkBtW/xRDd0p6NgbSXZcxkI3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.23"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=9"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
|
|
@ -12614,10 +12628,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"dev": true,
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
|
|
@ -14506,6 +14519,15 @@
|
|||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-image-crop": {
|
||||
"version": "11.0.10",
|
||||
"resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.0.10.tgz",
|
||||
"integrity": "sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-inspector": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-9.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@
|
|||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "^1.6.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cloudinary": "^2.10.0",
|
||||
"clsx": "^2.1.1",
|
||||
"compression": "^1.8.0",
|
||||
"date-fns": "^4.1.0",
|
||||
|
|
@ -68,6 +69,7 @@
|
|||
"react": "^19.1.0",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-image-crop": "^11.0.10",
|
||||
"react-is": "^19.2.5",
|
||||
"react-router": "^7.7.1",
|
||||
"recharts": "^3.4.1",
|
||||
|
|
|
|||
270
plans/avatar-system.md
Normal file
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