feat: ship blog platform admin and deploy stack
This commit is contained in:
5
frontend/.dockerignore
Normal file
5
frontend/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.log
|
||||
30
frontend/Dockerfile
Normal file
30
frontend/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG PUBLIC_API_BASE_URL=http://localhost:5150/api
|
||||
ENV PUBLIC_API_BASE_URL=${PUBLIC_API_BASE_URL}
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
EXPOSE 4321
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=5 CMD node -e "fetch('http://127.0.0.1:4321/healthz').then((res)=>{if(!res.ok)process.exit(1)}).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "./dist/server/entry.mjs"]
|
||||
@@ -1,43 +1,60 @@
|
||||
# Astro Starter Kit: Minimal
|
||||
# frontend
|
||||
|
||||
```sh
|
||||
npm create astro@latest -- --template minimal
|
||||
Astro 前台站点,当前运行模式为:
|
||||
|
||||
- `output: 'server'`
|
||||
- `@astrojs/node` standalone
|
||||
- 少量 Svelte 组件用于客户端激活
|
||||
|
||||
## 常用命令
|
||||
|
||||
```powershell
|
||||
pnpm install
|
||||
pnpm dev
|
||||
pnpm build
|
||||
```
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
默认本地开发端口:
|
||||
|
||||
## 🚀 Project Structure
|
||||
- frontend: `4321`
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
## API 地址约定
|
||||
|
||||
```text
|
||||
/
|
||||
├── public/
|
||||
├── src/
|
||||
│ └── pages/
|
||||
│ └── index.astro
|
||||
└── package.json
|
||||
前台现在区分两类 API 地址:
|
||||
|
||||
- `INTERNAL_API_BASE_URL`
|
||||
- 给 Astro SSR / Node 服务端渲染访问 backend 用
|
||||
- docker compose 默认推荐:`http://backend:5150/api`
|
||||
- `PUBLIC_API_BASE_URL`
|
||||
- 给浏览器里的评论、AI 问答、搜索等请求用
|
||||
- 如果不设置,生产环境会回退到“当前访问主机 + `:5150/api`”
|
||||
|
||||
如果你走正式域名 / HTTPS / 反向代理,建议显式设置:
|
||||
|
||||
```env
|
||||
PUBLIC_API_BASE_URL=https://api.blog.init.cool
|
||||
```
|
||||
|
||||
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||
## 图片处理链
|
||||
|
||||
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||
前台现在额外带了一个 SSR 图片优化端点:
|
||||
|
||||
Any static assets, like images, can be placed in the `public/` directory.
|
||||
- `/_img`
|
||||
|
||||
## 🧞 Commands
|
||||
它会对**同域图片**做:
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
- 响应式尺寸裁切/缩放
|
||||
- `AVIF / WebP / JPEG|PNG` 输出
|
||||
- 前台卡片 / 文章页封面的 `srcset` 生成
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
如果你的封面图来自额外 CDN / R2 公网域名,需要给 frontend 运行时增加:
|
||||
|
||||
## 👀 Want to learn more?
|
||||
```env
|
||||
PUBLIC_IMAGE_ALLOWED_HOSTS=cdn.example.com,pub-xxxx.r2.dev
|
||||
```
|
||||
|
||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||
admin 侧上传封面时也会额外做:
|
||||
|
||||
- 上传前压缩
|
||||
- 16:9 封面规范化
|
||||
- 优先转为 `AVIF / WebP`
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import node from '@astrojs/node';
|
||||
import svelte from '@astrojs/svelte';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
|
||||
const nodeProcess = /** @type {any} */ (globalThis).process;
|
||||
const disableHmrForLighthouse = nodeProcess?.env?.LIGHTHOUSE_NO_HMR === '1';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
adapter: node({
|
||||
mode: 'standalone',
|
||||
}),
|
||||
integrations: [
|
||||
svelte(),
|
||||
tailwind({
|
||||
applyBaseStyles: false
|
||||
})
|
||||
]
|
||||
],
|
||||
devToolbar: {
|
||||
enabled: false,
|
||||
},
|
||||
vite: disableHmrForLighthouse
|
||||
? {
|
||||
server: {
|
||||
hmr: false,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
191
frontend/package-lock.json
generated
191
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "termi-astro",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^10.0.4",
|
||||
"@astrojs/svelte": "^8.0.3",
|
||||
"@astrojs/tailwind": "^6.0.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
@@ -179,6 +180,20 @@
|
||||
"vfile": "^6.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/node": {
|
||||
"version": "10.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/node/-/node-10.0.4.tgz",
|
||||
"integrity": "sha512-7pVgiVSscQHRC2WqjlXcnbbcKMYp2GXrYpmuvdGg5zgA8J1lFm2vmwVhHZFuZK3Ik5PzoxiDROaEgoDGLbfhLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@astrojs/internal-helpers": "0.8.0",
|
||||
"send": "^1.2.1",
|
||||
"server-destroy": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"astro": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/prism": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.1.tgz",
|
||||
@@ -2728,6 +2743,15 @@
|
||||
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
@@ -2869,6 +2893,12 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.325",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz",
|
||||
@@ -2899,6 +2929,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
@@ -2967,6 +3006,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
||||
@@ -3001,6 +3046,15 @@
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
@@ -3146,6 +3200,15 @@
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -3425,6 +3488,32 @@
|
||||
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/iron-webcrypto": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
|
||||
@@ -4526,6 +4615,31 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "^1.54.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
@@ -4683,6 +4797,18 @@
|
||||
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/oniguruma-parser": {
|
||||
"version": "0.12.1",
|
||||
"resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz",
|
||||
@@ -5057,6 +5183,15 @@
|
||||
"integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -5457,6 +5592,44 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"etag": "^1.8.1",
|
||||
"fresh": "^2.0.0",
|
||||
"http-errors": "^2.0.1",
|
||||
"mime-types": "^3.0.2",
|
||||
"ms": "^2.1.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"range-parser": "^1.2.1",
|
||||
"statuses": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/server-destroy": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz",
|
||||
"integrity": "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
@@ -5558,6 +5731,15 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
@@ -5901,6 +6083,15 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/trim-lines": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^10.0.4",
|
||||
"@astrojs/svelte": "^8.0.3",
|
||||
"@astrojs/tailwind": "^6.0.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
@@ -19,6 +20,7 @@
|
||||
"autoprefixer": "^10.4.27",
|
||||
"lucide-astro": "^0.556.0",
|
||||
"postcss": "^8.5.8",
|
||||
"sharp": "^0.34.5",
|
||||
"svelte": "^5.55.0",
|
||||
"tailwindcss": "^3.4.19"
|
||||
},
|
||||
|
||||
4543
frontend/pnpm-lock.yaml
generated
Normal file
4543
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,46 +0,0 @@
|
||||
---
|
||||
// Back to Top Button Component
|
||||
---
|
||||
|
||||
<button
|
||||
id="back-to-top"
|
||||
class="fixed bottom-8 right-8 w-12 h-12 rounded-full bg-[var(--header-bg)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--primary)] hover:border-[var(--primary)] transition-all opacity-0 translate-y-4 z-50 flex items-center justify-center shadow-lg"
|
||||
aria-label="Back to top"
|
||||
>
|
||||
<i class="fas fa-chevron-up"></i>
|
||||
</button>
|
||||
|
||||
<script is:inline>
|
||||
(function() {
|
||||
const backToTopBtn = document.getElementById('back-to-top');
|
||||
if (!backToTopBtn) return;
|
||||
|
||||
function toggleVisibility() {
|
||||
const scrollTop = window.scrollY || document.documentElement.scrollTop;
|
||||
|
||||
if (scrollTop > 300) {
|
||||
backToTopBtn.classList.remove('opacity-0', 'translate-y-4');
|
||||
backToTopBtn.classList.add('opacity-100', 'translate-y-0');
|
||||
} else {
|
||||
backToTopBtn.classList.add('opacity-0', 'translate-y-4');
|
||||
backToTopBtn.classList.remove('opacity-100', 'translate-y-0');
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToTop() {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
|
||||
// Show/hide on scroll
|
||||
window.addEventListener('scroll', toggleVisibility, { passive: true });
|
||||
|
||||
// Click to scroll to top
|
||||
backToTopBtn.addEventListener('click', scrollToTop);
|
||||
|
||||
// Initial check
|
||||
toggleVisibility();
|
||||
})();
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import { API_BASE_URL, apiClient } from '../lib/api/client';
|
||||
import { apiClient, resolvePublicApiBaseUrl } from '../lib/api/client';
|
||||
import { getI18n } from '../lib/i18n';
|
||||
import type { Comment } from '../lib/api/client';
|
||||
|
||||
@@ -10,6 +10,7 @@ interface Props {
|
||||
|
||||
const { postSlug, class: className = '' } = Astro.props;
|
||||
const { locale, t } = getI18n(Astro);
|
||||
const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||
|
||||
let comments: Comment[] = [];
|
||||
let error: string | null = null;
|
||||
@@ -35,7 +36,7 @@ function formatCommentDate(dateStr: string): string {
|
||||
}
|
||||
---
|
||||
|
||||
<div class={`terminal-comments ${className}`} data-post-slug={postSlug} data-api-base={API_BASE_URL}>
|
||||
<div class={`terminal-comments ${className}`} data-post-slug={postSlug} data-api-base={publicApiBaseUrl}>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="space-y-3">
|
||||
<span class="terminal-kicker">
|
||||
@@ -104,6 +105,35 @@ function formatCommentDate(dateStr: string): string {
|
||||
<p class="mt-2 text-right text-xs text-[var(--text-tertiary)]">{t('comments.maxChars')}</p>
|
||||
</div>
|
||||
|
||||
<div class="hidden" aria-hidden="true">
|
||||
<label>
|
||||
Website
|
||||
<input type="text" name="website" tabindex="-1" autocomplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--header-bg)]/60 px-4 py-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
|
||||
验证码
|
||||
</p>
|
||||
<button type="button" id="refresh-captcha" class="terminal-action-button px-3 py-2 text-xs">
|
||||
<i class="fas fa-rotate-right"></i>
|
||||
<span>刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
<p id="captcha-question" class="mt-2 text-sm text-[var(--text-secondary)]">加载中...</p>
|
||||
<input type="hidden" name="captchaToken" />
|
||||
<input
|
||||
type="text"
|
||||
name="captchaAnswer"
|
||||
required
|
||||
inputmode="numeric"
|
||||
placeholder="请输入上方答案"
|
||||
class="mt-3 terminal-form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="replying-to" class="terminal-panel-muted hidden items-center justify-between gap-3 py-3">
|
||||
<span class="text-sm text-[var(--text-secondary)]">
|
||||
{t('common.reply')} -> <span id="reply-target" class="font-medium text-[var(--primary)]"></span>
|
||||
@@ -209,8 +239,12 @@ function formatCommentDate(dateStr: string): string {
|
||||
const cancelReply = document.getElementById('cancel-reply');
|
||||
const replyBtns = document.querySelectorAll('.reply-btn');
|
||||
const messageBox = document.getElementById('comment-message');
|
||||
const captchaQuestion = document.getElementById('captcha-question');
|
||||
const refreshCaptchaBtn = document.getElementById('refresh-captcha');
|
||||
const postSlug = wrapper?.getAttribute('data-post-slug') || '';
|
||||
const apiBase = wrapper?.getAttribute('data-api-base') || '/api';
|
||||
const captchaTokenInput = form?.querySelector('input[name=\"captchaToken\"]') as HTMLInputElement | null;
|
||||
const captchaAnswerInput = form?.querySelector('input[name=\"captchaAnswer\"]') as HTMLInputElement | null;
|
||||
|
||||
function showMessage(message: string, type: 'success' | 'error' | 'info') {
|
||||
if (!messageBox) return;
|
||||
@@ -251,6 +285,37 @@ function formatCommentDate(dateStr: string): string {
|
||||
replyingTo?.removeAttribute('data-reply-to');
|
||||
}
|
||||
|
||||
async function loadCaptcha(showError = true) {
|
||||
if (!captchaQuestion || !captchaTokenInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
captchaQuestion.textContent = '加载中...';
|
||||
captchaTokenInput.value = '';
|
||||
if (captchaAnswerInput) {
|
||||
captchaAnswerInput.value = '';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/comments/captcha`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
const payload = await response.json() as { token?: string; question?: string };
|
||||
captchaTokenInput.value = payload.token || '';
|
||||
captchaQuestion.textContent = payload.question || '请刷新验证码';
|
||||
} catch (error) {
|
||||
captchaQuestion.textContent = '验证码加载失败,请刷新重试';
|
||||
if (showError) {
|
||||
showMessage(
|
||||
t('comments.submitFailed', { message: error instanceof Error ? error.message : t('common.unknownError') }),
|
||||
'error'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggleBtn?.addEventListener('click', () => {
|
||||
formContainer?.classList.toggle('hidden');
|
||||
if (!formContainer?.classList.contains('hidden')) {
|
||||
@@ -285,6 +350,10 @@ function formatCommentDate(dateStr: string): string {
|
||||
resetReply();
|
||||
});
|
||||
|
||||
refreshCaptchaBtn?.addEventListener('click', () => {
|
||||
void loadCaptcha(false);
|
||||
});
|
||||
|
||||
form?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -306,6 +375,9 @@ function formatCommentDate(dateStr: string): string {
|
||||
content: formData.get('content'),
|
||||
scope: 'article',
|
||||
replyToCommentId: replyToId ? Number(replyToId) : null,
|
||||
captchaToken: formData.get('captchaToken'),
|
||||
captchaAnswer: formData.get('captchaAnswer'),
|
||||
website: formData.get('website'),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -318,8 +390,10 @@ function formatCommentDate(dateStr: string): string {
|
||||
resetReply();
|
||||
formContainer?.classList.add('hidden');
|
||||
showMessage(t('comments.submitSuccess'), 'success');
|
||||
void loadCaptcha(false);
|
||||
} catch (error) {
|
||||
showMessage(t('comments.submitFailed', { message: error instanceof Error ? error.message : t('common.unknownError') }), 'error');
|
||||
void loadCaptcha(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -335,4 +409,6 @@ function formatCommentDate(dateStr: string): string {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
void loadCaptcha(false);
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import { API_BASE_URL, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
|
||||
import { DEFAULT_SITE_SETTINGS, resolvePublicApiBaseUrl } from '../lib/api/client';
|
||||
import { getI18n } from '../lib/i18n';
|
||||
import type { SiteSettings } from '../lib/types';
|
||||
|
||||
@@ -10,11 +10,12 @@ interface Props {
|
||||
|
||||
const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.props;
|
||||
const { t } = getI18n(Astro);
|
||||
const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||
---
|
||||
|
||||
<div
|
||||
class={`terminal-friend-link-form ${className}`}
|
||||
data-api-base={API_BASE_URL}
|
||||
data-api-base={publicApiBaseUrl}
|
||||
data-site-name={siteSettings.siteName}
|
||||
data-site-url={siteSettings.siteUrl}
|
||||
data-site-description={siteSettings.siteDescription}
|
||||
|
||||
@@ -43,9 +43,9 @@ const { t } = getI18n(Astro);
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h4 class="font-bold text-[var(--title-color)] group-hover:text-[var(--primary)] transition-colors truncate text-base">
|
||||
<h3 class="font-bold text-[var(--title-color)] group-hover:text-[var(--primary)] transition-colors truncate text-base">
|
||||
{friend.name}
|
||||
</h4>
|
||||
</h3>
|
||||
<i class="fas fa-external-link-alt text-xs text-[var(--text-tertiary)] opacity-0 group-hover:opacity-100 transition-opacity"></i>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
import { API_BASE_URL } from '../lib/api/client';
|
||||
import { resolvePublicApiBaseUrl } from '../lib/api/client';
|
||||
import { terminalConfig } from '../lib/config/terminal';
|
||||
import { getI18n, SUPPORTED_LOCALES } from '../lib/i18n';
|
||||
import ThemeToggle from './interactive/ThemeToggle.svelte';
|
||||
import type { SiteSettings } from '../lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -19,6 +20,7 @@ const musicPlaylist = (Astro.props.siteSettings?.musicPlaylist || []).filter(
|
||||
(item) => item?.title?.trim() && item?.url?.trim()
|
||||
);
|
||||
const musicPlaylistPayload = JSON.stringify(musicPlaylist);
|
||||
const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||
const hasMusicPlaylist = musicPlaylist.length > 0;
|
||||
const currentMusicTrack = hasMusicPlaylist ? musicPlaylist[0] : null;
|
||||
const navItems = [
|
||||
@@ -89,7 +91,7 @@ const currentNavLabel =
|
||||
placeholder={t('header.searchPlaceholderKeyword')}
|
||||
class="terminal-console-input"
|
||||
/>
|
||||
<button id="search-btn" class="terminal-toolbar-iconbtn h-8 w-8">
|
||||
<button id="search-btn" class="terminal-toolbar-iconbtn h-8 w-8" aria-label="Search">
|
||||
<i id="search-btn-icon" class="fas fa-search text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -126,13 +128,13 @@ const currentNavLabel =
|
||||
{currentMusicTrack?.title || '未配置曲目'}
|
||||
</p>
|
||||
<div class="mt-1 flex items-center gap-1">
|
||||
<button id="desktop-music-prev" class="terminal-toolbar-iconbtn h-7 w-7" disabled={!hasMusicPlaylist}>
|
||||
<button id="desktop-music-prev" class="terminal-toolbar-iconbtn h-7 w-7" aria-label="Previous track" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-step-backward text-[11px]"></i>
|
||||
</button>
|
||||
<button id="desktop-music-play" class="terminal-toolbar-iconbtn h-7 w-7 bg-[var(--primary)]/12 text-[var(--primary)]" style="border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));" disabled={!hasMusicPlaylist}>
|
||||
<button id="desktop-music-play" class="terminal-toolbar-iconbtn h-7 w-7 bg-[var(--primary)]/12 text-[var(--primary)]" style="border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));" aria-label="Play or pause" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-play text-[11px]" id="desktop-music-play-icon"></i>
|
||||
</button>
|
||||
<button id="desktop-music-next" class="terminal-toolbar-iconbtn h-7 w-7" disabled={!hasMusicPlaylist}>
|
||||
<button id="desktop-music-next" class="terminal-toolbar-iconbtn h-7 w-7" aria-label="Next track" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-step-forward text-[11px]"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -169,17 +171,15 @@ const currentNavLabel =
|
||||
</div>
|
||||
|
||||
<div class="relative shrink-0">
|
||||
<button
|
||||
id="theme-toggle"
|
||||
class="theme-toggle terminal-toolbar-iconbtn h-8 w-8 shrink-0"
|
||||
aria-label={t('header.themeToggle')}
|
||||
title={t('header.themeToggle')}
|
||||
>
|
||||
<i id="theme-icon" class="fas fa-desktop text-sm text-[var(--text-secondary)]"></i>
|
||||
<span id="theme-toggle-label" class="sr-only">
|
||||
{t('header.themeSystem')}
|
||||
</span>
|
||||
</button>
|
||||
<ThemeToggle
|
||||
client:load
|
||||
labels={{
|
||||
toggle: t('header.themeToggle'),
|
||||
system: t('header.themeSystem'),
|
||||
light: t('header.themeLight'),
|
||||
dark: t('header.themeDark'),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -233,7 +233,7 @@ const currentNavLabel =
|
||||
placeholder={t('header.searchPlaceholderKeyword')}
|
||||
class="terminal-console-input"
|
||||
/>
|
||||
<button id="mobile-search-btn" class="terminal-toolbar-iconbtn">
|
||||
<button id="mobile-search-btn" class="terminal-toolbar-iconbtn" aria-label="Search">
|
||||
<i id="mobile-search-btn-icon" class="fas fa-search text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -284,16 +284,16 @@ const currentNavLabel =
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="terminal-toolbar-label">{t('header.musicPanel')}</div>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<button id="music-prev" class="terminal-toolbar-iconbtn" disabled={!hasMusicPlaylist}>
|
||||
<button id="music-prev" class="terminal-toolbar-iconbtn" aria-label="Previous track" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-step-backward text-xs"></i>
|
||||
</button>
|
||||
<button id="music-play" class="terminal-toolbar-iconbtn bg-[var(--primary)]/12 text-[var(--primary)]" style="border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));" disabled={!hasMusicPlaylist}>
|
||||
<button id="music-play" class="terminal-toolbar-iconbtn bg-[var(--primary)]/12 text-[var(--primary)]" style="border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));" aria-label="Play or pause" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-play text-xs" id="music-play-icon"></i>
|
||||
</button>
|
||||
<button id="music-next" class="terminal-toolbar-iconbtn" disabled={!hasMusicPlaylist}>
|
||||
<button id="music-next" class="terminal-toolbar-iconbtn" aria-label="Next track" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-step-forward text-xs"></i>
|
||||
</button>
|
||||
<button id="music-volume" class="terminal-toolbar-iconbtn" disabled={!hasMusicPlaylist}>
|
||||
<button id="music-volume" class="terminal-toolbar-iconbtn" aria-label="Mute or unmute" disabled={!hasMusicPlaylist}>
|
||||
<i class="fas fa-volume-up text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -337,88 +337,9 @@ const currentNavLabel =
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script is:inline define:vars={{ apiBase: API_BASE_URL, musicPlaylistPayload }}>
|
||||
<script is:inline define:vars={{ apiBase: publicApiBaseUrl, musicPlaylistPayload }}>
|
||||
const t = window.__termiTranslate;
|
||||
|
||||
// Theme selection
|
||||
function initThemeToggle() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const themeIcon = document.getElementById('theme-icon');
|
||||
const themeToggleLabel = document.getElementById('theme-toggle-label');
|
||||
const themeApi = window.__termiTheme;
|
||||
|
||||
if (!themeToggle || !themeIcon || !themeApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (themeToggle.dataset.bound === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
themeToggle.dataset.bound = 'true';
|
||||
|
||||
const themeMeta = {
|
||||
light: {
|
||||
iconClass: 'fas fa-sun text-sm',
|
||||
color: 'var(--secondary)',
|
||||
label: t('header.themeLight'),
|
||||
},
|
||||
dark: {
|
||||
iconClass: 'fas fa-moon text-sm',
|
||||
color: 'var(--primary)',
|
||||
label: t('header.themeDark'),
|
||||
},
|
||||
system: {
|
||||
iconClass: 'fas fa-desktop text-sm',
|
||||
color: 'var(--text-secondary)',
|
||||
label: t('header.themeSystem'),
|
||||
},
|
||||
};
|
||||
|
||||
function updateThemeUI(detail = null) {
|
||||
const mode = detail?.mode || themeApi.getMode();
|
||||
const resolved = detail?.resolved || themeApi.resolveTheme(mode);
|
||||
const modeMeta = themeMeta[mode] || themeMeta.system;
|
||||
const modeLabel = themeMeta[mode]?.label || themeMeta.system.label;
|
||||
const resolvedLabel = resolved === 'dark' ? t('header.themeDark') : t('header.themeLight');
|
||||
|
||||
themeIcon.className = modeMeta.iconClass;
|
||||
themeIcon.style.color = modeMeta.color;
|
||||
|
||||
if (themeToggleLabel) {
|
||||
themeToggleLabel.textContent = `${modeLabel} / ${resolvedLabel}`;
|
||||
}
|
||||
|
||||
const toggleTitle = `${t('header.themeToggle')} · ${modeLabel} / ${resolvedLabel}`;
|
||||
themeToggle.setAttribute('aria-label', toggleTitle);
|
||||
themeToggle.setAttribute('title', toggleTitle);
|
||||
}
|
||||
|
||||
themeToggle.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const currentMode = themeApi.getMode();
|
||||
const nextMode =
|
||||
currentMode === 'system' ? 'light' :
|
||||
currentMode === 'light' ? 'dark' :
|
||||
'system';
|
||||
themeApi.applyTheme(nextMode);
|
||||
});
|
||||
|
||||
window.addEventListener('termi:theme-change', function(event) {
|
||||
updateThemeUI(event.detail);
|
||||
});
|
||||
|
||||
updateThemeUI(themeApi.syncTheme());
|
||||
}
|
||||
|
||||
// Run immediately if DOM is ready, otherwise wait
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initThemeToggle);
|
||||
} else {
|
||||
initThemeToggle();
|
||||
}
|
||||
|
||||
// Site Menu
|
||||
const mobileMenuBtn = document.getElementById('mobile-menu-btn');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
@@ -689,7 +610,7 @@ const currentNavLabel =
|
||||
return buildLocalizedUrl(
|
||||
currentSearchMode === 'ai'
|
||||
? `/ask?q=${encodeURIComponent(query)}`
|
||||
: `/articles?search=${encodeURIComponent(query)}`
|
||||
: `/search?q=${encodeURIComponent(query)}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -845,7 +766,7 @@ const currentNavLabel =
|
||||
${escapeHtml(t('header.liveResults'))}
|
||||
</div>
|
||||
${itemsHtml}
|
||||
<a href="${buildLocalizedUrl(`/articles?search=${encodeURIComponent(query)}`)}" class="block px-4 py-3 text-sm font-medium text-[var(--primary)] transition-colors hover:bg-[var(--header-bg)]">
|
||||
<a href="${buildLocalizedUrl(`/search?q=${encodeURIComponent(query)}`)}" class="block px-4 py-3 text-sm font-medium text-[var(--primary)] transition-colors hover:bg-[var(--header-bg)]">
|
||||
${escapeHtml(t('header.searchAllResults'))}
|
||||
</a>
|
||||
${aiFooter}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import { API_BASE_URL } from '../lib/api/client';
|
||||
import { resolvePublicApiBaseUrl } from '../lib/api/client';
|
||||
import { getI18n } from '../lib/i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -9,12 +9,13 @@ interface Props {
|
||||
|
||||
const { postSlug, class: className = '' } = Astro.props;
|
||||
const { t } = getI18n(Astro);
|
||||
const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||
---
|
||||
|
||||
<div
|
||||
class={`paragraph-comments-shell ${className}`}
|
||||
data-post-slug={postSlug}
|
||||
data-api-base={API_BASE_URL}
|
||||
data-api-base={publicApiBaseUrl}
|
||||
data-storage-key={`termi:paragraph-comments:${postSlug}`}
|
||||
>
|
||||
<div class="paragraph-comments-toolbar terminal-panel-muted">
|
||||
@@ -335,6 +336,33 @@ const { t } = getI18n(Astro);
|
||||
<p class="mt-2 text-right text-xs text-[var(--text-tertiary)]">${escapeHtml(t('paragraphComments.maxChars'))}</p>
|
||||
</div>
|
||||
|
||||
<div class="hidden" aria-hidden="true">
|
||||
<label>
|
||||
Website
|
||||
<input type="text" name="website" tabindex="-1" autocomplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--header-bg)]/60 px-4 py-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--text-tertiary)]">验证码</p>
|
||||
<button type="button" class="terminal-action-button px-3 py-2 text-xs" data-refresh-captcha>
|
||||
<i class="fas fa-rotate-right"></i>
|
||||
<span>刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-[var(--text-secondary)]" data-captcha-question>加载中...</p>
|
||||
<input type="hidden" name="captchaToken" />
|
||||
<input
|
||||
type="text"
|
||||
name="captchaAnswer"
|
||||
required
|
||||
inputmode="numeric"
|
||||
placeholder="请输入上方答案"
|
||||
class="mt-3 terminal-form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button type="submit" class="terminal-action-button terminal-action-button-primary">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
@@ -359,6 +387,10 @@ const { t } = getI18n(Astro);
|
||||
const replyBanner = panel.querySelector('[data-reply-banner]') as HTMLElement;
|
||||
const replyTarget = panel.querySelector('[data-reply-target]') as HTMLElement;
|
||||
const focusButton = panel.querySelector('[data-focus-paragraph]') as HTMLButtonElement;
|
||||
const captchaQuestion = panel.querySelector('[data-captcha-question]') as HTMLElement;
|
||||
const refreshCaptchaButton = panel.querySelector('[data-refresh-captcha]') as HTMLButtonElement;
|
||||
const captchaTokenInput = form.querySelector('input[name=\"captchaToken\"]') as HTMLInputElement;
|
||||
const captchaAnswerInput = form.querySelector('input[name=\"captchaAnswer\"]') as HTMLInputElement;
|
||||
|
||||
function clearStatus() {
|
||||
statusBox.className = 'paragraph-comment-status hidden';
|
||||
@@ -370,6 +402,37 @@ const { t } = getI18n(Astro);
|
||||
statusBox.textContent = message;
|
||||
}
|
||||
|
||||
async function loadCaptcha(showStatusOnError = true) {
|
||||
if (!captchaQuestion || !captchaTokenInput || !captchaAnswerInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
captchaQuestion.textContent = '加载中...';
|
||||
captchaTokenInput.value = '';
|
||||
captchaAnswerInput.value = '';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/comments/captcha`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
const payload = await response.json() as { token?: string; question?: string };
|
||||
captchaTokenInput.value = payload.token || '';
|
||||
captchaQuestion.textContent = payload.question || '请刷新验证码';
|
||||
} catch (error) {
|
||||
captchaQuestion.textContent = '验证码加载失败,请刷新重试';
|
||||
if (showStatusOnError) {
|
||||
setStatus(
|
||||
t('paragraphComments.submitFailed', {
|
||||
message: error instanceof Error ? error.message : t('common.unknownError'),
|
||||
}),
|
||||
'error'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetReplyState() {
|
||||
activeReplyToCommentId = null;
|
||||
replyBanner.classList.add('hidden');
|
||||
@@ -579,6 +642,9 @@ const { t } = getI18n(Astro);
|
||||
descriptor.element.insertAdjacentElement('afterend', panel);
|
||||
panel.classList.remove('hidden');
|
||||
panel.dataset.paragraphKey = paragraphKey;
|
||||
if (!captchaTokenInput.value) {
|
||||
await loadCaptcha(false);
|
||||
}
|
||||
|
||||
paragraphDescriptors.forEach((item, key) => {
|
||||
item.element.classList.toggle('is-comment-focused', key === paragraphKey);
|
||||
@@ -685,6 +751,10 @@ const { t } = getI18n(Astro);
|
||||
descriptor?.element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
|
||||
refreshCaptchaButton?.addEventListener('click', () => {
|
||||
void loadCaptcha(false);
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -718,6 +788,9 @@ const { t } = getI18n(Astro);
|
||||
paragraphKey: descriptor.key,
|
||||
paragraphExcerpt: descriptor.excerpt,
|
||||
replyToCommentId: activeReplyToCommentId,
|
||||
captchaToken: formData.get('captchaToken'),
|
||||
captchaAnswer: formData.get('captchaAnswer'),
|
||||
website: formData.get('website'),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -741,8 +814,10 @@ const { t } = getI18n(Astro);
|
||||
const approvedComments = await loadThread(descriptor.key, false);
|
||||
renderThread(descriptor.key, approvedComments);
|
||||
setStatus(t('paragraphComments.submitSuccess'), 'success');
|
||||
void loadCaptcha(false);
|
||||
} catch (error) {
|
||||
setStatus(t('paragraphComments.submitFailed', { message: error instanceof Error ? error.message : t('common.unknownError') }), 'error');
|
||||
void loadCaptcha(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -798,6 +873,7 @@ const { t } = getI18n(Astro);
|
||||
|
||||
updateMarkerState();
|
||||
applyMarkerVisibility(markersVisible, { persist: false });
|
||||
await loadCaptcha(false);
|
||||
await openFromHash();
|
||||
window.addEventListener('hashchange', () => {
|
||||
void openFromHash();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import type { Post } from '../lib/types';
|
||||
import CodeBlock from './CodeBlock.astro';
|
||||
import ResponsiveImage from './ui/ResponsiveImage.astro';
|
||||
import { formatReadTime, getI18n } from '../lib/i18n';
|
||||
import {
|
||||
getAccentVars,
|
||||
@@ -55,9 +56,6 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||
style={`--post-border-color: ${typeColor}`}
|
||||
data-post-card-link
|
||||
data-post-url={`/articles/${post.slug}`}
|
||||
tabindex="0"
|
||||
role="link"
|
||||
aria-label={`Open ${post.title}`}
|
||||
>
|
||||
<div class="absolute left-0 top-4 bottom-4 w-1 rounded-full opacity-80" style={`background-color: ${typeColor}`}></div>
|
||||
|
||||
@@ -72,7 +70,7 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||
href={`/articles/${post.slug}`}
|
||||
class={`inline-flex min-w-0 items-center text-[var(--title-color)] transition hover:text-[var(--primary)] ${post.type === 'article' ? 'text-lg font-bold' : 'text-base font-bold'}`}
|
||||
>
|
||||
<h3 class="truncate" set:html={highlightText(post.title, highlightTerm)} />
|
||||
<h2 class="truncate" set:html={highlightText(post.title, highlightTerm)} />
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
@@ -105,11 +103,15 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||
post.images && post.images.length === 1 ? 'aspect-video' :
|
||||
'aspect-square'
|
||||
]}>
|
||||
<img
|
||||
<ResponsiveImage
|
||||
src={resolveFileRef(img)}
|
||||
alt={`${post.title} - ${index + 1}`}
|
||||
loading="lazy"
|
||||
class="w-full h-full object-cover hover:scale-105 transition-transform"
|
||||
pictureClass="block h-full w-full"
|
||||
imgClass="w-full h-full object-cover hover:scale-105 transition-transform"
|
||||
widths={post.images && post.images.length === 1 ? [640, 960, 1280, 1600] : [320, 480, 720, 960]}
|
||||
sizes={post.images && post.images.length === 1
|
||||
? '(min-width: 1024px) 40rem, 100vw'
|
||||
: '(min-width: 1024px) 18rem, (min-width: 640px) 45vw, 100vw'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -153,13 +155,8 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||
return Boolean(selection && selection.toString().trim());
|
||||
};
|
||||
|
||||
const navigateFromCard = (card) => {
|
||||
const href = card.dataset.postUrl;
|
||||
if (!href) return;
|
||||
window.location.href = href;
|
||||
};
|
||||
|
||||
document.querySelectorAll('[data-post-card-link]').forEach((card) => {
|
||||
if (!(card instanceof HTMLElement)) return;
|
||||
if (card.dataset.postCardBound === 'true') return;
|
||||
card.dataset.postCardBound = 'true';
|
||||
|
||||
@@ -167,15 +164,11 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||
if (event.defaultPrevented) return;
|
||||
if (hasTextSelection()) return;
|
||||
if (event.target instanceof Element && event.target.closest(interactiveSelector)) return;
|
||||
navigateFromCard(card);
|
||||
const href = card.dataset.postUrl;
|
||||
if (!href) return;
|
||||
window.location.href = href;
|
||||
});
|
||||
|
||||
card.addEventListener('keydown', (event) => {
|
||||
if (event.defaultPrevented) return;
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
if (event.target instanceof Element && event.target.closest(interactiveSelector)) return;
|
||||
event.preventDefault();
|
||||
navigateFromCard(card);
|
||||
});
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -13,7 +13,7 @@ const { stats } = Astro.props;
|
||||
<li class="rounded-2xl border border-[var(--border-color)] bg-[linear-gradient(135deg,rgba(var(--primary-rgb),0.12),rgba(255,255,255,0.55))] px-4 py-4 shadow-[0_12px_32px_rgba(37,99,235,0.08)]">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[var(--border-color)] bg-white/75 text-[var(--primary)] shadow-sm">
|
||||
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-transparent bg-[var(--primary)] text-[var(--terminal-bg)] shadow-[0_10px_24px_rgba(var(--primary-rgb),0.22)]">
|
||||
<span class="font-mono text-xs">{String(index + 1).padStart(2, '0')}</span>
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
|
||||
145
frontend/src/components/SubscriptionSignup.astro
Normal file
145
frontend/src/components/SubscriptionSignup.astro
Normal file
@@ -0,0 +1,145 @@
|
||||
---
|
||||
import { resolvePublicApiBaseUrl } from '../lib/api/client';
|
||||
|
||||
interface Props {
|
||||
requestUrl?: string | URL;
|
||||
}
|
||||
|
||||
const { requestUrl } = Astro.props as Props;
|
||||
const subscribeApiUrl = `${resolvePublicApiBaseUrl(requestUrl)}/subscriptions`;
|
||||
---
|
||||
|
||||
<section class="terminal-subscribe-card" data-subscribe-root data-api-url={subscribeApiUrl}>
|
||||
<div class="terminal-subscribe-head">
|
||||
<p class="terminal-subscribe-kicker">newsletter / notifications</p>
|
||||
<h3>订阅更新</h3>
|
||||
<p>输入邮箱后,可以收到新文章通知;提交后需要先去邮箱点击确认链接才会正式生效。</p>
|
||||
</div>
|
||||
|
||||
<form class="terminal-subscribe-form" data-subscribe-form>
|
||||
<input type="text" name="displayName" placeholder="称呼(可选)" autocomplete="name" />
|
||||
<input type="email" name="email" placeholder="name@example.com" autocomplete="email" required />
|
||||
<button type="submit">订阅</button>
|
||||
</form>
|
||||
|
||||
<p class="terminal-subscribe-status" data-subscribe-status>支持确认订阅、退订链接和偏好管理页。</p>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('[data-subscribe-root]').forEach((root) => {
|
||||
const form = root.querySelector('[data-subscribe-form]');
|
||||
const status = root.querySelector('[data-subscribe-status]');
|
||||
const apiUrl = root.getAttribute('data-api-url');
|
||||
|
||||
if (!(form instanceof HTMLFormElement) || !(status instanceof HTMLElement) || !apiUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(form);
|
||||
const email = String(formData.get('email') || '').trim();
|
||||
const displayName = String(formData.get('displayName') || '').trim();
|
||||
|
||||
if (!email) {
|
||||
status.textContent = '请输入邮箱地址。';
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = '提交中...';
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
displayName,
|
||||
source: 'frontend-home',
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || payload?.description || '订阅失败,请稍后再试。');
|
||||
}
|
||||
|
||||
form.reset();
|
||||
status.textContent =
|
||||
payload?.message || '订阅申请已提交,请前往邮箱确认后生效。';
|
||||
} catch (error) {
|
||||
status.textContent = error instanceof Error ? error.message : '订阅失败,请稍后重试。';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.terminal-subscribe-card {
|
||||
margin-top: 1.5rem;
|
||||
border: 1px solid rgba(94, 234, 212, 0.16);
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 0.86), rgba(15, 23, 42, 0.72));
|
||||
border-radius: 1rem;
|
||||
padding: 1.1rem;
|
||||
}
|
||||
|
||||
.terminal-subscribe-kicker {
|
||||
margin: 0 0 0.35rem;
|
||||
color: var(--primary);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.22em;
|
||||
}
|
||||
|
||||
.terminal-subscribe-head h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.terminal-subscribe-head p:last-child {
|
||||
margin: 0.45rem 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.terminal-subscribe-form {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.terminal-subscribe-form input {
|
||||
width: 100%;
|
||||
border-radius: 0.8rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
color: var(--text-primary);
|
||||
padding: 0.85rem 0.95rem;
|
||||
}
|
||||
|
||||
.terminal-subscribe-form button {
|
||||
border: 0;
|
||||
border-radius: 0.8rem;
|
||||
padding: 0.9rem 1rem;
|
||||
font-weight: 600;
|
||||
color: #08111f;
|
||||
background: linear-gradient(135deg, var(--primary), #8b5cf6);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.terminal-subscribe-status {
|
||||
margin: 0.75rem 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.terminal-subscribe-form {
|
||||
grid-template-columns: minmax(180px, 0.8fr) minmax(220px, 1.2fr) auto;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -10,7 +10,7 @@ const { items } = Astro.props;
|
||||
|
||||
<ul class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<li class="group overflow-hidden rounded-2xl border border-[var(--border-color)] bg-[linear-gradient(180deg,rgba(255,255,255,0.88),rgba(var(--primary-rgb),0.08))] shadow-[0_12px_30px_rgba(37,99,235,0.08)] transition-transform duration-200 hover:-translate-y-0.5">
|
||||
<li class="group overflow-hidden rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] shadow-[0_12px_30px_rgba(37,99,235,0.08)] transition-transform duration-200 hover:-translate-y-0.5">
|
||||
<div class="flex items-start gap-3 px-4 py-4">
|
||||
<span class="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[var(--primary)] text-white shadow-[0_10px_24px_rgba(37,99,235,0.24)]">
|
||||
<i class="fas fa-code text-xs"></i>
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let showButton = false;
|
||||
const scrollThreshold = 300;
|
||||
|
||||
onMount(() => {
|
||||
const handleScroll = () => {
|
||||
showButton = window.scrollY > scrollThreshold;
|
||||
};
|
||||
function handleScroll() {
|
||||
showButton = (window.scrollY || document.documentElement.scrollTop) > scrollThreshold;
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
onMount(() => {
|
||||
handleScroll();
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
});
|
||||
|
||||
function scrollToTop() {
|
||||
@@ -22,17 +25,12 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showButton}
|
||||
<div
|
||||
class="fixed bottom-5 right-5 z-50"
|
||||
transition:fade={{ duration: 200 }}
|
||||
>
|
||||
<button
|
||||
on:click={scrollToTop}
|
||||
class="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-[var(--primary)] bg-[var(--primary-light)] text-[var(--primary)] hover:bg-[var(--primary)] hover:text-[var(--terminal-bg)] transition-all text-sm font-mono"
|
||||
>
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
<span>top</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
class={`fixed bottom-8 right-8 z-50 flex h-12 w-12 items-center justify-center rounded-full border border-[var(--border-color)] bg-[var(--header-bg)] text-[var(--text-secondary)] shadow-lg transition-all hover:border-[var(--primary)] hover:text-[var(--primary)] ${
|
||||
showButton ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-4 opacity-0'
|
||||
}`}
|
||||
aria-label="Back to top"
|
||||
on:click={scrollToTop}
|
||||
>
|
||||
<i class="fas fa-chevron-up"></i>
|
||||
</button>
|
||||
|
||||
@@ -1,60 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let isDark = false;
|
||||
type ThemeMode = 'light' | 'dark' | 'system';
|
||||
type ResolvedTheme = 'light' | 'dark';
|
||||
|
||||
interface ThemeChangeDetail {
|
||||
mode: ThemeMode;
|
||||
resolved: ResolvedTheme;
|
||||
}
|
||||
|
||||
interface ThemeLabels {
|
||||
toggle: string;
|
||||
system: string;
|
||||
light: string;
|
||||
dark: string;
|
||||
}
|
||||
|
||||
interface ThemeApi {
|
||||
getMode(): ThemeMode;
|
||||
resolveTheme(mode: ThemeMode): ResolvedTheme;
|
||||
applyTheme(mode: ThemeMode): ThemeChangeDetail;
|
||||
syncTheme(): ThemeChangeDetail;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__termiTheme?: ThemeApi;
|
||||
}
|
||||
}
|
||||
|
||||
export let labels: ThemeLabels;
|
||||
|
||||
let mode: ThemeMode = 'system';
|
||||
let resolved: ResolvedTheme = 'light';
|
||||
|
||||
const themeMeta: Record<ThemeMode, { iconClass: string; color: string }> = {
|
||||
light: {
|
||||
iconClass: 'fas fa-sun text-sm',
|
||||
color: 'var(--secondary)',
|
||||
},
|
||||
dark: {
|
||||
iconClass: 'fas fa-moon text-sm',
|
||||
color: 'var(--primary)',
|
||||
},
|
||||
system: {
|
||||
iconClass: 'fas fa-desktop text-sm',
|
||||
color: 'var(--text-secondary)',
|
||||
},
|
||||
};
|
||||
|
||||
function syncTheme(detail?: ThemeChangeDetail) {
|
||||
const themeApi = window.__termiTheme;
|
||||
if (!themeApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextState = detail ?? themeApi.syncTheme();
|
||||
mode = nextState.mode;
|
||||
resolved = nextState.resolved;
|
||||
}
|
||||
|
||||
function cycleTheme() {
|
||||
const themeApi = window.__termiTheme;
|
||||
if (!themeApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentMode = themeApi.getMode();
|
||||
const nextMode: ThemeMode =
|
||||
currentMode === 'system' ? 'light' : currentMode === 'light' ? 'dark' : 'system';
|
||||
|
||||
syncTheme(themeApi.applyTheme(nextMode));
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
console.log('[ThemeToggle] onMount');
|
||||
// Check for saved theme preference or system preference
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
console.log('[ThemeToggle] savedTheme:', savedTheme);
|
||||
syncTheme();
|
||||
|
||||
if (savedTheme) {
|
||||
isDark = savedTheme === 'dark';
|
||||
} else {
|
||||
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
console.log('[ThemeToggle] initial isDark:', isDark);
|
||||
updateTheme();
|
||||
|
||||
// Listen for system theme changes
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
isDark = e.matches;
|
||||
updateTheme();
|
||||
}
|
||||
const handleThemeChange = (event: Event) => {
|
||||
syncTheme((event as CustomEvent<ThemeChangeDetail>).detail);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handler);
|
||||
return () => mediaQuery.removeEventListener('change', handler);
|
||||
window.addEventListener('termi:theme-change', handleThemeChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('termi:theme-change', handleThemeChange);
|
||||
};
|
||||
});
|
||||
|
||||
function updateTheme() {
|
||||
const root = document.documentElement;
|
||||
if (isDark) {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
isDark = !isDark;
|
||||
updateTheme();
|
||||
}
|
||||
$: modeLabel =
|
||||
mode === 'light' ? labels.light : mode === 'dark' ? labels.dark : labels.system;
|
||||
$: resolvedLabel = resolved === 'dark' ? labels.dark : labels.light;
|
||||
$: iconClass = themeMeta[mode].iconClass;
|
||||
$: iconColor = themeMeta[mode].color;
|
||||
$: toggleTitle = `${labels.toggle} · ${modeLabel} / ${resolvedLabel}`;
|
||||
</script>
|
||||
|
||||
<button
|
||||
onclick={toggleTheme}
|
||||
class="theme-toggle p-2 rounded-lg border border-[var(--border-color)] hover:bg-[var(--header-bg)] transition-all"
|
||||
aria-label={isDark ? '切换到亮色模式' : '切换到暗色模式'}
|
||||
title={isDark ? '切换到亮色模式' : '切换到暗色模式'}
|
||||
class="theme-toggle terminal-toolbar-iconbtn h-8 w-8 shrink-0"
|
||||
aria-label={toggleTitle}
|
||||
title={toggleTitle}
|
||||
on:click|preventDefault={cycleTheme}
|
||||
>
|
||||
{#if isDark}
|
||||
<i class="fas fa-sun text-[var(--secondary)]"></i>
|
||||
{:else}
|
||||
<i class="fas fa-moon text-[var(--primary)]"></i>
|
||||
{/if}
|
||||
<i class={iconClass} style:color={iconColor}></i>
|
||||
<span class="sr-only">{modeLabel} / {resolvedLabel}</span>
|
||||
</button>
|
||||
|
||||
@@ -25,7 +25,7 @@ const uniqueId = Math.random().toString(36).slice(2, 11);
|
||||
<span class="separator">:</span>
|
||||
<span class="path">{path}</span>
|
||||
<span class="suffix">$</span>
|
||||
<span class="command-text ml-2" id={`cmd-${uniqueId}`}></span>
|
||||
<span class="command-text ml-2" id={`cmd-${uniqueId}`}>{command}</span>
|
||||
<span class="cursor" id={`cursor-${uniqueId}`}>_</span>
|
||||
</a>
|
||||
) : (
|
||||
@@ -34,76 +34,12 @@ const uniqueId = Math.random().toString(36).slice(2, 11);
|
||||
<span class="separator">:</span>
|
||||
<span class="path">{path}</span>
|
||||
<span class="suffix">$</span>
|
||||
<span class="command-text ml-2" id={`cmd-${uniqueId}`}></span>
|
||||
<span class="command-text ml-2" id={`cmd-${uniqueId}`}>{command}</span>
|
||||
<span class="cursor" id={`cursor-${uniqueId}`}>_</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
(function() {
|
||||
function renderPrompt(el, nextCommand, typingMode) {
|
||||
const id = el.getAttribute('data-id');
|
||||
const cmdEl = document.getElementById('cmd-' + id);
|
||||
const cursorEl = document.getElementById('cursor-' + id);
|
||||
|
||||
if (!cmdEl || !cursorEl) return;
|
||||
|
||||
const command = String(nextCommand || '');
|
||||
const typing = typingMode === true || typingMode === 'true';
|
||||
const renderSeq = String((Number(el.getAttribute('data-render-seq') || '0') || 0) + 1);
|
||||
|
||||
el.setAttribute('data-command', command);
|
||||
el.setAttribute('data-render-seq', renderSeq);
|
||||
cmdEl.textContent = '';
|
||||
cursorEl.style.animation = 'none';
|
||||
cursorEl.style.opacity = '1';
|
||||
|
||||
if (!typing) {
|
||||
cmdEl.textContent = command;
|
||||
cursorEl.style.animation = 'blink 1s infinite';
|
||||
return;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
|
||||
function typeChar() {
|
||||
if (el.getAttribute('data-render-seq') !== renderSeq) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (index < command.length) {
|
||||
cmdEl.textContent += command.charAt(index);
|
||||
index += 1;
|
||||
setTimeout(typeChar, 42 + Math.random() * 22);
|
||||
} else {
|
||||
cursorEl.style.animation = 'blink 1s infinite';
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(typeChar, 120);
|
||||
}
|
||||
|
||||
if (!window.__termiCommandPrompt) {
|
||||
window.__termiCommandPrompt = {
|
||||
set(promptId, command, options = {}) {
|
||||
if (!promptId) return;
|
||||
const el = document.querySelector(`[data-prompt-id="${promptId}"]`);
|
||||
if (!el) return;
|
||||
renderPrompt(el, command, options.typing ?? true);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const prompts = document.querySelectorAll('[data-command]:not([data-command-mounted])');
|
||||
|
||||
prompts.forEach(function(el) {
|
||||
el.setAttribute('data-command-mounted', 'true');
|
||||
renderPrompt(el, el.getAttribute('data-command') || '', el.getAttribute('data-typing') === 'true');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.command-prompt {
|
||||
display: flex;
|
||||
|
||||
92
frontend/src/components/ui/ResponsiveImage.astro
Normal file
92
frontend/src/components/ui/ResponsiveImage.astro
Normal file
@@ -0,0 +1,92 @@
|
||||
---
|
||||
import {
|
||||
buildOptimizedImageUrl,
|
||||
buildOptimizedSrcSet,
|
||||
canOptimizeImageSource,
|
||||
getFallbackImageFormat,
|
||||
getResponsiveWidths,
|
||||
} from '../../lib/image';
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
alt: string;
|
||||
widths?: number[];
|
||||
sizes?: string;
|
||||
pictureClass?: string;
|
||||
imgClass?: string;
|
||||
loading?: 'lazy' | 'eager';
|
||||
decoding?: 'async' | 'sync' | 'auto';
|
||||
fetchpriority?: 'high' | 'low' | 'auto';
|
||||
quality?: number;
|
||||
lightbox?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
src,
|
||||
alt,
|
||||
widths = [480, 768, 1024, 1440, 1920],
|
||||
sizes = '100vw',
|
||||
pictureClass = '',
|
||||
imgClass = '',
|
||||
loading = 'lazy',
|
||||
decoding = 'async',
|
||||
fetchpriority = 'auto',
|
||||
quality = 72,
|
||||
lightbox = false,
|
||||
} = Astro.props;
|
||||
|
||||
const resolvedSrc = String(src || '').trim();
|
||||
const normalizedWidths = getResponsiveWidths(widths);
|
||||
const allowedHosts =
|
||||
(import.meta.env.PUBLIC_IMAGE_ALLOWED_HOSTS as string | undefined) ||
|
||||
process.env.PUBLIC_IMAGE_ALLOWED_HOSTS ||
|
||||
'';
|
||||
const optimize =
|
||||
Boolean(resolvedSrc) &&
|
||||
canOptimizeImageSource(resolvedSrc, Astro.url.origin, allowedHosts);
|
||||
const fallbackFormat = getFallbackImageFormat(resolvedSrc);
|
||||
const fallbackWidth = normalizedWidths[normalizedWidths.length - 1] ?? 1440;
|
||||
const dataLightboxImage = lightbox ? 'true' : undefined;
|
||||
---
|
||||
|
||||
{resolvedSrc ? (
|
||||
optimize ? (
|
||||
<picture class={pictureClass}>
|
||||
<source
|
||||
type="image/avif"
|
||||
srcset={buildOptimizedSrcSet(resolvedSrc, normalizedWidths, 'avif', quality)}
|
||||
sizes={sizes}
|
||||
/>
|
||||
<source
|
||||
type="image/webp"
|
||||
srcset={buildOptimizedSrcSet(resolvedSrc, normalizedWidths, 'webp', quality)}
|
||||
sizes={sizes}
|
||||
/>
|
||||
<img
|
||||
src={buildOptimizedImageUrl(resolvedSrc, {
|
||||
width: fallbackWidth,
|
||||
format: fallbackFormat,
|
||||
quality,
|
||||
})}
|
||||
srcset={buildOptimizedSrcSet(resolvedSrc, normalizedWidths, fallbackFormat, quality)}
|
||||
sizes={sizes}
|
||||
alt={alt}
|
||||
loading={loading}
|
||||
decoding={decoding}
|
||||
fetchpriority={fetchpriority}
|
||||
data-lightbox-image={dataLightboxImage}
|
||||
class={imgClass}
|
||||
/>
|
||||
</picture>
|
||||
) : (
|
||||
<img
|
||||
src={resolvedSrc}
|
||||
alt={alt}
|
||||
loading={loading}
|
||||
decoding={decoding}
|
||||
fetchpriority={fetchpriority}
|
||||
data-lightbox-image={dataLightboxImage}
|
||||
class={imgClass}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
@@ -11,6 +11,12 @@ interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
siteSettings?: SiteSettings;
|
||||
canonical?: string;
|
||||
noindex?: boolean;
|
||||
ogImage?: string;
|
||||
ogType?: string;
|
||||
twitterCard?: 'summary' | 'summary_large_image';
|
||||
jsonLd?: Record<string, unknown> | Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
const props = Astro.props;
|
||||
@@ -28,6 +34,59 @@ if (!props.siteSettings) {
|
||||
|
||||
const title = props.title || siteSettings.siteTitle;
|
||||
const description = props.description || siteSettings.siteDescription;
|
||||
const siteUrl = siteSettings.siteUrl.replace(/\/$/, '');
|
||||
const defaultCanonical = `${siteUrl}${Astro.url.pathname}`;
|
||||
const canonical = props.canonical
|
||||
? /^https?:\/\//i.test(props.canonical)
|
||||
? props.canonical
|
||||
: `${siteUrl}${props.canonical.startsWith('/') ? props.canonical : `/${props.canonical}`}`
|
||||
: defaultCanonical;
|
||||
const resolvedOgImage = props.ogImage || siteSettings.seo.defaultOgImage;
|
||||
const ogImage = resolvedOgImage
|
||||
? /^https?:\/\//i.test(resolvedOgImage)
|
||||
? resolvedOgImage
|
||||
: `${siteUrl}${resolvedOgImage.startsWith('/') ? resolvedOgImage : `/${resolvedOgImage}`}`
|
||||
: undefined;
|
||||
const ogType = props.ogType || 'website';
|
||||
const twitterCard = props.twitterCard || (ogImage ? 'summary_large_image' : 'summary');
|
||||
const defaultJsonLdObjects = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: siteSettings.siteName,
|
||||
alternateName: siteSettings.siteShortName,
|
||||
url: siteUrl,
|
||||
description,
|
||||
inLanguage: locale,
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: `${siteUrl}/search?q={search_term_string}`,
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
name: siteSettings.ownerName,
|
||||
url: siteUrl,
|
||||
image: siteSettings.ownerAvatarUrl || undefined,
|
||||
jobTitle: siteSettings.ownerTitle,
|
||||
description: siteSettings.ownerBio || description,
|
||||
sameAs: [
|
||||
siteSettings.social.github,
|
||||
siteSettings.social.twitter,
|
||||
].filter(Boolean),
|
||||
},
|
||||
].filter((item) => item.name || item.url);
|
||||
const mergedJsonLdObjects = [
|
||||
...defaultJsonLdObjects,
|
||||
...(props.jsonLd === undefined
|
||||
? []
|
||||
: Array.isArray(props.jsonLd)
|
||||
? props.jsonLd
|
||||
: [props.jsonLd]),
|
||||
];
|
||||
const jsonLd = mergedJsonLdObjects.length ? JSON.stringify(mergedJsonLdObjects) : undefined;
|
||||
const i18nPayload = JSON.stringify({ locale, messages });
|
||||
---
|
||||
|
||||
@@ -37,8 +96,27 @@ const i18nPayload = JSON.stringify({ locale, messages });
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="robots" content={props.noindex ? 'noindex, nofollow' : 'index, follow'} />
|
||||
<link rel="canonical" href={canonical} />
|
||||
<meta property="og:site_name" content={siteSettings.siteName} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:type" content={ogType} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta name="twitter:card" content={twitterCard} />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
{siteSettings.seo.defaultTwitterHandle && (
|
||||
<meta name="twitter:site" content={siteSettings.seo.defaultTwitterHandle} />
|
||||
)}
|
||||
{siteSettings.seo.defaultTwitterHandle && (
|
||||
<meta name="twitter:creator" content={siteSettings.seo.defaultTwitterHandle} />
|
||||
)}
|
||||
{ogImage && <meta property="og:image" content={ogImage} />}
|
||||
{ogImage && <meta name="twitter:image" content={ogImage} />}
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>{title}</title>
|
||||
{jsonLd && <script type="application/ld+json" set:html={jsonLd}></script>}
|
||||
|
||||
<style is:inline>
|
||||
:root {
|
||||
@@ -57,7 +135,7 @@ const i18nPayload = JSON.stringify({ locale, messages });
|
||||
--text: #0f172a;
|
||||
--text-rgb: 15 23 42;
|
||||
--text-secondary: #475569;
|
||||
--text-tertiary: #7c8aa0;
|
||||
--text-tertiary: #5f6f86;
|
||||
--terminal-text: #0f172a;
|
||||
--title-color: #0f172a;
|
||||
--button-text: #0f172a;
|
||||
@@ -103,7 +181,7 @@ const i18nPayload = JSON.stringify({ locale, messages });
|
||||
--text: #e6e6e6;
|
||||
--text-rgb: 230 230 230;
|
||||
--text-secondary: #d1d5db;
|
||||
--text-tertiary: #6b7280;
|
||||
--text-tertiary: #9ca3af;
|
||||
--terminal-text: #e6e6e6;
|
||||
--title-color: #ffffff;
|
||||
--button-text: #e6e6e6;
|
||||
@@ -141,7 +219,7 @@ const i18nPayload = JSON.stringify({ locale, messages });
|
||||
--text: #e6e6e6;
|
||||
--text-rgb: 230 230 230;
|
||||
--text-secondary: #d1d5db;
|
||||
--text-tertiary: #6b7280;
|
||||
--text-tertiary: #9ca3af;
|
||||
--terminal-text: #e6e6e6;
|
||||
--title-color: #ffffff;
|
||||
--button-text: #e6e6e6;
|
||||
@@ -195,6 +273,78 @@ const i18nPayload = JSON.stringify({ locale, messages });
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script is:inline>
|
||||
(function() {
|
||||
function renderPrompt(el, nextCommand, typingMode) {
|
||||
const id = el.getAttribute('data-id');
|
||||
const cmdEl = document.getElementById('cmd-' + id);
|
||||
const cursorEl = document.getElementById('cursor-' + id);
|
||||
|
||||
if (!cmdEl || !cursorEl) return;
|
||||
|
||||
const command = String(nextCommand || '');
|
||||
const typing = typingMode === true || typingMode === 'true';
|
||||
const renderSeq = String((Number(el.getAttribute('data-render-seq') || '0') || 0) + 1);
|
||||
|
||||
el.setAttribute('data-command', command);
|
||||
el.setAttribute('data-render-seq', renderSeq);
|
||||
cmdEl.textContent = '';
|
||||
cursorEl.style.animation = 'none';
|
||||
cursorEl.style.opacity = '1';
|
||||
|
||||
if (!typing) {
|
||||
cmdEl.textContent = command;
|
||||
cursorEl.style.animation = 'blink 1s infinite';
|
||||
return;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
|
||||
function typeChar() {
|
||||
if (el.getAttribute('data-render-seq') !== renderSeq) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (index < command.length) {
|
||||
cmdEl.textContent += command.charAt(index);
|
||||
index += 1;
|
||||
setTimeout(typeChar, 42 + Math.random() * 22);
|
||||
} else {
|
||||
cursorEl.style.animation = 'blink 1s infinite';
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(typeChar, 120);
|
||||
}
|
||||
|
||||
if (!window.__termiCommandPrompt) {
|
||||
window.__termiCommandPrompt = {
|
||||
set(promptId, command, options = {}) {
|
||||
if (!promptId) return;
|
||||
const el = document.querySelector(`[data-prompt-id="${promptId}"]`);
|
||||
if (!el) return;
|
||||
renderPrompt(el, command, options.typing ?? true);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function mountPrompts() {
|
||||
const prompts = document.querySelectorAll('[data-command]:not([data-command-mounted])');
|
||||
|
||||
prompts.forEach(function(el) {
|
||||
el.setAttribute('data-command-mounted', 'true');
|
||||
renderPrompt(el, el.getAttribute('data-command') || '', el.getAttribute('data-typing') === 'true');
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', mountPrompts, { once: true });
|
||||
} else {
|
||||
mountPrompts();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script is:inline>
|
||||
(function() {
|
||||
const STORAGE_KEY = 'theme';
|
||||
@@ -325,7 +475,7 @@ const i18nPayload = JSON.stringify({ locale, messages });
|
||||
</main>
|
||||
|
||||
<Footer siteSettings={siteSettings} />
|
||||
<BackToTop client:idle />
|
||||
<BackToTop client:load />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,19 +1,69 @@
|
||||
import type {
|
||||
Category as UiCategory,
|
||||
ContentOverview,
|
||||
ContentWindowHighlight,
|
||||
FriendLink as UiFriendLink,
|
||||
Post as UiPost,
|
||||
PopularPostHighlight,
|
||||
SiteSettings,
|
||||
Tag as UiTag,
|
||||
} from '../types';
|
||||
|
||||
const envApiBaseUrl = import.meta.env.PUBLIC_API_BASE_URL?.trim();
|
||||
const DEV_API_BASE_URL = 'http://127.0.0.1:5150/api';
|
||||
const PROD_DEFAULT_API_PORT = '5150';
|
||||
|
||||
export const API_BASE_URL =
|
||||
envApiBaseUrl && envApiBaseUrl.length > 0
|
||||
? envApiBaseUrl.replace(/\/$/, '')
|
||||
: import.meta.env.DEV
|
||||
? 'http://127.0.0.1:5150/api'
|
||||
: 'https://init.cool/api';
|
||||
function normalizeApiBaseUrl(value?: string | null) {
|
||||
return value?.trim().replace(/\/$/, '') ?? '';
|
||||
}
|
||||
|
||||
function getRuntimeEnv(name: 'PUBLIC_API_BASE_URL' | 'INTERNAL_API_BASE_URL') {
|
||||
const runtimeProcess = (globalThis as typeof globalThis & {
|
||||
process?: {
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
}).process;
|
||||
|
||||
return normalizeApiBaseUrl(runtimeProcess?.env?.[name]);
|
||||
}
|
||||
|
||||
function toUrlLike(value: string | URL) {
|
||||
return value instanceof URL ? value : new URL(value);
|
||||
}
|
||||
|
||||
const buildTimePublicApiBaseUrl = normalizeApiBaseUrl(import.meta.env.PUBLIC_API_BASE_URL);
|
||||
|
||||
export function resolvePublicApiBaseUrl(requestUrl?: string | URL) {
|
||||
const runtimePublicApiBaseUrl = getRuntimeEnv('PUBLIC_API_BASE_URL');
|
||||
if (runtimePublicApiBaseUrl) {
|
||||
return runtimePublicApiBaseUrl;
|
||||
}
|
||||
|
||||
if (buildTimePublicApiBaseUrl) {
|
||||
return buildTimePublicApiBaseUrl;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
return DEV_API_BASE_URL;
|
||||
}
|
||||
|
||||
if (requestUrl) {
|
||||
const { protocol, hostname } = toUrlLike(requestUrl);
|
||||
return `${protocol}//${hostname}:${PROD_DEFAULT_API_PORT}/api`;
|
||||
}
|
||||
|
||||
return DEV_API_BASE_URL;
|
||||
}
|
||||
|
||||
export function resolveInternalApiBaseUrl(requestUrl?: string | URL) {
|
||||
const runtimeInternalApiBaseUrl = getRuntimeEnv('INTERNAL_API_BASE_URL');
|
||||
if (runtimeInternalApiBaseUrl) {
|
||||
return runtimeInternalApiBaseUrl;
|
||||
}
|
||||
|
||||
return resolvePublicApiBaseUrl(requestUrl);
|
||||
}
|
||||
|
||||
export const API_BASE_URL = resolvePublicApiBaseUrl();
|
||||
|
||||
export interface ApiPost {
|
||||
id: number;
|
||||
@@ -27,6 +77,15 @@ export interface ApiPost {
|
||||
image: string | null;
|
||||
images: string[] | null;
|
||||
pinned: boolean;
|
||||
status: string | null;
|
||||
visibility: 'public' | 'unlisted' | 'private' | null;
|
||||
publish_at: string | null;
|
||||
unpublish_at: string | null;
|
||||
canonical_url: string | null;
|
||||
noindex: boolean | null;
|
||||
og_image: string | null;
|
||||
redirect_from: string[] | null;
|
||||
redirect_to: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -38,6 +97,9 @@ export interface Comment {
|
||||
author: string | null;
|
||||
email: string | null;
|
||||
avatar: string | null;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
referer: string | null;
|
||||
content: string | null;
|
||||
reply_to: string | null;
|
||||
reply_to_comment_id: number | null;
|
||||
@@ -59,6 +121,15 @@ export interface CreateCommentInput {
|
||||
paragraphExcerpt?: string;
|
||||
replyTo?: string | null;
|
||||
replyToCommentId?: number | null;
|
||||
captchaToken?: string;
|
||||
captchaAnswer?: string;
|
||||
website?: string;
|
||||
}
|
||||
|
||||
export interface CommentCaptchaChallenge {
|
||||
token: string;
|
||||
question: string;
|
||||
expires_in_seconds: number;
|
||||
}
|
||||
|
||||
export interface ParagraphCommentSummary {
|
||||
@@ -101,6 +172,35 @@ export interface CreateFriendLinkInput {
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface PublicSubscriptionResponse {
|
||||
ok: boolean;
|
||||
subscription_id: number;
|
||||
status: string;
|
||||
requires_confirmation: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PublicManagedSubscription {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
id: number;
|
||||
channel_type: string;
|
||||
target: string;
|
||||
display_name: string | null;
|
||||
status: string;
|
||||
filters: Record<string, unknown> | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
verified_at: string | null;
|
||||
last_notified_at: string | null;
|
||||
last_delivery_status: string | null;
|
||||
manage_token: string | null;
|
||||
}
|
||||
|
||||
export interface PublicSubscriptionManageResponse {
|
||||
ok: boolean;
|
||||
subscription: PublicManagedSubscription;
|
||||
}
|
||||
|
||||
export interface ApiSiteSettings {
|
||||
id: number;
|
||||
site_name: string | null;
|
||||
@@ -130,6 +230,19 @@ export interface ApiSiteSettings {
|
||||
}> | null;
|
||||
ai_enabled: boolean;
|
||||
paragraph_comments_enabled: boolean;
|
||||
seo_default_og_image: string | null;
|
||||
seo_default_twitter_handle: string | null;
|
||||
}
|
||||
|
||||
export interface ContentAnalyticsInput {
|
||||
eventType: 'page_view' | 'read_progress' | 'read_complete';
|
||||
path: string;
|
||||
postSlug?: string;
|
||||
sessionId?: string;
|
||||
durationMs?: number;
|
||||
progressPercent?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
referrer?: string;
|
||||
}
|
||||
|
||||
export interface ApiHomePagePayload {
|
||||
@@ -138,6 +251,42 @@ export interface ApiHomePagePayload {
|
||||
tags: ApiTag[];
|
||||
friend_links: ApiFriendLink[];
|
||||
categories: ApiCategory[];
|
||||
content_overview?: {
|
||||
total_page_views: number;
|
||||
page_views_last_24h: number;
|
||||
page_views_last_7d: number;
|
||||
total_read_completes: number;
|
||||
read_completes_last_7d: number;
|
||||
avg_read_progress_last_7d: number;
|
||||
avg_read_duration_ms_last_7d: number | null;
|
||||
};
|
||||
popular_posts?: Array<{
|
||||
slug: string;
|
||||
title: string;
|
||||
page_views: number;
|
||||
read_completes: number;
|
||||
avg_progress_percent: number;
|
||||
avg_duration_ms: number | null;
|
||||
}>;
|
||||
content_ranges?: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
days: number;
|
||||
overview: {
|
||||
page_views: number;
|
||||
read_completes: number;
|
||||
avg_read_progress: number;
|
||||
avg_read_duration_ms: number | null;
|
||||
};
|
||||
popular_posts: Array<{
|
||||
slug: string;
|
||||
title: string;
|
||||
page_views: number;
|
||||
read_completes: number;
|
||||
avg_progress_percent: number;
|
||||
avg_duration_ms: number | null;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface AiSource {
|
||||
@@ -249,6 +398,10 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
|
||||
comments: {
|
||||
paragraphsEnabled: true,
|
||||
},
|
||||
seo: {
|
||||
defaultOgImage: undefined,
|
||||
defaultTwitterHandle: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const formatPostDate = (dateString: string) => dateString.slice(0, 10);
|
||||
@@ -273,6 +426,15 @@ const normalizePost = (post: ApiPost): UiPost => ({
|
||||
image: post.image ?? undefined,
|
||||
images: post.images ?? undefined,
|
||||
pinned: post.pinned,
|
||||
status: post.status ?? undefined,
|
||||
visibility: post.visibility ?? undefined,
|
||||
publishAt: post.publish_at ?? undefined,
|
||||
unpublishAt: post.unpublish_at ?? undefined,
|
||||
canonicalUrl: post.canonical_url ?? undefined,
|
||||
noindex: post.noindex ?? undefined,
|
||||
ogImage: post.og_image ?? undefined,
|
||||
redirectFrom: post.redirect_from ?? undefined,
|
||||
redirectTo: post.redirect_to ?? undefined,
|
||||
});
|
||||
|
||||
const normalizeTag = (tag: ApiTag): UiTag => ({
|
||||
@@ -361,8 +523,127 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
|
||||
comments: {
|
||||
paragraphsEnabled: settings.paragraph_comments_enabled ?? true,
|
||||
},
|
||||
seo: {
|
||||
defaultOgImage: settings.seo_default_og_image ?? undefined,
|
||||
defaultTwitterHandle: settings.seo_default_twitter_handle ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const normalizeContentOverview = (
|
||||
overview: ApiHomePagePayload['content_overview'] | undefined,
|
||||
): ContentOverview => ({
|
||||
totalPageViews: overview?.total_page_views ?? 0,
|
||||
pageViewsLast24h: overview?.page_views_last_24h ?? 0,
|
||||
pageViewsLast7d: overview?.page_views_last_7d ?? 0,
|
||||
totalReadCompletes: overview?.total_read_completes ?? 0,
|
||||
readCompletesLast7d: overview?.read_completes_last_7d ?? 0,
|
||||
avgReadProgressLast7d: overview?.avg_read_progress_last_7d ?? 0,
|
||||
avgReadDurationMsLast7d: overview?.avg_read_duration_ms_last_7d ?? undefined,
|
||||
});
|
||||
|
||||
const CONTENT_WINDOW_META = [
|
||||
{ key: '24h', label: '24h', days: 1 },
|
||||
{ key: '7d', label: '7d', days: 7 },
|
||||
{ key: '30d', label: '30d', days: 30 },
|
||||
] as const;
|
||||
|
||||
const normalizePopularPost = (
|
||||
item: {
|
||||
slug: string;
|
||||
title: string;
|
||||
page_views: number;
|
||||
read_completes: number;
|
||||
avg_progress_percent: number;
|
||||
avg_duration_ms: number | null;
|
||||
},
|
||||
postsBySlug: Map<string, UiPost>,
|
||||
): PopularPostHighlight => ({
|
||||
slug: item.slug,
|
||||
title: item.title,
|
||||
pageViews: item.page_views,
|
||||
readCompletes: item.read_completes,
|
||||
avgProgressPercent: item.avg_progress_percent,
|
||||
avgDurationMs: item.avg_duration_ms ?? undefined,
|
||||
post: postsBySlug.get(item.slug),
|
||||
});
|
||||
|
||||
const normalizeContentRanges = (
|
||||
ranges: ApiHomePagePayload['content_ranges'] | undefined,
|
||||
overview: ApiHomePagePayload['content_overview'] | undefined,
|
||||
popularPosts: ApiHomePagePayload['popular_posts'] | undefined,
|
||||
postsBySlug: Map<string, UiPost>,
|
||||
): ContentWindowHighlight[] => {
|
||||
const normalizedRanges = new Map(
|
||||
(ranges ?? []).map((item) => [
|
||||
item.key,
|
||||
{
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
days: item.days,
|
||||
overview: {
|
||||
pageViews: item.overview?.page_views ?? 0,
|
||||
readCompletes: item.overview?.read_completes ?? 0,
|
||||
avgReadProgress: item.overview?.avg_read_progress ?? 0,
|
||||
avgReadDurationMs: item.overview?.avg_read_duration_ms ?? undefined,
|
||||
},
|
||||
popularPosts: (item.popular_posts ?? []).map((popularItem) =>
|
||||
normalizePopularPost(popularItem, postsBySlug),
|
||||
),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
return CONTENT_WINDOW_META.map((meta) => {
|
||||
const existing = normalizedRanges.get(meta.key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (meta.key === '7d') {
|
||||
return {
|
||||
key: meta.key,
|
||||
label: meta.label,
|
||||
days: meta.days,
|
||||
overview: {
|
||||
pageViews: overview?.page_views_last_7d ?? 0,
|
||||
readCompletes: overview?.read_completes_last_7d ?? 0,
|
||||
avgReadProgress: overview?.avg_read_progress_last_7d ?? 0,
|
||||
avgReadDurationMs: overview?.avg_read_duration_ms_last_7d ?? undefined,
|
||||
},
|
||||
popularPosts: (popularPosts ?? []).map((item) => normalizePopularPost(item, postsBySlug)),
|
||||
};
|
||||
}
|
||||
|
||||
if (meta.key === '24h') {
|
||||
return {
|
||||
key: meta.key,
|
||||
label: meta.label,
|
||||
days: meta.days,
|
||||
overview: {
|
||||
pageViews: overview?.page_views_last_24h ?? 0,
|
||||
readCompletes: 0,
|
||||
avgReadProgress: 0,
|
||||
avgReadDurationMs: undefined,
|
||||
},
|
||||
popularPosts: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: meta.key,
|
||||
label: meta.label,
|
||||
days: meta.days,
|
||||
overview: {
|
||||
pageViews: 0,
|
||||
readCompletes: 0,
|
||||
avgReadProgress: 0,
|
||||
avgReadDurationMs: undefined,
|
||||
},
|
||||
popularPosts: [],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
|
||||
@@ -424,6 +705,22 @@ class ApiClient {
|
||||
return normalizePost((await response.json()) as ApiPost);
|
||||
}
|
||||
|
||||
async recordContentEvent(input: ContentAnalyticsInput): Promise<void> {
|
||||
await this.fetch<{ recorded: boolean }>('/analytics/content', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
event_type: input.eventType,
|
||||
path: input.path,
|
||||
post_slug: input.postSlug,
|
||||
session_id: input.sessionId,
|
||||
duration_ms: input.durationMs,
|
||||
progress_percent: input.progressPercent,
|
||||
metadata: input.metadata,
|
||||
referrer: input.referrer,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async getComments(
|
||||
postSlug: string,
|
||||
options?: {
|
||||
@@ -463,10 +760,17 @@ class ApiClient {
|
||||
paragraphExcerpt: comment.paragraphExcerpt,
|
||||
replyTo: comment.replyTo,
|
||||
replyToCommentId: comment.replyToCommentId,
|
||||
captchaToken: comment.captchaToken,
|
||||
captchaAnswer: comment.captchaAnswer,
|
||||
website: comment.website,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async getCommentCaptcha(): Promise<CommentCaptchaChallenge> {
|
||||
return this.fetch<CommentCaptchaChallenge>('/comments/captcha');
|
||||
}
|
||||
|
||||
async getReviews(): Promise<Review[]> {
|
||||
return this.fetch<Review[]>('/reviews');
|
||||
}
|
||||
@@ -491,6 +795,54 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async subscribe(input: { email: string; displayName?: string; source?: string }): Promise<PublicSubscriptionResponse> {
|
||||
return this.fetch<PublicSubscriptionResponse>('/subscriptions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: input.email,
|
||||
displayName: input.displayName,
|
||||
source: input.source,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async confirmSubscription(token: string): Promise<PublicSubscriptionManageResponse> {
|
||||
return this.fetch<PublicSubscriptionManageResponse>('/subscriptions/confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
}
|
||||
|
||||
async getManagedSubscription(token: string): Promise<PublicSubscriptionManageResponse> {
|
||||
return this.fetch<PublicSubscriptionManageResponse>(
|
||||
`/subscriptions/manage?token=${encodeURIComponent(token)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async updateManagedSubscription(input: {
|
||||
token: string;
|
||||
displayName?: string | null;
|
||||
status?: string | null;
|
||||
filters?: Record<string, unknown> | null;
|
||||
}): Promise<PublicSubscriptionManageResponse> {
|
||||
return this.fetch<PublicSubscriptionManageResponse>('/subscriptions/manage', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
token: input.token,
|
||||
displayName: input.displayName,
|
||||
status: input.status,
|
||||
filters: input.filters,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async unsubscribeSubscription(token: string): Promise<PublicSubscriptionManageResponse> {
|
||||
return this.fetch<PublicSubscriptionManageResponse>('/subscriptions/unsubscribe', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
}
|
||||
|
||||
async getRawTags(): Promise<ApiTag[]> {
|
||||
return this.fetch<ApiTag[]>('/tags');
|
||||
}
|
||||
@@ -515,15 +867,31 @@ class ApiClient {
|
||||
tags: UiTag[];
|
||||
friendLinks: AppFriendLink[];
|
||||
categories: UiCategory[];
|
||||
contentOverview: ContentOverview;
|
||||
contentRanges: ContentWindowHighlight[];
|
||||
popularPosts: PopularPostHighlight[];
|
||||
}> {
|
||||
const payload = await this.fetch<ApiHomePagePayload>('/site_settings/home');
|
||||
const posts = (payload.posts ?? []).map(normalizePost);
|
||||
const postsBySlug = new Map(posts.map((post) => [post.slug, post]));
|
||||
const popularPosts = (payload.popular_posts ?? []).map((item) =>
|
||||
normalizePopularPost(item, postsBySlug),
|
||||
);
|
||||
|
||||
return {
|
||||
siteSettings: normalizeSiteSettings(payload.site_settings),
|
||||
posts: payload.posts.map(normalizePost),
|
||||
tags: payload.tags.map(normalizeTag),
|
||||
friendLinks: payload.friend_links.map(normalizeFriendLink),
|
||||
categories: payload.categories.map(normalizeCategory),
|
||||
posts,
|
||||
tags: (payload.tags ?? []).map(normalizeTag),
|
||||
friendLinks: (payload.friend_links ?? []).map(normalizeFriendLink),
|
||||
categories: (payload.categories ?? []).map(normalizeCategory),
|
||||
contentOverview: normalizeContentOverview(payload.content_overview),
|
||||
contentRanges: normalizeContentRanges(
|
||||
payload.content_ranges,
|
||||
payload.content_overview,
|
||||
payload.popular_posts,
|
||||
postsBySlug,
|
||||
),
|
||||
popularPosts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -579,5 +947,9 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient(API_BASE_URL);
|
||||
export function createApiClient(options?: { baseUrl?: string; requestUrl?: string | URL }) {
|
||||
return new ApiClient(options?.baseUrl ?? resolveInternalApiBaseUrl(options?.requestUrl));
|
||||
}
|
||||
|
||||
export const api = createApiClient();
|
||||
export const apiClient = api;
|
||||
|
||||
@@ -127,12 +127,30 @@ export const messages = {
|
||||
about: '关于我',
|
||||
techStack: '技术栈',
|
||||
systemStatus: '系统状态',
|
||||
hotNow: '最近热门内容',
|
||||
hotNowDescription: '基于 24h / 7d / 30d 的页面访问与阅读完成信号生成,可切换窗口查看最有反馈的内容。',
|
||||
hotNowEmpty: '当前筛选条件下还没有热门内容信号。',
|
||||
sortByViews: '最多浏览',
|
||||
sortByCompletes: '完读最多',
|
||||
sortByDepth: '阅读最深',
|
||||
readingSignals: '阅读信号',
|
||||
readingSignalsDescription: '以下统计面板会跟随所选窗口切换,展示全站内容消费表现。',
|
||||
views: '浏览',
|
||||
completes: '完读',
|
||||
avgProgress: '平均进度',
|
||||
avgDuration: '平均时长',
|
||||
totalViews: '累计浏览',
|
||||
totalCompletes: '累计完读',
|
||||
statsWindow: '统计窗口:最近 7 天',
|
||||
statsWindowLabel: '统计窗口:{label}',
|
||||
promptWelcome: 'pwd',
|
||||
promptDiscoverDefault: "find ./posts -type f | sort",
|
||||
promptDiscoverFiltered: 'grep -Ril "{filters}" ./posts',
|
||||
promptPinned: 'grep -Ril "^pinned: true$" ./posts',
|
||||
promptPostsDefault: "find ./posts -type f | head -n {count}",
|
||||
promptPostsFiltered: 'grep -Ril "{filters}" ./posts | head -n {count}',
|
||||
promptPopular: "awk 'NR<=6 {print}' ./analytics/popular-posts.log",
|
||||
promptPopularRange: "awk 'NR<=6 {print}' ./analytics/popular-posts.log --window={label}",
|
||||
promptFriends: "find ./links -maxdepth 1 -type f | sort",
|
||||
promptAbout: "sed -n '1,80p' ~/profile.md",
|
||||
},
|
||||
@@ -148,6 +166,25 @@ export const messages = {
|
||||
previous: '上一页',
|
||||
next: '下一页',
|
||||
},
|
||||
searchPage: {
|
||||
pageTitle: '站内搜索',
|
||||
title: '搜索结果',
|
||||
intro: '独立搜索页会保留搜索词,并支持按类型、分类、标签继续缩小范围。',
|
||||
promptIdle: "printf 'search query required\\n'",
|
||||
promptQuery: 'grep -Rin "{query}" ./posts',
|
||||
queryLabel: '当前查询',
|
||||
searchTips: '搜索会优先走站内索引,并自动复用同义词与轻量拼写纠错。',
|
||||
resultSummary: '找到 {count} 条结果',
|
||||
filteredSummary: '筛选后剩余 {count} 条结果',
|
||||
filtersTitle: '二次筛选',
|
||||
allCategories: '全部分类',
|
||||
allTags: '全部标签',
|
||||
emptyQueryTitle: '先输入关键词',
|
||||
emptyQueryDescription: '可以直接使用顶部搜索框,或在 URL 中传入 ?q= 进入搜索结果页。',
|
||||
emptyTitle: '没有匹配结果',
|
||||
emptyDescription: '可以切换分类 / 标签,或换一个关键词重新搜索。',
|
||||
askFallback: '改去 AI 问答',
|
||||
},
|
||||
article: {
|
||||
backToArticles: '返回文章索引',
|
||||
documentSession: '文档会话',
|
||||
@@ -158,6 +195,9 @@ export const messages = {
|
||||
title: '相关文章',
|
||||
description: '基于当前分类与标签关联出的相近内容,延续同一条阅读链路。',
|
||||
linked: '{count} 条关联',
|
||||
hotKicker: '热门延伸',
|
||||
hotTitle: '同类热门文章',
|
||||
hotDescription: '结合最近 7 天的访问与阅读数据,优先推荐同分类或共享标签的高反馈内容。',
|
||||
},
|
||||
comments: {
|
||||
title: '评论终端',
|
||||
@@ -528,12 +568,30 @@ export const messages = {
|
||||
about: 'About',
|
||||
techStack: 'Tech stack',
|
||||
systemStatus: 'System status',
|
||||
hotNow: 'Hot now',
|
||||
hotNowDescription: 'Generated from 24h / 7d / 30d page-view and completion signals so visitors can switch windows and spot the strongest feedback loops quickly.',
|
||||
hotNowEmpty: 'No hot content matched the current filters yet.',
|
||||
sortByViews: 'Most viewed',
|
||||
sortByCompletes: 'Most completed',
|
||||
sortByDepth: 'Deepest reads',
|
||||
readingSignals: 'Reading signals',
|
||||
readingSignalsDescription: 'These stats switch with the selected window to summarize site-wide content consumption.',
|
||||
views: 'Views',
|
||||
completes: 'Completes',
|
||||
avgProgress: 'Avg progress',
|
||||
avgDuration: 'Avg duration',
|
||||
totalViews: 'Total views',
|
||||
totalCompletes: 'Total completes',
|
||||
statsWindow: 'Window: last 7 days',
|
||||
statsWindowLabel: 'Window: {label}',
|
||||
promptWelcome: 'pwd',
|
||||
promptDiscoverDefault: "find ./posts -type f | sort",
|
||||
promptDiscoverFiltered: 'grep -Ril "{filters}" ./posts',
|
||||
promptPinned: 'grep -Ril "^pinned: true$" ./posts',
|
||||
promptPostsDefault: "find ./posts -type f | head -n {count}",
|
||||
promptPostsFiltered: 'grep -Ril "{filters}" ./posts | head -n {count}',
|
||||
promptPopular: "awk 'NR<=6 {print}' ./analytics/popular-posts.log",
|
||||
promptPopularRange: "awk 'NR<=6 {print}' ./analytics/popular-posts.log --window={label}",
|
||||
promptFriends: "find ./links -maxdepth 1 -type f | sort",
|
||||
promptAbout: "sed -n '1,80p' ~/profile.md",
|
||||
},
|
||||
@@ -549,6 +607,25 @@ export const messages = {
|
||||
previous: 'Prev',
|
||||
next: 'Next',
|
||||
},
|
||||
searchPage: {
|
||||
pageTitle: 'Site search',
|
||||
title: 'Search results',
|
||||
intro: 'The dedicated search page keeps the query visible and lets visitors narrow results by type, category, and tag.',
|
||||
promptIdle: "printf 'search query required\\n'",
|
||||
promptQuery: 'grep -Rin "{query}" ./posts',
|
||||
queryLabel: 'Current query',
|
||||
searchTips: 'Search uses the site index first and also applies synonyms plus lightweight typo correction automatically.',
|
||||
resultSummary: 'Found {count} results',
|
||||
filteredSummary: '{count} results after filters',
|
||||
filtersTitle: 'Refine results',
|
||||
allCategories: 'All categories',
|
||||
allTags: 'All tags',
|
||||
emptyQueryTitle: 'Enter a keyword first',
|
||||
emptyQueryDescription: 'Use the header search box or open this page with a ?q= query string.',
|
||||
emptyTitle: 'No matching results',
|
||||
emptyDescription: 'Try switching categories / tags or search again with another keyword.',
|
||||
askFallback: 'Ask the AI instead',
|
||||
},
|
||||
article: {
|
||||
backToArticles: 'Back to article index',
|
||||
documentSession: 'Document session',
|
||||
@@ -559,6 +636,9 @@ export const messages = {
|
||||
title: 'Related Posts',
|
||||
description: 'More nearby reading paths based on the current category and shared tags.',
|
||||
linked: '{count} linked',
|
||||
hotKicker: 'Hot follow-up',
|
||||
hotTitle: 'Popular related posts',
|
||||
hotDescription: 'Uses the last 7 days of visit and reading signals to recommend the strongest-performing posts from the same category or shared tags.',
|
||||
},
|
||||
comments: {
|
||||
title: 'Comment Terminal',
|
||||
|
||||
135
frontend/src/lib/image.ts
Normal file
135
frontend/src/lib/image.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
const OPTIMIZED_IMAGE_ENDPOINT = '/_img';
|
||||
|
||||
function trimToList(value: string | undefined | null) {
|
||||
return String(value || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeSrc(value: string) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function getPathname(value: string) {
|
||||
try {
|
||||
return new URL(value, 'http://localhost').pathname;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function getImageExtension(src: string) {
|
||||
const pathname = getPathname(normalizeSrc(src)).toLowerCase();
|
||||
const matched = pathname.match(/\.([a-z0-9]+)$/i);
|
||||
return matched?.[1] ?? '';
|
||||
}
|
||||
|
||||
export function shouldOptimizeImage(src: string) {
|
||||
const normalized = normalizeSrc(src);
|
||||
if (!normalized || normalized.startsWith('data:') || normalized.startsWith('blob:')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !['svg', 'gif'].includes(getImageExtension(normalized));
|
||||
}
|
||||
|
||||
export function isAllowedRemoteImageHost(
|
||||
src: string,
|
||||
currentOrigin: string,
|
||||
envHosts?: string,
|
||||
) {
|
||||
try {
|
||||
const sourceUrl = new URL(normalizeSrc(src), currentOrigin);
|
||||
const currentHost = new URL(currentOrigin).host.toLowerCase();
|
||||
const allowedHosts = new Set([currentHost, ...trimToList(envHosts)]);
|
||||
return allowedHosts.has(sourceUrl.host.toLowerCase()) || allowedHosts.has(sourceUrl.hostname.toLowerCase());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function canOptimizeImageSource(
|
||||
src: string,
|
||||
currentOrigin: string,
|
||||
envHosts?: string,
|
||||
) {
|
||||
const normalized = normalizeSrc(src);
|
||||
if (!shouldOptimizeImage(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized.startsWith('/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceUrl = new URL(normalized);
|
||||
return isAllowedRemoteImageHost(sourceUrl.toString(), currentOrigin, envHosts);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getFallbackImageFormat(src: string): 'jpeg' | 'png' {
|
||||
return getImageExtension(src) === 'png' ? 'png' : 'jpeg';
|
||||
}
|
||||
|
||||
export function getResponsiveWidths(widths?: number[]) {
|
||||
const defaults = [480, 768, 1024, 1440, 1920];
|
||||
const source = Array.isArray(widths) && widths.length ? widths : defaults;
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
source
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.map((value) => Math.round(value)),
|
||||
),
|
||||
).sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
export function buildOptimizedImageUrl(
|
||||
src: string,
|
||||
options?: {
|
||||
width?: number;
|
||||
format?: 'avif' | 'webp' | 'jpeg' | 'png';
|
||||
quality?: number;
|
||||
},
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
src: normalizeSrc(src),
|
||||
});
|
||||
|
||||
if (options?.width) {
|
||||
params.set('w', String(Math.round(options.width)));
|
||||
}
|
||||
|
||||
if (options?.format) {
|
||||
params.set('f', options.format);
|
||||
}
|
||||
|
||||
if (options?.quality) {
|
||||
params.set('q', String(Math.round(options.quality)));
|
||||
}
|
||||
|
||||
return `${OPTIMIZED_IMAGE_ENDPOINT}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function buildOptimizedSrcSet(
|
||||
src: string,
|
||||
widths: number[],
|
||||
format: 'avif' | 'webp' | 'jpeg' | 'png',
|
||||
quality?: number,
|
||||
) {
|
||||
return getResponsiveWidths(widths)
|
||||
.map(
|
||||
(width) =>
|
||||
`${buildOptimizedImageUrl(src, {
|
||||
width,
|
||||
format,
|
||||
quality,
|
||||
})} ${width}w`,
|
||||
)
|
||||
.join(', ');
|
||||
}
|
||||
@@ -14,6 +14,15 @@ export interface Post {
|
||||
code?: string;
|
||||
language?: string;
|
||||
pinned?: boolean;
|
||||
status?: string;
|
||||
visibility?: 'public' | 'unlisted' | 'private' | string;
|
||||
publishAt?: string;
|
||||
unpublishAt?: string;
|
||||
canonicalUrl?: string;
|
||||
noindex?: boolean;
|
||||
ogImage?: string;
|
||||
redirectFrom?: string[];
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
@@ -68,6 +77,10 @@ export interface SiteSettings {
|
||||
comments: {
|
||||
paragraphsEnabled: boolean;
|
||||
};
|
||||
seo: {
|
||||
defaultOgImage?: string;
|
||||
defaultTwitterHandle?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MusicTrack {
|
||||
@@ -103,3 +116,38 @@ export interface TechStackItem {
|
||||
icon?: string;
|
||||
level?: string;
|
||||
}
|
||||
|
||||
export interface ContentOverview {
|
||||
totalPageViews: number;
|
||||
pageViewsLast24h: number;
|
||||
pageViewsLast7d: number;
|
||||
totalReadCompletes: number;
|
||||
readCompletesLast7d: number;
|
||||
avgReadProgressLast7d: number;
|
||||
avgReadDurationMsLast7d?: number;
|
||||
}
|
||||
|
||||
export interface PopularPostHighlight {
|
||||
slug: string;
|
||||
title: string;
|
||||
pageViews: number;
|
||||
readCompletes: number;
|
||||
avgProgressPercent: number;
|
||||
avgDurationMs?: number;
|
||||
post?: Post;
|
||||
}
|
||||
|
||||
export interface ContentWindowOverview {
|
||||
pageViews: number;
|
||||
readCompletes: number;
|
||||
avgReadProgress: number;
|
||||
avgReadDurationMs?: number;
|
||||
}
|
||||
|
||||
export interface ContentWindowHighlight {
|
||||
key: string;
|
||||
label: string;
|
||||
days: number;
|
||||
overview: ContentWindowOverview;
|
||||
popularPosts: PopularPostHighlight[];
|
||||
}
|
||||
|
||||
171
frontend/src/pages/_img.ts
Normal file
171
frontend/src/pages/_img.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const PUBLIC_DIR = fileURLToPath(new URL('../../public/', import.meta.url));
|
||||
const MAX_WIDTH = 2400;
|
||||
const DEFAULT_QUALITY = 72;
|
||||
|
||||
function trimToList(value: string | undefined | null) {
|
||||
return String(value || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string | null, fallback: number) {
|
||||
const parsed = Number.parseInt(String(value || ''), 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function clampQuality(value: string | null) {
|
||||
return Math.min(Math.max(parsePositiveInt(value, DEFAULT_QUALITY), 40), 90);
|
||||
}
|
||||
|
||||
function normalizeFormat(value: string | null) {
|
||||
switch (String(value || '').trim().toLowerCase()) {
|
||||
case 'avif':
|
||||
return 'avif' as const;
|
||||
case 'webp':
|
||||
return 'webp' as const;
|
||||
case 'png':
|
||||
return 'png' as const;
|
||||
default:
|
||||
return 'jpeg' as const;
|
||||
}
|
||||
}
|
||||
|
||||
function contentTypeForFormat(format: 'avif' | 'webp' | 'jpeg' | 'png') {
|
||||
switch (format) {
|
||||
case 'avif':
|
||||
return 'image/avif';
|
||||
case 'webp':
|
||||
return 'image/webp';
|
||||
case 'png':
|
||||
return 'image/png';
|
||||
default:
|
||||
return 'image/jpeg';
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowedRemoteUrl(sourceUrl: URL, requestUrl: URL) {
|
||||
const allowedHosts = new Set([
|
||||
requestUrl.host.toLowerCase(),
|
||||
...trimToList(import.meta.env.PUBLIC_IMAGE_ALLOWED_HOSTS as string | undefined),
|
||||
...trimToList(process.env.PUBLIC_IMAGE_ALLOWED_HOSTS),
|
||||
]);
|
||||
|
||||
return (
|
||||
allowedHosts.has(sourceUrl.host.toLowerCase()) ||
|
||||
allowedHosts.has(sourceUrl.hostname.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
async function readPublicAsset(sourceUrl: URL) {
|
||||
const decodedPath = decodeURIComponent(sourceUrl.pathname);
|
||||
const normalizedPath = path.normalize(decodedPath).replace(/^(\.\.[/\\])+/, '');
|
||||
const absolutePath = path.resolve(PUBLIC_DIR, `.${normalizedPath}`);
|
||||
|
||||
if (!absolutePath.startsWith(PUBLIC_DIR) || !absolutePath.startsWith(path.resolve(PUBLIC_DIR))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await readFile(absolutePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSourceBuffer(sourceUrl: URL, requestUrl: URL) {
|
||||
if (sourceUrl.pathname === '/_img') {
|
||||
throw new Error('不允许递归优化图片请求');
|
||||
}
|
||||
|
||||
if (sourceUrl.origin === requestUrl.origin) {
|
||||
const fromPublic = await readPublicAsset(sourceUrl);
|
||||
if (fromPublic) {
|
||||
return fromPublic;
|
||||
}
|
||||
} else if (!isAllowedRemoteUrl(sourceUrl, requestUrl)) {
|
||||
throw new Error('该图片域名未加入 PUBLIC_IMAGE_ALLOWED_HOSTS');
|
||||
}
|
||||
|
||||
const response = await fetch(sourceUrl, {
|
||||
headers: {
|
||||
accept: 'image/*',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`图片源请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
if (!bytes.length) {
|
||||
throw new Error('图片源为空');
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async ({ url, request }) => {
|
||||
const src = url.searchParams.get('src')?.trim();
|
||||
|
||||
if (!src || src.startsWith('data:') || src.startsWith('blob:')) {
|
||||
return new Response('invalid image src', { status: 400 });
|
||||
}
|
||||
|
||||
let sourceUrl: URL;
|
||||
try {
|
||||
sourceUrl = new URL(src, request.url);
|
||||
} catch {
|
||||
return new Response('invalid image url', { status: 400 });
|
||||
}
|
||||
|
||||
const requestUrl = new URL(request.url);
|
||||
const width = Math.min(parsePositiveInt(url.searchParams.get('w'), MAX_WIDTH), MAX_WIDTH);
|
||||
const quality = clampQuality(url.searchParams.get('q'));
|
||||
const format = normalizeFormat(url.searchParams.get('f'));
|
||||
|
||||
try {
|
||||
const sourceBuffer = await loadSourceBuffer(sourceUrl, requestUrl);
|
||||
const image = sharp(sourceBuffer, {
|
||||
failOn: 'none',
|
||||
animated: false,
|
||||
});
|
||||
const metadata = await image.metadata();
|
||||
|
||||
const resized = image.resize({
|
||||
width: metadata.width ? Math.min(width, metadata.width) : width,
|
||||
withoutEnlargement: true,
|
||||
fit: 'inside',
|
||||
});
|
||||
|
||||
const output = await (format === 'avif'
|
||||
? resized.avif({ quality }).toBuffer()
|
||||
: format === 'webp'
|
||||
? resized.webp({ quality }).toBuffer()
|
||||
: format === 'png'
|
||||
? resized.png({ quality }).toBuffer()
|
||||
: resized.jpeg({ quality, mozjpeg: true }).toBuffer());
|
||||
|
||||
return new Response(output, {
|
||||
headers: {
|
||||
'Content-Type': contentTypeForFormat(format),
|
||||
'Cache-Control': 'public, max-age=604800, stale-while-revalidate=2592000',
|
||||
Vary: 'Accept',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(error instanceof Error ? error.message : 'image transform failed', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,331 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import TerminalWindow from '../components/ui/TerminalWindow.astro';
|
||||
import CommandPrompt from '../components/ui/CommandPrompt.astro';
|
||||
import { api } from '../lib/api/client';
|
||||
|
||||
let posts: Awaited<ReturnType<typeof api.getRawPosts>> = [];
|
||||
let tags: Awaited<ReturnType<typeof api.getRawTags>> = [];
|
||||
let friendLinks: Awaited<ReturnType<typeof api.getRawFriendLinks>> = [];
|
||||
let reviews: Awaited<ReturnType<typeof api.getReviews>> = [];
|
||||
let categories: Awaited<ReturnType<typeof api.getCategories>> = [];
|
||||
let error: string | null = null;
|
||||
|
||||
try {
|
||||
posts = await api.getRawPosts();
|
||||
tags = await api.getRawTags();
|
||||
friendLinks = await api.getRawFriendLinks();
|
||||
reviews = await api.getReviews();
|
||||
categories = await api.getCategories();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'API unavailable';
|
||||
}
|
||||
|
||||
const pendingFriendLinks = friendLinks.filter(friendLink => friendLink.status === 'pending');
|
||||
const pinnedPosts = posts.filter(post => post.pinned);
|
||||
const recentPosts = [...posts].sort((a, b) => b.created_at.localeCompare(a.created_at)).slice(0, 6);
|
||||
const activeCategories = [...categories]
|
||||
.sort((a, b) => (b.count ?? 0) - (a.count ?? 0))
|
||||
.slice(0, 6);
|
||||
const tagSamples = tags.slice(0, 12);
|
||||
const recentReviews = [...reviews].sort((a, b) => b.review_date.localeCompare(a.review_date)).slice(0, 4);
|
||||
---
|
||||
|
||||
<BaseLayout title="控制台 - Termi">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<TerminalWindow title="~/admin/control-center" class="w-full">
|
||||
<div class="px-4 pb-2">
|
||||
<CommandPrompt command="sudo termi-admin --dashboard --mode=ops" />
|
||||
|
||||
<div class="terminal-panel ml-4 mt-4">
|
||||
<div class="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="space-y-4">
|
||||
<span class="terminal-kicker">
|
||||
<i class="fas fa-sliders"></i>
|
||||
operator console
|
||||
</span>
|
||||
<div class="space-y-3">
|
||||
<h1 class="text-3xl font-bold tracking-tight text-[var(--title-color)]">前台控制台</h1>
|
||||
<p class="max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
|
||||
这个页面现在作为终端风格的运营工作台使用,用来查看内容库存、友链审核、标签分类和近期评价,
|
||||
保持和整站统一的 geek / terminal 气质。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-file-lines text-[var(--primary)]"></i>
|
||||
posts {posts.length}
|
||||
</span>
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-tags text-[var(--primary)]"></i>
|
||||
tags {tags.length}
|
||||
</span>
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-link text-[var(--primary)]"></i>
|
||||
links {friendLinks.length}
|
||||
</span>
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-star text-[var(--primary)]"></i>
|
||||
reviews {reviews.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div class="px-4 pb-2">
|
||||
<div class="ml-4 mt-4 rounded-2xl border px-4 py-4 text-sm text-[var(--danger)]" style="border-color: color-mix(in oklab, var(--danger) 30%, var(--border-color)); background: color-mix(in oklab, var(--danger) 10%, var(--header-bg));">
|
||||
API 连接失败: {error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="px-4 pb-2">
|
||||
<div class="ml-4 mt-4 grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<div class="terminal-panel-muted">
|
||||
<div class="terminal-toolbar-label">posts</div>
|
||||
<div class="mt-3 text-3xl font-bold text-[var(--title-color)]">{posts.length}</div>
|
||||
<p class="mt-2 text-sm text-[var(--text-secondary)]">内容总量</p>
|
||||
</div>
|
||||
<div class="terminal-panel-muted">
|
||||
<div class="terminal-toolbar-label">pinned</div>
|
||||
<div class="mt-3 text-3xl font-bold text-[var(--title-color)]">{pinnedPosts.length}</div>
|
||||
<p class="mt-2 text-sm text-[var(--text-secondary)]">置顶文章</p>
|
||||
</div>
|
||||
<div class="terminal-panel-muted">
|
||||
<div class="terminal-toolbar-label">pending links</div>
|
||||
<div class="mt-3 text-3xl font-bold text-[var(--title-color)]">{pendingFriendLinks.length}</div>
|
||||
<p class="mt-2 text-sm text-[var(--text-secondary)]">待处理友链</p>
|
||||
</div>
|
||||
<div class="terminal-panel-muted">
|
||||
<div class="terminal-toolbar-label">categories</div>
|
||||
<div class="mt-3 text-3xl font-bold text-[var(--title-color)]">{categories.length}</div>
|
||||
<p class="mt-2 text-sm text-[var(--text-secondary)]">分类数量</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4">
|
||||
<div class="ml-4 grid gap-6 lg:grid-cols-[1.25fr_0.95fr]">
|
||||
<section class="terminal-panel space-y-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-3">
|
||||
<span class="terminal-kicker">
|
||||
<i class="fas fa-file-waveform"></i>
|
||||
content queue
|
||||
</span>
|
||||
<div class="terminal-section-title">
|
||||
<span class="terminal-section-icon">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
</span>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-[var(--title-color)]">最近文章</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">按创建时间排列,方便快速检查最新导入和置顶状态。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/articles" class="terminal-action-button">
|
||||
<i class="fas fa-arrow-up-right-from-square"></i>
|
||||
<span>open feed</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
{recentPosts.map(post => (
|
||||
<div class="terminal-console-list-item">
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class={`h-2.5 w-2.5 rounded-full ${post.post_type === 'article' ? 'bg-[var(--primary)]' : 'bg-[var(--secondary)]'}`}></span>
|
||||
<span class="truncate font-medium text-[var(--title-color)]">{post.title}</span>
|
||||
{post.pinned && (
|
||||
<span class="terminal-chip px-2.5 py-1 text-xs">
|
||||
<i class="fas fa-thumbtack text-[var(--primary)]"></i>
|
||||
pinned
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 text-xs text-[var(--text-secondary)]">
|
||||
<span>{post.slug}</span>
|
||||
<span>/</span>
|
||||
<span>{post.category}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap gap-2">
|
||||
<span class="terminal-stat-pill px-2.5 py-1 text-xs">
|
||||
<i class="far fa-calendar text-[var(--primary)]"></i>
|
||||
{post.created_at.slice(0, 10)}
|
||||
</span>
|
||||
<a href={`/articles/${post.slug}`} class="terminal-action-button px-3 py-2 text-xs">
|
||||
<i class="fas fa-eye"></i>
|
||||
<span>view</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-6">
|
||||
<div class="terminal-panel space-y-5">
|
||||
<div class="terminal-section-title">
|
||||
<span class="terminal-section-icon">
|
||||
<i class="fas fa-link"></i>
|
||||
</span>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-[var(--title-color)]">友链队列</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">突出待审核项目,让控制台页面更像真实的处理台。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
{pendingFriendLinks.length > 0 ? (
|
||||
pendingFriendLinks.map(link => (
|
||||
<div class="terminal-console-list-item">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium text-[var(--title-color)]">{link.site_name}</p>
|
||||
<p class="mt-1 truncate text-sm text-[var(--text-secondary)]">{link.site_url}</p>
|
||||
</div>
|
||||
<span class="terminal-chip px-2.5 py-1 text-xs">
|
||||
<i class="fas fa-hourglass-half text-[var(--warning)]"></i>
|
||||
pending
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div class="terminal-empty py-8">
|
||||
<p class="text-sm text-[var(--text-secondary)]">当前没有待审核友链。</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="terminal-panel space-y-5">
|
||||
<div class="terminal-section-title">
|
||||
<span class="terminal-section-icon">
|
||||
<i class="fas fa-star-half-stroke"></i>
|
||||
</span>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-[var(--title-color)]">近期评价</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">评价模块也纳入控制台视野,保持内容维度统一。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
{recentReviews.map(review => (
|
||||
<div class="terminal-console-list-item">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium text-[var(--title-color)]">{review.title}</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">{review.review_type} / {review.status}</p>
|
||||
</div>
|
||||
<span class="terminal-stat-pill px-2.5 py-1 text-xs">
|
||||
<i class="fas fa-star text-[var(--warning)]"></i>
|
||||
{review.rating}/5
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 pb-6">
|
||||
<div class="ml-4 grid gap-6 lg:grid-cols-2">
|
||||
<section class="terminal-panel space-y-5">
|
||||
<div class="terminal-section-title">
|
||||
<span class="terminal-section-icon">
|
||||
<i class="fas fa-layer-group"></i>
|
||||
</span>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-[var(--title-color)]">分类与标签</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">展示当前内容分布,方便观察导入后的归类情况。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{activeCategories.map(category => (
|
||||
<a href={`/categories?category=${encodeURIComponent(category.slug)}`} class="terminal-filter">
|
||||
<i class="fas fa-folder-tree"></i>
|
||||
<span>{category.name}</span>
|
||||
<span class="text-[var(--text-tertiary)]">{category.count}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{tagSamples.map(tag => (
|
||||
<a href={`/tags?tag=${encodeURIComponent(tag.slug || tag.name)}`} class="terminal-chip">
|
||||
<i class="fas fa-hashtag text-[var(--primary)]"></i>
|
||||
{tag.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="terminal-panel space-y-5">
|
||||
<div class="terminal-section-title">
|
||||
<span class="terminal-section-icon">
|
||||
<i class="fas fa-terminal"></i>
|
||||
</span>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-[var(--title-color)]">快捷入口</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">把常用页面作为控制台指令入口来呈现,弱化“默认后台模板感”。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<a href="/" class="terminal-toolbar-module hover:border-[var(--primary)]">
|
||||
<span class="terminal-section-icon h-10 w-10 rounded-xl">
|
||||
<i class="fas fa-house"></i>
|
||||
</span>
|
||||
<div>
|
||||
<div class="terminal-toolbar-label">front</div>
|
||||
<div class="font-medium text-[var(--title-color)]">首页</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/friends" class="terminal-toolbar-module hover:border-[var(--primary)]">
|
||||
<span class="terminal-section-icon h-10 w-10 rounded-xl">
|
||||
<i class="fas fa-link"></i>
|
||||
</span>
|
||||
<div>
|
||||
<div class="terminal-toolbar-label">links</div>
|
||||
<div class="font-medium text-[var(--title-color)]">友链页</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/reviews" class="terminal-toolbar-module hover:border-[var(--primary)]">
|
||||
<span class="terminal-section-icon h-10 w-10 rounded-xl">
|
||||
<i class="fas fa-star"></i>
|
||||
</span>
|
||||
<div>
|
||||
<div class="terminal-toolbar-label">reviews</div>
|
||||
<div class="font-medium text-[var(--title-color)]">评价页</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="https://init.cool/" class="terminal-toolbar-module hover:border-[var(--primary)]">
|
||||
<span class="terminal-section-icon h-10 w-10 rounded-xl">
|
||||
<i class="fas fa-server"></i>
|
||||
</span>
|
||||
<div>
|
||||
<div class="terminal-toolbar-label">backend</div>
|
||||
<div class="font-medium text-[var(--title-color)]">Loco 后台</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="terminal-panel-muted">
|
||||
<div class="terminal-toolbar-label">api endpoint</div>
|
||||
<p class="mt-2 font-mono text-sm text-[var(--primary)]">https://init.cool/api</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</TerminalWindow>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -3,16 +3,17 @@ import { createMarkdownProcessor } from '@astrojs/markdown-remark';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||
import ResponsiveImage from '../../components/ui/ResponsiveImage.astro';
|
||||
import TableOfContents from '../../components/TableOfContents.astro';
|
||||
import RelatedPosts from '../../components/RelatedPosts.astro';
|
||||
import ReadingProgress from '../../components/ReadingProgress.astro';
|
||||
import BackToTop from '../../components/BackToTop.astro';
|
||||
import Lightbox from '../../components/Lightbox.astro';
|
||||
import CodeCopyButton from '../../components/CodeCopyButton.astro';
|
||||
import Comments from '../../components/Comments.astro';
|
||||
import ParagraphComments from '../../components/ParagraphComments.astro';
|
||||
import { apiClient, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
||||
import { formatReadTime, getI18n } from '../../lib/i18n';
|
||||
import type { PopularPostHighlight } from '../../lib/types';
|
||||
import {
|
||||
getAccentVars,
|
||||
getCategoryTheme,
|
||||
@@ -28,10 +29,12 @@ const { slug } = Astro.params;
|
||||
|
||||
let post = null;
|
||||
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||
let homeData: Awaited<ReturnType<typeof apiClient.getHomePageData>> | null = null;
|
||||
|
||||
const [postResult, siteSettingsResult] = await Promise.allSettled([
|
||||
const [postResult, siteSettingsResult, homeDataResult] = await Promise.allSettled([
|
||||
apiClient.getPostBySlug(slug ?? ''),
|
||||
apiClient.getSiteSettings(),
|
||||
apiClient.getHomePageData(),
|
||||
]);
|
||||
|
||||
if (postResult.status === 'fulfilled') {
|
||||
@@ -46,10 +49,23 @@ if (siteSettingsResult.status === 'fulfilled') {
|
||||
console.error('Site settings API Error:', siteSettingsResult.reason);
|
||||
}
|
||||
|
||||
if (homeDataResult.status === 'fulfilled') {
|
||||
homeData = homeDataResult.value;
|
||||
if (siteSettingsResult.status !== 'fulfilled') {
|
||||
siteSettings = homeData.siteSettings;
|
||||
}
|
||||
} else {
|
||||
console.error('Home data API Error:', homeDataResult.reason);
|
||||
}
|
||||
|
||||
if (!post) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
if (slug && post.slug !== slug) {
|
||||
return Astro.redirect(`/articles/${post.slug}`, 301);
|
||||
}
|
||||
|
||||
const typeColor = getPostTypeColor(post.type || 'article');
|
||||
const typeTheme = getPostTypeTheme(post.type || 'article');
|
||||
const categoryTheme = getCategoryTheme(post.category);
|
||||
@@ -62,11 +78,109 @@ const paragraphCommentsEnabled = siteSettings.comments.paragraphsEnabled;
|
||||
|
||||
const markdownProcessor = await createMarkdownProcessor();
|
||||
const renderedContent = await markdownProcessor.render(articleMarkdown);
|
||||
const siteBaseUrl = siteSettings.siteUrl || new URL(Astro.request.url).origin;
|
||||
const canonicalUrl = post.canonicalUrl || new URL(`/articles/${post.slug}`, siteBaseUrl).toString();
|
||||
const ogImage = post.ogImage || `/og/${post.slug}.svg`;
|
||||
const noindex = Boolean(post.noindex || post.visibility === 'unlisted');
|
||||
const publishedAt = post.publishAt || `${post.date}T00:00:00Z`;
|
||||
const hotRelatedPosts = (homeData?.popularPosts ?? [])
|
||||
.filter((item): item is PopularPostHighlight & { post: NonNullable<PopularPostHighlight['post']> } => Boolean(item.post))
|
||||
.filter((item) => item.slug !== post.slug)
|
||||
.map((item) => {
|
||||
const sharedTags = item.post.tags.filter((tag) => post.tags.includes(tag));
|
||||
const sameCategory = item.post.category === post.category;
|
||||
const sameType = item.post.type === post.type;
|
||||
const relevance = (sameCategory ? 4 : 0) + sharedTags.length * 3 + (sameType ? 1 : 0);
|
||||
const popularityScore =
|
||||
item.pageViews + item.readCompletes * 2 + item.avgProgressPercent / 20;
|
||||
|
||||
return {
|
||||
...item,
|
||||
sharedTags,
|
||||
sameCategory,
|
||||
relevance,
|
||||
popularityScore,
|
||||
finalScore: relevance * 100 + popularityScore,
|
||||
};
|
||||
})
|
||||
.filter((item) => item.relevance > 0)
|
||||
.sort((left, right) => {
|
||||
return (
|
||||
right.finalScore - left.finalScore ||
|
||||
right.pageViews - left.pageViews ||
|
||||
right.readCompletes - left.readCompletes
|
||||
);
|
||||
})
|
||||
.slice(0, 3);
|
||||
const articleJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: post.title,
|
||||
description: post.description,
|
||||
image: [new URL(ogImage, siteBaseUrl).toString()],
|
||||
mainEntityOfPage: canonicalUrl,
|
||||
url: canonicalUrl,
|
||||
datePublished: publishedAt,
|
||||
dateModified: publishedAt,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: siteSettings.ownerName,
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: siteSettings.siteName,
|
||||
logo: siteSettings.ownerAvatarUrl
|
||||
? {
|
||||
'@type': 'ImageObject',
|
||||
url: siteSettings.ownerAvatarUrl,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
articleSection: post.category,
|
||||
keywords: post.tags,
|
||||
inLanguage: locale,
|
||||
isAccessibleForFree: true,
|
||||
wordCount,
|
||||
timeRequired: `PT${Math.max(readTimeMinutes, 1)}M`,
|
||||
};
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: siteSettings.siteName,
|
||||
item: siteBaseUrl,
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: 'Articles',
|
||||
item: new URL('/articles', siteBaseUrl).toString(),
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: post.title,
|
||||
item: canonicalUrl,
|
||||
},
|
||||
],
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout title={post.title} description={post.description} siteSettings={siteSettings}>
|
||||
<BaseLayout
|
||||
title={post.title}
|
||||
description={post.description}
|
||||
siteSettings={siteSettings}
|
||||
canonical={canonicalUrl}
|
||||
noindex={noindex}
|
||||
ogImage={ogImage}
|
||||
ogType="article"
|
||||
twitterCard="summary_large_image"
|
||||
jsonLd={[articleJsonLd, breadcrumbJsonLd]}
|
||||
>
|
||||
<ReadingProgress />
|
||||
<BackToTop />
|
||||
<Lightbox />
|
||||
<CodeCopyButton />
|
||||
|
||||
@@ -143,11 +257,16 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
|
||||
<div class="ml-4 mt-4 space-y-6">
|
||||
{post.image && (
|
||||
<div class="terminal-panel-muted overflow-hidden">
|
||||
<img
|
||||
<ResponsiveImage
|
||||
src={resolveFileRef(post.image)}
|
||||
alt={post.title}
|
||||
data-lightbox-image="true"
|
||||
class="w-full h-auto rounded-xl border border-[var(--border-color)] cursor-zoom-in"
|
||||
pictureClass="block"
|
||||
imgClass="w-full h-auto rounded-xl border border-[var(--border-color)] cursor-zoom-in"
|
||||
widths={[640, 960, 1280, 1600, 1920]}
|
||||
sizes="(min-width: 1280px) 60rem, 100vw"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
lightbox={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -156,11 +275,14 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{post.images.map((image, index) => (
|
||||
<div class="terminal-panel-muted overflow-hidden">
|
||||
<img
|
||||
<ResponsiveImage
|
||||
src={resolveFileRef(image)}
|
||||
alt={`${post.title} 图片 ${index + 1}`}
|
||||
data-lightbox-image="true"
|
||||
class="h-full w-full rounded-xl border border-[var(--border-color)] object-cover cursor-zoom-in"
|
||||
pictureClass="block h-full w-full"
|
||||
imgClass="h-full w-full rounded-xl border border-[var(--border-color)] object-cover cursor-zoom-in"
|
||||
widths={[480, 720, 960, 1280]}
|
||||
sizes="(min-width: 1280px) 30vw, (min-width: 640px) 45vw, 100vw"
|
||||
lightbox={true}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -201,6 +323,103 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
|
||||
currentTags={post.tags}
|
||||
/>
|
||||
|
||||
{hotRelatedPosts.length > 0 && (
|
||||
<section class="terminal-panel mt-8">
|
||||
<div class="space-y-5">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="space-y-3">
|
||||
<span class="terminal-kicker">
|
||||
<i class="fas fa-fire"></i>
|
||||
{t('relatedPosts.hotKicker')}
|
||||
</span>
|
||||
<div class="terminal-section-title">
|
||||
<span class="terminal-section-icon">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
</span>
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-[var(--title-color)]">{t('relatedPosts.hotTitle')}</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
{t('relatedPosts.hotDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-signal text-[var(--primary)]"></i>
|
||||
{t('relatedPosts.linked', { count: hotRelatedPosts.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
{hotRelatedPosts.map((item) => (
|
||||
<a
|
||||
href={`/articles/${item.slug}`}
|
||||
class="terminal-panel-muted terminal-panel-accent terminal-interactive-card group flex h-full flex-col gap-4 p-4"
|
||||
style={getAccentVars(getPostTypeTheme(item.post.type))}
|
||||
>
|
||||
{item.post.image ? (
|
||||
<div class="overflow-hidden rounded-xl border border-[var(--border-color)]">
|
||||
<ResponsiveImage
|
||||
src={resolveFileRef(item.post.image)}
|
||||
alt={item.post.title}
|
||||
pictureClass="block"
|
||||
imgClass="aspect-[16/9] w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
|
||||
widths={[320, 480, 640, 960]}
|
||||
sizes="(min-width: 1280px) 18rem, (min-width: 768px) 30vw, 100vw"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="terminal-chip terminal-chip--accent px-2.5 py-1 text-xs" style={getAccentVars(getPostTypeTheme(item.post.type))}>
|
||||
{item.post.type === 'article' ? t('common.article') : t('common.tweet')}
|
||||
</span>
|
||||
<span class="terminal-chip terminal-chip--accent px-2.5 py-1 text-xs" style={getAccentVars(getCategoryTheme(item.post.category))}>
|
||||
<i class="fas fa-folder-tree text-[11px]"></i>
|
||||
{item.post.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 class="text-base font-semibold text-[var(--title-color)] group-hover:text-[var(--primary)]">
|
||||
{item.post.title}
|
||||
</h4>
|
||||
<p class="text-sm leading-7 text-[var(--text-secondary)]">{item.post.description}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto flex flex-wrap gap-2 pt-1">
|
||||
<span class="terminal-stat-pill px-2.5 py-1 text-xs">
|
||||
<i class="fas fa-eye text-[var(--primary)]"></i>
|
||||
{t('home.views')}: {item.pageViews}
|
||||
</span>
|
||||
<span class="terminal-stat-pill px-2.5 py-1 text-xs">
|
||||
<i class="fas fa-check-double text-[var(--primary)]"></i>
|
||||
{t('home.completes')}: {item.readCompletes}
|
||||
</span>
|
||||
<span class="terminal-stat-pill px-2.5 py-1 text-xs">
|
||||
<i class="fas fa-chart-line text-[var(--primary)]"></i>
|
||||
{t('home.avgProgress')}: {Math.round(item.avgProgressPercent)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{item.sharedTags.length > 0 && (
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{item.sharedTags.slice(0, 3).map((tag) => (
|
||||
<span class="terminal-chip terminal-chip--accent px-2.5 py-1 text-xs" style={getAccentVars(getTagTheme(tag))}>
|
||||
<i class="fas fa-hashtag text-[11px]"></i>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section class="mt-8">
|
||||
<Comments postSlug={post.slug} class="terminal-panel" />
|
||||
</section>
|
||||
@@ -210,3 +429,101 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<script is:inline define:vars={{ postSlug: post.slug }}>
|
||||
(() => {
|
||||
const endpoint = '/api/analytics/content';
|
||||
const sessionStorageKey = `termi:content-session:${postSlug}`;
|
||||
const startedAt = Date.now();
|
||||
let sentPageView = false;
|
||||
let lastReportedProgress = 0;
|
||||
|
||||
function ensureSessionId() {
|
||||
try {
|
||||
const existing = window.sessionStorage.getItem(sessionStorageKey);
|
||||
if (existing) return existing;
|
||||
const nextId = crypto.randomUUID();
|
||||
window.sessionStorage.setItem(sessionStorageKey, nextId);
|
||||
return nextId;
|
||||
} catch {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function getProgressPercent() {
|
||||
const doc = document.documentElement;
|
||||
const scrollTop = window.scrollY || doc.scrollTop || 0;
|
||||
const scrollHeight = Math.max(doc.scrollHeight - window.innerHeight, 1);
|
||||
return Math.max(0, Math.min(100, Math.round((scrollTop / scrollHeight) * 100)));
|
||||
}
|
||||
|
||||
function sendEvent(eventType, extras = {}, useBeacon = false) {
|
||||
const payload = JSON.stringify({
|
||||
event_type: eventType,
|
||||
path: window.location.pathname,
|
||||
post_slug: postSlug,
|
||||
session_id: ensureSessionId(),
|
||||
referrer: document.referrer || undefined,
|
||||
...extras,
|
||||
});
|
||||
|
||||
if (useBeacon && navigator.sendBeacon) {
|
||||
navigator.sendBeacon(endpoint, new Blob([payload], { type: 'application/json' }));
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload,
|
||||
keepalive: true,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
function reportProgress(forceComplete = false) {
|
||||
const progress = forceComplete ? 100 : getProgressPercent();
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const eventType = forceComplete || progress >= 95 ? 'read_complete' : 'read_progress';
|
||||
|
||||
if (!forceComplete && progress <= lastReportedProgress + 9) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastReportedProgress = Math.max(lastReportedProgress, progress);
|
||||
sendEvent(
|
||||
eventType,
|
||||
{
|
||||
progress_percent: progress,
|
||||
duration_ms: durationMs,
|
||||
metadata: {
|
||||
viewportHeight: window.innerHeight,
|
||||
},
|
||||
},
|
||||
eventType === 'read_complete',
|
||||
);
|
||||
}
|
||||
|
||||
if (!sentPageView) {
|
||||
sentPageView = true;
|
||||
sendEvent('page_view', {
|
||||
metadata: {
|
||||
title: document.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
reportProgress(false);
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
reportProgress(getProgressPercent() >= 95);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('pagehide', () => {
|
||||
reportProgress(getProgressPercent() >= 95);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -82,6 +82,9 @@ const categoryAccentMap = Object.fromEntries(
|
||||
const tagAccentMap = Object.fromEntries(
|
||||
allTags.map((tag) => [String(tag.slug || tag.name).toLowerCase(), getAccentVars(getTagTheme(tag.name))])
|
||||
);
|
||||
const hasActiveFilters =
|
||||
Boolean(selectedSearch || selectedTag || selectedCategory || selectedType !== 'all' || currentPage > 1);
|
||||
const canonicalUrl = hasActiveFilters ? '/articles' : undefined;
|
||||
|
||||
const buildArticlesUrl = ({
|
||||
type = selectedType,
|
||||
@@ -109,7 +112,11 @@ const buildArticlesUrl = ({
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout title={`${t('articlesPage.title')} - Termi`}>
|
||||
<BaseLayout
|
||||
title={`${t('articlesPage.title')} - Termi`}
|
||||
canonical={canonicalUrl}
|
||||
noindex={hasActiveFilters}
|
||||
>
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<TerminalWindow title="~/articles/index" class="w-full">
|
||||
<div class="px-4 pb-2">
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||
import { API_BASE_URL, api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
||||
import { api, DEFAULT_SITE_SETTINGS, resolvePublicApiBaseUrl } from '../../lib/api/client';
|
||||
import { getI18n } from '../../lib/i18n';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||
const { locale, t } = getI18n(Astro);
|
||||
const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||
|
||||
try {
|
||||
siteSettings = await api.getSiteSettings();
|
||||
@@ -123,7 +124,7 @@ const sampleQuestions = [
|
||||
</BaseLayout>
|
||||
|
||||
{aiEnabled && (
|
||||
<script is:inline define:vars={{ apiBase: API_BASE_URL }}>
|
||||
<script is:inline define:vars={{ apiBase: publicApiBaseUrl }}>
|
||||
const t = window.__termiTranslate;
|
||||
const form = document.getElementById('ai-form');
|
||||
const input = document.getElementById('ai-question');
|
||||
|
||||
@@ -102,9 +102,9 @@ const categoryAccentMap = Object.fromEntries(
|
||||
<div class="text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)] mb-1">
|
||||
all
|
||||
</div>
|
||||
<h3 class="font-bold text-[var(--title-color)] transition-colors text-lg">
|
||||
<h2 class="font-bold text-[var(--title-color)] transition-colors text-lg">
|
||||
{t('articlesPage.allCategories')}
|
||||
</h3>
|
||||
</h2>
|
||||
</div>
|
||||
<span class="terminal-chip text-xs py-1 px-2.5">
|
||||
<span>{t('common.postsCount', { count: allPosts.length })}</span>
|
||||
@@ -137,9 +137,9 @@ const categoryAccentMap = Object.fromEntries(
|
||||
<div class="text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)] mb-1">
|
||||
{category.slug || category.name}
|
||||
</div>
|
||||
<h3 class="font-bold text-[var(--title-color)] group-hover:text-[var(--primary)] transition-colors text-lg">
|
||||
<h2 class="font-bold text-[var(--title-color)] group-hover:text-[var(--primary)] transition-colors text-lg">
|
||||
{category.name}
|
||||
</h3>
|
||||
</h2>
|
||||
</div>
|
||||
<span class="terminal-chip terminal-chip--accent text-xs py-1 px-2.5" style={getAccentVars(getCategoryTheme(category.name))}>
|
||||
<span>{t('common.postsCount', { count: category.count })}</span>
|
||||
|
||||
14
frontend/src/pages/healthz.ts
Normal file
14
frontend/src/pages/healthz.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const GET = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
service: 'frontend',
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -8,11 +8,12 @@ import FriendLinkCard from '../components/FriendLinkCard.astro';
|
||||
import ViewMoreLink from '../components/ui/ViewMoreLink.astro';
|
||||
import StatsList from '../components/StatsList.astro';
|
||||
import TechStackList from '../components/TechStackList.astro';
|
||||
import SubscriptionSignup from '../components/SubscriptionSignup.astro';
|
||||
import { terminalConfig } from '../lib/config/terminal';
|
||||
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
|
||||
import { formatReadTime, getI18n } from '../lib/i18n';
|
||||
import type { AppFriendLink } from '../lib/api/client';
|
||||
import type { Post } from '../lib/types';
|
||||
import type { ContentOverview, ContentWindowHighlight, PopularPostHighlight, Post } from '../lib/types';
|
||||
import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../lib/utils';
|
||||
|
||||
export const prerender = false;
|
||||
@@ -24,6 +25,7 @@ const selectedCategory = url.searchParams.get('category') || '';
|
||||
const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||
const normalizedSelectedCategory = selectedCategory.trim().toLowerCase();
|
||||
const previewLimit = 5;
|
||||
const DEFAULT_HOME_RANGE_KEY = '7d';
|
||||
|
||||
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||
let allPosts: Post[] = [];
|
||||
@@ -33,9 +35,53 @@ let pinnedPost: Post | null = null;
|
||||
let tags: string[] = [];
|
||||
let friendLinks: AppFriendLink[] = [];
|
||||
let categories: Awaited<ReturnType<typeof api.getCategories>> = [];
|
||||
const createEmptyContentRange = (key: string, label: string, days: number): ContentWindowHighlight => ({
|
||||
key,
|
||||
label,
|
||||
days,
|
||||
overview: {
|
||||
pageViews: 0,
|
||||
readCompletes: 0,
|
||||
avgReadProgress: 0,
|
||||
avgReadDurationMs: undefined,
|
||||
},
|
||||
popularPosts: [],
|
||||
});
|
||||
let contentRanges: ContentWindowHighlight[] = [
|
||||
createEmptyContentRange('24h', '24h', 1),
|
||||
createEmptyContentRange('7d', '7d', 7),
|
||||
createEmptyContentRange('30d', '30d', 30),
|
||||
];
|
||||
let contentOverview: ContentOverview = {
|
||||
totalPageViews: 0,
|
||||
pageViewsLast24h: 0,
|
||||
pageViewsLast7d: 0,
|
||||
totalReadCompletes: 0,
|
||||
readCompletesLast7d: 0,
|
||||
avgReadProgressLast7d: 0,
|
||||
avgReadDurationMsLast7d: undefined,
|
||||
};
|
||||
let apiError: string | null = null;
|
||||
const { locale, t } = getI18n(Astro);
|
||||
|
||||
const formatDurationMs = (value: number | undefined) => {
|
||||
if (!value || value <= 0) return locale === 'en' ? 'N/A' : '暂无';
|
||||
if (value < 1000) return `${Math.round(value)} ms`;
|
||||
|
||||
const seconds = value / 1000;
|
||||
if (seconds < 60) {
|
||||
return locale === 'en'
|
||||
? `${seconds.toFixed(seconds >= 10 ? 0 : 1)}s`
|
||||
: `${seconds.toFixed(seconds >= 10 ? 0 : 1)} 秒`;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const restSeconds = Math.round(seconds % 60);
|
||||
return locale === 'en' ? `${minutes}m ${restSeconds}s` : `${minutes} 分 ${restSeconds} 秒`;
|
||||
};
|
||||
|
||||
const formatProgressPercent = (value: number) => `${Math.round(value)}%`;
|
||||
|
||||
try {
|
||||
const homeData = await api.getHomePageData();
|
||||
|
||||
@@ -44,6 +90,8 @@ try {
|
||||
tags = homeData.tags.map(tag => tag.name);
|
||||
friendLinks = homeData.friendLinks.filter(friend => friend.status === 'approved');
|
||||
categories = homeData.categories;
|
||||
contentRanges = homeData.contentRanges;
|
||||
contentOverview = homeData.contentOverview;
|
||||
|
||||
const filteredPosts = allPosts.filter(post => {
|
||||
const normalizedCategory = post.category?.trim().toLowerCase() || '';
|
||||
@@ -94,6 +142,26 @@ const matchesSelectedFilters = (post: Post) => {
|
||||
if (selectedTag && !post.tags.some(tag => tag.trim().toLowerCase() === normalizedSelectedTag)) return false;
|
||||
return true;
|
||||
};
|
||||
const activeContentRange =
|
||||
contentRanges.find((range) => range.key === DEFAULT_HOME_RANGE_KEY) ??
|
||||
contentRanges[0] ??
|
||||
createEmptyContentRange(DEFAULT_HOME_RANGE_KEY, DEFAULT_HOME_RANGE_KEY, 7);
|
||||
const popularRangeCards = contentRanges.flatMap((range) =>
|
||||
range.popularPosts
|
||||
.filter((item): item is PopularPostHighlight & { post: Post } => Boolean(item.post))
|
||||
.map((item) => ({
|
||||
rangeKey: range.key,
|
||||
item,
|
||||
post: item.post,
|
||||
})),
|
||||
);
|
||||
const popularRangeOptions = contentRanges.map((range) => ({
|
||||
key: range.key,
|
||||
label: range.label,
|
||||
}));
|
||||
const initialPopularCount = popularRangeCards.filter(
|
||||
({ rangeKey, post }) => rangeKey === activeContentRange.key && matchesSelectedFilters(post),
|
||||
).length;
|
||||
const buildHomeUrl = ({
|
||||
type = selectedType,
|
||||
tag = selectedTag,
|
||||
@@ -137,6 +205,12 @@ const discoverPrompt = hasActiveFilters
|
||||
const postsPrompt = hasActiveFilters
|
||||
? t('home.promptPostsFiltered', { count: previewCount, filters: activeFilterLabels.join(' · ') })
|
||||
: t('home.promptPostsDefault', { count: previewCount });
|
||||
const popularPrompt = t('home.promptPopularRange', { label: activeContentRange.label });
|
||||
const popularSortOptions = [
|
||||
{ id: 'views', label: t('home.sortByViews'), icon: 'fa-eye' },
|
||||
{ id: 'completes', label: t('home.sortByCompletes'), icon: 'fa-check-double' },
|
||||
{ id: 'depth', label: t('home.sortByDepth'), icon: 'fa-chart-line' },
|
||||
];
|
||||
const navLinks = [
|
||||
{ icon: 'fa-file-code', text: t('nav.articles'), href: '/articles' },
|
||||
{ icon: 'fa-folder', text: t('nav.categories'), href: '/categories' },
|
||||
@@ -186,6 +260,10 @@ const navLinks = [
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="ml-4 mt-5">
|
||||
<SubscriptionSignup requestUrl={Astro.request.url} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{apiError && (
|
||||
@@ -418,6 +496,160 @@ const navLinks = [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="popular" class="mb-10 px-4">
|
||||
<CommandPrompt promptId="home-popular-prompt" command={popularPrompt} />
|
||||
<div class="ml-4 grid gap-6 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<section class="terminal-panel space-y-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-[var(--title-color)]">{t('home.hotNow')}</h3>
|
||||
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
|
||||
{t('home.hotNowDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<span id="home-popular-count" class="terminal-stat-pill">{initialPopularCount}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="home-popular-sortbar">
|
||||
{popularRangeOptions.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
data-home-popular-range={option.key}
|
||||
class:list={[
|
||||
'home-popular-sort',
|
||||
option.key === activeContentRange.key && 'is-active'
|
||||
]}
|
||||
>
|
||||
<i class="fas fa-clock text-[10px]"></i>
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="home-popular-sortbar">
|
||||
{popularSortOptions.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
data-home-popular-sort={option.id}
|
||||
class:list={[
|
||||
'home-popular-sort',
|
||||
option.id === 'views' && 'is-active'
|
||||
]}
|
||||
>
|
||||
<i class={`fas ${option.icon} text-[10px]`}></i>
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="home-popular-list" class="space-y-3">
|
||||
{popularRangeCards.map(({ rangeKey, item, post }) => {
|
||||
return (
|
||||
<a
|
||||
href={`/articles/${post.slug}`}
|
||||
data-home-popular-card
|
||||
data-home-range={rangeKey}
|
||||
data-home-type={post.type}
|
||||
data-home-category={post.category?.trim().toLowerCase() || ''}
|
||||
data-home-tags={post.tags.map((tag) => tag.trim().toLowerCase()).join('|')}
|
||||
data-home-slug={post.slug}
|
||||
data-home-popular-views={item.pageViews}
|
||||
data-home-popular-completes={item.readCompletes}
|
||||
data-home-popular-depth={Math.round(item.avgProgressPercent)}
|
||||
class:list={[
|
||||
'block rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/70 p-4 transition hover:border-[var(--primary)] hover:-translate-y-0.5',
|
||||
rangeKey !== activeContentRange.key && 'hidden'
|
||||
]}
|
||||
style={getAccentVars(getPostTypeTheme(post.type))}
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="terminal-chip terminal-chip--accent text-[10px] py-1 px-2" style={getAccentVars(getPostTypeTheme(post.type))}>
|
||||
{post.type === 'article' ? t('common.article') : t('common.tweet')}
|
||||
</span>
|
||||
<span class="terminal-chip terminal-chip--accent text-[10px] py-1 px-2" style={getAccentVars(getCategoryTheme(post.category))}>
|
||||
{post.category}
|
||||
</span>
|
||||
</div>
|
||||
<h4 class="mt-3 text-base font-semibold text-[var(--title-color)]">{post.title}</h4>
|
||||
<p class="mt-2 line-clamp-2 text-sm leading-6 text-[var(--text-secondary)]">
|
||||
{post.description}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2 text-right text-xs text-[var(--text-tertiary)]">
|
||||
<div>{post.date}</div>
|
||||
<div>{t('common.readTime')}: {formatReadTime(locale, post.readTime, t)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-eye text-[10px] text-[var(--primary)]"></i>
|
||||
<span>{t('home.views')}: {item.pageViews}</span>
|
||||
</span>
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-check-double text-[10px] text-[var(--primary)]"></i>
|
||||
<span>{t('home.completes')}: {item.readCompletes}</span>
|
||||
</span>
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-chart-line text-[10px] text-[var(--primary)]"></i>
|
||||
<span>{t('home.avgProgress')}: {formatProgressPercent(item.avgProgressPercent)}</span>
|
||||
</span>
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-stopwatch text-[10px] text-[var(--primary)]"></i>
|
||||
<span>{t('home.avgDuration')}: {formatDurationMs(item.avgDurationMs)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div id="home-popular-empty" class:list={['terminal-empty', initialPopularCount > 0 && 'hidden']}>
|
||||
<p class="text-sm leading-6 text-[var(--text-secondary)]">{t('home.hotNowEmpty')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="terminal-panel space-y-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-[var(--title-color)]">{t('home.readingSignals')}</h3>
|
||||
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
|
||||
{t('home.readingSignalsDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<span id="home-stats-window-pill" class="terminal-stat-pill">{activeContentRange.label}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.views')}</p>
|
||||
<p id="home-reading-views-value" class="mt-3 text-3xl font-semibold text-[var(--title-color)]">{activeContentRange.overview.pageViews}</p>
|
||||
<p class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.totalViews')}: {contentOverview.totalPageViews}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.completes')}</p>
|
||||
<p id="home-reading-completes-value" class="mt-3 text-3xl font-semibold text-[var(--title-color)]">{activeContentRange.overview.readCompletes}</p>
|
||||
<p class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.totalCompletes')}: {contentOverview.totalReadCompletes}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.avgProgress')}</p>
|
||||
<p id="home-reading-progress-value" class="mt-3 text-3xl font-semibold text-[var(--title-color)]">{formatProgressPercent(activeContentRange.overview.avgReadProgress)}</p>
|
||||
<p id="home-reading-window-meta" class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.statsWindowLabel', { label: activeContentRange.label })}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.avgDuration')}</p>
|
||||
<p id="home-reading-duration-value" class="mt-3 text-3xl font-semibold text-[var(--title-color)]">{formatDurationMs(activeContentRange.overview.avgReadDurationMs)}</p>
|
||||
<p class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.totalViews')}: {contentOverview.totalPageViews}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--border-color)] my-8"></div>
|
||||
|
||||
<div id="friends" class="mb-8 px-4">
|
||||
@@ -463,10 +695,17 @@ const navLinks = [
|
||||
previewLimit,
|
||||
categoryAccentMap,
|
||||
tagAccentMap,
|
||||
contentRangesPayload: contentRanges.map((range) => ({
|
||||
key: range.key,
|
||||
label: range.label,
|
||||
days: range.days,
|
||||
overview: range.overview,
|
||||
})),
|
||||
initialHomeState: {
|
||||
type: selectedType,
|
||||
category: selectedCategory,
|
||||
tag: selectedTag,
|
||||
range: activeContentRange.key,
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -493,8 +732,27 @@ const navLinks = [
|
||||
const activeTagText = document.getElementById('home-active-tag-text');
|
||||
const emptyState = document.getElementById('home-posts-empty');
|
||||
const pinnedWrap = document.getElementById('home-pinned-wrap');
|
||||
const popularCards = Array.from(document.querySelectorAll('[data-home-popular-card]'));
|
||||
const popularEmpty = document.getElementById('home-popular-empty');
|
||||
const popularList = document.getElementById('home-popular-list');
|
||||
const popularCount = document.getElementById('home-popular-count');
|
||||
const popularRangeButtons = Array.from(document.querySelectorAll('[data-home-popular-range]'));
|
||||
const popularSortButtons = Array.from(document.querySelectorAll('[data-home-popular-sort]'));
|
||||
const readingWindowPill = document.getElementById('home-stats-window-pill');
|
||||
const readingViewsValue = document.getElementById('home-reading-views-value');
|
||||
const readingCompletesValue = document.getElementById('home-reading-completes-value');
|
||||
const readingProgressValue = document.getElementById('home-reading-progress-value');
|
||||
const readingDurationValue = document.getElementById('home-reading-duration-value');
|
||||
const readingWindowMeta = document.getElementById('home-reading-window-meta');
|
||||
|
||||
promptApi = window.__termiCommandPrompt;
|
||||
const contentRanges = Array.isArray(contentRangesPayload) ? contentRangesPayload : [];
|
||||
const contentRangesMap = contentRanges.reduce((map, item) => {
|
||||
if (item?.key) {
|
||||
map[item.key] = item;
|
||||
}
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
const typeMeta = {
|
||||
all: { icon: 'fa-stream', label: t('common.all') },
|
||||
@@ -506,8 +764,70 @@ const navLinks = [
|
||||
type: initialHomeState.type || 'all',
|
||||
category: initialHomeState.category || '',
|
||||
tag: initialHomeState.tag || '',
|
||||
range: initialHomeState.range || '7d',
|
||||
popularSort: 'views',
|
||||
};
|
||||
|
||||
function getActiveRange() {
|
||||
return contentRangesMap[state.range] || contentRanges[0] || {
|
||||
key: '7d',
|
||||
label: '7d',
|
||||
days: 7,
|
||||
overview: {
|
||||
pageViews: 0,
|
||||
readCompletes: 0,
|
||||
avgReadProgress: 0,
|
||||
avgReadDurationMs: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function syncPopularRangeButtons() {
|
||||
popularRangeButtons.forEach((button) => {
|
||||
button.classList.toggle(
|
||||
'is-active',
|
||||
(button.getAttribute('data-home-popular-range') || '7d') === state.range
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function syncPopularSortButtons() {
|
||||
popularSortButtons.forEach((button) => {
|
||||
button.classList.toggle(
|
||||
'is-active',
|
||||
(button.getAttribute('data-home-popular-sort') || 'views') === state.popularSort
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function sortPopularCards(cards) {
|
||||
const metricKey =
|
||||
state.popularSort === 'completes'
|
||||
? 'data-home-popular-completes'
|
||||
: state.popularSort === 'depth'
|
||||
? 'data-home-popular-depth'
|
||||
: 'data-home-popular-views';
|
||||
|
||||
return [...cards].sort((left, right) => {
|
||||
const leftValue = Number(left.getAttribute(metricKey) || '0');
|
||||
const rightValue = Number(right.getAttribute(metricKey) || '0');
|
||||
|
||||
if (rightValue !== leftValue) {
|
||||
return rightValue - leftValue;
|
||||
}
|
||||
|
||||
const leftViews = Number(left.getAttribute('data-home-popular-views') || '0');
|
||||
const rightViews = Number(right.getAttribute('data-home-popular-views') || '0');
|
||||
if (rightViews !== leftViews) {
|
||||
return rightViews - leftViews;
|
||||
}
|
||||
|
||||
return String(left.getAttribute('data-home-slug') || '').localeCompare(
|
||||
String(right.getAttribute('data-home-slug') || '')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getActiveTokens() {
|
||||
return [
|
||||
state.type !== 'all' ? `type=${state.type}` : '',
|
||||
@@ -518,15 +838,66 @@ const navLinks = [
|
||||
|
||||
function syncPromptText(total) {
|
||||
const tokens = getActiveTokens();
|
||||
const activeRange = getActiveRange();
|
||||
const discoverCommand = tokens.length
|
||||
? t('home.promptDiscoverFiltered', { filters: tokens.join(' · ') })
|
||||
: t('home.promptDiscoverDefault');
|
||||
const postsCommand = tokens.length
|
||||
? t('home.promptPostsFiltered', { count: Math.min(total, previewLimit), filters: tokens.join(' · ') })
|
||||
: t('home.promptPostsDefault', { count: Math.min(total, previewLimit) });
|
||||
const popularCommand = t('home.promptPopularRange', { label: activeRange.label || state.range });
|
||||
|
||||
promptApi?.set?.('home-discover-prompt', discoverCommand, { typing: false });
|
||||
promptApi?.set?.('home-posts-prompt', postsCommand, { typing: false });
|
||||
promptApi?.set?.('home-popular-prompt', popularCommand, { typing: false });
|
||||
}
|
||||
|
||||
function syncRangeMetrics(filteredPopularCount) {
|
||||
const activeRange = getActiveRange();
|
||||
|
||||
if (popularCount) {
|
||||
popularCount.textContent = String(filteredPopularCount);
|
||||
}
|
||||
if (readingWindowPill) {
|
||||
readingWindowPill.textContent = activeRange.label || state.range;
|
||||
}
|
||||
if (readingViewsValue) {
|
||||
readingViewsValue.textContent = String(activeRange.overview?.pageViews ?? 0);
|
||||
}
|
||||
if (readingCompletesValue) {
|
||||
readingCompletesValue.textContent = String(activeRange.overview?.readCompletes ?? 0);
|
||||
}
|
||||
if (readingProgressValue) {
|
||||
readingProgressValue.textContent = `${Math.round(activeRange.overview?.avgReadProgress ?? 0)}%`;
|
||||
}
|
||||
if (readingDurationValue) {
|
||||
const durationValue = activeRange.overview?.avgReadDurationMs;
|
||||
if (!durationValue || durationValue <= 0) {
|
||||
readingDurationValue.textContent = document.documentElement.lang === 'en' ? 'N/A' : '暂无';
|
||||
} else if (durationValue < 1000) {
|
||||
readingDurationValue.textContent = `${Math.round(durationValue)} ms`;
|
||||
} else {
|
||||
const seconds = durationValue / 1000;
|
||||
if (seconds < 60) {
|
||||
readingDurationValue.textContent =
|
||||
document.documentElement.lang === 'en'
|
||||
? `${seconds.toFixed(seconds >= 10 ? 0 : 1)}s`
|
||||
: `${seconds.toFixed(seconds >= 10 ? 0 : 1)} 秒`;
|
||||
} else {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const restSeconds = Math.round(seconds % 60);
|
||||
readingDurationValue.textContent =
|
||||
document.documentElement.lang === 'en'
|
||||
? `${minutes}m ${restSeconds}s`
|
||||
: `${minutes} 分 ${restSeconds} 秒`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (readingWindowMeta) {
|
||||
readingWindowMeta.textContent = t('home.statsWindowLabel', {
|
||||
label: activeRange.label || state.range,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateUrl() {
|
||||
@@ -623,6 +994,23 @@ const navLinks = [
|
||||
|
||||
postCards.forEach((card) => card.classList.add('hidden'));
|
||||
filtered.slice(0, previewLimit).forEach((card) => card.classList.remove('hidden'));
|
||||
const filteredPopular = popularCards.filter((card) => {
|
||||
const range = card.getAttribute('data-home-range') || '7d';
|
||||
const type = card.getAttribute('data-home-type') || '';
|
||||
const category = (card.getAttribute('data-home-category') || '').trim().toLowerCase();
|
||||
const tags = `|${(card.getAttribute('data-home-tags') || '').trim().toLowerCase()}|`;
|
||||
const rangeMatch = range === state.range;
|
||||
const typeMatch = state.type === 'all' || type === state.type;
|
||||
const categoryMatch = !state.category || category === state.category.trim().toLowerCase();
|
||||
const tagMatch = !state.tag || tags.includes(`|${state.tag.trim().toLowerCase()}|`);
|
||||
return rangeMatch && typeMatch && categoryMatch && tagMatch;
|
||||
});
|
||||
const sortedPopular = sortPopularCards(filteredPopular);
|
||||
popularCards.forEach((card) => card.classList.add('hidden'));
|
||||
sortedPopular.forEach((card) => {
|
||||
card.classList.remove('hidden');
|
||||
popularList?.appendChild(card);
|
||||
});
|
||||
|
||||
const total = filtered.length;
|
||||
const hasPinned = filtered.some((card) => card.getAttribute('data-home-pinned') === 'true');
|
||||
@@ -639,10 +1027,16 @@ const navLinks = [
|
||||
if (pinnedWrap) {
|
||||
pinnedWrap.classList.toggle('hidden', !hasPinned);
|
||||
}
|
||||
if (popularEmpty) {
|
||||
popularEmpty.classList.toggle('hidden', filteredPopular.length > 0);
|
||||
}
|
||||
|
||||
syncActiveButtons();
|
||||
syncActiveSummary();
|
||||
syncPostTagSelection();
|
||||
syncPopularRangeButtons();
|
||||
syncPopularSortButtons();
|
||||
syncRangeMetrics(filteredPopular.length);
|
||||
syncPromptText(total);
|
||||
|
||||
if (pushHistory) {
|
||||
@@ -686,6 +1080,20 @@ const navLinks = [
|
||||
applyHomeFilters();
|
||||
});
|
||||
|
||||
popularRangeButtons.forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
state.range = button.getAttribute('data-home-popular-range') || '7d';
|
||||
applyHomeFilters(false);
|
||||
});
|
||||
});
|
||||
|
||||
popularSortButtons.forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
state.popularSort = button.getAttribute('data-home-popular-sort') || 'views';
|
||||
applyHomeFilters(false);
|
||||
});
|
||||
});
|
||||
|
||||
postsRoot?.addEventListener('click', (event) => {
|
||||
const target = event.target instanceof Element ? event.target.closest('a[href*="tag="]') : null;
|
||||
if (!target) return;
|
||||
|
||||
78
frontend/src/pages/og/[slug].svg.ts
Normal file
78
frontend/src/pages/og/[slug].svg.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
import { apiClient, DEFAULT_SITE_SETTINGS } from '../../lib/api/client'
|
||||
|
||||
export const prerender = false
|
||||
|
||||
function escapeXml(value: string) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
function truncate(value: string, maxChars: number) {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed.length <= maxChars) {
|
||||
return trimmed
|
||||
}
|
||||
return `${trimmed.slice(0, maxChars - 1)}…`
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const slug = params.slug?.trim() || ''
|
||||
if (!slug) {
|
||||
return new Response('Missing slug', { status: 400 })
|
||||
}
|
||||
|
||||
const [post, siteSettings] = await Promise.all([
|
||||
apiClient.getPostBySlug(slug),
|
||||
apiClient.getSiteSettings().catch(() => DEFAULT_SITE_SETTINGS),
|
||||
])
|
||||
|
||||
if (!post) {
|
||||
return new Response('Not found', { status: 404 })
|
||||
}
|
||||
|
||||
const title = escapeXml(truncate(post.title, 72))
|
||||
const description = escapeXml(truncate(post.description || siteSettings.siteDescription, 120))
|
||||
const category = escapeXml(post.category || 'Article')
|
||||
const siteName = escapeXml(siteSettings.siteName || 'Termi')
|
||||
|
||||
const svg = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1200" y2="630" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#08111f"/>
|
||||
<stop offset="1" stop-color="#0f1f35"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="accent" x1="0" y1="0" x2="1200" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00ff9d"/>
|
||||
<stop offset="1" stop-color="#00b8ff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="630" fill="url(#bg)"/>
|
||||
<rect x="52" y="48" width="1096" height="534" rx="32" fill="#0D1117" stroke="rgba(255,255,255,0.12)"/>
|
||||
<rect x="52" y="48" width="1096" height="58" rx="32" fill="#111827"/>
|
||||
<circle cx="92" cy="77" r="10" fill="#FF5F56"/>
|
||||
<circle cx="124" cy="77" r="10" fill="#FFBD2E"/>
|
||||
<circle cx="156" cy="77" r="10" fill="#27C93F"/>
|
||||
<text x="190" y="83" fill="#9CA3AF" font-family="'JetBrains Mono', monospace" font-size="22">~/content/posts/${escapeXml(post.slug)}.md</text>
|
||||
<rect x="88" y="150" width="180" height="44" rx="22" fill="rgba(0,255,157,0.12)" stroke="url(#accent)"/>
|
||||
<text x="178" y="178" text-anchor="middle" fill="#8BFFD3" font-family="'JetBrains Mono', monospace" font-size="24">${category}</text>
|
||||
<text x="88" y="274" fill="#F8FAFC" font-family="'IBM Plex Sans', Arial, sans-serif" font-size="64" font-weight="700">${title}</text>
|
||||
<text x="88" y="358" fill="#CBD5E1" font-family="'IBM Plex Sans', Arial, sans-serif" font-size="32">${description}</text>
|
||||
<rect x="88" y="500" width="1024" height="2" fill="rgba(255,255,255,0.08)"/>
|
||||
<text x="88" y="548" fill="#00FF9D" font-family="'JetBrains Mono', monospace" font-size="28">${siteName}</text>
|
||||
<text x="1112" y="548" text-anchor="end" fill="#94A3B8" font-family="'JetBrains Mono', monospace" font-size="24">${escapeXml(post.date)}</text>
|
||||
</svg>`
|
||||
|
||||
return new Response(svg, {
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import Layout from '../../layouts/BaseLayout.astro';
|
||||
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||
import FilterPill from '../../components/ui/FilterPill.astro';
|
||||
import ResponsiveImage from '../../components/ui/ResponsiveImage.astro';
|
||||
import { apiClient } from '../../lib/api/client';
|
||||
import { getI18n } from '../../lib/i18n';
|
||||
import type { Review } from '../../lib/api/client';
|
||||
@@ -290,11 +291,13 @@ const statCards = [
|
||||
>
|
||||
<div class="review-card__poster">
|
||||
{review.coverUrl ? (
|
||||
<img
|
||||
<ResponsiveImage
|
||||
src={review.coverUrl}
|
||||
alt={`${review.title} cover`}
|
||||
class="review-card__poster-image"
|
||||
loading="lazy"
|
||||
pictureClass="block h-full w-full"
|
||||
imgClass="review-card__poster-image"
|
||||
widths={[320, 480, 720, 960]}
|
||||
sizes="(min-width: 1536px) 18vw, (min-width: 1024px) 28vw, (min-width: 640px) 45vw, 100vw"
|
||||
/>
|
||||
) : (
|
||||
<div class="review-card__poster-fallback">
|
||||
@@ -319,7 +322,7 @@ const statCards = [
|
||||
{statusLabels[review.normalizedStatus]}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="review-card__title">{review.title}</h3>
|
||||
<h2 class="review-card__title">{review.title}</h2>
|
||||
</div>
|
||||
<div class="review-card__rating">
|
||||
<div class="review-card__rating-value">{review.rating || 0}.0</div>
|
||||
|
||||
33
frontend/src/pages/robots.txt.ts
Normal file
33
frontend/src/pages/robots.txt.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client'
|
||||
|
||||
export const prerender = false
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const fallbackOrigin = new URL(request.url).origin
|
||||
|
||||
let siteUrl = DEFAULT_SITE_SETTINGS.siteUrl
|
||||
try {
|
||||
const settings = await api.getSiteSettings()
|
||||
siteUrl = settings.siteUrl || siteUrl
|
||||
} catch {
|
||||
siteUrl = DEFAULT_SITE_SETTINGS.siteUrl
|
||||
}
|
||||
|
||||
const base = (siteUrl || fallbackOrigin).replace(/\/$/, '')
|
||||
|
||||
const body = `User-agent: *
|
||||
Allow: /
|
||||
Disallow: /admin
|
||||
|
||||
Sitemap: ${base}/sitemap.xml
|
||||
`
|
||||
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=600',
|
||||
},
|
||||
})
|
||||
}
|
||||
78
frontend/src/pages/rss.xml.ts
Normal file
78
frontend/src/pages/rss.xml.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client'
|
||||
|
||||
export const prerender = false
|
||||
|
||||
function escapeXml(value: string) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
function ensureAbsoluteUrl(baseUrl: string, path: string) {
|
||||
const normalizedBase = baseUrl.replace(/\/$/, '')
|
||||
return `${normalizedBase}${path.startsWith('/') ? path : `/${path}`}`
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const fallbackOrigin = new URL(request.url).origin
|
||||
let siteSettings = DEFAULT_SITE_SETTINGS
|
||||
let posts = await api.getRawPosts().catch(() => [])
|
||||
|
||||
try {
|
||||
siteSettings = await api.getSiteSettings()
|
||||
} catch {
|
||||
siteSettings = DEFAULT_SITE_SETTINGS
|
||||
}
|
||||
|
||||
const siteUrl = (siteSettings.siteUrl || fallbackOrigin).replace(/\/$/, '')
|
||||
const feedTitle = siteSettings.siteTitle || siteSettings.siteName || 'Blog RSS Feed'
|
||||
const feedDescription =
|
||||
siteSettings.siteDescription || `${siteSettings.siteName || 'Blog'} RSS Feed`
|
||||
|
||||
const sortedPosts = [...posts]
|
||||
.filter((post) => !post.noindex)
|
||||
.sort((left, right) => right.created_at.localeCompare(left.created_at))
|
||||
.slice(0, 60)
|
||||
|
||||
const itemsXml = sortedPosts
|
||||
.map((post) => {
|
||||
const title = escapeXml(post.title || 'Untitled')
|
||||
const description = escapeXml(post.description || '')
|
||||
const link = ensureAbsoluteUrl(siteUrl, `/articles/${post.slug}`)
|
||||
const guid = escapeXml(link)
|
||||
const pubDate = new Date(post.updated_at || post.created_at).toUTCString()
|
||||
|
||||
return `
|
||||
<item>
|
||||
<title>${title}</title>
|
||||
<link>${escapeXml(link)}</link>
|
||||
<guid isPermaLink="true">${guid}</guid>
|
||||
<description>${description}</description>
|
||||
<pubDate>${escapeXml(pubDate)}</pubDate>
|
||||
</item>`
|
||||
})
|
||||
.join('')
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>${escapeXml(feedTitle)}</title>
|
||||
<link>${escapeXml(siteUrl)}</link>
|
||||
<description>${escapeXml(feedDescription)}</description>
|
||||
<language>zh-CN</language>
|
||||
<lastBuildDate>${escapeXml(new Date().toUTCString())}</lastBuildDate>${itemsXml}
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
return new Response(xml, {
|
||||
headers: {
|
||||
'Content-Type': 'application/rss+xml; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=600',
|
||||
},
|
||||
})
|
||||
}
|
||||
280
frontend/src/pages/search/index.astro
Normal file
280
frontend/src/pages/search/index.astro
Normal file
@@ -0,0 +1,280 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||
import FilterPill from '../../components/ui/FilterPill.astro';
|
||||
import PostCard from '../../components/PostCard.astro';
|
||||
import { api } from '../../lib/api/client';
|
||||
import { getI18n } from '../../lib/i18n';
|
||||
import type { Post } from '../../lib/types';
|
||||
import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../../lib/utils';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const url = new URL(Astro.request.url);
|
||||
const selectedQuery = url.searchParams.get('q')?.trim() || '';
|
||||
const selectedType = url.searchParams.get('type') || 'all';
|
||||
const selectedTag = url.searchParams.get('tag') || '';
|
||||
const selectedCategory = url.searchParams.get('category') || '';
|
||||
const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||
const normalizedSelectedCategory = selectedCategory.trim().toLowerCase();
|
||||
const { t } = getI18n(Astro);
|
||||
|
||||
let searchResults: Post[] = [];
|
||||
let apiError: string | null = null;
|
||||
|
||||
if (selectedQuery) {
|
||||
try {
|
||||
searchResults = await api.searchPosts(selectedQuery, 40);
|
||||
} catch (error) {
|
||||
apiError = error instanceof Error ? error.message : t('common.apiUnavailable');
|
||||
console.error('Search page error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredResults = searchResults.filter((post) => {
|
||||
const normalizedCategory = post.category?.trim().toLowerCase() || '';
|
||||
if (selectedType !== 'all' && post.type !== selectedType) return false;
|
||||
if (selectedCategory && normalizedCategory !== normalizedSelectedCategory) return false;
|
||||
if (selectedTag && !post.tags.some((tag) => tag.trim().toLowerCase() === normalizedSelectedTag)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const categoryCounts = Array.from(
|
||||
searchResults.reduce((map, post) => {
|
||||
const name = post.category?.trim();
|
||||
if (!name) return map;
|
||||
map.set(name, (map.get(name) ?? 0) + 1);
|
||||
return map;
|
||||
}, new Map<string, number>()),
|
||||
)
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
|
||||
|
||||
const tagCounts = Array.from(
|
||||
searchResults.reduce((map, post) => {
|
||||
for (const tag of post.tags) {
|
||||
const name = tag.trim();
|
||||
if (!name) continue;
|
||||
map.set(name, (map.get(name) ?? 0) + 1);
|
||||
}
|
||||
return map;
|
||||
}, new Map<string, number>()),
|
||||
)
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
|
||||
|
||||
const postTypeFilters = [
|
||||
{ id: 'all', name: t('common.all'), icon: 'fa-stream' },
|
||||
{ id: 'article', name: t('common.article'), icon: 'fa-file-alt' },
|
||||
{ id: 'tweet', name: t('common.tweet'), icon: 'fa-comment-dots' },
|
||||
];
|
||||
|
||||
const buildSearchUrl = ({
|
||||
q = selectedQuery,
|
||||
type = selectedType,
|
||||
tag = selectedTag,
|
||||
category = selectedCategory,
|
||||
}: {
|
||||
q?: string;
|
||||
type?: string;
|
||||
tag?: string;
|
||||
category?: string;
|
||||
}) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (q) params.set('q', q);
|
||||
if (type && type !== 'all') params.set('type', type);
|
||||
if (category) params.set('category', category);
|
||||
if (tag) params.set('tag', tag);
|
||||
|
||||
const queryString = params.toString();
|
||||
return queryString ? `/search?${queryString}` : '/search';
|
||||
};
|
||||
|
||||
const activeQueryCommand = selectedQuery
|
||||
? t('searchPage.promptQuery', { query: selectedQuery })
|
||||
: t('searchPage.promptIdle');
|
||||
const searchTagHrefBase = buildSearchUrl({ tag: '' });
|
||||
const searchTagHrefPrefix = `${searchTagHrefBase}${searchTagHrefBase.includes('?') ? '&' : '?'}tag=`;
|
||||
const pageTitle = selectedQuery
|
||||
? `${t('searchPage.pageTitle')} · ${selectedQuery}`
|
||||
: t('searchPage.pageTitle');
|
||||
---
|
||||
|
||||
<BaseLayout title={`${pageTitle} - Termi`} canonical="/search" noindex>
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<TerminalWindow title="~/search/index" class="w-full">
|
||||
<div class="px-4 pb-2">
|
||||
<CommandPrompt command={activeQueryCommand} />
|
||||
|
||||
<div class="ml-4 mt-4 space-y-3">
|
||||
<h1 class="text-3xl font-bold tracking-tight text-[var(--title-color)]">{t('searchPage.title')}</h1>
|
||||
<p class="max-w-3xl text-sm leading-7 text-[var(--text-secondary)]">
|
||||
{t('searchPage.intro')}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-magnifying-glass text-[var(--primary)]"></i>
|
||||
<span>{t('searchPage.resultSummary', { count: searchResults.length })}</span>
|
||||
</span>
|
||||
{selectedQuery && (
|
||||
<span class="terminal-stat-pill terminal-stat-pill--accent" style={getAccentVars(getPostTypeTheme('article'))}>
|
||||
<i class="fas fa-terminal"></i>
|
||||
<span>{t('searchPage.queryLabel')}: {selectedQuery}</span>
|
||||
</span>
|
||||
)}
|
||||
{(selectedType !== 'all' || selectedCategory || selectedTag) && (
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-filter text-[var(--primary)]"></i>
|
||||
<span>{t('searchPage.filteredSummary', { count: filteredResults.length })}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/60 px-4 py-3 text-sm leading-6 text-[var(--text-secondary)]">
|
||||
<i class="fas fa-circle-info mr-2 text-[var(--primary)]"></i>
|
||||
{t('searchPage.searchTips')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{apiError && (
|
||||
<div class="px-4 pb-4">
|
||||
<div class="ml-4 rounded-2xl border border-[var(--danger)]/20 bg-[var(--danger)]/10 p-4 text-sm text-[var(--danger)]">
|
||||
{apiError}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedQuery ? (
|
||||
<div class="px-4 py-8">
|
||||
<div class="terminal-empty ml-4">
|
||||
<div class="mx-auto flex max-w-md flex-col items-center gap-3 text-center">
|
||||
<span class="terminal-section-icon">
|
||||
<i class="fas fa-magnifying-glass"></i>
|
||||
</span>
|
||||
<h2 class="text-xl font-semibold text-[var(--title-color)]">{t('searchPage.emptyQueryTitle')}</h2>
|
||||
<p class="text-sm leading-7 text-[var(--text-secondary)]">
|
||||
{t('searchPage.emptyQueryDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div class="px-4 pb-2 space-y-4">
|
||||
<div class="ml-4">
|
||||
<CommandPrompt command={t('searchPage.filtersTitle')} typing={false} />
|
||||
<div class="mt-3 flex flex-wrap gap-3">
|
||||
{postTypeFilters.map((filter) => (
|
||||
<FilterPill
|
||||
href={buildSearchUrl({ type: filter.id })}
|
||||
tone={filter.id === 'all' ? 'neutral' : 'accent'}
|
||||
active={selectedType === filter.id}
|
||||
style={filter.id === 'all' ? undefined : getAccentVars(getPostTypeTheme(filter.id))}
|
||||
>
|
||||
<i class={`fas ${filter.icon}`}></i>
|
||||
<span class="font-medium">{filter.name}</span>
|
||||
</FilterPill>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{categoryCounts.length > 0 && (
|
||||
<div class="ml-4">
|
||||
<div class="mt-3 flex flex-wrap gap-3">
|
||||
<FilterPill
|
||||
href={buildSearchUrl({ category: '' })}
|
||||
tone="amber"
|
||||
active={!selectedCategory}
|
||||
>
|
||||
<i class="fas fa-folder-tree"></i>
|
||||
<span class="font-medium">{t('searchPage.allCategories')}</span>
|
||||
</FilterPill>
|
||||
{categoryCounts.map((category) => (
|
||||
<FilterPill
|
||||
href={buildSearchUrl({ category: category.name })}
|
||||
tone="accent"
|
||||
active={selectedCategory.toLowerCase() === category.name.toLowerCase()}
|
||||
style={getAccentVars(getCategoryTheme(category.name))}
|
||||
>
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<span class="font-medium">{category.name}</span>
|
||||
<span class="text-xs text-[var(--text-tertiary)]">{category.count}</span>
|
||||
</FilterPill>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tagCounts.length > 0 && (
|
||||
<div class="ml-4">
|
||||
<div class="mt-3 flex flex-wrap gap-3">
|
||||
<FilterPill
|
||||
href={buildSearchUrl({ tag: '' })}
|
||||
tone="teal"
|
||||
active={!selectedTag}
|
||||
>
|
||||
<i class="fas fa-hashtag"></i>
|
||||
<span class="font-medium">{t('searchPage.allTags')}</span>
|
||||
</FilterPill>
|
||||
{tagCounts.map((tag) => (
|
||||
<FilterPill
|
||||
href={buildSearchUrl({ tag: tag.name })}
|
||||
tone="accent"
|
||||
active={normalizedSelectedTag === tag.name.toLowerCase()}
|
||||
style={getAccentVars(getTagTheme(tag.name))}
|
||||
>
|
||||
<i class="fas fa-hashtag"></i>
|
||||
<span class="font-medium">{tag.name}</span>
|
||||
<span class="text-xs text-[var(--text-tertiary)]">{tag.count}</span>
|
||||
</FilterPill>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="px-4 pb-8">
|
||||
{filteredResults.length > 0 ? (
|
||||
<div class="ml-4 mt-4 space-y-4">
|
||||
{filteredResults.map((post) => (
|
||||
<PostCard
|
||||
post={post}
|
||||
selectedTag={selectedTag}
|
||||
highlightTerm={selectedQuery}
|
||||
tagHrefPrefix={searchTagHrefPrefix}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="terminal-empty ml-4 mt-4">
|
||||
<div class="mx-auto flex max-w-md flex-col items-center gap-3 text-center">
|
||||
<span class="terminal-section-icon">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
</span>
|
||||
<h2 class="text-xl font-semibold text-[var(--title-color)]">{t('searchPage.emptyTitle')}</h2>
|
||||
<p class="text-sm leading-7 text-[var(--text-secondary)]">
|
||||
{t('searchPage.emptyDescription')}
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<a href={buildSearchUrl({ type: 'all', category: '', tag: '' })} class="terminal-action-button terminal-action-button-primary">
|
||||
<i class="fas fa-rotate-left"></i>
|
||||
<span>{t('common.resetFilters')}</span>
|
||||
</a>
|
||||
<a href={`/ask?q=${encodeURIComponent(selectedQuery)}`} class="terminal-action-button">
|
||||
<i class="fas fa-robot"></i>
|
||||
<span>{t('searchPage.askFallback')}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TerminalWindow>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
87
frontend/src/pages/sitemap.xml.ts
Normal file
87
frontend/src/pages/sitemap.xml.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client'
|
||||
|
||||
export const prerender = false
|
||||
|
||||
function escapeXml(value: string) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
function ensureAbsoluteUrl(baseUrl: string, path: string) {
|
||||
const normalizedBase = baseUrl.replace(/\/$/, '')
|
||||
return `${normalizedBase}${path.startsWith('/') ? path : `/${path}`}`
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const fallbackOrigin = new URL(request.url).origin
|
||||
|
||||
let siteSettings = DEFAULT_SITE_SETTINGS
|
||||
let posts = await api.getRawPosts().catch(() => [])
|
||||
|
||||
try {
|
||||
siteSettings = await api.getSiteSettings()
|
||||
} catch {
|
||||
siteSettings = DEFAULT_SITE_SETTINGS
|
||||
}
|
||||
|
||||
const siteUrl = (siteSettings.siteUrl || fallbackOrigin).replace(/\/$/, '')
|
||||
const nowIso = new Date().toISOString()
|
||||
|
||||
const staticRoutes = [
|
||||
'/',
|
||||
'/about',
|
||||
'/articles',
|
||||
'/categories',
|
||||
'/tags',
|
||||
'/timeline',
|
||||
'/reviews',
|
||||
'/friends',
|
||||
'/ask',
|
||||
'/rss.xml',
|
||||
]
|
||||
|
||||
const staticUrls = staticRoutes.map((path) => ({
|
||||
loc: ensureAbsoluteUrl(siteUrl, path),
|
||||
lastmod: nowIso,
|
||||
changefreq: path === '/' ? 'hourly' : 'daily',
|
||||
priority: path === '/' ? '1.0' : '0.8',
|
||||
}))
|
||||
|
||||
const postUrls = posts
|
||||
.filter((post) => !post.noindex)
|
||||
.map((post) => ({
|
||||
loc: ensureAbsoluteUrl(siteUrl, `/articles/${post.slug}`),
|
||||
lastmod: new Date(post.updated_at || post.created_at).toISOString(),
|
||||
changefreq: 'weekly',
|
||||
priority: post.pinned ? '0.9' : '0.7',
|
||||
}))
|
||||
|
||||
const xmlBody = [...staticUrls, ...postUrls]
|
||||
.map(
|
||||
(item) => `
|
||||
<url>
|
||||
<loc>${escapeXml(item.loc)}</loc>
|
||||
<lastmod>${escapeXml(item.lastmod)}</lastmod>
|
||||
<changefreq>${item.changefreq}</changefreq>
|
||||
<priority>${item.priority}</priority>
|
||||
</url>`,
|
||||
)
|
||||
.join('')
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${xmlBody}
|
||||
</urlset>`
|
||||
|
||||
return new Response(xml, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=600',
|
||||
},
|
||||
})
|
||||
}
|
||||
100
frontend/src/pages/subscriptions/confirm.astro
Normal file
100
frontend/src/pages/subscriptions/confirm.astro
Normal file
@@ -0,0 +1,100 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { createApiClient } from '../../lib/api/client';
|
||||
|
||||
const token = Astro.url.searchParams.get('token')?.trim() ?? '';
|
||||
const api = createApiClient({ requestUrl: Astro.url });
|
||||
|
||||
let success = false;
|
||||
let message = '缺少确认令牌。';
|
||||
let manageToken = '';
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const response = await api.confirmSubscription(token);
|
||||
success = true;
|
||||
manageToken = response.subscription.manage_token?.trim() ?? '';
|
||||
message = '订阅已确认,现在开始你会收到后续更新。';
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : '确认失败,请稍后重试。';
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout title="确认订阅" description="确认订阅邮件中的链接,激活后续通知。">
|
||||
<section class="subscription-shell">
|
||||
<div class="subscription-card">
|
||||
<p class="subscription-kicker">subscriptions / confirm</p>
|
||||
<h1>确认订阅</h1>
|
||||
<p class={`subscription-status ${success ? 'is-success' : 'is-warning'}`}>{message}</p>
|
||||
<div class="subscription-actions">
|
||||
<a href="/">返回首页</a>
|
||||
{manageToken && <a href={`/subscriptions/manage?token=${encodeURIComponent(manageToken)}`}>管理偏好</a>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.subscription-shell {
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem 4rem;
|
||||
}
|
||||
|
||||
.subscription-card {
|
||||
border: 1px solid rgba(94, 234, 212, 0.16);
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 0.86), rgba(15, 23, 42, 0.72));
|
||||
border-radius: 1.25rem;
|
||||
padding: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.subscription-kicker {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--primary);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.22em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.subscription-status {
|
||||
margin: 1rem 0 0;
|
||||
padding: 0.95rem 1rem;
|
||||
border-radius: 0.95rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.subscription-status.is-success {
|
||||
background: rgba(16, 185, 129, 0.14);
|
||||
color: #d1fae5;
|
||||
}
|
||||
|
||||
.subscription-status.is-warning {
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.subscription-actions {
|
||||
margin-top: 1.25rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.subscription-actions a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
color: var(--text-primary);
|
||||
padding: 0.8rem 1rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
330
frontend/src/pages/subscriptions/manage.astro
Normal file
330
frontend/src/pages/subscriptions/manage.astro
Normal file
@@ -0,0 +1,330 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import {
|
||||
createApiClient,
|
||||
resolvePublicApiBaseUrl,
|
||||
type PublicManagedSubscription,
|
||||
} from '../../lib/api/client';
|
||||
|
||||
const token = Astro.url.searchParams.get('token')?.trim() ?? '';
|
||||
const api = createApiClient({ requestUrl: Astro.url });
|
||||
const apiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||
const EVENT_OPTIONS = [
|
||||
{ value: 'post.published', label: '新文章通知' },
|
||||
{ value: 'digest.weekly', label: '周报 Digest' },
|
||||
{ value: 'digest.monthly', label: '月报 Digest' },
|
||||
{ value: 'comment.created', label: '评论通知' },
|
||||
{ value: 'friend_link.created', label: '友链申请通知' },
|
||||
] as const;
|
||||
|
||||
const asObject = (value: unknown) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {} as Record<string, unknown>;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const normalizeStringList = (value: unknown) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [] as string[];
|
||||
}
|
||||
return value
|
||||
.map((item) => String(item).trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
let subscription: PublicManagedSubscription | null = null;
|
||||
let errorMessage = '';
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const response = await api.getManagedSubscription(token);
|
||||
subscription = response.subscription;
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : '无法加载订阅信息。';
|
||||
}
|
||||
} else {
|
||||
errorMessage = '缺少管理令牌。';
|
||||
}
|
||||
|
||||
const filters = asObject(subscription?.filters);
|
||||
const initialEvents = normalizeStringList(filters.event_types);
|
||||
const initialCategories = normalizeStringList(filters.categories).join(', ');
|
||||
const initialTags = normalizeStringList(filters.tags).join(', ');
|
||||
const initialDisplayName = subscription?.display_name ?? '';
|
||||
const initialStatus = subscription?.status === 'paused' ? 'paused' : 'active';
|
||||
---
|
||||
|
||||
<BaseLayout title="管理订阅偏好" description="调整订阅偏好、暂停订阅或查看当前订阅状态。">
|
||||
<section class="subscription-shell">
|
||||
<div class="subscription-card" data-subscription-manage-root data-api-base={apiBaseUrl}>
|
||||
<p class="subscription-kicker">subscriptions / manage</p>
|
||||
<h1>管理订阅偏好</h1>
|
||||
{subscription ? (
|
||||
<>
|
||||
<p class="subscription-copy">当前目标:<strong>{subscription.target}</strong> · 频道:{subscription.channel_type}</p>
|
||||
<p class="subscription-copy">验证状态:{subscription.verified_at ? '已确认' : '待确认'} · 当前状态:{subscription.status}</p>
|
||||
|
||||
<form class="subscription-form" data-manage-form>
|
||||
<input type="hidden" name="token" value={token} />
|
||||
<label>
|
||||
<span>称呼</span>
|
||||
<input type="text" name="displayName" value={initialDisplayName} placeholder="例如:主邮箱 / 运营邮箱" />
|
||||
</label>
|
||||
|
||||
<fieldset>
|
||||
<legend>通知类型</legend>
|
||||
<div class="subscription-grid">
|
||||
{EVENT_OPTIONS.map((item) => (
|
||||
<label class="subscription-checkbox">
|
||||
<input type="checkbox" name="eventTypes" value={item.value} checked={initialEvents.includes(item.value)} />
|
||||
<span>{item.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="subscription-split">
|
||||
<label>
|
||||
<span>只看这些分类(可选)</span>
|
||||
<input type="text" name="categories" value={initialCategories} placeholder="Rust, Astro, 日志" />
|
||||
</label>
|
||||
<label>
|
||||
<span>只看这些标签(可选)</span>
|
||||
<input type="text" name="tags" value={initialTags} placeholder="web, ai, deploy" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend>订阅状态</legend>
|
||||
<div class="subscription-grid compact">
|
||||
<label class="subscription-checkbox">
|
||||
<input type="radio" name="status" value="active" checked={initialStatus === 'active'} />
|
||||
<span>继续接收</span>
|
||||
</label>
|
||||
<label class="subscription-checkbox">
|
||||
<input type="radio" name="status" value="paused" checked={initialStatus === 'paused'} />
|
||||
<span>先暂停</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="subscription-actions">
|
||||
<button type="submit">保存偏好</button>
|
||||
<a href={`/subscriptions/unsubscribe?token=${encodeURIComponent(token)}`}>去退订</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="subscription-status" data-manage-status>你可以随时在这里调整通知类型、暂停或退订。</p>
|
||||
</>
|
||||
) : (
|
||||
<p class="subscription-status is-warning">{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
{subscription && (
|
||||
<script>
|
||||
const root = document.querySelector('[data-subscription-manage-root]');
|
||||
const form = root?.querySelector?.('[data-manage-form]');
|
||||
const status = root?.querySelector?.('[data-manage-status]');
|
||||
const apiBase = root?.getAttribute?.('data-api-base') || '';
|
||||
|
||||
if (form instanceof HTMLFormElement && status instanceof HTMLElement && apiBase) {
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(form);
|
||||
const token = String(formData.get('token') || '').trim();
|
||||
const displayName = String(formData.get('displayName') || '').trim();
|
||||
const statusValue = String(formData.get('status') || 'active').trim();
|
||||
const categories = String(formData.get('categories') || '')
|
||||
.split(/[,,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const tags = String(formData.get('tags') || '')
|
||||
.split(/[,,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const eventTypes = formData
|
||||
.getAll('eventTypes')
|
||||
.map((item) => String(item).trim())
|
||||
.filter(Boolean);
|
||||
|
||||
status.textContent = '保存中...';
|
||||
|
||||
const filters = {
|
||||
event_types: eventTypes,
|
||||
categories,
|
||||
tags,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/subscriptions/manage`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
displayName: displayName || null,
|
||||
status: statusValue,
|
||||
filters,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.description || payload?.message || '保存失败,请稍后重试。');
|
||||
}
|
||||
status.textContent = '偏好已保存。';
|
||||
} catch (error) {
|
||||
status.textContent = error instanceof Error ? error.message : '保存失败,请稍后重试。';
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
)}
|
||||
|
||||
<style>
|
||||
.subscription-shell {
|
||||
max-width: 54rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem 4rem;
|
||||
}
|
||||
|
||||
.subscription-card {
|
||||
border: 1px solid rgba(94, 234, 212, 0.16);
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 0.86), rgba(15, 23, 42, 0.72));
|
||||
border-radius: 1.25rem;
|
||||
padding: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.subscription-kicker {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--primary);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.22em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.subscription-copy {
|
||||
margin: 0.65rem 0 0;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.subscription-form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-top: 1.4rem;
|
||||
}
|
||||
|
||||
.subscription-form label,
|
||||
.subscription-form fieldset {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: 1rem;
|
||||
padding: 1rem;
|
||||
background: rgba(15, 23, 42, 0.24);
|
||||
}
|
||||
|
||||
.subscription-form span,
|
||||
.subscription-form legend {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.subscription-form input[type='text'] {
|
||||
width: 100%;
|
||||
border-radius: 0.8rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
color: var(--text-primary);
|
||||
padding: 0.85rem 0.95rem;
|
||||
}
|
||||
|
||||
.subscription-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.subscription-grid.compact {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.subscription-checkbox {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 0.95rem !important;
|
||||
}
|
||||
|
||||
.subscription-checkbox input {
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
.subscription-split {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.subscription-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.85rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.subscription-actions button,
|
||||
.subscription-actions a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
border: 0;
|
||||
padding: 0.9rem 1.05rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subscription-actions button {
|
||||
color: #08111f;
|
||||
background: linear-gradient(135deg, var(--primary), #8b5cf6);
|
||||
}
|
||||
|
||||
.subscription-actions a {
|
||||
color: var(--text-primary);
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
}
|
||||
|
||||
.subscription-status {
|
||||
margin: 1rem 0 0;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.subscription-status.is-warning {
|
||||
padding: 0.95rem 1rem;
|
||||
border-radius: 0.95rem;
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.subscription-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.subscription-split {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
133
frontend/src/pages/subscriptions/unsubscribe.astro
Normal file
133
frontend/src/pages/subscriptions/unsubscribe.astro
Normal file
@@ -0,0 +1,133 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { resolvePublicApiBaseUrl } from '../../lib/api/client';
|
||||
|
||||
const token = Astro.url.searchParams.get('token')?.trim() ?? '';
|
||||
const apiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||
---
|
||||
|
||||
<BaseLayout title="取消订阅" description="如果你不再需要站点通知,可以在这里安全退订。">
|
||||
<section class="subscription-shell">
|
||||
<div class="subscription-card" data-unsubscribe-root data-token={token} data-api-base={apiBaseUrl}>
|
||||
<p class="subscription-kicker">subscriptions / unsubscribe</p>
|
||||
<h1>取消订阅</h1>
|
||||
<p class="subscription-copy">如果你不再想接收通知,可以点击下面的按钮完成退订。后续也可以重新订阅并再次确认。</p>
|
||||
<div class="subscription-actions">
|
||||
<button type="button" data-unsubscribe-button disabled={!token}>确认退订</button>
|
||||
{token && <a href={`/subscriptions/manage?token=${encodeURIComponent(token)}`}>返回偏好页</a>}
|
||||
<a href="/">返回首页</a>
|
||||
</div>
|
||||
<p class="subscription-status" data-unsubscribe-status>
|
||||
{token ? '确认后会立即把当前订阅标记为已退订。' : '缺少退订令牌。'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('[data-unsubscribe-root]').forEach((root) => {
|
||||
const button = root.querySelector('[data-unsubscribe-button]');
|
||||
const status = root.querySelector('[data-unsubscribe-status]');
|
||||
const token = root.getAttribute('data-token') || '';
|
||||
const apiBase = root.getAttribute('data-api-base') || '';
|
||||
|
||||
if (!(button instanceof HTMLButtonElement) || !(status instanceof HTMLElement) || !token || !apiBase) {
|
||||
return;
|
||||
}
|
||||
|
||||
button.disabled = false;
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
status.textContent = '退订中...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/subscriptions/unsubscribe`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.description || payload?.message || '退订失败,请稍后再试。');
|
||||
}
|
||||
status.textContent = '已成功退订,后续不会再收到该订阅的通知。';
|
||||
} catch (error) {
|
||||
status.textContent = error instanceof Error ? error.message : '退订失败,请稍后再试。';
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.subscription-shell {
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem 4rem;
|
||||
}
|
||||
|
||||
.subscription-card {
|
||||
border: 1px solid rgba(94, 234, 212, 0.16);
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 0.86), rgba(15, 23, 42, 0.72));
|
||||
border-radius: 1.25rem;
|
||||
padding: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.subscription-kicker {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--primary);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.22em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.subscription-copy {
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.subscription-actions {
|
||||
margin-top: 1.25rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.subscription-actions button,
|
||||
.subscription-actions a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
padding: 0.85rem 1rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.subscription-actions button {
|
||||
border: 0;
|
||||
font-weight: 600;
|
||||
color: #08111f;
|
||||
background: linear-gradient(135deg, #f97316, #ef4444);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subscription-actions a {
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.subscription-status {
|
||||
margin: 1rem 0 0;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.8;
|
||||
}
|
||||
</style>
|
||||
@@ -120,9 +120,9 @@ const latestYear = years[0] || 'all';
|
||||
<span class="terminal-chip terminal-chip--accent text-[10px] py-1 px-2" style={getAccentVars(getPostTypeTheme(post.type))}>
|
||||
{post.type === 'article' ? t('common.article') : t('common.tweet')}
|
||||
</span>
|
||||
<h3 class="font-bold text-[var(--title-color)] group-hover:text-[var(--primary)] transition-colors truncate text-lg">
|
||||
<h2 class="font-bold text-[var(--title-color)] group-hover:text-[var(--primary)] transition-colors truncate text-lg">
|
||||
{post.title}
|
||||
</h3>
|
||||
</h2>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 leading-6">{post.description}</p>
|
||||
<div class="flex items-center gap-2 mt-2 flex-wrap">
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
--text: #0f172a;
|
||||
--text-rgb: 15 23 42;
|
||||
--text-secondary: #475569;
|
||||
--text-tertiary: #7c8aa0;
|
||||
--text-tertiary: #5f6f86;
|
||||
--terminal-text: #0f172a;
|
||||
--title-color: #0f172a;
|
||||
--button-text: #0f172a;
|
||||
@@ -95,7 +95,7 @@ html.dark {
|
||||
--text: #e6e6e6;
|
||||
--text-rgb: 230 230 230;
|
||||
--text-secondary: #d1d5db;
|
||||
--text-tertiary: #6b7280;
|
||||
--text-tertiary: #9ca3af;
|
||||
--terminal-text: #e6e6e6;
|
||||
--title-color: #ffffff;
|
||||
--button-text: #e6e6e6;
|
||||
@@ -144,7 +144,7 @@ html.dark {
|
||||
--text: #e6e6e6;
|
||||
--text-rgb: 230 230 230;
|
||||
--text-secondary: #d1d5db;
|
||||
--text-tertiary: #6b7280;
|
||||
--text-tertiary: #9ca3af;
|
||||
--terminal-text: #e6e6e6;
|
||||
--title-color: #ffffff;
|
||||
--button-text: #e6e6e6;
|
||||
@@ -777,6 +777,33 @@ html.dark {
|
||||
color: color-mix(in oklab, var(--accent-color, var(--primary)) 78%, var(--title-color));
|
||||
}
|
||||
|
||||
.home-popular-sortbar {
|
||||
@apply flex flex-wrap gap-2;
|
||||
}
|
||||
|
||||
.home-popular-sort {
|
||||
@apply inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-mono transition-all duration-200;
|
||||
border-color: color-mix(in oklab, var(--primary) 12%, var(--border-color));
|
||||
background: color-mix(in oklab, var(--terminal-bg) 96%, transparent);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.home-popular-sort:hover {
|
||||
border-color: color-mix(in oklab, var(--primary) 22%, var(--border-color));
|
||||
background: color-mix(in oklab, var(--primary) 6%, var(--terminal-bg));
|
||||
color: var(--title-color);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.home-popular-sort.is-active {
|
||||
border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));
|
||||
background: color-mix(in oklab, var(--primary) 10%, var(--terminal-bg));
|
||||
color: var(--title-color);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(var(--primary-rgb), 0.08),
|
||||
0 10px 24px rgba(var(--text-rgb), 0.04);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.home-tag-cloud__item:hover {
|
||||
transform: translateY(-1px);
|
||||
|
||||
Reference in New Issue
Block a user