Files
termi-blog/frontend/src/lib/api/client.ts
limitcool 9665c933b5
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
feat: update tag and timeline share panel copy for clarity and conciseness
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
2026-04-02 23:05:49 +08:00

1229 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: 'InitCoolGitHub 用户名 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;