270 lines
11 KiB
Markdown
270 lines
11 KiB
Markdown
# 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.
|