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 */}