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:
@@ -163,11 +163,20 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Cleanup docker cache
|
- name: Setup docker buildx
|
||||||
shell: bash
|
shell: bash
|
||||||
run: docker system prune -af --volumes || true
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
- name: Build image
|
if docker buildx inspect gitea-builder >/dev/null 2>&1; then
|
||||||
|
docker buildx use gitea-builder
|
||||||
|
else
|
||||||
|
docker buildx create --name gitea-builder --driver docker-container --use
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker buildx inspect --bootstrap
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
COMPONENT: ${{ matrix.component }}
|
COMPONENT: ${{ matrix.component }}
|
||||||
@@ -195,27 +204,19 @@ jobs:
|
|||||||
BUILD_ARGS+=(--build-arg "VITE_ADMIN_BASENAME=${ADMIN_VITE_BASENAME}")
|
BUILD_ARGS+=(--build-arg "VITE_ADMIN_BASENAME=${ADMIN_VITE_BASENAME}")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker build \
|
docker buildx build \
|
||||||
--file "${DOCKERFILE}" \
|
--file "${DOCKERFILE}" \
|
||||||
"${BUILD_ARGS[@]}" \
|
"${BUILD_ARGS[@]}" \
|
||||||
|
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||||
|
--cache-from "type=registry,ref=${IMAGE_BASE}:${TAG_BRANCH}" \
|
||||||
|
--cache-from "type=registry,ref=${IMAGE_BASE}:${TAG_LATEST}" \
|
||||||
|
--cache-to "type=inline" \
|
||||||
--tag "${IMAGE_BASE}:${TAG_LATEST}" \
|
--tag "${IMAGE_BASE}:${TAG_LATEST}" \
|
||||||
--tag "${IMAGE_BASE}:${TAG_BRANCH}" \
|
--tag "${IMAGE_BASE}:${TAG_BRANCH}" \
|
||||||
--tag "${IMAGE_BASE}:${TAG_SHA}" \
|
--tag "${IMAGE_BASE}:${TAG_SHA}" \
|
||||||
|
--push \
|
||||||
"${CONTEXT_DIR}"
|
"${CONTEXT_DIR}"
|
||||||
|
|
||||||
- name: Push image
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
IMAGE_BASE: ${{ steps.meta.outputs.image_base }}
|
|
||||||
TAG_LATEST: ${{ steps.meta.outputs.tag_latest }}
|
|
||||||
TAG_BRANCH: ${{ steps.meta.outputs.tag_branch }}
|
|
||||||
TAG_SHA: ${{ steps.meta.outputs.tag_sha }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
docker push "${IMAGE_BASE}:${TAG_LATEST}"
|
|
||||||
docker push "${IMAGE_BASE}:${TAG_BRANCH}"
|
|
||||||
docker push "${IMAGE_BASE}:${TAG_SHA}"
|
|
||||||
|
|
||||||
- name: Output image tags
|
- name: Output image tags
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -24,5 +24,13 @@ backend-start.log
|
|||||||
deploy/docker/.env
|
deploy/docker/.env
|
||||||
deploy/docker/config.yaml
|
deploy/docker/config.yaml
|
||||||
admin/tmp-playwright.*
|
admin/tmp-playwright.*
|
||||||
|
admin/.vite/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
blob-report/
|
||||||
|
*-playwright.err.log
|
||||||
|
*-playwright.out.log
|
||||||
|
backend-restart.err.log
|
||||||
|
backend-restart.out.log
|
||||||
lighthouse-*/
|
lighthouse-*/
|
||||||
lighthouse-*.json
|
lighthouse-*.json
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ const PostsPage = lazy(async () => {
|
|||||||
const mod = await import('@/pages/posts-page')
|
const mod = await import('@/pages/posts-page')
|
||||||
return { default: mod.PostsPage }
|
return { default: mod.PostsPage }
|
||||||
})
|
})
|
||||||
|
const CategoriesPage = lazy(async () => {
|
||||||
|
const mod = await import('@/pages/categories-page')
|
||||||
|
return { default: mod.CategoriesPage }
|
||||||
|
})
|
||||||
|
const TagsPage = lazy(async () => {
|
||||||
|
const mod = await import('@/pages/tags-page')
|
||||||
|
return { default: mod.TagsPage }
|
||||||
|
})
|
||||||
|
const BackupsPage = lazy(async () => {
|
||||||
|
const mod = await import('@/pages/backups-page')
|
||||||
|
return { default: mod.BackupsPage }
|
||||||
|
})
|
||||||
const RevisionsPage = lazy(async () => {
|
const RevisionsPage = lazy(async () => {
|
||||||
const mod = await import('@/pages/revisions-page')
|
const mod = await import('@/pages/revisions-page')
|
||||||
return { default: mod.RevisionsPage }
|
return { default: mod.RevisionsPage }
|
||||||
@@ -251,6 +263,30 @@ function AppRoutes() {
|
|||||||
</LazyRoute>
|
</LazyRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="categories"
|
||||||
|
element={
|
||||||
|
<LazyRoute>
|
||||||
|
<CategoriesPage />
|
||||||
|
</LazyRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="tags"
|
||||||
|
element={
|
||||||
|
<LazyRoute>
|
||||||
|
<TagsPage />
|
||||||
|
</LazyRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="backups"
|
||||||
|
element={
|
||||||
|
<LazyRoute>
|
||||||
|
<BackupsPage />
|
||||||
|
</LazyRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="revisions"
|
path="revisions"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import {
|
|||||||
BarChart3,
|
BarChart3,
|
||||||
BellRing,
|
BellRing,
|
||||||
BookOpenText,
|
BookOpenText,
|
||||||
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Folders,
|
||||||
History,
|
History,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -13,6 +15,7 @@ import {
|
|||||||
ScrollText,
|
ScrollText,
|
||||||
Settings,
|
Settings,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Tags,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
@@ -42,6 +45,24 @@ const primaryNav = [
|
|||||||
description: 'Markdown 内容管理',
|
description: 'Markdown 内容管理',
|
||||||
icon: ScrollText,
|
icon: ScrollText,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/categories',
|
||||||
|
label: '分类',
|
||||||
|
description: '分类目录与聚合统计',
|
||||||
|
icon: Folders,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/tags',
|
||||||
|
label: '标签',
|
||||||
|
description: '标签库与引用整理',
|
||||||
|
icon: Tags,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/backups',
|
||||||
|
label: '备份',
|
||||||
|
description: '全站导出与恢复',
|
||||||
|
icon: Download,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: '/revisions',
|
to: '/revisions',
|
||||||
label: '版本',
|
label: '版本',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
AdminMediaBatchDeleteResponse,
|
AdminMediaBatchDeleteResponse,
|
||||||
AdminMediaDeleteResponse,
|
AdminMediaDeleteResponse,
|
||||||
AdminMediaListResponse,
|
AdminMediaListResponse,
|
||||||
|
AdminMediaMetadataResponse,
|
||||||
AdminMediaReplaceResponse,
|
AdminMediaReplaceResponse,
|
||||||
AdminMediaUploadResponse,
|
AdminMediaUploadResponse,
|
||||||
AdminPostCoverImageRequest,
|
AdminPostCoverImageRequest,
|
||||||
@@ -20,6 +21,7 @@ import type {
|
|||||||
AdminSessionResponse,
|
AdminSessionResponse,
|
||||||
AdminSiteSettingsResponse,
|
AdminSiteSettingsResponse,
|
||||||
AuditLogRecord,
|
AuditLogRecord,
|
||||||
|
CategoryRecord,
|
||||||
CommentListQuery,
|
CommentListQuery,
|
||||||
CommentBlacklistRecord,
|
CommentBlacklistRecord,
|
||||||
CommentPersonaAnalysisLogRecord,
|
CommentPersonaAnalysisLogRecord,
|
||||||
@@ -33,7 +35,9 @@ import type {
|
|||||||
MarkdownDeleteResponse,
|
MarkdownDeleteResponse,
|
||||||
MarkdownDocumentResponse,
|
MarkdownDocumentResponse,
|
||||||
MarkdownImportResponse,
|
MarkdownImportResponse,
|
||||||
|
MediaAssetMetadataPayload,
|
||||||
NotificationDeliveryRecord,
|
NotificationDeliveryRecord,
|
||||||
|
PostPageResponse,
|
||||||
PostListQuery,
|
PostListQuery,
|
||||||
PostRevisionDetail,
|
PostRevisionDetail,
|
||||||
PostRevisionRecord,
|
PostRevisionRecord,
|
||||||
@@ -41,11 +45,16 @@ import type {
|
|||||||
ReviewRecord,
|
ReviewRecord,
|
||||||
RestoreRevisionResponse,
|
RestoreRevisionResponse,
|
||||||
SiteSettingsPayload,
|
SiteSettingsPayload,
|
||||||
|
SiteBackupDocument,
|
||||||
|
SiteBackupImportPayload,
|
||||||
|
SiteBackupImportResponse,
|
||||||
SubscriptionDigestResponse,
|
SubscriptionDigestResponse,
|
||||||
SubscriptionListResponse,
|
SubscriptionListResponse,
|
||||||
SubscriptionPayload,
|
SubscriptionPayload,
|
||||||
SubscriptionRecord,
|
SubscriptionRecord,
|
||||||
SubscriptionUpdatePayload,
|
SubscriptionUpdatePayload,
|
||||||
|
TagRecord,
|
||||||
|
TaxonomyPayload,
|
||||||
UpdateCommentPayload,
|
UpdateCommentPayload,
|
||||||
UpdatePostPayload,
|
UpdatePostPayload,
|
||||||
UpdateReviewPayload,
|
UpdateReviewPayload,
|
||||||
@@ -241,6 +250,68 @@ export const adminApi = {
|
|||||||
}),
|
}),
|
||||||
dashboard: () => request<AdminDashboardResponse>('/api/admin/dashboard'),
|
dashboard: () => request<AdminDashboardResponse>('/api/admin/dashboard'),
|
||||||
analytics: () => request<AdminAnalyticsResponse>('/api/admin/analytics'),
|
analytics: () => request<AdminAnalyticsResponse>('/api/admin/analytics'),
|
||||||
|
listCategories: () => request<CategoryRecord[]>('/api/admin/categories'),
|
||||||
|
createCategory: (payload: TaxonomyPayload) =>
|
||||||
|
request<CategoryRecord>('/api/admin/categories', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: payload.name,
|
||||||
|
slug: payload.slug,
|
||||||
|
description: payload.description,
|
||||||
|
cover_image: payload.coverImage,
|
||||||
|
accent_color: payload.accentColor,
|
||||||
|
seo_title: payload.seoTitle,
|
||||||
|
seo_description: payload.seoDescription,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
updateCategory: (id: number, payload: TaxonomyPayload) =>
|
||||||
|
request<CategoryRecord>(`/api/admin/categories/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: payload.name,
|
||||||
|
slug: payload.slug,
|
||||||
|
description: payload.description,
|
||||||
|
cover_image: payload.coverImage,
|
||||||
|
accent_color: payload.accentColor,
|
||||||
|
seo_title: payload.seoTitle,
|
||||||
|
seo_description: payload.seoDescription,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
deleteCategory: (id: number) =>
|
||||||
|
request<void>(`/api/admin/categories/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
listTags: () => request<TagRecord[]>('/api/admin/tags'),
|
||||||
|
createTag: (payload: TaxonomyPayload) =>
|
||||||
|
request<TagRecord>('/api/admin/tags', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: payload.name,
|
||||||
|
slug: payload.slug,
|
||||||
|
description: payload.description,
|
||||||
|
cover_image: payload.coverImage,
|
||||||
|
accent_color: payload.accentColor,
|
||||||
|
seo_title: payload.seoTitle,
|
||||||
|
seo_description: payload.seoDescription,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
updateTag: (id: number, payload: TaxonomyPayload) =>
|
||||||
|
request<TagRecord>(`/api/admin/tags/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: payload.name,
|
||||||
|
slug: payload.slug,
|
||||||
|
description: payload.description,
|
||||||
|
cover_image: payload.coverImage,
|
||||||
|
accent_color: payload.accentColor,
|
||||||
|
seo_title: payload.seoTitle,
|
||||||
|
seo_description: payload.seoDescription,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
deleteTag: (id: number) =>
|
||||||
|
request<void>(`/api/admin/tags/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
getSiteSettings: () => request<AdminSiteSettingsResponse>('/api/admin/site-settings'),
|
getSiteSettings: () => request<AdminSiteSettingsResponse>('/api/admin/site-settings'),
|
||||||
updateSiteSettings: (payload: SiteSettingsPayload) =>
|
updateSiteSettings: (payload: SiteSettingsPayload) =>
|
||||||
request<AdminSiteSettingsResponse>('/api/admin/site-settings', {
|
request<AdminSiteSettingsResponse>('/api/admin/site-settings', {
|
||||||
@@ -334,6 +405,24 @@ export const adminApi = {
|
|||||||
body: formData,
|
body: formData,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
updateMediaObjectMetadata: (payload: MediaAssetMetadataPayload) =>
|
||||||
|
request<AdminMediaMetadataResponse>('/api/admin/storage/media/metadata', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({
|
||||||
|
key: payload.key,
|
||||||
|
title: payload.title,
|
||||||
|
alt_text: payload.altText,
|
||||||
|
caption: payload.caption,
|
||||||
|
tags: payload.tags,
|
||||||
|
notes: payload.notes,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
exportSiteBackup: () => request<SiteBackupDocument>('/api/admin/site-backup/export'),
|
||||||
|
importSiteBackup: (payload: SiteBackupImportPayload) =>
|
||||||
|
request<SiteBackupImportResponse>('/api/admin/site-backup/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
generatePostMetadata: (markdown: string) =>
|
generatePostMetadata: (markdown: string) =>
|
||||||
request<AdminPostMetadataResponse>('/api/admin/ai/post-metadata', {
|
request<AdminPostMetadataResponse>('/api/admin/ai/post-metadata', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -387,6 +476,27 @@ export const adminApi = {
|
|||||||
preview: query?.preview ?? true,
|
preview: query?.preview ?? true,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
listPostsPage: (query?: PostListQuery) =>
|
||||||
|
request<PostPageResponse>(
|
||||||
|
appendQueryParams('/api/posts/page', {
|
||||||
|
slug: query?.slug,
|
||||||
|
category: query?.category,
|
||||||
|
tag: query?.tag,
|
||||||
|
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,
|
||||||
|
page: query?.page,
|
||||||
|
page_size: query?.pageSize,
|
||||||
|
sort_by: query?.sortBy,
|
||||||
|
sort_order: query?.sortOrder,
|
||||||
|
}),
|
||||||
|
),
|
||||||
getPostBySlug: (slug: string) =>
|
getPostBySlug: (slug: string) =>
|
||||||
request<PostRecord>(`/api/posts/slug/${encodeURIComponent(slug)}?preview=true&include_private=true`),
|
request<PostRecord>(`/api/posts/slug/${encodeURIComponent(slug)}?preview=true&include_private=true`),
|
||||||
createPost: (payload: CreatePostPayload) =>
|
createPost: (payload: CreatePostPayload) =>
|
||||||
|
|||||||
@@ -301,6 +301,14 @@ export interface AdminSiteSettingsResponse {
|
|||||||
music_playlist: MusicTrack[]
|
music_playlist: MusicTrack[]
|
||||||
ai_enabled: boolean
|
ai_enabled: boolean
|
||||||
paragraph_comments_enabled: boolean
|
paragraph_comments_enabled: boolean
|
||||||
|
comment_turnstile_enabled: boolean
|
||||||
|
subscription_turnstile_enabled: boolean
|
||||||
|
web_push_enabled: boolean
|
||||||
|
turnstile_site_key: string | null
|
||||||
|
turnstile_secret_key: string | null
|
||||||
|
web_push_vapid_public_key: string | null
|
||||||
|
web_push_vapid_private_key: string | null
|
||||||
|
web_push_vapid_subject: string | null
|
||||||
ai_provider: string | null
|
ai_provider: string | null
|
||||||
ai_api_base: string | null
|
ai_api_base: string | null
|
||||||
ai_api_key: string | null
|
ai_api_key: string | null
|
||||||
@@ -327,6 +335,7 @@ export interface AdminSiteSettingsResponse {
|
|||||||
seo_default_og_image: string | null
|
seo_default_og_image: string | null
|
||||||
seo_default_twitter_handle: string | null
|
seo_default_twitter_handle: string | null
|
||||||
notification_webhook_url: string | null
|
notification_webhook_url: string | null
|
||||||
|
notification_channel_type: 'webhook' | 'ntfy' | string
|
||||||
notification_comment_enabled: boolean
|
notification_comment_enabled: boolean
|
||||||
notification_friend_link_enabled: boolean
|
notification_friend_link_enabled: boolean
|
||||||
subscription_popup_enabled: boolean
|
subscription_popup_enabled: boolean
|
||||||
@@ -366,6 +375,14 @@ export interface SiteSettingsPayload {
|
|||||||
musicPlaylist?: MusicTrack[]
|
musicPlaylist?: MusicTrack[]
|
||||||
aiEnabled?: boolean
|
aiEnabled?: boolean
|
||||||
paragraphCommentsEnabled?: boolean
|
paragraphCommentsEnabled?: boolean
|
||||||
|
commentTurnstileEnabled?: boolean
|
||||||
|
subscriptionTurnstileEnabled?: boolean
|
||||||
|
webPushEnabled?: boolean
|
||||||
|
turnstileSiteKey?: string | null
|
||||||
|
turnstileSecretKey?: string | null
|
||||||
|
webPushVapidPublicKey?: string | null
|
||||||
|
webPushVapidPrivateKey?: string | null
|
||||||
|
webPushVapidSubject?: string | null
|
||||||
aiProvider?: string | null
|
aiProvider?: string | null
|
||||||
aiApiBase?: string | null
|
aiApiBase?: string | null
|
||||||
aiApiKey?: string | null
|
aiApiKey?: string | null
|
||||||
@@ -389,6 +406,7 @@ export interface SiteSettingsPayload {
|
|||||||
seoDefaultOgImage?: string | null
|
seoDefaultOgImage?: string | null
|
||||||
seoDefaultTwitterHandle?: string | null
|
seoDefaultTwitterHandle?: string | null
|
||||||
notificationWebhookUrl?: string | null
|
notificationWebhookUrl?: string | null
|
||||||
|
notificationChannelType?: 'webhook' | 'ntfy' | string | null
|
||||||
notificationCommentEnabled?: boolean
|
notificationCommentEnabled?: boolean
|
||||||
notificationFriendLinkEnabled?: boolean
|
notificationFriendLinkEnabled?: boolean
|
||||||
subscriptionPopupEnabled?: boolean
|
subscriptionPopupEnabled?: boolean
|
||||||
@@ -398,6 +416,44 @@ export interface SiteSettingsPayload {
|
|||||||
searchSynonyms?: string[]
|
searchSynonyms?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CategoryRecord {
|
||||||
|
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
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagRecord {
|
||||||
|
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
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxonomyPayload {
|
||||||
|
name: string
|
||||||
|
slug?: string | null
|
||||||
|
description?: string | null
|
||||||
|
coverImage?: string | null
|
||||||
|
accentColor?: string | null
|
||||||
|
seoTitle?: string | null
|
||||||
|
seoDescription?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminAiReindexResponse {
|
export interface AdminAiReindexResponse {
|
||||||
indexed_chunks: number
|
indexed_chunks: number
|
||||||
last_indexed_at: string | null
|
last_indexed_at: string | null
|
||||||
@@ -432,6 +488,11 @@ export interface AdminMediaObjectResponse {
|
|||||||
url: string
|
url: string
|
||||||
size_bytes: number
|
size_bytes: number
|
||||||
last_modified: string | null
|
last_modified: string | null
|
||||||
|
title: string | null
|
||||||
|
alt_text: string | null
|
||||||
|
caption: string | null
|
||||||
|
tags: string[]
|
||||||
|
notes: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminMediaListResponse {
|
export interface AdminMediaListResponse {
|
||||||
@@ -466,6 +527,64 @@ export interface AdminMediaReplaceResponse {
|
|||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MediaAssetMetadataPayload {
|
||||||
|
key: string
|
||||||
|
title?: string | null
|
||||||
|
altText?: string | null
|
||||||
|
caption?: string | null
|
||||||
|
tags?: string[]
|
||||||
|
notes?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminMediaMetadataResponse {
|
||||||
|
saved: boolean
|
||||||
|
key: string
|
||||||
|
title: string | null
|
||||||
|
alt_text: string | null
|
||||||
|
caption: string | null
|
||||||
|
tags: string[]
|
||||||
|
notes: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteBackupDocument {
|
||||||
|
version: string
|
||||||
|
exported_at: string
|
||||||
|
includes_storage_binaries: boolean
|
||||||
|
warning: string
|
||||||
|
site_settings: Record<string, unknown>
|
||||||
|
categories: Record<string, unknown>[]
|
||||||
|
tags: Record<string, unknown>[]
|
||||||
|
reviews: Record<string, unknown>[]
|
||||||
|
friend_links: Record<string, unknown>[]
|
||||||
|
media_assets: Record<string, unknown>[]
|
||||||
|
storage_manifest?: Record<string, unknown>[] | null
|
||||||
|
posts: Array<{
|
||||||
|
slug: string
|
||||||
|
file_name: string
|
||||||
|
markdown: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteBackupImportPayload {
|
||||||
|
backup: SiteBackupDocument
|
||||||
|
mode?: 'merge' | 'replace' | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteBackupImportResponse {
|
||||||
|
imported: boolean
|
||||||
|
mode: string
|
||||||
|
site_settings_restored: boolean
|
||||||
|
posts_written: number
|
||||||
|
categories_upserted: number
|
||||||
|
tags_upserted: number
|
||||||
|
reviews_upserted: number
|
||||||
|
friend_links_upserted: number
|
||||||
|
media_assets_upserted: number
|
||||||
|
storage_manifest_items: number
|
||||||
|
includes_storage_binaries: boolean
|
||||||
|
warning: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface CommentBlacklistRecord {
|
export interface CommentBlacklistRecord {
|
||||||
id: number
|
id: number
|
||||||
matcher_type: 'ip' | 'email' | 'user_agent' | string
|
matcher_type: 'ip' | 'email' | 'user_agent' | string
|
||||||
@@ -603,6 +722,20 @@ export interface PostListQuery {
|
|||||||
includePrivate?: boolean
|
includePrivate?: boolean
|
||||||
includeRedirects?: boolean
|
includeRedirects?: boolean
|
||||||
preview?: boolean
|
preview?: boolean
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
sortBy?: 'created_at' | 'updated_at' | 'title' | string
|
||||||
|
sortOrder?: 'asc' | 'desc' | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostPageResponse {
|
||||||
|
items: PostRecord[]
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
total: number
|
||||||
|
total_pages: number
|
||||||
|
sort_by: string
|
||||||
|
sort_order: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatePostPayload {
|
export interface CreatePostPayload {
|
||||||
|
|||||||
248
admin/src/pages/backups-page.tsx
Normal file
248
admin/src/pages/backups-page.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { Download, RefreshCcw, Upload } from 'lucide-react'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Select } from '@/components/ui/select'
|
||||||
|
import { adminApi, ApiError } from '@/lib/api'
|
||||||
|
import type { SiteBackupDocument, SiteBackupImportResponse } from '@/lib/types'
|
||||||
|
|
||||||
|
function downloadJson(filename: string, payload: unknown) {
|
||||||
|
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackupsPage() {
|
||||||
|
const [exporting, setExporting] = useState(false)
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
const [importMode, setImportMode] = useState<'merge' | 'replace'>('merge')
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||||
|
const [selectedBackup, setSelectedBackup] = useState<SiteBackupDocument | null>(null)
|
||||||
|
const [lastImportResult, setLastImportResult] = useState<SiteBackupImportResponse | null>(null)
|
||||||
|
|
||||||
|
const backupStats = useMemo(() => {
|
||||||
|
if (!selectedBackup) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
posts: selectedBackup.posts.length,
|
||||||
|
categories: selectedBackup.categories.length,
|
||||||
|
tags: selectedBackup.tags.length,
|
||||||
|
reviews: selectedBackup.reviews.length,
|
||||||
|
friendLinks: selectedBackup.friend_links.length,
|
||||||
|
mediaAssets: selectedBackup.media_assets.length,
|
||||||
|
storageManifest: selectedBackup.storage_manifest?.length ?? 0,
|
||||||
|
}
|
||||||
|
}, [selectedBackup])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Badge variant="secondary">备份 / 恢复</Badge>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-semibold tracking-tight">全站内容备份</h2>
|
||||||
|
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||||
|
导出站点内容、配置、分类标签元数据和媒体元数据。当前不包含对象存储二进制文件,只会附带对象清单。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>导出备份</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
一键导出当前内容与配置,建议定期下载到本地或同步到私有仓库 / 对象存储归档目录。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="rounded-3xl border border-border/70 bg-background/50 p-4 text-sm leading-7 text-muted-foreground">
|
||||||
|
<p>导出内容包含:</p>
|
||||||
|
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||||
|
<li>站点设置与运行时开关</li>
|
||||||
|
<li>Markdown 文章源文件</li>
|
||||||
|
<li>分类 / 标签扩展元数据</li>
|
||||||
|
<li>评测、友链、媒体元数据</li>
|
||||||
|
<li>对象存储文件清单(不含二进制)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={exporting}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
setExporting(true)
|
||||||
|
const backup = await adminApi.exportSiteBackup()
|
||||||
|
const exportedAt = backup.exported_at.replaceAll(':', '-').replaceAll('.', '-')
|
||||||
|
downloadJson(`termi-backup-${exportedAt}.json`, backup)
|
||||||
|
toast.success('备份已导出到本地。')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof ApiError ? error.message : '导出备份失败。')
|
||||||
|
} finally {
|
||||||
|
setExporting(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
{exporting ? '导出中...' : '下载备份 JSON'}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>导入恢复</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
支持 merge / replace 两种模式;replace 会覆盖当前 markdown 内容与对应元数据,请先确认当前环境是否允许回滚。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<div className="grid gap-4 md:grid-cols-[220px_minmax(0,1fr)]">
|
||||||
|
<Select
|
||||||
|
value={importMode}
|
||||||
|
onChange={(event) => setImportMode(event.target.value as 'merge' | 'replace')}
|
||||||
|
>
|
||||||
|
<option value="merge">merge(推荐)</option>
|
||||||
|
<option value="replace">replace(覆盖当前内容)</option>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept="application/json"
|
||||||
|
onChange={async (event) => {
|
||||||
|
const file = event.target.files?.item(0) ?? null
|
||||||
|
setSelectedFile(file)
|
||||||
|
setLastImportResult(null)
|
||||||
|
if (!file) {
|
||||||
|
setSelectedBackup(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(await file.text()) as SiteBackupDocument
|
||||||
|
setSelectedBackup(parsed)
|
||||||
|
} catch {
|
||||||
|
setSelectedBackup(null)
|
||||||
|
toast.error('备份文件不是合法的 JSON。')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-3xl border border-dashed border-border/70 bg-background/40 p-4 text-sm text-muted-foreground">
|
||||||
|
<p className="font-medium text-foreground">导入风险提示</p>
|
||||||
|
<ul className="mt-2 list-disc space-y-1 pl-5 leading-6">
|
||||||
|
<li>replace 会覆盖当前 markdown 源文件,并重建分类 / 标签 / 媒体元数据。</li>
|
||||||
|
<li>备份不会恢复对象存储二进制文件,请确保原桶仍可访问,或另行回传图片。</li>
|
||||||
|
<li>建议先导出当前环境,再执行恢复操作。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedBackup ? (
|
||||||
|
<div className="rounded-3xl border border-border/70 bg-background/50 p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">版本 {selectedBackup.version}</Badge>
|
||||||
|
<Badge variant="outline">导出时间 {selectedBackup.exported_at}</Badge>
|
||||||
|
<Badge variant="secondary">{selectedBackup.includes_storage_binaries ? '包含二进制' : '仅对象清单'}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-3 text-sm text-muted-foreground">
|
||||||
|
<div>文章:{backupStats?.posts ?? 0}</div>
|
||||||
|
<div>分类:{backupStats?.categories ?? 0}</div>
|
||||||
|
<div>标签:{backupStats?.tags ?? 0}</div>
|
||||||
|
<div>评测:{backupStats?.reviews ?? 0}</div>
|
||||||
|
<div>友链:{backupStats?.friendLinks ?? 0}</div>
|
||||||
|
<div>媒体元数据:{backupStats?.mediaAssets ?? 0}</div>
|
||||||
|
<div>对象清单:{backupStats?.storageManifest ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm leading-6 text-muted-foreground">{selectedBackup.warning}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-3xl border border-dashed border-border/70 bg-background/40 px-5 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
{selectedFile ? '当前文件未通过 JSON 校验。' : '选择一个备份 JSON 后,这里会显示导入概览。'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button
|
||||||
|
disabled={!selectedBackup || importing}
|
||||||
|
variant={importMode === 'replace' ? 'danger' : 'default'}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!selectedBackup) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
importMode === 'replace' &&
|
||||||
|
!window.confirm('replace 会覆盖当前内容,确认继续吗?')
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setImporting(true)
|
||||||
|
const result = await adminApi.importSiteBackup({
|
||||||
|
backup: selectedBackup,
|
||||||
|
mode: importMode,
|
||||||
|
})
|
||||||
|
setLastImportResult(result)
|
||||||
|
toast.success('备份已导入。')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof ApiError ? error.message : '导入备份失败。')
|
||||||
|
} finally {
|
||||||
|
setImporting(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
{importing ? '导入中...' : importMode === 'replace' ? '执行覆盖恢复' : '执行合并恢复'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedFile(null)
|
||||||
|
setSelectedBackup(null)
|
||||||
|
setLastImportResult(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCcw className="h-4 w-4" />
|
||||||
|
清空选择
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lastImportResult ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>最近一次恢复结果</CardTitle>
|
||||||
|
<CardDescription>模式:{lastImportResult.mode}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4 text-sm text-muted-foreground">
|
||||||
|
<div>站点设置:{lastImportResult.site_settings_restored ? '已恢复' : '未恢复'}</div>
|
||||||
|
<div>文章写入:{lastImportResult.posts_written}</div>
|
||||||
|
<div>分类更新:{lastImportResult.categories_upserted}</div>
|
||||||
|
<div>标签更新:{lastImportResult.tags_upserted}</div>
|
||||||
|
<div>评测更新:{lastImportResult.reviews_upserted}</div>
|
||||||
|
<div>友链更新:{lastImportResult.friend_links_upserted}</div>
|
||||||
|
<div>媒体元数据:{lastImportResult.media_assets_upserted}</div>
|
||||||
|
<div>对象清单:{lastImportResult.storage_manifest_items}</div>
|
||||||
|
<div className="sm:col-span-2 xl:col-span-4">{lastImportResult.warning}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
402
admin/src/pages/categories-page.tsx
Normal file
402
admin/src/pages/categories-page.tsx
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
import { Folders, Plus, RefreshCcw, Save, Trash2 } from 'lucide-react'
|
||||||
|
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { FormField } from '@/components/form-field'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { adminApi, ApiError } from '@/lib/api'
|
||||||
|
import { emptyToNull, formatDateTime } from '@/lib/admin-format'
|
||||||
|
import type { CategoryRecord, TaxonomyPayload } from '@/lib/types'
|
||||||
|
|
||||||
|
type CategoryFormState = {
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
description: string
|
||||||
|
coverImage: string
|
||||||
|
accentColor: string
|
||||||
|
seoTitle: string
|
||||||
|
seoDescription: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultCategoryForm: CategoryFormState = {
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
description: '',
|
||||||
|
coverImage: '',
|
||||||
|
accentColor: '',
|
||||||
|
seoTitle: '',
|
||||||
|
seoDescription: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFormState(item: CategoryRecord): CategoryFormState {
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
slug: item.slug,
|
||||||
|
description: item.description ?? '',
|
||||||
|
coverImage: item.cover_image ?? '',
|
||||||
|
accentColor: item.accent_color ?? '',
|
||||||
|
seoTitle: item.seo_title ?? '',
|
||||||
|
seoDescription: item.seo_description ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPayload(form: CategoryFormState): TaxonomyPayload {
|
||||||
|
return {
|
||||||
|
name: form.name.trim(),
|
||||||
|
slug: emptyToNull(form.slug),
|
||||||
|
description: emptyToNull(form.description),
|
||||||
|
coverImage: emptyToNull(form.coverImage),
|
||||||
|
accentColor: emptyToNull(form.accentColor),
|
||||||
|
seoTitle: emptyToNull(form.seoTitle),
|
||||||
|
seoDescription: emptyToNull(form.seoDescription),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoriesPage() {
|
||||||
|
const [items, setItems] = useState<CategoryRecord[]>([])
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||||
|
const [form, setForm] = useState<CategoryFormState>(defaultCategoryForm)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
|
||||||
|
const loadCategories = useCallback(async (showToast = false) => {
|
||||||
|
try {
|
||||||
|
if (showToast) {
|
||||||
|
setRefreshing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = await adminApi.listCategories()
|
||||||
|
startTransition(() => {
|
||||||
|
setItems(next)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (showToast) {
|
||||||
|
toast.success('分类列表已刷新。')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError && error.status === 401) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.error(error instanceof ApiError ? error.message : '无法加载分类列表。')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadCategories(false)
|
||||||
|
}, [loadCategories])
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
const keyword = searchTerm.trim().toLowerCase()
|
||||||
|
if (!keyword) {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.filter((item) =>
|
||||||
|
[item.name, item.slug, item.description ?? '', item.seo_title ?? '']
|
||||||
|
.join('\n')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(keyword),
|
||||||
|
)
|
||||||
|
}, [items, searchTerm])
|
||||||
|
|
||||||
|
const selectedItem = useMemo(
|
||||||
|
() => items.find((item) => item.id === selectedId) ?? null,
|
||||||
|
[items, selectedId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const resetForm = useCallback(() => {
|
||||||
|
setSelectedId(null)
|
||||||
|
setForm(defaultCategoryForm)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
toast.error('请先填写分类名称。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true)
|
||||||
|
if (selectedId) {
|
||||||
|
const updated = await adminApi.updateCategory(selectedId, toPayload(form))
|
||||||
|
startTransition(() => {
|
||||||
|
setItems((current) => current.map((item) => (item.id === updated.id ? updated : item)))
|
||||||
|
setSelectedId(updated.id)
|
||||||
|
setForm(toFormState(updated))
|
||||||
|
})
|
||||||
|
toast.success('分类已更新。')
|
||||||
|
} else {
|
||||||
|
const created = await adminApi.createCategory(toPayload(form))
|
||||||
|
startTransition(() => {
|
||||||
|
setItems((current) => [created, ...current])
|
||||||
|
setSelectedId(created.id)
|
||||||
|
setForm(toFormState(created))
|
||||||
|
})
|
||||||
|
toast.success('分类已创建。')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof ApiError ? error.message : '保存分类失败。')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [form, selectedId])
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!selectedItem) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.confirm(`确认删除分类「${selectedItem.name}」吗?相关文章会同步移除该分类引用。`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setDeleting(true)
|
||||||
|
await adminApi.deleteCategory(selectedItem.id)
|
||||||
|
startTransition(() => {
|
||||||
|
setItems((current) => current.filter((item) => item.id !== selectedItem.id))
|
||||||
|
})
|
||||||
|
toast.success('分类已删除。')
|
||||||
|
resetForm()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof ApiError ? error.message : '删除分类失败。')
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}, [resetForm, selectedItem])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-40 rounded-3xl" />
|
||||||
|
<Skeleton className="h-[720px] rounded-3xl" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Badge variant="secondary">分类管理</Badge>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-semibold tracking-tight">分类目录</h2>
|
||||||
|
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||||
|
现在可以给分类补充描述、封面、强调色和 SEO 字段,前台分类页会直接消费这些元数据。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button variant="outline" onClick={resetForm}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
新建分类
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => void loadCategories(true)} disabled={refreshing}>
|
||||||
|
<RefreshCcw className="h-4 w-4" />
|
||||||
|
{refreshing ? '刷新中...' : '刷新'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>分类列表</CardTitle>
|
||||||
|
<CardDescription>左侧列表支持按分类名、slug、简介和 SEO 标题检索。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Input
|
||||||
|
placeholder="按分类名 / slug / 描述搜索"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(event) => setSearchTerm(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{filteredItems.length ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(item.id)
|
||||||
|
setForm(toFormState(item))
|
||||||
|
}}
|
||||||
|
className={`w-full rounded-3xl border px-4 py-4 text-left transition ${
|
||||||
|
selectedId === item.id
|
||||||
|
? 'border-primary/30 bg-primary/10 shadow-[0_12px_30px_rgba(37,99,235,0.12)]'
|
||||||
|
: 'border-border/70 bg-background/60 hover:border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">{item.name}</span>
|
||||||
|
<Badge variant="outline">{item.slug}</Badge>
|
||||||
|
{item.accent_color ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex h-5 w-5 rounded-full border border-border/80"
|
||||||
|
style={{ backgroundColor: item.accent_color }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{item.description || `${item.count} 篇文章正在使用这个分类`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={item.count > 0 ? 'success' : 'secondary'}>{item.count}</Badge>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-3xl border border-dashed border-border/70 bg-background/40 px-5 py-10 text-center text-sm text-muted-foreground">
|
||||||
|
暂无匹配分类。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
|
||||||
|
<Folders className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>{selectedItem ? '编辑分类' : '新建分类'}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
除了名称 / slug 外,还可以维护前台展示描述和 SEO 元数据。
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<FormField label="分类名称" hint="例如:前端工程、随笔、工具链。">
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
|
||||||
|
placeholder="输入分类名称"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="分类 slug" hint="留空时自动从英文名称生成;中文建议手填。">
|
||||||
|
<Input
|
||||||
|
value={form.slug}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))}
|
||||||
|
placeholder="frontend-engineering"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="封面图 URL" hint="可选,用于前台分类头图。">
|
||||||
|
<Input
|
||||||
|
value={form.coverImage}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, coverImage: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="https://cdn.example.com/covers/frontend.jpg"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="强调色" hint="可选,用于前台分类详情强调色。">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Input
|
||||||
|
value={form.accentColor}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, accentColor: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="#3b82f6"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={form.accentColor || '#2563eb'}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, accentColor: event.target.value }))
|
||||||
|
}
|
||||||
|
className="h-10 w-14 rounded-xl border border-input bg-background px-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField label="分类描述" hint="会展示在前台分类卡片和分类详情区域。">
|
||||||
|
<Textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, description: event.target.value }))
|
||||||
|
}
|
||||||
|
rows={4}
|
||||||
|
placeholder="介绍这个分类主要收录哪些内容。"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<FormField label="SEO 标题" hint="留空时前台继续回退到常规标题。">
|
||||||
|
<Input
|
||||||
|
value={form.seoTitle}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, seoTitle: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="前端工程专题 - Termi"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="SEO 描述" hint="搜索引擎摘要或社交分享描述。">
|
||||||
|
<Textarea
|
||||||
|
value={form.seoDescription}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, seoDescription: event.target.value }))
|
||||||
|
}
|
||||||
|
rows={4}
|
||||||
|
placeholder="这个分类汇总了工程化、构建链路与调优经验。"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 rounded-3xl border border-border/70 bg-background/50 p-4 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">引用文章</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-foreground">{selectedItem?.count ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">创建时间</p>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">{formatDateTime(selectedItem?.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">最近更新</p>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">{formatDateTime(selectedItem?.updated_at)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button onClick={() => void handleSave()} disabled={saving}>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{saving ? '保存中...' : selectedItem ? '保存分类' : '创建分类'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={resetForm}>
|
||||||
|
取消选择
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => void handleDelete()}
|
||||||
|
disabled={!selectedItem || deleting}
|
||||||
|
className="text-rose-600 hover:text-rose-600"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
{deleting ? '删除中...' : '删除分类'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
Replace,
|
Replace,
|
||||||
|
Save,
|
||||||
Square,
|
Square,
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
@@ -24,6 +25,8 @@ import {
|
|||||||
normalizeCoverImageWithPrompt,
|
normalizeCoverImageWithPrompt,
|
||||||
} from '@/lib/image-compress'
|
} from '@/lib/image-compress'
|
||||||
import type { AdminMediaObjectResponse } from '@/lib/types'
|
import type { AdminMediaObjectResponse } from '@/lib/types'
|
||||||
|
import { FormField } from '@/components/form-field'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
|
||||||
function formatBytes(value: number) {
|
function formatBytes(value: number) {
|
||||||
if (!Number.isFinite(value) || value <= 0) {
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
@@ -39,6 +42,47 @@ function formatBytes(value: number) {
|
|||||||
return `${size >= 10 || unitIndex === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unitIndex]}`
|
return `${size >= 10 || unitIndex === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unitIndex]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MediaMetadataFormState = {
|
||||||
|
title: string
|
||||||
|
altText: string
|
||||||
|
caption: string
|
||||||
|
tags: string
|
||||||
|
notes: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultMetadataForm: MediaMetadataFormState = {
|
||||||
|
title: '',
|
||||||
|
altText: '',
|
||||||
|
caption: '',
|
||||||
|
tags: '',
|
||||||
|
notes: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMetadataForm(item: AdminMediaObjectResponse | null): MediaMetadataFormState {
|
||||||
|
if (!item) {
|
||||||
|
return defaultMetadataForm
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: item.title ?? '',
|
||||||
|
altText: item.alt_text ?? '',
|
||||||
|
caption: item.caption ?? '',
|
||||||
|
tags: item.tags.join(', '),
|
||||||
|
notes: item.notes ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTagList(value: string) {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
value
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function MediaPage() {
|
export function MediaPage() {
|
||||||
const [items, setItems] = useState<AdminMediaObjectResponse[]>([])
|
const [items, setItems] = useState<AdminMediaObjectResponse[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -54,6 +98,9 @@ export function MediaPage() {
|
|||||||
const [bucket, setBucket] = useState<string | null>(null)
|
const [bucket, setBucket] = useState<string | null>(null)
|
||||||
const [uploadFiles, setUploadFiles] = useState<File[]>([])
|
const [uploadFiles, setUploadFiles] = useState<File[]>([])
|
||||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([])
|
const [selectedKeys, setSelectedKeys] = useState<string[]>([])
|
||||||
|
const [activeKey, setActiveKey] = useState<string | null>(null)
|
||||||
|
const [metadataForm, setMetadataForm] = useState<MediaMetadataFormState>(defaultMetadataForm)
|
||||||
|
const [metadataSaving, setMetadataSaving] = useState(false)
|
||||||
const [compressBeforeUpload, setCompressBeforeUpload] = useState(true)
|
const [compressBeforeUpload, setCompressBeforeUpload] = useState(true)
|
||||||
const [compressQuality, setCompressQuality] = useState('0.82')
|
const [compressQuality, setCompressQuality] = useState('0.82')
|
||||||
|
|
||||||
@@ -90,6 +137,25 @@ export function MediaPage() {
|
|||||||
)
|
)
|
||||||
}, [items])
|
}, [items])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!items.length) {
|
||||||
|
setActiveKey(null)
|
||||||
|
setMetadataForm(defaultMetadataForm)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveKey((current) => (current && items.some((item) => item.key === current) ? current : items[0].key))
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
const activeItem = useMemo(
|
||||||
|
() => items.find((item) => item.key === activeKey) ?? null,
|
||||||
|
[activeKey, items],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMetadataForm(toMetadataForm(activeItem))
|
||||||
|
}, [activeItem])
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
const filteredItems = useMemo(() => {
|
||||||
const keyword = searchTerm.trim().toLowerCase()
|
const keyword = searchTerm.trim().toLowerCase()
|
||||||
if (!keyword) {
|
if (!keyword) {
|
||||||
@@ -266,6 +332,140 @@ export function MediaPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{activeItem ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>媒体元数据</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
当前编辑:{activeItem.key}。这里维护标题、alt、说明和标签,供文章封面 / 媒体选择器统一复用。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<FormField label="标题" hint="媒体资源的人类可读名称。">
|
||||||
|
<Input
|
||||||
|
value={metadataForm.title}
|
||||||
|
onChange={(event) =>
|
||||||
|
setMetadataForm((current) => ({ ...current, title: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="文章封面 / 站点横幅"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Alt 文本" hint="用于 img alt 和无障碍描述。">
|
||||||
|
<Input
|
||||||
|
value={metadataForm.altText}
|
||||||
|
onChange={(event) =>
|
||||||
|
setMetadataForm((current) => ({ ...current, altText: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="夜色下的终端风格博客封面"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField label="标签" hint="多个标签用英文逗号分隔。">
|
||||||
|
<Input
|
||||||
|
value={metadataForm.tags}
|
||||||
|
onChange={(event) =>
|
||||||
|
setMetadataForm((current) => ({ ...current, tags: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="cover, astro, terminal"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Caption" hint="适合前台图注、图片说明。">
|
||||||
|
<Textarea
|
||||||
|
value={metadataForm.caption}
|
||||||
|
onChange={(event) =>
|
||||||
|
setMetadataForm((current) => ({ ...current, caption: event.target.value }))
|
||||||
|
}
|
||||||
|
rows={4}
|
||||||
|
placeholder="这张图通常用于文章列表和详情页头图。"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="内部备注" hint="仅后台使用,例如素材来源、版权或推荐用途。">
|
||||||
|
<Textarea
|
||||||
|
value={metadataForm.notes}
|
||||||
|
onChange={(event) =>
|
||||||
|
setMetadataForm((current) => ({ ...current, notes: event.target.value }))
|
||||||
|
}
|
||||||
|
rows={4}
|
||||||
|
placeholder="来源:Unsplash / 站点截图 / AI 生成"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button
|
||||||
|
disabled={metadataSaving}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!activeItem) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setMetadataSaving(true)
|
||||||
|
const result = await adminApi.updateMediaObjectMetadata({
|
||||||
|
key: activeItem.key,
|
||||||
|
title: metadataForm.title || null,
|
||||||
|
altText: metadataForm.altText || null,
|
||||||
|
caption: metadataForm.caption || null,
|
||||||
|
tags: parseTagList(metadataForm.tags),
|
||||||
|
notes: metadataForm.notes || null,
|
||||||
|
})
|
||||||
|
startTransition(() => {
|
||||||
|
setItems((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.key === result.key
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
title: result.title,
|
||||||
|
alt_text: result.alt_text,
|
||||||
|
caption: result.caption,
|
||||||
|
tags: result.tags,
|
||||||
|
notes: result.notes,
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
toast.success('媒体元数据已保存。')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof ApiError ? error.message : '保存媒体元数据失败。')
|
||||||
|
} finally {
|
||||||
|
setMetadataSaving(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{metadataSaving ? '保存中...' : '保存元数据'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setMetadataForm(toMetadataForm(activeItem))}>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-3xl border border-border/70 bg-background/50 p-4">
|
||||||
|
<div className="aspect-[16/9] overflow-hidden rounded-2xl border border-border/70 bg-muted/30">
|
||||||
|
<img
|
||||||
|
src={activeItem.url}
|
||||||
|
alt={metadataForm.altText || activeItem.key}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 text-sm text-muted-foreground">
|
||||||
|
<p className="break-all font-medium text-foreground">{activeItem.key}</p>
|
||||||
|
<p>{formatBytes(activeItem.size_bytes)} · {activeItem.last_modified ?? '未知修改时间'}</p>
|
||||||
|
<p>{metadataForm.altText || '尚未填写 alt 文本'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Skeleton className="h-[520px] rounded-3xl" />
|
<Skeleton className="h-[520px] rounded-3xl" />
|
||||||
) : (
|
) : (
|
||||||
@@ -275,7 +475,10 @@ export function MediaPage() {
|
|||||||
const replaceInputId = `replace-media-${index}`
|
const replaceInputId = `replace-media-${index}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={item.key} className="overflow-hidden">
|
<Card
|
||||||
|
key={item.key}
|
||||||
|
className={`overflow-hidden ${activeKey === item.key ? 'ring-1 ring-primary/40' : ''}`}
|
||||||
|
>
|
||||||
<div className="relative aspect-[16/9] overflow-hidden bg-muted/30">
|
<div className="relative aspect-[16/9] overflow-hidden bg-muted/30">
|
||||||
<img src={item.url} alt={item.key} className="h-full w-full object-cover" />
|
<img src={item.url} alt={item.key} className="h-full w-full object-cover" />
|
||||||
<button
|
<button
|
||||||
@@ -300,8 +503,21 @@ export function MediaPage() {
|
|||||||
<span>{formatBytes(item.size_bytes)}</span>
|
<span>{formatBytes(item.size_bytes)}</span>
|
||||||
{item.last_modified ? <span>{item.last_modified}</span> : null}
|
{item.last_modified ? <span>{item.last_modified}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
|
{item.title ? <p className="text-sm text-foreground">{item.title}</p> : null}
|
||||||
|
{item.tags.length ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{item.tags.slice(0, 4).map((tag) => (
|
||||||
|
<Badge key={`${item.key}-${tag}`} variant="outline">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setActiveKey(item.key)}>
|
||||||
|
元数据
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -820,6 +820,26 @@ export function PostsPage() {
|
|||||||
const [pinnedFilter, setPinnedFilter] = useState('all')
|
const [pinnedFilter, setPinnedFilter] = useState('all')
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
const [pageSize, setPageSize] = useState<number>(POSTS_PAGE_SIZE_OPTIONS[0])
|
const [pageSize, setPageSize] = useState<number>(POSTS_PAGE_SIZE_OPTIONS[0])
|
||||||
|
const [sortKey, setSortKey] = useState('updated_at_desc')
|
||||||
|
const [totalPosts, setTotalPosts] = useState(0)
|
||||||
|
const [totalPages, setTotalPages] = useState(1)
|
||||||
|
|
||||||
|
const { sortBy, sortOrder } = useMemo(() => {
|
||||||
|
switch (sortKey) {
|
||||||
|
case 'created_at_asc':
|
||||||
|
return { sortBy: 'created_at', sortOrder: 'asc' }
|
||||||
|
case 'created_at_desc':
|
||||||
|
return { sortBy: 'created_at', sortOrder: 'desc' }
|
||||||
|
case 'title_asc':
|
||||||
|
return { sortBy: 'title', sortOrder: 'asc' }
|
||||||
|
case 'title_desc':
|
||||||
|
return { sortBy: 'title', sortOrder: 'desc' }
|
||||||
|
case 'updated_at_asc':
|
||||||
|
return { sortBy: 'updated_at', sortOrder: 'asc' }
|
||||||
|
default:
|
||||||
|
return { sortBy: 'updated_at', sortOrder: 'desc' }
|
||||||
|
}
|
||||||
|
}, [sortKey])
|
||||||
|
|
||||||
const loadPosts = useCallback(async (showToast = false) => {
|
const loadPosts = useCallback(async (showToast = false) => {
|
||||||
try {
|
try {
|
||||||
@@ -827,9 +847,28 @@ export function PostsPage() {
|
|||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = await adminApi.listPosts()
|
const next = await adminApi.listPostsPage({
|
||||||
|
search: searchTerm.trim() || undefined,
|
||||||
|
postType: typeFilter === 'all' ? undefined : typeFilter,
|
||||||
|
pinned:
|
||||||
|
pinnedFilter === 'all'
|
||||||
|
? undefined
|
||||||
|
: pinnedFilter === 'pinned',
|
||||||
|
includePrivate: true,
|
||||||
|
includeRedirects: true,
|
||||||
|
preview: true,
|
||||||
|
page: currentPage,
|
||||||
|
pageSize,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
})
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setPosts(next)
|
setPosts(next.items)
|
||||||
|
setTotalPosts(next.total)
|
||||||
|
setTotalPages(next.total_pages)
|
||||||
|
if (next.page !== currentPage) {
|
||||||
|
setCurrentPage(next.page)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (showToast) {
|
if (showToast) {
|
||||||
@@ -844,7 +883,7 @@ export function PostsPage() {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [currentPage, pageSize, pinnedFilter, searchTerm, sortBy, sortOrder, typeFilter])
|
||||||
|
|
||||||
const loadEditor = useCallback(
|
const loadEditor = useCallback(
|
||||||
async (nextSlug: string) => {
|
async (nextSlug: string) => {
|
||||||
@@ -931,49 +970,17 @@ export function PostsPage() {
|
|||||||
}
|
}
|
||||||
}, [createDialogOpen, metadataDialog, navigate, slug])
|
}, [createDialogOpen, metadataDialog, navigate, slug])
|
||||||
|
|
||||||
const normalizedSearchTerm = searchTerm.trim().toLowerCase()
|
|
||||||
const filteredPosts = useMemo(() => {
|
|
||||||
return posts.filter((post) => {
|
|
||||||
const matchesSearch =
|
|
||||||
!normalizedSearchTerm ||
|
|
||||||
[
|
|
||||||
post.title ?? '',
|
|
||||||
post.slug,
|
|
||||||
post.category ?? '',
|
|
||||||
post.description ?? '',
|
|
||||||
post.post_type ?? '',
|
|
||||||
postTagsToList(post.tags).join(' '),
|
|
||||||
]
|
|
||||||
.join('\n')
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(normalizedSearchTerm)
|
|
||||||
|
|
||||||
const matchesType = typeFilter === 'all' || (post.post_type ?? 'article') === typeFilter
|
|
||||||
const pinnedValue = Boolean(post.pinned)
|
|
||||||
const matchesPinned =
|
|
||||||
pinnedFilter === 'all' ||
|
|
||||||
(pinnedFilter === 'pinned' && pinnedValue) ||
|
|
||||||
(pinnedFilter === 'regular' && !pinnedValue)
|
|
||||||
|
|
||||||
return matchesSearch && matchesType && matchesPinned
|
|
||||||
})
|
|
||||||
}, [normalizedSearchTerm, pinnedFilter, posts, typeFilter])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPage(1)
|
setCurrentPage(1)
|
||||||
}, [pageSize, pinnedFilter, searchTerm, typeFilter])
|
}, [pageSize, pinnedFilter, searchTerm, sortKey, typeFilter])
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredPosts.length / pageSize))
|
|
||||||
const safeCurrentPage = Math.min(currentPage, totalPages)
|
const safeCurrentPage = Math.min(currentPage, totalPages)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPage((current) => Math.min(current, totalPages))
|
setCurrentPage((current) => Math.min(current, totalPages))
|
||||||
}, [totalPages])
|
}, [totalPages])
|
||||||
|
|
||||||
const paginatedPosts = useMemo(() => {
|
const paginatedPosts = posts
|
||||||
const startIndex = (safeCurrentPage - 1) * pageSize
|
|
||||||
return filteredPosts.slice(startIndex, startIndex + pageSize)
|
|
||||||
}, [filteredPosts, pageSize, safeCurrentPage])
|
|
||||||
|
|
||||||
const paginationItems = useMemo(() => {
|
const paginationItems = useMemo(() => {
|
||||||
const maxVisiblePages = 5
|
const maxVisiblePages = 5
|
||||||
@@ -988,8 +995,8 @@ export function PostsPage() {
|
|||||||
return Array.from({ length: endPage - startPage + 1 }, (_, index) => startPage + index)
|
return Array.from({ length: endPage - startPage + 1 }, (_, index) => startPage + index)
|
||||||
}, [safeCurrentPage, totalPages])
|
}, [safeCurrentPage, totalPages])
|
||||||
|
|
||||||
const pageStart = filteredPosts.length ? (safeCurrentPage - 1) * pageSize + 1 : 0
|
const pageStart = totalPosts ? (safeCurrentPage - 1) * pageSize + 1 : 0
|
||||||
const pageEnd = filteredPosts.length ? Math.min(safeCurrentPage * pageSize, filteredPosts.length) : 0
|
const pageEnd = totalPosts ? Math.min(safeCurrentPage * pageSize, totalPosts) : 0
|
||||||
const pinnedPostCount = useMemo(
|
const pinnedPostCount = useMemo(
|
||||||
() => posts.filter((post) => Boolean(post.pinned)).length,
|
() => posts.filter((post) => Boolean(post.pinned)).length,
|
||||||
[posts],
|
[posts],
|
||||||
@@ -1904,7 +1911,7 @@ export function PostsPage() {
|
|||||||
保持列表浏览,搜索、筛选、翻页都在这里完成;新建和编辑统一在页内窗口里处理。
|
保持列表浏览,搜索、筛选、翻页都在这里完成;新建和编辑统一在页内窗口里处理。
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">{filteredPosts.length} / {posts.length}</Badge>
|
<Badge variant="outline">{paginatedPosts.length} / {totalPosts}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row">
|
<div className="flex flex-col gap-3 lg:flex-row">
|
||||||
@@ -1921,7 +1928,7 @@ export function PostsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<Select value={typeFilter} onChange={(event) => setTypeFilter(event.target.value)}>
|
<Select value={typeFilter} onChange={(event) => setTypeFilter(event.target.value)}>
|
||||||
<option value="all">全部类型</option>
|
<option value="all">全部类型</option>
|
||||||
<option value="article">文章</option>
|
<option value="article">文章</option>
|
||||||
@@ -1947,11 +1954,18 @@ export function PostsPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
<Select value={sortKey} onChange={(event) => setSortKey(event.target.value)}>
|
||||||
|
<option value="updated_at_desc">最近更新优先</option>
|
||||||
|
<option value="created_at_desc">最新创建优先</option>
|
||||||
|
<option value="created_at_asc">最早创建优先</option>
|
||||||
|
<option value="title_asc">标题 A → Z</option>
|
||||||
|
<option value="title_desc">标题 Z → A</option>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Badge variant="secondary">已筛选 {filteredPosts.length}</Badge>
|
<Badge variant="secondary">匹配 {totalPosts}</Badge>
|
||||||
<Badge variant="outline">置顶 {pinnedPostCount}</Badge>
|
<Badge variant="outline">当前页置顶 {pinnedPostCount}</Badge>
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
第 {safeCurrentPage} / {totalPages} 页
|
第 {safeCurrentPage} / {totalPages} 页
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -2008,18 +2022,18 @@ export function PostsPage() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{!filteredPosts.length ? (
|
{!totalPosts ? (
|
||||||
<div className="rounded-[1.8rem] border border-dashed border-border/80 px-5 py-12 text-center text-sm text-muted-foreground">
|
<div className="rounded-[1.8rem] border border-dashed border-border/80 px-5 py-12 text-center text-sm text-muted-foreground">
|
||||||
当前筛选条件下没有匹配的文章。
|
当前筛选条件下没有匹配的文章。
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredPosts.length ? (
|
{totalPosts ? (
|
||||||
<div className="rounded-[1.5rem] border border-border/70 bg-background/65 px-4 py-3">
|
<div className="rounded-[1.5rem] border border-border/70 bg-background/65 px-4 py-3">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
当前显示第 {pageStart} - {pageEnd} 条,共 {filteredPosts.length} 条结果。
|
当前显示第 {pageStart} - {pageEnd} 条,共 {totalPosts} 条结果。
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ const MEDIA_STORAGE_PROVIDER_OPTIONS = [
|
|||||||
{ value: 'minio', label: 'MinIO' },
|
{ value: 'minio', label: 'MinIO' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
const NOTIFICATION_CHANNEL_OPTIONS = [
|
||||||
|
{ value: 'webhook', label: 'Webhook' },
|
||||||
|
{ value: 'ntfy', label: 'ntfy' },
|
||||||
|
] as const
|
||||||
|
|
||||||
function isCloudflareProvider(provider: string | null | undefined) {
|
function isCloudflareProvider(provider: string | null | undefined) {
|
||||||
const normalized = provider?.trim().toLowerCase()
|
const normalized = provider?.trim().toLowerCase()
|
||||||
return normalized === 'cloudflare' || normalized === 'cloudflare-workers-ai' || normalized === 'workers-ai'
|
return normalized === 'cloudflare' || normalized === 'cloudflare-workers-ai' || normalized === 'workers-ai'
|
||||||
@@ -89,6 +94,11 @@ function normalizeSettingsResponse(
|
|||||||
...input,
|
...input,
|
||||||
ai_providers: aiProviders,
|
ai_providers: aiProviders,
|
||||||
search_synonyms: searchSynonyms,
|
search_synonyms: searchSynonyms,
|
||||||
|
turnstile_site_key: input.turnstile_site_key ?? null,
|
||||||
|
turnstile_secret_key: input.turnstile_secret_key ?? null,
|
||||||
|
web_push_vapid_public_key: input.web_push_vapid_public_key ?? null,
|
||||||
|
web_push_vapid_private_key: input.web_push_vapid_private_key ?? null,
|
||||||
|
web_push_vapid_subject: input.web_push_vapid_subject ?? null,
|
||||||
ai_active_provider_id:
|
ai_active_provider_id:
|
||||||
input.ai_active_provider_id ?? aiProviders[0]?.id ?? null,
|
input.ai_active_provider_id ?? aiProviders[0]?.id ?? null,
|
||||||
}
|
}
|
||||||
@@ -133,6 +143,14 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload {
|
|||||||
musicPlaylist: form.music_playlist,
|
musicPlaylist: form.music_playlist,
|
||||||
aiEnabled: form.ai_enabled,
|
aiEnabled: form.ai_enabled,
|
||||||
paragraphCommentsEnabled: form.paragraph_comments_enabled,
|
paragraphCommentsEnabled: form.paragraph_comments_enabled,
|
||||||
|
commentTurnstileEnabled: form.comment_turnstile_enabled,
|
||||||
|
subscriptionTurnstileEnabled: form.subscription_turnstile_enabled,
|
||||||
|
webPushEnabled: form.web_push_enabled,
|
||||||
|
turnstileSiteKey: form.turnstile_site_key,
|
||||||
|
turnstileSecretKey: form.turnstile_secret_key,
|
||||||
|
webPushVapidPublicKey: form.web_push_vapid_public_key,
|
||||||
|
webPushVapidPrivateKey: form.web_push_vapid_private_key,
|
||||||
|
webPushVapidSubject: form.web_push_vapid_subject,
|
||||||
aiProvider: form.ai_provider,
|
aiProvider: form.ai_provider,
|
||||||
aiApiBase: form.ai_api_base,
|
aiApiBase: form.ai_api_base,
|
||||||
aiApiKey: form.ai_api_key,
|
aiApiKey: form.ai_api_key,
|
||||||
@@ -156,6 +174,7 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload {
|
|||||||
seoDefaultOgImage: form.seo_default_og_image,
|
seoDefaultOgImage: form.seo_default_og_image,
|
||||||
seoDefaultTwitterHandle: form.seo_default_twitter_handle,
|
seoDefaultTwitterHandle: form.seo_default_twitter_handle,
|
||||||
notificationWebhookUrl: form.notification_webhook_url,
|
notificationWebhookUrl: form.notification_webhook_url,
|
||||||
|
notificationChannelType: form.notification_channel_type,
|
||||||
notificationCommentEnabled: form.notification_comment_enabled,
|
notificationCommentEnabled: form.notification_comment_enabled,
|
||||||
notificationFriendLinkEnabled: form.notification_friend_link_enabled,
|
notificationFriendLinkEnabled: form.notification_friend_link_enabled,
|
||||||
subscriptionPopupEnabled: form.subscription_popup_enabled,
|
subscriptionPopupEnabled: form.subscription_popup_enabled,
|
||||||
@@ -624,6 +643,40 @@ export function SiteSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.web_push_enabled}
|
||||||
|
onChange={(event) => updateField('web_push_enabled', event.target.checked)}
|
||||||
|
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">开启浏览器推送</div>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||||
|
前台订阅弹窗会增加浏览器通知授权入口。保存下方 VAPID 公私钥后,前台会直接读取当前后台配置。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.subscription_turnstile_enabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateField('subscription_turnstile_enabled', event.target.checked)
|
||||||
|
}
|
||||||
|
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">订阅入口启用 Turnstile</div>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||||
|
邮箱订阅提交前要求完成 Cloudflare Turnstile。这里和评论区共用同一套 key,但开关仍可分别控制。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<Field label="弹窗标题" hint="建议直接传达价值,例如“订阅更新”或“别错过新文章”。">
|
<Field label="弹窗标题" hint="建议直接传达价值,例如“订阅更新”或“别错过新文章”。">
|
||||||
<Input
|
<Input
|
||||||
@@ -663,6 +716,77 @@ export function SiteSettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>运行时安全 / 推送配置</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
大部分前台安全与推送相关配置都可以直接在这里改;数据库配置优先,环境变量只作为回退。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<Field
|
||||||
|
label="Turnstile Site Key"
|
||||||
|
hint="评论区和订阅弹窗共用这一套站点 key,保存后前台会在运行时读取。"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={form.turnstile_site_key ?? ''}
|
||||||
|
onChange={(event) => updateField('turnstile_site_key', event.target.value)}
|
||||||
|
placeholder="0x4AAAA..."
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Turnstile Secret Key"
|
||||||
|
hint="后端验证 token 使用;留空可清除数据库配置并回退到环境变量。"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={form.turnstile_secret_key ?? ''}
|
||||||
|
onChange={(event) => updateField('turnstile_secret_key', event.target.value)}
|
||||||
|
placeholder="ts-secret-key"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Web Push VAPID Public Key"
|
||||||
|
hint="浏览器订阅按钮会把这把 public key 下发到前台。"
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
value={form.web_push_vapid_public_key ?? ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateField('web_push_vapid_public_key', event.target.value)
|
||||||
|
}
|
||||||
|
placeholder="BEl6..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Web Push VAPID Private Key"
|
||||||
|
hint="后端发送浏览器推送时签名使用。"
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
value={form.web_push_vapid_private_key ?? ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateField('web_push_vapid_private_key', event.target.value)
|
||||||
|
}
|
||||||
|
placeholder="5aQ..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<Field
|
||||||
|
label="Web Push Subject"
|
||||||
|
hint="推荐填写 mailto:you@example.com;留空时会优先回退到环境变量,再退回站点 URL / 默认值。"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={form.web_push_vapid_subject ?? ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateField('web_push_vapid_subject', event.target.value)
|
||||||
|
}
|
||||||
|
placeholder="mailto:admin@example.com"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>SEO、搜索与通知</CardTitle>
|
<CardTitle>SEO、搜索与通知</CardTitle>
|
||||||
@@ -685,13 +809,39 @@ export function SiteSettingsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<div className="lg:col-span-2">
|
<div className="grid gap-4 lg:col-span-2 md:grid-cols-[220px_minmax(0,1fr)]">
|
||||||
<Field label="Webhook URL" hint="评论和友链申请会向这个地址推送 JSON。">
|
<Field label="通知渠道" hint="可选 Webhook 或 ntfy。">
|
||||||
|
<Select
|
||||||
|
value={form.notification_channel_type}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateField('notification_channel_type', event.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{NOTIFICATION_CHANNEL_OPTIONS.map((item) => (
|
||||||
|
<option key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="通知目标"
|
||||||
|
hint={
|
||||||
|
form.notification_channel_type === 'ntfy'
|
||||||
|
? '支持 topic 名称或完整 ntfy URL。'
|
||||||
|
: '评论和友链申请会向这个地址推送 JSON。'
|
||||||
|
}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
value={form.notification_webhook_url ?? ''}
|
value={form.notification_webhook_url ?? ''}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateField('notification_webhook_url', event.target.value)
|
updateField('notification_webhook_url', event.target.value)
|
||||||
}
|
}
|
||||||
|
placeholder={
|
||||||
|
form.notification_channel_type === 'ntfy'
|
||||||
|
? 'blog-admin 或 https://ntfy.example.com/blog-admin'
|
||||||
|
: 'https://example.com/hooks/termi'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
@@ -708,7 +858,7 @@ export function SiteSettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="font-medium">新评论通知</div>
|
<div className="font-medium">新评论通知</div>
|
||||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||||
有新评论创建时,通过 Webhook 推送待审核提醒。
|
有新评论创建时,按上面的渠道配置推送待审核提醒。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -724,7 +874,7 @@ export function SiteSettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="font-medium">友链申请通知</div>
|
<div className="font-medium">友链申请通知</div>
|
||||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||||
有新的友链申请时,同样通过 Webhook 推送。
|
有新的友链申请时,同样走上面的通知渠道。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -775,6 +925,23 @@ export function SiteSettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 rounded-2xl border border-border/70 bg-background/60 p-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.comment_turnstile_enabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateField('comment_turnstile_enabled', event.target.checked)
|
||||||
|
}
|
||||||
|
className="mt-1 h-4 w-4 rounded border-input text-primary focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">评论区启用 Turnstile</div>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||||
|
文章评论和段落评论会优先使用 Cloudflare Turnstile;若这里未填 key / secret,则会自动回退到环境变量或原有校验。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const CHANNEL_OPTIONS = [
|
|||||||
{ value: 'discord', label: 'Discord Webhook' },
|
{ value: 'discord', label: 'Discord Webhook' },
|
||||||
{ value: 'telegram', label: 'Telegram Bot API' },
|
{ value: 'telegram', label: 'Telegram Bot API' },
|
||||||
{ value: 'ntfy', label: 'ntfy' },
|
{ value: 'ntfy', label: 'ntfy' },
|
||||||
|
{ value: 'web_push', label: 'Web Push / Browser Push' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const DEFAULT_FILTERS = {
|
const DEFAULT_FILTERS = {
|
||||||
@@ -174,7 +175,7 @@ export function SubscriptionsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-semibold tracking-tight">订阅中心 / 异步投递 / 汇总简报</h2>
|
<h2 className="text-3xl font-semibold tracking-tight">订阅中心 / 异步投递 / 汇总简报</h2>
|
||||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||||
这里统一管理邮件订阅、Webhook / Discord / Telegram / ntfy 推送目标;当前投递走异步队列,并支持 retry pending 状态追踪。
|
这里统一管理邮件订阅、Webhook / Discord / Telegram / ntfy / Web Push 推送目标;当前投递走异步队列,并支持 retry pending 状态追踪。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -251,7 +252,15 @@ export function SubscriptionsPage() {
|
|||||||
<Input
|
<Input
|
||||||
value={form.target}
|
value={form.target}
|
||||||
onChange={(event) => setForm((current) => ({ ...current, target: event.target.value }))}
|
onChange={(event) => setForm((current) => ({ ...current, target: event.target.value }))}
|
||||||
placeholder={form.channelType === 'email' ? 'name@example.com' : 'https://...'}
|
placeholder={
|
||||||
|
form.channelType === 'email'
|
||||||
|
? 'name@example.com'
|
||||||
|
: form.channelType === 'ntfy'
|
||||||
|
? 'topic-name 或 https://ntfy.example.com/topic'
|
||||||
|
: form.channelType === 'web_push'
|
||||||
|
? 'https://push-service/...'
|
||||||
|
: 'https://...'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
402
admin/src/pages/tags-page.tsx
Normal file
402
admin/src/pages/tags-page.tsx
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
import { Plus, RefreshCcw, Save, Tags, Trash2 } from 'lucide-react'
|
||||||
|
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { FormField } from '@/components/form-field'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { adminApi, ApiError } from '@/lib/api'
|
||||||
|
import { emptyToNull, formatDateTime } from '@/lib/admin-format'
|
||||||
|
import type { TagRecord, TaxonomyPayload } from '@/lib/types'
|
||||||
|
|
||||||
|
type TagFormState = {
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
description: string
|
||||||
|
coverImage: string
|
||||||
|
accentColor: string
|
||||||
|
seoTitle: string
|
||||||
|
seoDescription: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTagForm: TagFormState = {
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
description: '',
|
||||||
|
coverImage: '',
|
||||||
|
accentColor: '',
|
||||||
|
seoTitle: '',
|
||||||
|
seoDescription: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFormState(item: TagRecord): TagFormState {
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
slug: item.slug,
|
||||||
|
description: item.description ?? '',
|
||||||
|
coverImage: item.cover_image ?? '',
|
||||||
|
accentColor: item.accent_color ?? '',
|
||||||
|
seoTitle: item.seo_title ?? '',
|
||||||
|
seoDescription: item.seo_description ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPayload(form: TagFormState): TaxonomyPayload {
|
||||||
|
return {
|
||||||
|
name: form.name.trim(),
|
||||||
|
slug: emptyToNull(form.slug),
|
||||||
|
description: emptyToNull(form.description),
|
||||||
|
coverImage: emptyToNull(form.coverImage),
|
||||||
|
accentColor: emptyToNull(form.accentColor),
|
||||||
|
seoTitle: emptyToNull(form.seoTitle),
|
||||||
|
seoDescription: emptyToNull(form.seoDescription),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagsPage() {
|
||||||
|
const [items, setItems] = useState<TagRecord[]>([])
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||||
|
const [form, setForm] = useState<TagFormState>(defaultTagForm)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
|
||||||
|
const loadTags = useCallback(async (showToast = false) => {
|
||||||
|
try {
|
||||||
|
if (showToast) {
|
||||||
|
setRefreshing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = await adminApi.listTags()
|
||||||
|
startTransition(() => {
|
||||||
|
setItems(next)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (showToast) {
|
||||||
|
toast.success('标签列表已刷新。')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError && error.status === 401) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.error(error instanceof ApiError ? error.message : '无法加载标签列表。')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadTags(false)
|
||||||
|
}, [loadTags])
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
const keyword = searchTerm.trim().toLowerCase()
|
||||||
|
if (!keyword) {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.filter((item) =>
|
||||||
|
[item.name, item.slug, item.description ?? '', item.seo_title ?? '']
|
||||||
|
.join('\n')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(keyword),
|
||||||
|
)
|
||||||
|
}, [items, searchTerm])
|
||||||
|
|
||||||
|
const selectedItem = useMemo(
|
||||||
|
() => items.find((item) => item.id === selectedId) ?? null,
|
||||||
|
[items, selectedId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const resetForm = useCallback(() => {
|
||||||
|
setSelectedId(null)
|
||||||
|
setForm(defaultTagForm)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
toast.error('请先填写标签名称。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true)
|
||||||
|
if (selectedId) {
|
||||||
|
const updated = await adminApi.updateTag(selectedId, toPayload(form))
|
||||||
|
startTransition(() => {
|
||||||
|
setItems((current) => current.map((item) => (item.id === updated.id ? updated : item)))
|
||||||
|
setSelectedId(updated.id)
|
||||||
|
setForm(toFormState(updated))
|
||||||
|
})
|
||||||
|
toast.success('标签已更新。')
|
||||||
|
} else {
|
||||||
|
const created = await adminApi.createTag(toPayload(form))
|
||||||
|
startTransition(() => {
|
||||||
|
setItems((current) => [created, ...current])
|
||||||
|
setSelectedId(created.id)
|
||||||
|
setForm(toFormState(created))
|
||||||
|
})
|
||||||
|
toast.success('标签已创建。')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof ApiError ? error.message : '保存标签失败。')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [form, selectedId])
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!selectedItem) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.confirm(`确认删除标签「${selectedItem.name}」吗?相关文章会同步移除该标签引用。`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setDeleting(true)
|
||||||
|
await adminApi.deleteTag(selectedItem.id)
|
||||||
|
startTransition(() => {
|
||||||
|
setItems((current) => current.filter((item) => item.id !== selectedItem.id))
|
||||||
|
})
|
||||||
|
toast.success('标签已删除。')
|
||||||
|
resetForm()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof ApiError ? error.message : '删除标签失败。')
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}, [resetForm, selectedItem])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-40 rounded-3xl" />
|
||||||
|
<Skeleton className="h-[720px] rounded-3xl" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Badge variant="secondary">标签管理</Badge>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-semibold tracking-tight">标签库</h2>
|
||||||
|
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||||
|
标签现在也支持描述、封面、强调色和 SEO 字段,便于前台标签页做更像专题页的展示。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button variant="outline" onClick={resetForm}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
新建标签
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => void loadTags(true)} disabled={refreshing}>
|
||||||
|
<RefreshCcw className="h-4 w-4" />
|
||||||
|
{refreshing ? '刷新中...' : '刷新'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>标签列表</CardTitle>
|
||||||
|
<CardDescription>支持按名称、slug、描述和 SEO 标题检索。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Input
|
||||||
|
placeholder="按标签名 / slug / 描述搜索"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(event) => setSearchTerm(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{filteredItems.length ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(item.id)
|
||||||
|
setForm(toFormState(item))
|
||||||
|
}}
|
||||||
|
className={`w-full rounded-3xl border px-4 py-4 text-left transition ${
|
||||||
|
selectedId === item.id
|
||||||
|
? 'border-primary/30 bg-primary/10 shadow-[0_12px_30px_rgba(37,99,235,0.12)]'
|
||||||
|
: 'border-border/70 bg-background/60 hover:border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">{item.name}</span>
|
||||||
|
<Badge variant="outline">#{item.slug}</Badge>
|
||||||
|
{item.accent_color ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex h-5 w-5 rounded-full border border-border/80"
|
||||||
|
style={{ backgroundColor: item.accent_color }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{item.description || `${item.count} 篇文章引用了这个标签`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={item.count > 0 ? 'success' : 'secondary'}>{item.count}</Badge>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-3xl border border-dashed border-border/70 bg-background/40 px-5 py-10 text-center text-sm text-muted-foreground">
|
||||||
|
暂无匹配标签。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
|
||||||
|
<Tags className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>{selectedItem ? '编辑标签' : '新建标签'}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
标签除了基础名称,还可以直接补 SEO 信息与专题说明。
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<FormField label="标签名称" hint="例如:astro、rust、workflow。">
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
|
||||||
|
placeholder="输入标签名称"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="标签 slug" hint="留空时自动从英文名称生成;中文建议手填。">
|
||||||
|
<Input
|
||||||
|
value={form.slug}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))}
|
||||||
|
placeholder="astro"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="封面图 URL" hint="可选,用于前台标签头图。">
|
||||||
|
<Input
|
||||||
|
value={form.coverImage}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, coverImage: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="https://cdn.example.com/covers/astro.jpg"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="强调色" hint="可选,用于标签专题头部强调色。">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Input
|
||||||
|
value={form.accentColor}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, accentColor: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="#14b8a6"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={form.accentColor || '#14b8a6'}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, accentColor: event.target.value }))
|
||||||
|
}
|
||||||
|
className="h-10 w-14 rounded-xl border border-input bg-background px-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField label="标签描述" hint="用于前台标签卡片与专题说明。">
|
||||||
|
<Textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, description: event.target.value }))
|
||||||
|
}
|
||||||
|
rows={4}
|
||||||
|
placeholder="介绍这个标签常见主题、适合谁看。"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<FormField label="SEO 标题" hint="留空时前台继续使用标签名拼接默认标题。">
|
||||||
|
<Input
|
||||||
|
value={form.seoTitle}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, seoTitle: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Astro 相关文章 - Termi"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="SEO 描述" hint="搜索引擎摘要与分享描述。">
|
||||||
|
<Textarea
|
||||||
|
value={form.seoDescription}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, seoDescription: event.target.value }))
|
||||||
|
}
|
||||||
|
rows={4}
|
||||||
|
placeholder="围绕 Astro、内容站与渲染策略的文章汇总。"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 rounded-3xl border border-border/70 bg-background/50 p-4 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">引用文章</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-foreground">{selectedItem?.count ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">创建时间</p>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">{formatDateTime(selectedItem?.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">最近更新</p>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">{formatDateTime(selectedItem?.updated_at)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button onClick={() => void handleSave()} disabled={saving}>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{saving ? '保存中...' : selectedItem ? '保存标签' : '创建标签'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={resetForm}>
|
||||||
|
取消选择
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => void handleDelete()}
|
||||||
|
disabled={!selectedItem || deleting}
|
||||||
|
className="text-rose-600 hover:text-rose-600"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
{deleting ? '删除中...' : '删除标签'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
target
|
target
|
||||||
|
target-*
|
||||||
.git
|
.git
|
||||||
.github
|
.github
|
||||||
.gitea
|
.gitea
|
||||||
@@ -6,3 +7,4 @@ node_modules
|
|||||||
*.log
|
*.log
|
||||||
*.out
|
*.out
|
||||||
*.err
|
*.err
|
||||||
|
storage
|
||||||
|
|||||||
3
backend/.gitignore
vendored
3
backend/.gitignore
vendored
@@ -5,6 +5,7 @@
|
|||||||
# will have compiled files and executables
|
# will have compiled files and executables
|
||||||
debug/
|
debug/
|
||||||
target/
|
target/
|
||||||
|
target-*/
|
||||||
|
|
||||||
# include cargo lock
|
# include cargo lock
|
||||||
!Cargo.lock
|
!Cargo.lock
|
||||||
@@ -16,4 +17,4 @@ target/
|
|||||||
*.pdb
|
*.pdb
|
||||||
|
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite-*
|
*.sqlite-*
|
||||||
|
|||||||
589
backend/Cargo.lock
generated
589
backend/Cargo.lock
generated
@@ -8,6 +8,62 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aead"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common 0.1.7",
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cipher 0.4.4",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes"
|
||||||
|
version = "0.9.0-rc.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04097e08a47d9ad181c2e1f4a5fabc9ae06ce8839a333ba9a949bcb0d31fd2a3"
|
||||||
|
dependencies = [
|
||||||
|
"cipher 0.5.1",
|
||||||
|
"cpubits",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes-gcm"
|
||||||
|
version = "0.10.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||||
|
dependencies = [
|
||||||
|
"aead",
|
||||||
|
"aes 0.8.4",
|
||||||
|
"cipher 0.4.4",
|
||||||
|
"ctr",
|
||||||
|
"ghash",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes-keywrap"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10b6f24a1f796bc46415a1d0d18dc0a8203ccba088acf5def3291c4f61225522"
|
||||||
|
dependencies = [
|
||||||
|
"aes 0.9.0-rc.4",
|
||||||
|
"byteorder",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.7.8"
|
version = "0.7.8"
|
||||||
@@ -190,6 +246,12 @@ dependencies = [
|
|||||||
"password-hash",
|
"password-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayref"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrayvec"
|
name = "arrayvec"
|
||||||
version = "0.7.6"
|
version = "0.7.6"
|
||||||
@@ -547,7 +609,7 @@ dependencies = [
|
|||||||
"hmac",
|
"hmac",
|
||||||
"http 0.2.12",
|
"http 0.2.12",
|
||||||
"http 1.4.0",
|
"http 1.4.0",
|
||||||
"p256",
|
"p256 0.11.1",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"ring",
|
"ring",
|
||||||
"sha2",
|
"sha2",
|
||||||
@@ -950,12 +1012,24 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce"
|
checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base16ct"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
|
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.21.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@@ -992,6 +1066,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "binstring"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0669d5a35b64fdb5ab7fb19cae13148b6b5cbdf4b8247faf54ece47f699c8cef"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit_field"
|
name = "bit_field"
|
||||||
version = "0.10.3"
|
version = "0.10.3"
|
||||||
@@ -1043,6 +1123,17 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2b_simd"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3"
|
||||||
|
dependencies = [
|
||||||
|
"arrayref",
|
||||||
|
"arrayvec",
|
||||||
|
"constant_time_eq",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -1287,6 +1378,26 @@ dependencies = [
|
|||||||
"stacker",
|
"stacker",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common 0.1.7",
|
||||||
|
"inout 0.1.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common 0.2.1",
|
||||||
|
"inout 0.2.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.0"
|
version = "4.6.0"
|
||||||
@@ -1336,6 +1447,17 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coarsetime"
|
||||||
|
version = "0.1.37"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e58eb270476aa4fc7843849f8a35063e8743b4dbcdf6dd0f8ea0886980c204c2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"wasix",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "color_quant"
|
name = "color_quant"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -1449,12 +1571,24 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d6f2aa4d0537bcc1c74df8755072bd31c1ef1a3a1b85a68e8404a8c353b7b8b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.9.6"
|
version = "0.9.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "constant_time_eq"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cookie"
|
name = "cookie"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -1501,6 +1635,12 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpubits"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ef0c543070d296ea414df2dd7625d1b24866ce206709d8a4a424f28377f5861"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -1654,8 +1794,10 @@ version = "0.5.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
"subtle",
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1665,9 +1807,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
|
"rand_core 0.6.4",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cssparser"
|
name = "cssparser"
|
||||||
version = "0.34.0"
|
version = "0.34.0"
|
||||||
@@ -1691,6 +1843,21 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ct-codecs"
|
||||||
|
version = "1.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b10589d1a5e400d61f9f38f12f884cfd080ff345de8f17efda36fe0e4a02aa8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctr"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||||
|
dependencies = [
|
||||||
|
"cipher 0.4.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.20.11"
|
version = "0.20.11"
|
||||||
@@ -1749,13 +1916,23 @@ dependencies = [
|
|||||||
"parking_lot_core",
|
"parking_lot_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "der"
|
||||||
|
version = "0.4.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "79b71cca7d95d7681a4b3b9cdf63c8dbc3730d0584c2c74e31416d64a90493f4"
|
||||||
|
dependencies = [
|
||||||
|
"const-oid 0.6.2",
|
||||||
|
"der_derive",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de"
|
checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"const-oid",
|
"const-oid 0.9.6",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1765,7 +1942,7 @@ version = "0.7.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"const-oid",
|
"const-oid 0.9.6",
|
||||||
"pem-rfc7468 0.7.0",
|
"pem-rfc7468 0.7.0",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -1780,6 +1957,18 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "der_derive"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8aed3b3c608dc56cf36c45fe979d04eda51242e6703d8d0bb03426ef7c41db6a"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 1.0.109",
|
||||||
|
"synstructure 0.12.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
@@ -1873,8 +2062,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
"const-oid",
|
"const-oid 0.9.6",
|
||||||
"crypto-common",
|
"crypto-common 0.1.7",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1965,11 +2154,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c"
|
checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"der 0.6.1",
|
"der 0.6.1",
|
||||||
"elliptic-curve",
|
"elliptic-curve 0.12.3",
|
||||||
"rfc6979",
|
"rfc6979 0.3.1",
|
||||||
"signature 1.6.4",
|
"signature 1.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ecdsa"
|
||||||
|
version = "0.16.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||||
|
dependencies = [
|
||||||
|
"der 0.7.10",
|
||||||
|
"digest",
|
||||||
|
"elliptic-curve 0.13.8",
|
||||||
|
"rfc6979 0.4.0",
|
||||||
|
"signature 2.2.0",
|
||||||
|
"spki 0.7.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ece"
|
||||||
|
version = "2.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2ea1d2f2cc974957a4e2575d8e5bb494549bab66338d6320c2789abcfff5746"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.21.7",
|
||||||
|
"byteorder",
|
||||||
|
"hex",
|
||||||
|
"hkdf",
|
||||||
|
"lazy_static",
|
||||||
|
"once_cell",
|
||||||
|
"openssl",
|
||||||
|
"serde",
|
||||||
|
"sha2",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519-compact"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33ce99a9e19c84beb4cc35ece85374335ccc398240712114c85038319ed709bd"
|
||||||
|
dependencies = [
|
||||||
|
"ct-codecs",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ego-tree"
|
name = "ego-tree"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -1991,16 +2222,37 @@ version = "0.12.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3"
|
checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base16ct",
|
"base16ct 0.1.1",
|
||||||
"crypto-bigint 0.4.9",
|
"crypto-bigint 0.4.9",
|
||||||
"der 0.6.1",
|
"der 0.6.1",
|
||||||
"digest",
|
"digest",
|
||||||
"ff",
|
"ff 0.12.1",
|
||||||
"generic-array",
|
"generic-array",
|
||||||
"group",
|
"group 0.12.1",
|
||||||
"pkcs8 0.9.0",
|
"pkcs8 0.9.0",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
"sec1",
|
"sec1 0.3.0",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "elliptic-curve"
|
||||||
|
version = "0.13.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||||
|
dependencies = [
|
||||||
|
"base16ct 0.2.0",
|
||||||
|
"crypto-bigint 0.5.5",
|
||||||
|
"digest",
|
||||||
|
"ff 0.13.1",
|
||||||
|
"generic-array",
|
||||||
|
"group 0.13.0",
|
||||||
|
"hkdf",
|
||||||
|
"pem-rfc7468 0.7.0",
|
||||||
|
"pkcs8 0.10.2",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"sec1 0.7.3",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -2186,6 +2438,16 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ff"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@@ -2416,6 +2678,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"typenum",
|
"typenum",
|
||||||
"version_check",
|
"version_check",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2468,6 +2731,16 @@ dependencies = [
|
|||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ghash"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||||
|
dependencies = [
|
||||||
|
"opaque-debug",
|
||||||
|
"polyval",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gif"
|
name = "gif"
|
||||||
version = "0.14.1"
|
version = "0.14.1"
|
||||||
@@ -2526,7 +2799,18 @@ version = "0.12.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7"
|
checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ff",
|
"ff 0.12.1",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "group"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||||
|
dependencies = [
|
||||||
|
"ff 0.13.1",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
@@ -2689,11 +2973,29 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac-sha1-compact"
|
||||||
|
version = "1.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b0b3ba31f6dc772cc8221ce81dbbbd64fa1e668255a6737d95eeace59b5a8823"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hmac-sha256"
|
name = "hmac-sha256"
|
||||||
version = "1.1.14"
|
version = "1.1.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f"
|
checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac-sha512"
|
||||||
|
version = "1.1.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "019ece39bbefc17f13f677a690328cb978dbf6790e141a3c24e66372cb38588b"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "home"
|
name = "home"
|
||||||
@@ -2809,6 +3111,15 @@ dependencies = [
|
|||||||
"libm",
|
"libm",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hybrid-array"
|
||||||
|
version = "0.4.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "0.14.32"
|
version = "0.14.32"
|
||||||
@@ -2889,6 +3200,19 @@ dependencies = [
|
|||||||
"webpki-roots 1.0.6",
|
"webpki-roots 1.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-tls"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"hyper 0.14.32",
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-tls"
|
name = "hyper-tls"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@@ -3199,6 +3523,24 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "insta"
|
name = "insta"
|
||||||
version = "1.47.0"
|
version = "1.47.0"
|
||||||
@@ -3300,13 +3642,53 @@ checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"pem",
|
"pem 3.0.6",
|
||||||
"ring",
|
"ring",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"simple_asn1",
|
"simple_asn1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jwt-simple"
|
||||||
|
version = "0.12.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3991f54af4b009bb6efe01aa5a4fcce9ca52f3de7a104a3f6b6e2ad36c852c48"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"binstring",
|
||||||
|
"blake2b_simd",
|
||||||
|
"coarsetime",
|
||||||
|
"ct-codecs",
|
||||||
|
"ed25519-compact",
|
||||||
|
"hmac-sha1-compact",
|
||||||
|
"hmac-sha256",
|
||||||
|
"hmac-sha512",
|
||||||
|
"k256",
|
||||||
|
"p256 0.13.2",
|
||||||
|
"p384",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"superboring",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "k256"
|
||||||
|
version = "0.13.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"ecdsa 0.16.9",
|
||||||
|
"elliptic-curve 0.13.8",
|
||||||
|
"once_cell",
|
||||||
|
"sha2",
|
||||||
|
"signature 2.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kqueue"
|
name = "kqueue"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -4019,6 +4401,12 @@ dependencies = [
|
|||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opaque-debug"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opendal"
|
name = "opendal"
|
||||||
version = "0.54.1"
|
version = "0.54.1"
|
||||||
@@ -4174,8 +4562,32 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594"
|
checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ecdsa",
|
"ecdsa 0.14.8",
|
||||||
"elliptic-curve",
|
"elliptic-curve 0.12.3",
|
||||||
|
"sha2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "p256"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
|
||||||
|
dependencies = [
|
||||||
|
"ecdsa 0.16.9",
|
||||||
|
"elliptic-curve 0.13.8",
|
||||||
|
"primeorder",
|
||||||
|
"sha2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "p384"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
|
||||||
|
dependencies = [
|
||||||
|
"ecdsa 0.16.9",
|
||||||
|
"elliptic-curve 0.13.8",
|
||||||
|
"primeorder",
|
||||||
"sha2",
|
"sha2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4240,6 +4652,17 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pem"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fd56cbd21fea48d0c440b41cd69c589faacade08c992d9a54e471b79d0fd13eb"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.13.1",
|
||||||
|
"once_cell",
|
||||||
|
"regex",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem"
|
name = "pem"
|
||||||
version = "3.0.6"
|
version = "3.0.6"
|
||||||
@@ -4446,6 +4869,18 @@ dependencies = [
|
|||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "polyval"
|
||||||
|
version = "0.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
"opaque-debug",
|
||||||
|
"universal-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portable-atomic"
|
name = "portable-atomic"
|
||||||
version = "1.13.1"
|
version = "1.13.1"
|
||||||
@@ -4511,6 +4946,15 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "primeorder"
|
||||||
|
version = "0.13.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
|
||||||
|
dependencies = [
|
||||||
|
"elliptic-curve 0.13.8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "3.5.0"
|
version = "3.5.0"
|
||||||
@@ -5014,7 +5458,7 @@ dependencies = [
|
|||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper 1.8.1",
|
"hyper 1.8.1",
|
||||||
"hyper-rustls 0.27.7",
|
"hyper-rustls 0.27.7",
|
||||||
"hyper-tls",
|
"hyper-tls 0.6.0",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
@@ -5065,6 +5509,16 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rfc6979"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
||||||
|
dependencies = [
|
||||||
|
"hmac",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rgb"
|
name = "rgb"
|
||||||
version = "0.8.53"
|
version = "0.8.53"
|
||||||
@@ -5139,7 +5593,7 @@ version = "0.9.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"const-oid",
|
"const-oid 0.9.6",
|
||||||
"digest",
|
"digest",
|
||||||
"num-bigint-dig",
|
"num-bigint-dig",
|
||||||
"num-integer",
|
"num-integer",
|
||||||
@@ -5147,6 +5601,7 @@ dependencies = [
|
|||||||
"pkcs1",
|
"pkcs1",
|
||||||
"pkcs8 0.10.2",
|
"pkcs8 0.10.2",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
|
"sha2",
|
||||||
"signature 2.2.0",
|
"signature 2.2.0",
|
||||||
"spki 0.7.3",
|
"spki 0.7.3",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -5579,7 +6034,7 @@ version = "0.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928"
|
checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base16ct",
|
"base16ct 0.1.1",
|
||||||
"der 0.6.1",
|
"der 0.6.1",
|
||||||
"generic-array",
|
"generic-array",
|
||||||
"pkcs8 0.9.0",
|
"pkcs8 0.9.0",
|
||||||
@@ -5587,6 +6042,31 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sec1"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||||
|
dependencies = [
|
||||||
|
"base16ct 0.2.0",
|
||||||
|
"der 0.7.10",
|
||||||
|
"generic-array",
|
||||||
|
"pkcs8 0.10.2",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sec1_decode"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6326ddc956378a0739200b2c30892dccaf198992dfd7323274690b9e188af23"
|
||||||
|
dependencies = [
|
||||||
|
"der 0.4.5",
|
||||||
|
"pem 0.8.3",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -6331,6 +6811,21 @@ version = "2.6.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "superboring"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "af44d8b60bc4ffb966f80d1582d579c84f559419e7abafb948d706fc6f95b3d4"
|
||||||
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
|
"aes-keywrap",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"hmac-sha256",
|
||||||
|
"hmac-sha512",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"rsa",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "1.0.109"
|
version = "1.0.109"
|
||||||
@@ -6362,6 +6857,18 @@ dependencies = [
|
|||||||
"futures-core",
|
"futures-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "synstructure"
|
||||||
|
version = "0.12.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 1.0.109",
|
||||||
|
"unicode-xid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "synstructure"
|
name = "synstructure"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
@@ -6483,6 +6990,7 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
"validator",
|
"validator",
|
||||||
|
"web-push",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7063,6 +7571,16 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "universal-hash"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common 0.1.7",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unsafe-libyaml"
|
name = "unsafe-libyaml"
|
||||||
version = "0.2.11"
|
version = "0.2.11"
|
||||||
@@ -7300,6 +7818,15 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasix"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1757e0d1f8456693c7e5c6c629bdb54884e032aa0bb53c155f6a39f94440d332"
|
||||||
|
dependencies = [
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.114"
|
version = "0.2.114"
|
||||||
@@ -7406,6 +7933,28 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-push"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d5c305b9ee2993ab68b7744b13ef32231d83600dd879ac8183b4c76ae31d28ac"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"chrono",
|
||||||
|
"ct-codecs",
|
||||||
|
"ece",
|
||||||
|
"http 0.2.12",
|
||||||
|
"hyper 0.14.32",
|
||||||
|
"hyper-tls 0.5.0",
|
||||||
|
"jwt-simple",
|
||||||
|
"log",
|
||||||
|
"pem 3.0.6",
|
||||||
|
"sec1_decode",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.91"
|
version = "0.3.91"
|
||||||
@@ -7960,7 +8509,7 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
"synstructure",
|
"synstructure 0.13.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8001,7 +8550,7 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
"synstructure",
|
"synstructure 0.13.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ async-stream = "0.3"
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
aws-config = "1"
|
aws-config = "1"
|
||||||
aws-sdk-s3 = "1"
|
aws-sdk-s3 = "1"
|
||||||
|
web-push = { version = "0.11.0", default-features = false, features = ["hyper-client"] }
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "termi_api-cli"
|
name = "termi_api-cli"
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
FROM rust:1.94-trixie AS builder
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
FROM rust:1.94-trixie AS chef
|
||||||
|
RUN cargo install cargo-chef --locked
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY Cargo.toml Cargo.lock ./
|
FROM chef AS planner
|
||||||
COPY migration/Cargo.toml migration/Cargo.toml
|
COPY . .
|
||||||
COPY src src
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
COPY migration/src migration/src
|
|
||||||
COPY config config
|
|
||||||
COPY assets assets
|
|
||||||
|
|
||||||
|
FROM chef AS builder
|
||||||
|
COPY --from=planner /app/recipe.json recipe.json
|
||||||
|
RUN cargo chef cook --release --locked --recipe-path recipe.json
|
||||||
|
|
||||||
|
COPY . .
|
||||||
RUN cargo build --release --locked --bin termi_api-cli
|
RUN cargo build --release --locked --bin termi_api-cli
|
||||||
|
|
||||||
FROM debian:trixie-slim AS runtime
|
FROM debian:trixie-slim AS runtime
|
||||||
|
|||||||
40
backend/assets/static/404.html
Normal file
40
backend/assets/static/404.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>404 Not Found</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>404</h1>
|
||||||
|
<p>Not Found</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -37,6 +37,10 @@ mod m20260331_000026_create_subscriptions;
|
|||||||
mod m20260331_000027_create_notification_deliveries;
|
mod m20260331_000027_create_notification_deliveries;
|
||||||
mod m20260331_000028_expand_subscriptions_and_deliveries;
|
mod m20260331_000028_expand_subscriptions_and_deliveries;
|
||||||
mod m20260331_000029_add_subscription_popup_settings_to_site_settings;
|
mod m20260331_000029_add_subscription_popup_settings_to_site_settings;
|
||||||
|
mod m20260401_000030_add_public_security_and_web_push_to_site_settings;
|
||||||
|
mod m20260401_000031_add_notification_channel_type_to_site_settings;
|
||||||
|
mod m20260401_000032_add_runtime_security_keys_to_site_settings;
|
||||||
|
mod m20260401_000033_add_taxonomy_metadata_and_media_assets;
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -78,6 +82,10 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260331_000027_create_notification_deliveries::Migration),
|
Box::new(m20260331_000027_create_notification_deliveries::Migration),
|
||||||
Box::new(m20260331_000028_expand_subscriptions_and_deliveries::Migration),
|
Box::new(m20260331_000028_expand_subscriptions_and_deliveries::Migration),
|
||||||
Box::new(m20260331_000029_add_subscription_popup_settings_to_site_settings::Migration),
|
Box::new(m20260331_000029_add_subscription_popup_settings_to_site_settings::Migration),
|
||||||
|
Box::new(m20260401_000030_add_public_security_and_web_push_to_site_settings::Migration),
|
||||||
|
Box::new(m20260401_000031_add_notification_channel_type_to_site_settings::Migration),
|
||||||
|
Box::new(m20260401_000032_add_runtime_security_keys_to_site_settings::Migration),
|
||||||
|
Box::new(m20260401_000033_add_taxonomy_metadata_and_media_assets::Migration),
|
||||||
// inject-above (do not remove this comment)
|
// inject-above (do not remove this comment)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let table = Alias::new("site_settings");
|
||||||
|
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column_if_not_exists(
|
||||||
|
ColumnDef::new(Alias::new("comment_turnstile_enabled"))
|
||||||
|
.boolean()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.add_column_if_not_exists(
|
||||||
|
ColumnDef::new(Alias::new("subscription_turnstile_enabled"))
|
||||||
|
.boolean()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.add_column_if_not_exists(
|
||||||
|
ColumnDef::new(Alias::new("web_push_enabled"))
|
||||||
|
.boolean()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let table = Alias::new("site_settings");
|
||||||
|
|
||||||
|
for column in [
|
||||||
|
"web_push_enabled",
|
||||||
|
"subscription_turnstile_enabled",
|
||||||
|
"comment_turnstile_enabled",
|
||||||
|
] {
|
||||||
|
if manager.has_column("site_settings", column).await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.drop_column(Alias::new(column))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let table = Alias::new("site_settings");
|
||||||
|
|
||||||
|
if !manager
|
||||||
|
.has_column("site_settings", "notification_channel_type")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(Alias::new("notification_channel_type"))
|
||||||
|
.string()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let table = Alias::new("site_settings");
|
||||||
|
|
||||||
|
if manager
|
||||||
|
.has_column("site_settings", "notification_channel_type")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table)
|
||||||
|
.drop_column(Alias::new("notification_channel_type"))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let table = Alias::new("site_settings");
|
||||||
|
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table)
|
||||||
|
.add_column_if_not_exists(
|
||||||
|
ColumnDef::new(Alias::new("turnstile_site_key"))
|
||||||
|
.text()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.add_column_if_not_exists(
|
||||||
|
ColumnDef::new(Alias::new("turnstile_secret_key"))
|
||||||
|
.text()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.add_column_if_not_exists(
|
||||||
|
ColumnDef::new(Alias::new("web_push_vapid_public_key"))
|
||||||
|
.text()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.add_column_if_not_exists(
|
||||||
|
ColumnDef::new(Alias::new("web_push_vapid_private_key"))
|
||||||
|
.text()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.add_column_if_not_exists(
|
||||||
|
ColumnDef::new(Alias::new("web_push_vapid_subject"))
|
||||||
|
.text()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let table = Alias::new("site_settings");
|
||||||
|
|
||||||
|
for column in [
|
||||||
|
"web_push_vapid_subject",
|
||||||
|
"web_push_vapid_private_key",
|
||||||
|
"web_push_vapid_public_key",
|
||||||
|
"turnstile_secret_key",
|
||||||
|
"turnstile_site_key",
|
||||||
|
] {
|
||||||
|
if manager.has_column("site_settings", column).await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.drop_column(Alias::new(column))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
for table_name in ["categories", "tags"] {
|
||||||
|
if !manager.has_column(table_name, "description").await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Alias::new(table_name))
|
||||||
|
.add_column(ColumnDef::new(Alias::new("description")).text().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager.has_column(table_name, "cover_image").await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Alias::new(table_name))
|
||||||
|
.add_column(ColumnDef::new(Alias::new("cover_image")).string().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager.has_column(table_name, "accent_color").await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Alias::new(table_name))
|
||||||
|
.add_column(ColumnDef::new(Alias::new("accent_color")).string().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager.has_column(table_name, "seo_title").await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Alias::new(table_name))
|
||||||
|
.add_column(ColumnDef::new(Alias::new("seo_title")).string().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager.has_column(table_name, "seo_description").await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Alias::new(table_name))
|
||||||
|
.add_column(ColumnDef::new(Alias::new("seo_description")).text().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager.has_table("media_assets").await? {
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(Alias::new("media_assets"))
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Alias::new("created_at"))
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Alias::new("updated_at"))
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Alias::new("id"))
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.auto_increment()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(Alias::new("object_key")).string().not_null())
|
||||||
|
.col(ColumnDef::new(Alias::new("title")).string().null())
|
||||||
|
.col(ColumnDef::new(Alias::new("alt_text")).string().null())
|
||||||
|
.col(ColumnDef::new(Alias::new("caption")).text().null())
|
||||||
|
.col(ColumnDef::new(Alias::new("tags")).json_binary().null())
|
||||||
|
.col(ColumnDef::new(Alias::new("notes")).text().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_media_assets_object_key_unique")
|
||||||
|
.table(Alias::new("media_assets"))
|
||||||
|
.col(Alias::new("object_key"))
|
||||||
|
.unique()
|
||||||
|
.if_not_exists()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
if manager
|
||||||
|
.has_index("media_assets", "idx_media_assets_object_key_unique")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
manager
|
||||||
|
.drop_index(
|
||||||
|
Index::drop()
|
||||||
|
.name("idx_media_assets_object_key_unique")
|
||||||
|
.table(Alias::new("media_assets"))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if manager.has_table("media_assets").await? {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(Alias::new("media_assets")).to_owned())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for table_name in ["categories", "tags"] {
|
||||||
|
for column in [
|
||||||
|
"seo_description",
|
||||||
|
"seo_title",
|
||||||
|
"accent_color",
|
||||||
|
"cover_image",
|
||||||
|
"description",
|
||||||
|
] {
|
||||||
|
if manager.has_column(table_name, column).await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Alias::new(table_name))
|
||||||
|
.drop_column(Alias::new(column))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,6 +106,7 @@ impl Hooks for App {
|
|||||||
AppRoutes::with_default_routes() // controller routes below
|
AppRoutes::with_default_routes() // controller routes below
|
||||||
.add_route(controllers::health::routes())
|
.add_route(controllers::health::routes())
|
||||||
.add_route(controllers::admin_api::routes())
|
.add_route(controllers::admin_api::routes())
|
||||||
|
.add_route(controllers::admin_taxonomy::routes())
|
||||||
.add_route(controllers::admin_ops::routes())
|
.add_route(controllers::admin_ops::routes())
|
||||||
.add_route(controllers::review::routes())
|
.add_route(controllers::review::routes())
|
||||||
.add_route(controllers::category::routes())
|
.add_route(controllers::category::routes())
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ use crate::{
|
|||||||
ai_chunks, comment_blacklist, comment_persona_analysis_logs, comments, friend_links, posts,
|
ai_chunks, comment_blacklist, comment_persona_analysis_logs, comments, friend_links, posts,
|
||||||
reviews,
|
reviews,
|
||||||
},
|
},
|
||||||
services::{admin_audit, ai, analytics, comment_guard, content, storage},
|
services::{admin_audit, ai, analytics, comment_guard, content, media_assets, storage},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
@@ -170,6 +170,14 @@ pub struct AdminSiteSettingsResponse {
|
|||||||
pub music_playlist: Vec<site_settings::MusicTrackPayload>,
|
pub music_playlist: Vec<site_settings::MusicTrackPayload>,
|
||||||
pub ai_enabled: bool,
|
pub ai_enabled: bool,
|
||||||
pub paragraph_comments_enabled: bool,
|
pub paragraph_comments_enabled: bool,
|
||||||
|
pub comment_turnstile_enabled: bool,
|
||||||
|
pub subscription_turnstile_enabled: bool,
|
||||||
|
pub web_push_enabled: bool,
|
||||||
|
pub turnstile_site_key: Option<String>,
|
||||||
|
pub turnstile_secret_key: Option<String>,
|
||||||
|
pub web_push_vapid_public_key: Option<String>,
|
||||||
|
pub web_push_vapid_private_key: Option<String>,
|
||||||
|
pub web_push_vapid_subject: Option<String>,
|
||||||
pub ai_provider: Option<String>,
|
pub ai_provider: Option<String>,
|
||||||
pub ai_api_base: Option<String>,
|
pub ai_api_base: Option<String>,
|
||||||
pub ai_api_key: Option<String>,
|
pub ai_api_key: Option<String>,
|
||||||
@@ -196,6 +204,7 @@ pub struct AdminSiteSettingsResponse {
|
|||||||
pub seo_default_og_image: Option<String>,
|
pub seo_default_og_image: Option<String>,
|
||||||
pub seo_default_twitter_handle: Option<String>,
|
pub seo_default_twitter_handle: Option<String>,
|
||||||
pub notification_webhook_url: Option<String>,
|
pub notification_webhook_url: Option<String>,
|
||||||
|
pub notification_channel_type: String,
|
||||||
pub notification_comment_enabled: bool,
|
pub notification_comment_enabled: bool,
|
||||||
pub notification_friend_link_enabled: bool,
|
pub notification_friend_link_enabled: bool,
|
||||||
pub subscription_popup_enabled: bool,
|
pub subscription_popup_enabled: bool,
|
||||||
@@ -258,6 +267,11 @@ pub struct AdminMediaObjectResponse {
|
|||||||
pub url: String,
|
pub url: String,
|
||||||
pub size_bytes: i64,
|
pub size_bytes: i64,
|
||||||
pub last_modified: Option<String>,
|
pub last_modified: Option<String>,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub alt_text: Option<String>,
|
||||||
|
pub caption: Option<String>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub notes: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
@@ -304,6 +318,32 @@ pub struct AdminMediaReplaceResponse {
|
|||||||
pub url: String,
|
pub url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct AdminMediaMetadataPayload {
|
||||||
|
pub key: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub alt_text: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub caption: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct AdminMediaMetadataResponse {
|
||||||
|
pub saved: bool,
|
||||||
|
pub key: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub alt_text: Option<String>,
|
||||||
|
pub caption: Option<String>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
pub struct AdminMediaListQuery {
|
pub struct AdminMediaListQuery {
|
||||||
pub prefix: Option<String>,
|
pub prefix: Option<String>,
|
||||||
@@ -634,6 +674,25 @@ fn normalize_media_key(value: Option<String>) -> Option<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_media_object_response(
|
||||||
|
item: storage::StoredObjectSummary,
|
||||||
|
metadata: Option<&crate::models::_entities::media_assets::Model>,
|
||||||
|
) -> AdminMediaObjectResponse {
|
||||||
|
AdminMediaObjectResponse {
|
||||||
|
key: item.key,
|
||||||
|
url: item.url,
|
||||||
|
size_bytes: item.size_bytes,
|
||||||
|
last_modified: item.last_modified,
|
||||||
|
title: metadata.and_then(|entry| entry.title.clone()),
|
||||||
|
alt_text: metadata.and_then(|entry| entry.alt_text.clone()),
|
||||||
|
caption: metadata.and_then(|entry| entry.caption.clone()),
|
||||||
|
tags: metadata
|
||||||
|
.map(media_assets::tag_list)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
notes: metadata.and_then(|entry| entry.notes.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn tech_stack_values(value: &Option<serde_json::Value>) -> Vec<String> {
|
fn tech_stack_values(value: &Option<serde_json::Value>) -> Vec<String> {
|
||||||
value
|
value
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -665,6 +724,11 @@ fn build_settings_response(
|
|||||||
) -> AdminSiteSettingsResponse {
|
) -> AdminSiteSettingsResponse {
|
||||||
let ai_providers = site_settings::ai_provider_configs(&item);
|
let ai_providers = site_settings::ai_provider_configs(&item);
|
||||||
let ai_active_provider_id = site_settings::active_ai_provider_id(&item);
|
let ai_active_provider_id = site_settings::active_ai_provider_id(&item);
|
||||||
|
let turnstile_site_key = crate::services::turnstile::site_key(&item);
|
||||||
|
let turnstile_secret_key = crate::services::turnstile::secret_key(&item);
|
||||||
|
let web_push_vapid_public_key = crate::services::web_push::public_key(&item);
|
||||||
|
let web_push_vapid_private_key = crate::services::web_push::private_key(&item);
|
||||||
|
let web_push_vapid_subject = crate::services::web_push::vapid_subject(&item);
|
||||||
|
|
||||||
AdminSiteSettingsResponse {
|
AdminSiteSettingsResponse {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -687,6 +751,14 @@ fn build_settings_response(
|
|||||||
music_playlist: music_playlist_values(&item.music_playlist),
|
music_playlist: music_playlist_values(&item.music_playlist),
|
||||||
ai_enabled: item.ai_enabled.unwrap_or(false),
|
ai_enabled: item.ai_enabled.unwrap_or(false),
|
||||||
paragraph_comments_enabled: item.paragraph_comments_enabled.unwrap_or(true),
|
paragraph_comments_enabled: item.paragraph_comments_enabled.unwrap_or(true),
|
||||||
|
comment_turnstile_enabled: item.comment_turnstile_enabled.unwrap_or(false),
|
||||||
|
subscription_turnstile_enabled: item.subscription_turnstile_enabled.unwrap_or(false),
|
||||||
|
web_push_enabled: item.web_push_enabled.unwrap_or(false),
|
||||||
|
turnstile_site_key,
|
||||||
|
turnstile_secret_key,
|
||||||
|
web_push_vapid_public_key,
|
||||||
|
web_push_vapid_private_key,
|
||||||
|
web_push_vapid_subject,
|
||||||
ai_provider: item.ai_provider,
|
ai_provider: item.ai_provider,
|
||||||
ai_api_base: item.ai_api_base,
|
ai_api_base: item.ai_api_base,
|
||||||
ai_api_key: item.ai_api_key,
|
ai_api_key: item.ai_api_key,
|
||||||
@@ -713,6 +785,9 @@ fn build_settings_response(
|
|||||||
seo_default_og_image: item.seo_default_og_image,
|
seo_default_og_image: item.seo_default_og_image,
|
||||||
seo_default_twitter_handle: item.seo_default_twitter_handle,
|
seo_default_twitter_handle: item.seo_default_twitter_handle,
|
||||||
notification_webhook_url: item.notification_webhook_url,
|
notification_webhook_url: item.notification_webhook_url,
|
||||||
|
notification_channel_type: item
|
||||||
|
.notification_channel_type
|
||||||
|
.unwrap_or_else(|| "webhook".to_string()),
|
||||||
notification_comment_enabled: item.notification_comment_enabled.unwrap_or(false),
|
notification_comment_enabled: item.notification_comment_enabled.unwrap_or(false),
|
||||||
notification_friend_link_enabled: item.notification_friend_link_enabled.unwrap_or(false),
|
notification_friend_link_enabled: item.notification_friend_link_enabled.unwrap_or(false),
|
||||||
subscription_popup_enabled: item
|
subscription_popup_enabled: item
|
||||||
@@ -1115,14 +1190,18 @@ pub async fn list_media_objects(
|
|||||||
check_auth(&headers)?;
|
check_auth(&headers)?;
|
||||||
|
|
||||||
let settings = storage::require_r2_settings(&ctx).await?;
|
let settings = storage::require_r2_settings(&ctx).await?;
|
||||||
let items = storage::list_objects(&ctx, query.prefix.as_deref(), query.limit.unwrap_or(200))
|
let objects = storage::list_objects(&ctx, query.prefix.as_deref(), query.limit.unwrap_or(200))
|
||||||
.await?
|
.await?;
|
||||||
|
let keys = objects
|
||||||
|
.iter()
|
||||||
|
.map(|item| item.key.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let metadata_map = media_assets::list_by_keys(&ctx, &keys).await?;
|
||||||
|
let items = objects
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|item| AdminMediaObjectResponse {
|
.map(|item| {
|
||||||
key: item.key,
|
let metadata = metadata_map.get(&item.key);
|
||||||
url: item.url,
|
build_media_object_response(item, metadata)
|
||||||
size_bytes: item.size_bytes,
|
|
||||||
last_modified: item.last_modified,
|
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
@@ -1148,6 +1227,9 @@ pub async fn delete_media_object(
|
|||||||
}
|
}
|
||||||
|
|
||||||
storage::delete_object(&ctx, key).await?;
|
storage::delete_object(&ctx, key).await?;
|
||||||
|
if let Err(error) = media_assets::delete_by_key(&ctx, key).await {
|
||||||
|
tracing::warn!(?error, key, "failed to delete media metadata after object deletion");
|
||||||
|
}
|
||||||
|
|
||||||
format::json(AdminMediaDeleteResponse {
|
format::json(AdminMediaDeleteResponse {
|
||||||
deleted: true,
|
deleted: true,
|
||||||
@@ -1241,7 +1323,12 @@ pub async fn batch_delete_media_objects(
|
|||||||
|
|
||||||
for key in keys {
|
for key in keys {
|
||||||
match storage::delete_object(&ctx, &key).await {
|
match storage::delete_object(&ctx, &key).await {
|
||||||
Ok(()) => deleted.push(key),
|
Ok(()) => {
|
||||||
|
if let Err(error) = media_assets::delete_by_key(&ctx, &key).await {
|
||||||
|
tracing::warn!(?error, key, "failed to delete media metadata after batch removal");
|
||||||
|
}
|
||||||
|
deleted.push(key)
|
||||||
|
}
|
||||||
Err(_) => failed.push(key),
|
Err(_) => failed.push(key),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1249,6 +1336,43 @@ pub async fn batch_delete_media_objects(
|
|||||||
format::json(AdminMediaBatchDeleteResponse { deleted, failed })
|
format::json(AdminMediaBatchDeleteResponse { deleted, failed })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn update_media_object_metadata(
|
||||||
|
headers: HeaderMap,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(payload): Json<AdminMediaMetadataPayload>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
check_auth(&headers)?;
|
||||||
|
|
||||||
|
let key = payload.key.trim();
|
||||||
|
if key.is_empty() {
|
||||||
|
return Err(Error::BadRequest("缺少对象 key".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = media_assets::upsert_by_key(
|
||||||
|
&ctx,
|
||||||
|
key,
|
||||||
|
media_assets::MediaAssetMetadataInput {
|
||||||
|
title: payload.title,
|
||||||
|
alt_text: payload.alt_text,
|
||||||
|
caption: payload.caption,
|
||||||
|
tags: payload.tags,
|
||||||
|
notes: payload.notes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
format::json(AdminMediaMetadataResponse {
|
||||||
|
saved: true,
|
||||||
|
key: metadata.object_key.clone(),
|
||||||
|
title: metadata.title.clone(),
|
||||||
|
alt_text: metadata.alt_text.clone(),
|
||||||
|
caption: metadata.caption.clone(),
|
||||||
|
tags: media_assets::tag_list(&metadata),
|
||||||
|
notes: metadata.notes.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn replace_media_object(
|
pub async fn replace_media_object(
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -1831,6 +1955,7 @@ pub fn routes() -> Routes {
|
|||||||
"/storage/media/batch-delete",
|
"/storage/media/batch-delete",
|
||||||
post(batch_delete_media_objects),
|
post(batch_delete_media_objects),
|
||||||
)
|
)
|
||||||
|
.add("/storage/media/metadata", patch(update_media_object_metadata))
|
||||||
.add("/storage/media/replace", post(replace_media_object))
|
.add("/storage/media/replace", post(replace_media_object))
|
||||||
.add(
|
.add(
|
||||||
"/comments/blacklist",
|
"/comments/blacklist",
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ use crate::{
|
|||||||
models::_entities::{
|
models::_entities::{
|
||||||
admin_audit_logs, notification_deliveries, post_revisions, subscriptions,
|
admin_audit_logs, notification_deliveries, post_revisions, subscriptions,
|
||||||
},
|
},
|
||||||
services::{admin_audit, post_revisions as revision_service, subscriptions as subscription_service},
|
services::{
|
||||||
|
admin_audit, backups, post_revisions as revision_service,
|
||||||
|
subscriptions as subscription_service,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Deserialize)]
|
#[derive(Clone, Debug, Default, Deserialize)]
|
||||||
@@ -82,6 +85,13 @@ pub struct DigestDispatchRequest {
|
|||||||
pub period: Option<String>,
|
pub period: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct SiteBackupImportRequest {
|
||||||
|
pub backup: backups::SiteBackupDocument,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mode: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct PostRevisionListItem {
|
pub struct PostRevisionListItem {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
@@ -440,6 +450,25 @@ pub async fn send_subscription_digest(
|
|||||||
format::json(summary)
|
format::json(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn export_site_backup(
|
||||||
|
headers: HeaderMap,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
check_auth(&headers)?;
|
||||||
|
format::json(backups::export_site_backup(&ctx).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn import_site_backup(
|
||||||
|
headers: HeaderMap,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(payload): Json<SiteBackupImportRequest>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
check_auth(&headers)?;
|
||||||
|
format::json(backups::import_site_backup(&ctx, payload.backup, payload.mode.as_deref()).await?)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn routes() -> Routes {
|
pub fn routes() -> Routes {
|
||||||
Routes::new()
|
Routes::new()
|
||||||
.prefix("/api/admin")
|
.prefix("/api/admin")
|
||||||
@@ -452,4 +481,6 @@ pub fn routes() -> Routes {
|
|||||||
.add("/subscriptions/digest", post(send_subscription_digest))
|
.add("/subscriptions/digest", post(send_subscription_digest))
|
||||||
.add("/subscriptions/{id}", patch(update_subscription).delete(delete_subscription))
|
.add("/subscriptions/{id}", patch(update_subscription).delete(delete_subscription))
|
||||||
.add("/subscriptions/{id}/test", post(test_subscription))
|
.add("/subscriptions/{id}/test", post(test_subscription))
|
||||||
|
.add("/site-backup/export", get(export_site_backup))
|
||||||
|
.add("/site-backup/import", post(import_site_backup))
|
||||||
}
|
}
|
||||||
|
|||||||
465
backend/src/controllers/admin_taxonomy.rs
Normal file
465
backend/src/controllers/admin_taxonomy.rs
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
#![allow(clippy::missing_errors_doc)]
|
||||||
|
#![allow(clippy::unnecessary_struct_initialization)]
|
||||||
|
#![allow(clippy::unused_async)]
|
||||||
|
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
controllers::admin::check_auth,
|
||||||
|
models::_entities::{categories, posts, tags},
|
||||||
|
services::content,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct TaxonomyPayload {
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub slug: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cover_image: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub accent_color: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub seo_title: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub seo_description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct AdminCategoryRecord {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub count: usize,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub cover_image: Option<String>,
|
||||||
|
pub accent_color: Option<String>,
|
||||||
|
pub seo_title: Option<String>,
|
||||||
|
pub seo_description: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct AdminTagRecord {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub count: usize,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub cover_image: Option<String>,
|
||||||
|
pub accent_color: Option<String>,
|
||||||
|
pub seo_title: Option<String>,
|
||||||
|
pub seo_description: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slugify(value: &str) -> String {
|
||||||
|
let mut slug = String::new();
|
||||||
|
let mut last_was_dash = false;
|
||||||
|
|
||||||
|
for ch in value.trim().chars() {
|
||||||
|
if ch.is_ascii_alphanumeric() {
|
||||||
|
slug.push(ch.to_ascii_lowercase());
|
||||||
|
last_was_dash = false;
|
||||||
|
} else if (ch.is_whitespace() || ch == '-' || ch == '_') && !last_was_dash {
|
||||||
|
slug.push('-');
|
||||||
|
last_was_dash = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slug.trim_matches('-').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_name(params: &TaxonomyPayload, label: &str) -> Result<String> {
|
||||||
|
params
|
||||||
|
.name
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.ok_or_else(|| Error::BadRequest(format!("{label}名称不能为空")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_slug(value: Option<&str>, fallback: &str, label: &str) -> Result<String> {
|
||||||
|
let slug = value
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|item| !item.is_empty())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.unwrap_or_else(|| slugify(fallback));
|
||||||
|
|
||||||
|
if slug.is_empty() {
|
||||||
|
return Err(Error::BadRequest(format!(
|
||||||
|
"{label} slug 不能为空,请填写英文字母 / 数字 / 连字符"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_token(value: &str) -> String {
|
||||||
|
value.trim().to_ascii_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_to_option(value: Option<String>) -> Option<String> {
|
||||||
|
value.and_then(|item| {
|
||||||
|
let trimmed = item.trim().to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn post_tag_values(post: &posts::Model) -> Vec<String> {
|
||||||
|
post.tags
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| serde_json::from_value::<Vec<String>>(value.clone()).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| normalized_token(&item))
|
||||||
|
.filter(|item| !item.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn category_name(item: &categories::Model) -> String {
|
||||||
|
item.name.clone().unwrap_or_else(|| item.slug.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tag_name(item: &tags::Model) -> String {
|
||||||
|
item.name.clone().unwrap_or_else(|| item.slug.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_category_record(item: &categories::Model, post_items: &[posts::Model]) -> AdminCategoryRecord {
|
||||||
|
let name = category_name(item);
|
||||||
|
let aliases = [normalized_token(&name), normalized_token(&item.slug)];
|
||||||
|
let count = post_items
|
||||||
|
.iter()
|
||||||
|
.filter(|post| {
|
||||||
|
post.category
|
||||||
|
.as_deref()
|
||||||
|
.map(normalized_token)
|
||||||
|
.is_some_and(|value| aliases.iter().any(|alias| alias == &value))
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
|
||||||
|
AdminCategoryRecord {
|
||||||
|
id: item.id,
|
||||||
|
name,
|
||||||
|
slug: item.slug.clone(),
|
||||||
|
count,
|
||||||
|
description: item.description.clone(),
|
||||||
|
cover_image: item.cover_image.clone(),
|
||||||
|
accent_color: item.accent_color.clone(),
|
||||||
|
seo_title: item.seo_title.clone(),
|
||||||
|
seo_description: item.seo_description.clone(),
|
||||||
|
created_at: item.created_at.to_rfc3339(),
|
||||||
|
updated_at: item.updated_at.to_rfc3339(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_tag_record(item: &tags::Model, post_items: &[posts::Model]) -> AdminTagRecord {
|
||||||
|
let name = tag_name(item);
|
||||||
|
let aliases = [normalized_token(&name), normalized_token(&item.slug)];
|
||||||
|
let count = post_items
|
||||||
|
.iter()
|
||||||
|
.filter(|post| {
|
||||||
|
post_tag_values(post)
|
||||||
|
.into_iter()
|
||||||
|
.any(|value| aliases.iter().any(|alias| alias == &value))
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
|
||||||
|
AdminTagRecord {
|
||||||
|
id: item.id,
|
||||||
|
name,
|
||||||
|
slug: item.slug.clone(),
|
||||||
|
count,
|
||||||
|
description: item.description.clone(),
|
||||||
|
cover_image: item.cover_image.clone(),
|
||||||
|
accent_color: item.accent_color.clone(),
|
||||||
|
seo_title: item.seo_title.clone(),
|
||||||
|
seo_description: item.seo_description.clone(),
|
||||||
|
created_at: item.created_at.to_rfc3339(),
|
||||||
|
updated_at: item.updated_at.to_rfc3339(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_category(ctx: &AppContext, id: i32) -> Result<categories::Model> {
|
||||||
|
categories::Entity::find_by_id(id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(Error::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_tag(ctx: &AppContext, id: i32) -> Result<tags::Model> {
|
||||||
|
tags::Entity::find_by_id(id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(Error::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_category_slug_unique(
|
||||||
|
ctx: &AppContext,
|
||||||
|
slug: &str,
|
||||||
|
exclude_id: Option<i32>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if let Some(existing) = categories::Entity::find()
|
||||||
|
.filter(categories::Column::Slug.eq(slug))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
if Some(existing.id) != exclude_id {
|
||||||
|
return Err(Error::BadRequest("分类 slug 已存在".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_tag_slug_unique(ctx: &AppContext, slug: &str, exclude_id: Option<i32>) -> Result<()> {
|
||||||
|
if let Some(existing) = tags::Entity::find()
|
||||||
|
.filter(tags::Column::Slug.eq(slug))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
if Some(existing.id) != exclude_id {
|
||||||
|
return Err(Error::BadRequest("标签 slug 已存在".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_posts(ctx: &AppContext) -> Result<Vec<posts::Model>> {
|
||||||
|
Ok(posts::Entity::find().all(&ctx.db).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn list_categories(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
check_auth(&headers)?;
|
||||||
|
content::sync_markdown_posts(&ctx).await?;
|
||||||
|
|
||||||
|
let items = categories::Entity::find()
|
||||||
|
.order_by_asc(categories::Column::Slug)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let post_items = load_posts(&ctx).await?;
|
||||||
|
|
||||||
|
format::json(
|
||||||
|
items.into_iter()
|
||||||
|
.map(|item| build_category_record(&item, &post_items))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn create_category(
|
||||||
|
headers: HeaderMap,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(payload): Json<TaxonomyPayload>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
check_auth(&headers)?;
|
||||||
|
|
||||||
|
let name = normalized_name(&payload, "分类")?;
|
||||||
|
let slug = normalized_slug(payload.slug.as_deref(), &name, "分类")?;
|
||||||
|
ensure_category_slug_unique(&ctx, &slug, None).await?;
|
||||||
|
|
||||||
|
let item = categories::ActiveModel {
|
||||||
|
name: Set(Some(name)),
|
||||||
|
slug: Set(slug),
|
||||||
|
description: Set(trim_to_option(payload.description)),
|
||||||
|
cover_image: Set(trim_to_option(payload.cover_image)),
|
||||||
|
accent_color: Set(trim_to_option(payload.accent_color)),
|
||||||
|
seo_title: Set(trim_to_option(payload.seo_title)),
|
||||||
|
seo_description: Set(trim_to_option(payload.seo_description)),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let post_items = load_posts(&ctx).await?;
|
||||||
|
|
||||||
|
format::json(build_category_record(&item, &post_items))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn update_category(
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(payload): Json<TaxonomyPayload>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
check_auth(&headers)?;
|
||||||
|
|
||||||
|
let name = normalized_name(&payload, "分类")?;
|
||||||
|
let slug = normalized_slug(payload.slug.as_deref(), &name, "分类")?;
|
||||||
|
ensure_category_slug_unique(&ctx, &slug, Some(id)).await?;
|
||||||
|
|
||||||
|
let item = load_category(&ctx, id).await?;
|
||||||
|
let previous_name = item.name.clone();
|
||||||
|
let previous_slug = item.slug.clone();
|
||||||
|
|
||||||
|
if previous_name
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
!= Some(name.as_str())
|
||||||
|
{
|
||||||
|
content::rewrite_category_references(previous_name.as_deref(), &previous_slug, Some(&name))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut active = item.into_active_model();
|
||||||
|
active.name = Set(Some(name));
|
||||||
|
active.slug = Set(slug);
|
||||||
|
active.description = Set(trim_to_option(payload.description));
|
||||||
|
active.cover_image = Set(trim_to_option(payload.cover_image));
|
||||||
|
active.accent_color = Set(trim_to_option(payload.accent_color));
|
||||||
|
active.seo_title = Set(trim_to_option(payload.seo_title));
|
||||||
|
active.seo_description = Set(trim_to_option(payload.seo_description));
|
||||||
|
let updated = active.update(&ctx.db).await?;
|
||||||
|
content::sync_markdown_posts(&ctx).await?;
|
||||||
|
let post_items = load_posts(&ctx).await?;
|
||||||
|
|
||||||
|
format::json(build_category_record(&updated, &post_items))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn delete_category(
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
check_auth(&headers)?;
|
||||||
|
|
||||||
|
let item = load_category(&ctx, id).await?;
|
||||||
|
content::rewrite_category_references(item.name.as_deref(), &item.slug, None)?;
|
||||||
|
item.delete(&ctx.db).await?;
|
||||||
|
content::sync_markdown_posts(&ctx).await?;
|
||||||
|
|
||||||
|
format::empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn list_tags(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
check_auth(&headers)?;
|
||||||
|
content::sync_markdown_posts(&ctx).await?;
|
||||||
|
|
||||||
|
let items = tags::Entity::find()
|
||||||
|
.order_by_asc(tags::Column::Slug)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let post_items = load_posts(&ctx).await?;
|
||||||
|
|
||||||
|
format::json(
|
||||||
|
items.into_iter()
|
||||||
|
.map(|item| build_tag_record(&item, &post_items))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn create_tag(
|
||||||
|
headers: HeaderMap,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(payload): Json<TaxonomyPayload>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
check_auth(&headers)?;
|
||||||
|
|
||||||
|
let name = normalized_name(&payload, "标签")?;
|
||||||
|
let slug = normalized_slug(payload.slug.as_deref(), &name, "标签")?;
|
||||||
|
ensure_tag_slug_unique(&ctx, &slug, None).await?;
|
||||||
|
|
||||||
|
let item = tags::ActiveModel {
|
||||||
|
name: Set(Some(name)),
|
||||||
|
slug: Set(slug),
|
||||||
|
description: Set(trim_to_option(payload.description)),
|
||||||
|
cover_image: Set(trim_to_option(payload.cover_image)),
|
||||||
|
accent_color: Set(trim_to_option(payload.accent_color)),
|
||||||
|
seo_title: Set(trim_to_option(payload.seo_title)),
|
||||||
|
seo_description: Set(trim_to_option(payload.seo_description)),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let post_items = load_posts(&ctx).await?;
|
||||||
|
|
||||||
|
format::json(build_tag_record(&item, &post_items))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn update_tag(
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(payload): Json<TaxonomyPayload>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
check_auth(&headers)?;
|
||||||
|
|
||||||
|
let name = normalized_name(&payload, "标签")?;
|
||||||
|
let slug = normalized_slug(payload.slug.as_deref(), &name, "标签")?;
|
||||||
|
ensure_tag_slug_unique(&ctx, &slug, Some(id)).await?;
|
||||||
|
|
||||||
|
let item = load_tag(&ctx, id).await?;
|
||||||
|
let previous_name = item.name.clone();
|
||||||
|
let previous_slug = item.slug.clone();
|
||||||
|
|
||||||
|
if previous_name
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
!= Some(name.as_str())
|
||||||
|
{
|
||||||
|
content::rewrite_tag_references(previous_name.as_deref(), &previous_slug, Some(&name))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut active = item.into_active_model();
|
||||||
|
active.name = Set(Some(name));
|
||||||
|
active.slug = Set(slug);
|
||||||
|
active.description = Set(trim_to_option(payload.description));
|
||||||
|
active.cover_image = Set(trim_to_option(payload.cover_image));
|
||||||
|
active.accent_color = Set(trim_to_option(payload.accent_color));
|
||||||
|
active.seo_title = Set(trim_to_option(payload.seo_title));
|
||||||
|
active.seo_description = Set(trim_to_option(payload.seo_description));
|
||||||
|
let updated = active.update(&ctx.db).await?;
|
||||||
|
content::sync_markdown_posts(&ctx).await?;
|
||||||
|
let post_items = load_posts(&ctx).await?;
|
||||||
|
|
||||||
|
format::json(build_tag_record(&updated, &post_items))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn delete_tag(
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
check_auth(&headers)?;
|
||||||
|
|
||||||
|
let item = load_tag(&ctx, id).await?;
|
||||||
|
content::rewrite_tag_references(item.name.as_deref(), &item.slug, None)?;
|
||||||
|
item.delete(&ctx.db).await?;
|
||||||
|
content::sync_markdown_posts(&ctx).await?;
|
||||||
|
|
||||||
|
format::empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.add(
|
||||||
|
"/api/admin/categories",
|
||||||
|
get(list_categories).post(create_category),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
"/api/admin/categories/{id}",
|
||||||
|
patch(update_category).delete(delete_category),
|
||||||
|
)
|
||||||
|
.add("/api/admin/tags", get(list_tags).post(create_tag))
|
||||||
|
.add("/api/admin/tags/{id}", patch(update_tag).delete(delete_tag))
|
||||||
|
}
|
||||||
@@ -14,12 +14,41 @@ pub struct CategorySummary {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
pub count: usize,
|
pub count: usize,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub cover_image: Option<String>,
|
||||||
|
pub accent_color: Option<String>,
|
||||||
|
pub seo_title: Option<String>,
|
||||||
|
pub seo_description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct CategoryRecord {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub slug: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub cover_image: Option<String>,
|
||||||
|
pub accent_color: Option<String>,
|
||||||
|
pub seo_title: Option<String>,
|
||||||
|
pub seo_description: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct Params {
|
pub struct Params {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub slug: Option<String>,
|
pub slug: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cover_image: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub accent_color: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub seo_title: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub seo_description: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn slugify(value: &str) -> String {
|
fn slugify(value: &str) -> String {
|
||||||
@@ -39,6 +68,17 @@ fn slugify(value: &str) -> String {
|
|||||||
slug.trim_matches('-').to_string()
|
slug.trim_matches('-').to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn trim_to_option(value: Option<String>) -> Option<String> {
|
||||||
|
value.and_then(|item| {
|
||||||
|
let trimmed = item.trim().to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn normalized_name(params: &Params) -> Result<String> {
|
fn normalized_name(params: &Params) -> Result<String> {
|
||||||
let name = params
|
let name = params
|
||||||
.name
|
.name
|
||||||
@@ -60,6 +100,50 @@ fn normalized_slug(params: &Params, fallback: &str) -> String {
|
|||||||
.unwrap_or_else(|| slugify(fallback))
|
.unwrap_or_else(|| slugify(fallback))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn category_name(item: &categories::Model) -> String {
|
||||||
|
item.name.clone().unwrap_or_else(|| item.slug.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_summary(item: &categories::Model, post_items: &[posts::Model]) -> CategorySummary {
|
||||||
|
let name = category_name(item);
|
||||||
|
let count = post_items
|
||||||
|
.iter()
|
||||||
|
.filter(|post| {
|
||||||
|
post.category
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.is_some_and(|value| value.eq_ignore_ascii_case(&name) || value.eq_ignore_ascii_case(&item.slug))
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
|
||||||
|
CategorySummary {
|
||||||
|
id: item.id,
|
||||||
|
name,
|
||||||
|
slug: item.slug.clone(),
|
||||||
|
count,
|
||||||
|
description: item.description.clone(),
|
||||||
|
cover_image: item.cover_image.clone(),
|
||||||
|
accent_color: item.accent_color.clone(),
|
||||||
|
seo_title: item.seo_title.clone(),
|
||||||
|
seo_description: item.seo_description.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_record(item: categories::Model) -> CategoryRecord {
|
||||||
|
CategoryRecord {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
slug: item.slug,
|
||||||
|
description: item.description,
|
||||||
|
cover_image: item.cover_image,
|
||||||
|
accent_color: item.accent_color,
|
||||||
|
seo_title: item.seo_title,
|
||||||
|
seo_description: item.seo_description,
|
||||||
|
created_at: item.created_at.to_rfc3339(),
|
||||||
|
updated_at: item.updated_at.to_rfc3339(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn load_item(ctx: &AppContext, id: i32) -> Result<categories::Model> {
|
async fn load_item(ctx: &AppContext, id: i32) -> Result<categories::Model> {
|
||||||
let item = categories::Entity::find_by_id(id).one(&ctx.db).await?;
|
let item = categories::Entity::find_by_id(id).one(&ctx.db).await?;
|
||||||
item.ok_or(Error::NotFound)
|
item.ok_or(Error::NotFound)
|
||||||
@@ -77,23 +161,7 @@ pub async fn list(State(ctx): State<AppContext>) -> Result<Response> {
|
|||||||
|
|
||||||
let categories = category_items
|
let categories = category_items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|category| {
|
.map(|category| build_summary(&category, &post_items))
|
||||||
let name = category
|
|
||||||
.name
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| category.slug.clone());
|
|
||||||
let count = post_items
|
|
||||||
.iter()
|
|
||||||
.filter(|post| post.category.as_deref().map(str::trim) == Some(name.as_str()))
|
|
||||||
.count();
|
|
||||||
|
|
||||||
CategorySummary {
|
|
||||||
id: category.id,
|
|
||||||
name,
|
|
||||||
slug: category.slug,
|
|
||||||
count,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
format::json(categories)
|
format::json(categories)
|
||||||
@@ -113,18 +181,28 @@ pub async fn add(State(ctx): State<AppContext>, Json(params): Json<Params>) -> R
|
|||||||
let mut model = existing_category.into_active_model();
|
let mut model = existing_category.into_active_model();
|
||||||
model.name = Set(Some(name));
|
model.name = Set(Some(name));
|
||||||
model.slug = Set(slug);
|
model.slug = Set(slug);
|
||||||
|
model.description = Set(trim_to_option(params.description));
|
||||||
|
model.cover_image = Set(trim_to_option(params.cover_image));
|
||||||
|
model.accent_color = Set(trim_to_option(params.accent_color));
|
||||||
|
model.seo_title = Set(trim_to_option(params.seo_title));
|
||||||
|
model.seo_description = Set(trim_to_option(params.seo_description));
|
||||||
model.update(&ctx.db).await?
|
model.update(&ctx.db).await?
|
||||||
} else {
|
} else {
|
||||||
categories::ActiveModel {
|
categories::ActiveModel {
|
||||||
name: Set(Some(name)),
|
name: Set(Some(name)),
|
||||||
slug: Set(slug),
|
slug: Set(slug),
|
||||||
|
description: Set(trim_to_option(params.description)),
|
||||||
|
cover_image: Set(trim_to_option(params.cover_image)),
|
||||||
|
accent_color: Set(trim_to_option(params.accent_color)),
|
||||||
|
seo_title: Set(trim_to_option(params.seo_title)),
|
||||||
|
seo_description: Set(trim_to_option(params.seo_description)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.insert(&ctx.db)
|
.insert(&ctx.db)
|
||||||
.await?
|
.await?
|
||||||
};
|
};
|
||||||
|
|
||||||
format::json(item)
|
format::json(build_record(item))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
@@ -155,9 +233,14 @@ pub async fn update(
|
|||||||
let mut item = item.into_active_model();
|
let mut item = item.into_active_model();
|
||||||
item.name = Set(Some(name));
|
item.name = Set(Some(name));
|
||||||
item.slug = Set(slug);
|
item.slug = Set(slug);
|
||||||
|
item.description = Set(trim_to_option(params.description));
|
||||||
|
item.cover_image = Set(trim_to_option(params.cover_image));
|
||||||
|
item.accent_color = Set(trim_to_option(params.accent_color));
|
||||||
|
item.seo_title = Set(trim_to_option(params.seo_title));
|
||||||
|
item.seo_description = Set(trim_to_option(params.seo_description));
|
||||||
let item = item.update(&ctx.db).await?;
|
let item = item.update(&ctx.db).await?;
|
||||||
content::sync_markdown_posts(&ctx).await?;
|
content::sync_markdown_posts(&ctx).await?;
|
||||||
format::json(item)
|
format::json(build_record(item))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
@@ -171,7 +254,7 @@ pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Resul
|
|||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
format::json(load_item(&ctx, id).await?)
|
format::json(build_record(load_item(&ctx, id).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn routes() -> Routes {
|
pub fn routes() -> Routes {
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ pub struct CreateCommentRequest {
|
|||||||
pub captcha_token: Option<String>,
|
pub captcha_token: Option<String>,
|
||||||
#[serde(default, alias = "captchaAnswer")]
|
#[serde(default, alias = "captchaAnswer")]
|
||||||
pub captcha_answer: Option<String>,
|
pub captcha_answer: Option<String>,
|
||||||
|
#[serde(default, alias = "turnstileToken")]
|
||||||
|
pub turnstile_token: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub website: Option<String>,
|
pub website: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -383,6 +385,7 @@ pub async fn add(
|
|||||||
author: author.as_deref(),
|
author: author.as_deref(),
|
||||||
content: content.as_deref(),
|
content: content.as_deref(),
|
||||||
honeypot_website: params.website.as_deref(),
|
honeypot_website: params.website.as_deref(),
|
||||||
|
turnstile_token: params.turnstile_token.as_deref(),
|
||||||
captcha_token: params.captcha_token.as_deref(),
|
captcha_token: params.captcha_token.as_deref(),
|
||||||
captcha_answer: params.captcha_answer.as_deref(),
|
captcha_answer: params.captcha_answer.as_deref(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod admin;
|
pub mod admin;
|
||||||
pub mod admin_api;
|
pub mod admin_api;
|
||||||
|
pub mod admin_taxonomy;
|
||||||
pub mod admin_ops;
|
pub mod admin_ops;
|
||||||
pub mod ai;
|
pub mod ai;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
|||||||
@@ -95,6 +95,60 @@ fn publicly_accessible(post: &Model) -> bool {
|
|||||||
content::is_post_publicly_accessible(post, Utc::now().fixed_offset())
|
content::is_post_publicly_accessible(post, Utc::now().fixed_offset())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_post_sort_by(value: Option<&str>) -> String {
|
||||||
|
match value
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"updated_at" | "updated" => "updated_at".to_string(),
|
||||||
|
"title" => "title".to_string(),
|
||||||
|
_ => "created_at".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_sort_order(value: Option<&str>) -> String {
|
||||||
|
match value
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"asc" => "asc".to_string(),
|
||||||
|
_ => "desc".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sort_posts(items: &mut [Model], sort_by: &str, sort_order: &str) {
|
||||||
|
items.sort_by(|left, right| {
|
||||||
|
let ordering = match sort_by {
|
||||||
|
"updated_at" => left.updated_at.cmp(&right.updated_at),
|
||||||
|
"title" => left
|
||||||
|
.title
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(&left.slug)
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.cmp(
|
||||||
|
&right
|
||||||
|
.title
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(&right.slug)
|
||||||
|
.to_ascii_lowercase(),
|
||||||
|
),
|
||||||
|
_ => left.created_at.cmp(&right.created_at),
|
||||||
|
};
|
||||||
|
|
||||||
|
let ordering = if sort_order == "asc" {
|
||||||
|
ordering
|
||||||
|
} else {
|
||||||
|
ordering.reverse()
|
||||||
|
};
|
||||||
|
|
||||||
|
ordering.then_with(|| left.id.cmp(&right.id))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_optional_markdown_datetime(
|
fn parse_optional_markdown_datetime(
|
||||||
value: Option<&str>,
|
value: Option<&str>,
|
||||||
) -> Option<chrono::DateTime<chrono::FixedOffset>> {
|
) -> Option<chrono::DateTime<chrono::FixedOffset>> {
|
||||||
@@ -388,6 +442,28 @@ pub struct ListQuery {
|
|||||||
pub preview: Option<bool>,
|
pub preview: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize)]
|
||||||
|
pub struct PagedPostsQuery {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub filters: ListQuery,
|
||||||
|
pub page: Option<u64>,
|
||||||
|
#[serde(alias = "page_size")]
|
||||||
|
pub page_size: Option<u64>,
|
||||||
|
pub sort_by: Option<String>,
|
||||||
|
pub sort_order: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct PagedPostsResponse {
|
||||||
|
pub items: Vec<Model>,
|
||||||
|
pub page: u64,
|
||||||
|
pub page_size: u64,
|
||||||
|
pub total: usize,
|
||||||
|
pub total_pages: u64,
|
||||||
|
pub sort_by: String,
|
||||||
|
pub sort_order: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Deserialize)]
|
#[derive(Clone, Debug, Default, Deserialize)]
|
||||||
pub struct LookupQuery {
|
pub struct LookupQuery {
|
||||||
#[serde(default, deserialize_with = "deserialize_boolish_option")]
|
#[serde(default, deserialize_with = "deserialize_boolish_option")]
|
||||||
@@ -469,6 +545,61 @@ pub async fn list(
|
|||||||
format::json(filtered)
|
format::json(filtered)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn list_page(
|
||||||
|
Query(query): Query<PagedPostsQuery>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Response> {
|
||||||
|
content::sync_markdown_posts(&ctx).await?;
|
||||||
|
|
||||||
|
let preview = request_preview_mode(query.filters.preview, &headers);
|
||||||
|
let include_private = preview && query.filters.include_private.unwrap_or(true);
|
||||||
|
let include_redirects = query.filters.include_redirects.unwrap_or(preview);
|
||||||
|
let page_size = query.page_size.unwrap_or(20).clamp(1, 100);
|
||||||
|
let sort_by = normalize_post_sort_by(query.sort_by.as_deref());
|
||||||
|
let sort_order = normalize_sort_order(query.sort_order.as_deref());
|
||||||
|
|
||||||
|
let mut filtered = Entity::find()
|
||||||
|
.order_by_desc(Column::CreatedAt)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|post| {
|
||||||
|
should_include_post(
|
||||||
|
post,
|
||||||
|
&query.filters,
|
||||||
|
preview,
|
||||||
|
include_private,
|
||||||
|
include_redirects,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
sort_posts(&mut filtered, &sort_by, &sort_order);
|
||||||
|
|
||||||
|
let total = filtered.len();
|
||||||
|
let total_pages = std::cmp::max(1, ((total as u64) + page_size - 1) / page_size);
|
||||||
|
let page = query.page.unwrap_or(1).clamp(1, total_pages);
|
||||||
|
let start = ((page - 1) * page_size) as usize;
|
||||||
|
let end = std::cmp::min(start + page_size as usize, total);
|
||||||
|
let items = if start >= total {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
filtered[start..end].to_vec()
|
||||||
|
};
|
||||||
|
|
||||||
|
format::json(PagedPostsResponse {
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
total,
|
||||||
|
total_pages,
|
||||||
|
sort_by,
|
||||||
|
sort_order,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn add(
|
pub async fn add(
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -876,6 +1007,7 @@ pub async fn delete_markdown_by_slug(
|
|||||||
pub fn routes() -> Routes {
|
pub fn routes() -> Routes {
|
||||||
Routes::new()
|
Routes::new()
|
||||||
.prefix("api/posts/")
|
.prefix("api/posts/")
|
||||||
|
.add("page", get(list_page))
|
||||||
.add("/", get(list))
|
.add("/", get(list))
|
||||||
.add("/", post(add))
|
.add("/", post(add))
|
||||||
.add("markdown", post(create_markdown))
|
.add("markdown", post(create_markdown))
|
||||||
|
|||||||
@@ -274,6 +274,71 @@ fn is_preview_search(query: &SearchQuery, headers: &HeaderMap) -> bool {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_search_sort_by(value: Option<&str>) -> String {
|
||||||
|
match value
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"newest" | "created_at" => "newest".to_string(),
|
||||||
|
"oldest" => "oldest".to_string(),
|
||||||
|
"title" => "title".to_string(),
|
||||||
|
_ => "relevance".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_sort_order(value: Option<&str>, sort_by: &str) -> String {
|
||||||
|
match value
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"asc" => "asc".to_string(),
|
||||||
|
"desc" => "desc".to_string(),
|
||||||
|
_ if sort_by == "title" => "asc".to_string(),
|
||||||
|
_ => "desc".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sort_search_results(items: &mut [SearchResult], sort_by: &str, sort_order: &str) {
|
||||||
|
items.sort_by(|left, right| {
|
||||||
|
let ordering = match sort_by {
|
||||||
|
"newest" => right.created_at.cmp(&left.created_at),
|
||||||
|
"oldest" => left.created_at.cmp(&right.created_at),
|
||||||
|
"title" => left
|
||||||
|
.title
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(&left.slug)
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.cmp(
|
||||||
|
&right
|
||||||
|
.title
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(&right.slug)
|
||||||
|
.to_ascii_lowercase(),
|
||||||
|
),
|
||||||
|
_ => right
|
||||||
|
.rank
|
||||||
|
.partial_cmp(&left.rank)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
.then_with(|| right.created_at.cmp(&left.created_at)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if sort_by == "relevance" || sort_by == "newest" || sort_by == "oldest" {
|
||||||
|
return ordering;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ordering = if sort_order == "asc" {
|
||||||
|
ordering
|
||||||
|
} else {
|
||||||
|
ordering.reverse()
|
||||||
|
};
|
||||||
|
ordering.then_with(|| left.slug.cmp(&right.slug))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Deserialize)]
|
#[derive(Clone, Debug, Default, Deserialize)]
|
||||||
pub struct SearchQuery {
|
pub struct SearchQuery {
|
||||||
pub q: Option<String>,
|
pub q: Option<String>,
|
||||||
@@ -286,6 +351,17 @@ pub struct SearchQuery {
|
|||||||
pub preview: Option<bool>,
|
pub preview: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize)]
|
||||||
|
pub struct SearchPageQuery {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub search: SearchQuery,
|
||||||
|
pub page: Option<u64>,
|
||||||
|
#[serde(alias = "page_size")]
|
||||||
|
pub page_size: Option<u64>,
|
||||||
|
pub sort_by: Option<String>,
|
||||||
|
pub sort_order: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct SearchResult {
|
pub struct SearchResult {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
@@ -296,37 +372,47 @@ pub struct SearchResult {
|
|||||||
pub category: Option<String>,
|
pub category: Option<String>,
|
||||||
pub tags: Option<Value>,
|
pub tags: Option<Value>,
|
||||||
pub post_type: Option<String>,
|
pub post_type: Option<String>,
|
||||||
|
pub image: Option<String>,
|
||||||
pub pinned: Option<bool>,
|
pub pinned: Option<bool>,
|
||||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
pub rank: f64,
|
pub rank: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub async fn search(
|
pub struct PagedSearchResponse {
|
||||||
Query(query): Query<SearchQuery>,
|
pub query: String,
|
||||||
State(ctx): State<AppContext>,
|
pub items: Vec<SearchResult>,
|
||||||
headers: HeaderMap,
|
pub page: u64,
|
||||||
) -> Result<Response> {
|
pub page_size: u64,
|
||||||
let started_at = Instant::now();
|
pub total: usize,
|
||||||
let preview_search = is_preview_search(&query, &headers);
|
pub total_pages: u64,
|
||||||
content::sync_markdown_posts(&ctx).await?;
|
pub sort_by: String,
|
||||||
|
pub sort_order: String,
|
||||||
|
}
|
||||||
|
|
||||||
let q = query.q.unwrap_or_default().trim().to_string();
|
async fn build_search_results(
|
||||||
|
ctx: &AppContext,
|
||||||
|
query: &SearchQuery,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
) -> Result<(String, bool, Vec<SearchResult>)> {
|
||||||
|
let preview_search = is_preview_search(query, headers);
|
||||||
|
content::sync_markdown_posts(ctx).await?;
|
||||||
|
|
||||||
|
let q = query.q.clone().unwrap_or_default().trim().to_string();
|
||||||
if q.is_empty() {
|
if q.is_empty() {
|
||||||
return format::json(Vec::<SearchResult>::new());
|
return Ok((q, preview_search, Vec::new()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !preview_search {
|
if !preview_search {
|
||||||
abuse_guard::enforce_public_scope(
|
abuse_guard::enforce_public_scope(
|
||||||
"search",
|
"search",
|
||||||
abuse_guard::detect_client_ip(&headers).as_deref(),
|
abuse_guard::detect_client_ip(headers).as_deref(),
|
||||||
Some(&q),
|
Some(&q),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let limit = query.limit.unwrap_or(20).clamp(1, 100) as usize;
|
let settings = site_settings::load_current(ctx).await.ok();
|
||||||
let settings = site_settings::load_current(&ctx).await.ok();
|
|
||||||
let synonym_groups = settings
|
let synonym_groups = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|item| parse_synonym_groups(&item.search_synonyms))
|
.map(|item| parse_synonym_groups(&item.search_synonyms))
|
||||||
@@ -342,7 +428,12 @@ pub async fn search(
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
if let Some(category) = query.category.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
if let Some(category) = query
|
||||||
|
.category
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
all_posts.retain(|post| {
|
all_posts.retain(|post| {
|
||||||
post.category
|
post.category
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -355,7 +446,12 @@ pub async fn search(
|
|||||||
all_posts.retain(|post| post_has_tag(post, tag));
|
all_posts.retain(|post| post_has_tag(post, tag));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(post_type) = query.post_type.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
if let Some(post_type) = query
|
||||||
|
.post_type
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
all_posts.retain(|post| {
|
all_posts.retain(|post| {
|
||||||
post.post_type
|
post.post_type
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -378,6 +474,7 @@ pub async fn search(
|
|||||||
category: post.category.clone(),
|
category: post.category.clone(),
|
||||||
tags: post.tags.clone(),
|
tags: post.tags.clone(),
|
||||||
post_type: post.post_type.clone(),
|
post_type: post.post_type.clone(),
|
||||||
|
image: post.image.clone(),
|
||||||
pinned: post.pinned,
|
pinned: post.pinned,
|
||||||
created_at: post.created_at.into(),
|
created_at: post.created_at.into(),
|
||||||
updated_at: post.updated_at.into(),
|
updated_at: post.updated_at.into(),
|
||||||
@@ -401,6 +498,7 @@ pub async fn search(
|
|||||||
category: post.category.clone(),
|
category: post.category.clone(),
|
||||||
tags: post.tags.clone(),
|
tags: post.tags.clone(),
|
||||||
post_type: post.post_type.clone(),
|
post_type: post.post_type.clone(),
|
||||||
|
image: post.image.clone(),
|
||||||
pinned: post.pinned,
|
pinned: post.pinned,
|
||||||
created_at: post.created_at.into(),
|
created_at: post.created_at.into(),
|
||||||
updated_at: post.updated_at.into(),
|
updated_at: post.updated_at.into(),
|
||||||
@@ -410,13 +508,22 @@ pub async fn search(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
results.sort_by(|left, right| {
|
sort_search_results(&mut results, "relevance", "desc");
|
||||||
right
|
Ok((q, preview_search, results))
|
||||||
.rank
|
}
|
||||||
.partial_cmp(&left.rank)
|
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
#[debug_handler]
|
||||||
.then_with(|| right.created_at.cmp(&left.created_at))
|
pub async fn search(
|
||||||
});
|
Query(query): Query<SearchQuery>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let started_at = Instant::now();
|
||||||
|
let limit = query.limit.unwrap_or(20).clamp(1, 100) as usize;
|
||||||
|
let (q, preview_search, mut results) = build_search_results(&ctx, &query, &headers).await?;
|
||||||
|
if q.is_empty() {
|
||||||
|
return format::json(Vec::<SearchResult>::new());
|
||||||
|
}
|
||||||
results.truncate(limit);
|
results.truncate(limit);
|
||||||
|
|
||||||
if !preview_search {
|
if !preview_search {
|
||||||
@@ -433,6 +540,70 @@ pub async fn search(
|
|||||||
format::json(results)
|
format::json(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn routes() -> Routes {
|
#[debug_handler]
|
||||||
Routes::new().prefix("api/search/").add("/", get(search))
|
pub async fn search_page(
|
||||||
|
Query(query): Query<SearchPageQuery>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let started_at = Instant::now();
|
||||||
|
let page_size = query.page_size.unwrap_or(20).clamp(1, 100);
|
||||||
|
let sort_by = normalize_search_sort_by(query.sort_by.as_deref());
|
||||||
|
let sort_order = normalize_sort_order(query.sort_order.as_deref(), &sort_by);
|
||||||
|
let (q, preview_search, mut results) = build_search_results(&ctx, &query.search, &headers).await?;
|
||||||
|
|
||||||
|
if q.is_empty() {
|
||||||
|
return format::json(PagedSearchResponse {
|
||||||
|
query: q,
|
||||||
|
items: Vec::new(),
|
||||||
|
page: 1,
|
||||||
|
page_size,
|
||||||
|
total: 0,
|
||||||
|
total_pages: 1,
|
||||||
|
sort_by,
|
||||||
|
sort_order,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sort_search_results(&mut results, &sort_by, &sort_order);
|
||||||
|
|
||||||
|
let total = results.len();
|
||||||
|
let total_pages = std::cmp::max(1, ((total as u64) + page_size - 1) / page_size);
|
||||||
|
let page = query.page.unwrap_or(1).clamp(1, total_pages);
|
||||||
|
let start = ((page - 1) * page_size) as usize;
|
||||||
|
let end = std::cmp::min(start + page_size as usize, total);
|
||||||
|
let items = if start >= total {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
results[start..end].to_vec()
|
||||||
|
};
|
||||||
|
|
||||||
|
if !preview_search {
|
||||||
|
analytics::record_search_event(
|
||||||
|
&ctx,
|
||||||
|
&q,
|
||||||
|
total,
|
||||||
|
&headers,
|
||||||
|
started_at.elapsed().as_millis() as i64,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
format::json(PagedSearchResponse {
|
||||||
|
query: q,
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
total,
|
||||||
|
total_pages,
|
||||||
|
sort_by,
|
||||||
|
sort_order,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.prefix("api/search/")
|
||||||
|
.add("page", get(search_page))
|
||||||
|
.add("/", get(search))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,22 @@ pub struct SiteSettingsPayload {
|
|||||||
pub ai_enabled: Option<bool>,
|
pub ai_enabled: Option<bool>,
|
||||||
#[serde(default, alias = "paragraphCommentsEnabled")]
|
#[serde(default, alias = "paragraphCommentsEnabled")]
|
||||||
pub paragraph_comments_enabled: Option<bool>,
|
pub paragraph_comments_enabled: Option<bool>,
|
||||||
|
#[serde(default, alias = "commentTurnstileEnabled")]
|
||||||
|
pub comment_turnstile_enabled: Option<bool>,
|
||||||
|
#[serde(default, alias = "subscriptionTurnstileEnabled")]
|
||||||
|
pub subscription_turnstile_enabled: Option<bool>,
|
||||||
|
#[serde(default, alias = "webPushEnabled")]
|
||||||
|
pub web_push_enabled: Option<bool>,
|
||||||
|
#[serde(default, alias = "turnstileSiteKey")]
|
||||||
|
pub turnstile_site_key: Option<String>,
|
||||||
|
#[serde(default, alias = "turnstileSecretKey")]
|
||||||
|
pub turnstile_secret_key: Option<String>,
|
||||||
|
#[serde(default, alias = "webPushVapidPublicKey")]
|
||||||
|
pub web_push_vapid_public_key: Option<String>,
|
||||||
|
#[serde(default, alias = "webPushVapidPrivateKey")]
|
||||||
|
pub web_push_vapid_private_key: Option<String>,
|
||||||
|
#[serde(default, alias = "webPushVapidSubject")]
|
||||||
|
pub web_push_vapid_subject: Option<String>,
|
||||||
#[serde(default, alias = "aiProvider")]
|
#[serde(default, alias = "aiProvider")]
|
||||||
pub ai_provider: Option<String>,
|
pub ai_provider: Option<String>,
|
||||||
#[serde(default, alias = "aiApiBase")]
|
#[serde(default, alias = "aiApiBase")]
|
||||||
@@ -139,6 +155,8 @@ pub struct SiteSettingsPayload {
|
|||||||
pub seo_default_twitter_handle: Option<String>,
|
pub seo_default_twitter_handle: Option<String>,
|
||||||
#[serde(default, alias = "notificationWebhookUrl")]
|
#[serde(default, alias = "notificationWebhookUrl")]
|
||||||
pub notification_webhook_url: Option<String>,
|
pub notification_webhook_url: Option<String>,
|
||||||
|
#[serde(default, alias = "notificationChannelType")]
|
||||||
|
pub notification_channel_type: Option<String>,
|
||||||
#[serde(default, alias = "notificationCommentEnabled")]
|
#[serde(default, alias = "notificationCommentEnabled")]
|
||||||
pub notification_comment_enabled: Option<bool>,
|
pub notification_comment_enabled: Option<bool>,
|
||||||
#[serde(default, alias = "notificationFriendLinkEnabled")]
|
#[serde(default, alias = "notificationFriendLinkEnabled")]
|
||||||
@@ -177,6 +195,11 @@ pub struct PublicSiteSettingsResponse {
|
|||||||
pub music_playlist: Option<serde_json::Value>,
|
pub music_playlist: Option<serde_json::Value>,
|
||||||
pub ai_enabled: bool,
|
pub ai_enabled: bool,
|
||||||
pub paragraph_comments_enabled: bool,
|
pub paragraph_comments_enabled: bool,
|
||||||
|
pub comment_turnstile_enabled: bool,
|
||||||
|
pub subscription_turnstile_enabled: bool,
|
||||||
|
pub web_push_enabled: bool,
|
||||||
|
pub turnstile_site_key: Option<String>,
|
||||||
|
pub web_push_vapid_public_key: Option<String>,
|
||||||
pub subscription_popup_enabled: bool,
|
pub subscription_popup_enabled: bool,
|
||||||
pub subscription_popup_title: String,
|
pub subscription_popup_title: String,
|
||||||
pub subscription_popup_description: String,
|
pub subscription_popup_description: String,
|
||||||
@@ -220,6 +243,17 @@ fn normalize_optional_int(value: Option<i32>, min: i32, max: i32) -> Option<i32>
|
|||||||
value.map(|item| item.clamp(min, max))
|
value.map(|item| item.clamp(min, max))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_notification_channel_type(value: Option<String>) -> Option<String> {
|
||||||
|
value.and_then(|item| {
|
||||||
|
let normalized = item.trim().to_ascii_lowercase();
|
||||||
|
match normalized.as_str() {
|
||||||
|
"ntfy" => Some("ntfy".to_string()),
|
||||||
|
"webhook" => Some("webhook".to_string()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn default_subscription_popup_enabled() -> bool {
|
pub(crate) fn default_subscription_popup_enabled() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -515,6 +549,32 @@ impl SiteSettingsPayload {
|
|||||||
if let Some(paragraph_comments_enabled) = self.paragraph_comments_enabled {
|
if let Some(paragraph_comments_enabled) = self.paragraph_comments_enabled {
|
||||||
item.paragraph_comments_enabled = Some(paragraph_comments_enabled);
|
item.paragraph_comments_enabled = Some(paragraph_comments_enabled);
|
||||||
}
|
}
|
||||||
|
if let Some(comment_turnstile_enabled) = self.comment_turnstile_enabled {
|
||||||
|
item.comment_turnstile_enabled = Some(comment_turnstile_enabled);
|
||||||
|
}
|
||||||
|
if let Some(subscription_turnstile_enabled) = self.subscription_turnstile_enabled {
|
||||||
|
item.subscription_turnstile_enabled = Some(subscription_turnstile_enabled);
|
||||||
|
}
|
||||||
|
if let Some(web_push_enabled) = self.web_push_enabled {
|
||||||
|
item.web_push_enabled = Some(web_push_enabled);
|
||||||
|
}
|
||||||
|
if let Some(turnstile_site_key) = self.turnstile_site_key {
|
||||||
|
item.turnstile_site_key = normalize_optional_string(Some(turnstile_site_key));
|
||||||
|
}
|
||||||
|
if let Some(turnstile_secret_key) = self.turnstile_secret_key {
|
||||||
|
item.turnstile_secret_key = normalize_optional_string(Some(turnstile_secret_key));
|
||||||
|
}
|
||||||
|
if let Some(web_push_vapid_public_key) = self.web_push_vapid_public_key {
|
||||||
|
item.web_push_vapid_public_key =
|
||||||
|
normalize_optional_string(Some(web_push_vapid_public_key));
|
||||||
|
}
|
||||||
|
if let Some(web_push_vapid_private_key) = self.web_push_vapid_private_key {
|
||||||
|
item.web_push_vapid_private_key =
|
||||||
|
normalize_optional_string(Some(web_push_vapid_private_key));
|
||||||
|
}
|
||||||
|
if let Some(web_push_vapid_subject) = self.web_push_vapid_subject {
|
||||||
|
item.web_push_vapid_subject = normalize_optional_string(Some(web_push_vapid_subject));
|
||||||
|
}
|
||||||
let provider_list_supplied = self.ai_providers.is_some();
|
let provider_list_supplied = self.ai_providers.is_some();
|
||||||
let provided_ai_providers = self.ai_providers.map(normalize_ai_provider_configs);
|
let provided_ai_providers = self.ai_providers.map(normalize_ai_provider_configs);
|
||||||
let requested_active_provider_id = self
|
let requested_active_provider_id = self
|
||||||
@@ -591,6 +651,10 @@ impl SiteSettingsPayload {
|
|||||||
item.notification_webhook_url =
|
item.notification_webhook_url =
|
||||||
normalize_optional_string(Some(notification_webhook_url));
|
normalize_optional_string(Some(notification_webhook_url));
|
||||||
}
|
}
|
||||||
|
if self.notification_channel_type.is_some() {
|
||||||
|
item.notification_channel_type =
|
||||||
|
normalize_notification_channel_type(self.notification_channel_type);
|
||||||
|
}
|
||||||
if let Some(notification_comment_enabled) = self.notification_comment_enabled {
|
if let Some(notification_comment_enabled) = self.notification_comment_enabled {
|
||||||
item.notification_comment_enabled = Some(notification_comment_enabled);
|
item.notification_comment_enabled = Some(notification_comment_enabled);
|
||||||
}
|
}
|
||||||
@@ -699,6 +763,14 @@ fn default_payload() -> SiteSettingsPayload {
|
|||||||
]),
|
]),
|
||||||
ai_enabled: Some(false),
|
ai_enabled: Some(false),
|
||||||
paragraph_comments_enabled: Some(true),
|
paragraph_comments_enabled: Some(true),
|
||||||
|
comment_turnstile_enabled: Some(false),
|
||||||
|
subscription_turnstile_enabled: Some(false),
|
||||||
|
web_push_enabled: Some(false),
|
||||||
|
turnstile_site_key: None,
|
||||||
|
turnstile_secret_key: None,
|
||||||
|
web_push_vapid_public_key: None,
|
||||||
|
web_push_vapid_private_key: None,
|
||||||
|
web_push_vapid_subject: None,
|
||||||
ai_provider: Some(ai::provider_name(None)),
|
ai_provider: Some(ai::provider_name(None)),
|
||||||
ai_api_base: Some(ai::default_api_base().to_string()),
|
ai_api_base: Some(ai::default_api_base().to_string()),
|
||||||
ai_api_key: Some(ai::default_api_key().to_string()),
|
ai_api_key: Some(ai::default_api_key().to_string()),
|
||||||
@@ -725,6 +797,7 @@ fn default_payload() -> SiteSettingsPayload {
|
|||||||
seo_default_og_image: None,
|
seo_default_og_image: None,
|
||||||
seo_default_twitter_handle: None,
|
seo_default_twitter_handle: None,
|
||||||
notification_webhook_url: None,
|
notification_webhook_url: None,
|
||||||
|
notification_channel_type: Some("webhook".to_string()),
|
||||||
notification_comment_enabled: Some(false),
|
notification_comment_enabled: Some(false),
|
||||||
notification_friend_link_enabled: Some(false),
|
notification_friend_link_enabled: Some(false),
|
||||||
subscription_popup_enabled: Some(default_subscription_popup_enabled()),
|
subscription_popup_enabled: Some(default_subscription_popup_enabled()),
|
||||||
@@ -760,6 +833,18 @@ pub(crate) async fn load_current(ctx: &AppContext) -> Result<Model> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn public_response(model: Model) -> PublicSiteSettingsResponse {
|
fn public_response(model: Model) -> PublicSiteSettingsResponse {
|
||||||
|
let turnstile_site_key = crate::services::turnstile::site_key(&model);
|
||||||
|
let web_push_vapid_public_key = crate::services::web_push::public_key(&model);
|
||||||
|
let comment_turnstile_enabled = crate::services::turnstile::is_enabled(
|
||||||
|
&model,
|
||||||
|
crate::services::turnstile::TurnstileScope::Comment,
|
||||||
|
);
|
||||||
|
let subscription_turnstile_enabled = crate::services::turnstile::is_enabled(
|
||||||
|
&model,
|
||||||
|
crate::services::turnstile::TurnstileScope::Subscription,
|
||||||
|
);
|
||||||
|
let web_push_enabled = crate::services::web_push::is_enabled(&model);
|
||||||
|
|
||||||
PublicSiteSettingsResponse {
|
PublicSiteSettingsResponse {
|
||||||
id: model.id,
|
id: model.id,
|
||||||
site_name: model.site_name,
|
site_name: model.site_name,
|
||||||
@@ -781,6 +866,11 @@ fn public_response(model: Model) -> PublicSiteSettingsResponse {
|
|||||||
music_playlist: model.music_playlist,
|
music_playlist: model.music_playlist,
|
||||||
ai_enabled: model.ai_enabled.unwrap_or(false),
|
ai_enabled: model.ai_enabled.unwrap_or(false),
|
||||||
paragraph_comments_enabled: model.paragraph_comments_enabled.unwrap_or(true),
|
paragraph_comments_enabled: model.paragraph_comments_enabled.unwrap_or(true),
|
||||||
|
comment_turnstile_enabled,
|
||||||
|
subscription_turnstile_enabled,
|
||||||
|
web_push_enabled,
|
||||||
|
turnstile_site_key,
|
||||||
|
web_push_vapid_public_key,
|
||||||
subscription_popup_enabled: model
|
subscription_popup_enabled: model
|
||||||
.subscription_popup_enabled
|
.subscription_popup_enabled
|
||||||
.unwrap_or_else(default_subscription_popup_enabled),
|
.unwrap_or_else(default_subscription_popup_enabled),
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::services::{abuse_guard, admin_audit, subscriptions};
|
use axum::http::header;
|
||||||
|
|
||||||
|
use crate::services::{abuse_guard, admin_audit, subscriptions, turnstile};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
pub struct PublicSubscriptionPayload {
|
pub struct PublicSubscriptionPayload {
|
||||||
@@ -10,6 +12,17 @@ pub struct PublicSubscriptionPayload {
|
|||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub source: Option<String>,
|
pub source: Option<String>,
|
||||||
|
#[serde(default, alias = "turnstileToken")]
|
||||||
|
pub turnstile_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct PublicBrowserPushSubscriptionPayload {
|
||||||
|
pub subscription: serde_json::Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub source: Option<String>,
|
||||||
|
#[serde(default, alias = "turnstileToken")]
|
||||||
|
pub turnstile_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
@@ -55,6 +68,19 @@ fn public_subscription_metadata(source: Option<String>) -> serde_json::Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn public_browser_push_metadata(
|
||||||
|
source: Option<String>,
|
||||||
|
subscription: serde_json::Value,
|
||||||
|
user_agent: Option<String>,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"source": source,
|
||||||
|
"kind": "browser-push",
|
||||||
|
"subscription": subscription,
|
||||||
|
"user_agent": user_agent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn subscribe(
|
pub async fn subscribe(
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
@@ -62,11 +88,19 @@ pub async fn subscribe(
|
|||||||
Json(payload): Json<PublicSubscriptionPayload>,
|
Json(payload): Json<PublicSubscriptionPayload>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let email = payload.email.trim().to_ascii_lowercase();
|
let email = payload.email.trim().to_ascii_lowercase();
|
||||||
|
let client_ip = abuse_guard::detect_client_ip(&headers);
|
||||||
abuse_guard::enforce_public_scope(
|
abuse_guard::enforce_public_scope(
|
||||||
"subscription",
|
"subscription",
|
||||||
abuse_guard::detect_client_ip(&headers).as_deref(),
|
client_ip.as_deref(),
|
||||||
Some(&email),
|
Some(&email),
|
||||||
)?;
|
)?;
|
||||||
|
let _ = turnstile::verify_if_enabled(
|
||||||
|
&ctx,
|
||||||
|
turnstile::TurnstileScope::Subscription,
|
||||||
|
payload.turnstile_token.as_deref(),
|
||||||
|
client_ip.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let result = subscriptions::create_public_email_subscription(
|
let result = subscriptions::create_public_email_subscription(
|
||||||
&ctx,
|
&ctx,
|
||||||
@@ -103,6 +137,76 @@ pub async fn subscribe(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn subscribe_browser_push(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Json(payload): Json<PublicBrowserPushSubscriptionPayload>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let settings = crate::controllers::site_settings::load_current(&ctx).await?;
|
||||||
|
if !crate::services::web_push::is_enabled(&settings) {
|
||||||
|
return Err(Error::BadRequest("浏览器推送未启用".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let endpoint = payload
|
||||||
|
.subscription
|
||||||
|
.get("endpoint")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| Error::BadRequest("browser push subscription.endpoint 不能为空".to_string()))?
|
||||||
|
.to_string();
|
||||||
|
let client_ip = abuse_guard::detect_client_ip(&headers);
|
||||||
|
let user_agent = headers
|
||||||
|
.get(header::USER_AGENT)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToString::to_string);
|
||||||
|
|
||||||
|
abuse_guard::enforce_public_scope("browser-push-subscription", client_ip.as_deref(), Some(&endpoint))?;
|
||||||
|
let _ = turnstile::verify_if_enabled(
|
||||||
|
&ctx,
|
||||||
|
turnstile::TurnstileScope::Subscription,
|
||||||
|
payload.turnstile_token.as_deref(),
|
||||||
|
client_ip.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let result = subscriptions::create_public_web_push_subscription(
|
||||||
|
&ctx,
|
||||||
|
payload.subscription.clone(),
|
||||||
|
Some(public_browser_push_metadata(
|
||||||
|
payload.source,
|
||||||
|
payload.subscription,
|
||||||
|
user_agent,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
admin_audit::log_event(
|
||||||
|
&ctx,
|
||||||
|
None,
|
||||||
|
"subscription.public.web_push.active",
|
||||||
|
"subscription",
|
||||||
|
Some(result.subscription.id.to_string()),
|
||||||
|
Some(result.subscription.target.clone()),
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"channel_type": result.subscription.channel_type,
|
||||||
|
"status": result.subscription.status,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
format::json(PublicSubscriptionResponse {
|
||||||
|
ok: true,
|
||||||
|
subscription_id: result.subscription.id,
|
||||||
|
status: result.subscription.status,
|
||||||
|
requires_confirmation: false,
|
||||||
|
message: result.message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn confirm(
|
pub async fn confirm(
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
@@ -196,6 +300,7 @@ pub fn routes() -> Routes {
|
|||||||
Routes::new()
|
Routes::new()
|
||||||
.prefix("/api/subscriptions")
|
.prefix("/api/subscriptions")
|
||||||
.add("/", post(subscribe))
|
.add("/", post(subscribe))
|
||||||
|
.add("/browser-push", post(subscribe_browser_push))
|
||||||
.add("/confirm", post(confirm))
|
.add("/confirm", post(confirm))
|
||||||
.add("/manage", get(manage).patch(update_manage))
|
.add("/manage", get(manage).patch(update_manage))
|
||||||
.add("/unsubscribe", post(unsubscribe))
|
.add("/unsubscribe", post(unsubscribe))
|
||||||
|
|||||||
@@ -2,43 +2,217 @@
|
|||||||
#![allow(clippy::unnecessary_struct_initialization)]
|
#![allow(clippy::unnecessary_struct_initialization)]
|
||||||
#![allow(clippy::unused_async)]
|
#![allow(clippy::unused_async)]
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::models::_entities::tags::{ActiveModel, Entity, Model};
|
use crate::models::_entities::{posts, tags};
|
||||||
use crate::services::content;
|
use crate::services::content;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct TagSummary {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub count: usize,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub cover_image: Option<String>,
|
||||||
|
pub accent_color: Option<String>,
|
||||||
|
pub seo_title: Option<String>,
|
||||||
|
pub seo_description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct TagRecord {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub slug: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub cover_image: Option<String>,
|
||||||
|
pub accent_color: Option<String>,
|
||||||
|
pub seo_title: Option<String>,
|
||||||
|
pub seo_description: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct Params {
|
pub struct Params {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub slug: String,
|
pub slug: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cover_image: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub accent_color: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub seo_title: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub seo_description: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Params {
|
fn trim_to_option(value: Option<String>) -> Option<String> {
|
||||||
fn update(&self, item: &mut ActiveModel) {
|
value.and_then(|item| {
|
||||||
item.name = Set(self.name.clone());
|
let trimmed = item.trim().to_string();
|
||||||
item.slug = Set(self.slug.clone());
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slugify(value: &str) -> String {
|
||||||
|
let mut slug = String::new();
|
||||||
|
let mut last_was_dash = false;
|
||||||
|
|
||||||
|
for ch in value.trim().chars() {
|
||||||
|
if ch.is_ascii_alphanumeric() {
|
||||||
|
slug.push(ch.to_ascii_lowercase());
|
||||||
|
last_was_dash = false;
|
||||||
|
} else if (ch.is_whitespace() || ch == '-' || ch == '_') && !last_was_dash {
|
||||||
|
slug.push('-');
|
||||||
|
last_was_dash = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slug.trim_matches('-').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_name(params: &Params) -> Result<String> {
|
||||||
|
params
|
||||||
|
.name
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.ok_or_else(|| Error::BadRequest("tag name is required".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_slug(params: &Params, fallback: &str) -> String {
|
||||||
|
params
|
||||||
|
.slug
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.unwrap_or_else(|| slugify(fallback))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tag_name(item: &tags::Model) -> String {
|
||||||
|
item.name.clone().unwrap_or_else(|| item.slug.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tag_values(post: &posts::Model) -> Vec<String> {
|
||||||
|
post.tags
|
||||||
|
.as_ref()
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|item| item.as_str().map(|value| value.trim().to_ascii_lowercase()))
|
||||||
|
.filter(|item| !item.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_summary(item: &tags::Model, post_items: &[posts::Model]) -> TagSummary {
|
||||||
|
let name = tag_name(item);
|
||||||
|
let aliases = [name.trim().to_ascii_lowercase(), item.slug.trim().to_ascii_lowercase()];
|
||||||
|
let count = post_items
|
||||||
|
.iter()
|
||||||
|
.filter(|post| {
|
||||||
|
tag_values(post)
|
||||||
|
.into_iter()
|
||||||
|
.any(|value| aliases.iter().any(|alias| alias == &value))
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
|
||||||
|
TagSummary {
|
||||||
|
id: item.id,
|
||||||
|
name,
|
||||||
|
slug: item.slug.clone(),
|
||||||
|
count,
|
||||||
|
description: item.description.clone(),
|
||||||
|
cover_image: item.cover_image.clone(),
|
||||||
|
accent_color: item.accent_color.clone(),
|
||||||
|
seo_title: item.seo_title.clone(),
|
||||||
|
seo_description: item.seo_description.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
|
fn build_record(item: tags::Model) -> TagRecord {
|
||||||
let item = Entity::find_by_id(id).one(&ctx.db).await?;
|
TagRecord {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
slug: item.slug,
|
||||||
|
description: item.description,
|
||||||
|
cover_image: item.cover_image,
|
||||||
|
accent_color: item.accent_color,
|
||||||
|
seo_title: item.seo_title,
|
||||||
|
seo_description: item.seo_description,
|
||||||
|
created_at: item.created_at.to_rfc3339(),
|
||||||
|
updated_at: item.updated_at.to_rfc3339(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_item(ctx: &AppContext, id: i32) -> Result<tags::Model> {
|
||||||
|
let item = tags::Entity::find_by_id(id).one(&ctx.db).await?;
|
||||||
item.ok_or_else(|| Error::NotFound)
|
item.ok_or_else(|| Error::NotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn list(State(ctx): State<AppContext>) -> Result<Response> {
|
pub async fn list(State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
content::sync_markdown_posts(&ctx).await?;
|
content::sync_markdown_posts(&ctx).await?;
|
||||||
format::json(Entity::find().all(&ctx.db).await?)
|
let tag_items = tags::Entity::find()
|
||||||
|
.order_by_asc(tags::Column::Slug)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let post_items = posts::Entity::find().all(&ctx.db).await?;
|
||||||
|
|
||||||
|
format::json(
|
||||||
|
tag_items
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| build_summary(&item, &post_items))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn add(State(ctx): State<AppContext>, Json(params): Json<Params>) -> Result<Response> {
|
pub async fn add(State(ctx): State<AppContext>, Json(params): Json<Params>) -> Result<Response> {
|
||||||
let mut item = ActiveModel {
|
let name = normalized_name(¶ms)?;
|
||||||
..Default::default()
|
let slug = normalized_slug(¶ms, &name);
|
||||||
|
let existing = tags::Entity::find()
|
||||||
|
.filter(tags::Column::Slug.eq(&slug))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let item = if let Some(existing_tag) = existing {
|
||||||
|
let mut item = existing_tag.into_active_model();
|
||||||
|
item.name = Set(Some(name));
|
||||||
|
item.slug = Set(slug);
|
||||||
|
item.description = Set(trim_to_option(params.description));
|
||||||
|
item.cover_image = Set(trim_to_option(params.cover_image));
|
||||||
|
item.accent_color = Set(trim_to_option(params.accent_color));
|
||||||
|
item.seo_title = Set(trim_to_option(params.seo_title));
|
||||||
|
item.seo_description = Set(trim_to_option(params.seo_description));
|
||||||
|
item.update(&ctx.db).await?
|
||||||
|
} else {
|
||||||
|
tags::ActiveModel {
|
||||||
|
name: Set(Some(name)),
|
||||||
|
slug: Set(slug),
|
||||||
|
description: Set(trim_to_option(params.description)),
|
||||||
|
cover_image: Set(trim_to_option(params.cover_image)),
|
||||||
|
accent_color: Set(trim_to_option(params.accent_color)),
|
||||||
|
seo_title: Set(trim_to_option(params.seo_title)),
|
||||||
|
seo_description: Set(trim_to_option(params.seo_description)),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&ctx.db)
|
||||||
|
.await?
|
||||||
};
|
};
|
||||||
params.update(&mut item);
|
|
||||||
let item = item.insert(&ctx.db).await?;
|
format::json(build_record(item))
|
||||||
format::json(item)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
@@ -47,35 +221,36 @@ pub async fn update(
|
|||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
Json(params): Json<Params>,
|
Json(params): Json<Params>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
|
let name = normalized_name(¶ms)?;
|
||||||
|
let slug = normalized_slug(¶ms, &name);
|
||||||
let item = load_item(&ctx, id).await?;
|
let item = load_item(&ctx, id).await?;
|
||||||
let previous_name = item.name.clone();
|
let previous_name = item.name.clone();
|
||||||
let previous_slug = item.slug.clone();
|
let previous_slug = item.slug.clone();
|
||||||
let next_name = params
|
|
||||||
.name
|
if previous_name
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty());
|
.filter(|value| !value.is_empty())
|
||||||
|
!= Some(name.as_str())
|
||||||
if let Some(next_name) = next_name {
|
{
|
||||||
if previous_name
|
content::rewrite_tag_references(
|
||||||
.as_deref()
|
previous_name.as_deref(),
|
||||||
.map(str::trim)
|
&previous_slug,
|
||||||
.filter(|value| !value.is_empty())
|
Some(&name),
|
||||||
!= Some(next_name)
|
)?;
|
||||||
{
|
|
||||||
content::rewrite_tag_references(
|
|
||||||
previous_name.as_deref(),
|
|
||||||
&previous_slug,
|
|
||||||
Some(next_name),
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut item = item.into_active_model();
|
let mut item = item.into_active_model();
|
||||||
params.update(&mut item);
|
item.name = Set(Some(name));
|
||||||
|
item.slug = Set(slug);
|
||||||
|
item.description = Set(trim_to_option(params.description));
|
||||||
|
item.cover_image = Set(trim_to_option(params.cover_image));
|
||||||
|
item.accent_color = Set(trim_to_option(params.accent_color));
|
||||||
|
item.seo_title = Set(trim_to_option(params.seo_title));
|
||||||
|
item.seo_description = Set(trim_to_option(params.seo_description));
|
||||||
let item = item.update(&ctx.db).await?;
|
let item = item.update(&ctx.db).await?;
|
||||||
content::sync_markdown_posts(&ctx).await?;
|
content::sync_markdown_posts(&ctx).await?;
|
||||||
format::json(item)
|
format::json(build_record(item))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
@@ -89,7 +264,7 @@ pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Resul
|
|||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
format::json(load_item(&ctx, id).await?)
|
format::json(build_record(load_item(&ctx, id).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn routes() -> Routes {
|
pub fn routes() -> Routes {
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ pub struct Model {
|
|||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub cover_image: Option<String>,
|
||||||
|
pub accent_color: Option<String>,
|
||||||
|
pub seo_title: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub seo_description: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
|||||||
25
backend/src/models/_entities/media_assets.rs
Normal file
25
backend/src/models/_entities/media_assets.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
//! `SeaORM` Entity, manually maintained
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "media_assets")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub object_key: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub alt_text: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub caption: Option<String>,
|
||||||
|
#[sea_orm(column_type = "JsonBinary", nullable)]
|
||||||
|
pub tags: Option<Json>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
@@ -10,6 +10,7 @@ pub mod comment_persona_analysis_logs;
|
|||||||
pub mod comments;
|
pub mod comments;
|
||||||
pub mod content_events;
|
pub mod content_events;
|
||||||
pub mod friend_links;
|
pub mod friend_links;
|
||||||
|
pub mod media_assets;
|
||||||
pub mod notification_deliveries;
|
pub mod notification_deliveries;
|
||||||
pub mod post_revisions;
|
pub mod post_revisions;
|
||||||
pub mod posts;
|
pub mod posts;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub use super::comment_persona_analysis_logs::Entity as CommentPersonaAnalysisLo
|
|||||||
pub use super::comments::Entity as Comments;
|
pub use super::comments::Entity as Comments;
|
||||||
pub use super::content_events::Entity as ContentEvents;
|
pub use super::content_events::Entity as ContentEvents;
|
||||||
pub use super::friend_links::Entity as FriendLinks;
|
pub use super::friend_links::Entity as FriendLinks;
|
||||||
|
pub use super::media_assets::Entity as MediaAssets;
|
||||||
pub use super::notification_deliveries::Entity as NotificationDeliveries;
|
pub use super::notification_deliveries::Entity as NotificationDeliveries;
|
||||||
pub use super::post_revisions::Entity as PostRevisions;
|
pub use super::post_revisions::Entity as PostRevisions;
|
||||||
pub use super::posts::Entity as Posts;
|
pub use super::posts::Entity as Posts;
|
||||||
|
|||||||
@@ -32,6 +32,19 @@ pub struct Model {
|
|||||||
pub music_playlist: Option<Json>,
|
pub music_playlist: Option<Json>,
|
||||||
pub ai_enabled: Option<bool>,
|
pub ai_enabled: Option<bool>,
|
||||||
pub paragraph_comments_enabled: Option<bool>,
|
pub paragraph_comments_enabled: Option<bool>,
|
||||||
|
pub comment_turnstile_enabled: Option<bool>,
|
||||||
|
pub subscription_turnstile_enabled: Option<bool>,
|
||||||
|
pub web_push_enabled: Option<bool>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub turnstile_site_key: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub turnstile_secret_key: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub web_push_vapid_public_key: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub web_push_vapid_private_key: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub web_push_vapid_subject: Option<String>,
|
||||||
pub ai_provider: Option<String>,
|
pub ai_provider: Option<String>,
|
||||||
pub ai_api_base: Option<String>,
|
pub ai_api_base: Option<String>,
|
||||||
#[sea_orm(column_type = "Text", nullable)]
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
@@ -63,6 +76,7 @@ pub struct Model {
|
|||||||
pub seo_default_twitter_handle: Option<String>,
|
pub seo_default_twitter_handle: Option<String>,
|
||||||
#[sea_orm(column_type = "Text", nullable)]
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
pub notification_webhook_url: Option<String>,
|
pub notification_webhook_url: Option<String>,
|
||||||
|
pub notification_channel_type: Option<String>,
|
||||||
pub notification_comment_enabled: Option<bool>,
|
pub notification_comment_enabled: Option<bool>,
|
||||||
pub notification_friend_link_enabled: Option<bool>,
|
pub notification_friend_link_enabled: Option<bool>,
|
||||||
pub subscription_popup_enabled: Option<bool>,
|
pub subscription_popup_enabled: Option<bool>,
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ pub struct Model {
|
|||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub cover_image: Option<String>,
|
||||||
|
pub accent_color: Option<String>,
|
||||||
|
pub seo_title: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub seo_description: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
|||||||
23
backend/src/models/media_assets.rs
Normal file
23
backend/src/models/media_assets.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
pub use super::_entities::media_assets::{ActiveModel, Entity, Model};
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
pub type MediaAssets = Entity;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ActiveModelBehavior for ActiveModel {
|
||||||
|
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
|
||||||
|
where
|
||||||
|
C: ConnectionTrait,
|
||||||
|
{
|
||||||
|
if !insert && self.updated_at.is_unchanged() {
|
||||||
|
let mut this = self;
|
||||||
|
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
|
||||||
|
Ok(this)
|
||||||
|
} else {
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Model {}
|
||||||
|
impl ActiveModel {}
|
||||||
|
impl Entity {}
|
||||||
@@ -3,6 +3,7 @@ pub mod ai_chunks;
|
|||||||
pub mod categories;
|
pub mod categories;
|
||||||
pub mod comments;
|
pub mod comments;
|
||||||
pub mod friend_links;
|
pub mod friend_links;
|
||||||
|
pub mod media_assets;
|
||||||
pub mod posts;
|
pub mod posts;
|
||||||
pub mod site_settings;
|
pub mod site_settings;
|
||||||
pub mod tags;
|
pub mod tags;
|
||||||
|
|||||||
640
backend/src/services/backups.rs
Normal file
640
backend/src/services/backups.rs
Normal file
@@ -0,0 +1,640 @@
|
|||||||
|
use std::{fs, path::Path, path::PathBuf};
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, Condition, EntityTrait, IntoActiveModel, QueryFilter,
|
||||||
|
QueryOrder, Set,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
controllers::site_settings,
|
||||||
|
models::_entities::{
|
||||||
|
categories, friend_links, media_assets, posts, reviews, site_settings as site_settings_entity,
|
||||||
|
tags,
|
||||||
|
},
|
||||||
|
services::{content, media_assets as media_assets_service, storage},
|
||||||
|
};
|
||||||
|
|
||||||
|
const BACKUP_VERSION: &str = "2026-04-01";
|
||||||
|
const WARNING_STORAGE_BINARIES: &str =
|
||||||
|
"当前备份只包含内容、配置与对象清单,不包含对象存储二进制文件本身。恢复后如需图片等资源,仍需保留原对象存储桶或手动回传文件。";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct BackupTaxonomyRecord {
|
||||||
|
pub name: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub cover_image: Option<String>,
|
||||||
|
pub accent_color: Option<String>,
|
||||||
|
pub seo_title: Option<String>,
|
||||||
|
pub seo_description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct BackupReviewRecord {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub review_type: Option<String>,
|
||||||
|
pub rating: Option<i32>,
|
||||||
|
pub review_date: Option<String>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub tags: Option<String>,
|
||||||
|
pub cover: Option<String>,
|
||||||
|
pub link_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct BackupFriendLinkRecord {
|
||||||
|
pub site_name: Option<String>,
|
||||||
|
pub site_url: String,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct BackupMediaAssetRecord {
|
||||||
|
pub object_key: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub alt_text: Option<String>,
|
||||||
|
pub caption: Option<String>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct BackupStorageObjectRecord {
|
||||||
|
pub key: String,
|
||||||
|
pub url: String,
|
||||||
|
pub size_bytes: i64,
|
||||||
|
pub last_modified: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct BackupPostDocument {
|
||||||
|
pub slug: String,
|
||||||
|
pub file_name: String,
|
||||||
|
pub markdown: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SiteBackupDocument {
|
||||||
|
pub version: String,
|
||||||
|
pub exported_at: String,
|
||||||
|
pub includes_storage_binaries: bool,
|
||||||
|
pub warning: String,
|
||||||
|
pub site_settings: site_settings_entity::Model,
|
||||||
|
pub categories: Vec<BackupTaxonomyRecord>,
|
||||||
|
pub tags: Vec<BackupTaxonomyRecord>,
|
||||||
|
pub reviews: Vec<BackupReviewRecord>,
|
||||||
|
pub friend_links: Vec<BackupFriendLinkRecord>,
|
||||||
|
pub media_assets: Vec<BackupMediaAssetRecord>,
|
||||||
|
pub storage_manifest: Option<Vec<BackupStorageObjectRecord>>,
|
||||||
|
pub posts: Vec<BackupPostDocument>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SiteBackupImportSummary {
|
||||||
|
pub imported: bool,
|
||||||
|
pub mode: String,
|
||||||
|
pub site_settings_restored: bool,
|
||||||
|
pub posts_written: usize,
|
||||||
|
pub categories_upserted: usize,
|
||||||
|
pub tags_upserted: usize,
|
||||||
|
pub reviews_upserted: usize,
|
||||||
|
pub friend_links_upserted: usize,
|
||||||
|
pub media_assets_upserted: usize,
|
||||||
|
pub storage_manifest_items: usize,
|
||||||
|
pub includes_storage_binaries: bool,
|
||||||
|
pub warning: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_to_option(value: Option<String>) -> Option<String> {
|
||||||
|
value.and_then(|item| {
|
||||||
|
let trimmed = item.trim().to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slugify(value: &str) -> String {
|
||||||
|
let mut slug = String::new();
|
||||||
|
let mut last_was_dash = false;
|
||||||
|
|
||||||
|
for ch in value.trim().chars() {
|
||||||
|
if ch.is_ascii_alphanumeric() {
|
||||||
|
slug.push(ch.to_ascii_lowercase());
|
||||||
|
last_was_dash = false;
|
||||||
|
} else if (ch.is_whitespace() || ch == '-' || ch == '_') && !last_was_dash {
|
||||||
|
slug.push('-');
|
||||||
|
last_was_dash = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slug.trim_matches('-').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_backup_mode(value: Option<&str>) -> String {
|
||||||
|
match value
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or("merge")
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"replace" => "replace".to_string(),
|
||||||
|
_ => "merge".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn markdown_posts_dir() -> PathBuf {
|
||||||
|
PathBuf::from(content::MARKDOWN_POSTS_DIR)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn io_error(err: std::io::Error) -> Error {
|
||||||
|
Error::string(&err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_existing_markdown_documents() -> Result<usize> {
|
||||||
|
let dir = markdown_posts_dir();
|
||||||
|
fs::create_dir_all(&dir).map_err(io_error)?;
|
||||||
|
|
||||||
|
let mut removed = 0_usize;
|
||||||
|
for path in fs::read_dir(&dir)
|
||||||
|
.map_err(io_error)?
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
{
|
||||||
|
let extension = path
|
||||||
|
.extension()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.map(|value| value.to_ascii_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if extension == "md" || extension == "markdown" {
|
||||||
|
fs::remove_file(&path).map_err(io_error)?;
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_markdown(value: &str) -> String {
|
||||||
|
value.replace("\r\n", "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_backup_post(document: &BackupPostDocument) -> Result<(String, String)> {
|
||||||
|
let candidate_slug = trim_to_option(Some(document.slug.clone())).unwrap_or_default();
|
||||||
|
let file_name = trim_to_option(Some(document.file_name.clone()))
|
||||||
|
.unwrap_or_else(|| format!("{}.md", if candidate_slug.is_empty() { "post" } else { &candidate_slug }));
|
||||||
|
let file_stem = Path::new(&file_name)
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or("post");
|
||||||
|
let markdown = normalize_markdown(&document.markdown);
|
||||||
|
let parsed = content::parse_markdown_source(file_stem, &markdown, &file_name)?;
|
||||||
|
let slug = if parsed.slug.trim().is_empty() {
|
||||||
|
candidate_slug
|
||||||
|
} else {
|
||||||
|
parsed.slug
|
||||||
|
};
|
||||||
|
|
||||||
|
if slug.trim().is_empty() {
|
||||||
|
return Err(Error::BadRequest("备份中的文章 slug 不能为空".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((slug, markdown))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn export_storage_manifest(
|
||||||
|
ctx: &AppContext,
|
||||||
|
) -> Result<Option<Vec<BackupStorageObjectRecord>>> {
|
||||||
|
if storage::optional_r2_settings(ctx).await?.is_none() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(
|
||||||
|
storage::list_objects(ctx, None, 1000)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| BackupStorageObjectRecord {
|
||||||
|
key: item.key,
|
||||||
|
url: item.url,
|
||||||
|
size_bytes: item.size_bytes,
|
||||||
|
last_modified: item.last_modified,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export_category_record(item: categories::Model) -> BackupTaxonomyRecord {
|
||||||
|
BackupTaxonomyRecord {
|
||||||
|
name: item.name.unwrap_or_else(|| item.slug.clone()),
|
||||||
|
slug: item.slug,
|
||||||
|
description: item.description,
|
||||||
|
cover_image: item.cover_image,
|
||||||
|
accent_color: item.accent_color,
|
||||||
|
seo_title: item.seo_title,
|
||||||
|
seo_description: item.seo_description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export_tag_record(item: tags::Model) -> BackupTaxonomyRecord {
|
||||||
|
BackupTaxonomyRecord {
|
||||||
|
name: item.name.unwrap_or_else(|| item.slug.clone()),
|
||||||
|
slug: item.slug,
|
||||||
|
description: item.description,
|
||||||
|
cover_image: item.cover_image,
|
||||||
|
accent_color: item.accent_color,
|
||||||
|
seo_title: item.seo_title,
|
||||||
|
seo_description: item.seo_description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export_review_record(item: reviews::Model) -> BackupReviewRecord {
|
||||||
|
BackupReviewRecord {
|
||||||
|
title: item.title,
|
||||||
|
review_type: item.review_type,
|
||||||
|
rating: item.rating,
|
||||||
|
review_date: item.review_date,
|
||||||
|
status: item.status,
|
||||||
|
description: item.description,
|
||||||
|
tags: item.tags,
|
||||||
|
cover: item.cover,
|
||||||
|
link_url: item.link_url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export_friend_link_record(item: friend_links::Model) -> BackupFriendLinkRecord {
|
||||||
|
BackupFriendLinkRecord {
|
||||||
|
site_name: item.site_name,
|
||||||
|
site_url: item.site_url,
|
||||||
|
avatar_url: item.avatar_url,
|
||||||
|
description: item.description,
|
||||||
|
category: item.category,
|
||||||
|
status: item.status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export_media_asset_record(item: media_assets::Model) -> BackupMediaAssetRecord {
|
||||||
|
let tags = media_assets_service::tag_list(&item);
|
||||||
|
BackupMediaAssetRecord {
|
||||||
|
object_key: item.object_key,
|
||||||
|
title: item.title,
|
||||||
|
alt_text: item.alt_text,
|
||||||
|
caption: item.caption,
|
||||||
|
tags,
|
||||||
|
notes: item.notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn export_site_backup(ctx: &AppContext) -> Result<SiteBackupDocument> {
|
||||||
|
let site_settings_row = site_settings::load_current(ctx).await?;
|
||||||
|
let markdown_posts = content::sync_markdown_posts(ctx).await?;
|
||||||
|
let categories = categories::Entity::find()
|
||||||
|
.order_by_asc(categories::Column::Slug)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(export_category_record)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let tags = tags::Entity::find()
|
||||||
|
.order_by_asc(tags::Column::Slug)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(export_tag_record)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let reviews = reviews::Entity::find()
|
||||||
|
.order_by_desc(reviews::Column::UpdatedAt)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(export_review_record)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let friend_links = friend_links::Entity::find()
|
||||||
|
.order_by_asc(friend_links::Column::SiteUrl)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(export_friend_link_record)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let media_assets = media_assets::Entity::find()
|
||||||
|
.order_by_asc(media_assets::Column::ObjectKey)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(export_media_asset_record)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let posts = markdown_posts
|
||||||
|
.into_iter()
|
||||||
|
.map(|post| {
|
||||||
|
let (_, markdown) = content::read_markdown_document(&post.slug)?;
|
||||||
|
Ok(BackupPostDocument {
|
||||||
|
slug: post.slug.clone(),
|
||||||
|
file_name: format!("{}.md", post.slug),
|
||||||
|
markdown,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
let storage_manifest = match export_storage_manifest(ctx).await {
|
||||||
|
Ok(items) => items,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(?error, "failed to export storage manifest, continuing without it");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(SiteBackupDocument {
|
||||||
|
version: BACKUP_VERSION.to_string(),
|
||||||
|
exported_at: Utc::now().to_rfc3339(),
|
||||||
|
includes_storage_binaries: false,
|
||||||
|
warning: WARNING_STORAGE_BINARIES.to_string(),
|
||||||
|
site_settings: site_settings_row,
|
||||||
|
categories,
|
||||||
|
tags,
|
||||||
|
reviews,
|
||||||
|
friend_links,
|
||||||
|
media_assets,
|
||||||
|
storage_manifest,
|
||||||
|
posts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn restore_site_settings(
|
||||||
|
ctx: &AppContext,
|
||||||
|
value: &site_settings_entity::Model,
|
||||||
|
) -> Result<()> {
|
||||||
|
let current = site_settings::load_current(ctx).await?;
|
||||||
|
let mut active = value.clone().into_active_model();
|
||||||
|
active.id = Set(current.id);
|
||||||
|
active.created_at = Set(current.created_at);
|
||||||
|
active.updated_at = Set(Utc::now().into());
|
||||||
|
active.reset_all().update(&ctx.db).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert_category(ctx: &AppContext, item: &BackupTaxonomyRecord) -> Result<()> {
|
||||||
|
let name = trim_to_option(Some(item.name.clone())).unwrap_or_else(|| item.slug.clone());
|
||||||
|
let slug = trim_to_option(Some(item.slug.clone())).unwrap_or_else(|| slugify(&name));
|
||||||
|
if slug.is_empty() {
|
||||||
|
return Err(Error::BadRequest("分类 slug 不能为空".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing = categories::Entity::find()
|
||||||
|
.filter(
|
||||||
|
Condition::any()
|
||||||
|
.add(categories::Column::Slug.eq(&slug))
|
||||||
|
.add(categories::Column::Name.eq(name.clone())),
|
||||||
|
)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let has_existing = existing.is_some();
|
||||||
|
let mut active = existing
|
||||||
|
.map(|model| model.into_active_model())
|
||||||
|
.unwrap_or_default();
|
||||||
|
active.name = Set(Some(name));
|
||||||
|
active.slug = Set(slug);
|
||||||
|
active.description = Set(trim_to_option(item.description.clone()));
|
||||||
|
active.cover_image = Set(trim_to_option(item.cover_image.clone()));
|
||||||
|
active.accent_color = Set(trim_to_option(item.accent_color.clone()));
|
||||||
|
active.seo_title = Set(trim_to_option(item.seo_title.clone()));
|
||||||
|
active.seo_description = Set(trim_to_option(item.seo_description.clone()));
|
||||||
|
|
||||||
|
if has_existing {
|
||||||
|
active.update(&ctx.db).await?;
|
||||||
|
} else {
|
||||||
|
active.insert(&ctx.db).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert_tag(ctx: &AppContext, item: &BackupTaxonomyRecord) -> Result<()> {
|
||||||
|
let name = trim_to_option(Some(item.name.clone())).unwrap_or_else(|| item.slug.clone());
|
||||||
|
let slug = trim_to_option(Some(item.slug.clone())).unwrap_or_else(|| slugify(&name));
|
||||||
|
if slug.is_empty() {
|
||||||
|
return Err(Error::BadRequest("标签 slug 不能为空".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing = tags::Entity::find()
|
||||||
|
.filter(
|
||||||
|
Condition::any()
|
||||||
|
.add(tags::Column::Slug.eq(&slug))
|
||||||
|
.add(tags::Column::Name.eq(name.clone())),
|
||||||
|
)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let has_existing = existing.is_some();
|
||||||
|
let mut active = existing
|
||||||
|
.map(|model| model.into_active_model())
|
||||||
|
.unwrap_or_default();
|
||||||
|
active.name = Set(Some(name));
|
||||||
|
active.slug = Set(slug);
|
||||||
|
active.description = Set(trim_to_option(item.description.clone()));
|
||||||
|
active.cover_image = Set(trim_to_option(item.cover_image.clone()));
|
||||||
|
active.accent_color = Set(trim_to_option(item.accent_color.clone()));
|
||||||
|
active.seo_title = Set(trim_to_option(item.seo_title.clone()));
|
||||||
|
active.seo_description = Set(trim_to_option(item.seo_description.clone()));
|
||||||
|
|
||||||
|
if has_existing {
|
||||||
|
active.update(&ctx.db).await?;
|
||||||
|
} else {
|
||||||
|
active.insert(&ctx.db).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert_friend_link(ctx: &AppContext, item: &BackupFriendLinkRecord) -> Result<()> {
|
||||||
|
let site_url = trim_to_option(Some(item.site_url.clone()))
|
||||||
|
.ok_or_else(|| Error::BadRequest("友链 site_url 不能为空".to_string()))?;
|
||||||
|
|
||||||
|
let existing = friend_links::Entity::find()
|
||||||
|
.filter(friend_links::Column::SiteUrl.eq(&site_url))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let has_existing = existing.is_some();
|
||||||
|
let mut active = existing
|
||||||
|
.map(|model| model.into_active_model())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
active.site_name = Set(trim_to_option(item.site_name.clone()));
|
||||||
|
active.site_url = Set(site_url);
|
||||||
|
active.avatar_url = Set(trim_to_option(item.avatar_url.clone()));
|
||||||
|
active.description = Set(trim_to_option(item.description.clone()));
|
||||||
|
active.category = Set(trim_to_option(item.category.clone()));
|
||||||
|
active.status = Set(trim_to_option(item.status.clone()));
|
||||||
|
|
||||||
|
if has_existing {
|
||||||
|
active.update(&ctx.db).await?;
|
||||||
|
} else {
|
||||||
|
active.insert(&ctx.db).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert_review(ctx: &AppContext, item: &BackupReviewRecord) -> Result<()> {
|
||||||
|
let title = trim_to_option(item.title.clone());
|
||||||
|
let review_type = trim_to_option(item.review_type.clone());
|
||||||
|
let review_date = trim_to_option(item.review_date.clone());
|
||||||
|
|
||||||
|
let mut query = reviews::Entity::find();
|
||||||
|
if let Some(value) = title.clone() {
|
||||||
|
query = query.filter(reviews::Column::Title.eq(value));
|
||||||
|
}
|
||||||
|
if let Some(value) = review_type.clone() {
|
||||||
|
query = query.filter(reviews::Column::ReviewType.eq(value));
|
||||||
|
}
|
||||||
|
if let Some(value) = review_date.clone() {
|
||||||
|
query = query.filter(reviews::Column::ReviewDate.eq(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing = if title.is_some() || review_type.is_some() || review_date.is_some() {
|
||||||
|
query.order_by_asc(reviews::Column::Id).one(&ctx.db).await?
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let has_existing = existing.is_some();
|
||||||
|
let mut active = existing
|
||||||
|
.map(|model| model.into_active_model())
|
||||||
|
.unwrap_or_default();
|
||||||
|
active.title = Set(title);
|
||||||
|
active.review_type = Set(review_type);
|
||||||
|
active.rating = Set(item.rating);
|
||||||
|
active.review_date = Set(review_date);
|
||||||
|
active.status = Set(trim_to_option(item.status.clone()));
|
||||||
|
active.description = Set(trim_to_option(item.description.clone()));
|
||||||
|
active.tags = Set(trim_to_option(item.tags.clone()));
|
||||||
|
active.cover = Set(trim_to_option(item.cover.clone()));
|
||||||
|
active.link_url = Set(trim_to_option(item.link_url.clone()));
|
||||||
|
|
||||||
|
if has_existing {
|
||||||
|
active.update(&ctx.db).await?;
|
||||||
|
} else {
|
||||||
|
active.insert(&ctx.db).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert_media_asset(ctx: &AppContext, item: &BackupMediaAssetRecord) -> Result<()> {
|
||||||
|
media_assets_service::upsert_by_key(
|
||||||
|
ctx,
|
||||||
|
&item.object_key,
|
||||||
|
media_assets_service::MediaAssetMetadataInput {
|
||||||
|
title: item.title.clone(),
|
||||||
|
alt_text: item.alt_text.clone(),
|
||||||
|
caption: item.caption.clone(),
|
||||||
|
tags: Some(item.tags.clone()),
|
||||||
|
notes: item.notes.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_backup_posts(
|
||||||
|
ctx: &AppContext,
|
||||||
|
documents: &[BackupPostDocument],
|
||||||
|
replace_existing: bool,
|
||||||
|
) -> Result<usize> {
|
||||||
|
let dir = markdown_posts_dir();
|
||||||
|
fs::create_dir_all(&dir).map_err(io_error)?;
|
||||||
|
|
||||||
|
if replace_existing {
|
||||||
|
remove_existing_markdown_documents()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if documents.is_empty() {
|
||||||
|
if replace_existing {
|
||||||
|
posts::Entity::delete_many().exec(&ctx.db).await?;
|
||||||
|
}
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut written = std::collections::HashSet::new();
|
||||||
|
for document in documents {
|
||||||
|
let (slug, markdown) = normalized_backup_post(document)?;
|
||||||
|
fs::write(content::markdown_post_path(&slug), markdown).map_err(io_error)?;
|
||||||
|
written.insert(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
content::sync_markdown_posts(ctx).await?;
|
||||||
|
Ok(written.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn import_site_backup(
|
||||||
|
ctx: &AppContext,
|
||||||
|
backup: SiteBackupDocument,
|
||||||
|
mode: Option<&str>,
|
||||||
|
) -> Result<SiteBackupImportSummary> {
|
||||||
|
let mode = normalize_backup_mode(mode);
|
||||||
|
let replace_existing = mode == "replace";
|
||||||
|
|
||||||
|
if replace_existing {
|
||||||
|
friend_links::Entity::delete_many().exec(&ctx.db).await?;
|
||||||
|
reviews::Entity::delete_many().exec(&ctx.db).await?;
|
||||||
|
media_assets::Entity::delete_many().exec(&ctx.db).await?;
|
||||||
|
categories::Entity::delete_many().exec(&ctx.db).await?;
|
||||||
|
tags::Entity::delete_many().exec(&ctx.db).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_site_settings(ctx, &backup.site_settings).await?;
|
||||||
|
let posts_written = write_backup_posts(ctx, &backup.posts, replace_existing).await?;
|
||||||
|
|
||||||
|
let mut categories_upserted = 0_usize;
|
||||||
|
for item in &backup.categories {
|
||||||
|
upsert_category(ctx, item).await?;
|
||||||
|
categories_upserted += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tags_upserted = 0_usize;
|
||||||
|
for item in &backup.tags {
|
||||||
|
upsert_tag(ctx, item).await?;
|
||||||
|
tags_upserted += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut reviews_upserted = 0_usize;
|
||||||
|
for item in &backup.reviews {
|
||||||
|
upsert_review(ctx, item).await?;
|
||||||
|
reviews_upserted += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut friend_links_upserted = 0_usize;
|
||||||
|
for item in &backup.friend_links {
|
||||||
|
upsert_friend_link(ctx, item).await?;
|
||||||
|
friend_links_upserted += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut media_assets_upserted = 0_usize;
|
||||||
|
for item in &backup.media_assets {
|
||||||
|
upsert_media_asset(ctx, item).await?;
|
||||||
|
media_assets_upserted += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(SiteBackupImportSummary {
|
||||||
|
imported: true,
|
||||||
|
mode,
|
||||||
|
site_settings_restored: true,
|
||||||
|
posts_written,
|
||||||
|
categories_upserted,
|
||||||
|
tags_upserted,
|
||||||
|
reviews_upserted,
|
||||||
|
friend_links_upserted,
|
||||||
|
media_assets_upserted,
|
||||||
|
storage_manifest_items: backup.storage_manifest.as_ref().map(Vec::len).unwrap_or(0),
|
||||||
|
includes_storage_binaries: backup.includes_storage_binaries,
|
||||||
|
warning: WARNING_STORAGE_BINARIES.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ pub struct CommentGuardInput<'a> {
|
|||||||
pub author: Option<&'a str>,
|
pub author: Option<&'a str>,
|
||||||
pub content: Option<&'a str>,
|
pub content: Option<&'a str>,
|
||||||
pub honeypot_website: Option<&'a str>,
|
pub honeypot_website: Option<&'a str>,
|
||||||
|
pub turnstile_token: Option<&'a str>,
|
||||||
pub captcha_token: Option<&'a str>,
|
pub captcha_token: Option<&'a str>,
|
||||||
pub captcha_answer: Option<&'a str>,
|
pub captcha_answer: Option<&'a str>,
|
||||||
}
|
}
|
||||||
@@ -362,7 +363,16 @@ pub async fn enforce_comment_guard(ctx: &AppContext, input: &CommentGuardInput<'
|
|||||||
return Err(Error::BadRequest("提交未通过校验".to_string()));
|
return Err(Error::BadRequest("提交未通过校验".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
verify_captcha_solution(input.captcha_token, input.captcha_answer, input.ip_address)?;
|
if !crate::services::turnstile::verify_if_enabled(
|
||||||
|
ctx,
|
||||||
|
crate::services::turnstile::TurnstileScope::Comment,
|
||||||
|
input.turnstile_token,
|
||||||
|
input.ip_address,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
verify_captcha_solution(input.captcha_token, input.captcha_answer, input.ip_address)?;
|
||||||
|
}
|
||||||
|
|
||||||
if contains_blocked_keyword(input).is_some() {
|
if contains_blocked_keyword(input).is_some() {
|
||||||
return Err(Error::BadRequest("评论内容包含敏感关键词".to_string()));
|
return Err(Error::BadRequest("评论内容包含敏感关键词".to_string()));
|
||||||
|
|||||||
125
backend/src/services/media_assets.rs
Normal file
125
backend/src/services/media_assets.rs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, Set};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::models::_entities::media_assets;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||||
|
pub struct MediaAssetMetadataInput {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub alt_text: Option<String>,
|
||||||
|
pub caption: Option<String>,
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_to_option(value: Option<String>) -> Option<String> {
|
||||||
|
value.and_then(|item| {
|
||||||
|
let trimmed = item.trim().to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_tag_list(values: Option<Vec<String>>) -> Option<Value> {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let tags = values
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|item| trim_to_option(Some(item)))
|
||||||
|
.map(|item| item.to_ascii_lowercase())
|
||||||
|
.filter(|item| seen.insert(item.clone()))
|
||||||
|
.map(Value::String)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
(!tags.is_empty()).then_some(Value::Array(tags))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tag_list(model: &media_assets::Model) -> Vec<String> {
|
||||||
|
model
|
||||||
|
.tags
|
||||||
|
.as_ref()
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|item| item.as_str().map(ToString::to_string))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_by_keys(
|
||||||
|
ctx: &AppContext,
|
||||||
|
keys: &[String],
|
||||||
|
) -> Result<HashMap<String, media_assets::Model>> {
|
||||||
|
if keys.is_empty() {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(media_assets::Entity::find()
|
||||||
|
.filter(media_assets::Column::ObjectKey.is_in(keys.iter().cloned()))
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| (item.object_key.clone(), item))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_by_key(ctx: &AppContext, object_key: &str) -> Result<Option<media_assets::Model>> {
|
||||||
|
media_assets::Entity::find()
|
||||||
|
.filter(media_assets::Column::ObjectKey.eq(object_key))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn upsert_by_key(
|
||||||
|
ctx: &AppContext,
|
||||||
|
object_key: &str,
|
||||||
|
payload: MediaAssetMetadataInput,
|
||||||
|
) -> Result<media_assets::Model> {
|
||||||
|
let normalized_key = object_key.trim();
|
||||||
|
if normalized_key.is_empty() {
|
||||||
|
return Err(Error::BadRequest("object key 不能为空".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing = get_by_key(ctx, normalized_key).await?;
|
||||||
|
let has_existing = existing.is_some();
|
||||||
|
let mut active = existing
|
||||||
|
.map(|item| item.into_active_model())
|
||||||
|
.unwrap_or_else(|| media_assets::ActiveModel {
|
||||||
|
object_key: Set(normalized_key.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
active.title = Set(trim_to_option(payload.title));
|
||||||
|
active.alt_text = Set(trim_to_option(payload.alt_text));
|
||||||
|
active.caption = Set(trim_to_option(payload.caption));
|
||||||
|
active.tags = Set(normalize_tag_list(payload.tags));
|
||||||
|
active.notes = Set(trim_to_option(payload.notes));
|
||||||
|
|
||||||
|
if has_existing {
|
||||||
|
active.update(&ctx.db).await.map_err(Into::into)
|
||||||
|
} else {
|
||||||
|
active.insert(&ctx.db).await.map_err(Into::into)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_by_key(ctx: &AppContext, object_key: &str) -> Result<()> {
|
||||||
|
if let Some(item) = get_by_key(ctx, object_key).await? {
|
||||||
|
item.delete(&ctx.db).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_by_keys(ctx: &AppContext, object_keys: &[String]) -> Result<()> {
|
||||||
|
for key in object_keys {
|
||||||
|
delete_by_key(ctx, key).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -2,9 +2,13 @@ pub mod admin_audit;
|
|||||||
pub mod abuse_guard;
|
pub mod abuse_guard;
|
||||||
pub mod ai;
|
pub mod ai;
|
||||||
pub mod analytics;
|
pub mod analytics;
|
||||||
|
pub mod backups;
|
||||||
pub mod comment_guard;
|
pub mod comment_guard;
|
||||||
pub mod content;
|
pub mod content;
|
||||||
|
pub mod media_assets;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod post_revisions;
|
pub mod post_revisions;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod subscriptions;
|
pub mod subscriptions;
|
||||||
|
pub mod turnstile;
|
||||||
|
pub mod web_push;
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use crate::{
|
use crate::{
|
||||||
controllers::site_settings,
|
controllers::site_settings,
|
||||||
models::_entities::{comments, friend_links},
|
models::_entities::{comments, friend_links, site_settings as site_settings_model},
|
||||||
services::subscriptions,
|
services::subscriptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
fn notification_channel_type(settings: &site_settings_model::Model) -> &'static str {
|
||||||
|
match settings
|
||||||
|
.notification_channel_type
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.map(str::to_ascii_lowercase)
|
||||||
|
.as_deref()
|
||||||
|
{
|
||||||
|
Some("ntfy") => subscriptions::CHANNEL_NTFY,
|
||||||
|
_ => subscriptions::CHANNEL_WEBHOOK,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn trim_to_option(value: Option<String>) -> Option<String> {
|
fn trim_to_option(value: Option<String>) -> Option<String> {
|
||||||
value.and_then(|item| {
|
value.and_then(|item| {
|
||||||
let trimmed = item.trim().to_string();
|
let trimmed = item.trim().to_string();
|
||||||
@@ -81,9 +94,10 @@ pub async fn notify_new_comment(ctx: &AppContext, item: &comments::Model) {
|
|||||||
|
|
||||||
if settings.notification_comment_enabled.unwrap_or(false) {
|
if settings.notification_comment_enabled.unwrap_or(false) {
|
||||||
if let Some(target) = trim_to_option(settings.notification_webhook_url.clone()) {
|
if let Some(target) = trim_to_option(settings.notification_webhook_url.clone()) {
|
||||||
|
let channel_type = notification_channel_type(&settings);
|
||||||
if let Err(error) = subscriptions::queue_direct_notification(
|
if let Err(error) = subscriptions::queue_direct_notification(
|
||||||
ctx,
|
ctx,
|
||||||
subscriptions::CHANNEL_WEBHOOK,
|
channel_type,
|
||||||
&target,
|
&target,
|
||||||
subscriptions::EVENT_COMMENT_CREATED,
|
subscriptions::EVENT_COMMENT_CREATED,
|
||||||
"新评论通知",
|
"新评论通知",
|
||||||
@@ -94,7 +108,7 @@ pub async fn notify_new_comment(ctx: &AppContext, item: &comments::Model) {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!("failed to queue legacy comment webhook notification: {error}");
|
tracing::warn!("failed to queue comment admin notification: {error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,9 +158,10 @@ pub async fn notify_new_friend_link(ctx: &AppContext, item: &friend_links::Model
|
|||||||
|
|
||||||
if settings.notification_friend_link_enabled.unwrap_or(false) {
|
if settings.notification_friend_link_enabled.unwrap_or(false) {
|
||||||
if let Some(target) = trim_to_option(settings.notification_webhook_url.clone()) {
|
if let Some(target) = trim_to_option(settings.notification_webhook_url.clone()) {
|
||||||
|
let channel_type = notification_channel_type(&settings);
|
||||||
if let Err(error) = subscriptions::queue_direct_notification(
|
if let Err(error) = subscriptions::queue_direct_notification(
|
||||||
ctx,
|
ctx,
|
||||||
subscriptions::CHANNEL_WEBHOOK,
|
channel_type,
|
||||||
&target,
|
&target,
|
||||||
subscriptions::EVENT_FRIEND_LINK_CREATED,
|
subscriptions::EVENT_FRIEND_LINK_CREATED,
|
||||||
"新友链申请通知",
|
"新友链申请通知",
|
||||||
@@ -157,7 +172,7 @@ pub async fn notify_new_friend_link(ctx: &AppContext, item: &friend_links::Model
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!("failed to queue legacy friend-link webhook notification: {error}");
|
tracing::warn!("failed to queue friend-link admin notification: {error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use uuid::Uuid;
|
|||||||
use crate::{
|
use crate::{
|
||||||
mailers::subscription::SubscriptionMailer,
|
mailers::subscription::SubscriptionMailer,
|
||||||
models::_entities::{notification_deliveries, posts, subscriptions},
|
models::_entities::{notification_deliveries, posts, subscriptions},
|
||||||
services::content,
|
services::{content, web_push as web_push_service},
|
||||||
workers::notification_delivery::{
|
workers::notification_delivery::{
|
||||||
NotificationDeliveryWorker, NotificationDeliveryWorkerArgs,
|
NotificationDeliveryWorker, NotificationDeliveryWorkerArgs,
|
||||||
},
|
},
|
||||||
@@ -26,6 +26,7 @@ pub const CHANNEL_WEBHOOK: &str = "webhook";
|
|||||||
pub const CHANNEL_DISCORD: &str = "discord";
|
pub const CHANNEL_DISCORD: &str = "discord";
|
||||||
pub const CHANNEL_TELEGRAM: &str = "telegram";
|
pub const CHANNEL_TELEGRAM: &str = "telegram";
|
||||||
pub const CHANNEL_NTFY: &str = "ntfy";
|
pub const CHANNEL_NTFY: &str = "ntfy";
|
||||||
|
pub const CHANNEL_WEB_PUSH: &str = "web_push";
|
||||||
|
|
||||||
pub const STATUS_PENDING: &str = "pending";
|
pub const STATUS_PENDING: &str = "pending";
|
||||||
pub const STATUS_ACTIVE: &str = "active";
|
pub const STATUS_ACTIVE: &str = "active";
|
||||||
@@ -139,6 +140,9 @@ pub fn normalize_channel_type(value: &str) -> String {
|
|||||||
CHANNEL_DISCORD => CHANNEL_DISCORD.to_string(),
|
CHANNEL_DISCORD => CHANNEL_DISCORD.to_string(),
|
||||||
CHANNEL_TELEGRAM => CHANNEL_TELEGRAM.to_string(),
|
CHANNEL_TELEGRAM => CHANNEL_TELEGRAM.to_string(),
|
||||||
CHANNEL_NTFY => CHANNEL_NTFY.to_string(),
|
CHANNEL_NTFY => CHANNEL_NTFY.to_string(),
|
||||||
|
CHANNEL_WEB_PUSH | "browser_push" | "browser-push" | "webpush" => {
|
||||||
|
CHANNEL_WEB_PUSH.to_string()
|
||||||
|
}
|
||||||
_ => CHANNEL_EMAIL.to_string(),
|
_ => CHANNEL_EMAIL.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,6 +229,35 @@ fn merge_metadata(existing: Option<&Value>, incoming: Option<Value>) -> Option<V
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_browser_push_subscription(raw: Value) -> Result<Value> {
|
||||||
|
let mut subscription = serde_json::from_value::<web_push::SubscriptionInfo>(raw)
|
||||||
|
.map_err(|_| Error::BadRequest("browser push subscription 非法".to_string()))?;
|
||||||
|
|
||||||
|
subscription.endpoint = subscription.endpoint.trim().to_string();
|
||||||
|
subscription.keys.p256dh = subscription.keys.p256dh.trim().to_string();
|
||||||
|
subscription.keys.auth = subscription.keys.auth.trim().to_string();
|
||||||
|
|
||||||
|
if subscription.endpoint.is_empty()
|
||||||
|
|| subscription.keys.p256dh.is_empty()
|
||||||
|
|| subscription.keys.auth.is_empty()
|
||||||
|
{
|
||||||
|
return Err(Error::BadRequest(
|
||||||
|
"browser push subscription 缺少 endpoint / keys".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::to_value(subscription).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_browser_push_metadata(existing: Option<&Value>, incoming: Option<Value>, subscription: Value) -> Value {
|
||||||
|
let mut object = merge_metadata(existing, incoming)
|
||||||
|
.and_then(|value| value.as_object().cloned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
object.insert("kind".to_string(), Value::String("browser-push".to_string()));
|
||||||
|
object.insert("subscription".to_string(), subscription);
|
||||||
|
Value::Object(object)
|
||||||
|
}
|
||||||
|
|
||||||
fn json_string_list(value: Option<&Value>, key: &str) -> Vec<String> {
|
fn json_string_list(value: Option<&Value>, key: &str) -> Vec<String> {
|
||||||
value
|
value
|
||||||
.and_then(Value::as_object)
|
.and_then(Value::as_object)
|
||||||
@@ -592,6 +625,88 @@ pub async fn create_public_email_subscription(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn create_public_web_push_subscription(
|
||||||
|
ctx: &AppContext,
|
||||||
|
subscription: Value,
|
||||||
|
metadata: Option<Value>,
|
||||||
|
) -> Result<PublicSubscriptionResult> {
|
||||||
|
let normalized_subscription = normalize_browser_push_subscription(subscription)?;
|
||||||
|
let endpoint = normalized_subscription
|
||||||
|
.get("endpoint")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or_else(|| Error::BadRequest("browser push endpoint 非法".to_string()))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let existing = subscriptions::Entity::find()
|
||||||
|
.filter(subscriptions::Column::ChannelType.eq(CHANNEL_WEB_PUSH))
|
||||||
|
.filter(subscriptions::Column::Target.eq(&endpoint))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(existing) = existing {
|
||||||
|
let mut active = existing.clone().into_active_model();
|
||||||
|
let manage_token = existing
|
||||||
|
.manage_token
|
||||||
|
.clone()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(generate_subscription_token);
|
||||||
|
active.manage_token = Set(Some(manage_token));
|
||||||
|
active.status = Set(STATUS_ACTIVE.to_string());
|
||||||
|
active.confirm_token = Set(None);
|
||||||
|
active.verified_at = Set(Some(Utc::now().to_rfc3339()));
|
||||||
|
active.metadata = Set(Some(merge_browser_push_metadata(
|
||||||
|
existing.metadata.as_ref(),
|
||||||
|
metadata,
|
||||||
|
normalized_subscription,
|
||||||
|
)));
|
||||||
|
if existing
|
||||||
|
.display_name
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
active.display_name = Set(Some("Browser Push".to_string()));
|
||||||
|
}
|
||||||
|
let updated = active.update(&ctx.db).await?;
|
||||||
|
return Ok(PublicSubscriptionResult {
|
||||||
|
subscription: to_public_subscription_view(&updated),
|
||||||
|
requires_confirmation: false,
|
||||||
|
message: "浏览器推送已更新,后续有新内容时会直接提醒。".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = subscriptions::ActiveModel {
|
||||||
|
channel_type: Set(CHANNEL_WEB_PUSH.to_string()),
|
||||||
|
target: Set(endpoint),
|
||||||
|
display_name: Set(Some("Browser Push".to_string())),
|
||||||
|
status: Set(STATUS_ACTIVE.to_string()),
|
||||||
|
filters: Set(Some(default_public_filters())),
|
||||||
|
secret: Set(None),
|
||||||
|
notes: Set(None),
|
||||||
|
confirm_token: Set(None),
|
||||||
|
manage_token: Set(Some(generate_subscription_token())),
|
||||||
|
metadata: Set(Some(merge_browser_push_metadata(
|
||||||
|
None,
|
||||||
|
metadata,
|
||||||
|
normalized_subscription,
|
||||||
|
))),
|
||||||
|
verified_at: Set(Some(Utc::now().to_rfc3339())),
|
||||||
|
last_notified_at: Set(None),
|
||||||
|
failure_count: Set(Some(0)),
|
||||||
|
last_delivery_status: Set(None),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(PublicSubscriptionResult {
|
||||||
|
subscription: to_public_subscription_view(&created),
|
||||||
|
requires_confirmation: false,
|
||||||
|
message: "浏览器推送已开启,后续有新内容时会直接提醒。".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn confirm_subscription(ctx: &AppContext, token: &str) -> Result<subscriptions::Model> {
|
pub async fn confirm_subscription(ctx: &AppContext, token: &str) -> Result<subscriptions::Model> {
|
||||||
let token = token.trim();
|
let token = token.trim();
|
||||||
if token.is_empty() {
|
if token.is_empty() {
|
||||||
@@ -869,6 +984,7 @@ fn provider_name(channel_type: &str) -> &'static str {
|
|||||||
CHANNEL_DISCORD => "discord-webhook",
|
CHANNEL_DISCORD => "discord-webhook",
|
||||||
CHANNEL_TELEGRAM => "telegram-bot-api",
|
CHANNEL_TELEGRAM => "telegram-bot-api",
|
||||||
CHANNEL_NTFY => "ntfy",
|
CHANNEL_NTFY => "ntfy",
|
||||||
|
CHANNEL_WEB_PUSH => "web-push",
|
||||||
_ => "webhook",
|
_ => "webhook",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -882,10 +998,65 @@ fn resolve_ntfy_target(target: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn collapse_whitespace(value: &str) -> String {
|
||||||
|
value.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_chars(value: &str, max_chars: usize) -> String {
|
||||||
|
if value.chars().count() <= max_chars {
|
||||||
|
return value.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sliced = value.chars().take(max_chars).collect::<String>();
|
||||||
|
sliced.push('…');
|
||||||
|
sliced
|
||||||
|
}
|
||||||
|
|
||||||
|
fn site_asset_url(site_url: Option<&str>, path: &str) -> Option<String> {
|
||||||
|
let base = site_url?.trim().trim_end_matches('/');
|
||||||
|
if base.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(format!("{base}{path}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn web_push_target_url(message: &QueuedDeliveryPayload) -> Option<String> {
|
||||||
|
message
|
||||||
|
.payload
|
||||||
|
.get("url")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.or_else(|| message.site_url.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_web_push_payload(message: &QueuedDeliveryPayload) -> Value {
|
||||||
|
let body = truncate_chars(&collapse_whitespace(&message.text), 220);
|
||||||
|
|
||||||
|
serde_json::json!({
|
||||||
|
"title": message.subject,
|
||||||
|
"body": body,
|
||||||
|
"icon": site_asset_url(message.site_url.as_deref(), "/favicon.svg"),
|
||||||
|
"badge": site_asset_url(message.site_url.as_deref(), "/favicon.ico"),
|
||||||
|
"url": web_push_target_url(message),
|
||||||
|
"tag": message
|
||||||
|
.payload
|
||||||
|
.get("event_type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("subscription"),
|
||||||
|
"data": {
|
||||||
|
"event_type": message.payload.get("event_type").cloned().unwrap_or(Value::Null),
|
||||||
|
"payload": message.payload,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn deliver_via_channel(
|
async fn deliver_via_channel(
|
||||||
|
ctx: &AppContext,
|
||||||
channel_type: &str,
|
channel_type: &str,
|
||||||
target: &str,
|
target: &str,
|
||||||
message: &QueuedDeliveryPayload,
|
message: &QueuedDeliveryPayload,
|
||||||
|
metadata: Option<&Value>,
|
||||||
) -> Result<Option<String>> {
|
) -> Result<Option<String>> {
|
||||||
match channel_type {
|
match channel_type {
|
||||||
CHANNEL_EMAIL => Err(Error::BadRequest(
|
CHANNEL_EMAIL => Err(Error::BadRequest(
|
||||||
@@ -923,6 +1094,21 @@ async fn deliver_via_channel(
|
|||||||
.map(|_| None)
|
.map(|_| None)
|
||||||
.map_err(|error| Error::BadRequest(error.to_string()))
|
.map_err(|error| Error::BadRequest(error.to_string()))
|
||||||
}
|
}
|
||||||
|
CHANNEL_WEB_PUSH => {
|
||||||
|
let settings = crate::controllers::site_settings::load_current(ctx).await?;
|
||||||
|
let subscription_info = web_push_service::subscription_info_from_metadata(metadata)?;
|
||||||
|
let payload = serde_json::to_vec(&build_web_push_payload(message))?;
|
||||||
|
web_push_service::send_payload(
|
||||||
|
&settings,
|
||||||
|
&subscription_info,
|
||||||
|
&payload,
|
||||||
|
Some(web_push::Urgency::Normal),
|
||||||
|
24 * 60 * 60,
|
||||||
|
message.site_url.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let envelope = DeliveryEnvelope {
|
let envelope = DeliveryEnvelope {
|
||||||
event: message
|
event: message
|
||||||
@@ -1010,10 +1196,17 @@ pub async fn process_delivery(ctx: &AppContext, delivery_id: i32) -> Result<()>
|
|||||||
.await
|
.await
|
||||||
.map(|_| None)
|
.map(|_| None)
|
||||||
} else {
|
} else {
|
||||||
deliver_via_channel(&subscription.channel_type, &subscription.target, &message).await
|
deliver_via_channel(
|
||||||
|
ctx,
|
||||||
|
&subscription.channel_type,
|
||||||
|
&subscription.target,
|
||||||
|
&message,
|
||||||
|
subscription.metadata.as_ref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
deliver_via_channel(&delivery.channel_type, &delivery.target, &message).await
|
deliver_via_channel(ctx, &delivery.channel_type, &delivery.target, &message, None).await
|
||||||
};
|
};
|
||||||
let subscription_id = delivery.subscription_id;
|
let subscription_id = delivery.subscription_id;
|
||||||
let delivery_channel_type = delivery.channel_type.clone();
|
let delivery_channel_type = delivery.channel_type.clone();
|
||||||
|
|||||||
182
backend/src/services/turnstile.rs
Normal file
182
backend/src/services/turnstile.rs
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::models::_entities::site_settings;
|
||||||
|
|
||||||
|
const DEFAULT_TURNSTILE_VERIFY_URL: &str =
|
||||||
|
"https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
||||||
|
|
||||||
|
const ENV_TURNSTILE_SECRET_KEY: &str = "TERMI_TURNSTILE_SECRET_KEY";
|
||||||
|
const ENV_LEGACY_TURNSTILE_SECRET_KEY: &str = "TERMI_COMMENT_TURNSTILE_SECRET_KEY";
|
||||||
|
const ENV_TURNSTILE_SITE_KEY: &str = "PUBLIC_COMMENT_TURNSTILE_SITE_KEY";
|
||||||
|
const ENV_TURNSTILE_VERIFY_URL: &str = "TERMI_TURNSTILE_VERIFY_URL";
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum TurnstileScope {
|
||||||
|
Comment,
|
||||||
|
Subscription,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
struct TurnstileVerifyResponse {
|
||||||
|
success: bool,
|
||||||
|
#[serde(default, rename = "error-codes")]
|
||||||
|
error_codes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_to_option(value: Option<&str>) -> Option<String> {
|
||||||
|
value.and_then(|item| {
|
||||||
|
let trimmed = item.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_value(name: &str) -> Option<String> {
|
||||||
|
std::env::var(name)
|
||||||
|
.ok()
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configured_value(value: Option<&String>) -> Option<String> {
|
||||||
|
value.and_then(|item| {
|
||||||
|
let trimmed = item.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_ip(value: Option<&str>) -> Option<String> {
|
||||||
|
trim_to_option(value).map(|item| item.chars().take(96).collect::<String>())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_url() -> String {
|
||||||
|
env_value(ENV_TURNSTILE_VERIFY_URL)
|
||||||
|
.unwrap_or_else(|| DEFAULT_TURNSTILE_VERIFY_URL.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client() -> &'static Client {
|
||||||
|
static CLIENT: OnceLock<Client> = OnceLock::new();
|
||||||
|
CLIENT.get_or_init(Client::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn secret_key(settings: &site_settings::Model) -> Option<String> {
|
||||||
|
configured_value(settings.turnstile_secret_key.as_ref())
|
||||||
|
.or_else(|| env_value(ENV_TURNSTILE_SECRET_KEY))
|
||||||
|
.or_else(|| env_value(ENV_LEGACY_TURNSTILE_SECRET_KEY))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn site_key(settings: &site_settings::Model) -> Option<String> {
|
||||||
|
configured_value(settings.turnstile_site_key.as_ref())
|
||||||
|
.or_else(|| env_value(ENV_TURNSTILE_SITE_KEY))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn site_key_configured(settings: &site_settings::Model) -> bool {
|
||||||
|
site_key(settings).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn secret_key_configured(settings: &site_settings::Model) -> bool {
|
||||||
|
secret_key(settings).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scope_enabled(settings: &site_settings::Model, scope: TurnstileScope) -> bool {
|
||||||
|
match scope {
|
||||||
|
TurnstileScope::Comment => settings.comment_turnstile_enabled.unwrap_or(false),
|
||||||
|
TurnstileScope::Subscription => settings.subscription_turnstile_enabled.unwrap_or(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_enabled(settings: &site_settings::Model, scope: TurnstileScope) -> bool {
|
||||||
|
scope_enabled(settings, scope)
|
||||||
|
&& site_key_configured(settings)
|
||||||
|
&& secret_key_configured(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn is_enabled_for_ctx(ctx: &AppContext, scope: TurnstileScope) -> Result<bool> {
|
||||||
|
let settings = crate::controllers::site_settings::load_current(ctx).await?;
|
||||||
|
Ok(is_enabled(&settings, scope))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_token(
|
||||||
|
settings: &site_settings::Model,
|
||||||
|
token: Option<&str>,
|
||||||
|
client_ip: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let secret = secret_key(settings).ok_or_else(|| {
|
||||||
|
Error::BadRequest("人机验证尚未配置完成,请稍后重试".to_string())
|
||||||
|
})?;
|
||||||
|
let response_token = trim_to_option(token)
|
||||||
|
.ok_or_else(|| Error::BadRequest("请先完成人机验证".to_string()))?;
|
||||||
|
|
||||||
|
let mut form_data = vec![
|
||||||
|
("secret".to_string(), secret),
|
||||||
|
("response".to_string(), response_token),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(remote_ip) = normalize_ip(client_ip) {
|
||||||
|
form_data.push(("remoteip".to_string(), remote_ip));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = client()
|
||||||
|
.post(verify_url())
|
||||||
|
.form(&form_data)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
tracing::warn!("turnstile verify request failed: {error}");
|
||||||
|
Error::BadRequest("人机验证服务暂时不可用,请稍后重试".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
tracing::warn!(
|
||||||
|
"turnstile verify returned unexpected status: {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
return Err(Error::BadRequest(
|
||||||
|
"人机验证服务暂时不可用,请稍后重试".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = response
|
||||||
|
.json::<TurnstileVerifyResponse>()
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
tracing::warn!("turnstile verify decode failed: {error}");
|
||||||
|
Error::BadRequest("人机验证服务暂时不可用,请稍后重试".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !payload.success {
|
||||||
|
tracing::warn!(
|
||||||
|
error_codes = ?payload.error_codes,
|
||||||
|
"turnstile verify rejected request"
|
||||||
|
);
|
||||||
|
return Err(Error::BadRequest("人机验证未通过,请重试".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn verify_if_enabled(
|
||||||
|
ctx: &AppContext,
|
||||||
|
scope: TurnstileScope,
|
||||||
|
token: Option<&str>,
|
||||||
|
client_ip: Option<&str>,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let settings = crate::controllers::site_settings::load_current(ctx).await?;
|
||||||
|
if !is_enabled(&settings, scope) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_token(&settings, token, client_ip).await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
122
backend/src/services/web_push.rs
Normal file
122
backend/src/services/web_push.rs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
use loco_rs::prelude::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
use web_push::{
|
||||||
|
ContentEncoding, HyperWebPushClient, SubscriptionInfo, Urgency, VapidSignatureBuilder,
|
||||||
|
WebPushClient, WebPushMessageBuilder,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::models::_entities::site_settings;
|
||||||
|
|
||||||
|
const ENV_PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY: &str = "PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY";
|
||||||
|
const ENV_LEGACY_WEB_PUSH_VAPID_PUBLIC_KEY: &str = "TERMI_WEB_PUSH_VAPID_PUBLIC_KEY";
|
||||||
|
const ENV_WEB_PUSH_VAPID_PRIVATE_KEY: &str = "TERMI_WEB_PUSH_VAPID_PRIVATE_KEY";
|
||||||
|
const ENV_WEB_PUSH_VAPID_SUBJECT: &str = "TERMI_WEB_PUSH_VAPID_SUBJECT";
|
||||||
|
|
||||||
|
fn env_value(name: &str) -> Option<String> {
|
||||||
|
std::env::var(name)
|
||||||
|
.ok()
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configured_value(value: Option<&String>) -> Option<String> {
|
||||||
|
value.and_then(|item| {
|
||||||
|
let trimmed = item.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn public_key(settings: &site_settings::Model) -> Option<String> {
|
||||||
|
configured_value(settings.web_push_vapid_public_key.as_ref())
|
||||||
|
.or_else(|| env_value(ENV_PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY))
|
||||||
|
.or_else(|| env_value(ENV_LEGACY_WEB_PUSH_VAPID_PUBLIC_KEY))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn private_key(settings: &site_settings::Model) -> Option<String> {
|
||||||
|
configured_value(settings.web_push_vapid_private_key.as_ref())
|
||||||
|
.or_else(|| env_value(ENV_WEB_PUSH_VAPID_PRIVATE_KEY))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn vapid_subject(settings: &site_settings::Model) -> Option<String> {
|
||||||
|
configured_value(settings.web_push_vapid_subject.as_ref())
|
||||||
|
.or_else(|| env_value(ENV_WEB_PUSH_VAPID_SUBJECT))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effective_vapid_subject(settings: &site_settings::Model, site_url: Option<&str>) -> String {
|
||||||
|
vapid_subject(settings)
|
||||||
|
.or_else(|| {
|
||||||
|
site_url
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| value.starts_with("http://") || value.starts_with("https://"))
|
||||||
|
.map(ToString::to_string)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "mailto:noreply@example.com".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn public_key_configured(settings: &site_settings::Model) -> bool {
|
||||||
|
public_key(settings).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn private_key_configured(settings: &site_settings::Model) -> bool {
|
||||||
|
private_key(settings).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_enabled(settings: &site_settings::Model) -> bool {
|
||||||
|
settings.web_push_enabled.unwrap_or(false)
|
||||||
|
&& public_key_configured(settings)
|
||||||
|
&& private_key_configured(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subscription_info_from_metadata(metadata: Option<&Value>) -> Result<SubscriptionInfo> {
|
||||||
|
let subscription = metadata
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.and_then(|object| object.get("subscription"))
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| Error::BadRequest("browser push metadata 缺少 subscription".to_string()))?;
|
||||||
|
|
||||||
|
serde_json::from_value::<SubscriptionInfo>(subscription)
|
||||||
|
.map_err(|_| Error::BadRequest("browser push metadata 非法".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_payload(
|
||||||
|
settings: &site_settings::Model,
|
||||||
|
subscription_info: &SubscriptionInfo,
|
||||||
|
payload: &[u8],
|
||||||
|
urgency: Option<Urgency>,
|
||||||
|
ttl: u32,
|
||||||
|
site_url: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let private_key = private_key(settings)
|
||||||
|
.ok_or_else(|| Error::BadRequest("web push VAPID private key 未配置".to_string()))?;
|
||||||
|
|
||||||
|
let mut signature_builder = VapidSignatureBuilder::from_base64(&private_key, subscription_info)
|
||||||
|
.map_err(|error| Error::BadRequest(format!("web push vapid build failed: {error}")))?;
|
||||||
|
signature_builder.add_claim("sub", effective_vapid_subject(settings, site_url));
|
||||||
|
let signature = signature_builder
|
||||||
|
.build()
|
||||||
|
.map_err(|error| Error::BadRequest(format!("web push vapid sign failed: {error}")))?;
|
||||||
|
|
||||||
|
let mut builder = WebPushMessageBuilder::new(subscription_info);
|
||||||
|
builder.set_ttl(ttl);
|
||||||
|
if let Some(urgency) = urgency {
|
||||||
|
builder.set_urgency(urgency);
|
||||||
|
}
|
||||||
|
builder.set_payload(ContentEncoding::Aes128Gcm, payload);
|
||||||
|
builder.set_vapid_signature(signature);
|
||||||
|
|
||||||
|
let client = HyperWebPushClient::new();
|
||||||
|
let message = builder
|
||||||
|
.build()
|
||||||
|
.map_err(|error| Error::BadRequest(format!("web push message build failed: {error}")))?;
|
||||||
|
|
||||||
|
client
|
||||||
|
.send(message)
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::BadRequest(format!("web push send failed: {error}")))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"rustc_fingerprint":10734737548331824535,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.92.0 (ded5c06cf 2025-12-08)\nbinary: rustc\ncommit-hash: ded5c06cf21d2b93bffd5d884aa6e96934ee4234\ncommit-date: 2025-12-08\nhost: x86_64-pc-windows-msvc\nrelease: 1.92.0\nLLVM version: 21.1.3\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\Andorid\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""}},"successes":{}}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
Signature: 8a477f597d28d172789f06886806bc55
|
|
||||||
# This file is a cache directory tag created by cargo.
|
|
||||||
# For information about cache directory tags see https://bford.info/cachedir/
|
|
||||||
@@ -45,12 +45,17 @@ python deploy/scripts/render_compose_env.py \
|
|||||||
|
|
||||||
- `INTERNAL_API_BASE_URL`:frontend SSR 容器访问 backend 用,compose 默认推荐 `http://backend:5150/api`
|
- `INTERNAL_API_BASE_URL`:frontend SSR 容器访问 backend 用,compose 默认推荐 `http://backend:5150/api`
|
||||||
- `PUBLIC_API_BASE_URL`:浏览器访问 backend API 用;留空时前台会回退到“当前主机 + `:5150/api`”
|
- `PUBLIC_API_BASE_URL`:浏览器访问 backend API 用;留空时前台会回退到“当前主机 + `:5150/api`”
|
||||||
|
- `PUBLIC_COMMENT_TURNSTILE_SITE_KEY`:前台评论 / 订阅表单使用的 Cloudflare Turnstile site key
|
||||||
|
- `PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY`:前台浏览器推送订阅使用的 VAPID public key
|
||||||
- `PUBLIC_IMAGE_ALLOWED_HOSTS`:前台 `/_img` 图片优化端点允许的额外图片 host(逗号分隔)
|
- `PUBLIC_IMAGE_ALLOWED_HOSTS`:前台 `/_img` 图片优化端点允许的额外图片 host(逗号分隔)
|
||||||
- `ADMIN_API_BASE_URL`:admin 浏览器访问 backend API 用;留空时后台会回退到“当前主机 + `:5150`”
|
- `ADMIN_API_BASE_URL`:admin 浏览器访问 backend API 用;留空时后台会回退到“当前主机 + `:5150`”
|
||||||
- `ADMIN_FRONTEND_BASE_URL`:admin 里“打开前台 / 问答页 / 文章页预览”跳转用
|
- `ADMIN_FRONTEND_BASE_URL`:admin 里“打开前台 / 问答页 / 文章页预览”跳转用
|
||||||
- `TERMI_ADMIN_TRUST_PROXY_AUTH`:是否信任前置代理(如 Caddy + TinyAuth)注入的后台认证头
|
- `TERMI_ADMIN_TRUST_PROXY_AUTH`:是否信任前置代理(如 Caddy + TinyAuth)注入的后台认证头
|
||||||
- `TERMI_ADMIN_LOCAL_LOGIN_ENABLED`:是否保留本地账号密码登录兜底
|
- `TERMI_ADMIN_LOCAL_LOGIN_ENABLED`:是否保留本地账号密码登录兜底
|
||||||
- `TERMI_ADMIN_PROXY_SHARED_SECRET`:代理 SSO 共享密钥;建议和 Caddy 的 `X-Termi-Proxy-Secret` 配套使用
|
- `TERMI_ADMIN_PROXY_SHARED_SECRET`:代理 SSO 共享密钥;建议和 Caddy 的 `X-Termi-Proxy-Secret` 配套使用
|
||||||
|
- `TERMI_TURNSTILE_SECRET_KEY`:backend 评论 / 订阅接口使用的 Cloudflare Turnstile secret key(兼容旧的 `TERMI_COMMENT_TURNSTILE_SECRET_KEY`)
|
||||||
|
- `TERMI_WEB_PUSH_VAPID_PRIVATE_KEY`:backend / worker 发送浏览器推送时使用的 VAPID private key
|
||||||
|
- `TERMI_WEB_PUSH_VAPID_SUBJECT`:浏览器推送 VAPID subject,推荐 `mailto:xxx@example.com`
|
||||||
- `SMTP_ENABLE / SMTP_HOST / SMTP_PORT / SMTP_SECURE / SMTP_USER / SMTP_PASSWORD / SMTP_HELLO_NAME`:订阅确认和邮件通知需要
|
- `SMTP_ENABLE / SMTP_HOST / SMTP_PORT / SMTP_SECURE / SMTP_USER / SMTP_PASSWORD / SMTP_HELLO_NAME`:订阅确认和邮件通知需要
|
||||||
|
|
||||||
例如:
|
例如:
|
||||||
@@ -58,11 +63,16 @@ python deploy/scripts/render_compose_env.py \
|
|||||||
```yaml
|
```yaml
|
||||||
compose_env:
|
compose_env:
|
||||||
PUBLIC_API_BASE_URL: https://api.blog.init.cool
|
PUBLIC_API_BASE_URL: https://api.blog.init.cool
|
||||||
|
PUBLIC_COMMENT_TURNSTILE_SITE_KEY: 1x00000000000000000000AA
|
||||||
|
PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY: replace-with-web-push-vapid-public-key
|
||||||
ADMIN_API_BASE_URL: https://admin.blog.init.cool
|
ADMIN_API_BASE_URL: https://admin.blog.init.cool
|
||||||
ADMIN_FRONTEND_BASE_URL: https://blog.init.cool
|
ADMIN_FRONTEND_BASE_URL: https://blog.init.cool
|
||||||
TERMI_ADMIN_TRUST_PROXY_AUTH: true
|
TERMI_ADMIN_TRUST_PROXY_AUTH: true
|
||||||
TERMI_ADMIN_LOCAL_LOGIN_ENABLED: false
|
TERMI_ADMIN_LOCAL_LOGIN_ENABLED: false
|
||||||
TERMI_ADMIN_PROXY_SHARED_SECRET: replace-with-a-long-random-secret
|
TERMI_ADMIN_PROXY_SHARED_SECRET: replace-with-a-long-random-secret
|
||||||
|
TERMI_TURNSTILE_SECRET_KEY: replace-with-turnstile-secret-key
|
||||||
|
TERMI_WEB_PUSH_VAPID_PRIVATE_KEY: replace-with-web-push-vapid-private-key
|
||||||
|
TERMI_WEB_PUSH_VAPID_SUBJECT: mailto:noreply@blog.init.cool
|
||||||
```
|
```
|
||||||
|
|
||||||
> 这些值最终会被渲染成 `deploy/docker/.env`,再由 `compose.package.yml` 读取。
|
> 这些值最终会被渲染成 `deploy/docker/.env`,再由 `compose.package.yml` 读取。
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ services:
|
|||||||
TERMI_ADMIN_TRUST_PROXY_AUTH: ${TERMI_ADMIN_TRUST_PROXY_AUTH:-false}
|
TERMI_ADMIN_TRUST_PROXY_AUTH: ${TERMI_ADMIN_TRUST_PROXY_AUTH:-false}
|
||||||
TERMI_ADMIN_LOCAL_LOGIN_ENABLED: ${TERMI_ADMIN_LOCAL_LOGIN_ENABLED:-true}
|
TERMI_ADMIN_LOCAL_LOGIN_ENABLED: ${TERMI_ADMIN_LOCAL_LOGIN_ENABLED:-true}
|
||||||
TERMI_ADMIN_PROXY_SHARED_SECRET: ${TERMI_ADMIN_PROXY_SHARED_SECRET:-}
|
TERMI_ADMIN_PROXY_SHARED_SECRET: ${TERMI_ADMIN_PROXY_SHARED_SECRET:-}
|
||||||
|
TERMI_TURNSTILE_SECRET_KEY: ${TERMI_TURNSTILE_SECRET_KEY:-}
|
||||||
|
PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY: ${PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY:-}
|
||||||
|
TERMI_WEB_PUSH_VAPID_PRIVATE_KEY: ${TERMI_WEB_PUSH_VAPID_PRIVATE_KEY:-}
|
||||||
|
TERMI_WEB_PUSH_VAPID_SUBJECT: ${TERMI_WEB_PUSH_VAPID_SUBJECT:-}
|
||||||
RUST_LOG: ${RUST_LOG:-info}
|
RUST_LOG: ${RUST_LOG:-info}
|
||||||
ports:
|
ports:
|
||||||
# 这是“直连端口”示例;如果前面接 tohka 宿主机 Caddy,
|
# 这是“直连端口”示例;如果前面接 tohka 宿主机 Caddy,
|
||||||
@@ -39,6 +43,9 @@ services:
|
|||||||
TERMI_ADMIN_TRUST_PROXY_AUTH: ${TERMI_ADMIN_TRUST_PROXY_AUTH:-false}
|
TERMI_ADMIN_TRUST_PROXY_AUTH: ${TERMI_ADMIN_TRUST_PROXY_AUTH:-false}
|
||||||
TERMI_ADMIN_LOCAL_LOGIN_ENABLED: ${TERMI_ADMIN_LOCAL_LOGIN_ENABLED:-true}
|
TERMI_ADMIN_LOCAL_LOGIN_ENABLED: ${TERMI_ADMIN_LOCAL_LOGIN_ENABLED:-true}
|
||||||
TERMI_ADMIN_PROXY_SHARED_SECRET: ${TERMI_ADMIN_PROXY_SHARED_SECRET:-}
|
TERMI_ADMIN_PROXY_SHARED_SECRET: ${TERMI_ADMIN_PROXY_SHARED_SECRET:-}
|
||||||
|
PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY: ${PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY:-}
|
||||||
|
TERMI_WEB_PUSH_VAPID_PRIVATE_KEY: ${TERMI_WEB_PUSH_VAPID_PRIVATE_KEY:-}
|
||||||
|
TERMI_WEB_PUSH_VAPID_SUBJECT: ${TERMI_WEB_PUSH_VAPID_SUBJECT:-}
|
||||||
RUST_LOG: ${RUST_LOG:-info}
|
RUST_LOG: ${RUST_LOG:-info}
|
||||||
TERMI_SKIP_MIGRATIONS: 'true'
|
TERMI_SKIP_MIGRATIONS: 'true'
|
||||||
|
|
||||||
@@ -53,9 +60,13 @@ services:
|
|||||||
# frontend 是 Astro SSR(Node):
|
# frontend 是 Astro SSR(Node):
|
||||||
# - INTERNAL_API_BASE_URL 给服务端渲染访问 backend 用
|
# - INTERNAL_API_BASE_URL 给服务端渲染访问 backend 用
|
||||||
# - PUBLIC_API_BASE_URL 给浏览器里的评论 / AI 问答等请求用
|
# - PUBLIC_API_BASE_URL 给浏览器里的评论 / AI 问答等请求用
|
||||||
|
# - PUBLIC_COMMENT_TURNSTILE_SITE_KEY 给评论 / 订阅表单的人机验证组件用
|
||||||
|
# - PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY 给浏览器推送订阅用
|
||||||
# - PUBLIC_IMAGE_ALLOWED_HOSTS 给前台图片优化端点 /_img 放行额外图片域名
|
# - PUBLIC_IMAGE_ALLOWED_HOSTS 给前台图片优化端点 /_img 放行额外图片域名
|
||||||
INTERNAL_API_BASE_URL: ${INTERNAL_API_BASE_URL:-http://backend:5150/api}
|
INTERNAL_API_BASE_URL: ${INTERNAL_API_BASE_URL:-http://backend:5150/api}
|
||||||
PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL:-}
|
PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL:-}
|
||||||
|
PUBLIC_COMMENT_TURNSTILE_SITE_KEY: ${PUBLIC_COMMENT_TURNSTILE_SITE_KEY:-}
|
||||||
|
PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY: ${PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY:-}
|
||||||
PUBLIC_IMAGE_ALLOWED_HOSTS: ${PUBLIC_IMAGE_ALLOWED_HOSTS:-}
|
PUBLIC_IMAGE_ALLOWED_HOSTS: ${PUBLIC_IMAGE_ALLOWED_HOSTS:-}
|
||||||
# frontend 是 Astro SSR(Node) 服务,容器内部监听 4321
|
# frontend 是 Astro SSR(Node) 服务,容器内部监听 4321
|
||||||
# 生产建议由网关统一反代,仅对外开放 80/443
|
# 生产建议由网关统一反代,仅对外开放 80/443
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ compose_env:
|
|||||||
APP_BASE_URL: https://admin.blog.init.cool
|
APP_BASE_URL: https://admin.blog.init.cool
|
||||||
INTERNAL_API_BASE_URL: http://backend:5150/api
|
INTERNAL_API_BASE_URL: http://backend:5150/api
|
||||||
PUBLIC_API_BASE_URL: https://api.blog.init.cool
|
PUBLIC_API_BASE_URL: https://api.blog.init.cool
|
||||||
|
PUBLIC_COMMENT_TURNSTILE_SITE_KEY: 1x00000000000000000000AA
|
||||||
|
PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY: replace-with-web-push-vapid-public-key
|
||||||
ADMIN_API_BASE_URL: https://admin.blog.init.cool
|
ADMIN_API_BASE_URL: https://admin.blog.init.cool
|
||||||
ADMIN_FRONTEND_BASE_URL: https://blog.init.cool
|
ADMIN_FRONTEND_BASE_URL: https://blog.init.cool
|
||||||
PUBLIC_IMAGE_ALLOWED_HOSTS: cdn.example.com,pub-xxxx.r2.dev
|
PUBLIC_IMAGE_ALLOWED_HOSTS: cdn.example.com,pub-xxxx.r2.dev
|
||||||
@@ -50,6 +52,9 @@ compose_env:
|
|||||||
TERMI_ADMIN_TRUST_PROXY_AUTH: true
|
TERMI_ADMIN_TRUST_PROXY_AUTH: true
|
||||||
TERMI_ADMIN_LOCAL_LOGIN_ENABLED: false
|
TERMI_ADMIN_LOCAL_LOGIN_ENABLED: false
|
||||||
TERMI_ADMIN_PROXY_SHARED_SECRET: replace-with-another-long-random-secret
|
TERMI_ADMIN_PROXY_SHARED_SECRET: replace-with-another-long-random-secret
|
||||||
|
TERMI_TURNSTILE_SECRET_KEY: replace-with-turnstile-secret-key
|
||||||
|
TERMI_WEB_PUSH_VAPID_PRIVATE_KEY: replace-with-web-push-vapid-private-key
|
||||||
|
TERMI_WEB_PUSH_VAPID_SUBJECT: mailto:noreply@blog.init.cool
|
||||||
|
|
||||||
BACKEND_IMAGE: git.init.cool/cool/termi-astro-backend:latest
|
BACKEND_IMAGE: git.init.cool/cool/termi-astro-backend:latest
|
||||||
FRONTEND_IMAGE: git.init.cool/cool/termi-astro-frontend:latest
|
FRONTEND_IMAGE: git.init.cool/cool/termi-astro-frontend:latest
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/check": "^0.9.8",
|
"@astrojs/check": "^0.9.8",
|
||||||
|
"@types/node": "^25.5.0",
|
||||||
"typescript": "^6.0.2"
|
"typescript": "^6.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
frontend/pnpm-lock.yaml
generated
72
frontend/pnpm-lock.yaml
generated
@@ -13,25 +13,25 @@ importers:
|
|||||||
version: 7.0.1
|
version: 7.0.1
|
||||||
'@astrojs/node':
|
'@astrojs/node':
|
||||||
specifier: ^10.0.4
|
specifier: ^10.0.4
|
||||||
version: 10.0.4(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))
|
version: 10.0.4(astro@6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))
|
||||||
'@astrojs/svelte':
|
'@astrojs/svelte':
|
||||||
specifier: ^8.0.3
|
specifier: ^8.0.3
|
||||||
version: 8.0.3(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))(jiti@1.21.7)(svelte@5.55.0)(typescript@6.0.2)(yaml@2.8.3)
|
version: 8.0.3(@types/node@25.5.0)(astro@6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))(jiti@1.21.7)(svelte@5.55.0)(typescript@6.0.2)(yaml@2.8.3)
|
||||||
'@astrojs/tailwind':
|
'@astrojs/tailwind':
|
||||||
specifier: ^6.0.2
|
specifier: ^6.0.2
|
||||||
version: 6.0.2(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))(tailwindcss@3.4.19)
|
version: 6.0.2(astro@6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))(tailwindcss@3.4.19)
|
||||||
'@tailwindcss/typography':
|
'@tailwindcss/typography':
|
||||||
specifier: ^0.5.19
|
specifier: ^0.5.19
|
||||||
version: 0.5.19(tailwindcss@3.4.19)
|
version: 0.5.19(tailwindcss@3.4.19)
|
||||||
astro:
|
astro:
|
||||||
specifier: ^6.0.8
|
specifier: ^6.0.8
|
||||||
version: 6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)
|
version: 6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)
|
||||||
autoprefixer:
|
autoprefixer:
|
||||||
specifier: ^10.4.27
|
specifier: ^10.4.27
|
||||||
version: 10.4.27(postcss@8.5.8)
|
version: 10.4.27(postcss@8.5.8)
|
||||||
lucide-astro:
|
lucide-astro:
|
||||||
specifier: ^0.556.0
|
specifier: ^0.556.0
|
||||||
version: 0.556.0(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))
|
version: 0.556.0(astro@6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))
|
||||||
postcss:
|
postcss:
|
||||||
specifier: ^8.5.8
|
specifier: ^8.5.8
|
||||||
version: 8.5.8
|
version: 8.5.8
|
||||||
@@ -48,6 +48,9 @@ importers:
|
|||||||
'@astrojs/check':
|
'@astrojs/check':
|
||||||
specifier: ^0.9.8
|
specifier: ^0.9.8
|
||||||
version: 0.9.8(prettier@3.8.1)(typescript@6.0.2)
|
version: 0.9.8(prettier@3.8.1)(typescript@6.0.2)
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^25.5.0
|
||||||
|
version: 25.5.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^6.0.2
|
specifier: ^6.0.2
|
||||||
version: 6.0.2
|
version: 6.0.2
|
||||||
@@ -701,6 +704,9 @@ packages:
|
|||||||
'@types/nlcst@2.0.3':
|
'@types/nlcst@2.0.3':
|
||||||
resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==}
|
resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==}
|
||||||
|
|
||||||
|
'@types/node@25.5.0':
|
||||||
|
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
|
||||||
|
|
||||||
'@types/trusted-types@2.0.7':
|
'@types/trusted-types@2.0.7':
|
||||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||||
|
|
||||||
@@ -1864,6 +1870,9 @@ packages:
|
|||||||
uncrypto@0.1.3:
|
uncrypto@0.1.3:
|
||||||
resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
|
resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
|
||||||
|
|
||||||
|
undici-types@7.18.2:
|
||||||
|
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||||
|
|
||||||
unified@11.0.5:
|
unified@11.0.5:
|
||||||
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
||||||
|
|
||||||
@@ -2247,10 +2256,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@astrojs/node@10.0.4(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))':
|
'@astrojs/node@10.0.4(astro@6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@astrojs/internal-helpers': 0.8.0
|
'@astrojs/internal-helpers': 0.8.0
|
||||||
astro: 6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)
|
astro: 6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)
|
||||||
send: 1.2.1
|
send: 1.2.1
|
||||||
server-destroy: 1.0.1
|
server-destroy: 1.0.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -2260,14 +2269,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
prismjs: 1.30.0
|
prismjs: 1.30.0
|
||||||
|
|
||||||
'@astrojs/svelte@8.0.3(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))(jiti@1.21.7)(svelte@5.55.0)(typescript@6.0.2)(yaml@2.8.3)':
|
'@astrojs/svelte@8.0.3(@types/node@25.5.0)(astro@6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))(jiti@1.21.7)(svelte@5.55.0)(typescript@6.0.2)(yaml@2.8.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.0)(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3))
|
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.0)(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3))
|
||||||
astro: 6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)
|
astro: 6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)
|
||||||
svelte: 5.55.0
|
svelte: 5.55.0
|
||||||
svelte2tsx: 0.7.52(svelte@5.55.0)(typescript@6.0.2)
|
svelte2tsx: 0.7.52(svelte@5.55.0)(typescript@6.0.2)
|
||||||
typescript: 6.0.2
|
typescript: 6.0.2
|
||||||
vite: 7.3.1(jiti@1.21.7)(yaml@2.8.3)
|
vite: 7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/node'
|
- '@types/node'
|
||||||
- jiti
|
- jiti
|
||||||
@@ -2281,9 +2290,9 @@ snapshots:
|
|||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- yaml
|
||||||
|
|
||||||
'@astrojs/tailwind@6.0.2(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))(tailwindcss@3.4.19)':
|
'@astrojs/tailwind@6.0.2(astro@6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3))(tailwindcss@3.4.19)':
|
||||||
dependencies:
|
dependencies:
|
||||||
astro: 6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)
|
astro: 6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)
|
||||||
autoprefixer: 10.4.27(postcss@8.5.8)
|
autoprefixer: 10.4.27(postcss@8.5.8)
|
||||||
postcss: 8.5.8
|
postcss: 8.5.8
|
||||||
postcss-load-config: 4.0.2(postcss@8.5.8)
|
postcss-load-config: 4.0.2(postcss@8.5.8)
|
||||||
@@ -2695,22 +2704,22 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.16.0
|
acorn: 8.16.0
|
||||||
|
|
||||||
'@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.0)(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3)))(svelte@5.55.0)(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3))':
|
'@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.0)(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3)))(svelte@5.55.0)(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.0)(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3))
|
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.0)(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3))
|
||||||
obug: 2.1.1
|
obug: 2.1.1
|
||||||
svelte: 5.55.0
|
svelte: 5.55.0
|
||||||
vite: 7.3.1(jiti@1.21.7)(yaml@2.8.3)
|
vite: 7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3)
|
||||||
|
|
||||||
'@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.0)(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3))':
|
'@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.0)(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.0)(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3)))(svelte@5.55.0)(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3))
|
'@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.0)(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3)))(svelte@5.55.0)(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3))
|
||||||
deepmerge: 4.3.1
|
deepmerge: 4.3.1
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
obug: 2.1.1
|
obug: 2.1.1
|
||||||
svelte: 5.55.0
|
svelte: 5.55.0
|
||||||
vite: 7.3.1(jiti@1.21.7)(yaml@2.8.3)
|
vite: 7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3)
|
||||||
vitefu: 1.1.2(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3))
|
vitefu: 1.1.2(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3))
|
||||||
|
|
||||||
'@tailwindcss/typography@0.5.19(tailwindcss@3.4.19)':
|
'@tailwindcss/typography@0.5.19(tailwindcss@3.4.19)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2737,6 +2746,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
|
|
||||||
|
'@types/node@25.5.0':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 7.18.2
|
||||||
|
|
||||||
'@types/trusted-types@2.0.7': {}
|
'@types/trusted-types@2.0.7': {}
|
||||||
|
|
||||||
'@types/unist@3.0.3': {}
|
'@types/unist@3.0.3': {}
|
||||||
@@ -2831,7 +2844,7 @@ snapshots:
|
|||||||
|
|
||||||
array-iterate@2.0.1: {}
|
array-iterate@2.0.1: {}
|
||||||
|
|
||||||
astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3):
|
astro@6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@astrojs/compiler': 3.0.1
|
'@astrojs/compiler': 3.0.1
|
||||||
'@astrojs/internal-helpers': 0.8.0
|
'@astrojs/internal-helpers': 0.8.0
|
||||||
@@ -2883,8 +2896,8 @@ snapshots:
|
|||||||
unist-util-visit: 5.1.0
|
unist-util-visit: 5.1.0
|
||||||
unstorage: 1.17.4
|
unstorage: 1.17.4
|
||||||
vfile: 6.0.3
|
vfile: 6.0.3
|
||||||
vite: 7.3.1(jiti@1.21.7)(yaml@2.8.3)
|
vite: 7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3)
|
||||||
vitefu: 1.1.2(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3))
|
vitefu: 1.1.2(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3))
|
||||||
xxhash-wasm: 1.1.0
|
xxhash-wasm: 1.1.0
|
||||||
yargs-parser: 22.0.0
|
yargs-parser: 22.0.0
|
||||||
zod: 4.3.6
|
zod: 4.3.6
|
||||||
@@ -3403,9 +3416,9 @@ snapshots:
|
|||||||
|
|
||||||
lru-cache@11.2.7: {}
|
lru-cache@11.2.7: {}
|
||||||
|
|
||||||
lucide-astro@0.556.0(astro@6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)):
|
lucide-astro@0.556.0(astro@6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
astro: 6.0.8(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)
|
astro: 6.0.8(@types/node@25.5.0)(jiti@1.21.7)(rollup@4.60.0)(typescript@6.0.2)(yaml@2.8.3)
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -4285,6 +4298,8 @@ snapshots:
|
|||||||
|
|
||||||
uncrypto@0.1.3: {}
|
uncrypto@0.1.3: {}
|
||||||
|
|
||||||
|
undici-types@7.18.2: {}
|
||||||
|
|
||||||
unified@11.0.5:
|
unified@11.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
@@ -4377,7 +4392,7 @@ snapshots:
|
|||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
vfile-message: 4.0.3
|
vfile-message: 4.0.3
|
||||||
|
|
||||||
vite@7.3.1(jiti@1.21.7)(yaml@2.8.3):
|
vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.27.4
|
esbuild: 0.27.4
|
||||||
fdir: 6.5.0(picomatch@4.0.4)
|
fdir: 6.5.0(picomatch@4.0.4)
|
||||||
@@ -4386,13 +4401,14 @@ snapshots:
|
|||||||
rollup: 4.60.0
|
rollup: 4.60.0
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@types/node': 25.5.0
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 1.21.7
|
jiti: 1.21.7
|
||||||
yaml: 2.8.3
|
yaml: 2.8.3
|
||||||
|
|
||||||
vitefu@1.1.2(vite@7.3.1(jiti@1.21.7)(yaml@2.8.3)):
|
vitefu@1.1.2(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3)):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 7.3.1(jiti@1.21.7)(yaml@2.8.3)
|
vite: 7.3.1(@types/node@25.5.0)(jiti@1.21.7)(yaml@2.8.3)
|
||||||
|
|
||||||
volar-service-css@0.0.70(@volar/language-service@2.4.28):
|
volar-service-css@0.0.70(@volar/language-service@2.4.28):
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
51
frontend/public/termi-web-push-sw.js
Normal file
51
frontend/public/termi-web-push-sw.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
const payload = (() => {
|
||||||
|
if (!event.data) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return event.data.json();
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
body: event.data.text(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const title = payload.title || '订阅更新';
|
||||||
|
const url = payload.url || '/';
|
||||||
|
const options = {
|
||||||
|
body: payload.body || '',
|
||||||
|
icon: payload.icon || '/favicon.svg',
|
||||||
|
badge: payload.badge || '/favicon.ico',
|
||||||
|
tag: payload.tag || 'termi-subscription',
|
||||||
|
data: {
|
||||||
|
url,
|
||||||
|
...(payload.data || {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(self.registration.showNotification(title, options));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const targetUrl = event.notification?.data?.url || '/';
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
|
||||||
|
for (const client of clients) {
|
||||||
|
if ('focus' in client && client.url === targetUrl) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.clients.openWindow) {
|
||||||
|
return self.clients.openWindow(targetUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,16 +1,25 @@
|
|||||||
---
|
---
|
||||||
import { apiClient, resolvePublicApiBaseUrl } from '../lib/api/client';
|
import {
|
||||||
|
apiClient,
|
||||||
|
resolvePublicApiBaseUrl,
|
||||||
|
resolvePublicCommentTurnstileSiteKey,
|
||||||
|
} from '../lib/api/client';
|
||||||
import { getI18n } from '../lib/i18n';
|
import { getI18n } from '../lib/i18n';
|
||||||
import type { Comment } from '../lib/api/client';
|
import type { Comment } from '../lib/api/client';
|
||||||
|
import type { SiteSettings } from '../lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
postSlug: string;
|
postSlug: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
|
siteSettings: SiteSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { postSlug, class: className = '' } = Astro.props;
|
const { postSlug, class: className = '', siteSettings } = Astro.props as Props;
|
||||||
const { locale, t } = getI18n(Astro);
|
const { locale, t } = getI18n(Astro);
|
||||||
const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||||
|
const turnstileSiteKey = siteSettings.comments.turnstileEnabled
|
||||||
|
? siteSettings.comments.turnstileSiteKey || resolvePublicCommentTurnstileSiteKey()
|
||||||
|
: '';
|
||||||
|
|
||||||
let comments: Comment[] = [];
|
let comments: Comment[] = [];
|
||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
@@ -36,7 +45,12 @@ function formatCommentDate(dateStr: string): string {
|
|||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class={`terminal-comments ${className}`} data-post-slug={postSlug} data-api-base={publicApiBaseUrl}>
|
<div
|
||||||
|
class={`terminal-comments ${className}`}
|
||||||
|
data-post-slug={postSlug}
|
||||||
|
data-api-base={publicApiBaseUrl}
|
||||||
|
data-turnstile-site-key={turnstileSiteKey || undefined}
|
||||||
|
>
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<span class="terminal-kicker">
|
<span class="terminal-kicker">
|
||||||
@@ -115,23 +129,38 @@ function formatCommentDate(dateStr: string): string {
|
|||||||
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--header-bg)]/60 px-4 py-3">
|
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--header-bg)]/60 px-4 py-3">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
|
||||||
验证码
|
{t('common.humanVerification')}
|
||||||
</p>
|
</p>
|
||||||
<button type="button" id="refresh-captcha" class="terminal-action-button px-3 py-2 text-xs">
|
{turnstileSiteKey ? (
|
||||||
<i class="fas fa-rotate-right"></i>
|
<span class="text-xs text-[var(--text-tertiary)]">Cloudflare Turnstile</span>
|
||||||
<span>刷新</span>
|
) : (
|
||||||
</button>
|
<button type="button" id="refresh-captcha" class="terminal-action-button px-3 py-2 text-xs">
|
||||||
|
<i class="fas fa-rotate-right"></i>
|
||||||
|
<span>{t('common.refresh')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p id="captcha-question" class="mt-2 text-sm text-[var(--text-secondary)]">加载中...</p>
|
|
||||||
<input type="hidden" name="captchaToken" />
|
{turnstileSiteKey ? (
|
||||||
<input
|
<>
|
||||||
type="text"
|
<div class="mt-3" data-turnstile-container></div>
|
||||||
name="captchaAnswer"
|
<input type="hidden" name="turnstileToken" />
|
||||||
required
|
<p class="mt-3 text-sm text-[var(--text-secondary)]">{t('common.turnstileHint')}</p>
|
||||||
inputmode="numeric"
|
</>
|
||||||
placeholder="请输入上方答案"
|
) : (
|
||||||
class="mt-3 terminal-form-input"
|
<>
|
||||||
/>
|
<p id="captcha-question" class="mt-2 text-sm text-[var(--text-secondary)]">加载中...</p>
|
||||||
|
<input type="hidden" name="captchaToken" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="captchaAnswer"
|
||||||
|
required
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="请输入上方答案"
|
||||||
|
class="mt-3 terminal-form-input"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="replying-to" class="terminal-panel-muted hidden items-center justify-between gap-3 py-3">
|
<div id="replying-to" class="terminal-panel-muted hidden items-center justify-between gap-3 py-3">
|
||||||
@@ -228,6 +257,8 @@ function formatCommentDate(dateStr: string): string {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { mountTurnstile, type MountedTurnstile } from '../lib/utils/turnstile';
|
||||||
|
|
||||||
const t = window.__termiTranslate;
|
const t = window.__termiTranslate;
|
||||||
const wrapper = document.querySelector('.terminal-comments');
|
const wrapper = document.querySelector('.terminal-comments');
|
||||||
const toggleBtn = document.getElementById('toggle-comment-form');
|
const toggleBtn = document.getElementById('toggle-comment-form');
|
||||||
@@ -243,8 +274,12 @@ function formatCommentDate(dateStr: string): string {
|
|||||||
const refreshCaptchaBtn = document.getElementById('refresh-captcha');
|
const refreshCaptchaBtn = document.getElementById('refresh-captcha');
|
||||||
const postSlug = wrapper?.getAttribute('data-post-slug') || '';
|
const postSlug = wrapper?.getAttribute('data-post-slug') || '';
|
||||||
const apiBase = wrapper?.getAttribute('data-api-base') || '/api';
|
const apiBase = wrapper?.getAttribute('data-api-base') || '/api';
|
||||||
const captchaTokenInput = form?.querySelector('input[name=\"captchaToken\"]') as HTMLInputElement | null;
|
const turnstileSiteKey = wrapper?.getAttribute('data-turnstile-site-key') || '';
|
||||||
const captchaAnswerInput = form?.querySelector('input[name=\"captchaAnswer\"]') as HTMLInputElement | null;
|
const turnstileContainer = form?.querySelector('[data-turnstile-container]') as HTMLElement | null;
|
||||||
|
const turnstileTokenInput = form?.querySelector('input[name="turnstileToken"]') as HTMLInputElement | null;
|
||||||
|
const captchaTokenInput = form?.querySelector('input[name="captchaToken"]') as HTMLInputElement | null;
|
||||||
|
const captchaAnswerInput = form?.querySelector('input[name="captchaAnswer"]') as HTMLInputElement | null;
|
||||||
|
let turnstileWidget: MountedTurnstile | null = null;
|
||||||
|
|
||||||
function showMessage(message: string, type: 'success' | 'error' | 'info') {
|
function showMessage(message: string, type: 'success' | 'error' | 'info') {
|
||||||
if (!messageBox) return;
|
if (!messageBox) return;
|
||||||
@@ -316,6 +351,54 @@ function formatCommentDate(dateStr: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureTurnstile(showError = true) {
|
||||||
|
if (!turnstileSiteKey || !turnstileContainer || !turnstileTokenInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
turnstileTokenInput.value = '';
|
||||||
|
|
||||||
|
if (turnstileWidget) {
|
||||||
|
turnstileWidget.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
turnstileWidget = await mountTurnstile(turnstileContainer, {
|
||||||
|
siteKey: turnstileSiteKey,
|
||||||
|
onToken(token) {
|
||||||
|
turnstileTokenInput.value = token;
|
||||||
|
},
|
||||||
|
onExpire() {
|
||||||
|
turnstileTokenInput.value = '';
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
turnstileTokenInput.value = '';
|
||||||
|
if (showError) {
|
||||||
|
showMessage(t('common.turnstileLoadFailed'), 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (showError) {
|
||||||
|
showMessage(
|
||||||
|
error instanceof Error ? error.message : t('common.turnstileLoadFailed'),
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetHumanCheck() {
|
||||||
|
if (turnstileSiteKey) {
|
||||||
|
turnstileTokenInput && (turnstileTokenInput.value = '');
|
||||||
|
turnstileWidget?.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadCaptcha(false);
|
||||||
|
}
|
||||||
|
|
||||||
toggleBtn?.addEventListener('click', () => {
|
toggleBtn?.addEventListener('click', () => {
|
||||||
formContainer?.classList.toggle('hidden');
|
formContainer?.classList.toggle('hidden');
|
||||||
if (!formContainer?.classList.contains('hidden')) {
|
if (!formContainer?.classList.contains('hidden')) {
|
||||||
@@ -360,6 +443,14 @@ function formatCommentDate(dateStr: string): string {
|
|||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
const replyToId = replyingTo?.getAttribute('data-reply-to');
|
const replyToId = replyingTo?.getAttribute('data-reply-to');
|
||||||
|
|
||||||
|
if (turnstileSiteKey) {
|
||||||
|
const token = String(formData.get('turnstileToken') || '').trim();
|
||||||
|
if (!token) {
|
||||||
|
showMessage(t('common.turnstileRequired'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
showMessage(t('comments.submitting'), 'info');
|
showMessage(t('comments.submitting'), 'info');
|
||||||
|
|
||||||
@@ -375,6 +466,7 @@ function formatCommentDate(dateStr: string): string {
|
|||||||
content: formData.get('content'),
|
content: formData.get('content'),
|
||||||
scope: 'article',
|
scope: 'article',
|
||||||
replyToCommentId: replyToId ? Number(replyToId) : null,
|
replyToCommentId: replyToId ? Number(replyToId) : null,
|
||||||
|
turnstileToken: formData.get('turnstileToken'),
|
||||||
captchaToken: formData.get('captchaToken'),
|
captchaToken: formData.get('captchaToken'),
|
||||||
captchaAnswer: formData.get('captchaAnswer'),
|
captchaAnswer: formData.get('captchaAnswer'),
|
||||||
website: formData.get('website'),
|
website: formData.get('website'),
|
||||||
@@ -390,10 +482,10 @@ function formatCommentDate(dateStr: string): string {
|
|||||||
resetReply();
|
resetReply();
|
||||||
formContainer?.classList.add('hidden');
|
formContainer?.classList.add('hidden');
|
||||||
showMessage(t('comments.submitSuccess'), 'success');
|
showMessage(t('comments.submitSuccess'), 'success');
|
||||||
void loadCaptcha(false);
|
resetHumanCheck();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showMessage(t('comments.submitFailed', { message: error instanceof Error ? error.message : t('common.unknownError') }), 'error');
|
showMessage(t('comments.submitFailed', { message: error instanceof Error ? error.message : t('common.unknownError') }), 'error');
|
||||||
void loadCaptcha(false);
|
resetHumanCheck();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -410,5 +502,9 @@ function formatCommentDate(dateStr: string): string {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
void loadCaptcha(false);
|
if (turnstileSiteKey) {
|
||||||
|
void ensureTurnstile(false);
|
||||||
|
} else {
|
||||||
|
void loadCaptcha(false);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
---
|
---
|
||||||
import { resolvePublicApiBaseUrl } from '../lib/api/client';
|
import {
|
||||||
|
resolvePublicApiBaseUrl,
|
||||||
|
resolvePublicCommentTurnstileSiteKey,
|
||||||
|
} from '../lib/api/client';
|
||||||
import { getI18n } from '../lib/i18n';
|
import { getI18n } from '../lib/i18n';
|
||||||
|
import type { SiteSettings } from '../lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
postSlug: string;
|
postSlug: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
|
siteSettings: SiteSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { postSlug, class: className = '' } = Astro.props;
|
const { postSlug, class: className = '', siteSettings } = Astro.props as Props;
|
||||||
const { t } = getI18n(Astro);
|
const { t } = getI18n(Astro);
|
||||||
const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
||||||
|
const turnstileSiteKey = siteSettings.comments.turnstileEnabled
|
||||||
|
? siteSettings.comments.turnstileSiteKey || resolvePublicCommentTurnstileSiteKey()
|
||||||
|
: '';
|
||||||
---
|
---
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -17,6 +25,7 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
|||||||
data-post-slug={postSlug}
|
data-post-slug={postSlug}
|
||||||
data-api-base={publicApiBaseUrl}
|
data-api-base={publicApiBaseUrl}
|
||||||
data-storage-key={`termi:paragraph-comments:${postSlug}`}
|
data-storage-key={`termi:paragraph-comments:${postSlug}`}
|
||||||
|
data-turnstile-site-key={turnstileSiteKey || undefined}
|
||||||
>
|
>
|
||||||
<div class="paragraph-comments-toolbar terminal-panel-muted">
|
<div class="paragraph-comments-toolbar terminal-panel-muted">
|
||||||
<div class="paragraph-comments-toolbar-copy">
|
<div class="paragraph-comments-toolbar-copy">
|
||||||
@@ -45,6 +54,7 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
|||||||
const t = window.__termiTranslate;
|
const t = window.__termiTranslate;
|
||||||
const locale = document.documentElement.lang || 'zh-CN';
|
const locale = document.documentElement.lang || 'zh-CN';
|
||||||
import { buildParagraphDescriptors } from '../lib/utils/paragraph-comments';
|
import { buildParagraphDescriptors } from '../lib/utils/paragraph-comments';
|
||||||
|
import { mountTurnstile, type MountedTurnstile } from '../lib/utils/turnstile';
|
||||||
|
|
||||||
interface BrowserComment {
|
interface BrowserComment {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -73,6 +83,7 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
|||||||
const postSlug = wrapper?.dataset.postSlug || '';
|
const postSlug = wrapper?.dataset.postSlug || '';
|
||||||
const apiBase = wrapper?.dataset.apiBase || '/api';
|
const apiBase = wrapper?.dataset.apiBase || '/api';
|
||||||
const storageKey = wrapper?.dataset.storageKey || 'termi:paragraph-comments';
|
const storageKey = wrapper?.dataset.storageKey || 'termi:paragraph-comments';
|
||||||
|
const turnstileSiteKey = wrapper?.dataset.turnstileSiteKey || '';
|
||||||
const articleRoot = wrapper?.closest('[data-article-slug]') as HTMLElement | null;
|
const articleRoot = wrapper?.closest('[data-article-slug]') as HTMLElement | null;
|
||||||
const articleContent = articleRoot?.querySelector('.article-content') as HTMLElement | null;
|
const articleContent = articleRoot?.querySelector('.article-content') as HTMLElement | null;
|
||||||
const summaryText = wrapper?.querySelector('[data-summary-text]') as HTMLElement | null;
|
const summaryText = wrapper?.querySelector('[data-summary-text]') as HTMLElement | null;
|
||||||
@@ -93,6 +104,7 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
|||||||
let activeReplyToCommentId: number | null = null;
|
let activeReplyToCommentId: number | null = null;
|
||||||
let pendingCounter = 0;
|
let pendingCounter = 0;
|
||||||
let markersVisible = true;
|
let markersVisible = true;
|
||||||
|
let turnstileWidget: MountedTurnstile | null = null;
|
||||||
|
|
||||||
function escapeHtml(value: string): string {
|
function escapeHtml(value: string): string {
|
||||||
return value
|
return value
|
||||||
@@ -345,22 +357,32 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
|||||||
|
|
||||||
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--header-bg)]/60 px-4 py-3">
|
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--header-bg)]/60 px-4 py-3">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--text-tertiary)]">验证码</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--text-tertiary)]">${escapeHtml(t('common.humanVerification'))}</p>
|
||||||
<button type="button" class="terminal-action-button px-3 py-2 text-xs" data-refresh-captcha>
|
${
|
||||||
<i class="fas fa-rotate-right"></i>
|
turnstileSiteKey
|
||||||
<span>刷新</span>
|
? `<span class="text-xs text-[var(--text-tertiary)]">Cloudflare Turnstile</span>`
|
||||||
</button>
|
: `<button type="button" class="terminal-action-button px-3 py-2 text-xs" data-refresh-captcha>
|
||||||
|
<i class="fas fa-rotate-right"></i>
|
||||||
|
<span>${escapeHtml(t('common.refresh'))}</span>
|
||||||
|
</button>`
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-sm text-[var(--text-secondary)]" data-captcha-question>加载中...</p>
|
${
|
||||||
<input type="hidden" name="captchaToken" />
|
turnstileSiteKey
|
||||||
<input
|
? `<div class="mt-3" data-turnstile-container></div>
|
||||||
type="text"
|
<input type="hidden" name="turnstileToken" />
|
||||||
name="captchaAnswer"
|
<p class="mt-3 text-sm text-[var(--text-secondary)]">${escapeHtml(t('common.turnstileHint'))}</p>`
|
||||||
required
|
: `<p class="mt-2 text-sm text-[var(--text-secondary)]" data-captcha-question>加载中...</p>
|
||||||
inputmode="numeric"
|
<input type="hidden" name="captchaToken" />
|
||||||
placeholder="请输入上方答案"
|
<input
|
||||||
class="mt-3 terminal-form-input"
|
type="text"
|
||||||
/>
|
name="captchaAnswer"
|
||||||
|
required
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="请输入上方答案"
|
||||||
|
class="mt-3 terminal-form-input"
|
||||||
|
/>`
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
@@ -389,8 +411,10 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
|||||||
const focusButton = panel.querySelector('[data-focus-paragraph]') as HTMLButtonElement;
|
const focusButton = panel.querySelector('[data-focus-paragraph]') as HTMLButtonElement;
|
||||||
const captchaQuestion = panel.querySelector('[data-captcha-question]') as HTMLElement;
|
const captchaQuestion = panel.querySelector('[data-captcha-question]') as HTMLElement;
|
||||||
const refreshCaptchaButton = panel.querySelector('[data-refresh-captcha]') as HTMLButtonElement;
|
const refreshCaptchaButton = panel.querySelector('[data-refresh-captcha]') as HTMLButtonElement;
|
||||||
const captchaTokenInput = form.querySelector('input[name=\"captchaToken\"]') as HTMLInputElement;
|
const turnstileContainer = form.querySelector('[data-turnstile-container]') as HTMLElement | null;
|
||||||
const captchaAnswerInput = form.querySelector('input[name=\"captchaAnswer\"]') as HTMLInputElement;
|
const turnstileTokenInput = form.querySelector('input[name="turnstileToken"]') as HTMLInputElement | null;
|
||||||
|
const captchaTokenInput = form.querySelector('input[name="captchaToken"]') as HTMLInputElement | null;
|
||||||
|
const captchaAnswerInput = form.querySelector('input[name="captchaAnswer"]') as HTMLInputElement | null;
|
||||||
|
|
||||||
function clearStatus() {
|
function clearStatus() {
|
||||||
statusBox.className = 'paragraph-comment-status hidden';
|
statusBox.className = 'paragraph-comment-status hidden';
|
||||||
@@ -433,6 +457,54 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureTurnstile(showStatusOnError = true) {
|
||||||
|
if (!turnstileSiteKey || !turnstileContainer || !turnstileTokenInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
turnstileTokenInput.value = '';
|
||||||
|
|
||||||
|
if (turnstileWidget) {
|
||||||
|
turnstileWidget.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
turnstileWidget = await mountTurnstile(turnstileContainer, {
|
||||||
|
siteKey: turnstileSiteKey,
|
||||||
|
onToken(token) {
|
||||||
|
turnstileTokenInput.value = token;
|
||||||
|
},
|
||||||
|
onExpire() {
|
||||||
|
turnstileTokenInput.value = '';
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
turnstileTokenInput.value = '';
|
||||||
|
if (showStatusOnError) {
|
||||||
|
setStatus(t('common.turnstileLoadFailed'), 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (showStatusOnError) {
|
||||||
|
setStatus(
|
||||||
|
error instanceof Error ? error.message : t('common.turnstileLoadFailed'),
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetHumanCheck() {
|
||||||
|
if (turnstileSiteKey) {
|
||||||
|
turnstileTokenInput && (turnstileTokenInput.value = '');
|
||||||
|
turnstileWidget?.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadCaptcha(false);
|
||||||
|
}
|
||||||
|
|
||||||
function resetReplyState() {
|
function resetReplyState() {
|
||||||
activeReplyToCommentId = null;
|
activeReplyToCommentId = null;
|
||||||
replyBanner.classList.add('hidden');
|
replyBanner.classList.add('hidden');
|
||||||
@@ -642,7 +714,11 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
|||||||
descriptor.element.insertAdjacentElement('afterend', panel);
|
descriptor.element.insertAdjacentElement('afterend', panel);
|
||||||
panel.classList.remove('hidden');
|
panel.classList.remove('hidden');
|
||||||
panel.dataset.paragraphKey = paragraphKey;
|
panel.dataset.paragraphKey = paragraphKey;
|
||||||
if (!captchaTokenInput.value) {
|
if (turnstileSiteKey) {
|
||||||
|
if (!turnstileTokenInput?.value) {
|
||||||
|
await ensureTurnstile(false);
|
||||||
|
}
|
||||||
|
} else if (!captchaTokenInput?.value) {
|
||||||
await loadCaptcha(false);
|
await loadCaptcha(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,6 +849,14 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
|||||||
clearStatus();
|
clearStatus();
|
||||||
setStatus(t('paragraphComments.submitting'), 'info');
|
setStatus(t('paragraphComments.submitting'), 'info');
|
||||||
|
|
||||||
|
if (turnstileSiteKey) {
|
||||||
|
const token = String(formData.get('turnstileToken') || '').trim();
|
||||||
|
if (!token) {
|
||||||
|
setStatus(t('common.turnstileRequired'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${apiBase}/comments`, {
|
const response = await fetch(`${apiBase}/comments`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -788,6 +872,7 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
|||||||
paragraphKey: descriptor.key,
|
paragraphKey: descriptor.key,
|
||||||
paragraphExcerpt: descriptor.excerpt,
|
paragraphExcerpt: descriptor.excerpt,
|
||||||
replyToCommentId: activeReplyToCommentId,
|
replyToCommentId: activeReplyToCommentId,
|
||||||
|
turnstileToken: formData.get('turnstileToken'),
|
||||||
captchaToken: formData.get('captchaToken'),
|
captchaToken: formData.get('captchaToken'),
|
||||||
captchaAnswer: formData.get('captchaAnswer'),
|
captchaAnswer: formData.get('captchaAnswer'),
|
||||||
website: formData.get('website'),
|
website: formData.get('website'),
|
||||||
@@ -814,10 +899,10 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
|||||||
const approvedComments = await loadThread(descriptor.key, false);
|
const approvedComments = await loadThread(descriptor.key, false);
|
||||||
renderThread(descriptor.key, approvedComments);
|
renderThread(descriptor.key, approvedComments);
|
||||||
setStatus(t('paragraphComments.submitSuccess'), 'success');
|
setStatus(t('paragraphComments.submitSuccess'), 'success');
|
||||||
void loadCaptcha(false);
|
resetHumanCheck();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(t('paragraphComments.submitFailed', { message: error instanceof Error ? error.message : t('common.unknownError') }), 'error');
|
setStatus(t('paragraphComments.submitFailed', { message: error instanceof Error ? error.message : t('common.unknownError') }), 'error');
|
||||||
void loadCaptcha(false);
|
resetHumanCheck();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -873,7 +958,11 @@ const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
|
|||||||
|
|
||||||
updateMarkerState();
|
updateMarkerState();
|
||||||
applyMarkerVisibility(markersVisible, { persist: false });
|
applyMarkerVisibility(markersVisible, { persist: false });
|
||||||
await loadCaptcha(false);
|
if (turnstileSiteKey) {
|
||||||
|
await ensureTurnstile(false);
|
||||||
|
} else {
|
||||||
|
await loadCaptcha(false);
|
||||||
|
}
|
||||||
await openFromHash();
|
await openFromHash();
|
||||||
window.addEventListener('hashchange', () => {
|
window.addEventListener('hashchange', () => {
|
||||||
void openFromHash();
|
void openFromHash();
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
---
|
---
|
||||||
import { resolvePublicApiBaseUrl } from '../lib/api/client';
|
import {
|
||||||
|
resolvePublicApiBaseUrl,
|
||||||
|
resolvePublicCommentTurnstileSiteKey,
|
||||||
|
resolvePublicWebPushVapidPublicKey,
|
||||||
|
} from '../lib/api/client';
|
||||||
import type { SiteSettings } from '../lib/types';
|
import type { SiteSettings } from '../lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -9,7 +13,14 @@ interface Props {
|
|||||||
|
|
||||||
const { requestUrl, siteSettings } = Astro.props as Props;
|
const { requestUrl, siteSettings } = Astro.props as Props;
|
||||||
const subscribeApiUrl = `${resolvePublicApiBaseUrl(requestUrl)}/subscriptions`;
|
const subscribeApiUrl = `${resolvePublicApiBaseUrl(requestUrl)}/subscriptions`;
|
||||||
|
const browserPushApiUrl = `${resolvePublicApiBaseUrl(requestUrl)}/subscriptions/browser-push`;
|
||||||
const popupSettings = siteSettings.subscriptions;
|
const popupSettings = siteSettings.subscriptions;
|
||||||
|
const turnstileSiteKey = popupSettings.turnstileEnabled
|
||||||
|
? popupSettings.turnstileSiteKey || resolvePublicCommentTurnstileSiteKey()
|
||||||
|
: '';
|
||||||
|
const webPushPublicKey = popupSettings.webPushEnabled
|
||||||
|
? popupSettings.webPushVapidPublicKey || resolvePublicWebPushVapidPublicKey()
|
||||||
|
: '';
|
||||||
---
|
---
|
||||||
|
|
||||||
{popupSettings.popupEnabled && (
|
{popupSettings.popupEnabled && (
|
||||||
@@ -17,7 +28,10 @@ const popupSettings = siteSettings.subscriptions;
|
|||||||
class="subscription-popup-root"
|
class="subscription-popup-root"
|
||||||
data-subscription-popup-root
|
data-subscription-popup-root
|
||||||
data-api-url={subscribeApiUrl}
|
data-api-url={subscribeApiUrl}
|
||||||
|
data-browser-push-api-url={browserPushApiUrl}
|
||||||
data-delay-ms={String(Math.max(popupSettings.popupDelaySeconds, 3) * 1000)}
|
data-delay-ms={String(Math.max(popupSettings.popupDelaySeconds, 3) * 1000)}
|
||||||
|
data-turnstile-site-key={turnstileSiteKey || undefined}
|
||||||
|
data-web-push-public-key={webPushPublicKey || undefined}
|
||||||
hidden
|
hidden
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
@@ -51,6 +65,7 @@ const popupSettings = siteSettings.subscriptions;
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="subscription-popup-badges" aria-hidden="true">
|
<div class="subscription-popup-badges" aria-hidden="true">
|
||||||
|
{webPushPublicKey && <span class="subscription-popup-badge">浏览器提醒</span>}
|
||||||
<span class="subscription-popup-badge">新文章</span>
|
<span class="subscription-popup-badge">新文章</span>
|
||||||
<span class="subscription-popup-badge">汇总简报</span>
|
<span class="subscription-popup-badge">汇总简报</span>
|
||||||
<span class="subscription-popup-badge">低频提醒</span>
|
<span class="subscription-popup-badge">低频提醒</span>
|
||||||
@@ -58,6 +73,12 @@ const popupSettings = siteSettings.subscriptions;
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="subscription-popup-meta">
|
<div class="subscription-popup-meta">
|
||||||
|
{webPushPublicKey && (
|
||||||
|
<span class="terminal-stat-pill">
|
||||||
|
<i class="fas fa-bell text-[var(--primary)]"></i>
|
||||||
|
浏览器授权后生效
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span class="terminal-stat-pill">
|
<span class="terminal-stat-pill">
|
||||||
<i class="fas fa-newspaper text-[var(--primary)]"></i>
|
<i class="fas fa-newspaper text-[var(--primary)]"></i>
|
||||||
新文章 / 汇总简报
|
新文章 / 汇总简报
|
||||||
@@ -73,7 +94,37 @@ const popupSettings = siteSettings.subscriptions;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{webPushPublicKey && (
|
||||||
|
<div class="terminal-panel-muted flex flex-col gap-4 rounded-2xl px-4 py-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
|
||||||
|
浏览器推送
|
||||||
|
</p>
|
||||||
|
<p class="text-sm leading-6 text-[var(--text-secondary)]">
|
||||||
|
直接在浏览器收到新文章 / 汇总提醒,不用再等邮箱确认。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="terminal-action-button terminal-action-button-primary"
|
||||||
|
data-subscription-popup-browser-push
|
||||||
|
>
|
||||||
|
开启浏览器提醒
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form class="subscription-popup-form" data-subscription-popup-form>
|
<form class="subscription-popup-form" data-subscription-popup-form>
|
||||||
|
{webPushPublicKey && (
|
||||||
|
<div class="mb-4 flex items-center gap-3 text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
|
||||||
|
<span>或使用邮箱</span>
|
||||||
|
<span class="h-px flex-1 bg-[var(--border-color)]"></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<label class="subscription-popup-field">
|
<label class="subscription-popup-field">
|
||||||
<span class="subscription-popup-field-label">邮箱地址</span>
|
<span class="subscription-popup-field-label">邮箱地址</span>
|
||||||
<input
|
<input
|
||||||
@@ -86,6 +137,19 @@ const popupSettings = siteSettings.subscriptions;
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{turnstileSiteKey && (
|
||||||
|
<div class="mt-4 rounded-2xl border border-[var(--border-color)] bg-[var(--header-bg)]/60 px-4 py-3">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
|
||||||
|
人机验证
|
||||||
|
</p>
|
||||||
|
<span class="text-xs text-[var(--text-tertiary)]">Cloudflare Turnstile</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3" data-subscription-popup-turnstile></div>
|
||||||
|
<input type="hidden" name="turnstileToken" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div class="subscription-popup-actions">
|
<div class="subscription-popup-actions">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -112,6 +176,13 @@ const popupSettings = siteSettings.subscriptions;
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { mountTurnstile, type MountedTurnstile } from '../lib/utils/turnstile';
|
||||||
|
import {
|
||||||
|
ensureBrowserPushSubscription,
|
||||||
|
getBrowserPushSubscription,
|
||||||
|
supportsBrowserPush,
|
||||||
|
} from '../lib/utils/web-push';
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
const DISMISS_KEY = 'termi:subscription-popup:dismiss-until';
|
const DISMISS_KEY = 'termi:subscription-popup:dismiss-until';
|
||||||
const SUBSCRIBED_KEY = 'termi:subscription-popup:subscribed-at';
|
const SUBSCRIBED_KEY = 'termi:subscription-popup:subscribed-at';
|
||||||
@@ -127,6 +198,16 @@ const popupSettings = siteSettings.subscriptions;
|
|||||||
const emailInput = root.querySelector('[data-subscription-popup-email]');
|
const emailInput = root.querySelector('[data-subscription-popup-email]');
|
||||||
const dismissButton = root.querySelector('[data-subscription-popup-dismiss]');
|
const dismissButton = root.querySelector('[data-subscription-popup-dismiss]');
|
||||||
const apiUrl = root.getAttribute('data-api-url');
|
const apiUrl = root.getAttribute('data-api-url');
|
||||||
|
const browserPushApiUrl = root.getAttribute('data-browser-push-api-url');
|
||||||
|
const browserPushPublicKey = root.getAttribute('data-web-push-public-key') || '';
|
||||||
|
const browserPushButton = root.querySelector('[data-subscription-popup-browser-push]');
|
||||||
|
const turnstileSiteKey = root.getAttribute('data-turnstile-site-key') || '';
|
||||||
|
const turnstileContainer = root.querySelector(
|
||||||
|
'[data-subscription-popup-turnstile]',
|
||||||
|
) as HTMLElement | null;
|
||||||
|
const turnstileTokenInput = form?.querySelector(
|
||||||
|
'input[name="turnstileToken"]',
|
||||||
|
) as HTMLInputElement | null;
|
||||||
const pathname = window.location.pathname || '/';
|
const pathname = window.location.pathname || '/';
|
||||||
const delayMs = Math.max(3000, Number(root.getAttribute('data-delay-ms') || '18000'));
|
const delayMs = Math.max(3000, Number(root.getAttribute('data-delay-ms') || '18000'));
|
||||||
const defaultStatus = status instanceof HTMLElement ? status.textContent?.trim() || '' : '';
|
const defaultStatus = status instanceof HTMLElement ? status.textContent?.trim() || '' : '';
|
||||||
@@ -148,6 +229,7 @@ const popupSettings = siteSettings.subscriptions;
|
|||||||
let autoOpened = false;
|
let autoOpened = false;
|
||||||
let hideTimer = 0;
|
let hideTimer = 0;
|
||||||
let successTimer = 0;
|
let successTimer = 0;
|
||||||
|
let turnstileWidget: MountedTurnstile | null = null;
|
||||||
const header = document.querySelector('header');
|
const header = document.querySelector('header');
|
||||||
|
|
||||||
const shouldFocusEmail = () =>
|
const shouldFocusEmail = () =>
|
||||||
@@ -197,6 +279,30 @@ const popupSettings = siteSettings.subscriptions;
|
|||||||
status.textContent = defaultStatus;
|
status.textContent = defaultStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setPending = (message: string) => {
|
||||||
|
status.dataset.state = 'pending';
|
||||||
|
status.textContent = message;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setError = (message: string) => {
|
||||||
|
status.dataset.state = 'error';
|
||||||
|
status.textContent = message;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSuccess = (message: string) => {
|
||||||
|
status.dataset.state = 'success';
|
||||||
|
status.textContent = message;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateBrowserPushButtonLabel = (label: string, disabled = false) => {
|
||||||
|
if (!(browserPushButton instanceof HTMLButtonElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
browserPushButton.textContent = label;
|
||||||
|
browserPushButton.disabled = disabled;
|
||||||
|
};
|
||||||
|
|
||||||
const openPopup = ({ focusEmail = false } = {}) => {
|
const openPopup = ({ focusEmail = false } = {}) => {
|
||||||
if (opened || hasSubmitted()) {
|
if (opened || hasSubmitted()) {
|
||||||
return;
|
return;
|
||||||
@@ -216,6 +322,9 @@ const popupSettings = siteSettings.subscriptions;
|
|||||||
if (focusEmail && shouldFocusEmail()) {
|
if (focusEmail && shouldFocusEmail()) {
|
||||||
emailInput.focus({ preventScroll: true });
|
emailInput.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
|
if (turnstileSiteKey) {
|
||||||
|
void ensureTurnstile(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -264,7 +373,80 @@ const popupSettings = siteSettings.subscriptions;
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ensureTurnstile = async (showError = true) => {
|
||||||
|
if (!turnstileSiteKey || !turnstileContainer || !turnstileTokenInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
turnstileTokenInput.value = '';
|
||||||
|
|
||||||
|
if (turnstileWidget) {
|
||||||
|
turnstileWidget.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
turnstileWidget = await mountTurnstile(turnstileContainer, {
|
||||||
|
siteKey: turnstileSiteKey,
|
||||||
|
onToken(token) {
|
||||||
|
turnstileTokenInput.value = token;
|
||||||
|
},
|
||||||
|
onExpire() {
|
||||||
|
turnstileTokenInput.value = '';
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
turnstileTokenInput.value = '';
|
||||||
|
if (showError) {
|
||||||
|
setError('加载人机验证失败,请刷新页面后重试。');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (showError) {
|
||||||
|
setError(error instanceof Error ? error.message : '加载人机验证失败,请刷新页面后重试。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetHumanCheck = () => {
|
||||||
|
if (!turnstileSiteKey || !turnstileTokenInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
turnstileTokenInput.value = '';
|
||||||
|
turnstileWidget?.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncBrowserPushState = async () => {
|
||||||
|
if (!browserPushPublicKey || !(browserPushButton instanceof HTMLButtonElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supportsBrowserPush()) {
|
||||||
|
updateBrowserPushButtonLabel('当前浏览器不支持 Web Push', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const subscription = await getBrowserPushSubscription();
|
||||||
|
if (subscription) {
|
||||||
|
rememberSubmitted();
|
||||||
|
updateBrowserPushButtonLabel('浏览器提醒已开启', true);
|
||||||
|
if (!opened) {
|
||||||
|
root.hidden = true;
|
||||||
|
}
|
||||||
|
} else if (Notification.permission === 'denied') {
|
||||||
|
updateBrowserPushButtonLabel('通知权限已被拒绝', false);
|
||||||
|
} else {
|
||||||
|
updateBrowserPushButtonLabel('开启浏览器提醒', false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
updateBrowserPushButtonLabel('开启浏览器提醒', false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
syncPopupOffset();
|
syncPopupOffset();
|
||||||
|
void syncBrowserPushState();
|
||||||
|
|
||||||
if (header instanceof HTMLElement && typeof ResizeObserver !== 'undefined') {
|
if (header instanceof HTMLElement && typeof ResizeObserver !== 'undefined') {
|
||||||
const observer = new ResizeObserver(() => syncPopupOffset());
|
const observer = new ResizeObserver(() => syncPopupOffset());
|
||||||
@@ -306,6 +488,59 @@ const popupSettings = siteSettings.subscriptions;
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
browserPushButton instanceof HTMLButtonElement &&
|
||||||
|
browserPushButton.addEventListener('click', async () => {
|
||||||
|
if (!browserPushPublicKey || !browserPushApiUrl) {
|
||||||
|
setError('浏览器推送尚未配置完成。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (turnstileSiteKey) {
|
||||||
|
const token = turnstileTokenInput?.value.trim() || '';
|
||||||
|
if (!token) {
|
||||||
|
setError('请先完成人机验证。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPending('正在申请浏览器通知权限...');
|
||||||
|
updateBrowserPushButtonLabel('处理中...', true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const subscription = await ensureBrowserPushSubscription(browserPushPublicKey);
|
||||||
|
const response = await fetch(browserPushApiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
subscription,
|
||||||
|
source: 'frontend-popup',
|
||||||
|
turnstileToken: turnstileTokenInput?.value || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
payload?.message || payload?.description || '浏览器推送开启失败,请稍后再试。',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
rememberSubmitted();
|
||||||
|
resetHumanCheck();
|
||||||
|
setSuccess(payload?.message || '浏览器推送已开启,后续新内容会直接提醒。');
|
||||||
|
updateBrowserPushButtonLabel('浏览器提醒已开启', true);
|
||||||
|
successTimer = window.setTimeout(() => closePopup(false), 2200);
|
||||||
|
} catch (error) {
|
||||||
|
resetHumanCheck();
|
||||||
|
setError(
|
||||||
|
error instanceof Error ? error.message : '浏览器推送开启失败,请稍后重试。',
|
||||||
|
);
|
||||||
|
updateBrowserPushButtonLabel('开启浏览器提醒', false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
form.addEventListener('submit', async (event) => {
|
form.addEventListener('submit', async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
@@ -314,14 +549,20 @@ const popupSettings = siteSettings.subscriptions;
|
|||||||
const displayName = String(formData.get('displayName') || '').trim();
|
const displayName = String(formData.get('displayName') || '').trim();
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
status.dataset.state = 'error';
|
setError('请输入邮箱地址。');
|
||||||
status.textContent = '请输入邮箱地址。';
|
|
||||||
emailInput.focus();
|
emailInput.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
status.dataset.state = 'pending';
|
if (turnstileSiteKey) {
|
||||||
status.textContent = '正在提交订阅申请...';
|
const token = String(formData.get('turnstileToken') || '').trim();
|
||||||
|
if (!token) {
|
||||||
|
setError('请先完成人机验证。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPending('正在提交订阅申请...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(apiUrl, {
|
const response = await fetch(apiUrl, {
|
||||||
@@ -333,6 +574,7 @@ const popupSettings = siteSettings.subscriptions;
|
|||||||
email,
|
email,
|
||||||
displayName,
|
displayName,
|
||||||
source: 'frontend-popup',
|
source: 'frontend-popup',
|
||||||
|
turnstileToken: formData.get('turnstileToken'),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -343,13 +585,12 @@ const popupSettings = siteSettings.subscriptions;
|
|||||||
|
|
||||||
rememberSubmitted();
|
rememberSubmitted();
|
||||||
form.reset();
|
form.reset();
|
||||||
status.dataset.state = 'success';
|
resetHumanCheck();
|
||||||
status.textContent =
|
setSuccess(payload?.message || '订阅申请已提交,请前往邮箱确认后生效。');
|
||||||
payload?.message || '订阅申请已提交,请前往邮箱确认后生效。';
|
|
||||||
successTimer = window.setTimeout(() => closePopup(false), 2200);
|
successTimer = window.setTimeout(() => closePopup(false), 2200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
status.dataset.state = 'error';
|
resetHumanCheck();
|
||||||
status.textContent = error instanceof Error ? error.message : '订阅失败,请稍后重试。';
|
setError(error instanceof Error ? error.message : '订阅失败,请稍后重试。');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
3
frontend/src/env.d.ts
vendored
3
frontend/src/env.d.ts
vendored
@@ -1,7 +1,10 @@
|
|||||||
/// <reference types="astro/client" />
|
/// <reference types="astro/client" />
|
||||||
|
/// <reference types="node" />
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly PUBLIC_API_BASE_URL?: string;
|
readonly PUBLIC_API_BASE_URL?: string;
|
||||||
|
readonly PUBLIC_COMMENT_TURNSTILE_SITE_KEY?: string;
|
||||||
|
readonly PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ function normalizeApiBaseUrl(value?: string | null) {
|
|||||||
return value?.trim().replace(/\/$/, '') ?? '';
|
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 & {
|
const runtimeProcess = (globalThis as typeof globalThis & {
|
||||||
process?: {
|
process?: {
|
||||||
env?: Record<string, string | undefined>;
|
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 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) {
|
export function resolvePublicApiBaseUrl(requestUrl?: string | URL) {
|
||||||
const runtimePublicApiBaseUrl = getRuntimeEnv('PUBLIC_API_BASE_URL');
|
const runtimePublicApiBaseUrl = getRuntimeEnv('PUBLIC_API_BASE_URL');
|
||||||
@@ -63,6 +73,18 @@ export function resolveInternalApiBaseUrl(requestUrl?: string | URL) {
|
|||||||
return resolvePublicApiBaseUrl(requestUrl);
|
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 const API_BASE_URL = resolvePublicApiBaseUrl();
|
||||||
|
|
||||||
export interface ApiPost {
|
export interface ApiPost {
|
||||||
@@ -121,6 +143,7 @@ export interface CreateCommentInput {
|
|||||||
paragraphExcerpt?: string;
|
paragraphExcerpt?: string;
|
||||||
replyTo?: string | null;
|
replyTo?: string | null;
|
||||||
replyToCommentId?: number | null;
|
replyToCommentId?: number | null;
|
||||||
|
turnstileToken?: string;
|
||||||
captchaToken?: string;
|
captchaToken?: string;
|
||||||
captchaAnswer?: string;
|
captchaAnswer?: string;
|
||||||
website?: string;
|
website?: string;
|
||||||
@@ -141,8 +164,12 @@ export interface ApiTag {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
created_at: string;
|
count?: number;
|
||||||
updated_at: string;
|
description?: string | null;
|
||||||
|
cover_image?: string | null;
|
||||||
|
accent_color?: string | null;
|
||||||
|
seo_title?: string | null;
|
||||||
|
seo_description?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiCategory {
|
export interface ApiCategory {
|
||||||
@@ -150,6 +177,11 @@ export interface ApiCategory {
|
|||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
count: number;
|
count: number;
|
||||||
|
description?: string | null;
|
||||||
|
cover_image?: string | null;
|
||||||
|
accent_color?: string | null;
|
||||||
|
seo_title?: string | null;
|
||||||
|
seo_description?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiFriendLink {
|
export interface ApiFriendLink {
|
||||||
@@ -230,6 +262,11 @@ export interface ApiSiteSettings {
|
|||||||
}> | null;
|
}> | null;
|
||||||
ai_enabled: boolean;
|
ai_enabled: boolean;
|
||||||
paragraph_comments_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_enabled: boolean;
|
||||||
subscription_popup_title: string | null;
|
subscription_popup_title: string | null;
|
||||||
subscription_popup_description: string | null;
|
subscription_popup_description: string | null;
|
||||||
@@ -326,6 +363,20 @@ export interface ApiSearchResult {
|
|||||||
rank: number;
|
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 {
|
export interface Review {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -401,12 +452,18 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
|
|||||||
},
|
},
|
||||||
comments: {
|
comments: {
|
||||||
paragraphsEnabled: true,
|
paragraphsEnabled: true,
|
||||||
|
turnstileEnabled: false,
|
||||||
|
turnstileSiteKey: undefined,
|
||||||
},
|
},
|
||||||
subscriptions: {
|
subscriptions: {
|
||||||
popupEnabled: true,
|
popupEnabled: true,
|
||||||
popupTitle: '订阅更新',
|
popupTitle: '订阅更新',
|
||||||
popupDescription: '有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订。',
|
popupDescription: '有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订。',
|
||||||
popupDelaySeconds: 18,
|
popupDelaySeconds: 18,
|
||||||
|
turnstileEnabled: false,
|
||||||
|
turnstileSiteKey: undefined,
|
||||||
|
webPushEnabled: false,
|
||||||
|
webPushVapidPublicKey: undefined,
|
||||||
},
|
},
|
||||||
seo: {
|
seo: {
|
||||||
defaultOgImage: undefined,
|
defaultOgImage: undefined,
|
||||||
@@ -451,6 +508,12 @@ const normalizeTag = (tag: ApiTag): UiTag => ({
|
|||||||
id: String(tag.id),
|
id: String(tag.id),
|
||||||
name: tag.name,
|
name: tag.name,
|
||||||
slug: tag.slug,
|
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 => ({
|
const normalizeCategory = (category: ApiCategory): UiCategory => ({
|
||||||
@@ -458,6 +521,11 @@ const normalizeCategory = (category: ApiCategory): UiCategory => ({
|
|||||||
name: category.name,
|
name: category.name,
|
||||||
slug: category.slug,
|
slug: category.slug,
|
||||||
count: category.count,
|
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) => {
|
const normalizeAvatarUrl = (value: string | null | undefined) => {
|
||||||
@@ -532,6 +600,9 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
|
|||||||
},
|
},
|
||||||
comments: {
|
comments: {
|
||||||
paragraphsEnabled: settings.paragraph_comments_enabled ?? true,
|
paragraphsEnabled: settings.paragraph_comments_enabled ?? true,
|
||||||
|
turnstileEnabled: Boolean(settings.comment_turnstile_enabled),
|
||||||
|
turnstileSiteKey:
|
||||||
|
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined,
|
||||||
},
|
},
|
||||||
subscriptions: {
|
subscriptions: {
|
||||||
popupEnabled:
|
popupEnabled:
|
||||||
@@ -544,6 +615,14 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
|
|||||||
popupDelaySeconds:
|
popupDelaySeconds:
|
||||||
settings.subscription_popup_delay_seconds ??
|
settings.subscription_popup_delay_seconds ??
|
||||||
DEFAULT_SITE_SETTINGS.subscriptions.popupDelaySeconds,
|
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: {
|
seo: {
|
||||||
defaultOgImage: settings.seo_default_og_image ?? undefined,
|
defaultOgImage: settings.seo_default_og_image ?? undefined,
|
||||||
@@ -703,6 +782,46 @@ class ApiClient {
|
|||||||
return posts.map(normalizePost);
|
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> {
|
async getPost(id: number): Promise<UiPost> {
|
||||||
const post = await this.fetch<ApiPost>(`/posts/${id}`);
|
const post = await this.fetch<ApiPost>(`/posts/${id}`);
|
||||||
return normalizePost(post);
|
return normalizePost(post);
|
||||||
@@ -782,6 +901,7 @@ class ApiClient {
|
|||||||
paragraphExcerpt: comment.paragraphExcerpt,
|
paragraphExcerpt: comment.paragraphExcerpt,
|
||||||
replyTo: comment.replyTo,
|
replyTo: comment.replyTo,
|
||||||
replyToCommentId: comment.replyToCommentId,
|
replyToCommentId: comment.replyToCommentId,
|
||||||
|
turnstileToken: comment.turnstileToken,
|
||||||
captchaToken: comment.captchaToken,
|
captchaToken: comment.captchaToken,
|
||||||
captchaAnswer: comment.captchaAnswer,
|
captchaAnswer: comment.captchaAnswer,
|
||||||
website: comment.website,
|
website: comment.website,
|
||||||
@@ -955,12 +1075,87 @@ class ApiClient {
|
|||||||
image: result.image,
|
image: result.image,
|
||||||
images: null,
|
images: null,
|
||||||
pinned: result.pinned ?? false,
|
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,
|
created_at: result.created_at,
|
||||||
updated_at: result.updated_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> {
|
async askAi(question: string): Promise<AiAskResponse> {
|
||||||
return this.fetch<AiAskResponse>('/ai/ask', {
|
return this.fetch<AiAskResponse>('/ai/ask', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const messages = {
|
|||||||
cancel: '取消',
|
cancel: '取消',
|
||||||
clear: '清除',
|
clear: '清除',
|
||||||
reset: '重置',
|
reset: '重置',
|
||||||
|
refresh: '刷新',
|
||||||
reply: '回复',
|
reply: '回复',
|
||||||
like: '点赞',
|
like: '点赞',
|
||||||
visit: '访问',
|
visit: '访问',
|
||||||
@@ -63,6 +64,10 @@ export const messages = {
|
|||||||
featureOff: '功能未开启',
|
featureOff: '功能未开启',
|
||||||
emptyState: '当前还没有内容。',
|
emptyState: '当前还没有内容。',
|
||||||
apiUnavailable: 'API 暂时不可用',
|
apiUnavailable: 'API 暂时不可用',
|
||||||
|
humanVerification: '人机验证',
|
||||||
|
turnstileHint: '提交前请先完成 Cloudflare Turnstile 校验。',
|
||||||
|
turnstileRequired: '请先完成人机验证。',
|
||||||
|
turnstileLoadFailed: '加载人机验证失败,请刷新页面后重试。',
|
||||||
unknownError: '未知错误',
|
unknownError: '未知错误',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
@@ -176,6 +181,9 @@ export const messages = {
|
|||||||
searchTips: '搜索会优先走站内索引,并自动复用同义词与轻量拼写纠错。',
|
searchTips: '搜索会优先走站内索引,并自动复用同义词与轻量拼写纠错。',
|
||||||
resultSummary: '找到 {count} 条结果',
|
resultSummary: '找到 {count} 条结果',
|
||||||
filteredSummary: '筛选后剩余 {count} 条结果',
|
filteredSummary: '筛选后剩余 {count} 条结果',
|
||||||
|
pageSummary: '第 {current} / {total} 页 · 共 {count} 条结果',
|
||||||
|
previous: '上一页',
|
||||||
|
next: '下一页',
|
||||||
filtersTitle: '二次筛选',
|
filtersTitle: '二次筛选',
|
||||||
allCategories: '全部分类',
|
allCategories: '全部分类',
|
||||||
allTags: '全部标签',
|
allTags: '全部标签',
|
||||||
@@ -478,6 +486,7 @@ export const messages = {
|
|||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
clear: 'Clear',
|
clear: 'Clear',
|
||||||
reset: 'Reset',
|
reset: 'Reset',
|
||||||
|
refresh: 'Refresh',
|
||||||
reply: 'Reply',
|
reply: 'Reply',
|
||||||
like: 'Like',
|
like: 'Like',
|
||||||
visit: 'Visit',
|
visit: 'Visit',
|
||||||
@@ -504,6 +513,10 @@ export const messages = {
|
|||||||
featureOff: 'Feature off',
|
featureOff: 'Feature off',
|
||||||
emptyState: 'Nothing here yet.',
|
emptyState: 'Nothing here yet.',
|
||||||
apiUnavailable: 'API temporarily unavailable',
|
apiUnavailable: 'API temporarily unavailable',
|
||||||
|
humanVerification: 'Human verification',
|
||||||
|
turnstileHint: 'Please complete the Cloudflare Turnstile check before submitting.',
|
||||||
|
turnstileRequired: 'Please complete the human verification first.',
|
||||||
|
turnstileLoadFailed: 'Failed to load human verification. Refresh the page and try again.',
|
||||||
unknownError: 'unknown error',
|
unknownError: 'unknown error',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
@@ -617,6 +630,9 @@ export const messages = {
|
|||||||
searchTips: 'Search uses the site index first and also applies synonyms plus lightweight typo correction automatically.',
|
searchTips: 'Search uses the site index first and also applies synonyms plus lightweight typo correction automatically.',
|
||||||
resultSummary: 'Found {count} results',
|
resultSummary: 'Found {count} results',
|
||||||
filteredSummary: '{count} results after filters',
|
filteredSummary: '{count} results after filters',
|
||||||
|
pageSummary: 'Page {current}/{total} · {count} results',
|
||||||
|
previous: 'Prev',
|
||||||
|
next: 'Next',
|
||||||
filtersTitle: 'Refine results',
|
filtersTitle: 'Refine results',
|
||||||
allCategories: 'All categories',
|
allCategories: 'All categories',
|
||||||
allTags: 'All tags',
|
allTags: 'All tags',
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export interface Category {
|
|||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
coverImage?: string;
|
||||||
|
accentColor?: string;
|
||||||
|
seoTitle?: string;
|
||||||
|
seoDescription?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
}
|
}
|
||||||
@@ -38,6 +42,11 @@ export interface Tag {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
description?: string;
|
||||||
|
coverImage?: string;
|
||||||
|
accentColor?: string;
|
||||||
|
seoTitle?: string;
|
||||||
|
seoDescription?: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,12 +85,18 @@ export interface SiteSettings {
|
|||||||
};
|
};
|
||||||
comments: {
|
comments: {
|
||||||
paragraphsEnabled: boolean;
|
paragraphsEnabled: boolean;
|
||||||
|
turnstileEnabled: boolean;
|
||||||
|
turnstileSiteKey?: string;
|
||||||
};
|
};
|
||||||
subscriptions: {
|
subscriptions: {
|
||||||
popupEnabled: boolean;
|
popupEnabled: boolean;
|
||||||
popupTitle: string;
|
popupTitle: string;
|
||||||
popupDescription: string;
|
popupDescription: string;
|
||||||
popupDelaySeconds: number;
|
popupDelaySeconds: number;
|
||||||
|
turnstileEnabled: boolean;
|
||||||
|
turnstileSiteKey?: string;
|
||||||
|
webPushEnabled: boolean;
|
||||||
|
webPushVapidPublicKey?: string;
|
||||||
};
|
};
|
||||||
seo: {
|
seo: {
|
||||||
defaultOgImage?: string;
|
defaultOgImage?: string;
|
||||||
|
|||||||
110
frontend/src/lib/utils/turnstile.ts
Normal file
110
frontend/src/lib/utils/turnstile.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
const TURNSTILE_SCRIPT_ID = 'termi-turnstile-script';
|
||||||
|
const TURNSTILE_SCRIPT_SRC =
|
||||||
|
'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
||||||
|
|
||||||
|
type TurnstileApi = {
|
||||||
|
render: (
|
||||||
|
container: HTMLElement,
|
||||||
|
options: Record<string, unknown>,
|
||||||
|
) => string | number;
|
||||||
|
reset: (widgetId?: string | number) => void;
|
||||||
|
remove?: (widgetId?: string | number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
turnstile?: TurnstileApi;
|
||||||
|
__termiTurnstileLoader__?: Promise<TurnstileApi>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MountedTurnstile = {
|
||||||
|
reset: () => void;
|
||||||
|
remove: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadTurnstileScript(): Promise<TurnstileApi> {
|
||||||
|
if (window.turnstile) {
|
||||||
|
return window.turnstile;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.__termiTurnstileLoader__) {
|
||||||
|
window.__termiTurnstileLoader__ = new Promise<TurnstileApi>((resolve, reject) => {
|
||||||
|
const existing = document.getElementById(TURNSTILE_SCRIPT_ID) as HTMLScriptElement | null;
|
||||||
|
|
||||||
|
const handleReady = () => {
|
||||||
|
if (window.turnstile) {
|
||||||
|
resolve(window.turnstile);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Turnstile script loaded without API'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.addEventListener('load', handleReady, { once: true });
|
||||||
|
existing.addEventListener(
|
||||||
|
'error',
|
||||||
|
() => reject(new Error('Failed to load Turnstile script')),
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.id = TURNSTILE_SCRIPT_ID;
|
||||||
|
script.src = TURNSTILE_SCRIPT_SRC;
|
||||||
|
script.async = true;
|
||||||
|
script.defer = true;
|
||||||
|
script.addEventListener('load', handleReady, { once: true });
|
||||||
|
script.addEventListener(
|
||||||
|
'error',
|
||||||
|
() => reject(new Error('Failed to load Turnstile script')),
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.__termiTurnstileLoader__;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mountTurnstile(
|
||||||
|
container: HTMLElement,
|
||||||
|
options: {
|
||||||
|
siteKey: string;
|
||||||
|
onToken: (token: string) => void;
|
||||||
|
onExpire?: () => void;
|
||||||
|
onError?: () => void;
|
||||||
|
},
|
||||||
|
): Promise<MountedTurnstile> {
|
||||||
|
const turnstile = await loadTurnstileScript();
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
const widgetId = turnstile.render(container, {
|
||||||
|
sitekey: options.siteKey,
|
||||||
|
callback: (token: string) => {
|
||||||
|
options.onToken(token);
|
||||||
|
},
|
||||||
|
'expired-callback': () => {
|
||||||
|
options.onExpire?.();
|
||||||
|
},
|
||||||
|
'error-callback': () => {
|
||||||
|
options.onError?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
reset() {
|
||||||
|
turnstile.reset(widgetId);
|
||||||
|
},
|
||||||
|
remove() {
|
||||||
|
if (typeof turnstile.remove === 'function') {
|
||||||
|
turnstile.remove(widgetId);
|
||||||
|
} else {
|
||||||
|
turnstile.reset(widgetId);
|
||||||
|
}
|
||||||
|
container.innerHTML = '';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
112
frontend/src/lib/utils/web-push.ts
Normal file
112
frontend/src/lib/utils/web-push.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
const SERVICE_WORKER_URL = '/termi-web-push-sw.js';
|
||||||
|
|
||||||
|
export type BrowserPushSubscriptionPayload = {
|
||||||
|
endpoint: string;
|
||||||
|
expirationTime?: number | null;
|
||||||
|
keys: {
|
||||||
|
auth: string;
|
||||||
|
p256dh: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function ensureBrowserSupport() {
|
||||||
|
if (
|
||||||
|
typeof window === 'undefined' ||
|
||||||
|
!('Notification' in window) ||
|
||||||
|
!('serviceWorker' in navigator) ||
|
||||||
|
!('PushManager' in window)
|
||||||
|
) {
|
||||||
|
throw new Error('当前浏览器不支持 Web Push。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlBase64ToUint8Array(base64String: string) {
|
||||||
|
const normalized = base64String.trim();
|
||||||
|
const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
|
||||||
|
const base64 = (normalized + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const binary = window.atob(base64);
|
||||||
|
const output = new Uint8Array(binary.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < binary.length; index += 1) {
|
||||||
|
output[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRegistration() {
|
||||||
|
ensureBrowserSupport();
|
||||||
|
await navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: '/' });
|
||||||
|
return navigator.serviceWorker.ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSubscription(
|
||||||
|
subscription: PushSubscription | PushSubscriptionJSON,
|
||||||
|
): BrowserPushSubscriptionPayload {
|
||||||
|
const json = 'toJSON' in subscription ? subscription.toJSON() : subscription;
|
||||||
|
const endpoint = json.endpoint?.trim() || '';
|
||||||
|
const auth = json.keys?.auth?.trim() || '';
|
||||||
|
const p256dh = json.keys?.p256dh?.trim() || '';
|
||||||
|
|
||||||
|
if (!endpoint || !auth || !p256dh) {
|
||||||
|
throw new Error('浏览器返回的 PushSubscription 不完整。');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
endpoint,
|
||||||
|
expirationTime: json.expirationTime ?? null,
|
||||||
|
keys: {
|
||||||
|
auth,
|
||||||
|
p256dh,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsBrowserPush() {
|
||||||
|
return (
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
'Notification' in window &&
|
||||||
|
'serviceWorker' in navigator &&
|
||||||
|
'PushManager' in window
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBrowserPushSubscription() {
|
||||||
|
if (!supportsBrowserPush()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await getRegistration();
|
||||||
|
return registration.pushManager.getSubscription();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureBrowserPushSubscription(
|
||||||
|
publicKey: string,
|
||||||
|
): Promise<BrowserPushSubscriptionPayload> {
|
||||||
|
ensureBrowserSupport();
|
||||||
|
|
||||||
|
if (!publicKey.trim()) {
|
||||||
|
throw new Error('Web Push 公钥未配置。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission =
|
||||||
|
Notification.permission === 'granted'
|
||||||
|
? 'granted'
|
||||||
|
: await Notification.requestPermission();
|
||||||
|
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
throw new Error('浏览器通知权限未开启。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await getRegistration();
|
||||||
|
let subscription = await registration.pushManager.getSubscription();
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeSubscription(subscription);
|
||||||
|
}
|
||||||
@@ -156,7 +156,7 @@ export const GET: APIRoute = async ({ url, request }) => {
|
|||||||
? resized.png({ quality }).toBuffer()
|
? resized.png({ quality }).toBuffer()
|
||||||
: resized.jpeg({ quality, mozjpeg: true }).toBuffer());
|
: resized.jpeg({ quality, mozjpeg: true }).toBuffer());
|
||||||
|
|
||||||
return new Response(output, {
|
return new Response(new Uint8Array(output), {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': contentTypeForFormat(format),
|
'Content-Type': contentTypeForFormat(format),
|
||||||
'Cache-Control': 'public, max-age=604800, stale-while-revalidate=2592000',
|
'Cache-Control': 'public, max-age=604800, stale-while-revalidate=2592000',
|
||||||
|
|||||||
@@ -289,7 +289,9 @@ const breadcrumbJsonLd = {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{paragraphCommentsEnabled && <ParagraphComments postSlug={post.slug} class="mb-4" />}
|
{paragraphCommentsEnabled && (
|
||||||
|
<ParagraphComments postSlug={post.slug} class="mb-4" siteSettings={siteSettings} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div class="terminal-document article-content" set:html={renderedContent.code}></div>
|
<div class="terminal-document article-content" set:html={renderedContent.code}></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -421,7 +423,7 @@ const breadcrumbJsonLd = {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<section class="mt-8">
|
<section class="mt-8">
|
||||||
<Comments postSlug={post.slug} class="terminal-panel" />
|
<Comments postSlug={post.slug} class="terminal-panel" siteSettings={siteSettings} />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,24 +11,53 @@ import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '
|
|||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
let allPosts: Post[] = [];
|
|
||||||
let allTags: Tag[] = [];
|
|
||||||
let allCategories: Category[] = [];
|
|
||||||
const url = new URL(Astro.request.url);
|
const url = new URL(Astro.request.url);
|
||||||
const selectedSearch = url.searchParams.get('search') || '';
|
const selectedSearch = url.searchParams.get('search') || '';
|
||||||
|
const selectedType = url.searchParams.get('type') || 'all';
|
||||||
|
const selectedTag = url.searchParams.get('tag') || '';
|
||||||
|
const selectedCategory = url.searchParams.get('category') || '';
|
||||||
|
const requestedPage = Number.parseInt(url.searchParams.get('page') || '1', 10);
|
||||||
|
const postsPerPage = 10;
|
||||||
const { t } = getI18n(Astro);
|
const { t } = getI18n(Astro);
|
||||||
|
|
||||||
|
let paginatedPosts: Post[] = [];
|
||||||
|
let allTags: Tag[] = [];
|
||||||
|
let allCategories: Category[] = [];
|
||||||
|
let totalPosts = 0;
|
||||||
|
let totalPages = 1;
|
||||||
|
let currentPage = Number.isFinite(requestedPage) && requestedPage > 0 ? requestedPage : 1;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [posts, categories, rawTags] = await Promise.all([
|
const [pageResult, categories, rawTags] = await Promise.all([
|
||||||
selectedSearch ? api.searchPosts(selectedSearch) : api.getPosts(),
|
selectedSearch
|
||||||
|
? api.searchPostsPage({
|
||||||
|
query: selectedSearch,
|
||||||
|
page: currentPage,
|
||||||
|
pageSize: postsPerPage,
|
||||||
|
category: selectedCategory || undefined,
|
||||||
|
tag: selectedTag || undefined,
|
||||||
|
postType: selectedType !== 'all' ? selectedType : undefined,
|
||||||
|
})
|
||||||
|
: api.getPostsPage({
|
||||||
|
page: currentPage,
|
||||||
|
pageSize: postsPerPage,
|
||||||
|
search: undefined,
|
||||||
|
category: selectedCategory || undefined,
|
||||||
|
tag: selectedTag || undefined,
|
||||||
|
postType: selectedType !== 'all' ? selectedType : undefined,
|
||||||
|
}),
|
||||||
api.getCategories(),
|
api.getCategories(),
|
||||||
api.getTags(),
|
api.getTags(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
allPosts = posts;
|
paginatedPosts = pageResult.items;
|
||||||
|
totalPosts = pageResult.total;
|
||||||
|
totalPages = pageResult.totalPages;
|
||||||
|
currentPage = pageResult.page;
|
||||||
allCategories = categories;
|
allCategories = categories;
|
||||||
|
|
||||||
const seenTagIds = new Set<string>();
|
const seenTagIds = new Set<string>();
|
||||||
allTags = rawTags.filter(tag => {
|
allTags = rawTags.filter((tag) => {
|
||||||
const key = `${tag.slug}:${tag.name}`.toLowerCase();
|
const key = `${tag.slug}:${tag.name}`.toLowerCase();
|
||||||
if (seenTagIds.has(key)) return false;
|
if (seenTagIds.has(key)) return false;
|
||||||
seenTagIds.add(key);
|
seenTagIds.add(key);
|
||||||
@@ -38,32 +67,15 @@ try {
|
|||||||
console.error('API Error:', error);
|
console.error('API Error:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedType = url.searchParams.get('type') || 'all';
|
|
||||||
const selectedTag = url.searchParams.get('tag') || '';
|
|
||||||
const selectedCategory = url.searchParams.get('category') || '';
|
|
||||||
const currentPage = parseInt(url.searchParams.get('page') || '1');
|
|
||||||
const postsPerPage = 10;
|
|
||||||
const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||||
const isMatchingTag = (value: string) => value.trim().toLowerCase() === normalizedSelectedTag;
|
|
||||||
const isSelectedTag = (tag: Tag) =>
|
const isSelectedTag = (tag: Tag) =>
|
||||||
tag.name.trim().toLowerCase() === normalizedSelectedTag || tag.slug.trim().toLowerCase() === normalizedSelectedTag;
|
tag.name.trim().toLowerCase() === normalizedSelectedTag ||
|
||||||
|
tag.slug.trim().toLowerCase() === normalizedSelectedTag;
|
||||||
const filteredPosts = allPosts.filter(post => {
|
|
||||||
if (selectedType !== 'all' && post.type !== selectedType) return false;
|
|
||||||
if (selectedTag && !post.tags?.some(isMatchingTag)) return false;
|
|
||||||
if (selectedCategory && post.category?.toLowerCase() !== selectedCategory.toLowerCase()) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalPosts = filteredPosts.length;
|
|
||||||
const totalPages = Math.ceil(totalPosts / postsPerPage);
|
|
||||||
const startIndex = (currentPage - 1) * postsPerPage;
|
|
||||||
const paginatedPosts = filteredPosts.slice(startIndex, startIndex + postsPerPage);
|
|
||||||
|
|
||||||
const postTypeFilters = [
|
const postTypeFilters = [
|
||||||
{ id: 'all', name: t('common.all'), icon: 'fa-stream' },
|
{ id: 'all', name: t('common.all'), icon: 'fa-stream' },
|
||||||
{ id: 'article', name: t('common.article'), icon: 'fa-file-alt' },
|
{ id: 'article', name: t('common.article'), icon: 'fa-file-alt' },
|
||||||
{ id: 'tweet', name: t('common.tweet'), icon: 'fa-comment-dots' }
|
{ id: 'tweet', name: t('common.tweet'), icon: 'fa-comment-dots' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const typePromptCommand =
|
const typePromptCommand =
|
||||||
@@ -76,12 +88,6 @@ const categoryPromptCommand = selectedCategory
|
|||||||
const tagPromptCommand = selectedTag
|
const tagPromptCommand = selectedTag
|
||||||
? `grep -Ril "#${selectedTag}" ./posts`
|
? `grep -Ril "#${selectedTag}" ./posts`
|
||||||
: `cut -d: -f2 ./tags.index | sort -u`;
|
: `cut -d: -f2 ./tags.index | sort -u`;
|
||||||
const categoryAccentMap = Object.fromEntries(
|
|
||||||
allCategories.map((category) => [category.name.toLowerCase(), getAccentVars(getCategoryTheme(category.name))])
|
|
||||||
);
|
|
||||||
const tagAccentMap = Object.fromEntries(
|
|
||||||
allTags.map((tag) => [String(tag.slug || tag.name).toLowerCase(), getAccentVars(getTagTheme(tag.name))])
|
|
||||||
);
|
|
||||||
const hasActiveFilters =
|
const hasActiveFilters =
|
||||||
Boolean(selectedSearch || selectedTag || selectedCategory || selectedType !== 'all' || currentPage > 1);
|
Boolean(selectedSearch || selectedTag || selectedCategory || selectedType !== 'all' || currentPage > 1);
|
||||||
const canonicalUrl = hasActiveFilters ? '/articles' : undefined;
|
const canonicalUrl = hasActiveFilters ? '/articles' : undefined;
|
||||||
@@ -130,7 +136,7 @@ const buildArticlesUrl = ({
|
|||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<span class="terminal-stat-pill">
|
<span class="terminal-stat-pill">
|
||||||
<i class="fas fa-file-lines text-[var(--primary)]"></i>
|
<i class="fas fa-file-lines text-[var(--primary)]"></i>
|
||||||
<span id="articles-total-posts">{t('articlesPage.totalPosts', { count: filteredPosts.length })}</span>
|
<span>{t('articlesPage.totalPosts', { count: totalPosts })}</span>
|
||||||
</span>
|
</span>
|
||||||
{selectedSearch && (
|
{selectedSearch && (
|
||||||
<span class="terminal-stat-pill">
|
<span class="terminal-stat-pill">
|
||||||
@@ -138,28 +144,24 @@ const buildArticlesUrl = ({
|
|||||||
grep: {selectedSearch}
|
grep: {selectedSearch}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span
|
{selectedCategory && (
|
||||||
id="articles-current-category-pill"
|
<span
|
||||||
class:list={[
|
class="terminal-stat-pill terminal-stat-pill--accent"
|
||||||
'terminal-stat-pill terminal-stat-pill--accent',
|
style={getAccentVars(getCategoryTheme(selectedCategory))}
|
||||||
!selectedCategory && 'hidden'
|
>
|
||||||
]}
|
<i class="fas fa-folder-open"></i>
|
||||||
style={selectedCategory ? getAccentVars(getCategoryTheme(selectedCategory)) : undefined}
|
<span>{selectedCategory}</span>
|
||||||
>
|
</span>
|
||||||
<i class="fas fa-folder-open"></i>
|
)}
|
||||||
<span id="articles-current-category">{selectedCategory}</span>
|
{selectedTag && (
|
||||||
</span>
|
<span
|
||||||
<span
|
class="terminal-stat-pill terminal-stat-pill--accent"
|
||||||
id="articles-current-tag-pill"
|
style={getAccentVars(getTagTheme(selectedTag))}
|
||||||
class:list={[
|
>
|
||||||
'terminal-stat-pill terminal-stat-pill--accent',
|
<i class="fas fa-hashtag"></i>
|
||||||
!selectedTag && 'hidden'
|
<span>{selectedTag}</span>
|
||||||
]}
|
</span>
|
||||||
style={selectedTag ? getAccentVars(getTagTheme(selectedTag)) : undefined}
|
)}
|
||||||
>
|
|
||||||
<i class="fas fa-hashtag"></i>
|
|
||||||
<span id="articles-current-tag">{selectedTag}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,10 +170,9 @@ const buildArticlesUrl = ({
|
|||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<CommandPrompt promptId="articles-type-prompt" command={typePromptCommand} typing={false} />
|
<CommandPrompt promptId="articles-type-prompt" command={typePromptCommand} typing={false} />
|
||||||
<div class="mt-3 flex flex-wrap gap-3">
|
<div class="mt-3 flex flex-wrap gap-3">
|
||||||
{postTypeFilters.map(filter => (
|
{postTypeFilters.map((filter) => (
|
||||||
<FilterPill
|
<FilterPill
|
||||||
href={buildArticlesUrl({ type: filter.id, page: 1 })}
|
href={buildArticlesUrl({ type: filter.id, page: 1 })}
|
||||||
data-articles-type={filter.id}
|
|
||||||
tone={filter.id === 'all' ? 'neutral' : 'accent'}
|
tone={filter.id === 'all' ? 'neutral' : 'accent'}
|
||||||
active={selectedType === filter.id}
|
active={selectedType === filter.id}
|
||||||
style={filter.id === 'all' ? undefined : getAccentVars(getPostTypeTheme(filter.id))}
|
style={filter.id === 'all' ? undefined : getAccentVars(getPostTypeTheme(filter.id))}
|
||||||
@@ -187,19 +188,13 @@ const buildArticlesUrl = ({
|
|||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<CommandPrompt promptId="articles-category-prompt" command={categoryPromptCommand} typing={false} />
|
<CommandPrompt promptId="articles-category-prompt" command={categoryPromptCommand} typing={false} />
|
||||||
<div class="mt-3 flex flex-wrap gap-3">
|
<div class="mt-3 flex flex-wrap gap-3">
|
||||||
<FilterPill
|
<FilterPill href={buildArticlesUrl({ category: '', page: 1 })} tone="amber" active={!selectedCategory}>
|
||||||
href={buildArticlesUrl({ category: '', page: 1 })}
|
|
||||||
data-articles-category=""
|
|
||||||
tone="amber"
|
|
||||||
active={!selectedCategory}
|
|
||||||
>
|
|
||||||
<i class="fas fa-folder-tree"></i>
|
<i class="fas fa-folder-tree"></i>
|
||||||
<span class="font-medium">{t('articlesPage.allCategories')}</span>
|
<span class="font-medium">{t('articlesPage.allCategories')}</span>
|
||||||
</FilterPill>
|
</FilterPill>
|
||||||
{allCategories.map(category => (
|
{allCategories.map((category) => (
|
||||||
<FilterPill
|
<FilterPill
|
||||||
href={buildArticlesUrl({ category: category.name, page: 1 })}
|
href={buildArticlesUrl({ category: category.name, page: 1 })}
|
||||||
data-articles-category={category.name}
|
|
||||||
tone="accent"
|
tone="accent"
|
||||||
active={selectedCategory.toLowerCase() === category.name.toLowerCase()}
|
active={selectedCategory.toLowerCase() === category.name.toLowerCase()}
|
||||||
style={getAccentVars(getCategoryTheme(category.name))}
|
style={getAccentVars(getCategoryTheme(category.name))}
|
||||||
@@ -217,19 +212,13 @@ const buildArticlesUrl = ({
|
|||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<CommandPrompt promptId="articles-tag-prompt" command={tagPromptCommand} typing={false} />
|
<CommandPrompt promptId="articles-tag-prompt" command={tagPromptCommand} typing={false} />
|
||||||
<div class="mt-3 flex flex-wrap gap-3">
|
<div class="mt-3 flex flex-wrap gap-3">
|
||||||
<FilterPill
|
<FilterPill href={buildArticlesUrl({ tag: '', page: 1 })} tone="teal" active={!selectedTag}>
|
||||||
href={buildArticlesUrl({ tag: '', page: 1 })}
|
|
||||||
data-articles-tag=""
|
|
||||||
tone="teal"
|
|
||||||
active={!selectedTag}
|
|
||||||
>
|
|
||||||
<i class="fas fa-hashtag"></i>
|
<i class="fas fa-hashtag"></i>
|
||||||
<span class="font-medium">{t('articlesPage.allTags')}</span>
|
<span class="font-medium">{t('articlesPage.allTags')}</span>
|
||||||
</FilterPill>
|
</FilterPill>
|
||||||
{allTags.map(tag => (
|
{allTags.map((tag) => (
|
||||||
<FilterPill
|
<FilterPill
|
||||||
href={buildArticlesUrl({ tag: tag.slug || tag.name, page: 1 })}
|
href={buildArticlesUrl({ tag: tag.slug || tag.name, page: 1 })}
|
||||||
data-articles-tag={tag.slug || tag.name}
|
|
||||||
tone="accent"
|
tone="accent"
|
||||||
active={isSelectedTag(tag)}
|
active={isSelectedTag(tag)}
|
||||||
style={getAccentVars(getTagTheme(tag.name))}
|
style={getAccentVars(getTagTheme(tag.name))}
|
||||||
@@ -244,279 +233,51 @@ const buildArticlesUrl = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-4">
|
<div class="px-4">
|
||||||
{allPosts.length > 0 ? (
|
{paginatedPosts.length > 0 ? (
|
||||||
<div class="ml-4 mt-4 space-y-4">
|
<div class="ml-4 mt-4 space-y-4">
|
||||||
{allPosts.map((post, index) => {
|
{paginatedPosts.map((post) => (
|
||||||
const matchesCurrentFilter =
|
<PostCard post={post} selectedTag={selectedTag} highlightTerm={selectedSearch} />
|
||||||
(selectedType === 'all' || post.type === selectedType) &&
|
))}
|
||||||
(!selectedTag || post.tags?.some(isMatchingTag)) &&
|
|
||||||
(!selectedCategory || post.category?.toLowerCase() === selectedCategory.toLowerCase());
|
|
||||||
const filteredIndex = matchesCurrentFilter
|
|
||||||
? filteredPosts.findIndex((item) => item.slug === post.slug)
|
|
||||||
: -1;
|
|
||||||
const isVisible = matchesCurrentFilter && filteredIndex >= startIndex && filteredIndex < startIndex + postsPerPage;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-article-card
|
|
||||||
data-article-type={post.type}
|
|
||||||
data-article-category={post.category?.toLowerCase() || ''}
|
|
||||||
data-article-tags={post.tags.map((tag) => tag.trim().toLowerCase()).join('|')}
|
|
||||||
data-article-index={index}
|
|
||||||
class:list={[!isVisible && 'hidden']}
|
|
||||||
>
|
|
||||||
<PostCard post={post} selectedTag={selectedTag} highlightTerm={selectedSearch} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div id="articles-empty-state" class:list={['terminal-empty ml-4 mt-4', paginatedPosts.length > 0 && 'hidden']}>
|
<div class:list={['terminal-empty ml-4 mt-4', totalPosts > 0 && 'hidden']}>
|
||||||
<div class="mx-auto flex max-w-md flex-col items-center gap-3">
|
<div class="mx-auto flex max-w-md flex-col items-center gap-3">
|
||||||
<span class="terminal-section-icon">
|
<span class="terminal-section-icon">
|
||||||
<i class="fas fa-folder-open"></i>
|
<i class="fas fa-folder-open"></i>
|
||||||
</span>
|
</span>
|
||||||
<h2 class="text-xl font-semibold text-[var(--title-color)]">{t('articlesPage.emptyTitle')}</h2>
|
<h2 class="text-xl font-semibold text-[var(--title-color)]">{t('articlesPage.emptyTitle')}</h2>
|
||||||
<p class="text-sm leading-7 text-[var(--text-secondary)]">
|
<p class="text-sm leading-7 text-[var(--text-secondary)]">
|
||||||
{t('articlesPage.emptyDescription')}
|
{t('articlesPage.emptyDescription')}
|
||||||
</p>
|
</p>
|
||||||
<a href="/articles" class="terminal-action-button terminal-action-button-primary">
|
<a href="/articles" class="terminal-action-button terminal-action-button-primary">
|
||||||
<i class="fas fa-rotate-left"></i>
|
<i class="fas fa-rotate-left"></i>
|
||||||
<span>{t('common.resetFilters')}</span>
|
<span>{t('common.resetFilters')}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-4 py-6">
|
<div class="px-4 py-6">
|
||||||
<div id="articles-pagination" class:list={['terminal-panel-muted ml-4 mt-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between', totalPages <= 1 && 'hidden']}>
|
<div class:list={['terminal-panel-muted ml-4 mt-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between', totalPages <= 1 && 'hidden']}>
|
||||||
<span class="text-sm text-[var(--text-secondary)]">
|
<span class="text-sm text-[var(--text-secondary)]">
|
||||||
<span id="articles-page-summary">{t('articlesPage.pageSummary', { current: currentPage, total: totalPages, count: totalPosts })}</span>
|
{t('articlesPage.pageSummary', { current: currentPage, total: totalPages, count: totalPosts })}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button
|
{currentPage > 1 && (
|
||||||
id="articles-prev-btn"
|
<a href={buildArticlesUrl({ page: currentPage - 1 })} class="terminal-action-button">
|
||||||
type="button"
|
<i class="fas fa-chevron-left"></i>
|
||||||
class:list={['terminal-action-button', currentPage <= 1 && 'hidden']}
|
<span>{t('articlesPage.previous')}</span>
|
||||||
>
|
</a>
|
||||||
<i class="fas fa-chevron-left"></i>
|
)}
|
||||||
<span>{t('articlesPage.previous')}</span>
|
{currentPage < totalPages && (
|
||||||
</button>
|
<a href={buildArticlesUrl({ page: currentPage + 1 })} class="terminal-action-button terminal-action-button-primary">
|
||||||
<button
|
<span>{t('articlesPage.next')}</span>
|
||||||
id="articles-next-btn"
|
<i class="fas fa-chevron-right"></i>
|
||||||
type="button"
|
</a>
|
||||||
class:list={['terminal-action-button terminal-action-button-primary', currentPage >= totalPages && 'hidden']}
|
)}
|
||||||
>
|
|
||||||
<span>{t('articlesPage.next')}</span>
|
|
||||||
<i class="fas fa-chevron-right"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</TerminalWindow>
|
</TerminalWindow>
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<script
|
|
||||||
is:inline
|
|
||||||
define:vars={{
|
|
||||||
postsPerPage,
|
|
||||||
selectedSearch,
|
|
||||||
categoryAccentMap,
|
|
||||||
tagAccentMap,
|
|
||||||
initialArticlesState: {
|
|
||||||
type: selectedType,
|
|
||||||
category: selectedCategory,
|
|
||||||
tag: selectedTag,
|
|
||||||
page: currentPage,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
(function() {
|
|
||||||
/** @type {Window['__termiCommandPrompt']} */
|
|
||||||
let promptApi;
|
|
||||||
|
|
||||||
const articleCards = Array.from(document.querySelectorAll('[data-article-card]'));
|
|
||||||
const typeFilters = Array.from(document.querySelectorAll('[data-articles-type]'));
|
|
||||||
const categoryFilters = Array.from(document.querySelectorAll('[data-articles-category]'));
|
|
||||||
const tagFilters = Array.from(document.querySelectorAll('[data-articles-tag]'));
|
|
||||||
const totalPostsEl = document.getElementById('articles-total-posts');
|
|
||||||
const categoryPill = document.getElementById('articles-current-category-pill');
|
|
||||||
const categoryText = document.getElementById('articles-current-category');
|
|
||||||
const tagPill = document.getElementById('articles-current-tag-pill');
|
|
||||||
const tagText = document.getElementById('articles-current-tag');
|
|
||||||
const emptyState = document.getElementById('articles-empty-state');
|
|
||||||
const pagination = document.getElementById('articles-pagination');
|
|
||||||
const pageSummary = document.getElementById('articles-page-summary');
|
|
||||||
const prevBtn = document.getElementById('articles-prev-btn');
|
|
||||||
const nextBtn = document.getElementById('articles-next-btn');
|
|
||||||
const t = window.__termiTranslate;
|
|
||||||
|
|
||||||
promptApi = window.__termiCommandPrompt;
|
|
||||||
|
|
||||||
const state = {
|
|
||||||
type: initialArticlesState.type || 'all',
|
|
||||||
category: initialArticlesState.category || '',
|
|
||||||
tag: initialArticlesState.tag || '',
|
|
||||||
page: Number(initialArticlesState.page || 1),
|
|
||||||
};
|
|
||||||
|
|
||||||
function updateArticlePrompts() {
|
|
||||||
const typeCommand = state.type === 'all'
|
|
||||||
? 'grep -E "^type: (article|tweet)$" ./posts/*.md'
|
|
||||||
: `grep -E "^type: ${state.type}$" ./posts/*.md`;
|
|
||||||
const categoryCommand = state.category
|
|
||||||
? `grep -El "^category: ${state.category}$" ./posts/*.md`
|
|
||||||
: 'cut -d: -f2 ./categories.index | sort -u';
|
|
||||||
const tagCommand = state.tag
|
|
||||||
? `grep -Ril "#${state.tag}" ./posts`
|
|
||||||
: 'cut -d: -f2 ./tags.index | sort -u';
|
|
||||||
|
|
||||||
promptApi?.set?.('articles-type-prompt', typeCommand, { typing: false });
|
|
||||||
promptApi?.set?.('articles-category-prompt', categoryCommand, { typing: false });
|
|
||||||
promptApi?.set?.('articles-tag-prompt', tagCommand, { typing: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncActiveFilters(elements, key, emptyValue = '') {
|
|
||||||
elements.forEach((element) => {
|
|
||||||
const value = (element.getAttribute(key) || '').trim();
|
|
||||||
const activeValue =
|
|
||||||
key === 'data-articles-type'
|
|
||||||
? state.type
|
|
||||||
: key === 'data-articles-category'
|
|
||||||
? state.category
|
|
||||||
: state.tag;
|
|
||||||
element.classList.toggle('is-active', value === (activeValue || emptyValue));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSummaryPills() {
|
|
||||||
if (categoryPill && categoryText) {
|
|
||||||
if (state.category) {
|
|
||||||
categoryPill.classList.remove('hidden');
|
|
||||||
categoryText.textContent = state.category;
|
|
||||||
categoryPill.setAttribute('style', categoryAccentMap[String(state.category).toLowerCase()] || '');
|
|
||||||
} else {
|
|
||||||
categoryPill.classList.add('hidden');
|
|
||||||
categoryPill.removeAttribute('style');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tagPill && tagText) {
|
|
||||||
if (state.tag) {
|
|
||||||
tagPill.classList.remove('hidden');
|
|
||||||
tagText.textContent = state.tag;
|
|
||||||
tagPill.setAttribute('style', tagAccentMap[String(state.tag).toLowerCase()] || '');
|
|
||||||
} else {
|
|
||||||
tagPill.classList.add('hidden');
|
|
||||||
tagPill.removeAttribute('style');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUrl(totalPages) {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (state.type && state.type !== 'all') params.set('type', state.type);
|
|
||||||
if (selectedSearch) params.set('search', selectedSearch);
|
|
||||||
if (state.tag) params.set('tag', state.tag);
|
|
||||||
if (state.category) params.set('category', state.category);
|
|
||||||
if (state.page > 1 && totalPages > 1) params.set('page', String(state.page));
|
|
||||||
const nextUrl = params.toString() ? `/articles?${params.toString()}` : '/articles';
|
|
||||||
window.history.replaceState({}, '', nextUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyArticleFilters(pushHistory = true) {
|
|
||||||
const filtered = articleCards.filter((card) => {
|
|
||||||
const type = card.getAttribute('data-article-type') || '';
|
|
||||||
const category = (card.getAttribute('data-article-category') || '').toLowerCase();
|
|
||||||
const tags = `|${(card.getAttribute('data-article-tags') || '').toLowerCase()}|`;
|
|
||||||
const typeMatch = state.type === 'all' || type === state.type;
|
|
||||||
const categoryMatch = !state.category || category === state.category.toLowerCase();
|
|
||||||
const tagMatch = !state.tag || tags.includes(`|${state.tag.toLowerCase()}|`);
|
|
||||||
return typeMatch && categoryMatch && tagMatch;
|
|
||||||
});
|
|
||||||
|
|
||||||
const total = filtered.length;
|
|
||||||
const totalPages = Math.max(Math.ceil(total / postsPerPage), 1);
|
|
||||||
|
|
||||||
if (state.page > totalPages) state.page = totalPages;
|
|
||||||
if (state.page < 1) state.page = 1;
|
|
||||||
|
|
||||||
const startIndex = (state.page - 1) * postsPerPage;
|
|
||||||
const endIndex = startIndex + postsPerPage;
|
|
||||||
|
|
||||||
articleCards.forEach((card) => card.classList.add('hidden'));
|
|
||||||
filtered.slice(startIndex, endIndex).forEach((card) => card.classList.remove('hidden'));
|
|
||||||
|
|
||||||
syncActiveFilters(typeFilters, 'data-articles-type', 'all');
|
|
||||||
syncActiveFilters(categoryFilters, 'data-articles-category', '');
|
|
||||||
syncActiveFilters(tagFilters, 'data-articles-tag', '');
|
|
||||||
updateSummaryPills();
|
|
||||||
updateArticlePrompts();
|
|
||||||
|
|
||||||
if (totalPostsEl) {
|
|
||||||
totalPostsEl.textContent = t('articlesPage.totalPosts', { count: total });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (emptyState) {
|
|
||||||
emptyState.classList.toggle('hidden', total > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pagination) {
|
|
||||||
pagination.classList.toggle('hidden', totalPages <= 1);
|
|
||||||
}
|
|
||||||
if (pageSummary) {
|
|
||||||
pageSummary.textContent = t('articlesPage.pageSummary', { current: state.page, total: totalPages, count: total });
|
|
||||||
}
|
|
||||||
if (prevBtn) {
|
|
||||||
prevBtn.classList.toggle('hidden', state.page <= 1 || totalPages <= 1);
|
|
||||||
}
|
|
||||||
if (nextBtn) {
|
|
||||||
nextBtn.classList.toggle('hidden', state.page >= totalPages || totalPages <= 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pushHistory) {
|
|
||||||
updateUrl(totalPages);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
typeFilters.forEach((filter) => {
|
|
||||||
filter.addEventListener('click', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
state.type = filter.getAttribute('data-articles-type') || 'all';
|
|
||||||
state.page = 1;
|
|
||||||
applyArticleFilters();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
categoryFilters.forEach((filter) => {
|
|
||||||
filter.addEventListener('click', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
state.category = filter.getAttribute('data-articles-category') || '';
|
|
||||||
state.page = 1;
|
|
||||||
applyArticleFilters();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
tagFilters.forEach((filter) => {
|
|
||||||
filter.addEventListener('click', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
state.tag = filter.getAttribute('data-articles-tag') || '';
|
|
||||||
state.page = 1;
|
|
||||||
applyArticleFilters();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
prevBtn?.addEventListener('click', () => {
|
|
||||||
state.page -= 1;
|
|
||||||
applyArticleFilters();
|
|
||||||
});
|
|
||||||
|
|
||||||
nextBtn?.addEventListener('click', () => {
|
|
||||||
state.page += 1;
|
|
||||||
applyArticleFilters();
|
|
||||||
});
|
|
||||||
|
|
||||||
applyArticleFilters(false);
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ export const prerender = false;
|
|||||||
let categories: Awaited<ReturnType<typeof api.getCategories>> = [];
|
let categories: Awaited<ReturnType<typeof api.getCategories>> = [];
|
||||||
let allPosts: Post[] = [];
|
let allPosts: Post[] = [];
|
||||||
const url = new URL(Astro.request.url);
|
const url = new URL(Astro.request.url);
|
||||||
const selectedCategory = url.searchParams.get('category') || '';
|
const selectedCategoryParam = url.searchParams.get('category') || '';
|
||||||
const normalizedSelectedCategory = selectedCategory.trim().toLowerCase();
|
|
||||||
const { t } = getI18n(Astro);
|
const { t } = getI18n(Astro);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -26,6 +25,15 @@ try {
|
|||||||
console.error('Failed to fetch categories:', error);
|
console.error('Failed to fetch categories:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedCategoryRecord = categories.find((category) => {
|
||||||
|
const wanted = selectedCategoryParam.trim().toLowerCase();
|
||||||
|
if (!wanted) return false;
|
||||||
|
return [category.name, category.slug].some(
|
||||||
|
(value) => (value || '').trim().toLowerCase() === wanted
|
||||||
|
);
|
||||||
|
}) || null;
|
||||||
|
const selectedCategory = selectedCategoryRecord?.name || selectedCategoryParam;
|
||||||
|
const normalizedSelectedCategory = selectedCategory.trim().toLowerCase();
|
||||||
const filteredPosts = selectedCategory
|
const filteredPosts = selectedCategory
|
||||||
? allPosts.filter((post) => (post.category || '').trim().toLowerCase() === normalizedSelectedCategory)
|
? allPosts.filter((post) => (post.category || '').trim().toLowerCase() === normalizedSelectedCategory)
|
||||||
: [];
|
: [];
|
||||||
@@ -38,9 +46,15 @@ const resultsPromptCommand = selectedCategory
|
|||||||
const categoryAccentMap = Object.fromEntries(
|
const categoryAccentMap = Object.fromEntries(
|
||||||
categories.map((category) => [category.name.trim().toLowerCase(), getAccentVars(getCategoryTheme(category.name))])
|
categories.map((category) => [category.name.trim().toLowerCase(), getAccentVars(getCategoryTheme(category.name))])
|
||||||
);
|
);
|
||||||
|
const pageTitle = selectedCategoryRecord?.seoTitle || t('categories.pageTitle');
|
||||||
|
const pageDescription = selectedCategoryRecord?.seoDescription || selectedCategoryRecord?.description;
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={`${t('categories.pageTitle')} - Termi`}>
|
<BaseLayout
|
||||||
|
title={`${pageTitle} - Termi`}
|
||||||
|
description={pageDescription}
|
||||||
|
ogImage={selectedCategoryRecord?.coverImage}
|
||||||
|
>
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<TerminalWindow title="~/categories" class="w-full">
|
<TerminalWindow title="~/categories" class="w-full">
|
||||||
<div class="mb-6 px-4">
|
<div class="mb-6 px-4">
|
||||||
@@ -173,6 +187,28 @@ const categoryAccentMap = Object.fromEntries(
|
|||||||
<i class="fas fa-rotate-left text-xs"></i>
|
<i class="fas fa-rotate-left text-xs"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{selectedCategoryRecord && (selectedCategoryRecord.description || selectedCategoryRecord.coverImage) ? (
|
||||||
|
<div class="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_280px]">
|
||||||
|
<div class="space-y-3 text-sm leading-6 text-[var(--text-secondary)]">
|
||||||
|
{selectedCategoryRecord.description ? (
|
||||||
|
<p>{selectedCategoryRecord.description}</p>
|
||||||
|
) : null}
|
||||||
|
{selectedCategoryRecord.accentColor ? (
|
||||||
|
<div class="flex items-center gap-3 text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
|
||||||
|
<span class="inline-flex h-3 w-3 rounded-full border border-[var(--border-color)]" style={`background:${selectedCategoryRecord.accentColor}`}></span>
|
||||||
|
<span>{selectedCategoryRecord.accentColor}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{selectedCategoryRecord.coverImage ? (
|
||||||
|
<img
|
||||||
|
src={selectedCategoryRecord.coverImage}
|
||||||
|
alt={selectedCategoryRecord.name}
|
||||||
|
class="h-full w-full rounded-2xl border border-[var(--border-color)] object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -16,32 +16,59 @@ const selectedQuery = url.searchParams.get('q')?.trim() || '';
|
|||||||
const selectedType = url.searchParams.get('type') || 'all';
|
const selectedType = url.searchParams.get('type') || 'all';
|
||||||
const selectedTag = url.searchParams.get('tag') || '';
|
const selectedTag = url.searchParams.get('tag') || '';
|
||||||
const selectedCategory = url.searchParams.get('category') || '';
|
const selectedCategory = url.searchParams.get('category') || '';
|
||||||
|
const requestedPage = Number.parseInt(url.searchParams.get('page') || '1', 10);
|
||||||
const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||||
const normalizedSelectedCategory = selectedCategory.trim().toLowerCase();
|
const hasActiveFilters = Boolean(selectedType !== 'all' || selectedCategory || selectedTag);
|
||||||
|
const pageSize = 10;
|
||||||
const { t } = getI18n(Astro);
|
const { t } = getI18n(Astro);
|
||||||
|
|
||||||
let searchResults: Post[] = [];
|
let paginatedResults: Post[] = [];
|
||||||
|
let facetResults: Post[] = [];
|
||||||
let apiError: string | null = null;
|
let apiError: string | null = null;
|
||||||
|
let totalResults = 0;
|
||||||
|
let filteredTotal = 0;
|
||||||
|
let totalPages = 1;
|
||||||
|
let currentPage = Number.isFinite(requestedPage) && requestedPage > 0 ? requestedPage : 1;
|
||||||
|
|
||||||
if (selectedQuery) {
|
if (selectedQuery) {
|
||||||
try {
|
try {
|
||||||
searchResults = await api.searchPosts(selectedQuery, 40);
|
const pagePromise = api.searchPostsPage({
|
||||||
|
query: selectedQuery,
|
||||||
|
page: currentPage,
|
||||||
|
pageSize,
|
||||||
|
category: selectedCategory || undefined,
|
||||||
|
tag: selectedTag || undefined,
|
||||||
|
postType: selectedType !== 'all' ? selectedType : undefined,
|
||||||
|
});
|
||||||
|
const facetsPromise = api.searchPosts(selectedQuery, 100);
|
||||||
|
const totalPromise = hasActiveFilters
|
||||||
|
? api.searchPostsPage({
|
||||||
|
query: selectedQuery,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
})
|
||||||
|
: Promise.resolve(null);
|
||||||
|
|
||||||
|
const [pageResult, sampledResults, unfilteredSummary] = await Promise.all([
|
||||||
|
pagePromise,
|
||||||
|
facetsPromise,
|
||||||
|
totalPromise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
paginatedResults = pageResult.items;
|
||||||
|
facetResults = sampledResults;
|
||||||
|
filteredTotal = pageResult.total;
|
||||||
|
totalResults = unfilteredSummary?.total ?? pageResult.total;
|
||||||
|
totalPages = pageResult.totalPages;
|
||||||
|
currentPage = pageResult.page;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
apiError = error instanceof Error ? error.message : t('common.apiUnavailable');
|
apiError = error instanceof Error ? error.message : t('common.apiUnavailable');
|
||||||
console.error('Search page error:', error);
|
console.error('Search page error:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredResults = searchResults.filter((post) => {
|
|
||||||
const normalizedCategory = post.category?.trim().toLowerCase() || '';
|
|
||||||
if (selectedType !== 'all' && post.type !== selectedType) return false;
|
|
||||||
if (selectedCategory && normalizedCategory !== normalizedSelectedCategory) return false;
|
|
||||||
if (selectedTag && !post.tags.some((tag) => tag.trim().toLowerCase() === normalizedSelectedTag)) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const categoryCounts = Array.from(
|
const categoryCounts = Array.from(
|
||||||
searchResults.reduce((map, post) => {
|
facetResults.reduce((map, post) => {
|
||||||
const name = post.category?.trim();
|
const name = post.category?.trim();
|
||||||
if (!name) return map;
|
if (!name) return map;
|
||||||
map.set(name, (map.get(name) ?? 0) + 1);
|
map.set(name, (map.get(name) ?? 0) + 1);
|
||||||
@@ -52,7 +79,7 @@ const categoryCounts = Array.from(
|
|||||||
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
|
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
|
||||||
|
|
||||||
const tagCounts = Array.from(
|
const tagCounts = Array.from(
|
||||||
searchResults.reduce((map, post) => {
|
facetResults.reduce((map, post) => {
|
||||||
for (const tag of post.tags) {
|
for (const tag of post.tags) {
|
||||||
const name = tag.trim();
|
const name = tag.trim();
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
@@ -75,11 +102,13 @@ const buildSearchUrl = ({
|
|||||||
type = selectedType,
|
type = selectedType,
|
||||||
tag = selectedTag,
|
tag = selectedTag,
|
||||||
category = selectedCategory,
|
category = selectedCategory,
|
||||||
|
page,
|
||||||
}: {
|
}: {
|
||||||
q?: string;
|
q?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
|
page?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
@@ -87,6 +116,7 @@ const buildSearchUrl = ({
|
|||||||
if (type && type !== 'all') params.set('type', type);
|
if (type && type !== 'all') params.set('type', type);
|
||||||
if (category) params.set('category', category);
|
if (category) params.set('category', category);
|
||||||
if (tag) params.set('tag', tag);
|
if (tag) params.set('tag', tag);
|
||||||
|
if (page && page > 1) params.set('page', String(page));
|
||||||
|
|
||||||
const queryString = params.toString();
|
const queryString = params.toString();
|
||||||
return queryString ? `/search?${queryString}` : '/search';
|
return queryString ? `/search?${queryString}` : '/search';
|
||||||
@@ -95,7 +125,7 @@ const buildSearchUrl = ({
|
|||||||
const activeQueryCommand = selectedQuery
|
const activeQueryCommand = selectedQuery
|
||||||
? t('searchPage.promptQuery', { query: selectedQuery })
|
? t('searchPage.promptQuery', { query: selectedQuery })
|
||||||
: t('searchPage.promptIdle');
|
: t('searchPage.promptIdle');
|
||||||
const searchTagHrefBase = buildSearchUrl({ tag: '' });
|
const searchTagHrefBase = buildSearchUrl({ tag: '', page: 1 });
|
||||||
const searchTagHrefPrefix = `${searchTagHrefBase}${searchTagHrefBase.includes('?') ? '&' : '?'}tag=`;
|
const searchTagHrefPrefix = `${searchTagHrefBase}${searchTagHrefBase.includes('?') ? '&' : '?'}tag=`;
|
||||||
const pageTitle = selectedQuery
|
const pageTitle = selectedQuery
|
||||||
? `${t('searchPage.pageTitle')} · ${selectedQuery}`
|
? `${t('searchPage.pageTitle')} · ${selectedQuery}`
|
||||||
@@ -117,7 +147,7 @@ const pageTitle = selectedQuery
|
|||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<span class="terminal-stat-pill">
|
<span class="terminal-stat-pill">
|
||||||
<i class="fas fa-magnifying-glass text-[var(--primary)]"></i>
|
<i class="fas fa-magnifying-glass text-[var(--primary)]"></i>
|
||||||
<span>{t('searchPage.resultSummary', { count: searchResults.length })}</span>
|
<span>{t('searchPage.resultSummary', { count: totalResults })}</span>
|
||||||
</span>
|
</span>
|
||||||
{selectedQuery && (
|
{selectedQuery && (
|
||||||
<span class="terminal-stat-pill terminal-stat-pill--accent" style={getAccentVars(getPostTypeTheme('article'))}>
|
<span class="terminal-stat-pill terminal-stat-pill--accent" style={getAccentVars(getPostTypeTheme('article'))}>
|
||||||
@@ -125,10 +155,10 @@ const pageTitle = selectedQuery
|
|||||||
<span>{t('searchPage.queryLabel')}: {selectedQuery}</span>
|
<span>{t('searchPage.queryLabel')}: {selectedQuery}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(selectedType !== 'all' || selectedCategory || selectedTag) && (
|
{hasActiveFilters && (
|
||||||
<span class="terminal-stat-pill">
|
<span class="terminal-stat-pill">
|
||||||
<i class="fas fa-filter text-[var(--primary)]"></i>
|
<i class="fas fa-filter text-[var(--primary)]"></i>
|
||||||
<span>{t('searchPage.filteredSummary', { count: filteredResults.length })}</span>
|
<span>{t('searchPage.filteredSummary', { count: filteredTotal })}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +200,7 @@ const pageTitle = selectedQuery
|
|||||||
<div class="mt-3 flex flex-wrap gap-3">
|
<div class="mt-3 flex flex-wrap gap-3">
|
||||||
{postTypeFilters.map((filter) => (
|
{postTypeFilters.map((filter) => (
|
||||||
<FilterPill
|
<FilterPill
|
||||||
href={buildSearchUrl({ type: filter.id })}
|
href={buildSearchUrl({ type: filter.id, page: 1 })}
|
||||||
tone={filter.id === 'all' ? 'neutral' : 'accent'}
|
tone={filter.id === 'all' ? 'neutral' : 'accent'}
|
||||||
active={selectedType === filter.id}
|
active={selectedType === filter.id}
|
||||||
style={filter.id === 'all' ? undefined : getAccentVars(getPostTypeTheme(filter.id))}
|
style={filter.id === 'all' ? undefined : getAccentVars(getPostTypeTheme(filter.id))}
|
||||||
@@ -185,17 +215,13 @@ const pageTitle = selectedQuery
|
|||||||
{categoryCounts.length > 0 && (
|
{categoryCounts.length > 0 && (
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<div class="mt-3 flex flex-wrap gap-3">
|
<div class="mt-3 flex flex-wrap gap-3">
|
||||||
<FilterPill
|
<FilterPill href={buildSearchUrl({ category: '', page: 1 })} tone="amber" active={!selectedCategory}>
|
||||||
href={buildSearchUrl({ category: '' })}
|
|
||||||
tone="amber"
|
|
||||||
active={!selectedCategory}
|
|
||||||
>
|
|
||||||
<i class="fas fa-folder-tree"></i>
|
<i class="fas fa-folder-tree"></i>
|
||||||
<span class="font-medium">{t('searchPage.allCategories')}</span>
|
<span class="font-medium">{t('searchPage.allCategories')}</span>
|
||||||
</FilterPill>
|
</FilterPill>
|
||||||
{categoryCounts.map((category) => (
|
{categoryCounts.map((category) => (
|
||||||
<FilterPill
|
<FilterPill
|
||||||
href={buildSearchUrl({ category: category.name })}
|
href={buildSearchUrl({ category: category.name, page: 1 })}
|
||||||
tone="accent"
|
tone="accent"
|
||||||
active={selectedCategory.toLowerCase() === category.name.toLowerCase()}
|
active={selectedCategory.toLowerCase() === category.name.toLowerCase()}
|
||||||
style={getAccentVars(getCategoryTheme(category.name))}
|
style={getAccentVars(getCategoryTheme(category.name))}
|
||||||
@@ -212,17 +238,13 @@ const pageTitle = selectedQuery
|
|||||||
{tagCounts.length > 0 && (
|
{tagCounts.length > 0 && (
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<div class="mt-3 flex flex-wrap gap-3">
|
<div class="mt-3 flex flex-wrap gap-3">
|
||||||
<FilterPill
|
<FilterPill href={buildSearchUrl({ tag: '', page: 1 })} tone="teal" active={!selectedTag}>
|
||||||
href={buildSearchUrl({ tag: '' })}
|
|
||||||
tone="teal"
|
|
||||||
active={!selectedTag}
|
|
||||||
>
|
|
||||||
<i class="fas fa-hashtag"></i>
|
<i class="fas fa-hashtag"></i>
|
||||||
<span class="font-medium">{t('searchPage.allTags')}</span>
|
<span class="font-medium">{t('searchPage.allTags')}</span>
|
||||||
</FilterPill>
|
</FilterPill>
|
||||||
{tagCounts.map((tag) => (
|
{tagCounts.map((tag) => (
|
||||||
<FilterPill
|
<FilterPill
|
||||||
href={buildSearchUrl({ tag: tag.name })}
|
href={buildSearchUrl({ tag: tag.name, page: 1 })}
|
||||||
tone="accent"
|
tone="accent"
|
||||||
active={normalizedSelectedTag === tag.name.toLowerCase()}
|
active={normalizedSelectedTag === tag.name.toLowerCase()}
|
||||||
style={getAccentVars(getTagTheme(tag.name))}
|
style={getAccentVars(getTagTheme(tag.name))}
|
||||||
@@ -238,9 +260,9 @@ const pageTitle = selectedQuery
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-4 pb-8">
|
<div class="px-4 pb-8">
|
||||||
{filteredResults.length > 0 ? (
|
{filteredTotal > 0 ? (
|
||||||
<div class="ml-4 mt-4 space-y-4">
|
<div class="ml-4 mt-4 space-y-4">
|
||||||
{filteredResults.map((post) => (
|
{paginatedResults.map((post) => (
|
||||||
<PostCard
|
<PostCard
|
||||||
post={post}
|
post={post}
|
||||||
selectedTag={selectedTag}
|
selectedTag={selectedTag}
|
||||||
@@ -260,7 +282,10 @@ const pageTitle = selectedQuery
|
|||||||
{t('searchPage.emptyDescription')}
|
{t('searchPage.emptyDescription')}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-wrap justify-center gap-3">
|
<div class="flex flex-wrap justify-center gap-3">
|
||||||
<a href={buildSearchUrl({ type: 'all', category: '', tag: '' })} class="terminal-action-button terminal-action-button-primary">
|
<a
|
||||||
|
href={buildSearchUrl({ type: 'all', category: '', tag: '', page: 1 })}
|
||||||
|
class="terminal-action-button terminal-action-button-primary"
|
||||||
|
>
|
||||||
<i class="fas fa-rotate-left"></i>
|
<i class="fas fa-rotate-left"></i>
|
||||||
<span>{t('common.resetFilters')}</span>
|
<span>{t('common.resetFilters')}</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -273,6 +298,33 @@ const pageTitle = selectedQuery
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="px-4 py-6">
|
||||||
|
<div
|
||||||
|
class:list={[
|
||||||
|
'terminal-panel-muted ml-4 mt-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between',
|
||||||
|
totalPages <= 1 && 'hidden',
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<span class="text-sm text-[var(--text-secondary)]">
|
||||||
|
{t('searchPage.pageSummary', { current: currentPage, total: totalPages, count: filteredTotal })}
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{currentPage > 1 && (
|
||||||
|
<a href={buildSearchUrl({ page: currentPage - 1 })} class="terminal-action-button">
|
||||||
|
<i class="fas fa-chevron-left"></i>
|
||||||
|
<span>{t('searchPage.previous')}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{currentPage < totalPages && (
|
||||||
|
<a href={buildSearchUrl({ page: currentPage + 1 })} class="terminal-action-button terminal-action-button-primary">
|
||||||
|
<span>{t('searchPage.next')}</span>
|
||||||
|
<i class="fas fa-chevron-right"></i>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TerminalWindow>
|
</TerminalWindow>
|
||||||
|
|||||||
@@ -26,7 +26,13 @@ try {
|
|||||||
|
|
||||||
// Get URL params
|
// Get URL params
|
||||||
const url = new URL(Astro.request.url);
|
const url = new URL(Astro.request.url);
|
||||||
const selectedTag = url.searchParams.get('tag') || '';
|
const selectedTagParam = url.searchParams.get('tag') || '';
|
||||||
|
const selectedTagRecord = tags.find((tag) => {
|
||||||
|
const wanted = selectedTagParam.trim().toLowerCase();
|
||||||
|
if (!wanted) return false;
|
||||||
|
return [tag.name, tag.slug].some((value) => (value || '').trim().toLowerCase() === wanted);
|
||||||
|
}) || null;
|
||||||
|
const selectedTag = selectedTagRecord?.name || selectedTagParam;
|
||||||
const selectedTagToken = selectedTag.trim().toLowerCase();
|
const selectedTagToken = selectedTag.trim().toLowerCase();
|
||||||
const selectedTagTheme = getTagTheme(selectedTag);
|
const selectedTagTheme = getTagTheme(selectedTag);
|
||||||
const isSelectedTag = (tag: Tag) =>
|
const isSelectedTag = (tag: Tag) =>
|
||||||
@@ -38,9 +44,15 @@ const filteredPosts = selectedTag
|
|||||||
const tagAccentMap = Object.fromEntries(
|
const tagAccentMap = Object.fromEntries(
|
||||||
tags.map((tag) => [String(tag.slug || tag.name).toLowerCase(), getAccentVars(getTagTheme(tag.name))])
|
tags.map((tag) => [String(tag.slug || tag.name).toLowerCase(), getAccentVars(getTagTheme(tag.name))])
|
||||||
);
|
);
|
||||||
|
const pageTitle = selectedTagRecord?.seoTitle || t('tags.pageTitle');
|
||||||
|
const pageDescription = selectedTagRecord?.seoDescription || selectedTagRecord?.description;
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={`${t('tags.pageTitle')} - Termi`}>
|
<BaseLayout
|
||||||
|
title={`${pageTitle} - Termi`}
|
||||||
|
description={pageDescription}
|
||||||
|
ogImage={selectedTagRecord?.coverImage}
|
||||||
|
>
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<TerminalWindow title="~/tags" class="w-full">
|
<TerminalWindow title="~/tags" class="w-full">
|
||||||
<div class="mb-6 px-4">
|
<div class="mb-6 px-4">
|
||||||
@@ -87,6 +99,26 @@ const tagAccentMap = Object.fromEntries(
|
|||||||
<span>{t('common.clearFilters')}</span>
|
<span>{t('common.clearFilters')}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{selectedTagRecord && (selectedTagRecord.description || selectedTagRecord.coverImage) ? (
|
||||||
|
<div class="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_280px]">
|
||||||
|
<div class="space-y-3 text-sm leading-6 text-[var(--text-secondary)]">
|
||||||
|
{selectedTagRecord.description ? <p>{selectedTagRecord.description}</p> : null}
|
||||||
|
{selectedTagRecord.accentColor ? (
|
||||||
|
<div class="flex items-center gap-3 text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
|
||||||
|
<span class="inline-flex h-3 w-3 rounded-full border border-[var(--border-color)]" style={`background:${selectedTagRecord.accentColor}`}></span>
|
||||||
|
<span>{selectedTagRecord.accentColor}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{selectedTagRecord.coverImage ? (
|
||||||
|
<img
|
||||||
|
src={selectedTagRecord.coverImage}
|
||||||
|
alt={selectedTagRecord.name}
|
||||||
|
class="h-full w-full rounded-2xl border border-[var(--border-color)] object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user