From ce5dbd542c93263f4fd5b13fc1709220927dae26 Mon Sep 17 00:00:00 2001 From: isaacennalsbray <91916539+isaacennalsbray@users.noreply.github.com> Date: Sun, 5 Oct 2025 16:34:40 -0700 Subject: [PATCH 1/2] Add profile photo uploads --- docs/overview.md | 13 +- .../components/ProfilePhotoUploader.tsx | 137 ++++++++++++++++++ packages/client/components/ProfileSummary.tsx | 19 ++- .../__tests__/ProfileSummary.stories.tsx | 2 + packages/client/vitest.setup.ts | 32 ++++ packages/server/src/datastore.ts | 9 ++ packages/server/src/server.ts | 28 +++- packages/server/tests/datastore.test.ts | 8 + 8 files changed, 240 insertions(+), 8 deletions(-) create mode 100644 packages/client/components/ProfilePhotoUploader.tsx diff --git a/docs/overview.md b/docs/overview.md index b2355fb..2224488 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -17,12 +17,13 @@ * Has a button to export the list of people as a string you can use in your email client * Shows a list of groups you are in * Has a button to create a new group -* Profile - * URL is slowpost.org/username - * Shows a person’s name, photo, and blurb - * Lists public groups that they are part of, and private groups you are both in - * Has a button to follow their posts - * If it’s your profile, can edit your name, photo, blurb +* Profile + * URL is slowpost.org/username + * Shows a person’s name, photo, and blurb + * Lists public groups that they are part of, and private groups you are both in + * Has a button to follow their posts + * If it’s your profile, can edit your name, photo, blurb + * Uploading a new profile photo supports common image formats up to 5MB and updates the photo immediately on your profile page * Login * URL is slowpast.org/p/login * Asks for the user’s email address diff --git a/packages/client/components/ProfilePhotoUploader.tsx b/packages/client/components/ProfilePhotoUploader.tsx new file mode 100644 index 0000000..cf0b6e2 --- /dev/null +++ b/packages/client/components/ProfilePhotoUploader.tsx @@ -0,0 +1,137 @@ +import { ChangeEvent, useEffect, useId, useRef, useState } from 'react'; +import { Avatar, Button, Text, VertBox } from '../style'; + +const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB + +type UploadStatus = 'idle' | 'uploading' | 'success' | 'error'; + +export interface ProfilePhotoUploaderProps { + username: string; + name: string; + initialPhotoUrl: string; + onPhotoUpdated?: (photoUrl: string) => void; +} + +async function readFileAsDataUrl(file: File): Promise { + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === 'string') { + resolve(reader.result); + } else { + reject(new Error('Could not read the selected file.')); + } + }; + reader.onerror = () => { + reject(new Error('Could not read the selected file.')); + }; + reader.readAsDataURL(file); + }); +} + +export function ProfilePhotoUploader({ + username, + name, + initialPhotoUrl, + onPhotoUpdated +}: ProfilePhotoUploaderProps) { + const [photoUrl, setPhotoUrl] = useState(initialPhotoUrl); + const [status, setStatus] = useState('idle'); + const [message, setMessage] = useState(null); + const fileInputId = useId(); + const fileInputRef = useRef(null); + + useEffect(() => { + setPhotoUrl(initialPhotoUrl); + }, [initialPhotoUrl]); + + const resetInput = () => { + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleSelectFile = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + + if (!file.type.startsWith('image/')) { + setStatus('error'); + setMessage('Please choose an image file.'); + resetInput(); + return; + } + + if (file.size > MAX_FILE_SIZE_BYTES) { + setStatus('error'); + setMessage('Please choose an image smaller than 5MB.'); + resetInput(); + return; + } + + setStatus('uploading'); + setMessage('Uploading photo…'); + + try { + const photoData = await readFileAsDataUrl(file); + const response = await fetch(`/api/profile/${username}/photo`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ photoData }) + }); + if (!response.ok) { + let errorMessage = 'Unable to upload your photo. Please try again.'; + try { + const errorJson = (await response.json()) as { message?: string }; + if (errorJson.message) { + errorMessage = errorJson.message; + } + } catch (parseError) { + console.warn('Unable to parse photo upload error response', parseError); + } + throw new Error(errorMessage); + } + const data = (await response.json()) as { photoUrl: string }; + setPhotoUrl(data.photoUrl); + onPhotoUpdated?.(data.photoUrl); + setStatus('success'); + setMessage('Profile photo updated.'); + } catch (error) { + console.error('Failed to upload profile photo', error); + setStatus('error'); + setMessage(error instanceof Error ? error.message : 'Unable to upload your photo.'); + } finally { + resetInput(); + } + }; + + return ( + + + + + {message ? ( + + {message} + + ) : null} + + ); +} + +export default ProfilePhotoUploader; diff --git a/packages/client/components/ProfileSummary.tsx b/packages/client/components/ProfileSummary.tsx index c9404f3..d6f5ec7 100644 --- a/packages/client/components/ProfileSummary.tsx +++ b/packages/client/components/ProfileSummary.tsx @@ -1,18 +1,35 @@ import Link from 'next/link'; +import { useEffect, useState } from 'react'; import type { Profile } from '../lib/data'; import { Avatar, Button, Card, HorizBox, Pad, PadBox, Text, TileGrid, VertBox } from '../style'; +import { ProfilePhotoUploader } from './ProfilePhotoUploader'; export type ProfileSummaryProps = { profile: Profile; }; export function ProfileSummary({ profile }: ProfileSummaryProps) { + const [photoUrl, setPhotoUrl] = useState(profile.photoUrl); + + useEffect(() => { + setPhotoUrl(profile.photoUrl); + }, [profile.photoUrl]); + return ( - + {profile.isSelf ? ( + + ) : ( + + )}

{profile.name} diff --git a/packages/client/components/__tests__/ProfileSummary.stories.tsx b/packages/client/components/__tests__/ProfileSummary.stories.tsx index c67e4d4..83f0a18 100644 --- a/packages/client/components/__tests__/ProfileSummary.stories.tsx +++ b/packages/client/components/__tests__/ProfileSummary.stories.tsx @@ -19,6 +19,7 @@ type Story = StoryObj; export const OwnProfile: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); + await expect(canvas.getByRole('button', { name: /change photo/i })).toBeInTheDocument(); await expect(canvas.getByRole('button', { name: /edit profile/i })).toBeInTheDocument(); } }; @@ -30,5 +31,6 @@ export const OtherProfile: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByRole('button', { name: /follow/i })).toBeInTheDocument(); + expect(canvas.queryByRole('button', { name: /change photo/i })).not.toBeInTheDocument(); } }; diff --git a/packages/client/vitest.setup.ts b/packages/client/vitest.setup.ts index 9c1ec55..e36878b 100644 --- a/packages/client/vitest.setup.ts +++ b/packages/client/vitest.setup.ts @@ -16,3 +16,35 @@ vi.mock('next/link', () => { ) }; }); + +const jsonResponse = (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + ...init + }); + +vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + + if (url.includes('/api/login/request')) { + return jsonResponse({ message: 'PIN generated. Check the API server logs for the code.' }); + } + + if (url.includes('/api/login/verify')) { + const body = init?.body ? JSON.parse(String(init.body)) : {}; + return jsonResponse({ username: body?.email ? body.email.split('@')[0] ?? 'user' : 'user' }); + } + + if (url.includes('/api/login/dev-skip')) { + const body = init?.body ? JSON.parse(String(init.body)) : {}; + return jsonResponse({ username: body?.email ? body.email.split('@')[0] ?? 'user' : 'user' }); + } + + if (url.includes('/api/profile/') && url.endsWith('/photo')) { + const body = init?.body ? JSON.parse(String(init.body)) : {}; + return jsonResponse({ photoUrl: body?.photoData ?? '' }); + } + + return jsonResponse({}, { status: 404 }); +})); diff --git a/packages/server/src/datastore.ts b/packages/server/src/datastore.ts index e6e4b79..f7b126b 100644 --- a/packages/server/src/datastore.ts +++ b/packages/server/src/datastore.ts @@ -100,6 +100,15 @@ export class InMemoryStore { return this.profiles.get(username); } + updateProfilePhoto(username: string, photoUrl: string): Profile { + const profile = this.getProfile(username); + if (!profile) { + throw new Error(`Profile not found: ${username}`); + } + profile.photoUrl = photoUrl; + return profile; + } + getGroup(groupKey: string): Optional { return this.groups.get(groupKey); } diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 76bd547..76c74b1 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -37,7 +37,7 @@ async function deliverLoginPin(session: LoginSession) { } const app = express(); -app.use(express.json()); +app.use(express.json({ limit: '10mb' })); app.get('/api/home/:username', (req, res) => { try { @@ -82,6 +82,32 @@ app.post('/api/profile/:username/follow', (req, res) => { } }); +app.post('/api/profile/:username/photo', (req, res) => { + try { + const schema = z.object({ + photoData: z + .string() + .refine( + (value) => /^data:image\/[a-zA-Z0-9.+-]+;base64,/.test(value), + 'Photo must be a base64 encoded image data URL.' + ) + }); + const { photoData } = schema.parse(req.body); + const profile = store.updateProfilePhoto(req.params.username, photoData); + res.json({ photoUrl: profile.photoUrl }); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ message: error.issues[0]?.message ?? 'Invalid photo upload.' }); + return; + } + if ((error as Error).message.startsWith('Profile not found')) { + res.status(404).json({ message: (error as Error).message }); + return; + } + res.status(400).json({ message: (error as Error).message }); + } +}); + app.get('/api/group/:groupKey', (req, res) => { try { const { groupKey } = req.params; diff --git a/packages/server/tests/datastore.test.ts b/packages/server/tests/datastore.test.ts index a44a42a..f93ebdc 100644 --- a/packages/server/tests/datastore.test.ts +++ b/packages/server/tests/datastore.test.ts @@ -54,4 +54,12 @@ describe('InMemoryStore', () => { const forced = store.forceVerifyLogin(email); expect(forced.verified).toBe(true); }); + + it('updates profile photos', () => { + const newPhoto = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA'; + const profile = store.updateProfilePhoto('ada', newPhoto); + expect(profile.photoUrl).toBe(newPhoto); + const updatedView = store.getProfileView('ada'); + expect(updatedView.profile.photoUrl).toBe(newPhoto); + }); }); From a5e0e5b7b710f23d2df4f6fccfe0e77173d48bac Mon Sep 17 00:00:00 2001 From: robennals Date: Sat, 11 Oct 2025 14:30:16 -0700 Subject: [PATCH 2/2] Updated overview --- docs/overview.md | 54 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/docs/overview.md b/docs/overview.md index 2224488..39550a1 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -13,8 +13,12 @@ * Has a logo and description of the product * Has a login button * If logged in - * Shows a list of people who have followed you, with a toggle to choose which are close friends - * Has a button to export the list of people as a string you can use in your email client + * Shows a list of updates. An update can be someone following you, someone asking to join one of your groups, or you being accepted into a group + * Shows a button to view your followers, with the first 5 shown as a preview + * Shows a button to view your groups, with the first 5 shown as a preview +* Followers screen + * Shows a list of people who have followed you, with a toggle next to each one to choose whether than person is a close friend + * Has a button to manually add new followers by email address * Shows a list of groups you are in * Has a button to create a new group * Profile @@ -28,20 +32,23 @@ * URL is slowpast.org/p/login * Asks for the user’s email address * Then asks for the PIN it emailed them so the user can log in + * When running on localhost, allows the user to click "skip pin" to log in without having a PIN + * On the server, only allows "skip pin" if run with an env var telling it this is allowed * Asks you for your user name slug and real full name the first time you sign in + * If you try to sign in with an email that hasn't been signed in before, it asks you if you want to sign in + * If you try to sign up with an email that /has/ been signed in before, it asks you if you want to sign up * Group - * URL is slowpost.org/g/\[groupKey\] - * Has a non-guessable URL that is a key - * Shows a list of people in that group + * URL is slowpost.org/g/\[groupname\] + * Shows a list of people in that group + * Shows the name (and link to profile) of the group admin + * Each person's group bio shows a short summary (editable by that person) of their role in the group. * Clicking on a person takes you to their profile * Also has a button to ask to join the group * If you are a group admin, shows people who have asked to join, with a button to decide to admit them or not -* Followers - * Shows a list of people who have asked to follow you - * You can click on them to see their profile and ask to follow them - * You can choose which of them to give the “close friend” post -* Notifications - * Shows a list of updates +* Join Group + * Shown if you click the "join group" button on a group + * Asks you to enter a bio line to appear next to your profile on the group + * Lets you provide an optional message to the group admin ## Implementation @@ -61,10 +68,31 @@ I have the domain with cloudflare ## Database Model -* A "profile" collection contains one document for each person +* A "profile" collection contains one document for each person, with their username, real name, and bio * A "group" collection contains one document for each group * A "follow" collection contains one document for each follow relationship, with a key saying whether it is a "close" follow, and indexes to look up follow relationships in both directions -* A "member" collection contains one document for each group membership +* A "member" collection contains one document for each group membership, with their username, name, and per-group bio * A "notifications" collection contains one document for each notification sent to a user. Each of these is also sent my email. +* An "auth" collection has one document for each user. This contains their email, active login PIN, and any active login sessions (with their expirey times, and secure tokens) * Also have whatever collections you might need to manage login + +## Database Adapter + +* Regular server API functions don't call MongoDB directly. Instead they talk to it through a simple adapter layer. +* This adapter has the following methods: + * getDocument(collection, key) - Gets a single object. useful for getting profile, group, "user", etc objects that there is one of + * addDocument(collection, newKey, data) - Create a document + * updateDocument(collection, key, update) - Updates a document + * getChildLinks(collection, parentKey) | getParentLinks(collection, childKey) - Some collections represent one thing being part of something else. E.g. a group has members, a person has followers. In that case, we can query either all the children of a parent (e.g. the followers of a person) or the parents of a child (e.g. the people someone follows) + * addLink(collection, parentKey, childKey, data) - Create a new link + + +## Login model + +* A cookie contains username + real name + auth token + session expirey time +* An API allows you to request that it send an email (using PostMark) with a random PIN to the user's email address +* A "login" API takes an email and a valid PIN and generates a new login session, returns it as a value and sets it as a cookie +* If SKIP_PIN is set as an env variable (true when running local dev) then +* If the user is logged in, their name appears in the to right on the top-bar. Otherwise it says "Log In | Sign Up". +