brackt/plans/avatar-system.md
2026-05-06 14:47:37 -07:00

11 KiB
Raw Blame History

Avatar System: Flag Builder + Photo Upload

Context

Users and teams currently show initials in a colored box when no avatar is set. OAuth users get their Google/Discord photo in users.imageUrl, but there's no custom upload path and no procedurally generated identity. This plan adds:

  1. Flag avatars — SVG flags (12 patterns, 23 colors) as the default identity for every user and team, editable via a builder UI.
  2. Photo upload — Cloudinary-hosted images with Rekognition NSFW moderation before display.
  3. Hierarchy — teams inherit their owner's avatar by default; users and teams can independently override.

Architecture Decisions

  • Image hosting: Cloudinary. Store at 256×256, serve at 45×45 or 18×18 via URL-based transformations (e.g. w_45,h_45,c_fill). Transformations are generated once per unique image+size then CDN-cached; cost is ~2 credits/1k new uploads. At ~2 KB average delivered size, the free tier (25 GB bandwidth) covers ~80,000 active users. NSFW moderation via the AWS Rekognition add-on. Images are never shown until the webhook approves them.
  • Flag library: None exists. Build a custom FlagSvg component (~300 lines). Pure SVG, no dependencies, SSR-safe.
  • DB: Two new JSONB/varchar columns each on users and teams; keep existing imageUrl / logoUrl untouched.
  • OAuth protection: BetterAuth's updateUser hook will be patched to skip writing imageUrl once a user has set a custom avatar.

DB Schema Changes

database/schema.ts — add to users:

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.tsFlagPattern union type, FlagConfig interface
  • app/components/ui/FlagSvg.tsx — renders <svg> from {pattern, colors, size?}
  • app/lib/flag-generator.tsgenerateFlagConfig(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:generatenpm 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:

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
    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 protectionapp/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.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.