diff --git a/CHANGELOG.md b/CHANGELOG.md index 67e94722..70de4b76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,40 +1,66 @@ # Changelog -Notable changes are recorded here using the categories from +Notable changes use the categories from [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -This file starts with the documentation refresh; earlier release notes have not -been reconstructed. See [release note guidance](CONTRIBUTING.md#documentation-and-releases). +The pending v1 notes below cover changes since `v0.7.1`; earlier release entries +have not been reconstructed. See [release note guidance](CONTRIBUTING.md#documentation-and-releases). -## Unreleased +## [Unreleased] ### Added -- Public Collections directory and detail pages for admin-curated alternatives, - repository lists, and ecosystems, separate from private bookmarks. Includes - Markdown, catalogue-backed members, breadcrumbs, responsive numbered pagination - with clean first-page URLs, catalogue/source logo artwork, writer avatars, - bounded sticky navigation/sharing with active-project highlighting, mobile - layouts, canonical metadata, and sitemap discovery. -- Admin Collections CRUD, publication, project selection and ordering, explicit - account authorship, and revision checks. Hidden/deleted projects are filtered - from public collection surfaces. Apply the new Collections database migration - before deploying matching API/web changes; deployment acceptance remains pending. -- Contributor and security reporting guides. -- Development, architecture, and deployment guides. +- Public blog directory and articles with cover images, writer attribution, + publication/update dates, read time, heading permalinks, table of contents, + social images, and sitemap discovery. Admins can create, edit, publish, and + delete posts with rich Markdown authoring, cover uploads, and revision checks. +- Public About page at `/about` and admin editor at `/admin/about`. Title, + description, and Markdown can be previewed and published immediately, with + stale-edit protection and the shared public Markdown renderer. +- Sponsored campaign management with scheduling, artwork, tier eligibility, + weighted selection, and labeled public banner, listing, sidebar, corner, + and sponsor-grid placements. +- Account-free monthly advertising orders with private emailed management links, + Ko-fi payment links, manual receipt confirmation, material review, renewal, + and a durable notification outbox. Payments and refunds are not automated. +- Collection-based related-project suggestions on project detail pages. ### Changed -- Rebuilt the project README and simplified the PRD and roadmap. -- Consolidated documentation into five guides, with configuration/data import in - development, feature behavior in the PRD, and navigation in the root README. -- Replaced deployment-specific configuration with synthetic examples and removed - production evidence and generated QA artifacts from public documentation. +- Catalogue filters support selecting multiple categories and licenses while + preserving search state in the URL. +- Project and collection directories use 45 display slots, sharing available + space with listing ads and backfilling missing or blocked ads with organic items. +- Project cards reveal fuller descriptions on hover and keyboard focus, with + touch-friendly links to project details. +- Updated oRPC to the 2.0 beta API and refreshed frontend/toolchain dependencies. +- Added React Doctor diagnostics to the development workflow and pull requests. +- Consolidated advertising and ranking reference material into the five main + guides and synchronized the README, PRD, and roadmap with current behavior. ### Fixed -- Prevent project slugs from shadowing site routes across admin writes, - publication, imports, and generated submissions, without changing category slugs. - A read-only preflight blocks migration until legacy route collisions are resolved. -- Refresh collection management after failed deletions and omit public links for - drafts. Keep collection detail pages available while supplemental discovery is - pending or fails, rendering discovery cards independently when they become available. +- Keep the initial server/client catalogue offset consistent during hydration, + then restore tab-specific pagination boundaries without reusing another + offset's query results. +- Preserve application-defined OpenAPI error statuses, including upload errors, + after the oRPC upgrade. +- Prevent overlapping project-card links from blocking touch navigation. +- Release busy states after failed asynchronous actions so forms can be retried. +- Index category ancestry lookups and cancel/remove deleted-category detail + variants together, retaining protection against late responses. +- Replace deprecated oRPC client types and Recharts legend positioning props. + +### Upgrade notes + +- Deploy matching API and web revisions. Apply the additive blog, advertising, + order, and About migrations with `vp run db:migrate` before deploying them. + The reserved-project-slug preflight must pass, including `/blogs` and `/about`. +- Configure ad email delivery using `RESEND_API_KEY`, `RESEND_FROM_EMAIL`, a + trusted `EMAIL_APP_URL`, and optionally `ADS_CONTACT_EMAIL`. Keep an API + instance running for its advertising outbox loop. +- Review the Ko-fi destinations in `AD_PLANS` for the intended instance. Confirm + receipts manually; opening checkout does not record a payment or extend dates. +- Follow the [deployment guide](docs/deployment.md) for proxy `QUERY` support, + migration/configuration checks, scheduled workers, and instance acceptance. + +[Unreleased]: https://github.com/isntboxs/altstack/compare/v0.7.1...HEAD diff --git a/README.md b/README.md index e2b349cf..7cb3f7cc 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,17 @@ activity; save a shortlist; and submit projects for review. ## Features - Searchable catalogue with hierarchical categories, tags, license filters, - sorting, and observed GitHub star history. + Recommended ranking, other sort orders, and observed GitHub star history. - Project pages with Markdown documentation, media, link previews, shareable SVG badges, and admin-curated featured projects. - GitHub and email Magic Link sign-in, private bookmarks, submission tracking, rejection feedback, and resubmission. - Project representative verification, scoped owner/maintainer editing, and membership management. +- Public curated collections, editorial blogs, and an admin-editable About page + using the shared public Markdown renderer. +- Monthly advertising with private guest order links, Ko-fi checkout links, + manual payment confirmation, artwork review, and explicit renewal. - Admin workspace for moderation, reports, taxonomy, users, recorded activity, and operational observations. @@ -76,22 +80,18 @@ and recovery, see [deployment](docs/deployment.md). The five guides below cover local development, the implementation, operations, and product direction: -| Guide | Contents | -| ------------------------------------ | --------------------------------------------------------------------- | -| [Development](docs/development.md) | Local setup, configuration, testing, troubleshooting, and data import | -| [Architecture](docs/architecture.md) | Package responsibilities, request/data flow, and integrations | -| [Deployment](docs/deployment.md) | Builds, migrations, scheduled workers, monitoring, and recovery | -| [Product requirements](docs/prd.md) | User needs, feature behavior, permissions, and scope | -| [Roadmap](docs/roadmap.md) | Implemented scope, priorities, and conditional future work | +| Guide | Contents | +| ------------------------------------ | ----------------------------------------------------------------------- | +| [Development](docs/development.md) | Local setup, configuration, testing, troubleshooting, and data import | +| [Architecture](docs/architecture.md) | Request/data flow, ranking formula, ad selection/privacy, and rendering | +| [Deployment](docs/deployment.md) | Builds, migrations, workers, advertising operations, and recovery | +| [Product requirements](docs/prd.md) | User needs, feature behavior, permissions, and scope | +| [Roadmap](docs/roadmap.md) | Implemented scope, priorities, and conditional future work | See [CONTRIBUTING.md](CONTRIBUTING.md) for bug reports, pull requests, and scoped validation, [SECURITY.md](SECURITY.md) for vulnerability reporting, and [CHANGELOG.md](CHANGELOG.md) for notable changes. -All deployment examples use localhost or reserved example domains. Keep real -environment files, credentials, deployment identifiers, and production records -outside public documentation. - ## License A repository license has not been selected yet. No project license is declared. diff --git a/apps/web/src/components/action-confirmation.tsx b/apps/web/src/components/action-confirmation.tsx index 70001730..88170b67 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/category-combobox-view.tsx b/apps/web/src/components/category-combobox-view.tsx index 84994253..e50a9e2d 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/components/footer.tsx b/apps/web/src/components/footer.tsx index 0555a668..b3012077 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 ff74c06d..13a4ef01 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 72b4918f..87251835 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 8d1b9f8d..e7d249cf 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 00000000..0f3bb590 --- /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 00000000..eac67dcf --- /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 +