From ee9ce26d51232cfe3dabf5d492cd96bfc9b92533 Mon Sep 17 00:00:00 2001 From: Scott Persinger Date: Wed, 1 Jul 2026 23:25:06 +0000 Subject: [PATCH] Fix #2: Add user avatar upload and display Users can now upload a profile photo that's shown next to their username in the feed, composer, and site header. - New /profile page with an avatar upload form + remove button - New POST server actions: uploadAvatar (5MB image cap) and removeAvatar - New route handler /api/avatars/[username] serving the bytes from Postgres - users.avatar / avatar_type / avatar_updated_at columns (idempotent ALTER TABLE ADD COLUMN IF NOT EXISTS for existing databases) - Shared Avatar component with initial-letter fallback for users who haven't uploaded one yet; ?v= cache-bust so refreshed avatars appear immediately Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Avatar.tsx | 40 ++++++++++++ app/Composer.tsx | 17 +++-- app/actions.ts | 45 ++++++++++++++ app/api/avatars/[username]/route.ts | 29 +++++++++ app/page.tsx | 30 +++++++-- app/profile/AvatarForm.tsx | 92 ++++++++++++++++++++++++++++ app/profile/page.tsx | 40 ++++++++++++ db/schema.sql | 16 +++-- lib/auth.ts | 14 ++++- screenshots/feed-after.png | Bin 0 -> 139357 bytes screenshots/feed-before.png | Bin 0 -> 172052 bytes screenshots/profile-page.png | Bin 0 -> 64257 bytes 12 files changed, 306 insertions(+), 17 deletions(-) create mode 100644 app/Avatar.tsx create mode 100644 app/api/avatars/[username]/route.ts create mode 100644 app/profile/AvatarForm.tsx create mode 100644 app/profile/page.tsx create mode 100644 screenshots/feed-after.png create mode 100644 screenshots/feed-before.png create mode 100644 screenshots/profile-page.png diff --git a/app/Avatar.tsx b/app/Avatar.tsx new file mode 100644 index 0000000..4954cea --- /dev/null +++ b/app/Avatar.tsx @@ -0,0 +1,40 @@ +type AvatarProps = { + username: string; + avatarUpdatedAt: string | null; + size?: "sm" | "md" | "lg"; + className?: string; +}; + +const SIZE_CLASSES: Record, string> = { + sm: "h-8 w-8 text-sm", + md: "h-10 w-10 text-base", + lg: "h-24 w-24 text-3xl", +}; + +export default function Avatar({ + username, + avatarUpdatedAt, + size = "md", + className = "", +}: AvatarProps) { + const sizeClass = SIZE_CLASSES[size]; + const base = `flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-700 font-bold text-white ${sizeClass} ${className}`; + + if (!avatarUpdatedAt) { + return
{username.charAt(0).toUpperCase()}
; + } + + const version = Date.parse(avatarUpdatedAt) || 0; + const src = `/api/avatars/${encodeURIComponent(username)}?v=${version}`; + + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`${username} +
+ ); +} diff --git a/app/Composer.tsx b/app/Composer.tsx index 6ca0f01..ca07a33 100644 --- a/app/Composer.tsx +++ b/app/Composer.tsx @@ -3,8 +3,15 @@ import { useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { createPost } from "./actions"; +import Avatar from "./Avatar"; -export default function Composer({ username }: { username: string }) { +export default function Composer({ + username, + avatarUpdatedAt, +}: { + username: string; + avatarUpdatedAt: string | null; +}) { const router = useRouter(); const formRef = useRef(null); const [preview, setPreview] = useState(null); @@ -34,9 +41,11 @@ export default function Composer({ username }: { username: string }) { onSubmit={handleSubmit} className="flex gap-3 border-b border-neutral-800 p-4" > -
- {username.charAt(0).toUpperCase()} -
+