Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions apps/api/src/services/blog-post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string>();
return [...taIds]
.map((taId) => this.#state.tagAssignments.get(taId))
.filter((ta): ta is NonNullable<typeof ta> => ta?.taggableType === 'blog_post')
.map((ta) => this.#state.tags.get(ta.tagId))
.filter((t): t is Tag => t !== undefined);
}
}
14 changes: 11 additions & 3 deletions apps/api/src/services/serializers/blog-post.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
Expand All @@ -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,
};
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/services/serializers/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export interface ProjectDetail {
readonly featured: boolean;
readonly createdAt: string;
readonly updatedAt: string;
readonly deletedAt: string | null;
}

export interface HelpWantedRoleSummary {
Expand Down Expand Up @@ -243,5 +244,6 @@ export function serializeProjectDetail(
featured: project.featured,
createdAt: project.createdAt,
updatedAt: project.updatedAt,
deletedAt: project.deletedAt ?? null,
};
}
2 changes: 1 addition & 1 deletion apps/web/src/components/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ export function AppHeader() {
<img
src="/img/logo-horizontal.svg"
alt="Code for Philly"
className="h-8 w-auto"
className="h-12 w-auto"
/>
</Link>

Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export interface ProjectDetail {
readonly featured: boolean;
readonly createdAt: string;
readonly updatedAt: string;
readonly deletedAt: string | null;
}

export interface PersonListItem {
Expand Down Expand Up @@ -296,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;
}
Expand Down Expand Up @@ -591,6 +593,10 @@ export const api = {
request(`/api/projects/${encodeURIComponent(slug)}`, { method: 'DELETE' }),
restore: (slug: string): Promise<SuccessEnvelope<ProjectDetail>> =>
request(`/api/projects/${encodeURIComponent(slug)}/restore`, { method: 'POST' }),
join: (slug: string): Promise<void> =>
request(`/api/projects/${encodeURIComponent(slug)}/members/join`, { method: 'POST' }),
leave: (slug: string): Promise<void> =>
request(`/api/projects/${encodeURIComponent(slug)}/members/leave`, { method: 'POST' }),
changeMaintainer: (slug: string, personSlug: string): Promise<SuccessEnvelope<ProjectDetail>> =>
request(`/api/projects/${encodeURIComponent(slug)}/change-maintainer`, {
method: 'POST',
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/screens/BlogDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,19 @@ export function BlogDetail() {
</div>

<footer className="mt-10 pt-6 border-t border-border">
{post.tags.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2">
{post.tags.map((t) => (
<Link
key={`${t.namespace}.${t.slug}`}
to={`/blog?tag=${encodeURIComponent(`${t.namespace}.${t.slug}`)}`}
className="inline-flex items-center rounded-full border border-border bg-muted px-2.5 py-0.5 text-xs text-muted-foreground hover:text-foreground"
>
{t.title}
</Link>
))}
</div>
)}
<Link to="/blog" className="text-primary underline">
← Back to all posts
</Link>
Expand Down
23 changes: 20 additions & 3 deletions apps/web/src/screens/BlogIndex.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,15 +147,32 @@ function BlogIndexCard({ post }: { post: BlogPostResponse }) {
) : null}
<time dateTime={post.postedAt}>{formatPostedAt(post.postedAt)}</time>
</div>
{post.summary && (
<p className="text-muted-foreground mt-2 leading-relaxed">{post.summary}</p>
)}
{(() => {
// 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 ? (
<p className="text-muted-foreground mt-2 leading-relaxed">{text}</p>
) : null;
})()}
</div>
</article>
</li>
);
}

/**
* 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[^>]*>(.*?)<\/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' });
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/screens/PersonDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { MarkdownView } from '@/components/MarkdownView';
import { StageBadge } from '@/components/StageBadge';
import { TagChip } from '@/components/TagChip';
import { PersonAvatar } from '@/components/PersonAvatar';
import { useAuth } from '@/hooks/useAuth';
import { api, ApiError } from '@/lib/api';
import { formatMonthYear, formatRelativeTime } from '@/lib/time';

export function PersonDetail() {
const params = useParams();
const slug = params['slug']!;
const { person: viewer } = useAuth();

const personQ = useQuery({
queryKey: ['person', slug],
Expand All @@ -37,6 +39,7 @@ export function PersonDetail() {
}

const person = personQ.data!.data;
const isSelf = viewer !== null && viewer.slug === person.slug;
const allTags = [...person.tags.tech, ...person.tags.topic];

// Memberships sorted: maintainer desc, joinedAt desc
Expand Down Expand Up @@ -193,6 +196,13 @@ export function PersonDetail() {
</h3>
<p>{formatMonthYear(person.createdAt)}</p>
</section>
{isSelf && (
<section>
<Link to="/account" className="text-primary underline hover:no-underline">
Manage account
</Link>
</section>
)}
</aside>
</div>
);
Expand Down
Loading
Loading