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( +
+
+
+
CODESTZ.DEV
+
+ +
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 */} + + +
+
+ {/* Badges */} +
+ + {STATUS_LABEL[project.status]} + + {project.featured && Featured} +
+ + {/* Title */} +

+ {project.title} +

+ + {/* Thumbnail */} + {project.thumbnail && ( +
+ {project.title} +
+ )} + + {/* 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 */} +
+ + {project.liveUrl && ( + + )} +
+
+ + {/* 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 ( <> -
- - {/* Search Modal */} - setSearchOpen(false)} /> - + > + CODESTZ_V1.0 + - {/* Full-Screen Mobile Menu - Rendered via Portal */} - {mounted && mobileMenuOpen && createPortal( -
-
- {/* Menu Header with Close Button */} -
- Menu -
- - {/* Mobile Navigation Links */} - +
- {/* Mobile Menu Actions */} -
-
+
+ + {/* Mobile Menu Button */} +
+ {mounted && ( - {isDark ? ( -
+ )} - , - document.body - )} + + {/* Search Modal */} + setSearchOpen(false)} /> + + + {/* Full-Screen Mobile Menu - Rendered via Portal */} + {mounted && + mobileMenuOpen && + createPortal( +
+
+ {/* Menu Header with Close Button */} +
+ + Menu + +
+ + {/* Mobile Navigation Links */} + + + {/* Mobile Menu Actions */} +
+
+ } + 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 ( -