Skip to content

Commit a8a9fc3

Browse files
committed
feat: init nextjs base blog
1 parent 3a8a7df commit a8a9fc3

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+1683
-5
lines changed

.github/workflows/deploy.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: Deploy Next.js to GitHub Pages
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
build-and-deploy:
13+
runs-on: ubuntu-latest
14+
15+
steps:
16+
- name: Checkout repository
17+
uses: actions/checkout@v3
18+
19+
- name: Setup Node.js
20+
uses: actions/setup-node@v4
21+
with:
22+
node-version: 18
23+
24+
- name: Install dependencies
25+
run: npm install
26+
27+
- name: Build and Export
28+
run: |
29+
npm run build
30+
31+
- name: Deploy to GitHub Pages
32+
uses: peaceiris/actions-gh-pages@v3
33+
with:
34+
github_token: ${{ secrets.GITHUB_TOKEN }}
35+
publish_dir: ./out

.gitignore

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +0,0 @@
1-
_site/
2-
3-
.gitignore
4-
.idea/compiler.xml
5-
.idea/checkstyle-idea.xml

components/AdSense.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { useEffect } from 'react';
2+
3+
export default function AdSense() {
4+
useEffect(() => {
5+
try {
6+
(window as any).adsbygoogle = (window as any).adsbygoogle || [];
7+
(window as any).adsbygoogle.push({});
8+
} catch (e) {
9+
console.error('AdSense error', e);
10+
}
11+
}, []);
12+
13+
return (
14+
<ins
15+
className="adsbygoogle"
16+
style={{ display: 'block', textAlign: 'center', margin: '1rem 0' }}
17+
data-ad-client="ca-pub-5103032140213770"
18+
data-ad-slot="1100099846"
19+
data-ad-format="auto"
20+
data-full-width-responsive="true"
21+
></ins>
22+
);
23+
}

components/Footer.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import '@fortawesome/fontawesome-free/css/all.min.css';
2+
3+
export default function Footer() {
4+
return (
5+
<footer className="bg-slate-800 text-gray-400 px-6 py-6 text-sm">
6+
<div className="flex flex-col md:flex-row justify-between items-center text-sm">
7+
<div className="mb-2 md:mb-0">© 2025 Eottabom's Lab. All rights reserved.</div>
8+
<div className="flex items-center space-x-6">
9+
<a
10+
href="mailto:[email protected]"
11+
className="flex items-center gap-2 hover:text-white transition"
12+
>
13+
<i className="fas fa-envelope text-base" aria-hidden="true" />
14+
<span>Email</span>
15+
</a>
16+
<a
17+
href="https://github.com/eottabom"
18+
className="flex items-center gap-2 hover:text-white transition"
19+
>
20+
<i className="fab fa-github text-base" aria-hidden="true" />
21+
<span>GitHub</span>
22+
</a>
23+
</div>
24+
</div>
25+
</footer>
26+
);
27+
}

components/Header.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import Link from 'next/link';
2+
import ThemeToggle from './ThemeToggle';
3+
4+
type HeaderProps = {
5+
isDark: boolean;
6+
};
7+
8+
export default function Header({ isDark }: { isDark: boolean }) {
9+
return (
10+
<header className="flex justify-end items-center px-6 py-4">
11+
<div className="flex gap-6 items-center">
12+
<Link href="/" className={`${isDark ? "text-white" : "text-black"} hover:text-blue-300`}>
13+
<span className="font-bold">Main</span>
14+
</Link>
15+
<Link href="/post" className={`${isDark ? "text-white" : "text-black"} hover:text-blue-300 font-bold`}>
16+
Post
17+
</Link>
18+
<Link href="/about" className={`${isDark ? "text-white" : "text-black"} hover:text-blue-300 font-bold`}>
19+
About
20+
</Link>
21+
<ThemeToggle />
22+
</div>
23+
</header>
24+
);
25+
}

components/Highlight.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
type HighlightProps = {
2+
color?: 'red' | 'blue' | 'green' | 'yellow';
3+
children: React.ReactNode;
4+
};
5+
6+
const colorClassMap = {
7+
red: 'text-red-600',
8+
blue: 'text-blue-600',
9+
green: 'text-green-600',
10+
yellow: 'text-yellow-600',
11+
};
12+
13+
export function Highlight({ color = 'red', children }: HighlightProps) {
14+
const colorClass = colorClassMap[color] ?? 'text-red-600';
15+
return <span className={`${colorClass} font-bold`}>{children}</span>;
16+
}
17+
18+
export function RedText({ children }: { children: React.ReactNode }) {
19+
return <Highlight color="red">{children}</Highlight>;
20+
}
21+
22+
export function BlueText({ children }: { children: React.ReactNode }) {
23+
return <Highlight color="blue">{children}</Highlight>;
24+
}
25+
26+
export function GreenText({ children }: { children: React.ReactNode }) {
27+
return <Highlight color="green">{children}</Highlight>;
28+
}

components/Panel.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { ReactNode } from 'react';
2+
3+
type InfoPanelType =
4+
| 'info'
5+
| 'warning'
6+
| 'danger'
7+
| 'success'
8+
| 'note'
9+
| 'tip'
10+
| 'neutral'
11+
| 'quote';
12+
13+
type InfoPanelProps = {
14+
type?: InfoPanelType;
15+
children: ReactNode;
16+
};
17+
18+
export default function InfoPanel({ type = 'info', children }: InfoPanelProps) {
19+
const styles: Record<InfoPanelType, string> = {
20+
info: 'bg-blue-50 border-l-4 border-blue-400 text-blue-900',
21+
warning: 'bg-yellow-50 border-l-4 border-yellow-400 text-yellow-900',
22+
danger: 'bg-red-50 border-l-4 border-red-400 text-red-900',
23+
success: 'bg-green-50 border-l-4 border-green-400 text-green-900',
24+
note: 'bg-purple-50 border-l-4 border-purple-400 text-purple-900',
25+
tip: 'bg-cyan-50 border-l-4 border-cyan-400 text-cyan-900',
26+
neutral: 'bg-gray-50 border-l-4 border-gray-400 text-gray-900',
27+
quote: 'bg-indigo-50 border-l-4 border-indigo-400 text-indigo-900',
28+
};
29+
30+
return (
31+
<div className={`px-4 py-2 my-4 rounded ${styles[type]}`}>
32+
<div className="prose-sm">{children}</div>
33+
</div>
34+
);
35+
}

components/ThemeToggle.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
'use client';
2+
3+
import { useTheme } from 'next-themes';
4+
import { useState, useEffect } from 'react';
5+
import { Monitor, Moon, Sun } from 'lucide-react';
6+
7+
export default function ThemeToggle() {
8+
const { theme, setTheme, systemTheme } = useTheme();
9+
const [mounted, setMounted] = useState(false);
10+
11+
const cycleOrder = ['system', 'dark', 'light'] as const;
12+
type ThemeValue = typeof cycleOrder[number];
13+
14+
useEffect(() => {
15+
setMounted(true);
16+
}, []);
17+
18+
if (!mounted) return null;
19+
20+
const current = theme === 'system' ? systemTheme : theme;
21+
22+
const getIcon = (mode: ThemeValue) => {
23+
switch (mode) {
24+
case 'system':
25+
return <Monitor className="w-5 h-5" />;
26+
case 'dark':
27+
return <Moon className="w-5 h-5" />;
28+
case 'light':
29+
return <Sun className="w-5 h-5" />;
30+
default:
31+
return <Monitor className="w-5 h-5" />;
32+
}
33+
};
34+
35+
const handleClick = () => {
36+
const currentIndex = cycleOrder.indexOf(theme as ThemeValue);
37+
const nextIndex = (currentIndex + 1) % cycleOrder.length;
38+
setTheme(cycleOrder[nextIndex]);
39+
};
40+
41+
return (
42+
<button
43+
onClick={handleClick}
44+
className="p-2 rounded-md border border-gray-300 dark:border-gray-600 hover:border-blue-500 transition"
45+
aria-label="Toggle Theme"
46+
>
47+
{getIcon(theme as ThemeValue)}
48+
</button>
49+
);
50+
}

hooks/useTocObserver.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useEffect, useState } from 'react';
2+
3+
type TocItem = {
4+
id: string;
5+
text: string;
6+
};
7+
8+
export function useTocObserver(): [TocItem[], string] {
9+
const [toc, setToc] = useState<TocItem[]>([]);
10+
const [activeId, setActiveId] = useState<string>('');
11+
12+
useEffect(() => {
13+
const headers = Array.from(document.querySelectorAll('h3[id]')) as HTMLHeadingElement[];
14+
15+
const items = headers.map((h) => ({
16+
id: h.id,
17+
text: h.textContent ?? '',
18+
}));
19+
setToc(items);
20+
21+
const handleObserver = (entries: IntersectionObserverEntry[]) => {
22+
const visible = entries.find((entry) => entry.isIntersecting);
23+
if (visible?.target.id) {
24+
setActiveId(visible.target.id);
25+
}
26+
};
27+
28+
const observer = new IntersectionObserver(handleObserver, {
29+
rootMargin: '0px 0px -80% 0px',
30+
threshold: 1.0,
31+
});
32+
33+
headers.forEach((h) => observer.observe(h));
34+
35+
return () => observer.disconnect();
36+
}, []);
37+
38+
return [toc, activeId];
39+
}

lib/posts.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import matter from 'gray-matter';
4+
import { serialize } from 'next-mdx-remote/serialize';
5+
import remarkGfm from 'remark-gfm';
6+
import rehypeSlug from 'rehype-slug';
7+
import rehypePrism from 'rehype-prism-plus';
8+
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
9+
import remarkDirective from 'remark-directive';
10+
import remarkDirectiveTransformer from './remarkDirectiveTransformer';
11+
12+
const postsDirectory = path.join(process.cwd(), 'posts');
13+
14+
type PostFrontmatter = {
15+
title: string;
16+
date: string;
17+
tags?: string[];
18+
summary?: string;
19+
description?: string;
20+
[key: string]: any;
21+
};
22+
23+
type PostData = PostFrontmatter & {
24+
id: string;
25+
mdxSource: Awaited<ReturnType<typeof serialize>>;
26+
};
27+
28+
export function getAllMdxFiles(dirPath = postsDirectory): string[] {
29+
let results: string[] = [];
30+
const list = fs.readdirSync(dirPath);
31+
list.forEach((file) => {
32+
const filePath = path.join(dirPath, file);
33+
const stat = fs.statSync(filePath);
34+
if (stat && stat.isDirectory()) {
35+
results = results.concat(getAllMdxFiles(filePath));
36+
} else if (file.endsWith('.mdx')) {
37+
results.push(filePath);
38+
}
39+
});
40+
return results;
41+
}
42+
43+
function extractIdFromFilename(fullPath: string): string {
44+
return path.basename(fullPath, '.mdx');
45+
}
46+
47+
export function getAllPostIds(): { params: { id: string } }[] {
48+
const allFiles = getAllMdxFiles();
49+
return allFiles.map((fullPath) => ({
50+
params: {
51+
id: extractIdFromFilename(fullPath),
52+
},
53+
}));
54+
}
55+
56+
export async function getPostData(id: string): Promise<PostData> {
57+
const allFiles = getAllMdxFiles();
58+
const matchPath = allFiles.find((fp) => path.basename(fp, '.mdx') === id);
59+
if (!matchPath) throw new Error(`Post not found: ${id}`);
60+
61+
const fileContents = fs.readFileSync(matchPath, 'utf8');
62+
const { content, data } = matter(fileContents);
63+
64+
const mdxSource = await serialize(content, {
65+
mdxOptions: {
66+
remarkPlugins: [remarkGfm, remarkDirective, remarkDirectiveTransformer],
67+
rehypePlugins: [rehypeSlug, rehypeAutolinkHeadings, rehypePrism],
68+
},
69+
scope: data,
70+
});
71+
72+
return {
73+
id,
74+
mdxSource,
75+
...(data as PostFrontmatter),
76+
};
77+
}
78+
79+
export async function getSortedPostsDataWithContent(): Promise<PostData[]> {
80+
const allFiles = getAllMdxFiles();
81+
82+
const allPostsData: PostData[] = await Promise.all(
83+
allFiles.map(async (fullPath) => {
84+
const id = extractIdFromFilename(fullPath);
85+
const fileContents = fs.readFileSync(fullPath, 'utf8');
86+
const { content, data } = matter(fileContents);
87+
const mdxSource = await serialize(content, {
88+
mdxOptions: {
89+
remarkPlugins: [remarkGfm],
90+
rehypePlugins: [rehypeSlug, rehypeAutolinkHeadings],
91+
format: 'mdx',
92+
},
93+
scope: data,
94+
});
95+
96+
return {
97+
id,
98+
mdxSource,
99+
...(data as PostFrontmatter),
100+
};
101+
})
102+
);
103+
104+
return allPostsData.sort(
105+
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
106+
);
107+
}

0 commit comments

Comments
 (0)