feat: ship blog platform admin and deploy stack
This commit is contained in:
@@ -4,8 +4,11 @@ import type {
|
||||
AdminAiReindexResponse,
|
||||
AdminAiProviderTestResponse,
|
||||
AdminImageUploadResponse,
|
||||
AdminMediaBatchDeleteResponse,
|
||||
AdminMediaDeleteResponse,
|
||||
AdminMediaListResponse,
|
||||
AdminMediaReplaceResponse,
|
||||
AdminMediaUploadResponse,
|
||||
AdminPostCoverImageRequest,
|
||||
AdminPostCoverImageResponse,
|
||||
AdminDashboardResponse,
|
||||
@@ -16,7 +19,11 @@ import type {
|
||||
AdminR2ConnectivityResponse,
|
||||
AdminSessionResponse,
|
||||
AdminSiteSettingsResponse,
|
||||
AuditLogRecord,
|
||||
CommentListQuery,
|
||||
CommentBlacklistRecord,
|
||||
CommentPersonaAnalysisLogRecord,
|
||||
CommentPersonaAnalysisResponse,
|
||||
CommentRecord,
|
||||
CreatePostPayload,
|
||||
CreateReviewPayload,
|
||||
@@ -26,16 +33,52 @@ import type {
|
||||
MarkdownDeleteResponse,
|
||||
MarkdownDocumentResponse,
|
||||
MarkdownImportResponse,
|
||||
NotificationDeliveryRecord,
|
||||
PostListQuery,
|
||||
PostRevisionDetail,
|
||||
PostRevisionRecord,
|
||||
PostRecord,
|
||||
ReviewRecord,
|
||||
RestoreRevisionResponse,
|
||||
SiteSettingsPayload,
|
||||
SubscriptionDigestResponse,
|
||||
SubscriptionListResponse,
|
||||
SubscriptionPayload,
|
||||
SubscriptionRecord,
|
||||
SubscriptionUpdatePayload,
|
||||
UpdateCommentPayload,
|
||||
UpdatePostPayload,
|
||||
UpdateReviewPayload,
|
||||
} from '@/lib/types'
|
||||
import { getRuntimeAdminBaseUrl, normalizeAdminBaseUrl } from '@/lib/runtime-config'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE?.trim() || ''
|
||||
const envApiBase = normalizeAdminBaseUrl(import.meta.env.VITE_API_BASE)
|
||||
const DEV_API_BASE = 'http://localhost:5150'
|
||||
const PROD_DEFAULT_API_PORT = '5150'
|
||||
|
||||
function getApiBase() {
|
||||
const runtimeApiBase = getRuntimeAdminBaseUrl('apiBaseUrl')
|
||||
if (runtimeApiBase) {
|
||||
return runtimeApiBase
|
||||
}
|
||||
|
||||
if (envApiBase) {
|
||||
return envApiBase
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
return DEV_API_BASE
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return DEV_API_BASE
|
||||
}
|
||||
|
||||
const { protocol, hostname } = window.location
|
||||
return `${protocol}//${hostname}:${PROD_DEFAULT_API_PORT}`
|
||||
}
|
||||
|
||||
const API_BASE = getApiBase()
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
@@ -95,6 +138,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers,
|
||||
})
|
||||
|
||||
@@ -126,6 +170,74 @@ export const adminApi = {
|
||||
request<AdminSessionResponse>('/api/admin/session', {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
listAuditLogs: (query?: { action?: string; targetType?: string; limit?: number }) =>
|
||||
request<AuditLogRecord[]>(
|
||||
appendQueryParams('/api/admin/audit-logs', {
|
||||
action: query?.action,
|
||||
target_type: query?.targetType,
|
||||
limit: query?.limit,
|
||||
}),
|
||||
),
|
||||
listPostRevisions: (query?: { slug?: string; limit?: number }) =>
|
||||
request<PostRevisionRecord[]>(
|
||||
appendQueryParams('/api/admin/post-revisions', {
|
||||
slug: query?.slug,
|
||||
limit: query?.limit,
|
||||
}),
|
||||
),
|
||||
getPostRevision: (id: number) => request<PostRevisionDetail>(`/api/admin/post-revisions/${id}`),
|
||||
restorePostRevision: (id: number, mode: 'full' | 'markdown' | 'metadata' = 'full') =>
|
||||
request<RestoreRevisionResponse>(`/api/admin/post-revisions/${id}/restore`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode }),
|
||||
}),
|
||||
listSubscriptions: async () =>
|
||||
(await request<SubscriptionListResponse>('/api/admin/subscriptions')).subscriptions,
|
||||
createSubscription: (payload: SubscriptionPayload) =>
|
||||
request<SubscriptionRecord>('/api/admin/subscriptions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
channelType: payload.channelType,
|
||||
target: payload.target,
|
||||
displayName: payload.displayName,
|
||||
status: payload.status,
|
||||
filters: payload.filters,
|
||||
metadata: payload.metadata,
|
||||
secret: payload.secret,
|
||||
notes: payload.notes,
|
||||
}),
|
||||
}),
|
||||
updateSubscription: (id: number, payload: SubscriptionUpdatePayload) =>
|
||||
request<SubscriptionRecord>(`/api/admin/subscriptions/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
channelType: payload.channelType,
|
||||
target: payload.target,
|
||||
displayName: payload.displayName,
|
||||
status: payload.status,
|
||||
filters: payload.filters,
|
||||
metadata: payload.metadata,
|
||||
secret: payload.secret,
|
||||
notes: payload.notes,
|
||||
}),
|
||||
}),
|
||||
deleteSubscription: (id: number) =>
|
||||
request<void>(`/api/admin/subscriptions/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
testSubscription: (id: number) =>
|
||||
request<{ queued: boolean; id: number; delivery_id: number }>(`/api/admin/subscriptions/${id}/test`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
listSubscriptionDeliveries: async (limit = 80) =>
|
||||
(await request<{ deliveries: NotificationDeliveryRecord[] }>(
|
||||
appendQueryParams('/api/admin/subscriptions/deliveries', { limit }),
|
||||
)).deliveries,
|
||||
sendSubscriptionDigest: (period: 'weekly' | 'monthly') =>
|
||||
request<SubscriptionDigestResponse>('/api/admin/subscriptions/digest', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ period }),
|
||||
}),
|
||||
dashboard: () => request<AdminDashboardResponse>('/api/admin/dashboard'),
|
||||
analytics: () => request<AdminAnalyticsResponse>('/api/admin/analytics'),
|
||||
getSiteSettings: () => request<AdminSiteSettingsResponse>('/api/admin/site-settings'),
|
||||
@@ -192,6 +304,35 @@ export const adminApi = {
|
||||
method: 'DELETE',
|
||||
},
|
||||
),
|
||||
uploadMediaObjects: (files: File[], options?: { prefix?: string }) => {
|
||||
const formData = new FormData()
|
||||
if (options?.prefix) {
|
||||
formData.append('prefix', options.prefix)
|
||||
}
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file, file.name)
|
||||
})
|
||||
|
||||
return request<AdminMediaUploadResponse>('/api/admin/storage/media', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
},
|
||||
batchDeleteMediaObjects: (keys: string[]) =>
|
||||
request<AdminMediaBatchDeleteResponse>('/api/admin/storage/media/batch-delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ keys }),
|
||||
}),
|
||||
replaceMediaObject: (key: string, file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('key', key)
|
||||
formData.append('file', file, file.name)
|
||||
|
||||
return request<AdminMediaReplaceResponse>('/api/admin/storage/media/replace', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
},
|
||||
generatePostMetadata: (markdown: string) =>
|
||||
request<AdminPostMetadataResponse>('/api/admin/ai/post-metadata', {
|
||||
method: 'POST',
|
||||
@@ -237,9 +378,16 @@ export const adminApi = {
|
||||
search: query?.search,
|
||||
type: query?.postType,
|
||||
pinned: query?.pinned,
|
||||
status: query?.status,
|
||||
visibility: query?.visibility,
|
||||
listed_only: query?.listedOnly,
|
||||
include_private: query?.includePrivate ?? true,
|
||||
include_redirects: query?.includeRedirects ?? true,
|
||||
preview: query?.preview ?? true,
|
||||
}),
|
||||
),
|
||||
getPostBySlug: (slug: string) => request<PostRecord>(`/api/posts/slug/${encodeURIComponent(slug)}`),
|
||||
getPostBySlug: (slug: string) =>
|
||||
request<PostRecord>(`/api/posts/slug/${encodeURIComponent(slug)}?preview=true&include_private=true`),
|
||||
createPost: (payload: CreatePostPayload) =>
|
||||
request<MarkdownDocumentResponse>('/api/posts/markdown', {
|
||||
method: 'POST',
|
||||
@@ -254,6 +402,15 @@ export const adminApi = {
|
||||
image: payload.image,
|
||||
images: payload.images,
|
||||
pinned: payload.pinned,
|
||||
status: payload.status,
|
||||
visibility: payload.visibility,
|
||||
publish_at: payload.publishAt,
|
||||
unpublish_at: payload.unpublishAt,
|
||||
canonical_url: payload.canonicalUrl,
|
||||
noindex: payload.noindex,
|
||||
og_image: payload.ogImage,
|
||||
redirect_from: payload.redirectFrom,
|
||||
redirect_to: payload.redirectTo,
|
||||
published: payload.published,
|
||||
}),
|
||||
}),
|
||||
@@ -271,6 +428,15 @@ export const adminApi = {
|
||||
image: payload.image,
|
||||
images: payload.images,
|
||||
pinned: payload.pinned,
|
||||
status: payload.status,
|
||||
visibility: payload.visibility,
|
||||
publish_at: payload.publishAt,
|
||||
unpublish_at: payload.unpublishAt,
|
||||
canonical_url: payload.canonicalUrl,
|
||||
noindex: payload.noindex,
|
||||
og_image: payload.ogImage,
|
||||
redirect_from: payload.redirectFrom,
|
||||
redirect_to: payload.redirectTo,
|
||||
}),
|
||||
}),
|
||||
getPostMarkdown: (slug: string) =>
|
||||
@@ -315,6 +481,59 @@ export const adminApi = {
|
||||
request<void>(`/api/comments/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
listCommentBlacklist: () =>
|
||||
request<CommentBlacklistRecord[]>('/api/admin/comments/blacklist'),
|
||||
createCommentBlacklist: (payload: {
|
||||
matcher_type: 'ip' | 'email' | 'user_agent' | string
|
||||
matcher_value: string
|
||||
reason?: string | null
|
||||
active?: boolean
|
||||
expires_at?: string | null
|
||||
}) =>
|
||||
request<CommentBlacklistRecord>('/api/admin/comments/blacklist', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
updateCommentBlacklist: (
|
||||
id: number,
|
||||
payload: {
|
||||
reason?: string | null
|
||||
active?: boolean
|
||||
expires_at?: string | null
|
||||
clear_expires_at?: boolean
|
||||
},
|
||||
) =>
|
||||
request<CommentBlacklistRecord>(`/api/admin/comments/blacklist/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
deleteCommentBlacklist: (id: number) =>
|
||||
request<{ deleted: boolean; id: number }>(`/api/admin/comments/blacklist/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
analyzeCommentPersona: (payload: {
|
||||
matcher_type: 'ip' | 'email' | 'user_agent' | string
|
||||
matcher_value: string
|
||||
from?: string | null
|
||||
to?: string | null
|
||||
limit?: number
|
||||
}) =>
|
||||
request<CommentPersonaAnalysisResponse>('/api/admin/comments/analyze', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
listCommentPersonaAnalysisLogs: (query?: {
|
||||
matcher_type?: 'ip' | 'email' | 'user_agent' | string
|
||||
matcher_value?: string
|
||||
limit?: number
|
||||
}) =>
|
||||
request<CommentPersonaAnalysisLogRecord[]>(
|
||||
appendQueryParams('/api/admin/comments/analyze/logs', {
|
||||
matcher_type: query?.matcher_type,
|
||||
matcher_value: query?.matcher_value,
|
||||
limit: query?.limit,
|
||||
}),
|
||||
),
|
||||
listFriendLinks: (query?: FriendLinkListQuery) =>
|
||||
request<FriendLinkRecord[]>(
|
||||
appendQueryParams('/api/friend_links', {
|
||||
|
||||
Reference in New Issue
Block a user