feat: ship public ops features and cache docker builds
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Has been cancelled
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Has been cancelled
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Has been cancelled
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Has been cancelled
This commit is contained in:
@@ -16,7 +16,13 @@ function normalizeApiBaseUrl(value?: string | null) {
|
||||
return value?.trim().replace(/\/$/, '') ?? '';
|
||||
}
|
||||
|
||||
function getRuntimeEnv(name: 'PUBLIC_API_BASE_URL' | 'INTERNAL_API_BASE_URL') {
|
||||
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>;
|
||||
@@ -31,6 +37,10 @@ function toUrlLike(value: string | URL) {
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -63,6 +73,18 @@ export function resolveInternalApiBaseUrl(requestUrl?: string | URL) {
|
||||
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 {
|
||||
@@ -121,6 +143,7 @@ export interface CreateCommentInput {
|
||||
paragraphExcerpt?: string;
|
||||
replyTo?: string | null;
|
||||
replyToCommentId?: number | null;
|
||||
turnstileToken?: string;
|
||||
captchaToken?: string;
|
||||
captchaAnswer?: string;
|
||||
website?: string;
|
||||
@@ -141,8 +164,12 @@ export interface ApiTag {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
created_at: string;
|
||||
updated_at: 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 {
|
||||
@@ -150,6 +177,11 @@ export interface ApiCategory {
|
||||
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 {
|
||||
@@ -230,6 +262,11 @@ export interface ApiSiteSettings {
|
||||
}> | null;
|
||||
ai_enabled: boolean;
|
||||
paragraph_comments_enabled: boolean;
|
||||
comment_turnstile_enabled: boolean;
|
||||
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;
|
||||
@@ -326,6 +363,20 @@ export interface ApiSearchResult {
|
||||
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;
|
||||
@@ -401,12 +452,18 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
|
||||
},
|
||||
comments: {
|
||||
paragraphsEnabled: true,
|
||||
turnstileEnabled: false,
|
||||
turnstileSiteKey: undefined,
|
||||
},
|
||||
subscriptions: {
|
||||
popupEnabled: true,
|
||||
popupTitle: '订阅更新',
|
||||
popupDescription: '有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订。',
|
||||
popupDelaySeconds: 18,
|
||||
turnstileEnabled: false,
|
||||
turnstileSiteKey: undefined,
|
||||
webPushEnabled: false,
|
||||
webPushVapidPublicKey: undefined,
|
||||
},
|
||||
seo: {
|
||||
defaultOgImage: undefined,
|
||||
@@ -451,6 +508,12 @@ 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 => ({
|
||||
@@ -458,6 +521,11 @@ const normalizeCategory = (category: ApiCategory): UiCategory => ({
|
||||
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) => {
|
||||
@@ -532,6 +600,9 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
|
||||
},
|
||||
comments: {
|
||||
paragraphsEnabled: settings.paragraph_comments_enabled ?? true,
|
||||
turnstileEnabled: Boolean(settings.comment_turnstile_enabled),
|
||||
turnstileSiteKey:
|
||||
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined,
|
||||
},
|
||||
subscriptions: {
|
||||
popupEnabled:
|
||||
@@ -544,6 +615,14 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
|
||||
popupDelaySeconds:
|
||||
settings.subscription_popup_delay_seconds ??
|
||||
DEFAULT_SITE_SETTINGS.subscriptions.popupDelaySeconds,
|
||||
turnstileEnabled: Boolean(settings.subscription_turnstile_enabled),
|
||||
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,
|
||||
@@ -703,6 +782,46 @@ class ApiClient {
|
||||
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);
|
||||
@@ -782,6 +901,7 @@ class ApiClient {
|
||||
paragraphExcerpt: comment.paragraphExcerpt,
|
||||
replyTo: comment.replyTo,
|
||||
replyToCommentId: comment.replyToCommentId,
|
||||
turnstileToken: comment.turnstileToken,
|
||||
captchaToken: comment.captchaToken,
|
||||
captchaAnswer: comment.captchaAnswer,
|
||||
website: comment.website,
|
||||
@@ -955,12 +1075,87 @@ class ApiClient {
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user