feat: 个人博客全栈实现(Rust/Axum + React/Tailwind + PostgreSQL)
后端(Axum + sqlx): - GitHub OAuth 三层权限(管理员/用户/游客),MOCK_OAUTH 本地调试开关 - 文章 CRUD、草稿自动保存、发布/私有分离,发布时渲染 HTML - 目录(folder 路径分层)、标签、评论、RSS、热门文章(按评论数) - pages 表驱动动态内容:关于页、首页标题、个人名片 - S3 兼容对象存储图片上传接口(rust-s3,未配置时明确报错) - 发布时自动提取正文首图作为封面 cover_url - sqlx migrate 管理 5 个迁移 前端(React + Tailwind): - 白天 Editorial / 黑夜星空双主题 - 首页(列表 + 热门侧栏 + 可编辑大标题)、文库(目录树+标签+搜索) - Profile 页(GitHub 风格名片卡,全部字段后台可编辑) - 后台:文件树文章管理、CodeMirror 编辑器(实时预览/自动保存/传图) - Markdown 渲染:KaTeX 公式、代码块语言标注 + 双主题语法高亮 - 有封面的文章卡片自动整行展示,全局 ErrorBoundary 部署: - docker-compose 本地三容器(postgres/backend/frontend) - k8s manifests(适配 Caddy 终结 TLS → 隧道 → traefik 明文路由) - 前端静态文件可由 Caddy 直接托管,集群只需 backend + postgres - 可选 postgres 每日备份到 S3 的 CronJob
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.git
|
||||
*.md
|
||||
@@ -0,0 +1,7 @@
|
||||
/node_modules
|
||||
/dist
|
||||
/.vite
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# 构建阶段
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# 运行阶段
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM mcr.microsoft.com/playwright:v1.49.0-jammy
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY screenshot.cjs /app/screenshot.cjs
|
||||
|
||||
# 挂载中文字体
|
||||
VOLUME /usr/share/fonts
|
||||
|
||||
CMD ["node", "screenshot.cjs"]
|
||||
@@ -0,0 +1,32 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint 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 Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" translate="no">
|
||||
<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="google" content="notranslate" />
|
||||
<title>zhaoxi的个人博客</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
Generated
+4790
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-markdown": "^6.5.1",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.43.6",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@uiw/react-codemirror": "^4.25.11",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.21",
|
||||
"framer-motion": "^12.42.2",
|
||||
"github-markdown-css": "^5.9.0",
|
||||
"katex": "^0.18.0",
|
||||
"lucide-react": "^0.474.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"oxlint": "^1.71.0",
|
||||
"playwright": "^1.61.1",
|
||||
"postcss": "^8.5.19",
|
||||
"prettier": "^3.9.5",
|
||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#818CF8"/>
|
||||
<stop offset="0.5" stop-color="#A78BFA"/>
|
||||
<stop offset="1" stop-color="#22D3EE"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="14" fill="#0B1026"/>
|
||||
<path d="M32 8 L36.5 27.5 L56 32 L36.5 36.5 L32 56 L27.5 36.5 L8 32 L27.5 27.5 Z" fill="url(#g)"/>
|
||||
<circle cx="47" cy="15" r="2.5" fill="#818CF8" opacity="0.8"/>
|
||||
<circle cx="15" cy="47" r="2" fill="#22D3EE" opacity="0.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 590 B |
@@ -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 |
@@ -0,0 +1,21 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
// 强制浅色模式
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await page.goto('http://localhost:9000', { waitUntil: 'networkidle' });
|
||||
await page.screenshot({ path: '/output/05-light-editorial.png', fullPage: false });
|
||||
console.log('captured 05-light-editorial.png');
|
||||
|
||||
// 强制深色模式
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
await page.screenshot({ path: '/output/06-dark-space.png', fullPage: false });
|
||||
console.log('captured 06-dark-space.png');
|
||||
|
||||
await browser.close();
|
||||
console.log('done');
|
||||
})();
|
||||
@@ -0,0 +1,184 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { StarBackground } from './components/StarBackground';
|
||||
import { Header } from './components/Header';
|
||||
import { AuthInit } from './components/AuthInit';
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { PostPage } from './pages/PostPage';
|
||||
import { LibraryPage } from './pages/LibraryPage';
|
||||
import { ProfilePage } from './pages/ProfilePage';
|
||||
import { RssPage } from './pages/RssPage';
|
||||
import { OAuthCallback } from './pages/OAuthCallback';
|
||||
import { AdminPage } from './pages/admin/AdminPage';
|
||||
import { EditorPage } from './pages/admin/EditorPage';
|
||||
import { AdminProfilePage } from './pages/admin/AdminProfilePage';
|
||||
import { AdminHeroPage } from './pages/admin/AdminHeroPage';
|
||||
import { AdminLayout } from './pages/admin/AdminLayout';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter>
|
||||
<AuthInit>
|
||||
<StarBackground>
|
||||
<Header />
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/posts/:slug" element={<PostPage />} />
|
||||
<Route path="/library" element={<LibraryPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/about" element={<Navigate to="/profile" replace />} />
|
||||
<Route path="/rss" element={<RssPage />} />
|
||||
<Route path="/archive" element={<Navigate to="/library" replace />} />
|
||||
<Route path="/tags" element={<Navigate to="/library" replace />} />
|
||||
<Route path="/oauth/callback" element={<OAuthCallback />} />
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/admin/new" element={<EditorPage />} />
|
||||
<Route path="/admin/edit/:id" element={<EditorPage />} />
|
||||
<Route path="/admin/profile" element={<AdminProfilePage />} />
|
||||
<Route path="/admin/about" element={<Navigate to="/admin/profile" replace />} />
|
||||
<Route path="/admin/hero" element={<AdminHeroPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</StarBackground>
|
||||
</AuthInit>
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,46 @@
|
||||
export async function apiFetch<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...((options.headers as Record<string, string>) || {}),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let message = `HTTP ${res.status}`;
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (data.error) message = data.error;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new ApiError(message, res.status);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
if (!text) return undefined as T;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return text as T;
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { apiFetch, ApiError } from './client';
|
||||
import {
|
||||
Author,
|
||||
ArchiveItem,
|
||||
CommentView,
|
||||
CreateCommentRequest,
|
||||
CreatePostRequest,
|
||||
FolderCount,
|
||||
HotPost,
|
||||
PageContent,
|
||||
PostDetail,
|
||||
PostSummary,
|
||||
PreviewRequest,
|
||||
PreviewResponse,
|
||||
SaveDraftRequest,
|
||||
TagCount,
|
||||
UpdatePostRequest,
|
||||
} from '../types';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
async function fetcher<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
return apiFetch(`${API_URL}${path}`, options);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Public
|
||||
health: () => fetcher<string>('/health'),
|
||||
listPosts: (params?: { tag?: string; q?: string; folder?: string; page?: number }) => {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.tag) search.set('tag', params.tag);
|
||||
if (params?.q) search.set('q', params.q);
|
||||
if (params?.folder) search.set('folder', params.folder);
|
||||
if (params?.page) search.set('page', String(params.page));
|
||||
return fetcher<PostSummary[]>(`/posts?${search.toString()}`);
|
||||
},
|
||||
getPost: (slug: string) => fetcher<PostDetail>(`/posts/${slug}`),
|
||||
listComments: (slug: string) => fetcher<CommentView[]>(`/posts/${slug}/comments`),
|
||||
listTags: () => fetcher<TagCount[]>('/tags'),
|
||||
listFolders: () => fetcher<FolderCount[]>('/folders'),
|
||||
listHotPosts: () => fetcher<HotPost[]>('/posts/hot'),
|
||||
getArchive: () => fetcher<ArchiveItem[]>('/archive'),
|
||||
getPage: (slug: string) => fetcher<PageContent>(`/pages/${slug}`),
|
||||
savePage: (slug: string, content_md: string) =>
|
||||
fetcher<{ ok: boolean }>(`/admin/pages/${slug}`, { method: 'PUT', body: JSON.stringify({ content_md }) }),
|
||||
uploadImage: async (file: File): Promise<{ url: string }> => {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch(`${API_URL}/admin/upload`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let message = `HTTP ${res.status}`;
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (data.error) message = data.error;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new ApiError(message, res.status);
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
getRss: () => fetch(`${API_URL}/rss.xml`).then((r) => r.text()),
|
||||
|
||||
// Auth
|
||||
getMe: () => fetcher<{ role: string; user?: Author }>('/auth/me'),
|
||||
|
||||
// User
|
||||
createComment: (slug: string, body: CreateCommentRequest) =>
|
||||
fetcher<CommentView>(`/posts/${slug}/comments`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
// Admin
|
||||
adminListPosts: () => fetcher<PostSummary[]>('/admin/posts'),
|
||||
getAdminPost: (id: string) => fetcher<PostDetail>(`/admin/posts/${id}`),
|
||||
createPost: (body: CreatePostRequest) => fetcher<PostDetail>('/admin/posts', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updatePost: (id: string, body: UpdatePostRequest) =>
|
||||
fetcher<PostDetail>(`/admin/posts/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
deletePost: (id: string) => fetcher<{ ok: boolean }>(`/admin/posts/${id}`, { method: 'DELETE' }),
|
||||
saveDraft: (id: string, body: SaveDraftRequest) =>
|
||||
fetcher<{ ok: boolean }>(`/admin/posts/${id}/draft`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
getDraft: (id: string) => fetcher<{ content_md: string }>(`/admin/posts/${id}/draft`),
|
||||
publishPost: (id: string) => fetcher<PostDetail>(`/admin/posts/${id}/publish`, { method: 'POST' }),
|
||||
unpublishPost: (id: string) => fetcher<PostDetail>(`/admin/posts/${id}/unpublish`, { method: 'POST' }),
|
||||
preview: (body: PreviewRequest) => fetcher<PreviewResponse>('/admin/preview', { method: 'POST', body: JSON.stringify(body) }),
|
||||
deleteComment: (id: string) => fetcher<{ ok: boolean }>(`/admin/comments/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,25 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { api } from '../api';
|
||||
|
||||
export function AuthInit({ children }: { children: React.ReactNode }) {
|
||||
const { token, setUser, logout } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setUser('guest', null);
|
||||
return;
|
||||
}
|
||||
|
||||
api
|
||||
.getMe()
|
||||
.then((data) => {
|
||||
setUser(data.role as 'guest' | 'user' | 'admin', data.user || null);
|
||||
})
|
||||
.catch(() => {
|
||||
logout();
|
||||
});
|
||||
}, [token, setUser, logout]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Component, type ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/** 全局错误边界:渲染层报错时显示错误信息而不是白屏 */
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: unknown) {
|
||||
console.error('render error:', error, info);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-8 text-center">
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-star">页面出错了</h1>
|
||||
<pre className="max-w-xl overflow-x-auto rounded-lg bg-slate-100 p-4 text-left text-xs text-red-600 dark:bg-space-800 dark:text-red-400">
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white dark:bg-nebula"
|
||||
>
|
||||
刷新重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Moon, Sun, Menu, X, Github, Sparkles } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useThemeStore } from '../store/theme';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
|
||||
export function Header() {
|
||||
const { theme, toggle } = useThemeStore();
|
||||
const { role, user, logout } = useAuthStore();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
const navItems = [
|
||||
{ label: '首页', to: '/' },
|
||||
{ label: '文库', to: '/library' },
|
||||
];
|
||||
|
||||
if (role === 'admin') {
|
||||
navItems.push({ label: '后台', to: '/admin' });
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="fixed left-0 right-0 top-0 z-50 border-b border-stone-200/80 bg-white/80 backdrop-blur-md dark:border-indigo-500/10 dark:bg-space-900/80">
|
||||
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<Link to="/profile" className="flex items-center gap-2">
|
||||
<Sparkles className="text-indigo-500" size={22} />
|
||||
<span className="text-lg font-bold tracking-tight text-stone-900 dark:text-star">
|
||||
zhaoxi的个人博客
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-8 md:flex">
|
||||
{navItems.map((item) => (
|
||||
<Link key={item.to} to={item.to} className="nav-link text-sm font-medium">
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{role === 'guest' ? (
|
||||
<a
|
||||
href={`${import.meta.env.VITE_API_URL || '/api'}/auth/github?redirect=${encodeURIComponent(window.location.origin + '/oauth/callback')}`}
|
||||
className="btn-press hidden items-center gap-2 rounded-full bg-stone-900 px-4 py-2 text-sm font-medium text-white transition-transform hover:scale-105 dark:bg-nebula dark:text-white md:flex"
|
||||
>
|
||||
<Github size={16} />
|
||||
登录
|
||||
</a>
|
||||
) : (
|
||||
<div className="hidden items-center gap-3 md:flex">
|
||||
{user?.avatar_url ? (
|
||||
<img src={user.avatar_url} alt={user.name} className="h-8 w-8 rounded-full ring-2 ring-indigo-500/30" />
|
||||
) : null}
|
||||
<span className="text-sm text-stone-600 dark:text-star-dim">{user?.name}</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-sm text-stone-500 hover:text-stone-900 dark:text-star-dim dark:hover:text-star"
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="btn-press rounded-full p-2 text-stone-600 transition-colors hover:bg-stone-100 dark:text-star-dim dark:hover:bg-space-800"
|
||||
aria-label="toggle theme"
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
className="rounded-lg p-2 text-stone-600 hover:bg-stone-100 dark:text-star-dim dark:hover:bg-space-800 md:hidden"
|
||||
>
|
||||
{mobileOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mobileOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="border-t border-stone-200 bg-white px-4 py-4 dark:border-indigo-500/10 dark:bg-space-900 md:hidden"
|
||||
>
|
||||
<nav className="flex flex-col gap-3">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="text-base font-medium text-stone-700 dark:text-star"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
{role === 'guest' && (
|
||||
<a
|
||||
href={`${import.meta.env.VITE_API_URL || '/api'}/auth/github?redirect=${encodeURIComponent(window.location.origin + '/oauth/callback')}`}
|
||||
className="mt-2 flex items-center justify-center gap-2 rounded-full bg-stone-900 py-2 text-sm font-medium text-white dark:bg-nebula"
|
||||
>
|
||||
<Github size={16} />
|
||||
GitHub 登录
|
||||
</a>
|
||||
)}
|
||||
</nav>
|
||||
</motion.div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { isValidElement, type ComponentPropsWithoutRef, type ReactNode } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import rehypeSlug from 'rehype-slug';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
|
||||
/** 从 code 元素的 className(如 "language-python")中提取语言名 */
|
||||
function extractLanguage(children: ReactNode): string {
|
||||
if (isValidElement<{ className?: string }>(children)) {
|
||||
const match = /language-(\w+)/.exec(children.props.className ?? '');
|
||||
if (match) return match[1];
|
||||
}
|
||||
return 'text';
|
||||
}
|
||||
|
||||
/** 自定义代码块:浅色底框 + 顶部语言标注栏 */
|
||||
function Pre({ children }: ComponentPropsWithoutRef<'pre'>) {
|
||||
const lang = extractLanguage(children);
|
||||
return (
|
||||
<div className="code-block-wrapper">
|
||||
<div className="code-block-header">
|
||||
<span>{lang}</span>
|
||||
</div>
|
||||
<pre className="code-block-content">{children}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 统一的 Markdown 渲染组件,文章页与编辑器预览共用 */
|
||||
export function Markdown({ content }: { content: string }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex, rehypeHighlight, rehypeSlug]}
|
||||
components={{ pre: Pre }}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Calendar, Tag } from 'lucide-react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useRef } from 'react';
|
||||
import { PostSummary } from '../types';
|
||||
|
||||
interface PostCardProps {
|
||||
post: PostSummary;
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
export function PostCard({ post, featured }: PostCardProps) {
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!cardRef.current) return;
|
||||
const rect = cardRef.current.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
cardRef.current.style.setProperty('--mouse-x', `${x}%`);
|
||||
cardRef.current.style.setProperty('--mouse-y', `${y}%`);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.article
|
||||
ref={cardRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
whileHover={{ y: -4 }}
|
||||
className={`card-glow glow-border group p-6 transition-all hover:shadow-lg dark:shadow-none ${
|
||||
featured ? 'md:col-span-2 md:grid md:grid-cols-2 md:gap-8 md:p-8' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{post.tags.slice(0, 3).map((tag) => (
|
||||
<Link
|
||||
key={tag}
|
||||
to={`/?tag=${encodeURIComponent(tag)}`}
|
||||
className="tag-pill bg-indigo-50 text-indigo-600 transition-colors hover:bg-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-300 dark:hover:bg-indigo-500/20"
|
||||
>
|
||||
<Tag size={10} />
|
||||
{tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<Link to={`/posts/${post.slug}`}>
|
||||
<h3 className="mb-3 font-serif text-xl font-bold text-stone-900 transition-colors group-hover:text-indigo-600 dark:text-star dark:group-hover:text-nebula-glow md:text-2xl">
|
||||
{post.title}
|
||||
</h3>
|
||||
</Link>
|
||||
{post.summary && (
|
||||
<p className="mb-4 line-clamp-3 text-sm leading-relaxed text-stone-600 dark:text-star-dim">
|
||||
{post.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2 text-xs text-stone-500 dark:text-star-dim">
|
||||
<Calendar size={12} />
|
||||
<time dateTime={post.published_at}>
|
||||
{post.published_at ? dayjs(post.published_at).format('YYYY-MM-DD') : '未发布'}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
{featured && (
|
||||
<div className="mt-6 hidden overflow-hidden rounded-xl md:mt-0 md:block">
|
||||
{post.cover_url ? (
|
||||
<img
|
||||
src={post.cover_url}
|
||||
alt={post.title}
|
||||
className="h-full max-h-72 w-full rounded-xl object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500/10 to-purple-500/10 dark:from-indigo-500/20 dark:to-purple-500/20">
|
||||
<div className="text-6xl font-black text-indigo-300/30 dark:text-indigo-400/20">Blog</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface StarBackgroundProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function StarBackground({ children }: StarBackgroundProps) {
|
||||
return (
|
||||
<div className="relative min-h-screen">
|
||||
<div className="fixed inset-0 -z-10 bg-paper dark:bg-space-950">
|
||||
<div className="absolute inset-0 dark:bg-space-gradient">
|
||||
<div className="stars absolute inset-0 opacity-0 dark:opacity-100"></div>
|
||||
<div className="nebula absolute inset-0 opacity-0 dark:opacity-100"></div>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** 首页大标题内容,存于 pages 表(slug = home-hero,JSON 格式) */
|
||||
export interface HeroContent {
|
||||
title_pre: string;
|
||||
title_highlight: string;
|
||||
title_post: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export const defaultHero: HeroContent = {
|
||||
title_pre: '写作,是为了',
|
||||
title_highlight: '更好地',
|
||||
title_post: '思考。',
|
||||
subtitle: '记录代码、架构与生活中的灵感片段。每一篇文章都是一颗漂浮在数字星空中的星星。',
|
||||
};
|
||||
|
||||
/** 解析 DB 里的 JSON,缺字段或解析失败时回落到默认值 */
|
||||
export function parseHero(contentMd?: string): HeroContent {
|
||||
if (!contentMd) return defaultHero;
|
||||
try {
|
||||
return { ...defaultHero, ...JSON.parse(contentMd) };
|
||||
} catch {
|
||||
return defaultHero;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/** 个人名片信息,存于 pages 表(slug = profile-card,JSON 格式)。这里是默认值。 */
|
||||
export interface ProfileContent {
|
||||
name: string;
|
||||
bio: string;
|
||||
/** 头像 URL,留空则显示首字母占位头像 */
|
||||
avatar_url: string;
|
||||
github: string;
|
||||
email: string;
|
||||
/** 个人网站,留空则不显示 */
|
||||
website: string;
|
||||
}
|
||||
|
||||
export const defaultProfile: ProfileContent = {
|
||||
name: 'zhaoxi',
|
||||
bio: '全栈工程师,关注 Rust 与云原生',
|
||||
avatar_url: '',
|
||||
github: 'https://github.com/zhaoxi',
|
||||
email: 'hi@example.com',
|
||||
website: '',
|
||||
};
|
||||
|
||||
/** 解析 DB 里的 JSON,缺字段或解析失败时回落到默认值 */
|
||||
export function parseProfile(contentMd?: string): ProfileContent {
|
||||
if (!contentMd) return defaultProfile;
|
||||
try {
|
||||
return { ...defaultProfile, ...JSON.parse(contentMd) };
|
||||
} catch {
|
||||
return defaultProfile;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply scroll-smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-paper text-stone-900 antialiased font-serif;
|
||||
font-feature-settings: 'cv11', 'ss01';
|
||||
}
|
||||
|
||||
html.dark body {
|
||||
@apply bg-space-950 text-star font-sans;
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply bg-indigo-500/30 text-indigo-900;
|
||||
}
|
||||
|
||||
.dark ::selection {
|
||||
@apply bg-nebula/40 text-star;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.stars {
|
||||
background-image:
|
||||
radial-gradient(2px 2px at 20px 30px, rgba(255,255,255,0.3), transparent),
|
||||
radial-gradient(2px 2px at 40px 70px, rgba(255,255,255,0.2), transparent),
|
||||
radial-gradient(1px 1px at 90px 40px, rgba(255,255,255,0.4), transparent),
|
||||
radial-gradient(2px 2px at 160px 120px, rgba(255,255,255,0.2), transparent),
|
||||
radial-gradient(1px 1px at 230px 80px, rgba(255,255,255,0.3), transparent),
|
||||
radial-gradient(2px 2px at 300px 20px, rgba(255,255,255,0.2), transparent),
|
||||
radial-gradient(1px 1px at 350px 150px, rgba(255,255,255,0.4), transparent);
|
||||
background-repeat: repeat;
|
||||
background-size: 400px 200px;
|
||||
}
|
||||
|
||||
.nebula {
|
||||
background-image:
|
||||
radial-gradient(ellipse at 20% 30%, rgba(99,102,241,0.12), transparent 40%),
|
||||
radial-gradient(ellipse at 80% 70%, rgba(139,92,246,0.08), transparent 40%),
|
||||
radial-gradient(ellipse at 50% 50%, rgba(34,211,238,0.06), transparent 50%);
|
||||
}
|
||||
|
||||
.glass {
|
||||
@apply bg-white/70 backdrop-blur-md border border-white/20;
|
||||
}
|
||||
|
||||
.dark .glass {
|
||||
@apply bg-space-800/60 border-indigo-500/20;
|
||||
}
|
||||
|
||||
.glow-text {
|
||||
@apply text-transparent bg-clip-text bg-gradient-to-r from-indigo-400 via-purple-400 to-cyan-400;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
@apply relative text-stone-600 hover:text-stone-900 transition-colors;
|
||||
}
|
||||
|
||||
.dark .nav-link {
|
||||
@apply text-star-dim hover:text-star;
|
||||
}
|
||||
|
||||
.nav-link::after {
|
||||
content: '';
|
||||
@apply absolute left-0 -bottom-1 w-0 h-0.5 bg-indigo-500 transition-all duration-300;
|
||||
}
|
||||
|
||||
.nav-link:hover::after {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
/* 卡片悬浮光晕 */
|
||||
.card-glow {
|
||||
@apply relative overflow-hidden rounded-lg border border-stone-200 bg-white shadow-sm transition-all duration-300;
|
||||
}
|
||||
|
||||
.dark .card-glow {
|
||||
@apply border-indigo-500/20 bg-space-800/60 shadow-none;
|
||||
}
|
||||
|
||||
.card-glow::before {
|
||||
content: '';
|
||||
@apply absolute inset-0 opacity-0 transition-opacity duration-300 pointer-events-none;
|
||||
background: radial-gradient(600px circle at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(99,102,241,0.08), transparent 40%);
|
||||
}
|
||||
|
||||
.dark .card-glow::before {
|
||||
background: radial-gradient(600px circle at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(129,140,248,0.12), transparent 40%);
|
||||
}
|
||||
|
||||
.card-glow:hover::before {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
/* 发光边框 */
|
||||
.glow-border {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.glow-border::after {
|
||||
content: '';
|
||||
@apply absolute inset-0 rounded-2xl opacity-0 transition-opacity duration-300 pointer-events-none;
|
||||
padding: 1px;
|
||||
background: linear-gradient(135deg, rgba(99,102,241,0.4), rgba(139,92,246,0.4), rgba(34,211,238,0.4));
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
}
|
||||
|
||||
.glow-border:hover::after {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
/* 标题动画 */
|
||||
.title-reveal {
|
||||
@apply inline-block overflow-hidden;
|
||||
}
|
||||
|
||||
.title-reveal span {
|
||||
@apply inline-block transform translate-y-full;
|
||||
animation: titleUp 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes titleUp {
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 夜间模式正文调白(prose-invert 用的是 invert 变量,得在这里覆盖) */
|
||||
.dark .prose {
|
||||
--tw-prose-invert-body: #ffffff;
|
||||
--tw-prose-invert-headings: #ffffff;
|
||||
--tw-prose-invert-bold: #ffffff;
|
||||
--tw-prose-invert-quotes: #ffffff;
|
||||
--tw-prose-invert-counters: #e0e7ff;
|
||||
--tw-prose-invert-bullets: #818cf8;
|
||||
}
|
||||
|
||||
/* 代码块:浅色底框 + 顶部语言标注栏(夜间为深空蓝) */
|
||||
.prose .code-block-wrapper {
|
||||
@apply relative my-6 overflow-hidden rounded-lg border border-stone-200 bg-[#f6f8fa];
|
||||
}
|
||||
|
||||
.prose .code-block-header {
|
||||
@apply flex items-center justify-between border-b border-stone-200 bg-stone-100 px-4 py-1.5 text-xs font-semibold uppercase tracking-wider text-stone-500;
|
||||
}
|
||||
|
||||
.prose pre.code-block-content {
|
||||
@apply m-0 overflow-x-auto bg-transparent p-4 font-mono text-sm leading-relaxed text-stone-800;
|
||||
}
|
||||
|
||||
/* hljs 主题自带底色和内边距,去掉以免和容器冲突 */
|
||||
.prose pre.code-block-content .hljs {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 夜间:深空蓝底 + GitHub Dark 语法配色 */
|
||||
.dark .prose .code-block-wrapper {
|
||||
@apply border-indigo-500/20 bg-space-800;
|
||||
}
|
||||
|
||||
.dark .prose .code-block-header {
|
||||
@apply border-indigo-500/10 bg-space-900 text-star-dim;
|
||||
}
|
||||
|
||||
.dark .prose pre.code-block-content {
|
||||
@apply text-slate-200;
|
||||
}
|
||||
|
||||
.dark .hljs { color: #c9d1d9; }
|
||||
.dark .hljs-doctag,
|
||||
.dark .hljs-keyword,
|
||||
.dark .hljs-meta .hljs-keyword,
|
||||
.dark .hljs-template-tag,
|
||||
.dark .hljs-template-variable,
|
||||
.dark .hljs-type,
|
||||
.dark .hljs-variable.language_ { color: #ff7b72; }
|
||||
.dark .hljs-title,
|
||||
.dark .hljs-title.class_,
|
||||
.dark .hljs-title.class_.inherited__,
|
||||
.dark .hljs-title.function_ { color: #d2a8ff; }
|
||||
.dark .hljs-attr,
|
||||
.dark .hljs-attribute,
|
||||
.dark .hljs-literal,
|
||||
.dark .hljs-meta,
|
||||
.dark .hljs-number,
|
||||
.dark .hljs-operator,
|
||||
.dark .hljs-variable,
|
||||
.dark .hljs-selector-attr,
|
||||
.dark .hljs-selector-class,
|
||||
.dark .hljs-selector-id { color: #79c0ff; }
|
||||
.dark .hljs-regexp,
|
||||
.dark .hljs-string,
|
||||
.dark .hljs-meta .hljs-string { color: #a5d6ff; }
|
||||
.dark .hljs-built_in,
|
||||
.dark .hljs-symbol { color: #ffa657; }
|
||||
.dark .hljs-comment,
|
||||
.dark .hljs-code,
|
||||
.dark .hljs-formula { color: #8b949e; }
|
||||
.dark .hljs-name,
|
||||
.dark .hljs-quote,
|
||||
.dark .hljs-selector-tag,
|
||||
.dark .hljs-selector-pseudo { color: #7ee787; }
|
||||
.dark .hljs-subst { color: #c9d1d9; }
|
||||
.dark .hljs-section { color: #1f6feb; font-weight: bold; }
|
||||
.dark .hljs-bullet { color: #f2cc60; }
|
||||
.dark .hljs-emphasis { color: #c9d1d9; font-style: italic; }
|
||||
.dark .hljs-strong { color: #c9d1d9; font-weight: bold; }
|
||||
.dark .hljs-addition { color: #aff5b4; background-color: #033a16; }
|
||||
.dark .hljs-deletion { color: #ffdcd7; background-color: #67060c; }
|
||||
|
||||
/* 阅读进度条 */
|
||||
.progress-bar {
|
||||
@apply fixed left-0 top-0 h-1 bg-gradient-to-r from-indigo-500 via-purple-500 to-cyan-500 transition-transform origin-left z-50;
|
||||
}
|
||||
|
||||
/* TOC */
|
||||
.toc-item {
|
||||
@apply relative pl-4 text-sm text-stone-500 transition-colors hover:text-indigo-600 dark:text-star-dim dark:hover:text-nebula-glow;
|
||||
}
|
||||
|
||||
.toc-item::before {
|
||||
content: '';
|
||||
@apply absolute left-0 top-1/2 h-0 w-0.5 -translate-y-1/2 bg-indigo-500 transition-all duration-200;
|
||||
}
|
||||
|
||||
.toc-item.active::before {
|
||||
@apply h-4;
|
||||
}
|
||||
|
||||
.toc-item.active {
|
||||
@apply text-indigo-600 font-medium dark:text-nebula-glow;
|
||||
}
|
||||
|
||||
/* 滚动条美化 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply rounded-full bg-stone-300;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
@apply bg-space-700;
|
||||
}
|
||||
|
||||
/* 按钮按压效果 */
|
||||
.btn-press {
|
||||
@apply active:scale-95 transition-transform;
|
||||
}
|
||||
|
||||
/* 标签云 */
|
||||
.tag-pill {
|
||||
@apply inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-all duration-200;
|
||||
}
|
||||
|
||||
/* 输入框聚焦光效 */
|
||||
.input-glow {
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
.input-glow:focus {
|
||||
@apply shadow-[0_0_0_3px_rgba(99,102,241,0.15)];
|
||||
}
|
||||
|
||||
.dark .input-glow:focus {
|
||||
@apply shadow-[0_0_0_3px_rgba(129,140,248,0.2)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export interface FolderNode<T> {
|
||||
name: string;
|
||||
path: string;
|
||||
children: FolderNode<T>[];
|
||||
items: T[];
|
||||
}
|
||||
|
||||
/** 把带 folder 路径(如 "tech/rust")的扁平列表组装成目录树 */
|
||||
export function buildFolderTree<T>(items: T[], getFolder: (item: T) => string): FolderNode<T> {
|
||||
const root: FolderNode<T> = { name: '', path: '', children: [], items: [] };
|
||||
for (const item of items) {
|
||||
const parts = getFolder(item).split('/').filter(Boolean);
|
||||
let node = root;
|
||||
let path = '';
|
||||
for (const part of parts) {
|
||||
path = path ? `${path}/${part}` : part;
|
||||
let child = node.children.find((c) => c.name === part);
|
||||
if (!child) {
|
||||
child = { name: part, path, children: [], items: [] };
|
||||
node.children.push(child);
|
||||
}
|
||||
node = child;
|
||||
}
|
||||
node.items.push(item);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/** 递归统计节点下的条目总数 */
|
||||
export function countItems<T>(node: FolderNode<T>): number {
|
||||
return node.items.length + node.children.reduce((sum, c) => sum + countItems(c), 0);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import 'github-markdown-css/github-markdown-light.css'
|
||||
import 'highlight.js/styles/github.css'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Rss, Flame, MessageCircle } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PostCard } from '../components/PostCard';
|
||||
import { api } from '../api';
|
||||
import { parseHero } from '../config/hero';
|
||||
|
||||
export function HomePage() {
|
||||
const { data: posts, isLoading } = useQuery({
|
||||
queryKey: ['posts'],
|
||||
queryFn: () => api.listPosts({}),
|
||||
});
|
||||
|
||||
const { data: hotPosts } = useQuery({
|
||||
queryKey: ['hot-posts'],
|
||||
queryFn: () => api.listHotPosts(),
|
||||
});
|
||||
|
||||
const { data: heroPage } = useQuery({
|
||||
queryKey: ['page', 'home-hero'],
|
||||
queryFn: () => api.getPage('home-hero'),
|
||||
});
|
||||
|
||||
const hero = parseHero(heroPage?.content_md);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mx-auto max-w-6xl px-4 pb-20 pt-24 sm:px-6 lg:px-8"
|
||||
>
|
||||
<section className="mb-16">
|
||||
<h1 className="title-reveal mb-6 font-serif text-4xl font-bold tracking-tight text-stone-900 dark:text-star sm:text-5xl">
|
||||
<span>{hero.title_pre}</span>
|
||||
<span className="glow-text">{hero.title_highlight}</span>
|
||||
<span>{hero.title_post}</span>
|
||||
</h1>
|
||||
<p className="text-lg text-stone-600 dark:text-star-dim">
|
||||
{hero.subtitle}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-12 lg:grid-cols-[1fr_280px]">
|
||||
<div>
|
||||
<div className="mb-10 flex justify-end">
|
||||
<Link
|
||||
to="/rss"
|
||||
className="btn-press inline-flex items-center gap-2 rounded-full border border-stone-200 px-4 py-2 text-sm font-medium text-stone-600 transition-colors hover:border-indigo-500 hover:text-indigo-600 dark:border-indigo-500/20 dark:text-star-dim dark:hover:text-nebula-glow"
|
||||
>
|
||||
<Rss size={16} />
|
||||
RSS
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="h-48 animate-pulse rounded-2xl bg-stone-200 dark:bg-space-800"></div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{posts?.map((post) => (
|
||||
<PostCard key={post.id} post={post} featured={!!post.cover_url} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && posts?.length === 0 && (
|
||||
<div className="py-20 text-center text-stone-500 dark:text-star-dim">没有找到文章</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-24 space-y-8">
|
||||
{hotPosts && hotPosts.length > 0 && (
|
||||
<section>
|
||||
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-stone-500 dark:text-star-dim">
|
||||
<Flame size={14} className="text-orange-500" />
|
||||
热门文章
|
||||
</h2>
|
||||
<ul className="space-y-3">
|
||||
{hotPosts.map((post) => (
|
||||
<li key={post.id}>
|
||||
<Link
|
||||
to={`/posts/${post.slug}`}
|
||||
className="group flex items-start justify-between gap-2 text-sm"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-stone-700 transition-colors group-hover:text-indigo-600 dark:text-star-dim dark:group-hover:text-nebula-glow">
|
||||
{post.title}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1 text-xs text-stone-400 dark:text-star-dim">
|
||||
<MessageCircle size={12} />
|
||||
{post.comment_count}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Search, Folder, Tag } from 'lucide-react';
|
||||
import { PostCard } from '../components/PostCard';
|
||||
import { api } from '../api';
|
||||
import { FolderCount } from '../types';
|
||||
import { buildFolderTree, countItems, FolderNode } from '../lib/tree';
|
||||
|
||||
/** 文库:目录树 + 标签 + 搜索,全站文章的统一查询入口 */
|
||||
export function LibraryPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [inputQ, setInputQ] = useState(searchParams.get('q') || '');
|
||||
|
||||
const tag = searchParams.get('tag') || undefined;
|
||||
const q = searchParams.get('q') || undefined;
|
||||
const folder = searchParams.get('folder') || undefined;
|
||||
|
||||
const { data: posts, isLoading } = useQuery({
|
||||
queryKey: ['posts', { tag, q, folder }],
|
||||
queryFn: () => api.listPosts({ tag, q, folder }),
|
||||
});
|
||||
|
||||
const { data: folders } = useQuery({
|
||||
queryKey: ['folders'],
|
||||
queryFn: () => api.listFolders(),
|
||||
});
|
||||
|
||||
const { data: tags } = useQuery({
|
||||
queryKey: ['tags'],
|
||||
queryFn: () => api.listTags(),
|
||||
});
|
||||
|
||||
const setFilter = (key: string, value?: string) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (value) next.set(key, value);
|
||||
else next.delete(key);
|
||||
setSearchParams(next);
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFilter('q', inputQ || undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mx-auto max-w-6xl px-4 pb-20 pt-24 sm:px-6 lg:px-8"
|
||||
>
|
||||
<header className="mb-10">
|
||||
<h1 className="mb-3 font-serif text-4xl font-bold tracking-tight text-stone-900 dark:text-star">
|
||||
文库
|
||||
</h1>
|
||||
<p className="text-stone-600 dark:text-star-dim">按目录、标签或关键词检索全部文章。</p>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-12 lg:grid-cols-[240px_1fr]">
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-24 space-y-8">
|
||||
{folders && folders.length > 0 && (
|
||||
<section>
|
||||
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-stone-500 dark:text-star-dim">
|
||||
<Folder size={14} className="text-indigo-500 dark:text-nebula-glow" />
|
||||
目录
|
||||
</h2>
|
||||
<FolderTree folders={folders} active={folder} onSelect={(path) => setFilter('folder', path)} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tags && tags.length > 0 && (
|
||||
<section>
|
||||
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-stone-500 dark:text-star-dim">
|
||||
<Tag size={14} className="text-indigo-500 dark:text-nebula-glow" />
|
||||
标签
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((t) => (
|
||||
<button
|
||||
key={t.name}
|
||||
onClick={() => setFilter('tag', tag === t.name ? undefined : t.name)}
|
||||
className={`tag-pill border ${
|
||||
tag === t.name
|
||||
? 'border-indigo-500 bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-nebula-glow'
|
||||
: 'border-stone-200 text-stone-600 hover:border-indigo-500 hover:text-indigo-600 dark:border-indigo-500/20 dark:text-star-dim dark:hover:text-nebula-glow'
|
||||
}`}
|
||||
>
|
||||
{t.name}
|
||||
<span className="text-xs text-stone-400">{t.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div>
|
||||
<form onSubmit={handleSearch} className="relative mb-8 w-full max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
value={inputQ}
|
||||
onChange={(e) => setInputQ(e.target.value)}
|
||||
placeholder="搜索文章..."
|
||||
className="input-glow w-full rounded-full border border-stone-200 bg-white py-2.5 pl-10 pr-4 text-sm text-stone-900 outline-none focus:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star dark:placeholder:text-slate-500"
|
||||
/>
|
||||
</form>
|
||||
|
||||
{(tag || folder || q) && (
|
||||
<div className="mb-6 flex flex-wrap items-center gap-4 text-sm text-stone-600 dark:text-star-dim">
|
||||
{folder && <FilterChip label={`目录:${folder}`} onClear={() => setFilter('folder')} />}
|
||||
{tag && <FilterChip label={`标签:${tag}`} onClear={() => setFilter('tag')} />}
|
||||
{q && <FilterChip label={`搜索:${q}`} onClear={() => { setInputQ(''); setFilter('q'); }} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="h-48 animate-pulse rounded-2xl bg-stone-200 dark:bg-space-800"></div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{posts?.map((post) => (
|
||||
<PostCard key={post.id} post={post} featured={!!post.cover_url} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && posts?.length === 0 && (
|
||||
<div className="py-20 text-center text-stone-500 dark:text-star-dim">没有找到文章</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({ label, onClear }: { label: string; onClear: () => void }) {
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="tag-pill bg-indigo-100 font-medium text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300">
|
||||
{label}
|
||||
</span>
|
||||
<button onClick={onClear} className="text-stone-400 hover:text-stone-600 dark:hover:text-star">
|
||||
清除
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderTree({
|
||||
folders,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
folders: FolderCount[];
|
||||
active?: string;
|
||||
onSelect: (path?: string) => void;
|
||||
}) {
|
||||
const tree = useMemo(() => buildFolderTree(folders, (f) => f.name), [folders]);
|
||||
|
||||
return (
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>
|
||||
<button
|
||||
onClick={() => onSelect(undefined)}
|
||||
className={`w-full rounded-lg px-2 py-1.5 text-left transition-colors ${
|
||||
!active
|
||||
? 'bg-indigo-50 font-medium text-indigo-700 dark:bg-indigo-500/10 dark:text-nebula-glow'
|
||||
: 'text-stone-600 hover:bg-stone-100 dark:text-star-dim dark:hover:bg-space-800'
|
||||
}`}
|
||||
>
|
||||
全部文章
|
||||
</button>
|
||||
</li>
|
||||
{tree.children.map((node) => (
|
||||
<FolderTreeNode key={node.path} node={node} depth={0} active={active} onSelect={onSelect} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderTreeNode({
|
||||
node,
|
||||
depth,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
node: FolderNode<FolderCount>;
|
||||
depth: number;
|
||||
active?: string;
|
||||
onSelect: (path?: string) => void;
|
||||
}) {
|
||||
const isActive = active === node.path;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => onSelect(node.path)}
|
||||
className={`flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors ${
|
||||
isActive
|
||||
? 'bg-indigo-50 font-medium text-indigo-700 dark:bg-indigo-500/10 dark:text-nebula-glow'
|
||||
: 'text-stone-600 hover:bg-stone-100 dark:text-star-dim dark:hover:bg-space-800'
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 1 + 0.5}rem` }}
|
||||
>
|
||||
<span className="truncate">{node.name}</span>
|
||||
<span className="text-xs text-stone-400">{countItems(node)}</span>
|
||||
</button>
|
||||
{node.children.length > 0 && (
|
||||
<ul className="mt-1 space-y-1">
|
||||
{node.children.map((child) => (
|
||||
<FolderTreeNode
|
||||
key={child.path}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
active={active}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { api } from '../api';
|
||||
|
||||
export function OAuthCallback() {
|
||||
const navigate = useNavigate();
|
||||
const { setToken, setUser } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash.substring(1);
|
||||
const params = new URLSearchParams(hash);
|
||||
const token = params.get('token');
|
||||
|
||||
if (!token) {
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
|
||||
setToken(token);
|
||||
|
||||
api
|
||||
.getMe()
|
||||
.then((data) => {
|
||||
setUser(data.role as 'guest' | 'user' | 'admin', data.user || null);
|
||||
if (data.role === 'admin') {
|
||||
navigate('/admin');
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
navigate('/');
|
||||
});
|
||||
}, [setToken, setUser, navigate]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-indigo-500 border-t-transparent"></div>
|
||||
<p className="text-slate-600 dark:text-star-dim">登录中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft, Calendar, Clock, Tag, MessageCircle, Send, Trash2 } from 'lucide-react';
|
||||
import dayjs from 'dayjs';
|
||||
import { Markdown } from '../components/Markdown';
|
||||
import { api } from '../api';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { CommentView } from '../types';
|
||||
|
||||
export function PostPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { role } = useAuthStore();
|
||||
const queryClient = useQueryClient();
|
||||
const [comment, setComment] = useState('');
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: post, isLoading } = useQuery({
|
||||
queryKey: ['post', slug],
|
||||
queryFn: () => api.getPost(slug!),
|
||||
});
|
||||
|
||||
const { data: comments } = useQuery({
|
||||
queryKey: ['comments', slug],
|
||||
queryFn: () => api.listComments(slug!),
|
||||
enabled: !!slug,
|
||||
});
|
||||
|
||||
const createComment = useMutation({
|
||||
mutationFn: () => api.createComment(slug!, { content: comment }),
|
||||
onSuccess: () => {
|
||||
setComment('');
|
||||
queryClient.invalidateQueries({ queryKey: ['comments', slug] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteComment = useMutation({
|
||||
mutationFn: (id: string) => api.deleteComment(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['comments', slug] }),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.querySelectorAll('pre').forEach((pre) => {
|
||||
if (!pre.querySelector('button.copy-btn')) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'copy-btn absolute right-2 top-2 rounded bg-slate-700/80 px-2 py-1 text-xs text-white opacity-0 transition-opacity hover:bg-slate-600';
|
||||
btn.textContent = '复制';
|
||||
btn.onclick = () => {
|
||||
const code = pre.querySelector('code')?.textContent || '';
|
||||
navigator.clipboard.writeText(code);
|
||||
btn.textContent = '已复制';
|
||||
setTimeout(() => (btn.textContent = '复制'), 1500);
|
||||
};
|
||||
pre.classList.add('relative');
|
||||
pre.appendChild(btn);
|
||||
pre.onmouseenter = () => (btn.style.opacity = '1');
|
||||
pre.onmouseleave = () => (btn.style.opacity = '0');
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [post?.content_md]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (!progressRef.current) return;
|
||||
const winScroll = document.documentElement.scrollTop;
|
||||
const height = document.documentElement.scrollHeight - document.documentElement.clientHeight;
|
||||
const scrolled = height > 0 ? winScroll / height : 0;
|
||||
progressRef.current.style.transform = `scaleX(${scrolled})`;
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
if (isLoading) return <PostSkeleton />;
|
||||
if (!post) return <div className="pt-32 text-center text-slate-500">文章不存在</div>;
|
||||
|
||||
const readTime = Math.max(1, Math.ceil(post.content_md.replace(/[#*`_\[\]\(\)\n]/g, '').length / 400));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={progressRef} className="progress-bar" />
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mx-auto max-w-3xl px-4 pb-20 pt-24 sm:px-6 lg:px-8"
|
||||
>
|
||||
<Link
|
||||
to="/"
|
||||
className="mb-8 inline-flex items-center gap-2 text-sm text-slate-500 hover:text-indigo-600 dark:text-star-dim dark:hover:text-nebula-glow"
|
||||
>
|
||||
<ArrowLeft size={16} /> 返回首页
|
||||
</Link>
|
||||
|
||||
<header className="mb-10">
|
||||
<h1 className="mb-6 text-3xl font-black leading-tight text-slate-900 dark:text-star sm:text-5xl">
|
||||
{post.title}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-slate-500 dark:text-star-dim">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar size={14} />
|
||||
{post.published_at ? dayjs(post.published_at).format('YYYY-MM-DD') : '未发布'}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock size={14} />
|
||||
{readTime} 分钟阅读
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<Link
|
||||
key={tag}
|
||||
to={`/?tag=${encodeURIComponent(tag)}`}
|
||||
className="tag-pill bg-slate-100 text-slate-600 transition-colors hover:bg-indigo-100 hover:text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300 dark:hover:bg-indigo-500/20"
|
||||
>
|
||||
<Tag size={10} /> {tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<article
|
||||
ref={contentRef}
|
||||
className="prose prose-lg max-w-none dark:prose-invert prose-headings:scroll-mt-24 prose-a:text-indigo-600 dark:prose-a:text-nebula-glow"
|
||||
>
|
||||
<Markdown content={post.content_md} />
|
||||
</article>
|
||||
|
||||
<section className="mt-16">
|
||||
<h3 className="mb-6 flex items-center gap-2 text-xl font-bold text-slate-900 dark:text-star">
|
||||
<MessageCircle size={20} /> 评论 ({comments?.length || 0})
|
||||
</h3>
|
||||
|
||||
{role !== 'guest' ? (
|
||||
<div className="mb-8">
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="写下你的想法..."
|
||||
rows={3}
|
||||
className="input-glow w-full rounded-xl border border-slate-200 bg-white p-4 text-sm text-slate-900 outline-none transition-all focus:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star"
|
||||
/>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
onClick={() => createComment.mutate()}
|
||||
disabled={!comment.trim() || createComment.isPending}
|
||||
className="btn-press inline-flex items-center gap-2 rounded-full bg-indigo-600 px-5 py-2 text-sm font-medium text-white transition-all hover:bg-indigo-700 disabled:opacity-50 dark:bg-nebula dark:hover:bg-indigo-500"
|
||||
>
|
||||
<Send size={14} /> 发表评论
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mb-8 text-sm text-slate-500 dark:text-star-dim">
|
||||
登录后可发表评论。
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{comments?.map((c) => (
|
||||
<CommentItem
|
||||
key={c.id}
|
||||
comment={c}
|
||||
canDelete={role === 'admin'}
|
||||
onDelete={() => deleteComment.mutate(c.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentItem({
|
||||
comment,
|
||||
canDelete,
|
||||
onDelete,
|
||||
}: {
|
||||
comment: CommentView;
|
||||
canDelete: boolean;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-indigo-500/10 dark:bg-space-800/60">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{comment.author.avatar_url ? (
|
||||
<img src={comment.author.avatar_url} alt="" className="h-6 w-6 rounded-full" />
|
||||
) : (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-indigo-100 text-xs font-bold text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300">
|
||||
{comment.author.name[0]}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-star">{comment.author.name}</span>
|
||||
<span className="text-xs text-slate-400">{dayjs(comment.created_at).format('YYYY-MM-DD HH:mm')}</span>
|
||||
</div>
|
||||
{canDelete && (
|
||||
<button onClick={onDelete} className="text-slate-400 hover:text-red-500">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm text-slate-700 dark:text-star-dim">{comment.content}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 pb-20 pt-24">
|
||||
<div className="mb-6 h-12 animate-pulse rounded bg-slate-200 dark:bg-space-800"></div>
|
||||
<div className="mb-4 h-4 w-1/3 animate-pulse rounded bg-slate-200 dark:bg-space-800"></div>
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="h-24 animate-pulse rounded bg-slate-200 dark:bg-space-800"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Github, Mail, Globe, FileText, Tag } from 'lucide-react';
|
||||
import { api } from '../api';
|
||||
import { Markdown } from '../components/Markdown';
|
||||
import { parseProfile } from '../config/profile';
|
||||
|
||||
export function ProfilePage() {
|
||||
const { data: page, isLoading } = useQuery({
|
||||
queryKey: ['page', 'profile'],
|
||||
queryFn: () => api.getPage('profile'),
|
||||
});
|
||||
|
||||
const { data: cardPage } = useQuery({
|
||||
queryKey: ['page', 'profile-card'],
|
||||
queryFn: () => api.getPage('profile-card'),
|
||||
});
|
||||
|
||||
const { data: archive } = useQuery({
|
||||
queryKey: ['archive'],
|
||||
queryFn: () => api.getArchive(),
|
||||
});
|
||||
|
||||
const { data: tags } = useQuery({
|
||||
queryKey: ['tags'],
|
||||
queryFn: () => api.listTags(),
|
||||
});
|
||||
|
||||
const profile = parseProfile(cardPage?.content_md);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mx-auto max-w-5xl px-4 pb-20 pt-24 sm:px-6 lg:px-8"
|
||||
>
|
||||
<div className="grid gap-10 lg:grid-cols-[300px_1fr]">
|
||||
{/* 名片卡片 */}
|
||||
<aside>
|
||||
<div className="sticky top-24 rounded-2xl border border-stone-200 bg-white p-6 dark:border-indigo-500/20 dark:bg-space-800/60">
|
||||
{profile.avatar_url ? (
|
||||
<img
|
||||
src={profile.avatar_url}
|
||||
alt={profile.name}
|
||||
className="mb-5 h-32 w-32 rounded-full object-cover ring-4 ring-indigo-500/20"
|
||||
/>
|
||||
) : (
|
||||
<div className="mb-5 flex h-32 w-32 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 text-5xl font-bold text-white ring-4 ring-indigo-500/20">
|
||||
{profile.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className="text-2xl font-bold text-stone-900 dark:text-star">{profile.name}</h1>
|
||||
<p className="mt-2 text-sm text-stone-600 dark:text-star-dim">{profile.bio}</p>
|
||||
|
||||
<div className="mt-5 flex gap-4 border-t border-stone-100 pt-5 text-sm text-stone-600 dark:border-indigo-500/10 dark:text-star-dim">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<FileText size={14} className="text-indigo-500 dark:text-nebula-glow" />
|
||||
{archive?.length ?? 0} 文章
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Tag size={14} className="text-indigo-500 dark:text-nebula-glow" />
|
||||
{tags?.length ?? 0} 标签
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3 border-t border-stone-100 pt-5 dark:border-indigo-500/10">
|
||||
{profile.github && (
|
||||
<a
|
||||
href={profile.github}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-3 text-sm text-stone-600 transition-colors hover:text-indigo-600 dark:text-star-dim dark:hover:text-nebula-glow"
|
||||
>
|
||||
<Github size={16} />
|
||||
GitHub
|
||||
</a>
|
||||
)}
|
||||
{profile.email && (
|
||||
<a
|
||||
href={`mailto:${profile.email}`}
|
||||
className="flex items-center gap-3 text-sm text-stone-600 transition-colors hover:text-indigo-600 dark:text-star-dim dark:hover:text-nebula-glow"
|
||||
>
|
||||
<Mail size={16} />
|
||||
{profile.email}
|
||||
</a>
|
||||
)}
|
||||
{profile.website && (
|
||||
<a
|
||||
href={profile.website}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-3 text-sm text-stone-600 transition-colors hover:text-indigo-600 dark:text-star-dim dark:hover:text-nebula-glow"
|
||||
>
|
||||
<Globe size={16} />
|
||||
{profile.website.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 正文 */}
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-6 animate-pulse rounded bg-stone-200 dark:bg-space-800"></div>
|
||||
))}
|
||||
</div>
|
||||
) : page?.content_md ? (
|
||||
<article className="prose prose-lg max-w-none dark:prose-invert">
|
||||
<Markdown content={page.content_md} />
|
||||
</article>
|
||||
) : (
|
||||
<p className="py-20 text-center text-stone-500 dark:text-star-dim">
|
||||
还没有内容,去后台「个人主页」写点什么吧。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import { FileText } from 'lucide-react';
|
||||
import { api } from '../api';
|
||||
|
||||
export function RssPage() {
|
||||
const { data: rss, isLoading } = useQuery({
|
||||
queryKey: ['rss'],
|
||||
queryFn: () => api.getRss(),
|
||||
});
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mx-auto max-w-4xl px-4 pb-20 pt-24 sm:px-6 lg:px-8"
|
||||
>
|
||||
<header className="mb-8">
|
||||
<h1 className="mb-2 flex items-center gap-3 text-3xl font-black text-slate-900 dark:text-star">
|
||||
<FileText size={28} className="text-orange-500" /> RSS Feed
|
||||
</h1>
|
||||
<p className="text-slate-600 dark:text-star-dim">你可以复制下方 XML 地址订阅更新。</p>
|
||||
</header>
|
||||
|
||||
<div className="mb-6 overflow-hidden rounded-xl border border-slate-200 bg-white dark:border-indigo-500/20 dark:bg-space-800/60">
|
||||
<input
|
||||
readOnly
|
||||
value={`${import.meta.env.VITE_API_URL || '/api'}/rss.xml`}
|
||||
className="w-full bg-transparent px-4 py-3 text-sm text-slate-700 outline-none dark:text-star-dim"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="h-64 animate-pulse rounded-xl bg-slate-200 dark:bg-space-800"></div>
|
||||
) : (
|
||||
<pre className="max-h-[600px] overflow-auto rounded-xl border border-slate-200 bg-slate-50 p-4 text-xs text-slate-700 dark:border-indigo-500/20 dark:bg-space-950 dark:text-star-dim">
|
||||
{rss}
|
||||
</pre>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Check, Loader2 } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { api } from '../../api';
|
||||
import { defaultHero, parseHero, type HeroContent } from '../../config/hero';
|
||||
|
||||
/** 后台"首页标题"编辑:主标题三段(中间段为渐变色)+ 副标题 */
|
||||
export function AdminHeroPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [form, setForm] = useState<HeroContent>(defaultHero);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [savedAt, setSavedAt] = useState<Date | null>(null);
|
||||
|
||||
const { data: page, isLoading } = useQuery({
|
||||
queryKey: ['page', 'home-hero'],
|
||||
queryFn: () => api.getPage('home-hero'),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized && page) {
|
||||
setForm(parseHero(page.content_md));
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [page, initialized]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => api.savePage('home-hero', JSON.stringify(form)),
|
||||
onSuccess: () => {
|
||||
setSavedAt(new Date());
|
||||
queryClient.invalidateQueries({ queryKey: ['page', 'home-hero'] });
|
||||
},
|
||||
});
|
||||
|
||||
const set = (key: keyof HeroContent) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
setForm({ ...form, [key]: e.target.value });
|
||||
|
||||
if (isLoading || !initialized) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Loader2 className="animate-spin text-indigo-500" size={32} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
'w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star';
|
||||
const labelCls = 'mb-1 block text-xs font-medium text-slate-500 dark:text-star-dim';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mx-auto max-w-3xl px-4 pb-20 pt-24 sm:px-6 lg:px-8"
|
||||
>
|
||||
<header className="mb-8 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/admin')}
|
||||
className="rounded-lg p-2 text-slate-500 hover:bg-slate-100 dark:text-star-dim dark:hover:bg-space-800"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-slate-900 dark:text-star">首页标题</h1>
|
||||
<p className="text-sm text-slate-600 dark:text-star-dim">中间一段会以渐变色高亮显示</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50 dark:bg-nebula"
|
||||
>
|
||||
{saveMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
保存
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* 实时预览 */}
|
||||
<div className="mb-8 rounded-2xl border border-slate-200 bg-white p-8 dark:border-indigo-500/20 dark:bg-space-800/60">
|
||||
<h1 className="mb-4 font-serif text-4xl font-bold tracking-tight text-stone-900 dark:text-star">
|
||||
<span>{form.title_pre}</span>
|
||||
<span className="glow-text">{form.title_highlight}</span>
|
||||
<span>{form.title_post}</span>
|
||||
</h1>
|
||||
<p className="text-stone-600 dark:text-star-dim">{form.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label className={labelCls}>主标题(前段)</label>
|
||||
<input value={form.title_pre} onChange={set('title_pre')} className={inputCls} placeholder="写作,是为了" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>主标题(高亮段)</label>
|
||||
<input value={form.title_highlight} onChange={set('title_highlight')} className={inputCls} placeholder="更好地" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>主标题(后段)</label>
|
||||
<input value={form.title_post} onChange={set('title_post')} className={inputCls} placeholder="思考。" />
|
||||
</div>
|
||||
<div className="md:col-span-3">
|
||||
<label className={labelCls}>副标题</label>
|
||||
<textarea
|
||||
value={form.subtitle}
|
||||
onChange={set('subtitle')}
|
||||
rows={2}
|
||||
className={inputCls}
|
||||
placeholder="一句话介绍这个博客"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{savedAt && (
|
||||
<p className="mt-4 text-xs text-slate-500 dark:text-star-dim">已保存 {savedAt.toLocaleTimeString()}</p>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../store/auth';
|
||||
|
||||
export function AdminLayout() {
|
||||
const { role } = useAuthStore();
|
||||
const location = useLocation();
|
||||
|
||||
if (role !== 'admin') {
|
||||
return <Navigate to="/" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Plus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileText,
|
||||
ExternalLink,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '../../api';
|
||||
import { PostSummary } from '../../types';
|
||||
import { buildFolderTree, countItems, FolderNode } from '../../lib/tree';
|
||||
|
||||
export function AdminPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: posts, isLoading } = useQuery({
|
||||
queryKey: ['admin-posts'],
|
||||
queryFn: () => api.adminListPosts(),
|
||||
});
|
||||
|
||||
const tree = useMemo(() => buildFolderTree(posts ?? [], (p) => p.folder), [posts]);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.deletePost(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin-posts'] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mx-auto max-w-4xl px-4 pb-20 pt-24 sm:px-6 lg:px-8"
|
||||
>
|
||||
<header className="mb-8 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black text-slate-900 dark:text-star">文章管理</h1>
|
||||
<p className="text-slate-600 dark:text-star-dim">创建、编辑和发布你的文章</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/admin/hero')}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-slate-200 px-5 py-2.5 text-sm font-medium text-slate-600 transition-colors hover:border-indigo-500 hover:text-indigo-600 dark:border-indigo-500/20 dark:text-star-dim dark:hover:text-nebula-glow"
|
||||
>
|
||||
首页标题
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/admin/profile')}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-slate-200 px-5 py-2.5 text-sm font-medium text-slate-600 transition-colors hover:border-indigo-500 hover:text-indigo-600 dark:border-indigo-500/20 dark:text-star-dim dark:hover:text-nebula-glow"
|
||||
>
|
||||
个人主页
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/admin/new')}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white transition-transform hover:scale-105 dark:bg-nebula"
|
||||
>
|
||||
<Plus size={16} /> 新建文章
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-12 animate-pulse rounded-xl bg-slate-200 dark:bg-space-800"></div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white p-2 dark:border-indigo-500/20 dark:bg-space-800/60">
|
||||
{tree.children.map((folder) => (
|
||||
<FolderRow
|
||||
key={folder.path}
|
||||
node={folder}
|
||||
depth={0}
|
||||
onDelete={(id) => deleteMutation.mutate(id)}
|
||||
/>
|
||||
))}
|
||||
{tree.items.map((post) => (
|
||||
<PostRow key={post.id} post={post} depth={0} onDelete={() => deleteMutation.mutate(post.id)} />
|
||||
))}
|
||||
{posts?.length === 0 && (
|
||||
<div className="py-20 text-center text-slate-500 dark:text-star-dim">
|
||||
还没有文章,点击上方按钮创建第一篇。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderRow({
|
||||
node,
|
||||
depth,
|
||||
onDelete,
|
||||
}: {
|
||||
node: FolderNode<PostSummary>;
|
||||
depth: number;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2.5 text-left transition-colors hover:bg-slate-50 dark:hover:bg-space-800/80"
|
||||
style={{ paddingLeft: `${depth * 1.5 + 0.75}rem` }}
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown size={16} className="shrink-0 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight size={16} className="shrink-0 text-slate-400" />
|
||||
)}
|
||||
{open ? (
|
||||
<FolderOpen size={16} className="shrink-0 text-indigo-500 dark:text-nebula-glow" />
|
||||
) : (
|
||||
<Folder size={16} className="shrink-0 text-indigo-500 dark:text-nebula-glow" />
|
||||
)}
|
||||
<span className="font-medium text-slate-800 dark:text-star">{node.name}</span>
|
||||
<span className="text-xs text-slate-400 dark:text-star-dim">{countItems(node)} 篇</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div>
|
||||
{node.children.map((child) => (
|
||||
<FolderRow key={child.path} node={child} depth={depth + 1} onDelete={onDelete} />
|
||||
))}
|
||||
{node.items.map((post) => (
|
||||
<PostRow key={post.id} post={post} depth={depth + 1} onDelete={() => onDelete(post.id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostRow({
|
||||
post,
|
||||
depth,
|
||||
onDelete,
|
||||
}: {
|
||||
post: PostSummary;
|
||||
depth: number;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group flex items-center gap-3 rounded-lg px-3 py-2.5 transition-colors hover:bg-slate-50 dark:hover:bg-space-800/80"
|
||||
style={{ paddingLeft: `${depth * 1.5 + 2.75}rem` }}
|
||||
>
|
||||
<FileText size={16} className="shrink-0 text-slate-400" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-star">
|
||||
{post.title}
|
||||
</span>
|
||||
<StatusBadge status={post.status} />
|
||||
<span className="hidden w-24 shrink-0 text-right text-xs text-slate-400 dark:text-star-dim sm:block">
|
||||
{post.published_at ? dayjs(post.published_at).format('YYYY-MM-DD') : '-'}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{post.status === 'public' && (
|
||||
<Link
|
||||
to={`/posts/${post.slug}`}
|
||||
target="_blank"
|
||||
className="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-space-700 dark:hover:text-star"
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate(`/admin/edit/${post.id}`)}
|
||||
className="rounded-lg p-1.5 text-slate-400 hover:bg-indigo-50 hover:text-indigo-600 dark:hover:bg-indigo-500/10 dark:hover:text-nebula-glow"
|
||||
>
|
||||
<Edit2 size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="rounded-lg p-1.5 text-slate-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-500/10 dark:hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config: Record<string, { icon: React.ReactNode; className: string; label: string }> = {
|
||||
public: {
|
||||
icon: <Eye size={12} />,
|
||||
label: '已发布',
|
||||
className: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400',
|
||||
},
|
||||
private: {
|
||||
icon: <EyeOff size={12} />,
|
||||
label: '私有',
|
||||
className: 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400',
|
||||
},
|
||||
draft: {
|
||||
icon: <FileText size={12} />,
|
||||
label: '草稿',
|
||||
className: 'bg-slate-100 text-slate-700 dark:bg-space-700 dark:text-slate-300',
|
||||
},
|
||||
};
|
||||
|
||||
const c = config[status] || config.draft;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ${c.className}`}
|
||||
>
|
||||
{c.icon}
|
||||
{c.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Check, Loader2, ImagePlus, IdCard } from 'lucide-react';
|
||||
import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { motion } from 'framer-motion';
|
||||
import { api } from '../../api';
|
||||
import { Markdown } from '../../components/Markdown';
|
||||
import { useThemeStore } from '../../store/theme';
|
||||
import { defaultProfile, parseProfile, type ProfileContent } from '../../config/profile';
|
||||
|
||||
/** 后台"个人主页"编辑:名片信息(头像/联系方式)+ Markdown 正文 */
|
||||
export function AdminProfilePage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { theme } = useThemeStore();
|
||||
|
||||
const [content, setContent] = useState('');
|
||||
const [card, setCard] = useState<ProfileContent>(defaultProfile);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [savedAt, setSavedAt] = useState<Date | null>(null);
|
||||
const [showCard, setShowCard] = useState(false);
|
||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: page, isLoading } = useQuery({
|
||||
queryKey: ['page', 'profile'],
|
||||
queryFn: () => api.getPage('profile'),
|
||||
});
|
||||
|
||||
const { data: cardPage } = useQuery({
|
||||
queryKey: ['page', 'profile-card'],
|
||||
queryFn: () => api.getPage('profile-card'),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized && page && cardPage) {
|
||||
setContent(page.content_md || '# 关于我\n\n');
|
||||
setCard(parseProfile(cardPage.content_md));
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [page, cardPage, initialized]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => api.savePage('profile', content),
|
||||
onSuccess: () => {
|
||||
setSavedAt(new Date());
|
||||
queryClient.invalidateQueries({ queryKey: ['page', 'profile'] });
|
||||
},
|
||||
});
|
||||
|
||||
const saveCardMutation = useMutation({
|
||||
mutationFn: () => api.savePage('profile-card', JSON.stringify(card)),
|
||||
onSuccess: () => {
|
||||
setSavedAt(new Date());
|
||||
queryClient.invalidateQueries({ queryKey: ['page', 'profile-card'] });
|
||||
},
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => api.uploadImage(file),
|
||||
onSuccess: ({ url }, file) => {
|
||||
// 在光标处插入图片 Markdown,没有光标就追加到末尾
|
||||
const view = editorRef.current?.view;
|
||||
const snippet = ``;
|
||||
if (view) {
|
||||
const pos = view.state.selection.main.head;
|
||||
view.dispatch({ changes: { from: pos, insert: snippet } });
|
||||
} else {
|
||||
setContent((c) => `${c}\n${snippet}\n`);
|
||||
}
|
||||
},
|
||||
onError: (e) => alert(`上传失败:${e.message}`),
|
||||
});
|
||||
|
||||
const avatarUploadMutation = useMutation({
|
||||
mutationFn: (file: File) => api.uploadImage(file),
|
||||
onSuccess: ({ url }) => setCard((c) => ({ ...c, avatar_url: url })),
|
||||
onError: (e) => alert(`上传失败:${e.message}`),
|
||||
});
|
||||
|
||||
if (isLoading || !initialized) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Loader2 className="animate-spin text-indigo-500" size={32} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
'w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star';
|
||||
const labelCls = 'mb-1 block text-xs font-medium text-slate-500 dark:text-star-dim';
|
||||
const setCardField = (key: keyof ProfileContent) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setCard({ ...card, [key]: e.target.value });
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex h-screen flex-col pt-16">
|
||||
<header className="flex items-center justify-between border-b border-slate-200 bg-white/90 px-4 py-3 backdrop-blur dark:border-indigo-500/10 dark:bg-space-900/90">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/admin')}
|
||||
className="rounded-lg p-2 text-slate-500 hover:bg-slate-100 dark:text-star-dim dark:hover:bg-space-800"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<span className="text-lg font-bold text-slate-900 dark:text-star">个人主页</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowCard(!showCard)}
|
||||
className={`inline-flex items-center gap-1 rounded-lg border px-3 py-2 text-sm transition-colors ${
|
||||
showCard
|
||||
? 'border-indigo-500 bg-indigo-50 text-indigo-700 dark:border-nebula dark:bg-indigo-500/10 dark:text-nebula-glow'
|
||||
: 'border-slate-200 bg-white text-slate-600 hover:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star-dim'
|
||||
}`}
|
||||
>
|
||||
<IdCard size={14} /> 名片信息
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) uploadMutation.mutate(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadMutation.isPending}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-600 transition-colors hover:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star-dim"
|
||||
>
|
||||
{uploadMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <ImagePlus size={14} />}
|
||||
上传图片
|
||||
</button>
|
||||
<button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50 dark:bg-nebula"
|
||||
>
|
||||
{saveMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
保存正文
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 名片信息面板 */}
|
||||
{showCard && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
className="border-b border-slate-200 bg-slate-50 px-6 py-4 dark:border-indigo-500/10 dark:bg-space-900"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="md:col-span-1">
|
||||
<label className={labelCls}>头像</label>
|
||||
<div className="flex items-center gap-3">
|
||||
{card.avatar_url ? (
|
||||
<img src={card.avatar_url} alt="avatar" className="h-12 w-12 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 text-lg font-bold text-white">
|
||||
{card.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) avatarUploadMutation.mutate(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
disabled={avatarUploadMutation.isPending}
|
||||
className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-600 hover:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star-dim"
|
||||
>
|
||||
{avatarUploadMutation.isPending ? '上传中...' : '上传头像'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
value={card.avatar_url}
|
||||
onChange={setCardField('avatar_url')}
|
||||
className={`${inputCls} mt-2`}
|
||||
placeholder="或直接粘贴图片 URL"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>名字</label>
|
||||
<input value={card.name} onChange={setCardField('name')} className={inputCls} />
|
||||
<label className={`${labelCls} mt-3`}>GitHub</label>
|
||||
<input value={card.github} onChange={setCardField('github')} className={inputCls} placeholder="https://github.com/..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>一句话简介</label>
|
||||
<input value={card.bio} onChange={setCardField('bio')} className={inputCls} />
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelCls}>邮箱</label>
|
||||
<input value={card.email} onChange={setCardField('email')} className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>个人网站</label>
|
||||
<input value={card.website} onChange={setCardField('website')} className={inputCls} placeholder="留空不显示" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => saveCardMutation.mutate()}
|
||||
disabled={saveCardMutation.isPending}
|
||||
className="mt-4 inline-flex items-center gap-1 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50 dark:bg-nebula"
|
||||
>
|
||||
{saveCardMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
保存名片
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="relative flex flex-1 overflow-hidden">
|
||||
<div className="flex w-1/2 flex-col border-r border-slate-200 dark:border-indigo-500/20">
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={content}
|
||||
height="100%"
|
||||
theme={theme === 'dark' ? oneDark : 'light'}
|
||||
extensions={[markdown()]}
|
||||
onChange={(value) => setContent(value)}
|
||||
className="flex-1 text-sm"
|
||||
basicSetup={{ lineNumbers: true, highlightActiveLineGutter: true, highlightActiveLine: true, foldGutter: false }}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-1/2 overflow-y-auto bg-slate-50 dark:bg-space-950">
|
||||
<div className="prose prose-lg max-w-none p-8 dark:prose-invert">
|
||||
<Markdown content={content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-between border-t border-slate-200 bg-white px-4 py-2 text-xs text-slate-500 dark:border-indigo-500/10 dark:bg-space-900 dark:text-star-dim">
|
||||
<span>{content.length} 字符</span>
|
||||
{savedAt && <span>已保存 {savedAt.toLocaleTimeString()}</span>}
|
||||
</footer>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Save,
|
||||
Eye,
|
||||
Split,
|
||||
FileEdit,
|
||||
ArrowLeft,
|
||||
Send,
|
||||
Loader2,
|
||||
Settings,
|
||||
Check,
|
||||
ImagePlus,
|
||||
} from 'lucide-react';
|
||||
import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { useThemeStore } from '../../store/theme';
|
||||
import { api } from '../../api';
|
||||
import { PostDetail } from '../../types';
|
||||
import { Markdown } from '../../components/Markdown';
|
||||
|
||||
type ViewMode = 'edit' | 'preview' | 'split';
|
||||
|
||||
export function EditorPage() {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { theme } = useThemeStore();
|
||||
|
||||
const isEdit = Boolean(id);
|
||||
const [title, setTitle] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [summary, setSummary] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [folder, setFolder] = useState('');
|
||||
const [contentMd, setContentMd] = useState('# 开始写作\n\n');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('split');
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showMeta, setShowMeta] = useState(false);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const saveTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
const { data: post, isLoading } = useQuery({
|
||||
queryKey: ['admin-post', id],
|
||||
queryFn: () => api.getAdminPost(id!),
|
||||
enabled: isEdit,
|
||||
});
|
||||
|
||||
const { data: draft } = useQuery({
|
||||
queryKey: ['admin-draft', id],
|
||||
queryFn: () => api.getDraft(id!),
|
||||
enabled: isEdit,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized && (post || draft)) {
|
||||
setTitle(post?.title || '');
|
||||
setSlug(post?.slug || '');
|
||||
setSummary(post?.summary || '');
|
||||
setTags(post?.tags.join(', ') || '');
|
||||
setFolder(post?.folder || '');
|
||||
const md = draft?.content_md || post?.content_md || '# 开始写作\n\n';
|
||||
setContentMd(md);
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [post, draft, initialized]);
|
||||
|
||||
// 自动保存草稿
|
||||
useEffect(() => {
|
||||
if (!isEdit || !id || !initialized) return;
|
||||
if (saveTimeout.current) clearTimeout(saveTimeout.current);
|
||||
saveTimeout.current = setTimeout(() => {
|
||||
handleSaveDraft();
|
||||
}, 2000);
|
||||
return () => {
|
||||
if (saveTimeout.current) clearTimeout(saveTimeout.current);
|
||||
};
|
||||
}, [contentMd, id, isEdit, initialized]);
|
||||
|
||||
const createPostMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.createPost({
|
||||
title: title || '无标题',
|
||||
slug: slug || `post-${Date.now()}`,
|
||||
summary: summary || undefined,
|
||||
tags: tags.split(',').map((t) => t.trim()).filter(Boolean),
|
||||
folder: folder || undefined,
|
||||
content_md: contentMd,
|
||||
}),
|
||||
onSuccess: (data: PostDetail) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-posts'] });
|
||||
navigate(`/admin/edit/${data.id}`, { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const updatePostMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.updatePost(id!, {
|
||||
title,
|
||||
slug,
|
||||
summary: summary || undefined,
|
||||
tags: tags.split(',').map((t) => t.trim()).filter(Boolean),
|
||||
folder,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-post', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-posts'] });
|
||||
},
|
||||
});
|
||||
|
||||
const saveDraftMutation = useMutation({
|
||||
mutationFn: () => api.saveDraft(id!, { content_md: contentMd }),
|
||||
onMutate: () => setSaving(true),
|
||||
onSuccess: () => {
|
||||
setLastSaved(new Date());
|
||||
setSaving(false);
|
||||
queryClient.setQueryData(['admin-draft', id], { content_md: contentMd });
|
||||
},
|
||||
onError: () => setSaving(false),
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => api.uploadImage(file),
|
||||
onSuccess: ({ url }, file) => {
|
||||
// 在光标处插入图片 Markdown,没有光标就追加到末尾
|
||||
const view = editorRef.current?.view;
|
||||
const snippet = ``;
|
||||
if (view) {
|
||||
const pos = view.state.selection.main.head;
|
||||
view.dispatch({ changes: { from: pos, insert: snippet } });
|
||||
} else {
|
||||
setContentMd((c) => `${c}\n${snippet}\n`);
|
||||
}
|
||||
},
|
||||
onError: (e) => alert(`上传失败:${e.message}`),
|
||||
});
|
||||
|
||||
const publishMutation = useMutation({
|
||||
mutationFn: () => api.publishPost(id!),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-post', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-posts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-draft', id] });
|
||||
},
|
||||
});
|
||||
|
||||
const unpublishMutation = useMutation({
|
||||
mutationFn: () => api.unpublishPost(id!),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-post', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-posts'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSaveDraft = () => {
|
||||
if (id) saveDraftMutation.mutate();
|
||||
};
|
||||
|
||||
const handlePublish = () => {
|
||||
if (!id) return;
|
||||
saveDraftMutation.mutate(undefined, {
|
||||
onSuccess: () => publishMutation.mutate(),
|
||||
});
|
||||
};
|
||||
|
||||
if (isEdit && (isLoading || !initialized)) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Loader2 className="animate-spin text-indigo-500" size={32} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex h-screen flex-col pt-16"
|
||||
>
|
||||
{/* 顶部工具栏 */}
|
||||
<header className="flex items-center justify-between border-b border-slate-200 bg-white/90 px-4 py-3 backdrop-blur dark:border-indigo-500/10 dark:bg-space-900/90">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/admin')}
|
||||
className="rounded-lg p-2 text-slate-500 hover:bg-slate-100 dark:text-star-dim dark:hover:bg-space-800"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="文章标题"
|
||||
className="bg-transparent text-lg font-bold text-slate-900 outline-none placeholder:text-slate-400 dark:text-star"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mr-2 hidden items-center gap-1 rounded-lg border border-slate-200 bg-slate-50 p-1 dark:border-indigo-500/20 dark:bg-space-800 md:flex">
|
||||
{(['edit', 'split', 'preview'] as ViewMode[]).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setViewMode(mode)}
|
||||
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
|
||||
viewMode === mode
|
||||
? 'bg-white text-indigo-600 shadow-sm dark:bg-space-700 dark:text-nebula-glow'
|
||||
: 'text-slate-500 hover:text-slate-700 dark:text-star-dim dark:hover:text-star'
|
||||
}`}
|
||||
>
|
||||
{mode === 'edit' && <FileEdit size={14} className="inline mr-1" />}
|
||||
{mode === 'split' && <Split size={14} className="inline mr-1" />}
|
||||
{mode === 'preview' && <Eye size={14} className="inline mr-1" />}
|
||||
{mode === 'edit' && '编辑'}
|
||||
{mode === 'split' && '分屏'}
|
||||
{mode === 'preview' && '预览'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowMeta(!showMeta)}
|
||||
className={`rounded-lg border px-3 py-2 text-sm transition-colors ${
|
||||
showMeta
|
||||
? 'border-indigo-500 bg-indigo-50 text-indigo-700 dark:border-nebula dark:bg-indigo-500/10 dark:text-nebula-glow'
|
||||
: 'border-slate-200 bg-white text-slate-600 hover:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star-dim'
|
||||
}`}
|
||||
>
|
||||
<Settings size={14} className="inline mr-1" /> 元信息
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) uploadMutation.mutate(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadMutation.isPending}
|
||||
className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-600 transition-colors hover:border-indigo-500 disabled:opacity-50 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star-dim"
|
||||
>
|
||||
{uploadMutation.isPending ? (
|
||||
<Loader2 size={14} className="inline animate-spin" />
|
||||
) : (
|
||||
<ImagePlus size={14} className="inline" />
|
||||
)}
|
||||
传图
|
||||
</button>
|
||||
|
||||
{!isEdit ? (
|
||||
<button
|
||||
onClick={() => createPostMutation.mutate()}
|
||||
disabled={createPostMutation.isPending}
|
||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700 disabled:opacity-50 dark:bg-nebula dark:hover:bg-indigo-500"
|
||||
>
|
||||
{createPostMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
创建文章
|
||||
</button>
|
||||
) : post?.status === 'public' ? (
|
||||
<button
|
||||
onClick={() => unpublishMutation.mutate()}
|
||||
disabled={unpublishMutation.isPending}
|
||||
className="rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star-dim"
|
||||
>
|
||||
转私有
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
disabled={publishMutation.isPending}
|
||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700 disabled:opacity-50 dark:bg-nebula dark:hover:bg-indigo-500"
|
||||
>
|
||||
{publishMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
|
||||
发布
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 元信息面板 */}
|
||||
{showMeta && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
className="border-b border-slate-200 bg-slate-50 px-6 py-4 dark:border-indigo-500/10 dark:bg-space-900"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-slate-500 dark:text-star-dim">Slug</label>
|
||||
<input
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star"
|
||||
placeholder="post-slug"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-slate-500 dark:text-star-dim">目录(用 / 分层)</label>
|
||||
<input
|
||||
value={folder}
|
||||
onChange={(e) => setFolder(e.target.value)}
|
||||
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star"
|
||||
placeholder="如 tech/rust,留空为根目录"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-slate-500 dark:text-star-dim">摘要</label>
|
||||
<input
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star"
|
||||
placeholder="简短描述"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-3">
|
||||
<label className="mb-1 block text-xs font-medium text-slate-500 dark:text-star-dim">标签(逗号分隔)</label>
|
||||
<input
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-indigo-500 dark:border-indigo-500/20 dark:bg-space-800 dark:text-star"
|
||||
placeholder="rust, web, kubernetes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isEdit && (
|
||||
<button
|
||||
onClick={() => updatePostMutation.mutate()}
|
||||
className="mt-4 inline-flex items-center gap-1 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 dark:bg-nebula"
|
||||
>
|
||||
<Check size={14} /> 保存元信息
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 编辑器主体 */}
|
||||
<div className="relative flex flex-1 overflow-hidden">
|
||||
{(viewMode === 'edit' || viewMode === 'split') && (
|
||||
<div className={`flex flex-col ${viewMode === 'split' ? 'w-1/2 border-r border-slate-200 dark:border-indigo-500/20' : 'w-full'}`}>
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={contentMd}
|
||||
height="100%"
|
||||
theme={theme === 'dark' ? oneDark : 'light'}
|
||||
extensions={[markdown()]}
|
||||
onChange={(value) => setContentMd(value)}
|
||||
className="flex-1 text-sm"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
highlightActiveLineGutter: true,
|
||||
highlightActiveLine: true,
|
||||
foldGutter: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(viewMode === 'preview' || viewMode === 'split') && (
|
||||
<div
|
||||
ref={previewRef}
|
||||
className={`overflow-y-auto ${viewMode === 'split' ? 'w-1/2' : 'w-full'} bg-slate-50 dark:bg-space-950`}
|
||||
>
|
||||
<div className="prose prose-lg max-w-none p-8 dark:prose-invert prose-headings:scroll-mt-24"
|
||||
>
|
||||
<Markdown content={contentMd} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 状态栏 */}
|
||||
<footer className="flex items-center justify-between border-t border-slate-200 bg-white px-4 py-2 text-xs text-slate-500 dark:border-indigo-500/10 dark:bg-space-900 dark:text-star-dim">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>{contentMd.length} 字符</span>
|
||||
{post && (
|
||||
<span className="rounded px-1.5 py-0.5 capitalize">
|
||||
状态:
|
||||
<span
|
||||
className={`font-medium ${
|
||||
post.status === 'public'
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: post.status === 'private'
|
||||
? 'text-amber-600 dark:text-amber-400'
|
||||
: 'text-slate-600 dark:text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{post.status}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{saving && <Loader2 size={12} className="animate-spin" />}
|
||||
{lastSaved && !saving && (
|
||||
<span>已保存 {lastSaved.toLocaleTimeString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { Author, UserRole } from '../types';
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
role: UserRole;
|
||||
user: Author | null;
|
||||
setToken: (token: string | null) => void;
|
||||
setUser: (role: UserRole, user?: Author | null) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
role: 'guest',
|
||||
user: null,
|
||||
setToken: (token) => {
|
||||
if (token) localStorage.setItem('token', token);
|
||||
else localStorage.removeItem('token');
|
||||
set({ token });
|
||||
},
|
||||
setUser: (role, user = null) => set({ role, user }),
|
||||
logout: () => {
|
||||
localStorage.removeItem('token');
|
||||
set({ token: null, role: 'guest', user: null });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
partialize: (state) => ({ token: state.token, role: state.role, user: state.user }),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,41 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme;
|
||||
toggle: () => void;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
if (typeof window === 'undefined') return 'dark';
|
||||
const saved = localStorage.getItem('theme') as Theme | null;
|
||||
if (saved) return saved;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const forced = params.get('theme');
|
||||
if (forced === 'dark' || forced === 'light') return forced;
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>((set, get) => ({
|
||||
theme: getInitialTheme(),
|
||||
toggle: () => {
|
||||
const next = get().theme === 'dark' ? 'light' : 'dark';
|
||||
get().setTheme(next);
|
||||
},
|
||||
setTheme: (theme) => {
|
||||
localStorage.setItem('theme', theme);
|
||||
if (theme === 'dark') document.documentElement.classList.add('dark');
|
||||
else document.documentElement.classList.remove('dark');
|
||||
set({ theme });
|
||||
},
|
||||
}));
|
||||
|
||||
// apply on load
|
||||
if (typeof window !== 'undefined') {
|
||||
if (getInitialTheme() === 'dark') document.documentElement.classList.add('dark');
|
||||
else document.documentElement.classList.remove('dark');
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
export interface Author {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
export type UserRole = 'guest' | 'user' | 'admin';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
github_id: number;
|
||||
email?: string;
|
||||
name: string;
|
||||
avatar_url?: string;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
export type PostStatus = 'draft' | 'private' | 'public';
|
||||
|
||||
export interface PostSummary {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
status: PostStatus;
|
||||
published_at?: string;
|
||||
tags: string[];
|
||||
folder: string;
|
||||
cover_url?: string;
|
||||
}
|
||||
|
||||
export interface PostDetail extends PostSummary {
|
||||
content_md: string;
|
||||
author: Author;
|
||||
}
|
||||
|
||||
export interface CommentView {
|
||||
id: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
author: Author;
|
||||
}
|
||||
|
||||
export interface TagCount {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface FolderCount {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface HotPost extends PostSummary {
|
||||
comment_count: number;
|
||||
}
|
||||
|
||||
export interface ArchiveItem {
|
||||
slug: string;
|
||||
title: string;
|
||||
folder: string;
|
||||
published_at?: string;
|
||||
}
|
||||
|
||||
export interface PageContent {
|
||||
slug: string;
|
||||
content_md: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreatePostRequest {
|
||||
slug: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
tags?: string[];
|
||||
folder?: string;
|
||||
content_md?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePostRequest {
|
||||
slug?: string;
|
||||
title?: string;
|
||||
summary?: string;
|
||||
tags?: string[];
|
||||
folder?: string;
|
||||
}
|
||||
|
||||
export interface SaveDraftRequest {
|
||||
content_md: string;
|
||||
}
|
||||
|
||||
export interface CreateCommentRequest {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface PreviewRequest {
|
||||
content_md: string;
|
||||
}
|
||||
|
||||
export interface PreviewResponse {
|
||||
content_html: string;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,69 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
space: {
|
||||
950: '#050714',
|
||||
900: '#0B1026',
|
||||
800: '#111836',
|
||||
700: '#1A2347',
|
||||
},
|
||||
nebula: {
|
||||
DEFAULT: '#6366F1',
|
||||
glow: '#818CF8',
|
||||
dim: '#312E81',
|
||||
},
|
||||
star: {
|
||||
DEFAULT: '#E0E7FF',
|
||||
dim: '#94A3B8',
|
||||
},
|
||||
paper: {
|
||||
DEFAULT: '#fafaf9',
|
||||
dark: '#f5f5f4',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'],
|
||||
serif: ['Merriweather', 'Georgia', 'Noto Serif SC', 'Songti SC', 'serif'],
|
||||
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||
},
|
||||
animation: {
|
||||
'pulse-slow': 'pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'twinkle': 'twinkle 3s ease-in-out infinite alternate',
|
||||
},
|
||||
keyframes: {
|
||||
twinkle: {
|
||||
'0%': { opacity: '0.3' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
},
|
||||
backgroundImage: {
|
||||
'space-gradient': 'radial-gradient(ellipse at top, #1e1b4b 0%, #0f172a 40%, #020617 100%)',
|
||||
},
|
||||
typography: (theme) => ({
|
||||
DEFAULT: {
|
||||
css: {
|
||||
maxWidth: 'none',
|
||||
'--tw-prose-headings': theme('colors.stone.900'),
|
||||
'--tw-prose-links': theme('colors.blue.600'),
|
||||
'--tw-prose-code': theme('colors.rose.500'),
|
||||
'--tw-prose-pre-bg': theme('colors.stone.900'),
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
css: {
|
||||
color: theme('colors.slate.300'),
|
||||
'--tw-prose-headings': theme('colors.star.DEFAULT'),
|
||||
'--tw-prose-links': theme('colors.nebula.glow'),
|
||||
'--tw-prose-code': theme('colors.nebula.glow'),
|
||||
'--tw-prose-pre-bg': theme('colors.space.800'),
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
plugins: [require('@tailwindcss/typography')],
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"composite": true,
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
"module": "nodenext",
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/api/client.ts","./src/api/index.ts","./src/components/AuthInit.tsx","./src/components/Header.tsx","./src/components/PostCard.tsx","./src/components/StarBackground.tsx","./src/pages/HomePage.tsx","./src/pages/OAuthCallback.tsx","./src/pages/PostPage.tsx","./src/pages/RssPage.tsx","./src/pages/TagsPage.tsx","./src/pages/admin/AdminLayout.tsx","./src/pages/admin/AdminPage.tsx","./src/pages/admin/EditorPage.tsx","./src/store/auth.ts","./src/store/theme.ts"],"version":"6.0.3"}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_API_URL || 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_API_URL || 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user