Initial commit: anime-minimalist personal blog

- React 18 + Vite + React Router v6
- Glass morphism cards with rounded corners
- Dark/light theme toggle
- Article CRUD with localStorage persistence
- Search, admin panel (PIN: 2501), sakura petal canvas animation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 18:10:59 +08:00
commit c5994759fb
59 changed files with 5582 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(npm install *)",
"Bash(npm run *)",
"mcp__Claude_in_Chrome__tabs_context_mcp",
"Bash(git init *)",
"Bash(git add *)",
"Bash(git commit -m ' *)"
]
}
}

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

16
README.md Normal file
View File

@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

21
eslint.config.js Normal file
View File

@@ -0,0 +1,21 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
])

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="blweb — 思考·创作·记录" />
<title>blweb · ブログ</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2516
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "blweb",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"vite": "^8.0.10"
}
}

2
public/_redirects Normal file
View File

@@ -0,0 +1,2 @@
/* _redirects for Netlify */
/* /index.html 200

4
public/favicon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#1A1A1A"/>
<text x="16" y="22" font-family="serif" font-size="18" fill="#E8C4C4" text-anchor="middle"></text>
</svg>

After

Width:  |  Height:  |  Size: 227 B

24
public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

35
src/App.jsx Normal file
View File

@@ -0,0 +1,35 @@
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { ThemeProvider } from './contexts/ThemeContext';
import { ArticleProvider } from './contexts/ArticleContext';
import AppShell from './components/layout/AppShell';
import HomePage from './pages/HomePage';
import ArticleDetailPage from './pages/ArticleDetailPage';
import AboutPage from './pages/AboutPage';
import AdminPage from './pages/AdminPage';
import AdminEditorPage from './pages/AdminEditorPage';
import NotFoundPage from './pages/NotFoundPage';
const router = createBrowserRouter([
{
element: <AppShell />,
children: [
{ path: '/', element: <HomePage /> },
{ path: '/article/:slug', element: <ArticleDetailPage /> },
{ path: '/about', element: <AboutPage /> },
{ path: '/admin', element: <AdminPage /> },
{ path: '/admin/new', element: <AdminEditorPage /> },
{ path: '/admin/edit/:slug', element: <AdminEditorPage /> },
{ path: '*', element: <NotFoundPage /> },
],
},
]);
export default function App() {
return (
<ThemeProvider>
<ArticleProvider>
<RouterProvider router={router} />
</ArticleProvider>
</ThemeProvider>
);
}

View File

@@ -0,0 +1,58 @@
import { useState } from 'react';
import styles from './AdminGate.module.css';
const CORRECT_PIN = '2501';
export default function AdminGate({ children }) {
const authed = sessionStorage.getItem('blog_admin_auth') === 'true';
const [pin, setPin] = useState('');
const [shake, setShake] = useState(false);
const [passed, setPassed] = useState(authed);
const handleDigit = (d) => {
if (pin.length >= 4) return;
const next = pin + d;
setPin(next);
if (next.length === 4) {
if (next === CORRECT_PIN) {
sessionStorage.setItem('blog_admin_auth', 'true');
setPassed(true);
} else {
setShake(true);
setTimeout(() => { setPin(''); setShake(false); }, 600);
}
}
};
const handleDelete = () => setPin(p => p.slice(0, -1));
if (passed) return children;
return (
<div className={styles.gate}>
<div className={`${styles.panel} glass-card`}>
<h1 className={styles.title}>管理员入口</h1>
<p className={styles.sub}>请输入 PIN </p>
<div className={`${styles.dots} ${shake ? styles.shake : ''}`}>
{[0,1,2,3].map(i => (
<div key={i} className={`${styles.dot} ${i < pin.length ? styles.filled : ''}`} />
))}
</div>
<div className={styles.keypad}>
{['1','2','3','4','5','6','7','8','9','','0','⌫'].map((k, i) => (
<button
key={i}
className={`${styles.key} ${k === '' ? styles.empty : ''} ${k === '⌫' ? styles.del : ''}`}
onClick={() => k === '⌫' ? handleDelete() : k !== '' ? handleDigit(k) : null}
disabled={k === ''}
>
{k}
</button>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,109 @@
.gate {
display: flex;
align-items: center;
justify-content: center;
min-height: 80vh;
}
.panel {
width: 320px;
padding: var(--space-7) var(--space-6);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-5);
animation: scaleIn 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.title {
font-size: 1.125rem;
font-weight: 600;
color: var(--color-ink);
}
.sub {
font-size: 0.8125rem;
color: var(--color-muted);
margin-top: calc(-1 * var(--space-3));
}
.dots {
display: flex;
gap: var(--space-4);
padding: var(--space-3) 0;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid var(--color-border);
background: transparent;
transition: all var(--transition-fast);
}
.dot.filled {
background: var(--color-accent-strong);
border-color: var(--color-accent-strong);
transform: scale(1.1);
}
@keyframes shakeX {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-8px); }
40% { transform: translateX(8px); }
60% { transform: translateX(-5px); }
80% { transform: translateX(5px); }
}
.shake {
animation: shakeX 0.5s ease;
}
.shake .dot.filled {
background: #e05555;
border-color: #e05555;
}
.keypad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-3);
width: 100%;
}
.key {
display: flex;
align-items: center;
justify-content: center;
height: 56px;
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
background: rgba(var(--color-surface-raw), 0.5);
color: var(--color-ink);
font-family: var(--font-sans);
font-size: 1.25rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
backdrop-filter: blur(8px);
}
.key:hover:not(:disabled) {
background: var(--color-surface);
border-color: var(--color-muted);
transform: scale(1.04);
}
.key:active:not(:disabled) { transform: scale(0.96); }
.key.empty {
border: none;
background: transparent;
cursor: default;
pointer-events: none;
}
.key.del {
font-size: 1rem;
color: var(--color-muted);
}

View File

@@ -0,0 +1,173 @@
import { useState, useRef } from 'react';
import styles from './MarkdownToolbar.module.css';
const TOOLS = [
{ label: 'B', title: '加粗', wrap: (s) => `<strong>${s}</strong>` },
{ label: 'I', title: '斜体', wrap: (s) => `<em>${s}</em>` },
{ label: 'H2', title: '二级标题', wrap: (s) => `<h2>${s}</h2>` },
{ label: '❝', title: '引用', wrap: (s) => `<blockquote>${s}</blockquote>` },
{ label: '</>', title: '代码', wrap: (s) => `<code>${s}</code>` },
];
export default function MarkdownToolbar({ textareaRef }) {
const [imgPopover, setImgPopover] = useState(false);
const [imgUrl, setImgUrl] = useState('');
const [imgAlt, setImgAlt] = useState('');
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef(null);
const insertAt = (html) => {
const ta = textareaRef.current;
if (!ta) return;
const pos = ta.selectionEnd;
const before = ta.value.slice(0, pos);
const after = ta.value.slice(pos);
const next = before + '\n' + html + '\n' + after;
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
setter.call(ta, next);
ta.dispatchEvent(new Event('input', { bubbles: true }));
ta.focus();
};
const apply = (wrapFn) => {
const ta = textareaRef.current;
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const selected = ta.value.slice(start, end) || '内容';
const wrapped = wrapFn(selected);
const before = ta.value.slice(0, start);
const after = ta.value.slice(end);
const next = before + wrapped + after;
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
setter.call(ta, next);
ta.dispatchEvent(new Event('input', { bubbles: true }));
ta.focus();
ta.setSelectionRange(start + wrapped.length, start + wrapped.length);
};
const insertImgUrl = () => {
if (!imgUrl.trim()) return;
const alt = imgAlt.trim() || '图片';
insertAt(`<img src="${imgUrl.trim()}" alt="${alt}" />`);
setImgUrl('');
setImgAlt('');
setImgPopover(false);
};
const handleFileChange = (e) => {
const file = e.target.files[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
alert('图片文件不能超过 2MBbase64 会使文章体积变大)');
return;
}
setUploading(true);
const reader = new FileReader();
reader.onload = (ev) => {
const src = ev.target.result;
const alt = file.name.replace(/\.[^.]+$/, '') || '图片';
insertAt(`<img src="${src}" alt="${alt}" />`);
setUploading(false);
setImgPopover(false);
e.target.value = '';
};
reader.readAsDataURL(file);
};
return (
<div className={styles.toolbarWrap}>
<div className={styles.toolbar}>
{TOOLS.map(t => (
<button
key={t.label}
type="button"
className={styles.tool}
title={t.title}
onMouseDown={(e) => { e.preventDefault(); apply(t.wrap); }}
>
{t.label}
</button>
))}
<div className={styles.separator} />
<button
type="button"
className={styles.tool}
title="插入图片"
onMouseDown={(e) => { e.preventDefault(); setImgPopover(v => !v); }}
>
🖼
</button>
</div>
{imgPopover && (
<div className={`${styles.popover} glass-card`}>
<p className={styles.popoverTitle}>插入图片</p>
<div className={styles.popoverSection}>
<label className={styles.popoverLabel}>图片 URL</label>
<div className={styles.urlRow}>
<input
className={styles.popoverInput}
value={imgUrl}
onChange={e => setImgUrl(e.target.value)}
onKeyDown={e => e.key === 'Enter' && insertImgUrl()}
placeholder="https://example.com/image.jpg"
autoFocus
/>
<button
type="button"
className="btn btn--primary"
onClick={insertImgUrl}
disabled={!imgUrl.trim()}
>
插入
</button>
</div>
<input
className={styles.popoverInput}
value={imgAlt}
onChange={e => setImgAlt(e.target.value)}
placeholder="图片描述alt可选"
style={{ marginTop: 6 }}
/>
</div>
<div className={styles.divider}>
<span></span>
</div>
<div className={styles.popoverSection}>
<label className={styles.popoverLabel}>上传本地图片 2MB base64</label>
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<button
type="button"
className="btn"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
style={{ width: '100%', justifyContent: 'center' }}
>
{uploading ? '处理中…' : '选择图片文件'}
</button>
</div>
<button
type="button"
className={styles.popoverClose}
onClick={() => setImgPopover(false)}
>
取消
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,135 @@
.toolbarWrap {
position: relative;
}
.toolbar {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
background: var(--color-surface);
border-radius: var(--radius-md) var(--radius-md) 0 0;
border: 1px solid var(--color-border);
border-bottom: none;
}
.tool {
padding: var(--space-1) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-ink-muted);
font-family: var(--font-sans);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
}
.tool:hover {
background: var(--color-surface-2);
color: var(--color-ink);
border-color: var(--color-muted);
}
.separator {
width: 1px;
height: 20px;
background: var(--color-border);
margin: 0 var(--space-1);
flex-shrink: 0;
}
/* ── Image popover ── */
.popover {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 50;
width: 360px;
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-4);
animation: slideDown 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.popoverTitle {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-ink);
}
.popoverSection {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.popoverLabel {
font-size: 0.72rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.urlRow {
display: flex;
gap: var(--space-2);
}
.popoverInput {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
font-family: var(--font-sans);
font-size: 0.8125rem;
color: var(--color-ink);
outline: none;
transition: border-color var(--transition-fast);
width: 100%;
}
.popoverInput:focus {
border-color: var(--color-accent-strong);
}
.popoverInput::placeholder {
color: var(--color-muted);
font-size: 0.75rem;
}
.divider {
display: flex;
align-items: center;
gap: var(--space-3);
color: var(--color-muted);
font-size: 0.75rem;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--color-border);
}
.popoverClose {
align-self: flex-end;
border: none;
background: none;
color: var(--color-muted);
font-size: 0.8125rem;
cursor: pointer;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
transition: color var(--transition-fast);
}
.popoverClose:hover {
color: var(--color-ink);
}

View File

@@ -0,0 +1,10 @@
import styles from './ArticleBody.module.css';
export default function ArticleBody({ body }) {
return (
<div
className={`prose ${styles.body}`}
dangerouslySetInnerHTML={{ __html: body }}
/>
);
}

View File

@@ -0,0 +1,4 @@
.body {
max-width: 680px;
margin: 0 auto;
}

View File

@@ -0,0 +1,32 @@
import { Link } from 'react-router-dom';
import TagPill from '../ui/TagPill';
import { formatDateShort } from '../../utils/dateFormat';
import styles from './ArticleCard.module.css';
export default function ArticleCard({ article, index = 0 }) {
const delayClass = `anim-fade-up-delay-${Math.min(index + 1, 5)}`;
return (
<Link
to={`/article/${article.slug}`}
className={`${styles.card} glass-card anim-fade-up ${delayClass}`}
aria-label={article.title}
>
<div
className={styles.colorBar}
style={{ background: article.coverColor }}
/>
<div className={styles.body}>
<div className={styles.meta}>
<time className={styles.date}>{formatDateShort(article.createdAt)}</time>
<span className={styles.readTime}>{article.readingTime} min</span>
</div>
<h2 className={styles.title}>{article.title}</h2>
<p className={styles.excerpt}>{article.excerpt}</p>
<div className={styles.tags}>
{article.tags.map(tag => <TagPill key={tag} tag={tag} />)}
</div>
</div>
</Link>
);
}

View File

@@ -0,0 +1,78 @@
.card {
display: flex;
flex-direction: column;
overflow: hidden;
text-decoration: none;
color: inherit;
cursor: pointer;
}
.colorBar {
height: 4px;
width: 100%;
opacity: 0.7;
transition: opacity var(--transition-base);
}
.card:hover .colorBar {
opacity: 1;
}
.body {
padding: var(--space-5) var(--space-5) var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
flex: 1;
}
.meta {
display: flex;
align-items: center;
gap: var(--space-3);
}
.date {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--color-muted);
letter-spacing: 0.02em;
}
.readTime {
font-size: 0.72rem;
color: var(--color-muted);
padding: 2px 8px;
background: rgba(var(--color-accent-raw), 0.12);
border-radius: var(--radius-pill);
}
.title {
font-size: 1.1875rem;
font-weight: 600;
line-height: 1.35;
letter-spacing: -0.01em;
color: var(--color-ink);
transition: color var(--transition-fast);
}
.card:hover .title {
color: var(--color-accent-strong);
}
.excerpt {
font-size: 0.875rem;
line-height: 1.7;
color: var(--color-ink-muted);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-1);
}

View File

@@ -0,0 +1,16 @@
import ArticleCard from './ArticleCard';
import styles from './ArticleGrid.module.css';
export default function ArticleGrid({ articles }) {
if (!articles.length) {
return <p className={styles.empty}>还没有文章去写第一篇吧 </p>;
}
return (
<div className={styles.grid}>
{articles.map((article, i) => (
<ArticleCard key={article.id} article={article} index={i} />
))}
</div>
);
}

View File

@@ -0,0 +1,12 @@
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--space-5);
}
.empty {
text-align: center;
color: var(--color-muted);
padding: var(--space-9) 0;
font-size: 0.9375rem;
}

View File

@@ -0,0 +1,30 @@
import { Outlet, useLocation } from 'react-router-dom';
import { useState, useEffect, useRef } from 'react';
import Header from './Header';
import Footer from './Footer';
import PetalCanvas from '../ui/PetalCanvas';
export default function AppShell() {
const location = useLocation();
const prevPath = useRef(location.pathname);
const [petalTrigger, setPetalTrigger] = useState(0);
useEffect(() => {
if (prevPath.current !== location.pathname) {
prevPath.current = location.pathname;
setPetalTrigger(t => t + 1);
window.scrollTo({ top: 0, behavior: 'instant' });
}
}, [location.pathname]);
return (
<>
<PetalCanvas trigger={petalTrigger} />
<Header />
<main className="page-main" style={{ position: 'relative', zIndex: 1 }}>
<Outlet />
</main>
<Footer />
</>
);
}

View File

@@ -0,0 +1,12 @@
import styles from './Footer.module.css';
export default function Footer() {
return (
<footer className={styles.footer}>
<div className={`${styles.inner} container`}>
<p className={styles.quote}>静かな水面に深い墨が溶けていく</p>
<p className={styles.copy}>© {new Date().getFullYear()} blweb · All rights reserved</p>
</div>
</footer>
);
}

View File

@@ -0,0 +1,26 @@
.footer {
margin-top: var(--space-10);
padding: var(--space-7) 0 var(--space-6);
border-top: 1px solid var(--color-border);
}
.inner {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
text-align: center;
}
.quote {
font-family: var(--font-jp);
font-size: 0.875rem;
color: var(--color-muted);
letter-spacing: 0.04em;
}
.copy {
font-size: 0.75rem;
color: var(--color-muted);
font-family: var(--font-mono);
}

View File

@@ -0,0 +1,48 @@
import { useState } from 'react';
import { NavLink, Link } from 'react-router-dom';
import ThemeToggle from '../ui/ThemeToggle';
import SearchBar from '../ui/SearchBar';
import styles from './Header.module.css';
export default function Header() {
const [searchOpen, setSearchOpen] = useState(false);
return (
<>
<header className={styles.header}>
<div className={`${styles.inner} container container--wide`}>
<Link to="/" className={styles.logo}>
<span className={styles.logoMain}>blweb</span>
<span className={styles.logoSub}>ブログ</span>
</Link>
<nav className={styles.nav}>
<NavLink to="/" className={({ isActive }) => `${styles.navLink} ${isActive ? styles.active : ''}`} end>
首页
</NavLink>
<NavLink to="/about" className={({ isActive }) => `${styles.navLink} ${isActive ? styles.active : ''}`}>
关于
</NavLink>
</nav>
<div className={styles.actions}>
<button
className={styles.searchBtn}
onClick={() => setSearchOpen(true)}
aria-label="搜索"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2"/>
<line x1="21" y1="21" x2="16.65" y2="16.65" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
<span>搜索</span>
</button>
<ThemeToggle />
</div>
</div>
</header>
<SearchBar open={searchOpen} onClose={() => setSearchOpen(false)} />
</>
);
}

View File

@@ -0,0 +1,111 @@
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--header-height);
z-index: 100;
background: rgba(var(--color-bg-raw), 0.75);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border-bottom: 1px solid var(--color-border);
transition: background var(--transition-base), border-color var(--transition-base);
}
.inner {
display: flex;
align-items: center;
height: 100%;
gap: var(--space-6);
}
.logo {
display: flex;
flex-direction: column;
line-height: 1;
text-decoration: none;
flex-shrink: 0;
}
.logoMain {
font-size: 1.125rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-ink);
}
.logoSub {
font-size: 0.6rem;
color: var(--color-muted);
font-family: var(--font-jp);
letter-spacing: 0.08em;
margin-top: 1px;
}
.nav {
display: flex;
gap: var(--space-1);
flex: 1;
}
.navLink {
position: relative;
padding: var(--space-2) var(--space-3);
font-size: 0.875rem;
font-weight: 500;
color: var(--color-ink-muted);
border-radius: var(--radius-sm);
transition: color var(--transition-fast), background var(--transition-fast);
text-decoration: none;
}
.navLink::after {
content: '';
position: absolute;
bottom: 4px;
left: var(--space-3);
right: var(--space-3);
height: 1.5px;
background: var(--color-accent-strong);
border-radius: var(--radius-pill);
transform: scaleX(0);
transform-origin: center;
transition: transform var(--transition-base);
}
.navLink:hover { color: var(--color-ink); }
.navLink:hover::after { transform: scaleX(1); }
.navLink.active { color: var(--color-ink); }
.navLink.active::after { transform: scaleX(1); }
.actions {
display: flex;
align-items: center;
gap: var(--space-2);
margin-left: auto;
}
.searchBtn {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-pill);
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-ink-muted);
font-family: var(--font-sans);
font-size: 0.8125rem;
cursor: pointer;
transition: all var(--transition-fast);
}
.searchBtn:hover {
background: var(--color-surface);
color: var(--color-ink);
border-color: var(--color-muted);
}
@media (max-width: 600px) {
.searchBtn span { display: none; }
}

View File

@@ -0,0 +1,87 @@
import { useEffect, useRef } from 'react';
import { useTheme } from '../../contexts/ThemeContext';
function drawPetal(ctx, x, y, size, angle, alpha) {
ctx.save();
ctx.globalAlpha = alpha;
ctx.translate(x, y);
ctx.rotate(angle);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.bezierCurveTo(size * 0.5, -size * 0.3, size, size * 0.1, size * 0.5, size * 0.6);
ctx.bezierCurveTo(0, size * 1.1, -size * 0.5, size * 0.6, 0, 0);
ctx.closePath();
ctx.fill();
ctx.restore();
}
export default function PetalCanvas({ trigger }) {
const canvasRef = useRef(null);
const { theme } = useTheme();
const petalsRef = useRef([]);
const rafRef = useRef(null);
useEffect(() => {
if (trigger === 0) return;
const canvas = canvasRef.current;
if (!canvas) return;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const ctx = canvas.getContext('2d');
const color = theme === 'dark' ? '196,160,160' : '232,196,196';
petalsRef.current = Array.from({ length: 10 }, (_, i) => ({
x: Math.random() * canvas.width,
y: -20 - Math.random() * 100,
size: 8 + Math.random() * 10,
angle: Math.random() * Math.PI * 2,
vx: (Math.random() - 0.5) * 0.8,
vy: 1.2 + Math.random() * 1.5,
va: (Math.random() - 0.5) * 0.04,
life: 0,
maxLife: 180 + Math.random() * 80,
phase: i * 0.6,
color,
}));
const animate = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
let alive = false;
petalsRef.current.forEach(p => {
p.life++;
if (p.life > p.maxLife) return;
alive = true;
p.x += p.vx + Math.sin(p.life * 0.04 + p.phase) * 0.5;
p.y += p.vy;
p.angle += p.va;
const alpha = p.life < 20
? p.life / 20 * 0.65
: p.life > p.maxLife - 30
? (p.maxLife - p.life) / 30 * 0.65
: 0.65;
ctx.fillStyle = `rgba(${p.color}, 1)`;
drawPetal(ctx, p.x, p.y, p.size, p.angle, alpha);
});
if (alive) rafRef.current = requestAnimationFrame(animate);
else ctx.clearRect(0, 0, canvas.width, canvas.height);
};
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(rafRef.current);
}, [trigger, theme]);
return (
<canvas
ref={canvasRef}
style={{
position: 'fixed', inset: 0, pointerEvents: 'none',
zIndex: 0, width: '100%', height: '100%',
}}
aria-hidden="true"
/>
);
}

View File

@@ -0,0 +1,21 @@
import { useEffect, useState } from 'react';
import styles from './ScrollProgress.module.css';
export default function ScrollProgress() {
const [pct, setPct] = useState(0);
useEffect(() => {
const update = () => {
const el = document.documentElement;
const scrolled = el.scrollTop;
const total = el.scrollHeight - el.clientHeight;
setPct(total > 0 ? (scrolled / total) * 100 : 0);
};
window.addEventListener('scroll', update, { passive: true });
return () => window.removeEventListener('scroll', update);
}, []);
return (
<div className={styles.bar} style={{ width: `${pct}%` }} aria-hidden="true" />
);
}

View File

@@ -0,0 +1,10 @@
.bar {
position: fixed;
top: 0;
left: 0;
height: 2px;
background: linear-gradient(90deg, var(--color-accent-strong), var(--color-accent));
z-index: 200;
transition: width 0.1s linear;
border-radius: 0 var(--radius-pill) var(--radius-pill) 0;
}

View File

@@ -0,0 +1,76 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useArticles } from '../../contexts/ArticleContext';
import { search } from '../../utils/searchIndex';
import { formatDateShort } from '../../utils/dateFormat';
import styles from './SearchBar.module.css';
export default function SearchBar({ open, onClose }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const inputRef = useRef(null);
const { articles } = useArticles();
const navigate = useNavigate();
useEffect(() => {
if (open) {
setQuery('');
setResults([]);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
useEffect(() => {
const t = setTimeout(() => {
setResults(query.trim() ? search(query, articles) : []);
}, 150);
return () => clearTimeout(t);
}, [query, articles]);
const handleKey = useCallback((e) => {
if (e.key === 'Escape') onClose();
}, [onClose]);
const goToArticle = (slug) => {
onClose();
navigate(`/article/${slug}`);
};
if (!open) return null;
return (
<div className={styles.overlay} onClick={onClose} onKeyDown={handleKey}>
<div className={`${styles.panel} glass`} onClick={e => e.stopPropagation()}>
<div className={styles.inputRow}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" className={styles.icon}>
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2"/>
<line x1="21" y1="21" x2="16.65" y2="16.65" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
<input
ref={inputRef}
className={styles.input}
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="搜索文章…"
/>
<button className={styles.close} onClick={onClose}></button>
</div>
{results.length > 0 && (
<ul className={styles.results}>
{results.map(a => (
<li key={a.id} className={styles.result} onClick={() => goToArticle(a.slug)}>
<span className={styles.title}>{a.title}</span>
<span className={styles.date}>{formatDateShort(a.createdAt)}</span>
</li>
))}
</ul>
)}
{query.trim() && results.length === 0 && (
<p className={styles.empty}>没有找到相关文章</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.3);
backdrop-filter: blur(4px);
z-index: 300;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 100px;
animation: fadeIn 150ms ease;
}
.panel {
width: 100%;
max-width: 560px;
border-radius: var(--radius-lg);
overflow: hidden;
animation: slideDown 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.inputRow {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-border);
}
.icon {
color: var(--color-muted);
flex-shrink: 0;
}
.input {
flex: 1;
border: none;
background: transparent;
font-family: var(--font-sans);
font-size: 1rem;
color: var(--color-ink);
outline: none;
}
.input::placeholder { color: var(--color-muted); }
.close {
border: none;
background: none;
color: var(--color-muted);
cursor: pointer;
font-size: 0.875rem;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
transition: color var(--transition-fast);
}
.close:hover { color: var(--color-ink); }
.results {
list-style: none;
max-height: 320px;
overflow-y: auto;
padding: var(--space-2) 0;
}
.result {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-5);
cursor: pointer;
transition: background var(--transition-fast);
}
.result:hover { background: rgba(var(--color-accent-raw), 0.1); }
.title {
font-size: 0.9375rem;
color: var(--color-ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.date {
font-size: 0.75rem;
color: var(--color-muted);
flex-shrink: 0;
font-family: var(--font-mono);
}
.empty {
padding: var(--space-5);
text-align: center;
color: var(--color-muted);
font-size: 0.875rem;
}

View File

@@ -0,0 +1,14 @@
import styles from './TagPill.module.css';
export default function TagPill({ tag, onClick }) {
return (
<span
className={styles.pill}
onClick={onClick}
role={onClick ? 'button' : undefined}
style={onClick ? { cursor: 'pointer' } : undefined}
>
{tag}
</span>
);
}

View File

@@ -0,0 +1,19 @@
.pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: var(--radius-pill);
background: rgba(var(--color-accent-raw), 0.15);
color: var(--color-ink-muted);
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.02em;
border: 1px solid rgba(var(--color-accent-raw), 0.25);
transition: all var(--transition-fast);
user-select: none;
}
.pill:hover {
background: rgba(var(--color-accent-raw), 0.28);
color: var(--color-ink);
}

View File

@@ -0,0 +1,39 @@
import { useTheme } from '../../contexts/ThemeContext';
import styles from './ThemeToggle.module.css';
export default function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
const isDark = theme === 'dark';
return (
<button
className={styles.toggle}
onClick={toggleTheme}
aria-label={isDark ? '切换到亮色模式' : '切换到暗色模式'}
title={isDark ? '切换到亮色模式' : '切换到暗色模式'}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" className={styles.icon}>
{isDark ? (
/* Moon */
<path
d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
/>
) : (
/* Sun */
<>
<circle cx="12" cy="12" r="5" stroke="currentColor" strokeWidth="2"/>
<line x1="12" y1="1" x2="12" y2="3" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<line x1="12" y1="21" x2="12" y2="23" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<line x1="1" y1="12" x2="3" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<line x1="21" y1="12" x2="23" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</>
)}
</svg>
</button>
);
}

View File

@@ -0,0 +1,26 @@
.toggle {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--radius-pill);
border: 1px solid var(--color-border);
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
color: var(--color-ink);
cursor: pointer;
transition: all var(--transition-fast);
flex-shrink: 0;
}
.toggle:hover {
background: var(--color-surface);
border-color: var(--color-muted);
transform: rotate(12deg);
}
.icon {
transition: transform var(--transition-base);
}

View File

@@ -0,0 +1,77 @@
import { createContext, useContext, useState, useCallback } from 'react';
import { loadArticles, saveArticles } from '../utils/articleStorage';
import { slugify } from '../utils/slugify';
import { readingTime } from '../utils/dateFormat';
import { seedArticles } from '../data/seedArticles';
const ArticleContext = createContext(null);
export function ArticleProvider({ children }) {
const [articles, setArticles] = useState(() => {
const stored = loadArticles();
if (stored && stored.length > 0) return stored;
saveArticles(seedArticles);
return seedArticles;
});
const persist = useCallback((next) => {
setArticles(next);
saveArticles(next);
}, []);
const getPublished = useCallback(() =>
[...articles]
.filter(a => a.published)
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)),
[articles]
);
const getArticle = useCallback((slug) =>
articles.find(a => a.slug === slug),
[articles]
);
const createArticle = useCallback((data) => {
const now = new Date().toISOString();
const article = {
id: crypto.randomUUID(),
slug: slugify(data.title),
excerpt: data.excerpt || data.body.replace(/<[^>]+>/g, '').slice(0, 120),
readingTime: readingTime(data.body),
coverColor: '#E8C4C4',
published: false,
createdAt: now,
updatedAt: now,
...data,
};
persist([article, ...articles]);
return article;
}, [articles, persist]);
const updateArticle = useCallback((slug, data) => {
const next = articles.map(a => {
if (a.slug !== slug) return a;
const updated = { ...a, ...data, updatedAt: new Date().toISOString() };
if (data.body) updated.readingTime = readingTime(data.body);
if (!data.excerpt && data.body) {
updated.excerpt = data.body.replace(/<[^>]+>/g, '').slice(0, 120);
}
return updated;
});
persist(next);
}, [articles, persist]);
const deleteArticle = useCallback((slug) => {
persist(articles.filter(a => a.slug !== slug));
}, [articles, persist]);
return (
<ArticleContext.Provider value={{ articles, getPublished, getArticle, createArticle, updateArticle, deleteArticle }}>
{children}
</ArticleContext.Provider>
);
}
export function useArticles() {
return useContext(ArticleContext);
}

View File

@@ -0,0 +1,26 @@
import { createContext, useContext, useEffect, useState } from 'react';
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => {
return localStorage.getItem('blog_theme') || 'light';
});
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('blog_theme', theme);
}, [theme]);
const toggleTheme = () => setTheme(t => t === 'light' ? 'dark' : 'light');
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}

63
src/data/seedArticles.js Normal file
View File

@@ -0,0 +1,63 @@
export const seedArticles = [
{
id: 'seed-1',
slug: 'hello-world',
title: '你好,世界 · はじめまして',
excerpt: '这是我的第一篇博客文章。在这里,我会分享我的想法、设计灵感和技术探索。欢迎来到这片静谧的数字空间。',
body: `<p>欢迎来到这片属于我的数字角落。</p>
<p>我一直相信,写作是思考的延伸。当我们把想法付诸文字,它们就从模糊变得清晰,从短暂变得永恒。</p>
<h2>关于这个博客</h2>
<p>这里会有设计漫谈、代码随笔,也会有日常的碎碎念。没有固定的更新频率,只有真实的内容。</p>
<blockquote>静かな水面に、深い墨が溶けていく。<br>在静谧的水面,深墨缓缓溶开。</blockquote>
<p>如果你在这里找到了共鸣,那便是最大的礼物。</p>`,
tags: ['随笔', '生活'],
coverColor: '#E8C4C4',
published: true,
createdAt: new Date(Date.now() - 7 * 86400000).toISOString(),
updatedAt: new Date(Date.now() - 7 * 86400000).toISOString(),
readingTime: 2,
},
{
id: 'seed-2',
slug: 'design-notes-minimalism',
title: '极简设计笔记:少即是多',
excerpt: '极简主义不是贫乏,而是一种克制之美。本文探讨日系设计哲学中的留白、间距与视觉呼吸感。',
body: `<p>「间」——这个汉字在日语中读作 <em>ma</em>,意指空间、间隔、时机。</p>
<p>日本设计师深谙留白的力量。一张卡片上,空白的面积往往比内容更多,而这些空白并非浪费,而是让内容得以呼吸的空气。</p>
<h2>视觉层级的建立</h2>
<p>好的排版设计首先是一套清晰的层级系统。标题、正文、注脚,每一层都有自己的重量和呼吸节奏。</p>
<h2>颜色的克制</h2>
<p>极简调色板通常不超过三种色彩:一个中性底色、一个墨色文字色、一个点缀强调色。多余的颜色是噪音。</p>
<blockquote>Less is not more boring. Less is more focused.</blockquote>
<p>下次当你想添加一个新元素时,先问自己:移除它会怎样?如果答案是「变得更好」,那就移除它。</p>`,
tags: ['设计', '极简'],
coverColor: '#C4D4E8',
published: true,
createdAt: new Date(Date.now() - 3 * 86400000).toISOString(),
updatedAt: new Date(Date.now() - 3 * 86400000).toISOString(),
readingTime: 3,
},
{
id: 'seed-3',
slug: 'vite-react-setup',
title: '用 Vite + React 构建现代前端项目',
excerpt: 'Vite 的出现改变了前端开发的体验。零配置启动、极速热更新,让开发回归纯粹的乐趣。',
body: `<p>如果说 Webpack 是一辆重型卡车,那么 Vite 就是一辆跑车。</p>
<p>Vite 利用浏览器原生的 ES Module 特性,在开发模式下跳过打包步骤,实现毫秒级的冷启动。</p>
<h2>项目初始化</h2>
<pre><code>npm create vite@latest my-app -- --template react
cd my-app
npm install
npm run dev</code></pre>
<p>四行命令,一个现代 React 项目就诞生了。</p>
<h2>配置 CSS 模块</h2>
<p>Vite 内置支持 CSS 自定义属性、PostCSS 和 CSS Modules无需额外配置。配合设计 token 系统,可以轻松实现主题切换。</p>
<p>前端工具链的演进从未停止,但有些东西是永恒的:简单、快速、可靠。</p>`,
tags: ['技術', 'React', 'Vite'],
coverColor: '#C4E8C4',
published: true,
createdAt: new Date(Date.now() - 86400000).toISOString(),
updatedAt: new Date(Date.now() - 86400000).toISOString(),
readingTime: 3,
},
];

10
src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './styles/index.css';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
);

51
src/pages/AboutPage.jsx Normal file
View File

@@ -0,0 +1,51 @@
import styles from './AboutPage.module.css';
export default function AboutPage() {
return (
<div className={`container ${styles.page}`}>
<section className={styles.hero}>
<div className={styles.watermark} aria-hidden="true"></div>
<div className={styles.content} >
<p className={styles.label}>About Me</p>
<h1 className={styles.name}>你好我是博主</h1>
<p className={styles.bio}>
一个热爱设计与代码的创作者喜欢在简洁的界面中寻找美感
在复杂的系统中寻找秩序平时写写前端摄影随笔
偶尔发呆看云
</p>
</div>
</section>
<div className={styles.divider} />
<section className={styles.skills}>
<h2 className={styles.sectionTitle}>技能栈</h2>
<div className={styles.skillGrid}>
{[
{ cat: '前端', items: ['React', 'Vue', 'TypeScript', 'CSS'] },
{ cat: '工具', items: ['Vite', 'Git', 'Figma', 'VS Code'] },
{ cat: '兴趣', items: ['设计', '摄影', '阅读', '咖啡'] },
].map(({ cat, items }) => (
<div key={cat} className={`glass-card ${styles.skillCard}`}>
<h3 className={styles.skillCat}>{cat}</h3>
<ul className={styles.skillList}>
{items.map(i => <li key={i}>{i}</li>)}
</ul>
</div>
))}
</div>
</section>
<section className={styles.contact}>
<h2 className={styles.sectionTitle}>联系方式</h2>
<p className={styles.contactText}>
有什么想聊的欢迎通过以下方式找到我
</p>
<div className={styles.links}>
<a href="mailto:hello@example.com" className="btn btn--primary">发邮件</a>
<a href="https://github.com" target="_blank" rel="noopener noreferrer" className="btn">GitHub</a>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,129 @@
.page {
padding-bottom: var(--space-10);
}
.hero {
position: relative;
padding: var(--space-9) 0 var(--space-8);
overflow: hidden;
}
.watermark {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
font-size: clamp(10rem, 25vw, 20rem);
font-family: var(--font-jp);
font-weight: 700;
color: var(--color-ink);
opacity: 0.04;
line-height: 1;
pointer-events: none;
user-select: none;
}
.content {
position: relative;
z-index: 1;
max-width: 600px;
animation: fadeSlideUp 400ms both;
}
.label {
font-size: 0.72rem;
font-family: var(--font-mono);
color: var(--color-muted);
letter-spacing: 0.18em;
text-transform: uppercase;
margin-bottom: var(--space-3);
}
.name {
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 700;
letter-spacing: -0.025em;
margin-bottom: var(--space-5);
color: var(--color-ink);
}
.bio {
font-size: 1.0625rem;
line-height: 1.85;
color: var(--color-ink-muted);
max-width: 520px;
}
.divider {
height: 1px;
background: linear-gradient(90deg, var(--color-border), transparent);
margin: var(--space-7) 0;
}
.sectionTitle {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-muted);
letter-spacing: 0.1em;
text-transform: uppercase;
margin-bottom: var(--space-5);
}
.skills {
margin-bottom: var(--space-8);
}
.skillGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--space-4);
}
.skillCard {
padding: var(--space-5);
}
.skillCat {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-accent-strong);
margin-bottom: var(--space-3);
letter-spacing: 0.02em;
}
.skillList {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.skillList li {
font-size: 0.875rem;
color: var(--color-ink-muted);
padding-left: var(--space-3);
position: relative;
}
.skillList li::before {
content: '·';
position: absolute;
left: 0;
color: var(--color-accent);
}
.contact {
margin-top: var(--space-8);
}
.contactText {
font-size: 0.9375rem;
color: var(--color-ink-muted);
margin-bottom: var(--space-5);
}
.links {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
}

View File

@@ -0,0 +1,138 @@
import { useState, useRef, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import AdminGate from '../components/admin/AdminGate';
import MarkdownToolbar from '../components/admin/MarkdownToolbar';
import TagPill from '../components/ui/TagPill';
import { useArticles } from '../contexts/ArticleContext';
import styles from './AdminEditorPage.module.css';
const COLORS = ['#E8C4C4','#C4D4E8','#C4E8C4','#E8DCC4','#D4C4E8','#E8E4C4','#C4E8E8','#E8C4DC'];
function EditorContent() {
const { slug } = useParams();
const navigate = useNavigate();
const { getArticle, createArticle, updateArticle } = useArticles();
const existing = slug ? getArticle(slug) : null;
const isEdit = !!existing;
const [title, setTitle] = useState(existing?.title || '');
const [tagInput, setTagInput] = useState(existing?.tags.join(', ') || '');
const [body, setBody] = useState(existing?.body || '');
const [published, setPublished] = useState(existing?.published ?? false);
const [coverColor, setCoverColor] = useState(existing?.coverColor || COLORS[0]);
const [preview, setPreview] = useState(false);
const textareaRef = useRef(null);
const tags = tagInput.split(',').map(t => t.trim()).filter(Boolean);
const handleSave = () => {
if (!title.trim()) { alert('请输入标题'); return; }
const data = { title: title.trim(), body, tags, coverColor, published };
if (isEdit) {
updateArticle(slug, data);
} else {
createArticle(data);
}
navigate('/admin');
};
return (
<div className={`container ${styles.page}`}>
<div className={styles.topBar}>
<Link to="/admin" className={styles.back}> 返回管理</Link>
<div className={styles.topActions}>
<label className={styles.publishToggle}>
<input
type="checkbox"
checked={published}
onChange={e => setPublished(e.target.checked)}
/>
<span>{published ? '已发布' : '草稿'}</span>
</label>
<button className="btn btn--primary" onClick={handleSave}>保存</button>
</div>
</div>
<div className={styles.editor}>
<input
className={styles.titleInput}
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="文章标题…"
/>
<div className={styles.metaRow}>
<div className={styles.fieldGroup}>
<label className={styles.label}>标签逗号分隔</label>
<input
className={styles.input}
value={tagInput}
onChange={e => setTagInput(e.target.value)}
placeholder="随笔, 设计, 技术"
/>
{tags.length > 0 && (
<div className={styles.tagPreview}>
{tags.map(t => <TagPill key={t} tag={t} />)}
</div>
)}
</div>
<div className={styles.fieldGroup}>
<label className={styles.label}>主题色</label>
<div className={styles.colorRow}>
{COLORS.map(c => (
<button
key={c}
className={`${styles.colorSwatch} ${coverColor === c ? styles.selected : ''}`}
style={{ background: c }}
onClick={() => setCoverColor(c)}
aria-label={c}
/>
))}
</div>
</div>
</div>
<div className={styles.bodySection}>
<div className={styles.bodyTabs}>
<button
className={`${styles.tab} ${!preview ? styles.activeTab : ''}`}
onClick={() => setPreview(false)}
>编辑</button>
<button
className={`${styles.tab} ${preview ? styles.activeTab : ''}`}
onClick={() => setPreview(true)}
>预览</button>
</div>
{!preview ? (
<>
<MarkdownToolbar textareaRef={textareaRef} />
<textarea
ref={textareaRef}
className={styles.textarea}
value={body}
onChange={e => setBody(e.target.value)}
placeholder="<p>在这里写正文 HTML…</p>"
spellCheck={false}
/>
</>
) : (
<div
className={`prose ${styles.previewBox}`}
dangerouslySetInnerHTML={{ __html: body || '<p style="color:var(--color-muted)">预览将在此显示…</p>' }}
/>
)}
</div>
</div>
</div>
);
}
export default function AdminEditorPage() {
return (
<AdminGate>
<EditorContent />
</AdminGate>
);
}

View File

@@ -0,0 +1,182 @@
.page {
padding-bottom: var(--space-10);
max-width: var(--wide-width);
}
.topBar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-6);
flex-wrap: wrap;
gap: var(--space-3);
}
.back {
font-size: 0.875rem;
color: var(--color-muted);
transition: color var(--transition-fast);
text-decoration: none;
}
.back:hover { color: var(--color-ink); }
.topActions {
display: flex;
align-items: center;
gap: var(--space-4);
}
.publishToggle {
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
font-size: 0.875rem;
color: var(--color-ink-muted);
user-select: none;
}
.publishToggle input { accent-color: var(--color-accent-strong); }
.editor {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.titleInput {
width: 100%;
border: none;
border-bottom: 1px solid var(--color-border);
background: transparent;
font-family: var(--font-sans);
font-size: clamp(1.5rem, 4vw, 2.25rem);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-ink);
padding: var(--space-2) 0;
outline: none;
transition: border-color var(--transition-fast);
}
.titleInput:focus { border-color: var(--color-accent-strong); }
.titleInput::placeholder { color: var(--color-muted); }
.metaRow {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--space-5);
align-items: start;
}
.fieldGroup {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.label {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.input {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
font-family: var(--font-sans);
font-size: 0.875rem;
color: var(--color-ink);
outline: none;
transition: border-color var(--transition-fast);
width: 100%;
}
.input:focus { border-color: var(--color-accent-strong); }
.tagPreview {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.colorRow {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.colorSwatch {
width: 28px;
height: 28px;
border-radius: var(--radius-md);
border: 2px solid transparent;
cursor: pointer;
transition: all var(--transition-fast);
}
.colorSwatch:hover { transform: scale(1.15); }
.colorSwatch.selected {
border-color: var(--color-ink);
transform: scale(1.1);
}
.bodySection {
display: flex;
flex-direction: column;
}
.bodyTabs {
display: flex;
gap: var(--space-1);
margin-bottom: var(--space-2);
}
.tab {
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-pill);
border: 1px solid var(--color-border);
background: transparent;
font-family: var(--font-sans);
font-size: 0.8125rem;
color: var(--color-muted);
cursor: pointer;
transition: all var(--transition-fast);
}
.tab:hover { background: var(--color-surface); }
.tab.activeTab {
background: var(--color-ink);
color: var(--color-bg);
border-color: var(--color-ink);
}
.textarea {
width: 100%;
min-height: 400px;
padding: var(--space-4);
border: 1px solid var(--color-border);
border-top: none;
border-radius: 0 0 var(--radius-md) var(--radius-md);
background: var(--color-bg);
font-family: var(--font-mono);
font-size: 0.875rem;
line-height: 1.7;
color: var(--color-ink);
outline: none;
resize: vertical;
transition: border-color var(--transition-fast);
}
.textarea:focus { border-color: var(--color-accent-strong); }
.previewBox {
min-height: 400px;
padding: var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
}
@media (max-width: 640px) {
.metaRow { grid-template-columns: 1fr; }
}

95
src/pages/AdminPage.jsx Normal file
View File

@@ -0,0 +1,95 @@
import { Link } from 'react-router-dom';
import AdminGate from '../components/admin/AdminGate';
import { useArticles } from '../contexts/ArticleContext';
import { formatDateShort } from '../utils/dateFormat';
import styles from './AdminPage.module.css';
function AdminContent() {
const { articles, deleteArticle } = useArticles();
const sorted = [...articles].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
const handleDelete = (slug, title) => {
if (window.confirm(`确认删除「${title}」?此操作不可撤销。`)) {
deleteArticle(slug);
}
};
return (
<div className={`container ${styles.page}`}>
<div className={styles.topBar}>
<h1 className={styles.title}>文章管理</h1>
<Link to="/admin/new" className="btn btn--primary">+ 新建文章</Link>
</div>
<div className={styles.stats}>
<div className={`glass-card ${styles.stat}`}>
<span className={styles.statNum}>{articles.length}</span>
<span className={styles.statLabel}>总文章</span>
</div>
<div className={`glass-card ${styles.stat}`}>
<span className={styles.statNum}>{articles.filter(a => a.published).length}</span>
<span className={styles.statLabel}>已发布</span>
</div>
<div className={`glass-card ${styles.stat}`}>
<span className={styles.statNum}>{articles.filter(a => !a.published).length}</span>
<span className={styles.statLabel}>草稿</span>
</div>
</div>
<div className={`glass-card ${styles.tableWrap}`}>
<table className={styles.table}>
<thead>
<tr>
<th>标题</th>
<th>标签</th>
<th>日期</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{sorted.map(a => (
<tr key={a.id}>
<td className={styles.tdTitle}>
<div className={styles.colorDot} style={{ background: a.coverColor }} />
<span>{a.title}</span>
</td>
<td className={styles.tdTags}>
{a.tags.slice(0, 2).join(', ')}
{a.tags.length > 2 && <span className={styles.more}>+{a.tags.length - 2}</span>}
</td>
<td className={styles.tdDate}>{formatDateShort(a.createdAt)}</td>
<td>
<span className={`${styles.badge} ${a.published ? styles.published : styles.draft}`}>
{a.published ? '已发布' : '草稿'}
</span>
</td>
<td className={styles.tdActions}>
<Link to={`/admin/edit/${a.slug}`} className="btn btn--ghost">编辑</Link>
<button
className="btn btn--ghost"
style={{ color: 'var(--color-accent-strong)' }}
onClick={() => handleDelete(a.slug, a.title)}
>
删除
</button>
</td>
</tr>
))}
</tbody>
</table>
{sorted.length === 0 && (
<p className={styles.empty}>还没有文章<Link to="/admin/new">点击新建</Link></p>
)}
</div>
</div>
);
}
export default function AdminPage() {
return (
<AdminGate>
<AdminContent />
</AdminGate>
);
}

View File

@@ -0,0 +1,148 @@
.page {
padding-bottom: var(--space-10);
}
.topBar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-6);
flex-wrap: wrap;
gap: var(--space-4);
}
.title {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-4);
margin-bottom: var(--space-6);
max-width: 480px;
}
.stat {
padding: var(--space-4) var(--space-5);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
}
.statNum {
font-size: 1.75rem;
font-weight: 700;
color: var(--color-ink);
font-family: var(--font-mono);
}
.statLabel {
font-size: 0.72rem;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.tableWrap {
overflow-x: auto;
padding: 0;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.table th {
text-align: left;
padding: var(--space-3) var(--space-4);
font-size: 0.72rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid var(--color-border);
}
.table td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
color: var(--color-ink-muted);
vertical-align: middle;
}
.table tr:last-child td {
border-bottom: none;
}
.table tr:hover td {
background: rgba(var(--color-accent-raw), 0.04);
}
.tdTitle {
display: flex !important;
align-items: center;
gap: var(--space-3);
color: var(--color-ink) !important;
font-weight: 500;
min-width: 200px;
}
.colorDot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.tdTags { color: var(--color-muted); }
.tdDate { font-family: var(--font-mono); font-size: 0.75rem; white-space: nowrap; }
.more {
font-size: 0.7rem;
color: var(--color-muted);
margin-left: 2px;
}
.badge {
display: inline-flex;
padding: 2px 10px;
border-radius: var(--radius-pill);
font-size: 0.72rem;
font-weight: 500;
white-space: nowrap;
}
.published {
background: rgba(100, 200, 120, 0.15);
color: #3a9a50;
border: 1px solid rgba(100, 200, 120, 0.3);
}
.draft {
background: rgba(var(--color-accent-raw), 0.12);
color: var(--color-accent-strong);
border: 1px solid rgba(var(--color-accent-raw), 0.25);
}
.tdActions {
display: flex !important;
gap: var(--space-2);
white-space: nowrap;
}
.empty {
text-align: center;
padding: var(--space-7);
color: var(--color-muted);
}
.empty a {
color: var(--color-accent-strong);
text-decoration: underline;
}

View File

@@ -0,0 +1,54 @@
import { useParams, Link } from 'react-router-dom';
import { useArticles } from '../contexts/ArticleContext';
import ArticleBody from '../components/article/ArticleBody';
import TagPill from '../components/ui/TagPill';
import ScrollProgress from '../components/ui/ScrollProgress';
import { formatDate } from '../utils/dateFormat';
import styles from './ArticleDetailPage.module.css';
export default function ArticleDetailPage() {
const { slug } = useParams();
const { getArticle } = useArticles();
const article = getArticle(slug);
if (!article || !article.published) {
return (
<div className={`container ${styles.notFound}`}>
<p className={styles.notFoundText}>文章不存在或已隐藏</p>
<Link to="/" className="btn"> 返回首页</Link>
</div>
);
}
return (
<>
<ScrollProgress />
<div className={`container ${styles.page}`}>
<Link to="/" className={styles.back}> 返回</Link>
<article className={styles.article}>
<header className={styles.header}>
<div
className={styles.colorStrip}
style={{ background: article.coverColor }}
/>
<div className={styles.headerContent}>
<div className={styles.meta}>
<time className={styles.date}>{formatDate(article.createdAt)}</time>
<span className={styles.readTime}>{article.readingTime} 分钟阅读</span>
</div>
<h1 className={styles.title}>{article.title}</h1>
<div className={styles.tags}>
{article.tags.map(tag => <TagPill key={tag} tag={tag} />)}
</div>
</div>
</header>
<div className={styles.divider} />
<ArticleBody body={article.body} />
</article>
</div>
</>
);
}

View File

@@ -0,0 +1,103 @@
.page {
padding-bottom: var(--space-10);
max-width: 780px;
}
.back {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: 0.875rem;
color: var(--color-muted);
margin-bottom: var(--space-6);
transition: color var(--transition-fast);
text-decoration: none;
}
.back:hover { color: var(--color-ink); }
.article {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border: 1px solid var(--glass-border);
border-radius: var(--radius-xl);
overflow: hidden;
box-shadow: var(--glass-shadow);
animation: fadeSlideUp 400ms both;
}
.colorStrip {
height: 6px;
width: 100%;
opacity: 0.8;
}
.headerContent {
padding: var(--space-7) var(--space-7) var(--space-5);
}
.meta {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.date {
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--color-muted);
}
.readTime {
font-size: 0.75rem;
color: var(--color-muted);
padding: 2px 10px;
background: rgba(var(--color-accent-raw), 0.12);
border-radius: var(--radius-pill);
}
.title {
font-size: clamp(1.75rem, 4vw, 2.5rem);
font-weight: 700;
letter-spacing: -0.025em;
line-height: 1.2;
margin-bottom: var(--space-5);
color: var(--color-ink);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.divider {
height: 1px;
background: linear-gradient(90deg, transparent, var(--color-border), transparent);
margin: 0 var(--space-7);
}
/* ArticleBody renders inside .article, needs padding */
.article :global(.prose) {
padding: var(--space-6) var(--space-7) var(--space-8);
}
.notFound {
padding: var(--space-9) 0;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-5);
}
.notFoundText {
color: var(--color-muted);
}
@media (max-width: 600px) {
.headerContent { padding: var(--space-5); }
.article :global(.prose) { padding: var(--space-4) var(--space-5) var(--space-6); }
.divider { margin: 0 var(--space-5); }
}

33
src/pages/HomePage.jsx Normal file
View File

@@ -0,0 +1,33 @@
import { useArticles } from '../contexts/ArticleContext';
import ArticleGrid from '../components/article/ArticleGrid';
import styles from './HomePage.module.css';
export default function HomePage() {
const { getPublished } = useArticles();
const articles = getPublished();
return (
<div className={`container ${styles.page}`}>
<section className={styles.hero}>
<div className={styles.heroContent}>
<p className={styles.heroEn}>Personal Space</p>
<h1 className={styles.heroTitle}>
思考·创作·记录
</h1>
<p className={styles.heroSub}>
这里是我的数字角落分享设计灵感技术探索与日常随笔
</p>
</div>
<div className={styles.heroWatermark} aria-hidden="true"></div>
</section>
<section className={styles.articles}>
<div className={styles.sectionHeader}>
<h2 className={styles.sectionTitle}>近期文章</h2>
<span className={styles.count}>{articles.length} </span>
</div>
<ArticleGrid articles={articles} />
</section>
</div>
);
}

View File

@@ -0,0 +1,83 @@
.page {
padding-bottom: var(--space-10);
}
.hero {
position: relative;
padding: var(--space-9) 0 var(--space-8);
overflow: hidden;
}
.heroContent {
position: relative;
z-index: 1;
max-width: 560px;
}
.heroEn {
font-size: 0.75rem;
font-family: var(--font-mono);
color: var(--color-muted);
letter-spacing: 0.15em;
text-transform: uppercase;
margin-bottom: var(--space-3);
animation: fadeSlideUp 400ms both;
}
.heroTitle {
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.15;
margin-bottom: var(--space-4);
color: var(--color-ink);
animation: fadeSlideUp 400ms 80ms both;
}
.heroSub {
font-size: 1rem;
color: var(--color-ink-muted);
line-height: 1.75;
max-width: 420px;
animation: fadeSlideUp 400ms 160ms both;
}
.heroWatermark {
position: absolute;
right: -0.1em;
top: 50%;
transform: translateY(-55%);
font-size: clamp(8rem, 20vw, 16rem);
font-family: var(--font-jp);
font-weight: 700;
color: var(--color-ink);
opacity: 0.04;
line-height: 1;
pointer-events: none;
user-select: none;
}
.articles {
margin-top: var(--space-2);
}
.sectionHeader {
display: flex;
align-items: baseline;
gap: var(--space-3);
margin-bottom: var(--space-5);
}
.sectionTitle {
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--color-ink-muted);
text-transform: uppercase;
}
.count {
font-size: 0.75rem;
color: var(--color-muted);
font-family: var(--font-mono);
}

View File

@@ -0,0 +1,27 @@
import { Link } from 'react-router-dom';
import styles from './NotFoundPage.module.css';
export default function NotFoundPage() {
return (
<div className={`container ${styles.page}`}>
<div className={styles.enso} aria-hidden="true">
<svg viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M100 20 C140 20, 180 55, 180 100 C180 145, 145 178, 100 178 C58 178, 22 145, 22 100 C22 60, 52 28, 88 21"
stroke="currentColor" strokeWidth="8" strokeLinecap="round"
fill="none" opacity="0.5"
/>
</svg>
</div>
<div className={styles.content}>
<p className={styles.code}>404</p>
<div className={styles.haiku}>
<p>迷失的页面</p>
<p>如落叶随风而去</p>
<p>寻不见归处</p>
</div>
<Link to="/" className="btn btn--primary">回到首页</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,44 @@
.page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: var(--space-7);
text-align: center;
padding-top: var(--space-8);
}
.enso {
width: 160px;
height: 160px;
color: var(--color-muted);
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-5);
}
.code {
font-family: var(--font-mono);
font-size: 0.875rem;
color: var(--color-muted);
letter-spacing: 0.2em;
}
.haiku {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.haiku p {
font-family: var(--font-jp);
font-size: 1.125rem;
color: var(--color-ink-muted);
letter-spacing: 0.05em;
line-height: 2;
}

50
src/styles/animations.css Normal file
View File

@@ -0,0 +1,50 @@
@keyframes fadeSlideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes inkReveal {
from { clip-path: inset(0 100% 0 0); opacity: 0; }
to { clip-path: inset(0 0% 0 0); opacity: 1; }
}
@keyframes petalDrift {
0% { transform: translateY(-20px) rotate(0deg) translateX(0px); opacity: 0; }
10% { opacity: 0.7; }
90% { opacity: 0.4; }
100% { transform: translateY(110vh) rotate(720deg) translateX(60px); opacity: 0; }
}
@keyframes underlineGrow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
@keyframes progressGrow {
from { width: 0%; }
}
.anim-fade-up {
animation: fadeSlideUp var(--transition-slow) both;
}
.anim-fade-up-delay-1 { animation-delay: 80ms; }
.anim-fade-up-delay-2 { animation-delay: 160ms; }
.anim-fade-up-delay-3 { animation-delay: 240ms; }
.anim-fade-up-delay-4 { animation-delay: 320ms; }
.anim-fade-up-delay-5 { animation-delay: 400ms; }

190
src/styles/index.css Normal file
View File

@@ -0,0 +1,190 @@
@import './tokens.css';
@import './animations.css';
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
font-size: 16px;
}
body {
font-family: var(--font-sans);
background-color: var(--color-bg);
color: var(--color-ink);
line-height: 1.7;
transition: background-color var(--transition-base), color var(--transition-base);
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: var(--radius-pill); }
::-webkit-scrollbar-thumb:hover { background: var(--color-muted); }
/* ── Selection ── */
::selection {
background: rgba(var(--color-accent-raw), 0.35);
color: var(--color-ink);
}
/* ── Glass utility ── */
.glass {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
}
.glass-card {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
box-shadow: var(--glass-shadow);
transition:
transform var(--transition-base),
box-shadow var(--transition-base),
background var(--transition-base);
}
.glass-card:hover {
transform: translateY(-4px);
background: var(--glass-bg-hover);
box-shadow: var(--glass-shadow-hover);
}
/* ── Layout ── */
.container {
width: 100%;
max-width: var(--content-width);
margin: 0 auto;
padding: 0 var(--space-5);
}
.container--wide {
max-width: var(--wide-width);
}
/* ── Typography ── */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-sans);
font-weight: 600;
line-height: 1.25;
color: var(--color-ink);
}
a { color: inherit; text-decoration: none; }
code, pre {
font-family: var(--font-mono);
font-size: 0.875em;
}
pre {
background: var(--color-surface);
padding: var(--space-4);
border-radius: var(--radius-md);
overflow-x: auto;
border: 1px solid var(--color-border);
}
/* ── Prose (article body) ── */
.prose {
font-size: 1.0625rem;
line-height: 1.85;
color: var(--color-ink);
}
.prose h2 { font-size: 1.5rem; margin: var(--space-7) 0 var(--space-4); }
.prose h3 { font-size: 1.25rem; margin: var(--space-6) 0 var(--space-3); }
.prose p { margin-bottom: var(--space-5); }
.prose ul, .prose ol { padding-left: var(--space-5); margin-bottom: var(--space-5); }
.prose li { margin-bottom: var(--space-2); }
.prose blockquote {
border-left: 3px solid var(--color-accent);
padding: var(--space-3) var(--space-5);
margin: var(--space-6) 0;
color: var(--color-ink-muted);
font-style: italic;
background: rgba(var(--color-accent-raw), 0.06);
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
.prose a {
color: var(--color-accent-strong);
text-decoration: underline;
text-decoration-color: rgba(var(--color-accent-raw), 0.4);
text-underline-offset: 3px;
}
.prose img {
max-width: 100%;
border-radius: var(--radius-md);
margin: var(--space-5) 0;
}
.prose code {
background: rgba(var(--color-accent-raw), 0.12);
padding: 2px 6px;
border-radius: var(--radius-sm);
}
/* ── Buttons ── */
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-pill);
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-ink);
font-family: var(--font-sans);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
}
.btn:hover { background: var(--color-surface); border-color: var(--color-muted); }
.btn--primary {
background: var(--color-ink);
color: var(--color-bg);
border-color: var(--color-ink);
}
.btn--primary:hover {
background: var(--color-ink-muted);
border-color: var(--color-ink-muted);
}
.btn--accent {
background: var(--color-accent);
color: var(--color-ink);
border-color: var(--color-accent);
}
.btn--ghost {
border-color: transparent;
}
.btn--ghost:hover { background: var(--color-surface); border-color: transparent; }
/* ── Page shell ── */
.page-main {
padding-top: calc(var(--header-height) + var(--space-7));
min-height: 100vh;
}
/* ── Grid overlay (原稿用紙) ── */
.grid-bg::before {
content: '';
position: absolute;
inset: 0;
background-image:
repeating-linear-gradient(0deg, var(--color-border) 0, var(--color-border) 1px, transparent 1px, transparent 24px),
repeating-linear-gradient(90deg, var(--color-border) 0, var(--color-border) 1px, transparent 1px, transparent 24px);
opacity: 0.25;
pointer-events: none;
}

73
src/styles/tokens.css Normal file
View File

@@ -0,0 +1,73 @@
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Noto+Sans+JP:wght@300;400;500;700&family=JetBrains+Mono:wght@400;500&display=swap');
:root {
--color-bg: #FAFAF8;
--color-bg-raw: 250, 250, 248;
--color-ink: #1A1A1A;
--color-ink-muted: #5A5A5A;
--color-accent: #E8C4C4;
--color-accent-raw: 232, 196, 196;
--color-accent-strong: #C9958F;
--color-muted: #9BA8B9;
--color-surface: #F2F0ED;
--color-surface-raw: 242, 240, 237;
--color-surface-2: #E8E5E0;
--color-border: #E0DDD8;
--font-sans: 'Outfit', 'Noto Sans JP', sans-serif;
--font-jp: 'Noto Sans JP', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--space-7: 48px;
--space-8: 64px;
--space-9: 96px;
--space-10: 128px;
--radius-sm: 8px;
--radius-md: 16px;
--radius-lg: 24px;
--radius-xl: 32px;
--radius-pill: 999px;
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
--glass-bg: rgba(var(--color-bg-raw), 0.55);
--glass-bg-hover: rgba(var(--color-bg-raw), 0.78);
--glass-border: rgba(var(--color-accent-raw), 0.18);
--glass-blur: blur(18px) saturate(180%);
--glass-shadow: 0 4px 24px rgba(0,0,0,0.06), 0 1px 4px rgba(0,0,0,0.04);
--glass-shadow-hover: 0 16px 48px rgba(0,0,0,0.12), 0 4px 12px rgba(0,0,0,0.06);
--header-height: 64px;
--content-width: 860px;
--wide-width: 1200px;
}
[data-theme="dark"] {
--color-bg: #0F0F0F;
--color-bg-raw: 15, 15, 15;
--color-ink: #F0EDE8;
--color-ink-muted: #A0998F;
--color-accent: #C4A0A0;
--color-accent-raw: 196, 160, 160;
--color-accent-strong: #B08080;
--color-muted: #4A5568;
--color-surface: #1A1A1A;
--color-surface-raw: 26, 26, 26;
--color-surface-2: #242424;
--color-border: #2A2A2A;
--glass-bg: rgba(var(--color-bg-raw), 0.6);
--glass-bg-hover: rgba(var(--color-bg-raw), 0.82);
--glass-border: rgba(var(--color-accent-raw), 0.12);
--glass-shadow: 0 4px 24px rgba(0,0,0,0.3);
--glass-shadow-hover: 0 16px 48px rgba(0,0,0,0.5);
}

View File

@@ -0,0 +1,14 @@
const KEY = 'blog_articles';
export function loadArticles() {
try {
const raw = localStorage.getItem(KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
export function saveArticles(articles) {
localStorage.setItem(KEY, JSON.stringify(articles));
}

14
src/utils/dateFormat.js Normal file
View File

@@ -0,0 +1,14 @@
export function formatDate(iso) {
const d = new Date(iso);
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' });
}
export function formatDateShort(iso) {
const d = new Date(iso);
return d.toLocaleDateString('ja-JP', { year: 'numeric', month: '2-digit', day: '2-digit' });
}
export function readingTime(html) {
const text = html.replace(/<[^>]+>/g, '');
return Math.max(1, Math.ceil(text.length / 400));
}

18
src/utils/searchIndex.js Normal file
View File

@@ -0,0 +1,18 @@
export function search(query, articles) {
if (!query.trim()) return [];
const q = query.toLowerCase();
const scored = articles
.filter(a => a.published)
.map(a => {
let score = 0;
if (a.title.toLowerCase().includes(q)) score += 3;
if (a.excerpt.toLowerCase().includes(q)) score += 2;
if (a.tags.some(t => t.toLowerCase().includes(q))) score += 2;
const bodyText = a.body.replace(/<[^>]+>/g, '').toLowerCase();
if (bodyText.includes(q)) score += 1;
return { ...a, _score: score };
})
.filter(a => a._score > 0)
.sort((a, b) => b._score - a._score);
return scored;
}

10
src/utils/slugify.js Normal file
View File

@@ -0,0 +1,10 @@
export function slugify(str) {
return str
.toLowerCase()
.trim()
.replace(/[\s_]+/g, '-')
.replace(/[^\w\-一-鿿぀-ゟ゠-ヿ]/g, '')
.replace(/--+/g, '-')
.replace(/^-+|-+$/g, '')
|| `post-${Date.now()}`;
}

11
vite.config.js Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
assetsDir: 'assets',
},
})