From 5646dab359eb3c1cd8f252de5011f42b3980928c Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Fri, 26 Jun 2026 10:58:06 -0400 Subject: [PATCH 1/3] fix(web): enlarge header logo to fill the navbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The horizontal logo sat at h-8 (32px) in the h-14 (56px) bar, leaving ~12px of dead space above/below. Bump to h-12 (48px) so it fills ~86% of the bar height with a 4px gap top and bottom — a fuller, more present lockup. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/components/AppHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/AppHeader.tsx b/apps/web/src/components/AppHeader.tsx index 5a8d1f6..c0d6516 100644 --- a/apps/web/src/components/AppHeader.tsx +++ b/apps/web/src/components/AppHeader.tsx @@ -197,7 +197,7 @@ export function AppHeader() { Code for Philly From 8321e21f52112d40095809cd4e820b12c9b33ef4 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Fri, 26 Jun 2026 13:21:49 -0400 Subject: [PATCH 2/3] feat(web): add Join/Leave project buttons (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProjectDetail declared "Join Project" / "Leave project" actions in the spec (project-detail.md) and the API endpoints existed, but the SPA never rendered the buttons. Add them to the sidebar: - Join Project — signed-in users who aren't members (POST .../members/join) - Leave project — members, except a sole maintainer who must transfer the role first (POST .../members/leave); shows an explanatory hint instead. Membership is computed client-side from the members list + the signed-in user (the project response carries no per-viewer flag). Added api client join/leave methods and ProjectDetail tests for both states. First slice of the #113 UI-gaps umbrella. The soft-delete banner (the other half of the ProjectDetail pairing) is deferred — it needs the project response to expose `deletedAt`, a small API/serializer change tracked under #113. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/lib/api.ts | 4 ++ apps/web/src/screens/ProjectDetail.tsx | 63 ++++++++++++++++++++++++ apps/web/tests/ProjectDetail.test.tsx | 66 ++++++++++++++++++++++++++ 3 files changed, 133 insertions(+) diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 3a7bcb9..aad8868 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -591,6 +591,10 @@ export const api = { request(`/api/projects/${encodeURIComponent(slug)}`, { method: 'DELETE' }), restore: (slug: string): Promise> => request(`/api/projects/${encodeURIComponent(slug)}/restore`, { method: 'POST' }), + join: (slug: string): Promise => + request(`/api/projects/${encodeURIComponent(slug)}/members/join`, { method: 'POST' }), + leave: (slug: string): Promise => + request(`/api/projects/${encodeURIComponent(slug)}/members/leave`, { method: 'POST' }), changeMaintainer: (slug: string, personSlug: string): Promise> => request(`/api/projects/${encodeURIComponent(slug)}/change-maintainer`, { method: 'POST', diff --git a/apps/web/src/screens/ProjectDetail.tsx b/apps/web/src/screens/ProjectDetail.tsx index 7a27e7f..2441aa6 100644 --- a/apps/web/src/screens/ProjectDetail.tsx +++ b/apps/web/src/screens/ProjectDetail.tsx @@ -51,6 +51,8 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { const [interestRole, setInterestRole] = useState(null); const [fillRole, setFillRole] = useState(null); const [stageInfoOpen, setStageInfoOpen] = useState(false); + const [memberBusy, setMemberBusy] = useState(false); + const [memberError, setMemberError] = useState(null); // Allow ?openModal=help-wanted (from /help-wanted "Post a role" picker). // Use the state-sync pattern so we don't trigger a cascading re-render. @@ -122,6 +124,32 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { const helpWantedRoles = helpWantedQ.data?.data ?? []; const perms = project.permissions; + // #113 — Join / Leave the project. The endpoints exist; the UI was missing. + // The project response carries no per-viewer membership flag, so membership is + // derived from the members list + the signed-in user. + const myMembership = person + ? project.memberships.find((m) => m.person.slug === person.slug) + : undefined; + const isMember = myMembership !== undefined; + const maintainerCount = project.memberships.filter((m) => m.isMaintainer).length; + // A sole maintainer must transfer the role before leaving (project-detail.md authz). + const isSoleMaintainer = (myMembership?.isMaintainer ?? false) && maintainerCount === 1; + const canJoin = isSignedIn && !isMember; + const canLeave = isMember && !isSoleMaintainer; + + const runMembership = async (fn: () => Promise): Promise => { + setMemberBusy(true); + setMemberError(null); + try { + await fn(); + await projectQ.refetch(); + } catch (err) { + setMemberError(err instanceof ApiError ? err.message : 'Something went wrong. Please try again.'); + } finally { + setMemberBusy(false); + } + }; + const allTags = [...project.tags.tech, ...project.tags.topic, ...project.tags.event]; return ( @@ -297,6 +325,41 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { {/* Sidebar */} ); diff --git a/apps/web/tests/PersonDetail.test.tsx b/apps/web/tests/PersonDetail.test.tsx index 18f69ea..e5d2642 100644 --- a/apps/web/tests/PersonDetail.test.tsx +++ b/apps/web/tests/PersonDetail.test.tsx @@ -99,4 +99,54 @@ describe('PersonDetail Contact sidebar', () => { expect(mailto).toHaveAttribute('href', 'mailto:jane@example.com'); }); }); + + // #113 — "Manage account" link, self only + it('shows a "Manage account" link to /account when viewing your own profile', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => { + if (input.startsWith('/api/auth/me')) { + return Promise.resolve( + new Response( + JSON.stringify( + mockOk({ + person: { id: BASE_PERSON.id, slug: 'jane-doe', fullName: 'Jane Doe', accountLevel: 'user', avatarUrl: null }, + accountLevel: 'user', + }), + ), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + } + if (input.startsWith('/api/people/jane-doe')) { + return Promise.resolve(new Response(JSON.stringify(mockOk(BASE_PERSON)), { status: 200, headers: { 'content-type': 'application/json' } })); + } + return Promise.resolve(new Response(null, { status: 404 })); + }) as typeof fetch); + renderScreen( + + + } /> + + , + { initialEntries: ['/members/jane-doe'] }, + ); + await waitFor(() => { + expect(screen.getByRole('link', { name: /manage account/i })).toHaveAttribute('href', '/account'); + }); + }); + + it('does not show "Manage account" for anonymous viewers', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(makeFetchMock(BASE_PERSON)); + renderScreen( + + + } /> + + , + { initialEntries: ['/members/jane-doe'] }, + ); + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Jane Doe', level: 1 })).toBeInTheDocument(); + }); + expect(screen.queryByRole('link', { name: /manage account/i })).not.toBeInTheDocument(); + }); });