feat: ship blog platform admin and deploy stack

This commit is contained in:
2026-03-31 21:48:39 +08:00
parent a9a05aa105
commit 313f174fbc
210 changed files with 25476 additions and 5803 deletions

5
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
.git
.gitignore
*.log

30
frontend/Dockerfile Normal file
View 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"]

View File

@@ -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`

View File

@@ -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,
});

View File

@@ -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",

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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();

View File

@@ -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>

View File

@@ -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">

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View 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}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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
View 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(', ');
}

View File

@@ -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
View 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,
});
}
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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');

View File

@@ -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>

View 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',
},
},
)

View File

@@ -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;

View 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;')
}
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',
},
})
}

View File

@@ -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>

View 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',
},
})
}

View 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;')
}
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',
},
})
}

View 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>

View 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;')
}
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',
},
})
}

View 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>

View 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>

View 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>

View File

@@ -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">

View File

@@ -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);