feat: ship public ops features and cache docker builds
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Has been cancelled
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Has been cancelled
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Has been cancelled
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Has been cancelled
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.8",
|
||||
"@types/node": "^25.5.0",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
72
frontend/pnpm-lock.yaml
generated
72
frontend/pnpm-lock.yaml
generated
@@ -13,25 +13,25 @@ importers:
|
||||
version: 7.0.1
|
||||
'@astrojs/node':
|
||||
specifier: ^10.0.4
|
||||
version: 10.0.4(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))
|
||||
version: 10.0.4(astro@6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))
|
||||
'@astrojs/svelte':
|
||||
specifier: ^8.0.3
|
||||
version: 8.0.3(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))(jiti@1.21.7)(svelte@5.55.0)(typescript@6.0.2)(yaml@2.8.3)
|
||||
version: 8.0.3(@types/node@25.5.0)(astro@6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))(jiti@1.21.7)(svelte@5.55.0)(typescript@6.0.2)(yaml@2.8.3)
|
||||
'@astrojs/tailwind':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))(tailwindcss@3.4.19)
|
||||
version: 6.0.2(astro@6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))(tailwindcss@3.4.19)
|
||||
'@tailwindcss/typography':
|
||||
specifier: ^0.5.19
|
||||
version: 0.5.19(tailwindcss@3.4.19)
|
||||
astro:
|
||||
specifier: ^6.0.8
|
||||
version: 6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)
|
||||
version: 6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)
|
||||
autoprefixer:
|
||||
specifier: ^10.4.27
|
||||
version: 10.4.27(postcss@8.5.8)
|
||||
lucide-astro:
|
||||
specifier: ^0.556.0
|
||||
version: 0.556.0(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))
|
||||
version: 0.556.0(astro@6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))
|
||||
postcss:
|
||||
specifier: ^8.5.8
|
||||
version: 8.5.8
|
||||
@@ -48,6 +48,9 @@ importers:
|
||||
'@astrojs/check':
|
||||
specifier: ^0.9.8
|
||||
version: 0.9.8(prettier@3.8.1)(typescript@6.0.2)
|
||||
'@types/node':
|
||||
specifier: ^25.5.0
|
||||
version: 25.5.0
|
||||
typescript:
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
@@ -701,6 +704,9 @@ packages:
|
||||
'@types/nlcst@2.0.3':
|
||||
resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==}
|
||||
|
||||
'@types/node@25.5.0':
|
||||
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
@@ -1864,6 +1870,9 @@ packages:
|
||||
uncrypto@0.1.3:
|
||||
resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
|
||||
|
||||
undici-types@7.18.2:
|
||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||
|
||||
unified@11.0.5:
|
||||
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
||||
|
||||
@@ -2247,10 +2256,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@astrojs/node@10.0.4(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))':
|
||||
'@astrojs/node@10.0.4(astro@6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))':
|
||||
dependencies:
|
||||
'@astrojs/internal-helpers': 0.8.0
|
||||
astro: 6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)
|
||||
astro: 6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)
|
||||
send: 1.2.1
|
||||
server-destroy: 1.0.1
|
||||
transitivePeerDependencies:
|
||||
@@ -2260,14 +2269,14 @@ snapshots:
|
||||
dependencies:
|
||||
prismjs: 1.30.0
|
||||
|
||||
'@astrojs/svelte@8.0.3(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))(jiti@1.21.7)(svelte@5.55.0)(typescript@6.0.2)(yaml@2.8.3)':
|
||||
'@astrojs/svelte@8.0.3(@types/node@25.5.0)(astro@6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))(jiti@1.21.7)(svelte@5.55.0)(typescript@6.0.2)(yaml@2.8.3)':
|
||||
dependencies:
|
||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.0)(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3))
|
||||
astro: 6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)
|
||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.0)(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3))
|
||||
astro: 6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)
|
||||
svelte: 5.55.0
|
||||
svelte2tsx: 0.7.52(svelte@5.55.0)(typescript@6.0.2)
|
||||
typescript: 6.0.2
|
||||
vite: 7.3.1(jiti@1.21.7)(yaml@2.8.3)
|
||||
vite: 7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- jiti
|
||||
@@ -2281,9 +2290,9 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
'@astrojs/tailwind@6.0.2(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))(tailwindcss@3.4.19)':
|
||||
'@astrojs/tailwind@6.0.2(astro@6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))(tailwindcss@3.4.19)':
|
||||
dependencies:
|
||||
astro: 6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)
|
||||
astro: 6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)
|
||||
autoprefixer: 10.4.27(postcss@8.5.8)
|
||||
postcss: 8.5.8
|
||||
postcss-load-config: 4.0.2(postcss@8.5.8)
|
||||
@@ -2695,22 +2704,22 @@ snapshots:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
|
||||
'@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.0)(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3)))(svelte@5.55.0)(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3))':
|
||||
'@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.0)(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3)))(svelte@5.55.0)(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3))':
|
||||
dependencies:
|
||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.0)(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3))
|
||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.0)(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3))
|
||||
obug: 2.1.1
|
||||
svelte: 5.55.0
|
||||
vite: 7.3.1(jiti@1.21.7)(yaml@2.8.3)
|
||||
vite: 7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3)
|
||||
|
||||
'@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.0)(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3))':
|
||||
'@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.0)(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3))':
|
||||
dependencies:
|
||||
'@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.0)(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3)))(svelte@5.55.0)(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3))
|
||||
'@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.0)(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3)))(svelte@5.55.0)(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3))
|
||||
deepmerge: 4.3.1
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.1
|
||||
svelte: 5.55.0
|
||||
vite: 7.3.1(jiti@1.21.7)(yaml@2.8.3)
|
||||
vitefu: 1.1.2(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3))
|
||||
vite: 7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3)
|
||||
vitefu: 1.1.2(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3))
|
||||
|
||||
'@tailwindcss/typography@0.5.19(tailwindcss@3.4.19)':
|
||||
dependencies:
|
||||
@@ -2737,6 +2746,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/node@25.5.0':
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
@@ -2831,7 +2844,7 @@ snapshots:
|
||||
|
||||
array-iterate@2.0.1: {}
|
||||
|
||||
astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3):
|
||||
astro@6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3):
|
||||
dependencies:
|
||||
'@astrojs/compiler': 3.0.1
|
||||
'@astrojs/internal-helpers': 0.8.0
|
||||
@@ -2883,8 +2896,8 @@ snapshots:
|
||||
unist-util-visit: 5.1.0
|
||||
unstorage: 1.17.4
|
||||
vfile: 6.0.3
|
||||
vite: 7.3.1(jiti@1.21.7)(yaml@2.8.3)
|
||||
vitefu: 1.1.2(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3))
|
||||
vite: 7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3)
|
||||
vitefu: 1.1.2(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3))
|
||||
xxhash-wasm: 1.1.0
|
||||
yargs-parser: 22.0.0
|
||||
zod: 4.3.6
|
||||
@@ -3403,9 +3416,9 @@ snapshots:
|
||||
|
||||
lru-cache@11.2.7: {}
|
||||
|
||||
lucide-astro@0.556.0(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)):
|
||||
lucide-astro@0.556.0(astro@6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)):
|
||||
dependencies:
|
||||
astro: 6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)
|
||||
astro: 6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
@@ -4285,6 +4298,8 @@ snapshots:
|
||||
|
||||
uncrypto@0.1.3: {}
|
||||
|
||||
undici-types@7.18.2: {}
|
||||
|
||||
unified@11.0.5:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -4377,7 +4392,7 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
vite@7.3.1(jiti@1.21.7)(yaml@2.8.3):
|
||||
vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3):
|
||||
dependencies:
|
||||
esbuild: 0.27.4
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
@@ -4386,13 +4401,14 @@ snapshots:
|
||||
rollup: 4.60.0
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 25.5.0
|
||||
fsevents: 2.3.3
|
||||
jiti: 1.21.7
|
||||
yaml: 2.8.3
|
||||
|
||||
vitefu@1.1.2(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3)):
|
||||
vitefu@1.1.2(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3)):
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(jiti@1.21.7)(yaml@2.8.3)
|
||||
vite: 7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3)
|
||||
|
||||
volar-service-css@0.0.70(@volar/language-service@2.4.28):
|
||||
dependencies:
|
||||
|
||||
51
frontend/public/termi-web-push-sw.js
Normal file
51
frontend/public/termi-web-push-sw.js
Normal file
@@ -0,0 +1,51 @@
|
||||
self.addEventListener('push', (event) => {
|
||||
const payload = (() => {
|
||||
if (!event.data) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return event.data.json();
|
||||
} catch {
|
||||
return {
|
||||
body: event.data.text(),
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
const title = payload.title || '订阅更新';
|
||||
const url = payload.url || '/';
|
||||
const options = {
|
||||
body: payload.body || '',
|
||||
icon: payload.icon || '/favicon.svg',
|
||||
badge: payload.badge || '/favicon.ico',
|
||||
tag: payload.tag || 'termi-subscription',
|
||||
data: {
|
||||
url,
|
||||
...(payload.data || {}),
|
||||
},
|
||||
};
|
||||
|
||||
event.waitUntil(self.registration.showNotification(title, options));
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
const targetUrl = event.notification?.data?.url || '/';
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
|
||||
for (const client of clients) {
|
||||
if ('focus' in client && client.url === targetUrl) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow(targetUrl);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1,16 +1,25 @@
|
||||
---
|
||||
import { apiClient, resolvePublicApiBaseUrl } from '../lib/api/client';
|
||||
import {
|
||||
apiClient,
|
||||
resolvePublicApiBaseUrl,
|
||||
resolvePublicCommentTurnstileSiteKey,
|
||||
} from '../lib/api/client';
|
||||
import { getI18n } from '../lib/i18n';
|
||||
import type { Comment } from '../lib/api/client';
|
||||
import type { SiteSettings } from '../lib/types';
|
||||
|
||||
interface Props {
|
||||
postSlug: string;
|
||||
class?: string;
|
||||
siteSettings: SiteSettings;
|
||||
}
|
||||
|
||||
const { postSlug, class: className = '' } = Astro.props;
|
||||
const { postSlug, class: className = '', siteSettings } = Astro.props as Props;
|
||||
const { locale, t } = getI18n(Astro);
|
||||
const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||
const turnstileSiteKey = siteSettings.comments.turnstileEnabled
|
||||
? siteSettings.comments.turnstileSiteKey || resolvePublicCommentTurnstileSiteKey()
|
||||
: '';
|
||||
|
||||
let comments: Comment[] = [];
|
||||
let error: string | null = null;
|
||||
@@ -36,7 +45,12 @@ function formatCommentDate(dateStr: string): string {
|
||||
}
|
||||
---
|
||||
|
||||
<div class={`terminal-comments ${className}`} data-post-slug={postSlug} data-api-base={publicApiBaseUrl}>
|
||||
<div
|
||||
class={`terminal-comments ${className}`}
|
||||
data-post-slug={postSlug}
|
||||
data-api-base={publicApiBaseUrl}
|
||||
data-turnstile-site-key={turnstileSiteKey || undefined}
|
||||
>
|
||||
<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">
|
||||
@@ -115,23 +129,38 @@ function formatCommentDate(dateStr: string): string {
|
||||
<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)]">
|
||||
验证码
|
||||
{t('common.humanVerification')}
|
||||
</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>
|
||||
{turnstileSiteKey ? (
|
||||
<span class="text-xs text-[var(--text-tertiary)]">Cloudflare Turnstile</span>
|
||||
) : (
|
||||
<button type="button" id="refresh-captcha" class="terminal-action-button px-3 py-2 text-xs">
|
||||
<i class="fas fa-rotate-right"></i>
|
||||
<span>{t('common.refresh')}</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"
|
||||
/>
|
||||
|
||||
{turnstileSiteKey ? (
|
||||
<>
|
||||
<div class="mt-3" data-turnstile-container></div>
|
||||
<input type="hidden" name="turnstileToken" />
|
||||
<p class="mt-3 text-sm text-[var(--text-secondary)]">{t('common.turnstileHint')}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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">
|
||||
@@ -228,6 +257,8 @@ function formatCommentDate(dateStr: string): string {
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { mountTurnstile, type MountedTurnstile } from '../lib/utils/turnstile';
|
||||
|
||||
const t = window.__termiTranslate;
|
||||
const wrapper = document.querySelector('.terminal-comments');
|
||||
const toggleBtn = document.getElementById('toggle-comment-form');
|
||||
@@ -243,8 +274,12 @@ function formatCommentDate(dateStr: string): string {
|
||||
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;
|
||||
const turnstileSiteKey = wrapper?.getAttribute('data-turnstile-site-key') || '';
|
||||
const turnstileContainer = form?.querySelector('[data-turnstile-container]') as HTMLElement | null;
|
||||
const turnstileTokenInput = form?.querySelector('input[name="turnstileToken"]') as HTMLInputElement | null;
|
||||
const captchaTokenInput = form?.querySelector('input[name="captchaToken"]') as HTMLInputElement | null;
|
||||
const captchaAnswerInput = form?.querySelector('input[name="captchaAnswer"]') as HTMLInputElement | null;
|
||||
let turnstileWidget: MountedTurnstile | null = null;
|
||||
|
||||
function showMessage(message: string, type: 'success' | 'error' | 'info') {
|
||||
if (!messageBox) return;
|
||||
@@ -316,6 +351,54 @@ function formatCommentDate(dateStr: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureTurnstile(showError = true) {
|
||||
if (!turnstileSiteKey || !turnstileContainer || !turnstileTokenInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
turnstileTokenInput.value = '';
|
||||
|
||||
if (turnstileWidget) {
|
||||
turnstileWidget.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
turnstileWidget = await mountTurnstile(turnstileContainer, {
|
||||
siteKey: turnstileSiteKey,
|
||||
onToken(token) {
|
||||
turnstileTokenInput.value = token;
|
||||
},
|
||||
onExpire() {
|
||||
turnstileTokenInput.value = '';
|
||||
},
|
||||
onError() {
|
||||
turnstileTokenInput.value = '';
|
||||
if (showError) {
|
||||
showMessage(t('common.turnstileLoadFailed'), 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (showError) {
|
||||
showMessage(
|
||||
error instanceof Error ? error.message : t('common.turnstileLoadFailed'),
|
||||
'error',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetHumanCheck() {
|
||||
if (turnstileSiteKey) {
|
||||
turnstileTokenInput && (turnstileTokenInput.value = '');
|
||||
turnstileWidget?.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
void loadCaptcha(false);
|
||||
}
|
||||
|
||||
toggleBtn?.addEventListener('click', () => {
|
||||
formContainer?.classList.toggle('hidden');
|
||||
if (!formContainer?.classList.contains('hidden')) {
|
||||
@@ -360,6 +443,14 @@ function formatCommentDate(dateStr: string): string {
|
||||
const formData = new FormData(form);
|
||||
const replyToId = replyingTo?.getAttribute('data-reply-to');
|
||||
|
||||
if (turnstileSiteKey) {
|
||||
const token = String(formData.get('turnstileToken') || '').trim();
|
||||
if (!token) {
|
||||
showMessage(t('common.turnstileRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
showMessage(t('comments.submitting'), 'info');
|
||||
|
||||
@@ -375,6 +466,7 @@ function formatCommentDate(dateStr: string): string {
|
||||
content: formData.get('content'),
|
||||
scope: 'article',
|
||||
replyToCommentId: replyToId ? Number(replyToId) : null,
|
||||
turnstileToken: formData.get('turnstileToken'),
|
||||
captchaToken: formData.get('captchaToken'),
|
||||
captchaAnswer: formData.get('captchaAnswer'),
|
||||
website: formData.get('website'),
|
||||
@@ -390,10 +482,10 @@ function formatCommentDate(dateStr: string): string {
|
||||
resetReply();
|
||||
formContainer?.classList.add('hidden');
|
||||
showMessage(t('comments.submitSuccess'), 'success');
|
||||
void loadCaptcha(false);
|
||||
resetHumanCheck();
|
||||
} catch (error) {
|
||||
showMessage(t('comments.submitFailed', { message: error instanceof Error ? error.message : t('common.unknownError') }), 'error');
|
||||
void loadCaptcha(false);
|
||||
resetHumanCheck();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -410,5 +502,9 @@ function formatCommentDate(dateStr: string): string {
|
||||
});
|
||||
});
|
||||
|
||||
void loadCaptcha(false);
|
||||
if (turnstileSiteKey) {
|
||||
void ensureTurnstile(false);
|
||||
} else {
|
||||
void loadCaptcha(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
---
|
||||
import { resolvePublicApiBaseUrl } from '../lib/api/client';
|
||||
import {
|
||||
resolvePublicApiBaseUrl,
|
||||
resolvePublicCommentTurnstileSiteKey,
|
||||
} from '../lib/api/client';
|
||||
import { getI18n } from '../lib/i18n';
|
||||
import type { SiteSettings } from '../lib/types';
|
||||
|
||||
interface Props {
|
||||
postSlug: string;
|
||||
class?: string;
|
||||
siteSettings: SiteSettings;
|
||||
}
|
||||
|
||||
const { postSlug, class: className = '' } = Astro.props;
|
||||
const { postSlug, class: className = '', siteSettings } = Astro.props as Props;
|
||||
const { t } = getI18n(Astro);
|
||||
const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||
const turnstileSiteKey = siteSettings.comments.turnstileEnabled
|
||||
? siteSettings.comments.turnstileSiteKey || resolvePublicCommentTurnstileSiteKey()
|
||||
: '';
|
||||
---
|
||||
|
||||
<div
|
||||
@@ -17,6 +25,7 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||
data-post-slug={postSlug}
|
||||
data-api-base={publicApiBaseUrl}
|
||||
data-storage-key={`termi:paragraph-comments:${postSlug}`}
|
||||
data-turnstile-site-key={turnstileSiteKey || undefined}
|
||||
>
|
||||
<div class="paragraph-comments-toolbar terminal-panel-muted">
|
||||
<div class="paragraph-comments-toolbar-copy">
|
||||
@@ -45,6 +54,7 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||
const t = window.__termiTranslate;
|
||||
const locale = document.documentElement.lang || 'zh-CN';
|
||||
import { buildParagraphDescriptors } from '../lib/utils/paragraph-comments';
|
||||
import { mountTurnstile, type MountedTurnstile } from '../lib/utils/turnstile';
|
||||
|
||||
interface BrowserComment {
|
||||
id: number;
|
||||
@@ -73,6 +83,7 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||
const postSlug = wrapper?.dataset.postSlug || '';
|
||||
const apiBase = wrapper?.dataset.apiBase || '/api';
|
||||
const storageKey = wrapper?.dataset.storageKey || 'termi:paragraph-comments';
|
||||
const turnstileSiteKey = wrapper?.dataset.turnstileSiteKey || '';
|
||||
const articleRoot = wrapper?.closest('[data-article-slug]') as HTMLElement | null;
|
||||
const articleContent = articleRoot?.querySelector('.article-content') as HTMLElement | null;
|
||||
const summaryText = wrapper?.querySelector('[data-summary-text]') as HTMLElement | null;
|
||||
@@ -93,6 +104,7 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||
let activeReplyToCommentId: number | null = null;
|
||||
let pendingCounter = 0;
|
||||
let markersVisible = true;
|
||||
let turnstileWidget: MountedTurnstile | null = null;
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
@@ -345,22 +357,32 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||
|
||||
<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>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--text-tertiary)]">${escapeHtml(t('common.humanVerification'))}</p>
|
||||
${
|
||||
turnstileSiteKey
|
||||
? `<span class="text-xs text-[var(--text-tertiary)]">Cloudflare Turnstile</span>`
|
||||
: `<button type="button" class="terminal-action-button px-3 py-2 text-xs" data-refresh-captcha>
|
||||
<i class="fas fa-rotate-right"></i>
|
||||
<span>${escapeHtml(t('common.refresh'))}</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"
|
||||
/>
|
||||
${
|
||||
turnstileSiteKey
|
||||
? `<div class="mt-3" data-turnstile-container></div>
|
||||
<input type="hidden" name="turnstileToken" />
|
||||
<p class="mt-3 text-sm text-[var(--text-secondary)]">${escapeHtml(t('common.turnstileHint'))}</p>`
|
||||
: `<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">
|
||||
@@ -389,8 +411,10 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||
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;
|
||||
const turnstileContainer = form.querySelector('[data-turnstile-container]') as HTMLElement | null;
|
||||
const turnstileTokenInput = form.querySelector('input[name="turnstileToken"]') as HTMLInputElement | null;
|
||||
const captchaTokenInput = form.querySelector('input[name="captchaToken"]') as HTMLInputElement | null;
|
||||
const captchaAnswerInput = form.querySelector('input[name="captchaAnswer"]') as HTMLInputElement | null;
|
||||
|
||||
function clearStatus() {
|
||||
statusBox.className = 'paragraph-comment-status hidden';
|
||||
@@ -433,6 +457,54 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureTurnstile(showStatusOnError = true) {
|
||||
if (!turnstileSiteKey || !turnstileContainer || !turnstileTokenInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
turnstileTokenInput.value = '';
|
||||
|
||||
if (turnstileWidget) {
|
||||
turnstileWidget.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
turnstileWidget = await mountTurnstile(turnstileContainer, {
|
||||
siteKey: turnstileSiteKey,
|
||||
onToken(token) {
|
||||
turnstileTokenInput.value = token;
|
||||
},
|
||||
onExpire() {
|
||||
turnstileTokenInput.value = '';
|
||||
},
|
||||
onError() {
|
||||
turnstileTokenInput.value = '';
|
||||
if (showStatusOnError) {
|
||||
setStatus(t('common.turnstileLoadFailed'), 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (showStatusOnError) {
|
||||
setStatus(
|
||||
error instanceof Error ? error.message : t('common.turnstileLoadFailed'),
|
||||
'error',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetHumanCheck() {
|
||||
if (turnstileSiteKey) {
|
||||
turnstileTokenInput && (turnstileTokenInput.value = '');
|
||||
turnstileWidget?.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
void loadCaptcha(false);
|
||||
}
|
||||
|
||||
function resetReplyState() {
|
||||
activeReplyToCommentId = null;
|
||||
replyBanner.classList.add('hidden');
|
||||
@@ -642,7 +714,11 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||
descriptor.element.insertAdjacentElement('afterend', panel);
|
||||
panel.classList.remove('hidden');
|
||||
panel.dataset.paragraphKey = paragraphKey;
|
||||
if (!captchaTokenInput.value) {
|
||||
if (turnstileSiteKey) {
|
||||
if (!turnstileTokenInput?.value) {
|
||||
await ensureTurnstile(false);
|
||||
}
|
||||
} else if (!captchaTokenInput?.value) {
|
||||
await loadCaptcha(false);
|
||||
}
|
||||
|
||||
@@ -773,6 +849,14 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||
clearStatus();
|
||||
setStatus(t('paragraphComments.submitting'), 'info');
|
||||
|
||||
if (turnstileSiteKey) {
|
||||
const token = String(formData.get('turnstileToken') || '').trim();
|
||||
if (!token) {
|
||||
setStatus(t('common.turnstileRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/comments`, {
|
||||
method: 'POST',
|
||||
@@ -788,6 +872,7 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||
paragraphKey: descriptor.key,
|
||||
paragraphExcerpt: descriptor.excerpt,
|
||||
replyToCommentId: activeReplyToCommentId,
|
||||
turnstileToken: formData.get('turnstileToken'),
|
||||
captchaToken: formData.get('captchaToken'),
|
||||
captchaAnswer: formData.get('captchaAnswer'),
|
||||
website: formData.get('website'),
|
||||
@@ -814,10 +899,10 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||
const approvedComments = await loadThread(descriptor.key, false);
|
||||
renderThread(descriptor.key, approvedComments);
|
||||
setStatus(t('paragraphComments.submitSuccess'), 'success');
|
||||
void loadCaptcha(false);
|
||||
resetHumanCheck();
|
||||
} catch (error) {
|
||||
setStatus(t('paragraphComments.submitFailed', { message: error instanceof Error ? error.message : t('common.unknownError') }), 'error');
|
||||
void loadCaptcha(false);
|
||||
resetHumanCheck();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -873,7 +958,11 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||
|
||||
updateMarkerState();
|
||||
applyMarkerVisibility(markersVisible, { persist: false });
|
||||
await loadCaptcha(false);
|
||||
if (turnstileSiteKey) {
|
||||
await ensureTurnstile(false);
|
||||
} else {
|
||||
await loadCaptcha(false);
|
||||
}
|
||||
await openFromHash();
|
||||
window.addEventListener('hashchange', () => {
|
||||
void openFromHash();
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
---
|
||||
import { resolvePublicApiBaseUrl } from '../lib/api/client';
|
||||
import {
|
||||
resolvePublicApiBaseUrl,
|
||||
resolvePublicCommentTurnstileSiteKey,
|
||||
resolvePublicWebPushVapidPublicKey,
|
||||
} from '../lib/api/client';
|
||||
import type { SiteSettings } from '../lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -9,7 +13,14 @@ interface Props {
|
||||
|
||||
const { requestUrl, siteSettings } = Astro.props as Props;
|
||||
const subscribeApiUrl = `${resolvePublicApiBaseUrl(requestUrl)}/subscriptions`;
|
||||
const browserPushApiUrl = `${resolvePublicApiBaseUrl(requestUrl)}/subscriptions/browser-push`;
|
||||
const popupSettings = siteSettings.subscriptions;
|
||||
const turnstileSiteKey = popupSettings.turnstileEnabled
|
||||
? popupSettings.turnstileSiteKey || resolvePublicCommentTurnstileSiteKey()
|
||||
: '';
|
||||
const webPushPublicKey = popupSettings.webPushEnabled
|
||||
? popupSettings.webPushVapidPublicKey || resolvePublicWebPushVapidPublicKey()
|
||||
: '';
|
||||
---
|
||||
|
||||
{popupSettings.popupEnabled && (
|
||||
@@ -17,7 +28,10 @@ const popupSettings = siteSettings.subscriptions;
|
||||
class="subscription-popup-root"
|
||||
data-subscription-popup-root
|
||||
data-api-url={subscribeApiUrl}
|
||||
data-browser-push-api-url={browserPushApiUrl}
|
||||
data-delay-ms={String(Math.max(popupSettings.popupDelaySeconds, 3) * 1000)}
|
||||
data-turnstile-site-key={turnstileSiteKey || undefined}
|
||||
data-web-push-public-key={webPushPublicKey || undefined}
|
||||
hidden
|
||||
>
|
||||
<section
|
||||
@@ -51,6 +65,7 @@ const popupSettings = siteSettings.subscriptions;
|
||||
</div>
|
||||
|
||||
<div class="subscription-popup-badges" aria-hidden="true">
|
||||
{webPushPublicKey && <span class="subscription-popup-badge">浏览器提醒</span>}
|
||||
<span class="subscription-popup-badge">新文章</span>
|
||||
<span class="subscription-popup-badge">汇总简报</span>
|
||||
<span class="subscription-popup-badge">低频提醒</span>
|
||||
@@ -58,6 +73,12 @@ const popupSettings = siteSettings.subscriptions;
|
||||
</div>
|
||||
|
||||
<div class="subscription-popup-meta">
|
||||
{webPushPublicKey && (
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-bell text-[var(--primary)]"></i>
|
||||
浏览器授权后生效
|
||||
</span>
|
||||
)}
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-newspaper text-[var(--primary)]"></i>
|
||||
新文章 / 汇总简报
|
||||
@@ -73,7 +94,37 @@ const popupSettings = siteSettings.subscriptions;
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{webPushPublicKey && (
|
||||
<div class="terminal-panel-muted flex flex-col gap-4 rounded-2xl px-4 py-4">
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
|
||||
浏览器推送
|
||||
</p>
|
||||
<p class="text-sm leading-6 text-[var(--text-secondary)]">
|
||||
直接在浏览器收到新文章 / 汇总提醒,不用再等邮箱确认。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="terminal-action-button terminal-action-button-primary"
|
||||
data-subscription-popup-browser-push
|
||||
>
|
||||
开启浏览器提醒
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form class="subscription-popup-form" data-subscription-popup-form>
|
||||
{webPushPublicKey && (
|
||||
<div class="mb-4 flex items-center gap-3 text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
|
||||
<span>或使用邮箱</span>
|
||||
<span class="h-px flex-1 bg-[var(--border-color)]"></span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label class="subscription-popup-field">
|
||||
<span class="subscription-popup-field-label">邮箱地址</span>
|
||||
<input
|
||||
@@ -86,6 +137,19 @@ const popupSettings = siteSettings.subscriptions;
|
||||
/>
|
||||
</label>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<div class="mt-4 rounded-2xl border border-[var(--border-color)] bg-[var(--header-bg)]/60 px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
|
||||
人机验证
|
||||
</p>
|
||||
<span class="text-xs text-[var(--text-tertiary)]">Cloudflare Turnstile</span>
|
||||
</div>
|
||||
<div class="mt-3" data-subscription-popup-turnstile></div>
|
||||
<input type="hidden" name="turnstileToken" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="subscription-popup-actions">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -112,6 +176,13 @@ const popupSettings = siteSettings.subscriptions;
|
||||
)}
|
||||
|
||||
<script>
|
||||
import { mountTurnstile, type MountedTurnstile } from '../lib/utils/turnstile';
|
||||
import {
|
||||
ensureBrowserPushSubscription,
|
||||
getBrowserPushSubscription,
|
||||
supportsBrowserPush,
|
||||
} from '../lib/utils/web-push';
|
||||
|
||||
(() => {
|
||||
const DISMISS_KEY = 'termi:subscription-popup:dismiss-until';
|
||||
const SUBSCRIBED_KEY = 'termi:subscription-popup:subscribed-at';
|
||||
@@ -127,6 +198,16 @@ const popupSettings = siteSettings.subscriptions;
|
||||
const emailInput = root.querySelector('[data-subscription-popup-email]');
|
||||
const dismissButton = root.querySelector('[data-subscription-popup-dismiss]');
|
||||
const apiUrl = root.getAttribute('data-api-url');
|
||||
const browserPushApiUrl = root.getAttribute('data-browser-push-api-url');
|
||||
const browserPushPublicKey = root.getAttribute('data-web-push-public-key') || '';
|
||||
const browserPushButton = root.querySelector('[data-subscription-popup-browser-push]');
|
||||
const turnstileSiteKey = root.getAttribute('data-turnstile-site-key') || '';
|
||||
const turnstileContainer = root.querySelector(
|
||||
'[data-subscription-popup-turnstile]',
|
||||
) as HTMLElement | null;
|
||||
const turnstileTokenInput = form?.querySelector(
|
||||
'input[name="turnstileToken"]',
|
||||
) as HTMLInputElement | null;
|
||||
const pathname = window.location.pathname || '/';
|
||||
const delayMs = Math.max(3000, Number(root.getAttribute('data-delay-ms') || '18000'));
|
||||
const defaultStatus = status instanceof HTMLElement ? status.textContent?.trim() || '' : '';
|
||||
@@ -148,6 +229,7 @@ const popupSettings = siteSettings.subscriptions;
|
||||
let autoOpened = false;
|
||||
let hideTimer = 0;
|
||||
let successTimer = 0;
|
||||
let turnstileWidget: MountedTurnstile | null = null;
|
||||
const header = document.querySelector('header');
|
||||
|
||||
const shouldFocusEmail = () =>
|
||||
@@ -197,6 +279,30 @@ const popupSettings = siteSettings.subscriptions;
|
||||
status.textContent = defaultStatus;
|
||||
};
|
||||
|
||||
const setPending = (message: string) => {
|
||||
status.dataset.state = 'pending';
|
||||
status.textContent = message;
|
||||
};
|
||||
|
||||
const setError = (message: string) => {
|
||||
status.dataset.state = 'error';
|
||||
status.textContent = message;
|
||||
};
|
||||
|
||||
const setSuccess = (message: string) => {
|
||||
status.dataset.state = 'success';
|
||||
status.textContent = message;
|
||||
};
|
||||
|
||||
const updateBrowserPushButtonLabel = (label: string, disabled = false) => {
|
||||
if (!(browserPushButton instanceof HTMLButtonElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
browserPushButton.textContent = label;
|
||||
browserPushButton.disabled = disabled;
|
||||
};
|
||||
|
||||
const openPopup = ({ focusEmail = false } = {}) => {
|
||||
if (opened || hasSubmitted()) {
|
||||
return;
|
||||
@@ -216,6 +322,9 @@ const popupSettings = siteSettings.subscriptions;
|
||||
if (focusEmail && shouldFocusEmail()) {
|
||||
emailInput.focus({ preventScroll: true });
|
||||
}
|
||||
if (turnstileSiteKey) {
|
||||
void ensureTurnstile(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -264,7 +373,80 @@ const popupSettings = siteSettings.subscriptions;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureTurnstile = async (showError = true) => {
|
||||
if (!turnstileSiteKey || !turnstileContainer || !turnstileTokenInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
turnstileTokenInput.value = '';
|
||||
|
||||
if (turnstileWidget) {
|
||||
turnstileWidget.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
turnstileWidget = await mountTurnstile(turnstileContainer, {
|
||||
siteKey: turnstileSiteKey,
|
||||
onToken(token) {
|
||||
turnstileTokenInput.value = token;
|
||||
},
|
||||
onExpire() {
|
||||
turnstileTokenInput.value = '';
|
||||
},
|
||||
onError() {
|
||||
turnstileTokenInput.value = '';
|
||||
if (showError) {
|
||||
setError('加载人机验证失败,请刷新页面后重试。');
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (showError) {
|
||||
setError(error instanceof Error ? error.message : '加载人机验证失败,请刷新页面后重试。');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetHumanCheck = () => {
|
||||
if (!turnstileSiteKey || !turnstileTokenInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
turnstileTokenInput.value = '';
|
||||
turnstileWidget?.reset();
|
||||
};
|
||||
|
||||
const syncBrowserPushState = async () => {
|
||||
if (!browserPushPublicKey || !(browserPushButton instanceof HTMLButtonElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!supportsBrowserPush()) {
|
||||
updateBrowserPushButtonLabel('当前浏览器不支持 Web Push', true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = await getBrowserPushSubscription();
|
||||
if (subscription) {
|
||||
rememberSubmitted();
|
||||
updateBrowserPushButtonLabel('浏览器提醒已开启', true);
|
||||
if (!opened) {
|
||||
root.hidden = true;
|
||||
}
|
||||
} else if (Notification.permission === 'denied') {
|
||||
updateBrowserPushButtonLabel('通知权限已被拒绝', false);
|
||||
} else {
|
||||
updateBrowserPushButtonLabel('开启浏览器提醒', false);
|
||||
}
|
||||
} catch {
|
||||
updateBrowserPushButtonLabel('开启浏览器提醒', false);
|
||||
}
|
||||
};
|
||||
|
||||
syncPopupOffset();
|
||||
void syncBrowserPushState();
|
||||
|
||||
if (header instanceof HTMLElement && typeof ResizeObserver !== 'undefined') {
|
||||
const observer = new ResizeObserver(() => syncPopupOffset());
|
||||
@@ -306,6 +488,59 @@ const popupSettings = siteSettings.subscriptions;
|
||||
}
|
||||
});
|
||||
|
||||
browserPushButton instanceof HTMLButtonElement &&
|
||||
browserPushButton.addEventListener('click', async () => {
|
||||
if (!browserPushPublicKey || !browserPushApiUrl) {
|
||||
setError('浏览器推送尚未配置完成。');
|
||||
return;
|
||||
}
|
||||
|
||||
if (turnstileSiteKey) {
|
||||
const token = turnstileTokenInput?.value.trim() || '';
|
||||
if (!token) {
|
||||
setError('请先完成人机验证。');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setPending('正在申请浏览器通知权限...');
|
||||
updateBrowserPushButtonLabel('处理中...', true);
|
||||
|
||||
try {
|
||||
const subscription = await ensureBrowserPushSubscription(browserPushPublicKey);
|
||||
const response = await fetch(browserPushApiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
subscription,
|
||||
source: 'frontend-popup',
|
||||
turnstileToken: turnstileTokenInput?.value || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
payload?.message || payload?.description || '浏览器推送开启失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
|
||||
rememberSubmitted();
|
||||
resetHumanCheck();
|
||||
setSuccess(payload?.message || '浏览器推送已开启,后续新内容会直接提醒。');
|
||||
updateBrowserPushButtonLabel('浏览器提醒已开启', true);
|
||||
successTimer = window.setTimeout(() => closePopup(false), 2200);
|
||||
} catch (error) {
|
||||
resetHumanCheck();
|
||||
setError(
|
||||
error instanceof Error ? error.message : '浏览器推送开启失败,请稍后重试。',
|
||||
);
|
||||
updateBrowserPushButtonLabel('开启浏览器提醒', false);
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -314,14 +549,20 @@ const popupSettings = siteSettings.subscriptions;
|
||||
const displayName = String(formData.get('displayName') || '').trim();
|
||||
|
||||
if (!email) {
|
||||
status.dataset.state = 'error';
|
||||
status.textContent = '请输入邮箱地址。';
|
||||
setError('请输入邮箱地址。');
|
||||
emailInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
status.dataset.state = 'pending';
|
||||
status.textContent = '正在提交订阅申请...';
|
||||
if (turnstileSiteKey) {
|
||||
const token = String(formData.get('turnstileToken') || '').trim();
|
||||
if (!token) {
|
||||
setError('请先完成人机验证。');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setPending('正在提交订阅申请...');
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
@@ -333,6 +574,7 @@ const popupSettings = siteSettings.subscriptions;
|
||||
email,
|
||||
displayName,
|
||||
source: 'frontend-popup',
|
||||
turnstileToken: formData.get('turnstileToken'),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -343,13 +585,12 @@ const popupSettings = siteSettings.subscriptions;
|
||||
|
||||
rememberSubmitted();
|
||||
form.reset();
|
||||
status.dataset.state = 'success';
|
||||
status.textContent =
|
||||
payload?.message || '订阅申请已提交,请前往邮箱确认后生效。';
|
||||
resetHumanCheck();
|
||||
setSuccess(payload?.message || '订阅申请已提交,请前往邮箱确认后生效。');
|
||||
successTimer = window.setTimeout(() => closePopup(false), 2200);
|
||||
} catch (error) {
|
||||
status.dataset.state = 'error';
|
||||
status.textContent = error instanceof Error ? error.message : '订阅失败,请稍后重试。';
|
||||
resetHumanCheck();
|
||||
setError(error instanceof Error ? error.message : '订阅失败,请稍后重试。');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
3
frontend/src/env.d.ts
vendored
3
frontend/src/env.d.ts
vendored
@@ -1,7 +1,10 @@
|
||||
/// <reference types="astro/client" />
|
||||
/// <reference types="node" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly PUBLIC_API_BASE_URL?: string;
|
||||
readonly PUBLIC_COMMENT_TURNSTILE_SITE_KEY?: string;
|
||||
readonly PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -16,7 +16,13 @@ function normalizeApiBaseUrl(value?: string | null) {
|
||||
return value?.trim().replace(/\/$/, '') ?? '';
|
||||
}
|
||||
|
||||
function getRuntimeEnv(name: 'PUBLIC_API_BASE_URL' | 'INTERNAL_API_BASE_URL') {
|
||||
function getRuntimeEnv(
|
||||
name:
|
||||
| 'PUBLIC_API_BASE_URL'
|
||||
| 'INTERNAL_API_BASE_URL'
|
||||
| 'PUBLIC_COMMENT_TURNSTILE_SITE_KEY'
|
||||
| 'PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY',
|
||||
) {
|
||||
const runtimeProcess = (globalThis as typeof globalThis & {
|
||||
process?: {
|
||||
env?: Record<string, string | undefined>;
|
||||
@@ -31,6 +37,10 @@ function toUrlLike(value: string | URL) {
|
||||
}
|
||||
|
||||
const buildTimePublicApiBaseUrl = normalizeApiBaseUrl(import.meta.env.PUBLIC_API_BASE_URL);
|
||||
const buildTimeCommentTurnstileSiteKey =
|
||||
import.meta.env.PUBLIC_COMMENT_TURNSTILE_SITE_KEY?.trim() ?? '';
|
||||
const buildTimeWebPushVapidPublicKey =
|
||||
import.meta.env.PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY?.trim() ?? '';
|
||||
|
||||
export function resolvePublicApiBaseUrl(requestUrl?: string | URL) {
|
||||
const runtimePublicApiBaseUrl = getRuntimeEnv('PUBLIC_API_BASE_URL');
|
||||
@@ -63,6 +73,18 @@ export function resolveInternalApiBaseUrl(requestUrl?: string | URL) {
|
||||
return resolvePublicApiBaseUrl(requestUrl);
|
||||
}
|
||||
|
||||
export function resolvePublicCommentTurnstileSiteKey() {
|
||||
return (
|
||||
getRuntimeEnv('PUBLIC_COMMENT_TURNSTILE_SITE_KEY') || buildTimeCommentTurnstileSiteKey
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePublicWebPushVapidPublicKey() {
|
||||
return (
|
||||
getRuntimeEnv('PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY') || buildTimeWebPushVapidPublicKey
|
||||
);
|
||||
}
|
||||
|
||||
export const API_BASE_URL = resolvePublicApiBaseUrl();
|
||||
|
||||
export interface ApiPost {
|
||||
@@ -121,6 +143,7 @@ export interface CreateCommentInput {
|
||||
paragraphExcerpt?: string;
|
||||
replyTo?: string | null;
|
||||
replyToCommentId?: number | null;
|
||||
turnstileToken?: string;
|
||||
captchaToken?: string;
|
||||
captchaAnswer?: string;
|
||||
website?: string;
|
||||
@@ -141,8 +164,12 @@ export interface ApiTag {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
count?: number;
|
||||
description?: string | null;
|
||||
cover_image?: string | null;
|
||||
accent_color?: string | null;
|
||||
seo_title?: string | null;
|
||||
seo_description?: string | null;
|
||||
}
|
||||
|
||||
export interface ApiCategory {
|
||||
@@ -150,6 +177,11 @@ export interface ApiCategory {
|
||||
name: string;
|
||||
slug: string;
|
||||
count: number;
|
||||
description?: string | null;
|
||||
cover_image?: string | null;
|
||||
accent_color?: string | null;
|
||||
seo_title?: string | null;
|
||||
seo_description?: string | null;
|
||||
}
|
||||
|
||||
export interface ApiFriendLink {
|
||||
@@ -230,6 +262,11 @@ export interface ApiSiteSettings {
|
||||
}> | null;
|
||||
ai_enabled: boolean;
|
||||
paragraph_comments_enabled: boolean;
|
||||
comment_turnstile_enabled: boolean;
|
||||
subscription_turnstile_enabled: boolean;
|
||||
web_push_enabled: boolean;
|
||||
turnstile_site_key: string | null;
|
||||
web_push_vapid_public_key: string | null;
|
||||
subscription_popup_enabled: boolean;
|
||||
subscription_popup_title: string | null;
|
||||
subscription_popup_description: string | null;
|
||||
@@ -326,6 +363,20 @@ export interface ApiSearchResult {
|
||||
rank: number;
|
||||
}
|
||||
|
||||
export interface ApiPagedResponse<T> {
|
||||
items: T[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
sort_by: string;
|
||||
sort_order: string;
|
||||
}
|
||||
|
||||
export interface ApiPagedSearchResponse extends ApiPagedResponse<ApiSearchResult> {
|
||||
query: string;
|
||||
}
|
||||
|
||||
export interface Review {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -401,12 +452,18 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
|
||||
},
|
||||
comments: {
|
||||
paragraphsEnabled: true,
|
||||
turnstileEnabled: false,
|
||||
turnstileSiteKey: undefined,
|
||||
},
|
||||
subscriptions: {
|
||||
popupEnabled: true,
|
||||
popupTitle: '订阅更新',
|
||||
popupDescription: '有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订。',
|
||||
popupDelaySeconds: 18,
|
||||
turnstileEnabled: false,
|
||||
turnstileSiteKey: undefined,
|
||||
webPushEnabled: false,
|
||||
webPushVapidPublicKey: undefined,
|
||||
},
|
||||
seo: {
|
||||
defaultOgImage: undefined,
|
||||
@@ -451,6 +508,12 @@ const normalizeTag = (tag: ApiTag): UiTag => ({
|
||||
id: String(tag.id),
|
||||
name: tag.name,
|
||||
slug: tag.slug,
|
||||
count: tag.count,
|
||||
description: tag.description ?? undefined,
|
||||
coverImage: tag.cover_image ?? undefined,
|
||||
accentColor: tag.accent_color ?? undefined,
|
||||
seoTitle: tag.seo_title ?? undefined,
|
||||
seoDescription: tag.seo_description ?? undefined,
|
||||
});
|
||||
|
||||
const normalizeCategory = (category: ApiCategory): UiCategory => ({
|
||||
@@ -458,6 +521,11 @@ const normalizeCategory = (category: ApiCategory): UiCategory => ({
|
||||
name: category.name,
|
||||
slug: category.slug,
|
||||
count: category.count,
|
||||
description: category.description ?? undefined,
|
||||
coverImage: category.cover_image ?? undefined,
|
||||
accentColor: category.accent_color ?? undefined,
|
||||
seoTitle: category.seo_title ?? undefined,
|
||||
seoDescription: category.seo_description ?? undefined,
|
||||
});
|
||||
|
||||
const normalizeAvatarUrl = (value: string | null | undefined) => {
|
||||
@@ -532,6 +600,9 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
|
||||
},
|
||||
comments: {
|
||||
paragraphsEnabled: settings.paragraph_comments_enabled ?? true,
|
||||
turnstileEnabled: Boolean(settings.comment_turnstile_enabled),
|
||||
turnstileSiteKey:
|
||||
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined,
|
||||
},
|
||||
subscriptions: {
|
||||
popupEnabled:
|
||||
@@ -544,6 +615,14 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
|
||||
popupDelaySeconds:
|
||||
settings.subscription_popup_delay_seconds ??
|
||||
DEFAULT_SITE_SETTINGS.subscriptions.popupDelaySeconds,
|
||||
turnstileEnabled: Boolean(settings.subscription_turnstile_enabled),
|
||||
turnstileSiteKey:
|
||||
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined,
|
||||
webPushEnabled: Boolean(settings.web_push_enabled),
|
||||
webPushVapidPublicKey:
|
||||
settings.web_push_vapid_public_key ||
|
||||
resolvePublicWebPushVapidPublicKey() ||
|
||||
undefined,
|
||||
},
|
||||
seo: {
|
||||
defaultOgImage: settings.seo_default_og_image ?? undefined,
|
||||
@@ -703,6 +782,46 @@ class ApiClient {
|
||||
return posts.map(normalizePost);
|
||||
}
|
||||
|
||||
async getPostsPage(options?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
category?: string;
|
||||
tag?: string;
|
||||
postType?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
}): Promise<{
|
||||
items: UiPost[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
sortBy: string;
|
||||
sortOrder: string;
|
||||
}> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.page) params.set('page', String(options.page));
|
||||
if (options?.pageSize) params.set('page_size', String(options.pageSize));
|
||||
if (options?.search) params.set('search', options.search);
|
||||
if (options?.category) params.set('category', options.category);
|
||||
if (options?.tag) params.set('tag', options.tag);
|
||||
if (options?.postType) params.set('type', options.postType);
|
||||
if (options?.sortBy) params.set('sort_by', options.sortBy);
|
||||
if (options?.sortOrder) params.set('sort_order', options.sortOrder);
|
||||
|
||||
const payload = await this.fetch<ApiPagedResponse<ApiPost>>(`/posts/page?${params.toString()}`);
|
||||
return {
|
||||
items: payload.items.map(normalizePost),
|
||||
page: payload.page,
|
||||
pageSize: payload.page_size,
|
||||
total: payload.total,
|
||||
totalPages: payload.total_pages,
|
||||
sortBy: payload.sort_by,
|
||||
sortOrder: payload.sort_order,
|
||||
};
|
||||
}
|
||||
|
||||
async getPost(id: number): Promise<UiPost> {
|
||||
const post = await this.fetch<ApiPost>(`/posts/${id}`);
|
||||
return normalizePost(post);
|
||||
@@ -782,6 +901,7 @@ class ApiClient {
|
||||
paragraphExcerpt: comment.paragraphExcerpt,
|
||||
replyTo: comment.replyTo,
|
||||
replyToCommentId: comment.replyToCommentId,
|
||||
turnstileToken: comment.turnstileToken,
|
||||
captchaToken: comment.captchaToken,
|
||||
captchaAnswer: comment.captchaAnswer,
|
||||
website: comment.website,
|
||||
@@ -955,12 +1075,87 @@ class ApiClient {
|
||||
image: result.image,
|
||||
images: null,
|
||||
pinned: result.pinned ?? false,
|
||||
status: null,
|
||||
visibility: null,
|
||||
publish_at: null,
|
||||
unpublish_at: null,
|
||||
canonical_url: null,
|
||||
noindex: null,
|
||||
og_image: null,
|
||||
redirect_from: null,
|
||||
redirect_to: null,
|
||||
created_at: result.created_at,
|
||||
updated_at: result.updated_at,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async searchPostsPage(options: {
|
||||
query: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
category?: string;
|
||||
tag?: string;
|
||||
postType?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
}): Promise<{
|
||||
query: string;
|
||||
items: UiPost[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
sortBy: string;
|
||||
sortOrder: string;
|
||||
}> {
|
||||
const params = new URLSearchParams({ q: options.query });
|
||||
if (options.page) params.set('page', String(options.page));
|
||||
if (options.pageSize) params.set('page_size', String(options.pageSize));
|
||||
if (options.category) params.set('category', options.category);
|
||||
if (options.tag) params.set('tag', options.tag);
|
||||
if (options.postType) params.set('type', options.postType);
|
||||
if (options.sortBy) params.set('sort_by', options.sortBy);
|
||||
if (options.sortOrder) params.set('sort_order', options.sortOrder);
|
||||
|
||||
const payload = await this.fetch<ApiPagedSearchResponse>(`/search/page?${params.toString()}`);
|
||||
return {
|
||||
query: payload.query,
|
||||
items: payload.items.map((result) =>
|
||||
normalizePost({
|
||||
id: result.id,
|
||||
title: result.title || 'Untitled',
|
||||
slug: result.slug,
|
||||
description: result.description || '',
|
||||
content: result.content || '',
|
||||
category: result.category || '',
|
||||
tags: result.tags ?? [],
|
||||
post_type: result.post_type || 'article',
|
||||
image: result.image,
|
||||
images: null,
|
||||
pinned: result.pinned ?? false,
|
||||
status: null,
|
||||
visibility: null,
|
||||
publish_at: null,
|
||||
unpublish_at: null,
|
||||
canonical_url: null,
|
||||
noindex: null,
|
||||
og_image: null,
|
||||
redirect_from: null,
|
||||
redirect_to: null,
|
||||
created_at: result.created_at,
|
||||
updated_at: result.updated_at,
|
||||
}),
|
||||
),
|
||||
page: payload.page,
|
||||
pageSize: payload.page_size,
|
||||
total: payload.total,
|
||||
totalPages: payload.total_pages,
|
||||
sortBy: payload.sort_by,
|
||||
sortOrder: payload.sort_order,
|
||||
};
|
||||
}
|
||||
|
||||
async askAi(question: string): Promise<AiAskResponse> {
|
||||
return this.fetch<AiAskResponse>('/ai/ask', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -37,6 +37,7 @@ export const messages = {
|
||||
cancel: '取消',
|
||||
clear: '清除',
|
||||
reset: '重置',
|
||||
refresh: '刷新',
|
||||
reply: '回复',
|
||||
like: '点赞',
|
||||
visit: '访问',
|
||||
@@ -63,6 +64,10 @@ export const messages = {
|
||||
featureOff: '功能未开启',
|
||||
emptyState: '当前还没有内容。',
|
||||
apiUnavailable: 'API 暂时不可用',
|
||||
humanVerification: '人机验证',
|
||||
turnstileHint: '提交前请先完成 Cloudflare Turnstile 校验。',
|
||||
turnstileRequired: '请先完成人机验证。',
|
||||
turnstileLoadFailed: '加载人机验证失败,请刷新页面后重试。',
|
||||
unknownError: '未知错误',
|
||||
},
|
||||
nav: {
|
||||
@@ -176,6 +181,9 @@ export const messages = {
|
||||
searchTips: '搜索会优先走站内索引,并自动复用同义词与轻量拼写纠错。',
|
||||
resultSummary: '找到 {count} 条结果',
|
||||
filteredSummary: '筛选后剩余 {count} 条结果',
|
||||
pageSummary: '第 {current} / {total} 页 · 共 {count} 条结果',
|
||||
previous: '上一页',
|
||||
next: '下一页',
|
||||
filtersTitle: '二次筛选',
|
||||
allCategories: '全部分类',
|
||||
allTags: '全部标签',
|
||||
@@ -478,6 +486,7 @@ export const messages = {
|
||||
cancel: 'Cancel',
|
||||
clear: 'Clear',
|
||||
reset: 'Reset',
|
||||
refresh: 'Refresh',
|
||||
reply: 'Reply',
|
||||
like: 'Like',
|
||||
visit: 'Visit',
|
||||
@@ -504,6 +513,10 @@ export const messages = {
|
||||
featureOff: 'Feature off',
|
||||
emptyState: 'Nothing here yet.',
|
||||
apiUnavailable: 'API temporarily unavailable',
|
||||
humanVerification: 'Human verification',
|
||||
turnstileHint: 'Please complete the Cloudflare Turnstile check before submitting.',
|
||||
turnstileRequired: 'Please complete the human verification first.',
|
||||
turnstileLoadFailed: 'Failed to load human verification. Refresh the page and try again.',
|
||||
unknownError: 'unknown error',
|
||||
},
|
||||
nav: {
|
||||
@@ -617,6 +630,9 @@ export const messages = {
|
||||
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',
|
||||
pageSummary: 'Page {current}/{total} · {count} results',
|
||||
previous: 'Prev',
|
||||
next: 'Next',
|
||||
filtersTitle: 'Refine results',
|
||||
allCategories: 'All categories',
|
||||
allTags: 'All tags',
|
||||
|
||||
@@ -30,6 +30,10 @@ export interface Category {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
coverImage?: string;
|
||||
accentColor?: string;
|
||||
seoTitle?: string;
|
||||
seoDescription?: string;
|
||||
icon?: string;
|
||||
count?: number;
|
||||
}
|
||||
@@ -38,6 +42,11 @@ export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
coverImage?: string;
|
||||
accentColor?: string;
|
||||
seoTitle?: string;
|
||||
seoDescription?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
@@ -76,12 +85,18 @@ export interface SiteSettings {
|
||||
};
|
||||
comments: {
|
||||
paragraphsEnabled: boolean;
|
||||
turnstileEnabled: boolean;
|
||||
turnstileSiteKey?: string;
|
||||
};
|
||||
subscriptions: {
|
||||
popupEnabled: boolean;
|
||||
popupTitle: string;
|
||||
popupDescription: string;
|
||||
popupDelaySeconds: number;
|
||||
turnstileEnabled: boolean;
|
||||
turnstileSiteKey?: string;
|
||||
webPushEnabled: boolean;
|
||||
webPushVapidPublicKey?: string;
|
||||
};
|
||||
seo: {
|
||||
defaultOgImage?: string;
|
||||
|
||||
110
frontend/src/lib/utils/turnstile.ts
Normal file
110
frontend/src/lib/utils/turnstile.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
const TURNSTILE_SCRIPT_ID = 'termi-turnstile-script';
|
||||
const TURNSTILE_SCRIPT_SRC =
|
||||
'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
||||
|
||||
type TurnstileApi = {
|
||||
render: (
|
||||
container: HTMLElement,
|
||||
options: Record<string, unknown>,
|
||||
) => string | number;
|
||||
reset: (widgetId?: string | number) => void;
|
||||
remove?: (widgetId?: string | number) => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: TurnstileApi;
|
||||
__termiTurnstileLoader__?: Promise<TurnstileApi>;
|
||||
}
|
||||
}
|
||||
|
||||
export type MountedTurnstile = {
|
||||
reset: () => void;
|
||||
remove: () => void;
|
||||
};
|
||||
|
||||
async function loadTurnstileScript(): Promise<TurnstileApi> {
|
||||
if (window.turnstile) {
|
||||
return window.turnstile;
|
||||
}
|
||||
|
||||
if (!window.__termiTurnstileLoader__) {
|
||||
window.__termiTurnstileLoader__ = new Promise<TurnstileApi>((resolve, reject) => {
|
||||
const existing = document.getElementById(TURNSTILE_SCRIPT_ID) as HTMLScriptElement | null;
|
||||
|
||||
const handleReady = () => {
|
||||
if (window.turnstile) {
|
||||
resolve(window.turnstile);
|
||||
} else {
|
||||
reject(new Error('Turnstile script loaded without API'));
|
||||
}
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
existing.addEventListener('load', handleReady, { once: true });
|
||||
existing.addEventListener(
|
||||
'error',
|
||||
() => reject(new Error('Failed to load Turnstile script')),
|
||||
{ once: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.id = TURNSTILE_SCRIPT_ID;
|
||||
script.src = TURNSTILE_SCRIPT_SRC;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.addEventListener('load', handleReady, { once: true });
|
||||
script.addEventListener(
|
||||
'error',
|
||||
() => reject(new Error('Failed to load Turnstile script')),
|
||||
{ once: true },
|
||||
);
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
return window.__termiTurnstileLoader__;
|
||||
}
|
||||
|
||||
export async function mountTurnstile(
|
||||
container: HTMLElement,
|
||||
options: {
|
||||
siteKey: string;
|
||||
onToken: (token: string) => void;
|
||||
onExpire?: () => void;
|
||||
onError?: () => void;
|
||||
},
|
||||
): Promise<MountedTurnstile> {
|
||||
const turnstile = await loadTurnstileScript();
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
const widgetId = turnstile.render(container, {
|
||||
sitekey: options.siteKey,
|
||||
callback: (token: string) => {
|
||||
options.onToken(token);
|
||||
},
|
||||
'expired-callback': () => {
|
||||
options.onExpire?.();
|
||||
},
|
||||
'error-callback': () => {
|
||||
options.onError?.();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
reset() {
|
||||
turnstile.reset(widgetId);
|
||||
},
|
||||
remove() {
|
||||
if (typeof turnstile.remove === 'function') {
|
||||
turnstile.remove(widgetId);
|
||||
} else {
|
||||
turnstile.reset(widgetId);
|
||||
}
|
||||
container.innerHTML = '';
|
||||
},
|
||||
};
|
||||
}
|
||||
112
frontend/src/lib/utils/web-push.ts
Normal file
112
frontend/src/lib/utils/web-push.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
const SERVICE_WORKER_URL = '/termi-web-push-sw.js';
|
||||
|
||||
export type BrowserPushSubscriptionPayload = {
|
||||
endpoint: string;
|
||||
expirationTime?: number | null;
|
||||
keys: {
|
||||
auth: string;
|
||||
p256dh: string;
|
||||
};
|
||||
};
|
||||
|
||||
function ensureBrowserSupport() {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
!('Notification' in window) ||
|
||||
!('serviceWorker' in navigator) ||
|
||||
!('PushManager' in window)
|
||||
) {
|
||||
throw new Error('当前浏览器不支持 Web Push。');
|
||||
}
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string) {
|
||||
const normalized = base64String.trim();
|
||||
const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
|
||||
const base64 = (normalized + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const binary = window.atob(base64);
|
||||
const output = new Uint8Array(binary.length);
|
||||
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
output[index] = binary.charCodeAt(index);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
async function getRegistration() {
|
||||
ensureBrowserSupport();
|
||||
await navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: '/' });
|
||||
return navigator.serviceWorker.ready;
|
||||
}
|
||||
|
||||
function normalizeSubscription(
|
||||
subscription: PushSubscription | PushSubscriptionJSON,
|
||||
): BrowserPushSubscriptionPayload {
|
||||
const json = 'toJSON' in subscription ? subscription.toJSON() : subscription;
|
||||
const endpoint = json.endpoint?.trim() || '';
|
||||
const auth = json.keys?.auth?.trim() || '';
|
||||
const p256dh = json.keys?.p256dh?.trim() || '';
|
||||
|
||||
if (!endpoint || !auth || !p256dh) {
|
||||
throw new Error('浏览器返回的 PushSubscription 不完整。');
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
expirationTime: json.expirationTime ?? null,
|
||||
keys: {
|
||||
auth,
|
||||
p256dh,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function supportsBrowserPush() {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
'Notification' in window &&
|
||||
'serviceWorker' in navigator &&
|
||||
'PushManager' in window
|
||||
);
|
||||
}
|
||||
|
||||
export async function getBrowserPushSubscription() {
|
||||
if (!supportsBrowserPush()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const registration = await getRegistration();
|
||||
return registration.pushManager.getSubscription();
|
||||
}
|
||||
|
||||
export async function ensureBrowserPushSubscription(
|
||||
publicKey: string,
|
||||
): Promise<BrowserPushSubscriptionPayload> {
|
||||
ensureBrowserSupport();
|
||||
|
||||
if (!publicKey.trim()) {
|
||||
throw new Error('Web Push 公钥未配置。');
|
||||
}
|
||||
|
||||
const permission =
|
||||
Notification.permission === 'granted'
|
||||
? 'granted'
|
||||
: await Notification.requestPermission();
|
||||
|
||||
if (permission !== 'granted') {
|
||||
throw new Error('浏览器通知权限未开启。');
|
||||
}
|
||||
|
||||
const registration = await getRegistration();
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (!subscription) {
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
});
|
||||
}
|
||||
|
||||
return normalizeSubscription(subscription);
|
||||
}
|
||||
@@ -156,7 +156,7 @@ export const GET: APIRoute = async ({ url, request }) => {
|
||||
? resized.png({ quality }).toBuffer()
|
||||
: resized.jpeg({ quality, mozjpeg: true }).toBuffer());
|
||||
|
||||
return new Response(output, {
|
||||
return new Response(new Uint8Array(output), {
|
||||
headers: {
|
||||
'Content-Type': contentTypeForFormat(format),
|
||||
'Cache-Control': 'public, max-age=604800, stale-while-revalidate=2592000',
|
||||
|
||||
@@ -289,7 +289,9 @@ const breadcrumbJsonLd = {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paragraphCommentsEnabled && <ParagraphComments postSlug={post.slug} class="mb-4" />}
|
||||
{paragraphCommentsEnabled && (
|
||||
<ParagraphComments postSlug={post.slug} class="mb-4" siteSettings={siteSettings} />
|
||||
)}
|
||||
|
||||
<div class="terminal-document article-content" set:html={renderedContent.code}></div>
|
||||
</div>
|
||||
@@ -421,7 +423,7 @@ const breadcrumbJsonLd = {
|
||||
)}
|
||||
|
||||
<section class="mt-8">
|
||||
<Comments postSlug={post.slug} class="terminal-panel" />
|
||||
<Comments postSlug={post.slug} class="terminal-panel" siteSettings={siteSettings} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,24 +11,53 @@ import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
let allPosts: Post[] = [];
|
||||
let allTags: Tag[] = [];
|
||||
let allCategories: Category[] = [];
|
||||
const url = new URL(Astro.request.url);
|
||||
const selectedSearch = url.searchParams.get('search') || '';
|
||||
const selectedType = url.searchParams.get('type') || 'all';
|
||||
const selectedTag = url.searchParams.get('tag') || '';
|
||||
const selectedCategory = url.searchParams.get('category') || '';
|
||||
const requestedPage = Number.parseInt(url.searchParams.get('page') || '1', 10);
|
||||
const postsPerPage = 10;
|
||||
const { t } = getI18n(Astro);
|
||||
|
||||
let paginatedPosts: Post[] = [];
|
||||
let allTags: Tag[] = [];
|
||||
let allCategories: Category[] = [];
|
||||
let totalPosts = 0;
|
||||
let totalPages = 1;
|
||||
let currentPage = Number.isFinite(requestedPage) && requestedPage > 0 ? requestedPage : 1;
|
||||
|
||||
try {
|
||||
const [posts, categories, rawTags] = await Promise.all([
|
||||
selectedSearch ? api.searchPosts(selectedSearch) : api.getPosts(),
|
||||
const [pageResult, categories, rawTags] = await Promise.all([
|
||||
selectedSearch
|
||||
? api.searchPostsPage({
|
||||
query: selectedSearch,
|
||||
page: currentPage,
|
||||
pageSize: postsPerPage,
|
||||
category: selectedCategory || undefined,
|
||||
tag: selectedTag || undefined,
|
||||
postType: selectedType !== 'all' ? selectedType : undefined,
|
||||
})
|
||||
: api.getPostsPage({
|
||||
page: currentPage,
|
||||
pageSize: postsPerPage,
|
||||
search: undefined,
|
||||
category: selectedCategory || undefined,
|
||||
tag: selectedTag || undefined,
|
||||
postType: selectedType !== 'all' ? selectedType : undefined,
|
||||
}),
|
||||
api.getCategories(),
|
||||
api.getTags(),
|
||||
]);
|
||||
|
||||
allPosts = posts;
|
||||
paginatedPosts = pageResult.items;
|
||||
totalPosts = pageResult.total;
|
||||
totalPages = pageResult.totalPages;
|
||||
currentPage = pageResult.page;
|
||||
allCategories = categories;
|
||||
|
||||
const seenTagIds = new Set<string>();
|
||||
allTags = rawTags.filter(tag => {
|
||||
allTags = rawTags.filter((tag) => {
|
||||
const key = `${tag.slug}:${tag.name}`.toLowerCase();
|
||||
if (seenTagIds.has(key)) return false;
|
||||
seenTagIds.add(key);
|
||||
@@ -38,32 +67,15 @@ try {
|
||||
console.error('API Error:', error);
|
||||
}
|
||||
|
||||
const selectedType = url.searchParams.get('type') || 'all';
|
||||
const selectedTag = url.searchParams.get('tag') || '';
|
||||
const selectedCategory = url.searchParams.get('category') || '';
|
||||
const currentPage = parseInt(url.searchParams.get('page') || '1');
|
||||
const postsPerPage = 10;
|
||||
const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||
const isMatchingTag = (value: string) => value.trim().toLowerCase() === normalizedSelectedTag;
|
||||
const isSelectedTag = (tag: Tag) =>
|
||||
tag.name.trim().toLowerCase() === normalizedSelectedTag || tag.slug.trim().toLowerCase() === normalizedSelectedTag;
|
||||
|
||||
const filteredPosts = allPosts.filter(post => {
|
||||
if (selectedType !== 'all' && post.type !== selectedType) return false;
|
||||
if (selectedTag && !post.tags?.some(isMatchingTag)) return false;
|
||||
if (selectedCategory && post.category?.toLowerCase() !== selectedCategory.toLowerCase()) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalPosts = filteredPosts.length;
|
||||
const totalPages = Math.ceil(totalPosts / postsPerPage);
|
||||
const startIndex = (currentPage - 1) * postsPerPage;
|
||||
const paginatedPosts = filteredPosts.slice(startIndex, startIndex + postsPerPage);
|
||||
tag.name.trim().toLowerCase() === normalizedSelectedTag ||
|
||||
tag.slug.trim().toLowerCase() === normalizedSelectedTag;
|
||||
|
||||
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' }
|
||||
{ id: 'tweet', name: t('common.tweet'), icon: 'fa-comment-dots' },
|
||||
];
|
||||
|
||||
const typePromptCommand =
|
||||
@@ -76,12 +88,6 @@ const categoryPromptCommand = selectedCategory
|
||||
const tagPromptCommand = selectedTag
|
||||
? `grep -Ril "#${selectedTag}" ./posts`
|
||||
: `cut -d: -f2 ./tags.index | sort -u`;
|
||||
const categoryAccentMap = Object.fromEntries(
|
||||
allCategories.map((category) => [category.name.toLowerCase(), getAccentVars(getCategoryTheme(category.name))])
|
||||
);
|
||||
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;
|
||||
@@ -130,7 +136,7 @@ const buildArticlesUrl = ({
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-file-lines text-[var(--primary)]"></i>
|
||||
<span id="articles-total-posts">{t('articlesPage.totalPosts', { count: filteredPosts.length })}</span>
|
||||
<span>{t('articlesPage.totalPosts', { count: totalPosts })}</span>
|
||||
</span>
|
||||
{selectedSearch && (
|
||||
<span class="terminal-stat-pill">
|
||||
@@ -138,28 +144,24 @@ const buildArticlesUrl = ({
|
||||
grep: {selectedSearch}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
id="articles-current-category-pill"
|
||||
class:list={[
|
||||
'terminal-stat-pill terminal-stat-pill--accent',
|
||||
!selectedCategory && 'hidden'
|
||||
]}
|
||||
style={selectedCategory ? getAccentVars(getCategoryTheme(selectedCategory)) : undefined}
|
||||
>
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<span id="articles-current-category">{selectedCategory}</span>
|
||||
</span>
|
||||
<span
|
||||
id="articles-current-tag-pill"
|
||||
class:list={[
|
||||
'terminal-stat-pill terminal-stat-pill--accent',
|
||||
!selectedTag && 'hidden'
|
||||
]}
|
||||
style={selectedTag ? getAccentVars(getTagTheme(selectedTag)) : undefined}
|
||||
>
|
||||
<i class="fas fa-hashtag"></i>
|
||||
<span id="articles-current-tag">{selectedTag}</span>
|
||||
</span>
|
||||
{selectedCategory && (
|
||||
<span
|
||||
class="terminal-stat-pill terminal-stat-pill--accent"
|
||||
style={getAccentVars(getCategoryTheme(selectedCategory))}
|
||||
>
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<span>{selectedCategory}</span>
|
||||
</span>
|
||||
)}
|
||||
{selectedTag && (
|
||||
<span
|
||||
class="terminal-stat-pill terminal-stat-pill--accent"
|
||||
style={getAccentVars(getTagTheme(selectedTag))}
|
||||
>
|
||||
<i class="fas fa-hashtag"></i>
|
||||
<span>{selectedTag}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,10 +170,9 @@ const buildArticlesUrl = ({
|
||||
<div class="ml-4">
|
||||
<CommandPrompt promptId="articles-type-prompt" command={typePromptCommand} typing={false} />
|
||||
<div class="mt-3 flex flex-wrap gap-3">
|
||||
{postTypeFilters.map(filter => (
|
||||
{postTypeFilters.map((filter) => (
|
||||
<FilterPill
|
||||
href={buildArticlesUrl({ type: filter.id, page: 1 })}
|
||||
data-articles-type={filter.id}
|
||||
tone={filter.id === 'all' ? 'neutral' : 'accent'}
|
||||
active={selectedType === filter.id}
|
||||
style={filter.id === 'all' ? undefined : getAccentVars(getPostTypeTheme(filter.id))}
|
||||
@@ -187,19 +188,13 @@ const buildArticlesUrl = ({
|
||||
<div class="ml-4">
|
||||
<CommandPrompt promptId="articles-category-prompt" command={categoryPromptCommand} typing={false} />
|
||||
<div class="mt-3 flex flex-wrap gap-3">
|
||||
<FilterPill
|
||||
href={buildArticlesUrl({ category: '', page: 1 })}
|
||||
data-articles-category=""
|
||||
tone="amber"
|
||||
active={!selectedCategory}
|
||||
>
|
||||
<FilterPill href={buildArticlesUrl({ category: '', page: 1 })} tone="amber" active={!selectedCategory}>
|
||||
<i class="fas fa-folder-tree"></i>
|
||||
<span class="font-medium">{t('articlesPage.allCategories')}</span>
|
||||
</FilterPill>
|
||||
{allCategories.map(category => (
|
||||
{allCategories.map((category) => (
|
||||
<FilterPill
|
||||
href={buildArticlesUrl({ category: category.name, page: 1 })}
|
||||
data-articles-category={category.name}
|
||||
tone="accent"
|
||||
active={selectedCategory.toLowerCase() === category.name.toLowerCase()}
|
||||
style={getAccentVars(getCategoryTheme(category.name))}
|
||||
@@ -217,19 +212,13 @@ const buildArticlesUrl = ({
|
||||
<div class="ml-4">
|
||||
<CommandPrompt promptId="articles-tag-prompt" command={tagPromptCommand} typing={false} />
|
||||
<div class="mt-3 flex flex-wrap gap-3">
|
||||
<FilterPill
|
||||
href={buildArticlesUrl({ tag: '', page: 1 })}
|
||||
data-articles-tag=""
|
||||
tone="teal"
|
||||
active={!selectedTag}
|
||||
>
|
||||
<FilterPill href={buildArticlesUrl({ tag: '', page: 1 })} tone="teal" active={!selectedTag}>
|
||||
<i class="fas fa-hashtag"></i>
|
||||
<span class="font-medium">{t('articlesPage.allTags')}</span>
|
||||
</FilterPill>
|
||||
{allTags.map(tag => (
|
||||
{allTags.map((tag) => (
|
||||
<FilterPill
|
||||
href={buildArticlesUrl({ tag: tag.slug || tag.name, page: 1 })}
|
||||
data-articles-tag={tag.slug || tag.name}
|
||||
tone="accent"
|
||||
active={isSelectedTag(tag)}
|
||||
style={getAccentVars(getTagTheme(tag.name))}
|
||||
@@ -244,279 +233,51 @@ const buildArticlesUrl = ({
|
||||
</div>
|
||||
|
||||
<div class="px-4">
|
||||
{allPosts.length > 0 ? (
|
||||
{paginatedPosts.length > 0 ? (
|
||||
<div class="ml-4 mt-4 space-y-4">
|
||||
{allPosts.map((post, index) => {
|
||||
const matchesCurrentFilter =
|
||||
(selectedType === 'all' || post.type === selectedType) &&
|
||||
(!selectedTag || post.tags?.some(isMatchingTag)) &&
|
||||
(!selectedCategory || post.category?.toLowerCase() === selectedCategory.toLowerCase());
|
||||
const filteredIndex = matchesCurrentFilter
|
||||
? filteredPosts.findIndex((item) => item.slug === post.slug)
|
||||
: -1;
|
||||
const isVisible = matchesCurrentFilter && filteredIndex >= startIndex && filteredIndex < startIndex + postsPerPage;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-article-card
|
||||
data-article-type={post.type}
|
||||
data-article-category={post.category?.toLowerCase() || ''}
|
||||
data-article-tags={post.tags.map((tag) => tag.trim().toLowerCase()).join('|')}
|
||||
data-article-index={index}
|
||||
class:list={[!isVisible && 'hidden']}
|
||||
>
|
||||
<PostCard post={post} selectedTag={selectedTag} highlightTerm={selectedSearch} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{paginatedPosts.map((post) => (
|
||||
<PostCard post={post} selectedTag={selectedTag} highlightTerm={selectedSearch} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div id="articles-empty-state" class:list={['terminal-empty ml-4 mt-4', paginatedPosts.length > 0 && 'hidden']}>
|
||||
<div class="mx-auto flex max-w-md flex-col items-center gap-3">
|
||||
<span class="terminal-section-icon">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
</span>
|
||||
<h2 class="text-xl font-semibold text-[var(--title-color)]">{t('articlesPage.emptyTitle')}</h2>
|
||||
<p class="text-sm leading-7 text-[var(--text-secondary)]">
|
||||
{t('articlesPage.emptyDescription')}
|
||||
</p>
|
||||
<a href="/articles" class="terminal-action-button terminal-action-button-primary">
|
||||
<i class="fas fa-rotate-left"></i>
|
||||
<span>{t('common.resetFilters')}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class:list={['terminal-empty ml-4 mt-4', totalPosts > 0 && 'hidden']}>
|
||||
<div class="mx-auto flex max-w-md flex-col items-center gap-3">
|
||||
<span class="terminal-section-icon">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
</span>
|
||||
<h2 class="text-xl font-semibold text-[var(--title-color)]">{t('articlesPage.emptyTitle')}</h2>
|
||||
<p class="text-sm leading-7 text-[var(--text-secondary)]">
|
||||
{t('articlesPage.emptyDescription')}
|
||||
</p>
|
||||
<a href="/articles" class="terminal-action-button terminal-action-button-primary">
|
||||
<i class="fas fa-rotate-left"></i>
|
||||
<span>{t('common.resetFilters')}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-6">
|
||||
<div id="articles-pagination" class:list={['terminal-panel-muted ml-4 mt-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between', totalPages <= 1 && 'hidden']}>
|
||||
<span class="text-sm text-[var(--text-secondary)]">
|
||||
<span id="articles-page-summary">{t('articlesPage.pageSummary', { current: currentPage, total: totalPages, count: totalPosts })}</span>
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
id="articles-prev-btn"
|
||||
type="button"
|
||||
class:list={['terminal-action-button', currentPage <= 1 && 'hidden']}
|
||||
>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
<span>{t('articlesPage.previous')}</span>
|
||||
</button>
|
||||
<button
|
||||
id="articles-next-btn"
|
||||
type="button"
|
||||
class:list={['terminal-action-button terminal-action-button-primary', currentPage >= totalPages && 'hidden']}
|
||||
>
|
||||
<span>{t('articlesPage.next')}</span>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class:list={['terminal-panel-muted ml-4 mt-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between', totalPages <= 1 && 'hidden']}>
|
||||
<span class="text-sm text-[var(--text-secondary)]">
|
||||
{t('articlesPage.pageSummary', { current: currentPage, total: totalPages, count: totalPosts })}
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{currentPage > 1 && (
|
||||
<a href={buildArticlesUrl({ page: currentPage - 1 })} class="terminal-action-button">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
<span>{t('articlesPage.previous')}</span>
|
||||
</a>
|
||||
)}
|
||||
{currentPage < totalPages && (
|
||||
<a href={buildArticlesUrl({ page: currentPage + 1 })} class="terminal-action-button terminal-action-button-primary">
|
||||
<span>{t('articlesPage.next')}</span>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TerminalWindow>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<script
|
||||
is:inline
|
||||
define:vars={{
|
||||
postsPerPage,
|
||||
selectedSearch,
|
||||
categoryAccentMap,
|
||||
tagAccentMap,
|
||||
initialArticlesState: {
|
||||
type: selectedType,
|
||||
category: selectedCategory,
|
||||
tag: selectedTag,
|
||||
page: currentPage,
|
||||
},
|
||||
}}
|
||||
>
|
||||
(function() {
|
||||
/** @type {Window['__termiCommandPrompt']} */
|
||||
let promptApi;
|
||||
|
||||
const articleCards = Array.from(document.querySelectorAll('[data-article-card]'));
|
||||
const typeFilters = Array.from(document.querySelectorAll('[data-articles-type]'));
|
||||
const categoryFilters = Array.from(document.querySelectorAll('[data-articles-category]'));
|
||||
const tagFilters = Array.from(document.querySelectorAll('[data-articles-tag]'));
|
||||
const totalPostsEl = document.getElementById('articles-total-posts');
|
||||
const categoryPill = document.getElementById('articles-current-category-pill');
|
||||
const categoryText = document.getElementById('articles-current-category');
|
||||
const tagPill = document.getElementById('articles-current-tag-pill');
|
||||
const tagText = document.getElementById('articles-current-tag');
|
||||
const emptyState = document.getElementById('articles-empty-state');
|
||||
const pagination = document.getElementById('articles-pagination');
|
||||
const pageSummary = document.getElementById('articles-page-summary');
|
||||
const prevBtn = document.getElementById('articles-prev-btn');
|
||||
const nextBtn = document.getElementById('articles-next-btn');
|
||||
const t = window.__termiTranslate;
|
||||
|
||||
promptApi = window.__termiCommandPrompt;
|
||||
|
||||
const state = {
|
||||
type: initialArticlesState.type || 'all',
|
||||
category: initialArticlesState.category || '',
|
||||
tag: initialArticlesState.tag || '',
|
||||
page: Number(initialArticlesState.page || 1),
|
||||
};
|
||||
|
||||
function updateArticlePrompts() {
|
||||
const typeCommand = state.type === 'all'
|
||||
? 'grep -E "^type: (article|tweet)$" ./posts/*.md'
|
||||
: `grep -E "^type: ${state.type}$" ./posts/*.md`;
|
||||
const categoryCommand = state.category
|
||||
? `grep -El "^category: ${state.category}$" ./posts/*.md`
|
||||
: 'cut -d: -f2 ./categories.index | sort -u';
|
||||
const tagCommand = state.tag
|
||||
? `grep -Ril "#${state.tag}" ./posts`
|
||||
: 'cut -d: -f2 ./tags.index | sort -u';
|
||||
|
||||
promptApi?.set?.('articles-type-prompt', typeCommand, { typing: false });
|
||||
promptApi?.set?.('articles-category-prompt', categoryCommand, { typing: false });
|
||||
promptApi?.set?.('articles-tag-prompt', tagCommand, { typing: false });
|
||||
}
|
||||
|
||||
function syncActiveFilters(elements, key, emptyValue = '') {
|
||||
elements.forEach((element) => {
|
||||
const value = (element.getAttribute(key) || '').trim();
|
||||
const activeValue =
|
||||
key === 'data-articles-type'
|
||||
? state.type
|
||||
: key === 'data-articles-category'
|
||||
? state.category
|
||||
: state.tag;
|
||||
element.classList.toggle('is-active', value === (activeValue || emptyValue));
|
||||
});
|
||||
}
|
||||
|
||||
function updateSummaryPills() {
|
||||
if (categoryPill && categoryText) {
|
||||
if (state.category) {
|
||||
categoryPill.classList.remove('hidden');
|
||||
categoryText.textContent = state.category;
|
||||
categoryPill.setAttribute('style', categoryAccentMap[String(state.category).toLowerCase()] || '');
|
||||
} else {
|
||||
categoryPill.classList.add('hidden');
|
||||
categoryPill.removeAttribute('style');
|
||||
}
|
||||
}
|
||||
|
||||
if (tagPill && tagText) {
|
||||
if (state.tag) {
|
||||
tagPill.classList.remove('hidden');
|
||||
tagText.textContent = state.tag;
|
||||
tagPill.setAttribute('style', tagAccentMap[String(state.tag).toLowerCase()] || '');
|
||||
} else {
|
||||
tagPill.classList.add('hidden');
|
||||
tagPill.removeAttribute('style');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateUrl(totalPages) {
|
||||
const params = new URLSearchParams();
|
||||
if (state.type && state.type !== 'all') params.set('type', state.type);
|
||||
if (selectedSearch) params.set('search', selectedSearch);
|
||||
if (state.tag) params.set('tag', state.tag);
|
||||
if (state.category) params.set('category', state.category);
|
||||
if (state.page > 1 && totalPages > 1) params.set('page', String(state.page));
|
||||
const nextUrl = params.toString() ? `/articles?${params.toString()}` : '/articles';
|
||||
window.history.replaceState({}, '', nextUrl);
|
||||
}
|
||||
|
||||
function applyArticleFilters(pushHistory = true) {
|
||||
const filtered = articleCards.filter((card) => {
|
||||
const type = card.getAttribute('data-article-type') || '';
|
||||
const category = (card.getAttribute('data-article-category') || '').toLowerCase();
|
||||
const tags = `|${(card.getAttribute('data-article-tags') || '').toLowerCase()}|`;
|
||||
const typeMatch = state.type === 'all' || type === state.type;
|
||||
const categoryMatch = !state.category || category === state.category.toLowerCase();
|
||||
const tagMatch = !state.tag || tags.includes(`|${state.tag.toLowerCase()}|`);
|
||||
return typeMatch && categoryMatch && tagMatch;
|
||||
});
|
||||
|
||||
const total = filtered.length;
|
||||
const totalPages = Math.max(Math.ceil(total / postsPerPage), 1);
|
||||
|
||||
if (state.page > totalPages) state.page = totalPages;
|
||||
if (state.page < 1) state.page = 1;
|
||||
|
||||
const startIndex = (state.page - 1) * postsPerPage;
|
||||
const endIndex = startIndex + postsPerPage;
|
||||
|
||||
articleCards.forEach((card) => card.classList.add('hidden'));
|
||||
filtered.slice(startIndex, endIndex).forEach((card) => card.classList.remove('hidden'));
|
||||
|
||||
syncActiveFilters(typeFilters, 'data-articles-type', 'all');
|
||||
syncActiveFilters(categoryFilters, 'data-articles-category', '');
|
||||
syncActiveFilters(tagFilters, 'data-articles-tag', '');
|
||||
updateSummaryPills();
|
||||
updateArticlePrompts();
|
||||
|
||||
if (totalPostsEl) {
|
||||
totalPostsEl.textContent = t('articlesPage.totalPosts', { count: total });
|
||||
}
|
||||
|
||||
if (emptyState) {
|
||||
emptyState.classList.toggle('hidden', total > 0);
|
||||
}
|
||||
|
||||
if (pagination) {
|
||||
pagination.classList.toggle('hidden', totalPages <= 1);
|
||||
}
|
||||
if (pageSummary) {
|
||||
pageSummary.textContent = t('articlesPage.pageSummary', { current: state.page, total: totalPages, count: total });
|
||||
}
|
||||
if (prevBtn) {
|
||||
prevBtn.classList.toggle('hidden', state.page <= 1 || totalPages <= 1);
|
||||
}
|
||||
if (nextBtn) {
|
||||
nextBtn.classList.toggle('hidden', state.page >= totalPages || totalPages <= 1);
|
||||
}
|
||||
|
||||
if (pushHistory) {
|
||||
updateUrl(totalPages);
|
||||
}
|
||||
}
|
||||
|
||||
typeFilters.forEach((filter) => {
|
||||
filter.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
state.type = filter.getAttribute('data-articles-type') || 'all';
|
||||
state.page = 1;
|
||||
applyArticleFilters();
|
||||
});
|
||||
});
|
||||
|
||||
categoryFilters.forEach((filter) => {
|
||||
filter.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
state.category = filter.getAttribute('data-articles-category') || '';
|
||||
state.page = 1;
|
||||
applyArticleFilters();
|
||||
});
|
||||
});
|
||||
|
||||
tagFilters.forEach((filter) => {
|
||||
filter.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
state.tag = filter.getAttribute('data-articles-tag') || '';
|
||||
state.page = 1;
|
||||
applyArticleFilters();
|
||||
});
|
||||
});
|
||||
|
||||
prevBtn?.addEventListener('click', () => {
|
||||
state.page -= 1;
|
||||
applyArticleFilters();
|
||||
});
|
||||
|
||||
nextBtn?.addEventListener('click', () => {
|
||||
state.page += 1;
|
||||
applyArticleFilters();
|
||||
});
|
||||
|
||||
applyArticleFilters(false);
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -13,8 +13,7 @@ export const prerender = false;
|
||||
let categories: Awaited<ReturnType<typeof api.getCategories>> = [];
|
||||
let allPosts: Post[] = [];
|
||||
const url = new URL(Astro.request.url);
|
||||
const selectedCategory = url.searchParams.get('category') || '';
|
||||
const normalizedSelectedCategory = selectedCategory.trim().toLowerCase();
|
||||
const selectedCategoryParam = url.searchParams.get('category') || '';
|
||||
const { t } = getI18n(Astro);
|
||||
|
||||
try {
|
||||
@@ -26,6 +25,15 @@ try {
|
||||
console.error('Failed to fetch categories:', error);
|
||||
}
|
||||
|
||||
const selectedCategoryRecord = categories.find((category) => {
|
||||
const wanted = selectedCategoryParam.trim().toLowerCase();
|
||||
if (!wanted) return false;
|
||||
return [category.name, category.slug].some(
|
||||
(value) => (value || '').trim().toLowerCase() === wanted
|
||||
);
|
||||
}) || null;
|
||||
const selectedCategory = selectedCategoryRecord?.name || selectedCategoryParam;
|
||||
const normalizedSelectedCategory = selectedCategory.trim().toLowerCase();
|
||||
const filteredPosts = selectedCategory
|
||||
? allPosts.filter((post) => (post.category || '').trim().toLowerCase() === normalizedSelectedCategory)
|
||||
: [];
|
||||
@@ -38,9 +46,15 @@ const resultsPromptCommand = selectedCategory
|
||||
const categoryAccentMap = Object.fromEntries(
|
||||
categories.map((category) => [category.name.trim().toLowerCase(), getAccentVars(getCategoryTheme(category.name))])
|
||||
);
|
||||
const pageTitle = selectedCategoryRecord?.seoTitle || t('categories.pageTitle');
|
||||
const pageDescription = selectedCategoryRecord?.seoDescription || selectedCategoryRecord?.description;
|
||||
---
|
||||
|
||||
<BaseLayout title={`${t('categories.pageTitle')} - Termi`}>
|
||||
<BaseLayout
|
||||
title={`${pageTitle} - Termi`}
|
||||
description={pageDescription}
|
||||
ogImage={selectedCategoryRecord?.coverImage}
|
||||
>
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<TerminalWindow title="~/categories" class="w-full">
|
||||
<div class="mb-6 px-4">
|
||||
@@ -173,6 +187,28 @@ const categoryAccentMap = Object.fromEntries(
|
||||
<i class="fas fa-rotate-left text-xs"></i>
|
||||
</a>
|
||||
</div>
|
||||
{selectedCategoryRecord && (selectedCategoryRecord.description || selectedCategoryRecord.coverImage) ? (
|
||||
<div class="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_280px]">
|
||||
<div class="space-y-3 text-sm leading-6 text-[var(--text-secondary)]">
|
||||
{selectedCategoryRecord.description ? (
|
||||
<p>{selectedCategoryRecord.description}</p>
|
||||
) : null}
|
||||
{selectedCategoryRecord.accentColor ? (
|
||||
<div class="flex items-center gap-3 text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
|
||||
<span class="inline-flex h-3 w-3 rounded-full border border-[var(--border-color)]" style={`background:${selectedCategoryRecord.accentColor}`}></span>
|
||||
<span>{selectedCategoryRecord.accentColor}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedCategoryRecord.coverImage ? (
|
||||
<img
|
||||
src={selectedCategoryRecord.coverImage}
|
||||
alt={selectedCategoryRecord.name}
|
||||
class="h-full w-full rounded-2xl border border-[var(--border-color)] object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,32 +16,59 @@ 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 requestedPage = Number.parseInt(url.searchParams.get('page') || '1', 10);
|
||||
const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||
const normalizedSelectedCategory = selectedCategory.trim().toLowerCase();
|
||||
const hasActiveFilters = Boolean(selectedType !== 'all' || selectedCategory || selectedTag);
|
||||
const pageSize = 10;
|
||||
const { t } = getI18n(Astro);
|
||||
|
||||
let searchResults: Post[] = [];
|
||||
let paginatedResults: Post[] = [];
|
||||
let facetResults: Post[] = [];
|
||||
let apiError: string | null = null;
|
||||
let totalResults = 0;
|
||||
let filteredTotal = 0;
|
||||
let totalPages = 1;
|
||||
let currentPage = Number.isFinite(requestedPage) && requestedPage > 0 ? requestedPage : 1;
|
||||
|
||||
if (selectedQuery) {
|
||||
try {
|
||||
searchResults = await api.searchPosts(selectedQuery, 40);
|
||||
const pagePromise = api.searchPostsPage({
|
||||
query: selectedQuery,
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
category: selectedCategory || undefined,
|
||||
tag: selectedTag || undefined,
|
||||
postType: selectedType !== 'all' ? selectedType : undefined,
|
||||
});
|
||||
const facetsPromise = api.searchPosts(selectedQuery, 100);
|
||||
const totalPromise = hasActiveFilters
|
||||
? api.searchPostsPage({
|
||||
query: selectedQuery,
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
})
|
||||
: Promise.resolve(null);
|
||||
|
||||
const [pageResult, sampledResults, unfilteredSummary] = await Promise.all([
|
||||
pagePromise,
|
||||
facetsPromise,
|
||||
totalPromise,
|
||||
]);
|
||||
|
||||
paginatedResults = pageResult.items;
|
||||
facetResults = sampledResults;
|
||||
filteredTotal = pageResult.total;
|
||||
totalResults = unfilteredSummary?.total ?? pageResult.total;
|
||||
totalPages = pageResult.totalPages;
|
||||
currentPage = pageResult.page;
|
||||
} 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) => {
|
||||
facetResults.reduce((map, post) => {
|
||||
const name = post.category?.trim();
|
||||
if (!name) return map;
|
||||
map.set(name, (map.get(name) ?? 0) + 1);
|
||||
@@ -52,7 +79,7 @@ const categoryCounts = Array.from(
|
||||
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
|
||||
|
||||
const tagCounts = Array.from(
|
||||
searchResults.reduce((map, post) => {
|
||||
facetResults.reduce((map, post) => {
|
||||
for (const tag of post.tags) {
|
||||
const name = tag.trim();
|
||||
if (!name) continue;
|
||||
@@ -75,11 +102,13 @@ const buildSearchUrl = ({
|
||||
type = selectedType,
|
||||
tag = selectedTag,
|
||||
category = selectedCategory,
|
||||
page,
|
||||
}: {
|
||||
q?: string;
|
||||
type?: string;
|
||||
tag?: string;
|
||||
category?: string;
|
||||
page?: number;
|
||||
}) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -87,6 +116,7 @@ const buildSearchUrl = ({
|
||||
if (type && type !== 'all') params.set('type', type);
|
||||
if (category) params.set('category', category);
|
||||
if (tag) params.set('tag', tag);
|
||||
if (page && page > 1) params.set('page', String(page));
|
||||
|
||||
const queryString = params.toString();
|
||||
return queryString ? `/search?${queryString}` : '/search';
|
||||
@@ -95,7 +125,7 @@ const buildSearchUrl = ({
|
||||
const activeQueryCommand = selectedQuery
|
||||
? t('searchPage.promptQuery', { query: selectedQuery })
|
||||
: t('searchPage.promptIdle');
|
||||
const searchTagHrefBase = buildSearchUrl({ tag: '' });
|
||||
const searchTagHrefBase = buildSearchUrl({ tag: '', page: 1 });
|
||||
const searchTagHrefPrefix = `${searchTagHrefBase}${searchTagHrefBase.includes('?') ? '&' : '?'}tag=`;
|
||||
const pageTitle = selectedQuery
|
||||
? `${t('searchPage.pageTitle')} · ${selectedQuery}`
|
||||
@@ -117,7 +147,7 @@ const pageTitle = selectedQuery
|
||||
<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>{t('searchPage.resultSummary', { count: totalResults })}</span>
|
||||
</span>
|
||||
{selectedQuery && (
|
||||
<span class="terminal-stat-pill terminal-stat-pill--accent" style={getAccentVars(getPostTypeTheme('article'))}>
|
||||
@@ -125,10 +155,10 @@ const pageTitle = selectedQuery
|
||||
<span>{t('searchPage.queryLabel')}: {selectedQuery}</span>
|
||||
</span>
|
||||
)}
|
||||
{(selectedType !== 'all' || selectedCategory || selectedTag) && (
|
||||
{hasActiveFilters && (
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-filter text-[var(--primary)]"></i>
|
||||
<span>{t('searchPage.filteredSummary', { count: filteredResults.length })}</span>
|
||||
<span>{t('searchPage.filteredSummary', { count: filteredTotal })}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -170,7 +200,7 @@ const pageTitle = selectedQuery
|
||||
<div class="mt-3 flex flex-wrap gap-3">
|
||||
{postTypeFilters.map((filter) => (
|
||||
<FilterPill
|
||||
href={buildSearchUrl({ type: filter.id })}
|
||||
href={buildSearchUrl({ type: filter.id, page: 1 })}
|
||||
tone={filter.id === 'all' ? 'neutral' : 'accent'}
|
||||
active={selectedType === filter.id}
|
||||
style={filter.id === 'all' ? undefined : getAccentVars(getPostTypeTheme(filter.id))}
|
||||
@@ -185,17 +215,13 @@ const pageTitle = selectedQuery
|
||||
{categoryCounts.length > 0 && (
|
||||
<div class="ml-4">
|
||||
<div class="mt-3 flex flex-wrap gap-3">
|
||||
<FilterPill
|
||||
href={buildSearchUrl({ category: '' })}
|
||||
tone="amber"
|
||||
active={!selectedCategory}
|
||||
>
|
||||
<FilterPill href={buildSearchUrl({ category: '', page: 1 })} 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 })}
|
||||
href={buildSearchUrl({ category: category.name, page: 1 })}
|
||||
tone="accent"
|
||||
active={selectedCategory.toLowerCase() === category.name.toLowerCase()}
|
||||
style={getAccentVars(getCategoryTheme(category.name))}
|
||||
@@ -212,17 +238,13 @@ const pageTitle = selectedQuery
|
||||
{tagCounts.length > 0 && (
|
||||
<div class="ml-4">
|
||||
<div class="mt-3 flex flex-wrap gap-3">
|
||||
<FilterPill
|
||||
href={buildSearchUrl({ tag: '' })}
|
||||
tone="teal"
|
||||
active={!selectedTag}
|
||||
>
|
||||
<FilterPill href={buildSearchUrl({ tag: '', page: 1 })} 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 })}
|
||||
href={buildSearchUrl({ tag: tag.name, page: 1 })}
|
||||
tone="accent"
|
||||
active={normalizedSelectedTag === tag.name.toLowerCase()}
|
||||
style={getAccentVars(getTagTheme(tag.name))}
|
||||
@@ -238,9 +260,9 @@ const pageTitle = selectedQuery
|
||||
</div>
|
||||
|
||||
<div class="px-4 pb-8">
|
||||
{filteredResults.length > 0 ? (
|
||||
{filteredTotal > 0 ? (
|
||||
<div class="ml-4 mt-4 space-y-4">
|
||||
{filteredResults.map((post) => (
|
||||
{paginatedResults.map((post) => (
|
||||
<PostCard
|
||||
post={post}
|
||||
selectedTag={selectedTag}
|
||||
@@ -260,7 +282,10 @@ const pageTitle = selectedQuery
|
||||
{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">
|
||||
<a
|
||||
href={buildSearchUrl({ type: 'all', category: '', tag: '', page: 1 })}
|
||||
class="terminal-action-button terminal-action-button-primary"
|
||||
>
|
||||
<i class="fas fa-rotate-left"></i>
|
||||
<span>{t('common.resetFilters')}</span>
|
||||
</a>
|
||||
@@ -273,6 +298,33 @@ const pageTitle = selectedQuery
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-6">
|
||||
<div
|
||||
class:list={[
|
||||
'terminal-panel-muted ml-4 mt-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between',
|
||||
totalPages <= 1 && 'hidden',
|
||||
]}
|
||||
>
|
||||
<span class="text-sm text-[var(--text-secondary)]">
|
||||
{t('searchPage.pageSummary', { current: currentPage, total: totalPages, count: filteredTotal })}
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{currentPage > 1 && (
|
||||
<a href={buildSearchUrl({ page: currentPage - 1 })} class="terminal-action-button">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
<span>{t('searchPage.previous')}</span>
|
||||
</a>
|
||||
)}
|
||||
{currentPage < totalPages && (
|
||||
<a href={buildSearchUrl({ page: currentPage + 1 })} class="terminal-action-button terminal-action-button-primary">
|
||||
<span>{t('searchPage.next')}</span>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TerminalWindow>
|
||||
|
||||
@@ -26,7 +26,13 @@ try {
|
||||
|
||||
// Get URL params
|
||||
const url = new URL(Astro.request.url);
|
||||
const selectedTag = url.searchParams.get('tag') || '';
|
||||
const selectedTagParam = url.searchParams.get('tag') || '';
|
||||
const selectedTagRecord = tags.find((tag) => {
|
||||
const wanted = selectedTagParam.trim().toLowerCase();
|
||||
if (!wanted) return false;
|
||||
return [tag.name, tag.slug].some((value) => (value || '').trim().toLowerCase() === wanted);
|
||||
}) || null;
|
||||
const selectedTag = selectedTagRecord?.name || selectedTagParam;
|
||||
const selectedTagToken = selectedTag.trim().toLowerCase();
|
||||
const selectedTagTheme = getTagTheme(selectedTag);
|
||||
const isSelectedTag = (tag: Tag) =>
|
||||
@@ -38,9 +44,15 @@ const filteredPosts = selectedTag
|
||||
const tagAccentMap = Object.fromEntries(
|
||||
tags.map((tag) => [String(tag.slug || tag.name).toLowerCase(), getAccentVars(getTagTheme(tag.name))])
|
||||
);
|
||||
const pageTitle = selectedTagRecord?.seoTitle || t('tags.pageTitle');
|
||||
const pageDescription = selectedTagRecord?.seoDescription || selectedTagRecord?.description;
|
||||
---
|
||||
|
||||
<BaseLayout title={`${t('tags.pageTitle')} - Termi`}>
|
||||
<BaseLayout
|
||||
title={`${pageTitle} - Termi`}
|
||||
description={pageDescription}
|
||||
ogImage={selectedTagRecord?.coverImage}
|
||||
>
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<TerminalWindow title="~/tags" class="w-full">
|
||||
<div class="mb-6 px-4">
|
||||
@@ -87,6 +99,26 @@ const tagAccentMap = Object.fromEntries(
|
||||
<span>{t('common.clearFilters')}</span>
|
||||
</a>
|
||||
</div>
|
||||
{selectedTagRecord && (selectedTagRecord.description || selectedTagRecord.coverImage) ? (
|
||||
<div class="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_280px]">
|
||||
<div class="space-y-3 text-sm leading-6 text-[var(--text-secondary)]">
|
||||
{selectedTagRecord.description ? <p>{selectedTagRecord.description}</p> : null}
|
||||
{selectedTagRecord.accentColor ? (
|
||||
<div class="flex items-center gap-3 text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
|
||||
<span class="inline-flex h-3 w-3 rounded-full border border-[var(--border-color)]" style={`background:${selectedTagRecord.accentColor}`}></span>
|
||||
<span>{selectedTagRecord.accentColor}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedTagRecord.coverImage ? (
|
||||
<img
|
||||
src={selectedTagRecord.coverImage}
|
||||
alt={selectedTagRecord.name}
|
||||
class="h-full w-full rounded-2xl border border-[var(--border-color)] object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user