diff --git a/apps/admin/package.json b/apps/admin/package.json index b132536169c..6fdcc9f564c 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -75,6 +75,7 @@ "es-toolkit": "1.45.1", "event-source-polyfill": "1.0.31", "fuse.js": "7.1.0", + "immer": "^11.1.8", "jotai": "2.20.0", "js-cookie": "3.0.5", "js-yaml": "4.1.1", diff --git a/apps/admin/src/data/post-category-resource/hooks.ts b/apps/admin/src/data/post-category-resource/hooks.ts new file mode 100644 index 00000000000..cf1f039ca50 --- /dev/null +++ b/apps/admin/src/data/post-category-resource/hooks.ts @@ -0,0 +1,40 @@ +import { shallow } from 'zustand/shallow' + +import { + selectPostCategory, + selectPostCategories, + selectPostList, + selectVisiblePost, + serializeResourceListKey, + usePostCategoryResourceStore, +} from './store' + +export function usePostResourceList(queryKey: readonly unknown[]) { + const listKey = serializeResourceListKey(queryKey) + return usePostCategoryResourceStore( + (state) => selectPostList(state, listKey), + shallow, + ) +} + +export function usePostResourceCategories() { + return usePostCategoryResourceStore(selectPostCategories, shallow) +} + +export function usePostResourceCategoryIds() { + return usePostCategoryResourceStore((state) => state.categoryIds, shallow) +} + +export function usePostResourceCategory(categoryId: string) { + return usePostCategoryResourceStore( + (state) => (categoryId ? selectPostCategory(state, categoryId) : undefined), + shallow, + ) +} + +export function usePostResourcePost(postId: string) { + return usePostCategoryResourceStore( + (state) => (postId ? selectVisiblePost(state, postId) : undefined), + shallow, + ) +} diff --git a/apps/admin/src/data/post-category-resource/queries.ts b/apps/admin/src/data/post-category-resource/queries.ts new file mode 100644 index 00000000000..fe03e928a05 --- /dev/null +++ b/apps/admin/src/data/post-category-resource/queries.ts @@ -0,0 +1,96 @@ +import { useQuery } from '@tanstack/react-query' +import type { QueryKey } from '@tanstack/react-query' + +import type { PaginateResult } from '~/models/base' +import type { PostModel } from '~/models/post' + +import { + serializeResourceListKey, + usePostCategoryResourceStore, +} from './store' +import type { ResourceCategory } from './store' + +interface ResourceQueryOptions { + enabled?: boolean + queryFn: () => Promise + queryKey: QueryKey +} + +interface PostListResourceQueryOptions + extends ResourceQueryOptions { + toPaginatedResult?: (result: TResult) => PaginateResult +} + +export interface ResourceQueryReceipt { + hydratedAt: number +} + +export function usePostCategoriesResourceQuery( + options: ResourceQueryOptions, +) { + return useQuery({ + enabled: options.enabled, + queryFn: async () => { + const categories = await options.queryFn() + usePostCategoryResourceStore.getState().hydrateCategories(categories) + return createReceipt() + }, + queryKey: options.queryKey, + }) +} + +export function usePostCategoryResourceQuery( + options: ResourceQueryOptions, +) { + return useQuery({ + enabled: options.enabled, + queryFn: async () => { + const category = await options.queryFn() + usePostCategoryResourceStore.getState().hydrateCategory(category) + return createReceipt() + }, + queryKey: options.queryKey, + }) +} + +export function usePostDetailResourceQuery( + options: ResourceQueryOptions, +) { + return useQuery({ + enabled: options.enabled, + queryFn: async () => { + const post = await options.queryFn() + usePostCategoryResourceStore.getState().hydratePostDetail(post) + return createReceipt() + }, + queryKey: options.queryKey, + }) +} + +export function usePostListResourceQuery>( + options: PostListResourceQueryOptions, +) { + return useQuery({ + enabled: options.enabled, + queryFn: async () => { + const result = await options.queryFn() + const paginatedResult = options.toPaginatedResult + ? options.toPaginatedResult(result) + : (result as PaginateResult) + + usePostCategoryResourceStore + .getState() + .hydratePostList( + serializeResourceListKey(options.queryKey), + paginatedResult, + ) + + return createReceipt() + }, + queryKey: options.queryKey, + }) +} + +function createReceipt(): ResourceQueryReceipt { + return { hydratedAt: Date.now() } +} diff --git a/apps/admin/src/data/post-category-resource/store.test.ts b/apps/admin/src/data/post-category-resource/store.test.ts new file mode 100644 index 00000000000..bd3d395e600 --- /dev/null +++ b/apps/admin/src/data/post-category-resource/store.test.ts @@ -0,0 +1,262 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { PaginateResult } from '~/models/base' +import { CategoryType } from '~/models/category' +import type { CategoryModel } from '~/models/category' +import type { Category, PostModel } from '~/models/post' + +import { + resetPostCategoryResourceStoreForTest, + selectPostList, + selectVisiblePost, + serializeResourceListKey, + usePostCategoryResourceStore, +} from './store' +import { PostCategoryResourceTransaction } from './transaction' + +const listKey = serializeResourceListKey(['posts', 'list', { page: 1 }]) + +describe('post category resource store', () => { + beforeEach(() => { + resetPostCategoryResourceStoreForTest() + }) + + it('normalizes embedded post categories and projects list rows from the store', () => { + const store = usePostCategoryResourceStore.getState() + + store.hydratePostList(listKey, paginated([post({ id: 'p1' })])) + + const state = usePostCategoryResourceStore.getState() + const list = selectPostList(state, listKey) + + expect(state.categoriesById.c1?.name).toBe('Tech') + expect(state.postIdsByCategoryId.c1).toEqual(['p1']) + expect(list.posts).toHaveLength(1) + expect(list.posts[0].category.name).toBe('Tech') + }) + + it('keeps pending category changes visible when a stale query result arrives', () => { + const store = usePostCategoryResourceStore.getState() + store.hydrateCategories([ + categoryModel({ id: 'c1' }), + categoryModel({ id: 'c2', name: 'Life' }), + ]) + store.hydratePostList(listKey, paginated([post({ id: 'p1' })])) + + const transactionId = store.beginPostPatchTransaction('p1', { + categoryId: 'c2', + }) + + store.hydratePostList(listKey, paginated([post({ id: 'p1' })])) + + let state = usePostCategoryResourceStore.getState() + expect(selectVisiblePost(state, 'p1')?.categoryId).toBe('c2') + expect(selectVisiblePost(state, 'p1')?.category.name).toBe('Life') + expect(state.postIdsByCategoryId.c2).toEqual(['p1']) + + store.commitPostTransaction( + transactionId, + post({ + category: category({ id: 'c2', name: 'Life' }), + categoryId: 'c2', + id: 'p1', + }), + ) + + state = usePostCategoryResourceStore.getState() + expect(selectVisiblePost(state, 'p1')?.categoryId).toBe('c2') + expect(state.pendingTransactionIdsByPostId.p1).toBeUndefined() + }) + + it('removes only the failed transaction and replays later pending writes', () => { + const store = usePostCategoryResourceStore.getState() + store.hydratePostList(listKey, paginated([post({ id: 'p1', title: 'A' })])) + + const firstTransactionId = store.beginPostPatchTransaction('p1', { + title: 'B', + }) + store.beginPostPatchTransaction('p1', { + title: 'C', + }) + + store.rollbackPostTransaction(firstTransactionId, new Error('failed')) + + const state = usePostCategoryResourceStore.getState() + expect(selectVisiblePost(state, 'p1')?.title).toBe('C') + expect(state.errorsByPostId.p1).toBeInstanceOf(Error) + }) + + it('can roll back an optimistic delete', () => { + const store = usePostCategoryResourceStore.getState() + store.hydratePostList(listKey, paginated([post({ id: 'p1' })])) + + const transactionId = store.beginPostDeleteTransaction('p1') + + expect( + selectPostList(usePostCategoryResourceStore.getState(), listKey).posts, + ).toHaveLength(0) + + store.rollbackPostTransaction(transactionId) + + expect( + selectPostList(usePostCategoryResourceStore.getState(), listKey).posts, + ).toHaveLength(1) + }) + + it('hydrates detail rows into the shared post table', () => { + const store = usePostCategoryResourceStore.getState() + + store.hydratePostDetail(post({ id: 'p1', title: 'Detail title' })) + + const state = usePostCategoryResourceStore.getState() + expect(selectVisiblePost(state, 'p1')?.title).toBe('Detail title') + expect(state.postIdsByCategoryId.c1).toEqual(['p1']) + }) + + it('updates visible post category data when category management saves', () => { + const store = usePostCategoryResourceStore.getState() + store.hydratePostDetail(post({ id: 'p1' })) + + store.hydrateCategory( + categoryModel({ id: 'c1', name: 'Engineering', slug: 'engineering' }), + ) + + const visiblePost = selectVisiblePost( + usePostCategoryResourceStore.getState(), + 'p1', + ) + expect(visiblePost?.category.name).toBe('Engineering') + expect(visiblePost?.category.slug).toBe('engineering') + }) + + it('removes category management rows without deleting posts', () => { + const store = usePostCategoryResourceStore.getState() + store.hydrateCategories([categoryModel({ id: 'c1' })]) + store.hydratePostDetail(post({ id: 'p1' })) + + store.removeCategory('c1') + + const state = usePostCategoryResourceStore.getState() + expect(state.categoryIds).toEqual([]) + expect(selectVisiblePost(state, 'p1')?.id).toBe('p1') + }) + + it('supports class-based transaction commit and rollback', () => { + const store = usePostCategoryResourceStore.getState() + store.hydratePostList(listKey, paginated([post({ id: 'p1', title: 'A' })])) + + new PostCategoryResourceTransaction() + .patchPost('p1', { title: 'B' }) + .rollback() + + expect( + selectVisiblePost(usePostCategoryResourceStore.getState(), 'p1')?.title, + ).toBe('A') + + new PostCategoryResourceTransaction() + .patchPost('p1', { title: 'B' }) + .commitPost('p1', post({ id: 'p1', title: 'B' })) + + expect( + selectVisiblePost(usePostCategoryResourceStore.getState(), 'p1')?.title, + ).toBe('B') + }) + + it('runs request-backed transaction lifecycle', async () => { + const store = usePostCategoryResourceStore.getState() + store.hydratePostList(listKey, paginated([post({ id: 'p1', title: 'A' })])) + + const successTx = new PostCategoryResourceTransaction( + 'renamePost', + ) + .patchPost('p1', { title: 'B' }) + successTx.request = async () => post({ id: 'p1', title: 'B' }) + successTx.onSuccess = (serverPost: PostModel) => { + successTx.commitPost(serverPost.id, serverPost) + } + + await expect(successTx.commit()).resolves.toMatchObject({ + title: 'B', + }) + expect( + selectVisiblePost(usePostCategoryResourceStore.getState(), 'p1')?.title, + ).toBe('B') + + const onError = vi.fn() + const failedTx = new PostCategoryResourceTransaction( + 'failedRename', + ) + .patchPost('p1', { title: 'C' }) + failedTx.request = async () => { + throw new Error('failed') + } + failedTx.onError = onError + + await expect(failedTx.commit()).rejects.toThrow('failed') + expect( + selectVisiblePost(usePostCategoryResourceStore.getState(), 'p1')?.title, + ).toBe('B') + expect(onError).toHaveBeenCalledTimes(1) + }) +}) + +function paginated(data: PostModel[]): PaginateResult { + return { + data, + pagination: { + page: 1, + size: 10, + total: data.length, + totalPages: 1, + }, + } +} + +function post(overrides: Partial = {}): PostModel { + const baseCategory = overrides.category ?? category() + + return { + category: baseCategory, + categoryId: baseCategory.id, + contentFormat: 'markdown', + copyright: false, + createdAt: '2026-05-30T00:00:00.000Z', + id: 'p1', + images: [], + isPublished: false, + likeCount: 0, + modifiedAt: null, + pinAt: null, + pinOrder: null, + readCount: 0, + slug: 'post-1', + tags: [], + text: 'Post text', + title: 'Post title', + ...overrides, + } +} + +function category(overrides: Partial = {}): Category { + return { + id: 'c1', + name: 'Tech', + slug: 'tech', + type: CategoryType.Category, + ...overrides, + } +} + +function categoryModel(overrides: Partial = {}): CategoryModel { + return { + count: 0, + createdAt: '2026-05-30T00:00:00.000Z', + id: 'c1', + name: 'Tech', + slug: 'tech', + type: CategoryType.Category, + ...overrides, + } +} diff --git a/apps/admin/src/data/post-category-resource/store.ts b/apps/admin/src/data/post-category-resource/store.ts new file mode 100644 index 00000000000..e5caf6f17c1 --- /dev/null +++ b/apps/admin/src/data/post-category-resource/store.ts @@ -0,0 +1,345 @@ +import { produce } from 'immer' +import { shallow } from 'zustand/shallow' +import { createWithEqualityFn } from 'zustand/traditional' + +import type { PaginateResult, Pager } from '~/models/base' +import type { CategoryModel } from '~/models/category' +import type { Category, PostModel } from '~/models/post' + +export type ResourceCategory = Category | CategoryModel + +interface PostListIndex { + ids: string[] + pagination?: Pager + updatedAt: number +} + +interface PostTransaction { + id: string + kind: 'delete' | 'patch' + patch?: Partial + postId: string + startedAt: number +} + +export interface PostCategoryResourceState { + categoriesById: Record + categoryIds: string[] + errorsByPostId: Record + listIndexes: Record + pendingTransactionIdsByPostId: Record + postIdsByCategoryId: Record + postsById: Record + transactionsById: Record +} + +interface PostCategoryResourceActions { + beginPostDeleteTransaction: (postId: string) => string + beginPostPatchTransaction: ( + postId: string, + patch: Partial, + ) => string + commitPostTransaction: ( + transactionId: string, + serverPost?: PostModel, + ) => void + hydrateCategories: (categories: ResourceCategory[]) => void + hydrateCategory: (category: ResourceCategory) => void + hydratePostDetail: (post: PostModel) => void + hydratePostList: ( + listKey: string, + result: PaginateResult, + ) => void + removeCategory: (categoryId: string) => void + reset: () => void + rollbackPostTransaction: (transactionId: string, error?: unknown) => void +} + +export type PostCategoryResourceStore = PostCategoryResourceState & + PostCategoryResourceActions + +const initialState: PostCategoryResourceState = { + categoriesById: {}, + categoryIds: [], + errorsByPostId: {}, + listIndexes: {}, + pendingTransactionIdsByPostId: {}, + postIdsByCategoryId: {}, + postsById: {}, + transactionsById: {}, +} + +export const usePostCategoryResourceStore = + createWithEqualityFn()( + (set) => ({ + ...initialState, + beginPostDeleteTransaction: (postId) => { + const transactionId = createTransactionId() + set( + produce((draft) => { + addPostTransaction(draft, { + id: transactionId, + kind: 'delete', + postId, + startedAt: Date.now(), + }) + delete draft.errorsByPostId[postId] + rebuildPostCategoryRelations(draft) + }), + ) + return transactionId + }, + beginPostPatchTransaction: (postId, patch) => { + const transactionId = createTransactionId() + set( + produce((draft) => { + addPostTransaction(draft, { + id: transactionId, + kind: 'patch', + patch, + postId, + startedAt: Date.now(), + }) + delete draft.errorsByPostId[postId] + rebuildPostCategoryRelations(draft) + }), + ) + return transactionId + }, + commitPostTransaction: (transactionId, serverPost) => { + set( + produce((draft) => { + const transaction = draft.transactionsById[transactionId] + if (!transaction) return + + removePostTransaction(draft, transactionId) + + if (transaction.kind === 'delete') { + delete draft.postsById[transaction.postId] + delete draft.errorsByPostId[transaction.postId] + } else if (serverPost) { + upsertPost(draft, serverPost) + delete draft.errorsByPostId[serverPost.id] + } + + rebuildPostCategoryRelations(draft) + }), + ) + }, + hydrateCategories: (categories) => { + set( + produce((draft) => { + draft.categoryIds = categories.map((category) => category.id) + for (const category of categories) { + upsertCategory(draft, category) + } + rebuildPostCategoryRelations(draft) + }), + ) + }, + hydrateCategory: (category) => { + set( + produce((draft) => { + upsertCategory(draft, category) + rebuildPostCategoryRelations(draft) + }), + ) + }, + hydratePostDetail: (post) => { + set( + produce((draft) => { + upsertPost(draft, post) + rebuildPostCategoryRelations(draft) + }), + ) + }, + hydratePostList: (listKey, result) => { + set( + produce((draft) => { + for (const post of result.data) { + upsertPost(draft, post) + } + draft.listIndexes[listKey] = { + ids: result.data.map((post) => post.id), + pagination: result.pagination, + updatedAt: Date.now(), + } + rebuildPostCategoryRelations(draft) + }), + ) + }, + removeCategory: (categoryId) => { + set( + produce((draft) => { + delete draft.categoriesById[categoryId] + draft.categoryIds = draft.categoryIds.filter( + (id) => id !== categoryId, + ) + rebuildPostCategoryRelations(draft) + }), + ) + }, + reset: () => set({ ...initialState }), + rollbackPostTransaction: (transactionId, error) => { + set( + produce((draft) => { + const transaction = draft.transactionsById[transactionId] + if (!transaction) return + + removePostTransaction(draft, transactionId) + if (error) draft.errorsByPostId[transaction.postId] = error + rebuildPostCategoryRelations(draft) + }), + ) + }, + }), + shallow, + ) + +export function serializeResourceListKey(queryKey: readonly unknown[]) { + return JSON.stringify(queryKey) +} + +export function selectPostList( + state: PostCategoryResourceState, + listKey: string, +) { + const index = state.listIndexes[listKey] + if (!index) { + return { + pagination: undefined, + posts: [] as PostModel[], + updatedAt: 0, + } + } + + return { + pagination: index.pagination, + posts: index.ids + .map((id) => selectVisiblePost(state, id)) + .filter((post): post is PostModel => Boolean(post)), + updatedAt: index.updatedAt, + } +} + +export function selectPostCategories(state: PostCategoryResourceState) { + return state.categoryIds + .map((id) => state.categoriesById[id]) + .filter((category): category is ResourceCategory => Boolean(category)) +} + +export function selectPostCategory( + state: PostCategoryResourceState, + categoryId: string, +) { + return state.categoriesById[categoryId] +} + +export function selectVisiblePost( + state: PostCategoryResourceState, + postId: string, +) { + const base = state.postsById[postId] + if (!base) return + + let visible: PostModel | undefined = base + const transactionIds = state.pendingTransactionIdsByPostId[postId] ?? [] + + for (const transactionId of transactionIds) { + const transaction = state.transactionsById[transactionId] + if (!transaction) continue + if (transaction.kind === 'delete') { + visible = undefined + break + } + visible = { + ...visible, + ...transaction.patch, + } + } + + if (!visible) return + return attachCategory(state, visible) +} + +export function resetPostCategoryResourceStoreForTest() { + usePostCategoryResourceStore.getState().reset() +} + +function addPostTransaction( + draft: PostCategoryResourceStore, + transaction: PostTransaction, +) { + draft.transactionsById[transaction.id] = transaction + const ids = draft.pendingTransactionIdsByPostId[transaction.postId] ?? [] + draft.pendingTransactionIdsByPostId[transaction.postId] = [ + ...ids, + transaction.id, + ] +} + +function removePostTransaction( + draft: PostCategoryResourceStore, + transactionId: string, +) { + const transaction = draft.transactionsById[transactionId] + if (!transaction) return + + delete draft.transactionsById[transactionId] + const nextIds = ( + draft.pendingTransactionIdsByPostId[transaction.postId] ?? [] + ).filter((id) => id !== transactionId) + + if (nextIds.length === 0) { + delete draft.pendingTransactionIdsByPostId[transaction.postId] + } else { + draft.pendingTransactionIdsByPostId[transaction.postId] = nextIds + } +} + +function upsertPost(draft: PostCategoryResourceStore, post: PostModel) { + if (post.category) { + upsertCategory(draft, post.category) + } + draft.postsById[post.id] = post +} + +function upsertCategory( + draft: PostCategoryResourceStore, + category: ResourceCategory, +) { + draft.categoriesById[category.id] = { + ...draft.categoriesById[category.id], + ...category, + } + if (!draft.categoryIds.includes(category.id)) { + draft.categoryIds.push(category.id) + } +} + +function rebuildPostCategoryRelations(draft: PostCategoryResourceStore) { + const next: Record = {} + + for (const postId of Object.keys(draft.postsById)) { + const post = selectVisiblePost(draft, postId) + if (!post?.categoryId) continue + next[post.categoryId] = [...(next[post.categoryId] ?? []), postId] + } + + draft.postIdsByCategoryId = next +} + +function attachCategory( + state: PostCategoryResourceState, + post: PostModel, +): PostModel { + const category = state.categoriesById[post.categoryId] ?? post.category + return { + ...post, + category: category as Category, + } +} + +function createTransactionId() { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` +} diff --git a/apps/admin/src/data/post-category-resource/transaction.ts b/apps/admin/src/data/post-category-resource/transaction.ts new file mode 100644 index 00000000000..b6f7de8ef8c --- /dev/null +++ b/apps/admin/src/data/post-category-resource/transaction.ts @@ -0,0 +1,144 @@ +import type { PostModel } from '~/models/post' + +import { usePostCategoryResourceStore } from './store' + +type RemoteRequest = () => Promise +type TransactionFailureHandler = (error: unknown) => Promise | void +type TransactionSuccessHandler = (result: T) => Promise | void + +interface PostTransactionOperation { + postId: string + transactionId: string +} + +export class PostCategoryResourceTransaction { + private committed = false + private failureHandler?: TransactionFailureHandler + private readonly name: string + private readonly operations: PostTransactionOperation[] = [] + private requestFn?: RemoteRequest + private successHandler?: TransactionSuccessHandler + + constructor(name = 'postCategoryResourceTransaction') { + this.name = name + } + + deletePost(postId: string) { + this.assertMutable() + const transactionId = usePostCategoryResourceStore + .getState() + .beginPostDeleteTransaction(postId) + + this.operations.push({ postId, transactionId }) + return this + } + + patchPost(postId: string, patch: Partial) { + this.assertMutable() + const transactionId = usePostCategoryResourceStore + .getState() + .beginPostPatchTransaction(postId, patch) + + this.operations.push({ postId, transactionId }) + return this + } + + set onError(handler: TransactionFailureHandler) { + this.failureHandler = handler + } + + set onSuccess(handler: TransactionSuccessHandler) { + this.successHandler = handler + } + + set request(request: RemoteRequest) { + this.requestFn = request + } + + async commit() { + if (this.committed) { + throw new Error(`[PostCategoryResourceTransaction] "${this.name}" already committed`) + } + if (!this.requestFn) { + throw new Error(`[PostCategoryResourceTransaction] "${this.name}" missing request`) + } + + this.committed = true + + let result: T + try { + result = await this.requestFn() + } catch (error) { + this.rollback(error) + await this.failureHandler?.(error) + throw error + } + + await this.successHandler?.(result) + return result + } + + commitAll() { + for (const operation of this.operations) { + this.commitOperation(operation) + } + } + + commitPost(postId: string, serverPost?: PostModel) { + for (const operation of this.operationsForPost(postId)) { + this.commitOperation(operation, serverPost) + } + } + + commitPosts(postIds: Iterable) { + for (const postId of postIds) { + this.commitPost(postId) + } + } + + rollback(error?: unknown) { + for (const operation of this.operations) { + this.rollbackOperation(operation, error) + } + } + + rollbackPost(postId: string, error?: unknown) { + for (const operation of this.operationsForPost(postId)) { + this.rollbackOperation(operation, error) + } + } + + rollbackPosts(postIds: Iterable, error?: unknown) { + for (const postId of postIds) { + this.rollbackPost(postId, error) + } + } + + private commitOperation( + operation: PostTransactionOperation, + serverPost?: PostModel, + ) { + usePostCategoryResourceStore + .getState() + .commitPostTransaction(operation.transactionId, serverPost) + } + + private operationsForPost(postId: string) { + return this.operations.filter((operation) => operation.postId === postId) + } + + private rollbackOperation( + operation: PostTransactionOperation, + error?: unknown, + ) { + usePostCategoryResourceStore + .getState() + .rollbackPostTransaction(operation.transactionId, error) + } + + private assertMutable() { + if (this.committed) { + throw new Error(`[PostCategoryResourceTransaction] "${this.name}" is already committed`) + } + } +} diff --git a/apps/admin/src/features/categories/components/CategoriesRouteViewContent.tsx b/apps/admin/src/features/categories/components/CategoriesRouteViewContent.tsx index 464195c2bdb..84f2fac8336 100644 --- a/apps/admin/src/features/categories/components/CategoriesRouteViewContent.tsx +++ b/apps/admin/src/features/categories/components/CategoriesRouteViewContent.tsx @@ -3,6 +3,7 @@ import { useCallback, useMemo } from 'react' import { useNavigate, useParams } from 'react-router' import { APP_SHELL_HEADER_HEIGHT_CLASS } from '~/constants/layout' +import { usePostResourceCategoryIds } from '~/data/post-category-resource/hooks' import { useI18n } from '~/i18n' import type { CategoryModel } from '~/models/category' import { MasterDetailShell } from '~/ui/layout/master-detail-shell' @@ -42,7 +43,8 @@ export function CategoriesRouteViewContent() { const params = useParams<{ id?: string }>() const selectedItem = decodeTarget(params.id) - const { categories, categoriesQuery, tags, tagsQuery } = useCategoriesList() + const { categoriesQuery, tags, tagsQuery } = useCategoriesList() + const categoryIds = usePostResourceCategoryIds() const closeDetail = useCallback(() => { navigate('/posts/category') @@ -114,7 +116,7 @@ export function CategoriesRouteViewContent() { - {categories.length} / {tags.length} + {categoryIds.length} / {tags.length} ) diff --git a/apps/admin/src/features/categories/components/PostListRow.tsx b/apps/admin/src/features/categories/components/PostListRow.tsx index 20ba941f38e..9eb141d647b 100644 --- a/apps/admin/src/features/categories/components/PostListRow.tsx +++ b/apps/admin/src/features/categories/components/PostListRow.tsx @@ -1,32 +1,36 @@ import { ExternalLink } from 'lucide-react' import { Link } from 'react-router' -import type { PostModel } from '~/models/post' import { WEB_URL } from '~/constants/env' +import { usePostResourcePost } from '~/data/post-category-resource/hooks' import { useI18n } from '~/i18n' import { relativeTimeFromNow } from '~/utils/time' -export function PostListRow(props: { post: PostModel }) { +export function PostListRow(props: { postId: string }) { const { t } = useI18n() - const externalHref = `${WEB_URL}/posts/${props.post.category?.slug ?? props.post.categoryId}/${props.post.slug}` + const post = usePostResourcePost(props.postId) + + if (!post) return null + + const externalHref = `${WEB_URL}/posts/${post.category?.slug ?? post.categoryId}/${post.slug}` return (

- {props.post.title || t('categories.postRow.unnamed')} + {post.title || t('categories.postRow.unnamed')}

-
diff --git a/apps/admin/src/features/categories/components/PostListSection.tsx b/apps/admin/src/features/categories/components/PostListSection.tsx index 193d76dd2b7..ec248d19b68 100644 --- a/apps/admin/src/features/categories/components/PostListSection.tsx +++ b/apps/admin/src/features/categories/components/PostListSection.tsx @@ -1,5 +1,4 @@ -import type { PostModel } from '~/models/post' - +import { usePostResourceList } from '~/data/post-category-resource/hooks' import { useI18n } from '~/i18n' import { PostListRow } from './PostListRow' @@ -7,19 +6,22 @@ import { PostListRow } from './PostListRow' export function PostListSection(props: { emptyText: string loading: boolean - posts: PostModel[] + queryKey: readonly unknown[] title: string }) { const { t } = useI18n() + const postListResource = usePostResourceList(props.queryKey) + const postIds = postListResource.posts.map((post) => post.id) + return (

{props.title}

- {!props.loading && props.posts.length > 0 ? ( + {!props.loading && postIds.length > 0 ? ( - {t('categories.section.postsCount', { count: props.posts.length })} + {t('categories.section.postsCount', { count: postIds.length })} ) : null}
@@ -32,14 +34,14 @@ export function PostListSection(props: { /> ))}
- ) : props.posts.length === 0 ? ( + ) : postIds.length === 0 ? (

{props.emptyText}

) : (
- {props.posts.map((post) => ( - + {postIds.map((postId) => ( + ))}
)} diff --git a/apps/admin/src/features/categories/components/TagDetail.tsx b/apps/admin/src/features/categories/components/TagDetail.tsx index 04ad77c7b56..0a5636346a8 100644 --- a/apps/admin/src/features/categories/components/TagDetail.tsx +++ b/apps/admin/src/features/categories/components/TagDetail.tsx @@ -1,8 +1,9 @@ -import { useQuery } from '@tanstack/react-query' import { Tag } from 'lucide-react' +import { useMemo } from 'react' import type { TagModel } from '~/models/category' import { getPostsByTag } from '~/api/categories' +import { usePostListResourceQuery } from '~/data/post-category-resource/queries' import { useI18n } from '~/i18n' import { adminQueryKeys } from '~/query/keys' import { Scroll } from '~/ui/primitives/scroll' @@ -13,10 +14,23 @@ import { PostListSection } from './PostListSection' export function TagDetail(props: { onBack: () => void; tag: TagModel }) { const { t } = useI18n() - const postsQuery = useQuery({ + const postsQueryKey = useMemo( + () => adminQueryKeys.posts.tagDetail(props.tag.name), + [props.tag.name], + ) + const postsQuery = usePostListResourceQuery({ enabled: !!props.tag.name, queryFn: () => getPostsByTag(props.tag.name), - queryKey: adminQueryKeys.posts.tagDetail(props.tag.name), + queryKey: postsQueryKey, + toPaginatedResult: (posts) => ({ + data: posts, + pagination: { + page: 1, + size: posts.length, + total: posts.length, + totalPages: 1, + }, + }), }) return ( @@ -36,7 +50,7 @@ export function TagDetail(props: { onBack: () => void; tag: TagModel }) { getCategories({ type: 'Category' }), queryKey: adminQueryKeys.categories.list(), }) @@ -14,9 +18,18 @@ export function useCategoriesList() { }) return { - categories: categoriesQuery.data ?? [], + categories: resourceCategories.filter(isCategoryModel), categoriesQuery, tags: tagsQuery.data ?? [], tagsQuery, } } + +function isCategoryModel(category: unknown): category is CategoryModel { + return ( + typeof category === 'object' && + category !== null && + 'count' in category && + 'createdAt' in category + ) +} diff --git a/apps/admin/src/features/categories/hooks/use-category-mutations.ts b/apps/admin/src/features/categories/hooks/use-category-mutations.ts index deec2bff616..f96f2750451 100644 --- a/apps/admin/src/features/categories/hooks/use-category-mutations.ts +++ b/apps/admin/src/features/categories/hooks/use-category-mutations.ts @@ -3,6 +3,7 @@ import { useCallback } from 'react' import { toast } from 'sonner' import { deleteCategory } from '~/api/categories' +import { usePostCategoryResourceStore } from '~/data/post-category-resource/store' import { useI18n } from '~/i18n' import { adminQueryKeys } from '~/query/keys' @@ -28,7 +29,8 @@ export function useCategoryMutations( mutationFn: deleteCategory, onError: (error: unknown) => toast.error(getErrorMessage(error, t('categories.toast.deleteFailed'))), - onSuccess: async () => { + onSuccess: async (_, id) => { + usePostCategoryResourceStore.getState().removeCategory(id) toast.success(t('categories.toast.deleted')) options.onAfterDeleteSuccess?.() await invalidateCategories() diff --git a/apps/admin/src/features/posts/hooks/use-post-mutations.ts b/apps/admin/src/features/posts/hooks/use-post-mutations.ts index 49ea60871b1..f7669e348f8 100644 --- a/apps/admin/src/features/posts/hooks/use-post-mutations.ts +++ b/apps/admin/src/features/posts/hooks/use-post-mutations.ts @@ -2,11 +2,19 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import { deletePost, patchPost } from '~/api/posts' +import { PostCategoryResourceTransaction } from '~/data/post-category-resource/transaction' import { useI18n } from '~/i18n' +import type { PostModel } from '~/models/post' import { postsQueryKey } from '../constants' import { getErrorMessage } from '../utils/errors' +interface BatchDeleteResult { + failedCount: number + successfulIds: string[] + successCount: number +} + interface UsePostMutationsOptions { onBatchSuccess?: () => void } @@ -20,70 +28,120 @@ export function usePostMutations(options: UsePostMutationsOptions = {}) { } const publishMutation = useMutation({ - mutationFn: (payload: { id: string; isPublished: boolean }) => - patchPost(payload.id, { isPublished: payload.isPublished }), - onSuccess: invalidatePosts, + mutationFn: async (payload: { id: string; isPublished: boolean }) => { + const tx = new PostCategoryResourceTransaction( + `publishPost(${payload.id})`, + ).patchPost(payload.id, { + isPublished: payload.isPublished, + }) + tx.request = () => + patchPost(payload.id, { isPublished: payload.isPublished }) + tx.onSuccess = async (post: PostModel) => { + tx.commitPost(post.id, post) + await invalidatePosts() + } + return tx.commit() + }, }) const categoryMutation = useMutation({ - mutationFn: (payload: { categoryId: string; id: string }) => - patchPost(payload.id, { categoryId: payload.categoryId }), - onError: (error: unknown) => - toast.error( - getErrorMessage(error, t('posts.toast.categoryUpdateFailed')), - ), - onSuccess: invalidatePosts, + mutationFn: async (payload: { categoryId: string; id: string }) => { + const tx = new PostCategoryResourceTransaction( + `updatePostCategory(${payload.id})`, + ).patchPost(payload.id, { + categoryId: payload.categoryId, + }) + tx.request = () => + patchPost(payload.id, { categoryId: payload.categoryId }) + tx.onSuccess = async (post: PostModel) => { + tx.commitPost(post.id, post) + await invalidatePosts() + } + tx.onError = (error) => { + toast.error( + getErrorMessage(error, t('posts.toast.categoryUpdateFailed')), + ) + } + return tx.commit() + }, }) const deleteMutation = useMutation({ - mutationFn: deletePost, - onSuccess: async () => { - toast.success(t('posts.toast.deleted')) - await invalidatePosts() + mutationFn: async (id: string) => { + const tx = new PostCategoryResourceTransaction(`deletePost(${id})`) + .deletePost(id) + tx.request = () => deletePost(id) + tx.onSuccess = async () => { + tx.commitAll() + toast.success(t('posts.toast.deleted')) + await invalidatePosts() + } + return tx.commit() }, }) const batchDeleteMutation = useMutation({ mutationFn: async (ids: string[]) => { - const results = await Promise.allSettled(ids.map((id) => deletePost(id))) - const successfulIds = ids.filter( - (_, index) => results[index].status === 'fulfilled', + const tx = new PostCategoryResourceTransaction( + `batchDeletePosts(${ids.join(',')})`, ) + ids.forEach((id) => tx.deletePost(id)) + tx.request = async () => { + const results = await Promise.allSettled(ids.map((id) => deletePost(id))) + const successfulIds = ids.filter( + (_, index) => results[index].status === 'fulfilled', + ) - return { - failedCount: ids.length - successfulIds.length, - successfulIds, - successCount: successfulIds.length, + return { + failedCount: ids.length - successfulIds.length, + successfulIds, + successCount: successfulIds.length, + } } - }, - onError: (error: unknown) => - toast.error(getErrorMessage(error, t('posts.toast.batchDeleteFailed'))), - onSuccess: async ({ failedCount, successCount }) => { - options.onBatchSuccess?.() - if (failedCount > 0) { - toast.warning( - t('posts.toast.batchDeletePartial', { - failed: failedCount, - success: successCount, - }), - ) - } else { - toast.success( - t('posts.toast.batchDeleteSucceeded', { count: successCount }), - ) + tx.onSuccess = async ({ failedCount, successfulIds, successCount }) => { + const successfulIdSet = new Set(successfulIds) + tx.commitPosts(successfulIds) + tx.rollbackPosts(ids.filter((id) => !successfulIdSet.has(id))) + options.onBatchSuccess?.() + if (failedCount > 0) { + toast.warning( + t('posts.toast.batchDeletePartial', { + failed: failedCount, + success: successCount, + }), + ) + } else { + toast.success( + t('posts.toast.batchDeleteSucceeded', { count: successCount }), + ) + } + await invalidatePosts() } - await invalidatePosts() + tx.onError = (error) => { + toast.error(getErrorMessage(error, t('posts.toast.batchDeleteFailed'))) + } + return tx.commit() }, }) const pinMutation = useMutation({ - mutationFn: (payload: { id: string; isPinned: boolean }) => - patchPost(payload.id, { - pinAt: payload.isPinned ? new Date().toISOString() : null, - }), - onError: (error: unknown) => - toast.error(getErrorMessage(error, t('posts.toast.pinFailed'))), - onSuccess: invalidatePosts, + mutationFn: async (payload: { id: string; isPinned: boolean }) => { + const pinAt = payload.isPinned ? new Date().toISOString() : null + const tx = new PostCategoryResourceTransaction( + `pinPost(${payload.id})`, + ).patchPost(payload.id, { + pinAt, + }) + tx.request = () => patchPost(payload.id, { pinAt }) + tx.onSuccess = async (post: PostModel) => { + tx.commitPost(post.id, post) + await invalidatePosts() + } + tx.onError = (error) => { + toast.error(getErrorMessage(error, t('posts.toast.pinFailed'))) + } + return tx.commit() + }, }) return { diff --git a/apps/admin/src/features/posts/hooks/use-posts-list.ts b/apps/admin/src/features/posts/hooks/use-posts-list.ts index e7805e6a71b..f02379e6bbf 100644 --- a/apps/admin/src/features/posts/hooks/use-posts-list.ts +++ b/apps/admin/src/features/posts/hooks/use-posts-list.ts @@ -1,8 +1,18 @@ -import { useQuery } from '@tanstack/react-query' import { useEffect, useMemo, useState } from 'react' import { getCategories } from '~/api/categories' import { getPosts, searchPosts } from '~/api/posts' +import { + usePostResourceCategories, + usePostResourceList, +} from '~/data/post-category-resource/hooks' +import { + usePostCategoriesResourceQuery, + usePostListResourceQuery, +} from '~/data/post-category-resource/queries' +import { + serializeResourceListKey, +} from '~/data/post-category-resource/store' import { useUrlListState } from '~/features/_shared/hooks/use-url-list-state' import { adminQueryKeys } from '~/query/keys' @@ -54,13 +64,33 @@ export function usePostsList() { setKeywordInput(state.keyword) }, [state.keyword]) - const categoriesQuery = useQuery({ + const categoriesQuery = usePostCategoriesResourceQuery({ queryFn: () => getCategories({ type: 'Category' }), queryKey: adminQueryKeys.categories.postFilter(), }) - const postsQuery = useQuery({ - placeholderData: (previous) => previous, + const postsListQueryKey = useMemo( + () => + adminQueryKeys.posts.list({ + categoryId: state.categoryId, + keyword: state.keyword, + page: state.page, + size: postsPageSize, + sortKey: state.sortKey, + sortOrder: state.sortOrder, + }), + [ + state.categoryId, + state.keyword, + state.page, + state.sortKey, + state.sortOrder, + ], + ) + const postListResource = usePostResourceList(postsListQueryKey) + const categories = usePostResourceCategories() + + const postsQuery = usePostListResourceQuery({ queryFn: () => state.keyword ? searchPosts({ @@ -78,18 +108,11 @@ export function usePostsList() { sort_by: state.sortKey, sort_order: state.sortOrder, }), - queryKey: adminQueryKeys.posts.list({ - categoryId: state.categoryId, - keyword: state.keyword, - page: state.page, - size: postsPageSize, - sortKey: state.sortKey, - sortOrder: state.sortOrder, - }), + queryKey: postsListQueryKey, }) return { - categories: categoriesQuery.data ?? [], + categories, categoriesQuery, categoryId: state.categoryId, clearSearch: () => { @@ -99,8 +122,8 @@ export function usePostsList() { keyword: state.keyword, keywordInput, page: state.page, - pagination: postsQuery.data?.pagination, - posts: postsQuery.data?.data ?? [], + pagination: postListResource.pagination, + posts: postListResource.posts, postsQuery, rootQueryKey: postsQueryKey, setCategoryId: (categoryId: string) => diff --git a/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx b/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx index d850e2c1f6b..5ddf21bac23 100644 --- a/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx +++ b/apps/admin/src/features/write/components/WriteRouteViewsContent.tsx @@ -77,6 +77,20 @@ import { createPost, getPostById, getPosts, updatePost } from '~/api/posts' import { callBuiltInFunction } from '~/api/system' import { getTopics } from '~/api/topics' import { API_URL, WEB_URL } from '~/constants/env' +import { + usePostResourceCategories, + usePostResourceList, + usePostResourcePost, +} from '~/data/post-category-resource/hooks' +import { + usePostCategoriesResourceQuery, + usePostDetailResourceQuery, + usePostListResourceQuery, +} from '~/data/post-category-resource/queries' +import { + usePostCategoryResourceStore, +} from '~/data/post-category-resource/store' +import { PostCategoryResourceTransaction } from '~/data/post-category-resource/transaction' import { APP_SHELL_HEADER_HEIGHT_CLASS, APP_SHELL_HEADER_HEIGHT_VALUE, @@ -377,8 +391,14 @@ function WritePage(props: { kind: WriteKind }) { const latestDraftFingerprintRef = useRef('') const [lastSavedFingerprint, setLastSavedFingerprint] = useState('') const draftRefType = draftRefTypeByKind[props.kind] + const postResource = usePostResourcePost(props.kind === 'post' ? id : '') + const postResourceCategories = usePostResourceCategories() + const relatedPostsQueryKey = useMemo( + () => adminQueryKeys.posts.relatedOptions('write'), + [], + ) - const categoriesQuery = useQuery({ + usePostCategoriesResourceQuery({ enabled: props.kind === 'post', queryFn: () => getCategories({ type: 'Category' }), queryKey: adminQueryKeys.categories.list(), @@ -393,7 +413,7 @@ function WritePage(props: { kind: WriteKind }) { queryFn: getTags, queryKey: adminQueryKeys.categories.tags(), }) - const relatedPostsQuery = useQuery({ + usePostListResourceQuery({ enabled: props.kind === 'post', queryFn: () => getPosts({ @@ -402,13 +422,20 @@ function WritePage(props: { kind: WriteKind }) { sort_by: 'createdAt', sort_order: 'desc', }), - queryKey: adminQueryKeys.posts.relatedOptions('write'), + queryKey: relatedPostsQueryKey, }) - const detailQuery = useQuery({ - enabled: isEditing, + const postDetailQuery = usePostDetailResourceQuery({ + enabled: isEditing && props.kind === 'post', + queryFn: () => getPostById(id), + queryKey: adminQueryKeys.write.detail({ id, kind: props.kind }), + }) + const nonPostDetailQuery = useQuery({ + enabled: isEditing && props.kind !== 'post', queryFn: () => getWriteDetail(props.kind, id), queryKey: adminQueryKeys.write.detail({ id, kind: props.kind }), }) + const detailQuery = + props.kind === 'post' ? postDetailQuery : nonPostDetailQuery const refDraftQuery = useQuery({ enabled: isEditing, queryFn: () => getDraftByRef(draftRefType, id), @@ -425,10 +452,14 @@ function WritePage(props: { kind: WriteKind }) { queryKey: adminQueryKeys.drafts.newDraft(draftRefType), }) - const categories = categoriesQuery.data ?? emptyCategories + const detailModel = + props.kind === 'post' ? postResource : nonPostDetailQuery.data + const categories = + props.kind === 'post' + ? postResourceCategories.filter(isCategoryModel) + : emptyCategories const tags = tagsQuery.data ?? [] const topics = topicsQuery.data?.data ?? emptyTopics - const relatedPosts = relatedPostsQuery.data?.data ?? [] const firstCategoryId = categories[0]?.id ?? '' const activeCategory = categories.find((category) => category.id === state.categoryId) ?? @@ -441,21 +472,21 @@ function WritePage(props: { kind: WriteKind }) { )[0] }, [newDraftsQuery.data, refDraftQuery.data]) const publishedContent = useMemo( - () => (detailQuery.data ? getPublishedContent(detailQuery.data) : null), - [detailQuery.data], + () => (detailModel ? getPublishedContent(detailModel) : null), + [detailModel], ) const defaultNoteTitle = useMemo(() => { - if (props.kind === 'note' && detailQuery.data) { + if (props.kind === 'note' && detailModel) { return getDefaultNoteTitle( - new Date((detailQuery.data as NoteModel).createdAt), + new Date((detailModel as NoteModel).createdAt), ) } return getDefaultNoteTitle() - }, [detailQuery.data, props.kind]) + }, [detailModel, props.kind]) const notePublicPath = props.kind === 'note' - ? buildNotePublicPath(state, detailQuery.data as NoteModel | undefined) + ? buildNotePublicPath(state, detailModel as NoteModel | undefined) : '' const postPublicPath = props.kind === 'post' ? buildPostPublicPath(state, activeCategory) : '' @@ -583,7 +614,7 @@ function WritePage(props: { kind: WriteKind }) { }, [location.hash, location.pathname, location.search, navigate]) useEffect(() => { - if (!detailQuery.data) { + if (!detailModel) { if (props.kind !== 'post' || !firstCategoryId) return setState((previous) => previous.categoryId @@ -597,9 +628,9 @@ function WritePage(props: { kind: WriteKind }) { } if (!routeDraftId) { - setState(fromModel(props.kind, detailQuery.data)) + setState(fromModel(props.kind, detailModel)) } - }, [detailQuery.data, firstCategoryId, props.kind, routeDraftId]) + }, [detailModel, firstCategoryId, props.kind, routeDraftId]) useEffect(() => { if (refDraftQuery.data && !draftId) { @@ -609,11 +640,11 @@ function WritePage(props: { kind: WriteKind }) { const recoveryHintDraft = useMemo(() => { const draft = refDraftQuery.data - const published = detailQuery.data + const published = detailModel if (!isEditing || routeDraftId || !draft || !published) return null if (!isDraftNewerThanPublished(draft, published)) return null return draft - }, [detailQuery.data, isEditing, refDraftQuery.data, routeDraftId]) + }, [detailModel, isEditing, refDraftQuery.data, routeDraftId]) const openRecoveryDialog = (draft: DraftModel) => { if (!publishedContent) return @@ -678,6 +709,11 @@ function WritePage(props: { kind: WriteKind }) { onError: (error: unknown) => toast.error(getErrorMessage(error, t('write.toast.saveFailed'))), onSuccess: async (result) => { + if (props.kind === 'post') { + usePostCategoryResourceStore + .getState() + .hydratePostDetail(result as PostModel) + } draftDirtyRef.current = false lastSavedDraftFingerprintRef.current = latestDraftFingerprintRef.current setLastSavedFingerprint(latestDraftFingerprintRef.current) @@ -872,8 +908,8 @@ function WritePage(props: { kind: WriteKind }) { isEditing, isPendingDraftSave: draftMutation.isPending, latestDraft, - publishedUpdatedAt: detailQuery.data - ? getPublishedContent(detailQuery.data).updatedAt + publishedUpdatedAt: detailModel + ? getPublishedContent(detailModel).updatedAt : undefined, }) @@ -1180,9 +1216,8 @@ function WritePage(props: { kind: WriteKind }) { postFields={ props.kind === 'post' ? ( updateField: ( @@ -2107,9 +2141,11 @@ function PostFields(props: { ) => void }) { const { t } = useI18n() + const categories = usePostResourceCategories().filter(isCategoryModel) + const relatedPosts = usePostResourceList(props.relatedPostsQueryKey).posts const selectedTags = splitCommaList(props.state.tags) const selectedRelatedIds = splitCommaList(props.state.relatedId) - const visibleRelatedPosts = props.relatedPosts.filter( + const visibleRelatedPosts = relatedPosts.filter( (post) => post.id !== props.currentPostId, ) const toggleTag = (tag: string) => { @@ -2131,7 +2167,7 @@ function PostFields(props: { onValueChange={(categoryId) => props.updateField('categoryId', categoryId) } - options={props.categories.map((category) => ({ + options={categories.map((category) => ({ label: category.name, value: category.id, }))} @@ -3542,29 +3578,7 @@ function saveWrite( draftId?: string, ): Promise { if (kind === 'post') { - const data = { - categoryId: state.categoryId, - content: state.contentFormat === 'lexical' ? state.content : undefined, - contentFormat: state.contentFormat, - copyright: state.copyright, - draftId, - images: buildWriteImages(state), - isPublished: state.isPublished, - meta: state.meta, - pin: state.pin ? new Date().toISOString() : null, - pinOrder: state.pin ? Number(state.pinOrder) || 1 : null, - relatedId: splitCommaList(state.relatedId), - slug: state.slug, - summary: state.summary || null, - tags: state.tags - .split(',') - .map((tag) => tag.trim()) - .filter(Boolean), - text: state.text, - title: state.title, - } - - return id ? updatePost(id, data) : createPost(data) + return savePostWrite(id, state, draftId) } if (kind === 'note') { @@ -3609,6 +3623,69 @@ function saveWrite( return id ? updatePage(id, data) : createPage(data) } +function savePostWrite( + id: string, + state: WriteFormState, + draftId?: string, +): Promise { + const data = buildPostWriteData(state, draftId) + if (!id) return createPost(data) + + const tx = new PostCategoryResourceTransaction( + `savePost(${id})`, + ).patchPost(id, toOptimisticPostPatch(data)) + tx.request = () => updatePost(id, data) + tx.onSuccess = (post) => { + tx.commitPost(post.id, post) + } + return tx.commit() +} + +function buildPostWriteData(state: WriteFormState, draftId?: string) { + return { + categoryId: state.categoryId, + content: state.contentFormat === 'lexical' ? state.content : undefined, + contentFormat: state.contentFormat, + copyright: state.copyright, + draftId, + images: buildWriteImages(state), + isPublished: state.isPublished, + meta: state.meta, + pin: state.pin ? new Date().toISOString() : null, + pinOrder: state.pin ? Number(state.pinOrder) || 1 : null, + relatedId: splitCommaList(state.relatedId), + slug: state.slug, + summary: state.summary || null, + tags: state.tags + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean), + text: state.text, + title: state.title, + } +} + +function toOptimisticPostPatch( + data: ReturnType, +): Partial { + return { + categoryId: data.categoryId, + content: data.content, + contentFormat: data.contentFormat, + copyright: data.copyright, + images: data.images, + isPublished: data.isPublished, + meta: data.meta, + pinAt: data.pin, + pinOrder: data.pinOrder, + slug: data.slug, + summary: data.summary, + tags: data.tags, + text: data.text, + title: data.title, + } +} + function toDraftData( kind: WriteKind, state: WriteFormState, @@ -3861,6 +3938,15 @@ function validateState( return null } +function isCategoryModel(category: unknown): category is CategoryModel { + return ( + typeof category === 'object' && + category !== null && + 'count' in category && + 'createdAt' in category + ) +} + function getWriteAgentMetaSchema(kind: WriteKind) { if (kind === 'post') return POST_META_SCHEMA if (kind === 'note') return NOTE_META_SCHEMA diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9332f346bf9..0a13b94beb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -278,6 +278,9 @@ importers: fuse.js: specifier: 7.1.0 version: 7.1.0 + immer: + specifier: ^11.1.8 + version: 11.1.8 jotai: specifier: 2.20.0 version: 2.20.0(@babel/core@7.29.0)(@babel/template@7.29.7)(@types/react@19.2.14)(react@19.2.4)