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

270 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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