Skip to content

Commit 2879b72

Browse files
added tag cloud for projects with filter, and updated home page to show project count
1 parent e68b3b7 commit 2879b72

2 files changed

Lines changed: 186 additions & 7 deletions

File tree

src/pages/index.astro

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ const posts = (await getCollection('blog'))
99
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
1010
const postsSlice = posts.slice(0, 3);
1111
12+
const featuredProjectLimit = 4;
1213
const projects = (await getCollection('projects'))
1314
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
14-
const projectsSlice = projects.slice(0, 4);
15+
const projectsSlice = projects.slice(0, featuredProjectLimit);
1516
---
1617

1718
<PostHogLayout>
@@ -54,8 +55,8 @@ const projectsSlice = projects.slice(0, 4);
5455

5556
<section class="py-12 border-t border-blog-border">
5657
<div class="flex items-center justify-between mb-8">
57-
<h2 class="text-2xl font-bold text-blog-accent">Featured Projects</h2>
58-
{projects.length > 4 ? (
58+
<h2 class="text-2xl font-bold text-blog-accent">Featured Projects - {projectsSlice.length}/{projects.length}</h2>
59+
{projects.length > featuredProjectLimit ? (
5960
<a href="/projects" class="text-blog-link flip-animate">View <span data-hover="all">all</span></a>
6061
) : null}
6162
</div>
@@ -71,4 +72,4 @@ const projectsSlice = projects.slice(0, 4);
7172
</div>
7273
</section>
7374
</Layout>
74-
</PostHogLayout>
75+
</PostHogLayout>

src/pages/projects.astro

Lines changed: 181 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,201 @@ import { getCollection } from 'astro:content';
66
77
const projects = (await getCollection('projects'))
88
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
9+
10+
const tagCounts = projects.reduce((counts, project) => {
11+
for (const tag of project.data.tags ?? []) {
12+
counts.set(tag, (counts.get(tag) ?? 0) + 1);
13+
}
14+
return counts;
15+
}, new Map<string, number>());
16+
17+
const tagCloud = [...tagCounts.entries()]
18+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
19+
.map(([tag, count]) => ({ tag, count }));
20+
21+
const minTagCount = tagCloud.at(-1)?.count ?? 1;
22+
const maxTagCount = tagCloud[0]?.count ?? 1;
23+
24+
const getTagSizeClass = (count: number) => {
25+
if (maxTagCount === minTagCount) return 'text-sm';
26+
27+
const normalized = (count - minTagCount) / (maxTagCount - minTagCount);
28+
if (normalized >= 0.8) return 'text-xl';
29+
if (normalized >= 0.6) return 'text-lg';
30+
if (normalized >= 0.4) return 'text-base';
31+
if (normalized >= 0.2) return 'text-sm';
32+
return 'text-xs';
33+
};
934
---
1035

1136
<PostHogLayout>
1237
<Layout title="Projects | KyleUndefined.dev" description="Collection of my projects, that I still have, over the years...">
13-
<section class="py-12">
38+
<section class="py-12" data-projects-page>
1439
<div class="max-w-3xl mb-12">
1540
<h1 class="text-4xl font-bold mb-4 text-blog-accent">Projects</h1>
1641
<p class="text-xl text-blog-text">
1742
Collection of my projects, that I still have, over the years...
1843
</p>
1944
</div>
2045

46+
{tagCloud.length > 0 && (
47+
<div class="mb-10 rounded-2xl border border-blog-border bg-blog-code-bg/50 p-6">
48+
<div class="mb-4 flex items-center justify-between gap-3">
49+
<h2 class="text-lg font-semibold text-blog-accent">Tags</h2>
50+
<a href="/projects" class="text-sm text-blog-link hidden flip-animate" data-clear-filter>
51+
Clear <span data-hover="filter">filter</span>
52+
</a>
53+
</div>
54+
55+
<div class="flex flex-wrap items-center gap-3">
56+
{tagCloud.map(({ tag, count }) => {
57+
const sizeClass = getTagSizeClass(count);
58+
59+
return (
60+
<a
61+
href={`/projects?tag=${encodeURIComponent(tag)}`}
62+
data-cloud-tag={tag}
63+
class:list={[
64+
'inline-flex items-center gap-2 rounded-full border px-3 py-1 transition-colors',
65+
sizeClass,
66+
'border-blog-code/20 bg-blog-code-bg text-blog-text hover:border-blog-accent hover:text-blog-link',
67+
]}
68+
>
69+
<span>{tag}</span>
70+
<span class="text-[11px] opacity-75">{count}</span>
71+
</a>
72+
);
73+
})}
74+
</div>
75+
</div>
76+
)}
77+
78+
<p class="mb-6 text-sm text-blog-text hidden" data-filter-summary>
79+
Showing <span data-filter-count>0</span> <span data-filter-label>projects</span> tagged{' '}
80+
<span class="font-semibold text-blog-accent" data-filter-tag></span>:
81+
</p>
82+
2183
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
2284
{projects.map((project) => (
23-
<ProjectCard project={project} />
85+
<div data-project-item data-project-tags={JSON.stringify(project.data.tags ?? [])}>
86+
<ProjectCard project={project} />
87+
</div>
2488
))}
2589
</div>
2690
</section>
91+
92+
<script is:inline>
93+
(() => {
94+
const ACTIVE_CLASSES = ['border-blog-proj-link', 'text-blog-proj-link'];
95+
const INACTIVE_CLASSES = [
96+
'border-blog-code/20',
97+
'bg-blog-code-bg',
98+
'text-blog-text',
99+
'hover:border-blog-accent',
100+
'hover:text-blog-link',
101+
];
102+
103+
const setTagState = (tagLink, isActive) => {
104+
if (isActive) {
105+
tagLink.classList.add(...ACTIVE_CLASSES);
106+
tagLink.classList.remove(...INACTIVE_CLASSES);
107+
return;
108+
}
109+
110+
tagLink.classList.remove(...ACTIVE_CLASSES);
111+
tagLink.classList.add(...INACTIVE_CLASSES);
112+
};
113+
114+
const wireTagFilter = () => {
115+
const section = document.querySelector('[data-projects-page]');
116+
if (!section || section.dataset.tagFilterReady === 'true') return;
117+
section.dataset.tagFilterReady = 'true';
118+
119+
const tagLinks = Array.from(section.querySelectorAll('[data-cloud-tag]'));
120+
const clearFilterLink = section.querySelector('[data-clear-filter]');
121+
const summary = section.querySelector('[data-filter-summary]');
122+
const summaryCount = section.querySelector('[data-filter-count]');
123+
const summaryLabel = section.querySelector('[data-filter-label]');
124+
const summaryTag = section.querySelector('[data-filter-tag]');
125+
126+
const projectItems = Array.from(section.querySelectorAll('[data-project-item]')).map((item) => {
127+
let tags = [];
128+
129+
try {
130+
tags = JSON.parse(item.getAttribute('data-project-tags') || '[]');
131+
} catch {
132+
tags = [];
133+
}
134+
135+
return { item, tags: Array.isArray(tags) ? tags : [] };
136+
});
137+
138+
let activeTag = '';
139+
140+
const updateUrl = (tag) => {
141+
const url = new URL(window.location.href);
142+
if (tag) {
143+
url.searchParams.set('tag', tag);
144+
} else {
145+
url.searchParams.delete('tag');
146+
}
147+
window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`);
148+
};
149+
150+
const applyTagFilter = (tag) => {
151+
activeTag = tag;
152+
let visibleCount = 0;
153+
154+
for (const project of projectItems) {
155+
const matches = !tag || project.tags.includes(tag);
156+
project.item.classList.toggle('hidden', !matches);
157+
if (matches) visibleCount += 1;
158+
}
159+
160+
for (const tagLink of tagLinks) {
161+
const linkTag = tagLink.getAttribute('data-cloud-tag') || '';
162+
setTagState(tagLink, Boolean(tag) && linkTag === tag);
163+
}
164+
165+
if (clearFilterLink) {
166+
clearFilterLink.classList.toggle('hidden', !tag);
167+
}
168+
169+
if (summary && summaryCount && summaryLabel && summaryTag) {
170+
if (!tag) {
171+
summary.classList.add('hidden');
172+
} else {
173+
summaryCount.textContent = String(visibleCount);
174+
summaryLabel.textContent = visibleCount === 1 ? 'project' : 'projects';
175+
summaryTag.textContent = tag;
176+
summary.classList.remove('hidden');
177+
}
178+
}
179+
};
180+
181+
for (const tagLink of tagLinks) {
182+
tagLink.addEventListener('click', (event) => {
183+
event.preventDefault();
184+
const clickedTag = tagLink.getAttribute('data-cloud-tag') || '';
185+
const nextTag = activeTag === clickedTag ? '' : clickedTag;
186+
updateUrl(nextTag);
187+
applyTagFilter(nextTag);
188+
});
189+
}
190+
191+
clearFilterLink?.addEventListener('click', (event) => {
192+
event.preventDefault();
193+
updateUrl('');
194+
applyTagFilter('');
195+
});
196+
197+
const initialTag = new URLSearchParams(window.location.search).get('tag') || '';
198+
applyTagFilter(initialTag);
199+
};
200+
201+
wireTagFilter();
202+
document.addEventListener('astro:page-load', wireTagFilter);
203+
})();
204+
</script>
27205
</Layout>
28-
</PostHogLayout>
206+
</PostHogLayout>

0 commit comments

Comments
 (0)