Skip to content

Commit 50f5fe3

Browse files
authored
Add blog tag filtering to posts page (#232)
* Add blog tag filtering * refine agent.md * Update blog tag metadata
1 parent b34cf11 commit 50f5fe3

File tree

7 files changed

+483
-30
lines changed

7 files changed

+483
-30
lines changed

AGENTS.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,11 @@ Instructions for Codex when working in this repository:
1212
- Where appropriate, add new unit and e2e tests.
1313
- Never change the `next-env.d.ts` file.
1414
- Never, ever change the blog contents because that is my personal writing and should never change.
15-
15+
- For distinct feature requests, create a new branch before making commits. Branch names must use the format `tc/name-of-feature`, where `tc` is fixed and `name-of-feature` is a short kebab-case description of the feature.
16+
- Never, ever commit directly to the `main` or `master` branch. If the current branch is `main` or `master`, create and switch to a valid feature branch before committing any feature work.
17+
- You may use `git` commands such as `git add`, `git checkout -b`, `git switch -c`, `git commit`, and `git push` as part of feature delivery, but only when working from a non-`main` and non-`master` branch.
18+
- For the normal pull request workflow, you do not need to ask permission before using routine branch/PR commands such as `git checkout -b`, `git switch -c`, `git add`, `git commit`, `git push`, and `gh pr create`, as long as you still follow the branch, validation, and non-`main`/non-`master` rules in this file.
19+
- For new feature work, prefer a pull request workflow: create the feature branch, implement the change, validate locally, push the branch, and open a pull request.
20+
- Do not open a pull request until both `npm run test` and `npm run build` have completed successfully locally. If either validation fails, fix the issue before creating the pull request.
21+
- Pull requests should include a detailed description of the changes, including the purpose of the feature, the main implementation details, any tests or validation performed, and any follow-up notes that would help with review.
22+
- If the user explicitly says not to create a pull request, or explicitly asks for changes without branch creation or PR creation, skip the PR workflow and follow the user's instruction instead. In that case, you may still make the code changes locally, but do not open a PR unless the user later asks for one.

lib/posts/postsService.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { IPost } from '../../types/types';
2+
import { byNewestFirst } from '../helpers';
23

34
export const postsData: IPost[] = [
45
{
56
slug: 'a-letter-to-my-pre-bootcamp-self',
67
timeStamp: 1601009408032,
78
title: 'A letter to my pre-bootcamp self',
9+
tags: ['Career', 'Learning', 'Reflection', 'Programming'],
810
previewImgSrc: '/images/blog/a-letter.png',
911
preview:
1012
'Enrolling in a bootcamp takes some serious guts. Leaving behind a stable job and regular pay can be daunting. Even if your job is not stable or comfortable, going 12+ weeks without pay is extremely scary...',
@@ -14,6 +16,7 @@ export const postsData: IPost[] = [
1416
slug: 'hunting-with-my-father',
1517
timeStamp: 1596009408032,
1618
title: 'Hunting With My Father',
19+
tags: ['Family', 'Hunting', 'Outdoors'],
1720
previewImgSrc: '/images/blog/hunting-w-my-father.jpg',
1821
preview:
1922
'By the late afternoon on day five, I had squandered as many chances as a reasonable person could hope for. My buddies and I had hunted hard, and I was exhausted...',
@@ -23,6 +26,7 @@ export const postsData: IPost[] = [
2326
slug: 'just-the-right-thing',
2427
timeStamp: 1563009408032,
2528
title: 'Just the Right Thing',
29+
tags: ['Mindset', 'Reflection', 'Work'],
2630
previewImgSrc:
2731
'/images/blog/just-the-right-thing/just-the-right-thing-2.jpg',
2832
preview:
@@ -33,6 +37,7 @@ export const postsData: IPost[] = [
3337
slug: 'maf',
3438
timeStamp: 1554009408032,
3539
title: 'M.A.F.',
40+
tags: ['Health', 'Learning', 'Running'],
3641
previewImgSrc: '/images/blog/MAF/MAFTestInitial.jpg',
3742
preview:
3843
'The stress was piling on. The "fires" were popping up left and right. Urgent phone calls had to be made. My head was spinning. I had to take a walk...',
@@ -42,6 +47,7 @@ export const postsData: IPost[] = [
4247
slug: 'one-really-well-written-paragraph',
4348
timeStamp: 1563009408032,
4449
title: 'One Really Well-Written Paragraph',
50+
tags: ['Learning', 'Reflection', 'Writing'],
4551
previewImgSrc: '/images/blog/one-paragraph.jpg',
4652
preview:
4753
'A life-altering bit of knowledge can come from one reallywell-written paragraph...',
@@ -51,6 +57,7 @@ export const postsData: IPost[] = [
5157
slug: 'ritual',
5258
timeStamp: 1580438545989,
5359
title: 'Ritual',
60+
tags: ['Outdoors', 'Hunting'],
5461
previewImgSrc: '/images/blog/ritual/ritual-2.jpg',
5562
preview:
5663
'The order of operations is always the same. We park our trucks at odd angles in the alleyway behind the garage. The radio plays classic rock. We crack open beers immediately upon arrival...',
@@ -60,6 +67,7 @@ export const postsData: IPost[] = [
6067
slug: 'most-important-question',
6168
timeStamp: 1593438545989,
6269
title: 'The Most Important Question',
70+
tags: ['Mindset', 'Reflection'],
6371
previewImgSrc: '/images/blog/MIQ.jpg',
6472
preview: 'What is my MIQ?',
6573
componentName: 'MostImportantQuestion',
@@ -68,6 +76,7 @@ export const postsData: IPost[] = [
6876
slug: 'where-is-the-fear',
6977
timeStamp: 1563009408032,
7078
title: 'Where is the Fear?',
79+
tags: ['Creativity', 'Mindset', 'Writing'],
7180
previewImgSrc: '/images/blog/where-is-the-fear.jpg',
7281
preview:
7382
'The Resistance is the enemy within ourselves that opposes all of our most important creative ambitions...',
@@ -77,6 +86,7 @@ export const postsData: IPost[] = [
7786
slug: 'worn-out-boots',
7887
timeStamp: 1569009408032,
7988
title: 'Worn Out Boots',
89+
tags: ['Hunting', 'Outdoors', 'Reflection'],
8090
previewImgSrc: '/images/blog/newboots.jpg',
8191
preview:
8292
'The condition of a hunter’s boots is directly correlated to effort expended in the field. It takes time and mileage to wear out a good pair...',
@@ -86,10 +96,26 @@ export const postsData: IPost[] = [
8696
slug: 'go-lang',
8797
timeStamp: 1667486292000,
8898
title: 'What I Learned from Learning GoLang',
99+
tags: ['Career', 'Learning', 'Programming'],
89100
componentName: 'LearningGolang',
90101
},
91102
];
92103

104+
export const getSortedPostsData = (): IPost[] => [...postsData].sort(byNewestFirst);
105+
106+
export const getSortedPostTags = (): string[] => {
107+
const tags = postsData.flatMap((post) => post.tags);
108+
return Array.from(new Set(tags)).sort((a, b) => a.localeCompare(b));
109+
};
110+
111+
export const filterPostsByTag = (posts: IPost[], tag?: string): IPost[] => {
112+
if (!tag) {
113+
return posts;
114+
}
115+
116+
return posts.filter((post) => post.tags.includes(tag));
117+
};
118+
93119
export const getOnePostData = (slug: string): IPost | undefined => {
94120
const post = postsData.find((post) => post.slug === slug);
95121
return post;

pages/posts/index.tsx

Lines changed: 158 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,187 @@
1+
import { useEffect, useState } from 'react';
12
import Link from 'next/link';
3+
import { useRouter } from 'next/router';
24
import Layout from '../../components/Layout';
35
import { IPost } from '../../types/types';
46
import { GetStaticProps } from 'next';
5-
import { formatDate, byNewestFirst } from '../../lib/helpers';
6-
import { postsData } from '../../lib/posts/postsService';
7+
import { formatDate } from '../../lib/helpers';
8+
import {
9+
filterPostsByTag,
10+
getSortedPostTags,
11+
getSortedPostsData,
12+
} from '../../lib/posts/postsService';
713
import styles from '../../styles/ContentPages.module.css';
814

915
interface IProps {
1016
posts: IPost[];
17+
tags: string[];
1118
}
1219

13-
const PostsHome = ({ posts }: IProps) => {
20+
const getTagFromQuery = (
21+
tagQuery: string | string[] | undefined
22+
): string | null => {
23+
if (Array.isArray(tagQuery)) {
24+
return tagQuery[0] ?? null;
25+
}
26+
27+
return tagQuery ?? null;
28+
};
29+
30+
const PostsHome = ({ posts, tags }: IProps) => {
31+
const router = useRouter();
32+
const [selectedTag, setSelectedTag] = useState<string | null>(null);
33+
34+
useEffect(() => {
35+
if (!router.isReady) {
36+
return;
37+
}
38+
39+
setSelectedTag(getTagFromQuery(router.query.tag));
40+
}, [router.isReady, router.query.tag]);
41+
42+
const visiblePosts = filterPostsByTag(posts, selectedTag ?? undefined);
43+
const tagCounts = posts.reduce<Record<string, number>>((counts, post) => {
44+
post.tags.forEach((tag) => {
45+
counts[tag] = (counts[tag] ?? 0) + 1;
46+
});
47+
48+
return counts;
49+
}, {});
50+
51+
const updateTagFilter = async (tag: string | null) => {
52+
setSelectedTag(tag);
53+
54+
if (tag) {
55+
await router.replace(
56+
{
57+
pathname: '/posts',
58+
query: { tag },
59+
},
60+
undefined,
61+
{ shallow: true, scroll: false }
62+
);
63+
64+
return;
65+
}
66+
67+
await router.replace('/posts', undefined, {
68+
shallow: true,
69+
scroll: false,
70+
});
71+
};
72+
1473
return (
1574
<Layout>
1675
<div className={styles.page}>
1776
<header className={styles.pageHeader}>
1877
<p className={styles.eyebrow}>Writing</p>
1978
<h2 className={styles.pageTitle}>Blog</h2>
2079
</header>
21-
<ul className={styles.postList}>
22-
{posts.map((post) => (
23-
<li key={post.title}>
24-
<Link
25-
className={styles.postLink}
26-
key={`${post.title}-link`}
27-
href={'/posts/[slug]'}
28-
as={`/posts/${post.slug}`}
29-
passHref
80+
<section aria-labelledby="blog-filters" className={styles.postFilters}>
81+
<div className={styles.filterHeader}>
82+
<div>
83+
<h3 className={styles.filterTitle} id="blog-filters">
84+
Filter by tag
85+
</h3>
86+
<p className={styles.filterSummary}>
87+
{selectedTag
88+
? `${visiblePosts.length} post${
89+
visiblePosts.length === 1 ? '' : 's'
90+
} tagged “${selectedTag}”`
91+
: `Browse all ${posts.length} posts`}
92+
</p>
93+
</div>
94+
{selectedTag && (
95+
<button
96+
className={styles.clearFiltersButton}
97+
onClick={() => updateTagFilter(null)}
98+
type="button"
99+
>
100+
Clear filters
101+
</button>
102+
)}
103+
</div>
104+
<div aria-label="Blog tags" className={styles.filterChips} role="toolbar">
105+
<button
106+
aria-pressed={selectedTag === null}
107+
className={`${styles.filterChip} ${
108+
selectedTag === null ? styles.filterChipActive : ''
109+
}`}
110+
onClick={() => updateTagFilter(null)}
111+
type="button"
112+
>
113+
All
114+
</button>
115+
{tags.map((tag) => (
116+
<button
117+
aria-pressed={selectedTag === tag}
118+
className={`${styles.filterChip} ${
119+
selectedTag === tag ? styles.filterChipActive : ''
120+
}`}
121+
key={tag}
122+
onClick={() => updateTagFilter(tag)}
123+
type="button"
30124
>
31-
<article className={styles.postCard}>
32-
<div className={styles.postCardInner}>
33-
<h3 className={styles.postHeading}>{post.title}</h3>
34-
<p className={styles.postMeta}>{formatDate(post)}</p>
35-
</div>
36-
</article>
37-
</Link>
38-
</li>
39-
))}
40-
</ul>
125+
{tag} <span className={styles.filterChipCount}>({tagCounts[tag]})</span>
126+
</button>
127+
))}
128+
</div>
129+
</section>
130+
{visiblePosts.length > 0 ? (
131+
<ul className={styles.postList}>
132+
{visiblePosts.map((post) => (
133+
<li key={post.title}>
134+
<Link
135+
className={styles.postLink}
136+
key={`${post.title}-link`}
137+
href={'/posts/[slug]'}
138+
as={`/posts/${post.slug}`}
139+
passHref
140+
>
141+
<article className={styles.postCard}>
142+
<div className={styles.postCardInner}>
143+
<div className={styles.postCardContent}>
144+
<h3 className={styles.postHeading}>{post.title}</h3>
145+
<ul aria-label={`${post.title} tags`} className={styles.postTagList}>
146+
{post.tags.map((tag) => (
147+
<li className={styles.postTag} key={`${post.slug}-${tag}`}>
148+
{tag}
149+
</li>
150+
))}
151+
</ul>
152+
</div>
153+
<p className={styles.postMeta}>{formatDate(post)}</p>
154+
</div>
155+
</article>
156+
</Link>
157+
</li>
158+
))}
159+
</ul>
160+
) : (
161+
<section className={styles.emptyState}>
162+
<h3 className={styles.emptyStateTitle}>No posts match this tag yet.</h3>
163+
<p className={styles.emptyStateMessage}>
164+
Try another tag or return to the full blog list.
165+
</p>
166+
<button
167+
className={styles.clearFiltersButton}
168+
onClick={() => updateTagFilter(null)}
169+
type="button"
170+
>
171+
View all posts
172+
</button>
173+
</section>
174+
)}
41175
</div>
42176
</Layout>
43177
);
44178
};
45179

46180
export const getStaticProps: GetStaticProps = async () => {
47-
postsData.sort(byNewestFirst);
48-
49181
return {
50182
props: {
51-
posts: postsData,
183+
posts: getSortedPostsData(),
184+
tags: getSortedPostTags(),
52185
},
53186
};
54187
};

0 commit comments

Comments
 (0)