Fix admin login and add subscription popup settings
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 6s
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Failing after 5s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Failing after 6s

This commit is contained in:
2026-04-01 00:05:16 +08:00
parent 350262c910
commit 660b255700
19 changed files with 1096 additions and 32 deletions

View File

@@ -12,6 +12,7 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/markdown-remark": "^7.0.1",
"@astrojs/node": "^10.0.4",
"@astrojs/svelte": "^8.0.3",
"@astrojs/tailwind": "^6.0.2",

View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@astrojs/markdown-remark':
specifier: ^7.0.1
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))

View File

@@ -0,0 +1,678 @@
---
import { resolvePublicApiBaseUrl } from '../lib/api/client';
import type { SiteSettings } from '../lib/types';
interface Props {
requestUrl?: string | URL;
siteSettings: SiteSettings;
}
const { requestUrl, siteSettings } = Astro.props as Props;
const subscribeApiUrl = `${resolvePublicApiBaseUrl(requestUrl)}/subscriptions`;
const popupSettings = siteSettings.subscriptions;
---
{popupSettings.popupEnabled && (
<div
class="subscription-popup-root"
data-subscription-popup-root
data-api-url={subscribeApiUrl}
data-delay-ms={String(Math.max(popupSettings.popupDelaySeconds, 3) * 1000)}
hidden
>
<section
class="subscription-popup-panel terminal-panel"
data-subscription-popup-panel
role="dialog"
aria-labelledby="subscription-popup-title"
aria-describedby="subscription-popup-description"
>
<button
type="button"
class="subscription-popup-close"
data-subscription-popup-close
aria-label="关闭订阅提示"
>
<i class="fas fa-xmark"></i>
</button>
<div class="subscription-popup-main">
<div class="subscription-popup-copy">
<div class="subscription-popup-copy-head">
<div class="subscription-popup-copy-mark">
<span class="subscription-popup-icon" aria-hidden="true">
<i class="fas fa-paper-plane"></i>
</span>
<div class="subscription-popup-copy-body">
<p class="terminal-kicker">reader updates</p>
<h3 id="subscription-popup-title">{popupSettings.popupTitle}</h3>
<p id="subscription-popup-description">{popupSettings.popupDescription}</p>
</div>
</div>
<div class="subscription-popup-badges" aria-hidden="true">
<span class="subscription-popup-badge">新文章</span>
<span class="subscription-popup-badge">汇总简报</span>
<span class="subscription-popup-badge">低频提醒</span>
</div>
</div>
<div class="subscription-popup-meta">
<span class="terminal-stat-pill">
<i class="fas fa-newspaper text-[var(--primary)]"></i>
新文章 / 汇总简报
</span>
<span class="terminal-stat-pill">
<i class="fas fa-envelope-open-text text-[var(--primary)]"></i>
邮箱确认后生效
</span>
<span class="terminal-stat-pill">
<i class="fas fa-user-shield text-[var(--primary)]"></i>
随时可退订
</span>
</div>
</div>
<form class="subscription-popup-form" data-subscription-popup-form>
<label class="subscription-popup-field">
<span class="subscription-popup-field-label">邮箱地址</span>
<input
type="email"
name="email"
placeholder="name@example.com"
autocomplete="email"
required
data-subscription-popup-email
/>
</label>
<div class="subscription-popup-actions">
<button
type="submit"
class="terminal-action-button terminal-action-button-primary"
>
订阅更新
</button>
<button
type="button"
class="terminal-action-button"
data-subscription-popup-dismiss
>
稍后提醒
</button>
</div>
</form>
</div>
<p class="subscription-popup-status" data-subscription-popup-status aria-live="polite">
只在有新文章或汇总简报时发送提醒,不会把它做成高频打扰。
</p>
</section>
</div>
)}
<script>
(() => {
const DISMISS_KEY = 'termi:subscription-popup:dismiss-until';
const SUBSCRIBED_KEY = 'termi:subscription-popup:subscribed-at';
const DISMISS_TTL_MS = 7 * 24 * 60 * 60 * 1000;
document.querySelectorAll('[data-subscription-popup-root]').forEach((root) => {
if (!(root instanceof HTMLElement)) {
return;
}
const form = root.querySelector('[data-subscription-popup-form]');
const status = root.querySelector('[data-subscription-popup-status]');
const emailInput = root.querySelector('[data-subscription-popup-email]');
const dismissButton = root.querySelector('[data-subscription-popup-dismiss]');
const apiUrl = root.getAttribute('data-api-url');
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() || '' : '';
if (
!(form instanceof HTMLFormElement) ||
!(status instanceof HTMLElement) ||
!(emailInput instanceof HTMLInputElement) ||
!(dismissButton instanceof HTMLButtonElement) ||
!apiUrl ||
pathname.startsWith('/subscriptions/')
) {
return;
}
let opened = false;
let autoReady = false;
let engaged = false;
let autoOpened = false;
let hideTimer = 0;
let successTimer = 0;
const header = document.querySelector('header');
const shouldFocusEmail = () =>
window.matchMedia('(hover: hover) and (pointer: fine)').matches;
const syncPopupOffset = () => {
const headerBottom =
header instanceof HTMLElement ? Math.round(header.getBoundingClientRect().bottom) : 0;
const offset = Math.max(14, headerBottom + 14);
root.style.setProperty('--subscription-popup-offset', `${offset}px`);
};
const hasDismissed = () => {
try {
return Number(window.localStorage.getItem(DISMISS_KEY) || '0') > Date.now();
} catch {
return false;
}
};
const hasSubmitted = () => {
try {
return Boolean(window.localStorage.getItem(SUBSCRIBED_KEY));
} catch {
return false;
}
};
const rememberDismiss = () => {
try {
window.localStorage.setItem(DISMISS_KEY, String(Date.now() + DISMISS_TTL_MS));
} catch {
// Ignore storage failures.
}
};
const rememberSubmitted = () => {
try {
window.localStorage.setItem(SUBSCRIBED_KEY, new Date().toISOString());
} catch {
// Ignore storage failures.
}
};
const resetStatus = () => {
delete status.dataset.state;
status.textContent = defaultStatus;
};
const openPopup = ({ focusEmail = false } = {}) => {
if (opened || hasSubmitted()) {
return;
}
syncPopupOffset();
window.clearTimeout(hideTimer);
if (status.dataset.state !== 'pending') {
resetStatus();
}
opened = true;
root.hidden = false;
window.requestAnimationFrame(() => {
root.classList.add('is-visible');
if (focusEmail && shouldFocusEmail()) {
emailInput.focus({ preventScroll: true });
}
});
};
const closePopup = (remember = true) => {
if (!opened) {
return;
}
opened = false;
window.clearTimeout(successTimer);
root.classList.remove('is-visible');
if (remember) {
rememberDismiss();
}
hideTimer = window.setTimeout(() => {
if (!opened) {
root.hidden = true;
}
}, 260);
};
const maybeAutoOpen = () => {
if (autoOpened || !autoReady || !engaged || hasDismissed() || hasSubmitted()) {
return;
}
autoOpened = true;
openPopup();
};
const markEngaged = () => {
engaged = true;
maybeAutoOpen();
};
const handleScroll = () => {
const doc = document.documentElement;
const maxScroll = Math.max(doc.scrollHeight - window.innerHeight, 1);
const progress = (window.scrollY / maxScroll) * 100;
if (progress >= 35) {
markEngaged();
window.removeEventListener('scroll', handleScroll);
}
};
syncPopupOffset();
if (header instanceof HTMLElement && typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(() => syncPopupOffset());
observer.observe(header);
}
window.addEventListener('resize', syncPopupOffset, { passive: true });
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('pointerdown', markEngaged, { once: true, passive: true });
window.addEventListener('keydown', markEngaged, { once: true });
window.setTimeout(() => {
autoReady = true;
maybeAutoOpen();
}, delayMs);
document.addEventListener('click', (event) => {
const trigger =
event.target instanceof Element
? event.target.closest('[data-subscription-popup-open]')
: null;
if (!trigger) {
return;
}
event.preventDefault();
openPopup({ focusEmail: true });
});
root.querySelectorAll('[data-subscription-popup-close]').forEach((button) => {
button.addEventListener('click', () => closePopup(true));
});
dismissButton.addEventListener('click', () => closePopup(true));
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && opened) {
closePopup(true);
}
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(form);
const email = String(formData.get('email') || '').trim();
const displayName = String(formData.get('displayName') || '').trim();
if (!email) {
status.dataset.state = 'error';
status.textContent = '请输入邮箱地址。';
emailInput.focus();
return;
}
status.dataset.state = 'pending';
status.textContent = '正在提交订阅申请...';
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
displayName,
source: 'frontend-popup',
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.message || payload?.description || '订阅失败,请稍后再试。');
}
rememberSubmitted();
form.reset();
status.dataset.state = 'success';
status.textContent =
payload?.message || '订阅申请已提交,请前往邮箱确认后生效。';
successTimer = window.setTimeout(() => closePopup(false), 2200);
} catch (error) {
status.dataset.state = 'error';
status.textContent = error instanceof Error ? error.message : '订阅失败,请稍后重试。';
}
});
});
})();
</script>
<style>
.subscription-popup-root {
position: fixed;
inset: 0;
z-index: 90;
display: grid;
align-items: start;
justify-items: center;
padding: 0 max(1rem, env(safe-area-inset-right, 0px)) 0 max(1rem, env(safe-area-inset-left, 0px));
pointer-events: none;
}
.subscription-popup-root[hidden] {
display: none !important;
}
.subscription-popup-panel {
position: relative;
width: min(100%, 62rem);
display: grid;
gap: 1rem;
margin-top: var(--subscription-popup-offset, calc(env(safe-area-inset-top, 0px) + 5.25rem));
padding: 1rem 1rem 1.05rem;
border-radius: 1.45rem;
opacity: 0;
transform: translateY(-1rem) scale(0.985);
transition:
opacity 0.24s ease,
transform 0.24s ease;
pointer-events: auto;
overflow: hidden;
backdrop-filter: blur(16px) saturate(135%);
background:
radial-gradient(circle at top left, rgba(var(--primary-rgb), 0.12), transparent 24%),
linear-gradient(
180deg,
color-mix(in oklab, var(--terminal-bg) 98%, transparent),
color-mix(in oklab, var(--header-bg) 92%, transparent)
);
box-shadow:
0 24px 64px rgba(var(--text-rgb), 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.34);
}
.subscription-popup-panel::before {
content: '';
position: absolute;
inset: 0 0 auto;
height: 3px;
background:
linear-gradient(
90deg,
color-mix(in oklab, var(--primary) 88%, white),
color-mix(in oklab, var(--secondary) 80%, white)
);
opacity: 0.92;
}
.subscription-popup-root.is-visible .subscription-popup-panel {
opacity: 1;
transform: translateY(0) scale(1);
}
.subscription-popup-close {
position: absolute;
top: 0.8rem;
right: 0.8rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.15rem;
height: 2.15rem;
border-radius: 999px;
border: 1px solid color-mix(in oklab, var(--primary) 12%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 94%, transparent);
color: var(--text-tertiary);
cursor: pointer;
transition:
border-color 0.2s ease,
color 0.2s ease,
transform 0.2s ease;
}
.subscription-popup-close:hover {
color: var(--title-color);
border-color: color-mix(in oklab, var(--primary) 24%, var(--border-color));
transform: translateY(-1px);
}
.subscription-popup-copy {
display: grid;
gap: 0.85rem;
min-width: 0;
}
.subscription-popup-main {
display: grid;
gap: 1rem;
}
.subscription-popup-copy-head {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 0.8rem;
}
.subscription-popup-copy-mark {
display: flex;
align-items: flex-start;
gap: 0.9rem;
min-width: 0;
padding-right: 0.25rem;
}
.subscription-popup-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.8rem;
height: 2.8rem;
flex-shrink: 0;
border-radius: 1rem;
border: 1px solid color-mix(in oklab, var(--primary) 14%, var(--border-color));
background:
linear-gradient(
180deg,
color-mix(in oklab, var(--primary) 12%, var(--terminal-bg)),
color-mix(in oklab, var(--primary) 6%, var(--header-bg))
);
color: var(--primary);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.24),
0 10px 24px rgba(var(--primary-rgb), 0.12);
}
.subscription-popup-copy-body {
min-width: 0;
}
.subscription-popup-copy-body .terminal-kicker {
margin: 0 0 0.55rem;
}
.subscription-popup-copy h3 {
margin: 0;
font-size: 1.12rem;
line-height: 1.35;
color: var(--title-color);
}
.subscription-popup-copy p:last-child {
margin: 0.45rem 0 0;
color: var(--text-secondary);
line-height: 1.72;
font-size: 0.94rem;
}
.subscription-popup-meta {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
}
.subscription-popup-badges {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
padding-right: 2.55rem;
}
.subscription-popup-badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid color-mix(in oklab, var(--primary) 12%, var(--border-color));
background: color-mix(in oklab, var(--primary) 6%, var(--terminal-bg));
color: var(--text-secondary);
padding: 0.28rem 0.7rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
}
.subscription-popup-form {
display: grid;
gap: 0.8rem;
align-items: end;
}
.subscription-popup-field {
display: grid;
gap: 0.45rem;
min-width: 0;
}
.subscription-popup-field-label {
color: var(--text-tertiary);
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.subscription-popup-form input {
width: 100%;
min-height: 3rem;
border-radius: 1rem;
border: 1px solid color-mix(in oklab, var(--primary) 12%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 96%, transparent);
color: var(--title-color);
padding: 0.9rem 1rem;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
background 0.2s ease;
}
.subscription-popup-form input::placeholder {
color: var(--text-tertiary);
}
.subscription-popup-form input:focus-visible {
outline: none;
border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 98%, transparent);
box-shadow: 0 0 0 4px rgba(var(--primary-rgb), 0.08);
}
.subscription-popup-actions {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
}
.subscription-popup-actions .terminal-action-button {
min-width: 8.5rem;
justify-content: center;
}
.subscription-popup-status {
margin: 0;
padding: 0.8rem 0.95rem;
border-radius: 1rem;
border: 1px dashed color-mix(in oklab, var(--primary) 12%, var(--border-color));
background: color-mix(in oklab, var(--primary) 4%, var(--terminal-bg));
color: var(--text-secondary);
font-size: 0.9rem;
line-height: 1.7;
}
.subscription-popup-status[data-state='pending'] {
color: var(--primary);
border-color: color-mix(in oklab, var(--primary) 26%, var(--border-color));
background: color-mix(in oklab, var(--primary) 8%, var(--terminal-bg));
}
.subscription-popup-status[data-state='success'] {
color: var(--success-dark);
border-color: color-mix(in oklab, var(--success) 24%, var(--border-color));
background: color-mix(in oklab, var(--success) 10%, var(--terminal-bg));
}
.subscription-popup-status[data-state='error'] {
color: var(--danger);
border-color: color-mix(in oklab, var(--danger) 24%, var(--border-color));
background: color-mix(in oklab, var(--danger) 8%, var(--terminal-bg));
}
@media (min-width: 960px) {
.subscription-popup-main {
grid-template-columns: minmax(0, 1.18fr) minmax(19rem, 0.88fr);
align-items: end;
gap: 1rem 1.2rem;
}
.subscription-popup-panel {
padding: 1rem 1.1rem 1.1rem;
}
.subscription-popup-form {
align-self: end;
}
.subscription-popup-status {
grid-column: 1 / -1;
}
}
@media (max-width: 767px) {
.subscription-popup-root {
padding-inline: 0.75rem;
}
.subscription-popup-panel {
gap: 0.9rem;
padding: 0.95rem 0.9rem 0.95rem;
}
.subscription-popup-copy-mark {
gap: 0.75rem;
padding-right: 0;
}
.subscription-popup-icon {
width: 2.45rem;
height: 2.45rem;
border-radius: 0.9rem;
}
.subscription-popup-badges {
padding-right: 2.15rem;
}
.subscription-popup-actions .terminal-action-button {
flex: 1 1 100%;
}
}
</style>

View File

@@ -2,6 +2,7 @@
import '../styles/global.css';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import SubscriptionPopup from '../components/SubscriptionPopup.astro';
import BackToTop from '../components/interactive/BackToTop.svelte';
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
import { getI18n, LOCALE_COOKIE_NAME, SUPPORTED_LOCALES } from '../lib/i18n';
@@ -475,6 +476,7 @@ const i18nPayload = JSON.stringify({ locale, messages });
</main>
<Footer siteSettings={siteSettings} />
<SubscriptionPopup siteSettings={siteSettings} requestUrl={Astro.url} />
<BackToTop client:load />
</div>
</body>

View File

@@ -230,6 +230,10 @@ export interface ApiSiteSettings {
}> | null;
ai_enabled: boolean;
paragraph_comments_enabled: boolean;
subscription_popup_enabled: boolean;
subscription_popup_title: string | null;
subscription_popup_description: string | null;
subscription_popup_delay_seconds: number | null;
seo_default_og_image: string | null;
seo_default_twitter_handle: string | null;
}
@@ -398,6 +402,12 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
comments: {
paragraphsEnabled: true,
},
subscriptions: {
popupEnabled: true,
popupTitle: '订阅更新',
popupDescription: '有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订。',
popupDelaySeconds: 18,
},
seo: {
defaultOgImage: undefined,
defaultTwitterHandle: undefined,
@@ -523,6 +533,18 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
comments: {
paragraphsEnabled: settings.paragraph_comments_enabled ?? true,
},
subscriptions: {
popupEnabled:
settings.subscription_popup_enabled ?? DEFAULT_SITE_SETTINGS.subscriptions.popupEnabled,
popupTitle:
settings.subscription_popup_title || DEFAULT_SITE_SETTINGS.subscriptions.popupTitle,
popupDescription:
settings.subscription_popup_description ||
DEFAULT_SITE_SETTINGS.subscriptions.popupDescription,
popupDelaySeconds:
settings.subscription_popup_delay_seconds ??
DEFAULT_SITE_SETTINGS.subscriptions.popupDelaySeconds,
},
seo: {
defaultOgImage: settings.seo_default_og_image ?? undefined,
defaultTwitterHandle: settings.seo_default_twitter_handle ?? undefined,

View File

@@ -77,6 +77,12 @@ export interface SiteSettings {
comments: {
paragraphsEnabled: boolean;
};
subscriptions: {
popupEnabled: boolean;
popupTitle: string;
popupDescription: string;
popupDelaySeconds: number;
};
seo: {
defaultOgImage?: string;
defaultTwitterHandle?: string;

View File

@@ -8,7 +8,6 @@ import FriendLinkCard from '../components/FriendLinkCard.astro';
import ViewMoreLink from '../components/ui/ViewMoreLink.astro';
import StatsList from '../components/StatsList.astro';
import TechStackList from '../components/TechStackList.astro';
import SubscriptionSignup from '../components/SubscriptionSignup.astro';
import { terminalConfig } from '../lib/config/terminal';
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
import { formatReadTime, getI18n } from '../lib/i18n';
@@ -243,6 +242,12 @@ const navLinks = [
<i class="fas fa-file-alt text-[10px]"></i>
<span id="home-results-count">{t('common.resultsCount', { count: filteredPostsCount })}</span>
</span>
{siteSettings.subscriptions.popupEnabled && (
<button type="button" class="terminal-subtle-link" data-subscription-popup-open>
<i class="fas fa-envelope text-[11px]"></i>
<span>订阅更新</span>
</button>
)}
{siteSettings.ai.enabled && (
<a href="/ask" class="terminal-subtle-link">
<i class="fas fa-robot text-[11px]"></i>
@@ -260,10 +265,6 @@ const navLinks = [
</a>
))}
</div>
<div class="ml-4 mt-5">
<SubscriptionSignup requestUrl={Astro.request.url} />
</div>
</div>
{apiError && (

View File

@@ -11,8 +11,8 @@ const api = createApiClient({ requestUrl: Astro.url });
const apiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
const EVENT_OPTIONS = [
{ value: 'post.published', label: '新文章通知' },
{ value: 'digest.weekly', label: '周报 Digest' },
{ value: 'digest.monthly', label: '月报 Digest' },
{ value: 'digest.weekly', label: '每周简报' },
{ value: 'digest.monthly', label: '每月简报' },
{ value: 'comment.created', label: '评论通知' },
{ value: 'friend_link.created', label: '友链申请通知' },
] as const;