From 5646dab359eb3c1cd8f252de5011f42b3980928c Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Fri, 26 Jun 2026 10:58:06 -0400 Subject: [PATCH 1/6] 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/6] 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(); + }); }); From 344c30ea1923fe2cf7546491b34903bc51bb53eb Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 27 Jun 2026 05:15:51 -0400 Subject: [PATCH 4/6] feat(web): soft-delete banner on ProjectDetail for staff (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit project-detail.md declares a staff-only banner across the top of a soft-deleted project with a Restore action; the SPA never rendered it because the project response didn't carry deletedAt. - Expose `deletedAt` on the project detail response (serializer + spec api/projects.md). Null for active projects; non-null only when staff fetch a soft-deleted one (non-staff get 404). - Render the yellow banner with a Restore button (reuses the existing action runner + project refetch). Gated on deletedAt + staff accountLevel. - Tests: banner shows for staff + deleted, absent for active. The "More ▼" header dropdown (the other half of this ProjectDetail pairing in #113) is a separate follow-up — it's a pure UI refactor with no API dependency. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/services/serializers/project.ts | 2 ++ apps/web/src/lib/api.ts | 1 + apps/web/src/screens/ProjectDetail.tsx | 22 ++++++++++++++++++++ apps/web/tests/ProjectDetail.test.tsx | 19 +++++++++++++++++ specs/api/projects.md | 5 ++++- 5 files changed, 48 insertions(+), 1 deletion(-) diff --git a/apps/api/src/services/serializers/project.ts b/apps/api/src/services/serializers/project.ts index dd996fb..2fbe21a 100644 --- a/apps/api/src/services/serializers/project.ts +++ b/apps/api/src/services/serializers/project.ts @@ -76,6 +76,7 @@ export interface ProjectDetail { readonly featured: boolean; readonly createdAt: string; readonly updatedAt: string; + readonly deletedAt: string | null; } export interface HelpWantedRoleSummary { @@ -243,5 +244,6 @@ export function serializeProjectDetail( featured: project.featured, createdAt: project.createdAt, updatedAt: project.updatedAt, + deletedAt: project.deletedAt ?? null, }; } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index aad8868..4a9c1d7 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -175,6 +175,7 @@ export interface ProjectDetail { readonly featured: boolean; readonly createdAt: string; readonly updatedAt: string; + readonly deletedAt: string | null; } export interface PersonListItem { diff --git a/apps/web/src/screens/ProjectDetail.tsx b/apps/web/src/screens/ProjectDetail.tsx index 2441aa6..2a4c5b9 100644 --- a/apps/web/src/screens/ProjectDetail.tsx +++ b/apps/web/src/screens/ProjectDetail.tsx @@ -136,6 +136,10 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { const isSoleMaintainer = (myMembership?.isMaintainer ?? false) && maintainerCount === 1; const canJoin = isSignedIn && !isMember; const canLeave = isMember && !isSoleMaintainer; + // Only staff can see a soft-deleted project at all (non-staff get 404), so a + // non-null deletedAt here means the viewer is staff; gate anyway for clarity. + const isStaff = person?.accountLevel === 'staff' || person?.accountLevel === 'administrator'; + const showDeletedBanner = project.deletedAt !== null && isStaff; const runMembership = async (fn: () => Promise): Promise => { setMemberBusy(true); @@ -154,6 +158,24 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { return (
+ {/* Soft-delete banner — staff only (project-detail.md) */} + {showDeletedBanner && ( +
+ This project is deleted — only staff can see it. + +
+ )} + {/* Header */}
diff --git a/apps/web/tests/ProjectDetail.test.tsx b/apps/web/tests/ProjectDetail.test.tsx index 5c2e654..baf4206 100644 --- a/apps/web/tests/ProjectDetail.test.tsx +++ b/apps/web/tests/ProjectDetail.test.tsx @@ -257,4 +257,23 @@ describe('ProjectDetail', () => { }); expect(screen.queryByRole('button', { name: /join project/i })).not.toBeInTheDocument(); }); + + it('shows the soft-delete banner + Restore for staff viewing a deleted project', async () => { + const deleted = { ...PROJECT, deletedAt: '2026-06-01T00:00:00Z' } as unknown as typeof PROJECT; + mockSignedIn(deleted, 'staff'); + renderDetail(); + await waitFor(() => { + expect(screen.getByText(/this project is deleted/i)).toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: /restore/i })).toBeInTheDocument(); + }); + + it('does not show the soft-delete banner for an active project', async () => { + mockSignedIn({ ...PROJECT, deletedAt: null } as unknown as typeof PROJECT, 'staff'); + renderDetail(); + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Sample Project', level: 1 })).toBeInTheDocument(); + }); + expect(screen.queryByText(/this project is deleted/i)).not.toBeInTheDocument(); + }); }); diff --git a/specs/api/projects.md b/specs/api/projects.md index f0ffb13..488607c 100644 --- a/specs/api/projects.md +++ b/specs/api/projects.md @@ -130,12 +130,15 @@ Fetches a single project by slug. "canDelete": false }, "createdAt": "...", - "updatedAt": "..." + "updatedAt": "...", + "deletedAt": "..." | null } ``` `permissions` is the *current caller's* permissions on this project — the frontend uses it to decide which actions to render. The server still enforces the same rules on each mutation endpoint. +`deletedAt` is `null` for active projects. It is non-null only when a soft-deleted project is fetched by staff (non-staff get `404`), so the SPA can render the soft-delete banner with a Restore action. + ### Errors - `404 not_found` — slug doesn't match (or is soft-deleted and caller can't see deleted). From d0ab542fcdde787fb350981bbc70662f3eefc471 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 27 Jun 2026 05:30:19 -0400 Subject: [PATCH 5/6] feat(blog): excerpt fallback + tag chips (#113) Two blog UI gaps from the spec-drift audit: - BlogIndex (blog-index.md): when `summary` is null, fall back to the first paragraph of `bodyHtml` truncated to ~280 chars (plain-text, derived client-side from the already-sanitized HTML). - BlogDetail (blog-detail.md): render the post's tags as chips in the footer linking to `/blog?tag=`. The blog response didn't carry tags, so the serializer now resolves them (tag-assignments where taggableType=blog_post) and includes a `tags` field (spec api/blog.md updated). Web type + UI added. Tests: BlogIndex excerpt fallback; BlogDetail chips present/absent; API blog suite still green (tags in response). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/services/blog-post.ts | 13 +++- .../api/src/services/serializers/blog-post.ts | 14 +++- apps/web/src/lib/api.ts | 1 + apps/web/src/screens/BlogDetail.tsx | 13 ++++ apps/web/src/screens/BlogIndex.tsx | 23 +++++- apps/web/tests/BlogDetail.test.tsx | 70 +++++++++++++++++++ apps/web/tests/BlogIndex.test.tsx | 29 ++++++++ specs/api/blog.md | 1 + 8 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 apps/web/tests/BlogDetail.test.tsx diff --git a/apps/api/src/services/blog-post.ts b/apps/api/src/services/blog-post.ts index aedceed..62f032c 100644 --- a/apps/api/src/services/blog-post.ts +++ b/apps/api/src/services/blog-post.ts @@ -4,7 +4,7 @@ * Per specs/api/blog.md. Writes happen via PR to the data repo, not the * runtime — no mutation methods here. */ -import type { BlogPost } from '@cfp/shared/schemas'; +import type { BlogPost, Tag } from '@cfp/shared/schemas'; import type { InMemoryState } from '../store/memory/state.js'; import { serializeBlogPost, type BlogPostResponse } from './serializers/blog-post.js'; @@ -66,6 +66,15 @@ export class BlogPostService { #serialize(post: BlogPost): BlogPostResponse { const author = post.authorId ? (this.#state.people.get(post.authorId) ?? null) : null; - return serializeBlogPost(post, { author }); + return serializeBlogPost(post, { author, tags: this.#tagsFor(post.id) }); + } + + #tagsFor(postId: string): Tag[] { + const taIds = this.#state.tagAssignmentsByTaggable.get(postId) ?? new Set(); + return [...taIds] + .map((taId) => this.#state.tagAssignments.get(taId)) + .filter((ta): ta is NonNullable => ta?.taggableType === 'blog_post') + .map((ta) => this.#state.tags.get(ta.tagId)) + .filter((t): t is Tag => t !== undefined); } } diff --git a/apps/api/src/services/serializers/blog-post.ts b/apps/api/src/services/serializers/blog-post.ts index 5b5747e..e4de21d 100644 --- a/apps/api/src/services/serializers/blog-post.ts +++ b/apps/api/src/services/serializers/blog-post.ts @@ -1,8 +1,14 @@ /** * BlogPost serializer. */ -import type { BlogPost, Person } from '@cfp/shared/schemas'; -import { renderMarkdown, serializePersonAvatar, type PersonAvatar } from './common.js'; +import type { BlogPost, Person, Tag } from '@cfp/shared/schemas'; +import { + renderMarkdown, + serializePersonAvatar, + serializeTagItem, + type PersonAvatar, + type TagItem, +} from './common.js'; export interface BlogPostResponse { readonly id: string; @@ -16,13 +22,14 @@ export interface BlogPostResponse { readonly featuredImageUrl: string | null; readonly body: string; readonly bodyHtml: string; + readonly tags: TagItem[]; readonly createdAt: string; readonly updatedAt: string; } export function serializeBlogPost( post: BlogPost, - opts: { author: Person | null }, + opts: { author: Person | null; tags?: Tag[] }, ): BlogPostResponse { return { id: post.id, @@ -38,6 +45,7 @@ export function serializeBlogPost( : null, body: post.body, bodyHtml: renderMarkdown(post.body).html, + tags: (opts.tags ?? []).map(serializeTagItem), createdAt: post.createdAt, updatedAt: post.updatedAt, }; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 4a9c1d7..94130a3 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -297,6 +297,7 @@ export interface BlogPostResponse { readonly featuredImageUrl: string | null; readonly body: string; readonly bodyHtml: string; + readonly tags: TagItem[]; readonly createdAt: string; readonly updatedAt: string; } diff --git a/apps/web/src/screens/BlogDetail.tsx b/apps/web/src/screens/BlogDetail.tsx index e7e2a12..8fcbb1c 100644 --- a/apps/web/src/screens/BlogDetail.tsx +++ b/apps/web/src/screens/BlogDetail.tsx @@ -68,6 +68,19 @@ export function BlogDetail() {
+ {post.tags.length > 0 && ( +
+ {post.tags.map((t) => ( + + {t.title} + + ))} +
+ )} ← Back to all posts diff --git a/apps/web/src/screens/BlogIndex.tsx b/apps/web/src/screens/BlogIndex.tsx index 36e1253..8d3c383 100644 --- a/apps/web/src/screens/BlogIndex.tsx +++ b/apps/web/src/screens/BlogIndex.tsx @@ -147,15 +147,32 @@ function BlogIndexCard({ post }: { post: BlogPostResponse }) { ) : null}
- {post.summary && ( -

{post.summary}

- )} + {(() => { + // blog-index.md Display Rules: show `summary`; if absent, fall back + // to the first paragraph of bodyHtml truncated to ~280 chars. + const text = post.summary ?? excerptFromHtml(post.bodyHtml); + return text ? ( +

{text}

+ ) : null; + })()}
); } +/** + * Plain-text excerpt from already-sanitized post HTML: the first paragraph's + * text, truncated to ~280 chars at a word boundary. Only strips tags for the + * preview — the full post renders via the server-sanitized HTML elsewhere. + */ +function excerptFromHtml(html: string): string { + const firstParagraph = /]*>(.*?)<\/p>/is.exec(html)?.[1] ?? html; + const text = firstParagraph.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); + if (text.length <= 280) return text; + return text.slice(0, 280).replace(/\s+\S*$/, '') + '…'; +} + function formatPostedAt(iso: string): string { const d = new Date(iso); return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); diff --git a/apps/web/tests/BlogDetail.test.tsx b/apps/web/tests/BlogDetail.test.tsx new file mode 100644 index 0000000..a33e23e --- /dev/null +++ b/apps/web/tests/BlogDetail.test.tsx @@ -0,0 +1,70 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { Routes, Route } from 'react-router'; +import { renderScreen, mockOk } from './test-utils.js'; +import { BlogDetail } from '../src/screens/BlogDetail.js'; +import { AuthProvider } from '../src/hooks/useAuth.js'; + +const POST = { + id: '01951a3c-0000-7000-8000-bbbbbbbbbbbb', + slug: 'roundup', + title: 'Civic Tech Roundup', + summary: null, + author: null, + postedAt: '2026-05-10T12:00:00Z', + editedAt: null, + featuredImageKey: null, + featuredImageUrl: null, + body: '# x', + bodyHtml: '

Body

', + tags: [{ namespace: 'topic', slug: 'transit', title: 'Transit' }], + createdAt: '2026-05-10T12:00:00Z', + updatedAt: '2026-05-10T12:00:00Z', +}; + +describe('BlogDetail tag chips', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function mock(post: typeof POST): void { + vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => { + if (input.startsWith('/api/auth/me')) return Promise.resolve(new Response(null, { status: 404 })); + if (input.startsWith('/api/blog-posts/roundup')) { + return Promise.resolve( + new Response(JSON.stringify(mockOk(post)), { status: 200, headers: { 'content-type': 'application/json' } }), + ); + } + return Promise.resolve(new Response(null, { status: 404 })); + }) as typeof fetch); + } + + function render(): void { + renderScreen( + + + } /> + + , + { initialEntries: ['/blog/roundup'] }, + ); + } + + it('renders tag chips linking to /blog?tag=', async () => { + mock(POST); + render(); + await waitFor(() => { + expect(screen.getByRole('link', { name: 'Transit' })).toBeInTheDocument(); + }); + expect(screen.getByRole('link', { name: 'Transit' })).toHaveAttribute('href', '/blog?tag=topic.transit'); + }); + + it('renders no tag chips when the post has no tags', async () => { + mock({ ...POST, tags: [] } as unknown as typeof POST); + render(); + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Civic Tech Roundup' })).toBeInTheDocument(); + }); + expect(screen.queryByRole('link', { name: 'Transit' })).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/tests/BlogIndex.test.tsx b/apps/web/tests/BlogIndex.test.tsx index 9b0f06b..8a4048c 100644 --- a/apps/web/tests/BlogIndex.test.tsx +++ b/apps/web/tests/BlogIndex.test.tsx @@ -68,6 +68,35 @@ describe('BlogIndex', () => { expect(screen.getByText('A short blurb.')).toBeInTheDocument(); }); + it('falls back to a bodyHtml first-paragraph excerpt when summary is null', async () => { + const noSummary = { + ...SAMPLE_POST, + summary: null, + bodyHtml: '

Heading

First paragraph of the body.

Second.

', + }; + vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => { + if (input.startsWith('/api/auth/me')) return Promise.resolve(new Response(null, { status: 404 })); + if (input.startsWith('/api/blog-posts')) { + return Promise.resolve( + new Response(JSON.stringify(mockPaginated([noSummary], { totalItems: 1 })), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + } + return Promise.resolve(new Response(null, { status: 404 })); + }) as typeof fetch); + renderScreen( + + + , + { initialEntries: ['/blog'] }, + ); + await waitFor(() => { + expect(screen.getByText('First paragraph of the body.')).toBeInTheDocument(); + }); + }); + it('renders the empty state when no posts are returned', async () => { vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => { if (input.startsWith('/api/auth/me')) { diff --git a/specs/api/blog.md b/specs/api/blog.md index 99072fa..4447190 100644 --- a/specs/api/blog.md +++ b/specs/api/blog.md @@ -60,6 +60,7 @@ Standard 404 envelope (per [conventions.md](conventions.md)). Slug-history redir "featuredImageUrl": "/api/attachments/blog-posts/civic-tech-roundup-2026/cover.jpg", // or null — derived from featuredImageKey "body": "Markdown source", "bodyHtml": "

...

", // sanitized HTML, server-rendered + "tags": [{ "namespace": "topic", "slug": "transit", "title": "Transit" }, ...], // tags assigned to the post; [] when none "createdAt": "...", "updatedAt": "..." } From 786d8f3d426277e1f8a673d586926931d4820227 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 27 Jun 2026 05:38:11 -0400 Subject: [PATCH 6/6] =?UTF-8?q?feat(web):=20consolidate=20ProjectDetail=20?= =?UTF-8?q?header=20actions=20into=20"More=20=E2=96=BE"=20(#113)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per project-detail.md, the header keeps "Edit Project" as the primary button and moves the secondary actions into a "More ▾" dropdown: Add Member, Log Buzz, Post Update, Post Help-Wanted Role, Manage Members, and (admin) Delete Project behind a confirm dialog. The previously-contextual section buttons (Post Update, Log Buzz, Post new role) are removed now that they live in the header dropdown — single source per the spec. Delete soft-deletes via the existing endpoint and refetches, so the soft-delete banner (from the prior PR) appears for staff immediately. Tests: dropdown trigger present for management perms, absent for anonymous. Completes the #113 UI-gaps umbrella. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/screens/ProjectDetail.tsx | 133 +++++++++++++++++++------ apps/web/tests/ProjectDetail.test.tsx | 28 ++++++ 2 files changed, 131 insertions(+), 30 deletions(-) diff --git a/apps/web/src/screens/ProjectDetail.tsx b/apps/web/src/screens/ProjectDetail.tsx index 2a4c5b9..35d592e 100644 --- a/apps/web/src/screens/ProjectDetail.tsx +++ b/apps/web/src/screens/ProjectDetail.tsx @@ -2,6 +2,21 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useParams, useSearchParams } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} from '@/components/ui/dropdown-menu'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; import { MarkdownView } from '@/components/MarkdownView'; import { StageProgressBar, StageBadge } from '@/components/StageBadge'; import { StageInfoDialog } from '@/components/StageInfoDialog'; @@ -53,6 +68,7 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { const [stageInfoOpen, setStageInfoOpen] = useState(false); const [memberBusy, setMemberBusy] = useState(false); const [memberError, setMemberError] = useState(null); + const [deleteOpen, setDeleteOpen] = useState(false); // 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. @@ -154,6 +170,20 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { } }; + const doDelete = async (): Promise => { + setMemberBusy(true); + setMemberError(null); + try { + await api.projects.delete(slug); + await projectQ.refetch(); + setDeleteOpen(false); + } catch (err) { + setMemberError(err instanceof ApiError ? err.message : 'Could not delete the project.'); + } finally { + setMemberBusy(false); + } + }; + const allTags = [...project.tags.tech, ...project.tags.topic, ...project.tags.event]; return ( @@ -186,15 +216,54 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { Edit Project )} - {perms.canManageMembers && ( - - )} - {perms.canManageMembers && ( - + {(perms.canManageMembers || + perms.canPostUpdate || + perms.canLogBuzz || + perms.canPostHelpWanted || + perms.canDelete) && ( + + + + + + {perms.canManageMembers && ( + setAddMemberOpen(true)}> + Add Member + + )} + {perms.canLogBuzz && ( + + Log Buzz + + )} + {perms.canPostUpdate && ( + setUpdateModalOpen(true)}> + Post Update + + )} + {perms.canPostHelpWanted && ( + setHelpWantedModalOpen(true)}> + Post Help-Wanted Role + + )} + {perms.canManageMembers && ( + setManageMembersOpen(true)}> + Manage Members + + )} + {perms.canDelete && ( + <> + + setDeleteOpen(true)} + > + Delete Project + + + )} + + )} {!isSignedIn && ( - )} {helpWantedRoles.length === 0 ? (

No open roles right now.

@@ -303,22 +367,6 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) {

Project Activity

-
- {perms.canPostUpdate && ( - - )} - {isSignedIn && ( - - )} -
{updatesQ.isLoading || buzzQ.isLoading ? ( @@ -604,6 +652,31 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { roleTitle={fillRole.title} /> )} + + + + + Delete this project? + + “{project.title}” will be soft-deleted and hidden from public lists. Staff can + restore it afterward. + + + {memberError && ( +

+ {memberError} +

+ )} + + + + +
+
); } diff --git a/apps/web/tests/ProjectDetail.test.tsx b/apps/web/tests/ProjectDetail.test.tsx index baf4206..ff4c486 100644 --- a/apps/web/tests/ProjectDetail.test.tsx +++ b/apps/web/tests/ProjectDetail.test.tsx @@ -276,4 +276,32 @@ describe('ProjectDetail', () => { }); expect(screen.queryByText(/this project is deleted/i)).not.toBeInTheDocument(); }); + + it('shows the "More" actions dropdown for users with management permissions', async () => { + const asAdmin = { + ...PROJECT, + permissions: { ...PROJECT.permissions, canManageMembers: true, canPostUpdate: true, canDelete: true }, + } as unknown as typeof PROJECT; + mockSignedIn(asAdmin, 'administrator'); + renderDetail(); + await waitFor(() => { + expect(screen.getByRole('button', { name: /more/i })).toBeInTheDocument(); + }); + }); + + it('shows no "More" dropdown for anonymous viewers', async () => { + // default beforeEach mock is anonymous with no permissions + renderScreen( + + + } /> + + , + { initialEntries: ['/projects/sample-project'] }, + ); + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Sample Project', level: 1 })).toBeInTheDocument(); + }); + expect(screen.queryByRole('button', { name: /more/i })).not.toBeInTheDocument(); + }); });