@@ -6,23 +6,201 @@ import { getCollection } from 'astro:content';
66
77const 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