feat: add SharePanel component for social sharing with QR code support
- Implemented SharePanel component in `SharePanel.astro` for sharing content on social media platforms. - Integrated QR code generation for WeChat sharing using the `qrcode` library. - Added localization support for English and Chinese languages. - Created utility functions in `seo.ts` for building article summaries and FAQs. - Introduced API routes for serving IndexNow key and generating full LLM catalog and summaries. - Enhanced SEO capabilities with structured data for articles and pages.
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
FROM rust:1.94-trixie AS chef
|
||||
RUN cargo install cargo-chef --locked
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM rust:1.94.1-trixie AS chef
|
||||
RUN rustup component add rustfmt clippy \
|
||||
&& cargo install cargo-chef --locked
|
||||
WORKDIR /app
|
||||
|
||||
FROM chef AS planner
|
||||
@@ -7,11 +10,20 @@ COPY . .
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
FROM chef AS builder
|
||||
ENV CARGO_HOME=/usr/local/cargo \
|
||||
CARGO_TARGET_DIR=/app/.cargo-target
|
||||
COPY --from=planner /app/recipe.json recipe.json
|
||||
RUN cargo chef cook --release --locked --recipe-path recipe.json
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,target=/usr/local/cargo/git/db,sharing=locked \
|
||||
--mount=type=cache,target=/app/.cargo-target,sharing=locked \
|
||||
cargo chef cook --release --locked --recipe-path recipe.json
|
||||
|
||||
COPY . .
|
||||
RUN cargo build --release --locked --bin termi_api-cli
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,target=/usr/local/cargo/git/db,sharing=locked \
|
||||
--mount=type=cache,target=/app/.cargo-target,sharing=locked \
|
||||
cargo build --release --locked --bin termi_api-cli \
|
||||
&& install -Dm755 /app/.cargo-target/release/termi_api-cli /tmp/termi_api-cli
|
||||
|
||||
FROM debian:trixie-slim AS runtime
|
||||
RUN apt-get update \
|
||||
@@ -19,7 +31,7 @@ RUN apt-get update \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/target/release/termi_api-cli /usr/local/bin/termi_api-cli
|
||||
COPY --from=builder /tmp/termi_api-cli /usr/local/bin/termi_api-cli
|
||||
COPY --from=builder /app/config ./config
|
||||
COPY --from=builder /app/assets ./assets
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
@@ -44,6 +44,7 @@ mod m20260401_000033_add_taxonomy_metadata_and_media_assets;
|
||||
mod m20260401_000034_add_source_markdown_to_posts;
|
||||
mod m20260401_000035_add_human_verification_modes_to_site_settings;
|
||||
mod m20260402_000036_create_worker_jobs;
|
||||
mod m20260402_000037_add_wechat_share_qr_setting_to_site_settings;
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -92,6 +93,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260401_000034_add_source_markdown_to_posts::Migration),
|
||||
Box::new(m20260401_000035_add_human_verification_modes_to_site_settings::Migration),
|
||||
Box::new(m20260402_000036_create_worker_jobs::Migration),
|
||||
Box::new(m20260402_000037_add_wechat_share_qr_setting_to_site_settings::Migration),
|
||||
// inject-above (do not remove this comment)
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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", "seo_wechat_share_qr_enabled")
|
||||
.await?
|
||||
{
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table.clone())
|
||||
.add_column(
|
||||
ColumnDef::new(Alias::new("seo_wechat_share_qr_enabled"))
|
||||
.boolean()
|
||||
.null()
|
||||
.default(false),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let table = Alias::new("site_settings");
|
||||
|
||||
if manager
|
||||
.has_column("site_settings", "seo_wechat_share_qr_enabled")
|
||||
.await?
|
||||
{
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(table)
|
||||
.drop_column(Alias::new("seo_wechat_share_qr_enabled"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,7 @@ pub struct AdminSiteSettingsResponse {
|
||||
pub media_r2_secret_access_key: Option<String>,
|
||||
pub seo_default_og_image: Option<String>,
|
||||
pub seo_default_twitter_handle: Option<String>,
|
||||
pub seo_wechat_share_qr_enabled: bool,
|
||||
pub notification_webhook_url: Option<String>,
|
||||
pub notification_channel_type: String,
|
||||
pub notification_comment_enabled: bool,
|
||||
@@ -827,6 +828,7 @@ fn build_settings_response(
|
||||
media_r2_secret_access_key: item.media_r2_secret_access_key,
|
||||
seo_default_og_image: item.seo_default_og_image,
|
||||
seo_default_twitter_handle: item.seo_default_twitter_handle,
|
||||
seo_wechat_share_qr_enabled: item.seo_wechat_share_qr_enabled.unwrap_or(false),
|
||||
notification_webhook_url: item.notification_webhook_url,
|
||||
notification_channel_type: item
|
||||
.notification_channel_type
|
||||
|
||||
@@ -157,6 +157,8 @@ pub struct SiteSettingsPayload {
|
||||
pub seo_default_og_image: Option<String>,
|
||||
#[serde(default, alias = "seoDefaultTwitterHandle")]
|
||||
pub seo_default_twitter_handle: Option<String>,
|
||||
#[serde(default, alias = "seoWechatShareQrEnabled")]
|
||||
pub seo_wechat_share_qr_enabled: Option<bool>,
|
||||
#[serde(default, alias = "notificationWebhookUrl")]
|
||||
pub notification_webhook_url: Option<String>,
|
||||
#[serde(default, alias = "notificationChannelType")]
|
||||
@@ -212,6 +214,7 @@ pub struct PublicSiteSettingsResponse {
|
||||
pub subscription_popup_delay_seconds: i32,
|
||||
pub seo_default_og_image: Option<String>,
|
||||
pub seo_default_twitter_handle: Option<String>,
|
||||
pub seo_wechat_share_qr_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -693,6 +696,9 @@ impl SiteSettingsPayload {
|
||||
item.seo_default_twitter_handle =
|
||||
normalize_optional_string(Some(seo_default_twitter_handle));
|
||||
}
|
||||
if let Some(seo_wechat_share_qr_enabled) = self.seo_wechat_share_qr_enabled {
|
||||
item.seo_wechat_share_qr_enabled = Some(seo_wechat_share_qr_enabled);
|
||||
}
|
||||
if let Some(notification_webhook_url) = self.notification_webhook_url {
|
||||
item.notification_webhook_url =
|
||||
normalize_optional_string(Some(notification_webhook_url));
|
||||
@@ -848,6 +854,7 @@ fn default_payload() -> SiteSettingsPayload {
|
||||
media_r2_secret_access_key: None,
|
||||
seo_default_og_image: None,
|
||||
seo_default_twitter_handle: None,
|
||||
seo_wechat_share_qr_enabled: Some(false),
|
||||
notification_webhook_url: None,
|
||||
notification_channel_type: Some("webhook".to_string()),
|
||||
notification_comment_enabled: Some(false),
|
||||
@@ -945,6 +952,7 @@ fn public_response(model: Model) -> PublicSiteSettingsResponse {
|
||||
.unwrap_or_else(default_subscription_popup_delay_seconds),
|
||||
seo_default_og_image: model.seo_default_og_image,
|
||||
seo_default_twitter_handle: model.seo_default_twitter_handle,
|
||||
seo_wechat_share_qr_enabled: model.seo_wechat_share_qr_enabled.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ pub struct Model {
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub seo_default_og_image: Option<String>,
|
||||
pub seo_default_twitter_handle: Option<String>,
|
||||
pub seo_wechat_share_qr_enabled: Option<bool>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub notification_webhook_url: Option<String>,
|
||||
pub notification_channel_type: Option<String>,
|
||||
|
||||
@@ -140,6 +140,8 @@ pub struct AdminAnalyticsResponse {
|
||||
pub recent_events: Vec<AnalyticsRecentEvent>,
|
||||
pub providers_last_7d: Vec<AnalyticsProviderBucket>,
|
||||
pub top_referrers: Vec<AnalyticsReferrerBucket>,
|
||||
pub ai_referrers_last_7d: Vec<AnalyticsReferrerBucket>,
|
||||
pub ai_discovery_page_views_last_7d: u64,
|
||||
pub popular_posts: Vec<AnalyticsPopularPost>,
|
||||
pub daily_activity: Vec<AnalyticsDailyBucket>,
|
||||
}
|
||||
@@ -197,16 +199,112 @@ fn format_timestamp(value: DateTime<Utc>) -> String {
|
||||
value.format("%Y-%m-%d %H:%M").to_string()
|
||||
}
|
||||
|
||||
fn normalize_referrer_source(value: Option<String>) -> String {
|
||||
fn metadata_string(metadata: Option<&serde_json::Value>, key: &str) -> Option<String> {
|
||||
metadata
|
||||
.and_then(|value| value.get(key))
|
||||
.and_then(|value| value.as_str())
|
||||
.map(ToString::to_string)
|
||||
.and_then(|value| trim_to_option(Some(value)))
|
||||
}
|
||||
|
||||
fn parse_path_query_value(path: Option<&str>, key: &str) -> Option<String> {
|
||||
let path = path.and_then(|value| trim_to_option(Some(value.to_string())))?;
|
||||
let synthetic_url = if path.starts_with("http://") || path.starts_with("https://") {
|
||||
path
|
||||
} else if path.starts_with('/') {
|
||||
format!("https://local.test{path}")
|
||||
} else {
|
||||
format!("https://local.test/{path}")
|
||||
};
|
||||
|
||||
reqwest::Url::parse(&synthetic_url)
|
||||
.ok()
|
||||
.and_then(|url| {
|
||||
url.query_pairs()
|
||||
.find(|(item_key, _)| item_key == key)
|
||||
.map(|(_, value)| value.to_string())
|
||||
})
|
||||
.and_then(|value| trim_to_option(Some(value)))
|
||||
}
|
||||
|
||||
fn normalize_tracking_source_token(value: Option<String>) -> String {
|
||||
let Some(value) = trim_to_option(value) else {
|
||||
return "direct".to_string();
|
||||
};
|
||||
|
||||
reqwest::Url::parse(&value)
|
||||
let normalized = reqwest::Url::parse(&value)
|
||||
.ok()
|
||||
.and_then(|url| url.host_str().map(ToString::to_string))
|
||||
.filter(|item| !item.trim().is_empty())
|
||||
.unwrap_or(value)
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
match normalized.as_str() {
|
||||
"direct" => "direct".to_string(),
|
||||
value if value.contains("chatgpt") || value.contains("openai") => {
|
||||
"chatgpt-search".to_string()
|
||||
}
|
||||
value if value.contains("perplexity") => "perplexity".to_string(),
|
||||
value if value.contains("copilot") || value.contains("bing") => {
|
||||
"copilot-bing".to_string()
|
||||
}
|
||||
value if value.contains("gemini") => "gemini".to_string(),
|
||||
value if value.contains("google") => "google".to_string(),
|
||||
value if value.contains("claude") => "claude".to_string(),
|
||||
value if value.contains("duckduckgo") => "duckduckgo".to_string(),
|
||||
value if value.contains("kagi") => "kagi".to_string(),
|
||||
_ => normalized,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_tracking_source(
|
||||
path: Option<&str>,
|
||||
referrer: Option<String>,
|
||||
metadata: Option<&serde_json::Value>,
|
||||
) -> String {
|
||||
let preferred = metadata_string(metadata, "landingSource")
|
||||
.or_else(|| metadata_string(metadata, "landing_source"))
|
||||
.or_else(|| metadata_string(metadata, "utmSource"))
|
||||
.or_else(|| metadata_string(metadata, "utm_source"))
|
||||
.or_else(|| parse_path_query_value(path, "utm_source"))
|
||||
.or_else(|| metadata_string(metadata, "referrerHost"))
|
||||
.or_else(|| referrer);
|
||||
|
||||
normalize_tracking_source_token(preferred)
|
||||
}
|
||||
|
||||
fn is_ai_discovery_source(value: &str) -> bool {
|
||||
matches!(
|
||||
value,
|
||||
"chatgpt-search" | "perplexity" | "copilot-bing" | "gemini" | "claude"
|
||||
)
|
||||
}
|
||||
|
||||
fn sorted_referrer_buckets(
|
||||
breakdown: &HashMap<String, u64>,
|
||||
predicate: impl Fn(&str) -> bool,
|
||||
limit: usize,
|
||||
) -> Vec<AnalyticsReferrerBucket> {
|
||||
let mut items = breakdown
|
||||
.iter()
|
||||
.filter_map(|(referrer, count)| {
|
||||
predicate(referrer)
|
||||
.then(|| AnalyticsReferrerBucket {
|
||||
referrer: referrer.clone(),
|
||||
count: *count,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
items.sort_by(|left, right| {
|
||||
right
|
||||
.count
|
||||
.cmp(&left.count)
|
||||
.then_with(|| left.referrer.cmp(&right.referrer))
|
||||
});
|
||||
items.truncate(limit);
|
||||
items
|
||||
}
|
||||
|
||||
fn header_value(headers: &HeaderMap, key: &str) -> Option<String> {
|
||||
@@ -550,7 +648,8 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
|
||||
page_views_last_24h += 1;
|
||||
}
|
||||
|
||||
let referrer = normalize_referrer_source(event.referrer.clone());
|
||||
let referrer =
|
||||
normalize_tracking_source(Some(&event.path), event.referrer.clone(), event.metadata.as_ref());
|
||||
*referrer_breakdown.entry(referrer).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
@@ -637,17 +736,13 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
|
||||
});
|
||||
providers_last_7d.truncate(6);
|
||||
|
||||
let mut top_referrers = referrer_breakdown
|
||||
.into_iter()
|
||||
.map(|(referrer, count)| AnalyticsReferrerBucket { referrer, count })
|
||||
.collect::<Vec<_>>();
|
||||
top_referrers.sort_by(|left, right| {
|
||||
right
|
||||
.count
|
||||
.cmp(&left.count)
|
||||
.then_with(|| left.referrer.cmp(&right.referrer))
|
||||
});
|
||||
top_referrers.truncate(8);
|
||||
let top_referrers = sorted_referrer_buckets(&referrer_breakdown, |_| true, 8);
|
||||
let ai_referrers_last_7d = sorted_referrer_buckets(&referrer_breakdown, is_ai_discovery_source, 6);
|
||||
let ai_discovery_page_views_last_7d = referrer_breakdown
|
||||
.iter()
|
||||
.filter(|(referrer, _)| is_ai_discovery_source(referrer))
|
||||
.map(|(_, count)| *count)
|
||||
.sum::<u64>();
|
||||
|
||||
let mut popular_posts = post_breakdown
|
||||
.into_iter()
|
||||
@@ -748,6 +843,8 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
|
||||
recent_events,
|
||||
providers_last_7d,
|
||||
top_referrers,
|
||||
ai_referrers_last_7d,
|
||||
ai_discovery_page_views_last_7d,
|
||||
popular_posts,
|
||||
daily_activity,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user