diff --git a/.github/scripts/update-showcase-stats.js b/.github/scripts/update-showcase-stats.js
new file mode 100644
index 0000000..39b8e14
--- /dev/null
+++ b/.github/scripts/update-showcase-stats.js
@@ -0,0 +1,96 @@
+// Refreshes stars/forks in showcase project frontmatter from the GitHub API.
+// Surgical line edits keep diffs minimal (no frontmatter reserialization).
+// Run via: node .github/scripts/update-showcase-stats.js
+
+/* eslint-disable @typescript-eslint/no-require-imports */
+const fs = require('fs');
+const path = require('path');
+
+const SHOWCASE_DIR = path.join(process.cwd(), 'src/content/showcase');
+const TOKEN = process.env.GITHUB_TOKEN;
+
+async function fetchRepo(repo) {
+ const headers = { Accept: 'application/vnd.github+json', 'User-Agent': 'codestz-showcase-stats' };
+ if (TOKEN) headers.Authorization = `Bearer ${TOKEN}`;
+
+ const res = await fetch(`https://api.github.com/repos/${repo}`, { headers });
+ if (!res.ok) {
+ throw new Error(`GitHub API ${res.status} for ${repo}: ${await res.text()}`);
+ }
+ return res.json();
+}
+
+// Split a file into its YAML frontmatter block and the rest.
+function splitFrontmatter(text) {
+ const match = text.match(/^---\n([\s\S]*?)\n---/);
+ if (!match) return null;
+ return { block: match[1], start: match.index, end: match.index + match[0].length };
+}
+
+function readField(block, key) {
+ const m = block.match(new RegExp(`^${key}:\\s*['"]?([^'"\\n]+)['"]?\\s*$`, 'm'));
+ return m ? m[1].trim() : null;
+}
+
+// Set `key: value` inside the frontmatter block. Replaces the line if present,
+// otherwise inserts it right after the `repo:` line.
+function setField(block, key, value) {
+ const line = `${key}: ${value}`;
+ const re = new RegExp(`^${key}:.*$`, 'm');
+ if (re.test(block)) return block.replace(re, line);
+ return block.replace(/^(repo:.*)$/m, `$1\n${line}`);
+}
+
+async function main() {
+ if (!fs.existsSync(SHOWCASE_DIR)) {
+ console.log('No showcase directory, nothing to do.');
+ return;
+ }
+
+ const files = fs.readdirSync(SHOWCASE_DIR).filter((f) => f.endsWith('.mdx'));
+ let changed = 0;
+
+ for (const file of files) {
+ const filePath = path.join(SHOWCASE_DIR, file);
+ const text = fs.readFileSync(filePath, 'utf8');
+ const fm = splitFrontmatter(text);
+
+ if (!fm) {
+ console.warn(`! ${file}: no frontmatter, skipping`);
+ continue;
+ }
+
+ const repo = readField(fm.block, 'repo');
+ if (!repo) {
+ console.warn(`! ${file}: no repo field, skipping`);
+ continue;
+ }
+
+ try {
+ const data = await fetchRepo(repo);
+ const stars = data.stargazers_count ?? 0;
+ const forks = data.forks_count ?? 0;
+
+ let block = setField(fm.block, 'stars', stars);
+ block = setField(block, 'forks', forks);
+
+ if (block !== fm.block) {
+ const updated = `---\n${block}\n---` + text.slice(fm.end);
+ fs.writeFileSync(filePath, updated);
+ changed++;
+ console.log(`✓ ${file}: ${repo} → ★${stars} ⑂${forks}`);
+ } else {
+ console.log(`= ${file}: ${repo} unchanged (★${stars} ⑂${forks})`);
+ }
+ } catch (err) {
+ console.error(`! ${file}: ${err.message}`);
+ }
+ }
+
+ console.log(`Done. ${changed} file(s) updated.`);
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/.github/workflows/update-showcase-stats.yml b/.github/workflows/update-showcase-stats.yml
new file mode 100644
index 0000000..080539e
--- /dev/null
+++ b/.github/workflows/update-showcase-stats.yml
@@ -0,0 +1,34 @@
+name: Update showcase stars/forks
+
+on:
+ schedule:
+ - cron: '0 */6 * * *' # every 6 hours
+ push:
+ paths:
+ - 'src/content/showcase/**'
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ update-stats:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Refresh stars/forks from GitHub API
+ run: node .github/scripts/update-showcase-stats.js
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Commit updated frontmatter
+ run: |
+ git config --local user.email "github-actions[bot]@users.noreply.github.com"
+ git config --local user.name "github-actions[bot]"
+ git add src/content/showcase/
+ git diff --cached --quiet || (git commit -m "chore: refresh showcase stars/forks" && git push)
diff --git a/public/images/blog/claude-hindsight.png b/public/images/blog/claude-hindsight.png
new file mode 100644
index 0000000..445fbeb
Binary files /dev/null and b/public/images/blog/claude-hindsight.png differ
diff --git a/public/images/blog/hireloom.png b/public/images/blog/hireloom.png
new file mode 100644
index 0000000..c7029fa
Binary files /dev/null and b/public/images/blog/hireloom.png differ
diff --git a/public/images/blog/mcpx.png b/public/images/blog/mcpx.png
new file mode 100644
index 0000000..a9a5233
Binary files /dev/null and b/public/images/blog/mcpx.png differ
diff --git a/src/app/api/search-content/route.ts b/src/app/api/search-content/route.ts
index 0895999..06735a2 100644
--- a/src/app/api/search-content/route.ts
+++ b/src/app/api/search-content/route.ts
@@ -3,13 +3,15 @@ import { contentService } from '@/lib/services';
export async function GET() {
try {
- const [postsResult, projectsResult] = await Promise.all([
+ const [postsResult, projectsResult, showcaseResult] = await Promise.all([
contentService.getAllPosts(),
contentService.getAllProjects(),
+ contentService.getAllShowcase(),
]);
const posts = postsResult.success ? Array.from(postsResult.data) : [];
const projects = projectsResult.success ? Array.from(projectsResult.data) : [];
+ const showcase = showcaseResult.success ? Array.from(showcaseResult.data) : [];
const searchContent = [
...posts.map((post) => ({
@@ -30,6 +32,15 @@ export async function GET() {
tags: 'tags' in project ? project.tags : [],
url: `/experience/${project.slug}`,
})),
+ ...showcase.map((project) => ({
+ type: 'showcase' as const,
+ slug: project.slug,
+ title: project.title,
+ description: project.description,
+ category: 'project',
+ tags: project.technologies,
+ url: `/projects/${project.slug}`,
+ })),
];
return NextResponse.json(searchContent);
diff --git a/src/app/projects/[slug]/opengraph-image.tsx b/src/app/projects/[slug]/opengraph-image.tsx
new file mode 100644
index 0000000..5568b93
--- /dev/null
+++ b/src/app/projects/[slug]/opengraph-image.tsx
@@ -0,0 +1,87 @@
+import { ImageResponse } from 'next/og';
+import { contentService } from '@/lib/services';
+import { APP_CONFIG } from '@/lib/constants';
+
+export const alt = 'Project on codestz.dev';
+export const size = { width: 1200, height: 630 };
+export const contentType = 'image/png';
+
+const PURPLE = '#7c3aed';
+const BG = '#0a0a0a';
+const FG = '#fafafa';
+
+export default async function OgImage({ params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params;
+ const result = await contentService.getShowcaseBySlug(slug);
+ const project = result.success ? result.data : null;
+
+ const title = project?.title ?? 'codestz.dev';
+ const tags = project?.technologies?.slice(0, 4) ?? [];
+ const repo = project?.repo ?? '';
+ const stars = typeof project?.stars === 'number' ? `★ ${project.stars}` : '';
+
+ return new ImageResponse(
+
+
+
+
50 ? 64 : 78,
+ fontWeight: 800,
+ lineHeight: 1.05,
+ letterSpacing: '-1px',
+ }}
+ >
+ {title}
+
+
+
+
+ {tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+ {[repo, stars].filter(Boolean).join(' · ')}
+ {APP_CONFIG.author.name}
+
+
+
,
+ { ...size }
+ );
+}
diff --git a/src/app/projects/[slug]/page.tsx b/src/app/projects/[slug]/page.tsx
new file mode 100644
index 0000000..14528af
--- /dev/null
+++ b/src/app/projects/[slug]/page.tsx
@@ -0,0 +1,196 @@
+import { notFound } from 'next/navigation';
+import { ArrowLeft, ExternalLink, GitFork, Github, Star } from 'lucide-react';
+import { MDXRemote } from 'next-mdx-remote/rsc';
+import Image from 'next/image';
+import { Section } from '@/components/sections';
+import { Button, Badge } from '@/components/ui';
+import { contentService } from '@/lib/services';
+import { ROUTES } from '@/lib/constants';
+import type { Metadata } from 'next';
+import remarkGfm from 'remark-gfm';
+import { mdxComponents } from '../../../../mdx-components';
+import { generateShowcaseMetadata } from '@/lib/utils';
+import type { ProjectPageProps } from './page.types';
+
+const STATUS_VARIANT = {
+ active: 'success',
+ wip: 'warning',
+ archived: 'secondary',
+} as const;
+
+const STATUS_LABEL = {
+ active: 'Active',
+ wip: 'WIP',
+ archived: 'Archived',
+} as const;
+
+export async function generateMetadata({ params }: ProjectPageProps): Promise {
+ const { slug } = await params;
+ const result = await contentService.getShowcaseBySlug(slug);
+
+ if (!result.success || !result.data) {
+ return { title: 'Project Not Found' };
+ }
+
+ const project = result.data;
+
+ return generateShowcaseMetadata({
+ title: project.title,
+ description: project.description,
+ slug: project.slug,
+ thumbnail: project.thumbnail,
+ technologies: project.technologies,
+ });
+}
+
+export async function generateStaticParams() {
+ const result = await contentService.getAllShowcase();
+ const projects = result.success ? result.data : [];
+
+ return projects.map((project) => ({ slug: project.slug }));
+}
+
+export default async function ProjectDetailPage({ params }: ProjectPageProps) {
+ const { slug } = await params;
+ const result = await contentService.getShowcaseBySlug(slug);
+
+ if (!result.success || !result.data) {
+ notFound();
+ }
+
+ const project = result.data;
+
+ return (
+
+
+
+ {/* Back Link */}
+
}
+ className="mb-8"
+ >
+ Back to Projects
+
+
+
+
+ {/* Badges */}
+
+
+ {STATUS_LABEL[project.status]}
+
+ {project.featured && Featured}
+
+
+ {/* Title */}
+
+ {project.title}
+
+
+ {/* Thumbnail */}
+ {project.thumbnail && (
+
+
+
+ )}
+
+ {/* Repo meta */}
+
+ {project.repo}
+ {project.language && (
+ {project.language}
+ )}
+ {typeof project.stars === 'number' && (
+
+ {project.stars}
+
+ )}
+ {typeof project.forks === 'number' && project.forks > 0 && (
+
+ {project.forks}
+
+ )}
+
+
+ {/* Description */}
+
+ {project.description}
+
+
+ {/* Technologies */}
+ {project.technologies.length > 0 && (
+
+
+ Tech Stack
+
+
+ {project.technologies.map((tech) => (
+
+ {tech}
+
+ ))}
+
+
+ )}
+
+ {/* Links */}
+
+ }
+ >
+ View Code
+
+ {project.liveUrl && (
+ }
+ >
+ View Live
+
+ )}
+
+
+
+ {/* Content */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/projects/[slug]/page.types.ts b/src/app/projects/[slug]/page.types.ts
new file mode 100644
index 0000000..223c035
--- /dev/null
+++ b/src/app/projects/[slug]/page.types.ts
@@ -0,0 +1,7 @@
+/**
+ * Project (Showcase) Detail Page Types
+ */
+
+export interface ProjectPageProps {
+ params: Promise<{ slug: string }>;
+}
diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx
new file mode 100644
index 0000000..665227f
--- /dev/null
+++ b/src/app/projects/page.tsx
@@ -0,0 +1,36 @@
+import { AnimatedSection, ShowcaseGrid } from '@/components/sections';
+import { contentService } from '@/lib/services';
+import { generatePageMetadata } from '@/lib/utils';
+import { Metadata } from 'next';
+
+/**
+ * Static metadata for the Projects (GitHub showcase) listing page
+ */
+export const metadata: Metadata = generatePageMetadata({
+ title: 'Projects',
+ description:
+ 'Open-source projects and tools I build — local-first apps, developer tooling, and AI infrastructure on GitHub',
+ path: '/projects',
+});
+
+export default async function ProjectsPage() {
+ const result = await contentService.getAllShowcase();
+ const projects = result.success ? Array.from(result.data) : [];
+
+ return (
+
+
+ {projects.length > 0 ? (
+
+ ) : (
+
+
No projects yet. Check back soon!
+
+ )}
+
+
+ );
+}
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
index ee34bfc..da7e35a 100644
--- a/src/app/sitemap.ts
+++ b/src/app/sitemap.ts
@@ -5,7 +5,7 @@ export default async function sitemap(): Promise {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://codestz.dev';
// Static routes
- const routes = ['', '/about', '/experiments', '/experience'].map((route) => ({
+ const routes = ['', '/about', '/experiments', '/projects', '/experience'].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date().toISOString(),
changeFrequency: 'weekly' as const,
@@ -32,5 +32,15 @@ export default async function sitemap(): Promise {
priority: 0.7,
}));
- return [...routes, ...postRoutes, ...projectRoutes];
+ // Showcase / GitHub projects
+ const showcaseResult = await contentService.getAllShowcase();
+ const showcase = showcaseResult.success ? Array.from(showcaseResult.data) : [];
+ const showcaseRoutes = showcase.map((project) => ({
+ url: `${baseUrl}/projects/${project.slug}`,
+ lastModified: project.publishedAt,
+ changeFrequency: 'monthly' as const,
+ priority: project.featured ? 0.9 : 0.7,
+ }));
+
+ return [...routes, ...postRoutes, ...projectRoutes, ...showcaseRoutes];
}
diff --git a/src/components/layout/Header/Header.tsx b/src/components/layout/Header/Header.tsx
index 31f2e75..295048c 100644
--- a/src/components/layout/Header/Header.tsx
+++ b/src/components/layout/Header/Header.tsx
@@ -103,6 +103,7 @@ export function Header({ className }: HeaderProps) {
const navLinks = [
{ href: '/', label: 'KNOWLEDGE_BASE' },
{ href: '/experiments', label: 'EXPERIMENTS' },
+ { href: '/projects', label: 'PROJECTS' },
{ href: '/experience', label: 'EXPERIENCE' },
{ href: '/about', label: 'ABOUT_ME' },
];
@@ -121,193 +122,200 @@ export function Header({ className }: HeaderProps) {
return (
<>
-
+ >
+ CODESTZ_V1.0
+
- {/* Full-Screen Mobile Menu - Rendered via Portal */}
- {mounted && mobileMenuOpen && createPortal(
-
-
- {/* Menu Header with Close Button */}
-
- Menu
- }
- onClick={() => setMobileMenuOpen(false)}
- aria-label="Close menu"
- variant="default"
- size="md"
- />
-
-
- {/* Mobile Navigation Links */}
-
-
,
- document.body
- )}
+
+ {/* Search Modal */}
+ setSearchOpen(false)} />
+
+
+ {/* Full-Screen Mobile Menu - Rendered via Portal */}
+ {mounted &&
+ mobileMenuOpen &&
+ createPortal(
+
+
+ {/* Menu Header with Close Button */}
+
+
+ Menu
+
+ }
+ onClick={() => setMobileMenuOpen(false)}
+ aria-label="Close menu"
+ variant="default"
+ size="md"
+ />
+
+
+ {/* Mobile Navigation Links */}
+
+
+ {/* Mobile Menu Actions */}
+
+ }
+ onClick={() => {
+ setMobileMenuOpen(false);
+ setSearchOpen(true);
+ }}
+ aria-label="Search"
+ variant="default"
+ size="md"
+ />
+
+ {isDark ? (
+
+ ) : (
+
+ )}
+
+ }
+ onClick={() => setTheme(isDark ? 'light' : 'dark')}
+ aria-label={`Switch to ${isDark ? 'light' : 'dark'} mode`}
+ variant="default"
+ size="md"
+ />
+
+
+ ,
+ document.body
+ )}
>
);
}
diff --git a/src/components/layout/Header/MobileMenu.tsx b/src/components/layout/Header/MobileMenu.tsx
index 3878212..1564a13 100644
--- a/src/components/layout/Header/MobileMenu.tsx
+++ b/src/components/layout/Header/MobileMenu.tsx
@@ -22,6 +22,7 @@ export function MobileMenu({ className }: MobileMenuProps) {
const navItems = [
{ href: '/', label: 'Home' },
{ href: '/experiments', label: 'Experiments' },
+ { href: '/projects', label: 'Projects' },
{ href: '/experience', label: 'Experience' },
{ href: '/about', label: 'About' },
];
@@ -46,17 +47,19 @@ export function MobileMenu({ className }: MobileMenuProps) {
overlayRef.current,
{ opacity: 0 },
{ opacity: 1, duration: 0.2, ease: 'power2.out' }
- ).fromTo(
- menuRef.current,
- { x: '100%' },
- { x: '0%', duration: 0.3, ease: 'power2.out' },
- '-=0.1'
- ).fromTo(
- navItemsRef.current,
- { opacity: 0, y: 20 },
- { opacity: 1, y: 0, duration: 0.3, stagger: 0.05, ease: 'power2.out' },
- '-=0.2'
- );
+ )
+ .fromTo(
+ menuRef.current,
+ { x: '100%' },
+ { x: '0%', duration: 0.3, ease: 'power2.out' },
+ '-=0.1'
+ )
+ .fromTo(
+ navItemsRef.current,
+ { opacity: 0, y: 20 },
+ { opacity: 1, y: 0, duration: 0.3, stagger: 0.05, ease: 'power2.out' },
+ '-=0.2'
+ );
} else {
// Re-enable body scroll
document.body.style.overflow = '';
@@ -71,16 +74,8 @@ export function MobileMenu({ className }: MobileMenuProps) {
stagger: 0.03,
ease: 'power2.in',
})
- .to(
- menuRef.current,
- { x: '100%', duration: 0.3, ease: 'power2.in' },
- '-=0.1'
- )
- .to(
- overlayRef.current,
- { opacity: 0, duration: 0.2, ease: 'power2.in' },
- '-=0.2'
- );
+ .to(menuRef.current, { x: '100%', duration: 0.3, ease: 'power2.in' }, '-=0.1')
+ .to(overlayRef.current, { opacity: 0, duration: 0.2, ease: 'power2.in' }, '-=0.2');
}
}
diff --git a/src/components/layout/Header/Navigation.tsx b/src/components/layout/Header/Navigation.tsx
index 7df5330..d72be5f 100644
--- a/src/components/layout/Header/Navigation.tsx
+++ b/src/components/layout/Header/Navigation.tsx
@@ -15,18 +15,18 @@ export function Navigation({ className }: NavigationProps) {
const navItems = [
{ href: '/', label: 'Home' },
{ href: '/experiments', label: 'Experiments' },
+ { href: '/projects', label: 'Projects' },
{ href: '/experience', label: 'Experience' },
{ href: '/about', label: 'About' },
];
return (
-