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; }; }).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 | null; metadata: Record | 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; 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 { items: T[]; page: number; page_size: number; total: number; total_pages: number; sort_by: string; sort_order: string; } export interface ApiPagedSearchResponse extends ApiPagedResponse { 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, ): 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, ): 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(path: string, options?: RequestInit): Promise { 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; } async getRawPosts(): Promise { return this.fetch('/posts'); } async getPosts(): Promise { 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>(`/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 { const post = await this.fetch(`/posts/${id}`); return normalizePost(post); } async getPostBySlug(slug: string): Promise { 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 { 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 { 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(`/comments?${params.toString()}`); } async getParagraphCommentSummary(postSlug: string): Promise { const params = new URLSearchParams({ post_slug: postSlug }); return this.fetch(`/comments/paragraphs/summary?${params.toString()}`); } async createComment(comment: CreateCommentInput): Promise { return this.fetch('/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 { return this.fetch('/comments/captcha'); } async getReviews(): Promise { return this.fetch('/reviews'); } async getReview(id: number): Promise { return this.fetch(`/reviews/${id}`); } async getRawFriendLinks(): Promise { return this.fetch('/friend_links'); } async getFriendLinks(): Promise { const friendLinks = await this.getRawFriendLinks(); return friendLinks.map(normalizeFriendLink); } async createFriendLink(friendLink: CreateFriendLinkInput): Promise { return this.fetch('/friend_links', { method: 'POST', body: JSON.stringify(friendLink), }); } async subscribe(input: { email: string; displayName?: string; source?: string; turnstileToken?: string; captchaToken?: string; captchaAnswer?: string; }): Promise { return this.fetch('/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 { return this.fetch('/subscriptions/confirm', { method: 'POST', body: JSON.stringify({ token }), }); } async getManagedSubscription(token: string): Promise { return this.fetch( `/subscriptions/manage?token=${encodeURIComponent(token)}`, ); } async updateManagedSubscription(input: { token: string; displayName?: string | null; status?: string | null; filters?: Record | null; }): Promise { return this.fetch('/subscriptions/manage', { method: 'PATCH', body: JSON.stringify({ token: input.token, displayName: input.displayName, status: input.status, filters: input.filters, }), }); } async unsubscribeSubscription(token: string): Promise { return this.fetch('/subscriptions/unsubscribe', { method: 'POST', body: JSON.stringify({ token }), }); } async getRawTags(): Promise { return this.fetch('/tags'); } async getTags(): Promise { const tags = await this.getRawTags(); return tags.map(normalizeTag); } async getRawSiteSettings(): Promise { return this.fetch('/site_settings'); } async getSiteSettings(): Promise { 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('/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 { const categories = await this.fetch('/categories'); return categories.map(normalizeCategory); } async getPostsByCategory(category: string): Promise { const posts = await this.getPosts(); return posts.filter(post => post.category?.toLowerCase() === category.toLowerCase()); } async getPostsByTag(tag: string): Promise { 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 { const params = new URLSearchParams({ q: query, limit: String(limit), }); const results = await this.fetch(`/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(`/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 { return this.fetch('/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;