Implementation Plan

Ship a user profile page with drag-and-drop avatar upload that persists across sessions

todo
2026-05-30 agentics feature

Ship a user profile page with drag-and-drop avatar upload that persists across sessions — users can update their display name, bio, and profile photo from a single responsive form backed by S3 presigned URLs.

File sample-add-user-profile-page.html
Path docs/plans/sample-add-user-profile-page.html
Acceptance criteria 0 / 8 done

Context

The app currently has no profile management — users see a generic avatar and cannot update personal details. This drives support tickets (“how do I change my name?”) and reduces engagement. The backend already has a users table with display_name and bio columns; we need to add avatar_url and build the frontend.

Steps

1
todo Add avatar_url column to the users table via migration
The existing schema has no place to store a profile photo URL. A nullable TEXT column avoids breaking existing rows.
Verify
Run npx prisma migrate dev and confirm the column exists with SELECT avatar_url FROM users LIMIT 1.
Confirmed: migration is safe on current table size (~5k rows). No backfill needed — NULL is the default.
2
todo Wire up the S3 presigned URL endpoint at POST /api/upload/avatar
Client-direct S3 upload avoids proxying large files through the API server, keeping request sizes small and upload speeds fast.
Verify
Call the endpoint with curl -X POST /api/upload/avatar -H "Authorization: Bearer $TOKEN" and confirm a presigned URL is returned with a 60-second TTL.
Decision confirmed: client-direct S3 upload (not API proxy). Presigned URL TTL set to 60 seconds to limit abuse window.
3
todo Build the ProfileForm.tsx React component with avatar drag-and-drop zone
A single form for name, bio, and avatar keeps the UX simple. Drag-and-drop with a click fallback covers all interaction modes.
Verify
Render the component in Storybook; confirm drag-and-drop highlights the zone, click opens the file picker, and the preview updates immediately.
Added: show an optimistic preview of the selected image during upload. Display a circular progress indicator on the avatar thumbnail.
4
todo Resize uploaded images server-side with sharp to 256×256px WebP
Standardizing dimensions and format keeps storage predictable and page load fast. sharp is already a project dependency.
Verify
Upload a 4000×3000 JPEG; confirm the stored S3 object is 256×256 WebP and under 50KB.
5
todo Add default avatar fallback and error recovery to ProfileForm.tsx
Users who haven’t uploaded a photo need a recognizable placeholder. Upload failures need clear, recoverable error states.
Verify
Load the profile page with no avatar set — confirm the initials-based SVG placeholder renders. Simulate a network error during upload — confirm the inline error message appears with a “Retry” button.
Surfaced by interview: no default avatar or error recovery was originally specified. Added initials-based SVG placeholder and inline error with retry.
6
todo Implement keyboard and screen reader accessibility for the upload zone
The drag-and-drop zone must be operable without a mouse. Screen reader users need status announcements for upload progress and completion.
Verify
Tab to the upload zone — confirm it receives visible focus. Press Enter/Space — confirm the file picker opens. After upload, confirm VoiceOver announces “Profile photo uploaded successfully” via the aria-live region.
Surfaced by interview: original plan had no accessibility considerations. Added keyboard interaction, focus management, ARIA live region for upload status, and aria-describedby for format constraints.

Acceptance Criteria

Verification

End-to-end: log in as a test user, navigate to /settings/profile, update display name and bio, upload a photo via drag-and-drop, reload the page, and confirm all fields persist. Repeat on a 375px mobile viewport. Run the Lighthouse accessibility audit and confirm no new violations. Verify the S3 bucket contains the resized WebP object.

Next Steps

Add avatar cropping before upload

Paste this prompt into Claude to execute this follow-up:

Add a circular crop overlay to the avatar upload flow in ProfileForm.tsx. Use react-easy-crop (already in package.json). Show the cropper after file selection but before upload. Output the cropped region as a 256x256 canvas blob and upload that instead of the raw file.
Wish List
CDN-side image transforms via Cloudflare Images Wish List

Speculative / blue-sky idea — not on the critical path. Paste into Claude when ready to explore:

Evaluate replacing the sharp-based server-side resize with Cloudflare Images on-the-fly transforms. Compare: cost per 1M transforms, latency (origin round-trip vs edge), and whether we can serve multiple sizes (64px navbar, 256px profile) from a single upload. Output a decision doc with a recommendation.
Unresolved Questions
  • Should the profile update endpoint separate avatar metadata from user fields?
    The current plan updates display_name, bio, and avatar_url in a single PATCH /api/users/me request. Evaluate whether avatar_url should be updated separately (via a callback after S3 upload confirms) to handle partial failures — e.g., S3 upload succeeds but the DB write fails. Recommend an approach and explain the trade-offs.
  • What file size and format limits should the upload enforce?
    Define the file validation rules for avatar upload: max file size (in MB), accepted MIME types (JPEG, PNG, WebP, GIF?), and whether animated GIFs should be allowed or flattened to a single frame. Consider the sharp resize step and S3 storage costs. Output the validation config and error messages.