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:
12
.claude/settings.local.json
Normal file
12
.claude/settings.local.json
Normal 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
24
.gitignore
vendored
Normal 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
16
README.md
Normal 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
21
eslint.config.js
Normal 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
14
index.html
Normal 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
2516
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal 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
2
public/_redirects
Normal file
@@ -0,0 +1,2 @@
|
||||
/* _redirects for Netlify */
|
||||
/* /index.html 200
|
||||
4
public/favicon.svg
Normal file
4
public/favicon.svg
Normal 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
24
public/icons.svg
Normal 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
35
src/App.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
src/components/admin/AdminGate.jsx
Normal file
58
src/components/admin/AdminGate.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
src/components/admin/AdminGate.module.css
Normal file
109
src/components/admin/AdminGate.module.css
Normal 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);
|
||||
}
|
||||
173
src/components/admin/MarkdownToolbar.jsx
Normal file
173
src/components/admin/MarkdownToolbar.jsx
Normal 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('图片文件不能超过 2MB(base64 会使文章体积变大)');
|
||||
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>
|
||||
);
|
||||
}
|
||||
135
src/components/admin/MarkdownToolbar.module.css
Normal file
135
src/components/admin/MarkdownToolbar.module.css
Normal 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);
|
||||
}
|
||||
10
src/components/article/ArticleBody.jsx
Normal file
10
src/components/article/ArticleBody.jsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
4
src/components/article/ArticleBody.module.css
Normal file
4
src/components/article/ArticleBody.module.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.body {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
32
src/components/article/ArticleCard.jsx
Normal file
32
src/components/article/ArticleCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
src/components/article/ArticleCard.module.css
Normal file
78
src/components/article/ArticleCard.module.css
Normal 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);
|
||||
}
|
||||
16
src/components/article/ArticleGrid.jsx
Normal file
16
src/components/article/ArticleGrid.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/components/article/ArticleGrid.module.css
Normal file
12
src/components/article/ArticleGrid.module.css
Normal 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;
|
||||
}
|
||||
30
src/components/layout/AppShell.jsx
Normal file
30
src/components/layout/AppShell.jsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
src/components/layout/Footer.jsx
Normal file
12
src/components/layout/Footer.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/components/layout/Footer.module.css
Normal file
26
src/components/layout/Footer.module.css
Normal 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);
|
||||
}
|
||||
48
src/components/layout/Header.jsx
Normal file
48
src/components/layout/Header.jsx
Normal 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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
111
src/components/layout/Header.module.css
Normal file
111
src/components/layout/Header.module.css
Normal 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; }
|
||||
}
|
||||
87
src/components/ui/PetalCanvas.jsx
Normal file
87
src/components/ui/PetalCanvas.jsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
21
src/components/ui/ScrollProgress.jsx
Normal file
21
src/components/ui/ScrollProgress.jsx
Normal 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" />
|
||||
);
|
||||
}
|
||||
10
src/components/ui/ScrollProgress.module.css
Normal file
10
src/components/ui/ScrollProgress.module.css
Normal 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;
|
||||
}
|
||||
76
src/components/ui/SearchBar.jsx
Normal file
76
src/components/ui/SearchBar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src/components/ui/SearchBar.module.css
Normal file
97
src/components/ui/SearchBar.module.css
Normal 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;
|
||||
}
|
||||
14
src/components/ui/TagPill.jsx
Normal file
14
src/components/ui/TagPill.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/components/ui/TagPill.module.css
Normal file
19
src/components/ui/TagPill.module.css
Normal 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);
|
||||
}
|
||||
39
src/components/ui/ThemeToggle.jsx
Normal file
39
src/components/ui/ThemeToggle.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/components/ui/ThemeToggle.module.css
Normal file
26
src/components/ui/ThemeToggle.module.css
Normal 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);
|
||||
}
|
||||
77
src/contexts/ArticleContext.jsx
Normal file
77
src/contexts/ArticleContext.jsx
Normal 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);
|
||||
}
|
||||
26
src/contexts/ThemeContext.jsx
Normal file
26
src/contexts/ThemeContext.jsx
Normal 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
63
src/data/seedArticles.js
Normal 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
10
src/main.jsx
Normal 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
51
src/pages/AboutPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
src/pages/AboutPage.module.css
Normal file
129
src/pages/AboutPage.module.css
Normal 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;
|
||||
}
|
||||
138
src/pages/AdminEditorPage.jsx
Normal file
138
src/pages/AdminEditorPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
src/pages/AdminEditorPage.module.css
Normal file
182
src/pages/AdminEditorPage.module.css
Normal 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
95
src/pages/AdminPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
src/pages/AdminPage.module.css
Normal file
148
src/pages/AdminPage.module.css
Normal 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;
|
||||
}
|
||||
54
src/pages/ArticleDetailPage.jsx
Normal file
54
src/pages/ArticleDetailPage.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
103
src/pages/ArticleDetailPage.module.css
Normal file
103
src/pages/ArticleDetailPage.module.css
Normal 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
33
src/pages/HomePage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
src/pages/HomePage.module.css
Normal file
83
src/pages/HomePage.module.css
Normal 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);
|
||||
}
|
||||
27
src/pages/NotFoundPage.jsx
Normal file
27
src/pages/NotFoundPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/pages/NotFoundPage.module.css
Normal file
44
src/pages/NotFoundPage.module.css
Normal 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
50
src/styles/animations.css
Normal 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
190
src/styles/index.css
Normal 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
73
src/styles/tokens.css
Normal 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);
|
||||
}
|
||||
14
src/utils/articleStorage.js
Normal file
14
src/utils/articleStorage.js
Normal 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
14
src/utils/dateFormat.js
Normal 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
18
src/utils/searchIndex.js
Normal 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
10
src/utils/slugify.js
Normal 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
11
vite.config.js
Normal 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',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user