Compare commits

..

2 Commits

Author SHA1 Message Date
31b5821532 Migrate to Tailwind CSS v4 with CSS-first configuration
- Replace tailwindcss v3 + autoprefixer with tailwindcss v4 + @tailwindcss/postcss
- Migrate tailwind.config.cjs theme to @theme block in globals.css
- Add @custom-variant dark for class-based dark mode (next-themes)
- Load typography plugin via @plugin directive, replace prose-dark with prose-invert
- Convert prose dark mode overrides from JS config to CSS (.dark .prose rules)
- Add @source directive for content submodule detection
- Replace postcss.config.cjs with postcss.config.mjs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 14:55:43 +08:00
661b67cc01 Fix PPR empty generateStaticParams error and update dependencies
- Add placeholder fallback to generateStaticParams for cacheComponents compatibility
- Update npm packages within semver range (next 16.1.6, react 19.2.4, shiki 3.22.0, etc.)
- Add /new-post skill for blog publishing workflow
- Update CLAUDE.md with git remote mirroring docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 14:39:17 +08:00
12 changed files with 1518 additions and 1457 deletions

View File

@@ -0,0 +1,63 @@
# New Blog Post
Create and publish a new blog post for the personal blog.
## Step 1: Gather Information
Ask the user for the following using AskUserQuestion (all in one prompt):
1. **Title** — the article title (Chinese or English)
2. **Tags** — offer existing tags from past posts as multi-select options: `Medicine - 醫學`, `Writings - 創作`, `Hardware - 硬體`, `Software - 軟體`, `Unboxing - 開箱`. User can also input custom tags.
3. **Feature image** — options: no image, provide a URL or Unsplash link, or provide a local file path
4. **Description** — short excerpt for SEO/article list, or skip for now
## Step 2: Create the Post File
Create the markdown file at `content/posts/<title>.md` with this frontmatter format:
```yaml
---
title: <title>
slug: <english-slug-derived-from-title>
published_at: '<current-ISO-date>'
description: <description if provided>
tags:
- <tag1>
- <tag2>
authors:
- Gbanyan
feature_image: ../assets/<slug>.jpg
---
```
If the user provides article content, add it after the frontmatter.
## Step 3: Handle Feature Image
If the user provides an Unsplash URL:
1. Extract the real image URL by running: `curl -sL "<unsplash-page-url>" | grep -oE 'https://images\.unsplash\.com/photo-[^"? ]+' | head -1`
2. Download at 1920px width: `curl -sL -o content/assets/<slug>.jpg "<image-url>?w=1920&q=90"`
3. Optimize with jpegoptim: `jpegoptim --max=85 --strip-all --all-progressive content/assets/<slug>.jpg`
4. Verify the image visually using the Read tool
If the user provides a local file path, copy it to `content/assets/<slug>.jpg` and optimize.
If no image, omit `feature_image` from frontmatter.
## Step 4: Preview (Optional)
Ask the user if they want to preview with `npm run dev` before publishing.
## Step 5: Publish
Execute the two-step deployment:
```bash
# 1. Commit and push content submodule
git -C content add . && git -C content commit -m "Add new post: <title>" && git -C content push
# 2. Update main repo submodule pointer and push (triggers CI/CD)
git add content && git commit -m "Update content submodule" && git push
```
Confirm both pushes succeeded. The CI/CD pipeline on git.gbanyan.net will handle deployment automatically (and crontab mirrors to gitea.gbanyan.net).

View File

@@ -39,12 +39,13 @@ No test framework is configured.
## Styling
- Tailwind CSS v3 with `darkMode: 'class'` (toggled by `next-themes`)
- Tailwind CSS v4 with CSS-first configuration (no `tailwind.config.cjs`)
- Dark mode via `@custom-variant dark` in `styles/globals.css` (class-based, toggled by `next-themes`)
- Theme customization via `@theme` block in `styles/globals.css`: colors, fonts, easing, durations, shadows, keyframes, animations
- Accent color system via CSS variables set in `app/layout.tsx` from env vars: `--color-accent`, `--color-accent-soft`, `--color-accent-text-light`, `--color-accent-text-dark`
- Tailwind extends these as `accent`, `accent-soft`, `accent-textLight`, `accent-textDark` in `tailwind.config.cjs`
- Typography plugin (`@tailwindcss/typography`) with custom `prose` / `prose-dark` overrides
- Typography plugin (`@tailwindcss/typography`) loaded via `@plugin` directive; prose dark mode handled by custom `.dark .prose` CSS overrides
- English headings use Playfair Display serif (`--font-serif-eng`); body uses Inter + CJK fallback stack
- Config files use CommonJS (`.cjs`): `tailwind.config.cjs`, `postcss.config.cjs`
- PostCSS config: `postcss.config.mjs` using `@tailwindcss/postcss`
## Content Submodule
@@ -56,7 +57,7 @@ The `content/` directory is a git submodule pointing to a separate `personal-blo
## Deployment
Push to `main` on the Gitea remote (`git.gbanyan.net`) triggers CI/CD automatically (server-side hook). No Dockerfile or workflow file in this repo.
Two Git remotes are involved: `git.gbanyan.net` (SSH, primary push target) and `gitea.gbanyan.net` (HTTPS, Gitea web UI). A crontab on the server automatically mirrors `git.gbanyan.net``gitea.gbanyan.net`. Push to `main` on `git.gbanyan.net` triggers CI/CD automatically (server-side hook). No Dockerfile or workflow file in this repo.
**Content-only update** (new/edited posts) — both steps are required to trigger deploy:
1. Commit and push inside `content/` submodule: `git -C content add . && git -C content commit -m "..." && git -C content push`

View File

@@ -15,9 +15,10 @@ import { FooterCue } from '@/components/footer-cue';
import { JsonLd } from '@/components/json-ld';
export function generateStaticParams() {
return allPosts.map((post) => ({
const params = allPosts.map((post) => ({
slug: post.slug || post.flattenedPath
}));
return params.length > 0 ? params : [{ slug: '__placeholder__' }];
}
interface Props {
@@ -200,7 +201,7 @@ export default async function BlogPostPage({ params }: Props) {
<ScrollReveal>
<article
data-toc-content={slug}
className="prose prose-lg prose-slate mx-auto max-w-none dark:prose-dark"
className="prose prose-lg prose-slate mx-auto max-w-none dark:prose-invert"
>
{post.feature_image && (
<div className="-mx-4 mb-8 transition-all duration-500 sm:-mx-12 lg:-mx-20 group-[.toc-open]:lg:-mx-4">

View File

@@ -12,9 +12,10 @@ import { SectionDivider } from '@/components/section-divider';
import { JsonLd } from '@/components/json-ld';
export function generateStaticParams() {
return allPages.map((page) => ({
const params = allPages.map((page) => ({
slug: page.slug || page.flattenedPath
}));
return params.length > 0 ? params : [{ slug: '__placeholder__' }];
}
interface Props {
@@ -112,7 +113,7 @@ export default async function StaticPage({ params }: Props) {
<ScrollReveal>
<article
data-toc-content={slug}
className="prose prose-lg prose-slate mx-auto max-w-none dark:prose-dark"
className="prose prose-lg prose-slate mx-auto max-w-none dark:prose-invert"
>
{page.feature_image && (
<div className="-mx-4 mb-8 transition-all duration-500 sm:-mx-12 lg:-mx-20 group-[.toc-open]:lg:-mx-4">

View File

@@ -15,9 +15,10 @@ export function generateStaticParams() {
slugs.add(getTagSlug(tag));
}
}
return Array.from(slugs).map((slug) => ({
const params = Array.from(slugs).map((slug) => ({
tag: slug
}));
return params.length > 0 ? params : [{ tag: '__placeholder__' }];
}
interface Props {

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

2650
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -38,17 +38,17 @@
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.22",
"concurrently": "^9.2.1",
"eslint": "^9.39.1",
"eslint-config-next": "^16.0.3",
"pagefind": "^1.4.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
}
}

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

5
postcss.config.mjs Normal file
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};

View File

@@ -1,6 +1,50 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@plugin "@tailwindcss/typography";
/* Class-based dark mode for next-themes */
@custom-variant dark (&:where(.dark, .dark *));
/* Auto-detect content in the submodule */
@source "../content";
/* Tailwind v4 CSS-first theme configuration */
@theme {
/* Custom colors (CSS variable references) */
--color-accent: var(--color-accent);
--color-accent-soft: var(--color-accent-soft);
--color-accent-textLight: var(--color-accent-text-light);
--color-accent-textDark: var(--color-accent-text-dark);
/* Custom font families */
--font-serif-eng: var(--font-serif-eng), serif;
--font-serif-cn: "Songti SC", "Noto Serif TC", "SimSun", serif;
/* Custom transition timing */
--ease-snappy: cubic-bezier(0.32, 0.72, 0, 1);
/* Custom transition durations */
--duration-180: 180ms;
--duration-260: 260ms;
/* Custom box shadows */
--shadow-lifted: 0 12px 30px -14px rgba(15, 23, 42, 0.25);
--shadow-outline: 0 0 0 1px rgba(59, 130, 246, 0.25);
/* Custom keyframes */
--animate-fade-in-up: fade-in-up 0.6s ease-out both;
--animate-float-soft: float-soft 12s ease-in-out infinite;
}
@keyframes fade-in-up {
0% { opacity: 0; transform: translateY(8px) scale(0.98); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes float-soft {
0% { transform: translate3d(0,0,0) scale(1); }
50% { transform: translate3d(4px,-6px,0) scale(1.03); }
100% { transform: translate3d(0,0,0) scale(1); }
}
:root {
--motion-duration-short: 180ms;
@@ -129,7 +173,7 @@ body {
}
.prose blockquote::before {
content: '';
content: '\201C';
position: absolute;
top: 0.5rem;
left: 0.8rem;
@@ -147,22 +191,45 @@ body {
@apply -translate-y-0.5 shadow-md;
}
/* Typography plugin prose overrides */
.prose {
font-size: clamp(1rem, 0.2vw + 0.9rem, 1.2rem);
line-height: var(--line-height-body);
color: var(--color-ink-body);
}
.prose a {
color: var(--color-accent-text-light);
}
.prose a:hover {
color: var(--color-accent);
}
.dark .prose a {
color: var(--color-accent-text-dark);
}
.dark .prose a:hover {
color: var(--color-accent);
}
.prose h1 {
font-size: clamp(2.2rem, 1.4rem + 2.2vw, 3.4rem);
line-height: 1.25;
color: var(--color-ink-strong);
font-weight: 700;
letter-spacing: -0.03em;
font-family: var(--font-serif-eng), "Songti SC", serif;
}
.prose h2 {
font-size: clamp(1.8rem, 1.1rem + 1.6vw, 2.8rem);
line-height: 1.3;
color: var(--color-ink-strong);
font-weight: 600;
letter-spacing: -0.02em;
font-family: var(--font-serif-eng), "Songti SC", serif;
}
.prose h3 {
@@ -183,6 +250,26 @@ body {
font-size: clamp(0.85rem, 0.2vw + 0.8rem, 0.95rem);
}
/* Dark mode prose overrides (replaces prose-dark) */
.dark .prose {
color: var(--color-ink-body);
}
.dark .prose strong,
.dark .prose b {
color: #f8fafc;
font-weight: 700;
}
.dark .prose em {
color: #f1f5f9;
}
.dark .prose h1,
.dark .prose h2 {
color: #f8fafc;
}
.prose h1>a,
.prose h2>a,
.prose h3>a,

View File

@@ -1,126 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: [
"./app/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./content/**/*.{md,mdx}"
],
theme: {
extend: {
colors: {
accent: {
DEFAULT: 'var(--color-accent)',
soft: 'var(--color-accent-soft)',
textLight: 'var(--color-accent-text-light)',
textDark: 'var(--color-accent-text-dark)'
}
},
fontFamily: {
'serif-eng': ['var(--font-serif-eng)', 'serif'],
'serif-cn': ['"Songti SC"', '"Noto Serif TC"', '"SimSun"', 'serif'],
},
transitionTimingFunction: {
snappy: 'cubic-bezier(0.32, 0.72, 0, 1)'
},
transitionDuration: {
180: '180ms',
260: '260ms'
},
boxShadow: {
lifted: '0 12px 30px -14px rgba(15, 23, 42, 0.25)',
outline: '0 0 0 1px rgba(59, 130, 246, 0.25)'
},
keyframes: {
'fade-in-up': {
'0%': { opacity: '0', transform: 'translateY(8px) scale(0.98)' },
'100%': { opacity: '1', transform: 'translateY(0) scale(1)' }
},
'float-soft': {
'0%': { transform: 'translate3d(0,0,0) scale(1)' },
'50%': { transform: 'translate3d(4px,-6px,0) scale(1.03)' },
'100%': { transform: 'translate3d(0,0,0) scale(1)' }
}
},
animation: {
'fade-in-up': 'fade-in-up 0.6s ease-out both',
'float-soft': 'float-soft 12s ease-in-out infinite'
},
typography: (theme) => ({
DEFAULT: {
css: {
color: theme('colors.slate.700'),
a: {
color: 'var(--color-accent-text-light)',
'&:hover': {
color: 'var(--color-accent)'
}
},
h1: {
fontWeight: '700',
letterSpacing: '-0.03em',
fontFamily: 'var(--font-serif-eng), "Songti SC", serif',
},
h2: {
fontWeight: '600',
letterSpacing: '-0.02em',
fontFamily: 'var(--font-serif-eng), "Songti SC", serif',
},
blockquote: {
fontStyle: 'normal',
borderLeftColor: 'var(--color-accent-soft)',
color: theme('colors.slate.700'),
backgroundColor: theme('colors.slate.50')
},
code: {
backgroundColor: theme('colors.slate.100'),
padding: '0.15rem 0.35rem',
borderRadius: '0.25rem'
}
}
},
dark: {
css: {
// Slightly softer than pure white for body text
color: theme('colors.slate.200'),
a: {
color: 'var(--color-accent-text-dark)',
'&:hover': {
color: 'var(--color-accent)'
}
},
strong: {
color: theme('colors.slate.50'),
fontWeight: '700'
},
b: {
color: theme('colors.slate.50'),
fontWeight: '700'
},
em: {
color: theme('colors.slate.100')
},
h1: {
color: theme('colors.slate.50')
},
h2: {
color: theme('colors.slate.50')
},
blockquote: {
borderLeftColor: 'var(--color-accent)',
backgroundColor: theme('colors.slate.800'),
color: theme('colors.slate.200'),
p: {
color: theme('colors.slate.200')
}
},
code: {
backgroundColor: theme('colors.slate.800')
}
}
}
})
},
},
plugins: [require('@tailwindcss/typography')],
};