11 KiB
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:
- Flag avatars — SVG flags (12 patterns, 2–3 colors) as the default identity for every user and team, editable via a builder UI.
- Photo upload — Cloudinary-hosted images with Rekognition NSFW moderation before display.
- 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
FlagSvgcomponent (~300 lines). Pure SVG, no dependencies, SSR-safe. - DB: Two new JSONB/varchar columns each on
usersandteams; keep existingimageUrl/logoUrluntouched. - OAuth protection: BetterAuth's
updateUserhook will be patched to skip writingimageUrlonce a user has set a custom avatar.
DB Schema Changes
database/schema.ts — add to users:
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:
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—FlagPatternunion type,FlagConfiginterfaceapp/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
- Add columns to schema (
database/schema.ts) npm run db:generate→npm run db:migrate- Update model functions:
app/models/user.ts— addflagConfig,customAvatarUrl,avatarTypeto select/update typesapp/models/team.ts— addflagConfig,avatarType
- 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
- Queries all users/teams where
Phase 3 — Avatar Display Components
Modified files:
app/components/TeamAvatar.tsx— acceptflagConfig?prop; render<FlagSvg>when present instead of initialsapp/components/UserMenu.tsx— usecustomAvatarUrl ?? flagConfig ?? imageUrlpriority- New:
app/components/ui/UserAvatar.tsx— mirrorsTeamAvatarfor user contexts
TeamAvatar new props:
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
- Grid of 12 pattern thumbnails (4-col, using
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
- Flag tab:
Modified: app/routes/user-profile.tsx
- Add
AvatarEditorsection above existing profile fields - New action intent
"update-avatar-flag"— savesflagConfig+avatarType: 'flag' - Update
app/models/user.tsupdateUserto 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)helpercloudinary.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 ownershipapp/routes/api.webhooks.cloudinary.ts— POST webhook- Validates
X-Cld-Signatureheader - On
approved: readscontext.user_idorcontext.team_id, updatescustomAvatarUrl/logoUrl+avatarType: 'uploaded' - On
rejected: no-op (log only; user keeps prior avatar) - Register in
app/routes.ts
- Validates
New client file:
app/components/ui/AvatarUploader.tsx- Uses
react-image-cropfor square crop - Previews crop before submit
- POST to
/api/upload-avataror/api/upload-team-logo - Shows "Photo submitted for review — it'll appear within a few seconds" after submit
- Uses
OAuth protection — app/lib/auth.server.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.tsxor similar) - Add a "Team Avatar" section (card) for each team the logged-in user owns in the league
- Reuse
<AvatarEditor>withteamIdprop instead ofuserId - New action intent:
"update-team-avatar-flag"and"update-team-avatar-type" app/models/team.ts:updateTeamacceptsflagConfig,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:runpasses flag-generator tests;FlagSvgrenders all 12 patterns in browser - Phase 2:
npm run typecheckpasses; 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
- Cloudinary account: Need
CLOUD_NAME,API_KEY,API_SECRET, and to enable the AWS Rekognition add-on + "block-pending" mode. - League settings route path: Confirm the exact file before Phase 6.
- Flag aspect ratio in builder UI:
FlagSvgwill useviewBox="0 0 100 100"for square output. The builder preview will also be square.