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