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
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:
@@ -53,8 +53,8 @@ import type {
|
|||||||
import { getRuntimeAdminBaseUrl, normalizeAdminBaseUrl } from '@/lib/runtime-config'
|
import { getRuntimeAdminBaseUrl, normalizeAdminBaseUrl } from '@/lib/runtime-config'
|
||||||
|
|
||||||
const envApiBase = normalizeAdminBaseUrl(import.meta.env.VITE_API_BASE)
|
const envApiBase = normalizeAdminBaseUrl(import.meta.env.VITE_API_BASE)
|
||||||
const DEV_API_BASE = 'http://localhost:5150'
|
|
||||||
const PROD_DEFAULT_API_PORT = '5150'
|
const PROD_DEFAULT_API_PORT = '5150'
|
||||||
|
const DEV_DEFAULT_API_HOST = '127.0.0.1'
|
||||||
|
|
||||||
function getApiBase() {
|
function getApiBase() {
|
||||||
const runtimeApiBase = getRuntimeAdminBaseUrl('apiBaseUrl')
|
const runtimeApiBase = getRuntimeAdminBaseUrl('apiBaseUrl')
|
||||||
@@ -67,11 +67,12 @@ function getApiBase() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
return DEV_API_BASE
|
if (typeof window !== 'undefined') {
|
||||||
}
|
const { protocol, hostname } = window.location
|
||||||
|
return `${protocol}//${hostname}:${PROD_DEFAULT_API_PORT}`
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof window === 'undefined') {
|
return `http://${DEV_DEFAULT_API_HOST}:${PROD_DEFAULT_API_PORT}`
|
||||||
return DEV_API_BASE
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { protocol, hostname } = window.location
|
const { protocol, hostname } = window.location
|
||||||
|
|||||||
@@ -329,6 +329,10 @@ export interface AdminSiteSettingsResponse {
|
|||||||
notification_webhook_url: string | null
|
notification_webhook_url: string | null
|
||||||
notification_comment_enabled: boolean
|
notification_comment_enabled: boolean
|
||||||
notification_friend_link_enabled: boolean
|
notification_friend_link_enabled: boolean
|
||||||
|
subscription_popup_enabled: boolean
|
||||||
|
subscription_popup_title: string
|
||||||
|
subscription_popup_description: string
|
||||||
|
subscription_popup_delay_seconds: number
|
||||||
search_synonyms: string[]
|
search_synonyms: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,6 +391,10 @@ export interface SiteSettingsPayload {
|
|||||||
notificationWebhookUrl?: string | null
|
notificationWebhookUrl?: string | null
|
||||||
notificationCommentEnabled?: boolean
|
notificationCommentEnabled?: boolean
|
||||||
notificationFriendLinkEnabled?: boolean
|
notificationFriendLinkEnabled?: boolean
|
||||||
|
subscriptionPopupEnabled?: boolean
|
||||||
|
subscriptionPopupTitle?: string | null
|
||||||
|
subscriptionPopupDescription?: string | null
|
||||||
|
subscriptionPopupDelaySeconds?: number | null
|
||||||
searchSynonyms?: string[]
|
searchSynonyms?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,10 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload {
|
|||||||
notificationWebhookUrl: form.notification_webhook_url,
|
notificationWebhookUrl: form.notification_webhook_url,
|
||||||
notificationCommentEnabled: form.notification_comment_enabled,
|
notificationCommentEnabled: form.notification_comment_enabled,
|
||||||
notificationFriendLinkEnabled: form.notification_friend_link_enabled,
|
notificationFriendLinkEnabled: form.notification_friend_link_enabled,
|
||||||
|
subscriptionPopupEnabled: form.subscription_popup_enabled,
|
||||||
|
subscriptionPopupTitle: form.subscription_popup_title,
|
||||||
|
subscriptionPopupDescription: form.subscription_popup_description,
|
||||||
|
subscriptionPopupDelaySeconds: form.subscription_popup_delay_seconds,
|
||||||
searchSynonyms: form.search_synonyms,
|
searchSynonyms: form.search_synonyms,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -595,6 +599,70 @@ export function SiteSettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>订阅弹窗</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
前台会在用户停留一段时间并有滚动/交互后,再延迟弹出订阅窗口;这里统一控制开关和文案。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.subscription_popup_enabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateField('subscription_popup_enabled', event.target.checked)
|
||||||
|
}
|
||||||
|
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">开启前台订阅弹窗</div>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||||
|
开启后,首页不再塞一个突兀的大块订阅卡片,而是通过风格统一的弹窗承接订阅转化。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Field label="弹窗标题" hint="建议直接传达价值,例如“订阅更新”或“别错过新文章”。">
|
||||||
|
<Input
|
||||||
|
value={form.subscription_popup_title}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateField('subscription_popup_title', event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="触发延迟(秒)" hint="建议保持在 10~20 秒,避免首屏强打断。">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={3}
|
||||||
|
max={120}
|
||||||
|
value={form.subscription_popup_delay_seconds}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateField(
|
||||||
|
'subscription_popup_delay_seconds',
|
||||||
|
event.target.value ? Number(event.target.value) : 18,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="弹窗说明"
|
||||||
|
hint="建议明确订阅收益、需要邮箱确认,以及可随时退订,降低打扰感。"
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
value={form.subscription_popup_description}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateField('subscription_popup_description', event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>SEO、搜索与通知</CardTitle>
|
<CardTitle>SEO、搜索与通知</CardTitle>
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export function SubscriptionsPage() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Badge variant="secondary">订阅与推送</Badge>
|
<Badge variant="secondary">订阅与推送</Badge>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-semibold tracking-tight">订阅中心 / 异步投递 / Digest</h2>
|
<h2 className="text-3xl font-semibold tracking-tight">订阅中心 / 异步投递 / 汇总简报</h2>
|
||||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||||
这里统一管理邮件订阅、Webhook / Discord / Telegram / ntfy 推送目标;当前投递走异步队列,并支持 retry pending 状态追踪。
|
这里统一管理邮件订阅、Webhook / Discord / Telegram / ntfy 推送目标;当前投递走异步队列,并支持 retry pending 状态追踪。
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
FROM rust:1.88-bookworm AS builder
|
FROM rust:1.91.1-bookworm AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ mod m20260331_000025_create_post_revisions;
|
|||||||
mod m20260331_000026_create_subscriptions;
|
mod m20260331_000026_create_subscriptions;
|
||||||
mod m20260331_000027_create_notification_deliveries;
|
mod m20260331_000027_create_notification_deliveries;
|
||||||
mod m20260331_000028_expand_subscriptions_and_deliveries;
|
mod m20260331_000028_expand_subscriptions_and_deliveries;
|
||||||
|
mod m20260331_000029_add_subscription_popup_settings_to_site_settings;
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -76,6 +77,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260331_000026_create_subscriptions::Migration),
|
Box::new(m20260331_000026_create_subscriptions::Migration),
|
||||||
Box::new(m20260331_000027_create_notification_deliveries::Migration),
|
Box::new(m20260331_000027_create_notification_deliveries::Migration),
|
||||||
Box::new(m20260331_000028_expand_subscriptions_and_deliveries::Migration),
|
Box::new(m20260331_000028_expand_subscriptions_and_deliveries::Migration),
|
||||||
|
Box::new(m20260331_000029_add_subscription_popup_settings_to_site_settings::Migration),
|
||||||
// inject-above (do not remove this comment)
|
// inject-above (do not remove this comment)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let table = Alias::new("site_settings");
|
||||||
|
|
||||||
|
if !manager
|
||||||
|
.has_column("site_settings", "subscription_popup_enabled")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(Alias::new("subscription_popup_enabled"))
|
||||||
|
.boolean()
|
||||||
|
.null()
|
||||||
|
.default(true),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager
|
||||||
|
.has_column("site_settings", "subscription_popup_title")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(Alias::new("subscription_popup_title"))
|
||||||
|
.string()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager
|
||||||
|
.has_column("site_settings", "subscription_popup_description")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(Alias::new("subscription_popup_description"))
|
||||||
|
.text()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager
|
||||||
|
.has_column("site_settings", "subscription_popup_delay_seconds")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table)
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(Alias::new("subscription_popup_delay_seconds"))
|
||||||
|
.integer()
|
||||||
|
.null()
|
||||||
|
.default(18),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let table = Alias::new("site_settings");
|
||||||
|
|
||||||
|
for column in [
|
||||||
|
"subscription_popup_delay_seconds",
|
||||||
|
"subscription_popup_description",
|
||||||
|
"subscription_popup_title",
|
||||||
|
"subscription_popup_enabled",
|
||||||
|
] {
|
||||||
|
if manager.has_column("site_settings", column).await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.drop_column(Alias::new(column))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use axum::{http::Method, Router as AxumRouter};
|
use axum::{
|
||||||
|
http::{header, HeaderName, Method},
|
||||||
|
Router as AxumRouter,
|
||||||
|
};
|
||||||
use loco_rs::{
|
use loco_rs::{
|
||||||
app::{AppContext, Hooks, Initializer},
|
app::{AppContext, Hooks, Initializer},
|
||||||
bgworker::{BackgroundWorker, Queue},
|
bgworker::{BackgroundWorker, Queue},
|
||||||
@@ -15,8 +18,8 @@ use migration::Migrator;
|
|||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set,
|
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set,
|
||||||
};
|
};
|
||||||
use std::path::Path;
|
use std::{collections::BTreeSet, path::Path};
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::CorsLayer;
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -29,6 +32,48 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub struct App;
|
pub struct App;
|
||||||
|
|
||||||
|
fn normalized_origin(value: &str) -> Option<String> {
|
||||||
|
let trimmed = value.trim().trim_end_matches('/').to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_cors_origins() -> Vec<String> {
|
||||||
|
let mut origins = BTreeSet::new();
|
||||||
|
|
||||||
|
for origin in [
|
||||||
|
"http://127.0.0.1:4321",
|
||||||
|
"http://127.0.0.1:4322",
|
||||||
|
"http://localhost:4321",
|
||||||
|
"http://localhost:4322",
|
||||||
|
] {
|
||||||
|
origins.insert(origin.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in [
|
||||||
|
"APP_BASE_URL",
|
||||||
|
"ADMIN_API_BASE_URL",
|
||||||
|
"ADMIN_FRONTEND_BASE_URL",
|
||||||
|
"PUBLIC_API_BASE_URL",
|
||||||
|
"PUBLIC_FRONTEND_BASE_URL",
|
||||||
|
"TERMI_CORS_ALLOWED_ORIGINS",
|
||||||
|
] {
|
||||||
|
if let Ok(value) = std::env::var(key) {
|
||||||
|
for origin in value.split([',', ';', ' ']) {
|
||||||
|
if let Some(origin) = normalized_origin(origin) {
|
||||||
|
origins.insert(origin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
origins.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Hooks for App {
|
impl Hooks for App {
|
||||||
fn app_name() -> &'static str {
|
fn app_name() -> &'static str {
|
||||||
@@ -76,8 +121,22 @@ impl Hooks for App {
|
|||||||
.add_route(controllers::subscription::routes())
|
.add_route(controllers::subscription::routes())
|
||||||
}
|
}
|
||||||
async fn after_routes(router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
|
async fn after_routes(router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
|
||||||
|
let allowed_origins = collect_cors_origins()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|origin| origin.parse().ok())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let allowed_headers = [
|
||||||
|
header::ACCEPT,
|
||||||
|
header::ACCEPT_LANGUAGE,
|
||||||
|
header::AUTHORIZATION,
|
||||||
|
header::CONTENT_LANGUAGE,
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
header::COOKIE,
|
||||||
|
header::ORIGIN,
|
||||||
|
HeaderName::from_static("x-requested-with"),
|
||||||
|
];
|
||||||
let cors = CorsLayer::new()
|
let cors = CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(allowed_origins)
|
||||||
.allow_methods([
|
.allow_methods([
|
||||||
Method::GET,
|
Method::GET,
|
||||||
Method::POST,
|
Method::POST,
|
||||||
@@ -85,7 +144,8 @@ impl Hooks for App {
|
|||||||
Method::PATCH,
|
Method::PATCH,
|
||||||
Method::DELETE,
|
Method::DELETE,
|
||||||
])
|
])
|
||||||
.allow_headers(Any);
|
.allow_headers(allowed_headers)
|
||||||
|
.allow_credentials(true);
|
||||||
|
|
||||||
Ok(router.layer(cors))
|
Ok(router.layer(cors))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,13 @@ pub struct AdminSessionResponse {
|
|||||||
pub can_logout: bool,
|
pub can_logout: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_session_response(identity: Option<crate::controllers::admin::AdminIdentity>) -> AdminSessionResponse {
|
fn build_session_response(
|
||||||
let can_logout = matches!(identity.as_ref().map(|item| item.source.as_str()), Some("local"));
|
identity: Option<crate::controllers::admin::AdminIdentity>,
|
||||||
|
) -> AdminSessionResponse {
|
||||||
|
let can_logout = matches!(
|
||||||
|
identity.as_ref().map(|item| item.source.as_str()),
|
||||||
|
Some("local")
|
||||||
|
);
|
||||||
|
|
||||||
AdminSessionResponse {
|
AdminSessionResponse {
|
||||||
authenticated: identity.is_some(),
|
authenticated: identity.is_some(),
|
||||||
@@ -193,6 +198,10 @@ pub struct AdminSiteSettingsResponse {
|
|||||||
pub notification_webhook_url: Option<String>,
|
pub notification_webhook_url: Option<String>,
|
||||||
pub notification_comment_enabled: bool,
|
pub notification_comment_enabled: bool,
|
||||||
pub notification_friend_link_enabled: bool,
|
pub notification_friend_link_enabled: bool,
|
||||||
|
pub subscription_popup_enabled: bool,
|
||||||
|
pub subscription_popup_title: String,
|
||||||
|
pub subscription_popup_description: String,
|
||||||
|
pub subscription_popup_delay_seconds: i32,
|
||||||
pub search_synonyms: Vec<String>,
|
pub search_synonyms: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,6 +715,18 @@ fn build_settings_response(
|
|||||||
notification_webhook_url: item.notification_webhook_url,
|
notification_webhook_url: item.notification_webhook_url,
|
||||||
notification_comment_enabled: item.notification_comment_enabled.unwrap_or(false),
|
notification_comment_enabled: item.notification_comment_enabled.unwrap_or(false),
|
||||||
notification_friend_link_enabled: item.notification_friend_link_enabled.unwrap_or(false),
|
notification_friend_link_enabled: item.notification_friend_link_enabled.unwrap_or(false),
|
||||||
|
subscription_popup_enabled: item
|
||||||
|
.subscription_popup_enabled
|
||||||
|
.unwrap_or_else(site_settings::default_subscription_popup_enabled),
|
||||||
|
subscription_popup_title: item
|
||||||
|
.subscription_popup_title
|
||||||
|
.unwrap_or_else(site_settings::default_subscription_popup_title),
|
||||||
|
subscription_popup_description: item
|
||||||
|
.subscription_popup_description
|
||||||
|
.unwrap_or_else(site_settings::default_subscription_popup_description),
|
||||||
|
subscription_popup_delay_seconds: item
|
||||||
|
.subscription_popup_delay_seconds
|
||||||
|
.unwrap_or_else(site_settings::default_subscription_popup_delay_seconds),
|
||||||
search_synonyms: tech_stack_values(&item.search_synonyms),
|
search_synonyms: tech_stack_values(&item.search_synonyms),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -753,7 +774,10 @@ pub async fn session_login(
|
|||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn session_logout(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
pub async fn session_logout(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
let before = resolve_admin_identity(&headers);
|
let before = resolve_admin_identity(&headers);
|
||||||
if matches!(before.as_ref().map(|item| item.source.as_str()), Some("local")) {
|
if matches!(
|
||||||
|
before.as_ref().map(|item| item.source.as_str()),
|
||||||
|
Some("local")
|
||||||
|
) {
|
||||||
clear_local_session(&headers);
|
clear_local_session(&headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -764,7 +788,10 @@ pub async fn session_logout(headers: HeaderMap, State(ctx): State<AppContext>) -
|
|||||||
"admin.logout",
|
"admin.logout",
|
||||||
"admin_session",
|
"admin_session",
|
||||||
None,
|
None,
|
||||||
identity.email.clone().or_else(|| Some(identity.username.clone())),
|
identity
|
||||||
|
.email
|
||||||
|
.clone()
|
||||||
|
.or_else(|| Some(identity.username.clone())),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -843,10 +870,7 @@ pub async fn dashboard(headers: HeaderMap, State(ctx): State<AppContext>) -> Res
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut recent_posts = all_posts
|
let mut recent_posts = all_posts.clone().into_iter().collect::<Vec<_>>();
|
||||||
.clone()
|
|
||||||
.into_iter()
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
recent_posts.sort_by(|left, right| right.created_at.cmp(&left.created_at));
|
recent_posts.sort_by(|left, right| right.created_at.cmp(&left.created_at));
|
||||||
let recent_posts = recent_posts
|
let recent_posts = recent_posts
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -959,13 +983,19 @@ pub async fn dashboard(headers: HeaderMap, State(ctx): State<AppContext>) -> Res
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn analytics_overview(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
pub async fn analytics_overview(
|
||||||
|
headers: HeaderMap,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
check_auth(&headers)?;
|
check_auth(&headers)?;
|
||||||
format::json(analytics::build_admin_analytics(&ctx).await?)
|
format::json(analytics::build_admin_analytics(&ctx).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn get_site_settings(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
pub async fn get_site_settings(
|
||||||
|
headers: HeaderMap,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
check_auth(&headers)?;
|
check_auth(&headers)?;
|
||||||
let current = site_settings::load_current(&ctx).await?;
|
let current = site_settings::load_current(&ctx).await?;
|
||||||
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
|
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
|
||||||
@@ -1061,7 +1091,10 @@ pub async fn test_ai_image_provider(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn test_r2_storage(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
pub async fn test_r2_storage(
|
||||||
|
headers: HeaderMap,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
check_auth(&headers)?;
|
check_auth(&headers)?;
|
||||||
|
|
||||||
let settings = storage::require_r2_settings(&ctx).await?;
|
let settings = storage::require_r2_settings(&ctx).await?;
|
||||||
@@ -1278,7 +1311,10 @@ pub async fn replace_media_object(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn list_comment_blacklist(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
pub async fn list_comment_blacklist(
|
||||||
|
headers: HeaderMap,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
check_auth(&headers)?;
|
check_auth(&headers)?;
|
||||||
|
|
||||||
let items = comment_blacklist::Entity::find()
|
let items = comment_blacklist::Entity::find()
|
||||||
|
|||||||
@@ -143,6 +143,14 @@ pub struct SiteSettingsPayload {
|
|||||||
pub notification_comment_enabled: Option<bool>,
|
pub notification_comment_enabled: Option<bool>,
|
||||||
#[serde(default, alias = "notificationFriendLinkEnabled")]
|
#[serde(default, alias = "notificationFriendLinkEnabled")]
|
||||||
pub notification_friend_link_enabled: Option<bool>,
|
pub notification_friend_link_enabled: Option<bool>,
|
||||||
|
#[serde(default, alias = "subscriptionPopupEnabled")]
|
||||||
|
pub subscription_popup_enabled: Option<bool>,
|
||||||
|
#[serde(default, alias = "subscriptionPopupTitle")]
|
||||||
|
pub subscription_popup_title: Option<String>,
|
||||||
|
#[serde(default, alias = "subscriptionPopupDescription")]
|
||||||
|
pub subscription_popup_description: Option<String>,
|
||||||
|
#[serde(default, alias = "subscriptionPopupDelaySeconds")]
|
||||||
|
pub subscription_popup_delay_seconds: Option<i32>,
|
||||||
#[serde(default, alias = "searchSynonyms")]
|
#[serde(default, alias = "searchSynonyms")]
|
||||||
pub search_synonyms: Option<Vec<String>>,
|
pub search_synonyms: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
@@ -169,6 +177,10 @@ pub struct PublicSiteSettingsResponse {
|
|||||||
pub music_playlist: Option<serde_json::Value>,
|
pub music_playlist: Option<serde_json::Value>,
|
||||||
pub ai_enabled: bool,
|
pub ai_enabled: bool,
|
||||||
pub paragraph_comments_enabled: bool,
|
pub paragraph_comments_enabled: bool,
|
||||||
|
pub subscription_popup_enabled: bool,
|
||||||
|
pub subscription_popup_title: String,
|
||||||
|
pub subscription_popup_description: String,
|
||||||
|
pub subscription_popup_delay_seconds: i32,
|
||||||
pub seo_default_og_image: Option<String>,
|
pub seo_default_og_image: Option<String>,
|
||||||
pub seo_default_twitter_handle: Option<String>,
|
pub seo_default_twitter_handle: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -208,6 +220,22 @@ fn normalize_optional_int(value: Option<i32>, min: i32, max: i32) -> Option<i32>
|
|||||||
value.map(|item| item.clamp(min, max))
|
value.map(|item| item.clamp(min, max))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn default_subscription_popup_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn default_subscription_popup_title() -> String {
|
||||||
|
"订阅更新".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn default_subscription_popup_description() -> String {
|
||||||
|
"有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订。".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn default_subscription_popup_delay_seconds() -> i32 {
|
||||||
|
18
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_string_list(values: Vec<String>) -> Vec<String> {
|
fn normalize_string_list(values: Vec<String>) -> Vec<String> {
|
||||||
values
|
values
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -569,6 +597,21 @@ impl SiteSettingsPayload {
|
|||||||
if let Some(notification_friend_link_enabled) = self.notification_friend_link_enabled {
|
if let Some(notification_friend_link_enabled) = self.notification_friend_link_enabled {
|
||||||
item.notification_friend_link_enabled = Some(notification_friend_link_enabled);
|
item.notification_friend_link_enabled = Some(notification_friend_link_enabled);
|
||||||
}
|
}
|
||||||
|
if let Some(subscription_popup_enabled) = self.subscription_popup_enabled {
|
||||||
|
item.subscription_popup_enabled = Some(subscription_popup_enabled);
|
||||||
|
}
|
||||||
|
if let Some(subscription_popup_title) = self.subscription_popup_title {
|
||||||
|
item.subscription_popup_title =
|
||||||
|
normalize_optional_string(Some(subscription_popup_title));
|
||||||
|
}
|
||||||
|
if let Some(subscription_popup_description) = self.subscription_popup_description {
|
||||||
|
item.subscription_popup_description =
|
||||||
|
normalize_optional_string(Some(subscription_popup_description));
|
||||||
|
}
|
||||||
|
if self.subscription_popup_delay_seconds.is_some() {
|
||||||
|
item.subscription_popup_delay_seconds =
|
||||||
|
normalize_optional_int(self.subscription_popup_delay_seconds, 3, 120);
|
||||||
|
}
|
||||||
if let Some(search_synonyms) = self.search_synonyms {
|
if let Some(search_synonyms) = self.search_synonyms {
|
||||||
let normalized = normalize_string_list(search_synonyms);
|
let normalized = normalize_string_list(search_synonyms);
|
||||||
item.search_synonyms = (!normalized.is_empty()).then(|| serde_json::json!(normalized));
|
item.search_synonyms = (!normalized.is_empty()).then(|| serde_json::json!(normalized));
|
||||||
@@ -684,6 +727,10 @@ fn default_payload() -> SiteSettingsPayload {
|
|||||||
notification_webhook_url: None,
|
notification_webhook_url: None,
|
||||||
notification_comment_enabled: Some(false),
|
notification_comment_enabled: Some(false),
|
||||||
notification_friend_link_enabled: Some(false),
|
notification_friend_link_enabled: Some(false),
|
||||||
|
subscription_popup_enabled: Some(default_subscription_popup_enabled()),
|
||||||
|
subscription_popup_title: Some(default_subscription_popup_title()),
|
||||||
|
subscription_popup_description: Some(default_subscription_popup_description()),
|
||||||
|
subscription_popup_delay_seconds: Some(default_subscription_popup_delay_seconds()),
|
||||||
search_synonyms: Some(Vec::new()),
|
search_synonyms: Some(Vec::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -734,6 +781,18 @@ fn public_response(model: Model) -> PublicSiteSettingsResponse {
|
|||||||
music_playlist: model.music_playlist,
|
music_playlist: model.music_playlist,
|
||||||
ai_enabled: model.ai_enabled.unwrap_or(false),
|
ai_enabled: model.ai_enabled.unwrap_or(false),
|
||||||
paragraph_comments_enabled: model.paragraph_comments_enabled.unwrap_or(true),
|
paragraph_comments_enabled: model.paragraph_comments_enabled.unwrap_or(true),
|
||||||
|
subscription_popup_enabled: model
|
||||||
|
.subscription_popup_enabled
|
||||||
|
.unwrap_or_else(default_subscription_popup_enabled),
|
||||||
|
subscription_popup_title: model
|
||||||
|
.subscription_popup_title
|
||||||
|
.unwrap_or_else(default_subscription_popup_title),
|
||||||
|
subscription_popup_description: model
|
||||||
|
.subscription_popup_description
|
||||||
|
.unwrap_or_else(default_subscription_popup_description),
|
||||||
|
subscription_popup_delay_seconds: model
|
||||||
|
.subscription_popup_delay_seconds
|
||||||
|
.unwrap_or_else(default_subscription_popup_delay_seconds),
|
||||||
seo_default_og_image: model.seo_default_og_image,
|
seo_default_og_image: model.seo_default_og_image,
|
||||||
seo_default_twitter_handle: model.seo_default_twitter_handle,
|
seo_default_twitter_handle: model.seo_default_twitter_handle,
|
||||||
}
|
}
|
||||||
@@ -784,7 +843,8 @@ pub async fn home(State(ctx): State<AppContext>) -> Result<Response> {
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let content_highlights =
|
let content_highlights =
|
||||||
crate::services::analytics::build_public_content_highlights(&ctx, &posts).await?;
|
crate::services::analytics::build_public_content_highlights(&ctx, &posts).await?;
|
||||||
let content_ranges = crate::services::analytics::build_public_content_windows(&ctx, &posts).await?;
|
let content_ranges =
|
||||||
|
crate::services::analytics::build_public_content_windows(&ctx, &posts).await?;
|
||||||
|
|
||||||
format::json(HomePageResponse {
|
format::json(HomePageResponse {
|
||||||
site_settings,
|
site_settings,
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ pub struct Model {
|
|||||||
pub notification_webhook_url: Option<String>,
|
pub notification_webhook_url: Option<String>,
|
||||||
pub notification_comment_enabled: Option<bool>,
|
pub notification_comment_enabled: Option<bool>,
|
||||||
pub notification_friend_link_enabled: Option<bool>,
|
pub notification_friend_link_enabled: Option<bool>,
|
||||||
|
pub subscription_popup_enabled: Option<bool>,
|
||||||
|
pub subscription_popup_title: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub subscription_popup_description: Option<String>,
|
||||||
|
pub subscription_popup_delay_seconds: Option<i32>,
|
||||||
#[sea_orm(column_type = "JsonBinary", nullable)]
|
#[sea_orm(column_type = "JsonBinary", nullable)]
|
||||||
pub search_synonyms: Option<Json>,
|
pub search_synonyms: Option<Json>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@astrojs/markdown-remark": "^7.0.1",
|
||||||
"@astrojs/node": "^10.0.4",
|
"@astrojs/node": "^10.0.4",
|
||||||
"@astrojs/svelte": "^8.0.3",
|
"@astrojs/svelte": "^8.0.3",
|
||||||
"@astrojs/tailwind": "^6.0.2",
|
"@astrojs/tailwind": "^6.0.2",
|
||||||
|
|||||||
3
frontend/pnpm-lock.yaml
generated
3
frontend/pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@astrojs/markdown-remark':
|
||||||
|
specifier: ^7.0.1
|
||||||
|
version: 7.0.1
|
||||||
'@astrojs/node':
|
'@astrojs/node':
|
||||||
specifier: ^10.0.4
|
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(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))
|
||||||
|
|||||||
678
frontend/src/components/SubscriptionPopup.astro
Normal file
678
frontend/src/components/SubscriptionPopup.astro
Normal 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>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import '../styles/global.css';
|
import '../styles/global.css';
|
||||||
import Header from '../components/Header.astro';
|
import Header from '../components/Header.astro';
|
||||||
import Footer from '../components/Footer.astro';
|
import Footer from '../components/Footer.astro';
|
||||||
|
import SubscriptionPopup from '../components/SubscriptionPopup.astro';
|
||||||
import BackToTop from '../components/interactive/BackToTop.svelte';
|
import BackToTop from '../components/interactive/BackToTop.svelte';
|
||||||
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
|
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
|
||||||
import { getI18n, LOCALE_COOKIE_NAME, SUPPORTED_LOCALES } from '../lib/i18n';
|
import { getI18n, LOCALE_COOKIE_NAME, SUPPORTED_LOCALES } from '../lib/i18n';
|
||||||
@@ -475,6 +476,7 @@ const i18nPayload = JSON.stringify({ locale, messages });
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer siteSettings={siteSettings} />
|
<Footer siteSettings={siteSettings} />
|
||||||
|
<SubscriptionPopup siteSettings={siteSettings} requestUrl={Astro.url} />
|
||||||
<BackToTop client:load />
|
<BackToTop client:load />
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -230,6 +230,10 @@ export interface ApiSiteSettings {
|
|||||||
}> | null;
|
}> | null;
|
||||||
ai_enabled: boolean;
|
ai_enabled: boolean;
|
||||||
paragraph_comments_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_og_image: string | null;
|
||||||
seo_default_twitter_handle: string | null;
|
seo_default_twitter_handle: string | null;
|
||||||
}
|
}
|
||||||
@@ -398,6 +402,12 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
|
|||||||
comments: {
|
comments: {
|
||||||
paragraphsEnabled: true,
|
paragraphsEnabled: true,
|
||||||
},
|
},
|
||||||
|
subscriptions: {
|
||||||
|
popupEnabled: true,
|
||||||
|
popupTitle: '订阅更新',
|
||||||
|
popupDescription: '有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订。',
|
||||||
|
popupDelaySeconds: 18,
|
||||||
|
},
|
||||||
seo: {
|
seo: {
|
||||||
defaultOgImage: undefined,
|
defaultOgImage: undefined,
|
||||||
defaultTwitterHandle: undefined,
|
defaultTwitterHandle: undefined,
|
||||||
@@ -523,6 +533,18 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
|
|||||||
comments: {
|
comments: {
|
||||||
paragraphsEnabled: settings.paragraph_comments_enabled ?? true,
|
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: {
|
seo: {
|
||||||
defaultOgImage: settings.seo_default_og_image ?? undefined,
|
defaultOgImage: settings.seo_default_og_image ?? undefined,
|
||||||
defaultTwitterHandle: settings.seo_default_twitter_handle ?? undefined,
|
defaultTwitterHandle: settings.seo_default_twitter_handle ?? undefined,
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ export interface SiteSettings {
|
|||||||
comments: {
|
comments: {
|
||||||
paragraphsEnabled: boolean;
|
paragraphsEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
subscriptions: {
|
||||||
|
popupEnabled: boolean;
|
||||||
|
popupTitle: string;
|
||||||
|
popupDescription: string;
|
||||||
|
popupDelaySeconds: number;
|
||||||
|
};
|
||||||
seo: {
|
seo: {
|
||||||
defaultOgImage?: string;
|
defaultOgImage?: string;
|
||||||
defaultTwitterHandle?: string;
|
defaultTwitterHandle?: string;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import FriendLinkCard from '../components/FriendLinkCard.astro';
|
|||||||
import ViewMoreLink from '../components/ui/ViewMoreLink.astro';
|
import ViewMoreLink from '../components/ui/ViewMoreLink.astro';
|
||||||
import StatsList from '../components/StatsList.astro';
|
import StatsList from '../components/StatsList.astro';
|
||||||
import TechStackList from '../components/TechStackList.astro';
|
import TechStackList from '../components/TechStackList.astro';
|
||||||
import SubscriptionSignup from '../components/SubscriptionSignup.astro';
|
|
||||||
import { terminalConfig } from '../lib/config/terminal';
|
import { terminalConfig } from '../lib/config/terminal';
|
||||||
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
|
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
|
||||||
import { formatReadTime, getI18n } from '../lib/i18n';
|
import { formatReadTime, getI18n } from '../lib/i18n';
|
||||||
@@ -243,6 +242,12 @@ const navLinks = [
|
|||||||
<i class="fas fa-file-alt text-[10px]"></i>
|
<i class="fas fa-file-alt text-[10px]"></i>
|
||||||
<span id="home-results-count">{t('common.resultsCount', { count: filteredPostsCount })}</span>
|
<span id="home-results-count">{t('common.resultsCount', { count: filteredPostsCount })}</span>
|
||||||
</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 && (
|
{siteSettings.ai.enabled && (
|
||||||
<a href="/ask" class="terminal-subtle-link">
|
<a href="/ask" class="terminal-subtle-link">
|
||||||
<i class="fas fa-robot text-[11px]"></i>
|
<i class="fas fa-robot text-[11px]"></i>
|
||||||
@@ -260,10 +265,6 @@ const navLinks = [
|
|||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ml-4 mt-5">
|
|
||||||
<SubscriptionSignup requestUrl={Astro.request.url} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{apiError && (
|
{apiError && (
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ const api = createApiClient({ requestUrl: Astro.url });
|
|||||||
const apiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
const apiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||||
const EVENT_OPTIONS = [
|
const EVENT_OPTIONS = [
|
||||||
{ value: 'post.published', label: '新文章通知' },
|
{ value: 'post.published', label: '新文章通知' },
|
||||||
{ value: 'digest.weekly', label: '周报 Digest' },
|
{ value: 'digest.weekly', label: '每周简报' },
|
||||||
{ value: 'digest.monthly', label: '月报 Digest' },
|
{ value: 'digest.monthly', label: '每月简报' },
|
||||||
{ value: 'comment.created', label: '评论通知' },
|
{ value: 'comment.created', label: '评论通知' },
|
||||||
{ value: 'friend_link.created', label: '友链申请通知' },
|
{ value: 'friend_link.created', label: '友链申请通知' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user