feat: ship blog platform admin and deploy stack
This commit is contained in:
@@ -1,19 +1,69 @@
|
||||
import type {
|
||||
Category as UiCategory,
|
||||
ContentOverview,
|
||||
ContentWindowHighlight,
|
||||
FriendLink as UiFriendLink,
|
||||
Post as UiPost,
|
||||
PopularPostHighlight,
|
||||
SiteSettings,
|
||||
Tag as UiTag,
|
||||
} from '../types';
|
||||
|
||||
const envApiBaseUrl = import.meta.env.PUBLIC_API_BASE_URL?.trim();
|
||||
const DEV_API_BASE_URL = 'http://127.0.0.1:5150/api';
|
||||
const PROD_DEFAULT_API_PORT = '5150';
|
||||
|
||||
export const API_BASE_URL =
|
||||
envApiBaseUrl && envApiBaseUrl.length > 0
|
||||
? envApiBaseUrl.replace(/\/$/, '')
|
||||
: import.meta.env.DEV
|
||||
? 'http://127.0.0.1:5150/api'
|
||||
: 'https://init.cool/api';
|
||||
function normalizeApiBaseUrl(value?: string | null) {
|
||||
return value?.trim().replace(/\/$/, '') ?? '';
|
||||
}
|
||||
|
||||
function getRuntimeEnv(name: 'PUBLIC_API_BASE_URL' | 'INTERNAL_API_BASE_URL') {
|
||||
const runtimeProcess = (globalThis as typeof globalThis & {
|
||||
process?: {
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
}).process;
|
||||
|
||||
return normalizeApiBaseUrl(runtimeProcess?.env?.[name]);
|
||||
}
|
||||
|
||||
function toUrlLike(value: string | URL) {
|
||||
return value instanceof URL ? value : new URL(value);
|
||||
}
|
||||
|
||||
const buildTimePublicApiBaseUrl = normalizeApiBaseUrl(import.meta.env.PUBLIC_API_BASE_URL);
|
||||
|
||||
export function resolvePublicApiBaseUrl(requestUrl?: string | URL) {
|
||||
const runtimePublicApiBaseUrl = getRuntimeEnv('PUBLIC_API_BASE_URL');
|
||||
if (runtimePublicApiBaseUrl) {
|
||||
return runtimePublicApiBaseUrl;
|
||||
}
|
||||
|
||||
if (buildTimePublicApiBaseUrl) {
|
||||
return buildTimePublicApiBaseUrl;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
return DEV_API_BASE_URL;
|
||||
}
|
||||
|
||||
if (requestUrl) {
|
||||
const { protocol, hostname } = toUrlLike(requestUrl);
|
||||
return `${protocol}//${hostname}:${PROD_DEFAULT_API_PORT}/api`;
|
||||
}
|
||||
|
||||
return DEV_API_BASE_URL;
|
||||
}
|
||||
|
||||
export function resolveInternalApiBaseUrl(requestUrl?: string | URL) {
|
||||
const runtimeInternalApiBaseUrl = getRuntimeEnv('INTERNAL_API_BASE_URL');
|
||||
if (runtimeInternalApiBaseUrl) {
|
||||
return runtimeInternalApiBaseUrl;
|
||||
}
|
||||
|
||||
return resolvePublicApiBaseUrl(requestUrl);
|
||||
}
|
||||
|
||||
export const API_BASE_URL = resolvePublicApiBaseUrl();
|
||||
|
||||
export interface ApiPost {
|
||||
id: number;
|
||||
@@ -27,6 +77,15 @@ export interface ApiPost {
|
||||
image: string | null;
|
||||
images: string[] | null;
|
||||
pinned: boolean;
|
||||
status: string | null;
|
||||
visibility: 'public' | 'unlisted' | 'private' | null;
|
||||
publish_at: string | null;
|
||||
unpublish_at: string | null;
|
||||
canonical_url: string | null;
|
||||
noindex: boolean | null;
|
||||
og_image: string | null;
|
||||
redirect_from: string[] | null;
|
||||
redirect_to: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -38,6 +97,9 @@ export interface Comment {
|
||||
author: string | null;
|
||||
email: string | null;
|
||||
avatar: string | null;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
referer: string | null;
|
||||
content: string | null;
|
||||
reply_to: string | null;
|
||||
reply_to_comment_id: number | null;
|
||||
@@ -59,6 +121,15 @@ export interface CreateCommentInput {
|
||||
paragraphExcerpt?: string;
|
||||
replyTo?: string | null;
|
||||
replyToCommentId?: number | null;
|
||||
captchaToken?: string;
|
||||
captchaAnswer?: string;
|
||||
website?: string;
|
||||
}
|
||||
|
||||
export interface CommentCaptchaChallenge {
|
||||
token: string;
|
||||
question: string;
|
||||
expires_in_seconds: number;
|
||||
}
|
||||
|
||||
export interface ParagraphCommentSummary {
|
||||
@@ -101,6 +172,35 @@ export interface CreateFriendLinkInput {
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface PublicSubscriptionResponse {
|
||||
ok: boolean;
|
||||
subscription_id: number;
|
||||
status: string;
|
||||
requires_confirmation: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PublicManagedSubscription {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
id: number;
|
||||
channel_type: string;
|
||||
target: string;
|
||||
display_name: string | null;
|
||||
status: string;
|
||||
filters: Record<string, unknown> | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
verified_at: string | null;
|
||||
last_notified_at: string | null;
|
||||
last_delivery_status: string | null;
|
||||
manage_token: string | null;
|
||||
}
|
||||
|
||||
export interface PublicSubscriptionManageResponse {
|
||||
ok: boolean;
|
||||
subscription: PublicManagedSubscription;
|
||||
}
|
||||
|
||||
export interface ApiSiteSettings {
|
||||
id: number;
|
||||
site_name: string | null;
|
||||
@@ -130,6 +230,19 @@ export interface ApiSiteSettings {
|
||||
}> | null;
|
||||
ai_enabled: boolean;
|
||||
paragraph_comments_enabled: boolean;
|
||||
seo_default_og_image: string | null;
|
||||
seo_default_twitter_handle: string | null;
|
||||
}
|
||||
|
||||
export interface ContentAnalyticsInput {
|
||||
eventType: 'page_view' | 'read_progress' | 'read_complete';
|
||||
path: string;
|
||||
postSlug?: string;
|
||||
sessionId?: string;
|
||||
durationMs?: number;
|
||||
progressPercent?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
referrer?: string;
|
||||
}
|
||||
|
||||
export interface ApiHomePagePayload {
|
||||
@@ -138,6 +251,42 @@ export interface ApiHomePagePayload {
|
||||
tags: ApiTag[];
|
||||
friend_links: ApiFriendLink[];
|
||||
categories: ApiCategory[];
|
||||
content_overview?: {
|
||||
total_page_views: number;
|
||||
page_views_last_24h: number;
|
||||
page_views_last_7d: number;
|
||||
total_read_completes: number;
|
||||
read_completes_last_7d: number;
|
||||
avg_read_progress_last_7d: number;
|
||||
avg_read_duration_ms_last_7d: number | null;
|
||||
};
|
||||
popular_posts?: Array<{
|
||||
slug: string;
|
||||
title: string;
|
||||
page_views: number;
|
||||
read_completes: number;
|
||||
avg_progress_percent: number;
|
||||
avg_duration_ms: number | null;
|
||||
}>;
|
||||
content_ranges?: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
days: number;
|
||||
overview: {
|
||||
page_views: number;
|
||||
read_completes: number;
|
||||
avg_read_progress: number;
|
||||
avg_read_duration_ms: number | null;
|
||||
};
|
||||
popular_posts: Array<{
|
||||
slug: string;
|
||||
title: string;
|
||||
page_views: number;
|
||||
read_completes: number;
|
||||
avg_progress_percent: number;
|
||||
avg_duration_ms: number | null;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface AiSource {
|
||||
@@ -249,6 +398,10 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
|
||||
comments: {
|
||||
paragraphsEnabled: true,
|
||||
},
|
||||
seo: {
|
||||
defaultOgImage: undefined,
|
||||
defaultTwitterHandle: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const formatPostDate = (dateString: string) => dateString.slice(0, 10);
|
||||
@@ -273,6 +426,15 @@ const normalizePost = (post: ApiPost): UiPost => ({
|
||||
image: post.image ?? undefined,
|
||||
images: post.images ?? undefined,
|
||||
pinned: post.pinned,
|
||||
status: post.status ?? undefined,
|
||||
visibility: post.visibility ?? undefined,
|
||||
publishAt: post.publish_at ?? undefined,
|
||||
unpublishAt: post.unpublish_at ?? undefined,
|
||||
canonicalUrl: post.canonical_url ?? undefined,
|
||||
noindex: post.noindex ?? undefined,
|
||||
ogImage: post.og_image ?? undefined,
|
||||
redirectFrom: post.redirect_from ?? undefined,
|
||||
redirectTo: post.redirect_to ?? undefined,
|
||||
});
|
||||
|
||||
const normalizeTag = (tag: ApiTag): UiTag => ({
|
||||
@@ -361,8 +523,127 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
|
||||
comments: {
|
||||
paragraphsEnabled: settings.paragraph_comments_enabled ?? true,
|
||||
},
|
||||
seo: {
|
||||
defaultOgImage: settings.seo_default_og_image ?? undefined,
|
||||
defaultTwitterHandle: settings.seo_default_twitter_handle ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const normalizeContentOverview = (
|
||||
overview: ApiHomePagePayload['content_overview'] | undefined,
|
||||
): ContentOverview => ({
|
||||
totalPageViews: overview?.total_page_views ?? 0,
|
||||
pageViewsLast24h: overview?.page_views_last_24h ?? 0,
|
||||
pageViewsLast7d: overview?.page_views_last_7d ?? 0,
|
||||
totalReadCompletes: overview?.total_read_completes ?? 0,
|
||||
readCompletesLast7d: overview?.read_completes_last_7d ?? 0,
|
||||
avgReadProgressLast7d: overview?.avg_read_progress_last_7d ?? 0,
|
||||
avgReadDurationMsLast7d: overview?.avg_read_duration_ms_last_7d ?? undefined,
|
||||
});
|
||||
|
||||
const CONTENT_WINDOW_META = [
|
||||
{ key: '24h', label: '24h', days: 1 },
|
||||
{ key: '7d', label: '7d', days: 7 },
|
||||
{ key: '30d', label: '30d', days: 30 },
|
||||
] as const;
|
||||
|
||||
const normalizePopularPost = (
|
||||
item: {
|
||||
slug: string;
|
||||
title: string;
|
||||
page_views: number;
|
||||
read_completes: number;
|
||||
avg_progress_percent: number;
|
||||
avg_duration_ms: number | null;
|
||||
},
|
||||
postsBySlug: Map<string, UiPost>,
|
||||
): PopularPostHighlight => ({
|
||||
slug: item.slug,
|
||||
title: item.title,
|
||||
pageViews: item.page_views,
|
||||
readCompletes: item.read_completes,
|
||||
avgProgressPercent: item.avg_progress_percent,
|
||||
avgDurationMs: item.avg_duration_ms ?? undefined,
|
||||
post: postsBySlug.get(item.slug),
|
||||
});
|
||||
|
||||
const normalizeContentRanges = (
|
||||
ranges: ApiHomePagePayload['content_ranges'] | undefined,
|
||||
overview: ApiHomePagePayload['content_overview'] | undefined,
|
||||
popularPosts: ApiHomePagePayload['popular_posts'] | undefined,
|
||||
postsBySlug: Map<string, UiPost>,
|
||||
): ContentWindowHighlight[] => {
|
||||
const normalizedRanges = new Map(
|
||||
(ranges ?? []).map((item) => [
|
||||
item.key,
|
||||
{
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
days: item.days,
|
||||
overview: {
|
||||
pageViews: item.overview?.page_views ?? 0,
|
||||
readCompletes: item.overview?.read_completes ?? 0,
|
||||
avgReadProgress: item.overview?.avg_read_progress ?? 0,
|
||||
avgReadDurationMs: item.overview?.avg_read_duration_ms ?? undefined,
|
||||
},
|
||||
popularPosts: (item.popular_posts ?? []).map((popularItem) =>
|
||||
normalizePopularPost(popularItem, postsBySlug),
|
||||
),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
return CONTENT_WINDOW_META.map((meta) => {
|
||||
const existing = normalizedRanges.get(meta.key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (meta.key === '7d') {
|
||||
return {
|
||||
key: meta.key,
|
||||
label: meta.label,
|
||||
days: meta.days,
|
||||
overview: {
|
||||
pageViews: overview?.page_views_last_7d ?? 0,
|
||||
readCompletes: overview?.read_completes_last_7d ?? 0,
|
||||
avgReadProgress: overview?.avg_read_progress_last_7d ?? 0,
|
||||
avgReadDurationMs: overview?.avg_read_duration_ms_last_7d ?? undefined,
|
||||
},
|
||||
popularPosts: (popularPosts ?? []).map((item) => normalizePopularPost(item, postsBySlug)),
|
||||
};
|
||||
}
|
||||
|
||||
if (meta.key === '24h') {
|
||||
return {
|
||||
key: meta.key,
|
||||
label: meta.label,
|
||||
days: meta.days,
|
||||
overview: {
|
||||
pageViews: overview?.page_views_last_24h ?? 0,
|
||||
readCompletes: 0,
|
||||
avgReadProgress: 0,
|
||||
avgReadDurationMs: undefined,
|
||||
},
|
||||
popularPosts: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: meta.key,
|
||||
label: meta.label,
|
||||
days: meta.days,
|
||||
overview: {
|
||||
pageViews: 0,
|
||||
readCompletes: 0,
|
||||
avgReadProgress: 0,
|
||||
avgReadDurationMs: undefined,
|
||||
},
|
||||
popularPosts: [],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
|
||||
@@ -424,6 +705,22 @@ class ApiClient {
|
||||
return normalizePost((await response.json()) as ApiPost);
|
||||
}
|
||||
|
||||
async recordContentEvent(input: ContentAnalyticsInput): Promise<void> {
|
||||
await this.fetch<{ recorded: boolean }>('/analytics/content', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
event_type: input.eventType,
|
||||
path: input.path,
|
||||
post_slug: input.postSlug,
|
||||
session_id: input.sessionId,
|
||||
duration_ms: input.durationMs,
|
||||
progress_percent: input.progressPercent,
|
||||
metadata: input.metadata,
|
||||
referrer: input.referrer,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async getComments(
|
||||
postSlug: string,
|
||||
options?: {
|
||||
@@ -463,10 +760,17 @@ class ApiClient {
|
||||
paragraphExcerpt: comment.paragraphExcerpt,
|
||||
replyTo: comment.replyTo,
|
||||
replyToCommentId: comment.replyToCommentId,
|
||||
captchaToken: comment.captchaToken,
|
||||
captchaAnswer: comment.captchaAnswer,
|
||||
website: comment.website,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async getCommentCaptcha(): Promise<CommentCaptchaChallenge> {
|
||||
return this.fetch<CommentCaptchaChallenge>('/comments/captcha');
|
||||
}
|
||||
|
||||
async getReviews(): Promise<Review[]> {
|
||||
return this.fetch<Review[]>('/reviews');
|
||||
}
|
||||
@@ -491,6 +795,54 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async subscribe(input: { email: string; displayName?: string; source?: string }): Promise<PublicSubscriptionResponse> {
|
||||
return this.fetch<PublicSubscriptionResponse>('/subscriptions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: input.email,
|
||||
displayName: input.displayName,
|
||||
source: input.source,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async confirmSubscription(token: string): Promise<PublicSubscriptionManageResponse> {
|
||||
return this.fetch<PublicSubscriptionManageResponse>('/subscriptions/confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
}
|
||||
|
||||
async getManagedSubscription(token: string): Promise<PublicSubscriptionManageResponse> {
|
||||
return this.fetch<PublicSubscriptionManageResponse>(
|
||||
`/subscriptions/manage?token=${encodeURIComponent(token)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async updateManagedSubscription(input: {
|
||||
token: string;
|
||||
displayName?: string | null;
|
||||
status?: string | null;
|
||||
filters?: Record<string, unknown> | null;
|
||||
}): Promise<PublicSubscriptionManageResponse> {
|
||||
return this.fetch<PublicSubscriptionManageResponse>('/subscriptions/manage', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
token: input.token,
|
||||
displayName: input.displayName,
|
||||
status: input.status,
|
||||
filters: input.filters,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async unsubscribeSubscription(token: string): Promise<PublicSubscriptionManageResponse> {
|
||||
return this.fetch<PublicSubscriptionManageResponse>('/subscriptions/unsubscribe', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
}
|
||||
|
||||
async getRawTags(): Promise<ApiTag[]> {
|
||||
return this.fetch<ApiTag[]>('/tags');
|
||||
}
|
||||
@@ -515,15 +867,31 @@ class ApiClient {
|
||||
tags: UiTag[];
|
||||
friendLinks: AppFriendLink[];
|
||||
categories: UiCategory[];
|
||||
contentOverview: ContentOverview;
|
||||
contentRanges: ContentWindowHighlight[];
|
||||
popularPosts: PopularPostHighlight[];
|
||||
}> {
|
||||
const payload = await this.fetch<ApiHomePagePayload>('/site_settings/home');
|
||||
const posts = (payload.posts ?? []).map(normalizePost);
|
||||
const postsBySlug = new Map(posts.map((post) => [post.slug, post]));
|
||||
const popularPosts = (payload.popular_posts ?? []).map((item) =>
|
||||
normalizePopularPost(item, postsBySlug),
|
||||
);
|
||||
|
||||
return {
|
||||
siteSettings: normalizeSiteSettings(payload.site_settings),
|
||||
posts: payload.posts.map(normalizePost),
|
||||
tags: payload.tags.map(normalizeTag),
|
||||
friendLinks: payload.friend_links.map(normalizeFriendLink),
|
||||
categories: payload.categories.map(normalizeCategory),
|
||||
posts,
|
||||
tags: (payload.tags ?? []).map(normalizeTag),
|
||||
friendLinks: (payload.friend_links ?? []).map(normalizeFriendLink),
|
||||
categories: (payload.categories ?? []).map(normalizeCategory),
|
||||
contentOverview: normalizeContentOverview(payload.content_overview),
|
||||
contentRanges: normalizeContentRanges(
|
||||
payload.content_ranges,
|
||||
payload.content_overview,
|
||||
payload.popular_posts,
|
||||
postsBySlug,
|
||||
),
|
||||
popularPosts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -579,5 +947,9 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient(API_BASE_URL);
|
||||
export function createApiClient(options?: { baseUrl?: string; requestUrl?: string | URL }) {
|
||||
return new ApiClient(options?.baseUrl ?? resolveInternalApiBaseUrl(options?.requestUrl));
|
||||
}
|
||||
|
||||
export const api = createApiClient();
|
||||
export const apiClient = api;
|
||||
|
||||
@@ -127,12 +127,30 @@ export const messages = {
|
||||
about: '关于我',
|
||||
techStack: '技术栈',
|
||||
systemStatus: '系统状态',
|
||||
hotNow: '最近热门内容',
|
||||
hotNowDescription: '基于 24h / 7d / 30d 的页面访问与阅读完成信号生成,可切换窗口查看最有反馈的内容。',
|
||||
hotNowEmpty: '当前筛选条件下还没有热门内容信号。',
|
||||
sortByViews: '最多浏览',
|
||||
sortByCompletes: '完读最多',
|
||||
sortByDepth: '阅读最深',
|
||||
readingSignals: '阅读信号',
|
||||
readingSignalsDescription: '以下统计面板会跟随所选窗口切换,展示全站内容消费表现。',
|
||||
views: '浏览',
|
||||
completes: '完读',
|
||||
avgProgress: '平均进度',
|
||||
avgDuration: '平均时长',
|
||||
totalViews: '累计浏览',
|
||||
totalCompletes: '累计完读',
|
||||
statsWindow: '统计窗口:最近 7 天',
|
||||
statsWindowLabel: '统计窗口:{label}',
|
||||
promptWelcome: 'pwd',
|
||||
promptDiscoverDefault: "find ./posts -type f | sort",
|
||||
promptDiscoverFiltered: 'grep -Ril "{filters}" ./posts',
|
||||
promptPinned: 'grep -Ril "^pinned: true$" ./posts',
|
||||
promptPostsDefault: "find ./posts -type f | head -n {count}",
|
||||
promptPostsFiltered: 'grep -Ril "{filters}" ./posts | head -n {count}',
|
||||
promptPopular: "awk 'NR<=6 {print}' ./analytics/popular-posts.log",
|
||||
promptPopularRange: "awk 'NR<=6 {print}' ./analytics/popular-posts.log --window={label}",
|
||||
promptFriends: "find ./links -maxdepth 1 -type f | sort",
|
||||
promptAbout: "sed -n '1,80p' ~/profile.md",
|
||||
},
|
||||
@@ -148,6 +166,25 @@ export const messages = {
|
||||
previous: '上一页',
|
||||
next: '下一页',
|
||||
},
|
||||
searchPage: {
|
||||
pageTitle: '站内搜索',
|
||||
title: '搜索结果',
|
||||
intro: '独立搜索页会保留搜索词,并支持按类型、分类、标签继续缩小范围。',
|
||||
promptIdle: "printf 'search query required\\n'",
|
||||
promptQuery: 'grep -Rin "{query}" ./posts',
|
||||
queryLabel: '当前查询',
|
||||
searchTips: '搜索会优先走站内索引,并自动复用同义词与轻量拼写纠错。',
|
||||
resultSummary: '找到 {count} 条结果',
|
||||
filteredSummary: '筛选后剩余 {count} 条结果',
|
||||
filtersTitle: '二次筛选',
|
||||
allCategories: '全部分类',
|
||||
allTags: '全部标签',
|
||||
emptyQueryTitle: '先输入关键词',
|
||||
emptyQueryDescription: '可以直接使用顶部搜索框,或在 URL 中传入 ?q= 进入搜索结果页。',
|
||||
emptyTitle: '没有匹配结果',
|
||||
emptyDescription: '可以切换分类 / 标签,或换一个关键词重新搜索。',
|
||||
askFallback: '改去 AI 问答',
|
||||
},
|
||||
article: {
|
||||
backToArticles: '返回文章索引',
|
||||
documentSession: '文档会话',
|
||||
@@ -158,6 +195,9 @@ export const messages = {
|
||||
title: '相关文章',
|
||||
description: '基于当前分类与标签关联出的相近内容,延续同一条阅读链路。',
|
||||
linked: '{count} 条关联',
|
||||
hotKicker: '热门延伸',
|
||||
hotTitle: '同类热门文章',
|
||||
hotDescription: '结合最近 7 天的访问与阅读数据,优先推荐同分类或共享标签的高反馈内容。',
|
||||
},
|
||||
comments: {
|
||||
title: '评论终端',
|
||||
@@ -528,12 +568,30 @@ export const messages = {
|
||||
about: 'About',
|
||||
techStack: 'Tech stack',
|
||||
systemStatus: 'System status',
|
||||
hotNow: 'Hot now',
|
||||
hotNowDescription: 'Generated from 24h / 7d / 30d page-view and completion signals so visitors can switch windows and spot the strongest feedback loops quickly.',
|
||||
hotNowEmpty: 'No hot content matched the current filters yet.',
|
||||
sortByViews: 'Most viewed',
|
||||
sortByCompletes: 'Most completed',
|
||||
sortByDepth: 'Deepest reads',
|
||||
readingSignals: 'Reading signals',
|
||||
readingSignalsDescription: 'These stats switch with the selected window to summarize site-wide content consumption.',
|
||||
views: 'Views',
|
||||
completes: 'Completes',
|
||||
avgProgress: 'Avg progress',
|
||||
avgDuration: 'Avg duration',
|
||||
totalViews: 'Total views',
|
||||
totalCompletes: 'Total completes',
|
||||
statsWindow: 'Window: last 7 days',
|
||||
statsWindowLabel: 'Window: {label}',
|
||||
promptWelcome: 'pwd',
|
||||
promptDiscoverDefault: "find ./posts -type f | sort",
|
||||
promptDiscoverFiltered: 'grep -Ril "{filters}" ./posts',
|
||||
promptPinned: 'grep -Ril "^pinned: true$" ./posts',
|
||||
promptPostsDefault: "find ./posts -type f | head -n {count}",
|
||||
promptPostsFiltered: 'grep -Ril "{filters}" ./posts | head -n {count}',
|
||||
promptPopular: "awk 'NR<=6 {print}' ./analytics/popular-posts.log",
|
||||
promptPopularRange: "awk 'NR<=6 {print}' ./analytics/popular-posts.log --window={label}",
|
||||
promptFriends: "find ./links -maxdepth 1 -type f | sort",
|
||||
promptAbout: "sed -n '1,80p' ~/profile.md",
|
||||
},
|
||||
@@ -549,6 +607,25 @@ export const messages = {
|
||||
previous: 'Prev',
|
||||
next: 'Next',
|
||||
},
|
||||
searchPage: {
|
||||
pageTitle: 'Site search',
|
||||
title: 'Search results',
|
||||
intro: 'The dedicated search page keeps the query visible and lets visitors narrow results by type, category, and tag.',
|
||||
promptIdle: "printf 'search query required\\n'",
|
||||
promptQuery: 'grep -Rin "{query}" ./posts',
|
||||
queryLabel: 'Current query',
|
||||
searchTips: 'Search uses the site index first and also applies synonyms plus lightweight typo correction automatically.',
|
||||
resultSummary: 'Found {count} results',
|
||||
filteredSummary: '{count} results after filters',
|
||||
filtersTitle: 'Refine results',
|
||||
allCategories: 'All categories',
|
||||
allTags: 'All tags',
|
||||
emptyQueryTitle: 'Enter a keyword first',
|
||||
emptyQueryDescription: 'Use the header search box or open this page with a ?q= query string.',
|
||||
emptyTitle: 'No matching results',
|
||||
emptyDescription: 'Try switching categories / tags or search again with another keyword.',
|
||||
askFallback: 'Ask the AI instead',
|
||||
},
|
||||
article: {
|
||||
backToArticles: 'Back to article index',
|
||||
documentSession: 'Document session',
|
||||
@@ -559,6 +636,9 @@ export const messages = {
|
||||
title: 'Related Posts',
|
||||
description: 'More nearby reading paths based on the current category and shared tags.',
|
||||
linked: '{count} linked',
|
||||
hotKicker: 'Hot follow-up',
|
||||
hotTitle: 'Popular related posts',
|
||||
hotDescription: 'Uses the last 7 days of visit and reading signals to recommend the strongest-performing posts from the same category or shared tags.',
|
||||
},
|
||||
comments: {
|
||||
title: 'Comment Terminal',
|
||||
|
||||
135
frontend/src/lib/image.ts
Normal file
135
frontend/src/lib/image.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
const OPTIMIZED_IMAGE_ENDPOINT = '/_img';
|
||||
|
||||
function trimToList(value: string | undefined | null) {
|
||||
return String(value || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeSrc(value: string) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function getPathname(value: string) {
|
||||
try {
|
||||
return new URL(value, 'http://localhost').pathname;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function getImageExtension(src: string) {
|
||||
const pathname = getPathname(normalizeSrc(src)).toLowerCase();
|
||||
const matched = pathname.match(/\.([a-z0-9]+)$/i);
|
||||
return matched?.[1] ?? '';
|
||||
}
|
||||
|
||||
export function shouldOptimizeImage(src: string) {
|
||||
const normalized = normalizeSrc(src);
|
||||
if (!normalized || normalized.startsWith('data:') || normalized.startsWith('blob:')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !['svg', 'gif'].includes(getImageExtension(normalized));
|
||||
}
|
||||
|
||||
export function isAllowedRemoteImageHost(
|
||||
src: string,
|
||||
currentOrigin: string,
|
||||
envHosts?: string,
|
||||
) {
|
||||
try {
|
||||
const sourceUrl = new URL(normalizeSrc(src), currentOrigin);
|
||||
const currentHost = new URL(currentOrigin).host.toLowerCase();
|
||||
const allowedHosts = new Set([currentHost, ...trimToList(envHosts)]);
|
||||
return allowedHosts.has(sourceUrl.host.toLowerCase()) || allowedHosts.has(sourceUrl.hostname.toLowerCase());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function canOptimizeImageSource(
|
||||
src: string,
|
||||
currentOrigin: string,
|
||||
envHosts?: string,
|
||||
) {
|
||||
const normalized = normalizeSrc(src);
|
||||
if (!shouldOptimizeImage(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized.startsWith('/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceUrl = new URL(normalized);
|
||||
return isAllowedRemoteImageHost(sourceUrl.toString(), currentOrigin, envHosts);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getFallbackImageFormat(src: string): 'jpeg' | 'png' {
|
||||
return getImageExtension(src) === 'png' ? 'png' : 'jpeg';
|
||||
}
|
||||
|
||||
export function getResponsiveWidths(widths?: number[]) {
|
||||
const defaults = [480, 768, 1024, 1440, 1920];
|
||||
const source = Array.isArray(widths) && widths.length ? widths : defaults;
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
source
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.map((value) => Math.round(value)),
|
||||
),
|
||||
).sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
export function buildOptimizedImageUrl(
|
||||
src: string,
|
||||
options?: {
|
||||
width?: number;
|
||||
format?: 'avif' | 'webp' | 'jpeg' | 'png';
|
||||
quality?: number;
|
||||
},
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
src: normalizeSrc(src),
|
||||
});
|
||||
|
||||
if (options?.width) {
|
||||
params.set('w', String(Math.round(options.width)));
|
||||
}
|
||||
|
||||
if (options?.format) {
|
||||
params.set('f', options.format);
|
||||
}
|
||||
|
||||
if (options?.quality) {
|
||||
params.set('q', String(Math.round(options.quality)));
|
||||
}
|
||||
|
||||
return `${OPTIMIZED_IMAGE_ENDPOINT}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function buildOptimizedSrcSet(
|
||||
src: string,
|
||||
widths: number[],
|
||||
format: 'avif' | 'webp' | 'jpeg' | 'png',
|
||||
quality?: number,
|
||||
) {
|
||||
return getResponsiveWidths(widths)
|
||||
.map(
|
||||
(width) =>
|
||||
`${buildOptimizedImageUrl(src, {
|
||||
width,
|
||||
format,
|
||||
quality,
|
||||
})} ${width}w`,
|
||||
)
|
||||
.join(', ');
|
||||
}
|
||||
@@ -14,6 +14,15 @@ export interface Post {
|
||||
code?: string;
|
||||
language?: string;
|
||||
pinned?: boolean;
|
||||
status?: string;
|
||||
visibility?: 'public' | 'unlisted' | 'private' | string;
|
||||
publishAt?: string;
|
||||
unpublishAt?: string;
|
||||
canonicalUrl?: string;
|
||||
noindex?: boolean;
|
||||
ogImage?: string;
|
||||
redirectFrom?: string[];
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
@@ -68,6 +77,10 @@ export interface SiteSettings {
|
||||
comments: {
|
||||
paragraphsEnabled: boolean;
|
||||
};
|
||||
seo: {
|
||||
defaultOgImage?: string;
|
||||
defaultTwitterHandle?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MusicTrack {
|
||||
@@ -103,3 +116,38 @@ export interface TechStackItem {
|
||||
icon?: string;
|
||||
level?: string;
|
||||
}
|
||||
|
||||
export interface ContentOverview {
|
||||
totalPageViews: number;
|
||||
pageViewsLast24h: number;
|
||||
pageViewsLast7d: number;
|
||||
totalReadCompletes: number;
|
||||
readCompletesLast7d: number;
|
||||
avgReadProgressLast7d: number;
|
||||
avgReadDurationMsLast7d?: number;
|
||||
}
|
||||
|
||||
export interface PopularPostHighlight {
|
||||
slug: string;
|
||||
title: string;
|
||||
pageViews: number;
|
||||
readCompletes: number;
|
||||
avgProgressPercent: number;
|
||||
avgDurationMs?: number;
|
||||
post?: Post;
|
||||
}
|
||||
|
||||
export interface ContentWindowOverview {
|
||||
pageViews: number;
|
||||
readCompletes: number;
|
||||
avgReadProgress: number;
|
||||
avgReadDurationMs?: number;
|
||||
}
|
||||
|
||||
export interface ContentWindowHighlight {
|
||||
key: string;
|
||||
label: string;
|
||||
days: number;
|
||||
overview: ContentWindowOverview;
|
||||
popularPosts: PopularPostHighlight[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user