Skip to content

Commit d404dcc

Browse files
authored
Merge pull request #8 from Codestz/feat/projects-showcase
feat: add Projects (GitHub showcase) section
2 parents 95f8d8b + 21426c5 commit d404dcc

29 files changed

Lines changed: 1443 additions & 199 deletions
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Refreshes stars/forks in showcase project frontmatter from the GitHub API.
2+
// Surgical line edits keep diffs minimal (no frontmatter reserialization).
3+
// Run via: node .github/scripts/update-showcase-stats.js
4+
5+
/* eslint-disable @typescript-eslint/no-require-imports */
6+
const fs = require('fs');
7+
const path = require('path');
8+
9+
const SHOWCASE_DIR = path.join(process.cwd(), 'src/content/showcase');
10+
const TOKEN = process.env.GITHUB_TOKEN;
11+
12+
async function fetchRepo(repo) {
13+
const headers = { Accept: 'application/vnd.github+json', 'User-Agent': 'codestz-showcase-stats' };
14+
if (TOKEN) headers.Authorization = `Bearer ${TOKEN}`;
15+
16+
const res = await fetch(`https://api.github.com/repos/${repo}`, { headers });
17+
if (!res.ok) {
18+
throw new Error(`GitHub API ${res.status} for ${repo}: ${await res.text()}`);
19+
}
20+
return res.json();
21+
}
22+
23+
// Split a file into its YAML frontmatter block and the rest.
24+
function splitFrontmatter(text) {
25+
const match = text.match(/^---\n([\s\S]*?)\n---/);
26+
if (!match) return null;
27+
return { block: match[1], start: match.index, end: match.index + match[0].length };
28+
}
29+
30+
function readField(block, key) {
31+
const m = block.match(new RegExp(`^${key}:\\s*['"]?([^'"\\n]+)['"]?\\s*$`, 'm'));
32+
return m ? m[1].trim() : null;
33+
}
34+
35+
// Set `key: value` inside the frontmatter block. Replaces the line if present,
36+
// otherwise inserts it right after the `repo:` line.
37+
function setField(block, key, value) {
38+
const line = `${key}: ${value}`;
39+
const re = new RegExp(`^${key}:.*$`, 'm');
40+
if (re.test(block)) return block.replace(re, line);
41+
return block.replace(/^(repo:.*)$/m, `$1\n${line}`);
42+
}
43+
44+
async function main() {
45+
if (!fs.existsSync(SHOWCASE_DIR)) {
46+
console.log('No showcase directory, nothing to do.');
47+
return;
48+
}
49+
50+
const files = fs.readdirSync(SHOWCASE_DIR).filter((f) => f.endsWith('.mdx'));
51+
let changed = 0;
52+
53+
for (const file of files) {
54+
const filePath = path.join(SHOWCASE_DIR, file);
55+
const text = fs.readFileSync(filePath, 'utf8');
56+
const fm = splitFrontmatter(text);
57+
58+
if (!fm) {
59+
console.warn(`! ${file}: no frontmatter, skipping`);
60+
continue;
61+
}
62+
63+
const repo = readField(fm.block, 'repo');
64+
if (!repo) {
65+
console.warn(`! ${file}: no repo field, skipping`);
66+
continue;
67+
}
68+
69+
try {
70+
const data = await fetchRepo(repo);
71+
const stars = data.stargazers_count ?? 0;
72+
const forks = data.forks_count ?? 0;
73+
74+
let block = setField(fm.block, 'stars', stars);
75+
block = setField(block, 'forks', forks);
76+
77+
if (block !== fm.block) {
78+
const updated = `---\n${block}\n---` + text.slice(fm.end);
79+
fs.writeFileSync(filePath, updated);
80+
changed++;
81+
console.log(`✓ ${file}: ${repo} → ★${stars}${forks}`);
82+
} else {
83+
console.log(`= ${file}: ${repo} unchanged (★${stars}${forks})`);
84+
}
85+
} catch (err) {
86+
console.error(`! ${file}: ${err.message}`);
87+
}
88+
}
89+
90+
console.log(`Done. ${changed} file(s) updated.`);
91+
}
92+
93+
main().catch((err) => {
94+
console.error(err);
95+
process.exit(1);
96+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: Update showcase stars/forks
2+
3+
on:
4+
schedule:
5+
- cron: '0 */6 * * *' # every 6 hours
6+
push:
7+
paths:
8+
- 'src/content/showcase/**'
9+
workflow_dispatch:
10+
11+
permissions:
12+
contents: write
13+
14+
jobs:
15+
update-stats:
16+
runs-on: ubuntu-latest
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- uses: actions/setup-node@v4
21+
with:
22+
node-version: '20'
23+
24+
- name: Refresh stars/forks from GitHub API
25+
run: node .github/scripts/update-showcase-stats.js
26+
env:
27+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
28+
29+
- name: Commit updated frontmatter
30+
run: |
31+
git config --local user.email "github-actions[bot]@users.noreply.github.com"
32+
git config --local user.name "github-actions[bot]"
33+
git add src/content/showcase/
34+
git diff --cached --quiet || (git commit -m "chore: refresh showcase stars/forks" && git push)
434 KB
Loading

public/images/blog/hireloom.png

177 KB
Loading

public/images/blog/mcpx.png

172 KB
Loading

src/app/api/search-content/route.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import { contentService } from '@/lib/services';
33

44
export async function GET() {
55
try {
6-
const [postsResult, projectsResult] = await Promise.all([
6+
const [postsResult, projectsResult, showcaseResult] = await Promise.all([
77
contentService.getAllPosts(),
88
contentService.getAllProjects(),
9+
contentService.getAllShowcase(),
910
]);
1011

1112
const posts = postsResult.success ? Array.from(postsResult.data) : [];
1213
const projects = projectsResult.success ? Array.from(projectsResult.data) : [];
14+
const showcase = showcaseResult.success ? Array.from(showcaseResult.data) : [];
1315

1416
const searchContent = [
1517
...posts.map((post) => ({
@@ -30,6 +32,15 @@ export async function GET() {
3032
tags: 'tags' in project ? project.tags : [],
3133
url: `/experience/${project.slug}`,
3234
})),
35+
...showcase.map((project) => ({
36+
type: 'showcase' as const,
37+
slug: project.slug,
38+
title: project.title,
39+
description: project.description,
40+
category: 'project',
41+
tags: project.technologies,
42+
url: `/projects/${project.slug}`,
43+
})),
3344
];
3445

3546
return NextResponse.json(searchContent);
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { ImageResponse } from 'next/og';
2+
import { contentService } from '@/lib/services';
3+
import { APP_CONFIG } from '@/lib/constants';
4+
5+
export const alt = 'Project on codestz.dev';
6+
export const size = { width: 1200, height: 630 };
7+
export const contentType = 'image/png';
8+
9+
const PURPLE = '#7c3aed';
10+
const BG = '#0a0a0a';
11+
const FG = '#fafafa';
12+
13+
export default async function OgImage({ params }: { params: Promise<{ slug: string }> }) {
14+
const { slug } = await params;
15+
const result = await contentService.getShowcaseBySlug(slug);
16+
const project = result.success ? result.data : null;
17+
18+
const title = project?.title ?? 'codestz.dev';
19+
const tags = project?.technologies?.slice(0, 4) ?? [];
20+
const repo = project?.repo ?? '';
21+
const stars = typeof project?.stars === 'number' ? `★ ${project.stars}` : '';
22+
23+
return new ImageResponse(
24+
<div
25+
style={{
26+
width: '100%',
27+
height: '100%',
28+
display: 'flex',
29+
flexDirection: 'column',
30+
justifyContent: 'space-between',
31+
background: BG,
32+
color: FG,
33+
padding: '64px',
34+
border: `16px solid ${PURPLE}`,
35+
fontFamily: 'sans-serif',
36+
}}
37+
>
38+
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
39+
<div style={{ width: '20px', height: '20px', background: PURPLE }} />
40+
<div style={{ fontSize: 30, fontWeight: 700, letterSpacing: '2px' }}>CODESTZ.DEV</div>
41+
</div>
42+
43+
<div
44+
style={{
45+
display: 'flex',
46+
fontSize: title.length > 50 ? 64 : 78,
47+
fontWeight: 800,
48+
lineHeight: 1.05,
49+
letterSpacing: '-1px',
50+
}}
51+
>
52+
{title}
53+
</div>
54+
55+
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
56+
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
57+
{tags.map((tag) => (
58+
<div
59+
key={tag}
60+
style={{
61+
display: 'flex',
62+
fontSize: 24,
63+
color: PURPLE,
64+
border: `2px solid ${PURPLE}`,
65+
padding: '4px 16px',
66+
}}
67+
>
68+
{tag}
69+
</div>
70+
))}
71+
</div>
72+
<div
73+
style={{
74+
display: 'flex',
75+
justifyContent: 'space-between',
76+
fontSize: 28,
77+
color: '#a1a1aa',
78+
}}
79+
>
80+
<span>{[repo, stars].filter(Boolean).join(' · ')}</span>
81+
<span>{APP_CONFIG.author.name}</span>
82+
</div>
83+
</div>
84+
</div>,
85+
{ ...size }
86+
);
87+
}

0 commit comments

Comments
 (0)