feat: ship blog platform admin and deploy stack

This commit is contained in:
2026-03-31 21:48:39 +08:00
parent a9a05aa105
commit 313f174fbc
210 changed files with 25476 additions and 5803 deletions

View File

@@ -1,19 +1,69 @@
import type {
Category as UiCategory,
ContentOverview,
ContentWindowHighlight,
FriendLink as UiFriendLink,
Post as UiPost,
PopularPostHighlight,
SiteSettings,
Tag as UiTag,
} from '../types';
const envApiBaseUrl = import.meta.env.PUBLIC_API_BASE_URL?.trim();
const DEV_API_BASE_URL = 'http://127.0.0.1:5150/api';
const PROD_DEFAULT_API_PORT = '5150';
export const API_BASE_URL =
envApiBaseUrl && envApiBaseUrl.length > 0
? envApiBaseUrl.replace(/\/$/, '')
: import.meta.env.DEV
? 'http://127.0.0.1:5150/api'
: 'https://init.cool/api';
function normalizeApiBaseUrl(value?: string | null) {
return value?.trim().replace(/\/$/, '') ?? '';
}
function getRuntimeEnv(name: 'PUBLIC_API_BASE_URL' | 'INTERNAL_API_BASE_URL') {
const runtimeProcess = (globalThis as typeof globalThis & {
process?: {
env?: Record<string, string | undefined>;
};
}).process;
return normalizeApiBaseUrl(runtimeProcess?.env?.[name]);
}
function toUrlLike(value: string | URL) {
return value instanceof URL ? value : new URL(value);
}
const buildTimePublicApiBaseUrl = normalizeApiBaseUrl(import.meta.env.PUBLIC_API_BASE_URL);
export function resolvePublicApiBaseUrl(requestUrl?: string | URL) {
const runtimePublicApiBaseUrl = getRuntimeEnv('PUBLIC_API_BASE_URL');
if (runtimePublicApiBaseUrl) {
return runtimePublicApiBaseUrl;
}
if (buildTimePublicApiBaseUrl) {
return buildTimePublicApiBaseUrl;
}
if (import.meta.env.DEV) {
return DEV_API_BASE_URL;
}
if (requestUrl) {
const { protocol, hostname } = toUrlLike(requestUrl);
return `${protocol}//${hostname}:${PROD_DEFAULT_API_PORT}/api`;
}
return DEV_API_BASE_URL;
}
export function resolveInternalApiBaseUrl(requestUrl?: string | URL) {
const runtimeInternalApiBaseUrl = getRuntimeEnv('INTERNAL_API_BASE_URL');
if (runtimeInternalApiBaseUrl) {
return runtimeInternalApiBaseUrl;
}
return resolvePublicApiBaseUrl(requestUrl);
}
export const API_BASE_URL = resolvePublicApiBaseUrl();
export interface ApiPost {
id: number;
@@ -27,6 +77,15 @@ export interface ApiPost {
image: string | null;
images: string[] | null;
pinned: boolean;
status: string | null;
visibility: 'public' | 'unlisted' | 'private' | null;
publish_at: string | null;
unpublish_at: string | null;
canonical_url: string | null;
noindex: boolean | null;
og_image: string | null;
redirect_from: string[] | null;
redirect_to: string | null;
created_at: string;
updated_at: string;
}
@@ -38,6 +97,9 @@ export interface Comment {
author: string | null;
email: string | null;
avatar: string | null;
ip_address: string | null;
user_agent: string | null;
referer: string | null;
content: string | null;
reply_to: string | null;
reply_to_comment_id: number | null;
@@ -59,6 +121,15 @@ export interface CreateCommentInput {
paragraphExcerpt?: string;
replyTo?: string | null;
replyToCommentId?: number | null;
captchaToken?: string;
captchaAnswer?: string;
website?: string;
}
export interface CommentCaptchaChallenge {
token: string;
question: string;
expires_in_seconds: number;
}
export interface ParagraphCommentSummary {
@@ -101,6 +172,35 @@ export interface CreateFriendLinkInput {
category?: string;
}
export interface PublicSubscriptionResponse {
ok: boolean;
subscription_id: number;
status: string;
requires_confirmation: boolean;
message: string;
}
export interface PublicManagedSubscription {
created_at: string;
updated_at: string;
id: number;
channel_type: string;
target: string;
display_name: string | null;
status: string;
filters: Record<string, unknown> | null;
metadata: Record<string, unknown> | null;
verified_at: string | null;
last_notified_at: string | null;
last_delivery_status: string | null;
manage_token: string | null;
}
export interface PublicSubscriptionManageResponse {
ok: boolean;
subscription: PublicManagedSubscription;
}
export interface ApiSiteSettings {
id: number;
site_name: string | null;
@@ -130,6 +230,19 @@ export interface ApiSiteSettings {
}> | null;
ai_enabled: boolean;
paragraph_comments_enabled: boolean;
seo_default_og_image: string | null;
seo_default_twitter_handle: string | null;
}
export interface ContentAnalyticsInput {
eventType: 'page_view' | 'read_progress' | 'read_complete';
path: string;
postSlug?: string;
sessionId?: string;
durationMs?: number;
progressPercent?: number;
metadata?: Record<string, unknown>;
referrer?: string;
}
export interface ApiHomePagePayload {
@@ -138,6 +251,42 @@ export interface ApiHomePagePayload {
tags: ApiTag[];
friend_links: ApiFriendLink[];
categories: ApiCategory[];
content_overview?: {
total_page_views: number;
page_views_last_24h: number;
page_views_last_7d: number;
total_read_completes: number;
read_completes_last_7d: number;
avg_read_progress_last_7d: number;
avg_read_duration_ms_last_7d: number | null;
};
popular_posts?: Array<{
slug: string;
title: string;
page_views: number;
read_completes: number;
avg_progress_percent: number;
avg_duration_ms: number | null;
}>;
content_ranges?: Array<{
key: string;
label: string;
days: number;
overview: {
page_views: number;
read_completes: number;
avg_read_progress: number;
avg_read_duration_ms: number | null;
};
popular_posts: Array<{
slug: string;
title: string;
page_views: number;
read_completes: number;
avg_progress_percent: number;
avg_duration_ms: number | null;
}>;
}>;
}
export interface AiSource {
@@ -249,6 +398,10 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
comments: {
paragraphsEnabled: true,
},
seo: {
defaultOgImage: undefined,
defaultTwitterHandle: undefined,
},
};
const formatPostDate = (dateString: string) => dateString.slice(0, 10);
@@ -273,6 +426,15 @@ const normalizePost = (post: ApiPost): UiPost => ({
image: post.image ?? undefined,
images: post.images ?? undefined,
pinned: post.pinned,
status: post.status ?? undefined,
visibility: post.visibility ?? undefined,
publishAt: post.publish_at ?? undefined,
unpublishAt: post.unpublish_at ?? undefined,
canonicalUrl: post.canonical_url ?? undefined,
noindex: post.noindex ?? undefined,
ogImage: post.og_image ?? undefined,
redirectFrom: post.redirect_from ?? undefined,
redirectTo: post.redirect_to ?? undefined,
});
const normalizeTag = (tag: ApiTag): UiTag => ({
@@ -361,8 +523,127 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
comments: {
paragraphsEnabled: settings.paragraph_comments_enabled ?? true,
},
seo: {
defaultOgImage: settings.seo_default_og_image ?? undefined,
defaultTwitterHandle: settings.seo_default_twitter_handle ?? undefined,
},
});
const normalizeContentOverview = (
overview: ApiHomePagePayload['content_overview'] | undefined,
): ContentOverview => ({
totalPageViews: overview?.total_page_views ?? 0,
pageViewsLast24h: overview?.page_views_last_24h ?? 0,
pageViewsLast7d: overview?.page_views_last_7d ?? 0,
totalReadCompletes: overview?.total_read_completes ?? 0,
readCompletesLast7d: overview?.read_completes_last_7d ?? 0,
avgReadProgressLast7d: overview?.avg_read_progress_last_7d ?? 0,
avgReadDurationMsLast7d: overview?.avg_read_duration_ms_last_7d ?? undefined,
});
const CONTENT_WINDOW_META = [
{ key: '24h', label: '24h', days: 1 },
{ key: '7d', label: '7d', days: 7 },
{ key: '30d', label: '30d', days: 30 },
] as const;
const normalizePopularPost = (
item: {
slug: string;
title: string;
page_views: number;
read_completes: number;
avg_progress_percent: number;
avg_duration_ms: number | null;
},
postsBySlug: Map<string, UiPost>,
): PopularPostHighlight => ({
slug: item.slug,
title: item.title,
pageViews: item.page_views,
readCompletes: item.read_completes,
avgProgressPercent: item.avg_progress_percent,
avgDurationMs: item.avg_duration_ms ?? undefined,
post: postsBySlug.get(item.slug),
});
const normalizeContentRanges = (
ranges: ApiHomePagePayload['content_ranges'] | undefined,
overview: ApiHomePagePayload['content_overview'] | undefined,
popularPosts: ApiHomePagePayload['popular_posts'] | undefined,
postsBySlug: Map<string, UiPost>,
): ContentWindowHighlight[] => {
const normalizedRanges = new Map(
(ranges ?? []).map((item) => [
item.key,
{
key: item.key,
label: item.label,
days: item.days,
overview: {
pageViews: item.overview?.page_views ?? 0,
readCompletes: item.overview?.read_completes ?? 0,
avgReadProgress: item.overview?.avg_read_progress ?? 0,
avgReadDurationMs: item.overview?.avg_read_duration_ms ?? undefined,
},
popularPosts: (item.popular_posts ?? []).map((popularItem) =>
normalizePopularPost(popularItem, postsBySlug),
),
},
]),
);
return CONTENT_WINDOW_META.map((meta) => {
const existing = normalizedRanges.get(meta.key);
if (existing) {
return existing;
}
if (meta.key === '7d') {
return {
key: meta.key,
label: meta.label,
days: meta.days,
overview: {
pageViews: overview?.page_views_last_7d ?? 0,
readCompletes: overview?.read_completes_last_7d ?? 0,
avgReadProgress: overview?.avg_read_progress_last_7d ?? 0,
avgReadDurationMs: overview?.avg_read_duration_ms_last_7d ?? undefined,
},
popularPosts: (popularPosts ?? []).map((item) => normalizePopularPost(item, postsBySlug)),
};
}
if (meta.key === '24h') {
return {
key: meta.key,
label: meta.label,
days: meta.days,
overview: {
pageViews: overview?.page_views_last_24h ?? 0,
readCompletes: 0,
avgReadProgress: 0,
avgReadDurationMs: undefined,
},
popularPosts: [],
};
}
return {
key: meta.key,
label: meta.label,
days: meta.days,
overview: {
pageViews: 0,
readCompletes: 0,
avgReadProgress: 0,
avgReadDurationMs: undefined,
},
popularPosts: [],
};
});
};
class ApiClient {
private baseUrl: string;
@@ -424,6 +705,22 @@ class ApiClient {
return normalizePost((await response.json()) as ApiPost);
}
async recordContentEvent(input: ContentAnalyticsInput): Promise<void> {
await this.fetch<{ recorded: boolean }>('/analytics/content', {
method: 'POST',
body: JSON.stringify({
event_type: input.eventType,
path: input.path,
post_slug: input.postSlug,
session_id: input.sessionId,
duration_ms: input.durationMs,
progress_percent: input.progressPercent,
metadata: input.metadata,
referrer: input.referrer,
}),
})
}
async getComments(
postSlug: string,
options?: {
@@ -463,10 +760,17 @@ class ApiClient {
paragraphExcerpt: comment.paragraphExcerpt,
replyTo: comment.replyTo,
replyToCommentId: comment.replyToCommentId,
captchaToken: comment.captchaToken,
captchaAnswer: comment.captchaAnswer,
website: comment.website,
}),
});
}
async getCommentCaptcha(): Promise<CommentCaptchaChallenge> {
return this.fetch<CommentCaptchaChallenge>('/comments/captcha');
}
async getReviews(): Promise<Review[]> {
return this.fetch<Review[]>('/reviews');
}
@@ -491,6 +795,54 @@ class ApiClient {
});
}
async subscribe(input: { email: string; displayName?: string; source?: string }): Promise<PublicSubscriptionResponse> {
return this.fetch<PublicSubscriptionResponse>('/subscriptions', {
method: 'POST',
body: JSON.stringify({
email: input.email,
displayName: input.displayName,
source: input.source,
}),
});
}
async confirmSubscription(token: string): Promise<PublicSubscriptionManageResponse> {
return this.fetch<PublicSubscriptionManageResponse>('/subscriptions/confirm', {
method: 'POST',
body: JSON.stringify({ token }),
});
}
async getManagedSubscription(token: string): Promise<PublicSubscriptionManageResponse> {
return this.fetch<PublicSubscriptionManageResponse>(
`/subscriptions/manage?token=${encodeURIComponent(token)}`,
);
}
async updateManagedSubscription(input: {
token: string;
displayName?: string | null;
status?: string | null;
filters?: Record<string, unknown> | null;
}): Promise<PublicSubscriptionManageResponse> {
return this.fetch<PublicSubscriptionManageResponse>('/subscriptions/manage', {
method: 'PATCH',
body: JSON.stringify({
token: input.token,
displayName: input.displayName,
status: input.status,
filters: input.filters,
}),
});
}
async unsubscribeSubscription(token: string): Promise<PublicSubscriptionManageResponse> {
return this.fetch<PublicSubscriptionManageResponse>('/subscriptions/unsubscribe', {
method: 'POST',
body: JSON.stringify({ token }),
});
}
async getRawTags(): Promise<ApiTag[]> {
return this.fetch<ApiTag[]>('/tags');
}
@@ -515,15 +867,31 @@ class ApiClient {
tags: UiTag[];
friendLinks: AppFriendLink[];
categories: UiCategory[];
contentOverview: ContentOverview;
contentRanges: ContentWindowHighlight[];
popularPosts: PopularPostHighlight[];
}> {
const payload = await this.fetch<ApiHomePagePayload>('/site_settings/home');
const posts = (payload.posts ?? []).map(normalizePost);
const postsBySlug = new Map(posts.map((post) => [post.slug, post]));
const popularPosts = (payload.popular_posts ?? []).map((item) =>
normalizePopularPost(item, postsBySlug),
);
return {
siteSettings: normalizeSiteSettings(payload.site_settings),
posts: payload.posts.map(normalizePost),
tags: payload.tags.map(normalizeTag),
friendLinks: payload.friend_links.map(normalizeFriendLink),
categories: payload.categories.map(normalizeCategory),
posts,
tags: (payload.tags ?? []).map(normalizeTag),
friendLinks: (payload.friend_links ?? []).map(normalizeFriendLink),
categories: (payload.categories ?? []).map(normalizeCategory),
contentOverview: normalizeContentOverview(payload.content_overview),
contentRanges: normalizeContentRanges(
payload.content_ranges,
payload.content_overview,
payload.popular_posts,
postsBySlug,
),
popularPosts,
};
}
@@ -579,5 +947,9 @@ class ApiClient {
}
}
export const api = new ApiClient(API_BASE_URL);
export function createApiClient(options?: { baseUrl?: string; requestUrl?: string | URL }) {
return new ApiClient(options?.baseUrl ?? resolveInternalApiBaseUrl(options?.requestUrl));
}
export const api = createApiClient();
export const apiClient = api;