diff --git a/docs/superpowers/specs/2026-03-15-user-avatar-design.md b/docs/superpowers/specs/2026-03-15-user-avatar-design.md new file mode 100644 index 0000000..01ddf09 --- /dev/null +++ b/docs/superpowers/specs/2026-03-15-user-avatar-design.md @@ -0,0 +1,112 @@ +# User Avatar — Design Spec + +## Goal + +Allow users to upload a profile avatar image (with client-side circular crop) that replaces initials everywhere in the app. + +## Backend + +### Entity Changes + +**User** — add nullable field: +- `avatarFileName: ?string` (length 255) — UUID-based filename stored on disk + +### Storage + +- Directory: `var/uploads/avatars/` +- Parameter in `services.yaml`: `avatar_upload_dir` + +### Endpoints + +All under `/api/users/{id}/avatar`: + +| Method | Description | Auth | +|--------|-------------|------| +| `POST` | Upload avatar (multipart file) | Owner or ROLE_ADMIN | +| `GET` | Serve avatar image (inline) | ROLE_USER or ROLE_CLIENT | +| `DELETE` | Remove avatar | Owner or ROLE_ADMIN | + +**POST** accepts a single `file` field. Validates: image MIME (jpeg, png, webp, gif), max 5 MB. Stores with UUID filename, updates `avatarFileName`. Deletes previous file if exists. + +**GET** returns the image with proper `Content-Type`. Returns 404 if no avatar. + +**DELETE** removes file from disk, sets `avatarFileName` to null. + +These are custom Symfony controllers (not API Platform resources) under `/api/` with `priority: 1`. + +### Serialization + +Add a virtual `avatarUrl` field to User serialization (group `user:read`): +- If `avatarFileName` is set: `/api/users/{id}/avatar` +- If null: `null` + +This way the frontend knows if an avatar exists from any user payload. + +### Migration + +- Add `avatar_file_name` column (VARCHAR 255, nullable) to `user` table. + +## Frontend + +### New Components + +**`UserAvatar.vue`** (`frontend/components/user/UserAvatar.vue`): +- Props: `user: { id: number, username: string, avatarUrl?: string | null }`, `size: 'xs' | 'sm' | 'md' | 'lg'` +- Sizes: xs=20px, sm=24px, md=32px, lg=48px +- If `avatarUrl`: `` rounded-full, object-cover +- Else: initials badge (current bg-primary-500 style), 2 first chars of username uppercased +- Handles `@error` on img to fallback to initials (broken image) + +**`AvatarCropper.vue`** (`frontend/components/user/AvatarCropper.vue`): +- Uses `vue-advanced-cropper` with `CircleStencil` +- Props: `imageFile: File` +- Emits: `crop(blob: Blob)`, `cancel` +- Fixed output size: 256x256px +- Modal overlay with crop area + confirm/cancel buttons + +### New Page + +**`/profile`** (`frontend/pages/profile.vue`): +- Shows current avatar (large) with "Change" button +- File input triggers AvatarCropper modal +- On confirm: POST blob to `/api/users/{id}/avatar` +- On success: refresh auth store user data +- "Remove avatar" button if avatar exists +- Accessible from "Mon profil" button in AppTopNav dropdown + +### New Service + +**`frontend/services/avatar.ts`**: +- `upload(userId: number, file: Blob): Promise` — POST multipart +- `remove(userId: number): Promise` — DELETE +- `getUrl(userId: number): string` — returns URL path + +### DTO Update + +**`UserData`** — add: `avatarUrl?: string | null` + +### Replacement Points + +Replace initials/icon with `` in: + +| File | Current display | Size | +|------|----------------|------| +| `TaskCard.vue:48-53` | Initials badge (h-5 w-5) | xs | +| `archives.vue:50-55` | Initials badge (h-5 w-5) | xs | +| `AppTopNav.vue:13` | `mdi:account-circle-outline` icon | md | +| `AdminClientTicketTab.vue` | Username text for submitter | sm | +| `ClientTicketDetailModal.vue` | submittedBy display | sm | + +### Auth Store + +After avatar upload/delete, re-fetch current user data so `avatarUrl` updates everywhere reactively. + +## Dependencies + +- `vue-advanced-cropper` — npm install in frontend/ + +## Out of Scope + +- Server-side image processing/resize +- Multiple image formats conversion +- Avatar for clients (entities), only users