Some checks failed
docker-images / resolve-build-targets (push) Successful in 7s
ui-regression / playwright-regression (push) Failing after 13m4s
docker-images / build-and-push (admin) (push) Successful in 1m17s
docker-images / build-and-push (backend) (push) Successful in 28m13s
docker-images / build-and-push (frontend) (push) Successful in 47s
docker-images / submit-indexnow (push) Successful in 13s
style: enhance global CSS for better responsiveness of terminal chips and navigation pills test: remove inline subscription test and add maintenance mode access code test feat: implement media library picker dialog for selecting images from the media library feat: add media URL controls for uploading and managing media assets feat: add migration for music_enabled and maintenance_mode settings in site settings feat: implement maintenance mode functionality with access control feat: create maintenance page with access code input and error handling chore: add TypeScript declaration for QR code module
1229 lines
36 KiB
TypeScript
1229 lines
36 KiB
TypeScript
import type {
|
||
Category as UiCategory,
|
||
ContentOverview,
|
||
ContentWindowHighlight,
|
||
FriendLink as UiFriendLink,
|
||
HumanVerificationMode,
|
||
Post as UiPost,
|
||
PopularPostHighlight,
|
||
SiteSettings,
|
||
Tag as UiTag,
|
||
} from '../types';
|
||
|
||
const DEV_API_BASE_URL = 'http://127.0.0.1:5150/api';
|
||
const PROD_DEFAULT_API_PORT = '5150';
|
||
|
||
function normalizeApiBaseUrl(value?: string | null) {
|
||
return value?.trim().replace(/\/$/, '') ?? '';
|
||
}
|
||
|
||
function getRuntimeEnv(
|
||
name:
|
||
| 'PUBLIC_API_BASE_URL'
|
||
| 'INTERNAL_API_BASE_URL'
|
||
| 'PUBLIC_COMMENT_TURNSTILE_SITE_KEY'
|
||
| 'PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY',
|
||
) {
|
||
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);
|
||
}
|
||
|
||
function normalizeVerificationMode(
|
||
value: string | null | undefined,
|
||
fallback: HumanVerificationMode,
|
||
): HumanVerificationMode {
|
||
switch ((value ?? '').trim().toLowerCase()) {
|
||
case 'off':
|
||
return 'off';
|
||
case 'captcha':
|
||
case 'normal':
|
||
case 'simple':
|
||
return 'captcha';
|
||
case 'turnstile':
|
||
return 'turnstile';
|
||
default:
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
const buildTimePublicApiBaseUrl = normalizeApiBaseUrl(import.meta.env.PUBLIC_API_BASE_URL);
|
||
const buildTimeCommentTurnstileSiteKey =
|
||
import.meta.env.PUBLIC_COMMENT_TURNSTILE_SITE_KEY?.trim() ?? '';
|
||
const buildTimeWebPushVapidPublicKey =
|
||
import.meta.env.PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY?.trim() ?? '';
|
||
|
||
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 function resolvePublicCommentTurnstileSiteKey() {
|
||
return (
|
||
getRuntimeEnv('PUBLIC_COMMENT_TURNSTILE_SITE_KEY') || buildTimeCommentTurnstileSiteKey
|
||
);
|
||
}
|
||
|
||
export function resolvePublicWebPushVapidPublicKey() {
|
||
return (
|
||
getRuntimeEnv('PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY') || buildTimeWebPushVapidPublicKey
|
||
);
|
||
}
|
||
|
||
export const API_BASE_URL = resolvePublicApiBaseUrl();
|
||
|
||
export interface ApiPost {
|
||
id: number;
|
||
title: string;
|
||
slug: string;
|
||
description: string;
|
||
content: string;
|
||
category: string;
|
||
tags: string[];
|
||
post_type: 'article' | 'tweet';
|
||
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;
|
||
}
|
||
|
||
export interface Comment {
|
||
id: number;
|
||
post_id: string | null;
|
||
post_slug: string | null;
|
||
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;
|
||
scope: 'article' | 'paragraph';
|
||
paragraph_key: string | null;
|
||
paragraph_excerpt: string | null;
|
||
approved: boolean | null;
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
export interface CreateCommentInput {
|
||
postSlug: string;
|
||
nickname: string;
|
||
email?: string;
|
||
content: string;
|
||
scope?: 'article' | 'paragraph';
|
||
paragraphKey?: string;
|
||
paragraphExcerpt?: string;
|
||
replyTo?: string | null;
|
||
replyToCommentId?: number | null;
|
||
turnstileToken?: string;
|
||
captchaToken?: string;
|
||
captchaAnswer?: string;
|
||
website?: string;
|
||
}
|
||
|
||
export interface CommentCaptchaChallenge {
|
||
token: string;
|
||
question: string;
|
||
expires_in_seconds: number;
|
||
}
|
||
|
||
export interface ParagraphCommentSummary {
|
||
paragraph_key: string;
|
||
count: number;
|
||
}
|
||
|
||
export interface ApiTag {
|
||
id: number;
|
||
name: string;
|
||
slug: string;
|
||
count?: number;
|
||
description?: string | null;
|
||
cover_image?: string | null;
|
||
accent_color?: string | null;
|
||
seo_title?: string | null;
|
||
seo_description?: string | null;
|
||
}
|
||
|
||
export interface ApiCategory {
|
||
id: number;
|
||
name: string;
|
||
slug: string;
|
||
count: number;
|
||
description?: string | null;
|
||
cover_image?: string | null;
|
||
accent_color?: string | null;
|
||
seo_title?: string | null;
|
||
seo_description?: string | null;
|
||
}
|
||
|
||
export interface ApiFriendLink {
|
||
id: number;
|
||
site_name: string;
|
||
site_url: string;
|
||
avatar_url: string | null;
|
||
description: string | null;
|
||
category: string | null;
|
||
status: 'pending' | 'approved' | 'rejected';
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
export interface CreateFriendLinkInput {
|
||
siteName: string;
|
||
siteUrl: string;
|
||
avatarUrl?: string;
|
||
description: string;
|
||
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;
|
||
site_short_name: string | null;
|
||
site_url: string | null;
|
||
site_title: string | null;
|
||
site_description: string | null;
|
||
hero_title: string | null;
|
||
hero_subtitle: string | null;
|
||
owner_name: string | null;
|
||
owner_title: string | null;
|
||
owner_bio: string | null;
|
||
owner_avatar_url: string | null;
|
||
social_github: string | null;
|
||
social_twitter: string | null;
|
||
social_email: string | null;
|
||
location: string | null;
|
||
tech_stack: string[] | null;
|
||
music_enabled?: boolean | null;
|
||
music_playlist: Array<{
|
||
title: string;
|
||
artist?: string | null;
|
||
album?: string | null;
|
||
url: string;
|
||
cover_image_url?: string | null;
|
||
accent_color?: string | null;
|
||
description?: string | null;
|
||
}> | null;
|
||
ai_enabled: boolean;
|
||
paragraph_comments_enabled: boolean;
|
||
comment_verification_mode?: HumanVerificationMode | null;
|
||
comment_turnstile_enabled: boolean;
|
||
subscription_verification_mode?: HumanVerificationMode | null;
|
||
subscription_turnstile_enabled: boolean;
|
||
web_push_enabled: boolean;
|
||
turnstile_site_key: string | null;
|
||
web_push_vapid_public_key: string | null;
|
||
subscription_popup_enabled: boolean;
|
||
subscription_popup_title: string | null;
|
||
subscription_popup_description: string | null;
|
||
subscription_popup_delay_seconds: number | null;
|
||
seo_default_og_image: string | null;
|
||
seo_default_twitter_handle: string | null;
|
||
seo_wechat_share_qr_enabled: boolean;
|
||
}
|
||
|
||
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 {
|
||
site_settings: ApiSiteSettings;
|
||
posts: ApiPost[];
|
||
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 {
|
||
slug: string;
|
||
href: string;
|
||
title: string;
|
||
excerpt: string;
|
||
score: number;
|
||
chunk_index: number;
|
||
}
|
||
|
||
export interface AiAskResponse {
|
||
question: string;
|
||
answer: string;
|
||
sources: AiSource[];
|
||
indexed_chunks: number;
|
||
last_indexed_at: string | null;
|
||
}
|
||
|
||
export interface ApiSearchResult {
|
||
id: number;
|
||
title: string | null;
|
||
slug: string;
|
||
description: string | null;
|
||
content: string | null;
|
||
category: string | null;
|
||
tags: string[] | null;
|
||
post_type: 'article' | 'tweet' | null;
|
||
image: string | null;
|
||
pinned: boolean | null;
|
||
created_at: string;
|
||
updated_at: string;
|
||
rank: number;
|
||
}
|
||
|
||
export interface ApiPagedResponse<T> {
|
||
items: T[];
|
||
page: number;
|
||
page_size: number;
|
||
total: number;
|
||
total_pages: number;
|
||
sort_by: string;
|
||
sort_order: string;
|
||
}
|
||
|
||
export interface ApiPagedSearchResponse extends ApiPagedResponse<ApiSearchResult> {
|
||
query: string;
|
||
}
|
||
|
||
export interface Review {
|
||
id: number;
|
||
title: string;
|
||
review_type: 'game' | 'anime' | 'music' | 'book' | 'movie';
|
||
rating: number;
|
||
review_date: string;
|
||
status: 'published' | 'draft' | 'completed' | 'in-progress' | 'dropped';
|
||
description: string;
|
||
tags: string;
|
||
cover: string;
|
||
link_url: string | null;
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
export type AppFriendLink = UiFriendLink & {
|
||
status: ApiFriendLink['status'];
|
||
};
|
||
|
||
export const DEFAULT_SITE_SETTINGS: SiteSettings = {
|
||
id: '1',
|
||
siteName: 'InitCool',
|
||
siteShortName: 'Termi',
|
||
siteUrl: 'https://init.cool',
|
||
siteTitle: 'InitCool · 技术笔记与内容档案',
|
||
siteDescription: '围绕开发实践、产品观察与长期积累整理的中文内容站。',
|
||
heroTitle: '欢迎来到 InitCool',
|
||
heroSubtitle: '记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。',
|
||
ownerName: 'InitCool',
|
||
ownerTitle: 'Rust / Go / Python Developer · Builder @ init.cool',
|
||
ownerBio: 'InitCool,GitHub 用户名 limitcool。坚持不要重复造轮子,当前在维护 starter,平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。',
|
||
location: 'Hong Kong',
|
||
social: {
|
||
github: 'https://github.com/limitcool',
|
||
twitter: '',
|
||
email: 'mailto:initcoool@gmail.com',
|
||
},
|
||
techStack: ['Rust', 'Go', 'Python', 'Svelte', 'Astro', 'Loco.rs'],
|
||
musicEnabled: true,
|
||
musicPlaylist: [
|
||
{
|
||
title: '山中来信',
|
||
artist: 'InitCool Radio',
|
||
album: '站点默认歌单',
|
||
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
|
||
coverImageUrl:
|
||
'https://images.unsplash.com/photo-1510915228340-29c85a43dcfe?auto=format&fit=crop&w=600&q=80',
|
||
accentColor: '#2f6b5f',
|
||
description: '适合文章阅读时循环播放的轻氛围曲。',
|
||
},
|
||
{
|
||
title: '风吹松声',
|
||
artist: 'InitCool Radio',
|
||
album: '站点默认歌单',
|
||
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3',
|
||
coverImageUrl:
|
||
'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=600&q=80',
|
||
accentColor: '#8a5b35',
|
||
description: '偏木质感的器乐氛围,适合深夜浏览。',
|
||
},
|
||
{
|
||
title: '夜航小记',
|
||
artist: 'InitCool Radio',
|
||
album: '站点默认歌单',
|
||
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3',
|
||
coverImageUrl:
|
||
'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80',
|
||
accentColor: '#375a7f',
|
||
description: '节奏更明显一点,适合切换阅读状态。',
|
||
},
|
||
],
|
||
ai: {
|
||
enabled: false,
|
||
},
|
||
comments: {
|
||
paragraphsEnabled: true,
|
||
verificationMode: 'captcha',
|
||
turnstileEnabled: false,
|
||
turnstileSiteKey: undefined,
|
||
},
|
||
subscriptions: {
|
||
popupEnabled: true,
|
||
popupTitle: '订阅更新',
|
||
popupDescription: '有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订。',
|
||
popupDelaySeconds: 18,
|
||
verificationMode: 'off',
|
||
turnstileEnabled: false,
|
||
turnstileSiteKey: undefined,
|
||
webPushEnabled: false,
|
||
webPushVapidPublicKey: undefined,
|
||
},
|
||
seo: {
|
||
defaultOgImage: undefined,
|
||
defaultTwitterHandle: undefined,
|
||
wechatShareQrEnabled: false,
|
||
},
|
||
};
|
||
|
||
const formatPostDate = (dateString: string) => dateString.slice(0, 10);
|
||
|
||
const estimateReadTime = (content: string | null | undefined) => {
|
||
const text = content?.trim() || '';
|
||
const minutes = Math.max(1, Math.ceil(text.length / 300));
|
||
return `${minutes} 分钟`;
|
||
};
|
||
|
||
const normalizePost = (post: ApiPost): UiPost => ({
|
||
id: String(post.id),
|
||
slug: post.slug,
|
||
title: post.title,
|
||
description: post.description,
|
||
content: post.content,
|
||
date: formatPostDate(post.created_at),
|
||
createdAt: post.created_at,
|
||
updatedAt: post.updated_at,
|
||
readTime: estimateReadTime(post.content || post.description),
|
||
type: post.post_type,
|
||
tags: post.tags ?? [],
|
||
category: post.category,
|
||
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 => ({
|
||
id: String(tag.id),
|
||
name: tag.name,
|
||
slug: tag.slug,
|
||
count: tag.count,
|
||
description: tag.description ?? undefined,
|
||
coverImage: tag.cover_image ?? undefined,
|
||
accentColor: tag.accent_color ?? undefined,
|
||
seoTitle: tag.seo_title ?? undefined,
|
||
seoDescription: tag.seo_description ?? undefined,
|
||
});
|
||
|
||
const normalizeCategory = (category: ApiCategory): UiCategory => ({
|
||
id: String(category.id),
|
||
name: category.name,
|
||
slug: category.slug,
|
||
count: category.count,
|
||
description: category.description ?? undefined,
|
||
coverImage: category.cover_image ?? undefined,
|
||
accentColor: category.accent_color ?? undefined,
|
||
seoTitle: category.seo_title ?? undefined,
|
||
seoDescription: category.seo_description ?? undefined,
|
||
});
|
||
|
||
const normalizeAvatarUrl = (value: string | null | undefined) => {
|
||
if (!value) {
|
||
return undefined;
|
||
}
|
||
|
||
try {
|
||
const host = new URL(value).hostname.toLowerCase();
|
||
const isReservedExampleHost =
|
||
host === 'example.com' ||
|
||
host === 'example.org' ||
|
||
host === 'example.net' ||
|
||
host.endsWith('.example.com') ||
|
||
host.endsWith('.example.org') ||
|
||
host.endsWith('.example.net');
|
||
|
||
return isReservedExampleHost ? undefined : value;
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
};
|
||
|
||
const normalizeTagToken = (value: string) => value.trim().toLowerCase();
|
||
|
||
const normalizeFriendLink = (friendLink: ApiFriendLink): AppFriendLink => ({
|
||
id: String(friendLink.id),
|
||
name: friendLink.site_name,
|
||
url: friendLink.site_url,
|
||
avatar: normalizeAvatarUrl(friendLink.avatar_url),
|
||
description: friendLink.description ?? undefined,
|
||
category: friendLink.category ?? undefined,
|
||
status: friendLink.status,
|
||
});
|
||
|
||
const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
|
||
const commentVerificationMode = normalizeVerificationMode(
|
||
settings.comment_verification_mode,
|
||
settings.comment_turnstile_enabled ? 'turnstile' : 'captcha',
|
||
);
|
||
const subscriptionVerificationMode = normalizeVerificationMode(
|
||
settings.subscription_verification_mode,
|
||
settings.subscription_turnstile_enabled ? 'turnstile' : 'off',
|
||
);
|
||
const musicEnabled = settings.music_enabled ?? true;
|
||
const normalizedMusicPlaylist =
|
||
settings.music_playlist?.filter((item) => item?.title?.trim() && item?.url?.trim())?.length
|
||
? settings.music_playlist
|
||
.filter((item) => item.title.trim() && item.url.trim())
|
||
.map((item) => ({
|
||
title: item.title,
|
||
artist: item.artist ?? undefined,
|
||
album: item.album ?? undefined,
|
||
url: item.url,
|
||
coverImageUrl: item.cover_image_url ?? undefined,
|
||
accentColor: item.accent_color ?? undefined,
|
||
description: item.description ?? undefined,
|
||
}))
|
||
: DEFAULT_SITE_SETTINGS.musicPlaylist;
|
||
|
||
return {
|
||
id: String(settings.id),
|
||
siteName: settings.site_name || DEFAULT_SITE_SETTINGS.siteName,
|
||
siteShortName: settings.site_short_name || DEFAULT_SITE_SETTINGS.siteShortName,
|
||
siteUrl: settings.site_url || DEFAULT_SITE_SETTINGS.siteUrl,
|
||
siteTitle: settings.site_title || DEFAULT_SITE_SETTINGS.siteTitle,
|
||
siteDescription: settings.site_description || DEFAULT_SITE_SETTINGS.siteDescription,
|
||
heroTitle: settings.hero_title || DEFAULT_SITE_SETTINGS.heroTitle,
|
||
heroSubtitle: settings.hero_subtitle || DEFAULT_SITE_SETTINGS.heroSubtitle,
|
||
ownerName: settings.owner_name || DEFAULT_SITE_SETTINGS.ownerName,
|
||
ownerTitle: settings.owner_title || DEFAULT_SITE_SETTINGS.ownerTitle,
|
||
ownerBio: settings.owner_bio || DEFAULT_SITE_SETTINGS.ownerBio,
|
||
ownerAvatarUrl: settings.owner_avatar_url ?? undefined,
|
||
location: settings.location || DEFAULT_SITE_SETTINGS.location,
|
||
social: {
|
||
github: settings.social_github || DEFAULT_SITE_SETTINGS.social.github,
|
||
twitter: settings.social_twitter || DEFAULT_SITE_SETTINGS.social.twitter,
|
||
email: settings.social_email || DEFAULT_SITE_SETTINGS.social.email,
|
||
},
|
||
techStack: settings.tech_stack?.length ? settings.tech_stack : DEFAULT_SITE_SETTINGS.techStack,
|
||
musicEnabled,
|
||
musicPlaylist: musicEnabled ? normalizedMusicPlaylist : [],
|
||
ai: {
|
||
enabled: Boolean(settings.ai_enabled),
|
||
},
|
||
comments: {
|
||
verificationMode: commentVerificationMode,
|
||
paragraphsEnabled: settings.paragraph_comments_enabled ?? true,
|
||
turnstileEnabled: commentVerificationMode === 'turnstile',
|
||
turnstileSiteKey:
|
||
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined,
|
||
},
|
||
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,
|
||
verificationMode: subscriptionVerificationMode,
|
||
turnstileEnabled: subscriptionVerificationMode === 'turnstile',
|
||
turnstileSiteKey:
|
||
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined,
|
||
webPushEnabled: Boolean(settings.web_push_enabled),
|
||
webPushVapidPublicKey:
|
||
settings.web_push_vapid_public_key ||
|
||
resolvePublicWebPushVapidPublicKey() ||
|
||
undefined,
|
||
},
|
||
seo: {
|
||
defaultOgImage: settings.seo_default_og_image ?? undefined,
|
||
defaultTwitterHandle: settings.seo_default_twitter_handle ?? undefined,
|
||
wechatShareQrEnabled: Boolean(settings.seo_wechat_share_qr_enabled),
|
||
},
|
||
};
|
||
};
|
||
|
||
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;
|
||
|
||
constructor(baseUrl: string) {
|
||
this.baseUrl = baseUrl;
|
||
}
|
||
|
||
private async fetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||
...options,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...options?.headers,
|
||
},
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text().catch(() => '');
|
||
throw new Error(errorText || `API error: ${response.status} ${response.statusText}`);
|
||
}
|
||
|
||
if (response.status === 204) {
|
||
return undefined as T;
|
||
}
|
||
|
||
return response.json() as Promise<T>;
|
||
}
|
||
|
||
async getRawPosts(): Promise<ApiPost[]> {
|
||
return this.fetch<ApiPost[]>('/posts');
|
||
}
|
||
|
||
async getPosts(): Promise<UiPost[]> {
|
||
const posts = await this.getRawPosts();
|
||
return posts.map(normalizePost);
|
||
}
|
||
|
||
async getPostsPage(options?: {
|
||
page?: number;
|
||
pageSize?: number;
|
||
search?: string;
|
||
category?: string;
|
||
tag?: string;
|
||
postType?: string;
|
||
sortBy?: string;
|
||
sortOrder?: string;
|
||
}): Promise<{
|
||
items: UiPost[];
|
||
page: number;
|
||
pageSize: number;
|
||
total: number;
|
||
totalPages: number;
|
||
sortBy: string;
|
||
sortOrder: string;
|
||
}> {
|
||
const params = new URLSearchParams();
|
||
if (options?.page) params.set('page', String(options.page));
|
||
if (options?.pageSize) params.set('page_size', String(options.pageSize));
|
||
if (options?.search) params.set('search', options.search);
|
||
if (options?.category) params.set('category', options.category);
|
||
if (options?.tag) params.set('tag', options.tag);
|
||
if (options?.postType) params.set('type', options.postType);
|
||
if (options?.sortBy) params.set('sort_by', options.sortBy);
|
||
if (options?.sortOrder) params.set('sort_order', options.sortOrder);
|
||
|
||
const payload = await this.fetch<ApiPagedResponse<ApiPost>>(`/posts/page?${params.toString()}`);
|
||
return {
|
||
items: payload.items.map(normalizePost),
|
||
page: payload.page,
|
||
pageSize: payload.page_size,
|
||
total: payload.total,
|
||
totalPages: payload.total_pages,
|
||
sortBy: payload.sort_by,
|
||
sortOrder: payload.sort_order,
|
||
};
|
||
}
|
||
|
||
async getPost(id: number): Promise<UiPost> {
|
||
const post = await this.fetch<ApiPost>(`/posts/${id}`);
|
||
return normalizePost(post);
|
||
}
|
||
|
||
async getPostBySlug(slug: string): Promise<UiPost | null> {
|
||
const response = await fetch(`${this.baseUrl}/posts/slug/${encodeURIComponent(slug)}`, {
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
});
|
||
|
||
if (response.status === 404) {
|
||
return null;
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text().catch(() => '');
|
||
throw new Error(errorText || `API error: ${response.status} ${response.statusText}`);
|
||
}
|
||
|
||
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?: {
|
||
approved?: boolean;
|
||
scope?: 'article' | 'paragraph';
|
||
paragraphKey?: string;
|
||
}
|
||
): Promise<Comment[]> {
|
||
const params = new URLSearchParams({ post_slug: postSlug });
|
||
if (options?.approved !== undefined) {
|
||
params.set('approved', String(options.approved));
|
||
}
|
||
if (options?.scope) {
|
||
params.set('scope', options.scope);
|
||
}
|
||
if (options?.paragraphKey) {
|
||
params.set('paragraph_key', options.paragraphKey);
|
||
}
|
||
return this.fetch<Comment[]>(`/comments?${params.toString()}`);
|
||
}
|
||
|
||
async getParagraphCommentSummary(postSlug: string): Promise<ParagraphCommentSummary[]> {
|
||
const params = new URLSearchParams({ post_slug: postSlug });
|
||
return this.fetch<ParagraphCommentSummary[]>(`/comments/paragraphs/summary?${params.toString()}`);
|
||
}
|
||
|
||
async createComment(comment: CreateCommentInput): Promise<Comment> {
|
||
return this.fetch<Comment>('/comments', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
postSlug: comment.postSlug,
|
||
nickname: comment.nickname,
|
||
email: comment.email,
|
||
content: comment.content,
|
||
scope: comment.scope,
|
||
paragraphKey: comment.paragraphKey,
|
||
paragraphExcerpt: comment.paragraphExcerpt,
|
||
replyTo: comment.replyTo,
|
||
replyToCommentId: comment.replyToCommentId,
|
||
turnstileToken: comment.turnstileToken,
|
||
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');
|
||
}
|
||
|
||
async getReview(id: number): Promise<Review> {
|
||
return this.fetch<Review>(`/reviews/${id}`);
|
||
}
|
||
|
||
async getRawFriendLinks(): Promise<ApiFriendLink[]> {
|
||
return this.fetch<ApiFriendLink[]>('/friend_links');
|
||
}
|
||
|
||
async getFriendLinks(): Promise<AppFriendLink[]> {
|
||
const friendLinks = await this.getRawFriendLinks();
|
||
return friendLinks.map(normalizeFriendLink);
|
||
}
|
||
|
||
async createFriendLink(friendLink: CreateFriendLinkInput): Promise<ApiFriendLink> {
|
||
return this.fetch<ApiFriendLink>('/friend_links', {
|
||
method: 'POST',
|
||
body: JSON.stringify(friendLink),
|
||
});
|
||
}
|
||
|
||
async subscribe(input: {
|
||
email: string;
|
||
displayName?: string;
|
||
source?: string;
|
||
turnstileToken?: string;
|
||
captchaToken?: string;
|
||
captchaAnswer?: string;
|
||
}): Promise<PublicSubscriptionResponse> {
|
||
return this.fetch<PublicSubscriptionResponse>('/subscriptions', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
email: input.email,
|
||
displayName: input.displayName,
|
||
source: input.source,
|
||
turnstileToken: input.turnstileToken,
|
||
captchaToken: input.captchaToken,
|
||
captchaAnswer: input.captchaAnswer,
|
||
}),
|
||
});
|
||
}
|
||
|
||
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');
|
||
}
|
||
|
||
async getTags(): Promise<UiTag[]> {
|
||
const tags = await this.getRawTags();
|
||
return tags.map(normalizeTag);
|
||
}
|
||
|
||
async getRawSiteSettings(): Promise<ApiSiteSettings> {
|
||
return this.fetch<ApiSiteSettings>('/site_settings');
|
||
}
|
||
|
||
async getSiteSettings(): Promise<SiteSettings> {
|
||
const settings = await this.getRawSiteSettings();
|
||
return normalizeSiteSettings(settings);
|
||
}
|
||
|
||
async getHomePageData(): Promise<{
|
||
siteSettings: SiteSettings;
|
||
posts: UiPost[];
|
||
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,
|
||
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,
|
||
};
|
||
}
|
||
|
||
async getCategories(): Promise<UiCategory[]> {
|
||
const categories = await this.fetch<ApiCategory[]>('/categories');
|
||
return categories.map(normalizeCategory);
|
||
}
|
||
|
||
async getPostsByCategory(category: string): Promise<UiPost[]> {
|
||
const posts = await this.getPosts();
|
||
return posts.filter(post => post.category?.toLowerCase() === category.toLowerCase());
|
||
}
|
||
|
||
async getPostsByTag(tag: string): Promise<UiPost[]> {
|
||
const posts = await this.getPosts();
|
||
const normalizedTag = normalizeTagToken(tag);
|
||
return posts.filter(post =>
|
||
post.tags?.some(item => normalizeTagToken(item) === normalizedTag)
|
||
);
|
||
}
|
||
|
||
async searchPosts(query: string, limit = 20): Promise<UiPost[]> {
|
||
const params = new URLSearchParams({
|
||
q: query,
|
||
limit: String(limit),
|
||
});
|
||
const results = await this.fetch<ApiSearchResult[]>(`/search?${params.toString()}`);
|
||
|
||
return results.map(result =>
|
||
normalizePost({
|
||
id: result.id,
|
||
title: result.title || 'Untitled',
|
||
slug: result.slug,
|
||
description: result.description || '',
|
||
content: result.content || '',
|
||
category: result.category || '',
|
||
tags: result.tags ?? [],
|
||
post_type: result.post_type || 'article',
|
||
image: result.image,
|
||
images: null,
|
||
pinned: result.pinned ?? false,
|
||
status: null,
|
||
visibility: null,
|
||
publish_at: null,
|
||
unpublish_at: null,
|
||
canonical_url: null,
|
||
noindex: null,
|
||
og_image: null,
|
||
redirect_from: null,
|
||
redirect_to: null,
|
||
created_at: result.created_at,
|
||
updated_at: result.updated_at,
|
||
})
|
||
);
|
||
}
|
||
|
||
async searchPostsPage(options: {
|
||
query: string;
|
||
page?: number;
|
||
pageSize?: number;
|
||
category?: string;
|
||
tag?: string;
|
||
postType?: string;
|
||
sortBy?: string;
|
||
sortOrder?: string;
|
||
}): Promise<{
|
||
query: string;
|
||
items: UiPost[];
|
||
page: number;
|
||
pageSize: number;
|
||
total: number;
|
||
totalPages: number;
|
||
sortBy: string;
|
||
sortOrder: string;
|
||
}> {
|
||
const params = new URLSearchParams({ q: options.query });
|
||
if (options.page) params.set('page', String(options.page));
|
||
if (options.pageSize) params.set('page_size', String(options.pageSize));
|
||
if (options.category) params.set('category', options.category);
|
||
if (options.tag) params.set('tag', options.tag);
|
||
if (options.postType) params.set('type', options.postType);
|
||
if (options.sortBy) params.set('sort_by', options.sortBy);
|
||
if (options.sortOrder) params.set('sort_order', options.sortOrder);
|
||
|
||
const payload = await this.fetch<ApiPagedSearchResponse>(`/search/page?${params.toString()}`);
|
||
return {
|
||
query: payload.query,
|
||
items: payload.items.map((result) =>
|
||
normalizePost({
|
||
id: result.id,
|
||
title: result.title || 'Untitled',
|
||
slug: result.slug,
|
||
description: result.description || '',
|
||
content: result.content || '',
|
||
category: result.category || '',
|
||
tags: result.tags ?? [],
|
||
post_type: result.post_type || 'article',
|
||
image: result.image,
|
||
images: null,
|
||
pinned: result.pinned ?? false,
|
||
status: null,
|
||
visibility: null,
|
||
publish_at: null,
|
||
unpublish_at: null,
|
||
canonical_url: null,
|
||
noindex: null,
|
||
og_image: null,
|
||
redirect_from: null,
|
||
redirect_to: null,
|
||
created_at: result.created_at,
|
||
updated_at: result.updated_at,
|
||
}),
|
||
),
|
||
page: payload.page,
|
||
pageSize: payload.page_size,
|
||
total: payload.total,
|
||
totalPages: payload.total_pages,
|
||
sortBy: payload.sort_by,
|
||
sortOrder: payload.sort_order,
|
||
};
|
||
}
|
||
|
||
async askAi(question: string): Promise<AiAskResponse> {
|
||
return this.fetch<AiAskResponse>('/ai/ask', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ question }),
|
||
});
|
||
}
|
||
}
|
||
|
||
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;
|