Add user profile page with avatar upload

Medium todo
2026-05-30 agentics feature Medium effort

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.

Implement Read and implement all steps in the plan at docs/plans/sample-add-user-profile-page.md — Add user profile page with avatar upload. Verify against the plan's Tests, Verification, and Acceptance Criteria before reporting done. If everything passed, mark completion in docs/plans/sample-add-user-profile-page.md — tick each step's [x] marker and each criterion's - [x], set status: completed — and re-render the HTML from the spec. If any check failed, leave status: in-progress and say which.
More ways to run this plan — goal & workflow prompts, file path
Pursue as goal — optimize for the outcome
Achieve this goal: Add user profile page with avatar upload. The plan at docs/plans/sample-add-user-profile-page.md describes one approach — use it as reference, but optimize for the outcome. Verify against the plan's Tests, Verification, and Acceptance Criteria before reporting done. If everything passed, mark completion in docs/plans/sample-add-user-profile-page.md — tick each step's [x] marker and each criterion's - [x], set status: completed — and re-render the HTML from the spec. If any check failed, leave status: in-progress and say which.
File sample-add-user-profile-page.html
Path docs/plans/sample-add-user-profile-page.html
Spec docs/plans/sample-add-user-profile-page.md
Definition of done 0 / 8 done

Context

The story behind this plan — what prompted the work and why it matters now.

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

The step-by-step work, in order — each step says what to do, why it matters, and how to check it worked.

1
todo Add avatar_url column to the users table via migration
Why
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 .
2
todo Wire up the S3 presigned URL endpoint at POST /api/upload/avatar
Why
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.
3
todo Build the ProfileForm.tsx React component with avatar drag-and-drop zone
Why
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.
4
todo Resize uploaded images server-side with sharp to 256×256px WebP
Why
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
Why
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.
6
todo Implement keyboard and screen reader accessibility for the upload zone
Why
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.

Definition of done

The plan counts as done when every statement below is true — check each one off as you verify it.

Final check

One last pass to confirm the whole change works end to end.

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.

Wrapping up

Three gates that must all pass before this plan is marked completed.

Required

Completion Report

No items to report — all requirements met.

Next steps

Follow-up ideas that came up along the way — none of them are required to finish this plan.

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.
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:

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

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.