Files
Lesstime/docs/superpowers/specs/2026-03-15-user-avatar-design.md
matthieu 96f5c7c91c docs : add user avatar feature design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:47:38 +01:00

3.6 KiB

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: <img> 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<void> — POST multipart
  • remove(userId: number): Promise<void> — DELETE
  • getUrl(userId: number): string — returns URL path

DTO Update

UserData — add: avatarUrl?: string | null

Replacement Points

Replace initials/icon with <UserAvatar> 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