feat: refresh content workflow and verification settings
All checks were successful
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Successful in 43s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Successful in 25m9s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Successful in 51s

This commit is contained in:
2026-04-01 18:47:17 +08:00
parent f2c07df320
commit 7de4ddc3ee
66 changed files with 1455 additions and 2759 deletions

View File

@@ -17,7 +17,8 @@ interface 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
const commentVerificationMode = siteSettings.comments.verificationMode;
const turnstileSiteKey = commentVerificationMode === 'turnstile'
? siteSettings.comments.turnstileSiteKey || resolvePublicCommentTurnstileSiteKey()
: '';
@@ -49,6 +50,7 @@ function formatCommentDate(dateStr: string): string {
class={`terminal-comments ${className}`}
data-post-slug={postSlug}
data-api-base={publicApiBaseUrl}
data-verification-mode={commentVerificationMode}
data-turnstile-site-key={turnstileSiteKey || undefined}
>
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
@@ -126,42 +128,44 @@ function formatCommentDate(dateStr: string): string {
</label>
</div>
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--header-bg)]/60 px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
{t('common.humanVerification')}
</p>
{turnstileSiteKey ? (
<span class="text-xs text-[var(--text-tertiary)]">Cloudflare Turnstile</span>
{commentVerificationMode !== 'off' && (
<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>
{commentVerificationMode === 'turnstile' ? (
<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>
{commentVerificationMode === 'turnstile' ? (
<>
<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>
</>
) : (
<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>
<>
<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>
{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">
<span class="text-sm text-[var(--text-secondary)]">
@@ -274,6 +278,9 @@ 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 verificationMode = wrapper?.getAttribute('data-verification-mode') || 'captcha';
const useTurnstile = verificationMode === 'turnstile';
const useCaptcha = verificationMode === 'captcha';
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;
@@ -390,13 +397,15 @@ function formatCommentDate(dateStr: string): string {
}
function resetHumanCheck() {
if (turnstileSiteKey) {
if (useTurnstile) {
turnstileTokenInput && (turnstileTokenInput.value = '');
turnstileWidget?.reset();
return;
}
void loadCaptcha(false);
if (useCaptcha) {
void loadCaptcha(false);
}
}
toggleBtn?.addEventListener('click', () => {
@@ -443,7 +452,7 @@ function formatCommentDate(dateStr: string): string {
const formData = new FormData(form);
const replyToId = replyingTo?.getAttribute('data-reply-to');
if (turnstileSiteKey) {
if (useTurnstile) {
const token = String(formData.get('turnstileToken') || '').trim();
if (!token) {
showMessage(t('common.turnstileRequired'), 'error');
@@ -502,9 +511,9 @@ function formatCommentDate(dateStr: string): string {
});
});
if (turnstileSiteKey) {
if (useTurnstile) {
void ensureTurnstile(false);
} else {
} else if (useCaptcha) {
void loadCaptcha(false);
}
</script>

View File

@@ -15,7 +15,8 @@ interface Props {
const { postSlug, class: className = '', siteSettings } = Astro.props as Props;
const { t } = getI18n(Astro);
const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
const turnstileSiteKey = siteSettings.comments.turnstileEnabled
const commentVerificationMode = siteSettings.comments.verificationMode;
const turnstileSiteKey = commentVerificationMode === 'turnstile'
? siteSettings.comments.turnstileSiteKey || resolvePublicCommentTurnstileSiteKey()
: '';
---
@@ -25,6 +26,7 @@ const turnstileSiteKey = siteSettings.comments.turnstileEnabled
data-post-slug={postSlug}
data-api-base={publicApiBaseUrl}
data-storage-key={`termi:paragraph-comments:${postSlug}`}
data-verification-mode={commentVerificationMode}
data-turnstile-site-key={turnstileSiteKey || undefined}
>
<div class="paragraph-comments-toolbar terminal-panel-muted">
@@ -83,6 +85,9 @@ const turnstileSiteKey = siteSettings.comments.turnstileEnabled
const postSlug = wrapper?.dataset.postSlug || '';
const apiBase = wrapper?.dataset.apiBase || '/api';
const storageKey = wrapper?.dataset.storageKey || 'termi:paragraph-comments';
const verificationMode = wrapper?.dataset.verificationMode || 'captcha';
const useTurnstile = verificationMode === 'turnstile';
const useCaptcha = verificationMode === 'captcha';
const turnstileSiteKey = wrapper?.dataset.turnstileSiteKey || '';
const articleRoot = wrapper?.closest('[data-article-slug]') as HTMLElement | null;
const articleContent = articleRoot?.querySelector('.article-content') as HTMLElement | null;
@@ -355,35 +360,39 @@ const turnstileSiteKey = siteSettings.comments.turnstileEnabled
</label>
</div>
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--header-bg)]/60 px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--text-tertiary)]">${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>
${
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>
${
useTurnstile || useCaptcha
? `<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)]">${escapeHtml(t('common.humanVerification'))}</p>
${
useTurnstile
? `<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>
${
useTurnstile
? `<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">
<button type="submit" class="terminal-action-button terminal-action-button-primary">
@@ -496,13 +505,15 @@ const turnstileSiteKey = siteSettings.comments.turnstileEnabled
}
function resetHumanCheck() {
if (turnstileSiteKey) {
if (useTurnstile) {
turnstileTokenInput && (turnstileTokenInput.value = '');
turnstileWidget?.reset();
return;
}
void loadCaptcha(false);
if (useCaptcha) {
void loadCaptcha(false);
}
}
function resetReplyState() {
@@ -714,11 +725,11 @@ const turnstileSiteKey = siteSettings.comments.turnstileEnabled
descriptor.element.insertAdjacentElement('afterend', panel);
panel.classList.remove('hidden');
panel.dataset.paragraphKey = paragraphKey;
if (turnstileSiteKey) {
if (useTurnstile) {
if (!turnstileTokenInput?.value) {
await ensureTurnstile(false);
}
} else if (!captchaTokenInput?.value) {
} else if (useCaptcha && !captchaTokenInput?.value) {
await loadCaptcha(false);
}
@@ -849,7 +860,7 @@ const turnstileSiteKey = siteSettings.comments.turnstileEnabled
clearStatus();
setStatus(t('paragraphComments.submitting'), 'info');
if (turnstileSiteKey) {
if (useTurnstile) {
const token = String(formData.get('turnstileToken') || '').trim();
if (!token) {
setStatus(t('common.turnstileRequired'), 'error');
@@ -958,9 +969,9 @@ const turnstileSiteKey = siteSettings.comments.turnstileEnabled
updateMarkerState();
applyMarkerVisibility(markersVisible, { persist: false });
if (turnstileSiteKey) {
if (useTurnstile) {
await ensureTurnstile(false);
} else {
} else if (useCaptcha) {
await loadCaptcha(false);
}
await openFromHash();

View File

@@ -14,8 +14,10 @@ interface Props {
const { requestUrl, siteSettings } = Astro.props as Props;
const subscribeApiUrl = `${resolvePublicApiBaseUrl(requestUrl)}/subscriptions`;
const browserPushApiUrl = `${resolvePublicApiBaseUrl(requestUrl)}/subscriptions/browser-push`;
const captchaApiUrl = `${resolvePublicApiBaseUrl(requestUrl)}/comments/captcha`;
const popupSettings = siteSettings.subscriptions;
const turnstileSiteKey = popupSettings.turnstileEnabled
const verificationMode = popupSettings.verificationMode;
const turnstileSiteKey = verificationMode === 'turnstile'
? popupSettings.turnstileSiteKey || resolvePublicCommentTurnstileSiteKey()
: '';
const webPushPublicKey = popupSettings.webPushEnabled
@@ -29,7 +31,9 @@ const webPushPublicKey = popupSettings.webPushEnabled
data-subscription-popup-root
data-api-url={subscribeApiUrl}
data-browser-push-api-url={browserPushApiUrl}
data-captcha-url={captchaApiUrl}
data-delay-ms={String(Math.max(popupSettings.popupDelaySeconds, 3) * 1000)}
data-verification-mode={verificationMode}
data-turnstile-site-key={turnstileSiteKey || undefined}
data-web-push-public-key={webPushPublicKey || undefined}
hidden
@@ -137,16 +141,48 @@ const webPushPublicKey = popupSettings.webPushEnabled
/>
</label>
{turnstileSiteKey && (
{verificationMode !== 'off' && (
<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>
{verificationMode === 'turnstile' ? (
<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-subscription-popup-refresh-captcha
>
<i class="fas fa-rotate-right"></i>
<span>刷新</span>
</button>
)}
</div>
<div class="mt-3" data-subscription-popup-turnstile></div>
<input type="hidden" name="turnstileToken" />
{verificationMode === 'turnstile' ? (
<>
<div class="mt-3" data-subscription-popup-turnstile></div>
<input type="hidden" name="turnstileToken" />
</>
) : (
<>
<p
class="mt-3 text-sm text-[var(--text-secondary)]"
data-subscription-popup-captcha-question
>
加载中...
</p>
<input type="hidden" name="captchaToken" />
<input
type="text"
name="captchaAnswer"
inputmode="numeric"
placeholder="请输入上方答案"
class="mt-3 terminal-form-input"
/>
</>
)}
</div>
)}
@@ -199,8 +235,12 @@ const webPushPublicKey = popupSettings.webPushEnabled
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 captchaApiUrl = root.getAttribute('data-captcha-url') || '/api/comments/captcha';
const browserPushPublicKey = root.getAttribute('data-web-push-public-key') || '';
const browserPushButton = root.querySelector('[data-subscription-popup-browser-push]');
const verificationMode = root.getAttribute('data-verification-mode') || 'off';
const useTurnstile = verificationMode === 'turnstile';
const useCaptcha = verificationMode === 'captcha';
const turnstileSiteKey = root.getAttribute('data-turnstile-site-key') || '';
const turnstileContainer = root.querySelector(
'[data-subscription-popup-turnstile]',
@@ -208,6 +248,18 @@ const webPushPublicKey = popupSettings.webPushEnabled
const turnstileTokenInput = form?.querySelector(
'input[name="turnstileToken"]',
) as HTMLInputElement | null;
const captchaQuestion = root.querySelector(
'[data-subscription-popup-captcha-question]',
) as HTMLElement | null;
const refreshCaptchaButton = root.querySelector(
'[data-subscription-popup-refresh-captcha]',
) as HTMLButtonElement | null;
const captchaTokenInput = form?.querySelector(
'input[name="captchaToken"]',
) as HTMLInputElement | null;
const captchaAnswerInput = form?.querySelector(
'input[name="captchaAnswer"]',
) 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() || '' : '';
@@ -322,8 +374,10 @@ const webPushPublicKey = popupSettings.webPushEnabled
if (focusEmail && shouldFocusEmail()) {
emailInput.focus({ preventScroll: true });
}
if (turnstileSiteKey) {
if (useTurnstile) {
void ensureTurnstile(false);
} else if (useCaptcha) {
void loadCaptcha(false);
}
});
};
@@ -408,13 +462,42 @@ const webPushPublicKey = popupSettings.webPushEnabled
}
};
const resetHumanCheck = () => {
if (!turnstileSiteKey || !turnstileTokenInput) {
const loadCaptcha = async (showError = true) => {
if (!captchaQuestion || !captchaTokenInput || !captchaAnswerInput) {
return;
}
turnstileTokenInput.value = '';
turnstileWidget?.reset();
captchaQuestion.textContent = '加载中...';
captchaTokenInput.value = '';
captchaAnswerInput.value = '';
try {
const response = await fetch(captchaApiUrl);
if (!response.ok) {
throw new Error(await response.text());
}
const payload = (await response.json()) as { token?: string; question?: string };
captchaTokenInput.value = payload.token || '';
captchaQuestion.textContent = payload.question || '请刷新验证码';
} catch (error) {
captchaQuestion.textContent = '验证码加载失败,请刷新重试';
if (showError) {
setError(error instanceof Error ? error.message : '验证码加载失败,请刷新后重试。');
}
}
};
const resetHumanCheck = () => {
if (useTurnstile && turnstileTokenInput) {
turnstileTokenInput.value = '';
turnstileWidget?.reset();
return;
}
if (useCaptcha) {
void loadCaptcha(false);
}
};
const syncBrowserPushState = async () => {
@@ -481,6 +564,9 @@ const webPushPublicKey = popupSettings.webPushEnabled
});
dismissButton.addEventListener('click', () => closePopup(true));
refreshCaptchaButton?.addEventListener('click', () => {
void loadCaptcha(false);
});
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && opened) {
@@ -495,12 +581,24 @@ const webPushPublicKey = popupSettings.webPushEnabled
return;
}
if (turnstileSiteKey) {
if (useTurnstile) {
const token = turnstileTokenInput?.value.trim() || '';
if (!token) {
setError('请先完成人机验证。');
return;
}
} else if (useCaptcha) {
const captchaToken = captchaTokenInput?.value.trim() || '';
const captchaAnswer = captchaAnswerInput?.value.trim() || '';
if (!captchaToken) {
setError('验证码加载失败,请刷新后重试。');
return;
}
if (!captchaAnswer) {
setError('请先填写验证码答案。');
captchaAnswerInput?.focus();
return;
}
}
setPending('正在申请浏览器通知权限...');
@@ -517,6 +615,8 @@ const webPushPublicKey = popupSettings.webPushEnabled
subscription,
source: 'frontend-popup',
turnstileToken: turnstileTokenInput?.value || undefined,
captchaToken: captchaTokenInput?.value || undefined,
captchaAnswer: captchaAnswerInput?.value || undefined,
}),
});
@@ -554,12 +654,24 @@ const webPushPublicKey = popupSettings.webPushEnabled
return;
}
if (turnstileSiteKey) {
if (useTurnstile) {
const token = String(formData.get('turnstileToken') || '').trim();
if (!token) {
setError('请先完成人机验证。');
return;
}
} else if (useCaptcha) {
const captchaToken = String(formData.get('captchaToken') || '').trim();
const captchaAnswer = String(formData.get('captchaAnswer') || '').trim();
if (!captchaToken) {
setError('验证码加载失败,请刷新后重试。');
return;
}
if (!captchaAnswer) {
setError('请先填写验证码答案。');
captchaAnswerInput?.focus();
return;
}
}
setPending('正在提交订阅申请...');
@@ -575,6 +687,8 @@ const webPushPublicKey = popupSettings.webPushEnabled
displayName,
source: 'frontend-popup',
turnstileToken: formData.get('turnstileToken'),
captchaToken: formData.get('captchaToken'),
captchaAnswer: formData.get('captchaAnswer'),
}),
});

View File

@@ -3,6 +3,7 @@ import type {
ContentOverview,
ContentWindowHighlight,
FriendLink as UiFriendLink,
HumanVerificationMode,
Post as UiPost,
PopularPostHighlight,
SiteSettings,
@@ -36,6 +37,24 @@ function toUrlLike(value: string | URL) {
return value instanceof URL ? value : new URL(value);
}
function normalizeVerificationMode(
value: string | null | undefined,
fallback: HumanVerificationMode,
): HumanVerificationMode {
switch ((value ?? '').trim().toLowerCase()) {
case 'off':
return 'off';
case 'captcha':
case 'normal':
case 'simple':
return 'captcha';
case 'turnstile':
return 'turnstile';
default:
return fallback;
}
}
const buildTimePublicApiBaseUrl = normalizeApiBaseUrl(import.meta.env.PUBLIC_API_BASE_URL);
const buildTimeCommentTurnstileSiteKey =
import.meta.env.PUBLIC_COMMENT_TURNSTILE_SITE_KEY?.trim() ?? '';
@@ -262,7 +281,9 @@ export interface ApiSiteSettings {
}> | null;
ai_enabled: boolean;
paragraph_comments_enabled: boolean;
comment_verification_mode?: HumanVerificationMode | null;
comment_turnstile_enabled: boolean;
subscription_verification_mode?: HumanVerificationMode | null;
subscription_turnstile_enabled: boolean;
web_push_enabled: boolean;
turnstile_site_key: string | null;
@@ -452,6 +473,7 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
},
comments: {
paragraphsEnabled: true,
verificationMode: 'captcha',
turnstileEnabled: false,
turnstileSiteKey: undefined,
},
@@ -460,6 +482,7 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
popupTitle: '订阅更新',
popupDescription: '有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订。',
popupDelaySeconds: 18,
verificationMode: 'off',
turnstileEnabled: false,
turnstileSiteKey: undefined,
webPushEnabled: false,
@@ -561,7 +584,17 @@ const normalizeFriendLink = (friendLink: ApiFriendLink): AppFriendLink => ({
status: friendLink.status,
});
const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
const commentVerificationMode = normalizeVerificationMode(
settings.comment_verification_mode,
settings.comment_turnstile_enabled ? 'turnstile' : 'captcha',
);
const subscriptionVerificationMode = normalizeVerificationMode(
settings.subscription_verification_mode,
settings.subscription_turnstile_enabled ? 'turnstile' : 'off',
);
return {
id: String(settings.id),
siteName: settings.site_name || DEFAULT_SITE_SETTINGS.siteName,
siteShortName: settings.site_short_name || DEFAULT_SITE_SETTINGS.siteShortName,
@@ -599,8 +632,9 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
enabled: Boolean(settings.ai_enabled),
},
comments: {
verificationMode: commentVerificationMode,
paragraphsEnabled: settings.paragraph_comments_enabled ?? true,
turnstileEnabled: Boolean(settings.comment_turnstile_enabled),
turnstileEnabled: commentVerificationMode === 'turnstile',
turnstileSiteKey:
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined,
},
@@ -615,7 +649,8 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
popupDelaySeconds:
settings.subscription_popup_delay_seconds ??
DEFAULT_SITE_SETTINGS.subscriptions.popupDelaySeconds,
turnstileEnabled: Boolean(settings.subscription_turnstile_enabled),
verificationMode: subscriptionVerificationMode,
turnstileEnabled: subscriptionVerificationMode === 'turnstile',
turnstileSiteKey:
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined,
webPushEnabled: Boolean(settings.web_push_enabled),
@@ -628,7 +663,8 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
defaultOgImage: settings.seo_default_og_image ?? undefined,
defaultTwitterHandle: settings.seo_default_twitter_handle ?? undefined,
},
});
};
};
const normalizeContentOverview = (
overview: ApiHomePagePayload['content_overview'] | undefined,
@@ -937,13 +973,23 @@ class ApiClient {
});
}
async subscribe(input: { email: string; displayName?: string; source?: string }): Promise<PublicSubscriptionResponse> {
async subscribe(input: {
email: string;
displayName?: string;
source?: string;
turnstileToken?: string;
captchaToken?: string;
captchaAnswer?: string;
}): Promise<PublicSubscriptionResponse> {
return this.fetch<PublicSubscriptionResponse>('/subscriptions', {
method: 'POST',
body: JSON.stringify({
email: input.email,
displayName: input.displayName,
source: input.source,
turnstileToken: input.turnstileToken,
captchaToken: input.captchaToken,
captchaAnswer: input.captchaAnswer,
}),
});
}

View File

@@ -59,6 +59,8 @@ export interface FriendLink {
category?: string;
}
export type HumanVerificationMode = 'off' | 'captcha' | 'turnstile';
export interface SiteSettings {
id: string;
siteName: string;
@@ -85,6 +87,7 @@ export interface SiteSettings {
};
comments: {
paragraphsEnabled: boolean;
verificationMode: HumanVerificationMode;
turnstileEnabled: boolean;
turnstileSiteKey?: string;
};
@@ -93,6 +96,7 @@ export interface SiteSettings {
popupTitle: string;
popupDescription: string;
popupDelaySeconds: number;
verificationMode: HumanVerificationMode;
turnstileEnabled: boolean;
turnstileSiteKey?: string;
webPushEnabled: boolean;

View File

@@ -187,7 +187,7 @@ const breadcrumbJsonLd = {
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8" data-article-slug={post.slug}>
<div class="flex flex-col gap-8 lg:flex-row">
<div class="min-w-0 flex-1">
<TerminalWindow title={`~/content/posts/${post.slug}.md`} class="w-full">
<TerminalWindow title={`~/articles/${post.slug}`} class="w-full">
<div class="px-4 pb-2">
<div class="terminal-panel ml-4 mt-4 space-y-5">
<div class="flex flex-wrap items-start justify-between gap-4">
@@ -252,7 +252,7 @@ const breadcrumbJsonLd = {
</div>
<div class="px-4 pb-2">
<CommandPrompt command={`bat --style=plain ${post.slug}.md`} />
<CommandPrompt command={`preview article --slug ${post.slug}`} />
<div class="ml-4 mt-4 space-y-6">
{post.image && (
@@ -298,10 +298,7 @@ const breadcrumbJsonLd = {
</div>
<div class="px-4 py-6">
<div class="terminal-panel-muted ml-4 mt-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<span class="text-sm text-[var(--text-secondary)]">
file://content/posts/{post.slug}.md
</span>
<div class="terminal-panel-muted ml-4 mt-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-end">
<div class="flex flex-wrap gap-2">
<a href="/articles" class="terminal-action-button">
<i class="fas fa-list"></i>

View File

@@ -80,14 +80,14 @@ const postTypeFilters = [
const typePromptCommand =
selectedType === 'all'
? `grep -E "^type: (article|tweet)$" ./posts/*.md`
: `grep -E "^type: ${selectedType}$" ./posts/*.md`;
? 'posts query --type all'
: `posts query --type ${selectedType}`;
const categoryPromptCommand = selectedCategory
? `grep -El "^category: ${selectedCategory}$" ./posts/*.md`
: `cut -d: -f2 ./categories.index | sort -u`;
? `posts query --category "${selectedCategory}"`
: 'categories list --sort name';
const tagPromptCommand = selectedTag
? `grep -Ril "#${selectedTag}" ./posts`
: `cut -d: -f2 ./tags.index | sort -u`;
? `posts query --tag "${selectedTag}"`
: 'tags list --sort popularity';
const hasActiveFilters =
Boolean(selectedSearch || selectedTag || selectedCategory || selectedType !== 'all' || currentPage > 1);
const canonicalUrl = hasActiveFilters ? '/articles' : undefined;
@@ -126,7 +126,7 @@ const buildArticlesUrl = ({
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/articles/index" class="w-full">
<div class="px-4 pb-2">
<CommandPrompt command="find ./posts -type f -name '*.md' | sort" />
<CommandPrompt command="posts list --sort published_at --order desc" />
<div class="ml-4 mt-4 space-y-3">
<h1 class="text-3xl font-bold tracking-tight text-[var(--title-color)]">{t('articlesPage.title')}</h1>
@@ -141,7 +141,7 @@ const buildArticlesUrl = ({
{selectedSearch && (
<span class="terminal-stat-pill">
<i class="fas fa-magnifying-glass text-[var(--primary)]"></i>
grep: {selectedSearch}
search: {selectedSearch}
</span>
)}
{selectedCategory && (

View File

@@ -38,11 +38,11 @@ const filteredPosts = selectedCategory
? allPosts.filter((post) => (post.category || '').trim().toLowerCase() === normalizedSelectedCategory)
: [];
const categoryPromptCommand = selectedCategory
? `grep -El "^category: ${selectedCategory}$" ./posts/*.md`
: 'cut -d: -f2 ./categories.index | sort -u';
? `posts query --category "${selectedCategory}"`
: 'categories list --sort name';
const resultsPromptCommand = selectedCategory
? `find ./posts -type f | xargs grep -il "^category: ${selectedCategory}$"`
: 'find ./posts -type f | sort';
? `posts list --category "${selectedCategory}"`
: 'posts list --group-by category';
const categoryAccentMap = Object.fromEntries(
categories.map((category) => [category.name.trim().toLowerCase(), getAccentVars(getCategoryTheme(category.name))])
);
@@ -58,7 +58,7 @@ const pageDescription = selectedCategoryRecord?.seoDescription || selectedCatego
<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">
<CommandPrompt command="find ./categories -maxdepth 1 -type d | sort" />
<CommandPrompt command="categories list --with-counts" />
<div class="terminal-panel ml-4 mt-4">
<div class="terminal-kicker">content taxonomy</div>
<div class="terminal-section-title mt-4">
@@ -273,11 +273,11 @@ const pageDescription = selectedCategoryRecord?.seoDescription || selectedCatego
function updatePrompts() {
const filterCommand = state.category
? `grep -El "^category: ${state.category}$" ./posts/*.md`
: 'cut -d: -f2 ./categories.index | sort -u';
? `posts query --category "${state.category}"`
: 'categories list --sort name';
const resultsCommand = state.category
? `find ./posts -type f | xargs grep -il "^category: ${state.category}$"`
: 'find ./posts -type f | sort';
? `posts list --category "${state.category}"`
: 'posts list --group-by category';
promptApi?.set?.('categories-filter-prompt', filterCommand, { typing: false });
promptApi?.set?.('categories-results-prompt', resultsCommand, { typing: false });

View File

@@ -59,7 +59,7 @@ export const GET: APIRoute = async ({ params }) => {
<circle cx="92" cy="77" r="10" fill="#FF5F56"/>
<circle cx="124" cy="77" r="10" fill="#FFBD2E"/>
<circle cx="156" cy="77" r="10" fill="#27C93F"/>
<text x="190" y="83" fill="#9CA3AF" font-family="'JetBrains Mono', monospace" font-size="22">~/content/posts/${escapeXml(post.slug)}.md</text>
<text x="190" y="83" fill="#9CA3AF" font-family="'JetBrains Mono', monospace" font-size="22">~/articles/${escapeXml(post.slug)}</text>
<rect x="88" y="150" width="180" height="44" rx="22" fill="rgba(0,255,157,0.12)" stroke="url(#accent)"/>
<text x="178" y="178" text-anchor="middle" fill="#8BFFD3" font-family="'JetBrains Mono', monospace" font-size="24">${category}</text>
<text x="88" y="274" fill="#F8FAFC" font-family="'IBM Plex Sans', Arial, sans-serif" font-size="64" font-weight="700">${title}</text>