From 245b3f0c486ce391a10467cf8aa6a3614099f12b Mon Sep 17 00:00:00 2001 From: mrboxs Date: Wed, 4 Nov 2026 19:11:29 +0700 Subject: [PATCH 1/6] fix: update oRPC client types and chart legend positioning - Use the renamed `RouterContractClient` type in API tests - Align chart legend spacing with its position prop --- packages/api/tests/admin-project-github-metadata.test.ts | 4 ++-- packages/api/tests/admin-project-github-readme.test.ts | 4 ++-- packages/api/tests/openapi-mapping.test.ts | 4 ++-- packages/api/tests/submission-rate-limit.test.ts | 4 ++-- packages/ui/src/components/chart.tsx | 5 +++-- 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/api/tests/admin-project-github-metadata.test.ts b/packages/api/tests/admin-project-github-metadata.test.ts index 3234fec..7e5324c 100644 --- a/packages/api/tests/admin-project-github-metadata.test.ts +++ b/packages/api/tests/admin-project-github-metadata.test.ts @@ -1,6 +1,6 @@ import { createORPCClient, createORPCErrorFromJson } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' -import type { ContractRouterClient } from '@orpc/contract' +import type { RouterContractClient } from '@orpc/contract' import { OpenAPILink } from '@orpc/openapi/fetch' import { createRouterClient } from '@orpc/server' import { RequestError } from 'octokit' @@ -226,7 +226,7 @@ describe('admin GitHub metadata', () => { if (!result.response) throw new Error('Unmatched request') return result.response } - const wire = createORPCClient>( + const wire = createORPCClient>( transport === 'rpc' ? new RPCLink({ origin: 'http://localhost', diff --git a/packages/api/tests/admin-project-github-readme.test.ts b/packages/api/tests/admin-project-github-readme.test.ts index 6cba438..605b4b2 100644 --- a/packages/api/tests/admin-project-github-readme.test.ts +++ b/packages/api/tests/admin-project-github-readme.test.ts @@ -1,6 +1,6 @@ import { createORPCClient, createORPCErrorFromJson } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' -import type { ContractRouterClient } from '@orpc/contract' +import type { RouterContractClient } from '@orpc/contract' import { OpenAPILink } from '@orpc/openapi/fetch' import { createRouterClient } from '@orpc/server' import { RequestError } from 'octokit' @@ -269,7 +269,7 @@ describe('README wire contract', () => { if (!result.response) throw new Error('Unmatched request') return result.response } - const wire = createORPCClient>( + const wire = createORPCClient>( transport === 'rpc' ? new RPCLink({ origin: 'http://localhost', diff --git a/packages/api/tests/openapi-mapping.test.ts b/packages/api/tests/openapi-mapping.test.ts index b902128..d9e6a01 100644 --- a/packages/api/tests/openapi-mapping.test.ts +++ b/packages/api/tests/openapi-mapping.test.ts @@ -1,5 +1,5 @@ import { createORPCClient } from '@orpc/client' -import type { ContractRouterClient } from '@orpc/contract' +import type { RouterContractClient } from '@orpc/contract' import { SmartCoercionHandlerPlugin } from '@orpc/json-schema' import { getOpenAPIMeta, OpenAPIGenerator } from '@orpc/openapi' import { OpenAPIHandler, OpenAPILink } from '@orpc/openapi/fetch' @@ -724,7 +724,7 @@ describe('detailed REST mapping', () => { }) it('sends QUERY through OpenAPILink with path/query inputs in the URL and no body', async () => { const requests: Array<{ method: string; url: string; body: string }> = [] - const client = createORPCClient>( + const client = createORPCClient>( new OpenAPILink(contracts, { origin: 'http://localhost', url: '/', diff --git a/packages/api/tests/submission-rate-limit.test.ts b/packages/api/tests/submission-rate-limit.test.ts index cb5e141..093340f 100644 --- a/packages/api/tests/submission-rate-limit.test.ts +++ b/packages/api/tests/submission-rate-limit.test.ts @@ -5,7 +5,7 @@ import { } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' import type { FetchLinkTransportOptions } from '@orpc/client/fetch' -import type { ContractRouterClient } from '@orpc/contract' +import type { RouterContractClient } from '@orpc/contract' import { OpenAPILink } from '@orpc/openapi/fetch' import { createRouterClient } from '@orpc/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' @@ -441,7 +441,7 @@ describe.each(['rpc', 'rest'] as const)('%s transport', (transport) => { : defaultError() }, }) - const caller: ContractRouterClient = + const caller: RouterContractClient = createORPCClient(link) return { caller, responses } } diff --git a/packages/ui/src/components/chart.tsx b/packages/ui/src/components/chart.tsx index ed75d25..2659216 100644 --- a/packages/ui/src/components/chart.tsx +++ b/packages/ui/src/components/chart.tsx @@ -281,11 +281,12 @@ function ChartLegendContent({ className, hideIcon = false, payload, - verticalAlign = 'bottom', + position = 'bottom', nameKey, }: React.ComponentProps<'div'> & { hideIcon?: boolean nameKey?: string + position?: React.ComponentProps['position'] } & RechartsPrimitive.DefaultLegendContentProps) { const { config } = useChart() @@ -297,7 +298,7 @@ function ChartLegendContent({
From c6c6e8dc5ae928f3e0deef9e0c436a1466dc1422 Mon Sep 17 00:00:00 2001 From: mrboxs Date: Wed, 4 Nov 2026 19:27:11 +0700 Subject: [PATCH 2/6] fix(ads): keep catalogue offsets consistent during hydration --- apps/web/src/features/ads/use-catalogue-offset.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/web/src/features/ads/use-catalogue-offset.ts b/apps/web/src/features/ads/use-catalogue-offset.ts index d4f8854..68a3ab9 100644 --- a/apps/web/src/features/ads/use-catalogue-offset.ts +++ b/apps/web/src/features/ads/use-catalogue-offset.ts @@ -1,10 +1,13 @@ -import { useHydrated } from '@tanstack/react-router' +import { useSyncExternalStore } from 'react' import { catalogueOffset, DISPLAY_SLOTS } from './selection' +const subscribe = () => () => undefined + export function useCatalogueOffset(catalogue: string, page: number) { - const hydrated = useHydrated() - return hydrated - ? catalogueOffset(catalogue, page) - : Math.max(0, page - 1) * DISPLAY_SLOTS + return useSyncExternalStore( + subscribe, + () => catalogueOffset(catalogue, page), + () => Math.max(0, page - 1) * DISPLAY_SLOTS + ) } From e65cc965ed053bc4b923a0ffa8f4b2b20b1628f9 Mon Sep 17 00:00:00 2001 From: mrboxs Date: Wed, 4 Nov 2026 19:56:13 +0700 Subject: [PATCH 3/6] fix(categories): index ancestry lookups and cancel detail queries --- .../src/components/category-combobox-view.tsx | 9 +++- .../components/category-form.tsx | 4 +- .../components/category-table.tsx | 12 ++++-- .../src/features/admin-categories/model.ts | 19 +++++++-- .../src/features/admin-categories/queries.ts | 13 ++++-- apps/web/tests/category-catalog-ui.test.tsx | 41 +++++++++++++++++++ apps/web/tests/category-model.test.ts | 38 +++++++++++++++++ 7 files changed, 122 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/category-combobox-view.tsx b/apps/web/src/components/category-combobox-view.tsx index 8499425..e50a9e2 100644 --- a/apps/web/src/components/category-combobox-view.tsx +++ b/apps/web/src/components/category-combobox-view.tsx @@ -13,7 +13,11 @@ import { useComboboxAnchor, } from '@altstack/ui/components/combobox' -import { categoryHelp, categoryLabel } from '#/features/admin-categories/model' +import { + categoryHelp, + categoryLabel, + indexCategories, +} from '#/features/admin-categories/model' const MAX_CATEGORIES = 3 @@ -32,6 +36,7 @@ export const CategoryComboboxView = ({ categories: Array }) => { const anchor = useComboboxAnchor() + const categoriesById = indexCategories(categories) const nameBySlug = new Map( categories.map((category) => [category.slug, category.name]) @@ -39,7 +44,7 @@ export const CategoryComboboxView = ({ const labelBySlug = new Map( categories.map((category) => [ category.slug, - categoryLabel(category, categories), + categoryLabel(category, categoriesById), ]) ) const items = categories diff --git a/apps/web/src/features/admin-categories/components/category-form.tsx b/apps/web/src/features/admin-categories/components/category-form.tsx index 6f21938..f6ea410 100644 --- a/apps/web/src/features/admin-categories/components/category-form.tsx +++ b/apps/web/src/features/admin-categories/components/category-form.tsx @@ -26,6 +26,7 @@ import { categoryPreviewPath, categoryUpdatePayload, eligibleParents, + indexCategories, } from '#/features/admin-categories/model' import type { AdminCategory, @@ -86,6 +87,7 @@ export function CategoryForm({ }, }) const parents = eligibleParents(categories, original) + const categoriesById = indexCategories(categories) if ( original?.parentId && !parents.some((parent) => parent.id === original.parentId) @@ -214,7 +216,7 @@ export function CategoryForm({ {parents.map((parent) => ( - {categoryLabel(parent, categories)} + {categoryLabel(parent, categoriesById)} ))} {original?.parentId && diff --git a/apps/web/src/features/admin-categories/components/category-table.tsx b/apps/web/src/features/admin-categories/components/category-table.tsx index acd85a9..8511347 100644 --- a/apps/web/src/features/admin-categories/components/category-table.tsx +++ b/apps/web/src/features/admin-categories/components/category-table.tsx @@ -19,7 +19,10 @@ import { } from '@altstack/ui/components/table' import { CategoryActions } from '#/features/admin-categories/components/category-actions' -import { categoryLabel } from '#/features/admin-categories/model' +import { + categoryLabel, + indexCategories, +} from '#/features/admin-categories/model' import type { AdminCategory } from '#/features/admin-categories/model' import { features } from '#/utils/data-table-features' import type { DataTableFeatures } from '#/utils/data-table-features' @@ -39,6 +42,7 @@ export function CategoryTable({ pageIndex: 0, pageSize: 12, }) + const categoriesById = indexCategories(categories) const columns = helper.columns([ helper.accessor('name', { header: ({ column }) => ( @@ -51,14 +55,14 @@ export function CategoryTable({ ), filterFn: (row, _id, value: unknown) => typeof value !== 'string' || - `${categoryLabel(row.original, categories)} ${row.original.slug}` + `${categoryLabel(row.original, categoriesById)} ${row.original.slug}` .toLowerCase() .includes(value.trim().toLowerCase()), cell: ({ row }) => (

{row.original.name}

- {categoryLabel(row.original, categories)} + {categoryLabel(row.original, categoriesById)}

), @@ -72,7 +76,7 @@ export function CategoryTable({ helper.accessor( (node) => { const parent = categories.find((item) => item.id === node.parentId) - return parent ? categoryLabel(parent, categories) : 'Root' + return parent ? categoryLabel(parent, categoriesById) : 'Root' }, { id: 'parent', diff --git a/apps/web/src/features/admin-categories/model.ts b/apps/web/src/features/admin-categories/model.ts index 36d72b1..9f84b9d 100644 --- a/apps/web/src/features/admin-categories/model.ts +++ b/apps/web/src/features/admin-categories/model.ts @@ -16,15 +16,28 @@ export type AdminCategory = ORPCRouterOutputs['admin']['category']['list']['categories'][number] export type CategoryFormValues = z.input +type CategoryLookup = Array | ReadonlyMap + +export function indexCategories(categories: Array) { + const byId = new Map() + for (const category of categories) { + if (!byId.has(category.id)) byId.set(category.id, category) + } + return byId +} + export function categoryAncestry( category: CategoryNode, - categories: Array + categories: CategoryLookup ) { + const byId = Array.isArray(categories) + ? indexCategories(categories) + : categories const chain = [category] const seen = new Set([category.id]) let parentId = category.parentId while (parentId) { - const parent = categories.find((node) => node.id === parentId) + const parent = byId.get(parentId) if (!parent || seen.has(parent.id)) break chain.unshift(parent) seen.add(parent.id) @@ -35,7 +48,7 @@ export function categoryAncestry( export function categoryLabel( category: CategoryNode, - categories: Array + categories: CategoryLookup ) { return categoryAncestry(category, categories) .map((node) => node.name) diff --git a/apps/web/src/features/admin-categories/queries.ts b/apps/web/src/features/admin-categories/queries.ts index 9c35e0f..efcfb4d 100644 --- a/apps/web/src/features/admin-categories/queries.ts +++ b/apps/web/src/features/admin-categories/queries.ts @@ -1,8 +1,10 @@ import { + matchQuery, useMutation, useQueryClient, useSuspenseQuery, } from '@tanstack/react-query' +import type { QueryFilters } from '@tanstack/react-query' import { useRouter } from '@tanstack/react-router' import type { ORPCRouterInputs } from '@altstack/api/routers' @@ -54,13 +56,16 @@ export function useAdminCategoryDelete() { return useMutation({ ...adminCategoryQueries.remove(), onSuccess: async (_, { params: { id } }) => { - for (const queryKey of [ + const queryKeys = [ adminORPC.category.getById.key({ input: { params: { id } } }), orpc.admin.category.getById.key({ input: { params: { id } } }), - ]) { - await queryClient.cancelQueries({ queryKey }) - queryClient.removeQueries({ queryKey }) + ] + const deletedQueries: QueryFilters = { + predicate: (query) => + queryKeys.some((queryKey) => matchQuery({ queryKey }, query)), } + await queryClient.cancelQueries(deletedQueries) + queryClient.removeQueries(deletedQueries) await invalidateCatalog(queryClient) toast.add({ type: 'success', title: 'Category deleted' }) await router.navigate({ to: '/admin/categories', replace: true }) diff --git a/apps/web/tests/category-catalog-ui.test.tsx b/apps/web/tests/category-catalog-ui.test.tsx index 84ca24f..6a3e893 100644 --- a/apps/web/tests/category-catalog-ui.test.tsx +++ b/apps/web/tests/category-catalog-ui.test.tsx @@ -458,6 +458,47 @@ describe('CRUD cache callbacks with real oRPC query keys', () => { ) ).toBeTruthy() }) + it('cancels in-flight detail variants before deletion so late responses cannot restore them', async () => { + const { wrapper, queryClient } = provider() + const keys = [ + adminCategoryQueries.get({ id: flat.id }).queryKey, + orpc.admin.category.getById.queryOptions({ + input: { params: { id: flat.id } }, + }).queryKey, + ] + const signals: Array = [] + const complete: Array<(value: string) => void> = [] + const requests = keys.map((queryKey) => + queryClient.query({ + queryKey, + queryFn: ({ signal }) => { + signals.push(signal) + return new Promise((resolve) => complete.push(resolve)) + }, + }) + ) + const settled = Promise.allSettled(requests) + const { result } = renderHook(useAdminCategoryDelete, { wrapper }) + try { + await act(async () => { + await result.current.mutateAsync({ params: { id: flat.id } }) + }) + expect(signals).toHaveLength(2) + expect(signals.every((signal) => signal.aborted)).toBe(true) + for (const resolve of complete) resolve('late response') + expect( + (await settled).every((entry) => entry.status === 'rejected') + ).toBe(true) + for (const queryKey of keys) { + expect(queryClient.getQueryState(queryKey)).toBeUndefined() + } + expect(navigate).toHaveBeenCalledTimes(1) + } finally { + queryClient.clear() + for (const resolve of complete) resolve('cleanup') + await settled + } + }) it('a rejected category write leaves data cached and does not navigate', async () => { rpc.mockRejectedValueOnce(new Error('Conflict')) const { wrapper, queryClient } = provider() diff --git a/apps/web/tests/category-model.test.ts b/apps/web/tests/category-model.test.ts index e192918..0ef56c9 100644 --- a/apps/web/tests/category-model.test.ts +++ b/apps/web/tests/category-model.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vite-plus/test' import { + categoryAncestry, categoryDeletionReason, categoryFormSchema, categoryLabel, @@ -8,6 +9,7 @@ import { categoryPreviewPath, categoryUpdatePayload, eligibleParents, + indexCategories, } from '#/features/admin-categories/model' import { assigned, @@ -20,6 +22,42 @@ import { } from './fixtures/categories' describe('category hierarchy editor', () => { + it('resolves root-to-leaf ancestry from arrays and a shared index without changing the input', () => { + const unordered = [codeEditor, editors, software] + const byId = indexCategories(unordered) + expect(categoryAncestry(codeEditor, unordered)).toEqual([ + software, + editors, + codeEditor, + ]) + expect(categoryLabel(codeEditor, byId)).toBe( + 'Software / Editors / Code editor' + ) + expect(categoryLabel(editors, byId)).toBe('Software / Editors') + expect(categoryAncestry(software, byId)).toEqual([software]) + expect(unordered).toEqual([codeEditor, editors, software]) + }) + it('preserves the first matching parent when duplicate IDs are present', () => { + const duplicate = { ...editors, name: 'Duplicate editors', parentId: null } + const nodes = [software, editors, duplicate, codeEditor] + expect(categoryAncestry(codeEditor, indexCategories(nodes))).toEqual([ + software, + editors, + codeEditor, + ]) + }) + it('stops at missing parents and cycles without repeating categories', () => { + expect(categoryAncestry(codeEditor, indexCategories([codeEditor]))).toEqual( + [codeEditor] + ) + const cyclicRoot = { ...software, parentId: codeEditor.id } + expect( + categoryAncestry( + codeEditor, + indexCategories([codeEditor, editors, cyclicRoot]) + ) + ).toEqual([cyclicRoot, editors, codeEditor]) + }) it('excludes self, descendants, assigned parents, and depth that cannot fit the subtree', () => { expect(eligibleParents(categories, editors).map((node) => node.id)).toEqual( [software.id, flat.id] From e62b9c04434bd54cad634549fdf4ead13d7a2a10 Mon Sep 17 00:00:00 2001 From: mrboxs Date: Wed, 4 Nov 2026 20:39:04 +0700 Subject: [PATCH 4/6] feat(about): add editable About page - Add public About page with SEO metadata and sitemap coverage - Add admin editor with Markdown preview, validation, and conflict handling - Persist About content through API and database --- .../src/components/action-confirmation.tsx | 10 +- apps/web/src/components/footer.tsx | 1 + apps/web/src/components/header.tsx | 8 + .../src/components/main-sidebar-content.tsx | 2 + apps/web/src/components/workspace-header.tsx | 1 + apps/web/src/features/about/article.tsx | 25 + apps/web/src/features/about/editor.tsx | 299 + apps/web/src/features/about/queries.ts | 6 + apps/web/src/features/seo/sitemap.server.ts | 1 + apps/web/src/routeTree.gen.ts | 42 + apps/web/src/routes/_app/about.tsx | 36 + apps/web/src/routes/_main/admin.about.tsx | 20 + apps/web/tests/about.test.tsx | 69 + apps/web/tests/admin-about-editor.test.tsx | 210 + apps/web/tests/browser/about.browser.tsx | 175 + apps/web/tests/sitemap.test.ts | 3 + docs/deployment.md | 7 + packages/api/src/contracts/about.ts | 51 + packages/api/src/contracts/index.ts | 3 + packages/api/src/routers/about.ts | 68 + packages/api/src/routers/index.ts | 3 + packages/api/tests/about.test.ts | 187 + .../20261104131240_about-page/migration.sql | 10 + .../20261104131240_about-page/snapshot.json | 5772 +++++++++++++++++ packages/db/src/schemas.ts | 1 + packages/db/src/schemas/about.ts | 25 + packages/shared/src/constants/index.ts | 1 + packages/shared/src/schemas/about.ts | 46 + packages/shared/src/schemas/index.ts | 1 + packages/shared/tests/slug.test.ts | 2 + 30 files changed, 7083 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/features/about/article.tsx create mode 100644 apps/web/src/features/about/editor.tsx create mode 100644 apps/web/src/features/about/queries.ts create mode 100644 apps/web/src/routes/_app/about.tsx create mode 100644 apps/web/src/routes/_main/admin.about.tsx create mode 100644 apps/web/tests/about.test.tsx create mode 100644 apps/web/tests/admin-about-editor.test.tsx create mode 100644 apps/web/tests/browser/about.browser.tsx create mode 100644 packages/api/src/contracts/about.ts create mode 100644 packages/api/src/routers/about.ts create mode 100644 packages/api/tests/about.test.ts create mode 100644 packages/db/src/migrations/20261104131240_about-page/migration.sql create mode 100644 packages/db/src/migrations/20261104131240_about-page/snapshot.json create mode 100644 packages/db/src/schemas/about.ts create mode 100644 packages/shared/src/schemas/about.ts diff --git a/apps/web/src/components/action-confirmation.tsx b/apps/web/src/components/action-confirmation.tsx index 7000173..88170b6 100644 --- a/apps/web/src/components/action-confirmation.tsx +++ b/apps/web/src/components/action-confirmation.tsx @@ -22,6 +22,8 @@ export function ActionConfirmation({ onConfirm, children, disabled = false, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', }: { open: boolean title: string @@ -32,6 +34,8 @@ export function ActionConfirmation({ onConfirm: () => void children?: ReactNode disabled?: boolean + confirmLabel?: string + cancelLabel?: string }) { return ( )} - Cancel + + {cancelLabel} + - {pending ? 'Saving…' : 'Confirm'} + {pending ? 'Saving…' : confirmLabel} diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx index 0555a66..b301207 100644 --- a/apps/web/src/components/footer.tsx +++ b/apps/web/src/components/footer.tsx @@ -35,6 +35,7 @@ const exploreLinks = linkOptions([ ]) const communityLinks = linkOptions([ + { label: 'About us', to: '/about', search: {} }, { label: 'Blogs', to: '/blogs', search: {} }, { label: 'Submit a Project', to: '/submit', search: {} }, { label: 'Advertise', to: '/advertise', search: {} }, diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index ff74c06..13a4ef0 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -4,6 +4,7 @@ import { IconArrowRight, IconLicense, IconMenu2, + IconInfoCircle, IconTag, } from '@tabler/icons-react' import { @@ -46,6 +47,7 @@ import { ThemeSwitcher } from '#/components/theme-switcher' import { UserAvatar } from '#/components/user-avatar' const resourceLinks = [ + { to: '/about', label: 'About us', icon: IconInfoCircle }, { to: '/blogs', label: 'Blogs', icon: IconArticle }, { to: '/tags', label: 'Tags', icon: IconTag }, { to: '/licenses', label: 'Licenses', icon: IconLicense }, @@ -121,6 +123,12 @@ export const Header = ({ > Blogs + setOpenNavigation(false)} + render={} + > + About us + setOpenNavigation(false)} render={} diff --git a/apps/web/src/components/main-sidebar-content.tsx b/apps/web/src/components/main-sidebar-content.tsx index 72b4918..8725183 100644 --- a/apps/web/src/components/main-sidebar-content.tsx +++ b/apps/web/src/components/main-sidebar-content.tsx @@ -6,6 +6,7 @@ import { IconChevronDown, IconFlag, IconInbox, + IconInfoCircle, IconLayoutDashboard, IconPackage, IconPlus, @@ -85,6 +86,7 @@ const catalog = linkOptions([ { to: '/admin/ads', icon: IconStar, label: 'Ad campaigns' }, ]) const administration = linkOptions([ + { to: '/admin/about', icon: IconInfoCircle, label: 'About us' }, { to: '/admin/users', icon: IconUsers, label: 'Users' }, { to: '/admin/activity', icon: IconActivity, label: 'Activity' }, { to: '/admin/operations', icon: IconServer, label: 'Operations' }, diff --git a/apps/web/src/components/workspace-header.tsx b/apps/web/src/components/workspace-header.tsx index 8d1b9f8..e7d249c 100644 --- a/apps/web/src/components/workspace-header.tsx +++ b/apps/web/src/components/workspace-header.tsx @@ -15,6 +15,7 @@ import { SidebarTrigger } from '@altstack/ui/components/sidebar' import { ThemeSwitcher } from '#/components/theme-switcher' const labels: Record = { + '/admin/about': 'About us', '/admin': 'Overview', '/admin/submissions': 'Submissions', '/admin/ownership': 'Ownership requests', diff --git a/apps/web/src/features/about/article.tsx b/apps/web/src/features/about/article.tsx new file mode 100644 index 0000000..0f3bb59 --- /dev/null +++ b/apps/web/src/features/about/article.tsx @@ -0,0 +1,25 @@ +import type { AboutBody } from '@altstack/shared/schemas' + +import { PublicContent } from '#/components/public-content' + +export function AboutArticle({ page }: { page: AboutBody }) { + return ( +
+
+

+ {page.title} +

+

+ {page.description} +

+
+
+ +
+
+ ) +} diff --git a/apps/web/src/features/about/editor.tsx b/apps/web/src/features/about/editor.tsx new file mode 100644 index 0000000..eac67dc --- /dev/null +++ b/apps/web/src/features/about/editor.tsx @@ -0,0 +1,299 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { ClientOnly, Link, useBlocker, useRouter } from '@tanstack/react-router' +import { useRef, useState } from 'react' + +import { aboutBodySchema } from '@altstack/shared/schemas' +import type { AboutBody, AboutPage } from '@altstack/shared/schemas' + +import { + Alert, + AlertDescription, + AlertTitle, +} from '@altstack/ui/components/alert' +import { Button, buttonVariants } from '@altstack/ui/components/button' +import { + Collapsible, + CollapsibleTrigger, + CollapsibleContent, +} from '@altstack/ui/components/collapsible' +import { + Field, + FieldGroup, + FieldLabel, + FieldDescription, + FieldError, +} from '@altstack/ui/components/field' +import { Input } from '@altstack/ui/components/input' +import { Textarea } from '@altstack/ui/components/textarea' + +import { ActionConfirmation } from '#/components/action-confirmation' +import BlockNoteEditor from '#/components/block-note/editor' +import type { BlockNoteEditorHandle } from '#/components/block-note/editor' +import { PublicContent } from '#/components/public-content' +import { aboutQueries } from '#/features/about/queries' +import { orpc } from '#/utils/orpc' + +function bodyOf(page: AboutPage): AboutBody { + return { + title: page.title, + description: page.description, + content: page.content, + } +} + +export function AboutEditor({ initial }: { initial: AboutPage }) { + const [value, setValue] = useState(() => bodyOf(initial)) + const [version, setVersion] = useState(initial.version) + const [baseline, setBaseline] = useState(() => bodyOf(initial)) + const editor = useRef(null) + const [fieldErrors, setFieldErrors] = useState>({}) + const [saved, setSaved] = useState(false) + const [reloading, setReloading] = useState(false) + const [reloadError, setReloadError] = useState('') + const queryClient = useQueryClient() + const router = useRouter() + const update = useMutation(orpc.admin.about.update.mutationOptions()) + const busy = update.isPending || reloading + const dirty = JSON.stringify(value) !== JSON.stringify(baseline) + const blocker = useBlocker({ + shouldBlockFn: () => dirty, + enableBeforeUnload: () => dirty, + withResolver: true, + }) + const conflict = + update.error && 'code' in update.error && update.error.code === 'CONFLICT' + + function setField(field: K, next: AboutBody[K]) { + setSaved(false) + setValue((previous) => { + return { ...previous, [field]: next } + }) + } + + async function submit() { + if (busy) return + const current = { + ...value, + content: editor.current?.readMarkdown() ?? value.content, + } + setValue(current) + const parsed = aboutBodySchema.safeParse(current) + if (!parsed.success) { + const errors: Record = {} + for (const issue of parsed.error.issues) { + const field = String(issue.path[0]) + errors[field] = errors[field] ?? issue.message + } + setFieldErrors(errors) + return + } + setFieldErrors({}) + setSaved(false) + try { + const page = await update.mutateAsync({ + body: { ...parsed.data, expectedVersion: version }, + }) + setBaseline(bodyOf(page)) + setValue(bodyOf(page)) + setVersion(page.version) + queryClient.setQueryData(aboutQueries.admin().queryKey, page) + queryClient.setQueryData(aboutQueries.get().queryKey, page) + await queryClient.invalidateQueries({ queryKey: orpc.about.key() }) + setSaved(true) + } catch { + // The mutation error is displayed below; keep the current edits for retry. + } + } + + return ( +
+
+
+

About us

+

+ Tell visitors what AltStack is and how to take part. Saved changes + appear on the public page immediately. +

+
+ + View public page + +
+ {update.error && ( + + Unable to save About us + {update.error.message} + {conflict && ( + + )} + + )} + {reloadError &&

{reloadError}

} + {saved && ( + + About us saved. Your changes are live. + + )} +
+ + { + if (open) editor.current?.flush() + }} + > + } + > + Preview content + + +
+ +
+
+
+
+

+ {dirty ? 'You have unsaved changes.' : 'No unsaved changes.'} +

+ +
+ + blocker.reset?.()} + onConfirm={() => blocker.proceed?.()} + /> +
+ ) +} + +function AboutFields({ + value, + fieldErrors, + busy, + editor, + setField, +}: { + value: AboutBody + fieldErrors: Record + busy: boolean + editor: React.RefObject + setField: (field: K, next: AboutBody[K]) => void +}) { + return ( +
+ + + Title + setField('title', event.target.value)} + /> + {fieldErrors.title && ( + {fieldErrors.title} + )} + + + Description +