From 9d7173f95c31eb9575999c27a73ef1ec12b8e61b Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Tue, 31 Mar 2026 20:33:44 +0100 Subject: [PATCH 01/60] commit plan --- .../2026-03-31-admin-adapter-combined.md | 695 ++++++++++++++++++ 1 file changed, 695 insertions(+) create mode 100644 docs/plans/2026-03-31-admin-adapter-combined.md diff --git a/docs/plans/2026-03-31-admin-adapter-combined.md b/docs/plans/2026-03-31-admin-adapter-combined.md new file mode 100644 index 00000000000..f760af05ff7 --- /dev/null +++ b/docs/plans/2026-03-31-admin-adapter-combined.md @@ -0,0 +1,695 @@ +# AdminAdapter Pattern — Combined Design & Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Decouple Payload CMS from Next.js by introducing an `AdminAdapter` pattern (like `DatabaseAdapter`), enabling frameworks like TanStack Start as alternatives. + +**Architecture:** Two-layer abstraction — a `RouterProvider` React context replaces direct `next/navigation` imports in UI, and an `AdminAdapter` interface abstracts server-side concerns (request init, cookies, server functions, navigation primitives like `notFound`/`redirect`). Views move from `packages/next` to `packages/ui`. `packages/next` becomes a thin adapter implementation. + +**Tech Stack:** TypeScript, React (RSC), Payload CMS monorepo (pnpm + Turbo) + +## Table of Contents + +- [Design Decisions](#design-decisions) +- [RSC Compatibility: Next.js vs TanStack Start](#rsc-compatibility-nextjs-vs-tanstack-start) +- [Package Responsibilities After Refactor](#package-responsibilities-after-refactor) +- [AdminAdapter Interface](#adminadapter-interface) +- [RouterProvider Abstraction](#routerprovider-abstraction) +- [Core Package Decoupling](#core-package-decoupling) +- [Data Flows](#data-flows) +- [TanStack Start Adapter Shape](#tanstack-start-adapter-shape) +- [Implementation Phases](#implementation-phases) + - [Phase 1: Define AdminAdapter Interface in Core](#phase-1-define-adminadapter-interface-in-core) + - [Phase 2: RouterProvider Abstraction](#phase-2-routerprovider-abstraction) + - [Phase 3: Decouple Core Package](#phase-3-decouple-core-package) + - [Phase 4: Move Views from packages/next to packages/ui](#phase-4-move-views-from-packagesnext-to-packagesui) + - [Phase 5: Refactor packages/next to Implement AdminAdapter](#phase-5-refactor-packagesnext-to-implement-adminadapter) + - [Phase 6: CLI and Template Changes](#phase-6-cli-and-template-changes) + - [Phase 7: TanStack Start Proof-of-Concept](#phase-7-tanstack-start-proof-of-concept) + - [Phase 8: Testing](#phase-8-testing) +- [Work Items Summary](#work-items-summary) +- [Dependency Graph](#dependency-graph) + +--- + +## Design Decisions + +| Decision | Choice | +| ----------------- | ---------------------------------------------------------------------- | +| Approach | RouterProvider + AdminAdapter interface | +| React/RSC | React-only, RSC-required | +| Build plugins | Each adapter owns its own | +| Migration | Non-breaking, auto-detect `@payloadcms/next` | +| Adapter name | `AdminAdapter` | +| Views location | Move from `packages/next` to `packages/ui` | +| Core decoupling | Replace 3 Next.js imports with generic alternatives | +| UI decoupling | `RouterProvider` context replaces ~38 direct `next/navigation` imports | +| Server navigation | `notFound()` / `redirect()` abstracted via adapter | +| Config | `admin.adapter` field, optional (defaults to next) | + +--- + +## RSC Compatibility: Next.js vs TanStack Start + +Both frameworks support RSC but with different mechanisms. These differences drive the adapter contract: + +| Concern | Next.js | TanStack Start | Adapter Resolution | +| ---------------- | --------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| Server functions | `'use server'` directive | `createServerFn()` from `@tanstack/start` | `adapter.handleServerFunctions` — already abstracted via `ServerFunctionsProvider` | +| Request context | `headers()` / `cookies()` from `next/headers` | `getWebRequest()` from `vinxi/http` | `adapter.initReq()` — each adapter reads request differently | +| `notFound()` | Throws special error caught by Next.js | `throw notFound()` from `@tanstack/react-router` | `adapter.notFound()` — added to interface | +| `redirect(url)` | Throws special error caught by Next.js | `throw redirect({ to: url })` from `@tanstack/react-router` | `adapter.redirect(url)` — added to interface | +| Caching | `unstable_cache()` / `React.cache()` | Framework-level caching via Vinxi | Caching stays in adapter, not in shared views | +| OG Images | `ImageResponse` from `next/og` | Custom implementation | Stays in adapter package | +| Build config | `withPayload()` wrapping `next.config.mjs` | Vite plugin for `app.config.ts` | Each adapter owns its build plugin | + +### Framework-Specific Code Audit (packages/next) + +| Category | File Count | Must Stay in Adapter | +| --------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------- | +| `notFound()` / `redirect()` calls | ~12 views | Yes — views receive these as callbacks or adapter provides them | +| Client `next/navigation` hooks | 7 client components in packages/next + 38 in packages/ui | No — replaced with `RouterProvider` hooks | +| `initReq` direct calls | 2 views (Root, NotFound) | Yes — adapter calls initReq, passes result as props | +| `'use server'` directive | 4 files (auth actions + Root layout) | Yes — server function transport is adapter-specific | +| `next/headers` (cookies/headers) | 7 utility files | Yes — all go through adapter | +| `ImageResponse` (OG) | 1 file | Yes | + +### Key Constraint + +**`packages/ui` must be purely React — zero framework-specific imports.** Every `next/*` call must either: + +1. Route through the `AdminAdapter` interface, or +2. Be passed as props from the adapter's server entry point + +--- + +## Package Responsibilities After Refactor + +| Package | Responsibility | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `packages/payload` | Core CMS logic + `AdminAdapter` interface + `createAdminAdapter` helper | +| `packages/ui` | All React components + views (render only) + layouts + templates + `RouterProvider` context | +| `packages/next` | `nextAdapter()`: `initReq`, route handlers, `withPayload()`, cookies, auth actions, `notFound`/`redirect`, OG images, `RouterProvider` via `next/navigation` | +| `packages/tanstack-start` | `tanstackStartAdapter()`: same contract, TanStack-specific implementations | + +### View Split Pattern + +Views that currently call `initReq` or `notFound()`/`redirect()` are split: + +``` +BEFORE (in packages/next): + DocumentView.tsx → calls initReq(), calls notFound(), renders UI + +AFTER: + packages/next: DocumentEntry.tsx → calls initReq(), passes data as props + packages/ui: DocumentView.tsx → receives props, calls adapter.notFound() if needed, renders UI +``` + +For `notFound()` / `redirect()`: views receive these as functions from a `ServerNavigation` context provided by the adapter, or call `adapter.notFound()` / `adapter.redirect()` through a hook. + +--- + +## AdminAdapter Interface + +```typescript +// packages/payload/src/admin/adapter/types.ts + +type AdminAdapterResult = { + init: (args: { payload: Payload }) => T + name: string +} + +interface BaseAdminAdapter { + name: string + payload: Payload + + // Server-side request handling + initReq: (args: { + config: SanitizedConfig + importMap: ImportMap + }) => Promise + + // Cookie management + getCookie: (name: string) => string | undefined + setCookie: (name: string, value: string, options?: CookieOptions) => void + deleteCookie: (name: string) => void + + // Server function dispatcher + handleServerFunctions: ServerFunctionHandler + + // Server-side navigation (RSC) + notFound: () => never + redirect: (url: string) => never + + // Client-side router provider + RouterProvider: React.ComponentType<{ children: React.ReactNode }> + + // Route handler factories for REST + GraphQL + createRouteHandlers: (config: SanitizedConfig) => RouteHandlers + + // Optional + generateMetadata?: (args: { + config: SanitizedConfig + }) => Promise> + destroy?: () => void | Promise +} +``` + +**Config usage:** + +```typescript +export default buildConfig({ + db: postgresAdapter({ ... }), + admin: { + adapter: nextAdapter({ ... }), // optional, auto-detected + }, +}) +``` + +### Auto-Detection (Backwards Compatibility) + +1. If `admin.adapter` is explicitly set, use it +2. If not, check if `@payloadcms/next` is installed — auto-import and use as default +3. If neither, throw clear error + +--- + +## RouterProvider Abstraction + +```typescript +// packages/ui/src/providers/Router/types.ts + +type RouterContextType = { + Link: React.ComponentType + useParams: () => Record + usePathname: () => string + useRouter: () => RouterInstance + useSearchParams: () => URLSearchParams +} + +type RouterInstance = { + back: () => void + forward: () => void + prefetch: (url: string) => void + push: (url: string) => void + refresh: () => void + replace: (url: string) => void +} +``` + +Replaces `next/navigation` / `next/link` imports in: + +- ~38 files in `packages/ui/src/` (elements, providers, views, forms, graphics, utilities) +- ~7 client components in `packages/next/src/` (moved to packages/ui) + +--- + +## Core Package Decoupling + +| Import | File | Resolution | +| ------------------------ | ------------------------------------------------------ | ------------------------------------------------------------ | +| `@next/env` | `packages/payload/src/bin/loadEnv.ts` | Replace with `dotenv` + `dotenv-expand` | +| `Metadata` from `'next'` | `packages/payload/src/config/types.ts` | Define `PayloadMetadata` type (subset Payload actually uses) | +| `ReadonlyRequestCookies` | `packages/payload/src/utilities/getRequestLanguage.ts` | Remove from union — callers pass `Map` | + +--- + +## Data Flows + +**Admin page request:** + +``` +Request → Framework route handler + → adapter.initReq() (framework-specific: reads headers/cookies) + → PayloadRequest + → View (packages/ui) renders with data as props + → Components use RouterProvider for navigation + → Views use adapter.notFound() / adapter.redirect() for server navigation +``` + +**Server function call (e.g., form state):** + +``` +UI calls useServerFunctions().getFormState() + → ServerFunctionsProvider → adapter.handleServerFunctions() + → adapter.initReq() + → Dispatches to handler (buildFormStateHandler, etc.) + → Returns result +``` + +--- + +## TanStack Start Adapter Shape + +```typescript +export function tanstackStartAdapter(args?: TanStackStartArgs): AdminAdapterResult { + return { + name: 'tanstack-start', + init: ({ payload }) => createAdminAdapter({ + name: 'tanstack-start', + payload, + initReq: // vinxi getWebRequest / getEvent + getCookie: // vinxi/http getCookie + setCookie: // vinxi/http setCookie + deleteCookie: // vinxi/http deleteCookie + handleServerFunctions: // TanStack createServerFn() wrapper + notFound: // throw notFound() from @tanstack/react-router + redirect: // throw redirect({ to: url }) from @tanstack/react-router + RouterProvider: // @tanstack/react-router hooks + Link + createRouteHandlers: // Vinxi API routes + }) + } +} +``` + +Build plugin as separate export: + +```typescript +// user's app.config.ts +import { withPayload } from '@payloadcms/tanstack-start/vite' +export default withPayload(defineConfig({ ... })) +``` + +--- + +## Implementation Phases + +### Phase 1: Define AdminAdapter Interface in Core + +#### Task 1: Create AdminAdapter types + +**Files:** + +- Create: `packages/payload/src/admin/adapter/types.ts` + +Write the `BaseAdminAdapter`, `AdminAdapterResult`, `CookieOptions`, `RouteHandler`, `RouteHandlers` types as shown in the interface section above. Include `notFound` and `redirect` in the interface. + +**Commit:** `feat: define AdminAdapter types` + +#### Task 2: Create createAdminAdapter helper + +**Files:** + +- Create: `packages/payload/src/admin/adapter/createAdminAdapter.ts` +- Create: `packages/payload/src/admin/adapter/index.ts` (barrel export) +- Modify: `packages/payload/src/index.ts` (add public exports) + +Factory helper modeled after `packages/payload/src/database/createDatabaseAdapter.ts`. + +**Commit:** `feat: add createAdminAdapter helper and exports` + +#### Task 3: Add adapter field to Config types + +**Files:** + +- Modify: `packages/payload/src/config/types.ts` + +Add `adapter?: AdminAdapterResult` to the `admin` block (around line 820). Add the import for `AdminAdapterResult`. + +**Commit:** `feat: add admin.adapter field to Config type` + +#### Task 4: Wire adapter initialization into Payload lifecycle + +**Files:** + +- Modify: `packages/payload/src/index.ts` (around line 881) + +Add `adminAdapter?: BaseAdminAdapter` property to Payload class. After db init, add: + +```typescript +if (this.config.admin?.adapter) { + this.adminAdapter = this.config.admin.adapter.init({ payload: this }) + this.adminAdapter.payload = this +} +``` + +**Commit:** `feat: wire AdminAdapter initialization into Payload lifecycle` + +--- + +### Phase 2: RouterProvider Abstraction + +#### Task 5: Create RouterProvider context in packages/ui + +**Files:** + +- Create: `packages/ui/src/providers/Router/types.ts` +- Create: `packages/ui/src/providers/Router/index.tsx` +- Modify: `packages/ui/src/exports/client/index.ts` (add exports) + +Create `RouterContext`, `RouterProvider`, and hook wrappers (`useRouter`, `usePathname`, `useSearchParams`, `useParams`, `Link`) that read from context. + +**Commit:** `feat(ui): add RouterProvider context and navigation hooks` + +#### Task 6: Add RouterProvider to RootProvider tree + +**Files:** + +- Modify: `packages/ui/src/providers/Root/index.tsx` + +Add optional `router?: RouterContextType` prop. Wrap with `` when provided. Keep backwards compatibility when not provided. + +**Commit:** `feat(ui): integrate RouterProvider into RootProvider tree` + +#### Task 7: Refactor UI files to use RouterProvider hooks + +**Files:** + +- Modify: ~38 files in `packages/ui/src/` + +Mechanical refactoring — replace all `import { useRouter } from 'next/navigation.js'` with `import { useRouter } from '../../providers/Router/index.js'` (adjust paths). Do in batches of ~10, build between batches. + +Full file list in implementation plan appendix. + +**Commit:** `refactor(ui): replace next/navigation imports with RouterProvider hooks` + +--- + +### Phase 3: Decouple Core Package + +#### Task 8: Replace @next/env with dotenv + +**Files:** + +- Modify: `packages/payload/src/bin/loadEnv.ts` +- Modify: `packages/payload/package.json` + +Replace `@next/env` with `dotenv` + `dotenv-expand`. Support same .env file priority (.env, .env.local, .env.development, .env.production). + +**Commit:** `refactor: replace @next/env with dotenv in core package` + +#### Task 9: Replace Metadata type with PayloadMetadata + +**Files:** + +- Modify: `packages/payload/src/config/types.ts` + +Remove `import type { Metadata } from 'next'`. Define `PayloadMetadata` type covering the subset Payload uses (title, description, openGraph, icons, twitter). Change `MetaConfig` from `& DeepClone` to `& PayloadMetadata`. + +**Commit:** `refactor: replace Next.js Metadata type with PayloadMetadata` + +#### Task 10: Remove ReadonlyRequestCookies from core + +**Files:** + +- Modify: `packages/payload/src/utilities/getRequestLanguage.ts` +- Modify: `packages/ui/src/utilities/getRequestLanguage.ts` (if same import exists) + +Remove `ReadonlyRequestCookies` type. Change parameter to `cookies: Map`. Adapters convert their cookie format to Map before calling core. + +**Commit:** `refactor: remove ReadonlyRequestCookies Next.js type from core` + +#### Task 11: Remove 'next' from packages/payload dependencies + +**Files:** + +- Modify: `packages/payload/package.json` + +Verify zero `next` / `@next/*` imports remain, then remove from deps/peerDeps. + +**Commit:** `chore: remove next dependency from core payload package` + +--- + +### Phase 4: Move Views from packages/next to packages/ui + +**Complexity: High** — this is the hardest phase due to RSC framework differences. + +#### Task 12: Split views into server entry + render component + +Views that call `initReq`, `notFound()`, or `redirect()` must be split: + +**Server entry (stays in packages/next):** Calls `initReq()`, fetches data, handles `notFound()`/`redirect()` at the framework level, passes results as props. + +**Render component (moves to packages/ui):** Receives data as props, renders UI. Uses `adapter.notFound()` / `adapter.redirect()` from a `ServerNavigation` hook/context only when needed for post-init logic. + +**Views requiring split (~12):** + +| View | Uses `initReq` | Uses `notFound()` | Uses `redirect()` | +| ----------------- | -------------- | ----------------- | ----------------- | +| Root | Yes | Yes | Yes | +| NotFound | Yes | - | - | +| Document | - | Yes | Yes | +| Account | - | Yes | - | +| List | - | Yes | - | +| Login | - | - | Yes | +| BrowseByFolder | - | Yes | Yes | +| CollectionFolders | - | Yes | Yes | +| CollectionTrash | - | Yes | - | +| Version | - | Yes | - | +| Versions | - | Yes | - | + +**Views that can move cleanly:** Dashboard, Edit, CreateFirstUser, ForgotPassword, ResetPassword, Logout, Verify, Unauthorized, API. + +**Client components (~7) that need RouterProvider swap:** TabLink, Nav client, LogoutClient, ResetPasswordForm, Verify client, API client, CreatedAtCell — same mechanical refactoring as Task 7. + +**Step 1:** Create `ServerNavigation` context in packages/ui for `notFound()` / `redirect()`: + +```typescript +// packages/ui/src/providers/ServerNavigation/index.tsx +const ServerNavigationContext = createContext<{ + notFound: () => never + redirect: (url: string) => never +}>() +``` + +Populated by the adapter at the root layout level. + +**Step 2:** For each view requiring split, extract the render component to packages/ui. The server entry in packages/next becomes thin: + +```typescript +// packages/next/src/views/Document/index.tsx (stays in adapter) +import { DocumentView } from '@payloadcms/ui/views' +import { initReq } from '../../utilities/initReq.js' +import { notFound, redirect } from 'next/navigation.js' + +export async function DocumentEntry(props) { + const { req, permissions } = await initReq(...) + const doc = await payload.findByID(...) + if (!doc) notFound() + if (!permissions.canRead) redirect('/admin/unauthorized') + return +} +``` + +**Step 3:** Update packages/next export files to re-export from packages/ui for backwards compat. + +**Step 4:** Move client components, apply RouterProvider hook swap. + +**Commit:** `refactor: split views into server entries (adapter) and render components (ui)` + +--- + +### Phase 5: Refactor packages/next to Implement AdminAdapter + +#### Task 13: Create nextAdapter() factory function + +**Files:** + +- Create: `packages/next/src/adapter/index.ts` +- Create: `packages/next/src/adapter/RouterProvider.tsx` +- Modify: `packages/next/src/index.js` + +Implement `nextAdapter()` returning `AdminAdapterResult`. Wire existing `initReq`, `handleServerFunctions`, cookie helpers. Create `NextRouterProvider` wrapping `next/navigation` hooks. Implement `notFound` and `redirect` using `next/navigation`. + +**Commit:** `feat(next): create nextAdapter() implementing AdminAdapter interface` + +#### Task 14: Wire NextRouterProvider into Root Layout + +**Files:** + +- Modify: `packages/next/src/layouts/Root/index.tsx` + +Pass router context and `ServerNavigation` (notFound/redirect) to RootProvider. + +**Commit:** `feat(next): wire NextRouterProvider into admin root layout` + +--- + +### Phase 6: CLI and Template Changes + +#### Task 15: Add --framework flag to create-payload-app + +**Files:** + +- Modify: `packages/create-payload-app/src/types.ts` (add `FrameworkType`, `--framework` arg) +- Modify: `packages/create-payload-app/src/main.ts` (add flag + alias, call framework prompt) +- Create: `packages/create-payload-app/src/lib/select-framework.ts` (prompt logic) + +Add `--framework next|tanstack-start` flag (alias `-f`). Add interactive prompt similar to database selection. Default: `next`. + +**Commit:** `feat(create-payload-app): add --framework flag and framework selection prompt` + +#### Task 16: Update templates for framework support + +**Files:** + +- Create: `templates/blank-tanstack-start/` (TanStack Start blank template) +- Modify: `packages/create-payload-app/src/lib/templates.ts` (framework filtering) +- Modify: `packages/create-payload-app/src/lib/ast/payload-config.ts` (adapter imports) +- Modify: `packages/create-payload-app/src/lib/create-project.ts` (pass framework) + +Separate template directories per framework (different project structure). Update AST to insert correct adapter import and config. + +**Commit:** `feat(create-payload-app): add framework-specific templates and AST config` + +--- + +### Phase 7: TanStack Start Proof-of-Concept + +#### Task 17: Scaffold packages/tanstack-start + +**Files:** + +- Create: `packages/tanstack-start/package.json` +- Create: `packages/tanstack-start/src/index.ts` +- Create: `packages/tanstack-start/src/adapter/index.ts` +- Create: `packages/tanstack-start/src/adapter/RouterProvider.tsx` +- Create: `packages/tanstack-start/src/adapter/initReq.ts` +- Create: `packages/tanstack-start/src/vite/index.ts` (withPayload Vite plugin) + +Implement full `AdminAdapterResult` using: + +- `vinxi/http` for request context and cookies +- `@tanstack/start` `createServerFn()` for server functions +- `@tanstack/react-router` hooks for RouterProvider +- `@tanstack/react-router` `notFound()` / `redirect()` for server navigation + +**Commit:** `feat: scaffold @payloadcms/tanstack-start adapter package` + +--- + +### Phase 8: Testing + +#### Task 18: Verify packages/next works identically + +Run full test suite — all existing tests must pass unchanged: + +```bash +pnpm run test:int +pnpm run test:e2e +pnpm run dev # manual smoke test +``` + +#### Task 19: Add adapter-specific integration tests + +**Files:** + +- Create: `test/admin-adapter/config.ts` +- Create: `test/admin-adapter/int.spec.ts` + +Test: explicit adapter config works, auto-detection works, missing adapter errors clearly, `payload.adminAdapter` is set. + +**Commit:** `test: add AdminAdapter integration tests` + +#### Task 20: Refactor e2e test architecture for multi-framework support + +The current e2e test suite (Playwright) is tightly coupled to Next.js — the test harness spins up a Next.js dev server and tests assume Next.js routing behavior. This needs to become framework-agnostic. + +**Current architecture:** + +- Test harness starts a Next.js dev server per test suite +- Playwright navigates `localhost:3000/admin` +- Tests assume Next.js route transitions, loading states, error pages + +**Target architecture:** + +**Step 1: Abstract the dev server harness** + +Create a `FrameworkTestHarness` interface: + +```ts +interface FrameworkTestHarness { + start(config: PayloadTestConfig): Promise<{ url: string }> + stop(): Promise +} +``` + +Each adapter provides its own harness: + +- `@payloadcms/next` → starts Next.js dev server (existing behavior) +- `@payloadcms/tanstack-start` → starts Vinxi dev server + +The harness is selected via `PAYLOAD_FRAMEWORK` env var (defaults to `next`). + +**Step 2: Make tests framework-agnostic** + +- Remove any assertions that depend on Next.js-specific behavior (route transition animations, specific error page markup, etc.) +- Tests should assert on Payload admin UI behavior, not framework behavior +- Use `data-testid` attributes and accessibility snapshots rather than framework-specific selectors + +**Step 3: Run e2e matrix in CI** + +Update `.github/workflows/main.yml` to run e2e tests against each framework: + +```yaml +strategy: + matrix: + framework: [next, tanstack-start] +env: + PAYLOAD_FRAMEWORK: ${{ matrix.framework }} +``` + +**Step 4: Shared vs adapter-specific e2e tests** + +- **Shared tests** (~90%): Admin panel CRUD, navigation, auth, forms — these should work identically on any framework +- **Adapter-specific tests** (~10%): Framework-specific features like Next.js OG images, metadata, build config + +``` +test/ +├── e2e/ +│ ├── shared/ # framework-agnostic admin tests +│ ├── next/ # Next.js-specific e2e tests +│ └── tanstack-start/ # TanStack Start-specific e2e tests +``` + +**Commit:** `test: refactor e2e test architecture for multi-framework support` + +--- + +## Work Items Summary + +| Work Item | Complexity | Phase | +| --------------------------------------------------------------------- | ---------------- | ----- | +| Define `AdminAdapter` interface (with `notFound`/`redirect`) | Medium | 1 | +| Create `createAdminAdapter` helper + exports | Low | 1 | +| Add `admin.adapter` to Config types | Low | 1 | +| Wire adapter into Payload init lifecycle | Low | 1 | +| Create `RouterProvider` context + hooks in `packages/ui` | Low | 2 | +| Add `RouterProvider` to `RootProvider` tree | Low | 2 | +| Refactor ~45 UI files to use `RouterProvider` (38 existing + 7 moved) | Low (mechanical) | 2 | +| Replace `@next/env` with `dotenv` | Low | 3 | +| Replace `Metadata` type with `PayloadMetadata` | Low | 3 | +| Remove `ReadonlyRequestCookies` from core | Trivial | 3 | +| Remove `next` from core dependencies | Trivial | 3 | +| Split ~12 views into server entry + render component | **High** | 4 | +| Move clean views + client components to packages/ui | Medium | 4 | +| Create `ServerNavigation` context for `notFound`/`redirect` | Medium | 4 | +| Refactor `packages/next` to implement `AdminAdapter` | **High** | 5 | +| Wire `NextRouterProvider` into root layout | Low | 5 | +| CLI `--framework` flag + framework selection prompt | Medium | 6 | +| Framework-specific templates | Medium-High | 6 | +| Scaffold `@payloadcms/tanstack-start` | **High** | 7 | +| Testing — integration + adapter-specific | Medium | 8 | +| Testing — refactor e2e architecture for multi-framework | **High** | 8 | +| Testing — CI matrix for framework e2e | Medium | 8 | + +## Dependency Graph + +``` +Phase 1 (Tasks 1-4): Foundation + ↓ +Phase 2 (Tasks 5-7) ──parallel── Phase 3 (Tasks 8-11) + ↓ ↓ +Phase 4 (Task 12): Split & move views ← depends on both + ↓ +Phase 5 (Tasks 13-14): Refactor packages/next + ↓ +Phase 6 (Tasks 15-16): CLI + Templates ← can start after Phase 1 + ↓ +Phase 7 (Task 17): TanStack Start PoC ← depends on Phase 5 + ↓ +Phase 8 (Tasks 18-19): Testing ← depends on all +``` + +**Parallelizable:** Phase 2 + Phase 3. Phase 6 can start early (after Phase 1). From 2c72dfd15c4f717a26657de52ea8c5cafa488c5d Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Tue, 31 Mar 2026 20:40:21 +0100 Subject: [PATCH 02/60] add example --- .../2026-03-31-admin-adapter-combined.md | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/docs/plans/2026-03-31-admin-adapter-combined.md b/docs/plans/2026-03-31-admin-adapter-combined.md index f760af05ff7..86fbb88854f 100644 --- a/docs/plans/2026-03-31-admin-adapter-combined.md +++ b/docs/plans/2026-03-31-admin-adapter-combined.md @@ -13,6 +13,8 @@ - [Design Decisions](#design-decisions) - [RSC Compatibility: Next.js vs TanStack Start](#rsc-compatibility-nextjs-vs-tanstack-start) - [Package Responsibilities After Refactor](#package-responsibilities-after-refactor) + - [View Split Pattern](#view-split-pattern) + - [RSC Component Example: Before & After](#rsc-component-example-before--after) - [AdminAdapter Interface](#adminadapter-interface) - [RouterProvider Abstraction](#routerprovider-abstraction) - [Core Package Decoupling](#core-package-decoupling) @@ -107,6 +109,148 @@ AFTER: For `notFound()` / `redirect()`: views receive these as functions from a `ServerNavigation` context provided by the adapter, or call `adapter.notFound()` / `adapter.redirect()` through a hook. +### RSC Component Example: Before & After + +**CURRENT — tightly coupled to Next.js** (`packages/next/src/views/Document/index.tsx`): + +```tsx +// Imports Next.js-specific APIs directly +import { notFound, redirect } from 'next/navigation.js' + +export const renderDocument = async ({ initPageResult, params, ... }) => { + // initPageResult comes from initReq() called in the Root layout (also Next.js-specific) + const { collectionConfig, docID, locale, permissions, req } = initPageResult + + // Fetches data — this part is already framework-agnostic + const doc = await getDocumentData({ id: docID, collectionSlug, locale, payload: req.payload, req }) + + // Next.js-specific: throws special error caught by Next.js error boundary + if (isEditing && !doc) { + redirect(formatAdminURL({ adminRoute, path: `/collections/${collectionSlug}` })) + } + + const [docPreferences, { docPermissions }, { isLocked }] = await Promise.all([ + getDocPreferences({ id: docID, collectionSlug, req }), + getDocumentPermissions({ collectionConfig, id: docID, req }), + getIsLocked({ collectionSlug, id: docID, req }), + ]) + + // Renders UI — this part is framework-agnostic React + return { + data: doc, + Document: ( + + + + + ), + } +} +``` + +**AFTER — split into adapter entry + framework-agnostic render component:** + +`packages/next/src/views/Document/index.tsx` (adapter — stays in packages/next): + +```tsx +// Next.js adapter: thin entry point that handles framework-specific concerns +import { notFound, redirect } from 'next/navigation.js' +import { DocumentView } from '@payloadcms/ui/views/Document' + +export const renderDocument = async ({ initPageResult, params, ... }) => { + const { collectionConfig, docID, locale, permissions, req } = initPageResult + + const doc = await getDocumentData({ id: docID, collectionSlug, locale, payload: req.payload, req }) + + // Framework-specific navigation — handled HERE, not in the shared view + if (isEditing && !doc && collectionSlug) { + redirect(formatAdminURL({ adminRoute, path: `/collections/${collectionSlug}` })) + } + if (isEditing && !doc) { + notFound() + } + + const [docPreferences, { docPermissions }, { isLocked }] = await Promise.all([...]) + + // Delegates to the framework-agnostic render component + return { + data: doc, + Document: ( + + ), + } +} +``` + +`packages/tanstack-start/src/views/Document/index.tsx` (adapter — in packages/tanstack-start): + +```tsx +// TanStack Start adapter: same logic, different framework primitives +import { notFound, redirect } from '@tanstack/react-router' +import { DocumentView } from '@payloadcms/ui/views/Document' + +export const renderDocument = async ({ initPageResult, params, ... }) => { + const { collectionConfig, docID, locale, permissions, req } = initPageResult + + const doc = await getDocumentData({ id: docID, collectionSlug, locale, payload: req.payload, req }) + + // TanStack Start uses different throw signatures + if (isEditing && !doc && collectionSlug) { + throw redirect({ to: formatAdminURL({ adminRoute, path: `/collections/${collectionSlug}` }) }) + } + if (isEditing && !doc) { + throw notFound() + } + + const [docPreferences, { docPermissions }, { isLocked }] = await Promise.all([...]) + + // Same shared render component + return { + data: doc, + Document: ( + + ), + } +} +``` + +`packages/ui/src/views/Document/index.tsx` (shared — pure React, no framework imports): + +```tsx +// Framework-agnostic render component — pure React RSC +// NO imports from next/*, @tanstack/*, or any framework +import { DocumentInfoProvider, EditDepthProvider } from '@payloadcms/ui' + +export const DocumentView: React.FC = ({ + doc, + docPermissions, + docPreferences, + isLocked, + ... +}) => { + return ( + + + + + ) +} +``` + +**Key takeaway:** The data fetching and framework navigation live in the adapter's thin entry point (~20 lines). The actual UI rendering (~200+ lines) lives in packages/ui and is shared across all frameworks. Each adapter duplicates only the entry point glue, not the rendering logic. + --- ## AdminAdapter Interface From 450f03ace3fe237351d3840f05cc16eb469966e7 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 14:02:40 +0100 Subject: [PATCH 03/60] feat: define AdminAdapter interface, helper, and lifecycle wiring - Add BaseAdminAdapter and AdminAdapterResult types to packages/payload/src/admin/adapter/types.ts - Add createAdminAdapter helper factory (mirrors createDatabaseAdapter pattern) - Add admin.adapter field to Config type - Wire adapter.init() into Payload lifecycle after db init - Export types and helper from packages/payload/src/index.ts Co-Authored-By: Claude Sonnet 4.6 --- .../src/admin/adapter/createAdminAdapter.ts | 5 ++ packages/payload/src/admin/adapter/index.ts | 8 +++ packages/payload/src/admin/adapter/types.ts | 62 +++++++++++++++++++ packages/payload/src/config/types.ts | 6 ++ packages/payload/src/index.ts | 21 ++++++- 5 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 packages/payload/src/admin/adapter/createAdminAdapter.ts create mode 100644 packages/payload/src/admin/adapter/index.ts create mode 100644 packages/payload/src/admin/adapter/types.ts diff --git a/packages/payload/src/admin/adapter/createAdminAdapter.ts b/packages/payload/src/admin/adapter/createAdminAdapter.ts new file mode 100644 index 00000000000..706e1d1c760 --- /dev/null +++ b/packages/payload/src/admin/adapter/createAdminAdapter.ts @@ -0,0 +1,5 @@ +import type { BaseAdminAdapter } from './types.js' + +export function createAdminAdapter(args: T): T { + return args +} diff --git a/packages/payload/src/admin/adapter/index.ts b/packages/payload/src/admin/adapter/index.ts new file mode 100644 index 00000000000..25c0d690a26 --- /dev/null +++ b/packages/payload/src/admin/adapter/index.ts @@ -0,0 +1,8 @@ +export { createAdminAdapter } from './createAdminAdapter.js' +export type { + AdminAdapterResult, + BaseAdminAdapter, + CookieOptions, + RouteHandler, + RouteHandlers, +} from './types.js' diff --git a/packages/payload/src/admin/adapter/types.ts b/packages/payload/src/admin/adapter/types.ts new file mode 100644 index 00000000000..3e6b3f1c5ef --- /dev/null +++ b/packages/payload/src/admin/adapter/types.ts @@ -0,0 +1,62 @@ +import type React from 'react' + +import type { ImportMap } from '../../bin/generateImportMap/index.js' +import type { SanitizedConfig } from '../../config/types.js' +import type { Payload } from '../../types/index.js' +import type { InitReqResult, ServerFunctionHandler } from '../functions/index.js' + +export type CookieOptions = { + domain?: string + expires?: Date + httpOnly?: boolean + maxAge?: number + path?: string + sameSite?: 'lax' | 'none' | 'strict' + secure?: boolean +} + +export type RouteHandler = (request: Request) => Promise | Response + +export type RouteHandlers = { + DELETE?: RouteHandler + GET?: RouteHandler + PATCH?: RouteHandler + POST?: RouteHandler + PUT?: RouteHandler +} + +export type BaseAdminAdapter = { + /** Create route handlers for REST + GraphQL endpoints */ + createRouteHandlers: (config: SanitizedConfig) => RouteHandlers + /** Delete a cookie */ + deleteCookie: (name: string) => void + /** Destroy the adapter (cleanup) */ + destroy?: () => Promise | void + /** Generate metadata for the admin panel */ + generateMetadata?: (args: { config: SanitizedConfig }) => Promise> + /** Get a cookie value by name */ + getCookie: (name: string) => string | undefined + /** Handle server function dispatching */ + handleServerFunctions: ServerFunctionHandler + /** Initialize the request context, returning a PayloadRequest and related data */ + initReq: (args: { config: SanitizedConfig; importMap: ImportMap }) => Promise + /** Adapter name for identification */ + name: string + /** Server-side not found navigation — throws framework-specific error */ + notFound: () => never + /** The Payload instance, set after init */ + payload: Payload + /** Server-side redirect navigation — throws framework-specific error */ + redirect: (url: string) => never + /** Client-side router provider wrapping framework navigation hooks */ + RouterProvider: React.ComponentType<{ children: React.ReactNode }> + /** Set a cookie */ + setCookie: (name: string, value: string, options?: CookieOptions) => void +} + +export type AdminAdapterResult = { + /** Initialize the adapter, binding it to the Payload instance */ + init: (args: { payload: Payload }) => T + /** Adapter name for identification */ + name: string +} diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index e7fbbe4da86..1f791a29e1b 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -16,6 +16,7 @@ import type React from 'react' import type { default as sharp } from 'sharp' import type { DeepRequired } from 'ts-essentials' +import type { AdminAdapterResult } from '../admin/adapter/types.js' import type { RichTextAdapterProvider } from '../admin/RichText.js' import type { DocumentSubViewTypes, @@ -818,6 +819,11 @@ export type SanitizedDashboardConfig = { export type Config = { /** Configure admin dashboard */ admin?: { + /** + * The admin adapter to use for framework-specific concerns (request handling, routing, cookies). + * Defaults to auto-detecting @payloadcms/next if installed. + */ + adapter?: AdminAdapterResult /** Automatically log in as a user */ autoLogin?: | { diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 71dbb550232..b4bee32a85a 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -37,7 +37,8 @@ import { verifyEmailLocal, type Options as VerifyEmailOptions, } from './auth/operations/local/verifyEmail.js' -export type { FieldState } from './admin/forms/Form.js' +export { createAdminAdapter } from './admin/adapter/index.js' +import type { BaseAdminAdapter } from './admin/adapter/types.js' import type { InitOptions, SanitizedConfig } from './config/types.js' import type { BaseDatabaseAdapter, PaginatedDistinctDocs, PaginatedDocs } from './database/types.js' import type { InitializedEmailAdapter } from './email/types.js' @@ -119,6 +120,14 @@ import { updateGlobalLocal, type Options as UpdateGlobalOptions, } from './globals/operations/local/update.js' +export type { + AdminAdapterResult, + BaseAdminAdapter, + CookieOptions, + RouteHandler, + RouteHandlers, +} from './admin/adapter/index.js' +export type { FieldState } from './admin/forms/Form.js' export type * from './admin/types.js' export { EntityType } from './admin/views/dashboard.js' import type { SupportedLanguages } from '@payloadcms/translations' @@ -387,6 +396,8 @@ let checkedDependencies = false * @description Payload */ export class BasePayload { + adminAdapter?: BaseAdminAdapter + /** * @description Authorization and Authentication using headers and cookies to run auth user strategies * @returns permissions: Permissions @@ -401,8 +412,8 @@ export class BasePayload { blocks: Record = {} collections: Record = {} - config!: SanitizedConfig + /** * @description Performs count operation * @param options @@ -446,7 +457,6 @@ export class BasePayload { ): Promise> => { return createLocal(this, options) } - crons: Cron[] = [] db!: DatabaseAdapter @@ -881,6 +891,11 @@ export class BasePayload { this.db = this.config.db.init({ payload: this }) this.db.payload = this + if (this.config.admin?.adapter) { + this.adminAdapter = this.config.admin.adapter.init({ payload: this }) + this.adminAdapter.payload = this + } + this.kv = this.config.kv.init({ payload: this }) if (this.db?.init) { From b1bfbb2376b4c9e5c4e0e9a11484bdeedb354b7f Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 14:12:27 +0100 Subject: [PATCH 04/60] feat(ui): add RouterProvider context and integrate into RootProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add RouterProvider with value-based context (params, pathname, router, searchParams, Link) — avoids dynamic hook calls that react-compiler rejects - Add useRouter, usePathname, useSearchParams, useParams hook wrappers reading from context - Integrate RouterProvider into RootProvider via optional router prop - Export RouterProvider, useRouter, usePathname from public client index Co-Authored-By: Claude Sonnet 4.6 --- packages/ui/src/exports/client/index.ts | 2 ++ packages/ui/src/providers/Root/index.tsx | 12 +++++++- packages/ui/src/providers/Router/index.tsx | 36 ++++++++++++++++++++++ packages/ui/src/providers/Router/types.ts | 32 +++++++++++++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/providers/Router/index.tsx create mode 100644 packages/ui/src/providers/Router/types.ts diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts index a8a194ee218..bcd3e3e1c9b 100644 --- a/packages/ui/src/exports/client/index.ts +++ b/packages/ui/src/exports/client/index.ts @@ -326,6 +326,8 @@ export { RouteTransitionProvider, useRouteTransition, } from '../../providers/RouteTransition/index.js' +export { RouterProvider, usePathname, useRouter } from '../../providers/Router/index.js' +export type { LinkProps, RouterContextType, RouterInstance } from '../../providers/Router/types.js' export { ConfigProvider, PageConfigProvider, useConfig } from '../../providers/Config/index.js' export { DocumentEventsProvider, useDocumentEvents } from '../../providers/DocumentEvents/index.js' export { DocumentInfoProvider, useDocumentInfo } from '../../providers/DocumentInfo/index.js' diff --git a/packages/ui/src/providers/Root/index.tsx b/packages/ui/src/providers/Root/index.tsx index 7552be4c797..74a9cee4f02 100644 --- a/packages/ui/src/providers/Root/index.tsx +++ b/packages/ui/src/providers/Root/index.tsx @@ -14,6 +14,7 @@ import { ModalContainer, ModalProvider } from '@faceless-ui/modal' import { ScrollInfoProvider } from '@faceless-ui/scroll-info' import React from 'react' +import type { RouterContextType } from '../Router/types.js' import type { Theme } from '../Theme/index.js' import { CloseModalOnRouteChange } from '../../elements/CloseModalOnRouteChange/index.js' @@ -31,6 +32,7 @@ import { LocaleProvider } from '../Locale/index.js' import { ParamsProvider } from '../Params/index.js' import { PreferencesProvider } from '../Preferences/index.js' import { RouteCache } from '../RouteCache/index.js' +import { RouterProvider } from '../Router/index.js' import { RouteTransitionProvider } from '../RouteTransition/index.js' import { SearchParamsProvider } from '../SearchParams/index.js' import { ServerFunctionsProvider } from '../ServerFunctions/index.js' @@ -49,6 +51,7 @@ type Props = { readonly languageOptions: LanguageOptions readonly locale?: Locale['code'] readonly permissions: SanitizedPermissions + readonly router?: RouterContextType readonly serverFunction: ServerFunctionClient readonly switchLanguageServerAction?: (lang: string) => Promise readonly theme: Theme @@ -66,6 +69,7 @@ export const RootProvider: React.FC = ({ languageOptions, locale, permissions, + router, serverFunction, switchLanguageServerAction, theme, @@ -74,7 +78,7 @@ export const RootProvider: React.FC = ({ }) => { const dndContextID = React.useId() - return ( + const content = ( @@ -145,4 +149,10 @@ export const RootProvider: React.FC = ({ ) + + if (router) { + return {content} + } + + return content } diff --git a/packages/ui/src/providers/Router/index.tsx b/packages/ui/src/providers/Router/index.tsx new file mode 100644 index 00000000000..6b489596c06 --- /dev/null +++ b/packages/ui/src/providers/Router/index.tsx @@ -0,0 +1,36 @@ +'use client' +import React, { createContext, use } from 'react' + +import type { LinkProps, RouterContextType, RouterInstance } from './types.js' + +export type { LinkProps, RouterContextType, RouterInstance } + +const RouterContext = createContext(null) + +export const RouterProvider: React.FC<{ + children: React.ReactNode + router: RouterContextType +}> = ({ children, router }) => { + return {children} +} + +function useRouterContext(): RouterContextType { + const ctx = use(RouterContext) + if (!ctx) { + throw new Error('RouterProvider is not in the tree. Make sure your admin adapter provides one.') + } + return ctx +} + +export const useRouter = (): RouterInstance => useRouterContext().router + +export const usePathname = (): string => useRouterContext().pathname + +export const useSearchParams = (): URLSearchParams => useRouterContext().searchParams + +export const useParams = (): Record => useRouterContext().params + +export const Link: React.FC = (props) => { + const { Link: AdapterLink } = useRouterContext() + return +} diff --git a/packages/ui/src/providers/Router/types.ts b/packages/ui/src/providers/Router/types.ts new file mode 100644 index 00000000000..fcd2c79ea3a --- /dev/null +++ b/packages/ui/src/providers/Router/types.ts @@ -0,0 +1,32 @@ +import type React from 'react' + +export type LinkProps = { + children?: React.ReactNode + href: string + onClick?: React.MouseEventHandler + prefetch?: boolean + replace?: boolean + scroll?: boolean +} & Omit, 'href' | 'onClick'> + +export type RouterInstance = { + back: () => void + forward: () => void + prefetch: (url: string) => void + push: (url: string) => void + refresh: () => void + replace: (url: string) => void +} + +/** + * Values provided by a RouterProvider. The adapter creates a component that + * calls framework-specific hooks and places the results here, so that + * `packages/ui` never imports from any framework directly. + */ +export type RouterContextType = { + Link: React.ComponentType + params: Record + pathname: string + router: RouterInstance + searchParams: URLSearchParams +} From 7169001d2faf6573b7e21834f7178a87a7dfb2bd Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 14:39:59 +0100 Subject: [PATCH 05/60] refactor: decouple core packages from Next.js and implement nextAdapter Phase 2: Replace all next/navigation imports in packages/ui (~41 files) with RouterProvider hooks. Add NavigateOptions to RouterInstance for scroll support. Replace AppRouterInstance, ReadonlyURLSearchParams, and next/link LinkProps type imports with framework-agnostic equivalents. Phase 3: Remove all Next.js dependencies from packages/payload. - Replace @next/env with dotenv + dotenv-expand in loadEnv.ts - Replace Metadata type from 'next' with PayloadMetadata subset - Remove ReadonlyRequestCookies from getRequestLanguage (use Map) Phase 5: Create nextAdapter() factory in packages/next implementing the BaseAdminAdapter interface. Add NextRouterProvider wrapping next/navigation hooks into the RouterProvider context. Wire NextRouterProvider into the Root Layout. Co-Authored-By: Claude Sonnet 4.6 --- packages/next/src/adapter/RouterProvider.tsx | 45 ++++++++++++ packages/next/src/adapter/index.ts | 47 ++++++++++++ packages/next/src/index.js | 1 + packages/next/src/layouts/Root/index.tsx | 71 ++++++++++--------- .../next/src/utilities/getNextRequestI18n.ts | 6 +- packages/payload/package.json | 3 +- packages/payload/src/admin/adapter/types.ts | 8 +-- packages/payload/src/bin/loadEnv.ts | 58 ++++++++++++--- packages/payload/src/config/types.ts | 34 ++++++++- .../src/utilities/getRequestLanguage.ts | 9 +-- .../CloseModalOnRouteChange/index.tsx | 2 +- .../ui/src/elements/CopyLocaleData/index.tsx | 2 +- .../elements/DefaultListViewTabs/index.tsx | 2 +- .../ui/src/elements/DeleteDocument/index.tsx | 2 +- packages/ui/src/elements/DeleteMany/index.tsx | 2 +- .../src/elements/DuplicateDocument/index.tsx | 2 +- .../src/elements/EditMany/DrawerContent.tsx | 2 +- .../FolderView/CurrentFolderActions/index.tsx | 2 +- .../LeaveWithoutSaving/usePreventLeave.tsx | 6 +- packages/ui/src/elements/Link/index.tsx | 2 +- .../TitleActions/ListBulkUploadButton.tsx | 2 +- .../TitleActions/ListEmptyTrashButton.tsx | 2 +- packages/ui/src/elements/Localizer/index.tsx | 2 +- packages/ui/src/elements/Nav/context.tsx | 2 +- .../PermanentlyDeleteButton/index.tsx | 2 +- .../elements/Popup/PopupButtonList/index.tsx | 4 +- .../elements/PublishMany/DrawerContent.tsx | 2 +- .../ui/src/elements/RestoreButton/index.tsx | 2 +- .../ui/src/elements/RestoreMany/index.tsx | 2 +- .../ui/src/elements/SortComplex/index.tsx | 4 +- .../ui/src/elements/StayLoggedIn/index.tsx | 2 +- .../elements/UnpublishMany/DrawerContent.tsx | 2 +- packages/ui/src/exports/client/index.ts | 7 +- packages/ui/src/forms/Form/index.tsx | 2 +- packages/ui/src/graphics/Account/index.tsx | 2 +- packages/ui/src/providers/Auth/index.tsx | 2 +- packages/ui/src/providers/Folders/index.tsx | 2 +- packages/ui/src/providers/ListQuery/index.tsx | 2 +- packages/ui/src/providers/Locale/index.tsx | 2 +- packages/ui/src/providers/Params/index.tsx | 3 +- .../ui/src/providers/RouteCache/index.tsx | 2 +- packages/ui/src/providers/Router/types.ts | 8 ++- .../ui/src/providers/SearchParams/index.tsx | 3 +- packages/ui/src/providers/Selection/index.tsx | 2 +- .../ui/src/providers/Translation/index.tsx | 3 +- .../ui/src/utilities/getRequestLanguage.ts | 10 +-- .../src/utilities/handleBackToDashboard.tsx | 6 +- packages/ui/src/utilities/handleGoBack.tsx | 6 +- .../ui/src/utilities/parseSearchParams.ts | 4 +- .../ui/src/views/BrowseByFolder/index.tsx | 2 +- .../ui/src/views/CollectionFolder/index.tsx | 2 +- packages/ui/src/views/Edit/index.tsx | 2 +- packages/ui/src/views/List/index.tsx | 2 +- pnpm-lock.yaml | 42 ++++++----- 54 files changed, 314 insertions(+), 136 deletions(-) create mode 100644 packages/next/src/adapter/RouterProvider.tsx create mode 100644 packages/next/src/adapter/index.ts diff --git a/packages/next/src/adapter/RouterProvider.tsx b/packages/next/src/adapter/RouterProvider.tsx new file mode 100644 index 00000000000..f782b57fd93 --- /dev/null +++ b/packages/next/src/adapter/RouterProvider.tsx @@ -0,0 +1,45 @@ +'use client' +import type { RouterContextType, LinkProps as RouterLinkProps } from '@payloadcms/ui' + +import { RouterProvider as BaseRouterProvider } from '@payloadcms/ui' +import NextLinkImport from 'next/link.js' +import { + useParams as useNextParams, + usePathname as useNextPathname, + useRouter as useNextRouter, + useSearchParams as useNextSearchParams, +} from 'next/navigation.js' +import React from 'react' + +const NextLink = 'default' in NextLinkImport ? NextLinkImport.default : NextLinkImport + +const AdapterLink: React.FC = ({ children, ...rest }) => { + return {children} +} + +export const NextRouterProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const nextRouter = useNextRouter() + const pathname = useNextPathname() + const searchParams = useNextSearchParams() + const params = useNextParams() + + const router: RouterContextType = React.useMemo( + () => ({ + Link: AdapterLink, + params, + pathname, + router: { + back: nextRouter.back, + forward: nextRouter.forward, + prefetch: nextRouter.prefetch, + push: nextRouter.push, + refresh: nextRouter.refresh, + replace: nextRouter.replace, + }, + searchParams, + }), + [nextRouter, pathname, searchParams, params], + ) + + return {children} +} diff --git a/packages/next/src/adapter/index.ts b/packages/next/src/adapter/index.ts new file mode 100644 index 00000000000..2785434177a --- /dev/null +++ b/packages/next/src/adapter/index.ts @@ -0,0 +1,47 @@ +import type { AdminAdapterResult, BaseAdminAdapter, CookieOptions } from 'payload' + +import { notFound, redirect } from 'next/navigation.js' +import { createAdminAdapter } from 'payload' + +import { handleServerFunctions } from '../utilities/handleServerFunctions.js' +import { initReq as nextInitReq } from '../utilities/initReq.js' +import { NextRouterProvider } from './RouterProvider.js' + +export function nextAdapter(): AdminAdapterResult { + return { + name: 'next', + init: ({ payload }) => { + return createAdminAdapter({ + name: 'next', + createRouteHandlers: () => { + // Route handlers in the Next.js adapter are set up via the file-system routing + // convention (app/api/[...slug]/route.ts) rather than being created dynamically. + // This is a no-op for the Next.js adapter. + return {} + }, + deleteCookie: async (name: string) => { + const { cookies } = await import('next/headers.js') + const cookieStore = await cookies() + cookieStore.delete(name) + }, + getCookie: async (name: string) => { + const { cookies } = await import('next/headers.js') + const cookieStore = await cookies() + return cookieStore.get(name)?.value + }, + handleServerFunctions, + initReq: ({ config, importMap }) => + nextInitReq({ configPromise: config, importMap, key: 'adapter' }), + notFound: () => notFound(), + payload, + redirect: (url: string) => redirect(url), + RouterProvider: NextRouterProvider, + setCookie: async (name: string, value: string, options?: CookieOptions) => { + const { cookies } = await import('next/headers.js') + const cookieStore = await cookies() + cookieStore.set(name, value, options) + }, + } satisfies BaseAdminAdapter) + }, + } +} diff --git a/packages/next/src/index.js b/packages/next/src/index.js index d9154a66aa9..744b9e9501c 100644 --- a/packages/next/src/index.js +++ b/packages/next/src/index.js @@ -1 +1,2 @@ +export { nextAdapter } from './adapter/index.js' export { default as withPayload } from './withPayload/withPayload.js' diff --git a/packages/next/src/layouts/Root/index.tsx b/packages/next/src/layouts/Root/index.tsx index e9e1e6bd9b5..dfb5fc4699e 100644 --- a/packages/next/src/layouts/Root/index.tsx +++ b/packages/next/src/layouts/Root/index.tsx @@ -8,6 +8,7 @@ import { cookies as nextCookies } from 'next/headers.js' import { applyLocaleFiltering } from 'payload/shared' import React, { Suspense } from 'react' +import { NextRouterProvider } from '../../adapter/RouterProvider.js' import { getNavPrefs } from '../../elements/Nav/getNavPrefs.js' import { getRequestTheme } from '../../utilities/getRequestTheme.js' import { initReq } from '../../utilities/initReq.js' @@ -130,40 +131,42 @@ const RootLayoutContent = async ({ - - - {Array.isArray(config.admin?.components?.providers) && - config.admin?.components?.providers.length > 0 ? ( - - {children} - - ) : ( - children - )} - + + + + {Array.isArray(config.admin?.components?.providers) && + config.admin?.components?.providers.length > 0 ? ( + + {children} + + ) : ( + children + )} + +
diff --git a/packages/next/src/utilities/getNextRequestI18n.ts b/packages/next/src/utilities/getNextRequestI18n.ts index 07c71a3a0b8..0301ce4c301 100644 --- a/packages/next/src/utilities/getNextRequestI18n.ts +++ b/packages/next/src/utilities/getNextRequestI18n.ts @@ -27,6 +27,10 @@ export const getNextRequestI18n = async < return (await initI18n({ config: config.i18n, context: 'client', - language: getRequestLanguage({ config, cookies: await cookies(), headers: await headers() }), + language: getRequestLanguage({ + config, + cookies: new Map((await cookies()).getAll().map((c) => [c.name, c.value])), + headers: await headers(), + }), })) as any } diff --git a/packages/payload/package.json b/packages/payload/package.json index fb72419fa81..14b4a463509 100644 --- a/packages/payload/package.json +++ b/packages/payload/package.json @@ -99,7 +99,6 @@ "pretest": "pnpm build" }, "dependencies": { - "@next/env": "^15.1.5", "@payloadcms/translations": "workspace:*", "@types/busboy": "1.5.4", "ajv": "8.17.1", @@ -110,6 +109,8 @@ "croner": "9.1.0", "dataloader": "2.2.3", "deepmerge": "4.3.1", + "dotenv": "16.4.7", + "dotenv-expand": "12.0.1", "file-type": "19.3.0", "get-tsconfig": "4.8.1", "http-status": "2.1.0", diff --git a/packages/payload/src/admin/adapter/types.ts b/packages/payload/src/admin/adapter/types.ts index 3e6b3f1c5ef..585446051d4 100644 --- a/packages/payload/src/admin/adapter/types.ts +++ b/packages/payload/src/admin/adapter/types.ts @@ -25,17 +25,17 @@ export type RouteHandlers = { PUT?: RouteHandler } -export type BaseAdminAdapter = { +export interface BaseAdminAdapter { /** Create route handlers for REST + GraphQL endpoints */ createRouteHandlers: (config: SanitizedConfig) => RouteHandlers /** Delete a cookie */ - deleteCookie: (name: string) => void + deleteCookie: (name: string) => Promise | void /** Destroy the adapter (cleanup) */ destroy?: () => Promise | void /** Generate metadata for the admin panel */ generateMetadata?: (args: { config: SanitizedConfig }) => Promise> /** Get a cookie value by name */ - getCookie: (name: string) => string | undefined + getCookie: (name: string) => Promise | string | undefined /** Handle server function dispatching */ handleServerFunctions: ServerFunctionHandler /** Initialize the request context, returning a PayloadRequest and related data */ @@ -51,7 +51,7 @@ export type BaseAdminAdapter = { /** Client-side router provider wrapping framework navigation hooks */ RouterProvider: React.ComponentType<{ children: React.ReactNode }> /** Set a cookie */ - setCookie: (name: string, value: string, options?: CookieOptions) => void + setCookie: (name: string, value: string, options?: CookieOptions) => Promise | void } export type AdminAdapterResult = { diff --git a/packages/payload/src/bin/loadEnv.ts b/packages/payload/src/bin/loadEnv.ts index a365783a521..3b2a58b3cd9 100644 --- a/packages/payload/src/bin/loadEnv.ts +++ b/packages/payload/src/bin/loadEnv.ts @@ -1,27 +1,63 @@ -import nextEnvImport from '@next/env' +import dotenvImport from 'dotenv' +import dotenvExpandImport from 'dotenv-expand' +import fs from 'fs' +import path from 'path' import { findUpSync } from '../utilities/findUp.js' -const { loadEnvConfig } = nextEnvImport + +const dotenvConfig = + 'config' in dotenvImport ? dotenvImport.config : (dotenvImport as any).default.config +const dotenvExpand = + 'expand' in dotenvExpandImport + ? dotenvExpandImport.expand + : (dotenvExpandImport as any).default.expand + +function getEnvFilenames(dev: boolean): string[] { + if (dev) { + return ['.env.development.local', '.env.local', '.env.development', '.env'] + } + return ['.env.production.local', '.env.local', '.env.production', '.env'] +} + +function loadEnvFromDir(dir: string, dev: boolean): boolean { + const filenames = getEnvFilenames(dev) + let loaded = false + + for (const filename of filenames) { + const filePath = path.resolve(dir, filename) + try { + fs.accessSync(filePath) + } catch { + continue + } + const env = dotenvConfig({ path: filePath }) + if (env.parsed) { + dotenvExpand(env) + loaded = true + } + } + + return loaded +} /** - * Try to find user's env files and load it. Uses the same algorithm next.js uses to parse env files, meaning this also supports .env.local, .env.development, .env.production, etc. + * Try to find user's env files and load them. Supports .env, .env.local, + * .env.development, .env.production (same priority as Next.js). */ -export function loadEnv(path?: string) { - if (path?.length) { - loadEnvConfig(path, true) +export function loadEnv(envPath?: string) { + if (envPath?.length) { + loadEnvFromDir(envPath, true) return } const dev = process.env.NODE_ENV !== 'production' - const { loadedEnvFiles } = loadEnvConfig(process.cwd(), dev) + const loaded = loadEnvFromDir(process.cwd(), dev) - if (!loadedEnvFiles?.length) { - // use findUp to find the env file. So, run loadEnvConfig for every directory upwards + if (!loaded) { findUpSync({ // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve condition: (dir) => { - const { loadedEnvFiles } = loadEnvConfig(dir, true) - if (loadedEnvFiles?.length) { + if (loadEnvFromDir(dir, true)) { return true } }, diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 1f791a29e1b..f736c3587b5 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -10,7 +10,6 @@ import type { BusboyConfig } from 'busboy' import type GraphQL from 'graphql' import type { GraphQLFormattedError } from 'graphql' import type { JSONSchema4 } from 'json-schema' -import type { Metadata } from 'next' import type { DestinationStream, Level, LoggerOptions } from 'pino' import type React from 'react' import type { default as sharp } from 'sharp' @@ -208,6 +207,37 @@ export type OGImageConfig = { */ type DeepClone = T extends object ? { [K in keyof T]: DeepClone } : T +/** Subset of Next.js Metadata that Payload actually consumes. */ +export type PayloadMetadata = { + description?: null | string + icons?: + | { + apple?: PayloadMetadataIcon[] + icon?: PayloadMetadataIcon[] + shortcut?: PayloadMetadataIcon[] + } + | null + | PayloadMetadataIcon[] + keywords?: null | string | string[] + metadataBase?: null | URL + openGraph?: { + description?: string + images?: OGImageConfig[] + siteName?: string + title?: string + } | null + robots?: { follow?: boolean; index?: boolean } | null | string + title?: null | string +} + +type PayloadMetadataIcon = { + media?: string + rel?: string + sizes?: string + type?: string + url: string +} + export type MetaConfig = { /** * When `static`, a pre-made image will be used for all pages. @@ -221,7 +251,7 @@ export type MetaConfig = { * @example `" - Custom CMS"` */ titleSuffix?: string -} & DeepClone +} & DeepClone export type ServerOnlyLivePreviewProperties = keyof Pick diff --git a/packages/payload/src/utilities/getRequestLanguage.ts b/packages/payload/src/utilities/getRequestLanguage.ts index e2193052ec3..d88cc6ec33a 100644 --- a/packages/payload/src/utilities/getRequestLanguage.ts +++ b/packages/payload/src/utilities/getRequestLanguage.ts @@ -1,5 +1,4 @@ import type { AcceptedLanguages } from '@payloadcms/translations' -import type { ReadonlyRequestCookies } from 'next/dist/server/web/spec-extension/adapters/request-cookies.js' import { extractHeaderLanguage } from '@payloadcms/translations' @@ -7,7 +6,7 @@ import type { SanitizedConfig } from '../config/types.js' type GetRequestLanguageArgs = { config: SanitizedConfig - cookies: Map | ReadonlyRequestCookies + cookies: Map defaultLanguage?: AcceptedLanguages headers: Request['headers'] } @@ -18,10 +17,8 @@ export const getRequestLanguage = ({ headers, }: GetRequestLanguageArgs): AcceptedLanguages => { const supportedLanguageKeys = Object.keys(config.i18n.supportedLanguages) as AcceptedLanguages[] - const langCookie = cookies.get(`${config.cookiePrefix || 'payload'}-lng`) - - const languageFromCookie: AcceptedLanguages = ( - typeof langCookie === 'string' ? langCookie : langCookie?.value + const languageFromCookie = cookies.get( + `${config.cookiePrefix || 'payload'}-lng`, ) as AcceptedLanguages if (languageFromCookie && supportedLanguageKeys.includes(languageFromCookie)) { diff --git a/packages/ui/src/elements/CloseModalOnRouteChange/index.tsx b/packages/ui/src/elements/CloseModalOnRouteChange/index.tsx index 551ebecfd46..c673657b610 100644 --- a/packages/ui/src/elements/CloseModalOnRouteChange/index.tsx +++ b/packages/ui/src/elements/CloseModalOnRouteChange/index.tsx @@ -1,10 +1,10 @@ 'use client' import { useModal } from '@faceless-ui/modal' -import { usePathname } from 'next/navigation.js' import { useEffect, useRef } from 'react' import { useEffectEvent } from '../../hooks/useEffectEvent.js' +import { usePathname } from '../../providers/Router/index.js' export function CloseModalOnRouteChange() { const { closeAllModals } = useModal() diff --git a/packages/ui/src/elements/CopyLocaleData/index.tsx b/packages/ui/src/elements/CopyLocaleData/index.tsx index 544f22ab032..1af9a773dc8 100644 --- a/packages/ui/src/elements/CopyLocaleData/index.tsx +++ b/packages/ui/src/elements/CopyLocaleData/index.tsx @@ -2,7 +2,6 @@ import { useModal } from '@faceless-ui/modal' import { getTranslation } from '@payloadcms/translations' -import { useRouter } from 'next/navigation.js' import { formatAdminURL } from 'payload/shared' import React, { useCallback } from 'react' import { toast } from 'sonner' @@ -13,6 +12,7 @@ import { useFormModified } from '../../forms/Form/context.js' import { useConfig } from '../../providers/Config/index.js' import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' import { useLocale } from '../../providers/Locale/index.js' +import { useRouter } from '../../providers/Router/index.js' import { useRouteTransition } from '../../providers/RouteTransition/index.js' import { useServerFunctions } from '../../providers/ServerFunctions/index.js' import { useTranslation } from '../../providers/Translation/index.js' diff --git a/packages/ui/src/elements/DefaultListViewTabs/index.tsx b/packages/ui/src/elements/DefaultListViewTabs/index.tsx index a363259c153..fa1ad3c65e3 100644 --- a/packages/ui/src/elements/DefaultListViewTabs/index.tsx +++ b/packages/ui/src/elements/DefaultListViewTabs/index.tsx @@ -3,11 +3,11 @@ import type { ClientCollectionConfig, ClientConfig, ViewTypes } from 'payload' import { getTranslation } from '@payloadcms/translations' -import { useRouter } from 'next/navigation.js' import { formatAdminURL } from 'payload/shared' import React from 'react' import { usePreferences } from '../../providers/Preferences/index.js' +import { useRouter } from '../../providers/Router/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { Button } from '../Button/index.js' import './index.scss' diff --git a/packages/ui/src/elements/DeleteDocument/index.tsx b/packages/ui/src/elements/DeleteDocument/index.tsx index 4d546e1728c..6ecef20eec4 100644 --- a/packages/ui/src/elements/DeleteDocument/index.tsx +++ b/packages/ui/src/elements/DeleteDocument/index.tsx @@ -3,7 +3,6 @@ import type { SanitizedCollectionConfig } from 'payload' import { useModal } from '@faceless-ui/modal' import { getTranslation } from '@payloadcms/translations' -import { useRouter } from 'next/navigation.js' import { formatAdminURL } from 'payload/shared' import React, { Fragment, useCallback, useState } from 'react' import { toast } from 'sonner' @@ -15,6 +14,7 @@ import { useForm } from '../../forms/Form/context.js' import { useConfig } from '../../providers/Config/index.js' import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' import { useDocumentTitle } from '../../providers/DocumentTitle/index.js' +import { useRouter } from '../../providers/Router/index.js' import { useRouteTransition } from '../../providers/RouteTransition/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { requests } from '../../utilities/api.js' diff --git a/packages/ui/src/elements/DeleteMany/index.tsx b/packages/ui/src/elements/DeleteMany/index.tsx index 783a568b4ac..a7fa4a6081f 100644 --- a/packages/ui/src/elements/DeleteMany/index.tsx +++ b/packages/ui/src/elements/DeleteMany/index.tsx @@ -3,7 +3,6 @@ import type { ClientCollectionConfig, ViewTypes, Where } from 'payload' import { useModal } from '@faceless-ui/modal' import { getTranslation } from '@payloadcms/translations' -import { useRouter, useSearchParams } from 'next/navigation.js' import { formatAdminURL, mergeListSearchAndWhere } from 'payload/shared' import * as qs from 'qs-esm' import React from 'react' @@ -14,6 +13,7 @@ import { useAuth } from '../../providers/Auth/index.js' import { useConfig } from '../../providers/Config/index.js' import { useLocale } from '../../providers/Locale/index.js' import { useRouteCache } from '../../providers/RouteCache/index.js' +import { useRouter, useSearchParams } from '../../providers/Router/index.js' import { SelectAllStatus, useSelection } from '../../providers/Selection/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { requests } from '../../utilities/api.js' diff --git a/packages/ui/src/elements/DuplicateDocument/index.tsx b/packages/ui/src/elements/DuplicateDocument/index.tsx index bdb500a5aa6..bc2922200e8 100644 --- a/packages/ui/src/elements/DuplicateDocument/index.tsx +++ b/packages/ui/src/elements/DuplicateDocument/index.tsx @@ -4,7 +4,6 @@ import type { SanitizedCollectionConfig } from 'payload' import { useModal } from '@faceless-ui/modal' import { getTranslation } from '@payloadcms/translations' -import { useRouter } from 'next/navigation.js' import { formatAdminURL, hasDraftsEnabled } from 'payload/shared' import * as qs from 'qs-esm' import React, { useCallback, useMemo } from 'react' @@ -15,6 +14,7 @@ import type { DocumentDrawerContextType } from '../DocumentDrawer/Provider.js' import { useForm, useFormModified } from '../../forms/Form/context.js' import { useConfig } from '../../providers/Config/index.js' import { useLocale } from '../../providers/Locale/index.js' +import { useRouter } from '../../providers/Router/index.js' import { useRouteTransition } from '../../providers/RouteTransition/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { requests } from '../../utilities/api.js' diff --git a/packages/ui/src/elements/EditMany/DrawerContent.tsx b/packages/ui/src/elements/EditMany/DrawerContent.tsx index 526a79d7a57..17589c783a8 100644 --- a/packages/ui/src/elements/EditMany/DrawerContent.tsx +++ b/packages/ui/src/elements/EditMany/DrawerContent.tsx @@ -4,7 +4,6 @@ import type { SelectType, Where } from 'payload' import { useModal } from '@faceless-ui/modal' import { getTranslation } from '@payloadcms/translations' -import { useRouter, useSearchParams } from 'next/navigation.js' import { combineWhereConstraints, formatAdminURL, @@ -28,6 +27,7 @@ import { useConfig } from '../../providers/Config/index.js' import { DocumentInfoProvider } from '../../providers/DocumentInfo/index.js' import { useLocale } from '../../providers/Locale/index.js' import { OperationContext } from '../../providers/Operation/index.js' +import { useRouter, useSearchParams } from '../../providers/Router/index.js' import { useServerFunctions } from '../../providers/ServerFunctions/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js' diff --git a/packages/ui/src/elements/FolderView/CurrentFolderActions/index.tsx b/packages/ui/src/elements/FolderView/CurrentFolderActions/index.tsx index cc77bd85335..1e4dd9e4ef1 100644 --- a/packages/ui/src/elements/FolderView/CurrentFolderActions/index.tsx +++ b/packages/ui/src/elements/FolderView/CurrentFolderActions/index.tsx @@ -1,6 +1,5 @@ import { useModal } from '@faceless-ui/modal' import { getTranslation } from '@payloadcms/translations' -import { useRouter } from 'next/navigation.js' import { formatAdminURL } from 'payload/shared' import React from 'react' import { toast } from 'sonner' @@ -9,6 +8,7 @@ import { Dots } from '../../../icons/Dots/index.js' import { useConfig } from '../../../providers/Config/index.js' import { useFolder } from '../../../providers/Folders/index.js' import { useRouteCache } from '../../../providers/RouteCache/index.js' +import { useRouter } from '../../../providers/Router/index.js' import { useRouteTransition } from '../../../providers/RouteTransition/index.js' import { useTranslation } from '../../../providers/Translation/index.js' import { ConfirmationModal } from '../../ConfirmationModal/index.js' diff --git a/packages/ui/src/elements/LeaveWithoutSaving/usePreventLeave.tsx b/packages/ui/src/elements/LeaveWithoutSaving/usePreventLeave.tsx index 73f4ce5d8de..356b1aeb863 100644 --- a/packages/ui/src/elements/LeaveWithoutSaving/usePreventLeave.tsx +++ b/packages/ui/src/elements/LeaveWithoutSaving/usePreventLeave.tsx @@ -1,11 +1,11 @@ 'use client' +import { useCallback, useEffect, useRef } from 'react' + // Credit: @Taiki92777 // - Source: https://github.com/vercel/next.js/discussions/32231#discussioncomment-7284386 // Credit: `react-use` maintainers // - Source: https://github.com/streamich/react-use/blob/ade8d3905f544305515d010737b4ae604cc51024/src/useBeforeUnload.ts#L2 -import { useRouter } from 'next/navigation.js' -import { useCallback, useEffect, useRef } from 'react' - +import { useRouter } from '../../providers/Router/index.js' import { useRouteTransition } from '../../providers/RouteTransition/index.js' function on( diff --git a/packages/ui/src/elements/Link/index.tsx b/packages/ui/src/elements/Link/index.tsx index 2090851d9c3..8beca2bbf91 100644 --- a/packages/ui/src/elements/Link/index.tsx +++ b/packages/ui/src/elements/Link/index.tsx @@ -1,8 +1,8 @@ 'use client' import NextLinkImport from 'next/link.js' -import { useRouter } from 'next/navigation.js' import React from 'react' +import { useRouter } from '../../providers/Router/index.js' import { useRouteTransition } from '../../providers/RouteTransition/index.js' import { formatUrl } from './formatUrl.js' diff --git a/packages/ui/src/elements/ListHeader/TitleActions/ListBulkUploadButton.tsx b/packages/ui/src/elements/ListHeader/TitleActions/ListBulkUploadButton.tsx index dcdfa35e0e1..e9585ff1c26 100644 --- a/packages/ui/src/elements/ListHeader/TitleActions/ListBulkUploadButton.tsx +++ b/packages/ui/src/elements/ListHeader/TitleActions/ListBulkUploadButton.tsx @@ -2,10 +2,10 @@ import type { CollectionSlug } from 'payload' import { useModal } from '@faceless-ui/modal' -import { useRouter } from 'next/navigation.js' import React from 'react' import { useBulkUpload } from '../../../elements/BulkUpload/index.js' +import { useRouter } from '../../../providers/Router/index.js' import { useTranslation } from '../../../providers/Translation/index.js' import { Button } from '../../Button/index.js' diff --git a/packages/ui/src/elements/ListHeader/TitleActions/ListEmptyTrashButton.tsx b/packages/ui/src/elements/ListHeader/TitleActions/ListEmptyTrashButton.tsx index ffd23f208f1..d9916a91815 100644 --- a/packages/ui/src/elements/ListHeader/TitleActions/ListEmptyTrashButton.tsx +++ b/packages/ui/src/elements/ListHeader/TitleActions/ListEmptyTrashButton.tsx @@ -3,7 +3,6 @@ import type { ClientCollectionConfig } from 'payload' import { useModal } from '@faceless-ui/modal' import { getTranslation } from '@payloadcms/translations' -import { useRouter, useSearchParams } from 'next/navigation.js' import { formatAdminURL } from 'payload/shared' import * as qs from 'qs-esm' import React from 'react' @@ -12,6 +11,7 @@ import { toast } from 'sonner' import { useConfig } from '../../../providers/Config/index.js' import { useLocale } from '../../../providers/Locale/index.js' import { useRouteCache } from '../../../providers/RouteCache/index.js' +import { useRouter, useSearchParams } from '../../../providers/Router/index.js' import { useTranslation } from '../../../providers/Translation/index.js' import { requests } from '../../../utilities/api.js' import { Button } from '../../Button/index.js' diff --git a/packages/ui/src/elements/Localizer/index.tsx b/packages/ui/src/elements/Localizer/index.tsx index 88b6a79160c..f78af6c29ea 100644 --- a/packages/ui/src/elements/Localizer/index.tsx +++ b/packages/ui/src/elements/Localizer/index.tsx @@ -1,11 +1,11 @@ 'use client' import { getTranslation } from '@payloadcms/translations' -import { useRouter } from 'next/navigation.js' import * as qs from 'qs-esm' import React, { Fragment } from 'react' import { useConfig } from '../../providers/Config/index.js' import { useLocale, useLocaleLoading } from '../../providers/Locale/index.js' +import { useRouter } from '../../providers/Router/index.js' import { useRouteTransition } from '../../providers/RouteTransition/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { Popup, PopupList } from '../Popup/index.js' diff --git a/packages/ui/src/elements/Nav/context.tsx b/packages/ui/src/elements/Nav/context.tsx index 6f44ad5df7a..b171d73137a 100644 --- a/packages/ui/src/elements/Nav/context.tsx +++ b/packages/ui/src/elements/Nav/context.tsx @@ -1,10 +1,10 @@ 'use client' import { useWindowInfo } from '@faceless-ui/window-info' -import { usePathname } from 'next/navigation.js' import { PREFERENCE_KEYS } from 'payload/shared' import React, { useEffect, useRef } from 'react' import { usePreferences } from '../../providers/Preferences/index.js' +import { usePathname } from '../../providers/Router/index.js' type NavContextType = { hydrated: boolean diff --git a/packages/ui/src/elements/PermanentlyDeleteButton/index.tsx b/packages/ui/src/elements/PermanentlyDeleteButton/index.tsx index a5446d38717..580eeda3fd6 100644 --- a/packages/ui/src/elements/PermanentlyDeleteButton/index.tsx +++ b/packages/ui/src/elements/PermanentlyDeleteButton/index.tsx @@ -4,7 +4,6 @@ import type { SanitizedCollectionConfig } from 'payload' import { useModal } from '@faceless-ui/modal' import { getTranslation } from '@payloadcms/translations' -import { useRouter } from 'next/navigation.js' import { formatAdminURL } from 'payload/shared' import * as qs from 'qs-esm' import React, { Fragment, useCallback } from 'react' @@ -14,6 +13,7 @@ import type { DocumentDrawerContextType } from '../DocumentDrawer/Provider.js' import { useConfig } from '../../providers/Config/index.js' import { useDocumentTitle } from '../../providers/DocumentTitle/index.js' +import { useRouter } from '../../providers/Router/index.js' import { useRouteTransition } from '../../providers/RouteTransition/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { requests } from '../../utilities/api.js' diff --git a/packages/ui/src/elements/Popup/PopupButtonList/index.tsx b/packages/ui/src/elements/Popup/PopupButtonList/index.tsx index 64f856f98f4..7171a79ecc2 100644 --- a/packages/ui/src/elements/Popup/PopupButtonList/index.tsx +++ b/packages/ui/src/elements/Popup/PopupButtonList/index.tsx @@ -1,8 +1,8 @@ 'use client' -import type { LinkProps } from 'next/link.js' - import * as React from 'react' +import type { LinkProps } from '../../../providers/Router/types.js' + import { Link } from '../../Link/index.js' import './index.scss' diff --git a/packages/ui/src/elements/PublishMany/DrawerContent.tsx b/packages/ui/src/elements/PublishMany/DrawerContent.tsx index 4a4ae911ddc..f5fa79dd58c 100644 --- a/packages/ui/src/elements/PublishMany/DrawerContent.tsx +++ b/packages/ui/src/elements/PublishMany/DrawerContent.tsx @@ -1,7 +1,6 @@ import type { Where } from 'payload' import { getTranslation } from '@payloadcms/translations' -import { useRouter, useSearchParams } from 'next/navigation.js' import { combineWhereConstraints, formatAdminURL, mergeListSearchAndWhere } from 'payload/shared' import * as qs from 'qs-esm' import React, { useCallback } from 'react' @@ -12,6 +11,7 @@ import type { PublishManyProps } from './index.js' import { useConfig } from '../../providers/Config/index.js' import { useLocale } from '../../providers/Locale/index.js' import { useRouteCache } from '../../providers/RouteCache/index.js' +import { useRouter, useSearchParams } from '../../providers/Router/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { requests } from '../../utilities/api.js' import { parseSearchParams } from '../../utilities/parseSearchParams.js' diff --git a/packages/ui/src/elements/RestoreButton/index.tsx b/packages/ui/src/elements/RestoreButton/index.tsx index 6f8fcfb36c9..25fd5b25d7f 100644 --- a/packages/ui/src/elements/RestoreButton/index.tsx +++ b/packages/ui/src/elements/RestoreButton/index.tsx @@ -4,7 +4,6 @@ import type { SanitizedCollectionConfig } from 'payload' import { useModal } from '@faceless-ui/modal' import { getTranslation } from '@payloadcms/translations' -import { useRouter } from 'next/navigation.js' import { formatAdminURL } from 'payload/shared' import * as qs from 'qs-esm' import React, { Fragment, useCallback, useState } from 'react' @@ -15,6 +14,7 @@ import type { DocumentDrawerContextType } from '../DocumentDrawer/Provider.js' import { CheckboxInput } from '../../fields/Checkbox/Input.js' import { useConfig } from '../../providers/Config/index.js' import { useDocumentTitle } from '../../providers/DocumentTitle/index.js' +import { useRouter } from '../../providers/Router/index.js' import { useRouteTransition } from '../../providers/RouteTransition/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { requests } from '../../utilities/api.js' diff --git a/packages/ui/src/elements/RestoreMany/index.tsx b/packages/ui/src/elements/RestoreMany/index.tsx index 38f48e13884..ee1a0e451ea 100644 --- a/packages/ui/src/elements/RestoreMany/index.tsx +++ b/packages/ui/src/elements/RestoreMany/index.tsx @@ -3,7 +3,6 @@ import type { ClientCollectionConfig, ViewTypes, Where } from 'payload' import { useModal } from '@faceless-ui/modal' import { getTranslation } from '@payloadcms/translations' -import { useRouter, useSearchParams } from 'next/navigation.js' import { formatAdminURL, mergeListSearchAndWhere } from 'payload/shared' import * as qs from 'qs-esm' import React from 'react' @@ -14,6 +13,7 @@ import { useAuth } from '../../providers/Auth/index.js' import { useConfig } from '../../providers/Config/index.js' import { useLocale } from '../../providers/Locale/index.js' import { useRouteCache } from '../../providers/RouteCache/index.js' +import { useRouter, useSearchParams } from '../../providers/Router/index.js' import { SelectAllStatus, useSelection } from '../../providers/Selection/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { requests } from '../../utilities/api.js' diff --git a/packages/ui/src/elements/SortComplex/index.tsx b/packages/ui/src/elements/SortComplex/index.tsx index 4dc38d4b3ae..8e34dd1e28c 100644 --- a/packages/ui/src/elements/SortComplex/index.tsx +++ b/packages/ui/src/elements/SortComplex/index.tsx @@ -2,13 +2,13 @@ import type { OptionObject, SanitizedCollectionConfig } from 'payload' import { getTranslation } from '@payloadcms/translations' -// TODO: abstract the `next/navigation` dependency out from this component -import { usePathname, useRouter, useSearchParams } from 'next/navigation.js' import { sortableFieldTypes } from 'payload' import { fieldAffectsData } from 'payload/shared' import * as qs from 'qs-esm' import React, { useEffect, useState } from 'react' +import { usePathname, useRouter, useSearchParams } from '../../providers/Router/index.js' + export type SortComplexProps = { collection: SanitizedCollectionConfig handleChange?: (sort: string) => void diff --git a/packages/ui/src/elements/StayLoggedIn/index.tsx b/packages/ui/src/elements/StayLoggedIn/index.tsx index bd4b8c1b15c..431b14641bd 100644 --- a/packages/ui/src/elements/StayLoggedIn/index.tsx +++ b/packages/ui/src/elements/StayLoggedIn/index.tsx @@ -1,5 +1,4 @@ 'use client' -import { useRouter } from 'next/navigation.js' import { formatAdminURL } from 'payload/shared' import React, { useCallback } from 'react' @@ -7,6 +6,7 @@ import type { OnCancel } from '../ConfirmationModal/index.js' import { useAuth } from '../../providers/Auth/index.js' import { useConfig } from '../../providers/Config/index.js' +import { useRouter } from '../../providers/Router/index.js' import { useRouteTransition } from '../../providers/RouteTransition/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { ConfirmationModal } from '../ConfirmationModal/index.js' diff --git a/packages/ui/src/elements/UnpublishMany/DrawerContent.tsx b/packages/ui/src/elements/UnpublishMany/DrawerContent.tsx index 3ce7c06791f..8972ad093ea 100644 --- a/packages/ui/src/elements/UnpublishMany/DrawerContent.tsx +++ b/packages/ui/src/elements/UnpublishMany/DrawerContent.tsx @@ -1,7 +1,6 @@ import type { Where } from 'payload' import { getTranslation } from '@payloadcms/translations' -import { useRouter, useSearchParams } from 'next/navigation.js' import { combineWhereConstraints, formatAdminURL, mergeListSearchAndWhere } from 'payload/shared' import * as qs from 'qs-esm' import React, { useCallback } from 'react' @@ -12,6 +11,7 @@ import type { UnpublishManyProps } from './index.js' import { useConfig } from '../../providers/Config/index.js' import { useLocale } from '../../providers/Locale/index.js' import { useRouteCache } from '../../providers/RouteCache/index.js' +import { useRouter, useSearchParams } from '../../providers/Router/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { requests } from '../../utilities/api.js' import { parseSearchParams } from '../../utilities/parseSearchParams.js' diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts index bcd3e3e1c9b..8e6d698a591 100644 --- a/packages/ui/src/exports/client/index.ts +++ b/packages/ui/src/exports/client/index.ts @@ -327,7 +327,12 @@ export { useRouteTransition, } from '../../providers/RouteTransition/index.js' export { RouterProvider, usePathname, useRouter } from '../../providers/Router/index.js' -export type { LinkProps, RouterContextType, RouterInstance } from '../../providers/Router/types.js' +export type { + LinkProps, + NavigateOptions, + RouterContextType, + RouterInstance, +} from '../../providers/Router/types.js' export { ConfigProvider, PageConfigProvider, useConfig } from '../../providers/Config/index.js' export { DocumentEventsProvider, useDocumentEvents } from '../../providers/DocumentEvents/index.js' export { DocumentInfoProvider, useDocumentInfo } from '../../providers/DocumentInfo/index.js' diff --git a/packages/ui/src/forms/Form/index.tsx b/packages/ui/src/forms/Form/index.tsx index 8a5a105a3fc..2452bb35561 100644 --- a/packages/ui/src/forms/Form/index.tsx +++ b/packages/ui/src/forms/Form/index.tsx @@ -1,6 +1,5 @@ 'use client' import { dequal } from 'dequal/lite' // lite: no need for Map and Set support -import { useRouter } from 'next/navigation.js' import { serialize } from 'object-to-formdata' import { type FormState, type PayloadRequest } from 'payload' import { @@ -33,6 +32,7 @@ import { useConfig } from '../../providers/Config/index.js' import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' import { useLocale } from '../../providers/Locale/index.js' import { useOperation } from '../../providers/Operation/index.js' +import { useRouter } from '../../providers/Router/index.js' import { useRouteTransition } from '../../providers/RouteTransition/index.js' import { useServerFunctions } from '../../providers/ServerFunctions/index.js' import { useTranslation } from '../../providers/Translation/index.js' diff --git a/packages/ui/src/graphics/Account/index.tsx b/packages/ui/src/graphics/Account/index.tsx index e071dd79be3..a0ea7e76ad6 100644 --- a/packages/ui/src/graphics/Account/index.tsx +++ b/packages/ui/src/graphics/Account/index.tsx @@ -1,10 +1,10 @@ 'use client' -import { usePathname } from 'next/navigation.js' import { formatAdminURL } from 'payload/shared' import React from 'react' import { useAuth } from '../../providers/Auth/index.js' import { useConfig } from '../../providers/Config/index.js' +import { usePathname } from '../../providers/Router/index.js' import { DefaultAccountIcon } from './Default/index.js' import { GravatarAccountIcon } from './Gravatar/index.js' diff --git a/packages/ui/src/providers/Auth/index.tsx b/packages/ui/src/providers/Auth/index.tsx index ddcff9e9dd7..ce6d42f8cf8 100644 --- a/packages/ui/src/providers/Auth/index.tsx +++ b/packages/ui/src/providers/Auth/index.tsx @@ -2,7 +2,6 @@ import type { ClientUser, SanitizedPermissions, TypedUser } from 'payload' import { useModal } from '@faceless-ui/modal' -import { usePathname, useRouter } from 'next/navigation.js' import { formatAdminURL } from 'payload/shared' import * as qs from 'qs-esm' import React, { createContext, use, useCallback, useEffect, useState } from 'react' @@ -13,6 +12,7 @@ import { useEffectEvent } from '../../hooks/useEffectEvent.js' import { useTranslation } from '../../providers/Translation/index.js' import { requests } from '../../utilities/api.js' import { useConfig } from '../Config/index.js' +import { usePathname, useRouter } from '../Router/index.js' import { useRouteTransition } from '../RouteTransition/index.js' export type UserWithToken = { diff --git a/packages/ui/src/providers/Folders/index.tsx b/packages/ui/src/providers/Folders/index.tsx index 82ad74fe864..d3cbeb5ae13 100644 --- a/packages/ui/src/providers/Folders/index.tsx +++ b/packages/ui/src/providers/Folders/index.tsx @@ -3,7 +3,6 @@ import type { ClientCollectionConfig, CollectionSlug, FolderSortKeys } from 'payload' import type { FolderBreadcrumb, FolderDocumentItemKey, FolderOrDocument } from 'payload/shared' -import { useRouter, useSearchParams } from 'next/navigation.js' import { extractID, formatAdminURL, formatFolderOrDocumentItem } from 'payload/shared' import * as qs from 'qs-esm' import React from 'react' @@ -13,6 +12,7 @@ import { useDrawerDepth } from '../../elements/Drawer/index.js' import { parseSearchParams } from '../../utilities/parseSearchParams.js' import { useConfig } from '../Config/index.js' import { useLocale } from '../Locale/index.js' +import { useRouter, useSearchParams } from '../Router/index.js' import { useRouteTransition } from '../RouteTransition/index.js' import { useTranslation } from '../Translation/index.js' import { groupItemIDsByRelation } from './groupItemIDsByRelation.js' diff --git a/packages/ui/src/providers/ListQuery/index.tsx b/packages/ui/src/providers/ListQuery/index.tsx index ebbacb95d49..a79c98bf5a8 100644 --- a/packages/ui/src/providers/ListQuery/index.tsx +++ b/packages/ui/src/providers/ListQuery/index.tsx @@ -1,5 +1,4 @@ 'use client' -import { useRouter, useSearchParams } from 'next/navigation.js' import { type ListQuery, type Where } from 'payload' import * as qs from 'qs-esm' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -11,6 +10,7 @@ import { useEffectEvent } from '../../hooks/useEffectEvent.js' import { useRouteTransition } from '../../providers/RouteTransition/index.js' import { parseSearchParams } from '../../utilities/parseSearchParams.js' import { useConfig } from '../Config/index.js' +import { useRouter, useSearchParams } from '../Router/index.js' import { ListQueryContext, ListQueryModifiedContext } from './context.js' import { mergeQuery } from './mergeQuery.js' import { sanitizeQuery } from './sanitizeQuery.js' diff --git a/packages/ui/src/providers/Locale/index.tsx b/packages/ui/src/providers/Locale/index.tsx index 1e20b756af4..3b9618033a9 100644 --- a/packages/ui/src/providers/Locale/index.tsx +++ b/packages/ui/src/providers/Locale/index.tsx @@ -2,13 +2,13 @@ import type { Locale } from 'payload' -import { useSearchParams } from 'next/navigation.js' import { formatAdminURL } from 'payload/shared' import React, { createContext, use, useEffect, useRef, useState } from 'react' import { findLocaleFromCode } from '../../utilities/findLocaleFromCode.js' import { useAuth } from '../Auth/index.js' import { useConfig } from '../Config/index.js' +import { useSearchParams } from '../Router/index.js' const LocaleContext = createContext({} as Locale) diff --git a/packages/ui/src/providers/Params/index.tsx b/packages/ui/src/providers/Params/index.tsx index 15d9a2bc514..25c568432e8 100644 --- a/packages/ui/src/providers/Params/index.tsx +++ b/packages/ui/src/providers/Params/index.tsx @@ -1,8 +1,9 @@ 'use client' -import { useParams as useNextParams } from 'next/navigation.js' import React, { createContext, use } from 'react' +import { useParams as useNextParams } from '../Router/index.js' + export type Params = ReturnType interface IParamsContext extends Params {} diff --git a/packages/ui/src/providers/RouteCache/index.tsx b/packages/ui/src/providers/RouteCache/index.tsx index 36e8bf5ee37..55dd2c52dc1 100644 --- a/packages/ui/src/providers/RouteCache/index.tsx +++ b/packages/ui/src/providers/RouteCache/index.tsx @@ -1,9 +1,9 @@ 'use client' -import { usePathname, useRouter } from 'next/navigation.js' import React, { createContext, use, useCallback, useEffect, useRef } from 'react' import { useEffectEvent } from '../../hooks/useEffectEvent.js' +import { usePathname, useRouter } from '../Router/index.js' export type RouteCacheContext = { cachingEnabled: boolean diff --git a/packages/ui/src/providers/Router/types.ts b/packages/ui/src/providers/Router/types.ts index fcd2c79ea3a..5bb36ffbd0a 100644 --- a/packages/ui/src/providers/Router/types.ts +++ b/packages/ui/src/providers/Router/types.ts @@ -9,13 +9,17 @@ export type LinkProps = { scroll?: boolean } & Omit, 'href' | 'onClick'> +export type NavigateOptions = { + scroll?: boolean +} + export type RouterInstance = { back: () => void forward: () => void prefetch: (url: string) => void - push: (url: string) => void + push: (url: string, options?: NavigateOptions) => void refresh: () => void - replace: (url: string) => void + replace: (url: string, options?: NavigateOptions) => void } /** diff --git a/packages/ui/src/providers/SearchParams/index.tsx b/packages/ui/src/providers/SearchParams/index.tsx index cb66385ea84..3e3dafece1a 100644 --- a/packages/ui/src/providers/SearchParams/index.tsx +++ b/packages/ui/src/providers/SearchParams/index.tsx @@ -1,9 +1,10 @@ 'use client' -import { useSearchParams as useNextSearchParams } from 'next/navigation.js' import * as qs from 'qs-esm' import React, { createContext, use } from 'react' +import { useSearchParams as useNextSearchParams } from '../Router/index.js' + export type SearchParamsContext = { searchParams: qs.ParsedQs stringifyParams: ({ params, replace }: { params: qs.ParsedQs; replace?: boolean }) => string diff --git a/packages/ui/src/providers/Selection/index.tsx b/packages/ui/src/providers/Selection/index.tsx index f55ec5d87a6..897b905c7b7 100644 --- a/packages/ui/src/providers/Selection/index.tsx +++ b/packages/ui/src/providers/Selection/index.tsx @@ -1,7 +1,6 @@ 'use client' import type { Where } from 'payload' -import { useSearchParams } from 'next/navigation.js' import * as qs from 'qs-esm' import React, { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -9,6 +8,7 @@ import { parseSearchParams } from '../../utilities/parseSearchParams.js' import { useAuth } from '../Auth/index.js' import { useListQuery } from '../ListQuery/index.js' import { useLocale } from '../Locale/index.js' +import { useSearchParams } from '../Router/index.js' export enum SelectAllStatus { AllAvailable = 'allAvailable', diff --git a/packages/ui/src/providers/Translation/index.tsx b/packages/ui/src/providers/Translation/index.tsx index 01ba55fbd22..181099069ea 100644 --- a/packages/ui/src/providers/Translation/index.tsx +++ b/packages/ui/src/providers/Translation/index.tsx @@ -13,9 +13,10 @@ import type { LanguageOptions } from 'payload' import { importDateFNSLocale, t } from '@payloadcms/translations' import { enUS } from 'date-fns/locale/en-US' -import { useRouter } from 'next/navigation.js' import React, { createContext, use, useEffect, useState } from 'react' +import { useRouter } from '../Router/index.js' + type ContextType< TAdditionalTranslations = {}, TAdditionalClientTranslationKeys extends string = never, diff --git a/packages/ui/src/utilities/getRequestLanguage.ts b/packages/ui/src/utilities/getRequestLanguage.ts index 1c6813a8361..3f2c197029f 100644 --- a/packages/ui/src/utilities/getRequestLanguage.ts +++ b/packages/ui/src/utilities/getRequestLanguage.ts @@ -1,7 +1,5 @@ -import type { ReadonlyRequestCookies } from 'next/dist/server/web/spec-extension/adapters/request-cookies.js' - type GetRequestLanguageArgs = { - cookies: Map | ReadonlyRequestCookies + cookies: Map defaultLanguage?: string headers: Request['headers'] } @@ -14,9 +12,5 @@ export const getRequestLanguage = ({ const acceptLanguage = headers.get('Accept-Language') const cookieLanguage = cookies.get('lng') - return ( - acceptLanguage || - (typeof cookieLanguage === 'string' ? cookieLanguage : cookieLanguage.value) || - defaultLanguage - ) + return acceptLanguage || cookieLanguage || defaultLanguage } diff --git a/packages/ui/src/utilities/handleBackToDashboard.tsx b/packages/ui/src/utilities/handleBackToDashboard.tsx index d5b25efc159..02b0d77c9ef 100644 --- a/packages/ui/src/utilities/handleBackToDashboard.tsx +++ b/packages/ui/src/utilities/handleBackToDashboard.tsx @@ -1,10 +1,10 @@ -import type { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime.js' - import { formatAdminURL } from 'payload/shared' +import type { RouterInstance } from '../providers/Router/types.js' + type BackToDashboardProps = { adminRoute: string - router: AppRouterInstance + router: RouterInstance serverURL?: string } diff --git a/packages/ui/src/utilities/handleGoBack.tsx b/packages/ui/src/utilities/handleGoBack.tsx index 5a4cec75055..04f56f6933a 100644 --- a/packages/ui/src/utilities/handleGoBack.tsx +++ b/packages/ui/src/utilities/handleGoBack.tsx @@ -1,11 +1,11 @@ -import type { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime.js' - import { formatAdminURL } from 'payload/shared' +import type { RouterInstance } from '../providers/Router/types.js' + type GoBackProps = { adminRoute: string collectionSlug: string - router: AppRouterInstance + router: RouterInstance serverURL?: string } diff --git a/packages/ui/src/utilities/parseSearchParams.ts b/packages/ui/src/utilities/parseSearchParams.ts index d6631321010..b07fa75b327 100644 --- a/packages/ui/src/utilities/parseSearchParams.ts +++ b/packages/ui/src/utilities/parseSearchParams.ts @@ -1,5 +1,3 @@ -import type { ReadonlyURLSearchParams } from 'next/navigation.js' - import * as qs from 'qs-esm' /** @@ -10,7 +8,7 @@ import * as qs from 'qs-esm' * @param {ReadonlyURLSearchParams} searchParams - The URLSearchParams object to parse. * @returns {qs.ParsedQs} - The parsed query string object. */ -export function parseSearchParams(searchParams: ReadonlyURLSearchParams): qs.ParsedQs { +export function parseSearchParams(searchParams: URLSearchParams): qs.ParsedQs { const search = searchParams.toString() return qs.parse(search, { diff --git a/packages/ui/src/views/BrowseByFolder/index.tsx b/packages/ui/src/views/BrowseByFolder/index.tsx index a4fae274238..51c70c775d1 100644 --- a/packages/ui/src/views/BrowseByFolder/index.tsx +++ b/packages/ui/src/views/BrowseByFolder/index.tsx @@ -5,7 +5,6 @@ import type { FolderListViewClientProps } from 'payload' import { useDndMonitor } from '@dnd-kit/core' import { getTranslation } from '@payloadcms/translations' -import { useRouter } from 'next/navigation.js' import { PREFERENCE_KEYS } from 'payload/shared' import React, { Fragment } from 'react' @@ -28,6 +27,7 @@ import { useEditDepth } from '../../providers/EditDepth/index.js' import { FolderProvider, useFolder } from '../../providers/Folders/index.js' import { usePreferences } from '../../providers/Preferences/index.js' import { useRouteCache } from '../../providers/RouteCache/index.js' +import { useRouter } from '../../providers/Router/index.js' import { useRouteTransition } from '../../providers/RouteTransition/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { useWindowInfo } from '../../providers/WindowInfo/index.js' diff --git a/packages/ui/src/views/CollectionFolder/index.tsx b/packages/ui/src/views/CollectionFolder/index.tsx index 6a2a30511d6..bfcfa1b632f 100644 --- a/packages/ui/src/views/CollectionFolder/index.tsx +++ b/packages/ui/src/views/CollectionFolder/index.tsx @@ -5,7 +5,6 @@ import type { FolderListViewClientProps } from 'payload' import { useDndMonitor } from '@dnd-kit/core' import { getTranslation } from '@payloadcms/translations' -import { useRouter } from 'next/navigation.js' import { formatAdminURL } from 'payload/shared' import React, { Fragment } from 'react' @@ -30,6 +29,7 @@ import { useEditDepth } from '../../providers/EditDepth/index.js' import { FolderProvider, useFolder } from '../../providers/Folders/index.js' import { usePreferences } from '../../providers/Preferences/index.js' import { useRouteCache } from '../../providers/RouteCache/index.js' +import { useRouter } from '../../providers/Router/index.js' import './index.scss' import { useRouteTransition } from '../../providers/RouteTransition/index.js' import { useTranslation } from '../../providers/Translation/index.js' diff --git a/packages/ui/src/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx index 9f1cfceeabe..5e31ad82498 100644 --- a/packages/ui/src/views/Edit/index.tsx +++ b/packages/ui/src/views/Edit/index.tsx @@ -2,7 +2,6 @@ import type { ClientUser, DocumentViewClientProps } from 'payload' -import { useRouter, useSearchParams } from 'next/navigation.js' import { formatAdminURL, hasAutosaveEnabled } from 'payload/shared' import React, { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' @@ -30,6 +29,7 @@ import { useEditDepth } from '../../providers/EditDepth/index.js' import { useLivePreviewContext, usePreviewURL } from '../../providers/LivePreview/context.js' import { OperationProvider } from '../../providers/Operation/index.js' import { useRouteCache } from '../../providers/RouteCache/index.js' +import { useRouter, useSearchParams } from '../../providers/Router/index.js' import { useRouteTransition } from '../../providers/RouteTransition/index.js' import { useServerFunctions } from '../../providers/ServerFunctions/index.js' import { UploadControlsProvider } from '../../providers/UploadControls/index.js' diff --git a/packages/ui/src/views/List/index.tsx b/packages/ui/src/views/List/index.tsx index 661ca384068..246ad010fdd 100644 --- a/packages/ui/src/views/List/index.tsx +++ b/packages/ui/src/views/List/index.tsx @@ -3,7 +3,6 @@ import type { ListViewClientProps } from 'payload' import { getTranslation } from '@payloadcms/translations' -import { useRouter } from 'next/navigation.js' import { formatAdminURL, formatFilesize } from 'payload/shared' import React, { Fragment, useEffect } from 'react' @@ -24,6 +23,7 @@ import { ViewDescription } from '../../elements/ViewDescription/index.js' import { useControllableState } from '../../hooks/useControllableState.js' import { useConfig } from '../../providers/Config/index.js' import { useListQuery } from '../../providers/ListQuery/index.js' +import { useRouter } from '../../providers/Router/index.js' import { SelectionProvider } from '../../providers/Selection/index.js' import { TableColumnsProvider } from '../../providers/TableColumns/index.js' import { useTranslation } from '../../providers/Translation/index.js' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51b897a8504..6bc1dda1a26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -873,9 +873,6 @@ importers: packages/payload: dependencies: - '@next/env': - specifier: ^15.1.5 - version: 15.5.14 '@payloadcms/translations': specifier: workspace:* version: link:../translations @@ -906,6 +903,12 @@ importers: deepmerge: specifier: 4.3.1 version: 4.3.1 + dotenv: + specifier: 16.4.7 + version: 16.4.7 + dotenv-expand: + specifier: 12.0.1 + version: 12.0.1 file-type: specifier: 19.3.0 version: 19.3.0 @@ -1876,7 +1879,7 @@ importers: version: 16.8.1 next: specifier: 16.2.1 - version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + version: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) payload: specifier: workspace:* version: link:../../packages/payload @@ -1913,7 +1916,7 @@ importers: version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: 16.2.1 - version: 16.2.1(@typescript-eslint/parser@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3))(eslint-plugin-import-x@4.6.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3) + version: 16.2.1(eslint-plugin-import-x@4.6.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3) jsdom: specifier: 28.0.0 version: 28.0.0(@noble/hashes@1.8.0) @@ -2208,7 +2211,7 @@ importers: version: 16.4.7 geist: specifier: ^1.3.0 - version: 1.7.0(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0)) + version: 1.7.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0)) graphql: specifier: 16.8.1 version: 16.8.1 @@ -2217,10 +2220,10 @@ importers: version: 0.563.0(react@19.2.4) next: specifier: 16.2.1 - version: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) next-sitemap: specifier: ^4.2.3 - version: 4.2.3(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0)) + version: 4.2.3(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0)) payload: specifier: workspace:* version: link:../../packages/payload @@ -2281,7 +2284,7 @@ importers: version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: 16.2.1 - version: 16.2.1(eslint-plugin-import-x@4.6.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3) + version: 16.2.1(@typescript-eslint/parser@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3))(eslint-plugin-import-x@4.6.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3) jsdom: specifier: 28.0.0 version: 28.0.0(@noble/hashes@1.8.0) @@ -5590,9 +5593,6 @@ packages: '@next/env@13.5.11': resolution: {integrity: sha512-fbb2C7HChgM7CemdCY+y3N1n8pcTKdqtQLbC7/EQtPdLvlMUT9JX/dBYl8MMZAtYG4uVMyPFHXckb68q/NRwqg==} - '@next/env@15.5.14': - resolution: {integrity: sha512-aXeirLYuASxEgi4X4WhfXsShCFxWDfNn/8ZeC5YXAS2BB4A8FJi1kwwGL6nvMVboE7fZCzmJPNdMvVHc8JpaiA==} - '@next/env@16.2.1': resolution: {integrity: sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==} @@ -9621,6 +9621,10 @@ packages: resolution: {integrity: sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==} engines: {node: '>=18'} + dotenv-expand@12.0.1: + resolution: {integrity: sha512-LaKRbou8gt0RNID/9RoI+J2rvXsBRPMV7p+ElHlPhcSARbCPDYcYG2s1TIzAfWv4YSgyY5taidWzzs31lNV3yQ==} + engines: {node: '>=12'} + dotenv@16.4.7: resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} engines: {node: '>=12'} @@ -18454,8 +18458,6 @@ snapshots: '@next/env@13.5.11': {} - '@next/env@15.5.14': {} - '@next/env@16.2.1': {} '@next/eslint-plugin-next@15.5.14': @@ -23125,6 +23127,10 @@ snapshots: dependencies: type-fest: 4.41.0 + dotenv-expand@12.0.1: + dependencies: + dotenv: 16.4.7 + dotenv@16.4.7: {} drizzle-kit@0.31.7: @@ -24548,6 +24554,10 @@ snapshots: - encoding - supports-color + geist@1.7.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0)): + dependencies: + next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + geist@1.7.0(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0)): dependencies: next: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) @@ -26146,13 +26156,13 @@ snapshots: transitivePeerDependencies: - supports-color - next-sitemap@4.2.3(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0)): + next-sitemap@4.2.3(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0)): dependencies: '@corex/deepmerge': 4.0.43 '@next/env': 13.5.11 fast-glob: 3.3.3 minimist: 1.2.8 - next: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: From dce2d0238ad408dd16b308c769b21a4646f08c13 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 15:19:08 +0100 Subject: [PATCH 06/60] refactor: split views, refactor client components, add ServerNavigation Phase 4 (partial): Split Login and NotFound views into server entry (packages/next) + render component (packages/ui). Login server entry handles redirect when user is logged in, delegates rendering to framework-agnostic LoginView in packages/ui. NotFound client component moved to packages/ui. Refactor all 11 client components in packages/next that imported from next/navigation to use RouterProvider hooks from @payloadcms/ui instead. Replace deprecated useSearchParams/useParams exports from SearchParams and Params providers with the Router provider versions. Add views/* export path to @payloadcms/ui package.json. Add ServerNavigation context (provider + hook) for future use by client components that need notFound/redirect access. Co-Authored-By: Claude Sonnet 4.6 --- .../DocumentHeader/Tabs/Tab/TabLink.tsx | 3 +- .../next/src/elements/Nav/index.client.tsx | 10 +- packages/next/src/views/API/index.client.tsx | 2 +- packages/next/src/views/Login/index.tsx | 100 +++------------ .../next/src/views/Logout/LogoutClient.tsx | 2 +- packages/next/src/views/NotFound/index.tsx | 6 +- .../ResetPassword/ResetPasswordForm/index.tsx | 2 +- .../next/src/views/Verify/index.client.tsx | 3 +- .../next/src/views/Version/Default/index.tsx | 4 +- .../next/src/views/Version/Restore/index.tsx | 2 +- .../VersionDrawer/CreatedAtCell.tsx | 11 +- .../SelectComparison/VersionDrawer/index.tsx | 2 +- .../next/src/views/Versions/index.client.tsx | 2 +- packages/ui/package.json | 5 + packages/ui/src/exports/client/index.ts | 17 ++- .../src/providers/ServerNavigation/index.tsx | 29 +++++ .../ui/src/views/Login/LoginField/index.tsx | 78 +++++++++++ .../ui/src/views/Login/LoginForm/index.scss | 10 ++ .../ui/src/views/Login/LoginForm/index.tsx | 121 ++++++++++++++++++ packages/ui/src/views/Login/index.scss | 10 ++ packages/ui/src/views/Login/index.tsx | 104 +++++++++++++++ packages/ui/src/views/NotFound/index.scss | 57 +++++++++ packages/ui/src/views/NotFound/index.tsx | 57 +++++++++ 23 files changed, 533 insertions(+), 104 deletions(-) create mode 100644 packages/ui/src/providers/ServerNavigation/index.tsx create mode 100644 packages/ui/src/views/Login/LoginField/index.tsx create mode 100644 packages/ui/src/views/Login/LoginForm/index.scss create mode 100644 packages/ui/src/views/Login/LoginForm/index.tsx create mode 100644 packages/ui/src/views/Login/index.scss create mode 100644 packages/ui/src/views/Login/index.tsx create mode 100644 packages/ui/src/views/NotFound/index.scss create mode 100644 packages/ui/src/views/NotFound/index.tsx diff --git a/packages/next/src/elements/DocumentHeader/Tabs/Tab/TabLink.tsx b/packages/next/src/elements/DocumentHeader/Tabs/Tab/TabLink.tsx index fd890937121..2ba76893077 100644 --- a/packages/next/src/elements/DocumentHeader/Tabs/Tab/TabLink.tsx +++ b/packages/next/src/elements/DocumentHeader/Tabs/Tab/TabLink.tsx @@ -1,8 +1,7 @@ 'use client' import type { SanitizedConfig } from 'payload' -import { Button } from '@payloadcms/ui' -import { useParams, usePathname, useSearchParams } from 'next/navigation.js' +import { Button, useParams, usePathname, useSearchParams } from '@payloadcms/ui' import { formatAdminURL } from 'payload/shared' import React from 'react' diff --git a/packages/next/src/elements/Nav/index.client.tsx b/packages/next/src/elements/Nav/index.client.tsx index d65fc56a8ec..aafb513f381 100644 --- a/packages/next/src/elements/Nav/index.client.tsx +++ b/packages/next/src/elements/Nav/index.client.tsx @@ -4,9 +4,15 @@ import type { groupNavItems } from '@payloadcms/ui/shared' import type { NavPreferences } from 'payload' import { getTranslation } from '@payloadcms/translations' -import { BrowseByFolderButton, Link, NavGroup, useConfig, useTranslation } from '@payloadcms/ui' +import { + BrowseByFolderButton, + Link, + NavGroup, + useConfig, + usePathname, + useTranslation, +} from '@payloadcms/ui' import { EntityType } from '@payloadcms/ui/shared' -import { usePathname } from 'next/navigation.js' import { formatAdminURL } from 'payload/shared' import React, { Fragment } from 'react' diff --git a/packages/next/src/views/API/index.client.tsx b/packages/next/src/views/API/index.client.tsx index c51f583c523..08613d761dc 100644 --- a/packages/next/src/views/API/index.client.tsx +++ b/packages/next/src/views/API/index.client.tsx @@ -12,9 +12,9 @@ import { useConfig, useDocumentInfo, useLocale, + useSearchParams, useTranslation, } from '@payloadcms/ui' -import { useSearchParams } from 'next/navigation.js' import './index.scss' diff --git a/packages/next/src/views/Login/index.tsx b/packages/next/src/views/Login/index.tsx index c5c4318e33a..2b118c88a8b 100644 --- a/packages/next/src/views/Login/index.tsx +++ b/packages/next/src/views/Login/index.tsx @@ -1,103 +1,37 @@ -import type { AdminViewServerProps, ServerProps } from 'payload' +import type { AdminViewServerProps } from 'payload' -import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' +import { LoginView as LoginViewRender } from '@payloadcms/ui/views/Login' import { redirect } from 'next/navigation.js' import { getSafeRedirect } from 'payload/shared' -import React, { Fragment } from 'react' +import React from 'react' import { Logo } from '../../elements/Logo/index.js' -import { LoginForm } from './LoginForm/index.js' -import './index.scss' -export const loginBaseClass = 'login' -export function LoginView({ initPageResult, params, searchParams }: AdminViewServerProps) { - const { locale, permissions, req } = initPageResult +export { loginBaseClass } from '@payloadcms/ui/views/Login' +export function LoginView({ initPageResult, params, searchParams }: AdminViewServerProps) { const { - i18n, - payload: { config }, - payload, - user, - } = req + req: { + payload: { config }, + user, + }, + } = initPageResult const { - admin: { components: { afterLogin, beforeLogin } = {}, user: userSlug }, routes: { admin }, } = config - const redirectUrl = getSafeRedirect({ fallbackTo: admin, redirectTo: searchParams.redirect }) - if (user) { + const redirectUrl = getSafeRedirect({ fallbackTo: admin, redirectTo: searchParams.redirect }) redirect(redirectUrl) } - const collectionConfig = payload?.collections?.[userSlug]?.config - - const prefillAutoLogin = - typeof config.admin?.autoLogin === 'object' && config.admin?.autoLogin.prefillOnly - - const prefillUsername = - prefillAutoLogin && typeof config.admin?.autoLogin === 'object' - ? config.admin?.autoLogin.username - : undefined - - const prefillEmail = - prefillAutoLogin && typeof config.admin?.autoLogin === 'object' - ? config.admin?.autoLogin.email - : undefined - - const prefillPassword = - prefillAutoLogin && typeof config.admin?.autoLogin === 'object' - ? config.admin?.autoLogin.password - : undefined - return ( - -
- -
- {RenderServerComponent({ - Component: beforeLogin, - importMap: payload.importMap, - serverProps: { - i18n, - locale, - params, - payload, - permissions, - searchParams, - user, - } satisfies ServerProps, - })} - {!collectionConfig?.auth?.disableLocalStrategy && ( - - )} - {RenderServerComponent({ - Component: afterLogin, - importMap: payload.importMap, - serverProps: { - i18n, - locale, - params, - payload, - permissions, - searchParams, - user, - } satisfies ServerProps, - })} -
+ ) } diff --git a/packages/next/src/views/Logout/LogoutClient.tsx b/packages/next/src/views/Logout/LogoutClient.tsx index 212f1b47d5a..0d51ea53d88 100644 --- a/packages/next/src/views/Logout/LogoutClient.tsx +++ b/packages/next/src/views/Logout/LogoutClient.tsx @@ -5,10 +5,10 @@ import { toast, useAuth, useConfig, + useRouter, useRouteTransition, useTranslation, } from '@payloadcms/ui' -import { useRouter } from 'next/navigation.js' import { formatAdminURL } from 'payload/shared' import React, { useEffect } from 'react' diff --git a/packages/next/src/views/NotFound/index.tsx b/packages/next/src/views/NotFound/index.tsx index f58d78f44e8..39e833a0c9d 100644 --- a/packages/next/src/views/NotFound/index.tsx +++ b/packages/next/src/views/NotFound/index.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next' import type { AdminViewServerProps, ImportMap, SanitizedConfig } from 'payload' import { getVisibleEntities } from '@payloadcms/ui/shared' +import { NotFoundClient, NotFoundView as NotFoundViewRender } from '@payloadcms/ui/views/NotFound' import { formatAdminURL } from 'payload/shared' import * as qs from 'qs-esm' import React from 'react' @@ -9,7 +10,6 @@ import React from 'react' import { DefaultTemplate } from '../../templates/Default/index.js' import { getNextRequestI18n } from '../../utilities/getNextRequestI18n.js' import { initReq } from '../../utilities/initReq.js' -import { NotFoundClient } from './index.client.js' export const generateNotFoundViewMetadata = async ({ config: configPromise, @@ -94,6 +94,6 @@ export const NotFoundPage = async ({ ) } -export function NotFoundView(props: AdminViewServerProps) { - return +export function NotFoundView(_props: AdminViewServerProps) { + return } diff --git a/packages/next/src/views/ResetPassword/ResetPasswordForm/index.tsx b/packages/next/src/views/ResetPassword/ResetPasswordForm/index.tsx index d8aee4e8e20..e77812c6934 100644 --- a/packages/next/src/views/ResetPassword/ResetPasswordForm/index.tsx +++ b/packages/next/src/views/ResetPassword/ResetPasswordForm/index.tsx @@ -7,9 +7,9 @@ import { PasswordField, useAuth, useConfig, + useRouter, useTranslation, } from '@payloadcms/ui' -import { useRouter } from 'next/navigation.js' import { type FormState } from 'payload' import { formatAdminURL } from 'payload/shared' import React from 'react' diff --git a/packages/next/src/views/Verify/index.client.tsx b/packages/next/src/views/Verify/index.client.tsx index ebd032792e8..b5d711942a3 100644 --- a/packages/next/src/views/Verify/index.client.tsx +++ b/packages/next/src/views/Verify/index.client.tsx @@ -1,6 +1,5 @@ 'use client' -import { toast, useRouteTransition } from '@payloadcms/ui' -import { useRouter } from 'next/navigation.js' +import { toast, useRouter, useRouteTransition } from '@payloadcms/ui' import React, { useEffect } from 'react' type Props = { diff --git a/packages/next/src/views/Version/Default/index.tsx b/packages/next/src/views/Version/Default/index.tsx index dbf85896b15..1ed49a1ceb8 100644 --- a/packages/next/src/views/Version/Default/index.tsx +++ b/packages/next/src/views/Version/Default/index.tsx @@ -10,10 +10,12 @@ import { useConfig, useDocumentInfo, useLocale, + usePathname, + useRouter, useRouteTransition, + useSearchParams, useTranslation, } from '@payloadcms/ui' -import { usePathname, useRouter, useSearchParams } from 'next/navigation.js' import React, { type FormEventHandler, useCallback, useEffect, useMemo, useState } from 'react' import type { CompareOption, DefaultVersionsViewProps } from './types.js' diff --git a/packages/next/src/views/Version/Restore/index.tsx b/packages/next/src/views/Version/Restore/index.tsx index 547b341a2de..3971651f6fe 100644 --- a/packages/next/src/views/Version/Restore/index.tsx +++ b/packages/next/src/views/Version/Restore/index.tsx @@ -10,11 +10,11 @@ import { toast, useConfig, useModal, + useRouter, useRouteTransition, useTranslation, } from '@payloadcms/ui' import { requests } from '@payloadcms/ui/shared' -import { useRouter } from 'next/navigation.js' import { formatAdminURL } from 'payload/shared' import './index.scss' diff --git a/packages/next/src/views/Version/SelectComparison/VersionDrawer/CreatedAtCell.tsx b/packages/next/src/views/Version/SelectComparison/VersionDrawer/CreatedAtCell.tsx index 800360fc4ea..f922b062caf 100644 --- a/packages/next/src/views/Version/SelectComparison/VersionDrawer/CreatedAtCell.tsx +++ b/packages/next/src/views/Version/SelectComparison/VersionDrawer/CreatedAtCell.tsx @@ -1,7 +1,14 @@ 'use client' -import { useConfig, useModal, useRouteTransition, useTranslation } from '@payloadcms/ui' +import { + useConfig, + useModal, + usePathname, + useRouter, + useRouteTransition, + useSearchParams, + useTranslation, +} from '@payloadcms/ui' import { formatDate } from '@payloadcms/ui/shared' -import { usePathname, useRouter, useSearchParams } from 'next/navigation.js' import type { CreatedAtCellProps } from '../../../Versions/cells/CreatedAt/index.js' diff --git a/packages/next/src/views/Version/SelectComparison/VersionDrawer/index.tsx b/packages/next/src/views/Version/SelectComparison/VersionDrawer/index.tsx index c40b7abed95..4abe98e54fd 100644 --- a/packages/next/src/views/Version/SelectComparison/VersionDrawer/index.tsx +++ b/packages/next/src/views/Version/SelectComparison/VersionDrawer/index.tsx @@ -6,10 +6,10 @@ import { useDocumentInfo, useEditDepth, useModal, + useSearchParams, useServerFunctions, useTranslation, } from '@payloadcms/ui' -import { useSearchParams } from 'next/navigation.js' import './index.scss' diff --git a/packages/next/src/views/Versions/index.client.tsx b/packages/next/src/views/Versions/index.client.tsx index a3fa76d11d9..cb53826f382 100644 --- a/packages/next/src/views/Versions/index.client.tsx +++ b/packages/next/src/views/Versions/index.client.tsx @@ -7,9 +7,9 @@ import { PerPage, Table, useListQuery, + useSearchParams, useTranslation, } from '@payloadcms/ui' -import { useSearchParams } from 'next/navigation.js' import React from 'react' export const VersionsViewClient: React.FC<{ diff --git a/packages/ui/package.json b/packages/ui/package.json index 9684393c83a..70a8aec181f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -51,6 +51,11 @@ "types": "./src/elements/RenderServerComponent/index.tsx", "default": "./src/elements/RenderServerComponent/index.tsx" }, + "./views/*": { + "import": "./src/views/*/index.tsx", + "types": "./src/views/*/index.tsx", + "default": "./src/views/*/index.tsx" + }, "./rsc": { "import": "./src/exports/rsc/index.ts", "types": "./src/exports/rsc/index.ts", diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts index 8e6d698a591..923ef77bc9e 100644 --- a/packages/ui/src/exports/client/index.ts +++ b/packages/ui/src/exports/client/index.ts @@ -326,13 +326,24 @@ export { RouteTransitionProvider, useRouteTransition, } from '../../providers/RouteTransition/index.js' -export { RouterProvider, usePathname, useRouter } from '../../providers/Router/index.js' +export { + RouterProvider, + useParams, + usePathname, + useRouter, + useSearchParams, +} from '../../providers/Router/index.js' export type { LinkProps, NavigateOptions, RouterContextType, RouterInstance, } from '../../providers/Router/types.js' +export { + ServerNavigationProvider, + useServerNavigation, +} from '../../providers/ServerNavigation/index.js' +export type { ServerNavigationContextType } from '../../providers/ServerNavigation/index.js' export { ConfigProvider, PageConfigProvider, useConfig } from '../../providers/Config/index.js' export { DocumentEventsProvider, useDocumentEvents } from '../../providers/DocumentEvents/index.js' export { DocumentInfoProvider, useDocumentInfo } from '../../providers/DocumentInfo/index.js' @@ -352,7 +363,7 @@ export { export { ListQueryProvider, useListQuery } from '../../providers/ListQuery/index.js' export { LocaleProvider, useLocale } from '../../providers/Locale/index.js' export { OperationProvider, useOperation } from '../../providers/Operation/index.js' -export { ParamsProvider, useParams } from '../../providers/Params/index.js' +export { ParamsProvider } from '../../providers/Params/index.js' export { PreferencesProvider, usePreferences } from '../../providers/Preferences/index.js' export { RootProvider } from '../../providers/Root/index.js' export { @@ -360,7 +371,7 @@ export { useRouteCache, } from '../../providers/RouteCache/index.js' export { ScrollInfoProvider, useScrollInfo } from '../../providers/ScrollInfo/index.js' -export { SearchParamsProvider, useSearchParams } from '../../providers/SearchParams/index.js' +export { SearchParamsProvider } from '../../providers/SearchParams/index.js' export { SelectionProvider, useSelection } from '../../providers/Selection/index.js' export { UploadHandlersProvider, useUploadHandlers } from '../../providers/UploadHandlers/index.js' export type { UploadHandlersContext } from '../../providers/UploadHandlers/index.js' diff --git a/packages/ui/src/providers/ServerNavigation/index.tsx b/packages/ui/src/providers/ServerNavigation/index.tsx new file mode 100644 index 00000000000..299335efeab --- /dev/null +++ b/packages/ui/src/providers/ServerNavigation/index.tsx @@ -0,0 +1,29 @@ +'use client' +import React, { createContext, use } from 'react' + +export type ServerNavigationContextType = { + notFound: () => never + redirect: (url: string) => never +} + +const ServerNavigationContext = createContext(null) + +export const ServerNavigationProvider: React.FC<{ + children: React.ReactNode + notFound: () => never + redirect: (url: string) => never +}> = ({ children, notFound, redirect }) => { + return ( + {children} + ) +} + +export function useServerNavigation(): ServerNavigationContextType { + const ctx = use(ServerNavigationContext) + if (!ctx) { + throw new Error( + 'ServerNavigationProvider is not in the tree. Make sure your admin adapter provides one.', + ) + } + return ctx +} diff --git a/packages/ui/src/views/Login/LoginField/index.tsx b/packages/ui/src/views/Login/LoginField/index.tsx new file mode 100644 index 00000000000..92fc4c689a9 --- /dev/null +++ b/packages/ui/src/views/Login/LoginField/index.tsx @@ -0,0 +1,78 @@ +'use client' +import type { Validate, ValidateOptions } from 'payload' + +import { email, username } from 'payload/shared' +import React from 'react' + +import { EmailField } from '../../../fields/Email/index.js' +import { TextField } from '../../../fields/Text/index.js' +import { useTranslation } from '../../../providers/Translation/index.js' + +export type LoginFieldProps = { + readonly required?: boolean + readonly type: 'email' | 'emailOrUsername' | 'username' + readonly validate?: Validate +} + +export const LoginField: React.FC = ({ type, required = true }) => { + const { t } = useTranslation() + + if (type === 'email') { + return ( + + ) + } + + if (type === 'username') { + return ( + + ) + } + + if (type === 'emailOrUsername') { + return ( + { + const passesUsername = username(value, options) + const passesEmail = email( + value, + options as ValidateOptions, + ) + + if (!passesEmail && !passesUsername) { + return `${t('general:email')}: ${passesEmail} ${t('general:username')}: ${passesUsername}` + } + + return true + }} + /> + ) + } + + return null +} diff --git a/packages/ui/src/views/Login/LoginForm/index.scss b/packages/ui/src/views/Login/LoginForm/index.scss new file mode 100644 index 00000000000..77ba4a0cedc --- /dev/null +++ b/packages/ui/src/views/Login/LoginForm/index.scss @@ -0,0 +1,10 @@ +@layer payload-default { + .login__form { + &__inputWrap { + display: flex; + flex-direction: column; + gap: var(--base); + margin-bottom: calc(var(--base) / 4); + } + } +} diff --git a/packages/ui/src/views/Login/LoginForm/index.tsx b/packages/ui/src/views/Login/LoginForm/index.tsx new file mode 100644 index 00000000000..00ce075b095 --- /dev/null +++ b/packages/ui/src/views/Login/LoginForm/index.tsx @@ -0,0 +1,121 @@ +'use client' + +import React from 'react' + +const baseClass = 'login__form' + +import type { FormState } from 'payload' + +import { formatAdminURL, getLoginOptions, getSafeRedirect } from 'payload/shared' + +import type { UserWithToken } from '../../../providers/Auth/index.js' +import type { LoginFieldProps } from '../LoginField/index.js' + +import { Link } from '../../../elements/Link/index.js' +import { PasswordField } from '../../../fields/Password/index.js' +import { Form } from '../../../forms/Form/index.js' +import { FormSubmit } from '../../../forms/Submit/index.js' +import { useAuth } from '../../../providers/Auth/index.js' +import { useConfig } from '../../../providers/Config/index.js' +import { useTranslation } from '../../../providers/Translation/index.js' +import { LoginField } from '../LoginField/index.js' +import './index.scss' + +export const LoginForm: React.FC<{ + prefillEmail?: string + prefillPassword?: string + prefillUsername?: string + searchParams: { [key: string]: string | string[] | undefined } +}> = ({ prefillEmail, prefillPassword, prefillUsername, searchParams }) => { + const { config, getEntityConfig } = useConfig() + + const { + admin: { + routes: { forgot: forgotRoute }, + user: userSlug, + }, + routes: { admin: adminRoute, api: apiRoute }, + } = config + + const collectionConfig = getEntityConfig({ collectionSlug: userSlug }) + const { auth: authOptions } = collectionConfig + const loginWithUsername = authOptions.loginWithUsername + const { canLoginWithEmail, canLoginWithUsername } = getLoginOptions(loginWithUsername) + + const [loginType] = React.useState(() => { + if (canLoginWithEmail && canLoginWithUsername) { + return 'emailOrUsername' + } + if (canLoginWithUsername) { + return 'username' + } + return 'email' + }) + + const { t } = useTranslation() + const { setUser } = useAuth() + + const initialState: FormState = { + password: { + initialValue: prefillPassword ?? undefined, + valid: true, + value: prefillPassword ?? undefined, + }, + } + + if (loginWithUsername) { + initialState.username = { + initialValue: prefillUsername ?? undefined, + valid: true, + value: prefillUsername ?? undefined, + } + } else { + initialState.email = { + initialValue: prefillEmail ?? undefined, + valid: true, + value: prefillEmail ?? undefined, + } + } + + const handleLogin = (data: UserWithToken) => { + setUser(data) + } + + return ( +
+
+ + +
+ + {t('authentication:forgotPasswordQuestion')} + + {t('authentication:login')} +
+ ) +} diff --git a/packages/ui/src/views/Login/index.scss b/packages/ui/src/views/Login/index.scss new file mode 100644 index 00000000000..37721af4335 --- /dev/null +++ b/packages/ui/src/views/Login/index.scss @@ -0,0 +1,10 @@ +@layer payload-default { + .login { + &__brand { + display: flex; + justify-content: center; + width: 100%; + margin-bottom: calc(var(--base) * 2); + } + } +} diff --git a/packages/ui/src/views/Login/index.tsx b/packages/ui/src/views/Login/index.tsx new file mode 100644 index 00000000000..94caf24efec --- /dev/null +++ b/packages/ui/src/views/Login/index.tsx @@ -0,0 +1,104 @@ +import type { InitPageResult, ServerProps } from 'payload' + +import React, { Fragment } from 'react' + +import { RenderServerComponent } from '../../elements/RenderServerComponent/index.js' +import { LoginForm } from './LoginForm/index.js' +import './index.scss' + +export const loginBaseClass = 'login' + +export type LoginViewProps = { + initPageResult: InitPageResult + /** Logo component to render at the top of the login page */ + Logo?: React.ComponentType + params?: { [key: string]: string | string[] | undefined } + searchParams: { [key: string]: string | string[] | undefined } +} + +export function LoginView({ initPageResult, Logo, params, searchParams }: LoginViewProps) { + const { locale, permissions, req } = initPageResult + + const { + i18n, + payload: { config }, + payload, + user, + } = req + + const { + admin: { components: { afterLogin, beforeLogin } = {}, user: userSlug }, + } = config + + const collectionConfig = payload?.collections?.[userSlug]?.config + + const prefillAutoLogin = + typeof config.admin?.autoLogin === 'object' && config.admin?.autoLogin.prefillOnly + + const prefillUsername = + prefillAutoLogin && typeof config.admin?.autoLogin === 'object' + ? config.admin?.autoLogin.username + : undefined + + const prefillEmail = + prefillAutoLogin && typeof config.admin?.autoLogin === 'object' + ? config.admin?.autoLogin.email + : undefined + + const prefillPassword = + prefillAutoLogin && typeof config.admin?.autoLogin === 'object' + ? config.admin?.autoLogin.password + : undefined + + return ( + + {Logo && ( +
+ +
+ )} + {RenderServerComponent({ + Component: beforeLogin, + importMap: payload.importMap, + serverProps: { + i18n, + locale, + params, + payload, + permissions, + searchParams, + user, + } satisfies ServerProps, + })} + {!collectionConfig?.auth?.disableLocalStrategy && ( + + )} + {RenderServerComponent({ + Component: afterLogin, + importMap: payload.importMap, + serverProps: { + i18n, + locale, + params, + payload, + permissions, + searchParams, + user, + } satisfies ServerProps, + })} +
+ ) +} diff --git a/packages/ui/src/views/NotFound/index.scss b/packages/ui/src/views/NotFound/index.scss new file mode 100644 index 00000000000..c658e0c0ed6 --- /dev/null +++ b/packages/ui/src/views/NotFound/index.scss @@ -0,0 +1,57 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .not-found { + margin-top: var(--base); + display: flex; + + & > * { + &:first-child { + margin-top: 0; + } + &:last-child { + margin-bottom: 0; + } + } + + &__wrap { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: base(0.8); + max-width: base(36); + } + + &__content { + display: flex; + flex-direction: column; + gap: base(0.4); + + > * { + margin: 0; + } + } + + &__button { + margin: 0; + } + + &--margin-top-large { + margin-top: calc(var(--base) * 2); + } + + @include large-break { + &--margin-top-large { + margin-top: var(--base); + } + } + + @include small-break { + margin-top: calc(var(--base) / 2); + + &--margin-top-large { + margin-top: calc(var(--base) / 2); + } + } + } +} diff --git a/packages/ui/src/views/NotFound/index.tsx b/packages/ui/src/views/NotFound/index.tsx new file mode 100644 index 00000000000..aba2b3a821d --- /dev/null +++ b/packages/ui/src/views/NotFound/index.tsx @@ -0,0 +1,57 @@ +'use client' +import React, { useEffect } from 'react' + +import { Button } from '../../elements/Button/index.js' +import { Gutter } from '../../elements/Gutter/index.js' +import { useStepNav } from '../../elements/StepNav/index.js' +import { useConfig } from '../../providers/Config/index.js' +import { useTranslation } from '../../providers/Translation/index.js' +import './index.scss' + +const baseClass = 'not-found' + +export const NotFoundClient: React.FC<{ + marginTop?: 'large' +}> = (props) => { + const { marginTop = 'large' } = props + + const { setStepNav } = useStepNav() + const { t } = useTranslation() + + const { + config: { + routes: { admin: adminRoute }, + }, + } = useConfig() + + useEffect(() => { + setStepNav([ + { + label: t('general:notFound'), + }, + ]) + }, [setStepNav, t]) + + return ( +
+ +
+

{t('general:nothingFound')}

+

{t('general:sorryNotFound')}

+
+ +
+
+ ) +} + +/** Framework-agnostic NotFoundView that renders the NotFound client */ +export function NotFoundView() { + return +} From c9d120567f1d1cc5e2c36d483423580f3cce2b89 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 15:37:14 +0100 Subject: [PATCH 07/60] refactor: move 8 framework-agnostic views from packages/next to packages/ui Move Login, NotFound, ForgotPassword, Logout, Verify, CreateFirstUser, Unauthorized, and ResetPassword views to packages/ui. These views have zero Next.js dependencies and are now fully framework-agnostic. packages/next view files become thin re-exports for backwards compatibility. Login retains its server entry for redirect handling. Also move FormHeader and Logo elements, plus getDocPreferences and getDocumentData utilities to packages/ui. Co-Authored-By: Claude Sonnet 4.6 --- .../next/src/views/CreateFirstUser/index.tsx | 94 +---------- .../next/src/views/ForgotPassword/index.tsx | 76 +-------- packages/next/src/views/Logout/index.tsx | 39 +---- .../next/src/views/ResetPassword/index.tsx | 82 +--------- .../next/src/views/Unauthorized/index.tsx | 59 +------ packages/next/src/views/Verify/index.tsx | 71 +------- .../ui/src/elements/FormHeader/index.scss | 8 + packages/ui/src/elements/FormHeader/index.tsx | 22 +++ packages/ui/src/elements/Logo/index.tsx | 34 ++++ .../views/CreateFirstUser/index.client.tsx | 135 ++++++++++++++++ .../ui/src/views/CreateFirstUser/index.scss | 21 +++ .../ui/src/views/CreateFirstUser/index.tsx | 97 +++++++++++ .../src/views/Document/getDocPreferences.ts | 62 +++++++ .../ui/src/views/Document/getDocumentData.ts | 76 +++++++++ .../ForgotPasswordForm/index.tsx | 151 ++++++++++++++++++ .../ui/src/views/ForgotPassword/index.tsx | 78 +++++++++ packages/ui/src/views/Logout/LogoutClient.tsx | 90 +++++++++++ packages/ui/src/views/Logout/index.scss | 25 +++ packages/ui/src/views/Logout/index.tsx | 38 +++++ .../ResetPassword/ResetPasswordForm/index.tsx | 94 +++++++++++ .../ui/src/views/ResetPassword/index.scss | 11 ++ packages/ui/src/views/ResetPassword/index.tsx | 84 ++++++++++ packages/ui/src/views/Unauthorized/index.scss | 14 ++ packages/ui/src/views/Unauthorized/index.tsx | 61 +++++++ packages/ui/src/views/Verify/index.client.tsx | 36 +++++ packages/ui/src/views/Verify/index.scss | 16 ++ packages/ui/src/views/Verify/index.tsx | 72 +++++++++ 27 files changed, 1231 insertions(+), 415 deletions(-) create mode 100644 packages/ui/src/elements/FormHeader/index.scss create mode 100644 packages/ui/src/elements/FormHeader/index.tsx create mode 100644 packages/ui/src/elements/Logo/index.tsx create mode 100644 packages/ui/src/views/CreateFirstUser/index.client.tsx create mode 100644 packages/ui/src/views/CreateFirstUser/index.scss create mode 100644 packages/ui/src/views/CreateFirstUser/index.tsx create mode 100644 packages/ui/src/views/Document/getDocPreferences.ts create mode 100644 packages/ui/src/views/Document/getDocumentData.ts create mode 100644 packages/ui/src/views/ForgotPassword/ForgotPasswordForm/index.tsx create mode 100644 packages/ui/src/views/ForgotPassword/index.tsx create mode 100644 packages/ui/src/views/Logout/LogoutClient.tsx create mode 100644 packages/ui/src/views/Logout/index.scss create mode 100644 packages/ui/src/views/Logout/index.tsx create mode 100644 packages/ui/src/views/ResetPassword/ResetPasswordForm/index.tsx create mode 100644 packages/ui/src/views/ResetPassword/index.scss create mode 100644 packages/ui/src/views/ResetPassword/index.tsx create mode 100644 packages/ui/src/views/Unauthorized/index.scss create mode 100644 packages/ui/src/views/Unauthorized/index.tsx create mode 100644 packages/ui/src/views/Verify/index.client.tsx create mode 100644 packages/ui/src/views/Verify/index.scss create mode 100644 packages/ui/src/views/Verify/index.tsx diff --git a/packages/next/src/views/CreateFirstUser/index.tsx b/packages/next/src/views/CreateFirstUser/index.tsx index 604c03add42..df3d4aca25a 100644 --- a/packages/next/src/views/CreateFirstUser/index.tsx +++ b/packages/next/src/views/CreateFirstUser/index.tsx @@ -1,93 +1 @@ -import type { - AdminViewServerProps, - SanitizedDocumentPermissions, - SanitizedFieldsPermissions, -} from 'payload' - -import { buildFormState } from '@payloadcms/ui/utilities/buildFormState' -import React from 'react' - -import { getDocPreferences } from '../Document/getDocPreferences.js' -import { getDocumentData } from '../Document/getDocumentData.js' -import { CreateFirstUserClient } from './index.client.js' -import './index.scss' - -export async function CreateFirstUserView({ initPageResult }: AdminViewServerProps) { - const { - locale, - req, - req: { - payload: { - collections, - config: { - admin: { user: userSlug }, - }, - }, - }, - } = initPageResult - - const collectionConfig = collections?.[userSlug]?.config - const { auth: authOptions } = collectionConfig - const loginWithUsername = authOptions.loginWithUsername - - // Fetch the data required for the view - const data = await getDocumentData({ - collectionSlug: collectionConfig.slug, - locale, - payload: req.payload, - req, - user: req.user, - }) - - // Get document preferences - const docPreferences = await getDocPreferences({ - collectionSlug: collectionConfig.slug, - payload: req.payload, - user: req.user, - }) - - const baseFields: SanitizedFieldsPermissions = Object.fromEntries( - collectionConfig.fields - .filter((f): f is { name: string } & typeof f => 'name' in f && typeof f.name === 'string') - .map((f) => [f.name, { create: true, read: true, update: true }]), - ) - - // In create-first-user we should always allow all fields - const docPermissionsForForm: SanitizedDocumentPermissions = { - create: true, - delete: true, - fields: baseFields, - read: true, - readVersions: true, - update: true, - } - - // Build initial form state from data - const { state: formState } = await buildFormState({ - collectionSlug: collectionConfig.slug, - data, - docPermissions: docPermissionsForForm, - docPreferences, - locale: locale?.code, - operation: 'create', - renderAllFields: true, - req, - schemaPath: collectionConfig.slug, - skipClientConfigAuth: true, - skipValidation: true, - }) - - return ( -
-

{req.t('general:welcome')}

-

{req.t('authentication:beginCreateFirstUser')}

- -
- ) -} +export { CreateFirstUserView } from '@payloadcms/ui/views/CreateFirstUser' diff --git a/packages/next/src/views/ForgotPassword/index.tsx b/packages/next/src/views/ForgotPassword/index.tsx index bfaf3702477..c4eb27a6a69 100644 --- a/packages/next/src/views/ForgotPassword/index.tsx +++ b/packages/next/src/views/ForgotPassword/index.tsx @@ -1,75 +1 @@ -import type { AdminViewServerProps } from 'payload' - -import { Button, Link } from '@payloadcms/ui' -import { Translation } from '@payloadcms/ui/shared' -import { formatAdminURL } from 'payload/shared' -import React, { Fragment } from 'react' - -import { FormHeader } from '../../elements/FormHeader/index.js' -import { ForgotPasswordForm } from './ForgotPasswordForm/index.js' - -export const forgotPasswordBaseClass = 'forgot-password' - -export function ForgotPasswordView({ initPageResult }: AdminViewServerProps) { - const { - req: { - i18n, - payload: { config }, - user, - }, - } = initPageResult - - const { - admin: { - routes: { account: accountRoute, login: loginRoute }, - }, - routes: { admin: adminRoute }, - } = config - - if (user) { - return ( - - ( - - {children} - - ), - }} - i18nKey="authentication:loggedInChangePassword" - t={i18n.t} - /> - } - heading={i18n.t('authentication:alreadyLoggedIn')} - /> - - - ) - } - - return ( - - - - {i18n.t('authentication:backToLogin')} - - - ) -} +export { forgotPasswordBaseClass, ForgotPasswordView } from '@payloadcms/ui/views/ForgotPassword' diff --git a/packages/next/src/views/Logout/index.tsx b/packages/next/src/views/Logout/index.tsx index 8cd0dd0ff16..841ad19bcad 100644 --- a/packages/next/src/views/Logout/index.tsx +++ b/packages/next/src/views/Logout/index.tsx @@ -1,38 +1 @@ -import type { AdminViewServerProps } from 'payload' - -import React from 'react' - -import { LogoutClient } from './LogoutClient.js' -import './index.scss' - -const baseClass = 'logout' - -export const LogoutView: React.FC< - { - inactivity?: boolean - } & AdminViewServerProps -> = ({ inactivity, initPageResult, searchParams }) => { - const { - req: { - payload: { - config: { - routes: { admin: adminRoute }, - }, - }, - }, - } = initPageResult - - return ( -
- -
- ) -} - -export function LogoutInactivity(props: AdminViewServerProps) { - return -} +export { LogoutInactivity, LogoutView } from '@payloadcms/ui/views/Logout' diff --git a/packages/next/src/views/ResetPassword/index.tsx b/packages/next/src/views/ResetPassword/index.tsx index da8dfb72546..05d52ecbb90 100644 --- a/packages/next/src/views/ResetPassword/index.tsx +++ b/packages/next/src/views/ResetPassword/index.tsx @@ -1,81 +1 @@ -import type { AdminViewServerProps } from 'payload' - -import { Button, Link } from '@payloadcms/ui' -import { Translation } from '@payloadcms/ui/shared' -import { formatAdminURL } from 'payload/shared' -import React from 'react' - -import { FormHeader } from '../../elements/FormHeader/index.js' -import { ResetPasswordForm } from './ResetPasswordForm/index.js' -import './index.scss' - -export const resetPasswordBaseClass = 'reset-password' - -export function ResetPassword({ initPageResult, params }: AdminViewServerProps) { - const { req } = initPageResult - - const { - segments: [_, token], - } = params - - const { - i18n, - payload: { config }, - user, - } = req - - const { - admin: { - routes: { account: accountRoute, login: loginRoute }, - }, - routes: { admin: adminRoute }, - } = config - - if (user) { - return ( -
- ( - - {children} - - ), - }} - i18nKey="authentication:loggedInChangePassword" - t={i18n.t} - /> - } - heading={i18n.t('authentication:alreadyLoggedIn')} - /> - -
- ) - } - - return ( -
- - - - {i18n.t('authentication:backToLogin')} - -
- ) -} +export { ResetPassword, resetPasswordBaseClass } from '@payloadcms/ui/views/ResetPassword' diff --git a/packages/next/src/views/Unauthorized/index.tsx b/packages/next/src/views/Unauthorized/index.tsx index bc5678c43c5..f10036cee11 100644 --- a/packages/next/src/views/Unauthorized/index.tsx +++ b/packages/next/src/views/Unauthorized/index.tsx @@ -1,58 +1 @@ -import type { AdminViewServerProps } from 'payload' - -import { Button, Gutter } from '@payloadcms/ui' -import { formatAdminURL } from 'payload/shared' -import React from 'react' - -import { FormHeader } from '../../elements/FormHeader/index.js' -import './index.scss' - -const baseClass = 'unauthorized' - -export function UnauthorizedView({ initPageResult }: AdminViewServerProps) { - const { - permissions, - req: { - i18n, - payload: { - config: { - admin: { - routes: { logout: logoutRoute }, - }, - routes: { admin: adminRoute }, - }, - }, - user, - }, - } = initPageResult - - return ( -
- - -
- ) -} - -export const UnauthorizedViewWithGutter = (props: AdminViewServerProps) => { - return ( - - - - ) -} +export { UnauthorizedView, UnauthorizedViewWithGutter } from '@payloadcms/ui/views/Unauthorized' diff --git a/packages/next/src/views/Verify/index.tsx b/packages/next/src/views/Verify/index.tsx index b71245b97d2..a2fab0983d4 100644 --- a/packages/next/src/views/Verify/index.tsx +++ b/packages/next/src/views/Verify/index.tsx @@ -1,70 +1 @@ -import type { AdminViewServerProps } from 'payload' - -import { formatAdminURL } from 'payload/shared' -import React from 'react' - -import { Logo } from '../../elements/Logo/index.js' -import { ToastAndRedirect } from './index.client.js' -import './index.scss' - -export const verifyBaseClass = 'verify' - -export async function Verify({ initPageResult, params, searchParams }: AdminViewServerProps) { - // /:collectionSlug/verify/:token - - const [collectionSlug, verify, token] = params.segments - const { locale, permissions, req } = initPageResult - - const { - i18n, - payload: { config }, - payload, - user, - } = req - - const { - routes: { admin: adminRoute }, - serverURL, - } = config - - let textToRender - let isVerified = false - - try { - await req.payload.verifyEmail({ - collection: collectionSlug, - token, - }) - - isVerified = true - textToRender = req.t('authentication:emailVerified') - } catch (e) { - textToRender = req.t('authentication:unableToVerify') - } - - if (isVerified) { - return ( - - ) - } - - return ( - -
- -
-

{textToRender}

-
- ) -} +export { Verify, verifyBaseClass } from '@payloadcms/ui/views/Verify' diff --git a/packages/ui/src/elements/FormHeader/index.scss b/packages/ui/src/elements/FormHeader/index.scss new file mode 100644 index 00000000000..88896041ca1 --- /dev/null +++ b/packages/ui/src/elements/FormHeader/index.scss @@ -0,0 +1,8 @@ +@layer payload-default { + .form-header { + display: flex; + flex-direction: column; + gap: calc(var(--base) * 0.5); + margin-bottom: var(--base); + } +} diff --git a/packages/ui/src/elements/FormHeader/index.tsx b/packages/ui/src/elements/FormHeader/index.tsx new file mode 100644 index 00000000000..22a07eb8f21 --- /dev/null +++ b/packages/ui/src/elements/FormHeader/index.tsx @@ -0,0 +1,22 @@ +import React from 'react' + +import './index.scss' + +const baseClass = 'form-header' + +type Props = { + description?: React.ReactNode | string + heading: string +} +export function FormHeader({ description, heading }: Props) { + if (!heading) { + return null + } + + return ( +
+

{heading}

+ {Boolean(description) &&

{description}

} +
+ ) +} diff --git a/packages/ui/src/elements/Logo/index.tsx b/packages/ui/src/elements/Logo/index.tsx new file mode 100644 index 00000000000..e62c8d94b19 --- /dev/null +++ b/packages/ui/src/elements/Logo/index.tsx @@ -0,0 +1,34 @@ +import type { ServerProps } from 'payload' +import type React from 'react' + +import { PayloadLogo } from '../../graphics/Logo/index.js' +import { RenderServerComponent } from '../RenderServerComponent/index.js' + +export const Logo: React.FC = (props) => { + const { i18n, locale, params, payload, permissions, searchParams, user } = props + + const { + admin: { + components: { + graphics: { Logo: CustomLogo } = { + Logo: undefined, + }, + } = {}, + } = {}, + } = payload.config + + return RenderServerComponent({ + Component: CustomLogo, + Fallback: PayloadLogo, + importMap: payload.importMap, + serverProps: { + i18n, + locale, + params, + payload, + permissions, + searchParams, + user, + }, + }) +} diff --git a/packages/ui/src/views/CreateFirstUser/index.client.tsx b/packages/ui/src/views/CreateFirstUser/index.client.tsx new file mode 100644 index 00000000000..21516dfe02c --- /dev/null +++ b/packages/ui/src/views/CreateFirstUser/index.client.tsx @@ -0,0 +1,135 @@ +'use client' +import type { + DocumentPreferences, + FormState, + LoginWithUsernameOptions, + SanitizedDocumentPermissions, +} from 'payload' + +import { formatAdminURL } from 'payload/shared' +import React, { useEffect } from 'react' + +import type { FormProps } from '../../forms/Form/index.js' +import type { UserWithToken } from '../../providers/Auth/index.js' + +import { EmailAndUsernameFields } from '../../elements/EmailAndUsername/index.js' +import { ConfirmPasswordField } from '../../fields/ConfirmPassword/index.js' +import { PasswordField } from '../../fields/Password/index.js' +import { Form } from '../../forms/Form/index.js' +import { RenderFields } from '../../forms/RenderFields/index.js' +import { FormSubmit } from '../../forms/Submit/index.js' +import { useAuth } from '../../providers/Auth/index.js' +import { useConfig } from '../../providers/Config/index.js' +import { useServerFunctions } from '../../providers/ServerFunctions/index.js' +import { useTranslation } from '../../providers/Translation/index.js' +import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js' + +export const CreateFirstUserClient: React.FC<{ + docPermissions: SanitizedDocumentPermissions + docPreferences: DocumentPreferences + initialState: FormState + loginWithUsername?: false | LoginWithUsernameOptions + userSlug: string +}> = ({ docPermissions, docPreferences, initialState, loginWithUsername, userSlug }) => { + const { + config: { + routes: { admin, api: apiRoute }, + }, + getEntityConfig, + } = useConfig() + + const { getFormState } = useServerFunctions() + + const { t } = useTranslation() + const { setUser } = useAuth() + + const abortOnChangeRef = React.useRef(null) + + const collectionConfig = getEntityConfig({ collectionSlug: userSlug }) + + const onChange: FormProps['onChange'][0] = React.useCallback( + async ({ formState: prevFormState, submitted }) => { + const controller = handleAbortRef(abortOnChangeRef) + + const response = await getFormState({ + collectionSlug: userSlug, + docPermissions, + docPreferences, + formState: prevFormState, + operation: 'create', + schemaPath: userSlug, + signal: controller.signal, + skipValidation: !submitted, + }) + + abortOnChangeRef.current = null + + if (response && response.state) { + return response.state + } + }, + [userSlug, getFormState, docPermissions, docPreferences], + ) + + const handleFirstRegister = (data: UserWithToken) => { + setUser(data) + } + + useEffect(() => { + const abortOnChange = abortOnChangeRef.current + + return () => { + abortAndIgnore(abortOnChange) + } + }, []) + + return ( +
+ + + + + {t('general:create')} + + ) +} diff --git a/packages/ui/src/views/CreateFirstUser/index.scss b/packages/ui/src/views/CreateFirstUser/index.scss new file mode 100644 index 00000000000..e1e487e220c --- /dev/null +++ b/packages/ui/src/views/CreateFirstUser/index.scss @@ -0,0 +1,21 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .create-first-user { + display: flex; + flex-direction: column; + gap: base(0.4); + + > form > .field-type { + margin-bottom: var(--base); + + & .form-submit { + margin: 0; + } + } + } + + .emailAndUsername { + margin-bottom: var(--base); + } +} diff --git a/packages/ui/src/views/CreateFirstUser/index.tsx b/packages/ui/src/views/CreateFirstUser/index.tsx new file mode 100644 index 00000000000..44b6119770a --- /dev/null +++ b/packages/ui/src/views/CreateFirstUser/index.tsx @@ -0,0 +1,97 @@ +import type { + AdminViewServerProps, + SanitizedDocumentPermissions, + SanitizedFieldsPermissions, +} from 'payload' + +import React from 'react' + +import { buildFormState } from '../../utilities/buildFormState.js' +// TODO: getDocPreferences is currently in packages/next/src/views/Document/getDocPreferences.js +// It needs to be moved to packages/ui as a separate step +import { getDocPreferences } from '../Document/getDocPreferences.js' +// TODO: getDocumentData is currently in packages/next/src/views/Document/getDocumentData.js +// It needs to be moved to packages/ui as a separate step +import { getDocumentData } from '../Document/getDocumentData.js' +import { CreateFirstUserClient } from './index.client.js' +import './index.scss' + +export async function CreateFirstUserView({ initPageResult }: AdminViewServerProps) { + const { + locale, + req, + req: { + payload: { + collections, + config: { + admin: { user: userSlug }, + }, + }, + }, + } = initPageResult + + const collectionConfig = collections?.[userSlug]?.config + const { auth: authOptions } = collectionConfig + const loginWithUsername = authOptions.loginWithUsername + + // Fetch the data required for the view + const data = await getDocumentData({ + collectionSlug: collectionConfig.slug, + locale, + payload: req.payload, + req, + user: req.user, + }) + + // Get document preferences + const docPreferences = await getDocPreferences({ + collectionSlug: collectionConfig.slug, + payload: req.payload, + user: req.user, + }) + + const baseFields: SanitizedFieldsPermissions = Object.fromEntries( + collectionConfig.fields + .filter((f): f is { name: string } & typeof f => 'name' in f && typeof f.name === 'string') + .map((f) => [f.name, { create: true, read: true, update: true }]), + ) + + // In create-first-user we should always allow all fields + const docPermissionsForForm: SanitizedDocumentPermissions = { + create: true, + delete: true, + fields: baseFields, + read: true, + readVersions: true, + update: true, + } + + // Build initial form state from data + const { state: formState } = await buildFormState({ + collectionSlug: collectionConfig.slug, + data, + docPermissions: docPermissionsForForm, + docPreferences, + locale: locale?.code, + operation: 'create', + renderAllFields: true, + req, + schemaPath: collectionConfig.slug, + skipClientConfigAuth: true, + skipValidation: true, + }) + + return ( +
+

{req.t('general:welcome')}

+

{req.t('authentication:beginCreateFirstUser')}

+ +
+ ) +} diff --git a/packages/ui/src/views/Document/getDocPreferences.ts b/packages/ui/src/views/Document/getDocPreferences.ts new file mode 100644 index 00000000000..36b1287141d --- /dev/null +++ b/packages/ui/src/views/Document/getDocPreferences.ts @@ -0,0 +1,62 @@ +import type { DocumentPreferences, Payload, TypedUser } from 'payload' + +import { sanitizeID } from '../../utilities/sanitizeID.js' + +type Args = { + collectionSlug?: string + globalSlug?: string + id?: number | string + payload: Payload + user: TypedUser +} + +export const getDocPreferences = async ({ + id, + collectionSlug, + globalSlug, + payload, + user, +}: Args): Promise => { + let preferencesKey + + if (collectionSlug && id) { + preferencesKey = `collection-${collectionSlug}-${id}` + } + + if (globalSlug) { + preferencesKey = `global-${globalSlug}` + } + + if (preferencesKey) { + const preferencesResult = (await payload.find({ + collection: 'payload-preferences', + depth: 0, + limit: 1, + where: { + and: [ + { + key: { + equals: preferencesKey, + }, + }, + { + 'user.relationTo': { + equals: user.collection, + }, + }, + { + 'user.value': { + equals: sanitizeID(user.id), + }, + }, + ], + }, + })) as unknown as { docs: { value: DocumentPreferences }[] } + + if (preferencesResult?.docs?.[0]?.value) { + return preferencesResult.docs[0].value + } + } + + return { fields: {} } +} diff --git a/packages/ui/src/views/Document/getDocumentData.ts b/packages/ui/src/views/Document/getDocumentData.ts new file mode 100644 index 00000000000..9077317de34 --- /dev/null +++ b/packages/ui/src/views/Document/getDocumentData.ts @@ -0,0 +1,76 @@ +import { + type Locale, + logError, + type Payload, + type PayloadRequest, + type TypedUser, + type TypeWithID, +} from 'payload' + +import { sanitizeID } from '../../utilities/sanitizeID.js' + +type Args = { + collectionSlug?: string + globalSlug?: string + id?: number | string + locale?: Locale + payload: Payload + req?: PayloadRequest + segments?: string[] + user?: TypedUser +} + +export const getDocumentData = async ({ + id: idArg, + collectionSlug, + globalSlug, + locale, + payload, + req, + segments, + user, +}: Args): Promise | TypeWithID> => { + const id = sanitizeID(idArg) + let resolvedData: Record | TypeWithID = null + const { transactionID, ...rest } = req + + const isTrashedDoc = segments?.[2] === 'trash' && typeof segments?.[3] === 'string' // id exists at segment 3 + + try { + if (collectionSlug && id) { + resolvedData = await payload.findByID({ + id, + collection: collectionSlug, + depth: 0, + draft: true, + fallbackLocale: false, + locale: locale?.code, + overrideAccess: false, + req: { + ...rest, + }, + trash: isTrashedDoc ? true : false, + user, + }) + } + + if (globalSlug) { + resolvedData = await payload.findGlobal({ + slug: globalSlug, + depth: 0, + draft: true, + fallbackLocale: false, + locale: locale?.code, + overrideAccess: false, + req: { + ...rest, + }, + user, + }) + } + } catch (err) { + logError({ err, payload }) + } + + return resolvedData +} diff --git a/packages/ui/src/views/ForgotPassword/ForgotPasswordForm/index.tsx b/packages/ui/src/views/ForgotPassword/ForgotPasswordForm/index.tsx new file mode 100644 index 00000000000..163d32c1ca8 --- /dev/null +++ b/packages/ui/src/views/ForgotPassword/ForgotPasswordForm/index.tsx @@ -0,0 +1,151 @@ +'use client' + +import type { FormState, PayloadRequest } from 'payload' + +import { email, formatAdminURL, text } from 'payload/shared' +import React, { useState } from 'react' + +import type { FormProps } from '../../../forms/Form/index.js' + +import { EmailField } from '../../../fields/Email/index.js' +import { TextField } from '../../../fields/Text/index.js' +import { Form } from '../../../forms/Form/index.js' +import { FormSubmit } from '../../../forms/Submit/index.js' +import { useConfig } from '../../../providers/Config/index.js' +import { useTranslation } from '../../../providers/Translation/index.js' +// TODO: FormHeader is currently in packages/next/src/elements/FormHeader/ +// It needs to be moved to packages/ui/src/elements/FormHeader/ as a separate step +import { FormHeader } from '../../../elements/FormHeader/index.js' + +export const ForgotPasswordForm: React.FC = () => { + const { config, getEntityConfig } = useConfig() + + const { + admin: { user: userSlug }, + routes: { api: apiRoute }, + } = config + + const { t } = useTranslation() + const [hasSubmitted, setHasSubmitted] = useState(false) + const collectionConfig = getEntityConfig({ collectionSlug: userSlug }) + const loginWithUsername = collectionConfig?.auth?.loginWithUsername + + const handleResponse: FormProps['handleResponse'] = (res, successToast, errorToast) => { + res + .json() + .then(() => { + setHasSubmitted(true) + successToast(t('general:submissionSuccessful')) + }) + .catch(() => { + errorToast( + loginWithUsername + ? t('authentication:usernameNotValid') + : t('authentication:emailNotValid'), + ) + }) + } + + const initialState: FormState = loginWithUsername + ? { + username: { + initialValue: '', + valid: true, + value: undefined, + }, + } + : { + email: { + initialValue: '', + valid: true, + value: undefined, + }, + } + + if (hasSubmitted) { + return ( + + ) + } + + return ( +
+ + + {loginWithUsername ? ( + + text(value, { + name: 'username', + type: 'text', + blockData: {}, + data: {}, + event: 'onChange', + path: ['username'], + preferences: { fields: {} }, + req: { + payload: { + config, + }, + t, + } as unknown as PayloadRequest, + required: true, + siblingData: {}, + }) + } + /> + ) : ( + + email(value, { + name: 'email', + type: 'email', + blockData: {}, + data: {}, + event: 'onChange', + path: ['email'], + preferences: { fields: {} }, + req: { payload: { config }, t } as unknown as PayloadRequest, + required: true, + siblingData: {}, + }) + } + /> + )} + {t('general:submit')} + + ) +} diff --git a/packages/ui/src/views/ForgotPassword/index.tsx b/packages/ui/src/views/ForgotPassword/index.tsx new file mode 100644 index 00000000000..04cf19f8d25 --- /dev/null +++ b/packages/ui/src/views/ForgotPassword/index.tsx @@ -0,0 +1,78 @@ +import type { AdminViewServerProps } from 'payload' + +import { formatAdminURL } from 'payload/shared' +import React, { Fragment } from 'react' + +import { Button } from '../../elements/Button/index.js' +import { Link } from '../../elements/Link/index.js' +import { Translation } from '../../elements/Translation/index.js' +// TODO: FormHeader is currently in packages/next/src/elements/FormHeader/ +// It needs to be moved to packages/ui/src/elements/FormHeader/ as a separate step +import { FormHeader } from '../../elements/FormHeader/index.js' +import { ForgotPasswordForm } from './ForgotPasswordForm/index.js' + +export const forgotPasswordBaseClass = 'forgot-password' + +export function ForgotPasswordView({ initPageResult }: AdminViewServerProps) { + const { + req: { + i18n, + payload: { config }, + user, + }, + } = initPageResult + + const { + admin: { + routes: { account: accountRoute, login: loginRoute }, + }, + routes: { admin: adminRoute }, + } = config + + if (user) { + return ( + + ( + + {children} + + ), + }} + i18nKey="authentication:loggedInChangePassword" + t={i18n.t} + /> + } + heading={i18n.t('authentication:alreadyLoggedIn')} + /> + + + ) + } + + return ( + + + + {i18n.t('authentication:backToLogin')} + + + ) +} diff --git a/packages/ui/src/views/Logout/LogoutClient.tsx b/packages/ui/src/views/Logout/LogoutClient.tsx new file mode 100644 index 00000000000..306a65f0bb9 --- /dev/null +++ b/packages/ui/src/views/Logout/LogoutClient.tsx @@ -0,0 +1,90 @@ +'use client' +import { formatAdminURL } from 'payload/shared' +import React, { useEffect } from 'react' +import { toast } from 'sonner' + +import { Button } from '../../elements/Button/index.js' +import { LoadingOverlay } from '../../elements/Loading/index.js' +import { useAuth } from '../../providers/Auth/index.js' +import { useConfig } from '../../providers/Config/index.js' +import { useRouter } from '../../providers/Router/index.js' +import { useRouteTransition } from '../../providers/RouteTransition/index.js' +import { useTranslation } from '../../providers/Translation/index.js' +import './index.scss' + +const baseClass = 'logout' + +/** + * This component should **just** be the inactivity route and do nothing with logging the user out. + * + * It currently handles too much, the auth provider should just log the user out and then + * we could remove the useEffect in this file. So instead of the logout button + * being an anchor link, it should be a button that calls `logOut` in the provider. + * + * This view is still useful if cookies attempt to refresh and fail, i.e. the user + * is logged out due to inactivity. + */ +export const LogoutClient: React.FC<{ + adminRoute: string + inactivity?: boolean + redirect: string +}> = (props) => { + const { adminRoute, inactivity, redirect } = props + + const { logOut, user } = useAuth() + const { config } = useConfig() + + const { startRouteTransition } = useRouteTransition() + + const isLoggedIn = React.useMemo(() => { + return Boolean(user?.id) + }, [user?.id]) + + const navigatingToLoginRef = React.useRef(false) + + const [loginRoute] = React.useState(() => + formatAdminURL({ + adminRoute, + path: `/login${ + inactivity && redirect && redirect.length > 0 + ? `?redirect=${encodeURIComponent(redirect)}` + : '' + }`, + }), + ) + + const { t } = useTranslation() + const router = useRouter() + + const handleLogOut = React.useCallback(async () => { + if (!navigatingToLoginRef.current) { + navigatingToLoginRef.current = true + await logOut() + toast.success(t('authentication:loggedOutSuccessfully')) + startRouteTransition(() => router.push(loginRoute)) + return + } + }, [logOut, loginRoute, router, startRouteTransition, t]) + + useEffect(() => { + if (isLoggedIn && !inactivity) { + void handleLogOut() + } else if (!navigatingToLoginRef.current) { + navigatingToLoginRef.current = true + startRouteTransition(() => router.push(loginRoute)) + } + }, [handleLogOut, isLoggedIn, loginRoute, router, startRouteTransition, inactivity]) + + if (!isLoggedIn && inactivity) { + return ( +
+

{t('authentication:loggedOutInactivity')}

+ +
+ ) + } + + return +} diff --git a/packages/ui/src/views/Logout/index.scss b/packages/ui/src/views/Logout/index.scss new file mode 100644 index 00000000000..24cf381bb10 --- /dev/null +++ b/packages/ui/src/views/Logout/index.scss @@ -0,0 +1,25 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .logout { + display: flex; + flex-direction: column; + align-items: center; + flex-wrap: wrap; + + &__wrap { + z-index: 1; + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: base(0.8); + width: 100%; + max-width: base(36); + + & > * { + margin: 0; + } + } + } +} diff --git a/packages/ui/src/views/Logout/index.tsx b/packages/ui/src/views/Logout/index.tsx new file mode 100644 index 00000000000..8cd0dd0ff16 --- /dev/null +++ b/packages/ui/src/views/Logout/index.tsx @@ -0,0 +1,38 @@ +import type { AdminViewServerProps } from 'payload' + +import React from 'react' + +import { LogoutClient } from './LogoutClient.js' +import './index.scss' + +const baseClass = 'logout' + +export const LogoutView: React.FC< + { + inactivity?: boolean + } & AdminViewServerProps +> = ({ inactivity, initPageResult, searchParams }) => { + const { + req: { + payload: { + config: { + routes: { admin: adminRoute }, + }, + }, + }, + } = initPageResult + + return ( +
+ +
+ ) +} + +export function LogoutInactivity(props: AdminViewServerProps) { + return +} diff --git a/packages/ui/src/views/ResetPassword/ResetPasswordForm/index.tsx b/packages/ui/src/views/ResetPassword/ResetPasswordForm/index.tsx new file mode 100644 index 00000000000..f354b0a39f0 --- /dev/null +++ b/packages/ui/src/views/ResetPassword/ResetPasswordForm/index.tsx @@ -0,0 +1,94 @@ +'use client' +import { type FormState } from 'payload' +import { formatAdminURL } from 'payload/shared' +import React from 'react' + +import { ConfirmPasswordField } from '../../../fields/ConfirmPassword/index.js' +import { HiddenField } from '../../../fields/Hidden/index.js' +import { PasswordField } from '../../../fields/Password/index.js' +import { Form } from '../../../forms/Form/index.js' +import { FormSubmit } from '../../../forms/Submit/index.js' +import { useAuth } from '../../../providers/Auth/index.js' +import { useConfig } from '../../../providers/Config/index.js' +import { useRouter } from '../../../providers/Router/index.js' +import { useTranslation } from '../../../providers/Translation/index.js' + +type Args = { + readonly token: string +} + +export const ResetPasswordForm: React.FC = ({ token }) => { + const i18n = useTranslation() + const { + config: { + admin: { + routes: { login: loginRoute }, + user: userSlug, + }, + routes: { admin: adminRoute, api: apiRoute }, + serverURL, + }, + } = useConfig() + + const history = useRouter() + const { fetchFullUser } = useAuth() + + const onSuccess = React.useCallback(async () => { + const user = await fetchFullUser() + if (user) { + history.push(adminRoute) + } else { + history.push( + formatAdminURL({ + adminRoute, + path: loginRoute, + }), + ) + } + }, [adminRoute, fetchFullUser, history, loginRoute]) + + const initialState: FormState = { + 'confirm-password': { + initialValue: '', + valid: false, + value: '', + }, + password: { + initialValue: '', + valid: false, + value: '', + }, + token: { + initialValue: token, + valid: true, + value: token, + }, + } + + return ( +
+
+ + + +
+ {i18n.t('authentication:resetPassword')} +
+ ) +} diff --git a/packages/ui/src/views/ResetPassword/index.scss b/packages/ui/src/views/ResetPassword/index.scss new file mode 100644 index 00000000000..c5c73c3f725 --- /dev/null +++ b/packages/ui/src/views/ResetPassword/index.scss @@ -0,0 +1,11 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .reset-password__wrap { + .inputWrap { + display: flex; + flex-direction: column; + gap: base(0.8); + } + } +} diff --git a/packages/ui/src/views/ResetPassword/index.tsx b/packages/ui/src/views/ResetPassword/index.tsx new file mode 100644 index 00000000000..2df43d12ec6 --- /dev/null +++ b/packages/ui/src/views/ResetPassword/index.tsx @@ -0,0 +1,84 @@ +import type { AdminViewServerProps } from 'payload' + +import { formatAdminURL } from 'payload/shared' +import React from 'react' + +import { Button } from '../../elements/Button/index.js' +import { Link } from '../../elements/Link/index.js' +import { Translation } from '../../elements/Translation/index.js' +// TODO: FormHeader is currently in packages/next/src/elements/FormHeader/ +// It needs to be moved to packages/ui/src/elements/FormHeader/ as a separate step +import { FormHeader } from '../../elements/FormHeader/index.js' +import { ResetPasswordForm } from './ResetPasswordForm/index.js' +import './index.scss' + +export const resetPasswordBaseClass = 'reset-password' + +export function ResetPassword({ initPageResult, params }: AdminViewServerProps) { + const { req } = initPageResult + + const { + segments: [_, token], + } = params + + const { + i18n, + payload: { config }, + user, + } = req + + const { + admin: { + routes: { account: accountRoute, login: loginRoute }, + }, + routes: { admin: adminRoute }, + } = config + + if (user) { + return ( +
+ ( + + {children} + + ), + }} + i18nKey="authentication:loggedInChangePassword" + t={i18n.t} + /> + } + heading={i18n.t('authentication:alreadyLoggedIn')} + /> + +
+ ) + } + + return ( +
+ + + + {i18n.t('authentication:backToLogin')} + +
+ ) +} diff --git a/packages/ui/src/views/Unauthorized/index.scss b/packages/ui/src/views/Unauthorized/index.scss new file mode 100644 index 00000000000..d7d5d69f9b7 --- /dev/null +++ b/packages/ui/src/views/Unauthorized/index.scss @@ -0,0 +1,14 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .unauthorized { + &__button.btn { + margin: 0; + margin-block: 0; + } + + &--with-gutter { + margin-top: var(--base); + } + } +} diff --git a/packages/ui/src/views/Unauthorized/index.tsx b/packages/ui/src/views/Unauthorized/index.tsx new file mode 100644 index 00000000000..c1753a77edd --- /dev/null +++ b/packages/ui/src/views/Unauthorized/index.tsx @@ -0,0 +1,61 @@ +import type { AdminViewServerProps } from 'payload' + +import { formatAdminURL } from 'payload/shared' +import React from 'react' + +import { Button } from '../../elements/Button/index.js' +import { Gutter } from '../../elements/Gutter/index.js' +// TODO: FormHeader is currently in packages/next/src/elements/FormHeader/ +// It needs to be moved to packages/ui/src/elements/FormHeader/ as a separate step +import { FormHeader } from '../../elements/FormHeader/index.js' +import './index.scss' + +const baseClass = 'unauthorized' + +export function UnauthorizedView({ initPageResult }: AdminViewServerProps) { + const { + permissions, + req: { + i18n, + payload: { + config: { + admin: { + routes: { logout: logoutRoute }, + }, + routes: { admin: adminRoute }, + }, + }, + user, + }, + } = initPageResult + + return ( +
+ + +
+ ) +} + +export const UnauthorizedViewWithGutter = (props: AdminViewServerProps) => { + return ( + + + + ) +} diff --git a/packages/ui/src/views/Verify/index.client.tsx b/packages/ui/src/views/Verify/index.client.tsx new file mode 100644 index 00000000000..c0819d96fc7 --- /dev/null +++ b/packages/ui/src/views/Verify/index.client.tsx @@ -0,0 +1,36 @@ +'use client' +import React, { useEffect } from 'react' +import { toast } from 'sonner' + +import { useRouter } from '../../providers/Router/index.js' +import { useRouteTransition } from '../../providers/RouteTransition/index.js' + +type Props = { + message: string + redirectTo: string +} +export function ToastAndRedirect({ message, redirectTo }: Props) { + const router = useRouter() + const { startRouteTransition } = useRouteTransition() + const hasToastedRef = React.useRef(false) + + useEffect(() => { + let timeoutID + + if (toast) { + timeoutID = setTimeout(() => { + toast.success(message) + hasToastedRef.current = true + startRouteTransition(() => router.push(redirectTo)) + }, 100) + } + + return () => { + if (timeoutID) { + clearTimeout(timeoutID) + } + } + }, [router, redirectTo, message, startRouteTransition]) + + return null +} diff --git a/packages/ui/src/views/Verify/index.scss b/packages/ui/src/views/Verify/index.scss new file mode 100644 index 00000000000..77b4a8841c0 --- /dev/null +++ b/packages/ui/src/views/Verify/index.scss @@ -0,0 +1,16 @@ +@layer payload-default { + .verify { + display: flex; + align-items: center; + text-align: center; + flex-wrap: wrap; + min-height: 100vh; + + &__brand { + display: flex; + justify-content: center; + width: 100%; + margin-bottom: calc(var(--base) * 2); + } + } +} diff --git a/packages/ui/src/views/Verify/index.tsx b/packages/ui/src/views/Verify/index.tsx new file mode 100644 index 00000000000..8e099f58b66 --- /dev/null +++ b/packages/ui/src/views/Verify/index.tsx @@ -0,0 +1,72 @@ +import type { AdminViewServerProps } from 'payload' + +import { formatAdminURL } from 'payload/shared' +import React from 'react' + +// TODO: Logo is currently in packages/next/src/elements/Logo/ +// It needs to be moved to packages/ui/src/elements/Logo/ as a separate step +import { Logo } from '../../elements/Logo/index.js' +import { ToastAndRedirect } from './index.client.js' +import './index.scss' + +export const verifyBaseClass = 'verify' + +export async function Verify({ initPageResult, params, searchParams }: AdminViewServerProps) { + // /:collectionSlug/verify/:token + + const [collectionSlug, verify, token] = params.segments + const { locale, permissions, req } = initPageResult + + const { + i18n, + payload: { config }, + payload, + user, + } = req + + const { + routes: { admin: adminRoute }, + serverURL, + } = config + + let textToRender + let isVerified = false + + try { + await req.payload.verifyEmail({ + collection: collectionSlug, + token, + }) + + isVerified = true + textToRender = req.t('authentication:emailVerified') + } catch (e) { + textToRender = req.t('authentication:unableToVerify') + } + + if (isVerified) { + return ( + + ) + } + + return ( + +
+ +
+

{textToRender}

+
+ ) +} From 19592bacde083b14d9eb8762b3c82216cbb6cc35 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 15:59:03 +0100 Subject: [PATCH 08/60] refactor: move Dashboard view and utilities to packages/ui Move the entire Dashboard view (18 files, ~2400 lines) from packages/next to packages/ui. Convert all @payloadcms/ui self-imports to relative paths. Move getPreferences utility. Add @dnd-kit/modifiers dependency to packages/ui. packages/next Dashboard entry becomes a thin re-export. Co-Authored-By: Claude Sonnet 4.6 --- packages/next/src/views/Dashboard/index.tsx | 55 +-- packages/ui/package.json | 1 + packages/ui/src/utilities/getPreferences.ts | 42 ++ .../ModularDashboard/DashboardStepNav.tsx | 128 ++++++ .../ModularDashboard/WidgetConfigDrawer.tsx | 157 +++++++ .../ModularDashboard/WidgetEditControl.tsx | 62 +++ .../Default/ModularDashboard/index.client.tsx | 385 ++++++++++++++++++ .../Default/ModularDashboard/index.scss | 318 +++++++++++++++ .../Default/ModularDashboard/index.tsx | 67 +++ .../renderWidget/RenderWidget.tsx | 89 ++++ .../renderWidget/getDefaultLayoutServerFn.ts | 80 ++++ .../renderWidget/renderWidgetServerFn.ts | 107 +++++ .../ModularDashboard/useDashboardLayout.ts | 245 +++++++++++ .../utils/collisionDetection.ts | 40 ++ .../utils/getItemsFromConfig.ts | 27 ++ .../utils/getItemsFromPreferences.ts | 27 ++ .../ModularDashboard/utils/localeUtils.ts | 146 +++++++ .../Default/ModularDashboard/utils/sensors.ts | 332 +++++++++++++++ .../ui/src/views/Dashboard/Default/index.tsx | 80 ++++ packages/ui/src/views/Dashboard/index.tsx | 56 +++ pnpm-lock.yaml | 3 + 21 files changed, 2393 insertions(+), 54 deletions(-) create mode 100644 packages/ui/src/utilities/getPreferences.ts create mode 100644 packages/ui/src/views/Dashboard/Default/ModularDashboard/DashboardStepNav.tsx create mode 100644 packages/ui/src/views/Dashboard/Default/ModularDashboard/WidgetConfigDrawer.tsx create mode 100644 packages/ui/src/views/Dashboard/Default/ModularDashboard/WidgetEditControl.tsx create mode 100644 packages/ui/src/views/Dashboard/Default/ModularDashboard/index.client.tsx create mode 100644 packages/ui/src/views/Dashboard/Default/ModularDashboard/index.scss create mode 100644 packages/ui/src/views/Dashboard/Default/ModularDashboard/index.tsx create mode 100644 packages/ui/src/views/Dashboard/Default/ModularDashboard/renderWidget/RenderWidget.tsx create mode 100644 packages/ui/src/views/Dashboard/Default/ModularDashboard/renderWidget/getDefaultLayoutServerFn.ts create mode 100644 packages/ui/src/views/Dashboard/Default/ModularDashboard/renderWidget/renderWidgetServerFn.ts create mode 100644 packages/ui/src/views/Dashboard/Default/ModularDashboard/useDashboardLayout.ts create mode 100644 packages/ui/src/views/Dashboard/Default/ModularDashboard/utils/collisionDetection.ts create mode 100644 packages/ui/src/views/Dashboard/Default/ModularDashboard/utils/getItemsFromConfig.ts create mode 100644 packages/ui/src/views/Dashboard/Default/ModularDashboard/utils/getItemsFromPreferences.ts create mode 100644 packages/ui/src/views/Dashboard/Default/ModularDashboard/utils/localeUtils.ts create mode 100644 packages/ui/src/views/Dashboard/Default/ModularDashboard/utils/sensors.ts create mode 100644 packages/ui/src/views/Dashboard/Default/index.tsx create mode 100644 packages/ui/src/views/Dashboard/index.tsx diff --git a/packages/next/src/views/Dashboard/index.tsx b/packages/next/src/views/Dashboard/index.tsx index 576ae264656..658514c94bc 100644 --- a/packages/next/src/views/Dashboard/index.tsx +++ b/packages/next/src/views/Dashboard/index.tsx @@ -1,54 +1 @@ -import type { AdminViewServerProps } from 'payload' - -import { HydrateAuthProvider, SetStepNav } from '@payloadcms/ui' -import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' -import { getGlobalData, getNavGroups } from '@payloadcms/ui/shared' -import React, { Fragment } from 'react' - -import type { DashboardViewClientProps, DashboardViewServerPropsOnly } from './Default/index.js' - -import { DefaultDashboard } from './Default/index.js' - -export async function DashboardView(props: AdminViewServerProps) { - const { - locale, - permissions, - req: { - i18n, - payload: { config }, - payload, - user, - }, - req, - visibleEntities, - } = props.initPageResult - - const globalData = await getGlobalData(req) - const navGroups = getNavGroups(permissions, visibleEntities, config, i18n) - - return ( - - - - {RenderServerComponent({ - clientProps: { - locale, - } satisfies DashboardViewClientProps, - Component: config.admin?.components?.views?.dashboard?.Component, - Fallback: DefaultDashboard, - importMap: payload.importMap, - serverProps: { - ...props, - globalData, - i18n, - locale, - navGroups, - payload, - permissions, - user, - visibleEntities, - } satisfies DashboardViewServerPropsOnly, - })} - - ) -} +export { DashboardView } from '@payloadcms/ui/views/Dashboard' diff --git a/packages/ui/package.json b/packages/ui/package.json index 70a8aec181f..73b33c0779a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -144,6 +144,7 @@ "dependencies": { "@date-fns/tz": "1.2.0", "@dnd-kit/core": "6.3.1", + "@dnd-kit/modifiers": "9.0.0", "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", "@faceless-ui/modal": "3.0.0", diff --git a/packages/ui/src/utilities/getPreferences.ts b/packages/ui/src/utilities/getPreferences.ts new file mode 100644 index 00000000000..97e6811bc8e --- /dev/null +++ b/packages/ui/src/utilities/getPreferences.ts @@ -0,0 +1,42 @@ +import type { DefaultDocumentIDType, Payload } from 'payload' + +import { cache } from 'react' + +export const getPreferences = cache( + async ( + key: string, + payload: Payload, + userID: DefaultDocumentIDType, + userSlug: string, + ): Promise<{ id: DefaultDocumentIDType; value: T }> => { + const result = (await payload + .find({ + collection: 'payload-preferences', + depth: 0, + limit: 1, + pagination: false, + where: { + and: [ + { + key: { + equals: key, + }, + }, + { + 'user.relationTo': { + equals: userSlug, + }, + }, + { + 'user.value': { + equals: userID, + }, + }, + ], + }, + }) + .then((res) => res.docs?.[0])) as { id: DefaultDocumentIDType; value: T } + + return result + }, +) diff --git a/packages/ui/src/views/Dashboard/Default/ModularDashboard/DashboardStepNav.tsx b/packages/ui/src/views/Dashboard/Default/ModularDashboard/DashboardStepNav.tsx new file mode 100644 index 00000000000..ad658575807 --- /dev/null +++ b/packages/ui/src/views/Dashboard/Default/ModularDashboard/DashboardStepNav.tsx @@ -0,0 +1,128 @@ +'use client' +import type { ClientWidget } from 'payload' + +import { useEffect, useId } from 'react' + +import { Button } from '../../../../elements/Button/index.js' +import { DrawerToggler } from '../../../../elements/Drawer/index.js' +import { ItemsDrawer } from '../../../../elements/ItemsDrawer/index.js' +import { type Option as Option, ReactSelect } from '../../../../elements/ReactSelect/index.js' +import { useStepNav } from '../../../../elements/StepNav/index.js' +import { useTranslation } from '../../../../providers/Translation/index.js' + +export function DashboardStepNav({ + addWidget, + cancel, + isEditing, + resetLayout, + saveLayout, + setIsEditing, + widgets, +}: { + addWidget: (slug: string) => void + cancel: () => void + isEditing: boolean + resetLayout: () => Promise + saveLayout: () => Promise + setIsEditing: (isEditing: boolean) => void + widgets: ClientWidget[] +}) { + const { t } = useTranslation() + const { setStepNav } = useStepNav() + const uuid = useId() + const drawerSlug = `widgets-drawer-${uuid}` + + useEffect(() => { + setStepNav([ + { + label: ( + setIsEditing(true)} + onResetLayout={resetLayout} + onSaveChanges={saveLayout} + widgetsDrawerSlug={drawerSlug} + /> + ), + }, + ]) + }, [isEditing, drawerSlug, cancel, resetLayout, saveLayout, setIsEditing, setStepNav]) + + return ( + <> + {isEditing && ( + addWidget(widget.slug)} + searchPlaceholder={t('dashboard:searchWidgets')} + title={t('dashboard:addWidget')} + /> + )} + + ) +} + +export function DashboardBreadcrumbDropdown(props: { + isEditing: boolean + onCancel: () => void + onEditClick: () => void + onResetLayout: () => void + onSaveChanges: () => void + widgetsDrawerSlug: string +}) { + const { isEditing, onCancel, onEditClick, onResetLayout, onSaveChanges, widgetsDrawerSlug } = + props + const { t } = useTranslation() + + if (isEditing) { + return ( +
+ {t('dashboard:editingDashboard')} +
+ + + + + +
+
+ ) + } + + const options = [ + { label: t('dashboard:editDashboard'), value: 'edit' }, + { label: t('dashboard:resetLayout'), value: 'reset' }, + ] + + const handleChange = (selectedOption: Option | Option[]) => { + // Since isMulti is false, we expect a single Option + const option = Array.isArray(selectedOption) ? selectedOption[0] : selectedOption + + if (option?.value === 'edit') { + onEditClick() + } else if (option?.value === 'reset') { + onResetLayout() + } + } + + return ( + + ) +} diff --git a/packages/ui/src/views/Dashboard/Default/ModularDashboard/WidgetConfigDrawer.tsx b/packages/ui/src/views/Dashboard/Default/ModularDashboard/WidgetConfigDrawer.tsx new file mode 100644 index 00000000000..de517096df8 --- /dev/null +++ b/packages/ui/src/views/Dashboard/Default/ModularDashboard/WidgetConfigDrawer.tsx @@ -0,0 +1,157 @@ +'use client' + +import type { ClientWidget, FormState } from 'payload' + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { v4 as uuid } from 'uuid' + +import { Drawer } from '../../../../elements/Drawer/index.js' +import { useModal } from '../../../../elements/Modal/index.js' +import { ShimmerEffect } from '../../../../elements/ShimmerEffect/index.js' +import { Form } from '../../../../forms/Form/index.js' +import { RenderFields } from '../../../../forms/RenderFields/index.js' +import { FormSubmit } from '../../../../forms/Submit/index.js' +import { useLocale } from '../../../../providers/Locale/index.js' +import { OperationProvider } from '../../../../providers/Operation/index.js' +import { useServerFunctions } from '../../../../providers/ServerFunctions/index.js' +import { useTranslation } from '../../../../providers/Translation/index.js' +import { abortAndIgnore } from '../../../../utilities/abortAndIgnore.js' +import { extractLocaleData, mergeLocaleData } from './utils/localeUtils.js' + +type WidgetConfigDrawerProps = { + drawerSlug: string + onSave: (data: Record) => void + widget: ClientWidget + widgetData?: Record +} + +const EMPTY_WIDGET_PREFERENCES = { + fields: {}, +} + +export function WidgetConfigDrawer({ + drawerSlug, + onSave, + widget, + widgetData, +}: WidgetConfigDrawerProps) { + const { closeModal, modalState } = useModal() + const { getFormState } = useServerFunctions() + const { t } = useTranslation() + const locale = useLocale() + const localeCode = locale?.code ?? 'en' + const onChangeAbortControllerRef = useRef(null) + + const [initialState, setInitialState] = useState(false) + + const isOpen = Boolean(modalState?.[drawerSlug]?.isOpen) + const formUUID = useMemo(() => uuid(), []) + const widgetLabel = useMemo( + () => (typeof widget.label === 'string' ? widget.label : widget.slug), + [widget.label, widget.slug], + ) + const fields = useMemo(() => widget.fields ?? [], [widget.fields]) + + useEffect(() => { + if (!isOpen || fields.length === 0) { + setInitialState(false) + return + } + + const controller = new AbortController() + + const loadInitialState = async () => { + const localeFilteredData = extractLocaleData(widgetData ?? {}, localeCode, fields) + + const { state } = await getFormState({ + data: localeFilteredData, + docPermissions: { + fields: true, + }, + docPreferences: EMPTY_WIDGET_PREFERENCES, + locale: localeCode, + operation: 'update', + renderAllFields: true, + schemaPath: widget.slug, + signal: controller.signal, + widgetSlug: widget.slug, + }) + + if (state) { + setInitialState(state) + } + } + + void loadInitialState() + + return () => { + abortAndIgnore(controller) + } + }, [fields, getFormState, isOpen, localeCode, widget.slug, widgetData]) + + const onChange = useCallback( + async ({ formState: prevFormState }: { formState: FormState }) => { + abortAndIgnore(onChangeAbortControllerRef.current) + + const controller = new AbortController() + onChangeAbortControllerRef.current = controller + + const { state } = await getFormState({ + docPermissions: { + fields: true, + }, + docPreferences: EMPTY_WIDGET_PREFERENCES, + formState: prevFormState, + operation: 'update', + schemaPath: widget.slug, + signal: controller.signal, + widgetSlug: widget.slug, + }) + + if (!state) { + return prevFormState + } + + return state + }, + [getFormState, widget.slug], + ) + + useEffect(() => { + return () => { + abortAndIgnore(onChangeAbortControllerRef.current) + } + }, []) + + return ( + + {initialState === false ? ( + + ) : ( + +
{ + onSave(mergeLocaleData(widgetData ?? {}, data, localeCode, fields)) + closeModal(drawerSlug) + }} + uuid={formUUID} + > + + {t('fields:saveChanges')} + +
+ )} +
+ ) +} diff --git a/packages/ui/src/views/Dashboard/Default/ModularDashboard/WidgetEditControl.tsx b/packages/ui/src/views/Dashboard/Default/ModularDashboard/WidgetEditControl.tsx new file mode 100644 index 00000000000..46fde1f8e2a --- /dev/null +++ b/packages/ui/src/views/Dashboard/Default/ModularDashboard/WidgetEditControl.tsx @@ -0,0 +1,62 @@ +'use client' + +import type { Data } from 'payload' + +import React, { useId } from 'react' + +import { useModal } from '../../../../elements/Modal/index.js' +import { EditIcon } from '../../../../icons/Edit/index.js' +import { useConfig } from '../../../../providers/Config/index.js' +import { useTranslation } from '../../../../providers/Translation/index.js' +import { WidgetConfigDrawer } from './WidgetConfigDrawer.js' + +const getWidgetSlugFromID = (widgetID: string): string => + widgetID.slice(0, widgetID.lastIndexOf('-')) + +export function WidgetEditControl({ + onSave, + widgetData, + widgetID, +}: { + onSave: (data: Data) => void + widgetData?: Record + widgetID: string +}) { + const { t } = useTranslation() + const { openModal } = useModal() + const { widgets: configWidgets = [] } = useConfig().config.admin.dashboard ?? {} + + const widgetSlug = getWidgetSlugFromID(widgetID) + const widgetConfig = configWidgets.find((widget) => widget.slug === widgetSlug) + const hasEditableFields = Boolean(widgetConfig?.fields?.length) + + const drawerID = useId() + const drawerSlug = `widget-editor-${drawerID}` + + if (!hasEditableFields) { + return null + } + + return ( + <> + + + + ) +} diff --git a/packages/ui/src/views/Dashboard/Default/ModularDashboard/index.client.tsx b/packages/ui/src/views/Dashboard/Default/ModularDashboard/index.client.tsx new file mode 100644 index 00000000000..f690f4f2c42 --- /dev/null +++ b/packages/ui/src/views/Dashboard/Default/ModularDashboard/index.client.tsx @@ -0,0 +1,385 @@ +'use client' + +import type { Modifier } from '@dnd-kit/core' +import type { ClientWidget, WidgetWidth } from 'payload' + +import { DndContext, DragOverlay, useDraggable, useDroppable } from '@dnd-kit/core' +import { snapCenterToCursor } from '@dnd-kit/modifiers' +import React, { useMemo, useState } from 'react' + +import { Popup } from '../../../../elements/Popup/index.js' +import * as PopupList from '../../../../elements/Popup/PopupButtonList/index.js' +import { ChevronIcon } from '../../../../icons/Chevron/index.js' +import { XIcon } from '../../../../icons/X/index.js' +import { useTranslation } from '../../../../providers/Translation/index.js' + +/** + * Custom modifier that only applies snapCenterToCursor for pointer events. + * During keyboard navigation, we handle positioning ourselves via the coordinate getter. + */ +const snapCenterToCursorOnlyForPointer: Modifier = (args) => { + const { activatorEvent } = args + + // Only apply snap for pointer events (mouse/touch), not keyboard + // Check activatorEvent.type since KeyboardEvent may not exist on server + if (activatorEvent && 'key' in activatorEvent) { + return args.transform + } + + return snapCenterToCursor(args) +} + +import { DashboardStepNav } from './DashboardStepNav.js' +import { useDashboardLayout } from './useDashboardLayout.js' +import { closestInXAxis } from './utils/collisionDetection.js' +import { useDashboardSensors } from './utils/sensors.js' +import { WidgetEditControl } from './WidgetEditControl.js' + +export type WidgetItem = { + data?: Record + id: string + maxWidth: WidgetWidth + minWidth: WidgetWidth + width: WidgetWidth +} + +export type WidgetInstanceClient = { + component: React.ReactNode + item: WidgetItem +} + +export type DropTargetWidget = { + position: 'after' | 'before' + widget: WidgetInstanceClient +} | null + +/* eslint-disable perfectionist/sort-objects */ +const WIDTH_TO_PERCENTAGE = { + 'x-small': 25, + small: (1 / 3) * 100, + medium: 50, + large: (2 / 3) * 100, + 'x-large': 75, + full: 100, +} as const + +export function ModularDashboardClient({ + clientLayout: initialLayout, + widgets, +}: { + clientLayout: WidgetInstanceClient[] + widgets: ClientWidget[] +}) { + const { t } = useTranslation() + const { + addWidget, + cancel, + cancelModal, + currentLayout, + deleteWidget, + isEditing, + moveWidget, + resetLayout, + resizeWidget, + saveLayout, + setIsEditing, + updateWidgetData, + } = useDashboardLayout(initialLayout) + + const [activeDragId, setActiveDragId] = useState(null) + const sensors = useDashboardSensors() + + return ( +
+ { + setActiveDragId(null) + }} + onDragEnd={(event) => { + if (!event.over) { + setActiveDragId(null) + return + } + const droppableId = event.over.id as string + const i = droppableId.lastIndexOf('-') + const slug = droppableId.slice(0, i) + const position = droppableId.slice(i + 1) + + if (slug === event.active.id) { + return + } + + const moveFromIndex = currentLayout?.findIndex( + (widget) => widget.item.id === event.active.id, + ) + let moveToIndex = currentLayout?.findIndex((widget) => widget.item.id === slug) + if (moveFromIndex < moveToIndex) { + moveToIndex-- + } + if (position === 'after') { + moveToIndex++ + } + moveWidget({ moveFromIndex, moveToIndex }) + setActiveDragId(null) + }} + onDragStart={(event) => { + setActiveDragId(event.active.id as string) + }} + sensors={sensors} + > +
+ {currentLayout?.length === 0 && ( +
+

{t('dashboard:noItems')}

+
+ )} + {currentLayout?.map((widget, _index) => ( + + +
+
+ {widget.component} +
+ {isEditing && ( +
e.stopPropagation()} + > + { + updateWidgetData(widget.item.id, data) + }} + widgetData={widget.item.data} + widgetID={widget.item.id} + /> + resizeWidget(widget.item.id, width)} + /> + +
+ )} +
+
+
+ ))} + + {activeDragId + ? (() => { + const draggedWidget = currentLayout?.find( + (widget) => widget.item.id === activeDragId, + ) + return draggedWidget ? ( +
+
+
{draggedWidget.component}
+
+
+ ) : null + })() + : null} +
+
+
+ + {cancelModal} +
+ ) +} + +function WidgetWidthDropdown({ + currentWidth, + maxWidth, + minWidth, + onResize, +}: { + currentWidth: WidgetWidth + maxWidth: WidgetWidth + minWidth: WidgetWidth + onResize: (width: WidgetWidth) => void +}) { + // Filter options based on minWidth and maxWidth + const validOptions = useMemo(() => { + const minPercentage = WIDTH_TO_PERCENTAGE[minWidth] + const maxPercentage = WIDTH_TO_PERCENTAGE[maxWidth] + + return Object.entries(WIDTH_TO_PERCENTAGE) + .map(([key, value]) => ({ + width: key as WidgetWidth, + percentage: value, + })) + .filter((option) => option.percentage >= minPercentage && option.percentage <= maxPercentage) + }, [minWidth, maxWidth]) + + const isDisabled = validOptions.length <= 1 + + if (isDisabled) { + return null + } + + return ( + e.stopPropagation()} + type="button" + > + {currentWidth} + + + } + buttonType="custom" + render={({ close }) => ( + + {validOptions.map((option) => { + const isSelected = option.width === currentWidth + return ( + { + onResize(option.width) + close() + }} + > + {option.width} + + {option.percentage.toFixed(0)}% + + + ) + })} + + )} + size="small" + verticalAlign="bottom" + /> + ) +} + +function DraggableItem(props: { + children: React.ReactNode + disabled?: boolean + id: string + style?: React.CSSProperties + width: WidgetWidth +}) { + const { attributes, isDragging, listeners, setNodeRef } = useDraggable({ + id: props.id, + disabled: props.disabled, + }) + + const mergedStyles: React.CSSProperties = { + ...props.style, + opacity: isDragging ? 0.3 : 1, + position: 'relative', + } + + // Only apply draggable attributes and listeners when not disabled + // to prevent disabling interactive elements inside the widget + const draggableProps = props.disabled ? {} : { ...listeners, ...attributes } + + return ( +
+ +
+ {props.children} +
+ +
+ ) +} + +function DroppableItem({ id, position }: { id: string; position: 'after' | 'before' }) { + const { setNodeRef, isOver } = useDroppable({ id: `${id}-${position}`, data: { position } }) + + return ( +
+ ) +} diff --git a/packages/ui/src/views/Dashboard/Default/ModularDashboard/index.scss b/packages/ui/src/views/Dashboard/Default/ModularDashboard/index.scss new file mode 100644 index 00000000000..60483425d6f --- /dev/null +++ b/packages/ui/src/views/Dashboard/Default/ModularDashboard/index.scss @@ -0,0 +1,318 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .modular-dashboard { + .widget-content { + height: 100%; + } + + // In mobile, make the widget full width and hide the size button. + @media (max-width: 768px) { + .widget { + width: 100% !important; + } + + .widget-wrapper__size-btn { + display: none; + } + } + + &.editing { + .widget-content { + user-select: none; + -webkit-user-select: none; + pointer-events: none; + } + + .draggable { + cursor: grab; + } + } + + .drag-overlay { + pointer-events: none; + user-select: none; + } + + &__empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + padding: 24px; + } + } + + // Apply grabbing cursor to body when drag-overlay is present + body:has(.drag-overlay) * { + cursor: grabbing; + } + + .widget-wrapper__controls { + opacity: 0; + transition: opacity 0.2s ease; + } + + .widget:focus-visible, + .widget:has(:focus-visible) { + .widget-wrapper__controls { + opacity: 1; + } + } + + // This is used to highlight a newly added widget. + @keyframes widget-highlight { + 0% { + box-shadow: 0 0 0 3px rgba(255, 208, 0, 0.9); + } + 100% { + box-shadow: 0 0 0 3px rgba(255, 208, 0, 0); + } + } + + .widget { + &--highlight { + animation: widget-highlight 1.5s ease-out forwards; + border-radius: 8px; + } + } + + .widget-wrapper { + position: relative; + height: 100%; + width: 100%; + + &--editing { + .widget-wrapper__controls { + // opacity: 0; + transition: opacity 0.2s ease; + } + + &:hover { + .widget-wrapper__controls { + opacity: 1; + } + } + } + + &__controls { + position: absolute; + top: 16px; + right: 16px; + z-index: 10; + + display: flex; + align-items: center; + gap: 8px; + } + + &__delete-btn, + &__edit-btn { + display: flex; + align-items: center; + justify-content: center; + + width: 28px; + height: 28px; + + background: var(--theme-text); + border: none; + border-radius: 4px; + cursor: pointer; + + transition: all 0.2s ease; + + .icon { + width: 16px; + height: 16px; + + .stroke { + stroke: var(--theme-elevation-0); + stroke-width: 2; + } + } + + &:hover { + background: var(--theme-elevation-800); + } + + &:active { + background: var(--theme-elevation-900); + } + + svg { + pointer-events: none; + } + } + + &__size-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + min-width: auto; + height: 28px; + + background: var(--theme-text); + border: none; + border-radius: 4px; + cursor: pointer; + + transition: all 0.2s ease; + + font-size: 12px; + font-weight: 500; + color: var(--theme-elevation-0); + white-space: nowrap; + + .icon { + width: 12px; + height: 12px; + flex-shrink: 0; + + .stroke { + stroke: var(--theme-elevation-0); + stroke-width: 2; + } + } + + &:hover { + background: var(--theme-elevation-800); + } + + &:active { + background: var(--theme-elevation-900); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + + &:hover { + background: var(--theme-text); + } + } + + svg { + pointer-events: none; + } + } + + &__size-btn-percentage { + font-size: 12px; + margin-left: auto; + color: var(--theme-elevation-500); + } + } + + // Since popup uses portal and renders to body, we need global styles + // for the size selection buttons in widget wrappers + .popup-button-list:has(.widget-wrapper__size-btn-label) { + .popup__scroll-container { + padding: 0; + } + } + + .popup__content:has(.widget-wrapper__size-btn-label) { + padding: 5px; + box-shadow: rgba(0, 0, 0, 0.35) 0px 5px 15px; + } + + .popup-button-list__button:has(.widget-wrapper__size-btn-label) { + display: flex !important; + align-items: center !important; + gap: 20px; + + .widget-wrapper__size-btn-label { + flex: 0 0 auto; + } + + .widget-wrapper__size-btn-percentage { + margin-left: auto !important; + } + } + + .step-nav { + .dashboard-breadcrumb-dropdown { + &__editing { + align-items: center; + display: flex !important; + gap: 12px; + } + + &__actions { + display: flex !important; + gap: 8px; + } + } + + .dashboard-breadcrumb-select { + position: relative; + z-index: 10; + + .rs__control { + background: transparent; + border: none; + box-shadow: none; + padding: 0; + cursor: pointer; + + &:hover { + background: transparent; + } + } + + .rs__indicator-separator { + display: none; + } + + .rs__dropdown-indicator { + padding: 0 4px; + } + + .rs__menu { + position: absolute; + top: 100%; + left: 0; + min-width: max-content; + width: max-content; + z-index: 9999; + border-radius: 4px; + padding: 5px; + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); + } + + .rs__menu-list { + padding: 0; + } + + .rs__option { + padding: 4px 8px; + border-radius: 3px; + margin: 0; + } + } + + .rs__control { + box-shadow: none !important; + cursor: pointer !important; + } + + .drawer-toggler--unstyled { + background: transparent; + border: none; + margin: 0; + padding: 0; + outline: none; + box-shadow: none; + + &:hover, + &:focus { + background: transparent; + } + } + } +} diff --git a/packages/ui/src/views/Dashboard/Default/ModularDashboard/index.tsx b/packages/ui/src/views/Dashboard/Default/ModularDashboard/index.tsx new file mode 100644 index 00000000000..3b2d51bc5a6 --- /dev/null +++ b/packages/ui/src/views/Dashboard/Default/ModularDashboard/index.tsx @@ -0,0 +1,67 @@ +import type { TFunction } from '@payloadcms/translations' +import type { ClientWidget, Field, WidgetServerProps } from 'payload' + +import React from 'react' + +import type { DashboardViewServerProps } from '../index.js' +import type { WidgetInstanceClient } from './index.client.js' + +import { RenderServerComponent } from '../../../../elements/RenderServerComponent/index.js' +import { ModularDashboardClient } from './index.client.js' +import { getItemsFromConfig } from './utils/getItemsFromConfig.js' +import { getItemsFromPreferences } from './utils/getItemsFromPreferences.js' +import { extractLocaleData } from './utils/localeUtils.js' +import './index.scss' + +type ServerLayout = WidgetInstanceClient[] + +export async function ModularDashboard(props: DashboardViewServerProps) { + const { defaultLayout = [], widgets = [] } = props.payload.config.admin.dashboard || {} + const { importMap } = props.payload + const { user } = props + const { cookies, locale, permissions, req } = props.initPageResult + const { i18n } = req + + const layout = + (await getItemsFromPreferences(props.payload, user)) ?? + (await getItemsFromConfig(defaultLayout, req, widgets)) + + const serverLayout: ServerLayout = layout.map((layoutItem) => { + const widgetSlug = layoutItem.id.slice(0, layoutItem.id.lastIndexOf('-')) + const widgetConfig = widgets.find((widget) => widget.slug === widgetSlug) + const widgetData = widgetConfig?.fields?.length + ? extractLocaleData(layoutItem.data || {}, req.locale || 'en', widgetConfig.fields as Field[]) + : layoutItem.data || {} + + return { + component: RenderServerComponent({ + Component: widgetConfig?.Component, + importMap, + serverProps: { + cookies, + locale, + permissions, + req, + widgetData, + widgetSlug, + } satisfies WidgetServerProps, + }), + item: layoutItem, + } + }) + + // Resolve function labels to static labels for client components + const clientWidgets: ClientWidget[] = widgets.map((widget) => { + const { Component: _, fields: __, label, ...rest } = widget + return { + ...rest, + label: typeof label === 'function' ? label({ i18n, t: i18n.t as TFunction }) : label, + } + }) + + return ( +
+ +
+ ) +} diff --git a/packages/ui/src/views/Dashboard/Default/ModularDashboard/renderWidget/RenderWidget.tsx b/packages/ui/src/views/Dashboard/Default/ModularDashboard/renderWidget/RenderWidget.tsx new file mode 100644 index 00000000000..57adda513af --- /dev/null +++ b/packages/ui/src/views/Dashboard/Default/ModularDashboard/renderWidget/RenderWidget.tsx @@ -0,0 +1,89 @@ +'use client' + +import React, { useCallback, useEffect, useRef } from 'react' + +import type { + RenderWidgetServerFnArgs, + RenderWidgetServerFnReturnType, +} from './renderWidgetServerFn.js' + +import { ShimmerEffect } from '../../../../../elements/ShimmerEffect/index.js' +import { useServerFunctions } from '../../../../../providers/ServerFunctions/index.js' + +/** + * Utility to render a widget on-demand on the client. + */ +export const RenderWidget: React.FC<{ + /** + * Instance-specific data for this widget + */ + widgetData?: Record + /** + * Unique ID for this widget instance (format: "slug-timestamp") + */ + widgetId: string +}> = ({ widgetData, widgetId }) => { + const [Component, setComponent] = React.useState(null) + const { serverFunction } = useServerFunctions() + const requestIDRef = useRef(0) + + const renderWidget = useCallback(() => { + async function render() { + const requestID = ++requestIDRef.current + setComponent(null) + + try { + const widgetSlug = widgetId.slice(0, widgetId.lastIndexOf('-')) + + const result = (await serverFunction({ + name: 'render-widget', + args: { + widgetData, + widgetSlug, + } as RenderWidgetServerFnArgs, + })) as RenderWidgetServerFnReturnType + + if (requestID !== requestIDRef.current) { + return + } + + setComponent(result.component) + } catch (_error) { + if (requestID !== requestIDRef.current) { + return + } + + // Log error but don't expose details to console in production + + // Fallback error component + setComponent( + React.createElement( + 'div', + { + style: { + background: 'var(--theme-error-50)', + border: '1px solid var(--theme-error-200)', + borderRadius: '4px', + color: 'var(--theme-error-text)', + padding: '20px', + textAlign: 'center', + }, + }, + 'Failed to load widget. Please try again later.', + ), + ) + } + } + void render() + }, [serverFunction, widgetData, widgetId]) + + useEffect(() => { + void renderWidget() + }, [renderWidget]) + + if (!Component) { + return + } + + return <>{Component} +} diff --git a/packages/ui/src/views/Dashboard/Default/ModularDashboard/renderWidget/getDefaultLayoutServerFn.ts b/packages/ui/src/views/Dashboard/Default/ModularDashboard/renderWidget/getDefaultLayoutServerFn.ts new file mode 100644 index 00000000000..786ce6c1664 --- /dev/null +++ b/packages/ui/src/views/Dashboard/Default/ModularDashboard/renderWidget/getDefaultLayoutServerFn.ts @@ -0,0 +1,80 @@ +import type { + DashboardConfig, + PayloadRequest, + ServerFunction, + Widget, + WidgetServerProps, +} from 'payload' + +import type { WidgetInstanceClient, WidgetItem } from '../index.client.js' + +import { RenderServerComponent } from '../../../../../elements/RenderServerComponent/index.js' + +export type GetDefaultLayoutServerFnArgs = Record + +export type GetDefaultLayoutServerFnReturnType = { + layout: WidgetInstanceClient[] +} + +/** + * Server function to get the default dashboard layout on-demand. + * Used when resetting the dashboard to its default configuration. + */ +export const getDefaultLayoutHandler: ServerFunction< + GetDefaultLayoutServerFnArgs, + Promise +> = async ({ cookies, locale, permissions, req }) => { + if (!req.user) { + throw new Error('Unauthorized') + } + + const { defaultLayout = [], widgets = [] } = req.payload.config.admin.dashboard || {} + const { importMap } = req.payload + + const layoutItems = await getItemsFromConfig(defaultLayout, req, widgets) + + const layout: WidgetInstanceClient[] = layoutItems.map((layoutItem) => { + const widgetSlug = layoutItem.id.slice(0, layoutItem.id.lastIndexOf('-')) + return { + component: RenderServerComponent({ + Component: widgets.find((widget) => widget.slug === widgetSlug)?.Component, + importMap, + serverProps: { + cookies, + locale, + permissions, + req, + widgetData: layoutItem.data || {}, + widgetSlug, + } satisfies WidgetServerProps, + }), + item: layoutItem, + } + }) + + return { layout } +} + +async function getItemsFromConfig( + defaultLayout: NonNullable, + req: PayloadRequest, + widgets: Pick[], +): Promise { + let widgetInstances + if (typeof defaultLayout === 'function') { + widgetInstances = await defaultLayout({ req }) + } else { + widgetInstances = defaultLayout + } + + return widgetInstances.map((widgetInstance, index) => { + const widget = widgets.find((w) => w.slug === widgetInstance.widgetSlug) + return { + id: `${widgetInstance.widgetSlug}-${index}`, + data: widgetInstance.data, + maxWidth: widget?.maxWidth ?? 'full', + minWidth: widget?.minWidth ?? 'x-small', + width: widgetInstance.width || 'x-small', + } + }) +} diff --git a/packages/ui/src/views/Dashboard/Default/ModularDashboard/renderWidget/renderWidgetServerFn.ts b/packages/ui/src/views/Dashboard/Default/ModularDashboard/renderWidget/renderWidgetServerFn.ts new file mode 100644 index 00000000000..a4839713ee7 --- /dev/null +++ b/packages/ui/src/views/Dashboard/Default/ModularDashboard/renderWidget/renderWidgetServerFn.ts @@ -0,0 +1,107 @@ +import type { Field, ServerFunction, WidgetServerProps } from 'payload' + +import React from 'react' + +import { RenderServerComponent } from '../../../../../elements/RenderServerComponent/index.js' +import { extractLocaleData } from '../utils/localeUtils.js' + +export type RenderWidgetServerFnArgs = { + /** + * Instance-specific data for this widget + */ + widgetData?: Record + /** + * The slug of the widget to render + */ + widgetSlug: string +} + +export type RenderWidgetServerFnReturnType = { + component: React.ReactNode +} + +/** + * Server function to render a widget on-demand. + * Similar to render-field but specifically for dashboard widgets. + */ +export const renderWidgetHandler: ServerFunction< + RenderWidgetServerFnArgs, + RenderWidgetServerFnReturnType +> = ({ cookies, locale, permissions, req, widgetData, widgetSlug }) => { + if (!req.user) { + throw new Error('Unauthorized') + } + + const { widgets } = req.payload.config.admin.dashboard + const { importMap } = req.payload + + // Find the widget configuration + const widgetConfig = widgets.find((widget) => widget.slug === widgetSlug) + + if (!widgetConfig) { + return { + component: React.createElement( + 'div', + { + style: { + background: 'var(--theme-elevation-50)', + border: '1px solid var(--theme-elevation-200)', + borderRadius: '4px', + color: 'var(--theme-text)', + padding: '20px', + textAlign: 'center', + }, + }, + `Widget "${widgetSlug}" not found`, + ), + } + } + + try { + const localeFilteredData = widgetConfig.fields?.length + ? extractLocaleData(widgetData || {}, req.locale || 'en', widgetConfig.fields as Field[]) + : widgetData || {} + + const serverProps: WidgetServerProps = { + cookies, + locale, + permissions, + req, + widgetData: localeFilteredData, + widgetSlug, + } + + // Render the widget server component + const component = RenderServerComponent({ + Component: widgetConfig.Component, + importMap, + serverProps, + }) + + return { component } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + + req.payload.logger.error({ + err: error, + msg: `Error rendering widget "${widgetSlug}": ${errorMessage}`, + }) + + return { + component: React.createElement( + 'div', + { + style: { + background: 'var(--theme-error-50)', + border: '1px solid var(--theme-error-200)', + borderRadius: '4px', + color: 'var(--theme-error-text)', + padding: '20px', + textAlign: 'center', + }, + }, + 'Error loading widget', + ), + } + } +} diff --git a/packages/ui/src/views/Dashboard/Default/ModularDashboard/useDashboardLayout.ts b/packages/ui/src/views/Dashboard/Default/ModularDashboard/useDashboardLayout.ts new file mode 100644 index 00000000000..4f286186a34 --- /dev/null +++ b/packages/ui/src/views/Dashboard/Default/ModularDashboard/useDashboardLayout.ts @@ -0,0 +1,245 @@ +import type { WidgetWidth } from 'payload' + +import { arrayMove } from '@dnd-kit/sortable' +import { PREFERENCE_KEYS } from 'payload/shared' +import React, { useCallback, useEffect, useState } from 'react' +import { toast } from 'sonner' + +import type { WidgetInstanceClient, WidgetItem } from './index.client.js' +import type { GetDefaultLayoutServerFnReturnType } from './renderWidget/getDefaultLayoutServerFn.js' + +import { ConfirmationModal } from '../../../../elements/ConfirmationModal/index.js' +import { useModal } from '../../../../elements/Modal/index.js' +import { useConfig } from '../../../../providers/Config/index.js' +import { usePreferences } from '../../../../providers/Preferences/index.js' +import { useServerFunctions } from '../../../../providers/ServerFunctions/index.js' +import { useTranslation } from '../../../../providers/Translation/index.js' +import { RenderWidget } from './renderWidget/RenderWidget.js' + +export function useDashboardLayout(initialLayout: WidgetInstanceClient[]) { + const setLayoutPreference = useSetLayoutPreference() + const [isEditing, setIsEditing] = useState(false) + const { widgets = [] } = useConfig().config.admin.dashboard ?? {} + const [currentLayout, setCurrentLayout] = useState(initialLayout) + const { openModal } = useModal() + const cancelModalSlug = 'cancel-dashboard-changes' + const { serverFunction } = useServerFunctions() + const { t } = useTranslation() + + // Sync state when initialLayout prop changes (e.g., when query params change and server component re-renders) + useEffect(() => { + if (!isEditing) { + setCurrentLayout(initialLayout) + } + // do not sync while editing. Depending on `isEditing` in this effect causes an + // unintended rollback when toggling from editing -> view mode after save. + }, [initialLayout]) + + const saveLayout = useCallback(async () => { + try { + const layoutData: WidgetItem[] = currentLayout.map((item) => item.item) + setIsEditing(false) + await setLayoutPreference(layoutData) + } catch { + setIsEditing(true) + toast.error(t('error:failedToSaveLayout')) + } + }, [setLayoutPreference, currentLayout]) + + const resetLayout = useCallback(async () => { + try { + await setLayoutPreference(null) + + const result = (await serverFunction({ + name: 'get-default-layout', + args: {}, + })) as GetDefaultLayoutServerFnReturnType + + setCurrentLayout(result.layout) + setIsEditing(false) + } catch { + toast.error(t('error:failedToResetLayout')) + } + }, [setLayoutPreference, serverFunction]) + + const performCancel = useCallback(() => { + setCurrentLayout(initialLayout) + setIsEditing(false) + }, [initialLayout]) + + const cancel = useCallback(() => { + // Check if layout has changed + const hasChanges = + currentLayout.length !== initialLayout.length || + currentLayout.some((widget, index) => { + const initialWidget = initialLayout[index] + return ( + !initialWidget || + widget.item.id !== initialWidget.item.id || + widget.item.width !== initialWidget.item.width || + JSON.stringify(widget.item.data || {}) !== JSON.stringify(initialWidget.item.data || {}) + ) + }) + + // If there are changes, show confirmation modal + if (hasChanges) { + openModal(cancelModalSlug) + } else { + performCancel() + } + }, [currentLayout, initialLayout, openModal, cancelModalSlug, performCancel]) + + const moveWidget = useCallback( + ({ moveFromIndex, moveToIndex }: { moveFromIndex: number; moveToIndex: number }) => { + if (moveFromIndex === moveToIndex || moveFromIndex < 0 || moveToIndex < 0) { + return + } + + setCurrentLayout((prev) => { + return arrayMove(prev, moveFromIndex, moveToIndex) + }) + }, + [], + ) + + const addWidget = useCallback( + (widgetSlug: string) => { + if (!isEditing) { + return + } + + const widgetId = `${widgetSlug}-${Date.now()}` + const widget = widgets.find((widget) => widget.slug === widgetSlug) + + // Create a new widget instance using RenderWidget + const newWidgetInstance: WidgetInstanceClient = { + component: React.createElement(RenderWidget, { + widgetData: {}, + widgetId, + }), + item: { + id: widgetId, + data: {}, + maxWidth: widget?.maxWidth ?? 'full', + minWidth: widget?.minWidth ?? 'x-small', + width: widget?.minWidth ?? 'x-small', + }, + } + + setCurrentLayout((prev) => [...prev, newWidgetInstance]) + + // Scroll to the newly added widget after it's rendered and highlight it + setTimeout(() => { + const element = document.getElementById(widgetId) + if (element) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }) + + // Add highlight animation to the widget element + const widget = element.closest('.widget') + if (widget) { + widget.classList.add('widget--highlight') + // Remove the class after animation completes (1.5s fade out) + setTimeout(() => { + widget.classList.remove('widget--highlight') + }, 1500) + } + } + }, 100) + }, + [isEditing, widgets], + ) + + const deleteWidget = useCallback( + (widgetId: string) => { + if (!isEditing) { + return + } + setCurrentLayout((prev) => prev.filter((item) => item.item.id !== widgetId)) + }, + [isEditing], + ) + + const resizeWidget = useCallback( + (widgetId: string, newWidth: WidgetWidth) => { + if (!isEditing) { + return + } + setCurrentLayout((prev) => + prev.map((item) => + item.item.id === widgetId + ? { + ...item, + item: { + ...item.item, + width: newWidth, + } satisfies WidgetItem, + } + : item, + ), + ) + }, + [isEditing], + ) + + const updateWidgetData = useCallback( + (widgetId: string, data: Record) => { + if (!isEditing) { + return + } + + setCurrentLayout((prev) => + prev.map((item) => + item.item.id === widgetId + ? { + component: React.createElement(RenderWidget, { + widgetData: data, + widgetId, + }), + item: { + ...item.item, + data, + } satisfies WidgetItem, + } + : item, + ), + ) + }, + [isEditing], + ) + + const cancelModal = React.createElement(ConfirmationModal, { + body: t('dashboard:discardMessage'), + confirmLabel: t('dashboard:discardConfirmLabel'), + heading: t('dashboard:discardTitle'), + modalSlug: cancelModalSlug, + onConfirm: performCancel, + }) + + return { + addWidget, + cancel, + cancelModal, + currentLayout, + deleteWidget, + isEditing, + moveWidget, + resetLayout, + resizeWidget, + saveLayout, + setIsEditing, + updateWidgetData, + } +} + +function useSetLayoutPreference() { + const { setPreference } = usePreferences() + return useCallback( + async (layout: null | WidgetItem[]) => { + await setPreference(PREFERENCE_KEYS.DASHBOARD_LAYOUT, { layouts: layout }, false) + }, + [setPreference], + ) +} diff --git a/packages/ui/src/views/Dashboard/Default/ModularDashboard/utils/collisionDetection.ts b/packages/ui/src/views/Dashboard/Default/ModularDashboard/utils/collisionDetection.ts new file mode 100644 index 00000000000..8b140b2b112 --- /dev/null +++ b/packages/ui/src/views/Dashboard/Default/ModularDashboard/utils/collisionDetection.ts @@ -0,0 +1,40 @@ +import type { CollisionDetection } from '@dnd-kit/core' + +/** + * Collision detection that considers the X + * axis only with respect to the position of the pointer (or collisionRect for keyboard) + */ +export const closestInXAxis: CollisionDetection = (args) => { + const collisions: Array<{ data: { value: number }; id: string }> = [] + + // Use pointer coordinates if available (mouse/touch), otherwise use collisionRect center (keyboard) + let x: number + let y: number + + if (args.pointerCoordinates) { + x = args.pointerCoordinates.x + y = args.pointerCoordinates.y + } else if (args.collisionRect) { + // For keyboard navigation, use the center of the collisionRect + x = args.collisionRect.left + args.collisionRect.width / 2 + y = args.collisionRect.top + args.collisionRect.height / 2 + } else { + return [] + } + + for (const container of args.droppableContainers) { + const rect = args.droppableRects.get(container.id) + if (!rect) { + continue + } + + // Only consider widgets in the same row (same Y axis) + if (y >= rect.top && y <= rect.bottom) { + const centerX = rect.left + rect.width / 2 + const distance = Math.abs(x - centerX) + collisions.push({ id: String(container.id), data: { value: distance } }) + } + } + + return collisions.sort((a, b) => a.data.value - b.data.value) +} diff --git a/packages/ui/src/views/Dashboard/Default/ModularDashboard/utils/getItemsFromConfig.ts b/packages/ui/src/views/Dashboard/Default/ModularDashboard/utils/getItemsFromConfig.ts new file mode 100644 index 00000000000..6e2ad3be1f1 --- /dev/null +++ b/packages/ui/src/views/Dashboard/Default/ModularDashboard/utils/getItemsFromConfig.ts @@ -0,0 +1,27 @@ +import type { DashboardConfig, PayloadRequest, Widget, WidgetInstance } from 'payload' + +import type { WidgetItem } from '../index.client.js' + +export async function getItemsFromConfig( + defaultLayout: NonNullable, + req: PayloadRequest, + widgets: Pick[], +): Promise { + let widgetInstances: WidgetInstance[] + if (typeof defaultLayout === 'function') { + widgetInstances = await defaultLayout({ req }) + } else { + widgetInstances = defaultLayout + } + + return widgetInstances.map((widgetInstance, index) => { + const widget = widgets.find((w) => w.slug === widgetInstance.widgetSlug) + return { + id: `${widgetInstance.widgetSlug}-${index}`, + data: widgetInstance.data, + maxWidth: widget?.maxWidth ?? 'full', + minWidth: widget?.minWidth ?? 'x-small', + width: widgetInstance.width || 'x-small', + } + }) +} diff --git a/packages/ui/src/views/Dashboard/Default/ModularDashboard/utils/getItemsFromPreferences.ts b/packages/ui/src/views/Dashboard/Default/ModularDashboard/utils/getItemsFromPreferences.ts new file mode 100644 index 00000000000..285493cd3fa --- /dev/null +++ b/packages/ui/src/views/Dashboard/Default/ModularDashboard/utils/getItemsFromPreferences.ts @@ -0,0 +1,27 @@ +import type { BasePayload, TypedUser } from 'payload' + +import { PREFERENCE_KEYS } from 'payload/shared' + +import type { WidgetItem } from '../index.client.js' + +import { getPreferences } from '../../../../../utilities/getPreferences.js' + +export async function getItemsFromPreferences( + payload: BasePayload, + user: TypedUser, +): Promise { + const savedPreferences = await getPreferences( + PREFERENCE_KEYS.DASHBOARD_LAYOUT, + payload, + user.id, + user.collection, + ) + if ( + !savedPreferences?.value || + typeof savedPreferences.value !== 'object' || + !('layouts' in savedPreferences.value) + ) { + return null + } + return savedPreferences.value.layouts as null | WidgetItem[] +} diff --git a/packages/ui/src/views/Dashboard/Default/ModularDashboard/utils/localeUtils.ts b/packages/ui/src/views/Dashboard/Default/ModularDashboard/utils/localeUtils.ts new file mode 100644 index 00000000000..a87390cea11 --- /dev/null +++ b/packages/ui/src/views/Dashboard/Default/ModularDashboard/utils/localeUtils.ts @@ -0,0 +1,146 @@ +import type { ClientField, Field } from 'payload' + +import { fieldAffectsData, fieldHasSubFields, tabHasName } from 'payload/shared' + +type AnyField = ClientField | Field + +function isLocalized(field: AnyField): boolean { + return 'localized' in field && Boolean(field.localized) +} + +function getObjectValue(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : {} +} + +/** + * Extracts locale-specific data from widget data stored in preferences. + * + * Localized fields are stored as `{ fieldName: { en: "Hello", de: "Hallo" } }` in preferences. + * This function flattens them to `{ fieldName: "Hello" }` for the given locale, + * which is the format the form state builder expects. + * + * Recursively handles nested field types (group, row, collapsible, tabs). + */ +export function extractLocaleData( + widgetData: Record, + locale: string, + fields: readonly AnyField[], +): Record { + const result: Record = {} + + for (const field of fields) { + if (field.type === 'tabs') { + for (const tab of field.tabs) { + const tabFields = tab.fields as AnyField[] + if (tabHasName(tab)) { + result[tab.name] = extractLocaleData( + getObjectValue(widgetData[tab.name]), + locale, + tabFields, + ) + } else { + Object.assign(result, extractLocaleData(widgetData, locale, tabFields)) + } + } + continue + } + + if (fieldHasSubFields(field) && !fieldAffectsData(field)) { + Object.assign(result, extractLocaleData(widgetData, locale, field.fields as AnyField[])) + continue + } + + if (!fieldAffectsData(field)) { + continue + } + + const { name } = field + const value = widgetData[name] + + if (fieldHasSubFields(field)) { + result[name] = extractLocaleData(getObjectValue(value), locale, field.fields as AnyField[]) + continue + } + + if ( + isLocalized(field) && + value !== undefined && + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ) { + result[name] = (value as Record)[locale] + } else { + result[name] = value + } + } + + return result +} + +/** + * Merges locale-specific form data back into the full widget data structure. + * + * Non-localized fields are stored directly. Localized fields are stored as + * `{ fieldName: { en: "Hello", de: "Hallo" } }` so each locale's value is preserved independently. + * + * Recursively handles nested field types (group, row, collapsible, tabs). + */ +export function mergeLocaleData( + existingData: Record, + formData: Record, + locale: string, + fields: readonly AnyField[], +): Record { + const result: Record = { ...existingData } + + for (const field of fields) { + if (field.type === 'tabs') { + for (const tab of field.tabs) { + const tabFields = tab.fields as AnyField[] + if (tabHasName(tab)) { + result[tab.name] = mergeLocaleData( + getObjectValue(result[tab.name]), + getObjectValue(formData[tab.name]), + locale, + tabFields, + ) + } else { + Object.assign(result, mergeLocaleData(result, formData, locale, tabFields)) + } + } + continue + } + + if (fieldHasSubFields(field) && !fieldAffectsData(field)) { + Object.assign(result, mergeLocaleData(result, formData, locale, field.fields as AnyField[])) + continue + } + + if (!fieldAffectsData(field)) { + continue + } + + const { name } = field + + if (fieldHasSubFields(field)) { + result[name] = mergeLocaleData( + getObjectValue(result[name]), + getObjectValue(formData[name]), + locale, + field.fields as AnyField[], + ) + continue + } + + if (isLocalized(field)) { + result[name] = { ...getObjectValue(result[name]), [locale]: formData[name] } + } else { + result[name] = formData[name] + } + } + + return result +} diff --git a/packages/ui/src/views/Dashboard/Default/ModularDashboard/utils/sensors.ts b/packages/ui/src/views/Dashboard/Default/ModularDashboard/utils/sensors.ts new file mode 100644 index 00000000000..cbba218ce50 --- /dev/null +++ b/packages/ui/src/views/Dashboard/Default/ModularDashboard/utils/sensors.ts @@ -0,0 +1,332 @@ +import type { KeyboardCoordinateGetter, KeyboardSensorOptions } from '@dnd-kit/core' + +import { + KeyboardCode, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from '@dnd-kit/core' + +type DroppablePosition = { + centerX: number + centerY: number + element: Element + isBeforeDroppable: boolean + rect: DOMRect + row: number +} + +/** + * Get all droppable widget positions, filtering out overlapping "before" droppables + * and assigning row numbers based on Y position. + */ +function getDroppablePositions(): DroppablePosition[] { + const positionTolerance = 5 + const rowTolerance = 10 + const result: DroppablePosition[] = [] + let currentRow = 0 + let currentY: null | number = null + + const allDroppables = Array.from(document.querySelectorAll('.droppable-widget')) + + for (let i = 0; i < allDroppables.length; i++) { + const element = allDroppables[i] + const rect = element.getBoundingClientRect() + + // Skip hidden elements + if (rect.width === 0 || rect.height === 0) { + continue + } + + const centerX = rect.left + rect.width / 2 + const centerY = rect.top + rect.height / 2 + const testId = element.getAttribute('data-testid') || '' + const isBeforeDroppable = testId.endsWith('-before') + + // Skip "before" droppables that overlap with another droppable + if (isBeforeDroppable) { + const hasOverlapping = allDroppables.some((other, otherIndex) => { + if (otherIndex === i) { + return false + } + const otherRect = other.getBoundingClientRect() + const otherCenterX = otherRect.left + otherRect.width / 2 + const otherCenterY = otherRect.top + otherRect.height / 2 + return ( + Math.abs(otherCenterX - centerX) < positionTolerance && + Math.abs(otherCenterY - centerY) < positionTolerance + ) + }) + if (hasOverlapping) { + continue + } + } + + // Assign row number based on Y position change + if (currentY === null) { + currentY = centerY + } else if (Math.abs(centerY - currentY) >= rowTolerance) { + currentRow++ + currentY = centerY + } + + result.push({ + centerX, + centerY, + element, + isBeforeDroppable, + rect, + row: currentRow, + }) + } + + return result +} + +/** + * Find the row with the closest Y position to the given posY. + * Returns the row index, or null if no droppables exist. + */ +function findClosestRow(droppables: DroppablePosition[], posY: number): null | number { + if (droppables.length === 0) { + return null + } + + let closestRow = droppables[0].row + let minYDistance = Infinity + + for (const droppable of droppables) { + const yDistance = Math.abs(droppable.centerY - posY) + if (yDistance < minYDistance) { + minYDistance = yDistance + closestRow = droppable.row + } + } + + return closestRow +} + +/** + * Find the closest droppable within a specific row by X position. + * Returns the droppable and its index, or null if no droppables in that row. + */ +function findClosestDroppableInRow( + droppables: DroppablePosition[], + rowIndex: number, + posX: number, +): { droppable: DroppablePosition; index: number } | null { + let closestIndex = -1 + let minXDistance = Infinity + + for (let i = 0; i < droppables.length; i++) { + const droppable = droppables[i] + if (droppable.row === rowIndex) { + const xDistance = Math.abs(droppable.centerX - posX) + if (xDistance < minXDistance) { + minXDistance = xDistance + closestIndex = i + } + } + } + + if (closestIndex === -1) { + return null + } + + return { droppable: droppables[closestIndex], index: closestIndex } +} + +/** + * Find the target droppable based on direction + * - ArrowRight/Left: Next/previous in DOM order (now that overlapping droppables are filtered) + * - ArrowUp/Down: Closest in adjacent row (row +1 or -1) by X position + */ +function findTargetDroppable( + droppables: DroppablePosition[], + currentCenterX: number, + currentCenterY: number, + direction: string, +): DroppablePosition | null { + // Find the closest row, then the closest droppable in that row + const currentRow = findClosestRow(droppables, currentCenterY) + + if (currentRow === null) { + return null + } + + const currentDroppable = findClosestDroppableInRow(droppables, currentRow, currentCenterX) + + if (!currentDroppable) { + return null + } + + const { index: currentIndex } = currentDroppable + + switch (direction) { + case 'ArrowDown': { + const targetRow = currentRow + 1 + return findClosestDroppableInRow(droppables, targetRow, currentCenterX)?.droppable || null + } + + case 'ArrowLeft': + // Previous in DOM order + return droppables[currentIndex - 1] || null + + case 'ArrowRight': + // Next in DOM order + return droppables[currentIndex + 1] || null + + case 'ArrowUp': { + const targetRow = currentRow - 1 + return findClosestDroppableInRow(droppables, targetRow, currentCenterX)?.droppable || null + } + + default: + return null + } +} + +/** + * Custom coordinate getter that jumps directly to droppable positions + * instead of moving in pixel increments. This works better with scrolling + * and provides more predictable navigation. + */ +const droppableJumpKeyboardCoordinateGetter: KeyboardCoordinateGetter = ( + event, + { context, currentCoordinates }, +) => { + const { collisionRect } = context + const { code } = event + + if (!collisionRect) { + return currentCoordinates + } + + // Only handle arrow keys + if (!['ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp'].includes(code)) { + return currentCoordinates + } + + // Prevent default browser scroll behavior for arrow keys + event.preventDefault() + + // Clear scrollableAncestors to prevent dnd-kit from scrolling instead of moving + // This must be done on every keydown because context is updated by dnd-kit + if (context.scrollableAncestors) { + context.scrollableAncestors.length = 0 + } + + // Get all droppable widgets and their positions + const droppables = getDroppablePositions() + + if (droppables.length === 0) { + return currentCoordinates + } + + // Current position center (viewport coordinates from collisionRect) + const currentCenterX = collisionRect.left + collisionRect.width / 2 + const currentCenterY = collisionRect.top + collisionRect.height / 2 + + // Find the target droppable based on direction + const targetDroppable = findTargetDroppable(droppables, currentCenterX, currentCenterY, code) + + // If we found a target, scroll if needed and calculate the delta + if (targetDroppable) { + const viewportHeight = window.innerHeight + const targetRect = targetDroppable.rect + const scrollPadding = 20 // Extra padding to ensure element is fully visible + + // Check if target droppable is fully visible in viewport + const isAboveViewport = targetRect.top < scrollPadding + const isBelowViewport = targetRect.bottom > viewportHeight - scrollPadding + + // Scroll to make target visible (using instant scroll for synchronous behavior) + if (isAboveViewport) { + const scrollAmount = targetRect.top - scrollPadding + // don't use smooth scroll here, because it will mess up the delta calculation + window.scrollBy({ behavior: 'instant', top: scrollAmount }) + } else if (isBelowViewport) { + const scrollAmount = targetRect.bottom - viewportHeight + scrollPadding + window.scrollBy({ behavior: 'instant', top: scrollAmount }) + } + + // After scroll, recalculate target position (it may have changed due to scroll) + const newTargetRect = targetDroppable.element.getBoundingClientRect() + const newTargetCenterX = newTargetRect.left + newTargetRect.width / 2 + const newTargetCenterY = newTargetRect.top + newTargetRect.height / 2 + + // Calculate delta using current overlay position (which didn't change) and new target position + const deltaX = newTargetCenterX - currentCenterX + const deltaY = newTargetCenterY - currentCenterY + + // Add delta to currentCoordinates to position overlay's center at target's center + return { + x: currentCoordinates.x + deltaX, + y: currentCoordinates.y + deltaY, + } + } + + // No valid target found, stay in place + return currentCoordinates +} + +/** + * Custom KeyboardSensor that only activates when focus is directly on the + * draggable element, not on any of its descendants. This allows interactive + * elements inside draggables (like buttons) to work normally with the keyboard. + */ +class DirectFocusKeyboardSensor extends KeyboardSensor { + static override activators = [ + { + eventName: 'onKeyDown' as const, + handler: ( + event: React.KeyboardEvent, + { + keyboardCodes = { + cancel: [KeyboardCode.Esc], + end: [KeyboardCode.Space, KeyboardCode.Enter], + start: [KeyboardCode.Space, KeyboardCode.Enter], + }, + onActivation, + }: KeyboardSensorOptions, + { active }: { active: { node: React.MutableRefObject } }, + ) => { + const { code } = event.nativeEvent + + // Only activate if focus is directly on the draggable node, not descendants + if (event.target !== active.node.current) { + return false + } + + if (keyboardCodes.start.includes(code)) { + event.preventDefault() + onActivation?.({ event: event.nativeEvent }) + return true + } + + return false + }, + }, + ] +} + +export function useDashboardSensors() { + return useSensors( + useSensor(MouseSensor, { + activationConstraint: { + distance: 5, + }, + }), + useSensor(TouchSensor, { + activationConstraint: { + delay: 200, + tolerance: 5, + }, + }), + useSensor(DirectFocusKeyboardSensor, { + coordinateGetter: droppableJumpKeyboardCoordinateGetter, + }), + ) +} diff --git a/packages/ui/src/views/Dashboard/Default/index.tsx b/packages/ui/src/views/Dashboard/Default/index.tsx new file mode 100644 index 00000000000..5b12a408c0c --- /dev/null +++ b/packages/ui/src/views/Dashboard/Default/index.tsx @@ -0,0 +1,80 @@ +import type { AdminViewServerPropsOnly, ClientUser, Locale, ServerProps } from 'payload' + +import React from 'react' + +import type { groupNavItems } from '../../../utilities/groupNavItems.js' + +import { Gutter } from '../../../elements/Gutter/index.js' +import { RenderServerComponent } from '../../../elements/RenderServerComponent/index.js' +import { ModularDashboard } from './ModularDashboard/index.js' + +const baseClass = 'dashboard' + +export type DashboardViewClientProps = { + locale: Locale +} + +// Neither DashboardViewClientProps, DashboardViewServerPropsOnly, nor +// DashboardViewServerProps make much sense. They were created +// before the modular dashboard existed, and they are tightly coupled to +// the default layout of collection and global cards. All of their values +// could have been derived from the req object, and the same likely applies +// to other views. These types remain only for backward compatibility. +// It is recommended to use the modular dashboard widgets, which have props +// that are more agnostic to their content. + +export type DashboardViewServerPropsOnly = { + globalData: Array<{ + data: { _isLocked: boolean; _lastEditedAt: string; _userEditing: ClientUser | number | string } + lockDuration?: number + slug: string + }> + /** + * @deprecated + * This prop is deprecated and will be removed in the next major version. + * Components now import their own `Link` directly from `next/link`. + */ + Link?: React.ComponentType + navGroups?: ReturnType +} & AdminViewServerPropsOnly + +export type DashboardViewServerProps = DashboardViewClientProps & DashboardViewServerPropsOnly + +export function DefaultDashboard(props: DashboardViewServerProps) { + const { i18n, locale, params, payload, permissions, searchParams, user } = props + const { afterDashboard, beforeDashboard } = payload.config.admin.components + + return ( + + {beforeDashboard && + RenderServerComponent({ + Component: beforeDashboard, + importMap: payload.importMap, + serverProps: { + i18n, + locale, + params, + payload, + permissions, + searchParams, + user, + } satisfies ServerProps, + })} + + {afterDashboard && + RenderServerComponent({ + Component: afterDashboard, + importMap: payload.importMap, + serverProps: { + i18n, + locale, + params, + payload, + permissions, + searchParams, + user, + } satisfies ServerProps, + })} + + ) +} diff --git a/packages/ui/src/views/Dashboard/index.tsx b/packages/ui/src/views/Dashboard/index.tsx new file mode 100644 index 00000000000..a54946479ba --- /dev/null +++ b/packages/ui/src/views/Dashboard/index.tsx @@ -0,0 +1,56 @@ +import type { AdminViewServerProps } from 'payload' + +import React, { Fragment } from 'react' + +import type { DashboardViewClientProps, DashboardViewServerPropsOnly } from './Default/index.js' + +import { HydrateAuthProvider } from '../../elements/HydrateAuthProvider/index.js' +import { RenderServerComponent } from '../../elements/RenderServerComponent/index.js' +import { SetStepNav } from '../../elements/StepNav/SetStepNav.js' +import { getGlobalData } from '../../utilities/getGlobalData.js' +import { getNavGroups } from '../../utilities/getNavGroups.js' +import { DefaultDashboard } from './Default/index.js' + +export async function DashboardView(props: AdminViewServerProps) { + const { + locale, + permissions, + req: { + i18n, + payload: { config }, + payload, + user, + }, + req, + visibleEntities, + } = props.initPageResult + + const globalData = await getGlobalData(req) + const navGroups = getNavGroups(permissions, visibleEntities, config, i18n) + + return ( + + + + {RenderServerComponent({ + clientProps: { + locale, + } satisfies DashboardViewClientProps, + Component: config.admin?.components?.views?.dashboard?.Component, + Fallback: DefaultDashboard, + importMap: payload.importMap, + serverProps: { + ...props, + globalData, + i18n, + locale, + navGroups, + payload, + permissions, + user, + visibleEntities, + } satisfies DashboardViewServerPropsOnly, + })} + + ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6bc1dda1a26..88cb8860ecc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1738,6 +1738,9 @@ importers: '@dnd-kit/core': specifier: 6.3.1 version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@dnd-kit/modifiers': + specifier: 9.0.0 + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) '@dnd-kit/sortable': specifier: 10.0.0 version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) From 61b622fc2eb8b65862960faed8befc6341ea2565 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 16:04:56 +0100 Subject: [PATCH 09/60] feat(create-payload-app): add --framework flag and framework selection prompt Add --framework / -f flag with 'next' (default) and 'tanstack-start' options. Add interactive selectFramework() prompt following the same pattern as selectDb(). Add FrameworkType to types and frameworkType to templates and createProject args. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/create-project.ts | 2 + .../src/lib/select-framework.ts | 57 +++++++++++++++++++ packages/create-payload-app/src/main.ts | 5 ++ packages/create-payload-app/src/types.ts | 5 ++ 4 files changed, 69 insertions(+) create mode 100644 packages/create-payload-app/src/lib/select-framework.ts diff --git a/packages/create-payload-app/src/lib/create-project.ts b/packages/create-payload-app/src/lib/create-project.ts index 8eba16ab8e5..15bc861dc6c 100644 --- a/packages/create-payload-app/src/lib/create-project.ts +++ b/packages/create-payload-app/src/lib/create-project.ts @@ -8,6 +8,7 @@ import path from 'path' import type { CliArgs, DbDetails, + FrameworkType, PackageManager, ProjectExample, ProjectTemplate, @@ -74,6 +75,7 @@ export async function createProject( args: { cliArgs: CliArgs dbDetails?: DbDetails + frameworkType?: FrameworkType packageManager: PackageManager projectDir: string projectName: string diff --git a/packages/create-payload-app/src/lib/select-framework.ts b/packages/create-payload-app/src/lib/select-framework.ts new file mode 100644 index 00000000000..12b39c44d7c --- /dev/null +++ b/packages/create-payload-app/src/lib/select-framework.ts @@ -0,0 +1,57 @@ +import * as p from '@clack/prompts' + +import type { CliArgs, FrameworkType, ProjectTemplate } from '../types.js' + +type FrameworkChoice = { + description: string + title: string + value: FrameworkType +} + +export const frameworkChoiceRecord: Record = { + next: { + description: 'Next.js App Router (recommended)', + title: 'Next.js', + value: 'next', + }, + 'tanstack-start': { + description: 'TanStack Start (experimental)', + title: 'TanStack Start', + value: 'tanstack-start', + }, +} + +const validFrameworks = Object.keys(frameworkChoiceRecord) as FrameworkType[] + +export async function selectFramework( + args: CliArgs, + template?: ProjectTemplate, +): Promise { + if (args['--framework']) { + const value = args['--framework'] as FrameworkType + if (!validFrameworks.includes(value)) { + throw new Error(`Invalid framework given. Valid values are: ${validFrameworks.join(', ')}`) + } + return value + } + + if (template?.frameworkType) { + return template.frameworkType + } + + const framework = await p.select<{ label: string; value: FrameworkType }[], FrameworkType>({ + initialValue: 'next', + message: `Select a framework`, + options: Object.values(frameworkChoiceRecord).map((choice) => ({ + hint: choice.description, + label: choice.title, + value: choice.value, + })), + }) + + if (p.isCancel(framework)) { + process.exit(0) + } + + return framework +} diff --git a/packages/create-payload-app/src/main.ts b/packages/create-payload-app/src/main.ts index 947abee37f0..2639b2cc211 100644 --- a/packages/create-payload-app/src/main.ts +++ b/packages/create-payload-app/src/main.ts @@ -17,6 +17,7 @@ import { manageEnvFiles } from './lib/manage-env-files.js' import { parseProjectName } from './lib/parse-project-name.js' import { parseTemplate } from './lib/parse-template.js' import { selectDb } from './lib/select-db.js' +import { selectFramework } from './lib/select-framework.js' import { getValidTemplates, validateTemplate } from './lib/templates.js' import { updatePayloadInProject } from './lib/update-payload-in-project.js' import { getLatestPackageVersion } from './utils/getLatestPackageVersion.js' @@ -41,6 +42,7 @@ export class Main { '--db-accept-recommended': Boolean, '--db-connection-string': String, '--example': String, + '--framework': String, '--help': Boolean, '--local-template': String, '--name': String, @@ -69,6 +71,7 @@ export class Main { // Aliases '-d': '--db', '-e': '--example', + '-f': '--framework', '-h': '--help', '-n': '--name', '-t': '--template', @@ -266,10 +269,12 @@ export class Main { } case 'starter': { const dbDetails = await selectDb(this.args, projectName, template) + const frameworkType = await selectFramework(this.args, template) await createProject({ cliArgs: this.args, dbDetails, + frameworkType, packageManager, projectDir, projectName, diff --git a/packages/create-payload-app/src/types.ts b/packages/create-payload-app/src/types.ts index 6e6f9cdd975..c2bb2afe0d9 100644 --- a/packages/create-payload-app/src/types.ts +++ b/packages/create-payload-app/src/types.ts @@ -12,6 +12,7 @@ export interface Args extends arg.Spec { '--dry-run': BooleanConstructor '--example': StringConstructor + '--framework': StringConstructor '--help': BooleanConstructor '--init-next': BooleanConstructor '--local-example': StringConstructor @@ -29,6 +30,7 @@ export interface Args extends arg.Spec { // Aliases '-e': string + '-f': string '-h': string '-n': string '-t': string @@ -64,10 +66,13 @@ export interface PluginTemplate extends Template { interface Template { dbType?: DbType description?: string + frameworkType?: FrameworkType name: string type: ProjectTemplate['type'] } +export type FrameworkType = 'next' | 'tanstack-start' + export type PackageManager = 'bun' | 'npm' | 'pnpm' | 'yarn' export type DbType = (typeof ALL_DATABASE_ADAPTERS)[number] From 4dceb9e03b2a419f52ae5b3bd6c8afe131ce49ae Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 16:09:02 +0100 Subject: [PATCH 10/60] feat: scaffold @payloadcms/tanstack-start adapter package Proof-of-concept AdminAdapter implementation for TanStack Start. Implements the full BaseAdminAdapter interface with stub methods that show the intended @tanstack/react-router and vinxi/http integration. Replace stub hooks in RouterProvider.tsx with real @tanstack/react-router imports to activate. Replace stub methods in adapter/index.ts with vinxi/http cookie calls and TanStack navigation throws. Co-Authored-By: Claude Sonnet 4.6 --- packages/tanstack-start/package.json | 39 + .../src/adapter/RouterProvider.tsx | 69 + packages/tanstack-start/src/adapter/index.ts | 65 + packages/tanstack-start/src/index.ts | 2 + packages/tanstack-start/tsconfig.json | 11 + pnpm-lock.yaml | 3482 ++++++++++++++++- 6 files changed, 3570 insertions(+), 98 deletions(-) create mode 100644 packages/tanstack-start/package.json create mode 100644 packages/tanstack-start/src/adapter/RouterProvider.tsx create mode 100644 packages/tanstack-start/src/adapter/index.ts create mode 100644 packages/tanstack-start/src/index.ts create mode 100644 packages/tanstack-start/tsconfig.json diff --git a/packages/tanstack-start/package.json b/packages/tanstack-start/package.json new file mode 100644 index 00000000000..0ddd9058b13 --- /dev/null +++ b/packages/tanstack-start/package.json @@ -0,0 +1,39 @@ +{ + "name": "@payloadcms/tanstack-start", + "version": "0.1.0", + "description": "TanStack Start admin adapter for Payload CMS", + "homepage": "https://payloadcms.com", + "repository": { + "type": "git", + "url": "https://github.com/payloadcms/payload.git", + "directory": "packages/tanstack-start" + }, + "license": "MIT", + "author": "Payload (https://payloadcms.com)", + "type": "module", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "main": "./src/index.ts", + "types": "./src/index.ts", + "dependencies": { + "@payloadcms/ui": "workspace:*", + "payload": "workspace:*", + "react": "19.1.0" + }, + "peerDependencies": { + "@tanstack/react-router": ">=1.0.0", + "@tanstack/start": ">=1.0.0", + "react": "^19.0.0", + "vinxi": ">=0.4.0" + }, + "peerDependenciesOptional": { + "@tanstack/react-router": true, + "@tanstack/start": true, + "vinxi": true + } +} diff --git a/packages/tanstack-start/src/adapter/RouterProvider.tsx b/packages/tanstack-start/src/adapter/RouterProvider.tsx new file mode 100644 index 00000000000..35545bff871 --- /dev/null +++ b/packages/tanstack-start/src/adapter/RouterProvider.tsx @@ -0,0 +1,69 @@ +'use client' +/** + * TanStack Start RouterProvider — scaffold. + * + * Replace the stub hooks below with real @tanstack/react-router imports: + * import { Link, useLocation, useParams, useRouter } from '@tanstack/react-router' + */ + +import type { + RouterProvider as BaseRouterProvider, + type LinkProps, + RouterContextType, +} from '@payloadcms/ui' + +import React from 'react' + +// ─── Replace with real @tanstack/react-router imports ───────────────────── +type RouterStub = { + history: { back(): void; forward(): void } + invalidate(): void + navigate(o: { replace?: boolean; to: string }): void + preloadRoute(o: { to: string }): void +} +type LocationStub = { pathname: string; search: string } +function useTanStackRouter(): RouterStub { + throw new Error('Not implemented — swap in @tanstack/react-router') +} +function useTanStackLocation(): LocationStub { + throw new Error('Not implemented — swap in @tanstack/react-router') +} +function useTanStackParams(): Record { + throw new Error('Not implemented — swap in @tanstack/react-router') +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const TanStackLink: React.FC<{ [key: string]: any; to: string }> = () => null +// ────────────────────────────────────────────────────────────────────────── + +const AdapterLink: React.FC = ({ children, href, ...rest }) => ( + + {children} + +) + +export function TanStackRouterProvider({ children }: { children: React.ReactNode }) { + const router = useTanStackRouter() + const location = useTanStackLocation() + const params = useTanStackParams() + const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location]) + + const routerCtx: RouterContextType = React.useMemo( + () => ({ + Link: AdapterLink, + params, + pathname: location.pathname, + router: { + back: () => router.history.back(), + forward: () => router.history.forward(), + prefetch: (url: string) => router.preloadRoute({ to: url }), + push: (url: string) => router.navigate({ to: url }), + refresh: () => router.invalidate(), + replace: (url: string) => router.navigate({ replace: true, to: url }), + }, + searchParams, + }), + [router, location, params, searchParams], + ) + + return {children} +} diff --git a/packages/tanstack-start/src/adapter/index.ts b/packages/tanstack-start/src/adapter/index.ts new file mode 100644 index 00000000000..0a5e41cfc38 --- /dev/null +++ b/packages/tanstack-start/src/adapter/index.ts @@ -0,0 +1,65 @@ +import type { AdminAdapterResult, BaseAdminAdapter, CookieOptions, InitReqResult } from 'payload' + +import { createAdminAdapter } from 'payload' + +import { TanStackRouterProvider } from './RouterProvider.js' + +/** + * TanStack Start admin adapter for Payload CMS. + * + * Proof-of-concept scaffold. Full implementation requires + * @tanstack/start, @tanstack/react-router, and vinxi. + * + * Usage in payload.config.ts: + * ```ts + * import { tanstackStartAdapter } from '@payloadcms/tanstack-start' + * + * export default buildConfig({ + * admin: { adapter: tanstackStartAdapter() }, + * }) + * ``` + */ +export function tanstackStartAdapter(): AdminAdapterResult { + return { + name: 'tanstack-start', + init: ({ payload }) => { + return createAdminAdapter({ + name: 'tanstack-start', + createRouteHandlers: () => { + // In TanStack Start, API routes use Vinxi file-system routing. + return {} + }, + deleteCookie: (_name: string): void => { + // Implement: import { deleteCookie } from 'vinxi/http'; deleteCookie(name) + throw new Error('tanstackStartAdapter: deleteCookie not yet implemented.') + }, + getCookie: (_name: string): string | undefined => { + // Implement: import { getCookie } from 'vinxi/http'; return getCookie(name) + throw new Error('tanstackStartAdapter: getCookie not yet implemented.') + }, + handleServerFunctions: (_args): Promise => { + // Implement using @tanstack/start createServerFn() + throw new Error('tanstackStartAdapter: handleServerFunctions not yet implemented.') + }, + initReq: (_args): Promise => { + // Implement: import { getWebRequest } from 'vinxi/http'; use getWebRequest() + throw new Error('tanstackStartAdapter: initReq not yet implemented.') + }, + notFound: (): never => { + // Implement: import { notFound } from '@tanstack/react-router'; throw notFound() + throw new Error('Not found') + }, + payload, + redirect: (url: string): never => { + // Implement: import { redirect } from '@tanstack/react-router'; throw redirect({ to: url }) + throw new Error(`Redirect to ${url}`) + }, + RouterProvider: TanStackRouterProvider, + setCookie: (_name: string, _value: string, _options?: CookieOptions): void => { + // Implement: import { setCookie } from 'vinxi/http'; setCookie(name, value, options) + throw new Error('tanstackStartAdapter: setCookie not yet implemented.') + }, + } satisfies BaseAdminAdapter) + }, + } +} diff --git a/packages/tanstack-start/src/index.ts b/packages/tanstack-start/src/index.ts new file mode 100644 index 00000000000..9508f1d7449 --- /dev/null +++ b/packages/tanstack-start/src/index.ts @@ -0,0 +1,2 @@ +export { tanstackStartAdapter } from './adapter/index.js' +export { TanStackRouterProvider } from './adapter/RouterProvider.js' diff --git a/packages/tanstack-start/tsconfig.json b/packages/tanstack-start/tsconfig.json new file mode 100644 index 00000000000..9a64ab3e9ad --- /dev/null +++ b/packages/tanstack-start/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "strict": false, + "noUncheckedIndexedAccess": false + }, + "references": [ + { "path": "../payload" }, + { "path": "../ui" } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88cb8860ecc..beb71ac4911 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,7 +147,7 @@ importers: version: 8.15.1(@aws-sdk/credential-providers@3.1014.0)(socks@2.8.7) next: specifier: 16.2.1 - version: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) node-gyp: specifier: 12.2.0 version: 12.2.0 @@ -1680,7 +1680,7 @@ importers: version: link:../plugin-cloud-storage uploadthing: specifier: 7.3.0 - version: 7.3.0(express@5.2.1)(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(tailwindcss@4.2.2) + version: 7.3.0(express@5.2.1)(h3@1.15.10)(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(tailwindcss@4.2.2) devDependencies: payload: specifier: workspace:* @@ -1699,6 +1699,27 @@ importers: specifier: workspace:* version: link:../payload + packages/tanstack-start: + dependencies: + '@payloadcms/ui': + specifier: workspace:* + version: link:../ui + '@tanstack/react-router': + specifier: '>=1.0.0' + version: 1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/start': + specifier: '>=1.0.0' + version: 1.120.20(e1410331fba1110c79d7bc05e246b742) + payload: + specifier: workspace:* + version: link:../payload + react: + specifier: 19.2.4 + version: 19.2.4 + vinxi: + specifier: '>=0.4.0' + version: 0.5.11(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + packages/translations: dependencies: date-fns: @@ -1882,7 +1903,7 @@ importers: version: 16.8.1 next: specifier: 16.2.1 - version: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) payload: specifier: workspace:* version: link:../../packages/payload @@ -1919,7 +1940,7 @@ importers: version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: 16.2.1 - version: 16.2.1(eslint-plugin-import-x@4.6.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3) + version: 16.2.1(@typescript-eslint/parser@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3))(eslint-plugin-import-x@4.6.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3) jsdom: specifier: 28.0.0 version: 28.0.0(@noble/hashes@1.8.0) @@ -2033,7 +2054,7 @@ importers: version: 0.563.0(react@19.2.4) next: specifier: 16.2.1 - version: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) next-themes: specifier: 0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -2214,7 +2235,7 @@ importers: version: 16.4.7 geist: specifier: ^1.3.0 - version: 1.7.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0)) + version: 1.7.0(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0)) graphql: specifier: 16.8.1 version: 16.8.1 @@ -2226,7 +2247,7 @@ importers: version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) next-sitemap: specifier: ^4.2.3 - version: 4.2.3(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0)) + version: 4.2.3(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0)) payload: specifier: workspace:* version: link:../../packages/payload @@ -2287,7 +2308,7 @@ importers: version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: 16.2.1 - version: 16.2.1(@typescript-eslint/parser@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3))(eslint-plugin-import-x@4.6.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3) + version: 16.2.1(eslint-plugin-import-x@4.6.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3) jsdom: specifier: 28.0.0 version: 28.0.0(@noble/hashes@1.8.0) @@ -2614,7 +2635,7 @@ importers: version: 4.1.2 changelogen: specifier: ^0.6.2 - version: 0.6.2 + version: 0.6.2(magicast@0.5.2) execa: specifier: 5.1.1 version: 5.1.1 @@ -2663,7 +2684,7 @@ importers: version: 4.1.2 changelogen: specifier: ^0.6.2 - version: 0.6.2 + version: 0.6.2(magicast@0.5.2) create-payload-app: specifier: workspace:* version: link:../../packages/create-payload-app @@ -3202,6 +3223,14 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/code-frame@7.26.2': + resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} + engines: {node: '>=6.9.0'} + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -3909,6 +3938,12 @@ packages: '@date-fns/tz@1.2.0': resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==} + '@deno/shim-deno-test@0.5.0': + resolution: {integrity: sha512-4nMhecpGlPi0cSzT67L+Tm+GOJqvuk8gqHBziqcUQOarnuIax1z96/gJHCSIz2Z0zhxE6Rzwb3IZXPtFh51j+w==} + + '@deno/shim-deno@0.19.2': + resolution: {integrity: sha512-q3VTHl44ad8T2Tw2SpeAvghdGOjlnLPDNO2cpOxwMrBE/PVas6geWpbpIgrM+czOCH0yejp0yi8OaTuB+NU40Q==} + '@discoveryjs/json-ext@0.5.7': resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} @@ -4020,6 +4055,12 @@ packages: resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} deprecated: 'Merged into tsx: https://tsx.is' + '@esbuild/aix-ppc64@0.20.2': + resolution: {integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -4044,12 +4085,24 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.18.20': resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} engines: {node: '>=12'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.20.2': + resolution: {integrity: sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -4074,12 +4127,24 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.18.20': resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} engines: {node: '>=12'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.20.2': + resolution: {integrity: sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -4104,12 +4169,24 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.18.20': resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} engines: {node: '>=12'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.20.2': + resolution: {integrity: sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -4134,12 +4211,24 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.18.20': resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} engines: {node: '>=12'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.20.2': + resolution: {integrity: sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -4164,12 +4253,24 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.18.20': resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} engines: {node: '>=12'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.20.2': + resolution: {integrity: sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -4194,12 +4295,24 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.18.20': resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} engines: {node: '>=12'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.20.2': + resolution: {integrity: sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -4224,12 +4337,24 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.18.20': resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} engines: {node: '>=12'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.20.2': + resolution: {integrity: sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -4254,12 +4379,24 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.18.20': resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} engines: {node: '>=12'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.20.2': + resolution: {integrity: sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -4284,12 +4421,24 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.18.20': resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} engines: {node: '>=12'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.20.2': + resolution: {integrity: sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -4314,12 +4463,24 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.18.20': resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} engines: {node: '>=12'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.20.2': + resolution: {integrity: sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -4344,12 +4505,24 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.18.20': resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} engines: {node: '>=12'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.20.2': + resolution: {integrity: sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -4374,12 +4547,24 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.18.20': resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} engines: {node: '>=12'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.20.2': + resolution: {integrity: sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -4404,12 +4589,24 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.18.20': resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} engines: {node: '>=12'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.20.2': + resolution: {integrity: sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -4434,12 +4631,24 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.18.20': resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} engines: {node: '>=12'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.20.2': + resolution: {integrity: sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -4464,12 +4673,24 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.18.20': resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} engines: {node: '>=12'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.20.2': + resolution: {integrity: sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -4494,12 +4715,24 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.18.20': resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} engines: {node: '>=12'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.20.2': + resolution: {integrity: sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -4524,6 +4757,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -4548,12 +4787,24 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.18.20': resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} engines: {node: '>=12'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.20.2': + resolution: {integrity: sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -4578,6 +4829,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -4602,12 +4859,24 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.18.20': resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} engines: {node: '>=12'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.20.2': + resolution: {integrity: sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -4632,6 +4901,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -4650,12 +4925,24 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.18.20': resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} engines: {node: '>=12'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.20.2': + resolution: {integrity: sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -4680,12 +4967,24 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.18.20': resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} engines: {node: '>=12'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.20.2': + resolution: {integrity: sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -4710,12 +5009,24 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.18.20': resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} engines: {node: '>=12'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.20.2': + resolution: {integrity: sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -4740,12 +5051,24 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.18.20': resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} engines: {node: '>=12'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.20.2': + resolution: {integrity: sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -4770,6 +5093,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -5424,6 +5753,11 @@ packages: cpu: [x64] os: [win32] + '@mapbox/node-pre-gyp@2.0.3': + resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==} + engines: {node: '>=18'} + hasBin: true + '@miniflare/core@2.14.4': resolution: {integrity: sha512-FMmZcC1f54YpF4pDWPtdQPIO8NXfgUxCoR9uyrhxKJdZu7M6n8QKopPVNuaxR40jcsdxb7yKoQoFWnHfzJD9GQ==} engines: {node: '>=16.13'} @@ -5708,6 +6042,22 @@ packages: resolution: {integrity: sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==} engines: {node: ^20.17.0 || >=22.9.0} + '@oozcitak/dom@1.15.10': + resolution: {integrity: sha512-0JT29/LaxVgRcGKvHmSrUTEvZ8BXvZhGl2LASRUgHqDTC1M5g1pLmVv56IYNyt3bG2CUjDkc67wnyZC14pbQrQ==} + engines: {node: '>=8.0'} + + '@oozcitak/infra@1.0.8': + resolution: {integrity: sha512-JRAUc9VR6IGHOL7OGF+yrvs0LO8SlqGnPAMqyzOuFZPSZSXI7Xf2O9+awQPSMXgIWGtgUf/dA6Hs6X6ySEaWTg==} + engines: {node: '>=6.0'} + + '@oozcitak/url@1.0.4': + resolution: {integrity: sha512-kDcD8y+y3FCSOvnBI6HJgl00viO/nGbQoCINmQ0h98OhnGITrWR3bOGfwYCthgcrV8AnTJz8MzslTQbC3SOAmw==} + engines: {node: '>=8.0'} + + '@oozcitak/util@8.3.8': + resolution: {integrity: sha512-T8TbSnGsxo6TDBJx/Sgv/BlVJL3tshxZP7Aq5R1mSnM5OcHY2dQaxLMu2+E8u3gN0MLOzdjurqN4ZRVuzQycOQ==} + engines: {node: '>=8.0'} + '@opennextjs/aws@3.9.14': resolution: {integrity: sha512-ZNUY+r3FXr393Jli+wYqNjMllfQ0k7ZBIm8nDz6wIrrCuxX8FsNC4pioLY4ZySQfPGmiKWE6M0IyB7sOBnm58g==} hasBin: true @@ -6231,6 +6581,18 @@ packages: cpu: [x64] os: [linux] + '@parcel/watcher-wasm@2.3.0': + resolution: {integrity: sha512-ejBAX8H0ZGsD8lSICDNyMbSEtPMWgDL0WFCt/0z7hyf5v8Imz4rAM8xY379mBsECkq/Wdqa5WEDLqtjZ+6NxfA==} + engines: {node: '>= 10.0.0'} + bundledDependencies: + - napi-wasm + + '@parcel/watcher-wasm@2.5.6': + resolution: {integrity: sha512-byAiBZ1t3tXQvc8dMD/eoyE7lTXYorhn+6uVW5AC+JGI1KtJC/LvDche5cfUE+qiefH+Ybq0bUCJU0aB1cSHUA==} + engines: {node: '>= 10.0.0'} + bundledDependencies: + - napi-wasm + '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} engines: {node: '>= 10.0.0'} @@ -6287,6 +6649,9 @@ packages: '@poppinss/dumper@0.6.5': resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + '@poppinss/dumper@0.7.0': + resolution: {integrity: sha512-0UTYalzk2t6S4rA2uHOz5bSSW2CHdv4vggJI6Alg90yvl0UgXs6XSXpH96OH+bRkX4J/06djv29pqXJ0lq5Kag==} + '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} @@ -6712,6 +7077,15 @@ packages: '@rolldown/pluginutils@1.0.0-beta.11': resolution: {integrity: sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag==} + '@rollup/plugin-alias@6.0.0': + resolution: {integrity: sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g==} + engines: {node: '>=20.19.0'} + peerDependencies: + rollup: '>=4.0.0' + peerDependenciesMeta: + rollup: + optional: true + '@rollup/plugin-commonjs@28.0.1': resolution: {integrity: sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==} engines: {node: '>=16.0.0 || 14 >= 14.17'} @@ -6721,8 +7095,17 @@ packages: rollup: optional: true - '@rollup/pluginutils@5.3.0': - resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + '@rollup/plugin-commonjs@29.0.2': + resolution: {integrity: sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==} + engines: {node: '>=16.0.0 || 14 >= 14.17'} + peerDependencies: + rollup: ^2.68.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-inject@5.0.5': + resolution: {integrity: sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==} engines: {node: '>=14.0.0'} peerDependencies: rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 @@ -6730,8 +7113,53 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.59.0': - resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + '@rollup/plugin-json@6.1.0': + resolution: {integrity: sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-node-resolve@16.0.3': + resolution: {integrity: sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-replace@6.0.3': + resolution: {integrity: sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-terser@1.0.0': + resolution: {integrity: sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + rollup: ^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] os: [android] @@ -7159,6 +7587,10 @@ packages: resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@sindresorhus/slugify@1.1.2': resolution: {integrity: sha512-V9nR/W0Xd9TSGXpZ4iFUcFGhuOJtZX82Fzxj1YISlbSgKvIiNa7eLEZrT0vAraPOt++KHauIVNYgGRgjc13dXA==} engines: {node: '>=10'} @@ -7761,6 +8193,231 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + '@tanstack/directive-functions-plugin@1.131.2': + resolution: {integrity: sha512-5Pz6aVPS0BW+0bLvMzWsoajfjI6ZeWqkbVBaQfIbSTm4DOBO05JuQ/pb7W7m3GbCb5TK1a/SKDhuTX6Ag5I7UQ==} + engines: {node: '>=12'} + peerDependencies: + vite: '>=6.0.0' + + '@tanstack/directive-functions-plugin@1.142.1': + resolution: {integrity: sha512-k4HhAaitobp+z2pXBkmoWgE8Ollhx7fQXpVL+PQ7HeHZc2PilrQtC3ysxvoPunufrztIxweSE9HAWkZ2AFNaLw==} + engines: {node: '>=12'} + peerDependencies: + vite: '>=6.0.0 || >=7.0.0' + + '@tanstack/history@1.131.2': + resolution: {integrity: sha512-cs1WKawpXIe+vSTeiZUuSBy8JFjEuDgdMKZFRLKwQysKo8y2q6Q1HvS74Yw+m5IhOW1nTZooa6rlgdfXcgFAaw==} + engines: {node: '>=12'} + + '@tanstack/history@1.161.6': + resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==} + engines: {node: '>=20.19'} + + '@tanstack/react-router@1.168.10': + resolution: {integrity: sha512-/RmDlOwDkCug609KdPB3U+U1zmrtadJpvsmRg2zEn8TRCKRNri7dYZIjQZbNg8PgUiRL4T6njrZBV1ChzblNaA==} + engines: {node: '>=20.19'} + peerDependencies: + react: 19.2.4 + react-dom: 19.2.4 + + '@tanstack/react-start-client@1.166.25': + resolution: {integrity: sha512-FvD279zzneUtsfhaTv2c29qhE1Z3wHy3dt3cCjn9LzWZehOgn5Ij78s0YpmQaQ8lSF3YL7CySE3pDk9XHE6YeA==} + engines: {node: '>=22.12.0'} + peerDependencies: + react: 19.2.4 + react-dom: 19.2.4 + + '@tanstack/react-start-plugin@1.131.50': + resolution: {integrity: sha512-ys+sGvnnE8BUNjGsngg+MGn3F5lV4okL5CWEKFzjBSjQsrTN7apGfmqvBP3O6PkRPHpXZ8X3Z5QsFvSc0CaDRQ==} + engines: {node: '>=12'} + peerDependencies: + '@vitejs/plugin-react': '>=4.3.4' + vite: '>=6.0.0' + + '@tanstack/react-start-router-manifest@1.120.19': + resolution: {integrity: sha512-z+4YL6shTtsHjk32yaIemQwgkx6FcqwPBYfeNt7Co2eOpWrvsoo/Fe9869/oIY2sPyhiWDs1rDb3e0qnAy8Cag==} + engines: {node: '>=12'} + + '@tanstack/react-start-server@1.166.25': + resolution: {integrity: sha512-bPLADxlplvcnAcnZvBjJl2MzgUnB85d7Mu5aEkYoOFxhz0WiG6mZp7BDadIJuCd33NYMirsd3XrjfCHNzrMTyg==} + engines: {node: '>=22.12.0'} + peerDependencies: + react: 19.2.4 + react-dom: 19.2.4 + + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + peerDependencies: + react: 19.2.4 + react-dom: 19.2.4 + + '@tanstack/router-core@1.131.50': + resolution: {integrity: sha512-eojd4JZ5ziUhGEmXZ4CaVX5mQdiTMiz56Sp8ZQ6r7deb55Q+5G4JQDkeuXpI7HMAvzr+4qlsFeLaDRXXjXyOqQ==} + engines: {node: '>=12'} + + '@tanstack/router-core@1.168.9': + resolution: {integrity: sha512-18oeEwEDyXOIuO1VBP9ACaK7tYHZUjynGDCoUh/5c/BNhia9vCJCp9O0LfhZXOorDc/PmLSgvmweFhVmIxF10g==} + engines: {node: '>=20.19'} + hasBin: true + + '@tanstack/router-generator@1.131.50': + resolution: {integrity: sha512-zlMBw5l88GIg3v+378JsfDYq3ejEaJmD3P1R+m0yEPxh0N//Id1FjKNSS7yJbejlK2WGVm9DUG46iBdTDMQM+Q==} + engines: {node: '>=12'} + + '@tanstack/router-generator@1.166.24': + resolution: {integrity: sha512-vdaGKwuH+r+DPe6R1mjk+TDDmDH6NTG7QqwxHqGEvOH4aGf9sPjhmRKNJZqQr8cPIbfp6u5lXyZ1TeDcSNMVEA==} + engines: {node: '>=20.19'} + + '@tanstack/router-plugin@1.131.50': + resolution: {integrity: sha512-gdEBPGzx7llQNRnaqfPJ1iaPS3oqB8SlvKRG5l7Fxp4q4yINgkeowFYSKEhPOc9bjoNhGrIHOlvPTPXEzAQXzQ==} + engines: {node: '>=12'} + peerDependencies: + '@rsbuild/core': '>=1.0.2' + '@tanstack/react-router': ^1.131.50 + vite: '>=5.0.0 || >=6.0.0' + vite-plugin-solid: ^2.11.2 + webpack: '>=5.92.0' + peerDependenciesMeta: + '@rsbuild/core': + optional: true + '@tanstack/react-router': + optional: true + vite: + optional: true + vite-plugin-solid: + optional: true + webpack: + optional: true + + '@tanstack/router-plugin@1.167.12': + resolution: {integrity: sha512-StEHcctCuFI5taSjO+lhR/yQ+EK63BdyYa+ne6FoNQPB3MMrOUrz2ZVnbqILRLkh2b+p2EfBKt65sgAKdKygPQ==} + engines: {node: '>=20.19'} + hasBin: true + peerDependencies: + '@rsbuild/core': '>=1.0.2' + '@tanstack/react-router': ^1.168.10 + vite: '>=5.0.0 || >=6.0.0 || >=7.0.0' + vite-plugin-solid: ^2.11.10 + webpack: '>=5.92.0' + peerDependenciesMeta: + '@rsbuild/core': + optional: true + '@tanstack/react-router': + optional: true + vite: + optional: true + vite-plugin-solid: + optional: true + webpack: + optional: true + + '@tanstack/router-utils@1.131.2': + resolution: {integrity: sha512-sr3x0d2sx9YIJoVth0QnfEcAcl+39sQYaNQxThtHmRpyeFYNyM2TTH+Ud3TNEnI3bbzmLYEUD+7YqB987GzhDA==} + engines: {node: '>=12'} + + '@tanstack/router-utils@1.141.0': + resolution: {integrity: sha512-/eFGKCiix1SvjxwgzrmH4pHjMiMxc+GA4nIbgEkG2RdAJqyxLcRhd7RPLG0/LZaJ7d0ad3jrtRqsHLv2152Vbw==} + engines: {node: '>=12'} + + '@tanstack/router-utils@1.161.6': + resolution: {integrity: sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw==} + engines: {node: '>=20.19'} + + '@tanstack/server-functions-plugin@1.131.2': + resolution: {integrity: sha512-hWsaSgEZAVyzHg8+IcJWCEtfI9ZSlNELErfLiGHG9XCHEXMegFWsrESsKHlASzJqef9RsuOLDl+1IMPIskwdDw==} + engines: {node: '>=12'} + + '@tanstack/server-functions-plugin@1.142.1': + resolution: {integrity: sha512-ltTOj6dIDlRV3M8+PzontDYFMnIQ+icUnD+OKzIRfKo6bbvC0qvy8ttuWmVJxmqHy9xsWgkNt4gZrKVjtWXIhQ==} + engines: {node: '>=12'} + + '@tanstack/start-api-routes@1.120.19': + resolution: {integrity: sha512-zvMI9Rfwsm3CCLTLqdvUfteDRMdPKTOO05O3L8vp49BrYYsLrT0OplhounzdRMgGMnKd4qCXUC9Pj4UOUOodTw==} + engines: {node: '>=12'} + + '@tanstack/start-client-core@1.131.50': + resolution: {integrity: sha512-8fbwYca1NAu/5WyGvO3e341/FPpsiqdPrrzkoc0cXQimMN1DligoRjvHgP13q3n5w1tFMSqChGzXfOVJP9ndSw==} + engines: {node: '>=12'} + + '@tanstack/start-client-core@1.167.9': + resolution: {integrity: sha512-2ETQO/bxiZGsoTdPxZb7xR8YqCy5l4kv/QPkwIXuvx/A4BjufngXfgISjXUicXsFRIBZeiFnBzp9A38UMsS2iA==} + engines: {node: '>=22.12.0'} + hasBin: true + + '@tanstack/start-config@1.120.20': + resolution: {integrity: sha512-oH/mfTSHV8Qbil74tWicPLW6+kKmT3esXCnDzvrkhi3+N8ZuVUDr01Qpil0Wxf9lLPfM5L6VX03nF4hSU8vljg==} + engines: {node: '>=12'} + peerDependencies: + react: 19.2.4 + react-dom: 19.2.4 + vite: ^6.0.0 + + '@tanstack/start-fn-stubs@1.161.6': + resolution: {integrity: sha512-Y6QSlGiLga8cHfvxGGaonXIlt2bIUTVdH6AMjmpMp7+ANNCp+N96GQbjjhLye3JkaxDfP68x5iZA8NK4imgRig==} + engines: {node: '>=22.12.0'} + + '@tanstack/start-plugin-core@1.131.50': + resolution: {integrity: sha512-eFvMA0chqLtHbq+8ojp1fXN7AQjhmeoOpQaZaU1d51wb7ugetrn0k3OuHblxtE/O0L4HEC9s4X5zmFJt0vLh0w==} + engines: {node: '>=12'} + peerDependencies: + vite: '>=6.0.0' + + '@tanstack/start-server-core@1.131.50': + resolution: {integrity: sha512-3SWwwhW2GKMhPSaqWRal6Jj1Y9ObfdWEXKFQid1LBuk5xk/Es4bmW68o++MbVgs/GxUxyeZ3TRVqb0c7RG1sog==} + engines: {node: '>=12'} + + '@tanstack/start-server-core@1.167.9': + resolution: {integrity: sha512-vKkslQIihoDDVumF73VXT7PVFmN7Nea0nKhZx7gMbc0m09yPQYYR1dn86/dz14k6/7cDkJ+qKXa09rlVlN/i9Q==} + engines: {node: '>=22.12.0'} + hasBin: true + + '@tanstack/start-server-functions-client@1.131.50': + resolution: {integrity: sha512-4aM17fFdVAFH6uLPswKJxzrhhIjcCwKqzfTcgY3OnhUKnaZBTQwJA+nUHQCI6IWvEvrcrNVtFTtv13TkDk3YMw==} + engines: {node: '>=12'} + + '@tanstack/start-server-functions-fetcher@1.131.50': + resolution: {integrity: sha512-yeZekr84BkyLaNaZ4llKbDBb+CJPVESP881iJijP++SuRmvetivUs75KiV9VFIf7MhdefICmRcCdff/KbK5QnQ==} + engines: {node: '>=12'} + + '@tanstack/start-server-functions-handler@1.120.19': + resolution: {integrity: sha512-Ow8HkNieoqHumD3QK4YUDIhzBtFX9mMEDrxFYtbVBgxP1C9Rm/YDuwnUNP49q1tTOZ22Bs4wSDjBXvu+OgSSfA==} + engines: {node: '>=12'} + + '@tanstack/start-server-functions-server@1.131.2': + resolution: {integrity: sha512-u67d6XspczlC/dYki/Id28oWsTjkZMJhDqO4E23U3rHs8eYgxvMBHKqdeqWgOyC+QWT9k6ze1pJmbv+rmc3wOQ==} + engines: {node: '>=12'} + + '@tanstack/start-server-functions-ssr@1.120.19': + resolution: {integrity: sha512-D4HGvJXWvVUssgkLDtdSJTFfWuT+nVv9GauPfVQTtMUUy+NbExNkFWKvF+XvCS81lBqnCKL7VrWqZMXiod0gTA==} + engines: {node: '>=12'} + + '@tanstack/start-storage-context@1.131.50': + resolution: {integrity: sha512-qbVFdx/B5URJXzWjguaiCcQhJw2NL8qFGtSzLSGilxQnvtJdM+V9VBMizKIxhm9oiYnfqGsVfyMOBD7q9f8Y1Q==} + engines: {node: '>=12'} + + '@tanstack/start-storage-context@1.166.23': + resolution: {integrity: sha512-3vEdiYRMx+r+Q7Xqxj3YmADPIpMm7fkKxDa8ITwodGXiw+SBJCGkpBXGUWjOXyXkIyqGHKM5UrReTcVUTkmaug==} + engines: {node: '>=22.12.0'} + + '@tanstack/start@1.120.20': + resolution: {integrity: sha512-fQO+O/5xJpli5KlV6pwDz6DtpbqO/0atdVSyVnkemzk0Mej9azm4HXtw+cKkIPtsSplWs4B1EbMtgGMb9ADhSA==} + engines: {node: '>=12'} + + '@tanstack/store@0.7.7': + resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==} + + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + + '@tanstack/virtual-file-routes@1.131.2': + resolution: {integrity: sha512-VEEOxc4mvyu67O+Bl0APtYjwcNRcL9it9B4HKbNgcBTIOEalhk+ufBl4kiqc8WP1sx1+NAaiS+3CcJBhrqaSRg==} + engines: {node: '>=12'} + + '@tanstack/virtual-file-routes@1.161.7': + resolution: {integrity: sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==} + engines: {node: '>=20.19'} + hasBin: true + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -7836,6 +8493,9 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__code-frame@7.27.0': + resolution: {integrity: sha512-Dwlo+LrxDx/0SpfmJ/BKveHf7QXWvLBLc+x03l5sbzykj3oB9nHygCpSECF1a+s+QIxbghe+KHqC90vGtxLRAA==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -7851,6 +8511,9 @@ packages: '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/braces@3.0.5': + resolution: {integrity: sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==} + '@types/busboy@1.5.4': resolution: {integrity: sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==} @@ -7962,6 +8625,9 @@ packages: '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/micromatch@4.0.10': + resolution: {integrity: sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ==} + '@types/minimatch@6.0.0': resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==} deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed. @@ -8042,6 +8708,9 @@ packages: '@types/request@2.48.13': resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==} + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} @@ -8305,6 +8974,11 @@ packages: '@vercel/git-hooks@1.0.0': resolution: {integrity: sha512-OxDFAAdyiJ/H0b8zR9rFCu3BIb78LekBXOphOYG3snV4ULhKFX387pBPpqZ9HLiRTejBWBxYEahkw79tuIgdAA==} + '@vercel/nft@1.5.0': + resolution: {integrity: sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==} + engines: {node: '>=20'} + hasBin: true + '@vercel/oidc@3.1.0': resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} engines: {node: '>= 20'} @@ -8314,6 +8988,10 @@ packages: engines: {node: '>=14.6'} deprecated: '@vercel/postgres is deprecated. If you are setting up a new database, you can choose an alternate storage solution from the Vercel Marketplace. If you had an existing Vercel Postgres database, it should have been migrated to Neon as a native Vercel integration. You can find more details and the guide to migrate to Neon''s SDKs here: https://neon.com/docs/guides/vercel-postgres-transition-guide' + '@vinxi/listhen@1.5.6': + resolution: {integrity: sha512-WSN1z931BtasZJlgPp704zJFnQFRg7yzSjkm3MzAWQYe4uXFXlFr1hc5Ac2zae5/HDOz5x1/zDM5Cb54vTCnWw==} + hasBin: true + '@vitejs/plugin-react@4.5.2': resolution: {integrity: sha512-QNVT3/Lxx99nMQWJWF7K4N6apUEuT0KlZA3mx/mVaoGj3smm/8rc8ezz15J1pcbcjDK0V15rpHetVfya08r76Q==} engines: {node: ^14.18.0 || >=16.0.0} @@ -8519,6 +9197,10 @@ packages: abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + abbrev@3.0.1: + resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} + engines: {node: ^18.17.0 || >=20.5.0} + abbrev@4.0.0: resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} engines: {node: ^20.17.0 || >=22.9.0} @@ -8608,6 +9290,9 @@ packages: amazon-cognito-identity-js@6.3.16: resolution: {integrity: sha512-HPGSBGD6Q36t99puWh0LnptxO/4icnk2kqIQ9cTJ2tFQo5NMUnWQIgtrTAk8nm+caqUbjDzXzG56GBjI2tS6jQ==} + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -8644,6 +9329,10 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -8662,6 +9351,9 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -8733,6 +9425,10 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + ast-types@0.16.1: + resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + engines: {node: '>=4'} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -8747,6 +9443,9 @@ packages: async-retry@1.3.3: resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + async-sema@3.1.1: + resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -8794,6 +9493,9 @@ packages: react-native-b4a: optional: true + babel-dead-code-elimination@1.0.12: + resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} @@ -8917,9 +9619,20 @@ packages: body-scroll-lock@4.0.0-beta.0: resolution: {integrity: sha512-a7tP5+0Mw3YlUJcGAKUqIBkYYGlYxk2fnCasq/FUph1hadxlTRjF+gAcZksxANnaMnALjxEddmSi/H3OR8ugcQ==} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + boxen@7.1.1: + resolution: {integrity: sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==} + engines: {node: '>=14.16'} + + boxen@8.0.1: + resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} + engines: {node: '>=18'} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -9033,6 +9746,14 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} + camelcase@7.0.1: + resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} + engines: {node: '>=14.16'} + + camelcase@8.0.0: + resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} + engines: {node: '>=16'} + caniuse-lite@1.0.30001780: resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} @@ -9082,6 +9803,13 @@ packages: charenc@0.0.2: resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.2.0: + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} + engines: {node: '>=20.18.1'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -9125,6 +9853,10 @@ packages: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -9136,6 +9868,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + clipboardy@4.0.0: + resolution: {integrity: sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w==} + engines: {node: '>=18'} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -9227,6 +9963,9 @@ packages: compare-versions@6.1.1: resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + compatx@0.2.0: + resolution: {integrity: sha512-6gLRNt4ygsi5NyMVhceOCFv14CIdDFN7fQjX1U4+47qVE/+kjPoXMK65KWK+dWxmFzMTuKazoQ9sch6pM0p5oA==} + compress-commons@6.0.2: resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} engines: {node: '>= 14'} @@ -9241,6 +9980,9 @@ packages: resolution: {integrity: sha512-Bi6v586cy1CoTFViVO4lGTtx780lfF96fUmS1lSX6wpZf6330NvHUu6fReVuDP1de8Mg0nkZb01c8tAQdz1o3w==} engines: {node: '>=18'} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} @@ -9272,6 +10014,15 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@1.2.2: + resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} + + cookie-es@2.0.0: + resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==} + + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -9320,6 +10071,10 @@ packages: resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} engines: {node: '>= 14'} + croner@10.0.1: + resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==} + engines: {node: '>=18.0'} + croner@9.1.0: resolution: {integrity: sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g==} engines: {node: '>=18.0'} @@ -9333,6 +10088,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + crypt@0.0.2: resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} @@ -9344,10 +10102,17 @@ packages: resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==} engines: {node: '>=12'} + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -9451,6 +10216,37 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + dax-sh@0.39.2: + resolution: {integrity: sha512-gpuGEkBQM+5y6p4cWaw9+ePy5TNon+fdwFVtTI8leU3UhwhsBfPewRxMXGuQNC+M2b/MDGMlfgpqynkcd0C3FQ==} + deprecated: This package has moved to simply be 'dax' instead of 'dax-sh' + + dax-sh@0.43.2: + resolution: {integrity: sha512-uULa1sSIHgXKGCqJ/pA0zsnzbHlVnuq7g8O2fkHokWFNwEGIhh5lAJlxZa1POG5En5ba7AU4KcBAvGQWMMf8rg==} + deprecated: This package has moved to simply be 'dax' instead of 'dax-sh' + + db0@0.3.4: + resolution: {integrity: sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw==} + peerDependencies: + '@electric-sql/pglite': '*' + '@libsql/client': '*' + better-sqlite3: '*' + drizzle-orm: '*' + mysql2: '*' + sqlite3: '*' + peerDependenciesMeta: + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + better-sqlite3: + optional: true + drizzle-orm: + optional: true + mysql2: + optional: true + sqlite3: + optional: true + debounce-fn@6.0.0: resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==} engines: {node: '>=18'} @@ -9458,6 +10254,14 @@ packages: debounce@1.2.1: resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -9566,6 +10370,10 @@ packages: destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-file@1.0.0: resolution: {integrity: sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==} engines: {node: '>=0.10.0'} @@ -9595,6 +10403,10 @@ packages: dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -9617,9 +10429,26 @@ packages: dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + dompurify@3.2.7: resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dot-prop@10.1.0: + resolution: {integrity: sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==} + engines: {node: '>=20'} + dot-prop@9.0.0: resolution: {integrity: sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==} engines: {node: '>=18'} @@ -9788,6 +10617,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + encoding-sniffer@0.2.1: + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -9799,6 +10631,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -9882,6 +10718,11 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.20.2: + resolution: {integrity: sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -9902,6 +10743,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -10270,6 +11116,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -10543,6 +11392,10 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -10629,6 +11482,9 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + get-port-please@3.2.0: + resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -10745,6 +11601,10 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + globby@16.2.0: + resolution: {integrity: sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==} + engines: {node: '>=20'} + globjoin@0.1.4: resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} @@ -10800,6 +11660,29 @@ packages: resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} engines: {node: '>=10'} + gzip-size@7.0.0: + resolution: {integrity: sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + h3@1.13.0: + resolution: {integrity: sha512-vFEAu/yf8UMUcB4s43OaDaigcqpQd14yanmOsn+NcRX3/guSKncyE2rOYhq8RIchgJrPSs/QiIddnTTR1ddiAg==} + + h3@1.15.10: + resolution: {integrity: sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg==} + + h3@1.15.3: + resolution: {integrity: sha512-z6GknHqyX0h9aQaTx22VZDf6QyZn+0Nh+Ym8O/u0SGSkyF5cuTJYKlc8MkzW3Nzf9LE1ivcpmYC3FUGpywhuUQ==} + + h3@2.0.1-rc.16: + resolution: {integrity: sha512-h+pjvyujdo9way8qj6FUbhaQcHlR8FEq65EhTX9ViT5pK8aLj68uFl4hBkF+hsTJAH+H1END2Yv6hTIsabGfag==} + engines: {node: '>=20.11.1'} + hasBin: true + peerDependencies: + crossws: ^0.4.1 + peerDependenciesMeta: + crossws: + optional: true + happy-dom@20.8.9: resolution: {integrity: sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==} engines: {node: '>=20.0.0'} @@ -10863,6 +11746,9 @@ packages: resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==} engines: {node: '>=16.9.0'} + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + hookified@1.15.1: resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} @@ -10886,6 +11772,9 @@ packages: resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} engines: {node: '>=8'} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -10901,6 +11790,14 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + http-proxy@1.18.1: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + + http-shutdown@1.2.2: + resolution: {integrity: sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + http-status@2.1.0: resolution: {integrity: sha512-O5kPr7AW7wYd/BBiOezTwnVAnmSNFY+J7hlZD2X5IOxVBetjcHAiTXhzj0gMrnojQlwy+UT1/Y3H3vJ3UlmvLA==} engines: {node: '>= 0.4.0'} @@ -10917,6 +11814,9 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + httpxy@0.3.1: + resolution: {integrity: sha512-XjG/CEoofEisMrnFr0D6U6xOZ4mRfnwcYQ9qvvnT4lvnX8BoeA3x3WofB75D+vZwpaobFVkBIHrZzoK40w8XSw==} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -10937,6 +11837,10 @@ packages: engines: {node: '>=18'} hasBin: true + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -10982,6 +11886,9 @@ packages: import-in-the-middle@1.15.0: resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -11031,6 +11938,9 @@ packages: resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==} engines: {node: '>= 10'} + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -11140,6 +12050,10 @@ packages: eslint: '*' typescript: 5.7.3 + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} @@ -11149,6 +12063,9 @@ packages: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} + is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + is-negative-zero@2.0.3: resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} engines: {node: '>= 0.4'} @@ -11172,6 +12089,10 @@ packages: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + is-plain-obj@1.1.0: resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} engines: {node: '>=0.10.0'} @@ -11249,6 +12170,10 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} + is64bit@2.0.0: + resolution: {integrity: sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==} + engines: {node: '>=18'} + isarray@0.0.1: resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} @@ -11258,6 +12183,10 @@ packages: isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isbot@5.1.37: + resolution: {integrity: sha512-5bcicX81xf6NlTEV8rWdg7Pk01LFizDetuYGHx6d/f6y3lR2/oo8IfxjzJqn1UdDEyCcwT9e7NRloj8DwCYujQ==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -11290,6 +12219,10 @@ packages: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -11316,6 +12249,13 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -11437,6 +12377,13 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + klona@2.0.6: + resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} + engines: {node: '>= 8'} + + knitwork@1.3.0: + resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==} + known-css-properties@0.37.0: resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} @@ -11557,6 +12504,10 @@ packages: engines: {node: '>=18.12.0'} hasBin: true + listhen@1.9.0: + resolution: {integrity: sha512-I8oW2+QL5KJo8zXNWX046M134WchxsXC7SawLPvRQpogCbkyQIaFxPE89A2HiwR7vAK2Dm2ERBAmyjTYGYEpBg==} + hasBin: true + listr2@8.2.5: resolution: {integrity: sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==} engines: {node: '>=18.0.0'} @@ -11565,6 +12516,10 @@ packages: resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} engines: {node: '>=6.11.5'} + local-pkg@1.1.2: + resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} + engines: {node: '>=14'} + localforage@1.10.0: resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==} @@ -11663,6 +12618,9 @@ packages: resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} engines: {node: '>=12'} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + make-dir@2.1.0: resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} engines: {node: '>=6'} @@ -11838,11 +12796,21 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} hasBin: true + mime@4.1.0: + resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} + engines: {node: '>=16'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -11943,6 +12911,9 @@ packages: engines: {node: '>=10'} hasBin: true + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + mnemonist@0.38.3: resolution: {integrity: sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==} @@ -12014,6 +12985,9 @@ packages: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -12084,6 +13058,16 @@ packages: sass: optional: true + nitropack@2.13.2: + resolution: {integrity: sha512-R5TMzSBoTDG4gi6Y+pvvyCNnooShHePHsHxMLP9EXDGdrlR5RvNdSd4e5k8z0/EzP9Ske7ABRMDWg6O7Dm2OYw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + xml2js: ^0.6.2 + peerDependenciesMeta: + xml2js: + optional: true + node-abi@3.89.0: resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} engines: {node: '>=10'} @@ -12119,6 +13103,10 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-forge@1.4.0: + resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} + engines: {node: '>= 6.13.0'} + node-gyp-build@4.8.4: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true @@ -12128,6 +13116,9 @@ packages: engines: {node: ^20.17.0 || >=22.9.0} hasBin: true + node-mock-http@1.0.4: + resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} + node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} @@ -12142,6 +13133,11 @@ packages: resolution: {integrity: sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==} hasBin: true + nopt@8.1.0: + resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + nopt@9.0.0: resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -12172,6 +13168,9 @@ packages: npx-import@1.1.4: resolution: {integrity: sha512-3ShymTWOgqGyNlh5lMJAejLuIv3W1K3fbI5Ewc6YErZU3Sp0PqsNs8UIU1O8z5+KVl/Du5ag56Gza9vdorGEoA==} + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nypm@0.6.5: resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==} engines: {node: '>=18'} @@ -12228,6 +13227,9 @@ packages: ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + ohash@1.1.6: + resolution: {integrity: sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==} + ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} @@ -12258,6 +13260,10 @@ packages: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + openapi-fetch@0.15.0: resolution: {integrity: sha512-OjQUdi61WO4HYhr9+byCPMj0+bgste/LtSBEcV6FzDdONTs7x0fWn8/ndoYwzqCsKWIxEZwo4FN/TG1c1rI8IQ==} @@ -12348,6 +13354,15 @@ packages: resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} engines: {node: '>=0.10.0'} + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} @@ -12399,6 +13414,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -12503,6 +13521,9 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} @@ -12593,6 +13614,10 @@ packages: postgres-range@1.1.4: resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -12669,6 +13694,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-bytes@7.1.0: + resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==} + engines: {node: '>=20'} + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -12739,6 +13768,9 @@ packages: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -12749,6 +13781,9 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -12909,6 +13944,10 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + recast@0.23.11: + resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} + engines: {node: '>= 4'} + recharts@3.2.1: resolution: {integrity: sha512-0JKwHRiFZdmLq/6nmilxEZl3pqb4T+aKkOkOi/ZISRZwfBhVMgInxzlYU9D4KnCH3KINScLy68m/OvMXoYGZUw==} engines: {node: '>=18'} @@ -12986,6 +14025,9 @@ packages: resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==} engines: {node: '>=8.6.0'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} @@ -13061,6 +14103,19 @@ packages: rollup: ^3.29.4 || ^4 typescript: 5.7.3 + rollup-plugin-visualizer@7.0.1: + resolution: {integrity: sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==} + engines: {node: '>=22'} + hasBin: true + peerDependencies: + rolldown: 1.x || ^1.0.0-beta || ^1.0.0-rc + rollup: 2.x || 3.x || 4.x + peerDependenciesMeta: + rolldown: + optional: true + rollup: + optional: true + rollup@3.29.5: resolution: {integrity: sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==} engines: {node: '>=14.18.0', npm: '>=8.0.0'} @@ -13071,6 +14126,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rou3@0.8.1: + resolution: {integrity: sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -13287,10 +14345,35 @@ packages: engines: {node: '>=10'} hasBin: true + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + serialize-javascript@7.0.5: + resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==} + engines: {node: '>=20.0.0'} + + seroval-plugins@1.5.1: + resolution: {integrity: sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.1: + resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} + engines: {node: '>=10'} + + serve-placeholder@2.0.2: + resolution: {integrity: sha512-/TMG8SboeiQbZJWRlfTCqMs2DD3SZgWp0kDQePz9yUuCnDfDh/92gf7/PxGhzXTKBIPASIHxFcZndoNbp6QOLQ==} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -13404,6 +14487,10 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + slate-history@0.86.0: resolution: {integrity: sha512-OxObL9tbhgwvSlnKSCpGIh7wnuaqvOj5jRExGjEyCU2Ke8ctf22HjT+jw7GEi9ttLzNTUmTEU3YIzqKGeqN+og==} peerDependencies: @@ -13443,6 +14530,10 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + smob@1.6.1: + resolution: {integrity: sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==} + engines: {node: '>=20.0.0'} + socks-proxy-agent@8.0.5: resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} engines: {node: '>= 14'} @@ -13522,9 +14613,17 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sqids@0.3.0: resolution: {integrity: sha512-lOQK1ucVg+W6n3FhRwwSeUijxe93b51Bfz5PMRMihVf1iVkl82ePQG7V5vwrhzB11v0NtsR25PSZRGiSomJaJw==} + srvx@0.11.13: + resolution: {integrity: sha512-oknN6qduuMPafxKtHucUeG32Q963pjriA5g3/Bl05cwEsUe5VVbIU4qR9LrALHbipSCyBe+VmfDGGydqazDRkw==} + engines: {node: '>=20.16.0'} + hasBin: true + ssri@13.0.1: resolution: {integrity: sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==} engines: {node: ^20.17.0 || >=22.9.0} @@ -13555,6 +14654,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -13664,6 +14766,9 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + stripe@10.17.0: resolution: {integrity: sha512-JHV2KoL+nMQRXu3m9ervCZZvi4DDCJfzHUE6CmtJxR9TmizyYfrVuhGvnsZLLnheby9Qrnf4Hq6iOEcejGwnGQ==} engines: {node: ^8.1 || >=10.*} @@ -13779,6 +14884,10 @@ packages: resolution: {integrity: sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==} engines: {node: '>=16.0.0'} + system-architecture@0.1.0: + resolution: {integrity: sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==} + engines: {node: '>=18'} + tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} @@ -13786,6 +14895,10 @@ packages: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} @@ -14051,10 +15164,18 @@ packages: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + type-fest@4.41.0: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + type-fest@5.5.0: + resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} + engines: {node: '>=20'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -14101,6 +15222,9 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} + ultrahtml@1.6.0: + resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -14108,6 +15232,12 @@ packages: unbzip2-stream@1.4.3: resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==} + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + unctx@2.5.0: + resolution: {integrity: sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==} + undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} @@ -14130,6 +15260,9 @@ packages: resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==} engines: {node: '>=20.18.1'} + unenv@1.10.0: + resolution: {integrity: sha512-wY5bskBQFL9n3Eca5XnhH6KbUo/tfvkwm9OpcdCvLaeA7piBNbavbOKJySEwQ1V0RH6HvNlSAFRTpvTqgKRQXQ==} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -14152,6 +15285,14 @@ packages: resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} engines: {node: '>=4'} + unicorn-magic@0.4.0: + resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} + engines: {node: '>=20'} + + unimport@6.0.2: + resolution: {integrity: sha512-ZSOkrDw380w+KIPniY3smyXh2h7H9v2MNr9zejDuh239o5sdea44DRAYrv+rfUi2QGT186P2h0GPGKvy8avQ5g==} + engines: {node: '>=18.12.0'} + unique-string@2.0.0: resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} engines: {node: '>=8'} @@ -14179,16 +15320,101 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unplugin-utils@0.3.1: + resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} + engines: {node: '>=20.19.0'} + unplugin@1.0.1: resolution: {integrity: sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==} + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + + unplugin@3.0.0: + resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==} + engines: {node: ^20.19.0 || >=22.12.0} + unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + unstorage@1.17.5: + resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + untildify@4.0.0: resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} engines: {node: '>=8'} + untun@0.1.3: + resolution: {integrity: sha512-4luGP9LMYszMRZwsvyUd9MrxgEGZdZuZgpVQHEEX0lCYFESasVRvZd0EYpCkOIbJKHMuv0LskpXc/8Un+MJzEQ==} + hasBin: true + + untyped@2.0.0: + resolution: {integrity: sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==} + hasBin: true + + unwasm@0.5.3: + resolution: {integrity: sha512-keBgTSfp3r6+s9ZcSma+0chwxQdmLbB5+dAD9vjtB21UTMYuKAxHXCU1K2CbCtnP09EaWeRvACnXk0EJtUx+hw==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -14216,6 +15442,9 @@ packages: tailwindcss: optional: true + uqr@0.1.2: + resolution: {integrity: sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -14314,11 +15543,59 @@ packages: victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vinxi@0.5.11: + resolution: {integrity: sha512-82Qm+EG/b2PRFBvXBbz1lgWBGcd9totIL6SJhnrZYfakjloTVG9+5l6gfO6dbCCtztm5pqWFzLY0qpZ3H3ww/w==} + hasBin: true + + vinxi@0.5.3: + resolution: {integrity: sha512-4sL2SMrRzdzClapP44oXdGjCE1oq7/DagsbjY5A09EibmoIO4LP8ScRVdh03lfXxKRk7nCWK7n7dqKvm+fp/9w==} + hasBin: true + vite-tsconfig-paths@6.0.5: resolution: {integrity: sha512-f/WvY6ekHykUF1rWJUAbCU7iS/5QYDIugwpqJA+ttwKbxSbzNlqlE8vZSrsnxNQciUW+z6lvhlXMaEyZn9MSig==} peerDependencies: vite: '*' + vite@6.4.1: + resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -14359,6 +15636,14 @@ packages: yaml: optional: true + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + vitest@4.0.15: resolution: {integrity: sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -14474,6 +15759,9 @@ packages: webpack-virtual-modules@0.5.0: resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==} + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + webpack@5.105.4: resolution: {integrity: sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==} engines: {node: '>=10.13.0'} @@ -14484,10 +15772,19 @@ packages: webpack-cli: optional: true + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + whatwg-mimetype@5.0.0: resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} engines: {node: '>=20'} @@ -14546,6 +15843,14 @@ packages: engines: {node: '>=8'} hasBin: true + widest-line@4.0.1: + resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} + engines: {node: '>=12'} + + widest-line@5.0.0: + resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} + engines: {node: '>=18'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -14624,10 +15929,18 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xmlbuilder2@3.1.1: + resolution: {integrity: sha512-WCSfbfZnQDdLQLiMdGUQpMxxckeQ4oZNMNhLVkcekTu7xhD4tuUDyAPoY8CwXvBYE6LwBHd6QW2WZXlOWr1vCw==} + engines: {node: '>=12.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -14706,6 +16019,9 @@ packages: youch@4.1.0-beta.10: resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + youch@4.1.1: + resolution: {integrity: sha512-mxW3qiSnl+GRxXsaUMzv2Mbada1Y8CDltET9UxejDQe6DBYlSekghl5U5K0ReAikcHDi0G1vKZEmmo/NWAGKLA==} + zip-stream@6.0.1: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} @@ -16091,6 +17407,18 @@ snapshots: '@nicolo-ribaudo/chokidar-2': 2.1.8-no-fsevents.3 chokidar: 3.6.0 + '@babel/code-frame@7.26.2': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -16172,6 +17500,19 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.27.3)': dependencies: '@babel/core': 7.27.3 @@ -16248,6 +17589,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.29.0 @@ -16340,11 +17690,21 @@ snapshots: '@babel/core': 7.27.3 '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.27.3)': dependencies: '@babel/core': 7.27.3 '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.27.3)': dependencies: '@babel/core': 7.27.3 @@ -16511,6 +17871,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-modules-systemjs@7.29.0(@babel/core@7.27.3)': dependencies: '@babel/core': 7.27.3 @@ -16703,6 +18071,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.27.3)': dependencies: '@babel/core': 7.27.3 @@ -16831,6 +18210,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/preset-typescript@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + '@babel/runtime@7.29.2': {} '@babel/template@7.28.6': @@ -16966,6 +18356,13 @@ snapshots: '@date-fns/tz@1.2.0': {} + '@deno/shim-deno-test@0.5.0': {} + + '@deno/shim-deno@0.19.2': + dependencies: + '@deno/shim-deno-test': 0.5.0 + which: 4.0.0 + '@discoveryjs/json-ext@0.5.7': {} '@dnd-kit/accessibility@3.1.1(react@19.2.4)': @@ -17116,6 +18513,9 @@ snapshots: '@esbuild-kit/core-utils': 3.3.2 get-tsconfig: 4.8.1 + '@esbuild/aix-ppc64@0.20.2': + optional: true + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -17128,9 +18528,15 @@ snapshots: '@esbuild/aix-ppc64@0.27.1': optional: true + '@esbuild/aix-ppc64@0.27.4': + optional: true + '@esbuild/android-arm64@0.18.20': optional: true + '@esbuild/android-arm64@0.20.2': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true @@ -17143,9 +18549,15 @@ snapshots: '@esbuild/android-arm64@0.27.1': optional: true + '@esbuild/android-arm64@0.27.4': + optional: true + '@esbuild/android-arm@0.18.20': optional: true + '@esbuild/android-arm@0.20.2': + optional: true + '@esbuild/android-arm@0.25.12': optional: true @@ -17158,9 +18570,15 @@ snapshots: '@esbuild/android-arm@0.27.1': optional: true + '@esbuild/android-arm@0.27.4': + optional: true + '@esbuild/android-x64@0.18.20': optional: true + '@esbuild/android-x64@0.20.2': + optional: true + '@esbuild/android-x64@0.25.12': optional: true @@ -17173,9 +18591,15 @@ snapshots: '@esbuild/android-x64@0.27.1': optional: true + '@esbuild/android-x64@0.27.4': + optional: true + '@esbuild/darwin-arm64@0.18.20': optional: true + '@esbuild/darwin-arm64@0.20.2': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true @@ -17188,9 +18612,15 @@ snapshots: '@esbuild/darwin-arm64@0.27.1': optional: true + '@esbuild/darwin-arm64@0.27.4': + optional: true + '@esbuild/darwin-x64@0.18.20': optional: true + '@esbuild/darwin-x64@0.20.2': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true @@ -17203,9 +18633,15 @@ snapshots: '@esbuild/darwin-x64@0.27.1': optional: true + '@esbuild/darwin-x64@0.27.4': + optional: true + '@esbuild/freebsd-arm64@0.18.20': optional: true + '@esbuild/freebsd-arm64@0.20.2': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true @@ -17218,9 +18654,15 @@ snapshots: '@esbuild/freebsd-arm64@0.27.1': optional: true + '@esbuild/freebsd-arm64@0.27.4': + optional: true + '@esbuild/freebsd-x64@0.18.20': optional: true + '@esbuild/freebsd-x64@0.20.2': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true @@ -17233,9 +18675,15 @@ snapshots: '@esbuild/freebsd-x64@0.27.1': optional: true + '@esbuild/freebsd-x64@0.27.4': + optional: true + '@esbuild/linux-arm64@0.18.20': optional: true + '@esbuild/linux-arm64@0.20.2': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true @@ -17248,9 +18696,15 @@ snapshots: '@esbuild/linux-arm64@0.27.1': optional: true + '@esbuild/linux-arm64@0.27.4': + optional: true + '@esbuild/linux-arm@0.18.20': optional: true + '@esbuild/linux-arm@0.20.2': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true @@ -17263,9 +18717,15 @@ snapshots: '@esbuild/linux-arm@0.27.1': optional: true + '@esbuild/linux-arm@0.27.4': + optional: true + '@esbuild/linux-ia32@0.18.20': optional: true + '@esbuild/linux-ia32@0.20.2': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true @@ -17278,9 +18738,15 @@ snapshots: '@esbuild/linux-ia32@0.27.1': optional: true + '@esbuild/linux-ia32@0.27.4': + optional: true + '@esbuild/linux-loong64@0.18.20': optional: true + '@esbuild/linux-loong64@0.20.2': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true @@ -17293,9 +18759,15 @@ snapshots: '@esbuild/linux-loong64@0.27.1': optional: true + '@esbuild/linux-loong64@0.27.4': + optional: true + '@esbuild/linux-mips64el@0.18.20': optional: true + '@esbuild/linux-mips64el@0.20.2': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true @@ -17308,9 +18780,15 @@ snapshots: '@esbuild/linux-mips64el@0.27.1': optional: true + '@esbuild/linux-mips64el@0.27.4': + optional: true + '@esbuild/linux-ppc64@0.18.20': optional: true + '@esbuild/linux-ppc64@0.20.2': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true @@ -17323,9 +18801,15 @@ snapshots: '@esbuild/linux-ppc64@0.27.1': optional: true + '@esbuild/linux-ppc64@0.27.4': + optional: true + '@esbuild/linux-riscv64@0.18.20': optional: true + '@esbuild/linux-riscv64@0.20.2': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true @@ -17338,9 +18822,15 @@ snapshots: '@esbuild/linux-riscv64@0.27.1': optional: true + '@esbuild/linux-riscv64@0.27.4': + optional: true + '@esbuild/linux-s390x@0.18.20': optional: true + '@esbuild/linux-s390x@0.20.2': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true @@ -17353,9 +18843,15 @@ snapshots: '@esbuild/linux-s390x@0.27.1': optional: true + '@esbuild/linux-s390x@0.27.4': + optional: true + '@esbuild/linux-x64@0.18.20': optional: true + '@esbuild/linux-x64@0.20.2': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true @@ -17368,6 +18864,9 @@ snapshots: '@esbuild/linux-x64@0.27.1': optional: true + '@esbuild/linux-x64@0.27.4': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true @@ -17380,9 +18879,15 @@ snapshots: '@esbuild/netbsd-arm64@0.27.1': optional: true + '@esbuild/netbsd-arm64@0.27.4': + optional: true + '@esbuild/netbsd-x64@0.18.20': optional: true + '@esbuild/netbsd-x64@0.20.2': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true @@ -17395,6 +18900,9 @@ snapshots: '@esbuild/netbsd-x64@0.27.1': optional: true + '@esbuild/netbsd-x64@0.27.4': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true @@ -17407,9 +18915,15 @@ snapshots: '@esbuild/openbsd-arm64@0.27.1': optional: true + '@esbuild/openbsd-arm64@0.27.4': + optional: true + '@esbuild/openbsd-x64@0.18.20': optional: true + '@esbuild/openbsd-x64@0.20.2': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true @@ -17422,6 +18936,9 @@ snapshots: '@esbuild/openbsd-x64@0.27.1': optional: true + '@esbuild/openbsd-x64@0.27.4': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true @@ -17431,9 +18948,15 @@ snapshots: '@esbuild/openharmony-arm64@0.27.1': optional: true + '@esbuild/openharmony-arm64@0.27.4': + optional: true + '@esbuild/sunos-x64@0.18.20': optional: true + '@esbuild/sunos-x64@0.20.2': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true @@ -17446,9 +18969,15 @@ snapshots: '@esbuild/sunos-x64@0.27.1': optional: true + '@esbuild/sunos-x64@0.27.4': + optional: true + '@esbuild/win32-arm64@0.18.20': optional: true + '@esbuild/win32-arm64@0.20.2': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true @@ -17461,9 +18990,15 @@ snapshots: '@esbuild/win32-arm64@0.27.1': optional: true + '@esbuild/win32-arm64@0.27.4': + optional: true + '@esbuild/win32-ia32@0.18.20': optional: true + '@esbuild/win32-ia32@0.20.2': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true @@ -17476,9 +19011,15 @@ snapshots: '@esbuild/win32-ia32@0.27.1': optional: true + '@esbuild/win32-ia32@0.27.4': + optional: true + '@esbuild/win32-x64@0.18.20': optional: true + '@esbuild/win32-x64@0.20.2': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true @@ -17491,6 +19032,9 @@ snapshots: '@esbuild/win32-x64@0.27.1': optional: true + '@esbuild/win32-x64@0.27.4': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': dependencies: eslint: 9.39.2(jiti@2.6.1) @@ -18290,6 +19834,19 @@ snapshots: '@libsql/win32-x64-msvc@0.4.7': optional: true + '@mapbox/node-pre-gyp@2.0.3': + dependencies: + consola: 3.4.2 + detect-libc: 2.1.2 + https-proxy-agent: 7.0.6 + node-fetch: 2.7.0 + nopt: 8.1.0 + semver: 7.7.4 + tar: 7.5.12 + transitivePeerDependencies: + - encoding + - supports-color + '@miniflare/core@2.14.4': dependencies: '@iarna/toml': 2.2.5 @@ -18551,6 +20108,23 @@ snapshots: '@npmcli/redact@4.0.0': {} + '@oozcitak/dom@1.15.10': + dependencies: + '@oozcitak/infra': 1.0.8 + '@oozcitak/url': 1.0.4 + '@oozcitak/util': 8.3.8 + + '@oozcitak/infra@1.0.8': + dependencies: + '@oozcitak/util': 8.3.8 + + '@oozcitak/url@1.0.4': + dependencies: + '@oozcitak/infra': 1.0.8 + '@oozcitak/util': 8.3.8 + + '@oozcitak/util@8.3.8': {} + '@opennextjs/aws@3.9.14(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))': dependencies: '@ast-grep/napi': 0.40.0 @@ -19159,6 +20733,16 @@ snapshots: '@parcel/watcher-linux-x64-musl@2.5.6': optional: true + '@parcel/watcher-wasm@2.3.0': + dependencies: + is-glob: 4.0.3 + micromatch: 4.0.8 + + '@parcel/watcher-wasm@2.5.6': + dependencies: + is-glob: 4.0.3 + picomatch: 4.0.4 + '@parcel/watcher-win32-arm64@2.5.6': optional: true @@ -19188,7 +20772,6 @@ snapshots: '@parcel/watcher-win32-arm64': 2.5.6 '@parcel/watcher-win32-ia32': 2.5.6 '@parcel/watcher-win32-x64': 2.5.6 - optional: true '@payloadcms/figma@0.0.1-alpha.58(@payloadcms/plugin-cloud-storage@packages+plugin-cloud-storage)(@payloadcms/richtext-lexical@packages+richtext-lexical)(payload@packages+payload)': dependencies: @@ -19239,6 +20822,12 @@ snapshots: '@sindresorhus/is': 7.2.0 supports-color: 10.2.2 + '@poppinss/dumper@0.7.0': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + '@poppinss/exception@1.2.3': {} '@preact/signals-core@1.14.0': {} @@ -19634,6 +21223,10 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.11': {} + '@rollup/plugin-alias@6.0.0(rollup@4.59.0)': + optionalDependencies: + rollup: 4.59.0 + '@rollup/plugin-commonjs@28.0.1(rollup@3.29.5)': dependencies: '@rollup/pluginutils': 5.3.0(rollup@3.29.5) @@ -19658,6 +21251,57 @@ snapshots: optionalDependencies: rollup: 4.59.0 + '@rollup/plugin-commonjs@29.0.2(rollup@4.59.0)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + commondir: 1.0.1 + estree-walker: 2.0.2 + fdir: 6.5.0(picomatch@4.0.4) + is-reference: 1.2.1 + magic-string: 0.30.21 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.59.0 + + '@rollup/plugin-inject@5.0.5(rollup@4.59.0)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + estree-walker: 2.0.2 + magic-string: 0.30.21 + optionalDependencies: + rollup: 4.59.0 + + '@rollup/plugin-json@6.1.0(rollup@4.59.0)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + optionalDependencies: + rollup: 4.59.0 + + '@rollup/plugin-node-resolve@16.0.3(rollup@4.59.0)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.11 + optionalDependencies: + rollup: 4.59.0 + + '@rollup/plugin-replace@6.0.3(rollup@4.59.0)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + magic-string: 0.30.21 + optionalDependencies: + rollup: 4.59.0 + + '@rollup/plugin-terser@1.0.0(rollup@4.59.0)': + dependencies: + serialize-javascript: 7.0.5 + smob: 1.6.1 + terser: 5.46.1 + optionalDependencies: + rollup: 4.59.0 + '@rollup/pluginutils@5.3.0(rollup@3.29.5)': dependencies: '@types/estree': 1.0.8 @@ -20238,6 +21882,8 @@ snapshots: '@sindresorhus/is@7.2.0': {} + '@sindresorhus/merge-streams@4.0.0': {} + '@sindresorhus/slugify@1.1.2': dependencies: '@sindresorhus/transliterate': 0.1.2 @@ -21035,80 +22681,741 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.2.2 - '@testing-library/dom@10.4.1': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.29.2 - '@types/aria-query': 5.0.4 - aria-query: 5.3.0 - dom-accessibility-api: 0.5.16 - lz-string: 1.5.0 - picocolors: 1.1.1 - pretty-format: 27.5.1 - - '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@babel/runtime': 7.29.2 - '@testing-library/dom': 10.4.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@tokenizer/inflate@0.2.7': + '@tanstack/directive-functions-plugin@1.131.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: - debug: 4.4.3 - fflate: 0.8.2 - token-types: 6.1.2 + '@babel/code-frame': 7.27.1 + '@babel/core': 7.29.0 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@tanstack/router-utils': 1.131.2 + babel-dead-code-elimination: 1.0.12 + tiny-invariant: 1.3.3 + vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color - '@tokenizer/token@0.3.0': {} - - '@tootallnate/once@2.0.0': {} - - '@ts-morph/common@0.22.0': + '@tanstack/directive-functions-plugin@1.142.1(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: - fast-glob: 3.3.3 - minimatch: 9.0.9 - mkdirp: 3.0.1 - path-browserify: 1.0.1 - - '@tsconfig/node18@1.0.3': {} - - '@turbo/darwin-64@2.8.20': - optional: true - - '@turbo/darwin-arm64@2.8.20': - optional: true - - '@turbo/linux-64@2.8.20': - optional: true - - '@turbo/linux-arm64@2.8.20': - optional: true + '@babel/code-frame': 7.27.1 + '@babel/core': 7.29.0 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@tanstack/router-utils': 1.141.0 + babel-dead-code-elimination: 1.0.12 + pathe: 2.0.3 + tiny-invariant: 1.3.3 + vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - supports-color - '@turbo/windows-64@2.8.20': - optional: true + '@tanstack/history@1.131.2': {} - '@turbo/windows-arm64@2.8.20': - optional: true + '@tanstack/history@1.161.6': {} - '@tybys/wasm-util@0.10.1': + '@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - tslib: 2.8.1 - optional: true + '@tanstack/history': 1.161.6 + '@tanstack/react-store': 0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-core': 1.168.9 + isbot: 5.1.37 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - '@types/acorn@4.0.6': + '@tanstack/react-start-client@1.166.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@types/estree': 1.0.8 - - '@types/aria-query@5.0.4': {} + '@tanstack/react-router': 1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-core': 1.168.9 + '@tanstack/start-client-core': 1.167.9 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - '@types/babel__core@7.20.5': + '@tanstack/react-start-plugin@1.131.50(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@vercel/blob@2.3.1)(@vitejs/plugin-react@4.5.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12))': dependencies: - '@babel/parser': 7.29.2 + '@tanstack/start-plugin-core': 1.131.50(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12)) + '@vitejs/plugin-react': 4.5.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + pathe: 2.0.3 + vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + zod: 3.25.76 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@rsbuild/core' + - '@tanstack/react-router' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bare-abort-controller + - bare-buffer + - better-sqlite3 + - drizzle-orm + - encoding + - idb-keyval + - mysql2 + - react-native-b4a + - rolldown + - sqlite3 + - supports-color + - uploadthing + - vite-plugin-solid + - webpack + - xml2js + + '@tanstack/react-start-router-manifest@1.120.19(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)': + dependencies: + '@tanstack/router-core': 1.168.9 + tiny-invariant: 1.3.3 + vinxi: 0.5.3(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bare-abort-controller + - bare-buffer + - better-sqlite3 + - db0 + - debug + - drizzle-orm + - encoding + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - mysql2 + - react-native-b4a + - rolldown + - sass + - sass-embedded + - sqlite3 + - stylus + - sugarss + - supports-color + - terser + - tsx + - uploadthing + - xml2js + - yaml + + '@tanstack/react-start-server@1.166.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/history': 1.161.6 + '@tanstack/react-router': 1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-core': 1.168.9 + '@tanstack/start-client-core': 1.167.9 + '@tanstack/start-server-core': 1.167.9 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - crossws + + '@tanstack/react-store@0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) + + '@tanstack/router-core@1.131.50': + dependencies: + '@tanstack/history': 1.131.2 + '@tanstack/store': 0.7.7 + cookie-es: 1.2.2 + seroval: 1.5.1 + seroval-plugins: 1.5.1(seroval@1.5.1) + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + '@tanstack/router-core@1.168.9': + dependencies: + '@tanstack/history': 1.161.6 + cookie-es: 2.0.0 + seroval: 1.5.1 + seroval-plugins: 1.5.1(seroval@1.5.1) + + '@tanstack/router-generator@1.131.50': + dependencies: + '@tanstack/router-core': 1.131.50 + '@tanstack/router-utils': 1.131.2 + '@tanstack/virtual-file-routes': 1.131.2 + prettier: 3.5.3 + recast: 0.23.11 + source-map: 0.7.6 + tsx: 4.21.0 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@tanstack/router-generator@1.166.24': + dependencies: + '@tanstack/router-core': 1.168.9 + '@tanstack/router-utils': 1.161.6 + '@tanstack/virtual-file-routes': 1.161.7 + prettier: 3.5.3 + recast: 0.23.11 + source-map: 0.7.6 + tsx: 4.21.0 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@tanstack/router-plugin@1.131.50(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@tanstack/router-core': 1.131.50 + '@tanstack/router-generator': 1.131.50 + '@tanstack/router-utils': 1.131.2 + '@tanstack/virtual-file-routes': 1.131.2 + babel-dead-code-elimination: 1.0.12 + chokidar: 3.6.0 + unplugin: 2.3.11 + zod: 3.25.76 + optionalDependencies: + '@tanstack/react-router': 1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + webpack: 5.105.4(@swc/core@1.15.3)(esbuild@0.25.12) + transitivePeerDependencies: + - supports-color + + '@tanstack/router-plugin@1.167.12(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@tanstack/router-core': 1.168.9 + '@tanstack/router-generator': 1.166.24 + '@tanstack/router-utils': 1.161.6 + '@tanstack/virtual-file-routes': 1.161.7 + chokidar: 3.6.0 + unplugin: 2.3.11 + zod: 3.25.76 + optionalDependencies: + '@tanstack/react-router': 1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + webpack: 5.105.4(@swc/core@1.15.3)(esbuild@0.25.12) + transitivePeerDependencies: + - supports-color + + '@tanstack/router-utils@1.131.2': + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/preset-typescript': 7.27.1(@babel/core@7.29.0) + ansis: 4.2.0 + diff: 8.0.4 + transitivePeerDependencies: + - supports-color + + '@tanstack/router-utils@1.141.0': + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/preset-typescript': 7.27.1(@babel/core@7.29.0) + ansis: 4.2.0 + diff: 8.0.4 + pathe: 2.0.3 + tinyglobby: 0.2.15 + transitivePeerDependencies: + - supports-color + + '@tanstack/router-utils@1.161.6': + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + ansis: 4.2.0 + babel-dead-code-elimination: 1.0.12 + diff: 8.0.4 + pathe: 2.0.3 + tinyglobby: 0.2.15 + transitivePeerDependencies: + - supports-color + + '@tanstack/server-functions-plugin@1.131.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/core': 7.29.0 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@tanstack/directive-functions-plugin': 1.131.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + babel-dead-code-elimination: 1.0.12 + tiny-invariant: 1.3.3 + transitivePeerDependencies: + - supports-color + - vite + + '@tanstack/server-functions-plugin@1.142.1(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/core': 7.29.0 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@tanstack/directive-functions-plugin': 1.142.1(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + babel-dead-code-elimination: 1.0.12 + tiny-invariant: 1.3.3 + transitivePeerDependencies: + - supports-color + - vite + + '@tanstack/start-api-routes@1.120.19(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)': + dependencies: + '@tanstack/router-core': 1.168.9 + '@tanstack/start-server-core': 1.167.9 + vinxi: 0.5.3(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bare-abort-controller + - bare-buffer + - better-sqlite3 + - crossws + - db0 + - debug + - drizzle-orm + - encoding + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - mysql2 + - react-native-b4a + - rolldown + - sass + - sass-embedded + - sqlite3 + - stylus + - sugarss + - supports-color + - terser + - tsx + - uploadthing + - xml2js + - yaml + + '@tanstack/start-client-core@1.131.50': + dependencies: + '@tanstack/router-core': 1.131.50 + '@tanstack/start-storage-context': 1.131.50 + cookie-es: 1.2.2 + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + '@tanstack/start-client-core@1.167.9': + dependencies: + '@tanstack/router-core': 1.168.9 + '@tanstack/start-fn-stubs': 1.161.6 + '@tanstack/start-storage-context': 1.166.23 + seroval: 1.5.1 + + '@tanstack/start-config@1.120.20(e1410331fba1110c79d7bc05e246b742)': + dependencies: + '@tanstack/react-router': 1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-start-plugin': 1.131.50(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@vercel/blob@2.3.1)(@vitejs/plugin-react@4.5.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12)) + '@tanstack/router-generator': 1.166.24 + '@tanstack/router-plugin': 1.167.12(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12)) + '@tanstack/server-functions-plugin': 1.142.1(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@tanstack/start-server-functions-handler': 1.120.19 + '@vitejs/plugin-react': 4.5.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + import-meta-resolve: 4.2.0 + nitropack: 2.13.2(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)) + ofetch: 1.5.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + vinxi: 0.5.3(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + zod: 3.25.76 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@rsbuild/core' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bare-abort-controller + - bare-buffer + - better-sqlite3 + - crossws + - db0 + - debug + - drizzle-orm + - encoding + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - mysql2 + - react-native-b4a + - rolldown + - sass + - sass-embedded + - sqlite3 + - stylus + - sugarss + - supports-color + - terser + - tsx + - uploadthing + - vite-plugin-solid + - webpack + - xml2js + - yaml + + '@tanstack/start-fn-stubs@1.161.6': {} + + '@tanstack/start-plugin-core@1.131.50(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12))': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/core': 7.27.3 + '@babel/types': 7.29.0 + '@tanstack/router-core': 1.131.50 + '@tanstack/router-generator': 1.131.50 + '@tanstack/router-plugin': 1.131.50(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12)) + '@tanstack/router-utils': 1.131.2 + '@tanstack/server-functions-plugin': 1.131.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@tanstack/start-server-core': 1.131.50 + '@types/babel__code-frame': 7.27.0 + '@types/babel__core': 7.20.5 + babel-dead-code-elimination: 1.0.12 + cheerio: 1.2.0 + h3: 1.13.0 + nitropack: 2.13.2(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)) + pathe: 2.0.3 + ufo: 1.6.3 + vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vitefu: 1.1.3(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + xmlbuilder2: 3.1.1 + zod: 3.25.76 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@rsbuild/core' + - '@tanstack/react-router' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bare-abort-controller + - bare-buffer + - better-sqlite3 + - drizzle-orm + - encoding + - idb-keyval + - mysql2 + - react-native-b4a + - rolldown + - sqlite3 + - supports-color + - uploadthing + - vite-plugin-solid + - webpack + - xml2js + + '@tanstack/start-server-core@1.131.50': + dependencies: + '@tanstack/history': 1.131.2 + '@tanstack/router-core': 1.131.50 + '@tanstack/start-client-core': 1.131.50 + '@tanstack/start-storage-context': 1.131.50 + h3: 1.13.0 + isbot: 5.1.37 + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + unctx: 2.5.0 + + '@tanstack/start-server-core@1.167.9': + dependencies: + '@tanstack/history': 1.161.6 + '@tanstack/router-core': 1.168.9 + '@tanstack/start-client-core': 1.167.9 + '@tanstack/start-storage-context': 1.166.23 + h3-v2: h3@2.0.1-rc.16 + seroval: 1.5.1 + transitivePeerDependencies: + - crossws + + '@tanstack/start-server-functions-client@1.131.50(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@tanstack/server-functions-plugin': 1.131.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@tanstack/start-server-functions-fetcher': 1.131.50 + transitivePeerDependencies: + - supports-color + - vite + + '@tanstack/start-server-functions-fetcher@1.131.50': + dependencies: + '@tanstack/router-core': 1.131.50 + '@tanstack/start-client-core': 1.131.50 + + '@tanstack/start-server-functions-handler@1.120.19': + dependencies: + '@tanstack/router-core': 1.168.9 + '@tanstack/start-client-core': 1.167.9 + '@tanstack/start-server-core': 1.167.9 + tiny-invariant: 1.3.3 + transitivePeerDependencies: + - crossws + + '@tanstack/start-server-functions-server@1.131.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@tanstack/server-functions-plugin': 1.131.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + tiny-invariant: 1.3.3 + transitivePeerDependencies: + - supports-color + - vite + + '@tanstack/start-server-functions-ssr@1.120.19(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@tanstack/server-functions-plugin': 1.142.1(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@tanstack/start-client-core': 1.167.9 + '@tanstack/start-server-core': 1.167.9 + '@tanstack/start-server-functions-fetcher': 1.131.50 + tiny-invariant: 1.3.3 + transitivePeerDependencies: + - crossws + - supports-color + - vite + + '@tanstack/start-storage-context@1.131.50': + dependencies: + '@tanstack/router-core': 1.131.50 + + '@tanstack/start-storage-context@1.166.23': + dependencies: + '@tanstack/router-core': 1.168.9 + + '@tanstack/start@1.120.20(e1410331fba1110c79d7bc05e246b742)': + dependencies: + '@tanstack/react-start-client': 1.166.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-start-router-manifest': 1.120.19(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + '@tanstack/react-start-server': 1.166.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/start-api-routes': 1.120.19(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + '@tanstack/start-config': 1.120.20(e1410331fba1110c79d7bc05e246b742) + '@tanstack/start-server-functions-client': 1.131.50(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@tanstack/start-server-functions-handler': 1.120.19 + '@tanstack/start-server-functions-server': 1.131.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@tanstack/start-server-functions-ssr': 1.120.19(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@rsbuild/core' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bare-abort-controller + - bare-buffer + - better-sqlite3 + - crossws + - db0 + - debug + - drizzle-orm + - encoding + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - mysql2 + - react + - react-dom + - react-native-b4a + - rolldown + - sass + - sass-embedded + - sqlite3 + - stylus + - sugarss + - supports-color + - terser + - tsx + - uploadthing + - vite + - vite-plugin-solid + - webpack + - xml2js + - yaml + + '@tanstack/store@0.7.7': {} + + '@tanstack/store@0.9.3': {} + + '@tanstack/virtual-file-routes@1.131.2': {} + + '@tanstack/virtual-file-routes@1.161.7': {} + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@tokenizer/inflate@0.2.7': + dependencies: + debug: 4.4.3 + fflate: 0.8.2 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + + '@tootallnate/once@2.0.0': {} + + '@ts-morph/common@0.22.0': + dependencies: + fast-glob: 3.3.3 + minimatch: 9.0.9 + mkdirp: 3.0.1 + path-browserify: 1.0.1 + + '@tsconfig/node18@1.0.3': {} + + '@turbo/darwin-64@2.8.20': + optional: true + + '@turbo/darwin-arm64@2.8.20': + optional: true + + '@turbo/linux-64@2.8.20': + optional: true + + '@turbo/linux-arm64@2.8.20': + optional: true + + '@turbo/windows-64@2.8.20': + optional: true + + '@turbo/windows-arm64@2.8.20': + optional: true + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/acorn@4.0.6': + dependencies: + '@types/estree': 1.0.8 + + '@types/aria-query@5.0.4': {} + + '@types/babel__code-frame@7.27.0': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.2 '@babel/types': 7.29.0 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 @@ -21131,6 +23438,8 @@ snapshots: dependencies: '@types/node': 22.19.9 + '@types/braces@3.0.5': {} + '@types/busboy@1.5.4': dependencies: '@types/node': 22.19.9 @@ -21251,6 +23560,10 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/micromatch@4.0.10': + dependencies: + '@types/braces': 3.0.5 + '@types/minimatch@6.0.0': dependencies: minimatch: 10.2.4 @@ -21365,6 +23678,8 @@ snapshots: '@types/tough-cookie': 4.0.5 form-data: 2.5.5 + '@types/resolve@1.20.2': {} + '@types/semver@7.7.1': {} '@types/shelljs@0.8.15': @@ -21658,6 +23973,25 @@ snapshots: '@vercel/git-hooks@1.0.0': {} + '@vercel/nft@1.5.0(rollup@4.59.0)': + dependencies: + '@mapbox/node-pre-gyp': 2.0.3 + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) + async-sema: 3.1.1 + bindings: 1.5.0 + estree-walker: 2.0.2 + glob: 13.0.6 + graceful-fs: 4.2.11 + node-gyp-build: 4.8.4 + picomatch: 4.0.4 + resolve-from: 5.0.0 + transitivePeerDependencies: + - encoding + - rollup + - supports-color + '@vercel/oidc@3.1.0': {} '@vercel/postgres@0.9.0': @@ -21667,6 +24001,26 @@ snapshots: utf-8-validate: 6.0.6 ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@vinxi/listhen@1.5.6': + dependencies: + '@parcel/watcher': 2.5.6 + '@parcel/watcher-wasm': 2.3.0 + citty: 0.1.6 + clipboardy: 4.0.0 + consola: 3.4.2 + defu: 6.1.4 + get-port-please: 3.2.0 + h3: 1.15.3 + http-shutdown: 1.2.2 + jiti: 1.21.7 + mlly: 1.8.2 + node-forge: 1.4.0 + pathe: 1.1.2 + std-env: 3.10.0 + ufo: 1.6.3 + untun: 0.1.3 + uqr: 0.1.2 + '@vitejs/plugin-react@4.5.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 @@ -22016,6 +24370,8 @@ snapshots: abbrev@1.1.1: {} + abbrev@3.0.1: {} + abbrev@4.0.0: {} abort-controller@3.0.0: @@ -22107,6 +24463,10 @@ snapshots: transitivePeerDependencies: - encoding + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + ansi-colors@4.1.3: {} ansi-escapes@4.3.2: @@ -22133,6 +24493,8 @@ snapshots: ansi-styles@6.2.3: {} + ansis@4.2.0: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -22166,6 +24528,10 @@ snapshots: arg@5.0.2: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} aria-hidden@1.2.6: @@ -22259,6 +24625,10 @@ snapshots: ast-types-flow@0.0.8: {} + ast-types@0.16.1: + dependencies: + tslib: 2.8.1 + astral-regex@2.0.0: {} async-function@1.0.0: {} @@ -22271,6 +24641,8 @@ snapshots: dependencies: retry: 0.13.1 + async-sema@3.1.1: {} + async@3.2.6: {} asynckit@0.4.0: {} @@ -22305,6 +24677,15 @@ snapshots: b4a@1.8.0: {} + babel-dead-code-elimination@1.0.12: + dependencies: + '@babel/core': 7.27.3 + '@babel/parser': 7.29.2 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + babel-plugin-macros@3.1.0: dependencies: '@babel/runtime': 7.29.2 @@ -22440,8 +24821,32 @@ snapshots: body-scroll-lock@4.0.0-beta.0: {} + boolbase@1.0.0: {} + bowser@2.14.1: {} + boxen@7.1.1: + dependencies: + ansi-align: 3.0.1 + camelcase: 7.0.1 + chalk: 5.6.2 + cli-boxes: 3.0.0 + string-width: 5.1.2 + type-fest: 2.19.0 + widest-line: 4.0.1 + wrap-ansi: 8.1.0 + + boxen@8.0.1: + dependencies: + ansi-align: 3.0.1 + camelcase: 8.0.0 + chalk: 5.6.2 + cli-boxes: 3.0.0 + string-width: 7.2.0 + type-fest: 4.41.0 + widest-line: 5.0.0 + wrap-ansi: 9.0.2 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -22518,7 +24923,7 @@ snapshots: bytes@3.1.2: {} - c12@3.3.3: + c12@3.3.3(magicast@0.5.2): dependencies: chokidar: 5.0.0 confbox: 0.2.4 @@ -22532,6 +24937,8 @@ snapshots: perfect-debounce: 2.1.0 pkg-types: 2.3.0 rc9: 2.1.2 + optionalDependencies: + magicast: 0.5.2 cacache@20.0.4: dependencies: @@ -22587,6 +24994,10 @@ snapshots: camelcase@6.3.0: {} + camelcase@7.0.1: {} + + camelcase@8.0.0: {} + caniuse-lite@1.0.30001780: {} ccount@2.0.1: {} @@ -22613,9 +25024,9 @@ snapshots: chalk@5.6.2: {} - changelogen@0.6.2: + changelogen@0.6.2(magicast@0.5.2): dependencies: - c12: 3.3.3 + c12: 3.3.3(magicast@0.5.2) confbox: 0.2.4 consola: 3.4.2 convert-gitmoji: 0.1.5 @@ -22641,6 +25052,29 @@ snapshots: charenc@0.0.2: {} + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.2.2 + css-what: 6.2.2 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + + cheerio@1.2.0: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + encoding-sniffer: 0.2.1 + htmlparser2: 10.1.0 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 7.24.4 + whatwg-mimetype: 4.0.0 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -22656,7 +25090,6 @@ snapshots: chokidar@4.0.3: dependencies: readdirp: 4.1.2 - optional: true chokidar@5.0.0: dependencies: @@ -22684,6 +25117,8 @@ snapshots: clean-stack@2.2.0: {} + cli-boxes@3.0.0: {} + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -22695,6 +25130,12 @@ snapshots: client-only@0.0.1: {} + clipboardy@4.0.0: + dependencies: + execa: 8.0.1 + is-wsl: 3.1.1 + is64bit: 2.0.0 + cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -22780,6 +25221,8 @@ snapshots: compare-versions@6.1.1: {} + compatx@0.2.0: {} + compress-commons@6.0.2: dependencies: crc-32: 1.2.2 @@ -22804,6 +25247,8 @@ snapshots: semver: 7.7.4 uint8array-extras: 1.5.0 + confbox@0.1.8: {} + confbox@0.2.4: {} consola@3.4.2: {} @@ -22826,6 +25271,12 @@ snapshots: convert-source-map@2.0.0: {} + cookie-es@1.2.2: {} + + cookie-es@2.0.0: {} + + cookie-es@3.1.1: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -22877,6 +25328,8 @@ snapshots: crc-32: 1.2.2 readable-stream: 4.7.0 + croner@10.0.1: {} + croner@9.1.0: {} cross-env@7.0.3: @@ -22889,17 +25342,31 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + crypt@0.0.2: {} crypto-random-string@2.0.0: {} css-functions-list@3.3.3: {} + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 source-map-js: 1.2.1 + css-what@6.2.2: {} + cssesc@3.0.0: {} cssfilter@0.0.10: {} @@ -22994,12 +25461,32 @@ snapshots: dateformat@4.6.3: {} + dax-sh@0.39.2: + dependencies: + '@deno/shim-deno': 0.19.2 + undici-types: 5.26.5 + + dax-sh@0.43.2: + dependencies: + '@deno/shim-deno': 0.19.2 + undici-types: 5.26.5 + + db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)): + optionalDependencies: + '@libsql/client': 0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + better-sqlite3: 11.10.0 + drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3) + debounce-fn@6.0.0: dependencies: mimic-function: 5.0.1 debounce@1.2.1: {} + debug@2.6.9: + dependencies: + ms: 2.0.0 + debug@3.2.7: dependencies: ms: 2.1.3 @@ -23080,6 +25567,8 @@ snapshots: destr@2.0.5: {} + destroy@1.2.0: {} + detect-file@1.0.0: {} detect-indent@7.0.2: {} @@ -23101,6 +25590,8 @@ snapshots: asap: 2.0.6 wrappy: 1.0.2 + diff@8.0.4: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -23122,10 +25613,32 @@ snapshots: '@babel/runtime': 7.29.2 csstype: 3.1.3 + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + dompurify@3.2.7: optionalDependencies: '@types/trusted-types': 2.0.7 + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dot-prop@10.1.0: + dependencies: + type-fest: 5.5.0 + dot-prop@9.0.0: dependencies: type-fest: 4.41.0 @@ -23227,6 +25740,11 @@ snapshots: encodeurl@2.0.0: {} + encoding-sniffer@0.2.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -23241,6 +25759,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@4.5.0: {} + entities@6.0.1: {} entities@7.0.1: {} @@ -23405,6 +25925,32 @@ snapshots: '@esbuild/win32-ia32': 0.18.20 '@esbuild/win32-x64': 0.18.20 + esbuild@0.20.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.20.2 + '@esbuild/android-arm': 0.20.2 + '@esbuild/android-arm64': 0.20.2 + '@esbuild/android-x64': 0.20.2 + '@esbuild/darwin-arm64': 0.20.2 + '@esbuild/darwin-x64': 0.20.2 + '@esbuild/freebsd-arm64': 0.20.2 + '@esbuild/freebsd-x64': 0.20.2 + '@esbuild/linux-arm': 0.20.2 + '@esbuild/linux-arm64': 0.20.2 + '@esbuild/linux-ia32': 0.20.2 + '@esbuild/linux-loong64': 0.20.2 + '@esbuild/linux-mips64el': 0.20.2 + '@esbuild/linux-ppc64': 0.20.2 + '@esbuild/linux-riscv64': 0.20.2 + '@esbuild/linux-s390x': 0.20.2 + '@esbuild/linux-x64': 0.20.2 + '@esbuild/netbsd-x64': 0.20.2 + '@esbuild/openbsd-x64': 0.20.2 + '@esbuild/sunos-x64': 0.20.2 + '@esbuild/win32-arm64': 0.20.2 + '@esbuild/win32-ia32': 0.20.2 + '@esbuild/win32-x64': 0.20.2 + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -23520,6 +26066,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.1 '@esbuild/win32-x64': 0.27.1 + esbuild@0.27.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -24153,6 +26728,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter3@4.0.7: {} + eventemitter3@5.0.4: {} events-universal@1.0.1: @@ -24493,6 +27070,8 @@ snapshots: fraction.js@5.3.4: {} + fresh@0.5.2: {} + fresh@2.0.0: {} fs-constants@1.0.0: {} @@ -24557,10 +27136,6 @@ snapshots: - encoding - supports-color - geist@1.7.0(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0)): - dependencies: - next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) - geist@1.7.0(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0)): dependencies: next: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) @@ -24590,6 +27165,8 @@ snapshots: get-nonce@1.0.1: {} + get-port-please@3.2.0: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -24733,6 +27310,15 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + globby@16.2.0: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + fast-glob: 3.3.3 + ignore: 7.0.5 + is-path-inside: 4.0.0 + slash: 5.1.0 + unicorn-magic: 0.4.0 + globjoin@0.1.4: {} globrex@0.1.2: {} @@ -24798,6 +27384,52 @@ snapshots: dependencies: duplexer: 0.1.2 + gzip-size@7.0.0: + dependencies: + duplexer: 0.1.2 + + h3@1.13.0: + dependencies: + cookie-es: 1.2.2 + crossws: 0.3.5 + defu: 6.1.4 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + ohash: 1.1.6 + radix3: 1.1.2 + ufo: 1.6.3 + uncrypto: 0.1.3 + unenv: 1.10.0 + + h3@1.15.10: + dependencies: + cookie-es: 1.2.2 + crossws: 0.3.5 + defu: 6.1.4 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.4 + radix3: 1.1.2 + ufo: 1.6.3 + uncrypto: 0.1.3 + + h3@1.15.3: + dependencies: + cookie-es: 1.2.2 + crossws: 0.3.5 + defu: 6.1.4 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.4 + radix3: 1.1.2 + ufo: 1.6.3 + uncrypto: 0.1.3 + + h3@2.0.1-rc.16: + dependencies: + rou3: 0.8.1 + srvx: 0.11.13 + happy-dom@20.8.9(bufferutil@4.1.0)(utf-8-validate@6.0.6): dependencies: '@types/node': 22.19.9 @@ -24858,6 +27490,8 @@ snapshots: hono@4.12.8: {} + hookable@5.5.3: {} + hookified@1.15.1: {} hookified@2.1.0: {} @@ -24876,6 +27510,13 @@ snapshots: html-tags@3.3.1: {} + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + http-cache-semantics@4.2.0: {} http-errors@2.0.1: @@ -24901,6 +27542,16 @@ snapshots: transitivePeerDependencies: - supports-color + http-proxy@1.18.1: + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.15.11(debug@4.4.3) + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + + http-shutdown@1.2.2: {} + http-status@2.1.0: {} http2-wrapper@2.2.1: @@ -24922,6 +27573,8 @@ snapshots: transitivePeerDependencies: - supports-color + httpxy@0.3.1: {} + human-signals@2.1.0: {} human-signals@3.0.1: {} @@ -24934,6 +27587,10 @@ snapshots: husky@9.0.11: {} + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -24970,6 +27627,8 @@ snapshots: cjs-module-lexer: 1.4.3 module-details-from-path: 1.0.4 + import-meta-resolve@4.2.0: {} + imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -25017,6 +27676,8 @@ snapshots: ipaddr.js@2.2.0: {} + iron-webcrypto@1.2.1: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -25126,12 +27787,16 @@ snapshots: transitivePeerDependencies: - supports-color + is-in-ssh@1.0.0: {} + is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 is-map@2.0.3: {} + is-module@1.0.0: {} + is-negative-zero@2.0.3: {} is-node-process@1.2.0: {} @@ -25147,6 +27812,8 @@ snapshots: is-path-inside@3.0.3: {} + is-path-inside@4.0.0: {} + is-plain-obj@1.1.0: {} is-plain-obj@4.1.0: {} @@ -25212,12 +27879,18 @@ snapshots: dependencies: is-inside-container: 1.0.0 + is64bit@2.0.0: + dependencies: + system-architecture: 0.1.0 + isarray@0.0.1: {} isarray@1.0.0: {} isarray@2.0.5: {} + isbot@5.1.37: {} + isexe@2.0.0: {} isexe@3.1.5: {} @@ -25258,6 +27931,8 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 + jiti@1.21.7: {} + jiti@2.6.1: {} jose@5.9.6: {} @@ -25274,6 +27949,13 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + + js-yaml@3.14.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -25421,6 +28103,10 @@ snapshots: kleur@4.1.5: {} + klona@2.0.6: {} + + knitwork@1.3.0: {} + known-css-properties@0.37.0: {} language-subtag-registry@0.3.23: {} @@ -25544,6 +28230,27 @@ snapshots: transitivePeerDependencies: - supports-color + listhen@1.9.0: + dependencies: + '@parcel/watcher': 2.5.6 + '@parcel/watcher-wasm': 2.5.6 + citty: 0.1.6 + clipboardy: 4.0.0 + consola: 3.4.2 + crossws: 0.3.5 + defu: 6.1.4 + get-port-please: 3.2.0 + h3: 1.15.10 + http-shutdown: 1.2.2 + jiti: 2.6.1 + mlly: 1.8.2 + node-forge: 1.4.0 + pathe: 1.1.2 + std-env: 3.10.0 + ufo: 1.6.3 + untun: 0.1.3 + uqr: 0.1.2 + listr2@8.2.5: dependencies: cli-truncate: 4.0.0 @@ -25555,6 +28262,12 @@ snapshots: loader-runner@4.3.1: {} + local-pkg@1.1.2: + dependencies: + mlly: 1.8.2 + pkg-types: 2.3.0 + quansync: 0.2.11 + localforage@1.10.0: dependencies: lie: 3.1.1 @@ -25635,6 +28348,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + make-dir@2.1.0: dependencies: pify: 4.0.1 @@ -25941,8 +28660,12 @@ snapshots: dependencies: mime-db: 1.54.0 + mime@1.6.0: {} + mime@3.0.0: {} + mime@4.1.0: {} + mimic-fn@2.1.0: {} mimic-fn@4.0.0: {} @@ -26033,6 +28756,13 @@ snapshots: mkdirp@3.0.1: {} + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + mnemonist@0.38.3: dependencies: obliterator: 1.6.1 @@ -26135,6 +28865,8 @@ snapshots: mrmime@2.0.1: {} + ms@2.0.0: {} + ms@2.1.3: {} multipasta@0.2.7: {} @@ -26159,13 +28891,13 @@ snapshots: transitivePeerDependencies: - supports-color - next-sitemap@4.2.3(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0)): + next-sitemap@4.2.3(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0)): dependencies: '@corex/deepmerge': 4.0.43 '@next/env': 13.5.11 fast-glob: 3.3.3 minimist: 1.2.8 - next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + next: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: @@ -26264,7 +28996,7 @@ snapshots: postcss: 8.4.31 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(react@19.2.4) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) optionalDependencies: '@next/swc-darwin-arm64': 16.2.1 '@next/swc-darwin-x64': 16.2.1 @@ -26292,7 +29024,7 @@ snapshots: postcss: 8.4.31 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(react@19.2.4) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) optionalDependencies: '@next/swc-darwin-arm64': 16.2.1 '@next/swc-darwin-x64': 16.2.1 @@ -26310,14 +29042,116 @@ snapshots: - '@babel/core' - babel-plugin-macros + nitropack@2.13.2(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)): + dependencies: + '@cloudflare/kv-asset-handler': 0.4.2 + '@rollup/plugin-alias': 6.0.0(rollup@4.59.0) + '@rollup/plugin-commonjs': 29.0.2(rollup@4.59.0) + '@rollup/plugin-inject': 5.0.5(rollup@4.59.0) + '@rollup/plugin-json': 6.1.0(rollup@4.59.0) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.59.0) + '@rollup/plugin-replace': 6.0.3(rollup@4.59.0) + '@rollup/plugin-terser': 1.0.0(rollup@4.59.0) + '@vercel/nft': 1.5.0(rollup@4.59.0) + archiver: 7.0.1 + c12: 3.3.3(magicast@0.5.2) + chokidar: 5.0.0 + citty: 0.2.1 + compatx: 0.2.0 + confbox: 0.2.4 + consola: 3.4.2 + cookie-es: 2.0.0 + croner: 10.0.1 + crossws: 0.3.5 + db0: 0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)) + defu: 6.1.4 + destr: 2.0.5 + dot-prop: 10.1.0 + esbuild: 0.27.4 + escape-string-regexp: 5.0.0 + etag: 1.8.1 + exsolve: 1.0.8 + globby: 16.2.0 + gzip-size: 7.0.0 + h3: 1.15.10 + hookable: 5.5.3 + httpxy: 0.3.1 + ioredis: 5.10.1 + jiti: 2.6.1 + klona: 2.0.6 + knitwork: 1.3.0 + listhen: 1.9.0 + magic-string: 0.30.21 + magicast: 0.5.2 + mime: 4.1.0 + mlly: 1.8.2 + node-fetch-native: 1.6.7 + node-mock-http: 1.0.4 + ofetch: 1.5.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.0 + pretty-bytes: 7.1.0 + radix3: 1.1.2 + rollup: 4.59.0 + rollup-plugin-visualizer: 7.0.1(rollup@4.59.0) + scule: 1.3.0 + semver: 7.7.4 + serve-placeholder: 2.0.2 + serve-static: 2.2.1 + source-map: 0.7.6 + std-env: 4.0.0 + ufo: 1.6.3 + ultrahtml: 1.6.0 + uncrypto: 0.1.3 + unctx: 2.5.0 + unenv: 2.0.0-rc.24 + unimport: 6.0.2 + unplugin-utils: 0.3.1 + unstorage: 1.17.5(@azure/storage-blob@12.31.0)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(ioredis@5.10.1) + untyped: 2.0.0 + unwasm: 0.5.3 + youch: 4.1.1 + youch-core: 0.3.3 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bare-abort-controller + - bare-buffer + - better-sqlite3 + - drizzle-orm + - encoding + - idb-keyval + - mysql2 + - react-native-b4a + - rolldown + - sqlite3 + - supports-color + - uploadthing + node-abi@3.89.0: dependencies: semver: 7.7.4 node-addon-api@6.1.0: {} - node-addon-api@7.1.1: - optional: true + node-addon-api@7.1.1: {} node-domexception@1.0.0: {} @@ -26340,6 +29174,8 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-forge@1.4.0: {} + node-gyp-build@4.8.4: {} node-gyp@12.2.0: @@ -26357,6 +29193,8 @@ snapshots: transitivePeerDependencies: - supports-color + node-mock-http@1.0.4: {} + node-releases@2.0.36: {} nodemailer@7.0.12: {} @@ -26371,6 +29209,10 @@ snapshots: abbrev: 1.1.1 osenv: 0.1.5 + nopt@8.1.0: + dependencies: + abbrev: 3.0.1 + nopt@9.0.0: dependencies: abbrev: 4.0.0 @@ -26403,6 +29245,10 @@ snapshots: semver: 7.7.4 validate-npm-package-name: 4.0.0 + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + nypm@0.6.5: dependencies: citty: 0.2.1 @@ -26467,6 +29313,8 @@ snapshots: node-fetch-native: 1.6.7 ufo: 1.6.3 + ohash@1.1.6: {} + ohash@2.0.11: {} on-exit-leak-free@2.1.2: {} @@ -26498,6 +29346,15 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + openapi-fetch@0.15.0: dependencies: openapi-typescript-helpers: 0.0.15 @@ -26610,6 +29467,19 @@ snapshots: parse-passwd@1.0.0: {} + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.3.0 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.3.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parse5@8.0.0: dependencies: entities: 6.0.1 @@ -26646,6 +29516,8 @@ snapshots: path-type@4.0.0: {} + pathe@1.1.2: {} + pathe@2.0.3: {} peek-readable@5.4.2: {} @@ -26759,6 +29631,12 @@ snapshots: dependencies: find-up: 4.1.0 + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + pkg-types@2.3.0: dependencies: confbox: 0.2.4 @@ -26833,6 +29711,8 @@ snapshots: postgres-range@1.1.4: {} + powershell-utils@0.1.0: {} + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -26856,6 +29736,8 @@ snapshots: prettier@3.5.3: {} + pretty-bytes@7.1.0: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -26919,12 +29801,16 @@ snapshots: dependencies: side-channel: 1.1.0 + quansync@0.2.11: {} + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} quick-lru@5.1.1: {} + radix3@1.1.2: {} + range-parser@1.2.1: {} raw-body@3.0.2: @@ -27110,13 +29996,20 @@ snapshots: dependencies: picomatch: 2.3.2 - readdirp@4.1.2: - optional: true + readdirp@4.1.2: {} readdirp@5.0.0: {} real-require@0.2.0: {} + recast@0.23.11: + dependencies: + ast-types: 0.16.1 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 + recharts@3.2.1(@types/react@19.2.9)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1): dependencies: '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.9)(react@19.2.4)(redux@5.0.1))(react@19.2.4) @@ -27224,6 +30117,8 @@ snapshots: transitivePeerDependencies: - supports-color + requires-port@1.0.0: {} + reselect@5.1.1: {} resolve-alpn@1.2.1: {} @@ -27301,6 +30196,15 @@ snapshots: optionalDependencies: '@babel/code-frame': 7.29.0 + rollup-plugin-visualizer@7.0.1(rollup@4.59.0): + dependencies: + open: 11.0.0 + picomatch: 4.0.4 + source-map: 0.7.6 + yargs: 18.0.0 + optionalDependencies: + rollup: 4.59.0 + rollup@3.29.5: optionalDependencies: fsevents: 2.3.3 @@ -27336,6 +30240,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 + rou3@0.8.1: {} + router@2.2.0: dependencies: debug: 4.4.3 @@ -27536,6 +30442,24 @@ snapshots: semver@7.7.4: {} + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + send@1.2.1: dependencies: debug: 4.4.3 @@ -27552,6 +30476,27 @@ snapshots: transitivePeerDependencies: - supports-color + serialize-javascript@7.0.5: {} + + seroval-plugins@1.5.1(seroval@1.5.1): + dependencies: + seroval: 1.5.1 + + seroval@1.5.1: {} + + serve-placeholder@2.0.2: + dependencies: + defu: 6.1.4 + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -27745,6 +30690,8 @@ snapshots: slash@3.0.0: {} + slash@5.1.0: {} + slate-history@0.86.0(slate@0.91.4): dependencies: is-plain-object: 5.0.0 @@ -27796,6 +30743,8 @@ snapshots: smart-buffer@4.2.0: {} + smob@1.6.1: {} + socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 @@ -27886,8 +30835,12 @@ snapshots: split2@4.2.0: {} + sprintf-js@1.0.3: {} + sqids@0.3.0: {} + srvx@0.11.13: {} + ssri@13.0.1: dependencies: minipass: 7.1.3 @@ -27910,6 +30863,8 @@ snapshots: std-env@3.10.0: {} + std-env@4.0.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -28049,6 +31004,10 @@ snapshots: strip-json-comments@5.0.3: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + stripe@10.17.0: dependencies: '@types/node': 22.19.9 @@ -28101,11 +31060,6 @@ snapshots: optionalDependencies: '@babel/core': 7.29.0 - styled-jsx@5.1.6(react@19.2.4): - dependencies: - client-only: 0.0.1 - react: 19.2.4 - stylelint@16.26.1(typescript@5.7.3): dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) @@ -28196,6 +31150,8 @@ snapshots: sync-message-port@1.2.0: {} + system-architecture@0.1.0: {} + tabbable@6.4.0: {} table@6.9.0: @@ -28206,6 +31162,8 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + tagged-tag@1.0.0: {} + tailwind-merge@3.5.0: {} tailwindcss@4.1.18: {} @@ -28484,8 +31442,14 @@ snapshots: type-fest@0.7.1: {} + type-fest@2.19.0: {} + type-fest@4.41.0: {} + type-fest@5.5.0: + dependencies: + tagged-tag: 1.0.0 + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -28552,6 +31516,8 @@ snapshots: uint8array-extras@1.5.0: {} + ultrahtml@1.6.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -28564,6 +31530,15 @@ snapshots: buffer: 5.7.1 through: 2.3.8 + uncrypto@0.1.3: {} + + unctx@2.5.0: + dependencies: + acorn: 8.16.0 + estree-walker: 3.0.3 + magic-string: 0.30.21 + unplugin: 2.3.11 + undici-types@5.26.5: {} undici-types@6.21.0: {} @@ -28578,6 +31553,14 @@ snapshots: undici@7.24.4: {} + unenv@1.10.0: + dependencies: + consola: 3.4.2 + defu: 6.1.4 + mime: 3.0.0 + node-fetch-native: 1.6.7 + pathe: 1.1.2 + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -28595,6 +31578,25 @@ snapshots: unicode-property-aliases-ecmascript@2.2.0: {} + unicorn-magic@0.4.0: {} + + unimport@6.0.2: + dependencies: + acorn: 8.16.0 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + local-pkg: 1.1.2 + magic-string: 0.30.21 + mlly: 1.8.2 + pathe: 2.0.3 + picomatch: 4.0.4 + pkg-types: 2.3.0 + scule: 1.3.0 + strip-literal: 3.1.0 + tinyglobby: 0.2.15 + unplugin: 3.0.0 + unplugin-utils: 0.3.1 + unique-string@2.0.0: dependencies: crypto-random-string: 2.0.0 @@ -28626,6 +31628,11 @@ snapshots: unpipe@1.0.0: {} + unplugin-utils@0.3.1: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.4 + unplugin@1.0.1: dependencies: acorn: 8.16.0 @@ -28633,6 +31640,19 @@ snapshots: webpack-sources: 3.3.4 webpack-virtual-modules: 0.5.0 + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.16.0 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + + unplugin@3.0.0: + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.4 @@ -28657,15 +31677,55 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + unstorage@1.17.5(@azure/storage-blob@12.31.0)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(ioredis@5.10.1): + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.10 + lru-cache: 11.2.7 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.3 + optionalDependencies: + '@azure/storage-blob': 12.31.0 + '@vercel/blob': 2.3.1 + aws4fetch: 1.0.20 + db0: 0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)) + ioredis: 5.10.1 + untildify@4.0.0: {} + untun@0.1.3: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + pathe: 1.1.2 + + untyped@2.0.0: + dependencies: + citty: 0.1.6 + defu: 6.1.4 + jiti: 2.6.1 + knitwork: 1.3.0 + scule: 1.3.0 + + unwasm@0.5.3: + dependencies: + exsolve: 1.0.8 + knitwork: 1.3.0 + magic-string: 0.30.21 + mlly: 1.8.2 + pathe: 2.0.3 + pkg-types: 2.3.0 + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 escalade: 3.2.0 picocolors: 1.1.1 - uploadthing@7.3.0(express@5.2.1)(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(tailwindcss@4.2.2): + uploadthing@7.3.0(express@5.2.1)(h3@1.15.10)(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(tailwindcss@4.2.2): dependencies: '@effect/platform': 0.69.8(effect@3.10.3) '@uploadthing/mime-types': 0.3.2 @@ -28673,9 +31733,12 @@ snapshots: effect: 3.10.3 optionalDependencies: express: 5.2.1 + h3: 1.15.10 next: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) tailwindcss: 4.2.2 + uqr@0.1.2: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -28767,6 +31830,170 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + vinxi@0.5.11(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + '@babel/core': 7.27.3 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.27.3) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.27.3) + '@types/micromatch': 4.0.10 + '@vinxi/listhen': 1.5.6 + boxen: 8.0.1 + chokidar: 4.0.3 + citty: 0.1.6 + consola: 3.4.2 + crossws: 0.3.5 + dax-sh: 0.43.2 + defu: 6.1.4 + es-module-lexer: 1.7.0 + esbuild: 0.25.12 + get-port-please: 3.2.0 + h3: 1.15.3 + hookable: 5.5.3 + http-proxy: 1.18.1 + micromatch: 4.0.8 + nitropack: 2.13.2(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)) + node-fetch-native: 1.6.7 + path-to-regexp: 6.3.0 + pathe: 1.1.2 + radix3: 1.1.2 + resolve: 1.22.11 + serve-placeholder: 2.0.2 + serve-static: 1.16.3 + tinyglobby: 0.2.15 + ufo: 1.6.3 + unctx: 2.5.0 + unenv: 1.10.0 + unstorage: 1.17.5(@azure/storage-blob@12.31.0)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(ioredis@5.10.1) + vite: 6.4.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + zod: 4.3.6 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bare-abort-controller + - bare-buffer + - better-sqlite3 + - db0 + - debug + - drizzle-orm + - encoding + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - mysql2 + - react-native-b4a + - rolldown + - sass + - sass-embedded + - sqlite3 + - stylus + - sugarss + - supports-color + - terser + - tsx + - uploadthing + - xml2js + - yaml + + vinxi@0.5.3(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + '@babel/core': 7.27.3 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.27.3) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.27.3) + '@types/micromatch': 4.0.10 + '@vinxi/listhen': 1.5.6 + boxen: 7.1.1 + chokidar: 3.6.0 + citty: 0.1.6 + consola: 3.4.2 + crossws: 0.3.5 + dax-sh: 0.39.2 + defu: 6.1.4 + es-module-lexer: 1.7.0 + esbuild: 0.20.2 + fast-glob: 3.3.3 + get-port-please: 3.2.0 + h3: 1.13.0 + hookable: 5.5.3 + http-proxy: 1.18.1 + micromatch: 4.0.8 + nitropack: 2.13.2(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)) + node-fetch-native: 1.6.7 + path-to-regexp: 6.3.0 + pathe: 1.1.2 + radix3: 1.1.2 + resolve: 1.22.11 + serve-placeholder: 2.0.2 + serve-static: 1.16.3 + ufo: 1.6.3 + unctx: 2.5.0 + unenv: 1.10.0 + unstorage: 1.17.5(@azure/storage-blob@12.31.0)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(ioredis@5.10.1) + vite: 6.4.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + zod: 3.25.76 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bare-abort-controller + - bare-buffer + - better-sqlite3 + - db0 + - debug + - drizzle-orm + - encoding + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - mysql2 + - react-native-b4a + - rolldown + - sass + - sass-embedded + - sqlite3 + - stylus + - sugarss + - supports-color + - terser + - tsx + - uploadthing + - xml2js + - yaml + vite-tsconfig-paths@6.0.5(typescript@5.7.3)(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: debug: 4.4.3 @@ -28777,6 +32004,25 @@ snapshots: - supports-color - typescript + vite@6.4.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.9 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + sass: 1.98.0 + sass-embedded: 1.98.0 + terser: 5.46.1 + tsx: 4.21.0 + yaml: 2.8.3 + vite@7.3.1(@types/node@22.15.30)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.27.1 @@ -28834,6 +32080,10 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 + vitefu@1.1.3(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + optionalDependencies: + vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vitest@4.0.15(@opentelemetry/api@1.9.0)(@types/node@22.15.30)(@vitest/ui@4.0.15)(happy-dom@20.8.9(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: '@vitest/expect': 4.0.15 @@ -29009,6 +32259,8 @@ snapshots: webpack-virtual-modules@0.5.0: {} + webpack-virtual-modules@0.6.2: {} + webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12): dependencies: '@types/eslint-scope': 3.7.7 @@ -29041,8 +32293,14 @@ snapshots: - esbuild - uglify-js + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@4.0.0: {} + whatwg-mimetype@5.0.0: {} whatwg-url@14.2.0: @@ -29127,6 +32385,14 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + widest-line@4.0.1: + dependencies: + string-width: 5.1.2 + + widest-line@5.0.0: + dependencies: + string-width: 7.2.0 + word-wrap@1.2.5: {} workerd@1.20260128.0: @@ -29198,8 +32464,20 @@ snapshots: dependencies: is-wsl: 3.1.1 + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + xml-name-validator@5.0.0: {} + xmlbuilder2@3.1.1: + dependencies: + '@oozcitak/dom': 1.15.10 + '@oozcitak/infra': 1.0.8 + '@oozcitak/util': 8.3.8 + js-yaml: 3.14.1 + xmlchars@2.2.0: {} xss@1.0.15: @@ -29272,6 +32550,14 @@ snapshots: cookie: 1.1.1 youch-core: 0.3.3 + youch@4.1.1: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.7.0 + '@speed-highlight/core': 1.2.15 + cookie-es: 3.1.1 + youch-core: 0.3.3 + zip-stream@6.0.1: dependencies: archiver-utils: 5.0.2 From 59533d9347bf944120a7684fee6e325a52f4d749 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 16:15:27 +0100 Subject: [PATCH 11/60] test: add AdminAdapter integration tests Test suite covers: - adminAdapter is undefined without explicit configuration (backwards compat) - admin.adapter config field accepts AdminAdapterResult - adapter.init() is called and returns adapter instance - createAdminAdapter helper is an identity function Co-Authored-By: Claude Sonnet 4.6 --- test/admin-adapter/config.ts | 10 +++ test/admin-adapter/int.spec.ts | 155 +++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 test/admin-adapter/config.ts create mode 100644 test/admin-adapter/int.spec.ts diff --git a/test/admin-adapter/config.ts b/test/admin-adapter/config.ts new file mode 100644 index 00000000000..c979405e8a1 --- /dev/null +++ b/test/admin-adapter/config.ts @@ -0,0 +1,10 @@ +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' + +export default buildConfigWithDefaults({ + collections: [ + { + slug: 'posts', + fields: [{ name: 'title', type: 'text' }], + }, + ], +}) diff --git a/test/admin-adapter/int.spec.ts b/test/admin-adapter/int.spec.ts new file mode 100644 index 00000000000..bd2d5bc83f3 --- /dev/null +++ b/test/admin-adapter/int.spec.ts @@ -0,0 +1,155 @@ +import type { Payload } from 'payload' + +import path from 'path' +import { fileURLToPath } from 'url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { initPayloadInt } from '../__helpers/shared/initPayloadInt.js' + +let payload: Payload + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +describe('AdminAdapter', () => { + beforeAll(async () => { + ;({ payload } = await initPayloadInt(dirname)) + }) + + afterAll(async () => { + await payload.destroy() + }) + + describe('lifecycle wiring', () => { + it('should have adminAdapter undefined when no adapter is configured', () => { + // buildConfigWithDefaults does not set admin.adapter, so adminAdapter is undefined + expect(payload.adminAdapter).toBeUndefined() + }) + + it('should have a valid payload instance initialized', () => { + expect(payload).toBeDefined() + expect(payload.collections).toBeDefined() + }) + }) + + describe('config field types', () => { + it('should accept admin.adapter field in buildConfig', async () => { + const { buildConfig, createAdminAdapter } = await import('payload') + + const mockAdapterResult = { + name: 'mock-adapter', + init: ({ payload: p }: { payload: Payload }) => + createAdminAdapter({ + RouterProvider: (() => null) as any, + createRouteHandlers: () => ({}), + deleteCookie: () => {}, + getCookie: () => undefined, + handleServerFunctions: async () => {}, + initReq: () => { + throw new Error('not used') + }, + name: 'mock-adapter', + notFound: (): never => { + throw new Error('not found') + }, + payload: p, + redirect: (): never => { + throw new Error('redirect') + }, + setCookie: () => {}, + }), + } + + const config = await buildConfig({ + admin: { + adapter: mockAdapterResult, + disable: true, + }, + collections: [{ slug: 'test', fields: [] }], + db: (payload as any).config.db, + secret: 'test-secret', + }) + + expect(config.admin?.adapter).toBeDefined() + expect(config.admin?.adapter?.name).toBe('mock-adapter') + expect(typeof config.admin?.adapter?.init).toBe('function') + }) + + it('should call adapter.init and expose result on payload.adminAdapter', async () => { + const { buildConfig, createAdminAdapter } = await import('payload') + + let initCallCount = 0 + + const adapterResult = { + name: 'counting-adapter', + init: ({ payload: p }: { payload: Payload }) => { + initCallCount++ + return createAdminAdapter({ + RouterProvider: (() => null) as any, + createRouteHandlers: () => ({}), + deleteCookie: () => {}, + getCookie: () => undefined, + handleServerFunctions: async () => {}, + initReq: () => { + throw new Error('not used') + }, + name: 'counting-adapter', + notFound: (): never => { + throw new Error('not found') + }, + payload: p, + redirect: (): never => { + throw new Error('redirect') + }, + setCookie: () => {}, + }) + }, + } + + const config = await buildConfig({ + admin: { + adapter: adapterResult, + disable: true, + }, + collections: [{ slug: 'test', fields: [] }], + db: (payload as any).config.db, + secret: 'test-secret', + }) + + // Simulate what Payload.init() does + const adapterInstance = config.admin?.adapter?.init({ payload }) + expect(adapterInstance).toBeDefined() + expect(adapterInstance?.name).toBe('counting-adapter') + expect(initCallCount).toBe(1) + }) + }) + + describe('createAdminAdapter helper', () => { + it('should return the same object it receives (identity helper)', async () => { + const { createAdminAdapter } = await import('payload') + + const adapterShape = { + RouterProvider: (() => null) as any, + createRouteHandlers: () => ({}), + deleteCookie: () => {}, + getCookie: () => undefined, + handleServerFunctions: async () => {}, + initReq: () => { + throw new Error('not used') + }, + name: 'identity-test', + notFound: (): never => { + throw new Error('not found') + }, + payload: null as any, + redirect: (): never => { + throw new Error('redirect') + }, + setCookie: () => {}, + } + + const result = createAdminAdapter(adapterShape) + expect(result).toBe(adapterShape) + }) + }) +}) From a9ceee3e780e3fed73041f45329e312d009535bc Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 16:16:44 +0100 Subject: [PATCH 12/60] test: add FrameworkTestHarness interface for multi-framework e2e support Introduces a framework-agnostic FrameworkTestHarness interface so e2e tests can run against any admin adapter, not just Next.js. - FrameworkTestHarness interface with start()/stop() contract - NextJsTestHarness wrapping existing dev-server behavior - getTestHarness() factory reading PAYLOAD_FRAMEWORK env var - Stub for TanStack Start harness (pending implementation) Select framework via: PAYLOAD_FRAMEWORK=next|tanstack-start CI matrix usage: strategy: matrix: framework: [next] # add tanstack-start once harness is implemented env: PAYLOAD_FRAMEWORK: ${{ matrix.framework }} Co-Authored-By: Claude Sonnet 4.6 --- test/__helpers/e2e/framework-harness/index.ts | 40 +++++++++++++++++++ test/__helpers/e2e/framework-harness/next.ts | 33 +++++++++++++++ test/__helpers/e2e/framework-harness/types.ts | 23 +++++++++++ 3 files changed, 96 insertions(+) create mode 100644 test/__helpers/e2e/framework-harness/index.ts create mode 100644 test/__helpers/e2e/framework-harness/next.ts create mode 100644 test/__helpers/e2e/framework-harness/types.ts diff --git a/test/__helpers/e2e/framework-harness/index.ts b/test/__helpers/e2e/framework-harness/index.ts new file mode 100644 index 00000000000..c1451217a95 --- /dev/null +++ b/test/__helpers/e2e/framework-harness/index.ts @@ -0,0 +1,40 @@ +import type { FrameworkTestHarness } from './types.js' + +import { NextJsTestHarness } from './next.js' + +export type { FrameworkTestHarness, PayloadTestConfig } from './types.js' + +const SUPPORTED_FRAMEWORKS = ['next', 'tanstack-start'] as const +export type SupportedFramework = (typeof SUPPORTED_FRAMEWORKS)[number] + +/** + * Returns the test harness for the current framework. + * + * Framework is selected via PAYLOAD_FRAMEWORK env var (defaults to 'next'). + * + * @example + * ```ts + * // In playwright.config.ts or globalSetup.ts: + * const harness = getTestHarness() + * const { url } = await harness.start({ testDir, suiteName }) + * ``` + */ +export function getTestHarness(): FrameworkTestHarness { + const framework = (process.env.PAYLOAD_FRAMEWORK ?? 'next') as SupportedFramework + + switch (framework) { + case 'next': + return new NextJsTestHarness() + case 'tanstack-start': + throw new Error( + 'TanStack Start test harness is not yet implemented. ' + + 'Implement a TanStackStartTestHarness class following the FrameworkTestHarness interface.', + ) + default: { + const exhaustive: never = framework + throw new Error( + `Unknown PAYLOAD_FRAMEWORK: "${exhaustive}". Valid values: ${SUPPORTED_FRAMEWORKS.join(', ')}`, + ) + } + } +} diff --git a/test/__helpers/e2e/framework-harness/next.ts b/test/__helpers/e2e/framework-harness/next.ts new file mode 100644 index 00000000000..4f73d402400 --- /dev/null +++ b/test/__helpers/e2e/framework-harness/next.ts @@ -0,0 +1,33 @@ +/** + * Next.js framework test harness. + * + * Wraps the existing Next.js dev-server behavior (getNextRootDir + devServer) + * behind the FrameworkTestHarness interface so e2e tests can be written + * framework-agnostically. + */ + +import path from 'path' + +import type { FrameworkTestHarness, PayloadTestConfig } from './types.js' + +import { getNextRootDir } from '../shared/getNextRootDir.js' + +export class NextJsTestHarness implements FrameworkTestHarness { + readonly name = 'next' + + start(config: PayloadTestConfig): Promise<{ adminRoute: string; url: string }> { + const { rootDir, adminRoute } = getNextRootDir(config.suiteName) + + // The Next.js dev server is started by Playwright's globalSetup (not here). + // This method returns the metadata the harness provides to test files. + const port = process.env.PORT ?? '3000' + const url = `http://localhost:${port}` + + return Promise.resolve({ adminRoute, url }) + } + + stop(): Promise { + // Next.js dev server teardown is handled by Playwright's globalTeardown. + return Promise.resolve() + } +} diff --git a/test/__helpers/e2e/framework-harness/types.ts b/test/__helpers/e2e/framework-harness/types.ts new file mode 100644 index 00000000000..c387103e196 --- /dev/null +++ b/test/__helpers/e2e/framework-harness/types.ts @@ -0,0 +1,23 @@ +/** + * Framework-agnostic test harness interface for e2e tests. + * + * Each admin adapter (Next.js, TanStack Start, etc.) provides its own + * harness implementation. Tests select the harness via PAYLOAD_FRAMEWORK + * env var (defaults to 'next'). + */ + +export type PayloadTestConfig = { + /** Test suite name (e.g. 'admin', 'fields') */ + suiteName?: string + /** Absolute path to the test directory containing config.ts */ + testDir: string +} + +export interface FrameworkTestHarness { + /** Framework identifier */ + readonly name: string + /** Start the dev server. Returns the server URL. */ + start(config: PayloadTestConfig): Promise<{ adminRoute: string; url: string }> + /** Stop the dev server */ + stop(): Promise +} From 93b05fceb0bb83e03782466e8fd1cecd256050c2 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 16:47:47 +0100 Subject: [PATCH 13/60] docs: add TanStack Start adapter implementation plan 26-task plan covering: - Moving all framework-agnostic code from packages/next to packages/ui (elements, templates, utilities, view render functions with nav callbacks) - Creating a shared handleServerFunctions dispatcher in packages/ui - Full tanstack-start adapter with vinxi/http, @tanstack/react-router - tanstack-app/ shell (app.config.ts, routes, layouts, entry points) - pnpm dev:tanstack command and test/dev-tanstack.ts entry point Co-Authored-By: Claude Sonnet 4.6 --- .../2026-04-01-tanstack-start-adapter.md | 1516 +++++++++++++++++ 1 file changed, 1516 insertions(+) create mode 100644 docs/plans/2026-04-01-tanstack-start-adapter.md diff --git a/docs/plans/2026-04-01-tanstack-start-adapter.md b/docs/plans/2026-04-01-tanstack-start-adapter.md new file mode 100644 index 00000000000..434bb1a86c5 --- /dev/null +++ b/docs/plans/2026-04-01-tanstack-start-adapter.md @@ -0,0 +1,1516 @@ +# TanStack Start Adapter — Full Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Complete the TanStack Start admin adapter with a proper implementation by moving all framework-agnostic code from `packages/next` to `packages/ui`, then building a real adapter using `vinxi/http` and `@tanstack/react-router`. + +**Architecture:** Three-stage process: (1) extract all framework-agnostic utilities and components from `packages/next` to `packages/ui`, (2) refactor `renderDocument`/`renderListView`/etc. to accept `notFound`/`redirect` as callbacks making them moveable, (3) implement `packages/tanstack-start` using `vinxi/http` cookies, `getWebRequest()` for initReq, and real `@tanstack/react-router` hooks. + +**Tech Stack:** TypeScript, React 19 RSC, Payload CMS monorepo (pnpm + Turbo), vinxi/http, @tanstack/react-router, @tanstack/start + +--- + +## Current State + +The original plan (`2026-03-31-admin-adapter-combined.md`) is fully implemented. `packages/tanstack-start` exists with stub methods that throw errors. This plan completes it. + +**What `packages/next` still exclusively owns (needs to move):** + +- `elements/Nav/` — full Nav server+client component +- `elements/DocumentHeader/` — DocumentHeader with Tabs +- `templates/Default/` and `templates/Minimal/` +- `utilities/selectiveCache.ts`, `getRequestLocale.ts`, `getPreferences.ts`, `handleAuthRedirect.ts`, `slugify.ts`, `isCustomAdminView.ts`, `isPublicAdminRoute.ts`, `getRouteWithoutAdmin.ts`, `timestamp.ts` +- `views/Document/` utilities: `getDocumentData`, `getDocumentPermissions`, `getIsLocked`, `getVersions`, `getDocumentView`, `getCustomDocumentViewByKey`, `getCustomViewByRoute`, `renderDocumentSlots`, `handleServerFunction` +- `views/List/` utilities: `enrichDocsWithVersionStatus`, `handleGroupBy`, `renderListViewSlots`, `resolveAllFilterOptions`, `transformColumnsToSelect`, `createSerializableValue`, `extractRelationshipDisplayValue`, `extractValueOrRelationshipID`, `handleServerFunction` +- `views/Document/index.tsx` and `views/List/index.tsx` — main render functions using `notFound`/`redirect` +- `views/Account/` client files, `views/Version/`, `views/Versions/` utilities + +**Blocking the TanStack adapter:** + +- `renderDocument` and `renderListView` call `notFound()`/`redirect()` from `next/navigation` — to move them to packages/ui, we must pass these as callbacks +- `handleServerFunctions` registry lives in packages/next and references view handlers that haven't moved yet + +--- + +## Phase 1: Move Framework-Agnostic Utilities + +### Task 1: Move packages/next utilities to packages/ui + +**Files:** + +- Copy: `packages/next/src/utilities/selectiveCache.ts` → `packages/ui/src/utilities/selectiveCache.ts` +- Copy: `packages/next/src/utilities/getRequestLocale.ts` → `packages/ui/src/utilities/getRequestLocale.ts` +- Copy: `packages/next/src/utilities/handleAuthRedirect.ts` → `packages/ui/src/utilities/handleAuthRedirect.ts` +- Copy: `packages/next/src/utilities/isCustomAdminView.ts` → `packages/ui/src/utilities/isCustomAdminView.ts` +- Copy: `packages/next/src/utilities/isPublicAdminRoute.ts` → `packages/ui/src/utilities/isPublicAdminRoute.ts` +- Copy: `packages/next/src/utilities/getRouteWithoutAdmin.ts` → `packages/ui/src/utilities/getRouteWithoutAdmin.ts` +- Copy: `packages/next/src/utilities/timestamp.ts` → `packages/ui/src/utilities/timestamp.ts` +- Copy: `packages/next/src/utilities/slugify.ts` → `packages/ui/src/utilities/slugify.ts` (the server function handler) + +**Steps:** + +1. Copy each file to packages/ui/src/utilities/ +2. Fix any `@payloadcms/ui` self-imports → relative paths +3. Update packages/next to re-import from packages/ui where needed +4. Run: `pnpm run build:ui && pnpm run build:next` — expect no TypeScript errors +5. Commit: `refactor(ui): move shared utilities from packages/next to packages/ui` + +**Note:** `getPreferences.ts` is already in `packages/ui/src/utilities/getPreferences.ts` (moved in previous work). `getRequestLocale.ts` imports from `@payloadcms/ui/rsc` and `@payloadcms/ui/shared` — replace those with relative imports. + +--- + +### Task 2: Move Nav and DocumentHeader elements + +**Files:** + +- Copy: `packages/next/src/elements/Nav/index.tsx` → `packages/ui/src/elements/Nav/index.tsx` +- Copy: `packages/next/src/elements/Nav/index.client.tsx` → `packages/ui/src/elements/Nav/index.client.tsx` +- Copy: `packages/next/src/elements/Nav/NavHamburger/index.tsx` → `packages/ui/src/elements/Nav/NavHamburger/index.tsx` +- Copy: `packages/next/src/elements/Nav/NavWrapper/index.tsx` → `packages/ui/src/elements/Nav/NavWrapper/index.tsx` +- Copy: `packages/next/src/elements/Nav/SettingsMenuButton/index.tsx` → `packages/ui/src/elements/Nav/SettingsMenuButton/index.tsx` +- Copy: `packages/next/src/elements/Nav/getNavPrefs.ts` → `packages/ui/src/elements/Nav/getNavPrefs.ts` +- Copy: `packages/next/src/elements/DocumentHeader/` (entire dir) → `packages/ui/src/elements/DocumentHeader/` + +**Steps:** + +1. Check that `packages/ui/src/elements/Nav/context.tsx` already exists (the NavProvider/useNav hook). The Nav server component from packages/next is different — it renders the full nav sidebar. +2. Copy files, fix `@payloadcms/ui` self-imports → relative paths +3. Update packages/next Nav exports to re-export from packages/ui +4. Run: `pnpm run build:ui && pnpm run build:next` +5. Commit: `refactor(ui): move Nav and DocumentHeader elements from packages/next` + +--- + +### Task 3: Move templates to packages/ui + +**Files:** + +- Create: `packages/ui/src/templates/Default/index.tsx` (from `packages/next/src/templates/Default/index.tsx`) +- Create: `packages/ui/src/templates/Default/NavHamburger/index.tsx` +- Create: `packages/ui/src/templates/Default/Wrapper/index.tsx` +- Create: `packages/ui/src/templates/Minimal/index.tsx` + +**Steps:** + +1. Copy each file, convert `@payloadcms/ui` imports to relative paths +2. Add `"./templates/*"` export pattern to `packages/ui/package.json` exports +3. Update `packages/next/src/templates/` to re-export from `@payloadcms/ui/templates/*` +4. Run: `pnpm run build:ui && pnpm run build:next` +5. Commit: `refactor(ui): move Default and Minimal templates from packages/next` + +--- + +## Phase 2: Move View Utilities + +### Task 4: Move Document view utilities + +**Files to move (all have zero next/\* imports):** + +- `packages/next/src/views/Document/getDocumentData.ts` → `packages/ui/src/views/Document/getDocumentData.ts` (already exists — verify) +- `packages/next/src/views/Document/getDocumentPermissions.tsx` → `packages/ui/src/views/Document/getDocumentPermissions.tsx` +- `packages/next/src/views/Document/getIsLocked.ts` → `packages/ui/src/views/Document/getIsLocked.ts` +- `packages/next/src/views/Document/getVersions.ts` → `packages/ui/src/views/Document/getVersions.ts` +- `packages/next/src/views/Document/getDocumentView.tsx` → `packages/ui/src/views/Document/getDocumentView.tsx` +- `packages/next/src/views/Document/getCustomDocumentViewByKey.tsx` → `packages/ui/src/views/Document/getCustomDocumentViewByKey.tsx` +- `packages/next/src/views/Document/getCustomViewByRoute.tsx` → `packages/ui/src/views/Document/getCustomViewByRoute.tsx` +- `packages/next/src/views/Document/renderDocumentSlots.tsx` → `packages/ui/src/views/Document/renderDocumentSlots.tsx` + +**Steps:** + +1. `getDocumentData.ts` and `getDocPreferences.ts` already exist in packages/ui — verify they're correct copies +2. Copy remaining files, fix self-imports +3. Update packages/next to import these from `@payloadcms/ui/views/Document/*` (add export paths if needed) or relative package imports +4. Run: `pnpm run build:ui && pnpm run build:next` +5. Commit: `refactor(ui): move Document view utilities from packages/next` + +--- + +### Task 5: Move List view utilities + +**Files to move (all have zero next/\* imports):** + +- `packages/next/src/views/List/createSerializableValue.ts` → `packages/ui/src/views/List/createSerializableValue.ts` +- `packages/next/src/views/List/enrichDocsWithVersionStatus.ts` → `packages/ui/src/views/List/enrichDocsWithVersionStatus.ts` +- `packages/next/src/views/List/extractRelationshipDisplayValue.ts` → `packages/ui/src/views/List/extractRelationshipDisplayValue.ts` +- `packages/next/src/views/List/extractValueOrRelationshipID.ts` → `packages/ui/src/views/List/extractValueOrRelationshipID.ts` +- `packages/next/src/views/List/handleGroupBy.ts` → `packages/ui/src/views/List/handleGroupBy.ts` +- `packages/next/src/views/List/renderListViewSlots.tsx` → `packages/ui/src/views/List/renderListViewSlots.tsx` +- `packages/next/src/views/List/resolveAllFilterOptions.ts` → `packages/ui/src/views/List/resolveAllFilterOptions.ts` +- `packages/next/src/views/List/transformColumnsToSelect.ts` → `packages/ui/src/views/List/transformColumnsToSelect.ts` + +**Steps:** + +1. Copy files, fix self-imports +2. Run: `pnpm run build:ui && pnpm run build:next` +3. Commit: `refactor(ui): move List view utilities from packages/next` + +--- + +### Task 6: Move Version/Versions and Account utilities + +**Files:** + +- `packages/next/src/views/Version/fetchVersions.ts` → `packages/ui/src/views/Version/fetchVersions.ts` +- `packages/next/src/views/Version/RenderFieldsToDiff/` (entire dir) → `packages/ui/src/views/Version/RenderFieldsToDiff/` +- `packages/next/src/views/Version/SelectComparison/` → `packages/ui/src/views/Version/SelectComparison/` +- `packages/next/src/views/Version/VersionPillLabel/` → `packages/ui/src/views/Version/VersionPillLabel/` +- `packages/next/src/views/Version/Default/` (minus index.tsx) → `packages/ui/src/views/Version/Default/` +- `packages/next/src/views/Versions/buildColumns.tsx` → `packages/ui/src/views/Versions/buildColumns.tsx` +- `packages/next/src/views/Versions/cells/` → `packages/ui/src/views/Versions/cells/` +- `packages/next/src/views/Account/index.client.tsx` → `packages/ui/src/views/Account/index.client.tsx` +- `packages/next/src/views/Account/ResetPreferences/` → `packages/ui/src/views/Account/ResetPreferences/` +- `packages/next/src/views/Account/Settings/` → `packages/ui/src/views/Account/Settings/` +- `packages/next/src/views/Account/ToggleTheme/` → `packages/ui/src/views/Account/ToggleTheme/` + +**Steps:** + +1. Copy files, fix self-imports +2. Run: `pnpm run build:ui && pnpm run build:next` +3. Commit: `refactor(ui): move Version/Versions/Account utilities from packages/next` + +--- + +## Phase 3: Refactor Main View Functions to Accept Navigation Callbacks + +The `renderDocument`, `renderListView`, `renderVersion`, `renderVersions`, and `renderAccount` functions in `packages/next` call `notFound()`/`redirect()` from `next/navigation`. To move them to `packages/ui`, we add these as parameters. + +### Task 7: Add ServerNavigation params to renderDocument and move to packages/ui + +**Files:** + +- Modify: `packages/next/src/views/Document/index.tsx` +- Create: `packages/ui/src/views/Document/RenderDocument.tsx` (the moved render function) +- Modify: `packages/next/src/views/Document/handleServerFunction.tsx` + +**The pattern:** Extract `renderDocument` from `packages/next` to `packages/ui` with two new parameters: + +```typescript +// packages/ui/src/views/Document/RenderDocument.tsx +export const renderDocument = async ({ + // ...existing params... + notFound, // ADD: () => never + redirect, // ADD: (url: string) => never +}: { + // ... existing type ... + notFound: () => never + redirect: (url: string) => never +}): Promise<{ data: Data; Document: React.ReactNode }> => { + // same implementation, but calls params.notFound() and params.redirect() + // instead of importing from next/navigation +} +``` + +**Steps:** + +1. Create `packages/ui/src/views/Document/RenderDocument.tsx`: + + - Copy the entire `renderDocument` function from `packages/next/src/views/Document/index.tsx` + - Add `notFound: () => never` and `redirect: (url: string) => never` to the function params type + - Replace the `import { notFound, redirect } from 'next/navigation.js'` with no import + - Replace all `notFound()` calls with `notFound()` (params) and `redirect(url)` with `redirect(url)` (params) + - Fix any `@payloadcms/ui` self-imports → relative paths + - Fix imports of `DocumentHeader` → from `../../elements/DocumentHeader/index.js` + - Fix imports of Document utilities → relative `./getDocumentData.js` etc. + +2. Update `packages/next/src/views/Document/index.tsx` to: + + ```typescript + import { notFound, redirect } from 'next/navigation.js' + import { renderDocument as renderDocumentFromUI } from '@payloadcms/ui/views/Document/RenderDocument' + + // Re-export renderDocument passing in Next.js's notFound/redirect + export const renderDocument = (args) => + renderDocumentFromUI({ ...args, notFound, redirect }) + ``` + +3. Update `packages/next/src/views/Document/handleServerFunction.tsx` to import from the updated location + +4. Run: `pnpm run build:ui && pnpm run build:next` +5. Commit: `refactor(ui): move renderDocument to packages/ui with navigation callbacks` + +--- + +### Task 8: Move renderListView to packages/ui with navigation callbacks + +**Files:** + +- Create: `packages/ui/src/views/List/RenderListView.tsx` (the moved render function) +- Modify: `packages/next/src/views/List/index.tsx` +- Modify: `packages/next/src/views/List/handleServerFunction.tsx` + +**Steps:** + +1. Create `packages/ui/src/views/List/RenderListView.tsx`: + + - Copy `renderListView` function from `packages/next/src/views/List/index.tsx` + - Add `notFound: () => never` to params + - Replace `notFound()` call with the param + - Fix imports → relative paths within packages/ui + +2. Update `packages/next/src/views/List/index.tsx`: + + ```typescript + import { notFound } from 'next/navigation.js' + import { renderListView as renderListViewFromUI } from '@payloadcms/ui/views/List/RenderListView' + + export const renderListView = (args) => + renderListViewFromUI({ ...args, notFound }) + ``` + +3. Run: `pnpm run build:ui && pnpm run build:next` +4. Commit: `refactor(ui): move renderListView to packages/ui with notFound callback` + +--- + +### Task 9: Move renderVersion, renderVersions, renderAccount to packages/ui + +**Same pattern as Tasks 7-8, applied to:** + +- `packages/next/src/views/Version/index.tsx` → `packages/ui/src/views/Version/RenderVersion.tsx` +- `packages/next/src/views/Versions/index.tsx` → `packages/ui/src/views/Versions/RenderVersions.tsx` +- `packages/next/src/views/Account/index.tsx` → `packages/ui/src/views/Account/RenderAccount.tsx` + +For Version and Versions: add `notFound: () => never` to params. +For Account: add `notFound: () => never` to params. + +**Steps:** + +1. For each view: + - Create `RenderX.tsx` in packages/ui with navigation callback params + - Update packages/next entry to import from packages/ui and pass Next.js callbacks +2. Run: `pnpm run build:ui && pnpm run build:next` +3. Commit: `refactor(ui): move Version/Versions/Account render functions to packages/ui` + +--- + +## Phase 4: Move handleServerFunction Handlers to packages/ui + +### Task 10: Move renderDocumentHandler and renderListHandler + +These server function handlers (`handleServerFunction.tsx` in each view) call `renderDocument`/`renderListView`. Now that those functions are in packages/ui and accept navigation callbacks, the handlers can also move. + +**Files:** + +- Move: `packages/next/src/views/Document/handleServerFunction.tsx` → `packages/ui/src/views/Document/handleServerFunction.tsx` +- Move: `packages/next/src/views/List/handleServerFunction.tsx` → `packages/ui/src/views/List/handleServerFunction.tsx` + +**Key:** These handlers don't call `notFound`/`redirect` themselves — they call `renderDocument`/`renderListView` which accept callbacks. But the callbacks need to come from somewhere when invoked in a server function context. + +**Solution:** Server function handlers accept `notFound` and `redirect` from the `ServerFunctionArgs` context (which the adapter injects via `initReq`). Add these to `DefaultServerFunctionArgs`: + +```typescript +// packages/ui/src/views/Document/handleServerFunction.tsx +export const renderDocumentHandler: RenderDocumentServerFunction = async (args) => { + const { notFound, redirect } = args // injected by adapter's handleServerFunctions + const { data, Document } = await renderDocument({ + ...renderArgs, + notFound: notFound ?? (() => { throw new Error('notFound not provided') }), + redirect: redirect ?? (() => { throw new Error('redirect not provided') }), + }) + ... +} +``` + +**Steps:** + +1. Check the `RenderDocumentServerFunction` type in `packages/ui` — does it include `notFound`/`redirect`? If not, extend it in `packages/payload/src/admin/types.ts` +2. Update `DefaultServerFunctionArgs` in packages/payload to include optional `notFound`/`redirect` +3. Create `packages/ui/src/views/Document/handleServerFunction.tsx` with updated handler +4. Create `packages/ui/src/views/List/handleServerFunction.tsx` with updated handler +5. Update packages/next handlers to be thin re-exports +6. Run: `pnpm run build:ui && pnpm run build:next` +7. Commit: `refactor(ui): move renderDocumentHandler and renderListHandler to packages/ui` + +--- + +### Task 11: Create shared handleServerFunctions base in packages/ui + +**File:** Create `packages/ui/src/utilities/handleServerFunctions.ts` + +```typescript +// packages/ui/src/utilities/handleServerFunctions.ts +import type { + DefaultServerFunctionArgs, + ServerFunction, + ServerFunctionHandler, +} from 'payload' + +import { + _internal_renderFieldHandler, + copyDataFromLocaleHandler, +} from '../exports/rsc/index.js' +import { buildFormStateHandler } from './buildFormState.js' +import { buildTableStateHandler } from './buildTableState.js' +import { getFolderResultsComponentAndDataHandler } from './getFolderResultsComponentAndData.js' +import { schedulePublishHandler } from './schedulePublishHandler.js' +import { getDefaultLayoutHandler } from '../views/Dashboard/Default/ModularDashboard/renderWidget/getDefaultLayoutServerFn.js' +import { renderWidgetHandler } from '../views/Dashboard/Default/ModularDashboard/renderWidget/renderWidgetServerFn.js' +import { renderDocumentHandler } from '../views/Document/handleServerFunction.js' +import { renderDocumentSlotsHandler } from '../views/Document/renderDocumentSlots.js' +import { renderListHandler } from '../views/List/handleServerFunction.js' +import { slugifyHandler } from './slugify.js' + +export const baseServerFunctions: Record> = { + 'copy-data-from-locale': copyDataFromLocaleHandler, + 'form-state': buildFormStateHandler, + 'get-default-layout': getDefaultLayoutHandler, + 'get-folder-results-component-and-data': + getFolderResultsComponentAndDataHandler, + 'render-document': renderDocumentHandler, + 'render-document-slots': renderDocumentSlotsHandler, + 'render-field': _internal_renderFieldHandler, + 'render-list': renderListHandler, + 'render-widget': renderWidgetHandler, + 'schedule-publish': schedulePublishHandler, + slugify: slugifyHandler, + 'table-state': buildTableStateHandler, +} + +/** + * Framework-agnostic server function dispatcher. + * Adapters call this after running initReq to get the request context. + */ +export async function dispatchServerFunction(args: { + augmentedArgs: DefaultServerFunctionArgs + extraServerFunctions?: Record + name: string +}): Promise { + const { augmentedArgs, extraServerFunctions, name } = args + const fn = extraServerFunctions?.[name] || baseServerFunctions[name] + if (!fn) { + throw new Error(`Unknown Server Function: ${name}`) + } + return fn(augmentedArgs) +} +``` + +**Steps:** + +1. Create the file above +2. Update `packages/next/src/utilities/handleServerFunctions.ts` to: + + ```typescript + import { dispatchServerFunction } from '@payloadcms/ui/utilities/handleServerFunctions' + + export const handleServerFunctions: ServerFunctionHandler = async (args) => { + const { + name: fnKey, + args: fnArgs, + config, + importMap, + serverFunctions, + } = args + const { cookies, locale, permissions, req } = await initReq({ + configPromise: config, + importMap, + key: 'RootLayout', + }) + const augmentedArgs = { + ...fnArgs, + cookies, + importMap, + locale, + permissions, + req, + notFound: () => notFound(), // inject Next.js navigation + redirect: (url) => redirect(url), + } + return dispatchServerFunction({ + augmentedArgs, + extraServerFunctions: serverFunctions, + name: fnKey, + }) + } + ``` + +3. Run: `pnpm run build:ui && pnpm run build:next` +4. Commit: `refactor(ui): create shared server function dispatcher` + +--- + +## Phase 5: Implement TanStack Start Adapter + +### Task 12: Implement initReq for TanStack Start + +**File:** Create `packages/tanstack-start/src/utilities/initReq.ts` + +```typescript +import type { I18n, I18nClient } from '@payloadcms/translations' +import type { ImportMap, InitReqResult, SanitizedConfig } from 'payload' + +import { initI18n } from '@payloadcms/translations' +// vinxi/http provides server-side request context in TanStack Start +import { getCookie as vinxiGetCookie, getWebRequest } from 'vinxi/http' +import { + createLocalReq, + executeAuthStrategies, + getAccessResults, + getPayload, + getRequestLanguage, + parseCookies, +} from 'payload' + +import { getRequestLocale } from '@payloadcms/ui/utilities/getRequestLocale' +import { selectiveCache } from '@payloadcms/ui/utilities/selectiveCache' + +const partialReqCache = selectiveCache('partialReq') +const reqCache = selectiveCache('req') + +export const initReq = async function ({ + config: configArg, + importMap, + key = 'adapter', +}: { + config: Promise | SanitizedConfig + importMap: ImportMap + key?: string +}): Promise { + // getWebRequest() returns the current server request from Vinxi's context store + const request = getWebRequest() + const headers = new Headers(request.headers) + const cookies = parseCookies(headers) + + const partialResult = await partialReqCache.get(async () => { + const config = await configArg + const payload = await getPayload({ config, cron: true, importMap }) + const languageCode = getRequestLanguage({ config, cookies, headers }) + + const i18n: I18nClient = await initI18n({ + config: config.i18n, + context: 'client', + language: languageCode, + }) + + const { responseHeaders, user } = await executeAuthStrategies({ + headers, + payload, + }) + + return { i18n, languageCode, payload, responseHeaders, user } + }, 'global') + + return reqCache + .get(async () => { + const { i18n, languageCode, payload, responseHeaders, user } = + partialResult + + const req = await createLocalReq( + { + req: { + headers, + host: headers.get('host'), + i18n: i18n as I18n, + responseHeaders, + url: request.url, + user, + }, + }, + payload, + ) + + const locale = await getRequestLocale({ req }) + req.locale = locale?.code + + const permissions = await getAccessResults({ req }) + + return { cookies, headers, languageCode, locale, permissions, req } + }, key) + .then((result) => ({ + ...result, + req: { + ...result.req, + ...(result.req?.context ? { context: { ...result.req.context } } : {}), + }, + })) +} +``` + +**Steps:** + +1. Create the file +2. Add `vinxi` to `packages/tanstack-start/package.json` peerDependencies (already there) +3. Verify `getWebRequest` import path — check vinxi docs or `node_modules/vinxi/dist` for correct import +4. Run: `cd packages/tanstack-start && pnpm tsc --noEmit` — expect no errors on the new file (warnings about peer deps OK) +5. Commit: `feat(tanstack-start): implement initReq using vinxi/http getWebRequest` + +--- + +### Task 13: Implement handleServerFunctions for TanStack Start + +**File:** Create `packages/tanstack-start/src/utilities/handleServerFunctions.ts` + +```typescript +import type { ServerFunctionHandler } from 'payload' + +import { notFound, redirect } from '@tanstack/react-router' +import { dispatchServerFunction } from '@payloadcms/ui/utilities/handleServerFunctions' + +import { initReq } from './initReq.js' + +export const handleServerFunctions: ServerFunctionHandler = async (args) => { + const { name: fnKey, args: fnArgs, config, importMap, serverFunctions } = args + + const { cookies, locale, permissions, req } = await initReq({ + config, + importMap, + key: 'RootLayout', + }) + + const augmentedArgs = { + ...fnArgs, + cookies, + importMap, + locale, + notFound: () => { + throw notFound() + }, + permissions, + redirect: (url: string) => { + throw redirect({ to: url }) + }, + req, + } + + return dispatchServerFunction({ + augmentedArgs, + extraServerFunctions: serverFunctions, + name: fnKey, + }) +} +``` + +**Steps:** + +1. Create the file +2. Verify `@tanstack/react-router` exports `notFound` and `redirect` — check their API: `notFound()` returns a special error object to throw, `redirect({ to })` does the same +3. Commit: `feat(tanstack-start): implement handleServerFunctions using shared dispatcher` + +--- + +### Task 14: Implement real RouterProvider with @tanstack/react-router hooks + +**File:** Replace `packages/tanstack-start/src/adapter/RouterProvider.tsx` + +```typescript +'use client' +import type { LinkProps as RouterLinkProps, RouterContextType } from '@payloadcms/ui' + +import { RouterProvider as BaseRouterProvider } from '@payloadcms/ui' +import { Link as TanStackLink, useLocation, useParams, useRouter } from '@tanstack/react-router' +import React from 'react' + +const AdapterLink: React.FC = ({ href, children, ...rest }) => ( + + {children} + +) + +export function TanStackRouterProvider({ children }: { children: React.ReactNode }) { + const router = useRouter() + const location = useLocation() + const params = useParams({ strict: false }) + + const searchParams = React.useMemo( + () => new URLSearchParams(location.search), + [location.search], + ) + + const routerCtx: RouterContextType = React.useMemo( + () => ({ + Link: AdapterLink, + params, + pathname: location.pathname, + router: { + back: () => router.history.back(), + forward: () => router.history.forward(), + prefetch: (url: string) => router.preloadRoute({ to: url }), + push: (url: string) => { void router.navigate({ to: url }) }, + refresh: () => { void router.invalidate() }, + replace: (url: string) => { void router.navigate({ to: url, replace: true }) }, + }, + searchParams, + }), + [router, location, params, searchParams], + ) + + return {children} +} +``` + +**Steps:** + +1. Check what `@tanstack/react-router` exports: `useRouter`, `useLocation`, `useParams`, `Link` + - Run: `grep -r "export.*useRouter\|export.*useLocation" node_modules/@tanstack/react-router/dist/ 2>/dev/null | head -5` to verify +2. Replace the file. Note `AdapterLink` is module-level (not nested in component) — this satisfies react-compiler. +3. Run: `cd packages/tanstack-start && pnpm tsc --noEmit` +4. Commit: `feat(tanstack-start): implement TanStackRouterProvider with real hooks` + +--- + +### Task 15: Implement cookie helpers and wire adapter + +**File:** Update `packages/tanstack-start/src/adapter/index.ts` + +```typescript +import type { + AdminAdapterResult, + BaseAdminAdapter, + CookieOptions, +} from 'payload' + +import { notFound, redirect } from '@tanstack/react-router' +import { deleteCookie, getCookie, setCookie } from 'vinxi/http' +import { createAdminAdapter } from 'payload' + +import { handleServerFunctions } from '../utilities/handleServerFunctions.js' +import { initReq } from '../utilities/initReq.js' +import { TanStackRouterProvider } from './RouterProvider.js' + +export function tanstackStartAdapter(): AdminAdapterResult { + return { + name: 'tanstack-start', + init: ({ payload }) => + createAdminAdapter({ + RouterProvider: TanStackRouterProvider, + createRouteHandlers: () => ({}), // Vinxi handles routing via file-system + deleteCookie: (name) => deleteCookie(name), + getCookie: (name) => getCookie(name), + handleServerFunctions, + initReq: ({ config, importMap }) => initReq({ config, importMap }), + name: 'tanstack-start', + notFound: () => { + throw notFound() + }, + payload, + redirect: (url) => { + throw redirect({ to: url }) + }, + setCookie: (name, value, options) => setCookie(name, value, options), + } satisfies BaseAdminAdapter), + } +} +``` + +**Steps:** + +1. Replace the file with the above +2. Verify `vinxi/http` exports `getCookie`, `setCookie`, `deleteCookie` — check: `ls node_modules/vinxi/dist/` and search for cookie exports +3. Run: `cd packages/tanstack-start && pnpm tsc --noEmit` +4. Run: `pnpm run build:ui && pnpm run build:next` from repo root — packages/next should still pass +5. Commit: `feat(tanstack-start): implement full adapter with vinxi/http cookies` + +--- + +### Task 16: Update exports and package.json for tanstack-start + +**Files:** + +- Update: `packages/tanstack-start/src/index.ts` +- Update: `packages/tanstack-start/package.json` (add `@tanstack/react-router` and `vinxi` to dependencies or peerDependencies) + +```typescript +// packages/tanstack-start/src/index.ts +export { tanstackStartAdapter } from './adapter/index.js' +export { TanStackRouterProvider } from './adapter/RouterProvider.js' +// Export initReq for users who need direct access +export { initReq } from './utilities/initReq.js' +export { handleServerFunctions } from './utilities/handleServerFunctions.js' +``` + +```json +// packages/tanstack-start/package.json additions +"peerDependencies": { + "@tanstack/react-router": ">=1.0.0", + "@tanstack/start": ">=1.0.0", + "react": "^19.0.0", + "vinxi": ">=0.4.0" +}, +"peerDependenciesMeta": { + "@tanstack/react-router": { "optional": false }, + "vinxi": { "optional": false } +} +``` + +**Steps:** + +1. Update both files +2. Run: `pnpm install` +3. Run: `pnpm run build:next` — full build check +4. Commit: `feat(tanstack-start): finalize exports and peer dependencies` + +--- + +### Task 17: Add integration tests for TanStack Start adapter shape + +**File:** Create `test/admin-adapter/tanstack-start.spec.ts` + +```typescript +import { describe, expect, it } from 'vitest' + +describe('tanstackStartAdapter', () => { + it('should export tanstackStartAdapter function', async () => { + const { tanstackStartAdapter } = await import('@payloadcms/tanstack-start') + expect(typeof tanstackStartAdapter).toBe('function') + }) + + it('should return AdminAdapterResult with name and init', async () => { + const { tanstackStartAdapter } = await import('@payloadcms/tanstack-start') + const result = tanstackStartAdapter() + expect(result.name).toBe('tanstack-start') + expect(typeof result.init).toBe('function') + }) + + it('should satisfy BaseAdminAdapter interface when init is called', async () => { + const { tanstackStartAdapter } = await import('@payloadcms/tanstack-start') + const { createAdminAdapter } = await import('payload') + + // Can't call init without full payload instance, but verify the shape + const result = tanstackStartAdapter() + expect(result.name).toBe('tanstack-start') + + // The adapter factory is the correct type + expect(typeof result.init).toBe('function') + }) +}) +``` + +**Steps:** + +1. Create the test file +2. Run: `pnpm run test:int admin-adapter` +3. Fix any failures +4. Commit: `test: add TanStack Start adapter shape tests` + +--- + +## Dependency Graph + +``` +Phase 1 (Tasks 1-3): Move utilities/elements/templates + ↓ +Phase 2 (Tasks 4-6): Move view utilities + ↓ +Phase 3 (Tasks 7-9): Move render functions (with nav callbacks) + ↓ +Phase 4 (Tasks 10-11): Move handlers + create shared dispatcher + ↓ +Phase 5 (Tasks 12-16): TanStack Start adapter implementation + ↓ +Task 17: Tests +``` + +## Verification at Each Phase + +After each phase, run: + +```bash +pnpm run build:ui # packages/ui builds clean +pnpm run build:next # packages/next builds clean +pnpm run test:int admin-adapter # adapter tests pass +``` + +Final verification: + +```bash +pnpm run dev # dev server starts, admin panel works +``` + +## Key Files Summary + +**Moved from packages/next → packages/ui:** + +- `utilities/selectiveCache.ts`, `getRequestLocale.ts`, `handleAuthRedirect.ts`, `isCustomAdminView.ts`, `isPublicAdminRoute.ts`, `getRouteWithoutAdmin.ts`, `timestamp.ts`, `slugify.ts` +- `elements/Nav/` (full), `elements/DocumentHeader/` (full) +- `templates/Default/`, `templates/Minimal/` +- `views/Document/` utilities (8 files) +- `views/List/` utilities (8 files) +- `views/Version/`, `views/Versions/`, `views/Account/` utilities +- `views/Document/RenderDocument.tsx` (new, accepts nav callbacks) +- `views/List/RenderListView.tsx` (new, accepts nav callbacks) +- `views/Document/handleServerFunction.tsx`, `views/List/handleServerFunction.tsx` +- `utilities/handleServerFunctions.ts` → `utilities/handleServerFunctions.ts` (shared dispatcher) + +**New in packages/tanstack-start:** + +- `src/utilities/initReq.ts` — uses `getWebRequest()` from `vinxi/http` +- `src/utilities/handleServerFunctions.ts` — uses shared dispatcher + TanStack navigation +- `src/adapter/RouterProvider.tsx` — real `@tanstack/react-router` hooks +- `src/adapter/index.ts` — complete adapter using `vinxi/http` cookies + +--- + +## Phase 6: TanStack Start App Shell + `pnpm dev:tanstack` + +This phase creates the TanStack Start equivalent of the auto-generated `app/(payload)/` Next.js files, plus the dev entry point. After this phase `pnpm dev:tanstack` starts a working admin panel. + +### Background: How `pnpm dev` works today + +``` +pnpm dev + → test/dev.ts + → loadEnv() + → runInit(testSuiteArg) # writes importMap.js, generates DB adapter + → nextImport({ dir: rootDir }) # starts Next.js from repo root + → serves app/(payload)/ # auto-generated layout.tsx + page.tsx +``` + +The auto-generated files wire together: + +- `app/(payload)/layout.tsx` → `RootLayout` + `handleServerFunctions` from `@payloadcms/next/layouts` +- `app/(payload)/admin/[[...segments]]/page.tsx` → `RootPage` from `@payloadcms/next/views` +- `app/(payload)/api/[...slug]/route.ts` → REST handlers from `@payloadcms/next/routes` + +For TanStack Start we need the same wiring using TanStack's routing conventions. + +--- + +### Task 18: Install TanStack Start dependencies + +**Files:** + +- Modify: `packages/tanstack-start/package.json` — move peer deps to real deps for the workspace app +- Create: `tanstack-app/package.json` + +**Steps:** + +1. Create `tanstack-app/package.json`: + +```json +{ + "name": "payload-tanstack-app", + "private": true, + "type": "module", + "scripts": { + "dev": "vinxi dev", + "build": "vinxi build", + "start": "vinxi start" + }, + "dependencies": { + "@payloadcms/tanstack-start": "workspace:*", + "@payloadcms/ui": "workspace:*", + "@tanstack/react-router": "^1.0.0", + "@tanstack/start": "^1.0.0", + "payload": "workspace:*", + "react": "19.1.0", + "react-dom": "19.1.0", + "vinxi": "^0.4.0" + }, + "devDependencies": { + "typescript": "5.x", + "vite-tsconfig-paths": "^5.0.0" + } +} +``` + +2. Add `tanstack-app` to `pnpm-workspace.yaml`: + +```yaml +packages: + - 'packages/*' + - 'tanstack-app' # ADD THIS + - ... +``` + +3. Run: `pnpm install` + +4. Verify: `ls node_modules/@tanstack/start` inside `tanstack-app/` + +5. Commit: `chore: add tanstack-app workspace package with TanStack Start deps` + +--- + +### Task 19: Create TanStack Start app config (app.config.ts) + +TanStack Start uses `app.config.ts` (Vinxi config) instead of `next.config.mjs`. + +**File:** Create `tanstack-app/app.config.ts` + +```typescript +import { defineConfig } from '@tanstack/start/config' +import tsConfigPaths from 'vite-tsconfig-paths' + +export default defineConfig({ + tsr: { + appDirectory: './app', + }, + vite: { + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + ], + }, +}) +``` + +**File:** Create `tanstack-app/tsconfig.json` + +```json +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "strict": false, + "baseUrl": ".", + "paths": { + "@payload-config": ["../test/_community/config.ts"] + } + }, + "include": ["app/**/*", "app.config.ts"] +} +``` + +The `@payload-config` alias mirrors what Next.js uses — the test suite config is injected via path alias. + +**Steps:** + +1. Create both files +2. Run: `cd tanstack-app && pnpm vinxi dev --help` — verify Vinxi is available +3. Commit: `feat(tanstack-app): add app.config.ts and tsconfig` + +--- + +### Task 20: Create TanStack Start app entry points + +TanStack Start requires three entry files. + +**File:** Create `tanstack-app/app/client.tsx` + +```typescript +import { StartClient } from '@tanstack/start' +import { StrictMode } from 'react' +import { hydrateRoot } from 'react-dom/client' +import { createRouter } from './router.js' + +const router = createRouter() + +hydrateRoot( + document, + + + , +) +``` + +**File:** Create `tanstack-app/app/ssr.tsx` + +```typescript +import { + createStartHandler, + defaultStreamHandler, +} from '@tanstack/start/server' +import { createRouter } from './router.js' + +export default createStartHandler({ createRouter })(defaultStreamHandler) +``` + +**File:** Create `tanstack-app/app/router.tsx` + +```typescript +import { createRouter as createTanStackRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen.js' + +export function createRouter() { + return createTanStackRouter({ routeTree }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} +``` + +**Note:** `routeTree.gen.ts` is auto-generated by TanStack Router CLI from the routes directory. You need to run `tanstack-router generate` or configure `tsr.watch` in app.config.ts to auto-generate it. + +**Steps:** + +1. Create all three files +2. Create a placeholder `tanstack-app/app/routeTree.gen.ts` (will be replaced by codegen): + ```typescript + // This file is auto-generated by TanStack Router + import { rootRoute } from './routes/__root.js' + export const routeTree = rootRoute + ``` +3. Commit: `feat(tanstack-app): add TanStack Start app entry points` + +--- + +### Task 21: Create the root layout route + +The root layout is the TanStack Start equivalent of `app/(payload)/layout.tsx`. It wires in `TanStackRouterProvider` and the Payload `RootLayout`. + +**File:** Create `tanstack-app/app/routes/__root.tsx` + +```typescript +import type { ImportMap, ServerFunctionClient } from 'payload' + +import config from '@payload-config' +import { tanstackStartAdapter } from '@payloadcms/tanstack-start' +import { + createRootRoute, + HeadContent, + Outlet, + Scripts, +} from '@tanstack/react-router' +import { createServerFn } from '@tanstack/start' +import React from 'react' + +// Import the shared handler dispatcher from packages/ui (available after Phase 4) +import { dispatchServerFunction } from '@payloadcms/ui/utilities/handleServerFunctions' +import { initReq } from '@payloadcms/tanstack-start/utilities/initReq' + +// Import the generated importMap (written by dev script on startup) +import { importMap } from '../importMap.js' + +// Server function: handles all Payload server function calls +// This is the TanStack Start equivalent of Next.js's 'use server' function +const handleServerFn = createServerFn() + .validator((data: unknown) => data as Parameters[0]) + .handler(async ({ data: args }) => { + const { cookies, locale, permissions, req } = await initReq({ + config, + importMap, + key: 'RootLayout', + }) + return dispatchServerFunction({ + augmentedArgs: { + ...args, + cookies, + importMap, + locale, + permissions, + req, + notFound: () => { throw { _type: 'notFound' } }, + redirect: (url: string) => { throw { _type: 'redirect', url } }, + }, + extraServerFunctions: args.serverFunctions, + name: args.name, + }) + }) + +// Thin wrapper matching Payload's ServerFunctionClient signature +const serverFunction: ServerFunctionClient = (args) => handleServerFn({ data: args }) + +export const Route = createRootRoute({ + component: RootComponent, + head: () => ({ + meta: [{ charSet: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' }], + }), +}) + +// RootLayout from @payloadcms/tanstack-start will be created in packages/tanstack-start/src/layouts/ +// It is the equivalent of RootLayout from @payloadcms/next/layouts, using TanStackRouterProvider +function RootComponent() { + return ( + + + + + + {/* TanStack Start's payload root layout — created in Task 22 */} + + + + + + + ) +} +``` + +**Note on `PayloadRootLayout`:** This is `RootLayout` from `packages/tanstack-start/src/layouts/Root/`. It is equivalent to `packages/next/src/layouts/Root/index.tsx` but wraps with `TanStackRouterProvider` instead of `NextRouterProvider`. Created in Task 22. + +**Steps:** + +1. Create the file +2. Create placeholder `tanstack-app/app/importMap.ts` (will be auto-generated): + ```typescript + export const importMap = {} + ``` +3. Commit: `feat(tanstack-app): add root layout route` + +--- + +### Task 22: Create RootLayout for TanStack Start (in packages/tanstack-start) + +This is the TanStack Start equivalent of `packages/next/src/layouts/Root/index.tsx`. + +**File:** Create `packages/tanstack-start/src/layouts/Root/index.tsx` + +```typescript +import type { ImportMap, ServerFunctionClient, SanitizedConfig } from 'payload' + +import { RootProvider } from '@payloadcms/ui' +import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig' +import { applyLocaleFiltering } from 'payload/shared' +import React from 'react' + +import { TanStackRouterProvider } from '../../adapter/RouterProvider.js' +import { initReq } from '../../utilities/initReq.js' + +type Props = { + children: React.ReactNode + config: Promise | SanitizedConfig + importMap: ImportMap + serverFunction: ServerFunctionClient +} + +// Server-side layout: initializes request context and renders Payload RootProvider +export async function RootLayout({ children, config: configPromise, importMap, serverFunction }: Props) { + const { + cookies, + headers, + languageCode, + locale, + permissions, + req, + req: { payload: { config } }, + } = await initReq({ config: configPromise, importMap }) + + const theme = cookies.get(`${config.cookiePrefix || 'payload'}-theme`) || 'light' + + // Get client-safe config + const clientConfig = getClientConfig({ config, i18n: req.i18n, importMap, user: req.user }) + await applyLocaleFiltering({ clientConfig, config, req }) + + // Language options + const languageOptions = Object.entries(config.i18n.supportedLanguages || {}).map( + ([value, langConfig]) => ({ + label: (langConfig as any).translations?.general?.thisLanguage ?? value, + value, + }), + ) + + return ( + + + {children} + + + ) +} +``` + +**Update** `packages/tanstack-start/src/index.ts` to also export `RootLayout`. + +**Steps:** + +1. Create the file +2. Add export to `src/index.ts` +3. Run: `cd packages/tanstack-start && pnpm tsc --noEmit` +4. Commit: `feat(tanstack-start): add RootLayout server component` + +--- + +### Task 23: Create admin route and API routes + +**File:** Create `tanstack-app/app/routes/admin.$.tsx` + +```typescript +import config from '@payload-config' +import { createFileRoute } from '@tanstack/react-router' +import React from 'react' + +// The RootPage equivalent for TanStack Start — created in packages/tanstack-start/src/views/Root/ +import { RootPage } from '@payloadcms/tanstack-start/views' +import { importMap } from '../importMap.js' + +export const Route = createFileRoute('/admin/$')({ + component: AdminPage, +}) + +function AdminPage() { + const params = Route.useParams() + const search = Route.useSearch() + // Convert TanStack params to the shape Payload expects + const segments = params._splat?.split('/').filter(Boolean) ?? [] + + return +} +``` + +**File:** Create `tanstack-app/app/routes/api.$.ts` (API catch-all for REST) + +```typescript +import config from '@payload-config' +import { createAPIFileRoute } from '@tanstack/start/api' +import { + REST_DELETE, + REST_GET, + REST_OPTIONS, + REST_PATCH, + REST_POST, +} from '@payloadcms/tanstack-start/routes' + +export const APIRoute = createAPIFileRoute('/api/$')({ + GET: ({ request }) => REST_GET(config)(request), + POST: ({ request }) => REST_POST(config)(request), + DELETE: ({ request }) => REST_DELETE(config)(request), + PATCH: ({ request }) => REST_PATCH(config)(request), + OPTIONS: ({ request }) => REST_OPTIONS(config)(request), +}) +``` + +**Note:** `@payloadcms/tanstack-start/views` and `@payloadcms/tanstack-start/routes` require new exports: + +- `RootPage` — the TanStack Start admin page renderer (see Task 24) +- REST route handlers adapted from `@payloadcms/next/routes` (see Task 25) + +**Steps:** + +1. Create both files +2. Commit: `feat(tanstack-app): add admin and API routes` + +--- + +### Task 24: Create RootPage and route handlers for TanStack Start + +**File:** Create `packages/tanstack-start/src/views/Root/index.tsx` + +This is the TanStack Start equivalent of `packages/next/src/views/Root/index.tsx` — it: + +- Calls `initReq` using vinxi/http +- Routes to the correct view based on the URL segments +- Throws TanStack's `notFound()`/`redirect()` instead of Next.js's + +```typescript +import type { ImportMap, SanitizedConfig } from 'payload' + +import config from '@payload-config' +import { notFound, redirect } from '@tanstack/react-router' +import React from 'react' + +// Reuse the shared RootPage logic from packages/ui once it's moved (Phase 3) +// For now, replicate the core routing logic from packages/next/src/views/Root/index.tsx +// replacing notFound/redirect with TanStack's versions + +import { importMap } from '../../../importMap.js' // relative to tanstack-app when bundled + +type Props = { + config: Promise | SanitizedConfig + importMap: ImportMap + searchParams: Record + segments: string[] +} + +export async function RootPage({ + config: configPromise, + importMap, + segments, + searchParams, +}: Props) { + // Import the shared Root view logic from packages/ui/views/Root + // (which will accept notFound/redirect as callbacks after Phase 3 of this plan) + const { renderRootPage } = await import( + '@payloadcms/ui/views/Root/RenderRoot' + ) + + return renderRootPage({ + config: configPromise, + importMap, + notFound: () => { + throw notFound() + }, + redirect: (url) => { + throw redirect({ to: url }) + }, + searchParams, + segments, + }) +} +``` + +**File:** Create `packages/tanstack-start/src/routes/index.ts` + +```typescript +// REST route handlers adapted for TanStack Start's API route format +// They accept Request objects (same Web API) so we can delegate to the +// framework-agnostic REST handlers. +export { + REST_DELETE, + REST_GET, + REST_OPTIONS, + REST_PATCH, + REST_POST, +} from '@payloadcms/next/routes' +// Note: Until packages/next REST routes are decoupled, we re-export them. +// The REST handlers use standard Request/Response Web APIs and don't +// depend on Next.js internals — they work in any runtime. +``` + +**Update** `packages/tanstack-start/package.json` exports: + +```json +"./views": { "import": "./src/views/index.ts", ... }, +"./routes": { "import": "./src/routes/index.ts", ... } +``` + +**Steps:** + +1. Create `RootPage` (initially delegating to the shared Root render after Phase 3) +2. Create routes index re-exporting from `@payloadcms/next/routes` +3. Update package.json exports +4. Commit: `feat(tanstack-start): add RootPage and route exports` + +--- + +### Task 25: Create dev-tanstack.ts entry point + +**File:** Create `test/dev-tanstack.ts` + +```typescript +import chalk from 'chalk' +import execa from 'execa' +import minimist from 'minimist' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { loadEnv } from 'payload/node' + +import { runInit } from './runInit.js' + +loadEnv() + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +const { + _: [_testSuiteArg = '_community'], +} = minimist(process.argv.slice(2)) + +const testSuiteArg = _testSuiteArg as string + +console.log(`Selected test suite: ${testSuiteArg} [TanStack Start / Vinxi]`) + +// Generate importMap and DB adapter (reuses the same init logic as Next.js dev) +// This writes to tanstack-app/app/importMap.js instead of app/(payload)/admin/importMap.js +process.env.ROOT_DIR = path.resolve(dirname, '..', 'tanstack-app') + +await runInit(testSuiteArg, true, false) + +// Start Vinxi dev server +const tanstackAppDir = path.resolve(dirname, '..', 'tanstack-app') +const port = process.env.PORT ? Number(process.env.PORT) : 3100 // Use 3100 to avoid conflict with Next.js dev + +const vinxiProcess = execa('pnpm', ['vinxi', 'dev', '--port', String(port)], { + cwd: tanstackAppDir, + stdio: 'inherit', + env: { + ...process.env, + PORT: String(port), + }, +}) + +console.log(chalk.green(`✓ TanStack Start dev server starting on port ${port}`)) +console.log(chalk.cyan(` Admin: http://localhost:${port}/admin`)) + +// Forward signals +process.on('SIGINT', () => { + vinxiProcess.kill('SIGINT') + process.exit(0) +}) +process.on('SIGTERM', () => { + vinxiProcess.kill('SIGTERM') + process.exit(0) +}) + +await vinxiProcess +``` + +**Root package.json** — add the script: + +```json +"dev:tanstack": "cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=16384\" tsx ./test/dev-tanstack.ts", +"dev:tanstack:postgres": "cross-env PAYLOAD_DATABASE=postgres pnpm runts ./test/dev-tanstack.ts" +``` + +**Also update `initDevAndTest.ts`** to support writing the importMap to the TanStack app location when `ROOT_DIR` points there: + +- Currently hardcodes `./app/(payload)/admin/importMap.js` — make this path configurable +- Change: `path.resolve(process.env.ROOT_DIR || getNextRootDir(testSuiteArg).rootDir, './app/(payload)/admin/importMap.js')` + to a helper that checks whether `ROOT_DIR` contains a TanStack app and picks the right path + +**Steps:** + +1. Create `test/dev-tanstack.ts` +2. Add scripts to root `package.json` +3. Update `initDevAndTest.ts` to write importMap to `${ROOT_DIR}/app/importMap.js` when the TanStack app is the target (check by presence of `app.config.ts`) +4. Run: `pnpm dev:tanstack` — expect Vinxi to start +5. Fix any import/build errors +6. Commit: `feat: add pnpm dev:tanstack command and dev entry point` + +--- + +### Task 26: Smoke test `pnpm dev:tanstack` + +**Steps:** + +1. Run: `pnpm dev:tanstack` +2. Navigate to: `http://localhost:3100/admin` +3. Verify: admin panel loads, can log in, can navigate collections +4. Run: `curl -s http://localhost:3100/api/access | jq .` — verify REST API responds +5. Fix any runtime errors +6. Commit: `test: verify pnpm dev:tanstack works end-to-end` + +--- + +## Updated Dependency Graph + +``` +Phase 1 (Tasks 1-3): Move utilities/elements/templates + ↓ +Phase 2 (Tasks 4-6): Move view utilities + ↓ +Phase 3 (Tasks 7-9): Move render functions (with nav callbacks) + ↓ +Phase 4 (Tasks 10-11): Move handlers + create shared dispatcher + ↓ +Phase 5 (Tasks 12-16): TanStack Start adapter implementation + ↓ +Phase 6 (Tasks 18-26): TanStack Start app shell + pnpm dev:tanstack + ↓ +Task 17 + Task 26: Integration tests + smoke test +``` + +## File Summary — Phase 6 New Files + +``` +tanstack-app/ +├── app.config.ts # Vinxi/TanStack Start config +├── tsconfig.json # Path aliases (especially @payload-config) +├── package.json # TanStack deps +└── app/ + ├── client.tsx # Hydration entry + ├── ssr.tsx # SSR entry + ├── router.tsx # Router definition + ├── importMap.ts # Auto-generated on dev start + ├── routeTree.gen.ts # Auto-generated by TanStack Router + └── routes/ + ├── __root.tsx # Root layout (uses RootLayout from packages/tanstack-start) + ├── admin.$.tsx # Admin catch-all → RootPage + └── api.$.ts # REST API catch-all + +packages/tanstack-start/src/ +├── layouts/ +│ └── Root/ +│ └── index.tsx # RootLayout server component +├── views/ +│ └── Root/ +│ └── index.tsx # RootPage (route renderer) +└── routes/ + └── index.ts # Re-exports REST handlers + +test/ +└── dev-tanstack.ts # pnpm dev:tanstack entry point +``` From df1da0744a39c42f62a8bf887a75569ca26a9021 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 19:08:31 +0100 Subject: [PATCH 14/60] refactor(ui): move shared utilities from packages/next to packages/ui Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../src/utilities/handleServerFunctions.ts | 2 +- packages/next/src/utilities/initReq.ts | 5 +- packages/next/src/views/Root/index.tsx | 6 +- packages/ui/package.json | 45 ++++++++++++++ packages/ui/src/utilities/getRequestLocale.ts | 41 +++++++++++++ .../ui/src/utilities/getRouteWithoutAdmin.ts | 9 +++ .../ui/src/utilities/handleAuthRedirect.ts | 47 +++++++++++++++ .../ui/src/utilities/isCustomAdminView.ts | 39 +++++++++++++ .../ui/src/utilities/isPublicAdminRoute.ts | 43 ++++++++++++++ packages/ui/src/utilities/selectiveCache.ts | 57 ++++++++++++++++++ packages/ui/src/utilities/slugify.ts | 58 +++++++++++++++++++ packages/ui/src/utilities/timestamp.ts | 8 +++ 12 files changed, 353 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/utilities/getRequestLocale.ts create mode 100644 packages/ui/src/utilities/getRouteWithoutAdmin.ts create mode 100644 packages/ui/src/utilities/handleAuthRedirect.ts create mode 100644 packages/ui/src/utilities/isCustomAdminView.ts create mode 100644 packages/ui/src/utilities/isPublicAdminRoute.ts create mode 100644 packages/ui/src/utilities/selectiveCache.ts create mode 100644 packages/ui/src/utilities/slugify.ts create mode 100644 packages/ui/src/utilities/timestamp.ts diff --git a/packages/next/src/utilities/handleServerFunctions.ts b/packages/next/src/utilities/handleServerFunctions.ts index 9bcc5ce8d04..43e503a863b 100644 --- a/packages/next/src/utilities/handleServerFunctions.ts +++ b/packages/next/src/utilities/handleServerFunctions.ts @@ -5,6 +5,7 @@ import { buildFormStateHandler } from '@payloadcms/ui/utilities/buildFormState' import { buildTableStateHandler } from '@payloadcms/ui/utilities/buildTableState' import { getFolderResultsComponentAndDataHandler } from '@payloadcms/ui/utilities/getFolderResultsComponentAndData' import { schedulePublishHandler } from '@payloadcms/ui/utilities/schedulePublishHandler' +import { slugifyHandler } from '@payloadcms/ui/utilities/slugify' import { getDefaultLayoutHandler } from '../views/Dashboard/Default/ModularDashboard/renderWidget/getDefaultLayoutServerFn.js' import { renderWidgetHandler } from '../views/Dashboard/Default/ModularDashboard/renderWidget/renderWidgetServerFn.js' @@ -12,7 +13,6 @@ import { renderDocumentHandler } from '../views/Document/handleServerFunction.js import { renderDocumentSlotsHandler } from '../views/Document/renderDocumentSlots.js' import { renderListHandler } from '../views/List/handleServerFunction.js' import { initReq } from './initReq.js' -import { slugifyHandler } from './slugify.js' const baseServerFunctions: Record> = { 'copy-data-from-locale': copyDataFromLocaleHandler, diff --git a/packages/next/src/utilities/initReq.ts b/packages/next/src/utilities/initReq.ts index 5b3123d6c12..d178c2cb7d7 100644 --- a/packages/next/src/utilities/initReq.ts +++ b/packages/next/src/utilities/initReq.ts @@ -2,6 +2,8 @@ import type { I18n, I18nClient } from '@payloadcms/translations' import type { ImportMap, InitReqResult, PayloadRequest, SanitizedConfig } from 'payload' import { initI18n } from '@payloadcms/translations' +import { getRequestLocale } from '@payloadcms/ui/utilities/getRequestLocale' +import { selectiveCache } from '@payloadcms/ui/utilities/selectiveCache' import { headers as getHeaders } from 'next/headers.js' import { createLocalReq, @@ -12,9 +14,6 @@ import { parseCookies, } from 'payload' -import { getRequestLocale } from './getRequestLocale.js' -import { selectiveCache } from './selectiveCache.js' - type PartialResult = { i18n: I18nClient } & Pick & diff --git a/packages/next/src/views/Root/index.tsx b/packages/next/src/views/Root/index.tsx index e5514486fa1..8901e61842a 100644 --- a/packages/next/src/views/Root/index.tsx +++ b/packages/next/src/views/Root/index.tsx @@ -14,6 +14,9 @@ import { PageConfigProvider } from '@payloadcms/ui' import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' import { getVisibleEntities } from '@payloadcms/ui/shared' import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig' +import { handleAuthRedirect } from '@payloadcms/ui/utilities/handleAuthRedirect' +import { isCustomAdminView } from '@payloadcms/ui/utilities/isCustomAdminView' +import { isPublicAdminRoute } from '@payloadcms/ui/utilities/isPublicAdminRoute' import { notFound, redirect } from 'next/navigation.js' import { applyLocaleFiltering, formatAdminURL } from 'payload/shared' import * as qs from 'qs-esm' @@ -22,10 +25,7 @@ import React from 'react' import { DefaultTemplate } from '../../templates/Default/index.js' import { MinimalTemplate } from '../../templates/Minimal/index.js' import { getPreferences } from '../../utilities/getPreferences.js' -import { handleAuthRedirect } from '../../utilities/handleAuthRedirect.js' import { initReq } from '../../utilities/initReq.js' -import { isCustomAdminView } from '../../utilities/isCustomAdminView.js' -import { isPublicAdminRoute } from '../../utilities/isPublicAdminRoute.js' import { getCustomViewByRoute } from './getCustomViewByRoute.js' import { getRouteData } from './getRouteData.js' diff --git a/packages/ui/package.json b/packages/ui/package.json index 73b33c0779a..ca0c7dbc64d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -101,6 +101,51 @@ "types": "./src/utilities/buildFieldSchemaMap/traverseFields.ts", "default": "./src/utilities/buildFieldSchemaMap/traverseFields.ts" }, + "./utilities/selectiveCache": { + "import": "./src/utilities/selectiveCache.ts", + "types": "./src/utilities/selectiveCache.ts", + "default": "./src/utilities/selectiveCache.ts" + }, + "./utilities/getRequestLocale": { + "import": "./src/utilities/getRequestLocale.ts", + "types": "./src/utilities/getRequestLocale.ts", + "default": "./src/utilities/getRequestLocale.ts" + }, + "./utilities/handleAuthRedirect": { + "import": "./src/utilities/handleAuthRedirect.ts", + "types": "./src/utilities/handleAuthRedirect.ts", + "default": "./src/utilities/handleAuthRedirect.ts" + }, + "./utilities/isCustomAdminView": { + "import": "./src/utilities/isCustomAdminView.ts", + "types": "./src/utilities/isCustomAdminView.ts", + "default": "./src/utilities/isCustomAdminView.ts" + }, + "./utilities/isPublicAdminRoute": { + "import": "./src/utilities/isPublicAdminRoute.ts", + "types": "./src/utilities/isPublicAdminRoute.ts", + "default": "./src/utilities/isPublicAdminRoute.ts" + }, + "./utilities/getRouteWithoutAdmin": { + "import": "./src/utilities/getRouteWithoutAdmin.ts", + "types": "./src/utilities/getRouteWithoutAdmin.ts", + "default": "./src/utilities/getRouteWithoutAdmin.ts" + }, + "./utilities/timestamp": { + "import": "./src/utilities/timestamp.ts", + "types": "./src/utilities/timestamp.ts", + "default": "./src/utilities/timestamp.ts" + }, + "./utilities/slugify": { + "import": "./src/utilities/slugify.ts", + "types": "./src/utilities/slugify.ts", + "default": "./src/utilities/slugify.ts" + }, + "./utilities/handleServerFunctions": { + "import": "./src/utilities/handleServerFunctions.ts", + "types": "./src/utilities/handleServerFunctions.ts", + "default": "./src/utilities/handleServerFunctions.ts" + }, "./forms/fieldSchemasToFormState": { "import": "./src/forms/fieldSchemasToFormState/index.tsx", "types": "./src/forms/fieldSchemasToFormState/index.tsx", diff --git a/packages/ui/src/utilities/getRequestLocale.ts b/packages/ui/src/utilities/getRequestLocale.ts new file mode 100644 index 00000000000..bcd8d77df81 --- /dev/null +++ b/packages/ui/src/utilities/getRequestLocale.ts @@ -0,0 +1,41 @@ +import type { Locale, PayloadRequest } from 'payload' + +import { findLocaleFromCode } from './findLocaleFromCode.js' +import { getPreferences } from './getPreferences.js' +import { upsertPreferences } from './upsertPreferences.js' + +type GetRequestLocalesArgs = { + req: PayloadRequest +} + +export async function getRequestLocale({ req }: GetRequestLocalesArgs): Promise { + if (req.payload.config.localization) { + const localeFromParams = req.query.locale as string | undefined + + if (req.user && localeFromParams) { + await upsertPreferences({ key: 'locale', req, value: localeFromParams }) + } + + return ( + (req.user && + findLocaleFromCode( + req.payload.config.localization, + localeFromParams || + ( + await getPreferences( + 'locale', + req.payload, + req.user.id, + req.user.collection, + ) + )?.value, + )) || + findLocaleFromCode( + req.payload.config.localization, + req.payload.config.localization.defaultLocale || 'en', + ) + ) + } + + return undefined +} diff --git a/packages/ui/src/utilities/getRouteWithoutAdmin.ts b/packages/ui/src/utilities/getRouteWithoutAdmin.ts new file mode 100644 index 00000000000..d68aa62bca0 --- /dev/null +++ b/packages/ui/src/utilities/getRouteWithoutAdmin.ts @@ -0,0 +1,9 @@ +export const getRouteWithoutAdmin = ({ + adminRoute, + route, +}: { + adminRoute: string + route: string +}): string => { + return adminRoute && adminRoute !== '/' ? route.replace(adminRoute, '') : route +} diff --git a/packages/ui/src/utilities/handleAuthRedirect.ts b/packages/ui/src/utilities/handleAuthRedirect.ts new file mode 100644 index 00000000000..be6f00d263b --- /dev/null +++ b/packages/ui/src/utilities/handleAuthRedirect.ts @@ -0,0 +1,47 @@ +import type { TypedUser } from 'payload' + +import { formatAdminURL } from 'payload/shared' +import * as qs from 'qs-esm' + +type Args = { + config + route: string + searchParams: { [key: string]: string | string[] } + user?: TypedUser +} + +export const handleAuthRedirect = ({ config, route, searchParams, user }: Args): string => { + const { + admin: { + routes: { login: loginRouteFromConfig, unauthorized: unauthorizedRoute }, + }, + routes: { admin: adminRoute }, + } = config + + if (searchParams && 'redirect' in searchParams) { + delete searchParams.redirect + } + + const redirectRoute = + (route !== adminRoute ? route : '') + + (Object.keys(searchParams ?? {}).length > 0 + ? `${qs.stringify(searchParams, { addQueryPrefix: true })}` + : '') + + const redirectTo = formatAdminURL({ + adminRoute, + path: user ? unauthorizedRoute : loginRouteFromConfig, + }) + + const parsedLoginRouteSearchParams = qs.parse(redirectTo.split('?')[1] ?? '') + + const searchParamsWithRedirect = `${qs.stringify( + { + ...parsedLoginRouteSearchParams, + ...(redirectRoute ? { redirect: redirectRoute } : {}), + }, + { addQueryPrefix: true }, + )}` + + return `${redirectTo.split('?', 1)[0]}${searchParamsWithRedirect}` +} diff --git a/packages/ui/src/utilities/isCustomAdminView.ts b/packages/ui/src/utilities/isCustomAdminView.ts new file mode 100644 index 00000000000..e7883be9148 --- /dev/null +++ b/packages/ui/src/utilities/isCustomAdminView.ts @@ -0,0 +1,39 @@ +import type { SanitizedConfig } from 'payload' + +import { getRouteWithoutAdmin } from './getRouteWithoutAdmin.js' + +/** + * Returns an array of views marked with 'public: true' in the config + */ +export const isCustomAdminView = ({ + adminRoute, + config, + route, +}: { + adminRoute: string + config: SanitizedConfig + route: string +}): boolean => { + if (config.admin?.components?.views) { + const isPublicAdminRoute = Object.entries(config.admin.components.views).some(([_, view]) => { + const routeWithoutAdmin = getRouteWithoutAdmin({ adminRoute, route }) + + if (view.exact) { + if (routeWithoutAdmin === view.path) { + return true + } + } else { + if ( + view.path && + view.path !== '/' && + (routeWithoutAdmin === view.path || routeWithoutAdmin.startsWith(view.path + '/')) + ) { + return true + } + } + return false + }) + return isPublicAdminRoute + } + return false +} diff --git a/packages/ui/src/utilities/isPublicAdminRoute.ts b/packages/ui/src/utilities/isPublicAdminRoute.ts new file mode 100644 index 00000000000..7b59df516b4 --- /dev/null +++ b/packages/ui/src/utilities/isPublicAdminRoute.ts @@ -0,0 +1,43 @@ +import type { SanitizedConfig } from 'payload' + +import { getRouteWithoutAdmin } from './getRouteWithoutAdmin.js' + +// Routes that require admin authentication +const publicAdminRoutes: (keyof Pick< + SanitizedConfig['admin']['routes'], + 'createFirstUser' | 'forgot' | 'inactivity' | 'login' | 'logout' | 'reset' | 'unauthorized' +>)[] = [ + 'createFirstUser', + 'forgot', + 'login', + 'logout', + 'forgot', + 'inactivity', + 'unauthorized', + 'reset', +] + +export const isPublicAdminRoute = ({ + adminRoute, + config, + route, +}: { + adminRoute: string + config: SanitizedConfig + route: string +}): boolean => { + const isPublicAdminRoute = publicAdminRoutes.some((routeSegment) => { + const segment = config.admin?.routes?.[routeSegment] || routeSegment + const routeWithoutAdmin = getRouteWithoutAdmin({ adminRoute, route }) + + if (routeWithoutAdmin.startsWith(segment)) { + return true + } else if (routeWithoutAdmin.includes('/verify/')) { + return true + } else { + return false + } + }) + + return isPublicAdminRoute +} diff --git a/packages/ui/src/utilities/selectiveCache.ts b/packages/ui/src/utilities/selectiveCache.ts new file mode 100644 index 00000000000..f14dba88d40 --- /dev/null +++ b/packages/ui/src/utilities/selectiveCache.ts @@ -0,0 +1,57 @@ +import { cache } from 'react' + +type CachedValue = object + +// Module-scoped cache container that holds all cached, stable containers +// - these may hold the stable value, or a promise to the stable value +const globalCacheContainer: Record< + string, + ( + ...args: unknown[] + ) => { + value: null | Promise | TValue + } +> = {} + +/** + * Creates a selective cache function that provides more control over React's request-level caching behavior. + * + * @param namespace - A namespace to group related cached values + * @returns A function that manages cached values within the specified namespace + */ +export function selectiveCache(namespace: string) { + // Create a stable namespace container if it doesn't exist + if (!globalCacheContainer[namespace]) { + globalCacheContainer[namespace] = cache((..._args) => ({ + value: null, + })) + } + + /** + * Gets or creates a cached value for a specific key within the namespace + * + * @param key - The key to identify the cached value + * @param factory - A function that produces the value if not cached + * @returns The cached or newly created value + */ + const getCached = async (factory: () => Promise, ...cacheArgs): Promise => { + const stableObjectFn = globalCacheContainer[namespace] + const stableObject = stableObjectFn(...cacheArgs) + + if ( + stableObject?.value && + 'then' in stableObject.value && + typeof stableObject.value?.then === 'function' + ) { + return await stableObject.value + } + + stableObject.value = factory() + + return await stableObject.value + } + + return { + get: getCached, + } +} diff --git a/packages/ui/src/utilities/slugify.ts b/packages/ui/src/utilities/slugify.ts new file mode 100644 index 00000000000..fa64c640bca --- /dev/null +++ b/packages/ui/src/utilities/slugify.ts @@ -0,0 +1,58 @@ +import type { Slugify } from 'payload/shared' + +import { + flattenAllFields, + getFieldByPath, + type ServerFunction, + type SlugifyServerFunctionArgs, + UnauthorizedError, +} from 'payload' +import { slugify as defaultSlugify } from 'payload/shared' + +/** + * This server function is directly related to the {@link https://payloadcms.com/docs/fields/text#slug-field | Slug Field}. + * This is a server function that is used to invoke the user's custom slugify function from the client. + * This pattern is required, as there is no other way for us to pass their function across the client-server boundary. + * - Not through props + * - Not from a server function defined within a server component (see below) + * When a server function contains non-serializable data within its closure, it gets passed through the boundary (and breaks). + * The only way to pass server functions to the client (that contain non-serializable data) is if it is globally defined. + * But we also cannot define this function alongside the server component, as we will not have access to their custom slugify function. + * See `ServerFunctionsProvider` for more details. + */ +export const slugifyHandler: ServerFunction< + SlugifyServerFunctionArgs, + Promise> +> = async (args) => { + const { collectionSlug, data, globalSlug, path, req, valueToSlugify } = args + + if (!req.user) { + throw new UnauthorizedError() + } + + const docConfig = collectionSlug + ? req.payload.collections[collectionSlug]?.config + : globalSlug + ? req.payload.config.globals.find((g) => g.slug === globalSlug) + : null + + if (!docConfig) { + throw new Error() + } + + const { field } = getFieldByPath({ + config: req.payload.config, + fields: flattenAllFields({ fields: docConfig.fields }), + path, + }) + + const customSlugify = ( + typeof field?.custom?.slugify === 'function' ? field.custom.slugify : undefined + ) as Slugify + + const result = customSlugify + ? await customSlugify({ data, req, valueToSlugify }) + : defaultSlugify(valueToSlugify) + + return result +} diff --git a/packages/ui/src/utilities/timestamp.ts b/packages/ui/src/utilities/timestamp.ts new file mode 100644 index 00000000000..3ab2452243e --- /dev/null +++ b/packages/ui/src/utilities/timestamp.ts @@ -0,0 +1,8 @@ +export const timestamp = (label: string) => { + if (!process.env.PAYLOAD_TIME) { + process.env.PAYLOAD_TIME = String(new Date().getTime()) + } + const now = new Date() + // eslint-disable-next-line no-console + console.log(`[${now.getTime() - Number(process.env.PAYLOAD_TIME)}ms] ${label}`) +} From f1cd7575d3c27680c2e07883ac2f4d9419887651 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 19:17:24 +0100 Subject: [PATCH 15/60] refactor(ui): move Nav and DocumentHeader elements from packages/next Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../DocumentHeader/Tabs/ShouldRenderTabs.tsx | 19 ++ .../DocumentHeader/Tabs/Tab/TabLink.tsx | 76 +++++++ .../DocumentHeader/Tabs/Tab/index.scss | 38 ++++ .../DocumentHeader/Tabs/Tab/index.tsx | 93 ++++++++ .../elements/DocumentHeader/Tabs/index.scss | 54 +++++ .../elements/DocumentHeader/Tabs/index.tsx | 86 +++++++ .../Tabs/tabs/VersionsPill/index.scss | 10 + .../Tabs/tabs/VersionsPill/index.tsx | 17 ++ .../DocumentHeader/Tabs/tabs/index.tsx | 87 +++++++ .../ui/src/elements/DocumentHeader/index.scss | 64 ++++++ .../ui/src/elements/DocumentHeader/index.tsx | 46 ++++ .../src/elements/Nav/NavHamburger/index.tsx | 27 +++ .../ui/src/elements/Nav/NavWrapper/index.scss | 27 +++ .../ui/src/elements/Nav/NavWrapper/index.tsx | 35 +++ .../Nav/SettingsMenuButton/index.scss | 11 + .../elements/Nav/SettingsMenuButton/index.tsx | 36 +++ packages/ui/src/elements/Nav/getNavPrefs.ts | 37 +++ packages/ui/src/elements/Nav/index.client.tsx | 98 ++++++++ packages/ui/src/elements/Nav/index.scss | 173 ++++++++++++++ packages/ui/src/elements/Nav/index.tsx | 213 ++++++++++++++++++ 20 files changed, 1247 insertions(+) create mode 100644 packages/ui/src/elements/DocumentHeader/Tabs/ShouldRenderTabs.tsx create mode 100644 packages/ui/src/elements/DocumentHeader/Tabs/Tab/TabLink.tsx create mode 100644 packages/ui/src/elements/DocumentHeader/Tabs/Tab/index.scss create mode 100644 packages/ui/src/elements/DocumentHeader/Tabs/Tab/index.tsx create mode 100644 packages/ui/src/elements/DocumentHeader/Tabs/index.scss create mode 100644 packages/ui/src/elements/DocumentHeader/Tabs/index.tsx create mode 100644 packages/ui/src/elements/DocumentHeader/Tabs/tabs/VersionsPill/index.scss create mode 100644 packages/ui/src/elements/DocumentHeader/Tabs/tabs/VersionsPill/index.tsx create mode 100644 packages/ui/src/elements/DocumentHeader/Tabs/tabs/index.tsx create mode 100644 packages/ui/src/elements/DocumentHeader/index.scss create mode 100644 packages/ui/src/elements/DocumentHeader/index.tsx create mode 100644 packages/ui/src/elements/Nav/NavHamburger/index.tsx create mode 100644 packages/ui/src/elements/Nav/NavWrapper/index.scss create mode 100644 packages/ui/src/elements/Nav/NavWrapper/index.tsx create mode 100644 packages/ui/src/elements/Nav/SettingsMenuButton/index.scss create mode 100644 packages/ui/src/elements/Nav/SettingsMenuButton/index.tsx create mode 100644 packages/ui/src/elements/Nav/getNavPrefs.ts create mode 100644 packages/ui/src/elements/Nav/index.client.tsx create mode 100644 packages/ui/src/elements/Nav/index.scss create mode 100644 packages/ui/src/elements/Nav/index.tsx diff --git a/packages/ui/src/elements/DocumentHeader/Tabs/ShouldRenderTabs.tsx b/packages/ui/src/elements/DocumentHeader/Tabs/ShouldRenderTabs.tsx new file mode 100644 index 00000000000..9df2e3f258b --- /dev/null +++ b/packages/ui/src/elements/DocumentHeader/Tabs/ShouldRenderTabs.tsx @@ -0,0 +1,19 @@ +'use client' +import type React from 'react' + +import { useDocumentInfo } from '../../../providers/DocumentInfo/index.js' + +export const ShouldRenderTabs: React.FC<{ + children: React.ReactNode +}> = ({ children }) => { + const { id: idFromContext, collectionSlug, globalSlug } = useDocumentInfo() + + const id = idFromContext !== 'create' ? idFromContext : null + + // Don't show tabs when creating new documents + if ((collectionSlug && id) || globalSlug) { + return children + } + + return null +} diff --git a/packages/ui/src/elements/DocumentHeader/Tabs/Tab/TabLink.tsx b/packages/ui/src/elements/DocumentHeader/Tabs/Tab/TabLink.tsx new file mode 100644 index 00000000000..ef4ba4efb57 --- /dev/null +++ b/packages/ui/src/elements/DocumentHeader/Tabs/Tab/TabLink.tsx @@ -0,0 +1,76 @@ +'use client' +import type { SanitizedConfig } from 'payload' + +import { formatAdminURL } from 'payload/shared' +import React from 'react' + +import { useParams } from '../../../../providers/Params/index.js' +import { usePathname } from '../../../../providers/Router/index.js' +import { useSearchParams } from '../../../../providers/SearchParams/index.js' +import { Button } from '../../../Button/index.js' + +export const DocumentTabLink: React.FC<{ + adminRoute: SanitizedConfig['routes']['admin'] + ariaLabel?: string + baseClass: string + children?: React.ReactNode + href: string + isActive?: boolean + newTab?: boolean +}> = ({ + adminRoute, + ariaLabel, + baseClass, + children, + href: hrefFromProps, + isActive: isActiveFromProps, + newTab, +}) => { + const pathname = usePathname() + const params = useParams() + + const searchParams = useSearchParams() + + const locale = searchParams.get('locale') + + const [entityType, entitySlug, segmentThree, segmentFour, ...rest] = params.segments || [] + const isCollection = entityType === 'collections' + + let docPath = formatAdminURL({ + adminRoute, + path: `/${isCollection ? 'collections' : 'globals'}/${entitySlug}`, + }) + + if (isCollection) { + if (segmentThree === 'trash' && segmentFour) { + docPath += `/trash/${segmentFour}` + } else if (segmentThree) { + docPath += `/${segmentThree}` + } + } + + const href = `${docPath}${hrefFromProps}` + // separated the two so it doesn't break checks against pathname + const hrefWithLocale = `${href}${locale ? `?locale=${locale}` : ''}` + + const isActive = + (href === docPath && pathname === docPath) || + (href !== docPath && pathname.startsWith(href)) || + isActiveFromProps + + return ( + + ) +} diff --git a/packages/ui/src/elements/DocumentHeader/Tabs/Tab/index.scss b/packages/ui/src/elements/DocumentHeader/Tabs/Tab/index.scss new file mode 100644 index 00000000000..462c6a1660f --- /dev/null +++ b/packages/ui/src/elements/DocumentHeader/Tabs/Tab/index.scss @@ -0,0 +1,38 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .doc-tab { + display: flex; + justify-content: center; + align-items: center; + white-space: nowrap; + + &:hover { + .pill-version-count { + background-color: var(--theme-elevation-150); + } + } + + &--active { + .pill-version-count { + background-color: var(--theme-elevation-250); + } + + &:hover { + .pill-version-count { + background-color: var(--theme-elevation-250); + } + } + } + + &__label { + display: flex; + position: relative; + align-items: center; + gap: 4px; + width: 100%; + height: 100%; + line-height: calc(var(--base) * 1.2); + } + } +} diff --git a/packages/ui/src/elements/DocumentHeader/Tabs/Tab/index.tsx b/packages/ui/src/elements/DocumentHeader/Tabs/Tab/index.tsx new file mode 100644 index 00000000000..44006223d66 --- /dev/null +++ b/packages/ui/src/elements/DocumentHeader/Tabs/Tab/index.tsx @@ -0,0 +1,93 @@ +import type { + DocumentTabConfig, + DocumentTabServerPropsOnly, + PayloadRequest, + SanitizedCollectionConfig, + SanitizedGlobalConfig, + SanitizedPermissions, +} from 'payload' +import type React from 'react' + +import { Fragment } from 'react' + +import { RenderServerComponent } from '../../../RenderServerComponent/index.js' +import { DocumentTabLink } from './TabLink.js' +import './index.scss' + +export const baseClass = 'doc-tab' + +export const DefaultDocumentTab: React.FC<{ + apiURL?: string + collectionConfig?: SanitizedCollectionConfig + globalConfig?: SanitizedGlobalConfig + path?: string + permissions?: SanitizedPermissions + req: PayloadRequest + tabConfig: { readonly Pill_Component?: React.FC } & DocumentTabConfig +}> = (props) => { + const { + apiURL, + collectionConfig, + globalConfig, + permissions, + req, + tabConfig: { href: tabHref, isActive: tabIsActive, label, newTab, Pill, Pill_Component }, + } = props + + let href = typeof tabHref === 'string' ? tabHref : '' + let isActive = typeof tabIsActive === 'boolean' ? tabIsActive : false + + if (typeof tabHref === 'function') { + href = tabHref({ + apiURL, + collection: collectionConfig, + global: globalConfig, + routes: req.payload.config.routes, + }) + } + + if (typeof tabIsActive === 'function') { + isActive = tabIsActive({ + href, + }) + } + + const labelToRender = + typeof label === 'function' + ? label({ + t: req.i18n.t, + }) + : label + + return ( + + + {labelToRender} + {Pill || Pill_Component ? ( + +   + {RenderServerComponent({ + Component: Pill, + Fallback: Pill_Component, + importMap: req.payload.importMap, + serverProps: { + i18n: req.i18n, + payload: req.payload, + permissions, + req, + user: req.user, + } satisfies DocumentTabServerPropsOnly, + })} + + ) : null} + + + ) +} diff --git a/packages/ui/src/elements/DocumentHeader/Tabs/index.scss b/packages/ui/src/elements/DocumentHeader/Tabs/index.scss new file mode 100644 index 00000000000..0fe1c652e9b --- /dev/null +++ b/packages/ui/src/elements/DocumentHeader/Tabs/index.scss @@ -0,0 +1,54 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .doc-tabs { + display: flex; + + &__tabs { + display: flex; + gap: calc(var(--base) / 2); + list-style: none; + align-items: center; + margin: 0; + padding-left: 0; + } + + @include mid-break { + width: 100%; + padding: 0; + overflow: auto; + + // this container has a gradient overlay as visual indication of `overflow: scroll` + &::-webkit-scrollbar { + display: none; + } + + &::after { + content: ''; + display: block; + position: sticky; + right: 0; + width: calc(var(--base) * 2); + height: calc(var(--base) * 2); + background: linear-gradient(to right, transparent, var(--theme-bg)); + flex-shrink: 0; + z-index: 1111; + pointer-events: none; + } + + &__tabs { + padding: 0; + } + } + + @include small-break { + &__tabs-container { + margin-right: var(--gutter-h); + } + + &__tabs { + gap: var(--gutter-h); + } + } + } +} diff --git a/packages/ui/src/elements/DocumentHeader/Tabs/index.tsx b/packages/ui/src/elements/DocumentHeader/Tabs/index.tsx new file mode 100644 index 00000000000..cefbc3ac278 --- /dev/null +++ b/packages/ui/src/elements/DocumentHeader/Tabs/index.tsx @@ -0,0 +1,86 @@ +import type { + DocumentTabClientProps, + DocumentTabServerPropsOnly, + PayloadRequest, + SanitizedCollectionConfig, + SanitizedGlobalConfig, + SanitizedPermissions, +} from 'payload' + +import React from 'react' + +import { RenderServerComponent } from '../../RenderServerComponent/index.js' +import { ShouldRenderTabs } from './ShouldRenderTabs.js' +import { DefaultDocumentTab } from './Tab/index.js' +import { getTabs } from './tabs/index.js' +import './index.scss' + +const baseClass = 'doc-tabs' + +export const DocumentTabs: React.FC<{ + collectionConfig: SanitizedCollectionConfig + globalConfig: SanitizedGlobalConfig + permissions: SanitizedPermissions + req: PayloadRequest +}> = ({ collectionConfig, globalConfig, permissions, req }) => { + const { config } = req.payload + + const tabs = getTabs({ + collectionConfig, + globalConfig, + }) + + return ( + +
+
+
    + {tabs?.map(({ tab: tabConfig, viewPath }, index) => { + const { condition } = tabConfig || {} + + const meetsCondition = + !condition || + condition({ collectionConfig, config, globalConfig, permissions, req }) + + if (!meetsCondition) { + return null + } + + if (tabConfig?.Component) { + return RenderServerComponent({ + clientProps: { + path: viewPath, + } satisfies DocumentTabClientProps, + Component: tabConfig.Component, + importMap: req.payload.importMap, + key: `tab-${index}`, + serverProps: { + collectionConfig, + globalConfig, + i18n: req.i18n, + payload: req.payload, + permissions, + req, + user: req.user, + } satisfies DocumentTabServerPropsOnly, + }) + } + + return ( + + ) + })} +
+
+
+
+ ) +} diff --git a/packages/ui/src/elements/DocumentHeader/Tabs/tabs/VersionsPill/index.scss b/packages/ui/src/elements/DocumentHeader/Tabs/tabs/VersionsPill/index.scss new file mode 100644 index 00000000000..e1183b6cdfc --- /dev/null +++ b/packages/ui/src/elements/DocumentHeader/Tabs/tabs/VersionsPill/index.scss @@ -0,0 +1,10 @@ +@layer payload-default { + .pill-version-count { + line-height: calc(var(--base) * 0.8); + min-width: calc(var(--base) * 0.8); + text-align: center; + background-color: var(--theme-elevation-100); + border-radius: var(--style-radius-s); + padding: 2px 4px; + } +} diff --git a/packages/ui/src/elements/DocumentHeader/Tabs/tabs/VersionsPill/index.tsx b/packages/ui/src/elements/DocumentHeader/Tabs/tabs/VersionsPill/index.tsx new file mode 100644 index 00000000000..d9c1c8f59a2 --- /dev/null +++ b/packages/ui/src/elements/DocumentHeader/Tabs/tabs/VersionsPill/index.tsx @@ -0,0 +1,17 @@ +'use client' +import React from 'react' + +import { useDocumentInfo } from '../../../../../providers/DocumentInfo/index.js' +import './index.scss' + +const baseClass = 'pill-version-count' + +export const VersionsPill: React.FC = () => { + const { versionCount } = useDocumentInfo() + + if (!versionCount) { + return null + } + + return {versionCount} +} diff --git a/packages/ui/src/elements/DocumentHeader/Tabs/tabs/index.tsx b/packages/ui/src/elements/DocumentHeader/Tabs/tabs/index.tsx new file mode 100644 index 00000000000..8b28407d805 --- /dev/null +++ b/packages/ui/src/elements/DocumentHeader/Tabs/tabs/index.tsx @@ -0,0 +1,87 @@ +import type { DocumentTabConfig, SanitizedCollectionConfig, SanitizedGlobalConfig } from 'payload' + +import { VersionsPill } from './VersionsPill/index.js' + +export const documentViewKeys = ['api', 'default', 'livePreview', 'versions'] + +export type DocumentViewKey = (typeof documentViewKeys)[number] + +export const getTabs = ({ + collectionConfig, + globalConfig, +}: { + collectionConfig?: SanitizedCollectionConfig + globalConfig?: SanitizedGlobalConfig +}): { tab: DocumentTabConfig; viewPath: string }[] => { + const customViews = + collectionConfig?.admin?.components?.views?.edit || + globalConfig?.admin?.components?.views?.edit || + {} + + return [ + { + tab: { + href: '', + label: ({ t }) => t('general:edit'), + order: 100, + ...(customViews?.['default']?.tab || {}), + }, + viewPath: '/', + }, + { + tab: { + condition: ({ collectionConfig, globalConfig, permissions }) => + Boolean( + (collectionConfig?.versions && + permissions?.collections?.[collectionConfig?.slug]?.readVersions) || + (globalConfig?.versions && permissions?.globals?.[globalConfig?.slug]?.readVersions), + ), + href: '/versions', + label: ({ t }) => t('version:versions'), + order: 300, + Pill_Component: VersionsPill, + ...(customViews?.['versions']?.tab || {}), + }, + viewPath: '/versions', + }, + { + tab: { + condition: ({ collectionConfig, globalConfig }) => + (collectionConfig && !collectionConfig?.admin?.hideAPIURL) || + (globalConfig && !globalConfig?.admin?.hideAPIURL), + href: '/api', + label: 'API', + order: 400, + ...(customViews?.['api']?.tab || {}), + }, + viewPath: '/api', + }, + ] + .concat( + Object.entries(customViews).reduce((acc, [key, value]) => { + if (documentViewKeys.includes(key)) { + return acc + } + + if (value?.tab) { + acc.push({ + tab: value.tab, + viewPath: 'path' in value ? value.path : '', + }) + } + + return acc + }, []), + ) + ?.sort(({ tab: a }, { tab: b }) => { + if (a.order === undefined && b.order === undefined) { + return 0 + } else if (a.order === undefined) { + return 1 + } else if (b.order === undefined) { + return -1 + } + + return a.order - b.order + }) +} diff --git a/packages/ui/src/elements/DocumentHeader/index.scss b/packages/ui/src/elements/DocumentHeader/index.scss new file mode 100644 index 00000000000..1e0ddacf5b0 --- /dev/null +++ b/packages/ui/src/elements/DocumentHeader/index.scss @@ -0,0 +1,64 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .doc-header { + width: 100%; + margin-top: calc(var(--base) * 0.4); + padding-bottom: calc(var(--base) * 1.2); + position: relative; + + &::after { + content: ''; + display: block; + position: absolute; + height: 1px; + background: var(--theme-elevation-100); + width: 100%; + left: 0; + top: calc(100% - 1px); + } + + &__header { + display: flex; + align-items: center; + gap: calc(var(--base) / 2); + } + + &__title { + flex-grow: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin: 0; + padding-bottom: calc(var(--base) * 0.4); + vertical-align: top; + padding-bottom: 0; + } + + &__after-header { + padding-top: calc(var(--base) * 0.8); + } + + @include mid-break { + margin-top: calc(var(--base) * 0.25); + padding-bottom: calc(var(--base) / 1.5); + + &__header { + flex-direction: column; + gap: calc(var(--base) / 2); + } + + &__title { + width: 100%; + } + + &__after-header { + padding-top: calc(var(--base) / 4); + } + } + + @include small-break { + margin-top: 0; + } + } +} diff --git a/packages/ui/src/elements/DocumentHeader/index.tsx b/packages/ui/src/elements/DocumentHeader/index.tsx new file mode 100644 index 00000000000..a1f88abcbb9 --- /dev/null +++ b/packages/ui/src/elements/DocumentHeader/index.tsx @@ -0,0 +1,46 @@ +import type { + PayloadRequest, + SanitizedCollectionConfig, + SanitizedGlobalConfig, + SanitizedPermissions, +} from 'payload' + +import React from 'react' + +import { Gutter } from '../Gutter/index.js' +import { RenderTitle } from '../RenderTitle/index.js' +import { DocumentTabs } from './Tabs/index.js' +import './index.scss' + +const baseClass = `doc-header` + +/** + * @internal + */ +export const DocumentHeader: React.FC<{ + AfterHeader?: React.ReactNode + collectionConfig?: SanitizedCollectionConfig + globalConfig?: SanitizedGlobalConfig + hideTabs?: boolean + permissions: SanitizedPermissions + req: PayloadRequest +}> = (props) => { + const { AfterHeader, collectionConfig, globalConfig, hideTabs, permissions, req } = props + + return ( + +
+ + {!hideTabs && ( + + )} +
+ {AfterHeader ?
{AfterHeader}
: null} +
+ ) +} diff --git a/packages/ui/src/elements/Nav/NavHamburger/index.tsx b/packages/ui/src/elements/Nav/NavHamburger/index.tsx new file mode 100644 index 00000000000..edb42a4cede --- /dev/null +++ b/packages/ui/src/elements/Nav/NavHamburger/index.tsx @@ -0,0 +1,27 @@ +'use client' +import React from 'react' + +import { Hamburger } from '../../Hamburger/index.js' +import { useNav } from '../context.js' + +/** + * @internal + */ +export const NavHamburger: React.FC<{ + baseClass?: string +}> = ({ baseClass }) => { + const { navOpen, setNavOpen } = useNav() + + return ( + + ) +} diff --git a/packages/ui/src/elements/Nav/NavWrapper/index.scss b/packages/ui/src/elements/Nav/NavWrapper/index.scss new file mode 100644 index 00000000000..7ba9c6f7cd4 --- /dev/null +++ b/packages/ui/src/elements/Nav/NavWrapper/index.scss @@ -0,0 +1,27 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .nav { + position: sticky; + top: 0; + left: 0; + flex-shrink: 0; + height: 100vh; + width: var(--nav-width); + border-right: 1px solid var(--theme-elevation-100); + opacity: 0; + + [dir='rtl'] & { + border-right: none; + border-left: 1px solid var(--theme-elevation-100); + } + + &--nav-animate { + transition: opacity var(--nav-trans-time) ease-in-out; + } + + &--nav-open { + opacity: 1; + } + } +} diff --git a/packages/ui/src/elements/Nav/NavWrapper/index.tsx b/packages/ui/src/elements/Nav/NavWrapper/index.tsx new file mode 100644 index 00000000000..3fe8faaebf5 --- /dev/null +++ b/packages/ui/src/elements/Nav/NavWrapper/index.tsx @@ -0,0 +1,35 @@ +'use client' +import React from 'react' + +import { useNav } from '../context.js' +import './index.scss' + +/** + * @internal + */ +export const NavWrapper: React.FC<{ + baseClass?: string + children: React.ReactNode +}> = (props) => { + const { baseClass, children } = props + + const { hydrated, navOpen, navRef, shouldAnimate } = useNav() + + return ( + + ) +} diff --git a/packages/ui/src/elements/Nav/SettingsMenuButton/index.scss b/packages/ui/src/elements/Nav/SettingsMenuButton/index.scss new file mode 100644 index 00000000000..02a1c5e0f44 --- /dev/null +++ b/packages/ui/src/elements/Nav/SettingsMenuButton/index.scss @@ -0,0 +1,11 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .settings-menu-button { + &.popup--h-align-left { + .popup__content { + left: calc(var(--nav-padding-inline-start) * -0.5); + } + } + } +} diff --git a/packages/ui/src/elements/Nav/SettingsMenuButton/index.tsx b/packages/ui/src/elements/Nav/SettingsMenuButton/index.tsx new file mode 100644 index 00000000000..fa917fb1c2c --- /dev/null +++ b/packages/ui/src/elements/Nav/SettingsMenuButton/index.tsx @@ -0,0 +1,36 @@ +'use client' +import React, { Fragment } from 'react' + +import { GearIcon } from '../../../icons/Gear/index.js' +import { useTranslation } from '../../../providers/Translation/index.js' +import { Popup } from '../../Popup/index.js' +import './index.scss' + +const baseClass = 'settings-menu-button' + +export type SettingsMenuButtonProps = { + settingsMenu?: React.ReactNode[] +} + +export const SettingsMenuButton: React.FC = ({ settingsMenu }) => { + const { t } = useTranslation() + + if (!settingsMenu || settingsMenu.length === 0) { + return null + } + + return ( + } + className={baseClass} + horizontalAlign="left" + id="settings-menu" + size="small" + verticalAlign="bottom" + > + {settingsMenu.map((item, i) => ( + {item} + ))} + + ) +} diff --git a/packages/ui/src/elements/Nav/getNavPrefs.ts b/packages/ui/src/elements/Nav/getNavPrefs.ts new file mode 100644 index 00000000000..b6cdadacecd --- /dev/null +++ b/packages/ui/src/elements/Nav/getNavPrefs.ts @@ -0,0 +1,37 @@ +import type { NavPreferences, PayloadRequest } from 'payload' + +import { PREFERENCE_KEYS } from 'payload/shared' +import { cache } from 'react' + +export const getNavPrefs = cache(async (req: PayloadRequest): Promise => { + return req?.user?.collection + ? await req.payload + .find({ + collection: 'payload-preferences', + depth: 0, + limit: 1, + pagination: false, + req, + where: { + and: [ + { + key: { + equals: PREFERENCE_KEYS.NAV, + }, + }, + { + 'user.relationTo': { + equals: req.user.collection, + }, + }, + { + 'user.value': { + equals: req?.user?.id, + }, + }, + ], + }, + }) + ?.then((res) => res?.docs?.[0]?.value) + : null +}) diff --git a/packages/ui/src/elements/Nav/index.client.tsx b/packages/ui/src/elements/Nav/index.client.tsx new file mode 100644 index 00000000000..0cde176a367 --- /dev/null +++ b/packages/ui/src/elements/Nav/index.client.tsx @@ -0,0 +1,98 @@ +'use client' + +import type { NavPreferences } from 'payload' + +import { getTranslation } from '@payloadcms/translations' +import { formatAdminURL } from 'payload/shared' +import React, { Fragment } from 'react' + +import type { groupNavItems } from '../../utilities/groupNavItems.js' + +import { useConfig } from '../../providers/Config/index.js' +import { Link, usePathname } from '../../providers/Router/index.js' +import { useTranslation } from '../../providers/Translation/index.js' +import { EntityType } from '../../utilities/groupNavItems.js' +import { BrowseByFolderButton } from '../FolderView/BrowseByFolderButton/index.js' +import { NavGroup } from '../NavGroup/index.js' + +const baseClass = 'nav' + +/** + * @internal + */ +export const DefaultNavClient: React.FC<{ + groups: ReturnType + navPreferences: NavPreferences +}> = ({ groups, navPreferences }) => { + const pathname = usePathname() + + const { + config: { + admin: { + routes: { browseByFolder: foldersRoute }, + }, + folders, + routes: { admin: adminRoute }, + }, + } = useConfig() + + const { i18n } = useTranslation() + + const folderURL = formatAdminURL({ + adminRoute, + path: foldersRoute, + }) + + const viewingRootFolderView = pathname.startsWith(folderURL) + + return ( + + {folders && folders.browseByFolder && } + {groups.map(({ entities, label }, key) => { + return ( + + {entities.map(({ slug, type, label }, i) => { + let href: string + let id: string + + if (type === EntityType.collection) { + href = formatAdminURL({ adminRoute, path: `/collections/${slug}` }) + id = `nav-${slug}` + } + + if (type === EntityType.global) { + href = formatAdminURL({ adminRoute, path: `/globals/${slug}` }) + id = `nav-global-${slug}` + } + + const isActive = + pathname.startsWith(href) && ['/', undefined].includes(pathname[href.length]) + + const Label = ( + <> + {isActive &&
} + {getTranslation(label, i18n)} + + ) + + // If the URL matches the link exactly + if (pathname === href) { + return ( +
+ {Label} +
+ ) + } + + return ( + + {Label} + + ) + })} + + ) + })} + + ) +} diff --git a/packages/ui/src/elements/Nav/index.scss b/packages/ui/src/elements/Nav/index.scss new file mode 100644 index 00000000000..ad85dd60635 --- /dev/null +++ b/packages/ui/src/elements/Nav/index.scss @@ -0,0 +1,173 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .nav { + position: sticky; + top: 0; + left: 0; + flex-shrink: 0; + height: 100vh; + width: var(--nav-width); + border-right: 1px solid var(--theme-elevation-100); + opacity: 0; + overflow: hidden; + --nav-padding-inline-start: var(--base); + --nav-padding-inline-end: var(--base); + --nav-padding-block-start: var(--app-header-height); + --nav-padding-block-end: calc(var(--base) * 2); + + [dir='rtl'] & { + border-right: none; + border-left: 1px solid var(--theme-elevation-100); + } + + &--nav-animate { + transition: opacity var(--nav-trans-time) ease-in-out; + } + + &--nav-open { + opacity: 1; + } + + &__header { + position: absolute; + top: 0; + width: 100vw; + height: var(--app-header-height); + } + + &__header-content { + z-index: 1; + position: relative; + height: 100%; + width: 100%; + } + + &__mobile-close { + display: none; + background: none; + border: 0; + outline: 0; + padding: base(0.8) 0; + } + + &__scroll { + height: 100%; + display: flex; + flex-direction: column; + padding: var(--nav-padding-block-start) var(--nav-padding-inline-end) + var(--nav-padding-block-end) var(--nav-padding-inline-start); + overflow-y: auto; + + // remove the scrollbar here to prevent layout shift as nav groups are toggled + &::-webkit-scrollbar { + display: none; + } + } + + &__wrap { + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + flex-grow: 1; + } + + &__label { + color: var(--theme-elevation-400); + } + + &__controls { + display: flex; + flex-direction: column; + gap: base(0.75); + margin-top: auto; + margin-bottom: 0; + + > :first-child { + margin-top: base(1); + } + + a:focus-visible { + outline: var(--accessibility-outline); + } + } + + &__log-out { + &:hover { + g { + transform: translateX(-#{base(0.125)}); + } + } + } + + &__link { + display: flex; + align-items: center; + position: relative; + padding-block: base(0.125); + padding-inline-start: 0; + padding-inline-end: base(1.5); + text-decoration: none; + + &:focus:not(:focus-visible) { + box-shadow: none; + font-weight: 600; + } + + &.active { + font-weight: normal; + padding-left: 0; + font-weight: 600; + } + } + + a.nav__link { + &:hover, + &:focus-visible { + text-decoration: underline; + } + } + + &__link:has(.nav__link-indicator) { + font-weight: 600; + padding-left: 0; + } + + &__link-indicator { + position: absolute; + display: block; + // top: 0; + inset-inline-start: base(-1); + width: 2px; + height: 16px; + border-start-end-radius: 2px; + border-end-end-radius: 2px; + background: var(--theme-text); + } + + @include mid-break { + &__scroll { + --nav-padding-inline-start: calc(var(--base) * 0.5); + --nav-padding-inline-end: calc(var(--base) * 0.5); + } + } + + @include small-break { + &__scroll { + --nav-padding-inline-start: var(--gutter-h); + --nav-padding-inline-end: var(--gutter-h); + } + + &__link { + font-size: base(0.875); + line-height: base(1.5); + } + + &__mobile-close { + display: flex; + align-items: center; + } + } + } +} diff --git a/packages/ui/src/elements/Nav/index.tsx b/packages/ui/src/elements/Nav/index.tsx new file mode 100644 index 00000000000..f98f44c9d73 --- /dev/null +++ b/packages/ui/src/elements/Nav/index.tsx @@ -0,0 +1,213 @@ +import type { PayloadRequest, ServerProps } from 'payload' + +import React from 'react' + +import type { EntityToGroup } from '../../utilities/groupNavItems.js' + +import { EntityType, groupNavItems } from '../../utilities/groupNavItems.js' +import { Logout } from '../Logout/index.js' +import { RenderServerComponent } from '../RenderServerComponent/index.js' +import { NavHamburger } from './NavHamburger/index.js' +import { NavWrapper } from './NavWrapper/index.js' +import { SettingsMenuButton } from './SettingsMenuButton/index.js' +import './index.scss' + +const baseClass = 'nav' + +import { getNavPrefs } from './getNavPrefs.js' +import { DefaultNavClient } from './index.client.js' + +export type NavProps = { + req?: PayloadRequest +} & ServerProps + +export const DefaultNav: React.FC = async (props) => { + const { + documentSubViewType, + i18n, + locale, + params, + payload, + permissions, + req, + searchParams, + user, + viewType, + visibleEntities, + } = props + + if (!payload?.config) { + return null + } + + const { + admin: { + components: { afterNav, afterNavLinks, beforeNav, beforeNavLinks, logout, settingsMenu }, + }, + collections, + globals, + } = payload.config + + const groups = groupNavItems( + [ + ...collections + .filter(({ slug }) => visibleEntities.collections.includes(slug)) + .map( + (collection) => + ({ + type: EntityType.collection, + entity: collection, + }) satisfies EntityToGroup, + ), + ...globals + .filter(({ slug }) => visibleEntities.globals.includes(slug)) + .map( + (global) => + ({ + type: EntityType.global, + entity: global, + }) satisfies EntityToGroup, + ), + ], + permissions, + i18n, + ) + + const navPreferences = await getNavPrefs(req) + + const LogoutComponent = RenderServerComponent({ + clientProps: { + documentSubViewType, + viewType, + }, + Component: logout?.Button, + Fallback: Logout, + importMap: payload.importMap, + serverProps: { + i18n, + locale, + params, + payload, + permissions, + searchParams, + user, + }, + }) + + const RenderedSettingsMenu = + settingsMenu && Array.isArray(settingsMenu) + ? settingsMenu.map((item, index) => + RenderServerComponent({ + clientProps: { + documentSubViewType, + viewType, + }, + Component: item, + importMap: payload.importMap, + key: `settings-menu-item-${index}`, + serverProps: { + i18n, + locale, + params, + payload, + permissions, + searchParams, + user, + }, + }), + ) + : [] + + const RenderedBeforeNav = RenderServerComponent({ + clientProps: { + documentSubViewType, + viewType, + }, + Component: beforeNav, + importMap: payload.importMap, + serverProps: { + i18n, + locale, + params, + payload, + permissions, + searchParams, + user, + }, + }) + + const RenderedBeforeNavLinks = RenderServerComponent({ + clientProps: { + documentSubViewType, + viewType, + }, + Component: beforeNavLinks, + importMap: payload.importMap, + serverProps: { + i18n, + locale, + params, + payload, + permissions, + searchParams, + user, + }, + }) + + const RenderedAfterNavLinks = RenderServerComponent({ + clientProps: { + documentSubViewType, + viewType, + }, + Component: afterNavLinks, + importMap: payload.importMap, + serverProps: { + i18n, + locale, + params, + payload, + permissions, + searchParams, + user, + }, + }) + + const RenderedAfterNav = RenderServerComponent({ + clientProps: { + documentSubViewType, + viewType, + }, + Component: afterNav, + importMap: payload.importMap, + serverProps: { + i18n, + locale, + params, + payload, + permissions, + searchParams, + user, + }, + }) + + return ( + + {RenderedBeforeNav} + + {RenderedAfterNav} +
+
+ +
+
+
+ ) +} From 2acb87e5b42d0799258bcc26da86f18fa3b0eff5 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 19:36:18 +0100 Subject: [PATCH 16/60] refactor(ui): move Default and Minimal templates from packages/next Co-Authored-By: Claude Sonnet 4.6 (1M context) --- packages/next/src/templates/Default/index.tsx | 169 +----------------- packages/next/src/templates/Minimal/index.tsx | 25 +-- packages/ui/package.json | 5 + .../DocumentHeader/Tabs/Tab/TabLink.tsx | 3 +- .../templates/Default/NavHamburger/index.tsx | 10 ++ .../src/templates/Default/Wrapper/index.scss | 58 ++++++ .../src/templates/Default/Wrapper/index.tsx | 30 ++++ packages/ui/src/templates/Default/index.scss | 79 ++++++++ packages/ui/src/templates/Default/index.tsx | 164 +++++++++++++++++ packages/ui/src/templates/Minimal/index.scss | 30 ++++ packages/ui/src/templates/Minimal/index.tsx | 24 +++ 11 files changed, 403 insertions(+), 194 deletions(-) create mode 100644 packages/ui/src/templates/Default/NavHamburger/index.tsx create mode 100644 packages/ui/src/templates/Default/Wrapper/index.scss create mode 100644 packages/ui/src/templates/Default/Wrapper/index.tsx create mode 100644 packages/ui/src/templates/Default/index.scss create mode 100644 packages/ui/src/templates/Default/index.tsx create mode 100644 packages/ui/src/templates/Minimal/index.scss create mode 100644 packages/ui/src/templates/Minimal/index.tsx diff --git a/packages/next/src/templates/Default/index.tsx b/packages/next/src/templates/Default/index.tsx index cb2043f7fe1..b626405cd1e 100644 --- a/packages/next/src/templates/Default/index.tsx +++ b/packages/next/src/templates/Default/index.tsx @@ -1,168 +1 @@ -import type { - CustomComponent, - DocumentSubViewTypes, - PayloadRequest, - ServerProps, - ViewTypes, - VisibleEntities, -} from 'payload' - -import { - ActionsProvider, - AppHeader, - BulkUploadProvider, - EntityVisibilityProvider, - NavToggler, -} from '@payloadcms/ui' -import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' - -import './index.scss' - -import React from 'react' - -import { DefaultNav } from '../../elements/Nav/index.js' -import { NavHamburger } from './NavHamburger/index.js' -import { Wrapper } from './Wrapper/index.js' - -const baseClass = 'template-default' - -export type DefaultTemplateProps = { - children?: React.ReactNode - className?: string - collectionSlug?: string - docID?: number | string - documentSubViewType?: DocumentSubViewTypes - globalSlug?: string - req?: PayloadRequest - viewActions?: CustomComponent[] - viewType?: ViewTypes - visibleEntities: VisibleEntities -} & ServerProps - -export const DefaultTemplate: React.FC = ({ - children, - className, - collectionSlug, - docID, - documentSubViewType, - globalSlug, - i18n, - locale, - params, - payload, - permissions, - req, - searchParams, - user, - viewActions, - viewType, - visibleEntities, -}) => { - const { - admin: { - avatar, - components, - components: { header: CustomHeader, Nav: CustomNav } = { - header: undefined, - Nav: undefined, - }, - } = {}, - } = payload.config || {} - - const clientProps = { - documentSubViewType, - viewType, - visibleEntities, - } - - const serverProps: { - collectionSlug: string - docID: number | string - globalSlug: string - req: PayloadRequest - } & ServerProps = { - collectionSlug, - docID, - globalSlug, - i18n, - locale, - params, - payload, - permissions, - req, - searchParams, - user, - } - - const Actions: Record = {} - for (const action of viewActions ?? []) { - if (!action) { - continue - } - const key = typeof action === 'object' ? action.path : action - Actions[key] = RenderServerComponent({ - clientProps, - Component: action, - importMap: payload.importMap, - serverProps, - }) - } - - const NavComponent = RenderServerComponent({ - clientProps, - Component: CustomNav, - Fallback: DefaultNav, - importMap: payload.importMap, - serverProps, - }) - - return ( - - - - {RenderServerComponent({ - clientProps, - Component: CustomHeader, - importMap: payload.importMap, - serverProps, - })} -
- - - {NavComponent} -
- - {children} -
-
-
-
-
-
- ) -} +export { DefaultTemplate, type DefaultTemplateProps } from '@payloadcms/ui/templates/Default' diff --git a/packages/next/src/templates/Minimal/index.tsx b/packages/next/src/templates/Minimal/index.tsx index 792574b6e93..78beba7fc4b 100644 --- a/packages/next/src/templates/Minimal/index.tsx +++ b/packages/next/src/templates/Minimal/index.tsx @@ -1,24 +1 @@ -import React from 'react' - -import './index.scss' - -const baseClass = 'template-minimal' - -export type MinimalTemplateProps = { - children?: React.ReactNode - className?: string - style?: React.CSSProperties - width?: 'normal' | 'wide' -} - -export const MinimalTemplate: React.FC = (props) => { - const { children, className, style = {}, width = 'normal' } = props - - const classes = [className, baseClass, `${baseClass}--width-${width}`].filter(Boolean).join(' ') - - return ( -
-
{children}
-
- ) -} +export { MinimalTemplate, type MinimalTemplateProps } from '@payloadcms/ui/templates/Minimal' diff --git a/packages/ui/package.json b/packages/ui/package.json index ca0c7dbc64d..368fdfcf4da 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -56,6 +56,11 @@ "types": "./src/views/*/index.tsx", "default": "./src/views/*/index.tsx" }, + "./templates/*": { + "import": "./src/templates/*/index.tsx", + "types": "./src/templates/*/index.tsx", + "default": "./src/templates/*/index.tsx" + }, "./rsc": { "import": "./src/exports/rsc/index.ts", "types": "./src/exports/rsc/index.ts", diff --git a/packages/ui/src/elements/DocumentHeader/Tabs/Tab/TabLink.tsx b/packages/ui/src/elements/DocumentHeader/Tabs/Tab/TabLink.tsx index ef4ba4efb57..39493666ca0 100644 --- a/packages/ui/src/elements/DocumentHeader/Tabs/Tab/TabLink.tsx +++ b/packages/ui/src/elements/DocumentHeader/Tabs/Tab/TabLink.tsx @@ -5,8 +5,7 @@ import { formatAdminURL } from 'payload/shared' import React from 'react' import { useParams } from '../../../../providers/Params/index.js' -import { usePathname } from '../../../../providers/Router/index.js' -import { useSearchParams } from '../../../../providers/SearchParams/index.js' +import { usePathname, useSearchParams } from '../../../../providers/Router/index.js' import { Button } from '../../../Button/index.js' export const DocumentTabLink: React.FC<{ diff --git a/packages/ui/src/templates/Default/NavHamburger/index.tsx b/packages/ui/src/templates/Default/NavHamburger/index.tsx new file mode 100644 index 00000000000..e795c9b8553 --- /dev/null +++ b/packages/ui/src/templates/Default/NavHamburger/index.tsx @@ -0,0 +1,10 @@ +'use client' +import React from 'react' + +import { Hamburger } from '../../../elements/Hamburger/index.js' +import { useNav } from '../../../elements/Nav/context.js' + +export const NavHamburger: React.FC = () => { + const { navOpen } = useNav() + return +} diff --git a/packages/ui/src/templates/Default/Wrapper/index.scss b/packages/ui/src/templates/Default/Wrapper/index.scss new file mode 100644 index 00000000000..9e22705bf34 --- /dev/null +++ b/packages/ui/src/templates/Default/Wrapper/index.scss @@ -0,0 +1,58 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .template-default { + min-height: 100vh; + display: grid; + position: relative; + isolation: isolate; + + @media (prefers-reduced-motion) { + transition: none; + } + + &--nav-animate { + transition: grid-template-columns var(--nav-trans-time) linear; + } + + &--nav-open { + .template-default { + &__nav-overlay { + transition: opacity var(--nav-trans-time) linear; + } + } + } + } + + @media (min-width: 1441px) { + .template-default { + grid-template-columns: 0 auto; + + &--nav-open { + grid-template-columns: var(--nav-width) auto; + } + } + } + + @media (max-width: 1440px) { + .template-default--nav-hydrated.template-default--nav-open { + grid-template-columns: var(--nav-width) auto; + } + + .template-default { + grid-template-columns: 1fr auto; + + .nav { + display: none; + } + + &--nav-hydrated { + grid-template-columns: 0 auto; + + .nav { + display: unset; + } + } + } + } +} diff --git a/packages/ui/src/templates/Default/Wrapper/index.tsx b/packages/ui/src/templates/Default/Wrapper/index.tsx new file mode 100644 index 00000000000..d15d34cef34 --- /dev/null +++ b/packages/ui/src/templates/Default/Wrapper/index.tsx @@ -0,0 +1,30 @@ +'use client' +import React from 'react' + +import { useNav } from '../../../elements/Nav/context.js' +import './index.scss' + +export const Wrapper: React.FC<{ + baseClass?: string + children?: React.ReactNode + className?: string +}> = (props) => { + const { baseClass, children, className } = props + const { hydrated, navOpen, shouldAnimate } = useNav() + + return ( +
+ {children} +
+ ) +} diff --git a/packages/ui/src/templates/Default/index.scss b/packages/ui/src/templates/Default/index.scss new file mode 100644 index 00000000000..007b5213ab5 --- /dev/null +++ b/packages/ui/src/templates/Default/index.scss @@ -0,0 +1,79 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .template-default { + background-color: var(--theme-bg); + color: var(--theme-text); + + [dir='rtl'] &__nav-toggler-wrapper { + left: unset; + right: 0; + } + + &__nav-toggler-wrapper { + position: sticky; + z-index: var(--z-modal); + top: 0; + left: 0; + height: 0; + width: var(--gutter-h); + display: flex; + justify-content: center; + } + + &__nav-toggler-container { + height: var(--app-header-height); + display: flex; + align-items: center; + } + + &__nav-toggler { + display: flex; + align-items: center; + } + + &__wrap { + min-width: 0; + width: 100%; + flex-grow: 1; + position: relative; + background-color: var(--theme-bg); + &:before { + content: ''; + display: block; + position: absolute; + inset: 0; + background-color: inherit; + opacity: 0; + z-index: var(--z-status); + visibility: hidden; + transition: all var(--nav-trans-time) linear; + } + } + + @include mid-break { + &__nav-toggler-wrapper { + .hamburger { + left: unset; + } + } + } + + @include small-break { + &__nav-toggler-wrapper { + width: unset; + justify-content: unset; + + .hamburger { + display: none; + } + } + + .template-default { + &__wrap { + min-width: 100%; + } + } + } + } +} diff --git a/packages/ui/src/templates/Default/index.tsx b/packages/ui/src/templates/Default/index.tsx new file mode 100644 index 00000000000..456e4f612d1 --- /dev/null +++ b/packages/ui/src/templates/Default/index.tsx @@ -0,0 +1,164 @@ +import type { + CustomComponent, + DocumentSubViewTypes, + PayloadRequest, + ServerProps, + ViewTypes, + VisibleEntities, +} from 'payload' + +import React from 'react' + +import { AppHeader } from '../../elements/AppHeader/index.js' +import { BulkUploadProvider } from '../../elements/BulkUpload/index.js' +import { DefaultNav } from '../../elements/Nav/index.js' +import { NavToggler } from '../../elements/Nav/NavToggler/index.js' +import { RenderServerComponent } from '../../elements/RenderServerComponent/index.js' +import './index.scss' +import { ActionsProvider } from '../../providers/Actions/index.js' +import { EntityVisibilityProvider } from '../../providers/EntityVisibility/index.js' +import { NavHamburger } from './NavHamburger/index.js' +import { Wrapper } from './Wrapper/index.js' + +const baseClass = 'template-default' + +export type DefaultTemplateProps = { + children?: React.ReactNode + className?: string + collectionSlug?: string + docID?: number | string + documentSubViewType?: DocumentSubViewTypes + globalSlug?: string + req?: PayloadRequest + viewActions?: CustomComponent[] + viewType?: ViewTypes + visibleEntities: VisibleEntities +} & ServerProps + +export const DefaultTemplate: React.FC = ({ + children, + className, + collectionSlug, + docID, + documentSubViewType, + globalSlug, + i18n, + locale, + params, + payload, + permissions, + req, + searchParams, + user, + viewActions, + viewType, + visibleEntities, +}) => { + const { + admin: { + avatar, + components, + components: { header: CustomHeader, Nav: CustomNav } = { + header: undefined, + Nav: undefined, + }, + } = {}, + } = payload.config || {} + + const clientProps = { + documentSubViewType, + viewType, + visibleEntities, + } + + const serverProps: { + collectionSlug: string + docID: number | string + globalSlug: string + req: PayloadRequest + } & ServerProps = { + collectionSlug, + docID, + globalSlug, + i18n, + locale, + params, + payload, + permissions, + req, + searchParams, + user, + } + + const Actions: Record = {} + for (const action of viewActions ?? []) { + if (!action) { + continue + } + const key = typeof action === 'object' ? action.path : action + Actions[key] = RenderServerComponent({ + clientProps, + Component: action, + importMap: payload.importMap, + serverProps, + }) + } + + const NavComponent = RenderServerComponent({ + clientProps, + Component: CustomNav, + Fallback: DefaultNav, + importMap: payload.importMap, + serverProps, + }) + + return ( + + + + {RenderServerComponent({ + clientProps, + Component: CustomHeader, + importMap: payload.importMap, + serverProps, + })} +
+ + + {NavComponent} +
+ + {children} +
+
+
+
+
+
+ ) +} diff --git a/packages/ui/src/templates/Minimal/index.scss b/packages/ui/src/templates/Minimal/index.scss new file mode 100644 index 00000000000..f18125a8ff8 --- /dev/null +++ b/packages/ui/src/templates/Minimal/index.scss @@ -0,0 +1,30 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .template-minimal { + display: flex; + width: 100%; + justify-content: center; + align-items: center; + padding: base(3) $baseline; + margin-left: auto; + margin-right: auto; + min-height: 100%; + background-color: var(--theme-bg-color); + color: var(--theme-text); + + &--width-normal { + .template-minimal__wrap { + max-width: base(24); + width: 100%; + } + } + + &--width-wide { + .template-minimal__wrap { + max-width: base(48); + width: 100%; + } + } + } +} diff --git a/packages/ui/src/templates/Minimal/index.tsx b/packages/ui/src/templates/Minimal/index.tsx new file mode 100644 index 00000000000..792574b6e93 --- /dev/null +++ b/packages/ui/src/templates/Minimal/index.tsx @@ -0,0 +1,24 @@ +import React from 'react' + +import './index.scss' + +const baseClass = 'template-minimal' + +export type MinimalTemplateProps = { + children?: React.ReactNode + className?: string + style?: React.CSSProperties + width?: 'normal' | 'wide' +} + +export const MinimalTemplate: React.FC = (props) => { + const { children, className, style = {}, width = 'normal' } = props + + const classes = [className, baseClass, `${baseClass}--width-${width}`].filter(Boolean).join(' ') + + return ( +
+
{children}
+
+ ) +} From 0bdb5552d75e8c8800f2a72adb6d0ed9950068f4 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 19:50:37 +0100 Subject: [PATCH 17/60] refactor(ui): move Document view utilities from packages/next Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../Document/getCustomDocumentViewByKey.tsx | 14 +- .../views/Document/getCustomViewByRoute.tsx | 54 +-- .../views/Document/getDocumentPermissions.tsx | 154 +-------- .../next/src/views/Document/getIsLocked.ts | 121 +------ .../next/src/views/Document/getVersions.ts | 317 +---------------- .../views/Document/renderDocumentSlots.tsx | 246 +------------- packages/ui/package.json | 31 ++ .../ui/src/utilities/isPathMatchingRoute.ts | 40 +++ .../Document/getCustomDocumentViewByKey.tsx | 13 + .../views/Document/getCustomViewByRoute.tsx | 53 +++ .../views/Document/getDocumentPermissions.tsx | 152 +++++++++ packages/ui/src/views/Document/getIsLocked.ts | 121 +++++++ packages/ui/src/views/Document/getVersions.ts | 318 ++++++++++++++++++ .../views/Document/renderDocumentSlots.tsx | 242 +++++++++++++ 14 files changed, 979 insertions(+), 897 deletions(-) create mode 100644 packages/ui/src/utilities/isPathMatchingRoute.ts create mode 100644 packages/ui/src/views/Document/getCustomDocumentViewByKey.tsx create mode 100644 packages/ui/src/views/Document/getCustomViewByRoute.tsx create mode 100644 packages/ui/src/views/Document/getDocumentPermissions.tsx create mode 100644 packages/ui/src/views/Document/getIsLocked.ts create mode 100644 packages/ui/src/views/Document/getVersions.ts create mode 100644 packages/ui/src/views/Document/renderDocumentSlots.tsx diff --git a/packages/next/src/views/Document/getCustomDocumentViewByKey.tsx b/packages/next/src/views/Document/getCustomDocumentViewByKey.tsx index 8b0001946dc..2a7a5456e69 100644 --- a/packages/next/src/views/Document/getCustomDocumentViewByKey.tsx +++ b/packages/next/src/views/Document/getCustomDocumentViewByKey.tsx @@ -1,13 +1 @@ -import type { EditViewComponent, SanitizedCollectionConfig, SanitizedGlobalConfig } from 'payload' - -export const getCustomDocumentViewByKey = ( - views: - | SanitizedCollectionConfig['admin']['components']['views'] - | SanitizedGlobalConfig['admin']['components']['views'], - customViewKey: string, -): EditViewComponent => { - return typeof views?.edit?.[customViewKey] === 'object' && - 'Component' in views.edit[customViewKey] - ? views?.edit?.[customViewKey].Component - : null -} +export { getCustomDocumentViewByKey } from '@payloadcms/ui/views/Document/getCustomDocumentViewByKey' diff --git a/packages/next/src/views/Document/getCustomViewByRoute.tsx b/packages/next/src/views/Document/getCustomViewByRoute.tsx index 9f879d0815b..0a1a4ccfeb4 100644 --- a/packages/next/src/views/Document/getCustomViewByRoute.tsx +++ b/packages/next/src/views/Document/getCustomViewByRoute.tsx @@ -1,53 +1 @@ -import type { EditViewComponent, SanitizedCollectionConfig, SanitizedGlobalConfig } from 'payload' - -import { isPathMatchingRoute } from '../Root/isPathMatchingRoute.js' - -export const getCustomViewByRoute = ({ - baseRoute, - currentRoute, - views, -}: { - baseRoute: string - currentRoute: string - views: - | SanitizedCollectionConfig['admin']['components']['views'] - | SanitizedGlobalConfig['admin']['components']['views'] -}): { - Component: EditViewComponent - viewKey?: string -} => { - if (typeof views?.edit === 'object') { - let viewKey: string - - const foundViewConfig = Object.entries(views.edit).find(([key, view]) => { - if (typeof view === 'object' && 'path' in view) { - const viewPath = `${baseRoute}${view.path}` - - const isMatching = isPathMatchingRoute({ - currentRoute, - exact: true, - path: viewPath, - }) - - if (isMatching) { - viewKey = key - } - - return isMatching - } - - return false - })?.[1] - - if (foundViewConfig && 'Component' in foundViewConfig) { - return { - Component: foundViewConfig.Component, - viewKey, - } - } - } - - return { - Component: null, - } -} +export { getCustomViewByRoute } from '@payloadcms/ui/views/Document/getCustomViewByRoute' diff --git a/packages/next/src/views/Document/getDocumentPermissions.tsx b/packages/next/src/views/Document/getDocumentPermissions.tsx index 5b501cec30b..7afccee30f2 100644 --- a/packages/next/src/views/Document/getDocumentPermissions.tsx +++ b/packages/next/src/views/Document/getDocumentPermissions.tsx @@ -1,153 +1 @@ -import type { - Data, - PayloadRequest, - SanitizedCollectionConfig, - SanitizedDocumentPermissions, - SanitizedGlobalConfig, -} from 'payload' - -import { - hasSavePermission as getHasSavePermission, - isEditing as getIsEditing, -} from '@payloadcms/ui/shared' -import { docAccessOperation, docAccessOperationGlobal, logError } from 'payload' -import { hasDraftsEnabled } from 'payload/shared' - -export const getDocumentPermissions = async (args: { - collectionConfig?: SanitizedCollectionConfig - data: Data - globalConfig?: SanitizedGlobalConfig - /** - * When called for creating a new document, id is not provided. - */ - id?: number | string - req: PayloadRequest -}): Promise<{ - docPermissions: SanitizedDocumentPermissions - hasDeletePermission: boolean - hasPublishPermission: boolean - hasSavePermission: boolean - hasTrashPermission: boolean -}> => { - const { id, collectionConfig, data = {}, globalConfig, req } = args - - let docPermissions: SanitizedDocumentPermissions - let hasPublishPermission = false - let hasTrashPermission = false - let hasDeletePermission = false - - if (collectionConfig) { - try { - docPermissions = await docAccessOperation({ - id, - collection: { - config: collectionConfig, - }, - data: { - ...data, - _status: 'draft', - }, - req, - }) - - if (hasDraftsEnabled(collectionConfig)) { - hasPublishPermission = ( - await docAccessOperation({ - id, - collection: { - config: collectionConfig, - }, - data: { - ...data, - _status: 'published', - }, - req, - }) - ).update - } - - if (collectionConfig.trash) { - const { deletedAt: _, ...dataWithoutDeletedAt } = data || {} - - const [trashPermissionResult, deletePermissionResult] = await Promise.all([ - docAccessOperation({ - id, - collection: { - config: collectionConfig, - }, - data: { - ...data, - deletedAt: new Date().toISOString(), - }, - req, - }), - docAccessOperation({ - id, - collection: { - config: collectionConfig, - }, - data: dataWithoutDeletedAt, - req, - }), - ]) - - hasTrashPermission = trashPermissionResult.delete - hasDeletePermission = deletePermissionResult.delete - } else { - // When trash is not enabled, delete permission is straightforward - hasDeletePermission = 'delete' in docPermissions ? Boolean(docPermissions.delete) : false - hasTrashPermission = false - } - } catch (err) { - logError({ err, payload: req.payload }) - } - } - - if (globalConfig) { - try { - docPermissions = await docAccessOperationGlobal({ - data, - globalConfig, - req, - }) - - if (hasDraftsEnabled(globalConfig)) { - hasPublishPermission = ( - await docAccessOperationGlobal({ - data: { - ...data, - _status: 'published', - }, - globalConfig, - req, - }) - ).update - } - - // Globals don't support trash - hasDeletePermission = false - hasTrashPermission = false - } catch (err) { - logError({ err, payload: req.payload }) - } - } - - const hasSavePermission = getHasSavePermission({ - collectionSlug: collectionConfig?.slug, - docPermissions, - globalSlug: globalConfig?.slug, - isEditing: getIsEditing({ - id, - collectionSlug: collectionConfig?.slug, - globalSlug: globalConfig?.slug, - }), - }) - - return { - docPermissions, - hasDeletePermission, - hasPublishPermission, - hasSavePermission, - hasTrashPermission, - } -} +export { getDocumentPermissions } from '@payloadcms/ui/views/Document/getDocumentPermissions' diff --git a/packages/next/src/views/Document/getIsLocked.ts b/packages/next/src/views/Document/getIsLocked.ts index f69a29cf0b0..30c35ee64a5 100644 --- a/packages/next/src/views/Document/getIsLocked.ts +++ b/packages/next/src/views/Document/getIsLocked.ts @@ -1,120 +1 @@ -import type { - PayloadRequest, - SanitizedCollectionConfig, - SanitizedGlobalConfig, - TypedUser, - Where, -} from 'payload' - -import { sanitizeID } from '@payloadcms/ui/shared' -import { extractID } from 'payload/shared' - -type Args = { - collectionConfig?: SanitizedCollectionConfig - globalConfig?: SanitizedGlobalConfig - id?: number | string - isEditing: boolean - req: PayloadRequest -} - -type Result = Promise<{ - currentEditor?: TypedUser - isLocked: boolean - lastUpdateTime?: number -}> - -export const getIsLocked = async ({ - id, - collectionConfig, - globalConfig, - isEditing, - req, -}: Args): Result => { - const entityConfig = collectionConfig || globalConfig - - const entityHasLockingEnabled = - entityConfig?.lockDocuments !== undefined ? entityConfig?.lockDocuments : true - - // Check if the locked-documents collection exists - if (!req.payload.collections?.['payload-locked-documents']) { - // If the collection doesn't exist, locking is not available - return { - isLocked: false, - } - } - - if (!entityHasLockingEnabled || !isEditing) { - return { - isLocked: false, - } - } - - const where: Where = {} - - const lockDurationDefault = 300 // Default 5 minutes in seconds - const lockDuration = - typeof entityConfig.lockDocuments === 'object' - ? entityConfig.lockDocuments.duration - : lockDurationDefault - const lockDurationInMilliseconds = lockDuration * 1000 - - const now = new Date().getTime() - - if (globalConfig) { - where.and = [ - { - globalSlug: { - equals: globalConfig.slug, - }, - }, - { - updatedAt: { - greater_than: new Date(now - lockDurationInMilliseconds), - }, - }, - ] - } else { - where.and = [ - { - 'document.value': { - equals: sanitizeID(id), - }, - }, - { - 'document.relationTo': { - equals: collectionConfig.slug, - }, - }, - { - updatedAt: { - greater_than: new Date(now - lockDurationInMilliseconds), - }, - }, - ] - } - - const { docs } = await req.payload.find({ - collection: 'payload-locked-documents', - depth: 1, - overrideAccess: false, - req, - where, - }) - - if (docs.length > 0) { - const currentEditor = docs[0].user?.value - const lastUpdateTime = new Date(docs[0].updatedAt).getTime() - - if (extractID(currentEditor) !== req.user.id) { - return { - currentEditor, - isLocked: true, - lastUpdateTime, - } - } - } - - return { - isLocked: false, - } -} +export { getIsLocked } from '@payloadcms/ui/views/Document/getIsLocked' diff --git a/packages/next/src/views/Document/getVersions.ts b/packages/next/src/views/Document/getVersions.ts index 204ac871b02..ad02b98e841 100644 --- a/packages/next/src/views/Document/getVersions.ts +++ b/packages/next/src/views/Document/getVersions.ts @@ -1,316 +1 @@ -import { sanitizeID, traverseForLocalizedFields } from '@payloadcms/ui/shared' -import { - combineQueries, - extractAccessFromPermission, - type Payload, - type SanitizedCollectionConfig, - type SanitizedDocumentPermissions, - type SanitizedGlobalConfig, - type TypedUser, -} from 'payload' -import { hasAutosaveEnabled, hasDraftsEnabled } from 'payload/shared' - -type Args = { - collectionConfig?: SanitizedCollectionConfig - /** - * Optional - performance optimization. - * If a document has been fetched before fetching versions, pass it here. - * If this document is set to published, we can skip the query to find out if a published document exists, - * as the passed in document is proof of its existence. - */ - doc?: Record - docPermissions: SanitizedDocumentPermissions - globalConfig?: SanitizedGlobalConfig - id?: number | string - locale?: string - payload: Payload - user: TypedUser -} - -type Result = Promise<{ - hasPublishedDoc: boolean - mostRecentVersionIsAutosaved: boolean - unpublishedVersionCount: number - versionCount: number -}> - -// TODO: in the future, we can parallelize some of these queries -// this will speed up the API by ~30-100ms or so -// Note from the future: I have attempted parallelizing these queries, but it made this function almost 2x slower. -export const getVersions = async ({ - id: idArg, - collectionConfig, - doc, - docPermissions, - globalConfig, - locale, - payload, - user, -}: Args): Result => { - const id = sanitizeID(idArg) - let publishedDoc - let hasPublishedDoc = false - let mostRecentVersionIsAutosaved = false - let unpublishedVersionCount = 0 - let versionCount = 0 - - const entityConfig = collectionConfig || globalConfig - const versionsConfig = entityConfig?.versions - const hasLocalizedFields = traverseForLocalizedFields(entityConfig.fields) - const localizedDraftsEnabled = - hasDraftsEnabled(entityConfig) && - typeof payload.config.localization === 'object' && - hasLocalizedFields - - const shouldFetchVersions = Boolean(versionsConfig && docPermissions?.readVersions) - - if (!shouldFetchVersions) { - // Without readVersions permission, determine published status from the _status field - const hasPublishedDoc = localizedDraftsEnabled - ? doc?._status === 'published' - : doc?._status !== 'draft' - - return { - hasPublishedDoc, - mostRecentVersionIsAutosaved, - unpublishedVersionCount, - versionCount, - } - } - - if (collectionConfig) { - if (!id) { - return { - hasPublishedDoc, - mostRecentVersionIsAutosaved, - unpublishedVersionCount, - versionCount, - } - } - - if (hasDraftsEnabled(collectionConfig)) { - // Find out if a published document exists - if (doc?._status === 'published') { - publishedDoc = doc - } else { - publishedDoc = ( - await payload.find({ - collection: collectionConfig.slug, - depth: 0, - limit: 1, - locale: locale || undefined, - pagination: false, - select: { - updatedAt: true, - }, - user, - where: { - and: [ - { - _status: { - equals: 'published', - }, - }, - { - id: { - equals: id, - }, - }, - ], - }, - }) - )?.docs?.[0] - } - - if (publishedDoc) { - hasPublishedDoc = true - } - - if (hasAutosaveEnabled(collectionConfig)) { - const where: Record = { - and: [ - { - parent: { - equals: id, - }, - }, - ], - } - - if (localizedDraftsEnabled) { - where.and.push({ - snapshot: { - not_equals: true, - }, - }) - } - - const mostRecentVersion = await payload.findVersions({ - collection: collectionConfig.slug, - depth: 0, - limit: 1, - locale, - select: { - autosave: true, - }, - user, - where: combineQueries(where, extractAccessFromPermission(docPermissions.readVersions)), - }) - - if ( - mostRecentVersion.docs[0] && - 'autosave' in mostRecentVersion.docs[0] && - mostRecentVersion.docs[0].autosave - ) { - mostRecentVersionIsAutosaved = true - } - } - - if (publishedDoc?.updatedAt) { - ;({ totalDocs: unpublishedVersionCount } = await payload.countVersions({ - collection: collectionConfig.slug, - locale, - user, - where: combineQueries( - { - and: [ - { - parent: { - equals: id, - }, - }, - { - 'version._status': { - equals: 'draft', - }, - }, - { - updatedAt: { - greater_than: publishedDoc.updatedAt, - }, - }, - ], - }, - extractAccessFromPermission(docPermissions.readVersions), - ), - })) - } - } - - const countVersionsWhere: Record = { - and: [ - { - parent: { - equals: id, - }, - }, - ], - } - - if (localizedDraftsEnabled) { - countVersionsWhere.and.push({ - snapshot: { - not_equals: true, - }, - }) - } - - ;({ totalDocs: versionCount } = await payload.countVersions({ - collection: collectionConfig.slug, - locale, - user, - where: combineQueries( - countVersionsWhere, - extractAccessFromPermission(docPermissions.readVersions), - ), - })) - } - - if (globalConfig) { - // Find out if a published document exists - if (hasDraftsEnabled(globalConfig)) { - if (doc?._status === 'published') { - publishedDoc = doc - } else { - publishedDoc = await payload.findGlobal({ - slug: globalConfig.slug, - depth: 0, - locale, - select: { - updatedAt: true, - }, - user, - }) - } - - if (publishedDoc?._status === 'published') { - hasPublishedDoc = true - } - - if (hasAutosaveEnabled(globalConfig)) { - const mostRecentVersion = await payload.findGlobalVersions({ - slug: globalConfig.slug, - limit: 1, - locale, - select: { - autosave: true, - }, - user, - }) - - if ( - mostRecentVersion.docs[0] && - 'autosave' in mostRecentVersion.docs[0] && - mostRecentVersion.docs[0].autosave - ) { - mostRecentVersionIsAutosaved = true - } - } - - if (publishedDoc?.updatedAt) { - ;({ totalDocs: unpublishedVersionCount } = await payload.countGlobalVersions({ - global: globalConfig.slug, - locale, - user, - where: combineQueries( - { - and: [ - { - 'version._status': { - equals: 'draft', - }, - }, - { - updatedAt: { - greater_than: publishedDoc.updatedAt, - }, - }, - ], - }, - extractAccessFromPermission(docPermissions.readVersions), - ), - })) - } - } - - ;({ totalDocs: versionCount } = await payload.countGlobalVersions({ - global: globalConfig.slug, - locale, - user, - where: localizedDraftsEnabled - ? { - snapshot: { - not_equals: true, - }, - } - : undefined, - })) - } - - return { - hasPublishedDoc, - mostRecentVersionIsAutosaved, - unpublishedVersionCount, - versionCount, - } -} +export { getVersions } from '@payloadcms/ui/views/Document/getVersions' diff --git a/packages/next/src/views/Document/renderDocumentSlots.tsx b/packages/next/src/views/Document/renderDocumentSlots.tsx index 704276ea1ec..03a87c177f4 100644 --- a/packages/next/src/views/Document/renderDocumentSlots.tsx +++ b/packages/next/src/views/Document/renderDocumentSlots.tsx @@ -1,242 +1,4 @@ -import type { - BeforeDocumentControlsServerPropsOnly, - DocumentSlots, - EditMenuItemsServerPropsOnly, - Locale, - PayloadRequest, - PreviewButtonServerPropsOnly, - PublishButtonServerPropsOnly, - SanitizedCollectionConfig, - SanitizedGlobalConfig, - SanitizedPermissions, - SaveButtonServerPropsOnly, - SaveDraftButtonServerPropsOnly, - ServerFunction, - ServerProps, - StaticDescription, - UnpublishButtonServerPropsOnly, - ViewDescriptionClientProps, - ViewDescriptionServerPropsOnly, -} from 'payload' - -import { ViewDescription } from '@payloadcms/ui' -import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' -import { hasDraftsEnabled } from 'payload/shared' - -import { getDocumentPermissions } from './getDocumentPermissions.js' - -export const renderDocumentSlots: (args: { - collectionConfig?: SanitizedCollectionConfig - globalConfig?: SanitizedGlobalConfig - hasSavePermission: boolean - id?: number | string - locale: Locale - permissions: SanitizedPermissions - req: PayloadRequest -}) => DocumentSlots = (args) => { - const { id, collectionConfig, globalConfig, hasSavePermission, locale, permissions, req } = args - - const components: DocumentSlots = {} as DocumentSlots - - const unsavedDraftWithValidations = undefined - - const isPreviewEnabled = collectionConfig?.admin?.preview || globalConfig?.admin?.preview - - const serverProps: ServerProps = { - id, - i18n: req.i18n, - locale, - payload: req.payload, - permissions, - user: req.user, - // TODO: Add remaining serverProps - } - - const BeforeDocumentControls = - collectionConfig?.admin?.components?.edit?.beforeDocumentControls || - globalConfig?.admin?.components?.elements?.beforeDocumentControls - - if (BeforeDocumentControls) { - components.BeforeDocumentControls = RenderServerComponent({ - Component: BeforeDocumentControls, - importMap: req.payload.importMap, - serverProps: serverProps satisfies BeforeDocumentControlsServerPropsOnly, - }) - } - - const EditMenuItems = collectionConfig?.admin?.components?.edit?.editMenuItems - - if (EditMenuItems) { - components.EditMenuItems = RenderServerComponent({ - Component: EditMenuItems, - importMap: req.payload.importMap, - serverProps: serverProps satisfies EditMenuItemsServerPropsOnly, - }) - } - - const CustomPreviewButton = - collectionConfig?.admin?.components?.edit?.PreviewButton || - globalConfig?.admin?.components?.elements?.PreviewButton - - if (isPreviewEnabled && CustomPreviewButton) { - components.PreviewButton = RenderServerComponent({ - Component: CustomPreviewButton, - importMap: req.payload.importMap, - serverProps: serverProps satisfies PreviewButtonServerPropsOnly, - }) - } - - const LivePreview = - collectionConfig?.admin?.components?.views?.edit?.livePreview || - globalConfig?.admin?.components?.views?.edit?.livePreview - - if (LivePreview?.Component) { - components.LivePreview = RenderServerComponent({ - Component: LivePreview.Component, - importMap: req.payload.importMap, - serverProps, - }) - } - - const descriptionFromConfig = - collectionConfig?.admin?.description || globalConfig?.admin?.description - - const staticDescription: StaticDescription = - typeof descriptionFromConfig === 'function' - ? descriptionFromConfig({ t: req.i18n.t }) - : descriptionFromConfig - - const CustomDescription = - collectionConfig?.admin?.components?.Description || - globalConfig?.admin?.components?.elements?.Description - - const hasDescription = CustomDescription || staticDescription - - if (hasDescription) { - components.Description = RenderServerComponent({ - clientProps: { - collectionSlug: collectionConfig?.slug, - description: staticDescription, - } satisfies ViewDescriptionClientProps, - Component: CustomDescription, - Fallback: ViewDescription, - importMap: req.payload.importMap, - serverProps: serverProps satisfies ViewDescriptionServerPropsOnly, - }) - } - - if (collectionConfig?.versions?.drafts || globalConfig?.versions?.drafts) { - const CustomStatus = - collectionConfig?.admin?.components?.edit?.Status || - globalConfig?.admin?.components?.elements?.Status - - if (CustomStatus) { - components.Status = RenderServerComponent({ - Component: CustomStatus, - importMap: req.payload.importMap, - serverProps, - }) - } - } - - if (hasSavePermission) { - if (hasDraftsEnabled(collectionConfig || globalConfig)) { - const CustomPublishButton = - collectionConfig?.admin?.components?.edit?.PublishButton || - globalConfig?.admin?.components?.elements?.PublishButton - - if (CustomPublishButton) { - components.PublishButton = RenderServerComponent({ - Component: CustomPublishButton, - importMap: req.payload.importMap, - serverProps: serverProps satisfies PublishButtonServerPropsOnly, - }) - } - - const CustomUnpublishButton = - collectionConfig?.admin?.components?.edit?.UnpublishButton || - globalConfig?.admin?.components?.elements?.UnpublishButton - - if (CustomUnpublishButton) { - components.UnpublishButton = RenderServerComponent({ - Component: CustomUnpublishButton, - importMap: req.payload.importMap, - serverProps: serverProps satisfies UnpublishButtonServerPropsOnly, - }) - } - - const CustomSaveDraftButton = - collectionConfig?.admin?.components?.edit?.SaveDraftButton || - globalConfig?.admin?.components?.elements?.SaveDraftButton - - const draftsEnabled = hasDraftsEnabled(collectionConfig || globalConfig) - - if ((draftsEnabled || unsavedDraftWithValidations) && CustomSaveDraftButton) { - components.SaveDraftButton = RenderServerComponent({ - Component: CustomSaveDraftButton, - importMap: req.payload.importMap, - serverProps: serverProps satisfies SaveDraftButtonServerPropsOnly, - }) - } - } else { - const CustomSaveButton = - collectionConfig?.admin?.components?.edit?.SaveButton || - globalConfig?.admin?.components?.elements?.SaveButton - - if (CustomSaveButton) { - components.SaveButton = RenderServerComponent({ - Component: CustomSaveButton, - importMap: req.payload.importMap, - serverProps: serverProps satisfies SaveButtonServerPropsOnly, - }) - } - } - } - - if (collectionConfig?.upload && collectionConfig?.admin?.components?.edit?.Upload) { - components.Upload = RenderServerComponent({ - Component: collectionConfig.admin.components.edit.Upload, - importMap: req.payload.importMap, - serverProps, - }) - } - - if (collectionConfig?.upload && collectionConfig.upload.admin?.components?.controls) { - components.UploadControls = RenderServerComponent({ - Component: collectionConfig.upload.admin.components.controls, - importMap: req.payload.importMap, - serverProps, - }) - } - - return components -} - -export const renderDocumentSlotsHandler: ServerFunction<{ - collectionSlug: string - id?: number | string -}> = async (args) => { - const { id, collectionSlug, locale, permissions, req } = args - - const collectionConfig = req.payload.collections[collectionSlug]?.config - - if (!collectionConfig) { - throw new Error(req.t('error:incorrectCollection')) - } - - const { hasSavePermission } = await getDocumentPermissions({ - id, - collectionConfig, - data: {}, - req, - }) - - return renderDocumentSlots({ - id, - collectionConfig, - hasSavePermission, - locale, - permissions, - req, - }) -} +export { + renderDocumentSlots, + renderDocumentSlotsHandler, +} from '@payloadcms/ui/views/Document/renderDocumentSlots' diff --git a/packages/ui/package.json b/packages/ui/package.json index 368fdfcf4da..d0fff9d3675 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -56,6 +56,36 @@ "types": "./src/views/*/index.tsx", "default": "./src/views/*/index.tsx" }, + "./views/Document/getDocumentPermissions": { + "import": "./src/views/Document/getDocumentPermissions.tsx", + "types": "./src/views/Document/getDocumentPermissions.tsx", + "default": "./src/views/Document/getDocumentPermissions.tsx" + }, + "./views/Document/getIsLocked": { + "import": "./src/views/Document/getIsLocked.ts", + "types": "./src/views/Document/getIsLocked.ts", + "default": "./src/views/Document/getIsLocked.ts" + }, + "./views/Document/getVersions": { + "import": "./src/views/Document/getVersions.ts", + "types": "./src/views/Document/getVersions.ts", + "default": "./src/views/Document/getVersions.ts" + }, + "./views/Document/getCustomDocumentViewByKey": { + "import": "./src/views/Document/getCustomDocumentViewByKey.tsx", + "types": "./src/views/Document/getCustomDocumentViewByKey.tsx", + "default": "./src/views/Document/getCustomDocumentViewByKey.tsx" + }, + "./views/Document/getCustomViewByRoute": { + "import": "./src/views/Document/getCustomViewByRoute.tsx", + "types": "./src/views/Document/getCustomViewByRoute.tsx", + "default": "./src/views/Document/getCustomViewByRoute.tsx" + }, + "./views/Document/renderDocumentSlots": { + "import": "./src/views/Document/renderDocumentSlots.tsx", + "types": "./src/views/Document/renderDocumentSlots.tsx", + "default": "./src/views/Document/renderDocumentSlots.tsx" + }, "./templates/*": { "import": "./src/templates/*/index.tsx", "types": "./src/templates/*/index.tsx", @@ -207,6 +237,7 @@ "dequal": "2.0.3", "md5": "2.3.0", "object-to-formdata": "4.5.1", + "path-to-regexp": "6.3.0", "qs-esm": "8.0.1", "react-datepicker": "7.6.0", "react-image-crop": "10.1.8", diff --git a/packages/ui/src/utilities/isPathMatchingRoute.ts b/packages/ui/src/utilities/isPathMatchingRoute.ts new file mode 100644 index 00000000000..193fc0042a8 --- /dev/null +++ b/packages/ui/src/utilities/isPathMatchingRoute.ts @@ -0,0 +1,40 @@ +import { pathToRegexp } from 'path-to-regexp' + +export const isPathMatchingRoute = ({ + currentRoute, + exact, + path: viewPath, + sensitive, + strict, +}: { + currentRoute: string + exact?: boolean + path?: string + sensitive?: boolean + strict?: boolean +}) => { + // if no path is defined, we cannot match it so return false early + if (!viewPath) { + return false + } + + const keys = [] + + // run the view path through `pathToRegexp` to resolve any dynamic segments + // i.e. `/admin/custom-view/:id` -> `/admin/custom-view/123` + const regex = pathToRegexp(viewPath, keys, { + sensitive, + strict, + }) + + const match = regex.exec(currentRoute) + const viewRoute = match?.[0] || viewPath + + if (exact) { + return currentRoute === viewRoute + } + + if (!exact) { + return viewRoute.startsWith(currentRoute) + } +} diff --git a/packages/ui/src/views/Document/getCustomDocumentViewByKey.tsx b/packages/ui/src/views/Document/getCustomDocumentViewByKey.tsx new file mode 100644 index 00000000000..8b0001946dc --- /dev/null +++ b/packages/ui/src/views/Document/getCustomDocumentViewByKey.tsx @@ -0,0 +1,13 @@ +import type { EditViewComponent, SanitizedCollectionConfig, SanitizedGlobalConfig } from 'payload' + +export const getCustomDocumentViewByKey = ( + views: + | SanitizedCollectionConfig['admin']['components']['views'] + | SanitizedGlobalConfig['admin']['components']['views'], + customViewKey: string, +): EditViewComponent => { + return typeof views?.edit?.[customViewKey] === 'object' && + 'Component' in views.edit[customViewKey] + ? views?.edit?.[customViewKey].Component + : null +} diff --git a/packages/ui/src/views/Document/getCustomViewByRoute.tsx b/packages/ui/src/views/Document/getCustomViewByRoute.tsx new file mode 100644 index 00000000000..f4041b63138 --- /dev/null +++ b/packages/ui/src/views/Document/getCustomViewByRoute.tsx @@ -0,0 +1,53 @@ +import type { EditViewComponent, SanitizedCollectionConfig, SanitizedGlobalConfig } from 'payload' + +import { isPathMatchingRoute } from '../../utilities/isPathMatchingRoute.js' + +export const getCustomViewByRoute = ({ + baseRoute, + currentRoute, + views, +}: { + baseRoute: string + currentRoute: string + views: + | SanitizedCollectionConfig['admin']['components']['views'] + | SanitizedGlobalConfig['admin']['components']['views'] +}): { + Component: EditViewComponent + viewKey?: string +} => { + if (typeof views?.edit === 'object') { + let viewKey: string + + const foundViewConfig = Object.entries(views.edit).find(([key, view]) => { + if (typeof view === 'object' && 'path' in view) { + const viewPath = `${baseRoute}${view.path}` + + const isMatching = isPathMatchingRoute({ + currentRoute, + exact: true, + path: viewPath, + }) + + if (isMatching) { + viewKey = key + } + + return isMatching + } + + return false + })?.[1] + + if (foundViewConfig && 'Component' in foundViewConfig) { + return { + Component: foundViewConfig.Component, + viewKey, + } + } + } + + return { + Component: null, + } +} diff --git a/packages/ui/src/views/Document/getDocumentPermissions.tsx b/packages/ui/src/views/Document/getDocumentPermissions.tsx new file mode 100644 index 00000000000..5efa7fe1f1e --- /dev/null +++ b/packages/ui/src/views/Document/getDocumentPermissions.tsx @@ -0,0 +1,152 @@ +import type { + Data, + PayloadRequest, + SanitizedCollectionConfig, + SanitizedDocumentPermissions, + SanitizedGlobalConfig, +} from 'payload' + +import { docAccessOperation, docAccessOperationGlobal, logError } from 'payload' +import { hasDraftsEnabled } from 'payload/shared' + +import { hasSavePermission as getHasSavePermission } from '../../utilities/hasSavePermission.js' +import { isEditing as getIsEditing } from '../../utilities/isEditing.js' + +export const getDocumentPermissions = async (args: { + collectionConfig?: SanitizedCollectionConfig + data: Data + globalConfig?: SanitizedGlobalConfig + /** + * When called for creating a new document, id is not provided. + */ + id?: number | string + req: PayloadRequest +}): Promise<{ + docPermissions: SanitizedDocumentPermissions + hasDeletePermission: boolean + hasPublishPermission: boolean + hasSavePermission: boolean + hasTrashPermission: boolean +}> => { + const { id, collectionConfig, data = {}, globalConfig, req } = args + + let docPermissions: SanitizedDocumentPermissions + let hasPublishPermission = false + let hasTrashPermission = false + let hasDeletePermission = false + + if (collectionConfig) { + try { + docPermissions = await docAccessOperation({ + id, + collection: { + config: collectionConfig, + }, + data: { + ...data, + _status: 'draft', + }, + req, + }) + + if (hasDraftsEnabled(collectionConfig)) { + hasPublishPermission = ( + await docAccessOperation({ + id, + collection: { + config: collectionConfig, + }, + data: { + ...data, + _status: 'published', + }, + req, + }) + ).update + } + + if (collectionConfig.trash) { + const { deletedAt: _, ...dataWithoutDeletedAt } = data || {} + + const [trashPermissionResult, deletePermissionResult] = await Promise.all([ + docAccessOperation({ + id, + collection: { + config: collectionConfig, + }, + data: { + ...data, + deletedAt: new Date().toISOString(), + }, + req, + }), + docAccessOperation({ + id, + collection: { + config: collectionConfig, + }, + data: dataWithoutDeletedAt, + req, + }), + ]) + + hasTrashPermission = trashPermissionResult.delete + hasDeletePermission = deletePermissionResult.delete + } else { + // When trash is not enabled, delete permission is straightforward + hasDeletePermission = 'delete' in docPermissions ? Boolean(docPermissions.delete) : false + hasTrashPermission = false + } + } catch (err) { + logError({ err, payload: req.payload }) + } + } + + if (globalConfig) { + try { + docPermissions = await docAccessOperationGlobal({ + data, + globalConfig, + req, + }) + + if (hasDraftsEnabled(globalConfig)) { + hasPublishPermission = ( + await docAccessOperationGlobal({ + data: { + ...data, + _status: 'published', + }, + globalConfig, + req, + }) + ).update + } + + // Globals don't support trash + hasDeletePermission = false + hasTrashPermission = false + } catch (err) { + logError({ err, payload: req.payload }) + } + } + + const hasSavePermission = getHasSavePermission({ + collectionSlug: collectionConfig?.slug, + docPermissions, + globalSlug: globalConfig?.slug, + isEditing: getIsEditing({ + id, + collectionSlug: collectionConfig?.slug, + globalSlug: globalConfig?.slug, + }), + }) + + return { + docPermissions, + hasDeletePermission, + hasPublishPermission, + hasSavePermission, + hasTrashPermission, + } +} diff --git a/packages/ui/src/views/Document/getIsLocked.ts b/packages/ui/src/views/Document/getIsLocked.ts new file mode 100644 index 00000000000..cf822e81dfc --- /dev/null +++ b/packages/ui/src/views/Document/getIsLocked.ts @@ -0,0 +1,121 @@ +import type { + PayloadRequest, + SanitizedCollectionConfig, + SanitizedGlobalConfig, + TypedUser, + Where, +} from 'payload' + +import { extractID } from 'payload/shared' + +import { sanitizeID } from '../../utilities/sanitizeID.js' + +type Args = { + collectionConfig?: SanitizedCollectionConfig + globalConfig?: SanitizedGlobalConfig + id?: number | string + isEditing: boolean + req: PayloadRequest +} + +type Result = Promise<{ + currentEditor?: TypedUser + isLocked: boolean + lastUpdateTime?: number +}> + +export const getIsLocked = async ({ + id, + collectionConfig, + globalConfig, + isEditing, + req, +}: Args): Result => { + const entityConfig = collectionConfig || globalConfig + + const entityHasLockingEnabled = + entityConfig?.lockDocuments !== undefined ? entityConfig?.lockDocuments : true + + // Check if the locked-documents collection exists + if (!req.payload.collections?.['payload-locked-documents']) { + // If the collection doesn't exist, locking is not available + return { + isLocked: false, + } + } + + if (!entityHasLockingEnabled || !isEditing) { + return { + isLocked: false, + } + } + + const where: Where = {} + + const lockDurationDefault = 300 // Default 5 minutes in seconds + const lockDuration = + typeof entityConfig.lockDocuments === 'object' + ? entityConfig.lockDocuments.duration + : lockDurationDefault + const lockDurationInMilliseconds = lockDuration * 1000 + + const now = new Date().getTime() + + if (globalConfig) { + where.and = [ + { + globalSlug: { + equals: globalConfig.slug, + }, + }, + { + updatedAt: { + greater_than: new Date(now - lockDurationInMilliseconds), + }, + }, + ] + } else { + where.and = [ + { + 'document.value': { + equals: sanitizeID(id), + }, + }, + { + 'document.relationTo': { + equals: collectionConfig.slug, + }, + }, + { + updatedAt: { + greater_than: new Date(now - lockDurationInMilliseconds), + }, + }, + ] + } + + const { docs } = await req.payload.find({ + collection: 'payload-locked-documents', + depth: 1, + overrideAccess: false, + req, + where, + }) + + if (docs.length > 0) { + const currentEditor = docs[0].user?.value + const lastUpdateTime = new Date(docs[0].updatedAt).getTime() + + if (extractID(currentEditor) !== req.user.id) { + return { + currentEditor, + isLocked: true, + lastUpdateTime, + } + } + } + + return { + isLocked: false, + } +} diff --git a/packages/ui/src/views/Document/getVersions.ts b/packages/ui/src/views/Document/getVersions.ts new file mode 100644 index 00000000000..23bc107c84f --- /dev/null +++ b/packages/ui/src/views/Document/getVersions.ts @@ -0,0 +1,318 @@ +import { + combineQueries, + extractAccessFromPermission, + type Payload, + type SanitizedCollectionConfig, + type SanitizedDocumentPermissions, + type SanitizedGlobalConfig, + type TypedUser, +} from 'payload' +import { hasAutosaveEnabled, hasDraftsEnabled } from 'payload/shared' + +import { sanitizeID } from '../../utilities/sanitizeID.js' +import { traverseForLocalizedFields } from '../../utilities/traverseForLocalizedFields.js' + +type Args = { + collectionConfig?: SanitizedCollectionConfig + /** + * Optional - performance optimization. + * If a document has been fetched before fetching versions, pass it here. + * If this document is set to published, we can skip the query to find out if a published document exists, + * as the passed in document is proof of its existence. + */ + doc?: Record + docPermissions: SanitizedDocumentPermissions + globalConfig?: SanitizedGlobalConfig + id?: number | string + locale?: string + payload: Payload + user: TypedUser +} + +type Result = Promise<{ + hasPublishedDoc: boolean + mostRecentVersionIsAutosaved: boolean + unpublishedVersionCount: number + versionCount: number +}> + +// TODO: in the future, we can parallelize some of these queries +// this will speed up the API by ~30-100ms or so +// Note from the future: I have attempted parallelizing these queries, but it made this function almost 2x slower. +export const getVersions = async ({ + id: idArg, + collectionConfig, + doc, + docPermissions, + globalConfig, + locale, + payload, + user, +}: Args): Result => { + const id = sanitizeID(idArg) + let publishedDoc + let hasPublishedDoc = false + let mostRecentVersionIsAutosaved = false + let unpublishedVersionCount = 0 + let versionCount = 0 + + const entityConfig = collectionConfig || globalConfig + const versionsConfig = entityConfig?.versions + const hasLocalizedFields = traverseForLocalizedFields(entityConfig.fields) + const localizedDraftsEnabled = + hasDraftsEnabled(entityConfig) && + typeof payload.config.localization === 'object' && + hasLocalizedFields + + const shouldFetchVersions = Boolean(versionsConfig && docPermissions?.readVersions) + + if (!shouldFetchVersions) { + // Without readVersions permission, determine published status from the _status field + const hasPublishedDoc = localizedDraftsEnabled + ? doc?._status === 'published' + : doc?._status !== 'draft' + + return { + hasPublishedDoc, + mostRecentVersionIsAutosaved, + unpublishedVersionCount, + versionCount, + } + } + + if (collectionConfig) { + if (!id) { + return { + hasPublishedDoc, + mostRecentVersionIsAutosaved, + unpublishedVersionCount, + versionCount, + } + } + + if (hasDraftsEnabled(collectionConfig)) { + // Find out if a published document exists + if (doc?._status === 'published') { + publishedDoc = doc + } else { + publishedDoc = ( + await payload.find({ + collection: collectionConfig.slug, + depth: 0, + limit: 1, + locale: locale || undefined, + pagination: false, + select: { + updatedAt: true, + }, + user, + where: { + and: [ + { + _status: { + equals: 'published', + }, + }, + { + id: { + equals: id, + }, + }, + ], + }, + }) + )?.docs?.[0] + } + + if (publishedDoc) { + hasPublishedDoc = true + } + + if (hasAutosaveEnabled(collectionConfig)) { + const where: Record = { + and: [ + { + parent: { + equals: id, + }, + }, + ], + } + + if (localizedDraftsEnabled) { + where.and.push({ + snapshot: { + not_equals: true, + }, + }) + } + + const mostRecentVersion = await payload.findVersions({ + collection: collectionConfig.slug, + depth: 0, + limit: 1, + locale, + select: { + autosave: true, + }, + user, + where: combineQueries(where, extractAccessFromPermission(docPermissions.readVersions)), + }) + + if ( + mostRecentVersion.docs[0] && + 'autosave' in mostRecentVersion.docs[0] && + mostRecentVersion.docs[0].autosave + ) { + mostRecentVersionIsAutosaved = true + } + } + + if (publishedDoc?.updatedAt) { + ;({ totalDocs: unpublishedVersionCount } = await payload.countVersions({ + collection: collectionConfig.slug, + locale, + user, + where: combineQueries( + { + and: [ + { + parent: { + equals: id, + }, + }, + { + 'version._status': { + equals: 'draft', + }, + }, + { + updatedAt: { + greater_than: publishedDoc.updatedAt, + }, + }, + ], + }, + extractAccessFromPermission(docPermissions.readVersions), + ), + })) + } + } + + const countVersionsWhere: Record = { + and: [ + { + parent: { + equals: id, + }, + }, + ], + } + + if (localizedDraftsEnabled) { + countVersionsWhere.and.push({ + snapshot: { + not_equals: true, + }, + }) + } + + ;({ totalDocs: versionCount } = await payload.countVersions({ + collection: collectionConfig.slug, + locale, + user, + where: combineQueries( + countVersionsWhere, + extractAccessFromPermission(docPermissions.readVersions), + ), + })) + } + + if (globalConfig) { + // Find out if a published document exists + if (hasDraftsEnabled(globalConfig)) { + if (doc?._status === 'published') { + publishedDoc = doc + } else { + publishedDoc = await payload.findGlobal({ + slug: globalConfig.slug, + depth: 0, + locale, + select: { + updatedAt: true, + }, + user, + }) + } + + if (publishedDoc?._status === 'published') { + hasPublishedDoc = true + } + + if (hasAutosaveEnabled(globalConfig)) { + const mostRecentVersion = await payload.findGlobalVersions({ + slug: globalConfig.slug, + limit: 1, + locale, + select: { + autosave: true, + }, + user, + }) + + if ( + mostRecentVersion.docs[0] && + 'autosave' in mostRecentVersion.docs[0] && + mostRecentVersion.docs[0].autosave + ) { + mostRecentVersionIsAutosaved = true + } + } + + if (publishedDoc?.updatedAt) { + ;({ totalDocs: unpublishedVersionCount } = await payload.countGlobalVersions({ + global: globalConfig.slug, + locale, + user, + where: combineQueries( + { + and: [ + { + 'version._status': { + equals: 'draft', + }, + }, + { + updatedAt: { + greater_than: publishedDoc.updatedAt, + }, + }, + ], + }, + extractAccessFromPermission(docPermissions.readVersions), + ), + })) + } + } + + ;({ totalDocs: versionCount } = await payload.countGlobalVersions({ + global: globalConfig.slug, + locale, + user, + where: localizedDraftsEnabled + ? { + snapshot: { + not_equals: true, + }, + } + : undefined, + })) + } + + return { + hasPublishedDoc, + mostRecentVersionIsAutosaved, + unpublishedVersionCount, + versionCount, + } +} diff --git a/packages/ui/src/views/Document/renderDocumentSlots.tsx b/packages/ui/src/views/Document/renderDocumentSlots.tsx new file mode 100644 index 00000000000..9bfdb904d0c --- /dev/null +++ b/packages/ui/src/views/Document/renderDocumentSlots.tsx @@ -0,0 +1,242 @@ +import type { + BeforeDocumentControlsServerPropsOnly, + DocumentSlots, + EditMenuItemsServerPropsOnly, + Locale, + PayloadRequest, + PreviewButtonServerPropsOnly, + PublishButtonServerPropsOnly, + SanitizedCollectionConfig, + SanitizedGlobalConfig, + SanitizedPermissions, + SaveButtonServerPropsOnly, + SaveDraftButtonServerPropsOnly, + ServerFunction, + ServerProps, + StaticDescription, + UnpublishButtonServerPropsOnly, + ViewDescriptionClientProps, + ViewDescriptionServerPropsOnly, +} from 'payload' + +import { hasDraftsEnabled } from 'payload/shared' + +import { RenderServerComponent } from '../../elements/RenderServerComponent/index.js' +import { ViewDescription } from '../../elements/ViewDescription/index.js' +import { getDocumentPermissions } from './getDocumentPermissions.js' + +export const renderDocumentSlots: (args: { + collectionConfig?: SanitizedCollectionConfig + globalConfig?: SanitizedGlobalConfig + hasSavePermission: boolean + id?: number | string + locale: Locale + permissions: SanitizedPermissions + req: PayloadRequest +}) => DocumentSlots = (args) => { + const { id, collectionConfig, globalConfig, hasSavePermission, locale, permissions, req } = args + + const components: DocumentSlots = {} as DocumentSlots + + const unsavedDraftWithValidations = undefined + + const isPreviewEnabled = collectionConfig?.admin?.preview || globalConfig?.admin?.preview + + const serverProps: ServerProps = { + id, + i18n: req.i18n, + locale, + payload: req.payload, + permissions, + user: req.user, + // TODO: Add remaining serverProps + } + + const BeforeDocumentControls = + collectionConfig?.admin?.components?.edit?.beforeDocumentControls || + globalConfig?.admin?.components?.elements?.beforeDocumentControls + + if (BeforeDocumentControls) { + components.BeforeDocumentControls = RenderServerComponent({ + Component: BeforeDocumentControls, + importMap: req.payload.importMap, + serverProps: serverProps satisfies BeforeDocumentControlsServerPropsOnly, + }) + } + + const EditMenuItems = collectionConfig?.admin?.components?.edit?.editMenuItems + + if (EditMenuItems) { + components.EditMenuItems = RenderServerComponent({ + Component: EditMenuItems, + importMap: req.payload.importMap, + serverProps: serverProps satisfies EditMenuItemsServerPropsOnly, + }) + } + + const CustomPreviewButton = + collectionConfig?.admin?.components?.edit?.PreviewButton || + globalConfig?.admin?.components?.elements?.PreviewButton + + if (isPreviewEnabled && CustomPreviewButton) { + components.PreviewButton = RenderServerComponent({ + Component: CustomPreviewButton, + importMap: req.payload.importMap, + serverProps: serverProps satisfies PreviewButtonServerPropsOnly, + }) + } + + const LivePreview = + collectionConfig?.admin?.components?.views?.edit?.livePreview || + globalConfig?.admin?.components?.views?.edit?.livePreview + + if (LivePreview?.Component) { + components.LivePreview = RenderServerComponent({ + Component: LivePreview.Component, + importMap: req.payload.importMap, + serverProps, + }) + } + + const descriptionFromConfig = + collectionConfig?.admin?.description || globalConfig?.admin?.description + + const staticDescription: StaticDescription = + typeof descriptionFromConfig === 'function' + ? descriptionFromConfig({ t: req.i18n.t }) + : descriptionFromConfig + + const CustomDescription = + collectionConfig?.admin?.components?.Description || + globalConfig?.admin?.components?.elements?.Description + + const hasDescription = CustomDescription || staticDescription + + if (hasDescription) { + components.Description = RenderServerComponent({ + clientProps: { + collectionSlug: collectionConfig?.slug, + description: staticDescription, + } satisfies ViewDescriptionClientProps, + Component: CustomDescription, + Fallback: ViewDescription, + importMap: req.payload.importMap, + serverProps: serverProps satisfies ViewDescriptionServerPropsOnly, + }) + } + + if (collectionConfig?.versions?.drafts || globalConfig?.versions?.drafts) { + const CustomStatus = + collectionConfig?.admin?.components?.edit?.Status || + globalConfig?.admin?.components?.elements?.Status + + if (CustomStatus) { + components.Status = RenderServerComponent({ + Component: CustomStatus, + importMap: req.payload.importMap, + serverProps, + }) + } + } + + if (hasSavePermission) { + if (hasDraftsEnabled(collectionConfig || globalConfig)) { + const CustomPublishButton = + collectionConfig?.admin?.components?.edit?.PublishButton || + globalConfig?.admin?.components?.elements?.PublishButton + + if (CustomPublishButton) { + components.PublishButton = RenderServerComponent({ + Component: CustomPublishButton, + importMap: req.payload.importMap, + serverProps: serverProps satisfies PublishButtonServerPropsOnly, + }) + } + + const CustomUnpublishButton = + collectionConfig?.admin?.components?.edit?.UnpublishButton || + globalConfig?.admin?.components?.elements?.UnpublishButton + + if (CustomUnpublishButton) { + components.UnpublishButton = RenderServerComponent({ + Component: CustomUnpublishButton, + importMap: req.payload.importMap, + serverProps: serverProps satisfies UnpublishButtonServerPropsOnly, + }) + } + + const CustomSaveDraftButton = + collectionConfig?.admin?.components?.edit?.SaveDraftButton || + globalConfig?.admin?.components?.elements?.SaveDraftButton + + const draftsEnabled = hasDraftsEnabled(collectionConfig || globalConfig) + + if ((draftsEnabled || unsavedDraftWithValidations) && CustomSaveDraftButton) { + components.SaveDraftButton = RenderServerComponent({ + Component: CustomSaveDraftButton, + importMap: req.payload.importMap, + serverProps: serverProps satisfies SaveDraftButtonServerPropsOnly, + }) + } + } else { + const CustomSaveButton = + collectionConfig?.admin?.components?.edit?.SaveButton || + globalConfig?.admin?.components?.elements?.SaveButton + + if (CustomSaveButton) { + components.SaveButton = RenderServerComponent({ + Component: CustomSaveButton, + importMap: req.payload.importMap, + serverProps: serverProps satisfies SaveButtonServerPropsOnly, + }) + } + } + } + + if (collectionConfig?.upload && collectionConfig?.admin?.components?.edit?.Upload) { + components.Upload = RenderServerComponent({ + Component: collectionConfig.admin.components.edit.Upload, + importMap: req.payload.importMap, + serverProps, + }) + } + + if (collectionConfig?.upload && collectionConfig.upload.admin?.components?.controls) { + components.UploadControls = RenderServerComponent({ + Component: collectionConfig.upload.admin.components.controls, + importMap: req.payload.importMap, + serverProps, + }) + } + + return components +} + +export const renderDocumentSlotsHandler: ServerFunction<{ + collectionSlug: string + id?: number | string +}> = async (args) => { + const { id, collectionSlug, locale, permissions, req } = args + + const collectionConfig = req.payload.collections[collectionSlug]?.config + + if (!collectionConfig) { + throw new Error(req.t('error:incorrectCollection')) + } + + const { hasSavePermission } = await getDocumentPermissions({ + id, + collectionConfig, + data: {}, + req, + }) + + return renderDocumentSlots({ + id, + collectionConfig, + hasSavePermission, + locale, + permissions, + req, + }) +} From fbf3c82f6306cdb549582a8782855003c6c1fb10 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 20:05:15 +0100 Subject: [PATCH 18/60] refactor(ui): move List view utilities from packages/next --- .../src/views/List/createSerializableValue.ts | 14 +- .../views/List/enrichDocsWithVersionStatus.ts | 118 +-------- .../List/extractRelationshipDisplayValue.ts | 26 +- .../List/extractValueOrRelationshipID.ts | 22 +- packages/next/src/views/List/handleGroupBy.ts | 224 +----------------- .../src/views/List/renderListViewSlots.tsx | 123 +--------- .../src/views/List/resolveAllFilterOptions.ts | 86 +------ .../views/List/transformColumnsToSelect.ts | 15 +- packages/ui/package.json | 40 ++++ .../src/views/List/createSerializableValue.ts | 13 + .../views/List/enrichDocsWithVersionStatus.ts | 117 +++++++++ .../List/extractRelationshipDisplayValue.ts | 25 ++ .../List/extractValueOrRelationshipID.ts | 21 ++ packages/ui/src/views/List/handleGroupBy.ts | 223 +++++++++++++++++ .../ui/src/views/List/renderListViewSlots.tsx | 123 ++++++++++ .../src/views/List/resolveAllFilterOptions.ts | 86 +++++++ .../views/List/transformColumnsToSelect.ts | 14 ++ 17 files changed, 670 insertions(+), 620 deletions(-) create mode 100644 packages/ui/src/views/List/createSerializableValue.ts create mode 100644 packages/ui/src/views/List/enrichDocsWithVersionStatus.ts create mode 100644 packages/ui/src/views/List/extractRelationshipDisplayValue.ts create mode 100644 packages/ui/src/views/List/extractValueOrRelationshipID.ts create mode 100644 packages/ui/src/views/List/handleGroupBy.ts create mode 100644 packages/ui/src/views/List/renderListViewSlots.tsx create mode 100644 packages/ui/src/views/List/resolveAllFilterOptions.ts create mode 100644 packages/ui/src/views/List/transformColumnsToSelect.ts diff --git a/packages/next/src/views/List/createSerializableValue.ts b/packages/next/src/views/List/createSerializableValue.ts index a6932813a04..25f89a2070e 100644 --- a/packages/next/src/views/List/createSerializableValue.ts +++ b/packages/next/src/views/List/createSerializableValue.ts @@ -1,13 +1 @@ -// Helper function to create serializable value for client components -export const createSerializableValue = (value: any): string => { - if (value === null || value === undefined) { - return 'null' - } - if (typeof value === 'object' && value?.relationTo && value?.value) { - return `${value.relationTo}:${value.value}` - } - if (typeof value === 'object' && value?.id) { - return String(value.id) - } - return String(value) -} +export { createSerializableValue } from '@payloadcms/ui/views/List/createSerializableValue' diff --git a/packages/next/src/views/List/enrichDocsWithVersionStatus.ts b/packages/next/src/views/List/enrichDocsWithVersionStatus.ts index ff1973ebbfe..a4ad86cf6b6 100644 --- a/packages/next/src/views/List/enrichDocsWithVersionStatus.ts +++ b/packages/next/src/views/List/enrichDocsWithVersionStatus.ts @@ -1,117 +1 @@ -import type { PaginatedDocs, PayloadRequest, SanitizedCollectionConfig } from 'payload' - -/** - * Enriches list view documents with correct draft status display. - * When draft=true is used in the query, Payload returns the latest draft version if it exists. - * This function checks if draft documents also have a published version to determine "changed" status. - * - * Performance: Uses a single query to find all documents with "changed" status instead of N queries. - */ -export async function enrichDocsWithVersionStatus({ - collectionConfig, - data, - req, -}: { - collectionConfig: SanitizedCollectionConfig - data: PaginatedDocs - req: PayloadRequest -}): Promise { - const draftsEnabled = collectionConfig?.versions?.drafts - - if (!draftsEnabled || !data?.docs?.length) { - return data - } - - // Find all draft documents - // When querying with draft:true, we get the latest draft if it exists - // We need to check if these drafts have a published version - const draftDocs = data.docs.filter((doc) => doc._status === 'draft') - - if (draftDocs.length === 0) { - return data - } - - const draftDocIds = draftDocs.map((doc) => doc.id).filter(Boolean) - - if (draftDocIds.length === 0) { - return data - } - - // OPTIMIZATION: Single query to find all document IDs that have BOTH: - // 1. A draft version (latest=true, _status='draft') - // 2. A published version (_status='published') - // These are the documents with "changed" status - try { - // TODO: This could be more efficient with a findDistinctVersions() API: - // const { values } = await req.payload.findDistinctVersions({ - // collection: collectionConfig.slug, - // field: 'parent', - // where: { - // and: [ - // { parent: { in: draftDocIds } }, - // { 'version._status': { equals: 'published' } }, - // ], - // }, - // }) - // const hasPublishedVersionSet = new Set(values) - // - // For now, we query all published versions but only select the 'parent' field - // to minimize data transfer, then deduplicate with a Set - const publishedVersions = await req.payload.findVersions({ - collection: collectionConfig.slug, - depth: 0, - limit: 0, - pagination: false, - select: { - parent: true, - }, - where: { - and: [ - { - parent: { - in: draftDocIds, - }, - }, - { - 'version._status': { - equals: 'published', - }, - }, - ], - }, - }) - - // Create a Set of document IDs that have published versions - const hasPublishedVersionSet = new Set( - publishedVersions.docs.map((version) => version.parent).filter(Boolean), - ) - - // Enrich documents with display status - const enrichedDocs = data.docs.map((doc) => { - // If it's a draft and has a published version, show "changed" - if (doc._status === 'draft' && hasPublishedVersionSet.has(doc.id)) { - return { - ...doc, - _displayStatus: 'changed' as const, - } - } - - return { - ...doc, - _displayStatus: doc._status as 'draft' | 'published', - } - }) - - return { - ...data, - docs: enrichedDocs, - } - } catch (error) { - // If there's an error querying versions, just return the original data - req.payload.logger.error({ - err: error, - msg: `Error checking version status for collection ${collectionConfig.slug}`, - }) - return data - } -} +export { enrichDocsWithVersionStatus } from '@payloadcms/ui/views/List/enrichDocsWithVersionStatus' diff --git a/packages/next/src/views/List/extractRelationshipDisplayValue.ts b/packages/next/src/views/List/extractRelationshipDisplayValue.ts index ee97d7ff837..c6df380e017 100644 --- a/packages/next/src/views/List/extractRelationshipDisplayValue.ts +++ b/packages/next/src/views/List/extractRelationshipDisplayValue.ts @@ -1,25 +1 @@ -import type { ClientCollectionConfig, ClientConfig } from 'payload' - -// Helper function to extract display value from relationship -export const extractRelationshipDisplayValue = ( - relationship: any, - clientConfig: ClientConfig, - relationshipConfig?: ClientCollectionConfig, -): string => { - if (!relationship) { - return '' - } - - // Handle polymorphic relationships - if (typeof relationship === 'object' && relationship?.relationTo && relationship?.value) { - const config = clientConfig.collections.find((c) => c.slug === relationship.relationTo) - return relationship.value?.[config?.admin?.useAsTitle || 'id'] || '' - } - - // Handle regular relationships - if (typeof relationship === 'object' && relationship?.id) { - return relationship[relationshipConfig?.admin?.useAsTitle || 'id'] || '' - } - - return String(relationship) -} +export { extractRelationshipDisplayValue } from '@payloadcms/ui/views/List/extractRelationshipDisplayValue' diff --git a/packages/next/src/views/List/extractValueOrRelationshipID.ts b/packages/next/src/views/List/extractValueOrRelationshipID.ts index 17e6df50ee0..fe1bc03a0a9 100644 --- a/packages/next/src/views/List/extractValueOrRelationshipID.ts +++ b/packages/next/src/views/List/extractValueOrRelationshipID.ts @@ -1,21 +1 @@ -// Helper function to extract value or relationship ID for database queries -export const extractValueOrRelationshipID = (relationship: any): any => { - if (!relationship || typeof relationship !== 'object') { - return relationship - } - - // For polymorphic relationships, preserve structure but ensure IDs are strings - if (relationship?.relationTo && relationship?.value) { - return { - relationTo: relationship.relationTo, - value: String(relationship.value?.id || relationship.value), - } - } - - // For regular relationships, extract ID - if (relationship?.id) { - return String(relationship.id) - } - - return relationship -} +export { extractValueOrRelationshipID } from '@payloadcms/ui/views/List/extractValueOrRelationshipID' diff --git a/packages/next/src/views/List/handleGroupBy.ts b/packages/next/src/views/List/handleGroupBy.ts index be1fe168ed1..714cadf7033 100644 --- a/packages/next/src/views/List/handleGroupBy.ts +++ b/packages/next/src/views/List/handleGroupBy.ts @@ -1,223 +1 @@ -import type { - ClientCollectionConfig, - ClientConfig, - Column, - ListQuery, - PaginatedDocs, - PayloadRequest, - SanitizedCollectionConfig, - SanitizedFieldsPermissions, - SelectType, - ViewTypes, - Where, -} from 'payload' - -import { renderTable } from '@payloadcms/ui/rsc' -import { formatDate } from '@payloadcms/ui/shared' -import { flattenAllFields } from 'payload' - -import { createSerializableValue } from './createSerializableValue.js' -import { extractRelationshipDisplayValue } from './extractRelationshipDisplayValue.js' -import { extractValueOrRelationshipID } from './extractValueOrRelationshipID.js' - -export const handleGroupBy = async ({ - clientCollectionConfig, - clientConfig, - collectionConfig, - collectionSlug, - columns, - customCellProps, - drawerSlug, - enableRowSelections, - fieldPermissions, - query, - req, - select, - trash = false, - user, - viewType, - where: whereWithMergedSearch, -}: { - clientCollectionConfig: ClientCollectionConfig - clientConfig: ClientConfig - collectionConfig: SanitizedCollectionConfig - collectionSlug: string - columns: any[] - customCellProps?: Record - drawerSlug?: string - enableRowSelections?: boolean - fieldPermissions?: SanitizedFieldsPermissions - query?: ListQuery - req: PayloadRequest - select?: SelectType - trash?: boolean - user: any - viewType?: ViewTypes - where: Where -}): Promise<{ - columnState: Column[] - data: PaginatedDocs - Table: null | React.ReactNode | React.ReactNode[] -}> => { - let Table: React.ReactNode | React.ReactNode[] = null - let columnState: Column[] - - const dataByGroup: Record = {} - - // NOTE: is there a faster/better way to do this? - const flattenedFields = flattenAllFields({ fields: collectionConfig.fields }) - - const groupByFieldPath = query.groupBy.replace(/^-/, '') - - const groupByField = flattenedFields.find((f) => f.name === groupByFieldPath) - - // Set up population for relationships - let populate - - if (groupByField?.type === 'relationship' && groupByField.relationTo) { - const relationTo = Array.isArray(groupByField.relationTo) - ? groupByField.relationTo - : [groupByField.relationTo] - - populate = {} - relationTo.forEach((rel) => { - const config = clientConfig.collections.find((c) => c.slug === rel) - populate[rel] = { [config?.admin?.useAsTitle || 'id']: true } - }) - } - - const distinct = await req.payload.findDistinct({ - collection: collectionSlug, - depth: 1, - field: groupByFieldPath, - limit: query?.limit ? Number(query.limit) : undefined, - locale: req.locale, - overrideAccess: false, - page: query?.page ? Number(query.page) : undefined, - populate, - req, - sort: query?.groupBy, - trash, - where: whereWithMergedSearch, - }) - - const data = { - ...distinct, - docs: distinct.values?.map(() => ({})) || [], - values: undefined, - } - - await Promise.all( - (distinct.values || []).map(async (distinctValue, i) => { - const potentiallyPopulatedRelationship = distinctValue[groupByFieldPath] - - // Extract value or relationship ID for database query - const valueOrRelationshipID = extractValueOrRelationshipID(potentiallyPopulatedRelationship) - - const groupData = await req.payload.find({ - collection: collectionSlug, - depth: 0, - draft: true, - fallbackLocale: false, - includeLockStatus: true, - limit: query?.queryByGroup?.[valueOrRelationshipID]?.limit - ? Number(query.queryByGroup[valueOrRelationshipID].limit) - : undefined, - locale: req.locale, - overrideAccess: false, - page: query?.queryByGroup?.[valueOrRelationshipID]?.page - ? Number(query.queryByGroup[valueOrRelationshipID].page) - : undefined, - req, - // Note: if we wanted to enable table-by-table sorting, we could use this: - // sort: query?.queryByGroup?.[valueOrRelationshipID]?.sort, - select, - sort: query?.sort, - trash, - user, - where: { - ...(whereWithMergedSearch || {}), - [groupByFieldPath]: { - equals: valueOrRelationshipID, - }, - }, - }) - - // Extract heading - let heading: string - - if (potentiallyPopulatedRelationship === null) { - heading = req.i18n.t('general:noValue') - } else if (groupByField?.type === 'relationship') { - const relationshipConfig = Array.isArray(groupByField.relationTo) - ? undefined - : clientConfig.collections.find((c) => c.slug === groupByField.relationTo) - heading = extractRelationshipDisplayValue( - potentiallyPopulatedRelationship, - clientConfig, - relationshipConfig, - ) - } else if (groupByField?.type === 'date') { - heading = formatDate({ - date: String(valueOrRelationshipID), - i18n: req.i18n, - pattern: clientConfig.admin.dateFormat, - }) - } else if (groupByField?.type === 'checkbox') { - if (valueOrRelationshipID === true) { - heading = req.i18n.t('general:true') - } - if (valueOrRelationshipID === false) { - heading = req.i18n.t('general:false') - } - } else { - heading = String(valueOrRelationshipID) - } - - // Create serializable value for client - const serializableValue = createSerializableValue(valueOrRelationshipID) - - if (groupData.docs && groupData.docs.length > 0) { - const { columnState: newColumnState, Table: NewTable } = renderTable({ - clientCollectionConfig, - collectionConfig, - columns, - customCellProps, - data: groupData, - drawerSlug, - enableRowSelections, - fieldPermissions, - groupByFieldPath, - groupByValue: serializableValue, - heading: heading || req.i18n.t('general:noValue'), - i18n: req.i18n, - key: `table-${serializableValue}`, - orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined, - payload: req.payload, - query, - useAsTitle: collectionConfig.admin.useAsTitle, - viewType, - }) - - // Only need to set `columnState` once, using the first table's column state - // This will avoid needing to generate column state explicitly for root context that wraps all tables - if (!columnState) { - columnState = newColumnState - } - - if (!Table) { - Table = [] - } - - dataByGroup[serializableValue] = groupData - ;(Table as Array)[i] = NewTable - } - }), - ) - - return { - columnState, - data, - Table, - } -} +export { handleGroupBy } from '@payloadcms/ui/views/List/handleGroupBy' diff --git a/packages/next/src/views/List/renderListViewSlots.tsx b/packages/next/src/views/List/renderListViewSlots.tsx index 200fcf7f37e..f33f1d26297 100644 --- a/packages/next/src/views/List/renderListViewSlots.tsx +++ b/packages/next/src/views/List/renderListViewSlots.tsx @@ -1,122 +1 @@ -import type { - AfterListClientProps, - AfterListTableClientProps, - AfterListTableServerPropsOnly, - BeforeListClientProps, - BeforeListServerPropsOnly, - BeforeListTableClientProps, - BeforeListTableServerPropsOnly, - ListViewServerPropsOnly, - ListViewSlots, - ListViewSlotSharedClientProps, - Payload, - SanitizedCollectionConfig, - StaticDescription, - ViewDescriptionClientProps, - ViewDescriptionServerPropsOnly, -} from 'payload' - -import { Banner } from '@payloadcms/ui/elements/Banner' -import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' -import React from 'react' - -type Args = { - clientProps: ListViewSlotSharedClientProps - collectionConfig: SanitizedCollectionConfig - description?: StaticDescription - notFoundDocId?: null | string - payload: Payload - serverProps: ListViewServerPropsOnly -} - -export const renderListViewSlots = ({ - clientProps, - collectionConfig, - description, - notFoundDocId, - payload, - serverProps, -}: Args): ListViewSlots => { - const result: ListViewSlots = {} as ListViewSlots - - if (collectionConfig.admin.components?.afterList) { - result.AfterList = RenderServerComponent({ - clientProps: clientProps satisfies AfterListClientProps, - Component: collectionConfig.admin.components.afterList, - importMap: payload.importMap, - serverProps: serverProps satisfies AfterListTableServerPropsOnly, - }) - } - - const listMenuItems = collectionConfig.admin.components?.listMenuItems - - if (Array.isArray(listMenuItems)) { - result.listMenuItems = [ - RenderServerComponent({ - clientProps, - Component: listMenuItems, - importMap: payload.importMap, - serverProps, - }), - ] - } - - if (collectionConfig.admin.components?.afterListTable) { - result.AfterListTable = RenderServerComponent({ - clientProps: clientProps satisfies AfterListTableClientProps, - Component: collectionConfig.admin.components.afterListTable, - importMap: payload.importMap, - serverProps: serverProps satisfies AfterListTableServerPropsOnly, - }) - } - - if (collectionConfig.admin.components?.beforeList) { - result.BeforeList = RenderServerComponent({ - clientProps: clientProps satisfies BeforeListClientProps, - Component: collectionConfig.admin.components.beforeList, - importMap: payload.importMap, - serverProps: serverProps satisfies BeforeListServerPropsOnly, - }) - } - - // Handle beforeListTable with optional banner - const existingBeforeListTable = collectionConfig.admin.components?.beforeListTable - ? RenderServerComponent({ - clientProps: clientProps satisfies BeforeListTableClientProps, - Component: collectionConfig.admin.components.beforeListTable, - importMap: payload.importMap, - serverProps: serverProps satisfies BeforeListTableServerPropsOnly, - }) - : null - - // Create banner for document not found - const notFoundBanner = notFoundDocId ? ( - - {serverProps.i18n.t('error:documentNotFound', { id: notFoundDocId })} - - ) : null - - // Combine banner and existing component - if (notFoundBanner || existingBeforeListTable) { - result.BeforeListTable = ( - - {notFoundBanner} - {existingBeforeListTable} - - ) - } - - if (collectionConfig.admin.components?.Description) { - result.Description = RenderServerComponent({ - clientProps: { - collectionSlug: collectionConfig.slug, - description, - } satisfies ViewDescriptionClientProps, - Component: collectionConfig.admin.components.Description, - importMap: payload.importMap, - serverProps: serverProps satisfies ViewDescriptionServerPropsOnly, - }) - } - - return result -} +export { renderListViewSlots } from '@payloadcms/ui/views/List/renderListViewSlots' diff --git a/packages/next/src/views/List/resolveAllFilterOptions.ts b/packages/next/src/views/List/resolveAllFilterOptions.ts index c0582386da2..7b756037bb6 100644 --- a/packages/next/src/views/List/resolveAllFilterOptions.ts +++ b/packages/next/src/views/List/resolveAllFilterOptions.ts @@ -1,85 +1 @@ -import type { Field, PayloadRequest, ResolvedFilterOptions } from 'payload' - -import { resolveFilterOptions } from '@payloadcms/ui/rsc' -import { - fieldAffectsData, - fieldHasSubFields, - fieldIsHiddenOrDisabled, - tabHasName, -} from 'payload/shared' - -export const resolveAllFilterOptions = async ({ - fields, - pathPrefix, - req, - result, -}: { - fields: Field[] - pathPrefix?: string - req: PayloadRequest - result?: Map -}): Promise> => { - const resolvedFilterOptions = !result ? new Map() : result - - await Promise.all( - fields.map(async (field) => { - if (fieldIsHiddenOrDisabled(field)) { - return - } - - const fieldPath = fieldAffectsData(field) - ? pathPrefix - ? `${pathPrefix}.${field.name}` - : field.name - : pathPrefix - - if ( - (field.type === 'relationship' || field.type === 'upload') && - 'filterOptions' in field && - field.filterOptions - ) { - const options = await resolveFilterOptions(field.filterOptions, { - id: undefined, - blockData: undefined, - data: {}, // use empty object to prevent breaking queries when accessing properties of `data` - relationTo: field.relationTo, - req, - siblingData: {}, // use empty object to prevent breaking queries when accessing properties of `siblingData` - user: req.user, - }) - - resolvedFilterOptions.set(fieldPath, options) - } - - if (fieldHasSubFields(field)) { - await resolveAllFilterOptions({ - fields: field.fields, - pathPrefix: fieldPath, - req, - result: resolvedFilterOptions, - }) - } - - if (field.type === 'tabs') { - await Promise.all( - field.tabs.map(async (tab) => { - const tabPath = tabHasName(tab) - ? fieldPath - ? `${fieldPath}.${tab.name}` - : tab.name - : fieldPath - - await resolveAllFilterOptions({ - fields: tab.fields, - pathPrefix: tabPath, - req, - result: resolvedFilterOptions, - }) - }), - ) - } - }), - ) - - return resolvedFilterOptions -} +export { resolveAllFilterOptions } from '@payloadcms/ui/views/List/resolveAllFilterOptions' diff --git a/packages/next/src/views/List/transformColumnsToSelect.ts b/packages/next/src/views/List/transformColumnsToSelect.ts index 1f268884da7..3f985b2bbf3 100644 --- a/packages/next/src/views/List/transformColumnsToSelect.ts +++ b/packages/next/src/views/List/transformColumnsToSelect.ts @@ -1,14 +1 @@ -import type { ColumnPreference, SelectType } from 'payload' - -import { unflatten } from 'payload/shared' - -export const transformColumnsToSelect = (columns: ColumnPreference[]): SelectType => { - const columnsSelect = columns.reduce((acc, column) => { - if (column.active) { - acc[column.accessor] = true - } - return acc - }, {} as SelectType) - - return unflatten(columnsSelect) -} +export { transformColumnsToSelect } from '@payloadcms/ui/views/List/transformColumnsToSelect' diff --git a/packages/ui/package.json b/packages/ui/package.json index d0fff9d3675..e3cadcc1e88 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -86,6 +86,46 @@ "types": "./src/views/Document/renderDocumentSlots.tsx", "default": "./src/views/Document/renderDocumentSlots.tsx" }, + "./views/List/createSerializableValue": { + "import": "./src/views/List/createSerializableValue.ts", + "types": "./src/views/List/createSerializableValue.ts", + "default": "./src/views/List/createSerializableValue.ts" + }, + "./views/List/enrichDocsWithVersionStatus": { + "import": "./src/views/List/enrichDocsWithVersionStatus.ts", + "types": "./src/views/List/enrichDocsWithVersionStatus.ts", + "default": "./src/views/List/enrichDocsWithVersionStatus.ts" + }, + "./views/List/extractRelationshipDisplayValue": { + "import": "./src/views/List/extractRelationshipDisplayValue.ts", + "types": "./src/views/List/extractRelationshipDisplayValue.ts", + "default": "./src/views/List/extractRelationshipDisplayValue.ts" + }, + "./views/List/extractValueOrRelationshipID": { + "import": "./src/views/List/extractValueOrRelationshipID.ts", + "types": "./src/views/List/extractValueOrRelationshipID.ts", + "default": "./src/views/List/extractValueOrRelationshipID.ts" + }, + "./views/List/handleGroupBy": { + "import": "./src/views/List/handleGroupBy.ts", + "types": "./src/views/List/handleGroupBy.ts", + "default": "./src/views/List/handleGroupBy.ts" + }, + "./views/List/renderListViewSlots": { + "import": "./src/views/List/renderListViewSlots.tsx", + "types": "./src/views/List/renderListViewSlots.tsx", + "default": "./src/views/List/renderListViewSlots.tsx" + }, + "./views/List/resolveAllFilterOptions": { + "import": "./src/views/List/resolveAllFilterOptions.ts", + "types": "./src/views/List/resolveAllFilterOptions.ts", + "default": "./src/views/List/resolveAllFilterOptions.ts" + }, + "./views/List/transformColumnsToSelect": { + "import": "./src/views/List/transformColumnsToSelect.ts", + "types": "./src/views/List/transformColumnsToSelect.ts", + "default": "./src/views/List/transformColumnsToSelect.ts" + }, "./templates/*": { "import": "./src/templates/*/index.tsx", "types": "./src/templates/*/index.tsx", diff --git a/packages/ui/src/views/List/createSerializableValue.ts b/packages/ui/src/views/List/createSerializableValue.ts new file mode 100644 index 00000000000..a6932813a04 --- /dev/null +++ b/packages/ui/src/views/List/createSerializableValue.ts @@ -0,0 +1,13 @@ +// Helper function to create serializable value for client components +export const createSerializableValue = (value: any): string => { + if (value === null || value === undefined) { + return 'null' + } + if (typeof value === 'object' && value?.relationTo && value?.value) { + return `${value.relationTo}:${value.value}` + } + if (typeof value === 'object' && value?.id) { + return String(value.id) + } + return String(value) +} diff --git a/packages/ui/src/views/List/enrichDocsWithVersionStatus.ts b/packages/ui/src/views/List/enrichDocsWithVersionStatus.ts new file mode 100644 index 00000000000..ff1973ebbfe --- /dev/null +++ b/packages/ui/src/views/List/enrichDocsWithVersionStatus.ts @@ -0,0 +1,117 @@ +import type { PaginatedDocs, PayloadRequest, SanitizedCollectionConfig } from 'payload' + +/** + * Enriches list view documents with correct draft status display. + * When draft=true is used in the query, Payload returns the latest draft version if it exists. + * This function checks if draft documents also have a published version to determine "changed" status. + * + * Performance: Uses a single query to find all documents with "changed" status instead of N queries. + */ +export async function enrichDocsWithVersionStatus({ + collectionConfig, + data, + req, +}: { + collectionConfig: SanitizedCollectionConfig + data: PaginatedDocs + req: PayloadRequest +}): Promise { + const draftsEnabled = collectionConfig?.versions?.drafts + + if (!draftsEnabled || !data?.docs?.length) { + return data + } + + // Find all draft documents + // When querying with draft:true, we get the latest draft if it exists + // We need to check if these drafts have a published version + const draftDocs = data.docs.filter((doc) => doc._status === 'draft') + + if (draftDocs.length === 0) { + return data + } + + const draftDocIds = draftDocs.map((doc) => doc.id).filter(Boolean) + + if (draftDocIds.length === 0) { + return data + } + + // OPTIMIZATION: Single query to find all document IDs that have BOTH: + // 1. A draft version (latest=true, _status='draft') + // 2. A published version (_status='published') + // These are the documents with "changed" status + try { + // TODO: This could be more efficient with a findDistinctVersions() API: + // const { values } = await req.payload.findDistinctVersions({ + // collection: collectionConfig.slug, + // field: 'parent', + // where: { + // and: [ + // { parent: { in: draftDocIds } }, + // { 'version._status': { equals: 'published' } }, + // ], + // }, + // }) + // const hasPublishedVersionSet = new Set(values) + // + // For now, we query all published versions but only select the 'parent' field + // to minimize data transfer, then deduplicate with a Set + const publishedVersions = await req.payload.findVersions({ + collection: collectionConfig.slug, + depth: 0, + limit: 0, + pagination: false, + select: { + parent: true, + }, + where: { + and: [ + { + parent: { + in: draftDocIds, + }, + }, + { + 'version._status': { + equals: 'published', + }, + }, + ], + }, + }) + + // Create a Set of document IDs that have published versions + const hasPublishedVersionSet = new Set( + publishedVersions.docs.map((version) => version.parent).filter(Boolean), + ) + + // Enrich documents with display status + const enrichedDocs = data.docs.map((doc) => { + // If it's a draft and has a published version, show "changed" + if (doc._status === 'draft' && hasPublishedVersionSet.has(doc.id)) { + return { + ...doc, + _displayStatus: 'changed' as const, + } + } + + return { + ...doc, + _displayStatus: doc._status as 'draft' | 'published', + } + }) + + return { + ...data, + docs: enrichedDocs, + } + } catch (error) { + // If there's an error querying versions, just return the original data + req.payload.logger.error({ + err: error, + msg: `Error checking version status for collection ${collectionConfig.slug}`, + }) + return data + } +} diff --git a/packages/ui/src/views/List/extractRelationshipDisplayValue.ts b/packages/ui/src/views/List/extractRelationshipDisplayValue.ts new file mode 100644 index 00000000000..ee97d7ff837 --- /dev/null +++ b/packages/ui/src/views/List/extractRelationshipDisplayValue.ts @@ -0,0 +1,25 @@ +import type { ClientCollectionConfig, ClientConfig } from 'payload' + +// Helper function to extract display value from relationship +export const extractRelationshipDisplayValue = ( + relationship: any, + clientConfig: ClientConfig, + relationshipConfig?: ClientCollectionConfig, +): string => { + if (!relationship) { + return '' + } + + // Handle polymorphic relationships + if (typeof relationship === 'object' && relationship?.relationTo && relationship?.value) { + const config = clientConfig.collections.find((c) => c.slug === relationship.relationTo) + return relationship.value?.[config?.admin?.useAsTitle || 'id'] || '' + } + + // Handle regular relationships + if (typeof relationship === 'object' && relationship?.id) { + return relationship[relationshipConfig?.admin?.useAsTitle || 'id'] || '' + } + + return String(relationship) +} diff --git a/packages/ui/src/views/List/extractValueOrRelationshipID.ts b/packages/ui/src/views/List/extractValueOrRelationshipID.ts new file mode 100644 index 00000000000..17e6df50ee0 --- /dev/null +++ b/packages/ui/src/views/List/extractValueOrRelationshipID.ts @@ -0,0 +1,21 @@ +// Helper function to extract value or relationship ID for database queries +export const extractValueOrRelationshipID = (relationship: any): any => { + if (!relationship || typeof relationship !== 'object') { + return relationship + } + + // For polymorphic relationships, preserve structure but ensure IDs are strings + if (relationship?.relationTo && relationship?.value) { + return { + relationTo: relationship.relationTo, + value: String(relationship.value?.id || relationship.value), + } + } + + // For regular relationships, extract ID + if (relationship?.id) { + return String(relationship.id) + } + + return relationship +} diff --git a/packages/ui/src/views/List/handleGroupBy.ts b/packages/ui/src/views/List/handleGroupBy.ts new file mode 100644 index 00000000000..942900b5fa5 --- /dev/null +++ b/packages/ui/src/views/List/handleGroupBy.ts @@ -0,0 +1,223 @@ +import type { + ClientCollectionConfig, + ClientConfig, + Column, + ListQuery, + PaginatedDocs, + PayloadRequest, + SanitizedCollectionConfig, + SanitizedFieldsPermissions, + SelectType, + ViewTypes, + Where, +} from 'payload' + +import { flattenAllFields } from 'payload' + +import { formatDate } from '../../utilities/formatDocTitle/formatDateTitle.js' +import { renderTable } from '../../utilities/renderTable.js' +import { createSerializableValue } from './createSerializableValue.js' +import { extractRelationshipDisplayValue } from './extractRelationshipDisplayValue.js' +import { extractValueOrRelationshipID } from './extractValueOrRelationshipID.js' + +export const handleGroupBy = async ({ + clientCollectionConfig, + clientConfig, + collectionConfig, + collectionSlug, + columns, + customCellProps, + drawerSlug, + enableRowSelections, + fieldPermissions, + query, + req, + select, + trash = false, + user, + viewType, + where: whereWithMergedSearch, +}: { + clientCollectionConfig: ClientCollectionConfig + clientConfig: ClientConfig + collectionConfig: SanitizedCollectionConfig + collectionSlug: string + columns: any[] + customCellProps?: Record + drawerSlug?: string + enableRowSelections?: boolean + fieldPermissions?: SanitizedFieldsPermissions + query?: ListQuery + req: PayloadRequest + select?: SelectType + trash?: boolean + user: any + viewType?: ViewTypes + where: Where +}): Promise<{ + columnState: Column[] + data: PaginatedDocs + Table: null | React.ReactNode | React.ReactNode[] +}> => { + let Table: React.ReactNode | React.ReactNode[] = null + let columnState: Column[] + + const dataByGroup: Record = {} + + // NOTE: is there a faster/better way to do this? + const flattenedFields = flattenAllFields({ fields: collectionConfig.fields }) + + const groupByFieldPath = query.groupBy.replace(/^-/, '') + + const groupByField = flattenedFields.find((f) => f.name === groupByFieldPath) + + // Set up population for relationships + let populate + + if (groupByField?.type === 'relationship' && groupByField.relationTo) { + const relationTo = Array.isArray(groupByField.relationTo) + ? groupByField.relationTo + : [groupByField.relationTo] + + populate = {} + relationTo.forEach((rel) => { + const config = clientConfig.collections.find((c) => c.slug === rel) + populate[rel] = { [config?.admin?.useAsTitle || 'id']: true } + }) + } + + const distinct = await req.payload.findDistinct({ + collection: collectionSlug, + depth: 1, + field: groupByFieldPath, + limit: query?.limit ? Number(query.limit) : undefined, + locale: req.locale, + overrideAccess: false, + page: query?.page ? Number(query.page) : undefined, + populate, + req, + sort: query?.groupBy, + trash, + where: whereWithMergedSearch, + }) + + const data = { + ...distinct, + docs: distinct.values?.map(() => ({})) || [], + values: undefined, + } + + await Promise.all( + (distinct.values || []).map(async (distinctValue, i) => { + const potentiallyPopulatedRelationship = distinctValue[groupByFieldPath] + + // Extract value or relationship ID for database query + const valueOrRelationshipID = extractValueOrRelationshipID(potentiallyPopulatedRelationship) + + const groupData = await req.payload.find({ + collection: collectionSlug, + depth: 0, + draft: true, + fallbackLocale: false, + includeLockStatus: true, + limit: query?.queryByGroup?.[valueOrRelationshipID]?.limit + ? Number(query.queryByGroup[valueOrRelationshipID].limit) + : undefined, + locale: req.locale, + overrideAccess: false, + page: query?.queryByGroup?.[valueOrRelationshipID]?.page + ? Number(query.queryByGroup[valueOrRelationshipID].page) + : undefined, + req, + // Note: if we wanted to enable table-by-table sorting, we could use this: + // sort: query?.queryByGroup?.[valueOrRelationshipID]?.sort, + select, + sort: query?.sort, + trash, + user, + where: { + ...(whereWithMergedSearch || {}), + [groupByFieldPath]: { + equals: valueOrRelationshipID, + }, + }, + }) + + // Extract heading + let heading: string + + if (potentiallyPopulatedRelationship === null) { + heading = req.i18n.t('general:noValue') + } else if (groupByField?.type === 'relationship') { + const relationshipConfig = Array.isArray(groupByField.relationTo) + ? undefined + : clientConfig.collections.find((c) => c.slug === groupByField.relationTo) + heading = extractRelationshipDisplayValue( + potentiallyPopulatedRelationship, + clientConfig, + relationshipConfig, + ) + } else if (groupByField?.type === 'date') { + heading = formatDate({ + date: String(valueOrRelationshipID), + i18n: req.i18n, + pattern: clientConfig.admin.dateFormat, + }) + } else if (groupByField?.type === 'checkbox') { + if (valueOrRelationshipID === true) { + heading = req.i18n.t('general:true') + } + if (valueOrRelationshipID === false) { + heading = req.i18n.t('general:false') + } + } else { + heading = String(valueOrRelationshipID) + } + + // Create serializable value for client + const serializableValue = createSerializableValue(valueOrRelationshipID) + + if (groupData.docs && groupData.docs.length > 0) { + const { columnState: newColumnState, Table: NewTable } = renderTable({ + clientCollectionConfig, + collectionConfig, + columns, + customCellProps, + data: groupData, + drawerSlug, + enableRowSelections, + fieldPermissions, + groupByFieldPath, + groupByValue: serializableValue, + heading: heading || req.i18n.t('general:noValue'), + i18n: req.i18n, + key: `table-${serializableValue}`, + orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined, + payload: req.payload, + query, + useAsTitle: collectionConfig.admin.useAsTitle, + viewType, + }) + + // Only need to set `columnState` once, using the first table's column state + // This will avoid needing to generate column state explicitly for root context that wraps all tables + if (!columnState) { + columnState = newColumnState + } + + if (!Table) { + Table = [] + } + + dataByGroup[serializableValue] = groupData + ;(Table as Array)[i] = NewTable + } + }), + ) + + return { + columnState, + data, + Table, + } +} diff --git a/packages/ui/src/views/List/renderListViewSlots.tsx b/packages/ui/src/views/List/renderListViewSlots.tsx new file mode 100644 index 00000000000..b2ae563d9b4 --- /dev/null +++ b/packages/ui/src/views/List/renderListViewSlots.tsx @@ -0,0 +1,123 @@ +import type { + AfterListClientProps, + AfterListTableClientProps, + AfterListTableServerPropsOnly, + BeforeListClientProps, + BeforeListServerPropsOnly, + BeforeListTableClientProps, + BeforeListTableServerPropsOnly, + ListViewServerPropsOnly, + ListViewSlots, + ListViewSlotSharedClientProps, + Payload, + SanitizedCollectionConfig, + StaticDescription, + ViewDescriptionClientProps, + ViewDescriptionServerPropsOnly, +} from 'payload' + +import React from 'react' + +import { Banner } from '../../elements/Banner/index.js' +import { RenderServerComponent } from '../../elements/RenderServerComponent/index.js' + +type Args = { + clientProps: ListViewSlotSharedClientProps + collectionConfig: SanitizedCollectionConfig + description?: StaticDescription + notFoundDocId?: null | string + payload: Payload + serverProps: ListViewServerPropsOnly +} + +export const renderListViewSlots = ({ + clientProps, + collectionConfig, + description, + notFoundDocId, + payload, + serverProps, +}: Args): ListViewSlots => { + const result: ListViewSlots = {} as ListViewSlots + + if (collectionConfig.admin.components?.afterList) { + result.AfterList = RenderServerComponent({ + clientProps: clientProps satisfies AfterListClientProps, + Component: collectionConfig.admin.components.afterList, + importMap: payload.importMap, + serverProps: serverProps satisfies AfterListTableServerPropsOnly, + }) + } + + const listMenuItems = collectionConfig.admin.components?.listMenuItems + + if (Array.isArray(listMenuItems)) { + result.listMenuItems = [ + RenderServerComponent({ + clientProps, + Component: listMenuItems, + importMap: payload.importMap, + serverProps, + }), + ] + } + + if (collectionConfig.admin.components?.afterListTable) { + result.AfterListTable = RenderServerComponent({ + clientProps: clientProps satisfies AfterListTableClientProps, + Component: collectionConfig.admin.components.afterListTable, + importMap: payload.importMap, + serverProps: serverProps satisfies AfterListTableServerPropsOnly, + }) + } + + if (collectionConfig.admin.components?.beforeList) { + result.BeforeList = RenderServerComponent({ + clientProps: clientProps satisfies BeforeListClientProps, + Component: collectionConfig.admin.components.beforeList, + importMap: payload.importMap, + serverProps: serverProps satisfies BeforeListServerPropsOnly, + }) + } + + // Handle beforeListTable with optional banner + const existingBeforeListTable = collectionConfig.admin.components?.beforeListTable + ? RenderServerComponent({ + clientProps: clientProps satisfies BeforeListTableClientProps, + Component: collectionConfig.admin.components.beforeListTable, + importMap: payload.importMap, + serverProps: serverProps satisfies BeforeListTableServerPropsOnly, + }) + : null + + // Create banner for document not found + const notFoundBanner = notFoundDocId ? ( + + {serverProps.i18n.t('error:documentNotFound', { id: notFoundDocId })} + + ) : null + + // Combine banner and existing component + if (notFoundBanner || existingBeforeListTable) { + result.BeforeListTable = ( + + {notFoundBanner} + {existingBeforeListTable} + + ) + } + + if (collectionConfig.admin.components?.Description) { + result.Description = RenderServerComponent({ + clientProps: { + collectionSlug: collectionConfig.slug, + description, + } satisfies ViewDescriptionClientProps, + Component: collectionConfig.admin.components.Description, + importMap: payload.importMap, + serverProps: serverProps satisfies ViewDescriptionServerPropsOnly, + }) + } + + return result +} diff --git a/packages/ui/src/views/List/resolveAllFilterOptions.ts b/packages/ui/src/views/List/resolveAllFilterOptions.ts new file mode 100644 index 00000000000..a0331874fbc --- /dev/null +++ b/packages/ui/src/views/List/resolveAllFilterOptions.ts @@ -0,0 +1,86 @@ +import type { Field, PayloadRequest, ResolvedFilterOptions } from 'payload' + +import { + fieldAffectsData, + fieldHasSubFields, + fieldIsHiddenOrDisabled, + tabHasName, +} from 'payload/shared' + +import { resolveFilterOptions } from '../../utilities/resolveFilterOptions.js' + +export const resolveAllFilterOptions = async ({ + fields, + pathPrefix, + req, + result, +}: { + fields: Field[] + pathPrefix?: string + req: PayloadRequest + result?: Map +}): Promise> => { + const resolvedFilterOptions = !result ? new Map() : result + + await Promise.all( + fields.map(async (field) => { + if (fieldIsHiddenOrDisabled(field)) { + return + } + + const fieldPath = fieldAffectsData(field) + ? pathPrefix + ? `${pathPrefix}.${field.name}` + : field.name + : pathPrefix + + if ( + (field.type === 'relationship' || field.type === 'upload') && + 'filterOptions' in field && + field.filterOptions + ) { + const options = await resolveFilterOptions(field.filterOptions, { + id: undefined, + blockData: undefined, + data: {}, // use empty object to prevent breaking queries when accessing properties of `data` + relationTo: field.relationTo, + req, + siblingData: {}, // use empty object to prevent breaking queries when accessing properties of `siblingData` + user: req.user, + }) + + resolvedFilterOptions.set(fieldPath, options) + } + + if (fieldHasSubFields(field)) { + await resolveAllFilterOptions({ + fields: field.fields, + pathPrefix: fieldPath, + req, + result: resolvedFilterOptions, + }) + } + + if (field.type === 'tabs') { + await Promise.all( + field.tabs.map(async (tab) => { + const tabPath = tabHasName(tab) + ? fieldPath + ? `${fieldPath}.${tab.name}` + : tab.name + : fieldPath + + await resolveAllFilterOptions({ + fields: tab.fields, + pathPrefix: tabPath, + req, + result: resolvedFilterOptions, + }) + }), + ) + } + }), + ) + + return resolvedFilterOptions +} diff --git a/packages/ui/src/views/List/transformColumnsToSelect.ts b/packages/ui/src/views/List/transformColumnsToSelect.ts new file mode 100644 index 00000000000..1f268884da7 --- /dev/null +++ b/packages/ui/src/views/List/transformColumnsToSelect.ts @@ -0,0 +1,14 @@ +import type { ColumnPreference, SelectType } from 'payload' + +import { unflatten } from 'payload/shared' + +export const transformColumnsToSelect = (columns: ColumnPreference[]): SelectType => { + const columnsSelect = columns.reduce((acc, column) => { + if (column.active) { + acc[column.accessor] = true + } + return acc + }, {} as SelectType) + + return unflatten(columnsSelect) +} From a6fb85974ce002259153bd8abb61d3fd068e8bc8 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 20:25:51 +0100 Subject: [PATCH 19/60] refactor(ui): move Version/Versions/Account utilities from packages/next --- .../views/Account/ResetPreferences/index.tsx | 91 +-- .../Account/Settings/LanguageSelector.tsx | 28 +- .../next/src/views/Account/Settings/index.tsx | 36 +- .../src/views/Account/ToggleTheme/index.tsx | 44 +- .../next/src/views/Account/index.client.tsx | 22 +- .../Default/SelectedLocalesContext.tsx | 17 +- .../src/views/Version/Default/SetStepNav.tsx | 132 +--- .../next/src/views/Version/Default/types.ts | 29 +- .../DiffCollapser/index.tsx | 123 +--- .../RenderVersionFieldsToDiff.tsx | 74 +-- .../RenderFieldsToDiff/buildVersionFields.tsx | 567 +----------------- .../fields/Collapsible/index.tsx | 47 +- .../RenderFieldsToDiff/fields/Date/index.tsx | 79 +-- .../RenderFieldsToDiff/fields/Group/index.tsx | 54 +- .../fields/Iterable/index.tsx | 123 +--- .../Relationship/generateLabelFromValue.ts | 101 +--- .../fields/Relationship/index.tsx | 343 +---------- .../RenderFieldsToDiff/fields/Row/index.tsx | 17 +- .../fields/Select/index.tsx | 123 +--- .../RenderFieldsToDiff/fields/Tabs/index.tsx | 121 +--- .../RenderFieldsToDiff/fields/Text/index.tsx | 99 +-- .../fields/Upload/index.tsx | 312 +--------- .../RenderFieldsToDiff/fields/index.ts | 41 +- .../Version/RenderFieldsToDiff/index.tsx | 9 +- .../utilities/countChangedFields.ts | 259 +------- .../utilities/fieldHasChanges.ts | 4 +- .../utilities/getFieldsForRowComparison.ts | 90 +-- .../VersionDrawer/CreatedAtCell.tsx | 53 +- .../SelectComparison/VersionDrawer/index.tsx | 207 +------ .../views/Version/SelectComparison/index.tsx | 69 +-- .../views/Version/SelectComparison/types.ts | 35 +- .../VersionPillLabel/VersionPillLabel.tsx | 122 +--- .../VersionPillLabel/getVersionLabel.ts | 86 +-- .../next/src/views/Version/fetchVersions.ts | 211 +------ .../next/src/views/Versions/buildColumns.tsx | 106 +--- .../Versions/cells/AutosaveCell/index.tsx | 49 +- .../views/Versions/cells/CreatedAt/index.tsx | 59 +- .../src/views/Versions/cells/ID/index.tsx | 7 +- .../next/src/views/Versions/index.client.tsx | 77 +-- packages/ui/package.json | 195 ++++++ .../views/Account/ResetPreferences/index.tsx | 89 +++ .../Account/Settings/LanguageSelector.tsx | 30 + .../ui/src/views/Account/Settings/index.scss | 48 ++ .../ui/src/views/Account/Settings/index.tsx | 35 ++ .../src/views/Account/ToggleTheme/index.tsx | 46 ++ .../ui/src/views/Account/index.client.tsx | 23 + .../Default/SelectedLocalesContext.tsx | 13 + .../src/views/Version/Default/SetStepNav.tsx | 136 +++++ .../ui/src/views/Version/Default/index.scss | 170 ++++++ .../ui/src/views/Version/Default/types.ts | 24 + .../DiffCollapser/index.scss | 81 +++ .../DiffCollapser/index.tsx | 125 ++++ .../RenderVersionFieldsToDiff.tsx | 74 +++ .../RenderFieldsToDiff/buildVersionFields.tsx | 565 +++++++++++++++++ .../fields/Collapsible/index.tsx | 46 ++ .../RenderFieldsToDiff/fields/Date/index.scss | 12 + .../RenderFieldsToDiff/fields/Date/index.tsx | 78 +++ .../fields/Group/index.scss | 9 + .../RenderFieldsToDiff/fields/Group/index.tsx | 53 ++ .../fields/Iterable/index.scss | 59 ++ .../fields/Iterable/index.tsx | 121 ++++ .../Relationship/generateLabelFromValue.ts | 100 +++ .../fields/Relationship/index.scss | 91 +++ .../fields/Relationship/index.tsx | 337 +++++++++++ .../RenderFieldsToDiff/fields/Row/index.tsx | 16 + .../fields/Select/index.scss | 4 + .../fields/Select/index.tsx | 122 ++++ .../RenderFieldsToDiff/fields/Tabs/index.scss | 9 + .../RenderFieldsToDiff/fields/Tabs/index.tsx | 120 ++++ .../RenderFieldsToDiff/fields/Text/index.scss | 4 + .../RenderFieldsToDiff/fields/Text/index.tsx | 98 +++ .../fields/Upload/index.scss | 121 ++++ .../fields/Upload/index.tsx | 308 ++++++++++ .../RenderFieldsToDiff/fields/index.ts | 40 ++ .../Version/RenderFieldsToDiff/index.scss | 24 + .../Version/RenderFieldsToDiff/index.tsx | 8 + .../utilities/countChangedFields.spec.ts | 540 +++++++++++++++++ .../utilities/countChangedFields.ts | 255 ++++++++ .../utilities/fieldHasChanges.spec.ts | 40 ++ .../utilities/fieldHasChanges.ts | 3 + .../getFieldsForRowComparison.spec.ts | 108 ++++ .../utilities/getFieldsForRowComparison.ts | 89 +++ .../VersionDrawer/CreatedAtCell.tsx | 48 ++ .../SelectComparison/VersionDrawer/index.scss | 18 + .../SelectComparison/VersionDrawer/index.tsx | 197 ++++++ .../views/Version/SelectComparison/index.scss | 9 + .../views/Version/SelectComparison/index.tsx | 68 +++ .../views/Version/SelectComparison/types.ts | 30 + .../VersionPillLabel/VersionPillLabel.tsx | 124 ++++ .../VersionPillLabel/getVersionLabel.ts | 86 +++ .../views/Version/VersionPillLabel/index.scss | 26 + .../ui/src/views/Version/fetchVersions.ts | 206 +++++++ .../ui/src/views/Versions/buildColumns.tsx | 105 ++++ .../Versions/cells/AutosaveCell/index.scss | 9 + .../Versions/cells/AutosaveCell/index.tsx | 49 ++ .../views/Versions/cells/CreatedAt/index.tsx | 60 ++ .../ui/src/views/Versions/cells/ID/index.tsx | 6 + .../ui/src/views/Versions/index.client.tsx | 75 +++ 98 files changed, 5658 insertions(+), 3993 deletions(-) create mode 100644 packages/ui/src/views/Account/ResetPreferences/index.tsx create mode 100644 packages/ui/src/views/Account/Settings/LanguageSelector.tsx create mode 100644 packages/ui/src/views/Account/Settings/index.scss create mode 100644 packages/ui/src/views/Account/Settings/index.tsx create mode 100644 packages/ui/src/views/Account/ToggleTheme/index.tsx create mode 100644 packages/ui/src/views/Account/index.client.tsx create mode 100644 packages/ui/src/views/Version/Default/SelectedLocalesContext.tsx create mode 100644 packages/ui/src/views/Version/Default/SetStepNav.tsx create mode 100644 packages/ui/src/views/Version/Default/index.scss create mode 100644 packages/ui/src/views/Version/Default/types.ts create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/DiffCollapser/index.scss create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/DiffCollapser/index.tsx create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/RenderVersionFieldsToDiff.tsx create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/buildVersionFields.tsx create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/fields/Collapsible/index.tsx create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/fields/Date/index.scss create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/fields/Date/index.tsx create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/fields/Group/index.scss create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/fields/Group/index.tsx create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/fields/Iterable/index.scss create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/fields/Iterable/index.tsx create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/fields/Relationship/generateLabelFromValue.ts create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/fields/Relationship/index.scss create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/fields/Relationship/index.tsx create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/fields/Row/index.tsx create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/fields/Select/index.scss create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/fields/Select/index.tsx create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/fields/Tabs/index.scss create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/fields/Tabs/index.tsx create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/fields/Text/index.scss create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/fields/Text/index.tsx create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/fields/Upload/index.scss create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/fields/Upload/index.tsx create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/fields/index.ts create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/index.scss create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/index.tsx create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.spec.ts create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.ts create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/utilities/fieldHasChanges.spec.ts create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/utilities/fieldHasChanges.ts create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison.spec.ts create mode 100644 packages/ui/src/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison.ts create mode 100644 packages/ui/src/views/Version/SelectComparison/VersionDrawer/CreatedAtCell.tsx create mode 100644 packages/ui/src/views/Version/SelectComparison/VersionDrawer/index.scss create mode 100644 packages/ui/src/views/Version/SelectComparison/VersionDrawer/index.tsx create mode 100644 packages/ui/src/views/Version/SelectComparison/index.scss create mode 100644 packages/ui/src/views/Version/SelectComparison/index.tsx create mode 100644 packages/ui/src/views/Version/SelectComparison/types.ts create mode 100644 packages/ui/src/views/Version/VersionPillLabel/VersionPillLabel.tsx create mode 100644 packages/ui/src/views/Version/VersionPillLabel/getVersionLabel.ts create mode 100644 packages/ui/src/views/Version/VersionPillLabel/index.scss create mode 100644 packages/ui/src/views/Version/fetchVersions.ts create mode 100644 packages/ui/src/views/Versions/buildColumns.tsx create mode 100644 packages/ui/src/views/Versions/cells/AutosaveCell/index.scss create mode 100644 packages/ui/src/views/Versions/cells/AutosaveCell/index.tsx create mode 100644 packages/ui/src/views/Versions/cells/CreatedAt/index.tsx create mode 100644 packages/ui/src/views/Versions/cells/ID/index.tsx create mode 100644 packages/ui/src/views/Versions/index.client.tsx diff --git a/packages/next/src/views/Account/ResetPreferences/index.tsx b/packages/next/src/views/Account/ResetPreferences/index.tsx index dd427bd2f77..834457fabd2 100644 --- a/packages/next/src/views/Account/ResetPreferences/index.tsx +++ b/packages/next/src/views/Account/ResetPreferences/index.tsx @@ -1,90 +1 @@ -'use client' -import type { TypedUser } from 'payload' - -import { - Button, - ConfirmationModal, - toast, - useConfig, - useModal, - useTranslation, -} from '@payloadcms/ui' -import { formatAdminURL } from 'payload/shared' -import * as qs from 'qs-esm' -import { Fragment, useCallback } from 'react' - -const confirmResetModalSlug = 'confirm-reset-modal' - -export const ResetPreferences: React.FC<{ - readonly user?: TypedUser -}> = ({ user }) => { - const { openModal } = useModal() - const { t } = useTranslation() - const { - config: { - routes: { api: apiRoute }, - }, - } = useConfig() - - const handleResetPreferences = useCallback(async () => { - if (!user) { - return - } - - const stringifiedQuery = qs.stringify( - { - depth: 0, - where: { - 'user.value': { - equals: user.id, - }, - }, - }, - { addQueryPrefix: true }, - ) - - try { - const res = await fetch( - formatAdminURL({ - apiRoute, - path: `/payload-preferences${stringifiedQuery}`, - }), - { - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - }, - method: 'DELETE', - }, - ) - - const json = await res.json() - const message = json.message - - if (res.ok) { - toast.success(message) - } else { - toast.error(message) - } - } catch (_err) { - // swallow error - } - }, [apiRoute, user]) - - return ( - -
- -
- -
- ) -} +export { ResetPreferences } from '@payloadcms/ui/views/Account/ResetPreferences/index' diff --git a/packages/next/src/views/Account/Settings/LanguageSelector.tsx b/packages/next/src/views/Account/Settings/LanguageSelector.tsx index 1bea75fdec7..65609c6e0bf 100644 --- a/packages/next/src/views/Account/Settings/LanguageSelector.tsx +++ b/packages/next/src/views/Account/Settings/LanguageSelector.tsx @@ -1,27 +1 @@ -'use client' -import type { AcceptedLanguages } from '@payloadcms/translations' -import type { ReactSelectOption } from '@payloadcms/ui' -import type { LanguageOptions } from 'payload' - -import { ReactSelect, useTranslation } from '@payloadcms/ui' -import React from 'react' - -export const LanguageSelector: React.FC<{ - languageOptions: LanguageOptions -}> = (props) => { - const { languageOptions } = props - - const { i18n, switchLanguage } = useTranslation() - - return ( - ) => { - await switchLanguage(option.value) - }} - options={languageOptions} - value={languageOptions.find((language) => language.value === i18n.language)} - /> - ) -} +export { LanguageSelector } from '@payloadcms/ui/views/Account/Settings/LanguageSelector' diff --git a/packages/next/src/views/Account/Settings/index.tsx b/packages/next/src/views/Account/Settings/index.tsx index 555d8d6e752..74df72f93f9 100644 --- a/packages/next/src/views/Account/Settings/index.tsx +++ b/packages/next/src/views/Account/Settings/index.tsx @@ -1,35 +1 @@ -import type { I18n } from '@payloadcms/translations' -import type { BasePayload, Config, LanguageOptions, TypedUser } from 'payload' - -import { FieldLabel } from '@payloadcms/ui' -import React from 'react' - -import { ResetPreferences } from '../ResetPreferences/index.js' -import './index.scss' -import { ToggleTheme } from '../ToggleTheme/index.js' -import { LanguageSelector } from './LanguageSelector.js' - -const baseClass = 'payload-settings' - -export const Settings: React.FC<{ - readonly className?: string - readonly i18n: I18n - readonly languageOptions: LanguageOptions - readonly payload: BasePayload - readonly theme: Config['admin']['theme'] - readonly user?: TypedUser -}> = (props) => { - const { className, i18n, languageOptions, theme, user } = props - - return ( -
-

{i18n.t('general:payloadSettings')}

-
- - -
- {theme === 'all' && } - -
- ) -} +export { Settings } from '@payloadcms/ui/views/Account/Settings/index' diff --git a/packages/next/src/views/Account/ToggleTheme/index.tsx b/packages/next/src/views/Account/ToggleTheme/index.tsx index 3fd6a13a867..140ba302b81 100644 --- a/packages/next/src/views/Account/ToggleTheme/index.tsx +++ b/packages/next/src/views/Account/ToggleTheme/index.tsx @@ -1,43 +1 @@ -'use client' - -import { RadioGroupField, useTheme, useTranslation } from '@payloadcms/ui' -import React, { useCallback } from 'react' - -export const ToggleTheme: React.FC = () => { - const { autoMode, setTheme, theme } = useTheme() - const { t } = useTranslation() - - const onChange = useCallback( - (newTheme) => { - setTheme(newTheme) - }, - [setTheme], - ) - - return ( - - ) -} +export { ToggleTheme } from '@payloadcms/ui/views/Account/ToggleTheme/index' diff --git a/packages/next/src/views/Account/index.client.tsx b/packages/next/src/views/Account/index.client.tsx index d3e1ccf6945..352e2a228db 100644 --- a/packages/next/src/views/Account/index.client.tsx +++ b/packages/next/src/views/Account/index.client.tsx @@ -1,21 +1 @@ -'use client' -import { type StepNavItem, useStepNav, useTranslation } from '@payloadcms/ui' -import React from 'react' - -export const AccountClient: React.FC = () => { - const { setStepNav } = useStepNav() - const { t } = useTranslation() - - React.useEffect(() => { - const nav: StepNavItem[] = [] - - nav.push({ - label: t('authentication:account'), - url: '/account', - }) - - setStepNav(nav) - }, [setStepNav, t]) - - return null -} +export { AccountClient } from '@payloadcms/ui/views/Account/index.client' diff --git a/packages/next/src/views/Version/Default/SelectedLocalesContext.tsx b/packages/next/src/views/Version/Default/SelectedLocalesContext.tsx index 2913b82a64e..42b424b770c 100644 --- a/packages/next/src/views/Version/Default/SelectedLocalesContext.tsx +++ b/packages/next/src/views/Version/Default/SelectedLocalesContext.tsx @@ -1,13 +1,4 @@ -'use client' - -import { createContext, use } from 'react' - -type SelectedLocalesContextType = { - selectedLocales: string[] -} - -export const SelectedLocalesContext = createContext({ - selectedLocales: [], -}) - -export const useSelectedLocales = () => use(SelectedLocalesContext) +export { + SelectedLocalesContext, + useSelectedLocales, +} from '@payloadcms/ui/views/Version/Default/SelectedLocalesContext' diff --git a/packages/next/src/views/Version/Default/SetStepNav.tsx b/packages/next/src/views/Version/Default/SetStepNav.tsx index b74f7a1be6a..4948adaab2a 100644 --- a/packages/next/src/views/Version/Default/SetStepNav.tsx +++ b/packages/next/src/views/Version/Default/SetStepNav.tsx @@ -1,131 +1 @@ -'use client' - -import type { ClientCollectionConfig, ClientGlobalConfig } from 'payload' -import type React from 'react' - -import { getTranslation } from '@payloadcms/translations' -import { useConfig, useDocumentTitle, useLocale, useStepNav, useTranslation } from '@payloadcms/ui' -import { formatAdminURL } from 'payload/shared' -import { useEffect } from 'react' - -export const SetStepNav: React.FC<{ - readonly collectionConfig?: ClientCollectionConfig - readonly globalConfig?: ClientGlobalConfig - readonly id?: number | string - readonly isTrashed?: boolean - versionToCreatedAtFormatted?: string - versionToID?: string -}> = ({ - id, - collectionConfig, - globalConfig, - isTrashed, - versionToCreatedAtFormatted, - versionToID, -}) => { - const { config } = useConfig() - const { setStepNav } = useStepNav() - const { i18n, t } = useTranslation() - const locale = useLocale() - const { title } = useDocumentTitle() - - useEffect(() => { - const { - routes: { admin: adminRoute }, - serverURL, - } = config - - if (collectionConfig) { - const collectionSlug = collectionConfig.slug - - const pluralLabel = collectionConfig.labels?.plural - - const docBasePath: `/${string}` = isTrashed - ? `/collections/${collectionSlug}/trash/${id}` - : `/collections/${collectionSlug}/${id}` - - const nav = [ - { - label: getTranslation(pluralLabel, i18n), - url: formatAdminURL({ - adminRoute, - path: `/collections/${collectionSlug}`, - }), - }, - ] - - if (isTrashed) { - nav.push({ - label: t('general:trash'), - url: formatAdminURL({ - adminRoute, - path: `/collections/${collectionSlug}/trash`, - }), - }) - } - - nav.push( - { - label: title, - url: formatAdminURL({ - adminRoute, - path: docBasePath, - }), - }, - { - label: t('version:versions'), - url: formatAdminURL({ - adminRoute, - path: `${docBasePath}/versions`, - }), - }, - { - label: versionToCreatedAtFormatted, - url: undefined, - }, - ) - - setStepNav(nav) - return - } - - if (globalConfig) { - const globalSlug = globalConfig.slug - - setStepNav([ - { - label: globalConfig.label, - url: formatAdminURL({ - adminRoute, - path: `/globals/${globalSlug}`, - }), - }, - { - label: t('version:versions'), - url: formatAdminURL({ - adminRoute, - path: `/globals/${globalSlug}/versions`, - }), - }, - { - label: versionToCreatedAtFormatted, - }, - ]) - } - }, [ - config, - setStepNav, - id, - isTrashed, - locale, - t, - i18n, - collectionConfig, - globalConfig, - title, - versionToCreatedAtFormatted, - versionToID, - ]) - - return null -} +export { SetStepNav } from '@payloadcms/ui/views/Version/Default/SetStepNav' diff --git a/packages/next/src/views/Version/Default/types.ts b/packages/next/src/views/Version/Default/types.ts index 5017ff2d097..3682423e46b 100644 --- a/packages/next/src/views/Version/Default/types.ts +++ b/packages/next/src/views/Version/Default/types.ts @@ -1,24 +1,5 @@ -export type CompareOption = { - label: React.ReactNode | string - value: string -} - -export type VersionPill = { - id: string - Label: React.ReactNode -} - -export type DefaultVersionsViewProps = { - canUpdate: boolean - modifiedOnly: boolean - RenderedDiff: React.ReactNode - selectedLocales: string[] - versionFromCreatedAt?: string - versionFromID?: string - versionFromOptions: CompareOption[] - versionToCreatedAt?: string - versionToCreatedAtFormatted: string - VersionToCreatedAtLabel: React.ReactNode - versionToID?: string - versionToStatus?: string -} +export type { + CompareOption, + DefaultVersionsViewProps, + VersionPill, +} from '@payloadcms/ui/views/Version/Default/types' diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/DiffCollapser/index.tsx b/packages/next/src/views/Version/RenderFieldsToDiff/DiffCollapser/index.tsx index 35f59a667d7..e496db3717c 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/DiffCollapser/index.tsx +++ b/packages/next/src/views/Version/RenderFieldsToDiff/DiffCollapser/index.tsx @@ -1,122 +1 @@ -'use client' -import type { ClientField } from 'payload' - -import { ChevronIcon, FieldDiffLabel, useConfig, useTranslation } from '@payloadcms/ui' -import { fieldIsArrayType, fieldIsBlockType } from 'payload/shared' -import React, { useState } from 'react' - -import './index.scss' -import { countChangedFields, countChangedFieldsInRows } from '../utilities/countChangedFields.js' - -const baseClass = 'diff-collapser' - -type Props = { - hideGutter?: boolean - initCollapsed?: boolean - Label: React.ReactNode - locales: string[] | undefined - parentIsLocalized: boolean - valueTo: unknown -} & ( - | { - // fields collapser - children: React.ReactNode - field?: never - fields: ClientField[] - isIterable?: false - valueFrom: unknown - } - | { - // iterable collapser - children: React.ReactNode - field: ClientField - fields?: never - isIterable: true - valueFrom?: unknown - } -) - -export const DiffCollapser: React.FC = ({ - children, - field, - fields, - hideGutter = false, - initCollapsed = false, - isIterable = false, - Label, - locales, - parentIsLocalized, - valueFrom, - valueTo, -}) => { - const { t } = useTranslation() - const [isCollapsed, setIsCollapsed] = useState(initCollapsed) - const { config } = useConfig() - - let changeCount = 0 - - if (isIterable) { - if (!fieldIsArrayType(field) && !fieldIsBlockType(field)) { - throw new Error( - 'DiffCollapser: field must be an array or blocks field when isIterable is true', - ) - } - const valueFromRows = valueFrom ?? [] - const valueToRows = valueTo ?? [] - - if (!Array.isArray(valueFromRows) || !Array.isArray(valueToRows)) { - throw new Error( - 'DiffCollapser: valueFrom and valueTro must be arrays when isIterable is true', - ) - } - - changeCount = countChangedFieldsInRows({ - config, - field, - locales, - parentIsLocalized, - valueFromRows, - valueToRows, - }) - } else { - changeCount = countChangedFields({ - config, - fields, - locales, - parentIsLocalized, - valueFrom, - valueTo, - }) - } - - const contentClassNames = [ - `${baseClass}__content`, - isCollapsed && `${baseClass}__content--is-collapsed`, - hideGutter && `${baseClass}__content--hide-gutter`, - ] - .filter(Boolean) - .join(' ') - - return ( -
- - - {changeCount > 0 && isCollapsed && ( - - {t('version:changedFieldsCount', { count: changeCount })} - - )} - -
{children}
-
- ) -} +export { DiffCollapser } from '@payloadcms/ui/views/Version/RenderFieldsToDiff/DiffCollapser/index' diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/RenderVersionFieldsToDiff.tsx b/packages/next/src/views/Version/RenderFieldsToDiff/RenderVersionFieldsToDiff.tsx index 3f8ec527880..6b855840fce 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/RenderVersionFieldsToDiff.tsx +++ b/packages/next/src/views/Version/RenderFieldsToDiff/RenderVersionFieldsToDiff.tsx @@ -1,73 +1 @@ -'use client' -const baseClass = 'render-field-diffs' -import type { VersionField } from 'payload' - -import './index.scss' - -import { ShimmerEffect } from '@payloadcms/ui' -import React, { Fragment, useEffect } from 'react' - -export const RenderVersionFieldsToDiff = ({ - parent = false, - versionFields, -}: { - /** - * If true, this is the parent render version fields component, not one nested in - * a field with children (e.g. group) - */ - parent?: boolean - versionFields: VersionField[] -}): React.ReactNode => { - const [hasMounted, setHasMounted] = React.useState(false) - - // defer rendering until after the first mount as the CSS is loaded with Emotion - // this will ensure that the CSS is loaded before rendering the diffs and prevent CLS - useEffect(() => { - setHasMounted(true) - }, []) - - return ( -
- {!hasMounted ? ( - - - - ) : ( - versionFields?.map((field, fieldIndex) => { - if (field.fieldByLocale) { - const LocaleComponents: React.ReactNode[] = [] - for (const [locale, baseField] of Object.entries(field.fieldByLocale)) { - LocaleComponents.push( -
-
{baseField.CustomComponent}
-
, - ) - } - return ( -
- {LocaleComponents} -
- ) - } else if (field.field) { - return ( -
- {field.field.CustomComponent} -
- ) - } - - return null - }) - )} -
- ) -} +export { RenderVersionFieldsToDiff } from '@payloadcms/ui/views/Version/RenderFieldsToDiff/RenderVersionFieldsToDiff' diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/buildVersionFields.tsx b/packages/next/src/views/Version/RenderFieldsToDiff/buildVersionFields.tsx index 1b8bbcd362c..4a6a0f5c886 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/buildVersionFields.tsx +++ b/packages/next/src/views/Version/RenderFieldsToDiff/buildVersionFields.tsx @@ -1,565 +1,2 @@ -import type { I18nClient } from '@payloadcms/translations' - -import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' -import { dequal } from 'dequal/lite' -import { - type BaseVersionField, - type ClientField, - type ClientFieldSchemaMap, - type Field, - type FieldDiffClientProps, - type FieldDiffServerProps, - type FieldTypes, - type FlattenedBlock, - MissingEditorProp, - type PayloadComponent, - type PayloadRequest, - type SanitizedFieldPermissions, - type SanitizedFieldsPermissions, - type VersionField, -} from 'payload' -import { - fieldIsID, - fieldShouldBeLocalized, - getFieldPaths, - getUniqueListBy, - tabHasName, -} from 'payload/shared' - -import { diffComponents } from './fields/index.js' - -export type BuildVersionFieldsArgs = { - clientSchemaMap: ClientFieldSchemaMap - customDiffComponents: Partial< - Record> - > - entitySlug: string - fields: Field[] - fieldsPermissions: SanitizedFieldsPermissions - i18n: I18nClient - modifiedOnly: boolean - nestingLevel?: number - parentIndexPath: string - parentIsLocalized: boolean - parentPath: string - parentSchemaPath: string - req: PayloadRequest - selectedLocales: string[] - versionFromSiblingData: object - versionToSiblingData: object -} - -/** - * Build up an object that contains rendered diff components for each field. - * This is then sent to the client to be rendered. - * - * Here, the server is responsible for traversing through the document data and building up this - * version state object. - */ -export const buildVersionFields = ({ - clientSchemaMap, - customDiffComponents, - entitySlug, - fields, - fieldsPermissions, - i18n, - modifiedOnly, - nestingLevel = 0, - parentIndexPath, - parentIsLocalized, - parentPath, - parentSchemaPath, - req, - selectedLocales, - versionFromSiblingData, - versionToSiblingData, -}: BuildVersionFieldsArgs): { - versionFields: VersionField[] -} => { - const versionFields: VersionField[] = [] - let fieldIndex = -1 - - for (const field of fields) { - fieldIndex++ - - if (fieldIsID(field)) { - continue - } - - const { indexPath, path, schemaPath } = getFieldPaths({ - field, - index: fieldIndex, - parentIndexPath, - parentPath, - parentSchemaPath, - }) - - const clientField = clientSchemaMap.get(entitySlug + '.' + schemaPath) - - if (!clientField) { - req.payload.logger.error({ - clientFieldKey: entitySlug + '.' + schemaPath, - clientSchemaMapKeys: Array.from(clientSchemaMap.keys()), - msg: 'No client field found for ' + entitySlug + '.' + schemaPath, - parentPath, - parentSchemaPath, - path, - schemaPath, - }) - throw new Error('No client field found for ' + entitySlug + '.' + schemaPath) - } - - const versionField: VersionField = {} - const isLocalized = fieldShouldBeLocalized({ field, parentIsLocalized }) - - const fieldName: null | string = 'name' in field ? field.name : null - - const valueFrom = fieldName ? versionFromSiblingData?.[fieldName] : versionFromSiblingData - const valueTo = fieldName ? versionToSiblingData?.[fieldName] : versionToSiblingData - - if (isLocalized) { - versionField.fieldByLocale = {} - - for (const locale of selectedLocales) { - const localizedVersionField = buildVersionField({ - clientField: clientField as ClientField, - clientSchemaMap, - customDiffComponents, - entitySlug, - field, - i18n, - indexPath, - locale, - modifiedOnly, - nestingLevel, - parentFieldsPermissions: fieldsPermissions, - parentIsLocalized: true, - parentPath, - parentSchemaPath, - path, - req, - schemaPath, - selectedLocales, - valueFrom: valueFrom?.[locale], - valueTo: valueTo?.[locale], - }) - if (localizedVersionField) { - versionField.fieldByLocale[locale] = localizedVersionField - } - } - } else { - const baseVersionField = buildVersionField({ - clientField: clientField as ClientField, - clientSchemaMap, - customDiffComponents, - entitySlug, - field, - i18n, - indexPath, - modifiedOnly, - nestingLevel, - parentFieldsPermissions: fieldsPermissions, - parentIsLocalized: parentIsLocalized || ('localized' in field && field.localized), - parentPath, - parentSchemaPath, - path, - req, - schemaPath, - selectedLocales, - valueFrom, - valueTo, - }) - - if (baseVersionField) { - versionField.field = baseVersionField - } - } - - if ( - versionField.field || - (versionField.fieldByLocale && Object.keys(versionField.fieldByLocale).length) - ) { - versionFields.push(versionField) - } - } - - return { - versionFields, - } -} - -const buildVersionField = ({ - clientField, - clientSchemaMap, - customDiffComponents, - entitySlug, - field, - i18n, - indexPath, - locale, - modifiedOnly, - nestingLevel, - parentFieldsPermissions, - parentIsLocalized, - parentPath, - parentSchemaPath, - path, - req, - schemaPath, - selectedLocales, - valueFrom, - valueTo, -}: { - clientField: ClientField - field: Field - indexPath: string - locale?: string - modifiedOnly?: boolean - nestingLevel: number - parentFieldsPermissions: SanitizedFieldsPermissions - parentIsLocalized: boolean - path: string - schemaPath: string - valueFrom: unknown - valueTo: unknown -} & Omit< - BuildVersionFieldsArgs, - | 'fields' - | 'fieldsPermissions' - | 'parentIndexPath' - | 'versionFromSiblingData' - | 'versionToSiblingData' ->): BaseVersionField | null => { - let hasReadPermission: boolean = false - let fieldPermissions: SanitizedFieldPermissions | undefined = undefined - - if (typeof parentFieldsPermissions === 'boolean') { - hasReadPermission = parentFieldsPermissions - fieldPermissions = parentFieldsPermissions - } else { - if ('name' in field) { - fieldPermissions = parentFieldsPermissions?.[field.name] - if (typeof fieldPermissions === 'boolean') { - hasReadPermission = fieldPermissions - } else if (typeof fieldPermissions?.read === 'boolean') { - hasReadPermission = fieldPermissions.read - } - } else { - // If the field is unnamed and parentFieldsPermissions is an object, its sub-fields will decide their read permissions state. - // As far as this field is concerned, we are allowed to read it, as we need to reach its sub-fields to determine their read permissions. - hasReadPermission = true - } - } - - if (!hasReadPermission) { - // HasReadPermission is only valid if the field has a name. E.g. for a tabs field it would incorrectly return `false`. - return null - } - - if (modifiedOnly && dequal(valueFrom, valueTo)) { - return null - } - - let CustomComponent = customDiffComponents?.[field.type] - if (field?.type === 'richText') { - if (!field?.editor) { - throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor - } - - if (typeof field?.editor === 'function') { - throw new Error('Attempted to access unsanitized rich text editor.') - } - - if (field.editor.CellComponent) { - CustomComponent = field.editor.DiffComponent - } - } - if (field?.admin?.components?.Diff) { - CustomComponent = field.admin.components.Diff - } - - const DefaultComponent = diffComponents?.[field.type] - - const baseVersionField: BaseVersionField = { - type: field.type, - fields: [], - path, - schemaPath, - } - - if (field.type === 'tabs' && 'tabs' in field) { - baseVersionField.tabs = [] - let tabIndex = -1 - for (const tab of field.tabs) { - tabIndex++ - const isNamedTab = tabHasName(tab) - - const tabAsField = { ...tab, type: 'tab' } - - const { - indexPath: tabIndexPath, - path: tabPath, - schemaPath: tabSchemaPath, - } = getFieldPaths({ - field: tabAsField, - index: tabIndex, - parentIndexPath: indexPath, - parentPath: path, - parentSchemaPath: schemaPath, - }) - - let tabFieldsPermissions: SanitizedFieldsPermissions = undefined - - // The tabs field does not have its own permissions as it's unnamed => use parentFieldsPermissions - if (typeof parentFieldsPermissions === 'boolean') { - tabFieldsPermissions = parentFieldsPermissions - } else { - if ('name' in tab) { - const tabPermissions = parentFieldsPermissions?.[tab.name] - if (typeof tabPermissions === 'boolean') { - tabFieldsPermissions = tabPermissions - } else { - tabFieldsPermissions = tabPermissions?.fields - } - } else { - tabFieldsPermissions = parentFieldsPermissions - } - } - - const tabVersion = { - name: 'name' in tab ? tab.name : null, - fields: buildVersionFields({ - clientSchemaMap, - customDiffComponents, - entitySlug, - fields: tab.fields, - fieldsPermissions: tabFieldsPermissions, - i18n, - modifiedOnly, - nestingLevel: nestingLevel + 1, - parentIndexPath: isNamedTab ? '' : tabIndexPath, - parentIsLocalized: parentIsLocalized || tab.localized, - parentPath: isNamedTab ? tabPath : 'name' in field ? path : parentPath, - parentSchemaPath: tabSchemaPath, - req, - selectedLocales, - versionFromSiblingData: 'name' in tab ? valueFrom?.[tab.name] : valueFrom, - versionToSiblingData: 'name' in tab ? valueTo?.[tab.name] : valueTo, - }).versionFields, - label: typeof tab.label === 'function' ? tab.label({ i18n, t: i18n.t }) : tab.label, - } - if (tabVersion?.fields?.length) { - baseVersionField.tabs.push(tabVersion) - } - } - - if (modifiedOnly && !baseVersionField.tabs.length) { - return null - } - } // At this point, we are dealing with a `row`, `collapsible`, array`, etc - else if ('fields' in field) { - let subFieldsPermissions: SanitizedFieldsPermissions = undefined - - if ('name' in field && typeof fieldPermissions !== 'undefined') { - // Named fields like arrays - subFieldsPermissions = - typeof fieldPermissions === 'boolean' ? fieldPermissions : fieldPermissions.fields - } else { - // Unnamed fields like collapsible and row inherit directly from parent permissions - subFieldsPermissions = parentFieldsPermissions - } - - if (field.type === 'array' && (valueTo || valueFrom)) { - const maxLength = Math.max( - Array.isArray(valueTo) ? valueTo.length : 0, - Array.isArray(valueFrom) ? valueFrom.length : 0, - ) - baseVersionField.rows = [] - - for (let i = 0; i < maxLength; i++) { - const fromRow = (Array.isArray(valueFrom) && valueFrom?.[i]) || {} - const toRow = (Array.isArray(valueTo) && valueTo?.[i]) || {} - - const versionFields = buildVersionFields({ - clientSchemaMap, - customDiffComponents, - entitySlug, - fields: field.fields, - fieldsPermissions: subFieldsPermissions, - i18n, - modifiedOnly, - nestingLevel: nestingLevel + 1, - parentIndexPath: 'name' in field ? '' : indexPath, - parentIsLocalized: parentIsLocalized || field.localized, - parentPath: ('name' in field ? path : parentPath) + '.' + i, - parentSchemaPath: schemaPath, - req, - selectedLocales, - versionFromSiblingData: fromRow, - versionToSiblingData: toRow, - }).versionFields - - if (versionFields?.length) { - baseVersionField.rows[i] = versionFields - } - } - - if (!baseVersionField.rows?.length && modifiedOnly) { - return null - } - } else { - baseVersionField.fields = buildVersionFields({ - clientSchemaMap, - customDiffComponents, - entitySlug, - fields: field.fields, - fieldsPermissions: subFieldsPermissions, - i18n, - modifiedOnly, - nestingLevel: field.type !== 'row' ? nestingLevel + 1 : nestingLevel, - parentIndexPath: 'name' in field ? '' : indexPath, - parentIsLocalized: parentIsLocalized || ('localized' in field && field.localized), - parentPath: 'name' in field ? path : parentPath, - parentSchemaPath: schemaPath, - req, - selectedLocales, - versionFromSiblingData: valueFrom as object, - versionToSiblingData: valueTo as object, - }).versionFields - - if (modifiedOnly && !baseVersionField.fields?.length) { - return null - } - } - } else if (field.type === 'blocks') { - baseVersionField.rows = [] - - const maxLength = Math.max( - Array.isArray(valueTo) ? valueTo.length : 0, - Array.isArray(valueFrom) ? valueFrom.length : 0, - ) - - for (let i = 0; i < maxLength; i++) { - const fromRow = (Array.isArray(valueFrom) && valueFrom?.[i]) || {} - const toRow = (Array.isArray(valueTo) && valueTo?.[i]) || {} - - const blockSlugToMatch: string = toRow?.blockType ?? fromRow?.blockType - const toBlock = - req.payload.blocks[blockSlugToMatch] ?? - ((field.blockReferences ?? field.blocks).find( - (block) => typeof block !== 'string' && block.slug === blockSlugToMatch, - ) as FlattenedBlock | undefined) - - let fields = [] - - if (toRow.blockType === fromRow.blockType) { - fields = toBlock.fields - } else { - const fromBlockSlugToMatch: string = toRow?.blockType ?? fromRow?.blockType - - const fromBlock = - req.payload.blocks[fromBlockSlugToMatch] ?? - ((field.blockReferences ?? field.blocks).find( - (block) => typeof block !== 'string' && block.slug === fromBlockSlugToMatch, - ) as FlattenedBlock | undefined) - - if (fromBlock) { - fields = getUniqueListBy([...toBlock.fields, ...fromBlock.fields], 'name') - } else { - fields = toBlock.fields - } - } - - let blockFieldsPermissions: SanitizedFieldsPermissions = undefined - - // fieldPermissions will be set here, as the blocks field has a name - if (typeof fieldPermissions === 'boolean') { - blockFieldsPermissions = fieldPermissions - } else if (typeof fieldPermissions?.blocks === 'boolean') { - blockFieldsPermissions = fieldPermissions.blocks - } else { - const permissionsBlockSpecific = fieldPermissions?.blocks?.[blockSlugToMatch] - if (typeof permissionsBlockSpecific === 'boolean') { - blockFieldsPermissions = permissionsBlockSpecific - } else { - blockFieldsPermissions = permissionsBlockSpecific?.fields - } - } - - const versionFields = buildVersionFields({ - clientSchemaMap, - customDiffComponents, - entitySlug, - fields, - fieldsPermissions: blockFieldsPermissions, - i18n, - modifiedOnly, - nestingLevel: nestingLevel + 1, - parentIndexPath: 'name' in field ? '' : indexPath, - parentIsLocalized: parentIsLocalized || ('localized' in field && field.localized), - parentPath: ('name' in field ? path : parentPath) + '.' + i, - parentSchemaPath: schemaPath + '.' + toBlock.slug, - req, - selectedLocales, - versionFromSiblingData: fromRow, - versionToSiblingData: toRow, - }).versionFields - - if (versionFields?.length) { - baseVersionField.rows[i] = versionFields - } - } - - if (!baseVersionField.rows?.length && modifiedOnly) { - return null - } - } - - const clientDiffProps: FieldDiffClientProps = { - baseVersionField: { - ...baseVersionField, - CustomComponent: undefined, - }, - /** - * TODO: Change to valueFrom in 4.0 - */ - comparisonValue: valueFrom, - /** - * @deprecated remove in 4.0. Each field should handle its own diffing logic - */ - diffMethod: 'diffWordsWithSpace', - field: clientField, - fieldPermissions: - typeof fieldPermissions === 'undefined' ? parentFieldsPermissions : fieldPermissions, - parentIsLocalized, - - nestingLevel: nestingLevel ? nestingLevel : undefined, - /** - * TODO: Change to valueTo in 4.0 - */ - versionValue: valueTo, - } - if (locale) { - clientDiffProps.locale = locale - } - - const serverDiffProps: FieldDiffServerProps = { - ...clientDiffProps, - clientField, - field, - i18n, - req, - selectedLocales, - } - - baseVersionField.CustomComponent = RenderServerComponent({ - clientProps: clientDiffProps, - Component: CustomComponent, - Fallback: DefaultComponent, - importMap: req.payload.importMap, - key: 'diff component', - serverProps: serverDiffProps, - }) - - return baseVersionField -} +export type { BuildVersionFieldsArgs } from '@payloadcms/ui/views/Version/RenderFieldsToDiff/buildVersionFields' +export { buildVersionFields } from '@payloadcms/ui/views/Version/RenderFieldsToDiff/buildVersionFields' diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Collapsible/index.tsx b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Collapsible/index.tsx index 01da8257f2d..c86cde21e58 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Collapsible/index.tsx +++ b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Collapsible/index.tsx @@ -1,46 +1 @@ -'use client' -import type { CollapsibleFieldDiffClientComponent } from 'payload' - -import { getTranslation } from '@payloadcms/translations' -import { useTranslation } from '@payloadcms/ui' -import React from 'react' - -import { useSelectedLocales } from '../../../Default/SelectedLocalesContext.js' -import { DiffCollapser } from '../../DiffCollapser/index.js' -import { RenderVersionFieldsToDiff } from '../../RenderVersionFieldsToDiff.js' - -const baseClass = 'collapsible-diff' - -export const Collapsible: CollapsibleFieldDiffClientComponent = ({ - baseVersionField, - comparisonValue: valueFrom, - field, - parentIsLocalized, - versionValue: valueTo, -}) => { - const { i18n } = useTranslation() - const { selectedLocales } = useSelectedLocales() - - if (!baseVersionField.fields?.length) { - return null - } - - return ( -
- {getTranslation(field.label, i18n)} - } - locales={selectedLocales} - parentIsLocalized={parentIsLocalized || field.localized} - valueFrom={valueFrom} - valueTo={valueTo} - > - - -
- ) -} +export { Collapsible } from '@payloadcms/ui/views/Version/RenderFieldsToDiff/fields/Collapsible/index' diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Date/index.tsx b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Date/index.tsx index c108ff120d5..f3374325863 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Date/index.tsx +++ b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Date/index.tsx @@ -1,78 +1 @@ -'use client' -import type { DateFieldDiffClientComponent } from 'payload' - -import { - escapeDiffHTML, - FieldDiffContainer, - getHTMLDiffComponents, - unescapeDiffHTML, - useConfig, - useTranslation, -} from '@payloadcms/ui' -import { formatDate } from '@payloadcms/ui/shared' -import React from 'react' - -import './index.scss' - -const baseClass = 'date-diff' - -export const DateDiffComponent: DateFieldDiffClientComponent = ({ - comparisonValue: valueFrom, - field, - locale, - nestingLevel, - versionValue: valueTo, -}) => { - const { i18n } = useTranslation() - const { - config: { - admin: { dateFormat }, - }, - } = useConfig() - - const formattedFromDate = valueFrom - ? formatDate({ - date: typeof valueFrom === 'string' ? new Date(valueFrom) : (valueFrom as Date), - i18n, - pattern: dateFormat, - }) - : '' - - const formattedToDate = valueTo - ? formatDate({ - date: typeof valueTo === 'string' ? new Date(valueTo) : (valueTo as Date), - i18n, - pattern: dateFormat, - }) - : '' - - const escapedFromDate = escapeDiffHTML(formattedFromDate) - const escapedToDate = escapeDiffHTML(formattedToDate) - - const { From, To } = getHTMLDiffComponents({ - fromHTML: - `

` + - escapedFromDate + - '

', - postProcess: unescapeDiffHTML, - toHTML: - `

` + - escapedToDate + - '

', - tokenizeByCharacter: false, - }) - - return ( - - ) -} +export { DateDiffComponent } from '@payloadcms/ui/views/Version/RenderFieldsToDiff/fields/Date/index' diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Group/index.tsx b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Group/index.tsx index 95761c47da2..490bfc468cb 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Group/index.tsx +++ b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Group/index.tsx @@ -1,53 +1 @@ -'use client' -import type { GroupFieldDiffClientComponent } from 'payload' - -import { getTranslation } from '@payloadcms/translations' - -import './index.scss' - -import { useTranslation } from '@payloadcms/ui' -import React from 'react' - -import { useSelectedLocales } from '../../../Default/SelectedLocalesContext.js' -import { DiffCollapser } from '../../DiffCollapser/index.js' -import { RenderVersionFieldsToDiff } from '../../RenderVersionFieldsToDiff.js' - -const baseClass = 'group-diff' - -export const Group: GroupFieldDiffClientComponent = ({ - baseVersionField, - comparisonValue: valueFrom, - field, - locale, - parentIsLocalized, - versionValue: valueTo, -}) => { - const { i18n } = useTranslation() - const { selectedLocales } = useSelectedLocales() - - return ( -
- - {locale && {locale}} - {getTranslation(field.label, i18n)} - - ) : ( - - <{i18n.t('version:noLabelGroup')}> - - ) - } - locales={selectedLocales} - parentIsLocalized={parentIsLocalized || field.localized} - valueFrom={valueFrom} - valueTo={valueTo} - > - - -
- ) -} +export { Group } from '@payloadcms/ui/views/Version/RenderFieldsToDiff/fields/Group/index' diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Iterable/index.tsx b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Iterable/index.tsx index 2f0a9201072..ed6abd706f8 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Iterable/index.tsx +++ b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Iterable/index.tsx @@ -1,122 +1 @@ -'use client' - -import type { FieldDiffClientProps } from 'payload' - -import { getTranslation } from '@payloadcms/translations' -import { useConfig, useTranslation } from '@payloadcms/ui' - -import './index.scss' - -import { fieldIsArrayType, fieldIsBlockType } from 'payload/shared' -import React from 'react' - -import { useSelectedLocales } from '../../../Default/SelectedLocalesContext.js' -import { DiffCollapser } from '../../DiffCollapser/index.js' -import { RenderVersionFieldsToDiff } from '../../RenderVersionFieldsToDiff.js' -import { getFieldsForRowComparison } from '../../utilities/getFieldsForRowComparison.js' - -const baseClass = 'iterable-diff' - -export const Iterable: React.FC = ({ - baseVersionField, - comparisonValue: valueFrom, - field, - locale, - parentIsLocalized, - versionValue: valueTo, -}) => { - const { i18n, t } = useTranslation() - const { selectedLocales } = useSelectedLocales() - const { config } = useConfig() - - if (!fieldIsArrayType(field) && !fieldIsBlockType(field)) { - throw new Error(`Expected field to be an array or blocks type but got: ${field.type}`) - } - - const valueToRowCount = Array.isArray(valueTo) ? valueTo.length : 0 - const valueFromRowCount = Array.isArray(valueFrom) ? valueFrom.length : 0 - const maxRows = Math.max(valueToRowCount, valueFromRowCount) - - return ( -
- - {locale && {locale}} - {getTranslation(field.label, i18n)} - - ) - } - locales={selectedLocales} - parentIsLocalized={parentIsLocalized} - valueFrom={valueFrom} - valueTo={valueTo} - > - {maxRows > 0 && ( -
- {Array.from({ length: maxRows }, (_, i) => { - const valueToRow = valueTo?.[i] || {} - const valueFromRow = valueFrom?.[i] || {} - - const { fields, versionFields } = getFieldsForRowComparison({ - baseVersionField, - config, - field, - row: i, - valueFromRow, - valueToRow, - }) - - if (!versionFields?.length) { - // Rows without a diff create "holes" in the baseVersionField.rows (=versionFields) array - this is to maintain the correct row indexes. - // It does mean that this row has no diff and should not be rendered => skip it. - return null - } - - const rowNumber = String(i + 1).padStart(2, '0') - const rowLabel = fieldIsArrayType(field) - ? `${t('general:item')} ${rowNumber}` - : `${t('fields:block')} ${rowNumber}` - - return ( -
- -
- {rowLabel} -
- } - locales={selectedLocales} - parentIsLocalized={parentIsLocalized || field.localized} - valueFrom={valueFromRow} - valueTo={valueToRow} - > - - -
- ) - })} -
- )} - {maxRows === 0 && ( -
- {i18n.t('version:noRowsFound', { - label: - 'labels' in field && field.labels?.plural - ? getTranslation(field.labels.plural, i18n) - : i18n.t('general:rows'), - })} -
- )} - -
- ) -} +export { Iterable } from '@payloadcms/ui/views/Version/RenderFieldsToDiff/fields/Iterable/index' diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Relationship/generateLabelFromValue.ts b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Relationship/generateLabelFromValue.ts index fe7d7084c9b..98f59159c0b 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Relationship/generateLabelFromValue.ts +++ b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Relationship/generateLabelFromValue.ts @@ -1,100 +1 @@ -import type { PayloadRequest, RelationshipField, TypeWithID } from 'payload' - -import { - fieldAffectsData, - fieldIsPresentationalOnly, - fieldShouldBeLocalized, - flattenTopLevelFields, -} from 'payload/shared' - -import type { RelationshipValue } from './index.js' - -export const generateLabelFromValue = async ({ - field, - locale, - parentIsLocalized, - req, - value, -}: { - field: RelationshipField - locale: string - parentIsLocalized: boolean - req: PayloadRequest - value: RelationshipValue -}): Promise => { - let relatedDoc: number | string | TypeWithID - let relationTo: string = field.relationTo as string - let valueToReturn: string = '' - - if (typeof value === 'object' && 'relationTo' in value) { - relatedDoc = value.value - relationTo = value.relationTo - } else { - // Non-polymorphic relationship or deleted document - relatedDoc = value - } - - const relatedCollection = req.payload.collections[relationTo].config - - const useAsTitle = relatedCollection?.admin?.useAsTitle - - const flattenedRelatedCollectionFields = flattenTopLevelFields(relatedCollection.fields, { - moveSubFieldsToTop: true, - }) - - const useAsTitleField = flattenedRelatedCollectionFields.find( - (f) => fieldAffectsData(f) && !fieldIsPresentationalOnly(f) && f.name === useAsTitle, - ) - let titleFieldIsLocalized = false - - if (useAsTitleField && fieldAffectsData(useAsTitleField)) { - titleFieldIsLocalized = fieldShouldBeLocalized({ field: useAsTitleField, parentIsLocalized }) - } - - if (typeof relatedDoc?.[useAsTitle] !== 'undefined') { - valueToReturn = relatedDoc[useAsTitle] - } else if (typeof relatedDoc === 'string' || typeof relatedDoc === 'number') { - // When relatedDoc is just an ID (due to maxDepth: 0), fetch the document to get the title - try { - const fetchedDoc = await req.payload.findByID({ - id: relatedDoc, - collection: relationTo, - depth: 0, - locale: titleFieldIsLocalized ? locale : undefined, - req, - select: { - [useAsTitle]: true, - }, - }) - - if (fetchedDoc?.[useAsTitle]) { - valueToReturn = fetchedDoc[useAsTitle] - } else { - valueToReturn = `${req.i18n.t('general:untitled')} - ID: ${relatedDoc}` - } - } catch (error) { - // Document might have been deleted or user doesn't have access - valueToReturn = `${req.i18n.t('general:untitled')} - ID: ${relatedDoc}` - } - } else { - valueToReturn = String(typeof relatedDoc === 'object' ? relatedDoc.id : relatedDoc) - } - - if ( - typeof valueToReturn === 'object' && - valueToReturn && - titleFieldIsLocalized && - valueToReturn?.[locale] - ) { - valueToReturn = valueToReturn[locale] - } - - if ( - (valueToReturn && typeof valueToReturn === 'object' && valueToReturn !== null) || - typeof valueToReturn !== 'string' - ) { - valueToReturn = JSON.stringify(valueToReturn) - } - - return valueToReturn -} +export { generateLabelFromValue } from '@payloadcms/ui/views/Version/RenderFieldsToDiff/fields/Relationship/generateLabelFromValue' diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Relationship/index.tsx b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Relationship/index.tsx index 3a1eb90e68c..b99375facd8 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Relationship/index.tsx +++ b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Relationship/index.tsx @@ -1,338 +1,5 @@ -import type { - PayloadRequest, - RelationshipField, - RelationshipFieldDiffServerComponent, - TypeWithID, -} from 'payload' - -import { getTranslation, type I18nClient } from '@payloadcms/translations' -import { FieldDiffContainer, getHTMLDiffComponents } from '@payloadcms/ui/rsc' - -import './index.scss' - -import React from 'react' - -import { generateLabelFromValue } from './generateLabelFromValue.js' - -const baseClass = 'relationship-diff' - -export type RelationshipValue = - | { relationTo: string; value: number | string | TypeWithID } - | (number | string | TypeWithID) - -export const Relationship: RelationshipFieldDiffServerComponent = ({ - comparisonValue: valueFrom, - field, - i18n, - locale, - nestingLevel, - parentIsLocalized, - req, - versionValue: valueTo, -}) => { - const hasMany = - ('hasMany' in field && field.hasMany) || - // Check data structure (handles block swaps where schema may not match data) - Array.isArray(valueFrom) || - Array.isArray(valueTo) - const polymorphic = Array.isArray(field.relationTo) - - if (hasMany) { - return ( - - ) - } - - return ( - - ) -} - -export const SingleRelationshipDiff: React.FC<{ - field: RelationshipField - i18n: I18nClient - locale: string - nestingLevel?: number - parentIsLocalized: boolean - polymorphic: boolean - req: PayloadRequest - valueFrom: RelationshipValue - valueTo: RelationshipValue -}> = async (args) => { - const { - field, - i18n, - locale, - nestingLevel, - parentIsLocalized, - polymorphic, - req, - valueFrom, - valueTo, - } = args - - const ReactDOMServer = (await import('react-dom/server')).default - - const localeToUse = - locale ?? - (req.payload.config?.localization && req.payload.config?.localization?.defaultLocale) ?? - 'en' - - // Generate titles asynchronously before creating components - const [titleFrom, titleTo] = await Promise.all([ - valueFrom - ? generateLabelFromValue({ - field, - locale: localeToUse, - parentIsLocalized, - req, - value: valueFrom, - }) - : Promise.resolve(null), - valueTo - ? generateLabelFromValue({ - field, - locale: localeToUse, - parentIsLocalized, - req, - value: valueTo, - }) - : Promise.resolve(null), - ]) - - const FromComponent = valueFrom ? ( - - ) : null - const ToComponent = valueTo ? ( - - ) : null - - const fromHTML = FromComponent ? ReactDOMServer.renderToStaticMarkup(FromComponent) : `

` - const toHTML = ToComponent ? ReactDOMServer.renderToStaticMarkup(ToComponent) : `

` - - const diff = getHTMLDiffComponents({ - fromHTML, - toHTML, - tokenizeByCharacter: false, - }) - - return ( - - ) -} - -const ManyRelationshipDiff: React.FC<{ - field: RelationshipField - i18n: I18nClient - locale: string - nestingLevel?: number - parentIsLocalized: boolean - polymorphic: boolean - req: PayloadRequest - valueFrom: RelationshipValue[] | undefined - valueTo: RelationshipValue[] | undefined -}> = async ({ - field, - i18n, - locale, - nestingLevel, - parentIsLocalized, - polymorphic, - req, - valueFrom, - valueTo, -}) => { - const ReactDOMServer = (await import('react-dom/server')).default - - const fromArr = Array.isArray(valueFrom) ? valueFrom : [] - const toArr = Array.isArray(valueTo) ? valueTo : [] - - const localeToUse = - locale ?? - (req.payload.config?.localization && req.payload.config?.localization?.defaultLocale) ?? - 'en' - - // Generate all titles asynchronously before creating components - const [titlesFrom, titlesTo] = await Promise.all([ - Promise.all( - fromArr.map((val) => - generateLabelFromValue({ - field, - locale: localeToUse, - parentIsLocalized, - req, - value: val, - }), - ), - ), - Promise.all( - toArr.map((val) => - generateLabelFromValue({ - field, - locale: localeToUse, - parentIsLocalized, - req, - value: val, - }), - ), - ), - ]) - - const makeNodes = (list: RelationshipValue[], titles: string[]) => - list.map((val, idx) => ( - - )) - - const fromNodes = - fromArr.length > 0 ? makeNodes(fromArr, titlesFrom) :

- - const toNodes = - toArr.length > 0 ? makeNodes(toArr, titlesTo) :

- - const fromHTML = ReactDOMServer.renderToStaticMarkup(fromNodes) - const toHTML = ReactDOMServer.renderToStaticMarkup(toNodes) - - const diff = getHTMLDiffComponents({ - fromHTML, - toHTML, - tokenizeByCharacter: false, - }) - - return ( - - ) -} - -const RelationshipDocumentDiff = ({ - field, - i18n, - locale, - parentIsLocalized, - polymorphic, - relationTo, - req, - showPill = false, - title, - value, -}: { - field: RelationshipField - i18n: I18nClient - locale: string - parentIsLocalized: boolean - polymorphic: boolean - relationTo: string - req: PayloadRequest - showPill?: boolean - title: null | string - value: RelationshipValue -}) => { - let pillLabel: null | string = null - if (showPill) { - const collectionConfig = req.payload.collections[relationTo].config - pillLabel = collectionConfig.labels?.singular - ? getTranslation(collectionConfig.labels.singular, i18n) - : collectionConfig.slug - } - - return ( -
- {pillLabel && ( - - {pillLabel} - - )} - - {title} - -
- ) -} +export type { RelationshipValue } from '@payloadcms/ui/views/Version/RenderFieldsToDiff/fields/Relationship/index' +export { + Relationship, + SingleRelationshipDiff, +} from '@payloadcms/ui/views/Version/RenderFieldsToDiff/fields/Relationship/index' diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Row/index.tsx b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Row/index.tsx index db73c912dc0..371b7b0e244 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Row/index.tsx +++ b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Row/index.tsx @@ -1,16 +1 @@ -'use client' -import type { RowFieldDiffClientComponent } from 'payload' - -import React from 'react' - -import { RenderVersionFieldsToDiff } from '../../RenderVersionFieldsToDiff.js' - -const baseClass = 'row-diff' - -export const Row: RowFieldDiffClientComponent = ({ baseVersionField }) => { - return ( -
- -
- ) -} +export { Row } from '@payloadcms/ui/views/Version/RenderFieldsToDiff/fields/Row/index' diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Select/index.tsx b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Select/index.tsx index 0c7b3a5c711..912c5b7b254 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Select/index.tsx +++ b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Select/index.tsx @@ -1,122 +1 @@ -'use client' -import type { I18nClient } from '@payloadcms/translations' -import type { Option, SelectField, SelectFieldDiffClientComponent } from 'payload' - -import { getTranslation } from '@payloadcms/translations' -import { - escapeDiffHTML, - FieldDiffContainer, - getHTMLDiffComponents, - unescapeDiffHTML, - useTranslation, -} from '@payloadcms/ui' -import React from 'react' - -import './index.scss' - -const baseClass = 'select-diff' - -const getOptionsToRender = ( - value: string, - options: SelectField['options'], - hasMany: boolean, -): Option | Option[] => { - if (hasMany && Array.isArray(value)) { - return value.map( - (val) => - options.find((option) => (typeof option === 'string' ? option : option.value) === val) || - String(val), - ) - } - return ( - options.find((option) => (typeof option === 'string' ? option : option.value) === value) || - String(value) - ) -} - -/** - * Translates option labels while ensuring they are strings. - * If `options.label` is a JSX element, it falls back to `options.value` because `DiffViewer` - * expects all values to be strings. - */ -const getTranslatedOptions = (options: Option | Option[], i18n: I18nClient): string => { - if (Array.isArray(options)) { - return options - .map((option) => { - if (typeof option === 'string') { - return option - } - const translatedLabel = getTranslation(option.label, i18n) - - // Ensure the result is a string, otherwise use option.value - return typeof translatedLabel === 'string' ? translatedLabel : option.value - }) - .join(', ') - } - - if (typeof options === 'string') { - return options - } - - const translatedLabel = getTranslation(options.label, i18n) - - return typeof translatedLabel === 'string' ? translatedLabel : options.value -} - -export const Select: SelectFieldDiffClientComponent = ({ - comparisonValue: valueFrom, - diffMethod, - field, - locale, - nestingLevel, - versionValue: valueTo, -}) => { - const { i18n } = useTranslation() - - const options = 'options' in field && field.options - - const renderedValueFrom = - typeof valueFrom !== 'undefined' - ? getTranslatedOptions( - getOptionsToRender( - typeof valueFrom === 'string' ? valueFrom : JSON.stringify(valueFrom), - options, - field.hasMany, - ), - i18n, - ) - : '' - - const renderedValueTo = - typeof valueTo !== 'undefined' - ? getTranslatedOptions( - getOptionsToRender( - typeof valueTo === 'string' ? valueTo : JSON.stringify(valueTo), - options, - field.hasMany, - ), - i18n, - ) - : '' - - const { From, To } = getHTMLDiffComponents({ - fromHTML: '

' + escapeDiffHTML(renderedValueFrom) + '

', - postProcess: unescapeDiffHTML, - toHTML: '

' + escapeDiffHTML(renderedValueTo) + '

', - tokenizeByCharacter: true, - }) - - return ( - - ) -} +export { Select } from '@payloadcms/ui/views/Version/RenderFieldsToDiff/fields/Select/index' diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Tabs/index.tsx b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Tabs/index.tsx index 91c41170ef9..f6357820200 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Tabs/index.tsx +++ b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Tabs/index.tsx @@ -1,120 +1 @@ -'use client' -import type { - ClientTab, - FieldDiffClientProps, - TabsFieldClient, - TabsFieldDiffClientComponent, - VersionTab, -} from 'payload' - -import { getTranslation } from '@payloadcms/translations' -import { useTranslation } from '@payloadcms/ui' -import React from 'react' - -import './index.scss' -import { useSelectedLocales } from '../../../Default/SelectedLocalesContext.js' -import { DiffCollapser } from '../../DiffCollapser/index.js' -import { RenderVersionFieldsToDiff } from '../../RenderVersionFieldsToDiff.js' - -const baseClass = 'tabs-diff' - -export const Tabs: TabsFieldDiffClientComponent = (props) => { - const { baseVersionField, comparisonValue: valueFrom, field, versionValue: valueTo } = props - const { selectedLocales } = useSelectedLocales() - - return ( -
- {baseVersionField.tabs.map((tab, i) => { - if (!tab?.fields?.length) { - return null - } - const fieldTab = field.tabs?.[i] - if (!fieldTab) { - return null - } - return ( -
- {(() => { - if ('name' in fieldTab && selectedLocales && fieldTab.localized) { - // Named localized tab - return selectedLocales.map((locale, index) => { - const localizedTabProps: TabProps = { - ...props, - comparisonValue: valueFrom?.[tab.name]?.[locale], - fieldTab, - locale, - tab, - versionValue: valueTo?.[tab.name]?.[locale], - } - return ( -
-
- -
-
- ) - }) - } else if ('name' in tab && tab.name) { - // Named tab - const namedTabProps: TabProps = { - ...props, - comparisonValue: valueFrom?.[tab.name], - fieldTab, - tab, - versionValue: valueTo?.[tab.name], - } - return - } else { - // Unnamed tab - return - } - })()} -
- ) - })} -
- ) -} - -type TabProps = { - fieldTab: ClientTab - tab: VersionTab -} & FieldDiffClientProps - -const Tab: React.FC = ({ - comparisonValue: valueFrom, - fieldTab, - locale, - parentIsLocalized, - tab, - versionValue: valueTo, -}) => { - const { i18n } = useTranslation() - const { selectedLocales } = useSelectedLocales() - - if (!tab.fields?.length) { - return null - } - - return ( - - {locale && {locale}} - {getTranslation(tab.label, i18n)} - - ) - } - locales={selectedLocales} - parentIsLocalized={parentIsLocalized || fieldTab.localized} - valueFrom={valueFrom} - valueTo={valueTo} - > - - - ) -} +export { Tabs } from '@payloadcms/ui/views/Version/RenderFieldsToDiff/fields/Tabs/index' diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Text/index.tsx b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Text/index.tsx index d968c584120..3683876c1f4 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Text/index.tsx +++ b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Text/index.tsx @@ -1,98 +1 @@ -'use client' -import type { TextFieldDiffClientComponent } from 'payload' - -import { - escapeDiffHTML, - FieldDiffContainer, - getHTMLDiffComponents, - unescapeDiffHTML, - useTranslation, -} from '@payloadcms/ui' -import React from 'react' - -import './index.scss' - -const baseClass = 'text-diff' - -function formatValue(value: unknown): { - tokenizeByCharacter: boolean - value: string -} { - if (typeof value === 'string') { - return { tokenizeByCharacter: true, value: escapeDiffHTML(value) } - } - if (typeof value === 'number') { - return { - tokenizeByCharacter: true, - value: String(value), - } - } - if (typeof value === 'boolean') { - return { - tokenizeByCharacter: false, - value: String(value), - } - } - - if (value && typeof value === 'object') { - return { - tokenizeByCharacter: false, - value: `
${escapeDiffHTML(JSON.stringify(value, null, 2))}
`, - } - } - - return { - tokenizeByCharacter: true, - value: undefined, - } -} - -export const Text: TextFieldDiffClientComponent = ({ - comparisonValue: valueFrom, - field, - locale, - nestingLevel, - versionValue: valueTo, -}) => { - const { i18n } = useTranslation() - - let placeholder = '' - - if (valueTo == valueFrom) { - placeholder = `` - } - - const formattedValueFrom = formatValue(valueFrom) - const formattedValueTo = formatValue(valueTo) - - let tokenizeByCharacter = true - if (formattedValueFrom.value?.length) { - tokenizeByCharacter = formattedValueFrom.tokenizeByCharacter - } else if (formattedValueTo.value?.length) { - tokenizeByCharacter = formattedValueTo.tokenizeByCharacter - } - - const renderedValueFrom = formattedValueFrom.value ?? placeholder - const renderedValueTo: string = formattedValueTo.value ?? placeholder - - const { From, To } = getHTMLDiffComponents({ - fromHTML: '

' + renderedValueFrom + '

', - postProcess: unescapeDiffHTML, - toHTML: '

' + renderedValueTo + '

', - tokenizeByCharacter, - }) - - return ( - - ) -} +export { Text } from '@payloadcms/ui/views/Version/RenderFieldsToDiff/fields/Text/index' diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Upload/index.tsx b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Upload/index.tsx index d27b3c77479..0018b592793 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Upload/index.tsx +++ b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Upload/index.tsx @@ -1,307 +1,5 @@ -import type { - FileData, - PayloadRequest, - TypeWithID, - UploadField, - UploadFieldDiffServerComponent, -} from 'payload' - -import { getTranslation, type I18nClient } from '@payloadcms/translations' -import { FieldDiffContainer, File, getHTMLDiffComponents } from '@payloadcms/ui/rsc' - -import './index.scss' - -import React from 'react' - -const baseClass = 'upload-diff' - -type NonPolyUploadDoc = (FileData & TypeWithID) | number | string -type PolyUploadDoc = { relationTo: string; value: (FileData & TypeWithID) | number | string } - -type UploadDoc = NonPolyUploadDoc | PolyUploadDoc - -export const Upload: UploadFieldDiffServerComponent = (args) => { - const { - comparisonValue: valueFrom, - field, - i18n, - locale, - nestingLevel, - req, - versionValue: valueTo, - } = args - const hasMany = 'hasMany' in field && field.hasMany && Array.isArray(valueTo) - const polymorphic = Array.isArray(field.relationTo) - - if (hasMany) { - return ( - - ) - } - - return ( - - ) -} - -export const HasManyUploadDiff: React.FC<{ - field: UploadField - i18n: I18nClient - locale: string - nestingLevel?: number - polymorphic: boolean - req: PayloadRequest - valueFrom: Array - valueTo: Array -}> = async (args) => { - const { field, i18n, locale, nestingLevel, polymorphic, req, valueFrom, valueTo } = args - const ReactDOMServer = (await import('react-dom/server')).default - - let From: React.ReactNode = '' - let To: React.ReactNode = '' - - const showCollectionSlug = Array.isArray(field.relationTo) - - const getUploadDocKey = (uploadDoc: UploadDoc): number | string => { - if (typeof uploadDoc === 'object' && 'relationTo' in uploadDoc) { - // Polymorphic case - const value = uploadDoc.value - return typeof value === 'object' ? value.id : value - } - // Non-polymorphic case - return typeof uploadDoc === 'object' ? uploadDoc.id : uploadDoc - } - - const FromComponents = valueFrom - ? valueFrom.map((uploadDoc) => ( - - )) - : null - const ToComponents = valueTo - ? valueTo.map((uploadDoc) => ( - - )) - : null - - const diffResult = getHTMLDiffComponents({ - fromHTML: - `
` + - (FromComponents - ? FromComponents.map( - (component) => `
${ReactDOMServer.renderToStaticMarkup(component)}
`, - ).join('') - : '') + - '
', - toHTML: - `
` + - (ToComponents - ? ToComponents.map( - (component) => `
${ReactDOMServer.renderToStaticMarkup(component)}
`, - ).join('') - : '') + - '
', - tokenizeByCharacter: false, - }) - From = diffResult.From - To = diffResult.To - - return ( - - ) -} - -export const SingleUploadDiff: React.FC<{ - field: UploadField - i18n: I18nClient - locale: string - nestingLevel?: number - polymorphic: boolean - req: PayloadRequest - valueFrom: UploadDoc - valueTo: UploadDoc -}> = async (args) => { - const { field, i18n, locale, nestingLevel, polymorphic, req, valueFrom, valueTo } = args - - const ReactDOMServer = (await import('react-dom/server')).default - - let From: React.ReactNode = '' - let To: React.ReactNode = '' - - const showCollectionSlug = Array.isArray(field.relationTo) - - const FromComponent = valueFrom ? ( - - ) : null - const ToComponent = valueTo ? ( - - ) : null - - const fromHtml = FromComponent - ? ReactDOMServer.renderToStaticMarkup(FromComponent) - : '

' + '' + '

' - const toHtml = ToComponent - ? ReactDOMServer.renderToStaticMarkup(ToComponent) - : '

' + '' + '

' - - const diffResult = getHTMLDiffComponents({ - fromHTML: fromHtml, - toHTML: toHtml, - tokenizeByCharacter: false, - }) - From = diffResult.From - To = diffResult.To - - return ( - - ) -} - -const UploadDocumentDiff = (args: { - i18n: I18nClient - polymorphic: boolean - relationTo: string | string[] - req: PayloadRequest - showCollectionSlug?: boolean - uploadDoc: UploadDoc -}) => { - const { i18n, polymorphic, relationTo, req, showCollectionSlug, uploadDoc } = args - - let thumbnailSRC: string = '' - - const value = polymorphic - ? (uploadDoc as { relationTo: string; value: FileData & TypeWithID }).value - : (uploadDoc as FileData & TypeWithID) - - if (value && typeof value === 'object' && 'thumbnailURL' in value) { - thumbnailSRC = - (typeof value.thumbnailURL === 'string' && value.thumbnailURL) || - (typeof value.url === 'string' && value.url) || - '' - } - - let filename: string - if (value && typeof value === 'object') { - filename = value.filename - } else { - filename = `${i18n.t('general:untitled')} - ID: ${uploadDoc as number | string}` - } - - let pillLabel: null | string = null - - if (showCollectionSlug) { - let collectionSlug: string - if (polymorphic && typeof uploadDoc === 'object' && 'relationTo' in uploadDoc) { - collectionSlug = uploadDoc.relationTo - } else { - collectionSlug = typeof relationTo === 'string' ? relationTo : relationTo[0] - } - const uploadConfig = req.payload.collections[collectionSlug].config - pillLabel = uploadConfig.labels?.singular - ? getTranslation(uploadConfig.labels.singular, i18n) - : uploadConfig.slug - } - - let id: number | string | undefined - if (polymorphic && typeof uploadDoc === 'object' && 'relationTo' in uploadDoc) { - const polyValue = uploadDoc.value - id = typeof polyValue === 'object' ? polyValue.id : polyValue - } else if (typeof uploadDoc === 'object' && 'id' in uploadDoc) { - id = uploadDoc.id - } else if (typeof uploadDoc === 'string' || typeof uploadDoc === 'number') { - id = uploadDoc - } - - const alt = - (value && typeof value === 'object' && (value as { alt?: string }).alt) || filename || '' - - return ( -
-
-
- {thumbnailSRC?.length ? {alt} : } -
- {pillLabel && ( -
- {pillLabel} -
- )} -
- {filename} -
-
-
- ) -} +export { + HasManyUploadDiff, + SingleUploadDiff, + Upload, +} from '@payloadcms/ui/views/Version/RenderFieldsToDiff/fields/Upload/index' diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/fields/index.ts b/packages/next/src/views/Version/RenderFieldsToDiff/fields/index.ts index cc00eeab186..072b35d23e4 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/fields/index.ts +++ b/packages/next/src/views/Version/RenderFieldsToDiff/fields/index.ts @@ -1,40 +1 @@ -import type { FieldDiffClientProps, FieldDiffServerProps, FieldTypes } from 'payload' - -import { Collapsible } from './Collapsible/index.js' -import { DateDiffComponent } from './Date/index.js' -import { Group } from './Group/index.js' -import { Iterable } from './Iterable/index.js' -import { Relationship } from './Relationship/index.js' -import { Row } from './Row/index.js' -import { Select } from './Select/index.js' -import { Tabs } from './Tabs/index.js' -import { Text } from './Text/index.js' -import { Upload } from './Upload/index.js' - -export const diffComponents: Record< - FieldTypes, - React.ComponentType -> = { - array: Iterable, - blocks: Iterable, - checkbox: Text, - code: Text, - collapsible: Collapsible, - date: DateDiffComponent, - email: Text, - group: Group, - join: null, - json: Text, - number: Text, - point: Text, - radio: Select, - relationship: Relationship, - richText: Text, - row: Row, - select: Select, - tabs: Tabs, - text: Text, - textarea: Text, - ui: null, - upload: Upload, -} +export { diffComponents } from '@payloadcms/ui/views/Version/RenderFieldsToDiff/fields/index' diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/index.tsx b/packages/next/src/views/Version/RenderFieldsToDiff/index.tsx index a2e90a8424c..9abc64f0950 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/index.tsx +++ b/packages/next/src/views/Version/RenderFieldsToDiff/index.tsx @@ -1,8 +1 @@ -import { buildVersionFields, type BuildVersionFieldsArgs } from './buildVersionFields.js' -import { RenderVersionFieldsToDiff } from './RenderVersionFieldsToDiff.js' - -export const RenderDiff = (args: BuildVersionFieldsArgs): React.ReactNode => { - const { versionFields } = buildVersionFields(args) - - return -} +export { RenderDiff } from '@payloadcms/ui/views/Version/RenderFieldsToDiff/index' diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.ts b/packages/next/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.ts index 3c6fcbd1e33..34eedb86cda 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.ts +++ b/packages/next/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.ts @@ -1,255 +1,4 @@ -import type { ArrayFieldClient, BlocksFieldClient, ClientConfig, ClientField } from 'payload' - -import { fieldShouldBeLocalized, groupHasName } from 'payload/shared' - -import { fieldHasChanges } from './fieldHasChanges.js' -import { getFieldsForRowComparison } from './getFieldsForRowComparison.js' - -type Args = { - config: ClientConfig - fields: ClientField[] - locales: string[] | undefined - parentIsLocalized: boolean - valueFrom: unknown - valueTo: unknown -} - -/** - * Recursively counts the number of changed fields between comparison and - * version data for a given set of fields. - */ -export function countChangedFields({ - config, - fields, - locales, - parentIsLocalized, - valueFrom, - valueTo, -}: Args) { - let count = 0 - - fields.forEach((field) => { - // Don't count the id field since it is not displayed in the UI - if ('name' in field && field.name === 'id') { - return - } - const fieldType = field.type - switch (fieldType) { - // Iterable fields are arrays and blocks fields. We iterate over each row and - // count the number of changed fields in each. - case 'array': - case 'blocks': { - if (locales && fieldShouldBeLocalized({ field, parentIsLocalized })) { - locales.forEach((locale) => { - const valueFromRows = valueFrom?.[field.name]?.[locale] ?? [] - const valueToRows = valueTo?.[field.name]?.[locale] ?? [] - count += countChangedFieldsInRows({ - config, - field, - locales, - parentIsLocalized: parentIsLocalized || field.localized, - valueFromRows, - valueToRows, - }) - }) - } else { - const valueFromRows = valueFrom?.[field.name] ?? [] - const valueToRows = valueTo?.[field.name] ?? [] - count += countChangedFieldsInRows({ - config, - field, - locales, - parentIsLocalized: parentIsLocalized || field.localized, - valueFromRows, - valueToRows, - }) - } - break - } - - // Regular fields without nested fields. - case 'checkbox': - case 'code': - case 'date': - case 'email': - case 'join': - case 'json': - case 'number': - case 'point': - case 'radio': - case 'relationship': - case 'richText': - case 'select': - case 'text': - case 'textarea': - case 'upload': { - // Fields that have a name and contain data. We can just check if the data has changed. - if (locales && fieldShouldBeLocalized({ field, parentIsLocalized })) { - locales.forEach((locale) => { - if ( - fieldHasChanges(valueTo?.[field.name]?.[locale], valueFrom?.[field.name]?.[locale]) - ) { - count++ - } - }) - } else if (fieldHasChanges(valueTo?.[field.name], valueFrom?.[field.name])) { - count++ - } - break - } - // Fields that have nested fields, but don't nest their fields' data. - case 'collapsible': - case 'row': { - count += countChangedFields({ - config, - fields: field.fields, - locales, - parentIsLocalized: parentIsLocalized || field.localized, - valueFrom, - valueTo, - }) - - break - } - - // Fields that have nested fields and nest their fields' data. - case 'group': { - if (groupHasName(field)) { - if (locales && fieldShouldBeLocalized({ field, parentIsLocalized })) { - locales.forEach((locale) => { - count += countChangedFields({ - config, - fields: field.fields, - locales, - parentIsLocalized: parentIsLocalized || field.localized, - valueFrom: valueFrom?.[field.name]?.[locale], - valueTo: valueTo?.[field.name]?.[locale], - }) - }) - } else { - count += countChangedFields({ - config, - fields: field.fields, - locales, - parentIsLocalized: parentIsLocalized || field.localized, - valueFrom: valueFrom?.[field.name], - valueTo: valueTo?.[field.name], - }) - } - } else { - // Unnamed group field: data is NOT nested under `field.name` - count += countChangedFields({ - config, - fields: field.fields, - locales, - parentIsLocalized: parentIsLocalized || field.localized, - valueFrom, - valueTo, - }) - } - break - } - - // Each tab in a tabs field has nested fields. The fields data may be - // nested or not depending on the existence of a name property. - case 'tabs': { - field.tabs.forEach((tab) => { - if ('name' in tab && locales && tab.localized) { - // Named localized tab - locales.forEach((locale) => { - count += countChangedFields({ - config, - fields: tab.fields, - locales, - parentIsLocalized: parentIsLocalized || tab.localized, - valueFrom: valueFrom?.[tab.name]?.[locale], - valueTo: valueTo?.[tab.name]?.[locale], - }) - }) - } else if ('name' in tab) { - // Named tab - count += countChangedFields({ - config, - fields: tab.fields, - locales, - parentIsLocalized: parentIsLocalized || tab.localized, - valueFrom: valueFrom?.[tab.name], - valueTo: valueTo?.[tab.name], - }) - } else { - // Unnamed tab - count += countChangedFields({ - config, - fields: tab.fields, - locales, - parentIsLocalized: parentIsLocalized || tab.localized, - valueFrom, - valueTo, - }) - } - }) - break - } - - // UI fields don't have data and are not displayed in the version view - // so we can ignore them. - case 'ui': { - break - } - - default: { - const _exhaustiveCheck: never = fieldType - throw new Error(`Unexpected field.type in countChangedFields : ${String(fieldType)}`) - } - } - }) - - return count -} - -type countChangedFieldsInRowsArgs = { - config: ClientConfig - field: ArrayFieldClient | BlocksFieldClient - locales: string[] | undefined - parentIsLocalized: boolean - valueFromRows: unknown[] - valueToRows: unknown[] -} - -export function countChangedFieldsInRows({ - config, - field, - locales, - parentIsLocalized, - valueFromRows = [], - valueToRows = [], -}: countChangedFieldsInRowsArgs) { - let count = 0 - let i = 0 - - while (valueFromRows[i] || valueToRows[i]) { - const valueFromRow = valueFromRows?.[i] || {} - const valueToRow = valueToRows?.[i] || {} - - const { fields: rowFields } = getFieldsForRowComparison({ - baseVersionField: { type: 'text', fields: [], path: '', schemaPath: '' }, // Doesn't matter, as we don't need the versionFields output here - config, - field, - row: i, - valueFromRow, - valueToRow, - }) - - count += countChangedFields({ - config, - fields: rowFields, - locales, - parentIsLocalized: parentIsLocalized || field.localized, - valueFrom: valueFromRow, - valueTo: valueToRow, - }) - - i++ - } - return count -} +export { + countChangedFields, + countChangedFieldsInRows, +} from '@payloadcms/ui/views/Version/RenderFieldsToDiff/utilities/countChangedFields' diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/utilities/fieldHasChanges.ts b/packages/next/src/views/Version/RenderFieldsToDiff/utilities/fieldHasChanges.ts index fa2a0659b7d..3bc169dfed9 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/utilities/fieldHasChanges.ts +++ b/packages/next/src/views/Version/RenderFieldsToDiff/utilities/fieldHasChanges.ts @@ -1,3 +1 @@ -export function fieldHasChanges(a: unknown, b: unknown) { - return JSON.stringify(a) !== JSON.stringify(b) -} +export { fieldHasChanges } from '@payloadcms/ui/views/Version/RenderFieldsToDiff/utilities/fieldHasChanges' diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison.ts b/packages/next/src/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison.ts index 4ef258c097f..b0e47f1a605 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison.ts +++ b/packages/next/src/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison.ts @@ -1,89 +1 @@ -import type { - ArrayFieldClient, - BaseVersionField, - BlocksFieldClient, - ClientBlock, - ClientConfig, - ClientField, - VersionField, -} from 'payload' - -import { getUniqueListBy } from 'payload/shared' - -/** - * Get the fields for a row in an iterable field for comparison. - * - Array fields: the fields of the array field, because the fields are the same for each row. - * - Blocks fields: the union of fields from the comparison and version row, - * because the fields from the version and comparison rows may differ. - */ -export function getFieldsForRowComparison({ - baseVersionField, - config, - field, - row, - valueFromRow, - valueToRow, -}: { - baseVersionField: BaseVersionField - config: ClientConfig - field: ArrayFieldClient | BlocksFieldClient - row: number - valueFromRow: any - valueToRow: any -}): { fields: ClientField[]; versionFields: VersionField[] } { - let fields: ClientField[] = [] - let versionFields: VersionField[] = [] - - if (field.type === 'array' && 'fields' in field) { - fields = field.fields - versionFields = baseVersionField.rows?.length - ? baseVersionField.rows[row] - : baseVersionField.fields - } else if (field.type === 'blocks') { - if (valueToRow?.blockType === valueFromRow?.blockType) { - const matchedBlock: ClientBlock = - config?.blocksMap?.[valueToRow?.blockType] ?? - (((('blocks' in field || 'blockReferences' in field) && - (field.blockReferences ?? field.blocks)?.find( - (block) => typeof block !== 'string' && block.slug === valueToRow?.blockType, - )) || { - fields: [], - }) as ClientBlock) - - fields = matchedBlock.fields - versionFields = baseVersionField.rows?.length - ? baseVersionField.rows[row] - : baseVersionField.fields - } else { - const matchedVersionBlock = - config?.blocksMap?.[valueToRow?.blockType] ?? - (((('blocks' in field || 'blockReferences' in field) && - (field.blockReferences ?? field.blocks)?.find( - (block) => typeof block !== 'string' && block.slug === valueToRow?.blockType, - )) || { - fields: [], - }) as ClientBlock) - - const matchedComparisonBlock = - config?.blocksMap?.[valueFromRow?.blockType] ?? - (((('blocks' in field || 'blockReferences' in field) && - (field.blockReferences ?? field.blocks)?.find( - (block) => typeof block !== 'string' && block.slug === valueFromRow?.blockType, - )) || { - fields: [], - }) as ClientBlock) - - fields = getUniqueListBy( - [...matchedVersionBlock.fields, ...matchedComparisonBlock.fields], - 'name', - ) - - // buildVersionFields already merged the fields of the version and comparison rows together - versionFields = baseVersionField.rows?.length - ? baseVersionField.rows[row] - : baseVersionField.fields - } - } - - return { fields, versionFields } -} +export { getFieldsForRowComparison } from '@payloadcms/ui/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison' diff --git a/packages/next/src/views/Version/SelectComparison/VersionDrawer/CreatedAtCell.tsx b/packages/next/src/views/Version/SelectComparison/VersionDrawer/CreatedAtCell.tsx index f922b062caf..a24fb0466cc 100644 --- a/packages/next/src/views/Version/SelectComparison/VersionDrawer/CreatedAtCell.tsx +++ b/packages/next/src/views/Version/SelectComparison/VersionDrawer/CreatedAtCell.tsx @@ -1,52 +1 @@ -'use client' -import { - useConfig, - useModal, - usePathname, - useRouter, - useRouteTransition, - useSearchParams, - useTranslation, -} from '@payloadcms/ui' -import { formatDate } from '@payloadcms/ui/shared' - -import type { CreatedAtCellProps } from '../../../Versions/cells/CreatedAt/index.js' - -export const VersionDrawerCreatedAtCell: React.FC = ({ - rowData: { id, updatedAt } = {}, -}) => { - const { - config: { - admin: { dateFormat }, - }, - } = useConfig() - const { closeAllModals } = useModal() - const router = useRouter() - const pathname = usePathname() - const searchParams = useSearchParams() - const { startRouteTransition } = useRouteTransition() - - const { i18n } = useTranslation() - - return ( - - ) -} +export { VersionDrawerCreatedAtCell } from '@payloadcms/ui/views/Version/SelectComparison/VersionDrawer/CreatedAtCell' diff --git a/packages/next/src/views/Version/SelectComparison/VersionDrawer/index.tsx b/packages/next/src/views/Version/SelectComparison/VersionDrawer/index.tsx index 4abe98e54fd..92b660f4960 100644 --- a/packages/next/src/views/Version/SelectComparison/VersionDrawer/index.tsx +++ b/packages/next/src/views/Version/SelectComparison/VersionDrawer/index.tsx @@ -1,200 +1,7 @@ -'use client' -import { - Drawer, - LoadingOverlay, - toast, - useDocumentInfo, - useEditDepth, - useModal, - useSearchParams, - useServerFunctions, - useTranslation, -} from '@payloadcms/ui' - -import './index.scss' - -import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' - -export const baseClass = 'version-drawer' -export const formatVersionDrawerSlug = ({ - depth, - uuid, -}: { - depth: number - uuid: string // supply when creating a new document and no id is available -}) => `version-drawer_${depth}_${uuid}` - -export const VersionDrawerContent: React.FC<{ - collectionSlug?: string - docID?: number | string - drawerSlug: string - globalSlug?: string -}> = (props) => { - const { collectionSlug, docID, drawerSlug, globalSlug } = props - const { isTrashed } = useDocumentInfo() - const { closeModal } = useModal() - const searchParams = useSearchParams() - const prevSearchParams = useRef(searchParams) - - const { renderDocument } = useServerFunctions() - - const [DocumentView, setDocumentView] = useState(undefined) - const [isLoading, setIsLoading] = useState(true) - const hasRenderedDocument = useRef(false) - const { t } = useTranslation() - - const getDocumentView = useCallback( - (docID?: number | string) => { - const fetchDocumentView = async () => { - setIsLoading(true) - - try { - const isGlobal = Boolean(globalSlug) - const entitySlug = collectionSlug ?? globalSlug - - const result = await renderDocument({ - collectionSlug: entitySlug, - docID, - drawerSlug, - paramsOverride: { - segments: [ - isGlobal ? 'globals' : 'collections', - entitySlug, - ...(isTrashed ? ['trash'] : []), - isGlobal ? undefined : String(docID), - 'versions', - ].filter(Boolean), - }, - redirectAfterDelete: false, - redirectAfterDuplicate: false, - searchParams: Object.fromEntries(searchParams.entries()), - versions: { - disableGutter: true, - useVersionDrawerCreatedAtCell: true, - }, - }) - - if (result?.Document) { - setDocumentView(result.Document) - setIsLoading(false) - } - } catch (error) { - toast.error(error?.message || t('error:unspecific')) - closeModal(drawerSlug) - // toast.error(data?.errors?.[0].message || t('error:unspecific')) - } - } - - void fetchDocumentView() - }, - [ - closeModal, - collectionSlug, - drawerSlug, - globalSlug, - isTrashed, - renderDocument, - searchParams, - t, - ], - ) - - useEffect(() => { - if (!hasRenderedDocument.current || prevSearchParams.current !== searchParams) { - prevSearchParams.current = searchParams - getDocumentView(docID) - hasRenderedDocument.current = true - } - }, [docID, getDocumentView, searchParams]) - - if (isLoading) { - return - } - - return DocumentView -} -export const VersionDrawer: React.FC<{ - collectionSlug?: string - docID?: number | string - drawerSlug: string - globalSlug?: string -}> = (props) => { - const { collectionSlug, docID, drawerSlug, globalSlug } = props - const { t } = useTranslation() - - return ( - - - - ) -} - -export const useVersionDrawer = ({ - collectionSlug, - docID, - globalSlug, -}: { - collectionSlug?: string - docID?: number | string - globalSlug?: string -}) => { - const drawerDepth = useEditDepth() - const uuid = useId() - const { closeModal, modalState, openModal, toggleModal } = useModal() - const [isOpen, setIsOpen] = useState(false) - - const drawerSlug = formatVersionDrawerSlug({ - depth: drawerDepth, - uuid, - }) - - useEffect(() => { - setIsOpen(Boolean(modalState[drawerSlug]?.isOpen)) - }, [modalState, drawerSlug]) - - const toggleDrawer = useCallback(() => { - toggleModal(drawerSlug) - }, [toggleModal, drawerSlug]) - - const closeDrawer = useCallback(() => { - closeModal(drawerSlug) - }, [drawerSlug, closeModal]) - - const openDrawer = useCallback(() => { - openModal(drawerSlug) - }, [drawerSlug, openModal]) - - const MemoizedDrawer = useMemo(() => { - return () => ( - - ) - }, [collectionSlug, docID, drawerSlug, globalSlug]) - - return useMemo( - () => ({ - closeDrawer, - Drawer: MemoizedDrawer, - drawerDepth, - drawerSlug, - isDrawerOpen: isOpen, - openDrawer, - toggleDrawer, - }), - [MemoizedDrawer, closeDrawer, drawerDepth, drawerSlug, isOpen, openDrawer, toggleDrawer], - ) -} +export { + baseClass, + formatVersionDrawerSlug, + useVersionDrawer, + VersionDrawer, + VersionDrawerContent, +} from '@payloadcms/ui/views/Version/SelectComparison/VersionDrawer/index' diff --git a/packages/next/src/views/Version/SelectComparison/index.tsx b/packages/next/src/views/Version/SelectComparison/index.tsx index 2f1082ae9e7..71809657ba9 100644 --- a/packages/next/src/views/Version/SelectComparison/index.tsx +++ b/packages/next/src/views/Version/SelectComparison/index.tsx @@ -1,68 +1 @@ -'use client' - -import { fieldBaseClass, ReactSelect, useTranslation } from '@payloadcms/ui' -import React, { memo, useCallback, useMemo } from 'react' - -import type { CompareOption } from '../Default/types.js' - -import './index.scss' - -import type { Props } from './types.js' - -import { useVersionDrawer } from './VersionDrawer/index.js' - -const baseClass = 'compare-version' - -export const SelectComparison: React.FC = memo((props) => { - const { - collectionSlug, - docID, - globalSlug, - onChange: onChangeFromProps, - versionFromID, - versionFromOptions, - } = props - const { t } = useTranslation() - - const { Drawer, openDrawer } = useVersionDrawer({ collectionSlug, docID, globalSlug }) - - const options = useMemo(() => { - return [ - ...versionFromOptions, - { - label: {t('version:moreVersions')}, - value: 'more', - }, - ] - }, [t, versionFromOptions]) - - const currentOption = useMemo( - () => versionFromOptions.find((option) => option.value === versionFromID), - [versionFromOptions, versionFromID], - ) - - const onChange = useCallback( - (val: CompareOption) => { - if (val.value === 'more') { - openDrawer() - return - } - onChangeFromProps(val) - }, - [onChangeFromProps, openDrawer], - ) - - return ( -
- - -
- ) -}) +export { SelectComparison } from '@payloadcms/ui/views/Version/SelectComparison/index' diff --git a/packages/next/src/views/Version/SelectComparison/types.ts b/packages/next/src/views/Version/SelectComparison/types.ts index 703b73b12c4..dc51701a923 100644 --- a/packages/next/src/views/Version/SelectComparison/types.ts +++ b/packages/next/src/views/Version/SelectComparison/types.ts @@ -1,30 +1,5 @@ -import type { PaginatedDocs, SanitizedCollectionConfig } from 'payload' - -import type { CompareOption } from '../Default/types.js' - -export type Props = { - collectionSlug?: string - docID?: number | string - globalSlug?: string - onChange: (val: CompareOption) => void - versionFromID?: string - versionFromOptions: CompareOption[] -} - -type CLEAR = { - required: boolean - type: 'CLEAR' -} - -type ADD = { - collection: SanitizedCollectionConfig - data: PaginatedDocs - type: 'ADD' -} - -export type Action = ADD | CLEAR - -export type ValueWithRelation = { - relationTo: string - value: string -} +export type { + Action, + Props, + ValueWithRelation, +} from '@payloadcms/ui/views/Version/SelectComparison/types' diff --git a/packages/next/src/views/Version/VersionPillLabel/VersionPillLabel.tsx b/packages/next/src/views/Version/VersionPillLabel/VersionPillLabel.tsx index 50674b82aae..cc41bfde0fd 100644 --- a/packages/next/src/views/Version/VersionPillLabel/VersionPillLabel.tsx +++ b/packages/next/src/views/Version/VersionPillLabel/VersionPillLabel.tsx @@ -1,121 +1 @@ -'use client' - -import type { TypeWithVersion } from 'payload' - -import { Pill, useConfig, useLocale, useTranslation } from '@payloadcms/ui' -import { formatDate } from '@payloadcms/ui/shared' -import React from 'react' - -import './index.scss' -import { getVersionLabel } from './getVersionLabel.js' - -const baseClass = 'version-pill-label' - -const renderPill = (label: React.ReactNode, pillStyle: Parameters[0]['pillStyle']) => { - return ( - - {label} - - ) -} - -export const VersionPillLabel: React.FC<{ - currentlyPublishedVersion?: TypeWithVersion - disableDate?: boolean - - doc: { - [key: string]: unknown - id: number | string - publishedLocale?: string - updatedAt?: string - version: { - [key: string]: unknown - _status: 'draft' | 'published' - updatedAt: string - } - } - /** - * By default, the date is displayed first, followed by the version label. - * @default false - */ - labelFirst?: boolean - labelOverride?: React.ReactNode - /** - * @default 'pill' - */ - labelStyle?: 'pill' | 'text' - labelSuffix?: React.ReactNode - latestDraftVersion?: TypeWithVersion -}> = ({ - currentlyPublishedVersion, - disableDate = false, - doc, - labelFirst = false, - labelOverride, - labelStyle = 'pill', - labelSuffix, - latestDraftVersion, -}) => { - const { - config: { - admin: { dateFormat }, - localization, - }, - } = useConfig() - const { i18n, t } = useTranslation() - const { code: currentLocale } = useLocale() - - const { label, pillStyle } = getVersionLabel({ - currentLocale, - currentlyPublishedVersion, - latestDraftVersion, - t, - version: doc, - }) - const labelText: React.ReactNode = ( - - {labelOverride || label} - {labelSuffix} - - ) - - const showDate = !disableDate && doc.updatedAt - const formattedDate = showDate - ? formatDate({ date: doc.updatedAt, i18n, pattern: dateFormat }) - : null - - const localeCode = Array.isArray(doc.publishedLocale) - ? doc.publishedLocale[0] - : doc.publishedLocale - - const locale = - localization && localization?.locales - ? localization.locales.find((loc) => loc.code === localeCode) - : null - const localeLabel = locale ? locale?.label?.[i18n?.language] || locale?.label : null - - return ( -
- {labelFirst ? ( - - {labelStyle === 'pill' ? ( - renderPill(labelText, pillStyle) - ) : ( - {labelText} - )} - {showDate && {formattedDate}} - - ) : ( - - {showDate && {formattedDate}} - {labelStyle === 'pill' ? ( - renderPill(labelText, pillStyle) - ) : ( - {labelText} - )} - - )} - {localeLabel && {localeLabel}} -
- ) -} +export { VersionPillLabel } from '@payloadcms/ui/views/Version/VersionPillLabel/VersionPillLabel' diff --git a/packages/next/src/views/Version/VersionPillLabel/getVersionLabel.ts b/packages/next/src/views/Version/VersionPillLabel/getVersionLabel.ts index d35ebc303b8..1f7eac9d60e 100644 --- a/packages/next/src/views/Version/VersionPillLabel/getVersionLabel.ts +++ b/packages/next/src/views/Version/VersionPillLabel/getVersionLabel.ts @@ -1,85 +1 @@ -import type { TFunction } from '@payloadcms/translations' -import type { Pill } from '@payloadcms/ui' - -type Args = { - currentLocale?: string - currentlyPublishedVersion?: { - id: number | string - publishedLocale?: string - updatedAt: string - version: { - updatedAt: string - } - } - latestDraftVersion?: { - id: number | string - updatedAt: string - } - t: TFunction - version: { - id: number | string - publishedLocale?: string - version: { _status?: 'draft' | 'published'; updatedAt: string } - } -} - -/** - * Gets the appropriate version label and version pill styling - * given existing versions and the current version status. - */ -export function getVersionLabel({ - currentLocale, - currentlyPublishedVersion, - latestDraftVersion, - t, - version, -}: Args): { - label: string - name: 'currentDraft' | 'currentlyPublished' | 'draft' | 'previouslyPublished' | 'published' - pillStyle: Parameters[0]['pillStyle'] -} { - const status = version.version._status - - if (status === 'draft') { - const publishedNewerThanDraft = - currentlyPublishedVersion?.updatedAt > latestDraftVersion?.updatedAt - - if (publishedNewerThanDraft) { - return { - name: 'draft', - label: t('version:draft'), - pillStyle: 'light', - } - } - - const isCurrentDraft = version.id === latestDraftVersion?.id - - return { - name: isCurrentDraft ? 'currentDraft' : 'draft', - label: isCurrentDraft ? t('version:currentDraft') : t('version:draft'), - pillStyle: 'light', - } - } - - const publishedInAnotherLocale = - status === 'published' && version.publishedLocale && currentLocale !== version.publishedLocale - - if (publishedInAnotherLocale) { - return { - name: 'currentDraft', - label: t('version:currentDraft'), - pillStyle: 'light', - } - } - - const isCurrentlyPublished = - currentlyPublishedVersion && version.id === currentlyPublishedVersion.id - - return { - name: isCurrentlyPublished ? 'currentlyPublished' : 'previouslyPublished', - label: isCurrentlyPublished - ? t('version:currentlyPublished') - : t('version:previouslyPublished'), - pillStyle: isCurrentlyPublished ? 'success' : 'light', - } -} +export { getVersionLabel } from '@payloadcms/ui/views/Version/VersionPillLabel/getVersionLabel' diff --git a/packages/next/src/views/Version/fetchVersions.ts b/packages/next/src/views/Version/fetchVersions.ts index 858f40f3964..7048cdd0ac8 100644 --- a/packages/next/src/views/Version/fetchVersions.ts +++ b/packages/next/src/views/Version/fetchVersions.ts @@ -1,206 +1,5 @@ -import { - logError, - type PaginatedDocs, - type PayloadRequest, - type SelectType, - type Sort, - type TypedUser, - type TypeWithVersion, - type Where, -} from 'payload' - -export const fetchVersion = async ({ - id, - collectionSlug, - depth, - globalSlug, - locale, - overrideAccess, - req, - select, - user, -}: { - collectionSlug?: string - depth?: number - globalSlug?: string - id: number | string - locale?: 'all' | ({} & string) - overrideAccess?: boolean - req: PayloadRequest - select?: SelectType - user?: TypedUser -}): Promise> => { - try { - if (collectionSlug) { - return (await req.payload.findVersionByID({ - id: String(id), - collection: collectionSlug, - depth, - locale, - overrideAccess, - req, - select, - user, - })) as TypeWithVersion - } else if (globalSlug) { - return (await req.payload.findGlobalVersionByID({ - id: String(id), - slug: globalSlug, - depth, - locale, - overrideAccess, - req, - select, - user, - })) as TypeWithVersion - } - } catch (err) { - logError({ err, payload: req.payload }) - return null - } -} - -export const fetchVersions = async ({ - collectionSlug, - depth, - draft, - globalSlug, - limit, - locale, - overrideAccess, - page, - parentID, - req, - select, - sort, - user, - where: whereFromArgs, -}: { - collectionSlug?: string - depth?: number - draft?: boolean - globalSlug?: string - limit?: number - locale?: 'all' | ({} & string) - overrideAccess?: boolean - page?: number - parentID?: number | string - req: PayloadRequest - select?: SelectType - sort?: Sort - user?: TypedUser - where?: Where -}): Promise>> => { - const where: Where = { and: [...(whereFromArgs ? [whereFromArgs] : [])] } - - try { - if (collectionSlug) { - if (parentID) { - where.and.push({ - parent: { - equals: parentID, - }, - }) - } - return (await req.payload.findVersions({ - collection: collectionSlug, - depth, - draft, - limit, - locale, - overrideAccess, - page, - req, - select, - sort, - user, - where, - })) as PaginatedDocs> - } else if (globalSlug) { - return (await req.payload.findGlobalVersions({ - slug: globalSlug, - depth, - limit, - locale, - overrideAccess, - page, - req, - select, - sort, - user, - where, - })) as PaginatedDocs> - } - } catch (err) { - logError({ err, payload: req.payload }) - - return null - } -} - -export const fetchLatestVersion = async ({ - collectionSlug, - depth, - globalSlug, - locale, - overrideAccess, - parentID, - req, - select, - status, - user, - where, -}: { - collectionSlug?: string - depth?: number - globalSlug?: string - locale?: 'all' | ({} & string) - overrideAccess?: boolean - parentID?: number | string - req: PayloadRequest - select?: SelectType - status: 'draft' | 'published' - user?: TypedUser - where?: Where -}): Promise> => { - // Get the entity config to check if drafts are enabled - const entityConfig = collectionSlug - ? req.payload.collections[collectionSlug]?.config - : globalSlug - ? req.payload.globals[globalSlug]?.config - : undefined - - // Only query by _status if drafts are enabled (since _status field only exists with drafts) - const draftsEnabled = entityConfig?.versions?.drafts - - const and: Where[] = [ - ...(draftsEnabled - ? [ - { - 'version._status': { - equals: status, - }, - }, - ] - : []), - ...(where ? [where] : []), - ] - - const latest = await fetchVersions({ - collectionSlug, - depth, - draft: true, - globalSlug, - limit: 1, - locale, - overrideAccess, - parentID, - req, - select, - sort: '-updatedAt', - user, - where: { and }, - }) - - return latest?.docs?.length ? (latest.docs[0] as TypeWithVersion) : null -} +export { + fetchLatestVersion, + fetchVersion, + fetchVersions, +} from '@payloadcms/ui/views/Version/fetchVersions' diff --git a/packages/next/src/views/Versions/buildColumns.tsx b/packages/next/src/views/Versions/buildColumns.tsx index 28eccead80a..244358b8332 100644 --- a/packages/next/src/views/Versions/buildColumns.tsx +++ b/packages/next/src/views/Versions/buildColumns.tsx @@ -1,105 +1 @@ -import type { I18n } from '@payloadcms/translations' -import type { - Column, - PaginatedDocs, - SanitizedCollectionConfig, - SanitizedGlobalConfig, - TypeWithVersion, -} from 'payload' - -import { SortColumn } from '@payloadcms/ui' -import { hasDraftsEnabled } from 'payload/shared' -import React from 'react' - -import { AutosaveCell } from './cells/AutosaveCell/index.js' -import { CreatedAtCell, type CreatedAtCellProps } from './cells/CreatedAt/index.js' -import { IDCell } from './cells/ID/index.js' - -export const buildVersionColumns = ({ - collectionConfig, - CreatedAtCellOverride, - currentlyPublishedVersion, - docID, - docs, - globalConfig, - i18n: { t }, - isTrashed, - latestDraftVersion, -}: { - collectionConfig?: SanitizedCollectionConfig - CreatedAtCellOverride?: React.ComponentType - currentlyPublishedVersion?: TypeWithVersion - docID?: number | string - docs: PaginatedDocs>['docs'] - globalConfig?: SanitizedGlobalConfig - i18n: I18n - isTrashed?: boolean - latestDraftVersion?: TypeWithVersion -}): Column[] => { - const entityConfig = collectionConfig || globalConfig - - const CreatedAtCellComponent = CreatedAtCellOverride ?? CreatedAtCell - - const columns: Column[] = [ - { - accessor: 'updatedAt', - active: true, - field: { - name: '', - type: 'date', - }, - Heading: , - renderedCells: docs.map((doc, i) => { - return ( - - ) - }), - }, - { - accessor: 'id', - active: true, - field: { - name: '', - type: 'text', - }, - Heading: , - renderedCells: docs.map((doc, i) => { - return - }), - }, - ] - - if (hasDraftsEnabled(entityConfig)) { - columns.push({ - accessor: '_status', - active: true, - field: { - name: '', - type: 'checkbox', - }, - Heading: , - renderedCells: docs.map((doc, i) => { - return ( - - ) - }), - }) - } - - return columns -} +export { buildVersionColumns } from '@payloadcms/ui/views/Versions/buildColumns' diff --git a/packages/next/src/views/Versions/cells/AutosaveCell/index.tsx b/packages/next/src/views/Versions/cells/AutosaveCell/index.tsx index e9486595b20..b3a6b703354 100644 --- a/packages/next/src/views/Versions/cells/AutosaveCell/index.tsx +++ b/packages/next/src/views/Versions/cells/AutosaveCell/index.tsx @@ -1,48 +1 @@ -'use client' -import type { TypeWithVersion } from 'payload' - -import { Pill, useTranslation } from '@payloadcms/ui' -import React from 'react' - -import { VersionPillLabel } from '../../../Version/VersionPillLabel/VersionPillLabel.js' -import './index.scss' - -const baseClass = 'autosave-cell' - -type AutosaveCellProps = { - currentlyPublishedVersion?: TypeWithVersion - latestDraftVersion?: TypeWithVersion - rowData: { - autosave?: boolean - id: number | string - publishedLocale?: string - updatedAt?: string - version: { - [key: string]: unknown - _status: 'draft' | 'published' - updatedAt: string - } - } -} - -export const AutosaveCell: React.FC = ({ - currentlyPublishedVersion, - latestDraftVersion, - rowData, -}) => { - const { t } = useTranslation() - - return ( -
- {rowData?.autosave && {t('version:autosave')}} - -
- ) -} +export { AutosaveCell } from '@payloadcms/ui/views/Versions/cells/AutosaveCell/index' diff --git a/packages/next/src/views/Versions/cells/CreatedAt/index.tsx b/packages/next/src/views/Versions/cells/CreatedAt/index.tsx index 15abcd988fe..d55fb64e5a0 100644 --- a/packages/next/src/views/Versions/cells/CreatedAt/index.tsx +++ b/packages/next/src/views/Versions/cells/CreatedAt/index.tsx @@ -1,57 +1,2 @@ -'use client' -import { Link, useConfig, useTranslation } from '@payloadcms/ui' -import { formatDate } from '@payloadcms/ui/shared' -import { formatAdminURL } from 'payload/shared' -import React from 'react' - -export type CreatedAtCellProps = { - collectionSlug?: string - docID?: number | string - globalSlug?: string - isTrashed?: boolean - rowData?: { - id: number | string - updatedAt: Date | number | string - } -} - -export const CreatedAtCell: React.FC = ({ - collectionSlug, - docID, - globalSlug, - isTrashed, - rowData: { id, updatedAt } = {}, -}) => { - const { - config: { - admin: { dateFormat }, - routes: { admin: adminRoute }, - }, - } = useConfig() - - const { i18n } = useTranslation() - - const trashedDocPrefix = isTrashed ? 'trash/' : '' - - let to: string - - if (collectionSlug) { - to = formatAdminURL({ - adminRoute, - path: `/collections/${collectionSlug}/${trashedDocPrefix}${docID}/versions/${id}`, - }) - } - - if (globalSlug) { - to = formatAdminURL({ - adminRoute, - path: `/globals/${globalSlug}/versions/${id}`, - }) - } - - return ( - - {formatDate({ date: updatedAt, i18n, pattern: dateFormat })} - - ) -} +export type { CreatedAtCellProps } from '@payloadcms/ui/views/Versions/cells/CreatedAt/index' +export { CreatedAtCell } from '@payloadcms/ui/views/Versions/cells/CreatedAt/index' diff --git a/packages/next/src/views/Versions/cells/ID/index.tsx b/packages/next/src/views/Versions/cells/ID/index.tsx index 7b3853fc16f..e854c69f575 100644 --- a/packages/next/src/views/Versions/cells/ID/index.tsx +++ b/packages/next/src/views/Versions/cells/ID/index.tsx @@ -1,6 +1 @@ -'use client' -import React, { Fragment } from 'react' - -export function IDCell({ id }: { id: number | string }) { - return {id} -} +export { IDCell } from '@payloadcms/ui/views/Versions/cells/ID/index' diff --git a/packages/next/src/views/Versions/index.client.tsx b/packages/next/src/views/Versions/index.client.tsx index cb53826f382..4e503f9bdae 100644 --- a/packages/next/src/views/Versions/index.client.tsx +++ b/packages/next/src/views/Versions/index.client.tsx @@ -1,76 +1 @@ -'use client' -import type { Column, SanitizedCollectionConfig } from 'payload' - -import { - LoadingOverlayToggle, - Pagination, - PerPage, - Table, - useListQuery, - useSearchParams, - useTranslation, -} from '@payloadcms/ui' -import React from 'react' - -export const VersionsViewClient: React.FC<{ - readonly baseClass: string - readonly columns: Column[] - readonly fetchURL: string - readonly paginationLimits?: SanitizedCollectionConfig['admin']['pagination']['limits'] -}> = (props) => { - const { baseClass, columns, paginationLimits } = props - - const { data, handlePageChange, handlePerPageChange } = useListQuery() - - const searchParams = useSearchParams() - const limit = searchParams.get('limit') - - const { i18n } = useTranslation() - - const versionCount = data?.totalDocs || 0 - - return ( - - - {versionCount === 0 && ( -
- {i18n.t('version:noFurtherVersionsFound')} -
- )} - {versionCount > 0 && ( - - -
- - {data?.totalDocs > 0 && ( - -
- {data.page * data.limit - (data.limit - 1)}- - {data.totalPages > 1 && data.totalPages !== data.page - ? data.limit * data.page - : data.totalDocs}{' '} - {i18n.t('general:of')} {data.totalDocs} -
- -
- )} -
- - )} - - ) -} +export { VersionsViewClient } from '@payloadcms/ui/views/Versions/index.client' diff --git a/packages/ui/package.json b/packages/ui/package.json index e3cadcc1e88..b7bfd56a2c8 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -86,6 +86,201 @@ "types": "./src/views/Document/renderDocumentSlots.tsx", "default": "./src/views/Document/renderDocumentSlots.tsx" }, + "./views/Version/fetchVersions": { + "import": "./src/views/Version/fetchVersions.ts", + "types": "./src/views/Version/fetchVersions.ts", + "default": "./src/views/Version/fetchVersions.ts" + }, + "./views/Version/RenderFieldsToDiff/DiffCollapser/index": { + "import": "./src/views/Version/RenderFieldsToDiff/DiffCollapser/index.tsx", + "types": "./src/views/Version/RenderFieldsToDiff/DiffCollapser/index.tsx", + "default": "./src/views/Version/RenderFieldsToDiff/DiffCollapser/index.tsx" + }, + "./views/Version/RenderFieldsToDiff/RenderVersionFieldsToDiff": { + "import": "./src/views/Version/RenderFieldsToDiff/RenderVersionFieldsToDiff.tsx", + "types": "./src/views/Version/RenderFieldsToDiff/RenderVersionFieldsToDiff.tsx", + "default": "./src/views/Version/RenderFieldsToDiff/RenderVersionFieldsToDiff.tsx" + }, + "./views/Version/RenderFieldsToDiff/buildVersionFields": { + "import": "./src/views/Version/RenderFieldsToDiff/buildVersionFields.tsx", + "types": "./src/views/Version/RenderFieldsToDiff/buildVersionFields.tsx", + "default": "./src/views/Version/RenderFieldsToDiff/buildVersionFields.tsx" + }, + "./views/Version/RenderFieldsToDiff/fields/Collapsible/index": { + "import": "./src/views/Version/RenderFieldsToDiff/fields/Collapsible/index.tsx", + "types": "./src/views/Version/RenderFieldsToDiff/fields/Collapsible/index.tsx", + "default": "./src/views/Version/RenderFieldsToDiff/fields/Collapsible/index.tsx" + }, + "./views/Version/RenderFieldsToDiff/fields/Date/index": { + "import": "./src/views/Version/RenderFieldsToDiff/fields/Date/index.tsx", + "types": "./src/views/Version/RenderFieldsToDiff/fields/Date/index.tsx", + "default": "./src/views/Version/RenderFieldsToDiff/fields/Date/index.tsx" + }, + "./views/Version/RenderFieldsToDiff/fields/Group/index": { + "import": "./src/views/Version/RenderFieldsToDiff/fields/Group/index.tsx", + "types": "./src/views/Version/RenderFieldsToDiff/fields/Group/index.tsx", + "default": "./src/views/Version/RenderFieldsToDiff/fields/Group/index.tsx" + }, + "./views/Version/RenderFieldsToDiff/fields/Iterable/index": { + "import": "./src/views/Version/RenderFieldsToDiff/fields/Iterable/index.tsx", + "types": "./src/views/Version/RenderFieldsToDiff/fields/Iterable/index.tsx", + "default": "./src/views/Version/RenderFieldsToDiff/fields/Iterable/index.tsx" + }, + "./views/Version/RenderFieldsToDiff/fields/Relationship/generateLabelFromValue": { + "import": "./src/views/Version/RenderFieldsToDiff/fields/Relationship/generateLabelFromValue.ts", + "types": "./src/views/Version/RenderFieldsToDiff/fields/Relationship/generateLabelFromValue.ts", + "default": "./src/views/Version/RenderFieldsToDiff/fields/Relationship/generateLabelFromValue.ts" + }, + "./views/Version/RenderFieldsToDiff/fields/Relationship/index": { + "import": "./src/views/Version/RenderFieldsToDiff/fields/Relationship/index.tsx", + "types": "./src/views/Version/RenderFieldsToDiff/fields/Relationship/index.tsx", + "default": "./src/views/Version/RenderFieldsToDiff/fields/Relationship/index.tsx" + }, + "./views/Version/RenderFieldsToDiff/fields/Row/index": { + "import": "./src/views/Version/RenderFieldsToDiff/fields/Row/index.tsx", + "types": "./src/views/Version/RenderFieldsToDiff/fields/Row/index.tsx", + "default": "./src/views/Version/RenderFieldsToDiff/fields/Row/index.tsx" + }, + "./views/Version/RenderFieldsToDiff/fields/Select/index": { + "import": "./src/views/Version/RenderFieldsToDiff/fields/Select/index.tsx", + "types": "./src/views/Version/RenderFieldsToDiff/fields/Select/index.tsx", + "default": "./src/views/Version/RenderFieldsToDiff/fields/Select/index.tsx" + }, + "./views/Version/RenderFieldsToDiff/fields/Tabs/index": { + "import": "./src/views/Version/RenderFieldsToDiff/fields/Tabs/index.tsx", + "types": "./src/views/Version/RenderFieldsToDiff/fields/Tabs/index.tsx", + "default": "./src/views/Version/RenderFieldsToDiff/fields/Tabs/index.tsx" + }, + "./views/Version/RenderFieldsToDiff/fields/Text/index": { + "import": "./src/views/Version/RenderFieldsToDiff/fields/Text/index.tsx", + "types": "./src/views/Version/RenderFieldsToDiff/fields/Text/index.tsx", + "default": "./src/views/Version/RenderFieldsToDiff/fields/Text/index.tsx" + }, + "./views/Version/RenderFieldsToDiff/fields/Upload/index": { + "import": "./src/views/Version/RenderFieldsToDiff/fields/Upload/index.tsx", + "types": "./src/views/Version/RenderFieldsToDiff/fields/Upload/index.tsx", + "default": "./src/views/Version/RenderFieldsToDiff/fields/Upload/index.tsx" + }, + "./views/Version/RenderFieldsToDiff/fields/index": { + "import": "./src/views/Version/RenderFieldsToDiff/fields/index.ts", + "types": "./src/views/Version/RenderFieldsToDiff/fields/index.ts", + "default": "./src/views/Version/RenderFieldsToDiff/fields/index.ts" + }, + "./views/Version/RenderFieldsToDiff/index": { + "import": "./src/views/Version/RenderFieldsToDiff/index.tsx", + "types": "./src/views/Version/RenderFieldsToDiff/index.tsx", + "default": "./src/views/Version/RenderFieldsToDiff/index.tsx" + }, + "./views/Version/RenderFieldsToDiff/utilities/countChangedFields": { + "import": "./src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.ts", + "types": "./src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.ts", + "default": "./src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.ts" + }, + "./views/Version/RenderFieldsToDiff/utilities/fieldHasChanges": { + "import": "./src/views/Version/RenderFieldsToDiff/utilities/fieldHasChanges.ts", + "types": "./src/views/Version/RenderFieldsToDiff/utilities/fieldHasChanges.ts", + "default": "./src/views/Version/RenderFieldsToDiff/utilities/fieldHasChanges.ts" + }, + "./views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison": { + "import": "./src/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison.ts", + "types": "./src/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison.ts", + "default": "./src/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison.ts" + }, + "./views/Version/SelectComparison/VersionDrawer/CreatedAtCell": { + "import": "./src/views/Version/SelectComparison/VersionDrawer/CreatedAtCell.tsx", + "types": "./src/views/Version/SelectComparison/VersionDrawer/CreatedAtCell.tsx", + "default": "./src/views/Version/SelectComparison/VersionDrawer/CreatedAtCell.tsx" + }, + "./views/Version/SelectComparison/VersionDrawer/index": { + "import": "./src/views/Version/SelectComparison/VersionDrawer/index.tsx", + "types": "./src/views/Version/SelectComparison/VersionDrawer/index.tsx", + "default": "./src/views/Version/SelectComparison/VersionDrawer/index.tsx" + }, + "./views/Version/SelectComparison/index": { + "import": "./src/views/Version/SelectComparison/index.tsx", + "types": "./src/views/Version/SelectComparison/index.tsx", + "default": "./src/views/Version/SelectComparison/index.tsx" + }, + "./views/Version/SelectComparison/types": { + "import": "./src/views/Version/SelectComparison/types.ts", + "types": "./src/views/Version/SelectComparison/types.ts", + "default": "./src/views/Version/SelectComparison/types.ts" + }, + "./views/Version/VersionPillLabel/VersionPillLabel": { + "import": "./src/views/Version/VersionPillLabel/VersionPillLabel.tsx", + "types": "./src/views/Version/VersionPillLabel/VersionPillLabel.tsx", + "default": "./src/views/Version/VersionPillLabel/VersionPillLabel.tsx" + }, + "./views/Version/VersionPillLabel/getVersionLabel": { + "import": "./src/views/Version/VersionPillLabel/getVersionLabel.ts", + "types": "./src/views/Version/VersionPillLabel/getVersionLabel.ts", + "default": "./src/views/Version/VersionPillLabel/getVersionLabel.ts" + }, + "./views/Version/Default/SelectedLocalesContext": { + "import": "./src/views/Version/Default/SelectedLocalesContext.tsx", + "types": "./src/views/Version/Default/SelectedLocalesContext.tsx", + "default": "./src/views/Version/Default/SelectedLocalesContext.tsx" + }, + "./views/Version/Default/SetStepNav": { + "import": "./src/views/Version/Default/SetStepNav.tsx", + "types": "./src/views/Version/Default/SetStepNav.tsx", + "default": "./src/views/Version/Default/SetStepNav.tsx" + }, + "./views/Version/Default/types": { + "import": "./src/views/Version/Default/types.ts", + "types": "./src/views/Version/Default/types.ts", + "default": "./src/views/Version/Default/types.ts" + }, + "./views/Versions/buildColumns": { + "import": "./src/views/Versions/buildColumns.tsx", + "types": "./src/views/Versions/buildColumns.tsx", + "default": "./src/views/Versions/buildColumns.tsx" + }, + "./views/Versions/index.client": { + "import": "./src/views/Versions/index.client.tsx", + "types": "./src/views/Versions/index.client.tsx", + "default": "./src/views/Versions/index.client.tsx" + }, + "./views/Versions/cells/AutosaveCell/index": { + "import": "./src/views/Versions/cells/AutosaveCell/index.tsx", + "types": "./src/views/Versions/cells/AutosaveCell/index.tsx", + "default": "./src/views/Versions/cells/AutosaveCell/index.tsx" + }, + "./views/Versions/cells/CreatedAt/index": { + "import": "./src/views/Versions/cells/CreatedAt/index.tsx", + "types": "./src/views/Versions/cells/CreatedAt/index.tsx", + "default": "./src/views/Versions/cells/CreatedAt/index.tsx" + }, + "./views/Versions/cells/ID/index": { + "import": "./src/views/Versions/cells/ID/index.tsx", + "types": "./src/views/Versions/cells/ID/index.tsx", + "default": "./src/views/Versions/cells/ID/index.tsx" + }, + "./views/Account/index.client": { + "import": "./src/views/Account/index.client.tsx", + "types": "./src/views/Account/index.client.tsx", + "default": "./src/views/Account/index.client.tsx" + }, + "./views/Account/ResetPreferences/index": { + "import": "./src/views/Account/ResetPreferences/index.tsx", + "types": "./src/views/Account/ResetPreferences/index.tsx", + "default": "./src/views/Account/ResetPreferences/index.tsx" + }, + "./views/Account/Settings/LanguageSelector": { + "import": "./src/views/Account/Settings/LanguageSelector.tsx", + "types": "./src/views/Account/Settings/LanguageSelector.tsx", + "default": "./src/views/Account/Settings/LanguageSelector.tsx" + }, + "./views/Account/Settings/index": { + "import": "./src/views/Account/Settings/index.tsx", + "types": "./src/views/Account/Settings/index.tsx", + "default": "./src/views/Account/Settings/index.tsx" + }, + "./views/Account/ToggleTheme/index": { + "import": "./src/views/Account/ToggleTheme/index.tsx", + "types": "./src/views/Account/ToggleTheme/index.tsx", + "default": "./src/views/Account/ToggleTheme/index.tsx" + }, "./views/List/createSerializableValue": { "import": "./src/views/List/createSerializableValue.ts", "types": "./src/views/List/createSerializableValue.ts", diff --git a/packages/ui/src/views/Account/ResetPreferences/index.tsx b/packages/ui/src/views/Account/ResetPreferences/index.tsx new file mode 100644 index 00000000000..50fbf2d6cca --- /dev/null +++ b/packages/ui/src/views/Account/ResetPreferences/index.tsx @@ -0,0 +1,89 @@ +'use client' +import type { TypedUser } from 'payload' + +import { formatAdminURL } from 'payload/shared' +import * as qs from 'qs-esm' +import { Fragment, useCallback } from 'react' +import { toast } from 'sonner' + +import { Button } from '../../../elements/Button/index.js' +import { ConfirmationModal } from '../../../elements/ConfirmationModal/index.js' +import { useModal } from '../../../elements/Modal/index.js' +import { useConfig } from '../../../providers/Config/index.js' +import { useTranslation } from '../../../providers/Translation/index.js' + +const confirmResetModalSlug = 'confirm-reset-modal' + +export const ResetPreferences: React.FC<{ + readonly user?: TypedUser +}> = ({ user }) => { + const { openModal } = useModal() + const { t } = useTranslation() + const { + config: { + routes: { api: apiRoute }, + }, + } = useConfig() + + const handleResetPreferences = useCallback(async () => { + if (!user) { + return + } + + const stringifiedQuery = qs.stringify( + { + depth: 0, + where: { + 'user.value': { + equals: user.id, + }, + }, + }, + { addQueryPrefix: true }, + ) + + try { + const res = await fetch( + formatAdminURL({ + apiRoute, + path: `/payload-preferences${stringifiedQuery}`, + }), + { + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + method: 'DELETE', + }, + ) + + const json = await res.json() + const message = json.message + + if (res.ok) { + toast.success(message) + } else { + toast.error(message) + } + } catch (_err) { + // swallow error + } + }, [apiRoute, user]) + + return ( + +
+ +
+ +
+ ) +} diff --git a/packages/ui/src/views/Account/Settings/LanguageSelector.tsx b/packages/ui/src/views/Account/Settings/LanguageSelector.tsx new file mode 100644 index 00000000000..a64f62fc368 --- /dev/null +++ b/packages/ui/src/views/Account/Settings/LanguageSelector.tsx @@ -0,0 +1,30 @@ +'use client' +import type { AcceptedLanguages } from '@payloadcms/translations' +import type { LanguageOptions } from 'payload' + +import React from 'react' + +import type { Option as ReactSelectOption } from '../../../elements/ReactSelect/index.js' + +import { ReactSelect } from '../../../elements/ReactSelect/index.js' +import { useTranslation } from '../../../providers/Translation/index.js' + +export const LanguageSelector: React.FC<{ + languageOptions: LanguageOptions +}> = (props) => { + const { languageOptions } = props + + const { i18n, switchLanguage } = useTranslation() + + return ( + ) => { + await switchLanguage(option.value) + }} + options={languageOptions} + value={languageOptions.find((language) => language.value === i18n.language)} + /> + ) +} diff --git a/packages/ui/src/views/Account/Settings/index.scss b/packages/ui/src/views/Account/Settings/index.scss new file mode 100644 index 00000000000..b7e01fcc562 --- /dev/null +++ b/packages/ui/src/views/Account/Settings/index.scss @@ -0,0 +1,48 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .payload-settings { + position: relative; + margin-bottom: calc(var(--base) * 2); + + h3 { + margin: 0; + } + + &::before, + &::after { + content: ''; + display: block; + height: 1px; + background: var(--theme-elevation-100); + width: calc(100% + calc(var(--base) * 5)); + left: calc(var(--gutter-h) * -1); + top: 0; + position: absolute; + } + + &::after { + display: none; + bottom: 0; + top: unset; + } + + margin-top: calc(var(--base) * 3); + padding-top: calc(var(--base) * 3); + padding-bottom: calc(var(--base) * 3); + display: flex; + flex-direction: column; + gap: var(--base); + + @include mid-break { + margin-bottom: var(--base); + padding-top: calc(var(--base) * 2); + margin-top: calc(var(--base) * 2); + padding-bottom: calc(var(--base) * 2); + + &::after { + display: block; + } + } + } +} diff --git a/packages/ui/src/views/Account/Settings/index.tsx b/packages/ui/src/views/Account/Settings/index.tsx new file mode 100644 index 00000000000..9690b128d08 --- /dev/null +++ b/packages/ui/src/views/Account/Settings/index.tsx @@ -0,0 +1,35 @@ +import type { I18n } from '@payloadcms/translations' +import type { BasePayload, Config, LanguageOptions, TypedUser } from 'payload' + +import React from 'react' + +import { FieldLabel } from '../../../fields/FieldLabel/index.js' +import { ResetPreferences } from '../ResetPreferences/index.js' +import './index.scss' +import { ToggleTheme } from '../ToggleTheme/index.js' +import { LanguageSelector } from './LanguageSelector.js' + +const baseClass = 'payload-settings' + +export const Settings: React.FC<{ + readonly className?: string + readonly i18n: I18n + readonly languageOptions: LanguageOptions + readonly payload: BasePayload + readonly theme: Config['admin']['theme'] + readonly user?: TypedUser +}> = (props) => { + const { className, i18n, languageOptions, theme, user } = props + + return ( +
+

{i18n.t('general:payloadSettings')}

+
+ + +
+ {theme === 'all' && } + +
+ ) +} diff --git a/packages/ui/src/views/Account/ToggleTheme/index.tsx b/packages/ui/src/views/Account/ToggleTheme/index.tsx new file mode 100644 index 00000000000..d5c1c22f73b --- /dev/null +++ b/packages/ui/src/views/Account/ToggleTheme/index.tsx @@ -0,0 +1,46 @@ +'use client' + +import React, { useCallback } from 'react' + +import { RadioGroupField } from '../../../fields/RadioGroup/index.js' +import { useTheme } from '../../../providers/Theme/index.js' +import { useTranslation } from '../../../providers/Translation/index.js' + +export const ToggleTheme: React.FC = () => { + const { autoMode, setTheme, theme } = useTheme() + const { t } = useTranslation() + + const onChange = useCallback( + (newTheme) => { + setTheme(newTheme) + }, + [setTheme], + ) + + return ( + + ) +} diff --git a/packages/ui/src/views/Account/index.client.tsx b/packages/ui/src/views/Account/index.client.tsx new file mode 100644 index 00000000000..764c1ee4174 --- /dev/null +++ b/packages/ui/src/views/Account/index.client.tsx @@ -0,0 +1,23 @@ +'use client' +import React from 'react' + +import { type StepNavItem, useStepNav } from '../../elements/StepNav/index.js' +import { useTranslation } from '../../providers/Translation/index.js' + +export const AccountClient: React.FC = () => { + const { setStepNav } = useStepNav() + const { t } = useTranslation() + + React.useEffect(() => { + const nav: StepNavItem[] = [] + + nav.push({ + label: t('authentication:account'), + url: '/account', + }) + + setStepNav(nav) + }, [setStepNav, t]) + + return null +} diff --git a/packages/ui/src/views/Version/Default/SelectedLocalesContext.tsx b/packages/ui/src/views/Version/Default/SelectedLocalesContext.tsx new file mode 100644 index 00000000000..2913b82a64e --- /dev/null +++ b/packages/ui/src/views/Version/Default/SelectedLocalesContext.tsx @@ -0,0 +1,13 @@ +'use client' + +import { createContext, use } from 'react' + +type SelectedLocalesContextType = { + selectedLocales: string[] +} + +export const SelectedLocalesContext = createContext({ + selectedLocales: [], +}) + +export const useSelectedLocales = () => use(SelectedLocalesContext) diff --git a/packages/ui/src/views/Version/Default/SetStepNav.tsx b/packages/ui/src/views/Version/Default/SetStepNav.tsx new file mode 100644 index 00000000000..fd8825e7a26 --- /dev/null +++ b/packages/ui/src/views/Version/Default/SetStepNav.tsx @@ -0,0 +1,136 @@ +'use client' + +import type { ClientCollectionConfig, ClientGlobalConfig } from 'payload' +import type React from 'react' + +import { getTranslation } from '@payloadcms/translations' +import { formatAdminURL } from 'payload/shared' +import { useEffect } from 'react' + +import { useStepNav } from '../../../elements/StepNav/index.js' +import { useConfig } from '../../../providers/Config/index.js' +import { useDocumentTitle } from '../../../providers/DocumentTitle/index.js' +import { useLocale } from '../../../providers/Locale/index.js' +import { useTranslation } from '../../../providers/Translation/index.js' + +export const SetStepNav: React.FC<{ + readonly collectionConfig?: ClientCollectionConfig + readonly globalConfig?: ClientGlobalConfig + readonly id?: number | string + readonly isTrashed?: boolean + versionToCreatedAtFormatted?: string + versionToID?: string +}> = ({ + id, + collectionConfig, + globalConfig, + isTrashed, + versionToCreatedAtFormatted, + versionToID, +}) => { + const { config } = useConfig() + const { setStepNav } = useStepNav() + const { i18n, t } = useTranslation() + const locale = useLocale() + const { title } = useDocumentTitle() + + useEffect(() => { + const { + routes: { admin: adminRoute }, + serverURL, + } = config + + if (collectionConfig) { + const collectionSlug = collectionConfig.slug + + const pluralLabel = collectionConfig.labels?.plural + + const docBasePath: `/${string}` = isTrashed + ? `/collections/${collectionSlug}/trash/${id}` + : `/collections/${collectionSlug}/${id}` + + const nav = [ + { + label: getTranslation(pluralLabel, i18n), + url: formatAdminURL({ + adminRoute, + path: `/collections/${collectionSlug}`, + }), + }, + ] + + if (isTrashed) { + nav.push({ + label: t('general:trash'), + url: formatAdminURL({ + adminRoute, + path: `/collections/${collectionSlug}/trash`, + }), + }) + } + + nav.push( + { + label: title, + url: formatAdminURL({ + adminRoute, + path: docBasePath, + }), + }, + { + label: t('version:versions'), + url: formatAdminURL({ + adminRoute, + path: `${docBasePath}/versions`, + }), + }, + { + label: versionToCreatedAtFormatted, + url: undefined, + }, + ) + + setStepNav(nav) + return + } + + if (globalConfig) { + const globalSlug = globalConfig.slug + + setStepNav([ + { + label: globalConfig.label, + url: formatAdminURL({ + adminRoute, + path: `/globals/${globalSlug}`, + }), + }, + { + label: t('version:versions'), + url: formatAdminURL({ + adminRoute, + path: `/globals/${globalSlug}/versions`, + }), + }, + { + label: versionToCreatedAtFormatted, + }, + ]) + } + }, [ + config, + setStepNav, + id, + isTrashed, + locale, + t, + i18n, + collectionConfig, + globalConfig, + title, + versionToCreatedAtFormatted, + versionToID, + ]) + + return null +} diff --git a/packages/ui/src/views/Version/Default/index.scss b/packages/ui/src/views/Version/Default/index.scss new file mode 100644 index 00000000000..f9a3f23f7ad --- /dev/null +++ b/packages/ui/src/views/Version/Default/index.scss @@ -0,0 +1,170 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .view-version { + width: 100%; + padding-bottom: var(--spacing-view-bottom); + + &__toggle-locales-label { + color: var(--theme-elevation-500); + } + + &-controls-top { + border-bottom: 1px solid var(--theme-elevation-100); + padding: 16px var(--gutter-h) 16px var(--gutter-h); + + &__wrapper { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + &-actions { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--base); + } + } + + h2 { + font-size: 18px; + } + } + + &-controls-bottom { + border-bottom: 1px solid var(--theme-elevation-100); + padding: 16px var(--gutter-h) 16px var(--gutter-h); + position: relative; + + // Vertical separator line + &::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 1px; + background-color: var(--theme-elevation-100); + transform: translateX(-50%); // Center the line + } + + &__wrapper { + display: grid; + grid-template-columns: 1fr 1fr; + grid-gap: var(--base); + gap: var(--base); + } + } + + &__time-elapsed { + color: var(--theme-elevation-500); + } + + &__version-from { + display: flex; + flex-direction: column; + gap: 5px; + + &-labels { + display: flex; + flex-direction: row; + justify-content: space-between; + } + } + + &__version-to { + display: flex; + flex-direction: column; + gap: 5px; + + &-labels { + display: flex; + flex-direction: row; + justify-content: space-between; + } + + &-version { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + background: var(--theme-elevation-50); + padding: 8px 12px; + gap: calc(var(--base) / 2); + + h2 { + font-size: 13px; + font-weight: 400; + } + } + } + + &__restore { + div { + margin-block: 0; + } + } + + &__modifiedCheckBox { + margin: 0 0 0 var(--base); + display: flex; + align-items: center; + } + + &__diff-wrap { + padding-top: var(--base); + display: flex; + flex-direction: column; + gap: var(--base); + position: relative; + + // Vertical separator line + &::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 1px; + background-color: var(--theme-elevation-100); + transform: translateX(-50%); // Center the line + z-index: 2; + } + } + + @include mid-break { + &__version-to { + &-version { + flex-direction: column; + align-items: flex-start; + } + } + } + + @include small-break { + &__diff-wrap { + padding-top: calc(var(--base) / 2); + } + + &__version-to, + &__version-from { + &-labels { + flex-direction: column; + align-items: flex-start; + } + } + + &-controls-top { + &__wrapper { + flex-direction: column; + align-items: flex-start; + + .view-version__modifiedCheckBox { + margin-left: 0; + } + } + } + } + } +} diff --git a/packages/ui/src/views/Version/Default/types.ts b/packages/ui/src/views/Version/Default/types.ts new file mode 100644 index 00000000000..5017ff2d097 --- /dev/null +++ b/packages/ui/src/views/Version/Default/types.ts @@ -0,0 +1,24 @@ +export type CompareOption = { + label: React.ReactNode | string + value: string +} + +export type VersionPill = { + id: string + Label: React.ReactNode +} + +export type DefaultVersionsViewProps = { + canUpdate: boolean + modifiedOnly: boolean + RenderedDiff: React.ReactNode + selectedLocales: string[] + versionFromCreatedAt?: string + versionFromID?: string + versionFromOptions: CompareOption[] + versionToCreatedAt?: string + versionToCreatedAtFormatted: string + VersionToCreatedAtLabel: React.ReactNode + versionToID?: string + versionToStatus?: string +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/DiffCollapser/index.scss b/packages/ui/src/views/Version/RenderFieldsToDiff/DiffCollapser/index.scss new file mode 100644 index 00000000000..d73cac43a46 --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/DiffCollapser/index.scss @@ -0,0 +1,81 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .diff-collapser { + &__toggle-button { + all: unset; + cursor: pointer; + position: relative; + z-index: 1; + display: flex; + align-items: center; + + .icon { + color: var(--theme-elevation-500); + } + + &:hover { + // Apply background color but with padding, thus we use after + &::before { + content: ''; + position: absolute; + top: -(base(0.15)); + left: -(base(0.15)); + right: -(base(0.15)); + bottom: -(base(0.15)); + background-color: var(--theme-elevation-50); + border-radius: var(--style-radius-s); + z-index: -1; + } + + .iterable-diff__label { + background-color: var(--theme-elevation-50); + z-index: 1; + } + } + } + + &__label { + // Add space between label, chevron, and change count + margin: 0 calc(var(--base) * 0.3) 0 0; + display: inline-flex; + height: 100%; + } + + &__field-change-count { + // Reset the font weight of the change count to normal + font-weight: normal; + margin-left: calc(var(--base) * 0.3); + padding: calc(var(--base) * 0.1) calc(var(--base) * 0.2); + background: var(--theme-elevation-100); + border-radius: var(--style-radius-s); + font-size: 0.8rem; + } + + &__content:not(.diff-collapser__content--hide-gutter) { + [dir='ltr'] & { + // Vertical gutter + border-left: 2px solid var(--theme-elevation-100); + // Center-align the gutter with the chevron + margin-left: 3px; + // Content indentation + padding-left: calc(var(--base) * 0.5); + } + [dir='rtl'] & { + // Vertical gutter + border-right: 2px solid var(--theme-elevation-100); + // Center-align the gutter with the chevron + margin-right: 3px; + // Content indentation + padding-right: calc(var(--base) * 0.5); + } + } + + &__content--is-collapsed { + // Hide the content when collapsed. We use display: none instead of + // conditional rendering to avoid loosing children's collapsed state when + // remounting. + display: none; + } + } +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/DiffCollapser/index.tsx b/packages/ui/src/views/Version/RenderFieldsToDiff/DiffCollapser/index.tsx new file mode 100644 index 00000000000..c7e8d5b1570 --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/DiffCollapser/index.tsx @@ -0,0 +1,125 @@ +'use client' +import type { ClientField } from 'payload' + +import { fieldIsArrayType, fieldIsBlockType } from 'payload/shared' +import React, { useState } from 'react' + +import { FieldDiffLabel } from '../../../../elements/FieldDiffLabel/index.js' +import { ChevronIcon } from '../../../../icons/Chevron/index.js' +import { useConfig } from '../../../../providers/Config/index.js' +import { useTranslation } from '../../../../providers/Translation/index.js' +import './index.scss' +import { countChangedFields, countChangedFieldsInRows } from '../utilities/countChangedFields.js' + +const baseClass = 'diff-collapser' + +type Props = { + hideGutter?: boolean + initCollapsed?: boolean + Label: React.ReactNode + locales: string[] | undefined + parentIsLocalized: boolean + valueTo: unknown +} & ( + | { + // fields collapser + children: React.ReactNode + field?: never + fields: ClientField[] + isIterable?: false + valueFrom: unknown + } + | { + // iterable collapser + children: React.ReactNode + field: ClientField + fields?: never + isIterable: true + valueFrom?: unknown + } +) + +export const DiffCollapser: React.FC = ({ + children, + field, + fields, + hideGutter = false, + initCollapsed = false, + isIterable = false, + Label, + locales, + parentIsLocalized, + valueFrom, + valueTo, +}) => { + const { t } = useTranslation() + const [isCollapsed, setIsCollapsed] = useState(initCollapsed) + const { config } = useConfig() + + let changeCount = 0 + + if (isIterable) { + if (!fieldIsArrayType(field) && !fieldIsBlockType(field)) { + throw new Error( + 'DiffCollapser: field must be an array or blocks field when isIterable is true', + ) + } + const valueFromRows = valueFrom ?? [] + const valueToRows = valueTo ?? [] + + if (!Array.isArray(valueFromRows) || !Array.isArray(valueToRows)) { + throw new Error( + 'DiffCollapser: valueFrom and valueTro must be arrays when isIterable is true', + ) + } + + changeCount = countChangedFieldsInRows({ + config, + field, + locales, + parentIsLocalized, + valueFromRows, + valueToRows, + }) + } else { + changeCount = countChangedFields({ + config, + fields, + locales, + parentIsLocalized, + valueFrom, + valueTo, + }) + } + + const contentClassNames = [ + `${baseClass}__content`, + isCollapsed && `${baseClass}__content--is-collapsed`, + hideGutter && `${baseClass}__content--hide-gutter`, + ] + .filter(Boolean) + .join(' ') + + return ( +
+ + + {changeCount > 0 && isCollapsed && ( + + {t('version:changedFieldsCount', { count: changeCount })} + + )} + +
{children}
+
+ ) +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/RenderVersionFieldsToDiff.tsx b/packages/ui/src/views/Version/RenderFieldsToDiff/RenderVersionFieldsToDiff.tsx new file mode 100644 index 00000000000..87955d488af --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/RenderVersionFieldsToDiff.tsx @@ -0,0 +1,74 @@ +'use client' +const baseClass = 'render-field-diffs' +import type { VersionField } from 'payload' + +import './index.scss' + +import React, { Fragment, useEffect } from 'react' + +import { ShimmerEffect } from '../../../elements/ShimmerEffect/index.js' + +export const RenderVersionFieldsToDiff = ({ + parent = false, + versionFields, +}: { + /** + * If true, this is the parent render version fields component, not one nested in + * a field with children (e.g. group) + */ + parent?: boolean + versionFields: VersionField[] +}): React.ReactNode => { + const [hasMounted, setHasMounted] = React.useState(false) + + // defer rendering until after the first mount as the CSS is loaded with Emotion + // this will ensure that the CSS is loaded before rendering the diffs and prevent CLS + useEffect(() => { + setHasMounted(true) + }, []) + + return ( +
+ {!hasMounted ? ( + + + + ) : ( + versionFields?.map((field, fieldIndex) => { + if (field.fieldByLocale) { + const LocaleComponents: React.ReactNode[] = [] + for (const [locale, baseField] of Object.entries(field.fieldByLocale)) { + LocaleComponents.push( +
+
{baseField.CustomComponent}
+
, + ) + } + return ( +
+ {LocaleComponents} +
+ ) + } else if (field.field) { + return ( +
+ {field.field.CustomComponent} +
+ ) + } + + return null + }) + )} +
+ ) +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/buildVersionFields.tsx b/packages/ui/src/views/Version/RenderFieldsToDiff/buildVersionFields.tsx new file mode 100644 index 00000000000..83771fd2197 --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/buildVersionFields.tsx @@ -0,0 +1,565 @@ +import type { I18nClient } from '@payloadcms/translations' + +import { dequal } from 'dequal/lite' +import { + type BaseVersionField, + type ClientField, + type ClientFieldSchemaMap, + type Field, + type FieldDiffClientProps, + type FieldDiffServerProps, + type FieldTypes, + type FlattenedBlock, + MissingEditorProp, + type PayloadComponent, + type PayloadRequest, + type SanitizedFieldPermissions, + type SanitizedFieldsPermissions, + type VersionField, +} from 'payload' +import { + fieldIsID, + fieldShouldBeLocalized, + getFieldPaths, + getUniqueListBy, + tabHasName, +} from 'payload/shared' + +import { RenderServerComponent } from '../../../elements/RenderServerComponent/index.js' +import { diffComponents } from './fields/index.js' + +export type BuildVersionFieldsArgs = { + clientSchemaMap: ClientFieldSchemaMap + customDiffComponents: Partial< + Record> + > + entitySlug: string + fields: Field[] + fieldsPermissions: SanitizedFieldsPermissions + i18n: I18nClient + modifiedOnly: boolean + nestingLevel?: number + parentIndexPath: string + parentIsLocalized: boolean + parentPath: string + parentSchemaPath: string + req: PayloadRequest + selectedLocales: string[] + versionFromSiblingData: object + versionToSiblingData: object +} + +/** + * Build up an object that contains rendered diff components for each field. + * This is then sent to the client to be rendered. + * + * Here, the server is responsible for traversing through the document data and building up this + * version state object. + */ +export const buildVersionFields = ({ + clientSchemaMap, + customDiffComponents, + entitySlug, + fields, + fieldsPermissions, + i18n, + modifiedOnly, + nestingLevel = 0, + parentIndexPath, + parentIsLocalized, + parentPath, + parentSchemaPath, + req, + selectedLocales, + versionFromSiblingData, + versionToSiblingData, +}: BuildVersionFieldsArgs): { + versionFields: VersionField[] +} => { + const versionFields: VersionField[] = [] + let fieldIndex = -1 + + for (const field of fields) { + fieldIndex++ + + if (fieldIsID(field)) { + continue + } + + const { indexPath, path, schemaPath } = getFieldPaths({ + field, + index: fieldIndex, + parentIndexPath, + parentPath, + parentSchemaPath, + }) + + const clientField = clientSchemaMap.get(entitySlug + '.' + schemaPath) + + if (!clientField) { + req.payload.logger.error({ + clientFieldKey: entitySlug + '.' + schemaPath, + clientSchemaMapKeys: Array.from(clientSchemaMap.keys()), + msg: 'No client field found for ' + entitySlug + '.' + schemaPath, + parentPath, + parentSchemaPath, + path, + schemaPath, + }) + throw new Error('No client field found for ' + entitySlug + '.' + schemaPath) + } + + const versionField: VersionField = {} + const isLocalized = fieldShouldBeLocalized({ field, parentIsLocalized }) + + const fieldName: null | string = 'name' in field ? field.name : null + + const valueFrom = fieldName ? versionFromSiblingData?.[fieldName] : versionFromSiblingData + const valueTo = fieldName ? versionToSiblingData?.[fieldName] : versionToSiblingData + + if (isLocalized) { + versionField.fieldByLocale = {} + + for (const locale of selectedLocales) { + const localizedVersionField = buildVersionField({ + clientField: clientField as ClientField, + clientSchemaMap, + customDiffComponents, + entitySlug, + field, + i18n, + indexPath, + locale, + modifiedOnly, + nestingLevel, + parentFieldsPermissions: fieldsPermissions, + parentIsLocalized: true, + parentPath, + parentSchemaPath, + path, + req, + schemaPath, + selectedLocales, + valueFrom: valueFrom?.[locale], + valueTo: valueTo?.[locale], + }) + if (localizedVersionField) { + versionField.fieldByLocale[locale] = localizedVersionField + } + } + } else { + const baseVersionField = buildVersionField({ + clientField: clientField as ClientField, + clientSchemaMap, + customDiffComponents, + entitySlug, + field, + i18n, + indexPath, + modifiedOnly, + nestingLevel, + parentFieldsPermissions: fieldsPermissions, + parentIsLocalized: parentIsLocalized || ('localized' in field && field.localized), + parentPath, + parentSchemaPath, + path, + req, + schemaPath, + selectedLocales, + valueFrom, + valueTo, + }) + + if (baseVersionField) { + versionField.field = baseVersionField + } + } + + if ( + versionField.field || + (versionField.fieldByLocale && Object.keys(versionField.fieldByLocale).length) + ) { + versionFields.push(versionField) + } + } + + return { + versionFields, + } +} + +const buildVersionField = ({ + clientField, + clientSchemaMap, + customDiffComponents, + entitySlug, + field, + i18n, + indexPath, + locale, + modifiedOnly, + nestingLevel, + parentFieldsPermissions, + parentIsLocalized, + parentPath, + parentSchemaPath, + path, + req, + schemaPath, + selectedLocales, + valueFrom, + valueTo, +}: { + clientField: ClientField + field: Field + indexPath: string + locale?: string + modifiedOnly?: boolean + nestingLevel: number + parentFieldsPermissions: SanitizedFieldsPermissions + parentIsLocalized: boolean + path: string + schemaPath: string + valueFrom: unknown + valueTo: unknown +} & Omit< + BuildVersionFieldsArgs, + | 'fields' + | 'fieldsPermissions' + | 'parentIndexPath' + | 'versionFromSiblingData' + | 'versionToSiblingData' +>): BaseVersionField | null => { + let hasReadPermission: boolean = false + let fieldPermissions: SanitizedFieldPermissions | undefined = undefined + + if (typeof parentFieldsPermissions === 'boolean') { + hasReadPermission = parentFieldsPermissions + fieldPermissions = parentFieldsPermissions + } else { + if ('name' in field) { + fieldPermissions = parentFieldsPermissions?.[field.name] + if (typeof fieldPermissions === 'boolean') { + hasReadPermission = fieldPermissions + } else if (typeof fieldPermissions?.read === 'boolean') { + hasReadPermission = fieldPermissions.read + } + } else { + // If the field is unnamed and parentFieldsPermissions is an object, its sub-fields will decide their read permissions state. + // As far as this field is concerned, we are allowed to read it, as we need to reach its sub-fields to determine their read permissions. + hasReadPermission = true + } + } + + if (!hasReadPermission) { + // HasReadPermission is only valid if the field has a name. E.g. for a tabs field it would incorrectly return `false`. + return null + } + + if (modifiedOnly && dequal(valueFrom, valueTo)) { + return null + } + + let CustomComponent = customDiffComponents?.[field.type] + if (field?.type === 'richText') { + if (!field?.editor) { + throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor + } + + if (typeof field?.editor === 'function') { + throw new Error('Attempted to access unsanitized rich text editor.') + } + + if (field.editor.CellComponent) { + CustomComponent = field.editor.DiffComponent + } + } + if (field?.admin?.components?.Diff) { + CustomComponent = field.admin.components.Diff + } + + const DefaultComponent = diffComponents?.[field.type] + + const baseVersionField: BaseVersionField = { + type: field.type, + fields: [], + path, + schemaPath, + } + + if (field.type === 'tabs' && 'tabs' in field) { + baseVersionField.tabs = [] + let tabIndex = -1 + for (const tab of field.tabs) { + tabIndex++ + const isNamedTab = tabHasName(tab) + + const tabAsField = { ...tab, type: 'tab' } + + const { + indexPath: tabIndexPath, + path: tabPath, + schemaPath: tabSchemaPath, + } = getFieldPaths({ + field: tabAsField, + index: tabIndex, + parentIndexPath: indexPath, + parentPath: path, + parentSchemaPath: schemaPath, + }) + + let tabFieldsPermissions: SanitizedFieldsPermissions = undefined + + // The tabs field does not have its own permissions as it's unnamed => use parentFieldsPermissions + if (typeof parentFieldsPermissions === 'boolean') { + tabFieldsPermissions = parentFieldsPermissions + } else { + if ('name' in tab) { + const tabPermissions = parentFieldsPermissions?.[tab.name] + if (typeof tabPermissions === 'boolean') { + tabFieldsPermissions = tabPermissions + } else { + tabFieldsPermissions = tabPermissions?.fields + } + } else { + tabFieldsPermissions = parentFieldsPermissions + } + } + + const tabVersion = { + name: 'name' in tab ? tab.name : null, + fields: buildVersionFields({ + clientSchemaMap, + customDiffComponents, + entitySlug, + fields: tab.fields, + fieldsPermissions: tabFieldsPermissions, + i18n, + modifiedOnly, + nestingLevel: nestingLevel + 1, + parentIndexPath: isNamedTab ? '' : tabIndexPath, + parentIsLocalized: parentIsLocalized || tab.localized, + parentPath: isNamedTab ? tabPath : 'name' in field ? path : parentPath, + parentSchemaPath: tabSchemaPath, + req, + selectedLocales, + versionFromSiblingData: 'name' in tab ? valueFrom?.[tab.name] : valueFrom, + versionToSiblingData: 'name' in tab ? valueTo?.[tab.name] : valueTo, + }).versionFields, + label: typeof tab.label === 'function' ? tab.label({ i18n, t: i18n.t }) : tab.label, + } + if (tabVersion?.fields?.length) { + baseVersionField.tabs.push(tabVersion) + } + } + + if (modifiedOnly && !baseVersionField.tabs.length) { + return null + } + } // At this point, we are dealing with a `row`, `collapsible`, array`, etc + else if ('fields' in field) { + let subFieldsPermissions: SanitizedFieldsPermissions = undefined + + if ('name' in field && typeof fieldPermissions !== 'undefined') { + // Named fields like arrays + subFieldsPermissions = + typeof fieldPermissions === 'boolean' ? fieldPermissions : fieldPermissions.fields + } else { + // Unnamed fields like collapsible and row inherit directly from parent permissions + subFieldsPermissions = parentFieldsPermissions + } + + if (field.type === 'array' && (valueTo || valueFrom)) { + const maxLength = Math.max( + Array.isArray(valueTo) ? valueTo.length : 0, + Array.isArray(valueFrom) ? valueFrom.length : 0, + ) + baseVersionField.rows = [] + + for (let i = 0; i < maxLength; i++) { + const fromRow = (Array.isArray(valueFrom) && valueFrom?.[i]) || {} + const toRow = (Array.isArray(valueTo) && valueTo?.[i]) || {} + + const versionFields = buildVersionFields({ + clientSchemaMap, + customDiffComponents, + entitySlug, + fields: field.fields, + fieldsPermissions: subFieldsPermissions, + i18n, + modifiedOnly, + nestingLevel: nestingLevel + 1, + parentIndexPath: 'name' in field ? '' : indexPath, + parentIsLocalized: parentIsLocalized || field.localized, + parentPath: ('name' in field ? path : parentPath) + '.' + i, + parentSchemaPath: schemaPath, + req, + selectedLocales, + versionFromSiblingData: fromRow, + versionToSiblingData: toRow, + }).versionFields + + if (versionFields?.length) { + baseVersionField.rows[i] = versionFields + } + } + + if (!baseVersionField.rows?.length && modifiedOnly) { + return null + } + } else { + baseVersionField.fields = buildVersionFields({ + clientSchemaMap, + customDiffComponents, + entitySlug, + fields: field.fields, + fieldsPermissions: subFieldsPermissions, + i18n, + modifiedOnly, + nestingLevel: field.type !== 'row' ? nestingLevel + 1 : nestingLevel, + parentIndexPath: 'name' in field ? '' : indexPath, + parentIsLocalized: parentIsLocalized || ('localized' in field && field.localized), + parentPath: 'name' in field ? path : parentPath, + parentSchemaPath: schemaPath, + req, + selectedLocales, + versionFromSiblingData: valueFrom as object, + versionToSiblingData: valueTo as object, + }).versionFields + + if (modifiedOnly && !baseVersionField.fields?.length) { + return null + } + } + } else if (field.type === 'blocks') { + baseVersionField.rows = [] + + const maxLength = Math.max( + Array.isArray(valueTo) ? valueTo.length : 0, + Array.isArray(valueFrom) ? valueFrom.length : 0, + ) + + for (let i = 0; i < maxLength; i++) { + const fromRow = (Array.isArray(valueFrom) && valueFrom?.[i]) || {} + const toRow = (Array.isArray(valueTo) && valueTo?.[i]) || {} + + const blockSlugToMatch: string = toRow?.blockType ?? fromRow?.blockType + const toBlock = + req.payload.blocks[blockSlugToMatch] ?? + ((field.blockReferences ?? field.blocks).find( + (block) => typeof block !== 'string' && block.slug === blockSlugToMatch, + ) as FlattenedBlock | undefined) + + let fields = [] + + if (toRow.blockType === fromRow.blockType) { + fields = toBlock.fields + } else { + const fromBlockSlugToMatch: string = toRow?.blockType ?? fromRow?.blockType + + const fromBlock = + req.payload.blocks[fromBlockSlugToMatch] ?? + ((field.blockReferences ?? field.blocks).find( + (block) => typeof block !== 'string' && block.slug === fromBlockSlugToMatch, + ) as FlattenedBlock | undefined) + + if (fromBlock) { + fields = getUniqueListBy([...toBlock.fields, ...fromBlock.fields], 'name') + } else { + fields = toBlock.fields + } + } + + let blockFieldsPermissions: SanitizedFieldsPermissions = undefined + + // fieldPermissions will be set here, as the blocks field has a name + if (typeof fieldPermissions === 'boolean') { + blockFieldsPermissions = fieldPermissions + } else if (typeof fieldPermissions?.blocks === 'boolean') { + blockFieldsPermissions = fieldPermissions.blocks + } else { + const permissionsBlockSpecific = fieldPermissions?.blocks?.[blockSlugToMatch] + if (typeof permissionsBlockSpecific === 'boolean') { + blockFieldsPermissions = permissionsBlockSpecific + } else { + blockFieldsPermissions = permissionsBlockSpecific?.fields + } + } + + const versionFields = buildVersionFields({ + clientSchemaMap, + customDiffComponents, + entitySlug, + fields, + fieldsPermissions: blockFieldsPermissions, + i18n, + modifiedOnly, + nestingLevel: nestingLevel + 1, + parentIndexPath: 'name' in field ? '' : indexPath, + parentIsLocalized: parentIsLocalized || ('localized' in field && field.localized), + parentPath: ('name' in field ? path : parentPath) + '.' + i, + parentSchemaPath: schemaPath + '.' + toBlock.slug, + req, + selectedLocales, + versionFromSiblingData: fromRow, + versionToSiblingData: toRow, + }).versionFields + + if (versionFields?.length) { + baseVersionField.rows[i] = versionFields + } + } + + if (!baseVersionField.rows?.length && modifiedOnly) { + return null + } + } + + const clientDiffProps: FieldDiffClientProps = { + baseVersionField: { + ...baseVersionField, + CustomComponent: undefined, + }, + /** + * TODO: Change to valueFrom in 4.0 + */ + comparisonValue: valueFrom, + /** + * @deprecated remove in 4.0. Each field should handle its own diffing logic + */ + diffMethod: 'diffWordsWithSpace', + field: clientField, + fieldPermissions: + typeof fieldPermissions === 'undefined' ? parentFieldsPermissions : fieldPermissions, + parentIsLocalized, + + nestingLevel: nestingLevel ? nestingLevel : undefined, + /** + * TODO: Change to valueTo in 4.0 + */ + versionValue: valueTo, + } + if (locale) { + clientDiffProps.locale = locale + } + + const serverDiffProps: FieldDiffServerProps = { + ...clientDiffProps, + clientField, + field, + i18n, + req, + selectedLocales, + } + + baseVersionField.CustomComponent = RenderServerComponent({ + clientProps: clientDiffProps, + Component: CustomComponent, + Fallback: DefaultComponent, + importMap: req.payload.importMap, + key: 'diff component', + serverProps: serverDiffProps, + }) + + return baseVersionField +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Collapsible/index.tsx b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Collapsible/index.tsx new file mode 100644 index 00000000000..4ea57478baa --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Collapsible/index.tsx @@ -0,0 +1,46 @@ +'use client' +import type { CollapsibleFieldDiffClientComponent } from 'payload' + +import { getTranslation } from '@payloadcms/translations' +import React from 'react' + +import { useTranslation } from '../../../../../providers/Translation/index.js' +import { useSelectedLocales } from '../../../Default/SelectedLocalesContext.js' +import { DiffCollapser } from '../../DiffCollapser/index.js' +import { RenderVersionFieldsToDiff } from '../../RenderVersionFieldsToDiff.js' + +const baseClass = 'collapsible-diff' + +export const Collapsible: CollapsibleFieldDiffClientComponent = ({ + baseVersionField, + comparisonValue: valueFrom, + field, + parentIsLocalized, + versionValue: valueTo, +}) => { + const { i18n } = useTranslation() + const { selectedLocales } = useSelectedLocales() + + if (!baseVersionField.fields?.length) { + return null + } + + return ( +
+ {getTranslation(field.label, i18n)} + } + locales={selectedLocales} + parentIsLocalized={parentIsLocalized || field.localized} + valueFrom={valueFrom} + valueTo={valueTo} + > + + +
+ ) +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Date/index.scss b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Date/index.scss new file mode 100644 index 00000000000..3b41515ca11 --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Date/index.scss @@ -0,0 +1,12 @@ +@layer payload-default { + .date-diff { + p *[data-match-type='delete'] { + color: unset !important; + background-color: unset !important; + } + p *[data-match-type='create'] { + color: unset !important; + background-color: unset !important; + } + } +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Date/index.tsx b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Date/index.tsx new file mode 100644 index 00000000000..21e2fa60bff --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Date/index.tsx @@ -0,0 +1,78 @@ +'use client' +import type { DateFieldDiffClientComponent } from 'payload' + +import React from 'react' + +import { FieldDiffContainer } from '../../../../../elements/FieldDiffContainer/index.js' +import { + escapeDiffHTML, + getHTMLDiffComponents, + unescapeDiffHTML, +} from '../../../../../elements/HTMLDiff/index.js' +import { useConfig } from '../../../../../providers/Config/index.js' +import { useTranslation } from '../../../../../providers/Translation/index.js' +import { formatDate } from '../../../../../utilities/formatDocTitle/formatDateTitle.js' +import './index.scss' + +const baseClass = 'date-diff' + +export const DateDiffComponent: DateFieldDiffClientComponent = ({ + comparisonValue: valueFrom, + field, + locale, + nestingLevel, + versionValue: valueTo, +}) => { + const { i18n } = useTranslation() + const { + config: { + admin: { dateFormat }, + }, + } = useConfig() + + const formattedFromDate = valueFrom + ? formatDate({ + date: typeof valueFrom === 'string' ? new Date(valueFrom) : (valueFrom as Date), + i18n, + pattern: dateFormat, + }) + : '' + + const formattedToDate = valueTo + ? formatDate({ + date: typeof valueTo === 'string' ? new Date(valueTo) : (valueTo as Date), + i18n, + pattern: dateFormat, + }) + : '' + + const escapedFromDate = escapeDiffHTML(formattedFromDate) + const escapedToDate = escapeDiffHTML(formattedToDate) + + const { From, To } = getHTMLDiffComponents({ + fromHTML: + `

` + + escapedFromDate + + '

', + postProcess: unescapeDiffHTML, + toHTML: + `

` + + escapedToDate + + '

', + tokenizeByCharacter: false, + }) + + return ( + + ) +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Group/index.scss b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Group/index.scss new file mode 100644 index 00000000000..6a2af115c2e --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Group/index.scss @@ -0,0 +1,9 @@ +@layer payload-default { + .group-diff { + &__locale-label { + &--no-label { + color: var(--theme-elevation-600); + } + } + } +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Group/index.tsx b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Group/index.tsx new file mode 100644 index 00000000000..d4b5edd4928 --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Group/index.tsx @@ -0,0 +1,53 @@ +'use client' +import type { GroupFieldDiffClientComponent } from 'payload' + +import { getTranslation } from '@payloadcms/translations' + +import './index.scss' + +import React from 'react' + +import { useTranslation } from '../../../../../providers/Translation/index.js' +import { useSelectedLocales } from '../../../Default/SelectedLocalesContext.js' +import { DiffCollapser } from '../../DiffCollapser/index.js' +import { RenderVersionFieldsToDiff } from '../../RenderVersionFieldsToDiff.js' + +const baseClass = 'group-diff' + +export const Group: GroupFieldDiffClientComponent = ({ + baseVersionField, + comparisonValue: valueFrom, + field, + locale, + parentIsLocalized, + versionValue: valueTo, +}) => { + const { i18n } = useTranslation() + const { selectedLocales } = useSelectedLocales() + + return ( +
+ + {locale && {locale}} + {getTranslation(field.label, i18n)} + + ) : ( + + <{i18n.t('version:noLabelGroup')}> + + ) + } + locales={selectedLocales} + parentIsLocalized={parentIsLocalized || field.localized} + valueFrom={valueFrom} + valueTo={valueTo} + > + + +
+ ) +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Iterable/index.scss b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Iterable/index.scss new file mode 100644 index 00000000000..96b430610b0 --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Iterable/index.scss @@ -0,0 +1,59 @@ +@layer payload-default { + .iterable-diff { + &-label-container { + position: relative; + height: 20px; + display: flex; + flex-direction: row; + height: 100%; + } + + &-label-prefix { + background-color: var(--theme-bg); + position: relative; + width: calc(var(--base) * 0.5); + height: 16px; + margin-left: calc((var(--base) * -0.5) - 5px); + margin-right: calc(var(--base) * 0.5); + + &::before { + content: ''; + position: absolute; + left: 1px; + top: 8px; + transform: translateY(-50%); + width: 6px; + height: 6px; + background-color: var(--theme-elevation-200); + border-radius: 50%; + margin-right: 5px; + } + } + &__label { + font-weight: 400; + color: var(--theme-elevation-600); + } + + &__locale-label { + background: var(--theme-elevation-100); + border-radius: var(--style-radius-s); + padding: calc(var(--base) * 0.2); + // border-radius: $style-radius-m; + [dir='ltr'] & { + margin-right: calc(var(--base) * 0.25); + } + [dir='rtl'] & { + margin-left: calc(var(--base) * 0.25); + } + } + + // Space between each row + &__row:not(:first-of-type) { + margin-top: calc(var(--base) * 0.5); + } + + &__no-rows { + color: var(--theme-elevation-400); + } + } +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Iterable/index.tsx b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Iterable/index.tsx new file mode 100644 index 00000000000..ce16e3fc9a0 --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Iterable/index.tsx @@ -0,0 +1,121 @@ +'use client' + +import type { FieldDiffClientProps } from 'payload' + +import { getTranslation } from '@payloadcms/translations' +import { fieldIsArrayType, fieldIsBlockType } from 'payload/shared' +import React from 'react' + +import './index.scss' +import { useConfig } from '../../../../../providers/Config/index.js' +import { useTranslation } from '../../../../../providers/Translation/index.js' +import { useSelectedLocales } from '../../../Default/SelectedLocalesContext.js' +import { DiffCollapser } from '../../DiffCollapser/index.js' +import { RenderVersionFieldsToDiff } from '../../RenderVersionFieldsToDiff.js' +import { getFieldsForRowComparison } from '../../utilities/getFieldsForRowComparison.js' + +const baseClass = 'iterable-diff' + +export const Iterable: React.FC = ({ + baseVersionField, + comparisonValue: valueFrom, + field, + locale, + parentIsLocalized, + versionValue: valueTo, +}) => { + const { i18n, t } = useTranslation() + const { selectedLocales } = useSelectedLocales() + const { config } = useConfig() + + if (!fieldIsArrayType(field) && !fieldIsBlockType(field)) { + throw new Error(`Expected field to be an array or blocks type but got: ${field.type}`) + } + + const valueToRowCount = Array.isArray(valueTo) ? valueTo.length : 0 + const valueFromRowCount = Array.isArray(valueFrom) ? valueFrom.length : 0 + const maxRows = Math.max(valueToRowCount, valueFromRowCount) + + return ( +
+ + {locale && {locale}} + {getTranslation(field.label, i18n)} + + ) + } + locales={selectedLocales} + parentIsLocalized={parentIsLocalized} + valueFrom={valueFrom} + valueTo={valueTo} + > + {maxRows > 0 && ( +
+ {Array.from({ length: maxRows }, (_, i) => { + const valueToRow = valueTo?.[i] || {} + const valueFromRow = valueFrom?.[i] || {} + + const { fields, versionFields } = getFieldsForRowComparison({ + baseVersionField, + config, + field, + row: i, + valueFromRow, + valueToRow, + }) + + if (!versionFields?.length) { + // Rows without a diff create "holes" in the baseVersionField.rows (=versionFields) array - this is to maintain the correct row indexes. + // It does mean that this row has no diff and should not be rendered => skip it. + return null + } + + const rowNumber = String(i + 1).padStart(2, '0') + const rowLabel = fieldIsArrayType(field) + ? `${t('general:item')} ${rowNumber}` + : `${t('fields:block')} ${rowNumber}` + + return ( +
+ +
+ {rowLabel} +
+ } + locales={selectedLocales} + parentIsLocalized={parentIsLocalized || field.localized} + valueFrom={valueFromRow} + valueTo={valueToRow} + > + + +
+ ) + })} +
+ )} + {maxRows === 0 && ( +
+ {i18n.t('version:noRowsFound', { + label: + 'labels' in field && field.labels?.plural + ? getTranslation(field.labels.plural, i18n) + : i18n.t('general:rows'), + })} +
+ )} + + + ) +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Relationship/generateLabelFromValue.ts b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Relationship/generateLabelFromValue.ts new file mode 100644 index 00000000000..fe7d7084c9b --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Relationship/generateLabelFromValue.ts @@ -0,0 +1,100 @@ +import type { PayloadRequest, RelationshipField, TypeWithID } from 'payload' + +import { + fieldAffectsData, + fieldIsPresentationalOnly, + fieldShouldBeLocalized, + flattenTopLevelFields, +} from 'payload/shared' + +import type { RelationshipValue } from './index.js' + +export const generateLabelFromValue = async ({ + field, + locale, + parentIsLocalized, + req, + value, +}: { + field: RelationshipField + locale: string + parentIsLocalized: boolean + req: PayloadRequest + value: RelationshipValue +}): Promise => { + let relatedDoc: number | string | TypeWithID + let relationTo: string = field.relationTo as string + let valueToReturn: string = '' + + if (typeof value === 'object' && 'relationTo' in value) { + relatedDoc = value.value + relationTo = value.relationTo + } else { + // Non-polymorphic relationship or deleted document + relatedDoc = value + } + + const relatedCollection = req.payload.collections[relationTo].config + + const useAsTitle = relatedCollection?.admin?.useAsTitle + + const flattenedRelatedCollectionFields = flattenTopLevelFields(relatedCollection.fields, { + moveSubFieldsToTop: true, + }) + + const useAsTitleField = flattenedRelatedCollectionFields.find( + (f) => fieldAffectsData(f) && !fieldIsPresentationalOnly(f) && f.name === useAsTitle, + ) + let titleFieldIsLocalized = false + + if (useAsTitleField && fieldAffectsData(useAsTitleField)) { + titleFieldIsLocalized = fieldShouldBeLocalized({ field: useAsTitleField, parentIsLocalized }) + } + + if (typeof relatedDoc?.[useAsTitle] !== 'undefined') { + valueToReturn = relatedDoc[useAsTitle] + } else if (typeof relatedDoc === 'string' || typeof relatedDoc === 'number') { + // When relatedDoc is just an ID (due to maxDepth: 0), fetch the document to get the title + try { + const fetchedDoc = await req.payload.findByID({ + id: relatedDoc, + collection: relationTo, + depth: 0, + locale: titleFieldIsLocalized ? locale : undefined, + req, + select: { + [useAsTitle]: true, + }, + }) + + if (fetchedDoc?.[useAsTitle]) { + valueToReturn = fetchedDoc[useAsTitle] + } else { + valueToReturn = `${req.i18n.t('general:untitled')} - ID: ${relatedDoc}` + } + } catch (error) { + // Document might have been deleted or user doesn't have access + valueToReturn = `${req.i18n.t('general:untitled')} - ID: ${relatedDoc}` + } + } else { + valueToReturn = String(typeof relatedDoc === 'object' ? relatedDoc.id : relatedDoc) + } + + if ( + typeof valueToReturn === 'object' && + valueToReturn && + titleFieldIsLocalized && + valueToReturn?.[locale] + ) { + valueToReturn = valueToReturn[locale] + } + + if ( + (valueToReturn && typeof valueToReturn === 'object' && valueToReturn !== null) || + typeof valueToReturn !== 'string' + ) { + valueToReturn = JSON.stringify(valueToReturn) + } + + return valueToReturn +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Relationship/index.scss b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Relationship/index.scss new file mode 100644 index 00000000000..825252028cf --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Relationship/index.scss @@ -0,0 +1,91 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .relationship-diff-container .field-diff-content { + padding: 0; + background: unset; + } + + .relationship-diff-container--hasOne { + .relationship-diff { + min-width: 100%; + max-width: fit-content; + } + } + + .relationship-diff-container--hasMany .field-diff-content { + background: var(--theme-elevation-50); + padding: 10px; + + .html-diff { + display: flex; + min-width: 0; + max-width: max-content; + flex-wrap: wrap; + gap: calc(var(--base) * 0.5); + } + + .relationship-diff { + padding: calc(var(--base) * 0.15) calc(var(--base) * 0.3); + } + } + + .relationship-diff { + @extend %body; + display: flex; + align-items: center; + border-radius: $style-radius-s; + border: 1px solid var(--theme-elevation-150); + position: relative; + font-family: var(--font-body); + max-height: calc(var(--base) * 3); + padding: calc(var(--base) * 0.35); + + &[data-match-type='create'] { + border-color: var(--diff-create-pill-border); + color: var(--diff-create-parent-color); + + * { + color: var(--diff-create-parent-color); + } + } + + &[data-match-type='delete'] { + border-color: var(--diff-delete-pill-border); + color: var(--diff-delete-parent-color); + background-color: var(--diff-delete-pill-bg); + text-decoration-line: none !important; + + * { + color: var(--diff-delete-parent-color); + text-decoration-line: none; + } + + .relationship-diff__info { + text-decoration-line: line-through; + } + } + + &__info { + font-weight: 500; + } + + &__pill { + border-radius: $style-radius-s; + margin: 0 calc(var(--base) * 0.4) 0 calc(var(--base) * 0.2); + padding: 0 calc(var(--base) * 0.1); + background-color: var(--theme-elevation-150); + color: var(--theme-elevation-750); + } + + &[data-match-type='create'] .relationship-diff__pill { + background-color: var(--diff-create-parent-bg); + color: var(--diff-create-pill-color); + } + + &[data-match-type='delete'] .relationship-diff__pill { + background-color: var(--diff-delete-parent-bg); + color: var(--diff-delete-pill-color); + } + } +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Relationship/index.tsx b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Relationship/index.tsx new file mode 100644 index 00000000000..a4d50ef0cb1 --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Relationship/index.tsx @@ -0,0 +1,337 @@ +import type { + PayloadRequest, + RelationshipField, + RelationshipFieldDiffServerComponent, + TypeWithID, +} from 'payload' + +import { getTranslation, type I18nClient } from '@payloadcms/translations' +import React from 'react' + +import { FieldDiffContainer } from '../../../../../elements/FieldDiffContainer/index.js' +import './index.scss' +import { getHTMLDiffComponents } from '../../../../../elements/HTMLDiff/index.js' +import { generateLabelFromValue } from './generateLabelFromValue.js' + +const baseClass = 'relationship-diff' + +export type RelationshipValue = + | { relationTo: string; value: number | string | TypeWithID } + | (number | string | TypeWithID) + +export const Relationship: RelationshipFieldDiffServerComponent = ({ + comparisonValue: valueFrom, + field, + i18n, + locale, + nestingLevel, + parentIsLocalized, + req, + versionValue: valueTo, +}) => { + const hasMany = + ('hasMany' in field && field.hasMany) || + // Check data structure (handles block swaps where schema may not match data) + Array.isArray(valueFrom) || + Array.isArray(valueTo) + const polymorphic = Array.isArray(field.relationTo) + + if (hasMany) { + return ( + + ) + } + + return ( + + ) +} + +export const SingleRelationshipDiff: React.FC<{ + field: RelationshipField + i18n: I18nClient + locale: string + nestingLevel?: number + parentIsLocalized: boolean + polymorphic: boolean + req: PayloadRequest + valueFrom: RelationshipValue + valueTo: RelationshipValue +}> = async (args) => { + const { + field, + i18n, + locale, + nestingLevel, + parentIsLocalized, + polymorphic, + req, + valueFrom, + valueTo, + } = args + + const ReactDOMServer = (await import('react-dom/server')).default + + const localeToUse = + locale ?? + (req.payload.config?.localization && req.payload.config?.localization?.defaultLocale) ?? + 'en' + + // Generate titles asynchronously before creating components + const [titleFrom, titleTo] = await Promise.all([ + valueFrom + ? generateLabelFromValue({ + field, + locale: localeToUse, + parentIsLocalized, + req, + value: valueFrom, + }) + : Promise.resolve(null), + valueTo + ? generateLabelFromValue({ + field, + locale: localeToUse, + parentIsLocalized, + req, + value: valueTo, + }) + : Promise.resolve(null), + ]) + + const FromComponent = valueFrom ? ( + + ) : null + const ToComponent = valueTo ? ( + + ) : null + + const fromHTML = FromComponent ? ReactDOMServer.renderToStaticMarkup(FromComponent) : `

` + const toHTML = ToComponent ? ReactDOMServer.renderToStaticMarkup(ToComponent) : `

` + + const diff = getHTMLDiffComponents({ + fromHTML, + toHTML, + tokenizeByCharacter: false, + }) + + return ( + + ) +} + +const ManyRelationshipDiff: React.FC<{ + field: RelationshipField + i18n: I18nClient + locale: string + nestingLevel?: number + parentIsLocalized: boolean + polymorphic: boolean + req: PayloadRequest + valueFrom: RelationshipValue[] | undefined + valueTo: RelationshipValue[] | undefined +}> = async ({ + field, + i18n, + locale, + nestingLevel, + parentIsLocalized, + polymorphic, + req, + valueFrom, + valueTo, +}) => { + const ReactDOMServer = (await import('react-dom/server')).default + + const fromArr = Array.isArray(valueFrom) ? valueFrom : [] + const toArr = Array.isArray(valueTo) ? valueTo : [] + + const localeToUse = + locale ?? + (req.payload.config?.localization && req.payload.config?.localization?.defaultLocale) ?? + 'en' + + // Generate all titles asynchronously before creating components + const [titlesFrom, titlesTo] = await Promise.all([ + Promise.all( + fromArr.map((val) => + generateLabelFromValue({ + field, + locale: localeToUse, + parentIsLocalized, + req, + value: val, + }), + ), + ), + Promise.all( + toArr.map((val) => + generateLabelFromValue({ + field, + locale: localeToUse, + parentIsLocalized, + req, + value: val, + }), + ), + ), + ]) + + const makeNodes = (list: RelationshipValue[], titles: string[]) => + list.map((val, idx) => ( + + )) + + const fromNodes = + fromArr.length > 0 ? makeNodes(fromArr, titlesFrom) :

+ + const toNodes = + toArr.length > 0 ? makeNodes(toArr, titlesTo) :

+ + const fromHTML = ReactDOMServer.renderToStaticMarkup(fromNodes) + const toHTML = ReactDOMServer.renderToStaticMarkup(toNodes) + + const diff = getHTMLDiffComponents({ + fromHTML, + toHTML, + tokenizeByCharacter: false, + }) + + return ( + + ) +} + +const RelationshipDocumentDiff = ({ + field, + i18n, + locale, + parentIsLocalized, + polymorphic, + relationTo, + req, + showPill = false, + title, + value, +}: { + field: RelationshipField + i18n: I18nClient + locale: string + parentIsLocalized: boolean + polymorphic: boolean + relationTo: string + req: PayloadRequest + showPill?: boolean + title: null | string + value: RelationshipValue +}) => { + let pillLabel: null | string = null + if (showPill) { + const collectionConfig = req.payload.collections[relationTo].config + pillLabel = collectionConfig.labels?.singular + ? getTranslation(collectionConfig.labels.singular, i18n) + : collectionConfig.slug + } + + return ( +
+ {pillLabel && ( + + {pillLabel} + + )} + + {title} + +
+ ) +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Row/index.tsx b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Row/index.tsx new file mode 100644 index 00000000000..db73c912dc0 --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Row/index.tsx @@ -0,0 +1,16 @@ +'use client' +import type { RowFieldDiffClientComponent } from 'payload' + +import React from 'react' + +import { RenderVersionFieldsToDiff } from '../../RenderVersionFieldsToDiff.js' + +const baseClass = 'row-diff' + +export const Row: RowFieldDiffClientComponent = ({ baseVersionField }) => { + return ( +
+ +
+ ) +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Select/index.scss b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Select/index.scss new file mode 100644 index 00000000000..8ebe408f32f --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Select/index.scss @@ -0,0 +1,4 @@ +@layer payload-default { + .select-diff { + } +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Select/index.tsx b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Select/index.tsx new file mode 100644 index 00000000000..1cd5ec2841e --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Select/index.tsx @@ -0,0 +1,122 @@ +'use client' +import type { I18nClient } from '@payloadcms/translations' +import type { Option, SelectField, SelectFieldDiffClientComponent } from 'payload' + +import { getTranslation } from '@payloadcms/translations' +import React from 'react' + +import { FieldDiffContainer } from '../../../../../elements/FieldDiffContainer/index.js' +import { + escapeDiffHTML, + getHTMLDiffComponents, + unescapeDiffHTML, +} from '../../../../../elements/HTMLDiff/index.js' +import { useTranslation } from '../../../../../providers/Translation/index.js' +import './index.scss' + +const baseClass = 'select-diff' + +const getOptionsToRender = ( + value: string, + options: SelectField['options'], + hasMany: boolean, +): Option | Option[] => { + if (hasMany && Array.isArray(value)) { + return value.map( + (val) => + options.find((option) => (typeof option === 'string' ? option : option.value) === val) || + String(val), + ) + } + return ( + options.find((option) => (typeof option === 'string' ? option : option.value) === value) || + String(value) + ) +} + +/** + * Translates option labels while ensuring they are strings. + * If `options.label` is a JSX element, it falls back to `options.value` because `DiffViewer` + * expects all values to be strings. + */ +const getTranslatedOptions = (options: Option | Option[], i18n: I18nClient): string => { + if (Array.isArray(options)) { + return options + .map((option) => { + if (typeof option === 'string') { + return option + } + const translatedLabel = getTranslation(option.label, i18n) + + // Ensure the result is a string, otherwise use option.value + return typeof translatedLabel === 'string' ? translatedLabel : option.value + }) + .join(', ') + } + + if (typeof options === 'string') { + return options + } + + const translatedLabel = getTranslation(options.label, i18n) + + return typeof translatedLabel === 'string' ? translatedLabel : options.value +} + +export const Select: SelectFieldDiffClientComponent = ({ + comparisonValue: valueFrom, + diffMethod, + field, + locale, + nestingLevel, + versionValue: valueTo, +}) => { + const { i18n } = useTranslation() + + const options = 'options' in field && field.options + + const renderedValueFrom = + typeof valueFrom !== 'undefined' + ? getTranslatedOptions( + getOptionsToRender( + typeof valueFrom === 'string' ? valueFrom : JSON.stringify(valueFrom), + options, + field.hasMany, + ), + i18n, + ) + : '' + + const renderedValueTo = + typeof valueTo !== 'undefined' + ? getTranslatedOptions( + getOptionsToRender( + typeof valueTo === 'string' ? valueTo : JSON.stringify(valueTo), + options, + field.hasMany, + ), + i18n, + ) + : '' + + const { From, To } = getHTMLDiffComponents({ + fromHTML: '

' + escapeDiffHTML(renderedValueFrom) + '

', + postProcess: unescapeDiffHTML, + toHTML: '

' + escapeDiffHTML(renderedValueTo) + '

', + tokenizeByCharacter: true, + }) + + return ( + + ) +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Tabs/index.scss b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Tabs/index.scss new file mode 100644 index 00000000000..02f067d9c9b --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Tabs/index.scss @@ -0,0 +1,9 @@ +@layer payload-default { + .tabs-diff { + // Space between each tab or tab locale + &__tab:not(:first-of-type), + &__tab-locale:not(:first-of-type) { + margin-top: var(--base); + } + } +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Tabs/index.tsx b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Tabs/index.tsx new file mode 100644 index 00000000000..31a0a34117e --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Tabs/index.tsx @@ -0,0 +1,120 @@ +'use client' +import type { + ClientTab, + FieldDiffClientProps, + TabsFieldClient, + TabsFieldDiffClientComponent, + VersionTab, +} from 'payload' + +import { getTranslation } from '@payloadcms/translations' +import React from 'react' + +import { useTranslation } from '../../../../../providers/Translation/index.js' +import './index.scss' +import { useSelectedLocales } from '../../../Default/SelectedLocalesContext.js' +import { DiffCollapser } from '../../DiffCollapser/index.js' +import { RenderVersionFieldsToDiff } from '../../RenderVersionFieldsToDiff.js' + +const baseClass = 'tabs-diff' + +export const Tabs: TabsFieldDiffClientComponent = (props) => { + const { baseVersionField, comparisonValue: valueFrom, field, versionValue: valueTo } = props + const { selectedLocales } = useSelectedLocales() + + return ( +
+ {baseVersionField.tabs.map((tab, i) => { + if (!tab?.fields?.length) { + return null + } + const fieldTab = field.tabs?.[i] + if (!fieldTab) { + return null + } + return ( +
+ {(() => { + if ('name' in fieldTab && selectedLocales && fieldTab.localized) { + // Named localized tab + return selectedLocales.map((locale, index) => { + const localizedTabProps: TabProps = { + ...props, + comparisonValue: valueFrom?.[tab.name]?.[locale], + fieldTab, + locale, + tab, + versionValue: valueTo?.[tab.name]?.[locale], + } + return ( +
+
+ +
+
+ ) + }) + } else if ('name' in tab && tab.name) { + // Named tab + const namedTabProps: TabProps = { + ...props, + comparisonValue: valueFrom?.[tab.name], + fieldTab, + tab, + versionValue: valueTo?.[tab.name], + } + return + } else { + // Unnamed tab + return + } + })()} +
+ ) + })} +
+ ) +} + +type TabProps = { + fieldTab: ClientTab + tab: VersionTab +} & FieldDiffClientProps + +const Tab: React.FC = ({ + comparisonValue: valueFrom, + fieldTab, + locale, + parentIsLocalized, + tab, + versionValue: valueTo, +}) => { + const { i18n } = useTranslation() + const { selectedLocales } = useSelectedLocales() + + if (!tab.fields?.length) { + return null + } + + return ( + + {locale && {locale}} + {getTranslation(tab.label, i18n)} + + ) + } + locales={selectedLocales} + parentIsLocalized={parentIsLocalized || fieldTab.localized} + valueFrom={valueFrom} + valueTo={valueTo} + > + + + ) +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Text/index.scss b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Text/index.scss new file mode 100644 index 00000000000..dcb659cc593 --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Text/index.scss @@ -0,0 +1,4 @@ +@layer payload-default { + .text-diff { + } +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Text/index.tsx b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Text/index.tsx new file mode 100644 index 00000000000..9748629c261 --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Text/index.tsx @@ -0,0 +1,98 @@ +'use client' +import type { TextFieldDiffClientComponent } from 'payload' + +import React from 'react' + +import { FieldDiffContainer } from '../../../../../elements/FieldDiffContainer/index.js' +import { + escapeDiffHTML, + getHTMLDiffComponents, + unescapeDiffHTML, +} from '../../../../../elements/HTMLDiff/index.js' +import { useTranslation } from '../../../../../providers/Translation/index.js' +import './index.scss' + +const baseClass = 'text-diff' + +function formatValue(value: unknown): { + tokenizeByCharacter: boolean + value: string +} { + if (typeof value === 'string') { + return { tokenizeByCharacter: true, value: escapeDiffHTML(value) } + } + if (typeof value === 'number') { + return { + tokenizeByCharacter: true, + value: String(value), + } + } + if (typeof value === 'boolean') { + return { + tokenizeByCharacter: false, + value: String(value), + } + } + + if (value && typeof value === 'object') { + return { + tokenizeByCharacter: false, + value: `
${escapeDiffHTML(JSON.stringify(value, null, 2))}
`, + } + } + + return { + tokenizeByCharacter: true, + value: undefined, + } +} + +export const Text: TextFieldDiffClientComponent = ({ + comparisonValue: valueFrom, + field, + locale, + nestingLevel, + versionValue: valueTo, +}) => { + const { i18n } = useTranslation() + + let placeholder = '' + + if (valueTo == valueFrom) { + placeholder = `` + } + + const formattedValueFrom = formatValue(valueFrom) + const formattedValueTo = formatValue(valueTo) + + let tokenizeByCharacter = true + if (formattedValueFrom.value?.length) { + tokenizeByCharacter = formattedValueFrom.tokenizeByCharacter + } else if (formattedValueTo.value?.length) { + tokenizeByCharacter = formattedValueTo.tokenizeByCharacter + } + + const renderedValueFrom = formattedValueFrom.value ?? placeholder + const renderedValueTo: string = formattedValueTo.value ?? placeholder + + const { From, To } = getHTMLDiffComponents({ + fromHTML: '

' + renderedValueFrom + '

', + postProcess: unescapeDiffHTML, + toHTML: '

' + renderedValueTo + '

', + tokenizeByCharacter, + }) + + return ( + + ) +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Upload/index.scss b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Upload/index.scss new file mode 100644 index 00000000000..2cda867e15b --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Upload/index.scss @@ -0,0 +1,121 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .upload-diff-container .field-diff-content { + padding: 0; + background: unset; + } + + .upload-diff-hasMany { + display: flex; + flex-direction: column; + gap: calc(var(--base) * 0.4); + } + + .upload-diff { + @extend %body; + min-width: 100%; + max-width: fit-content; + display: flex; + align-items: center; + background-color: var(--theme-elevation-50); + border-radius: $style-radius-s; + border: 1px solid var(--theme-elevation-150); + position: relative; + font-family: var(--font-body); + max-height: calc(var(--base) * 3); + padding: calc(var(--base) * 0.1); + + &[data-match-type='create'] { + border-color: var(--diff-create-pill-border); + color: var(--diff-create-parent-color); + + * { + color: var(--diff-create-parent-color); + } + + .upload-diff__thumbnail { + border-radius: 0px; + border-color: var(--diff-create-pill-border); + background-color: none; + } + } + + &[data-match-type='delete'] { + border-color: var(--diff-delete-pill-border); + text-decoration-line: none; + color: var(--diff-delete-parent-color); + background-color: var(--diff-delete-pill-bg); + + * { + text-decoration-line: none; + color: var(--diff-delete-parent-color); + } + + .upload-diff__thumbnail { + border-radius: 0px; + border-color: var(--diff-delete-pill-border); + background-color: none; + } + } + + &__card { + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + } + + &__thumbnail { + width: calc(var(--base) * 3 - base(0.8) * 2); + height: calc(var(--base) * 3 - base(0.8) * 2); + position: relative; + overflow: hidden; + flex-shrink: 0; + border-radius: 0px; + border: 1px solid var(--theme-elevation-100); + + img, + svg { + position: absolute; + object-fit: cover; + width: 100%; + height: 100%; + border-radius: 0px; + } + } + + &__info { + flex-grow: 1; + display: flex; + align-items: flex-start; + flex-direction: column; + padding: calc(var(--base) * 0.25) calc(var(--base) * 0.6); + justify-content: space-between; + font-weight: 400; + + strong { + font-weight: 500; + } + } + + &__pill { + border-radius: $style-radius-s; + margin-left: calc(var(--base) * 0.6); + padding: 0 calc(var(--base) * 0.1); + + background-color: var(--theme-elevation-150); + color: var(--theme-elevation-750); + } + + &[data-match-type='create'] .upload-diff__pill { + background-color: var(--diff-create-parent-bg); + color: var(--diff-create-pill-color); + } + + &[data-match-type='delete'] .upload-diff__pill { + background-color: var(--diff-delete-parent-bg); + color: var(--diff-delete-pill-color); + } + } +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Upload/index.tsx b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Upload/index.tsx new file mode 100644 index 00000000000..c9b10f29c17 --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/Upload/index.tsx @@ -0,0 +1,308 @@ +import type { + FileData, + PayloadRequest, + TypeWithID, + UploadField, + UploadFieldDiffServerComponent, +} from 'payload' + +import { getTranslation, type I18nClient } from '@payloadcms/translations' +import React from 'react' + +import { FieldDiffContainer } from '../../../../../elements/FieldDiffContainer/index.js' +import { getHTMLDiffComponents } from '../../../../../elements/HTMLDiff/index.js' +import './index.scss' +import { File } from '../../../../../graphics/File/index.js' + +const baseClass = 'upload-diff' + +type NonPolyUploadDoc = (FileData & TypeWithID) | number | string +type PolyUploadDoc = { relationTo: string; value: (FileData & TypeWithID) | number | string } + +type UploadDoc = NonPolyUploadDoc | PolyUploadDoc + +export const Upload: UploadFieldDiffServerComponent = (args) => { + const { + comparisonValue: valueFrom, + field, + i18n, + locale, + nestingLevel, + req, + versionValue: valueTo, + } = args + const hasMany = 'hasMany' in field && field.hasMany && Array.isArray(valueTo) + const polymorphic = Array.isArray(field.relationTo) + + if (hasMany) { + return ( + + ) + } + + return ( + + ) +} + +export const HasManyUploadDiff: React.FC<{ + field: UploadField + i18n: I18nClient + locale: string + nestingLevel?: number + polymorphic: boolean + req: PayloadRequest + valueFrom: Array + valueTo: Array +}> = async (args) => { + const { field, i18n, locale, nestingLevel, polymorphic, req, valueFrom, valueTo } = args + const ReactDOMServer = (await import('react-dom/server')).default + + let From: React.ReactNode = '' + let To: React.ReactNode = '' + + const showCollectionSlug = Array.isArray(field.relationTo) + + const getUploadDocKey = (uploadDoc: UploadDoc): number | string => { + if (typeof uploadDoc === 'object' && 'relationTo' in uploadDoc) { + // Polymorphic case + const value = uploadDoc.value + return typeof value === 'object' ? value.id : value + } + // Non-polymorphic case + return typeof uploadDoc === 'object' ? uploadDoc.id : uploadDoc + } + + const FromComponents = valueFrom + ? valueFrom.map((uploadDoc) => ( + + )) + : null + const ToComponents = valueTo + ? valueTo.map((uploadDoc) => ( + + )) + : null + + const diffResult = getHTMLDiffComponents({ + fromHTML: + `
` + + (FromComponents + ? FromComponents.map( + (component) => `
${ReactDOMServer.renderToStaticMarkup(component)}
`, + ).join('') + : '') + + '
', + toHTML: + `
` + + (ToComponents + ? ToComponents.map( + (component) => `
${ReactDOMServer.renderToStaticMarkup(component)}
`, + ).join('') + : '') + + '
', + tokenizeByCharacter: false, + }) + From = diffResult.From + To = diffResult.To + + return ( + + ) +} + +export const SingleUploadDiff: React.FC<{ + field: UploadField + i18n: I18nClient + locale: string + nestingLevel?: number + polymorphic: boolean + req: PayloadRequest + valueFrom: UploadDoc + valueTo: UploadDoc +}> = async (args) => { + const { field, i18n, locale, nestingLevel, polymorphic, req, valueFrom, valueTo } = args + + const ReactDOMServer = (await import('react-dom/server')).default + + let From: React.ReactNode = '' + let To: React.ReactNode = '' + + const showCollectionSlug = Array.isArray(field.relationTo) + + const FromComponent = valueFrom ? ( + + ) : null + const ToComponent = valueTo ? ( + + ) : null + + const fromHtml = FromComponent + ? ReactDOMServer.renderToStaticMarkup(FromComponent) + : '

' + '' + '

' + const toHtml = ToComponent + ? ReactDOMServer.renderToStaticMarkup(ToComponent) + : '

' + '' + '

' + + const diffResult = getHTMLDiffComponents({ + fromHTML: fromHtml, + toHTML: toHtml, + tokenizeByCharacter: false, + }) + From = diffResult.From + To = diffResult.To + + return ( + + ) +} + +const UploadDocumentDiff = (args: { + i18n: I18nClient + polymorphic: boolean + relationTo: string | string[] + req: PayloadRequest + showCollectionSlug?: boolean + uploadDoc: UploadDoc +}) => { + const { i18n, polymorphic, relationTo, req, showCollectionSlug, uploadDoc } = args + + let thumbnailSRC: string = '' + + const value = polymorphic + ? (uploadDoc as { relationTo: string; value: FileData & TypeWithID }).value + : (uploadDoc as FileData & TypeWithID) + + if (value && typeof value === 'object' && 'thumbnailURL' in value) { + thumbnailSRC = + (typeof value.thumbnailURL === 'string' && value.thumbnailURL) || + (typeof value.url === 'string' && value.url) || + '' + } + + let filename: string + if (value && typeof value === 'object') { + filename = value.filename + } else { + filename = `${i18n.t('general:untitled')} - ID: ${uploadDoc as number | string}` + } + + let pillLabel: null | string = null + + if (showCollectionSlug) { + let collectionSlug: string + if (polymorphic && typeof uploadDoc === 'object' && 'relationTo' in uploadDoc) { + collectionSlug = uploadDoc.relationTo + } else { + collectionSlug = typeof relationTo === 'string' ? relationTo : relationTo[0] + } + const uploadConfig = req.payload.collections[collectionSlug].config + pillLabel = uploadConfig.labels?.singular + ? getTranslation(uploadConfig.labels.singular, i18n) + : uploadConfig.slug + } + + let id: number | string | undefined + if (polymorphic && typeof uploadDoc === 'object' && 'relationTo' in uploadDoc) { + const polyValue = uploadDoc.value + id = typeof polyValue === 'object' ? polyValue.id : polyValue + } else if (typeof uploadDoc === 'object' && 'id' in uploadDoc) { + id = uploadDoc.id + } else if (typeof uploadDoc === 'string' || typeof uploadDoc === 'number') { + id = uploadDoc + } + + const alt = + (value && typeof value === 'object' && (value as { alt?: string }).alt) || filename || '' + + return ( +
+
+
+ {thumbnailSRC?.length ? {alt} : } +
+ {pillLabel && ( +
+ {pillLabel} +
+ )} +
+ {filename} +
+
+
+ ) +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/fields/index.ts b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/index.ts new file mode 100644 index 00000000000..cc00eeab186 --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/fields/index.ts @@ -0,0 +1,40 @@ +import type { FieldDiffClientProps, FieldDiffServerProps, FieldTypes } from 'payload' + +import { Collapsible } from './Collapsible/index.js' +import { DateDiffComponent } from './Date/index.js' +import { Group } from './Group/index.js' +import { Iterable } from './Iterable/index.js' +import { Relationship } from './Relationship/index.js' +import { Row } from './Row/index.js' +import { Select } from './Select/index.js' +import { Tabs } from './Tabs/index.js' +import { Text } from './Text/index.js' +import { Upload } from './Upload/index.js' + +export const diffComponents: Record< + FieldTypes, + React.ComponentType +> = { + array: Iterable, + blocks: Iterable, + checkbox: Text, + code: Text, + collapsible: Collapsible, + date: DateDiffComponent, + email: Text, + group: Group, + join: null, + json: Text, + number: Text, + point: Text, + radio: Select, + relationship: Relationship, + richText: Text, + row: Row, + select: Select, + tabs: Tabs, + text: Text, + textarea: Text, + ui: null, + upload: Upload, +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/index.scss b/packages/ui/src/views/Version/RenderFieldsToDiff/index.scss new file mode 100644 index 00000000000..03eed0412be --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/index.scss @@ -0,0 +1,24 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .render-field-diffs { + display: flex; + flex-direction: column; + gap: var(--base); + + [role='banner'] { + display: none !important; + } + + &__field { + overflow-wrap: anywhere; + display: flex; + flex-direction: column; + gap: var(--base); + } + + @include small-break { + gap: calc(var(--base) / 2); + } + } +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/index.tsx b/packages/ui/src/views/Version/RenderFieldsToDiff/index.tsx new file mode 100644 index 00000000000..a2e90a8424c --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/index.tsx @@ -0,0 +1,8 @@ +import { buildVersionFields, type BuildVersionFieldsArgs } from './buildVersionFields.js' +import { RenderVersionFieldsToDiff } from './RenderVersionFieldsToDiff.js' + +export const RenderDiff = (args: BuildVersionFieldsArgs): React.ReactNode => { + const { versionFields } = buildVersionFields(args) + + return +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.spec.ts b/packages/ui/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.spec.ts new file mode 100644 index 00000000000..f3ed248ec75 --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.spec.ts @@ -0,0 +1,540 @@ +import type { ClientField } from 'payload' +import { describe, it, expect } from 'vitest' + +import { countChangedFields, countChangedFieldsInRows } from './countChangedFields.js' + +describe('countChangedFields', () => { + // locales can be undefined when not configured in payload.config.js + const locales = undefined + it('should return 0 when no fields have changed', () => { + const fields: ClientField[] = [ + { name: 'a', type: 'text' }, + { name: 'b', type: 'number' }, + ] + const valueFrom = { a: 'original', b: 123 } + const valueTo = { a: 'original', b: 123 } + + const result = countChangedFields({ valueFrom, fields, valueTo, locales }) + expect(result).toBe(0) + }) + + it('should count simple changed fields', () => { + const fields: ClientField[] = [ + { name: 'a', type: 'text' }, + { name: 'b', type: 'number' }, + ] + const valueFrom = { a: 'original', b: 123 } + const valueTo = { a: 'changed', b: 123 } + + const result = countChangedFields({ valueFrom, fields, valueTo, locales }) + expect(result).toBe(1) + }) + + it('should count previously undefined fields', () => { + const fields: ClientField[] = [ + { name: 'a', type: 'text' }, + { name: 'b', type: 'number' }, + ] + const valueFrom = {} + const valueTo = { a: 'new', b: 123 } + + const result = countChangedFields({ valueFrom, fields, valueTo, locales }) + expect(result).toBe(2) + }) + + it('should not count the id field because it is not displayed in the version view', () => { + const fields: ClientField[] = [ + { name: 'id', type: 'text' }, + { name: 'a', type: 'text' }, + ] + const valueFrom = { id: 'original', a: 'original' } + const valueTo = { id: 'changed', a: 'original' } + + const result = countChangedFields({ valueFrom, fields, valueTo, locales }) + expect(result).toBe(0) + }) + + it('should count changed fields inside collapsible fields', () => { + const fields: ClientField[] = [ + { + type: 'collapsible', + label: 'A collapsible field', + fields: [ + { name: 'a', type: 'text' }, + { name: 'b', type: 'text' }, + { name: 'c', type: 'text' }, + ], + }, + ] + const valueFrom = { a: 'original', b: 'original', c: 'original' } + const valueTo = { a: 'changed', b: 'changed', c: 'original' } + + const result = countChangedFields({ valueFrom, fields, valueTo, locales }) + expect(result).toBe(2) + }) + + it('should count changed fields inside row fields', () => { + const fields: ClientField[] = [ + { + type: 'row', + fields: [ + { name: 'a', type: 'text' }, + { name: 'b', type: 'text' }, + { name: 'c', type: 'text' }, + ], + }, + ] + const valueFrom = { a: 'original', b: 'original', c: 'original' } + const valueTo = { a: 'changed', b: 'changed', c: 'original' } + + const result = countChangedFields({ valueFrom, fields, valueTo, locales }) + expect(result).toBe(2) + }) + + it('should count changed fields inside group fields', () => { + const fields: ClientField[] = [ + { + type: 'group', + name: 'group', + fields: [ + { name: 'a', type: 'text' }, + { name: 'b', type: 'text' }, + { name: 'c', type: 'text' }, + ], + }, + ] + const valueFrom = { group: { a: 'original', b: 'original', c: 'original' } } + const valueTo = { group: { a: 'changed', b: 'changed', c: 'original' } } + + const result = countChangedFields({ valueFrom, fields, valueTo, locales }) + expect(result).toBe(2) + }) + + it('should count changed fields inside unnamed tabs ', () => { + const fields: ClientField[] = [ + { + type: 'tabs', + tabs: [ + { + label: 'Unnamed tab', + fields: [ + { name: 'a', type: 'text' }, + { name: 'b', type: 'text' }, + { name: 'c', type: 'text' }, + ], + }, + ], + }, + ] + const valueFrom = { a: 'original', b: 'original', c: 'original' } + const valueTo = { a: 'changed', b: 'changed', c: 'original' } + + const result = countChangedFields({ valueFrom, fields, valueTo, locales }) + expect(result).toBe(2) + }) + + it('should count changed fields inside named tabs ', () => { + const fields: ClientField[] = [ + { + type: 'tabs', + tabs: [ + { + name: 'namedTab', + fields: [ + { name: 'a', type: 'text' }, + { name: 'b', type: 'text' }, + { name: 'c', type: 'text' }, + ], + }, + ], + }, + ] + const valueFrom = { namedTab: { a: 'original', b: 'original', c: 'original' } } + const valueTo = { namedTab: { a: 'changed', b: 'changed', c: 'original' } } + + const result = countChangedFields({ valueFrom, fields, valueTo, locales }) + expect(result).toBe(2) + }) + + it('should ignore UI fields', () => { + const fields: ClientField[] = [ + { name: 'a', type: 'text' }, + { + name: 'b', + type: 'ui', + admin: {}, + }, + ] + const valueFrom = { a: 'original', b: 'original' } + const valueTo = { a: 'original', b: 'changed' } + + const result = countChangedFields({ valueFrom, fields, valueTo, locales }) + expect(result).toBe(0) + }) + + it('should count changed fields inside array fields', () => { + const fields: ClientField[] = [ + { + name: 'arrayField', + type: 'array', + fields: [ + { + name: 'a', + type: 'text', + }, + { + name: 'b', + type: 'text', + }, + { + name: 'c', + type: 'text', + }, + ], + }, + ] + const valueFrom = { + arrayField: [ + { a: 'original', b: 'original', c: 'original' }, + { a: 'original', b: 'original' }, + ], + } + const valueTo = { + arrayField: [ + { a: 'changed', b: 'changed', c: 'original' }, + { a: 'changed', b: 'changed', c: 'changed' }, + ], + } + + const result = countChangedFields({ valueFrom, fields, valueTo, locales }) + expect(result).toBe(5) + }) + + it('should count changed fields inside arrays nested inside of collapsibles', () => { + const fields: ClientField[] = [ + { + type: 'collapsible', + label: 'A collapsible field', + fields: [ + { + name: 'arrayField', + type: 'array', + fields: [ + { + name: 'a', + type: 'text', + }, + { + name: 'b', + type: 'text', + }, + { + name: 'c', + type: 'text', + }, + ], + }, + ], + }, + ] + const valueFrom = { arrayField: [{ a: 'original', b: 'original', c: 'original' }] } + const valueTo = { arrayField: [{ a: 'changed', b: 'changed', c: 'original' }] } + + const result = countChangedFields({ valueFrom, fields, valueTo, locales }) + expect(result).toBe(2) + }) + + it('should count changed fields inside blocks fields', () => { + const fields: ClientField[] = [ + { + name: 'blocks', + type: 'blocks', + blocks: [ + { + slug: 'blockA', + fields: [ + { name: 'a', type: 'text' }, + { name: 'b', type: 'text' }, + { name: 'c', type: 'text' }, + ], + }, + ], + }, + ] + const valueFrom = { + blocks: [ + { blockType: 'blockA', a: 'original', b: 'original', c: 'original' }, + { blockType: 'blockA', a: 'original', b: 'original' }, + ], + } + const valueTo = { + blocks: [ + { blockType: 'blockA', a: 'changed', b: 'changed', c: 'original' }, + { blockType: 'blockA', a: 'changed', b: 'changed', c: 'changed' }, + ], + } + + const result = countChangedFields({ valueFrom, fields, valueTo, locales }) + expect(result).toBe(5) + }) + + it('should count changed fields between blocks with different slugs', () => { + const fields: ClientField[] = [ + { + name: 'blocks', + type: 'blocks', + blocks: [ + { + slug: 'blockA', + fields: [ + { name: 'a', type: 'text' }, + { name: 'b', type: 'text' }, + { name: 'c', type: 'text' }, + ], + }, + { + slug: 'blockB', + fields: [ + { name: 'b', type: 'text' }, + { name: 'c', type: 'text' }, + { name: 'd', type: 'text' }, + ], + }, + ], + }, + ] + const valueFrom = { + blocks: [{ blockType: 'blockA', a: 'removed', b: 'original', c: 'original' }], + } + const valueTo = { + blocks: [{ blockType: 'blockB', b: 'original', c: 'changed', d: 'new' }], + } + + const result = countChangedFields({ valueFrom, fields, valueTo, locales }) + expect(result).toBe(3) + }) + + describe('localized fields', () => { + const locales = ['en', 'de'] + it('should count simple localized fields', () => { + const fields: ClientField[] = [ + { name: 'a', type: 'text', localized: true }, + { name: 'b', type: 'text', localized: true }, + ] + const valueFrom = { + a: { en: 'original', de: 'original' }, + b: { en: 'original', de: 'original' }, + } + const valueTo = { + a: { en: 'changed', de: 'original' }, + b: { en: 'original', de: 'original' }, + } + const result = countChangedFields({ valueFrom, fields, valueTo, locales }) + expect(result).toBe(1) + }) + + it('should count multiple locales of the same localized field', () => { + const locales = ['en', 'de'] + const fields: ClientField[] = [ + { name: 'a', type: 'text', localized: true }, + { name: 'b', type: 'text', localized: true }, + ] + const valueFrom = { + a: { en: 'original', de: 'original' }, + b: { en: 'original', de: 'original' }, + } + const valueTo = { + a: { en: 'changed', de: 'changed' }, + b: { en: 'original', de: 'original' }, + } + const result = countChangedFields({ valueFrom, fields, valueTo, locales }) + expect(result).toBe(2) + }) + + it('should count changed fields inside localized groups fields', () => { + const fields: ClientField[] = [ + { + type: 'group', + name: 'group', + localized: true, + fields: [ + { name: 'a', type: 'text' }, + { name: 'b', type: 'text' }, + { name: 'c', type: 'text' }, + ], + }, + ] + const valueFrom = { + group: { + en: { a: 'original', b: 'original', c: 'original' }, + de: { a: 'original', b: 'original', c: 'original' }, + }, + } + const valueTo = { + group: { + en: { a: 'changed', b: 'changed', c: 'original' }, + de: { a: 'original', b: 'changed', c: 'original' }, + }, + } + const result = countChangedFields({ valueFrom, fields, valueTo, locales }) + expect(result).toBe(3) + }) + it('should count changed fields inside localized tabs', () => { + const fields: ClientField[] = [ + { + type: 'tabs', + tabs: [ + { + name: 'tab', + localized: true, + fields: [ + { name: 'a', type: 'text' }, + { name: 'b', type: 'text' }, + { name: 'c', type: 'text' }, + ], + }, + ], + }, + ] + const valueFrom = { + tab: { + en: { a: 'original', b: 'original', c: 'original' }, + de: { a: 'original', b: 'original', c: 'original' }, + }, + } + const valueTo = { + tab: { + en: { a: 'changed', b: 'changed', c: 'original' }, + de: { a: 'original', b: 'changed', c: 'original' }, + }, + } + const result = countChangedFields({ valueFrom, fields, valueTo, locales }) + expect(result).toBe(3) + }) + + it('should count changed fields inside localized array fields', () => { + const fields: ClientField[] = [ + { + name: 'arrayField', + type: 'array', + localized: true, + fields: [ + { + name: 'a', + type: 'text', + }, + { + name: 'b', + type: 'text', + }, + { + name: 'c', + type: 'text', + }, + ], + }, + ] + const valueFrom = { + arrayField: { + en: [{ a: 'original', b: 'original', c: 'original' }], + de: [{ a: 'original', b: 'original', c: 'original' }], + }, + } + const valueTo = { + arrayField: { + en: [{ a: 'changed', b: 'changed', c: 'original' }], + de: [{ a: 'original', b: 'changed', c: 'original' }], + }, + } + const result = countChangedFields({ valueFrom, fields, valueTo, locales }) + expect(result).toBe(3) + }) + + it('should count changed fields inside localized blocks fields', () => { + const fields: ClientField[] = [ + { + name: 'blocks', + type: 'blocks', + localized: true, + blocks: [ + { + slug: 'blockA', + fields: [ + { name: 'a', type: 'text' }, + { name: 'b', type: 'text' }, + { name: 'c', type: 'text' }, + ], + }, + ], + }, + ] + const valueFrom = { + blocks: { + en: [{ blockType: 'blockA', a: 'original', b: 'original', c: 'original' }], + de: [{ blockType: 'blockA', a: 'original', b: 'original', c: 'original' }], + }, + } + const valueTo = { + blocks: { + en: [{ blockType: 'blockA', a: 'changed', b: 'changed', c: 'original' }], + de: [{ blockType: 'blockA', a: 'original', b: 'changed', c: 'original' }], + }, + } + const result = countChangedFields({ valueFrom, fields, valueTo, locales }) + expect(result).toBe(3) + }) + }) +}) + +describe('countChangedFieldsInRows', () => { + it('should count fields in array rows', () => { + const field: ClientField = { + name: 'myArray', + type: 'array', + fields: [ + { name: 'a', type: 'text' }, + { name: 'b', type: 'text' }, + { name: 'c', type: 'text' }, + ], + } + + const valueFromRows = [{ a: 'original', b: 'original', c: 'original' }] + const valueToRows = [{ a: 'changed', b: 'changed', c: 'original' }] + + const result = countChangedFieldsInRows({ + valueFromRows, + field, + locales: undefined, + valueToRows: valueToRows, + }) + expect(result).toBe(2) + }) + + it('should count fields in blocks', () => { + const field: ClientField = { + name: 'myBlocks', + type: 'blocks', + blocks: [ + { + slug: 'blockA', + fields: [ + { name: 'a', type: 'text' }, + { name: 'b', type: 'text' }, + { name: 'c', type: 'text' }, + ], + }, + ], + } + + const valueFromRows = [{ blockType: 'blockA', a: 'original', b: 'original', c: 'original' }] + const valueToRows = [{ blockType: 'blockA', a: 'changed', b: 'changed', c: 'original' }] + + const result = countChangedFieldsInRows({ + valueFromRows, + field, + locales: undefined, + valueToRows, + }) + expect(result).toBe(2) + }) +}) diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.ts b/packages/ui/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.ts new file mode 100644 index 00000000000..3c6fcbd1e33 --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/utilities/countChangedFields.ts @@ -0,0 +1,255 @@ +import type { ArrayFieldClient, BlocksFieldClient, ClientConfig, ClientField } from 'payload' + +import { fieldShouldBeLocalized, groupHasName } from 'payload/shared' + +import { fieldHasChanges } from './fieldHasChanges.js' +import { getFieldsForRowComparison } from './getFieldsForRowComparison.js' + +type Args = { + config: ClientConfig + fields: ClientField[] + locales: string[] | undefined + parentIsLocalized: boolean + valueFrom: unknown + valueTo: unknown +} + +/** + * Recursively counts the number of changed fields between comparison and + * version data for a given set of fields. + */ +export function countChangedFields({ + config, + fields, + locales, + parentIsLocalized, + valueFrom, + valueTo, +}: Args) { + let count = 0 + + fields.forEach((field) => { + // Don't count the id field since it is not displayed in the UI + if ('name' in field && field.name === 'id') { + return + } + const fieldType = field.type + switch (fieldType) { + // Iterable fields are arrays and blocks fields. We iterate over each row and + // count the number of changed fields in each. + case 'array': + case 'blocks': { + if (locales && fieldShouldBeLocalized({ field, parentIsLocalized })) { + locales.forEach((locale) => { + const valueFromRows = valueFrom?.[field.name]?.[locale] ?? [] + const valueToRows = valueTo?.[field.name]?.[locale] ?? [] + count += countChangedFieldsInRows({ + config, + field, + locales, + parentIsLocalized: parentIsLocalized || field.localized, + valueFromRows, + valueToRows, + }) + }) + } else { + const valueFromRows = valueFrom?.[field.name] ?? [] + const valueToRows = valueTo?.[field.name] ?? [] + count += countChangedFieldsInRows({ + config, + field, + locales, + parentIsLocalized: parentIsLocalized || field.localized, + valueFromRows, + valueToRows, + }) + } + break + } + + // Regular fields without nested fields. + case 'checkbox': + case 'code': + case 'date': + case 'email': + case 'join': + case 'json': + case 'number': + case 'point': + case 'radio': + case 'relationship': + case 'richText': + case 'select': + case 'text': + case 'textarea': + case 'upload': { + // Fields that have a name and contain data. We can just check if the data has changed. + if (locales && fieldShouldBeLocalized({ field, parentIsLocalized })) { + locales.forEach((locale) => { + if ( + fieldHasChanges(valueTo?.[field.name]?.[locale], valueFrom?.[field.name]?.[locale]) + ) { + count++ + } + }) + } else if (fieldHasChanges(valueTo?.[field.name], valueFrom?.[field.name])) { + count++ + } + break + } + // Fields that have nested fields, but don't nest their fields' data. + case 'collapsible': + case 'row': { + count += countChangedFields({ + config, + fields: field.fields, + locales, + parentIsLocalized: parentIsLocalized || field.localized, + valueFrom, + valueTo, + }) + + break + } + + // Fields that have nested fields and nest their fields' data. + case 'group': { + if (groupHasName(field)) { + if (locales && fieldShouldBeLocalized({ field, parentIsLocalized })) { + locales.forEach((locale) => { + count += countChangedFields({ + config, + fields: field.fields, + locales, + parentIsLocalized: parentIsLocalized || field.localized, + valueFrom: valueFrom?.[field.name]?.[locale], + valueTo: valueTo?.[field.name]?.[locale], + }) + }) + } else { + count += countChangedFields({ + config, + fields: field.fields, + locales, + parentIsLocalized: parentIsLocalized || field.localized, + valueFrom: valueFrom?.[field.name], + valueTo: valueTo?.[field.name], + }) + } + } else { + // Unnamed group field: data is NOT nested under `field.name` + count += countChangedFields({ + config, + fields: field.fields, + locales, + parentIsLocalized: parentIsLocalized || field.localized, + valueFrom, + valueTo, + }) + } + break + } + + // Each tab in a tabs field has nested fields. The fields data may be + // nested or not depending on the existence of a name property. + case 'tabs': { + field.tabs.forEach((tab) => { + if ('name' in tab && locales && tab.localized) { + // Named localized tab + locales.forEach((locale) => { + count += countChangedFields({ + config, + fields: tab.fields, + locales, + parentIsLocalized: parentIsLocalized || tab.localized, + valueFrom: valueFrom?.[tab.name]?.[locale], + valueTo: valueTo?.[tab.name]?.[locale], + }) + }) + } else if ('name' in tab) { + // Named tab + count += countChangedFields({ + config, + fields: tab.fields, + locales, + parentIsLocalized: parentIsLocalized || tab.localized, + valueFrom: valueFrom?.[tab.name], + valueTo: valueTo?.[tab.name], + }) + } else { + // Unnamed tab + count += countChangedFields({ + config, + fields: tab.fields, + locales, + parentIsLocalized: parentIsLocalized || tab.localized, + valueFrom, + valueTo, + }) + } + }) + break + } + + // UI fields don't have data and are not displayed in the version view + // so we can ignore them. + case 'ui': { + break + } + + default: { + const _exhaustiveCheck: never = fieldType + throw new Error(`Unexpected field.type in countChangedFields : ${String(fieldType)}`) + } + } + }) + + return count +} + +type countChangedFieldsInRowsArgs = { + config: ClientConfig + field: ArrayFieldClient | BlocksFieldClient + locales: string[] | undefined + parentIsLocalized: boolean + valueFromRows: unknown[] + valueToRows: unknown[] +} + +export function countChangedFieldsInRows({ + config, + field, + locales, + parentIsLocalized, + valueFromRows = [], + valueToRows = [], +}: countChangedFieldsInRowsArgs) { + let count = 0 + let i = 0 + + while (valueFromRows[i] || valueToRows[i]) { + const valueFromRow = valueFromRows?.[i] || {} + const valueToRow = valueToRows?.[i] || {} + + const { fields: rowFields } = getFieldsForRowComparison({ + baseVersionField: { type: 'text', fields: [], path: '', schemaPath: '' }, // Doesn't matter, as we don't need the versionFields output here + config, + field, + row: i, + valueFromRow, + valueToRow, + }) + + count += countChangedFields({ + config, + fields: rowFields, + locales, + parentIsLocalized: parentIsLocalized || field.localized, + valueFrom: valueFromRow, + valueTo: valueToRow, + }) + + i++ + } + return count +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/utilities/fieldHasChanges.spec.ts b/packages/ui/src/views/Version/RenderFieldsToDiff/utilities/fieldHasChanges.spec.ts new file mode 100644 index 00000000000..72d85c1cb6c --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/utilities/fieldHasChanges.spec.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest' + +import { fieldHasChanges } from './fieldHasChanges.js' + +describe('hasChanges', () => { + it('should return false for identical values', () => { + const a = 'value' + const b = 'value' + expect(fieldHasChanges(a, b)).toBe(false) + }) + it('should return true for different values', () => { + const a = 1 + const b = 2 + expect(fieldHasChanges(a, b)).toBe(true) + }) + + it('should return false for identical objects', () => { + const a = { key: 'value' } + const b = { key: 'value' } + expect(fieldHasChanges(a, b)).toBe(false) + }) + + it('should return true for different objects', () => { + const a = { key: 'value' } + const b = { key: 'differentValue' } + expect(fieldHasChanges(a, b)).toBe(true) + }) + + it('should handle undefined values', () => { + const a = { key: 'value' } + const b = undefined + expect(fieldHasChanges(a, b)).toBe(true) + }) + + it('should handle null values', () => { + const a = { key: 'value' } + const b = null + expect(fieldHasChanges(a, b)).toBe(true) + }) +}) diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/utilities/fieldHasChanges.ts b/packages/ui/src/views/Version/RenderFieldsToDiff/utilities/fieldHasChanges.ts new file mode 100644 index 00000000000..fa2a0659b7d --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/utilities/fieldHasChanges.ts @@ -0,0 +1,3 @@ +export function fieldHasChanges(a: unknown, b: unknown) { + return JSON.stringify(a) !== JSON.stringify(b) +} diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison.spec.ts b/packages/ui/src/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison.spec.ts new file mode 100644 index 00000000000..44a8216a102 --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison.spec.ts @@ -0,0 +1,108 @@ +import type { ArrayFieldClient, BlocksFieldClient, ClientField } from 'payload' +import { describe, it, expect } from 'vitest' + +import { getFieldsForRowComparison } from './getFieldsForRowComparison' + +describe('getFieldsForRowComparison', () => { + describe('array fields', () => { + it('should return fields from array field', () => { + const arrayFields: ClientField[] = [ + { name: 'title', type: 'text' }, + { name: 'description', type: 'textarea' }, + ] + + const field: ArrayFieldClient = { + type: 'array', + name: 'items', + fields: arrayFields, + } + + const { fields } = getFieldsForRowComparison({ + field, + valueToRow: {}, + valueFromRow: {}, + row: 0, + baseVersionField: { fields: [], path: 'items', schemaPath: 'items', type: 'array' }, + config: {} as any, + }) + + expect(fields).toEqual(arrayFields) + }) + }) + + describe('blocks fields', () => { + it('should return combined fields when block types match', () => { + const blockAFields: ClientField[] = [ + { name: 'a', type: 'text' }, + { name: 'b', type: 'text' }, + ] + + const field: BlocksFieldClient = { + type: 'blocks', + name: 'myBlocks', + blocks: [ + { + slug: 'blockA', + fields: blockAFields, + }, + ], + } + + const valueToRow = { blockType: 'blockA' } + const valueFromRow = { blockType: 'blockA' } + + const { fields } = getFieldsForRowComparison({ + field, + valueToRow, + valueFromRow, + row: 0, + baseVersionField: { fields: [], path: 'myBlocks', schemaPath: 'myBlocks', type: 'blocks' }, + config: {} as any, + }) + + expect(fields).toEqual(blockAFields) + }) + + it('should return unique combined fields when block types differ', () => { + const field: BlocksFieldClient = { + type: 'blocks', + name: 'myBlocks', + blocks: [ + { + slug: 'blockA', + fields: [ + { name: 'a', type: 'text' }, + { name: 'b', type: 'text' }, + ], + }, + { + slug: 'blockB', + fields: [ + { name: 'b', type: 'text' }, + { name: 'c', type: 'text' }, + ], + }, + ], + } + + const valueToRow = { blockType: 'blockA' } + const valueFromRow = { blockType: 'blockB' } + + const { fields } = getFieldsForRowComparison({ + field, + valueToRow, + valueFromRow, + row: 0, + baseVersionField: { fields: [], path: 'myBlocks', schemaPath: 'myBlocks', type: 'blocks' }, + config: {} as any, + }) + + // Should contain all unique fields from both blocks + expect(fields).toEqual([ + { name: 'a', type: 'text' }, + { name: 'b', type: 'text' }, + { name: 'c', type: 'text' }, + ]) + }) + }) +}) diff --git a/packages/ui/src/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison.ts b/packages/ui/src/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison.ts new file mode 100644 index 00000000000..4ef258c097f --- /dev/null +++ b/packages/ui/src/views/Version/RenderFieldsToDiff/utilities/getFieldsForRowComparison.ts @@ -0,0 +1,89 @@ +import type { + ArrayFieldClient, + BaseVersionField, + BlocksFieldClient, + ClientBlock, + ClientConfig, + ClientField, + VersionField, +} from 'payload' + +import { getUniqueListBy } from 'payload/shared' + +/** + * Get the fields for a row in an iterable field for comparison. + * - Array fields: the fields of the array field, because the fields are the same for each row. + * - Blocks fields: the union of fields from the comparison and version row, + * because the fields from the version and comparison rows may differ. + */ +export function getFieldsForRowComparison({ + baseVersionField, + config, + field, + row, + valueFromRow, + valueToRow, +}: { + baseVersionField: BaseVersionField + config: ClientConfig + field: ArrayFieldClient | BlocksFieldClient + row: number + valueFromRow: any + valueToRow: any +}): { fields: ClientField[]; versionFields: VersionField[] } { + let fields: ClientField[] = [] + let versionFields: VersionField[] = [] + + if (field.type === 'array' && 'fields' in field) { + fields = field.fields + versionFields = baseVersionField.rows?.length + ? baseVersionField.rows[row] + : baseVersionField.fields + } else if (field.type === 'blocks') { + if (valueToRow?.blockType === valueFromRow?.blockType) { + const matchedBlock: ClientBlock = + config?.blocksMap?.[valueToRow?.blockType] ?? + (((('blocks' in field || 'blockReferences' in field) && + (field.blockReferences ?? field.blocks)?.find( + (block) => typeof block !== 'string' && block.slug === valueToRow?.blockType, + )) || { + fields: [], + }) as ClientBlock) + + fields = matchedBlock.fields + versionFields = baseVersionField.rows?.length + ? baseVersionField.rows[row] + : baseVersionField.fields + } else { + const matchedVersionBlock = + config?.blocksMap?.[valueToRow?.blockType] ?? + (((('blocks' in field || 'blockReferences' in field) && + (field.blockReferences ?? field.blocks)?.find( + (block) => typeof block !== 'string' && block.slug === valueToRow?.blockType, + )) || { + fields: [], + }) as ClientBlock) + + const matchedComparisonBlock = + config?.blocksMap?.[valueFromRow?.blockType] ?? + (((('blocks' in field || 'blockReferences' in field) && + (field.blockReferences ?? field.blocks)?.find( + (block) => typeof block !== 'string' && block.slug === valueFromRow?.blockType, + )) || { + fields: [], + }) as ClientBlock) + + fields = getUniqueListBy( + [...matchedVersionBlock.fields, ...matchedComparisonBlock.fields], + 'name', + ) + + // buildVersionFields already merged the fields of the version and comparison rows together + versionFields = baseVersionField.rows?.length + ? baseVersionField.rows[row] + : baseVersionField.fields + } + } + + return { fields, versionFields } +} diff --git a/packages/ui/src/views/Version/SelectComparison/VersionDrawer/CreatedAtCell.tsx b/packages/ui/src/views/Version/SelectComparison/VersionDrawer/CreatedAtCell.tsx new file mode 100644 index 00000000000..e9b68076cd0 --- /dev/null +++ b/packages/ui/src/views/Version/SelectComparison/VersionDrawer/CreatedAtCell.tsx @@ -0,0 +1,48 @@ +'use client' +import type { CreatedAtCellProps } from '../../../Versions/cells/CreatedAt/index.js' + +import { useModal } from '../../../../elements/Modal/index.js' +import { useConfig } from '../../../../providers/Config/index.js' +import { usePathname, useRouter, useSearchParams } from '../../../../providers/Router/index.js' +import { useRouteTransition } from '../../../../providers/RouteTransition/index.js' +import { useTranslation } from '../../../../providers/Translation/index.js' +import { formatDate } from '../../../../utilities/formatDocTitle/formatDateTitle.js' + +export const VersionDrawerCreatedAtCell: React.FC = ({ + rowData: { id, updatedAt } = {}, +}) => { + const { + config: { + admin: { dateFormat }, + }, + } = useConfig() + const { closeAllModals } = useModal() + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + const { startRouteTransition } = useRouteTransition() + + const { i18n } = useTranslation() + + return ( + + ) +} diff --git a/packages/ui/src/views/Version/SelectComparison/VersionDrawer/index.scss b/packages/ui/src/views/Version/SelectComparison/VersionDrawer/index.scss new file mode 100644 index 00000000000..e1a63564ee6 --- /dev/null +++ b/packages/ui/src/views/Version/SelectComparison/VersionDrawer/index.scss @@ -0,0 +1,18 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .version-drawer { + .table { + width: 100%; + } + + .created-at-cell { + // Button reset, + underline + background: none; + border: none; + cursor: pointer; + padding: 0; + text-decoration: underline; + } + } +} diff --git a/packages/ui/src/views/Version/SelectComparison/VersionDrawer/index.tsx b/packages/ui/src/views/Version/SelectComparison/VersionDrawer/index.tsx new file mode 100644 index 00000000000..7da1f159808 --- /dev/null +++ b/packages/ui/src/views/Version/SelectComparison/VersionDrawer/index.tsx @@ -0,0 +1,197 @@ +'use client' +import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import { toast } from 'sonner' + +import { Drawer } from '../../../../elements/Drawer/index.js' +import { LoadingOverlay } from '../../../../elements/Loading/index.js' +import { useModal } from '../../../../elements/Modal/index.js' +import { useDocumentInfo } from '../../../../providers/DocumentInfo/index.js' +import { useEditDepth } from '../../../../providers/EditDepth/index.js' +import { useSearchParams } from '../../../../providers/Router/index.js' +import { useServerFunctions } from '../../../../providers/ServerFunctions/index.js' +import './index.scss' +import { useTranslation } from '../../../../providers/Translation/index.js' + +export const baseClass = 'version-drawer' +export const formatVersionDrawerSlug = ({ + depth, + uuid, +}: { + depth: number + uuid: string // supply when creating a new document and no id is available +}) => `version-drawer_${depth}_${uuid}` + +export const VersionDrawerContent: React.FC<{ + collectionSlug?: string + docID?: number | string + drawerSlug: string + globalSlug?: string +}> = (props) => { + const { collectionSlug, docID, drawerSlug, globalSlug } = props + const { isTrashed } = useDocumentInfo() + const { closeModal } = useModal() + const searchParams = useSearchParams() + const prevSearchParams = useRef(searchParams) + + const { renderDocument } = useServerFunctions() + + const [DocumentView, setDocumentView] = useState(undefined) + const [isLoading, setIsLoading] = useState(true) + const hasRenderedDocument = useRef(false) + const { t } = useTranslation() + + const getDocumentView = useCallback( + (docID?: number | string) => { + const fetchDocumentView = async () => { + setIsLoading(true) + + try { + const isGlobal = Boolean(globalSlug) + const entitySlug = collectionSlug ?? globalSlug + + const result = await renderDocument({ + collectionSlug: entitySlug, + docID, + drawerSlug, + paramsOverride: { + segments: [ + isGlobal ? 'globals' : 'collections', + entitySlug, + ...(isTrashed ? ['trash'] : []), + isGlobal ? undefined : String(docID), + 'versions', + ].filter(Boolean), + }, + redirectAfterDelete: false, + redirectAfterDuplicate: false, + searchParams: Object.fromEntries(searchParams.entries()), + versions: { + disableGutter: true, + useVersionDrawerCreatedAtCell: true, + }, + }) + + if (result?.Document) { + setDocumentView(result.Document) + setIsLoading(false) + } + } catch (error) { + toast.error(error?.message || t('error:unspecific')) + closeModal(drawerSlug) + // toast.error(data?.errors?.[0].message || t('error:unspecific')) + } + } + + void fetchDocumentView() + }, + [ + closeModal, + collectionSlug, + drawerSlug, + globalSlug, + isTrashed, + renderDocument, + searchParams, + t, + ], + ) + + useEffect(() => { + if (!hasRenderedDocument.current || prevSearchParams.current !== searchParams) { + prevSearchParams.current = searchParams + getDocumentView(docID) + hasRenderedDocument.current = true + } + }, [docID, getDocumentView, searchParams]) + + if (isLoading) { + return + } + + return DocumentView +} +export const VersionDrawer: React.FC<{ + collectionSlug?: string + docID?: number | string + drawerSlug: string + globalSlug?: string +}> = (props) => { + const { collectionSlug, docID, drawerSlug, globalSlug } = props + const { t } = useTranslation() + + return ( + + + + ) +} + +export const useVersionDrawer = ({ + collectionSlug, + docID, + globalSlug, +}: { + collectionSlug?: string + docID?: number | string + globalSlug?: string +}) => { + const drawerDepth = useEditDepth() + const uuid = useId() + const { closeModal, modalState, openModal, toggleModal } = useModal() + const [isOpen, setIsOpen] = useState(false) + + const drawerSlug = formatVersionDrawerSlug({ + depth: drawerDepth, + uuid, + }) + + useEffect(() => { + setIsOpen(Boolean(modalState[drawerSlug]?.isOpen)) + }, [modalState, drawerSlug]) + + const toggleDrawer = useCallback(() => { + toggleModal(drawerSlug) + }, [toggleModal, drawerSlug]) + + const closeDrawer = useCallback(() => { + closeModal(drawerSlug) + }, [drawerSlug, closeModal]) + + const openDrawer = useCallback(() => { + openModal(drawerSlug) + }, [drawerSlug, openModal]) + + const MemoizedDrawer = useMemo(() => { + return () => ( + + ) + }, [collectionSlug, docID, drawerSlug, globalSlug]) + + return useMemo( + () => ({ + closeDrawer, + Drawer: MemoizedDrawer, + drawerDepth, + drawerSlug, + isDrawerOpen: isOpen, + openDrawer, + toggleDrawer, + }), + [MemoizedDrawer, closeDrawer, drawerDepth, drawerSlug, isOpen, openDrawer, toggleDrawer], + ) +} diff --git a/packages/ui/src/views/Version/SelectComparison/index.scss b/packages/ui/src/views/Version/SelectComparison/index.scss new file mode 100644 index 00000000000..f8c75f45196 --- /dev/null +++ b/packages/ui/src/views/Version/SelectComparison/index.scss @@ -0,0 +1,9 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .compare-version { + &-moreVersions { + color: var(--theme-elevation-500); + } + } +} diff --git a/packages/ui/src/views/Version/SelectComparison/index.tsx b/packages/ui/src/views/Version/SelectComparison/index.tsx new file mode 100644 index 00000000000..d2c837d5fca --- /dev/null +++ b/packages/ui/src/views/Version/SelectComparison/index.tsx @@ -0,0 +1,68 @@ +'use client' + +import React, { memo, useCallback, useMemo } from 'react' + +import type { CompareOption } from '../Default/types.js' +import type { Props } from './types.js' + +import { ReactSelect } from '../../../elements/ReactSelect/index.js' +import { fieldBaseClass } from '../../../fields/shared/index.js' +import './index.scss' +import { useTranslation } from '../../../providers/Translation/index.js' +import { useVersionDrawer } from './VersionDrawer/index.js' + +const baseClass = 'compare-version' + +export const SelectComparison: React.FC = memo((props) => { + const { + collectionSlug, + docID, + globalSlug, + onChange: onChangeFromProps, + versionFromID, + versionFromOptions, + } = props + const { t } = useTranslation() + + const { Drawer, openDrawer } = useVersionDrawer({ collectionSlug, docID, globalSlug }) + + const options = useMemo(() => { + return [ + ...versionFromOptions, + { + label: {t('version:moreVersions')}, + value: 'more', + }, + ] + }, [t, versionFromOptions]) + + const currentOption = useMemo( + () => versionFromOptions.find((option) => option.value === versionFromID), + [versionFromOptions, versionFromID], + ) + + const onChange = useCallback( + (val: CompareOption) => { + if (val.value === 'more') { + openDrawer() + return + } + onChangeFromProps(val) + }, + [onChangeFromProps, openDrawer], + ) + + return ( +
+ + +
+ ) +}) diff --git a/packages/ui/src/views/Version/SelectComparison/types.ts b/packages/ui/src/views/Version/SelectComparison/types.ts new file mode 100644 index 00000000000..703b73b12c4 --- /dev/null +++ b/packages/ui/src/views/Version/SelectComparison/types.ts @@ -0,0 +1,30 @@ +import type { PaginatedDocs, SanitizedCollectionConfig } from 'payload' + +import type { CompareOption } from '../Default/types.js' + +export type Props = { + collectionSlug?: string + docID?: number | string + globalSlug?: string + onChange: (val: CompareOption) => void + versionFromID?: string + versionFromOptions: CompareOption[] +} + +type CLEAR = { + required: boolean + type: 'CLEAR' +} + +type ADD = { + collection: SanitizedCollectionConfig + data: PaginatedDocs + type: 'ADD' +} + +export type Action = ADD | CLEAR + +export type ValueWithRelation = { + relationTo: string + value: string +} diff --git a/packages/ui/src/views/Version/VersionPillLabel/VersionPillLabel.tsx b/packages/ui/src/views/Version/VersionPillLabel/VersionPillLabel.tsx new file mode 100644 index 00000000000..aa60086ad1c --- /dev/null +++ b/packages/ui/src/views/Version/VersionPillLabel/VersionPillLabel.tsx @@ -0,0 +1,124 @@ +'use client' + +import type { TypeWithVersion } from 'payload' + +import React from 'react' + +import { Pill } from '../../../elements/Pill/index.js' +import { useConfig } from '../../../providers/Config/index.js' +import { useLocale } from '../../../providers/Locale/index.js' +import { useTranslation } from '../../../providers/Translation/index.js' +import { formatDate } from '../../../utilities/formatDocTitle/formatDateTitle.js' +import './index.scss' +import { getVersionLabel } from './getVersionLabel.js' + +const baseClass = 'version-pill-label' + +const renderPill = (label: React.ReactNode, pillStyle: Parameters[0]['pillStyle']) => { + return ( + + {label} + + ) +} + +export const VersionPillLabel: React.FC<{ + currentlyPublishedVersion?: TypeWithVersion + disableDate?: boolean + + doc: { + [key: string]: unknown + id: number | string + publishedLocale?: string + updatedAt?: string + version: { + [key: string]: unknown + _status: 'draft' | 'published' + updatedAt: string + } + } + /** + * By default, the date is displayed first, followed by the version label. + * @default false + */ + labelFirst?: boolean + labelOverride?: React.ReactNode + /** + * @default 'pill' + */ + labelStyle?: 'pill' | 'text' + labelSuffix?: React.ReactNode + latestDraftVersion?: TypeWithVersion +}> = ({ + currentlyPublishedVersion, + disableDate = false, + doc, + labelFirst = false, + labelOverride, + labelStyle = 'pill', + labelSuffix, + latestDraftVersion, +}) => { + const { + config: { + admin: { dateFormat }, + localization, + }, + } = useConfig() + const { i18n, t } = useTranslation() + const { code: currentLocale } = useLocale() + + const { label, pillStyle } = getVersionLabel({ + currentLocale, + currentlyPublishedVersion, + latestDraftVersion, + t, + version: doc, + }) + const labelText: React.ReactNode = ( + + {labelOverride || label} + {labelSuffix} + + ) + + const showDate = !disableDate && doc.updatedAt + const formattedDate = showDate + ? formatDate({ date: doc.updatedAt, i18n, pattern: dateFormat }) + : null + + const localeCode = Array.isArray(doc.publishedLocale) + ? doc.publishedLocale[0] + : doc.publishedLocale + + const locale = + localization && localization?.locales + ? localization.locales.find((loc) => loc.code === localeCode) + : null + const localeLabel = locale ? locale?.label?.[i18n?.language] || locale?.label : null + + return ( +
+ {labelFirst ? ( + + {labelStyle === 'pill' ? ( + renderPill(labelText, pillStyle) + ) : ( + {labelText} + )} + {showDate && {formattedDate}} + + ) : ( + + {showDate && {formattedDate}} + {labelStyle === 'pill' ? ( + renderPill(labelText, pillStyle) + ) : ( + {labelText} + )} + + )} + {localeLabel && {localeLabel}} +
+ ) +} diff --git a/packages/ui/src/views/Version/VersionPillLabel/getVersionLabel.ts b/packages/ui/src/views/Version/VersionPillLabel/getVersionLabel.ts new file mode 100644 index 00000000000..1b57fc022e2 --- /dev/null +++ b/packages/ui/src/views/Version/VersionPillLabel/getVersionLabel.ts @@ -0,0 +1,86 @@ +import type { TFunction } from '@payloadcms/translations' + +import type { Pill } from '../../../elements/Pill/index.js' + +type Args = { + currentLocale?: string + currentlyPublishedVersion?: { + id: number | string + publishedLocale?: string + updatedAt: string + version: { + updatedAt: string + } + } + latestDraftVersion?: { + id: number | string + updatedAt: string + } + t: TFunction + version: { + id: number | string + publishedLocale?: string + version: { _status?: 'draft' | 'published'; updatedAt: string } + } +} + +/** + * Gets the appropriate version label and version pill styling + * given existing versions and the current version status. + */ +export function getVersionLabel({ + currentLocale, + currentlyPublishedVersion, + latestDraftVersion, + t, + version, +}: Args): { + label: string + name: 'currentDraft' | 'currentlyPublished' | 'draft' | 'previouslyPublished' | 'published' + pillStyle: Parameters[0]['pillStyle'] +} { + const status = version.version._status + + if (status === 'draft') { + const publishedNewerThanDraft = + currentlyPublishedVersion?.updatedAt > latestDraftVersion?.updatedAt + + if (publishedNewerThanDraft) { + return { + name: 'draft', + label: t('version:draft'), + pillStyle: 'light', + } + } + + const isCurrentDraft = version.id === latestDraftVersion?.id + + return { + name: isCurrentDraft ? 'currentDraft' : 'draft', + label: isCurrentDraft ? t('version:currentDraft') : t('version:draft'), + pillStyle: 'light', + } + } + + const publishedInAnotherLocale = + status === 'published' && version.publishedLocale && currentLocale !== version.publishedLocale + + if (publishedInAnotherLocale) { + return { + name: 'currentDraft', + label: t('version:currentDraft'), + pillStyle: 'light', + } + } + + const isCurrentlyPublished = + currentlyPublishedVersion && version.id === currentlyPublishedVersion.id + + return { + name: isCurrentlyPublished ? 'currentlyPublished' : 'previouslyPublished', + label: isCurrentlyPublished + ? t('version:currentlyPublished') + : t('version:previouslyPublished'), + pillStyle: isCurrentlyPublished ? 'success' : 'light', + } +} diff --git a/packages/ui/src/views/Version/VersionPillLabel/index.scss b/packages/ui/src/views/Version/VersionPillLabel/index.scss new file mode 100644 index 00000000000..fb6a15c6b39 --- /dev/null +++ b/packages/ui/src/views/Version/VersionPillLabel/index.scss @@ -0,0 +1,26 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .version-pill-label { + display: flex; + align-items: center; + gap: calc(var(--base) / 2); + + &-text { + font-weight: 500; + } + + &-date { + color: var(--theme-elevation-500); + } + } + + @include small-break { + .version-pill-label { + // Column + flex-direction: column; + align-items: flex-start; + gap: 0; + } + } +} diff --git a/packages/ui/src/views/Version/fetchVersions.ts b/packages/ui/src/views/Version/fetchVersions.ts new file mode 100644 index 00000000000..858f40f3964 --- /dev/null +++ b/packages/ui/src/views/Version/fetchVersions.ts @@ -0,0 +1,206 @@ +import { + logError, + type PaginatedDocs, + type PayloadRequest, + type SelectType, + type Sort, + type TypedUser, + type TypeWithVersion, + type Where, +} from 'payload' + +export const fetchVersion = async ({ + id, + collectionSlug, + depth, + globalSlug, + locale, + overrideAccess, + req, + select, + user, +}: { + collectionSlug?: string + depth?: number + globalSlug?: string + id: number | string + locale?: 'all' | ({} & string) + overrideAccess?: boolean + req: PayloadRequest + select?: SelectType + user?: TypedUser +}): Promise> => { + try { + if (collectionSlug) { + return (await req.payload.findVersionByID({ + id: String(id), + collection: collectionSlug, + depth, + locale, + overrideAccess, + req, + select, + user, + })) as TypeWithVersion + } else if (globalSlug) { + return (await req.payload.findGlobalVersionByID({ + id: String(id), + slug: globalSlug, + depth, + locale, + overrideAccess, + req, + select, + user, + })) as TypeWithVersion + } + } catch (err) { + logError({ err, payload: req.payload }) + return null + } +} + +export const fetchVersions = async ({ + collectionSlug, + depth, + draft, + globalSlug, + limit, + locale, + overrideAccess, + page, + parentID, + req, + select, + sort, + user, + where: whereFromArgs, +}: { + collectionSlug?: string + depth?: number + draft?: boolean + globalSlug?: string + limit?: number + locale?: 'all' | ({} & string) + overrideAccess?: boolean + page?: number + parentID?: number | string + req: PayloadRequest + select?: SelectType + sort?: Sort + user?: TypedUser + where?: Where +}): Promise>> => { + const where: Where = { and: [...(whereFromArgs ? [whereFromArgs] : [])] } + + try { + if (collectionSlug) { + if (parentID) { + where.and.push({ + parent: { + equals: parentID, + }, + }) + } + return (await req.payload.findVersions({ + collection: collectionSlug, + depth, + draft, + limit, + locale, + overrideAccess, + page, + req, + select, + sort, + user, + where, + })) as PaginatedDocs> + } else if (globalSlug) { + return (await req.payload.findGlobalVersions({ + slug: globalSlug, + depth, + limit, + locale, + overrideAccess, + page, + req, + select, + sort, + user, + where, + })) as PaginatedDocs> + } + } catch (err) { + logError({ err, payload: req.payload }) + + return null + } +} + +export const fetchLatestVersion = async ({ + collectionSlug, + depth, + globalSlug, + locale, + overrideAccess, + parentID, + req, + select, + status, + user, + where, +}: { + collectionSlug?: string + depth?: number + globalSlug?: string + locale?: 'all' | ({} & string) + overrideAccess?: boolean + parentID?: number | string + req: PayloadRequest + select?: SelectType + status: 'draft' | 'published' + user?: TypedUser + where?: Where +}): Promise> => { + // Get the entity config to check if drafts are enabled + const entityConfig = collectionSlug + ? req.payload.collections[collectionSlug]?.config + : globalSlug + ? req.payload.globals[globalSlug]?.config + : undefined + + // Only query by _status if drafts are enabled (since _status field only exists with drafts) + const draftsEnabled = entityConfig?.versions?.drafts + + const and: Where[] = [ + ...(draftsEnabled + ? [ + { + 'version._status': { + equals: status, + }, + }, + ] + : []), + ...(where ? [where] : []), + ] + + const latest = await fetchVersions({ + collectionSlug, + depth, + draft: true, + globalSlug, + limit: 1, + locale, + overrideAccess, + parentID, + req, + select, + sort: '-updatedAt', + user, + where: { and }, + }) + + return latest?.docs?.length ? (latest.docs[0] as TypeWithVersion) : null +} diff --git a/packages/ui/src/views/Versions/buildColumns.tsx b/packages/ui/src/views/Versions/buildColumns.tsx new file mode 100644 index 00000000000..c7f1c00d8a6 --- /dev/null +++ b/packages/ui/src/views/Versions/buildColumns.tsx @@ -0,0 +1,105 @@ +import type { I18n } from '@payloadcms/translations' +import type { + Column, + PaginatedDocs, + SanitizedCollectionConfig, + SanitizedGlobalConfig, + TypeWithVersion, +} from 'payload' + +import { hasDraftsEnabled } from 'payload/shared' +import React from 'react' + +import { SortColumn } from '../../elements/SortColumn/index.js' +import { AutosaveCell } from './cells/AutosaveCell/index.js' +import { CreatedAtCell, type CreatedAtCellProps } from './cells/CreatedAt/index.js' +import { IDCell } from './cells/ID/index.js' + +export const buildVersionColumns = ({ + collectionConfig, + CreatedAtCellOverride, + currentlyPublishedVersion, + docID, + docs, + globalConfig, + i18n: { t }, + isTrashed, + latestDraftVersion, +}: { + collectionConfig?: SanitizedCollectionConfig + CreatedAtCellOverride?: React.ComponentType + currentlyPublishedVersion?: TypeWithVersion + docID?: number | string + docs: PaginatedDocs>['docs'] + globalConfig?: SanitizedGlobalConfig + i18n: I18n + isTrashed?: boolean + latestDraftVersion?: TypeWithVersion +}): Column[] => { + const entityConfig = collectionConfig || globalConfig + + const CreatedAtCellComponent = CreatedAtCellOverride ?? CreatedAtCell + + const columns: Column[] = [ + { + accessor: 'updatedAt', + active: true, + field: { + name: '', + type: 'date', + }, + Heading: , + renderedCells: docs.map((doc, i) => { + return ( + + ) + }), + }, + { + accessor: 'id', + active: true, + field: { + name: '', + type: 'text', + }, + Heading: , + renderedCells: docs.map((doc, i) => { + return + }), + }, + ] + + if (hasDraftsEnabled(entityConfig)) { + columns.push({ + accessor: '_status', + active: true, + field: { + name: '', + type: 'checkbox', + }, + Heading: , + renderedCells: docs.map((doc, i) => { + return ( + + ) + }), + }) + } + + return columns +} diff --git a/packages/ui/src/views/Versions/cells/AutosaveCell/index.scss b/packages/ui/src/views/Versions/cells/AutosaveCell/index.scss new file mode 100644 index 00000000000..b4d1d7099c5 --- /dev/null +++ b/packages/ui/src/views/Versions/cells/AutosaveCell/index.scss @@ -0,0 +1,9 @@ +@layer payload-default { + .autosave-cell { + &__items { + display: flex; + align-items: center; + gap: calc(var(--base) * 0.5); + } + } +} diff --git a/packages/ui/src/views/Versions/cells/AutosaveCell/index.tsx b/packages/ui/src/views/Versions/cells/AutosaveCell/index.tsx new file mode 100644 index 00000000000..0c5a4d01af9 --- /dev/null +++ b/packages/ui/src/views/Versions/cells/AutosaveCell/index.tsx @@ -0,0 +1,49 @@ +'use client' +import type { TypeWithVersion } from 'payload' + +import React from 'react' + +import { Pill } from '../../../../elements/Pill/index.js' +import { useTranslation } from '../../../../providers/Translation/index.js' +import { VersionPillLabel } from '../../../Version/VersionPillLabel/VersionPillLabel.js' +import './index.scss' + +const baseClass = 'autosave-cell' + +type AutosaveCellProps = { + currentlyPublishedVersion?: TypeWithVersion + latestDraftVersion?: TypeWithVersion + rowData: { + autosave?: boolean + id: number | string + publishedLocale?: string + updatedAt?: string + version: { + [key: string]: unknown + _status: 'draft' | 'published' + updatedAt: string + } + } +} + +export const AutosaveCell: React.FC = ({ + currentlyPublishedVersion, + latestDraftVersion, + rowData, +}) => { + const { t } = useTranslation() + + return ( +
+ {rowData?.autosave && {t('version:autosave')}} + +
+ ) +} diff --git a/packages/ui/src/views/Versions/cells/CreatedAt/index.tsx b/packages/ui/src/views/Versions/cells/CreatedAt/index.tsx new file mode 100644 index 00000000000..5d7512f60be --- /dev/null +++ b/packages/ui/src/views/Versions/cells/CreatedAt/index.tsx @@ -0,0 +1,60 @@ +'use client' +import { formatAdminURL } from 'payload/shared' +import React from 'react' + +import { useConfig } from '../../../../providers/Config/index.js' +import { Link } from '../../../../providers/Router/index.js' +import { useTranslation } from '../../../../providers/Translation/index.js' +import { formatDate } from '../../../../utilities/formatDocTitle/formatDateTitle.js' + +export type CreatedAtCellProps = { + collectionSlug?: string + docID?: number | string + globalSlug?: string + isTrashed?: boolean + rowData?: { + id: number | string + updatedAt: Date | number | string + } +} + +export const CreatedAtCell: React.FC = ({ + collectionSlug, + docID, + globalSlug, + isTrashed, + rowData: { id, updatedAt } = {}, +}) => { + const { + config: { + admin: { dateFormat }, + routes: { admin: adminRoute }, + }, + } = useConfig() + + const { i18n } = useTranslation() + + const trashedDocPrefix = isTrashed ? 'trash/' : '' + + let to: string + + if (collectionSlug) { + to = formatAdminURL({ + adminRoute, + path: `/collections/${collectionSlug}/${trashedDocPrefix}${docID}/versions/${id}`, + }) + } + + if (globalSlug) { + to = formatAdminURL({ + adminRoute, + path: `/globals/${globalSlug}/versions/${id}`, + }) + } + + return ( + + {formatDate({ date: updatedAt, i18n, pattern: dateFormat })} + + ) +} diff --git a/packages/ui/src/views/Versions/cells/ID/index.tsx b/packages/ui/src/views/Versions/cells/ID/index.tsx new file mode 100644 index 00000000000..7b3853fc16f --- /dev/null +++ b/packages/ui/src/views/Versions/cells/ID/index.tsx @@ -0,0 +1,6 @@ +'use client' +import React, { Fragment } from 'react' + +export function IDCell({ id }: { id: number | string }) { + return {id} +} diff --git a/packages/ui/src/views/Versions/index.client.tsx b/packages/ui/src/views/Versions/index.client.tsx new file mode 100644 index 00000000000..30cb5b920f1 --- /dev/null +++ b/packages/ui/src/views/Versions/index.client.tsx @@ -0,0 +1,75 @@ +'use client' +import type { Column, SanitizedCollectionConfig } from 'payload' + +import React from 'react' + +import { LoadingOverlayToggle } from '../../elements/Loading/index.js' +import { Pagination } from '../../elements/Pagination/index.js' +import { PerPage } from '../../elements/PerPage/index.js' +import { Table } from '../../elements/Table/index.js' +import { useListQuery } from '../../providers/ListQuery/index.js' +import { useSearchParams } from '../../providers/Router/index.js' +import { useTranslation } from '../../providers/Translation/index.js' + +export const VersionsViewClient: React.FC<{ + readonly baseClass: string + readonly columns: Column[] + readonly fetchURL: string + readonly paginationLimits?: SanitizedCollectionConfig['admin']['pagination']['limits'] +}> = (props) => { + const { baseClass, columns, paginationLimits } = props + + const { data, handlePageChange, handlePerPageChange } = useListQuery() + + const searchParams = useSearchParams() + const limit = searchParams.get('limit') + + const { i18n } = useTranslation() + + const versionCount = data?.totalDocs || 0 + + return ( + + + {versionCount === 0 && ( +
+ {i18n.t('version:noFurtherVersionsFound')} +
+ )} + {versionCount > 0 && ( + +
+
+ + {data?.totalDocs > 0 && ( + +
+ {data.page * data.limit - (data.limit - 1)}- + {data.totalPages > 1 && data.totalPages !== data.page + ? data.limit * data.page + : data.totalDocs}{' '} + {i18n.t('general:of')} {data.totalDocs} +
+ +
+ )} +
+ + )} + + ) +} From 2052471e6a4d303250aa3ef9eb371f5d2ea2794e Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 21:19:30 +0100 Subject: [PATCH 20/60] refactor(ui): move renderDocument and renderListView to packages/ui with navigation callbacks Co-Authored-By: Claude Sonnet 4.6 (1M context) --- packages/next/src/views/Document/index.tsx | 472 +----------------- packages/next/src/views/List/index.tsx | 454 +---------------- packages/ui/package.json | 15 + .../ui/src/views/API/LocaleSelector/index.tsx | 27 + .../ui/src/views/API/RenderJSON/index.scss | 129 +++++ .../ui/src/views/API/RenderJSON/index.tsx | 153 ++++++ packages/ui/src/views/API/index.client.tsx | 224 +++++++++ packages/ui/src/views/API/index.scss | 1 + packages/ui/src/views/API/index.tsx | 9 + .../ui/src/views/Document/RenderDocument.tsx | 458 +++++++++++++++++ .../ui/src/views/Document/getDocumentView.tsx | 393 +++++++++++++++ packages/ui/src/views/List/RenderListView.tsx | 449 +++++++++++++++++ packages/ui/src/views/Version/index.tsx | 10 + packages/ui/src/views/Versions/index.tsx | 10 + 14 files changed, 1909 insertions(+), 895 deletions(-) create mode 100644 packages/ui/src/views/API/LocaleSelector/index.tsx create mode 100644 packages/ui/src/views/API/RenderJSON/index.scss create mode 100644 packages/ui/src/views/API/RenderJSON/index.tsx create mode 100644 packages/ui/src/views/API/index.client.tsx create mode 100644 packages/ui/src/views/API/index.scss create mode 100644 packages/ui/src/views/API/index.tsx create mode 100644 packages/ui/src/views/Document/RenderDocument.tsx create mode 100644 packages/ui/src/views/Document/getDocumentView.tsx create mode 100644 packages/ui/src/views/List/RenderListView.tsx create mode 100644 packages/ui/src/views/Version/index.tsx create mode 100644 packages/ui/src/views/Versions/index.tsx diff --git a/packages/next/src/views/Document/index.tsx b/packages/next/src/views/Document/index.tsx index 3e790c5c8cc..790f91d673e 100644 --- a/packages/next/src/views/Document/index.tsx +++ b/packages/next/src/views/Document/index.tsx @@ -1,466 +1,34 @@ -import type { - AdminViewServerProps, - CollectionPreferences, - Data, - DocumentViewClientProps, - DocumentViewServerProps, - DocumentViewServerPropsOnly, - EditViewComponent, - PayloadComponent, - RenderDocumentVersionsProperties, -} from 'payload' +import type { AdminViewServerProps, RenderDocumentVersionsProperties } from 'payload' -import { - DocumentInfoProvider, - EditDepthProvider, - HydrateAuthProvider, - LivePreviewProvider, -} from '@payloadcms/ui' -import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' -import { handleLivePreview, handlePreview } from '@payloadcms/ui/rsc' -import { isEditing as getIsEditing } from '@payloadcms/ui/shared' -import { buildFormState } from '@payloadcms/ui/utilities/buildFormState' +import { renderDocument as renderDocumentFromUI } from '@payloadcms/ui/views/Document/RenderDocument' import { notFound, redirect } from 'next/navigation.js' -import { isolateObjectProperty, logError } from 'payload' -import { formatAdminURL, hasAutosaveEnabled, hasDraftsEnabled } from 'payload/shared' -import React from 'react' +import { logError } from 'payload' import type { GenerateEditViewMetadata } from './getMetaBySegment.js' -import { DocumentHeader } from '../../elements/DocumentHeader/index.js' -import { getPreferences } from '../../utilities/getPreferences.js' -import { NotFoundView } from '../NotFound/index.js' -import { getDocPreferences } from './getDocPreferences.js' -import { getDocumentData } from './getDocumentData.js' -import { getDocumentPermissions } from './getDocumentPermissions.js' -import { getDocumentView } from './getDocumentView.js' -import { getIsLocked } from './getIsLocked.js' import { getMetaBySegment } from './getMetaBySegment.js' -import { getVersions } from './getVersions.js' -import { renderDocumentSlots } from './renderDocumentSlots.js' -export const generateMetadata: GenerateEditViewMetadata = async (args) => getMetaBySegment(args) - -export type ViewToRender = - | EditViewComponent - | PayloadComponent - | React.FC - | React.FC - -/** - * This function is responsible for rendering - * an Edit Document view on the server for both: - * - default document edit views - * - on-demand edit views within drawers - */ -export const renderDocument = async ({ - disableActions, - documentSubViewType, - drawerSlug, - importMap, - initialData, - initPageResult, - overrideEntityVisibility, - params, - redirectAfterCreate, - redirectAfterDelete, - redirectAfterDuplicate, - redirectAfterRestore, - searchParams, - versions, - viewType, -}: { - drawerSlug?: string - overrideEntityVisibility?: boolean - readonly redirectAfterCreate?: boolean - readonly redirectAfterDelete?: boolean - readonly redirectAfterDuplicate?: boolean - readonly redirectAfterRestore?: boolean - versions?: RenderDocumentVersionsProperties -} & AdminViewServerProps): Promise<{ - data: Data - Document: React.ReactNode -}> => { - const { - collectionConfig, - docID: idFromArgs, - globalConfig, - locale, - permissions, - req, - req: { - i18n, - payload, - payload: { - config, - config: { - routes: { admin: adminRoute, api: apiRoute }, - }, - }, - user, - }, - visibleEntities, - } = initPageResult - - const segments = Array.isArray(params?.segments) ? params.segments : [] - const collectionSlug = collectionConfig?.slug || undefined - const globalSlug = globalConfig?.slug || undefined - let isEditing = getIsEditing({ id: idFromArgs, collectionSlug, globalSlug }) - - // Fetch the doc required for the view - let doc = - !idFromArgs && !globalSlug - ? initialData || null - : await getDocumentData({ - id: idFromArgs, - collectionSlug, - globalSlug, - locale, - payload, - req, - segments, - user, - }) - - if (isEditing && !doc) { - // If it's a collection document that doesn't exist, redirect to collection list - if (collectionSlug) { - const redirectURL = formatAdminURL({ - adminRoute, - path: `/collections/${collectionSlug}?notFound=${encodeURIComponent(idFromArgs)}`, - }) - redirect(redirectURL) - } else { - // For globals or other cases, keep the 404 behavior - throw new Error('not-found') - } - } - - const isTrashedDoc = Boolean(doc && 'deletedAt' in doc && typeof doc?.deletedAt === 'string') - - // CRITICAL FIX FOR TRANSACTION RACE CONDITION: - // When running parallel operations with Promise.all, if they share the same req object - // and one operation calls initTransaction() which MUTATES req.transactionID, that mutation - // is visible to all parallel operations. This causes: - // 1. Operation A (e.g., getDocumentPermissions → docAccessOperation) calls initTransaction() - // which sets req.transactionID = Promise, then resolves it to a UUID - // 2. Operation B (e.g., getIsLocked) running in parallel receives the SAME req with the mutated transactionID - // 3. Operation A (does not even know that Operation B even exists and is stil using the transactionID) commits/ends its transaction - // 4. Operation B tries to use the now-expired session → MongoExpiredSessionError! - // - // Solution: Use isolateObjectProperty to create a Proxy that isolates the 'transactionID' property. - // This allows each operation to have its own transactionID without affecting the parent req. - // If parent req already has a transaction, preserve it (don't isolate), since this - // issue only arises when one of the operations calls initTransaction() themselves - - // because then, that operation will also try to commit/end the transaction itself. - - // If the transactionID is already set, the parallel operations will not try to - // commit/end the transaction themselves, so we don't need to isolate the - // transactionID property. - const reqForPermissions = req.transactionID ? req : isolateObjectProperty(req, 'transactionID') - const reqForLockCheck = req.transactionID ? req : isolateObjectProperty(req, 'transactionID') - - const [ - docPreferences, - { - docPermissions, - hasDeletePermission, - hasPublishPermission, - hasSavePermission, - hasTrashPermission, - }, - { currentEditor, isLocked, lastUpdateTime }, - entityPreferences, - ] = await Promise.all([ - // Get document preferences - getDocPreferences({ - id: idFromArgs, - collectionSlug, - globalSlug, - payload, - user, - }), - - // Get permissions - isolated transactionID prevents cross-contamination - getDocumentPermissions({ - id: idFromArgs, - collectionConfig, - data: doc, - globalConfig, - req: reqForPermissions, - }), - - // Fetch document lock state - isolated transactionID prevents cross-contamination - getIsLocked({ - id: idFromArgs, - collectionConfig, - globalConfig, - isEditing, - req: reqForLockCheck, - }), +export type { ViewToRender } from '@payloadcms/ui/views/Document/RenderDocument' - // get entity preferences - getPreferences( - collectionSlug ? `collection-${collectionSlug}` : `global-${globalSlug}`, - payload, - req.user.id, - req.user.collection, - ), - ]) - - const operation = (collectionSlug && idFromArgs) || globalSlug ? 'update' : 'create' - - const [ - { hasPublishedDoc, mostRecentVersionIsAutosaved, unpublishedVersionCount, versionCount }, - { state: formState }, - ] = await Promise.all([ - getVersions({ - id: idFromArgs, - collectionConfig, - doc, - docPermissions, - globalConfig, - locale: locale?.code, - payload, - user, - }), - buildFormState({ - id: idFromArgs, - collectionSlug, - data: doc, - docPermissions, - docPreferences, - fallbackLocale: false, - globalSlug, - locale: locale?.code, - operation, - readOnly: isTrashedDoc || isLocked, - renderAllFields: true, - req, - schemaPath: collectionSlug || globalSlug, - skipValidation: true, - }), - ]) - - const documentViewServerProps: DocumentViewServerPropsOnly = { - doc, - hasPublishedDoc, - i18n, - initPageResult, - locale, - params, - payload, - permissions, - routeSegments: segments, - searchParams, - user, - versions, - } - - if ( - !overrideEntityVisibility && - ((collectionSlug && - !visibleEntities?.collections?.find((visibleSlug) => visibleSlug === collectionSlug)) || - (globalSlug && !visibleEntities?.globals?.find((visibleSlug) => visibleSlug === globalSlug))) - ) { - throw new Error('not-found') - } - - const formattedParams = new URLSearchParams() - - if (hasDraftsEnabled(collectionConfig || globalConfig)) { - formattedParams.append('draft', 'true') - } - - if (locale?.code) { - formattedParams.append('locale', locale.code) - } - - const apiQueryParams = `?${formattedParams.toString()}` - - const apiURL = formatAdminURL({ - apiRoute, - path: collectionSlug - ? `/${collectionSlug}/${idFromArgs}${apiQueryParams}` - : globalSlug - ? `/${globalSlug}${apiQueryParams}` - : '', - }) - - let View: ViewToRender = null - - let showHeader = true - - const RootViewOverride = - collectionConfig?.admin?.components?.views?.edit?.root && - 'Component' in collectionConfig.admin.components.views.edit.root - ? collectionConfig?.admin?.components?.views?.edit?.root?.Component - : globalConfig?.admin?.components?.views?.edit?.root && - 'Component' in globalConfig.admin.components.views.edit.root - ? globalConfig?.admin?.components?.views?.edit?.root?.Component - : null - - if (RootViewOverride) { - View = RootViewOverride - showHeader = false - } else { - ;({ View } = getDocumentView({ - collectionConfig, - config, - docPermissions, - globalConfig, - routeSegments: segments, - })) - } - - if (!View) { - View = NotFoundView - } - - /** - * Handle case where autoSave is enabled and the document is being created - * => create document and redirect - */ - const shouldAutosave = hasSavePermission && hasAutosaveEnabled(collectionConfig || globalConfig) - - const validateDraftData = - collectionConfig?.versions?.drafts && collectionConfig?.versions?.drafts?.validate - - let id = idFromArgs - - if (shouldAutosave && !validateDraftData && !idFromArgs && collectionSlug) { - doc = await payload.create({ - collection: collectionSlug, - data: initialData || {}, - depth: 0, - draft: true, - fallbackLocale: false, - locale: locale?.code, - req, - user, - }) - - if (doc?.id) { - id = doc.id - isEditing = getIsEditing({ id: doc.id, collectionSlug, globalSlug }) - - if (!drawerSlug && redirectAfterCreate !== false) { - const redirectURL = formatAdminURL({ - adminRoute, - path: `/collections/${collectionSlug}/${doc.id}`, - }) - - redirect(redirectURL) - } - } else { - throw new Error('not-found') - } - } - - const documentSlots = renderDocumentSlots({ - id, - collectionConfig, - globalConfig, - hasSavePermission, - locale, - permissions, - req, - }) - - // Extract Description from documentSlots to pass to DocumentHeader - const { Description } = documentSlots - - const clientProps: DocumentViewClientProps = { - formState, - ...documentSlots, - documentSubViewType, - viewType, - } - - const { isLivePreviewEnabled, livePreviewConfig, livePreviewURL } = await handleLivePreview({ - collectionSlug, - config, - data: doc, - globalSlug, - operation, - req, - }) +export const generateMetadata: GenerateEditViewMetadata = async (args) => getMetaBySegment(args) - const { isPreviewEnabled, previewURL } = await handlePreview({ - collectionSlug, - config, - data: doc, - globalSlug, - operation, - req, +export const renderDocument = ( + args: { + drawerSlug?: string + overrideEntityVisibility?: boolean + readonly redirectAfterCreate?: boolean + readonly redirectAfterDelete?: boolean + readonly redirectAfterDuplicate?: boolean + readonly redirectAfterRestore?: boolean + versions?: RenderDocumentVersionsProperties + } & AdminViewServerProps, +) => + renderDocumentFromUI({ + ...args, + notFound: () => notFound(), + redirect: (url) => redirect(url), }) - return { - data: doc, - Document: ( - - - {showHeader && !drawerSlug && ( - - )} - - - {RenderServerComponent({ - clientProps, - Component: View, - importMap, - serverProps: documentViewServerProps, - })} - - - - ), - } -} - export async function DocumentView(props: AdminViewServerProps) { try { const { Document: RenderedDocument } = await renderDocument(props) diff --git a/packages/next/src/views/List/index.tsx b/packages/next/src/views/List/index.tsx index 204de13055f..596c068f96c 100644 --- a/packages/next/src/views/List/index.tsx +++ b/packages/next/src/views/List/index.tsx @@ -1,453 +1,21 @@ -import type { - AdminViewServerProps, - CollectionPreferences, - Column, - ColumnPreference, - ListQuery, - ListViewClientProps, - ListViewServerPropsOnly, - PaginatedDocs, - PayloadComponent, - QueryPreset, - SanitizedCollectionPermission, -} from 'payload' +import type React from 'react' -import { DefaultListView, HydrateAuthProvider, ListQueryProvider } from '@payloadcms/ui' -import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' -import { getColumns, renderFilters, renderTable, upsertPreferences } from '@payloadcms/ui/rsc' -import { notFound } from 'next/navigation.js' import { - appendUploadSelectFields, - combineWhereConstraints, - formatAdminURL, - isNumber, - mergeListSearchAndWhere, - transformColumnsToPreferences, - transformColumnsToSearchParams, -} from 'payload/shared' -import React, { Fragment } from 'react' - -import { getDocumentPermissions } from '../Document/getDocumentPermissions.js' -import { enrichDocsWithVersionStatus } from './enrichDocsWithVersionStatus.js' -import { handleGroupBy } from './handleGroupBy.js' -import { renderListViewSlots } from './renderListViewSlots.js' -import { resolveAllFilterOptions } from './resolveAllFilterOptions.js' -import { transformColumnsToSelect } from './transformColumnsToSelect.js' - -/** - * @internal - */ -export type RenderListViewArgs = { - /** - * Allows providing your own list view component. This will override the default list view component and - * the collection's configured list view component (if any). - */ - ComponentOverride?: - | PayloadComponent - | React.ComponentType - customCellProps?: Record - disableBulkDelete?: boolean - disableBulkEdit?: boolean - disableQueryPresets?: boolean - drawerSlug?: string - enableRowSelections: boolean - overrideEntityVisibility?: boolean - /** - * If not ListQuery is provided, `req.query` will be used. - */ - query?: ListQuery - redirectAfterDelete?: boolean - redirectAfterDuplicate?: boolean - /** - * @experimental This prop is subject to change in future releases. - */ - trash?: boolean -} & AdminViewServerProps - -/** - * This function is responsible for rendering - * the list view on the server for both: - * - default list view - * - list view within drawers - * - * @internal - */ -export const renderListView = async ( - args: RenderListViewArgs, -): Promise<{ - List: React.ReactNode -}> => { - const { - clientConfig, - ComponentOverride, - customCellProps, - disableBulkDelete, - disableBulkEdit, - disableQueryPresets, - drawerSlug, - enableRowSelections, - initPageResult, - overrideEntityVisibility, - params, - query: queryFromArgs, - searchParams, - trash, - viewType, - } = args - - const { - collectionConfig, - collectionConfig: { slug: collectionSlug }, - locale: fullLocale, - permissions, - req, - req: { - i18n, - payload, - payload: { config }, - query: queryFromReq, - user, - }, - visibleEntities, - } = initPageResult - const { - routes: { admin: adminRoute }, - } = config - - if ( - !collectionConfig || - !permissions?.collections?.[collectionSlug]?.read || - (!visibleEntities.collections.includes(collectionSlug) && !overrideEntityVisibility) - ) { - throw new Error('not-found') - } - - const query: ListQuery = queryFromArgs || queryFromReq - - const columnsFromQuery: ColumnPreference[] = transformColumnsToPreferences(query?.columns) - - query.queryByGroup = - query?.queryByGroup && typeof query.queryByGroup === 'string' - ? JSON.parse(query.queryByGroup) - : query?.queryByGroup - - const collectionPreferences = await upsertPreferences({ - key: `collection-${collectionSlug}`, - req, - value: { - columns: columnsFromQuery, - groupBy: query?.groupBy, - limit: isNumber(query?.limit) ? Number(query.limit) : undefined, - preset: query?.preset, - sort: query?.sort as string, - }, - }) - - let queryPreset: QueryPreset | undefined - let queryPresetPermissions: SanitizedCollectionPermission | undefined - - if (collectionPreferences?.preset) { - try { - queryPreset = (await payload.findByID({ - id: collectionPreferences?.preset, - collection: 'payload-query-presets', - depth: 0, - overrideAccess: false, - user, - })) as QueryPreset - - if (queryPreset) { - queryPresetPermissions = ( - await getDocumentPermissions({ - id: queryPreset.id, - collectionConfig: req.payload.collections['payload-query-presets'].config, - data: queryPreset, - req, - }) - )?.docPermissions - } - } catch (err) { - req.payload.logger.error(`Error fetching query preset or preset permissions: ${err}`) - } - } - - query.preset = queryPreset?.id - if (queryPreset?.where && !query.where) { - query.where = queryPreset.where - } - query.groupBy = query.groupBy ?? queryPreset?.groupBy ?? collectionPreferences?.groupBy - - const columnPreference = query.columns - ? transformColumnsToPreferences(query.columns) - : (queryPreset?.columns ?? collectionPreferences?.columns) - query.columns = transformColumnsToSearchParams(columnPreference) - - query.page = isNumber(query?.page) ? Number(query.page) : 0 - - query.limit = collectionPreferences?.limit || collectionConfig.admin.pagination.defaultLimit - - query.sort = - collectionPreferences?.sort || - (typeof collectionConfig.defaultSort === 'string' ? collectionConfig.defaultSort : undefined) - - const baseFilterConstraint = await ( - collectionConfig.admin?.baseFilter ?? collectionConfig.admin?.baseListFilter - )?.({ - limit: query.limit, - page: query.page, - req, - sort: query.sort, - }) - - let whereWithMergedSearch = mergeListSearchAndWhere({ - collectionConfig, - search: typeof query?.search === 'string' ? query.search : undefined, - where: combineWhereConstraints([query?.where, baseFilterConstraint]), - }) - - if (trash === true) { - whereWithMergedSearch = { - and: [ - whereWithMergedSearch, - { - deletedAt: { - exists: true, - }, - }, - ], - } - } - - let Table: React.ReactNode | React.ReactNode[] = null - let columnState: Column[] = [] - let data: PaginatedDocs = { - // no results default - docs: [], - hasNextPage: false, - hasPrevPage: false, - limit: query.limit, - nextPage: null, - page: 1, - pagingCounter: 0, - prevPage: null, - totalDocs: 0, - totalPages: 0, - } - - const clientCollectionConfig = clientConfig.collections.find((c) => c.slug === collectionSlug) - - const columns = getColumns({ - clientConfig, - collectionConfig: clientCollectionConfig, - collectionSlug, - columns: columnPreference, - i18n, - permissions, - }) - - const select = collectionConfig.admin.enableListViewSelectAPI - ? transformColumnsToSelect(columns) - : undefined - - /** Force select image fields for list view thumbnails */ - appendUploadSelectFields({ - collectionConfig, - select, - }) - - try { - if (collectionConfig.admin.groupBy && query.groupBy) { - ;({ columnState, data, Table } = await handleGroupBy({ - clientCollectionConfig, - clientConfig, - collectionConfig, - collectionSlug, - columns, - customCellProps, - drawerSlug, - enableRowSelections, - fieldPermissions: permissions?.collections?.[collectionSlug]?.fields, - query, - req, - select, - trash, - user, - viewType, - where: whereWithMergedSearch, - })) - - // Enrich documents with correct display status for drafts - data = await enrichDocsWithVersionStatus({ - collectionConfig, - data, - req, - }) - } else { - data = await req.payload.find({ - collection: collectionSlug, - depth: 0, - draft: true, - fallbackLocale: false, - includeLockStatus: true, - limit: query?.limit ? Number(query.limit) : undefined, - locale: req.locale, - overrideAccess: false, - page: query?.page ? Number(query.page) : undefined, - req, - select, - sort: query?.sort, - trash, - user, - where: whereWithMergedSearch, - }) - - // Enrich documents with correct display status for drafts - data = await enrichDocsWithVersionStatus({ - collectionConfig, - data, - req, - }) - ;({ columnState, Table } = renderTable({ - clientCollectionConfig, - collectionConfig, - columns, - customCellProps, - data, - drawerSlug, - enableRowSelections, - fieldPermissions: permissions?.collections?.[collectionSlug]?.fields, - i18n: req.i18n, - orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined, - payload: req.payload, - query, - req, - useAsTitle: collectionConfig.admin.useAsTitle, - viewType, - })) - } - } catch (err) { - if (err.name !== 'QueryError') { - // QueryErrors are expected when a user filters by a field they do not have access to - req.payload.logger.error({ - err, - msg: `There was an error fetching the list view data for collection ${collectionSlug}`, - }) - throw err - } - } - - const renderedFilters = renderFilters(collectionConfig.fields, req.payload.importMap) - - const resolvedFilterOptions = await resolveAllFilterOptions({ - fields: collectionConfig.fields, - req, - }) - - const staticDescription = - typeof collectionConfig.admin.description === 'function' - ? collectionConfig.admin.description({ t: i18n.t }) - : collectionConfig.admin.description - - const newDocumentURL = formatAdminURL({ - adminRoute, - path: `/collections/${collectionSlug}/create`, - }) - - const hasCreatePermission = permissions?.collections?.[collectionSlug]?.create - - const { hasDeletePermission, hasTrashPermission } = await getDocumentPermissions({ - collectionConfig, - // Empty object serves as base for computing differentiated trash/delete permissions - data: {}, - req, - }) - - // Check if there's a notFound query parameter (document ID that wasn't found) - const notFoundDocId = typeof searchParams?.notFound === 'string' ? searchParams.notFound : null - - const serverProps: ListViewServerPropsOnly = { - collectionConfig, - data, - i18n, - limit: query.limit, - listPreferences: collectionPreferences, - listSearchableFields: collectionConfig.admin.listSearchableFields, - locale: fullLocale, - params, - payload, - permissions, - searchParams, - user, - } - - const listViewSlots = renderListViewSlots({ - clientProps: { - collectionSlug, - hasCreatePermission, - hasDeletePermission, - hasTrashPermission, - newDocumentURL, - }, - collectionConfig, - description: staticDescription, - notFoundDocId, - payload, - serverProps, - }) - - const isInDrawer = Boolean(drawerSlug) + type RenderListViewArgs, + renderListView as renderListViewFromUI, +} from '@payloadcms/ui/views/List/RenderListView' +import { notFound } from 'next/navigation.js' - // Needed to prevent: Only plain objects can be passed to Client Components from Server Components. Objects with toJSON methods are not supported. Convert it manually to a simple value before passing it to props. - // Is there a way to avoid this? The `where` object is already seemingly plain, but is not bc it originates from the params. - query.where = query?.where ? JSON.parse(JSON.stringify(query?.where || {})) : undefined +export type { RenderListViewArgs } - return { - List: ( - - - - {RenderServerComponent({ - clientProps: { - ...listViewSlots, - collectionSlug, - columnState, - disableBulkDelete, - disableBulkEdit: collectionConfig.disableBulkEdit ?? disableBulkEdit, - disableQueryPresets, - enableRowSelections, - hasCreatePermission, - hasDeletePermission, - hasTrashPermission, - listPreferences: collectionPreferences, - newDocumentURL, - queryPreset, - queryPresetPermissions, - renderedFilters, - resolvedFilterOptions, - Table, - viewType, - } satisfies ListViewClientProps, - Component: - ComponentOverride ?? collectionConfig?.admin?.components?.views?.list?.Component, - Fallback: DefaultListView, - importMap: payload.importMap, - serverProps, - })} - - - ), - } -} +export { renderListView } from '@payloadcms/ui/views/List/RenderListView' export const ListView: React.FC = async (args) => { try { - const { List: RenderedList } = await renderListView({ ...args, enableRowSelections: true }) + const { List: RenderedList } = await renderListViewFromUI({ + ...args, + enableRowSelections: true, + }) return RenderedList } catch (error) { // Pass through Next.js errors diff --git a/packages/ui/package.json b/packages/ui/package.json index b7bfd56a2c8..a75e28e39a5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -86,6 +86,16 @@ "types": "./src/views/Document/renderDocumentSlots.tsx", "default": "./src/views/Document/renderDocumentSlots.tsx" }, + "./views/Document/RenderDocument": { + "import": "./src/views/Document/RenderDocument.tsx", + "types": "./src/views/Document/RenderDocument.tsx", + "default": "./src/views/Document/RenderDocument.tsx" + }, + "./views/Document/getDocumentView": { + "import": "./src/views/Document/getDocumentView.tsx", + "types": "./src/views/Document/getDocumentView.tsx", + "default": "./src/views/Document/getDocumentView.tsx" + }, "./views/Version/fetchVersions": { "import": "./src/views/Version/fetchVersions.ts", "types": "./src/views/Version/fetchVersions.ts", @@ -321,6 +331,11 @@ "types": "./src/views/List/transformColumnsToSelect.ts", "default": "./src/views/List/transformColumnsToSelect.ts" }, + "./views/List/RenderListView": { + "import": "./src/views/List/RenderListView.tsx", + "types": "./src/views/List/RenderListView.tsx", + "default": "./src/views/List/RenderListView.tsx" + }, "./templates/*": { "import": "./src/templates/*/index.tsx", "types": "./src/templates/*/index.tsx", diff --git a/packages/ui/src/views/API/LocaleSelector/index.tsx b/packages/ui/src/views/API/LocaleSelector/index.tsx new file mode 100644 index 00000000000..d34c1c4dde3 --- /dev/null +++ b/packages/ui/src/views/API/LocaleSelector/index.tsx @@ -0,0 +1,27 @@ +'use client' +import React from 'react' + +import { SelectField } from '../../../fields/Select/index.js' +import { useTranslation } from '../../../providers/Translation/index.js' + +export const LocaleSelector: React.FC<{ + readonly localeOptions: { + label: Record | string + value: string + }[] + readonly onChange: (value: string) => void +}> = ({ localeOptions, onChange }) => { + const { t } = useTranslation() + + return ( + onChange(value)} + path="locale" + /> + ) +} diff --git a/packages/ui/src/views/API/RenderJSON/index.scss b/packages/ui/src/views/API/RenderJSON/index.scss new file mode 100644 index 00000000000..29fd5ef2557 --- /dev/null +++ b/packages/ui/src/views/API/RenderJSON/index.scss @@ -0,0 +1,129 @@ +@import '~@payloadcms/ui/scss'; + +$tab-width: 24px; + +@layer payload-default { + .query-inspector { + --tab-width: 24px; + + &__json-children { + position: relative; + + &--nested { + & li { + padding-left: 8px; + } + } + + &:before { + content: ''; + position: absolute; + top: 0; + width: 1px; + height: 100%; + border-left: 1px dashed var(--theme-elevation-200); + } + } + + &__row-line { + &--nested { + .query-inspector__json-children { + padding-left: var(--tab-width); + } + } + } + + &__list-wrap { + position: relative; + } + + &__list-toggle { + all: unset; + width: 100%; + text-align: left; + cursor: pointer; + border-radius: 3px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + position: relative; + display: flex; + column-gap: 14px; + row-gap: 10px; + align-items: center; + left: 0; + width: calc(100% + 3px); + background-color: var(--theme-elevation-50); + + &:not(.query-inspector__list-toggle--empty) { + margin-left: calc(var(--tab-width) * -1 - 10px); + } + + svg .stroke { + stroke: var(--theme-elevation-400); + } + + &:hover { + background-color: var(--theme-elevation-100); + } + + &--empty { + cursor: default; + pointer-events: none; + } + } + + &__toggle-row-icon { + &--open { + transform: rotate(0deg); + } + &--closed { + transform: rotate(-90deg); + } + } + + &__value-type { + &--number { + .query-inspector__value { + color: var(--number-color); + } + } + + &--string { + .query-inspector__value { + color: var(--string-color); + } + } + } + + &__bracket { + position: relative; + + &--position-end { + left: 2px; + width: calc(100% - 5px); + } + } + + // Some specific rules targetting the very top of the nested JSON structure or very first items since they need slightly different styling + &__results { + & > .query-inspector__row-line--nested { + & > .query-inspector__list-toggle { + margin-left: 0; + column-gap: 6px; + + .query-inspector__toggle-row-icon { + margin-left: -4px; + } + } + + & > .query-inspector__json-children { + padding-left: calc(var(--base) * 1); + } + + & > .query-inspector__bracket--nested > .query-inspector__bracket--position-end { + padding-left: 16px; + } + } + } + } +} diff --git a/packages/ui/src/views/API/RenderJSON/index.tsx b/packages/ui/src/views/API/RenderJSON/index.tsx new file mode 100644 index 00000000000..b13b4f42e8a --- /dev/null +++ b/packages/ui/src/views/API/RenderJSON/index.tsx @@ -0,0 +1,153 @@ +'use client' +import * as React from 'react' + +import { ChevronIcon } from '../../../icons/Chevron/index.js' +import './index.scss' + +const chars = { + leftCurlyBracket: '\u007B', + leftSquareBracket: '\u005B', + rightCurlyBracket: '\u007D', + rightSquareBracket: '\u005D', +} + +const baseClass = 'query-inspector' + +const Bracket = ({ + type, + comma = false, + position, +}: { + comma?: boolean + position: 'end' | 'start' + type: 'array' | 'object' +}) => { + const rightBracket = type === 'object' ? chars.rightCurlyBracket : chars.rightSquareBracket + const leftBracket = type === 'object' ? chars.leftCurlyBracket : chars.leftSquareBracket + const bracketToRender = position === 'end' ? rightBracket : leftBracket + + return ( + + {bracketToRender} + {position === 'end' && comma ? ',' : null} + + ) +} + +type Args = { + isEmpty?: boolean + object: any[] | Record + objectKey?: string + parentType?: 'array' | 'object' + trailingComma?: boolean +} + +export const RenderJSON = ({ + isEmpty = false, + object, + objectKey, + parentType = 'object', + trailingComma = false, +}: Args) => { + const objectKeys = object ? Object.keys(object) : [] + const objectLength = objectKeys.length + const [isOpen, setIsOpen] = React.useState(true) + const isNested = parentType === 'object' || parentType === 'array' + return ( +
  • + + +
      + {isOpen && + objectKeys.map((key, keyIndex) => { + let value = object[key] + let type = 'string' + const isLastKey = keyIndex === objectLength - 1 + + if (value === null) { + type = 'null' + } else if (value instanceof Date) { + type = 'date' + value = value.toISOString() + } else if (Array.isArray(value)) { + type = 'array' + } else if (typeof value === 'object') { + type = 'object' + } else if (typeof value === 'number') { + type = 'number' + } else if (typeof value === 'boolean') { + type = 'boolean' + } else { + type = 'string' + } + + if (type === 'object' || type === 'array') { + return ( + + ) + } + + if ( + type === 'date' || + type === 'string' || + type === 'null' || + type === 'number' || + type === 'boolean' + ) { + const parentHasKey = Boolean(parentType === 'object' && key) + + const rowClasses = [ + `${baseClass}__row-line`, + `${baseClass}__value-type--${type}`, + `${baseClass}__row-line--${objectKey ? 'nested' : 'top'}`, + ] + .filter(Boolean) + .join(' ') + + return ( +
    • + {parentHasKey ? {`"${key}": `} : null} + + {JSON.stringify(value)} + {isLastKey ? '' : ','} +
    • + ) + } + })} +
    + + {!isEmpty && ( + + + + )} +
  • + ) +} diff --git a/packages/ui/src/views/API/index.client.tsx b/packages/ui/src/views/API/index.client.tsx new file mode 100644 index 00000000000..8df18cbc7f9 --- /dev/null +++ b/packages/ui/src/views/API/index.client.tsx @@ -0,0 +1,224 @@ +'use client' + +import { formatAdminURL, hasDraftsEnabled } from 'payload/shared' +import * as React from 'react' +import { toast } from 'sonner' + +import { CopyToClipboard } from '../../elements/CopyToClipboard/index.js' +import { Gutter } from '../../elements/Gutter/index.js' +import { CheckboxField } from '../../fields/Checkbox/index.js' +import { NumberField } from '../../fields/Number/index.js' +import { Form } from '../../forms/Form/index.js' +import { MinimizeMaximizeIcon } from '../../icons/MinimizeMaximize/index.js' +import { useConfig } from '../../providers/Config/index.js' +import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' +import { useLocale } from '../../providers/Locale/index.js' +import { useSearchParams } from '../../providers/Router/index.js' +import './index.scss' +import { useTranslation } from '../../providers/Translation/index.js' +import { SetDocumentStepNav } from '../Edit/SetDocumentStepNav/index.js' +import { LocaleSelector } from './LocaleSelector/index.js' +import { RenderJSON } from './RenderJSON/index.js' + +const baseClass = 'query-inspector' + +export const APIViewClient: React.FC = () => { + const { id, collectionSlug, globalSlug, initialData, isTrashed } = useDocumentInfo() + + const searchParams = useSearchParams() + const { i18n, t } = useTranslation() + const { code } = useLocale() + + const { + config: { + defaultDepth, + localization, + routes: { api: apiRoute }, + serverURL, + }, + getEntityConfig, + } = useConfig() + + const collectionConfig = getEntityConfig({ collectionSlug }) + const globalConfig = getEntityConfig({ globalSlug }) + + const localeOptions = + localization && + localization.locales.map((locale) => ({ label: locale.label, value: locale.code })) + + let draftsEnabled: boolean = false + let docEndpoint: `/${string}` = undefined + + if (collectionConfig) { + draftsEnabled = hasDraftsEnabled(collectionConfig) + docEndpoint = `/${collectionSlug}/${id}` + } + + if (globalConfig) { + draftsEnabled = hasDraftsEnabled(globalConfig) + docEndpoint = `/globals/${globalSlug}` + } + + const [data, setData] = React.useState(initialData) + const [draft, setDraft] = React.useState(searchParams.get('draft') === 'true') + const [locale, setLocale] = React.useState(searchParams?.get('locale') || code) + const [depth, setDepth] = React.useState( + searchParams.get('depth') || defaultDepth.toString(), + ) + const [authenticated, setAuthenticated] = React.useState(true) + const [fullscreen, setFullscreen] = React.useState(false) + const [origin, setOrigin] = React.useState(serverURL || '') + + // Set the origin to the window.location.origin in useEffect to avoid hydration errors + React.useEffect(() => { + if (!serverURL) { + setOrigin(window.location.origin) + } + }, [serverURL]) + + const trashParam = typeof initialData?.deletedAt === 'string' + + const params = new URLSearchParams({ + depth, + draft: String(draft), + locale, + trash: trashParam ? 'true' : 'false', + }).toString() + + const fetchURL = formatAdminURL({ + apiRoute, + path: `${docEndpoint}?${params}`, + serverURL: origin, + }) + + React.useEffect(() => { + const fetchData = async () => { + try { + const res = await fetch(fetchURL, { + credentials: authenticated ? 'include' : 'omit', + headers: { + 'Accept-Language': i18n.language, + }, + method: 'GET', + }) + + try { + const json = await res.json() + setData(json) + } catch (error) { + toast.error('Error parsing response') + console.error(error) // eslint-disable-line no-console + } + } catch (error) { + toast.error('Error making request') + console.error(error) // eslint-disable-line no-console + } + } + + void fetchData() + }, [i18n.language, fetchURL, authenticated]) + + return ( + + +
    +
    + + API URL + + + {fetchURL} + +
    +
    +
    +
    + {draftsEnabled && ( + setDraft(!draft)} + path="draft" + /> + )} + setAuthenticated(!authenticated)} + path="authenticated" + /> +
    + {localeOptions && } + setDepth(value?.toString())} + path="depth" + /> +
    + +
    +
    +
    + +
    +
    + +
    +
    +
    + ) +} diff --git a/packages/ui/src/views/API/index.scss b/packages/ui/src/views/API/index.scss new file mode 100644 index 00000000000..da53b99b777 --- /dev/null +++ b/packages/ui/src/views/API/index.scss @@ -0,0 +1 @@ +@import '~@payloadcms/ui/scss'; diff --git a/packages/ui/src/views/API/index.tsx b/packages/ui/src/views/API/index.tsx new file mode 100644 index 00000000000..df81692f596 --- /dev/null +++ b/packages/ui/src/views/API/index.tsx @@ -0,0 +1,9 @@ +import type { DocumentViewServerProps } from 'payload' + +import React from 'react' + +import { APIViewClient } from './index.client.js' + +export function APIView(_props: DocumentViewServerProps) { + return +} diff --git a/packages/ui/src/views/Document/RenderDocument.tsx b/packages/ui/src/views/Document/RenderDocument.tsx new file mode 100644 index 00000000000..524e1298e96 --- /dev/null +++ b/packages/ui/src/views/Document/RenderDocument.tsx @@ -0,0 +1,458 @@ +import type { + AdminViewServerProps, + CollectionPreferences, + Data, + DocumentViewClientProps, + DocumentViewServerProps, + DocumentViewServerPropsOnly, + EditViewComponent, + PayloadComponent, + RenderDocumentVersionsProperties, +} from 'payload' + +import { isolateObjectProperty } from 'payload' +import { formatAdminURL, hasAutosaveEnabled, hasDraftsEnabled } from 'payload/shared' +import React from 'react' + +import { DocumentHeader } from '../../elements/DocumentHeader/index.js' +import { HydrateAuthProvider } from '../../elements/HydrateAuthProvider/index.js' +import { RenderServerComponent } from '../../elements/RenderServerComponent/index.js' +import { DocumentInfoProvider } from '../../providers/DocumentInfo/index.js' +import { EditDepthProvider } from '../../providers/EditDepth/index.js' +import { LivePreviewProvider } from '../../providers/LivePreview/index.js' +import { buildFormState } from '../../utilities/buildFormState.js' +import { getPreferences } from '../../utilities/getPreferences.js' +import { handleLivePreview } from '../../utilities/handleLivePreview.js' +import { handlePreview } from '../../utilities/handlePreview.js' +import { isEditing as getIsEditing } from '../../utilities/isEditing.js' +import { NotFoundView } from '../NotFound/index.js' +import { getDocPreferences } from './getDocPreferences.js' +import { getDocumentData } from './getDocumentData.js' +import { getDocumentPermissions } from './getDocumentPermissions.js' +import { getDocumentView } from './getDocumentView.js' +import { getIsLocked } from './getIsLocked.js' +import { getVersions } from './getVersions.js' +import { renderDocumentSlots } from './renderDocumentSlots.js' + +export type ViewToRender = + | EditViewComponent + | PayloadComponent + | React.FC + | React.FC + +/** + * This function is responsible for rendering + * an Edit Document view on the server for both: + * - default document edit views + * - on-demand edit views within drawers + */ +export const renderDocument = async ({ + disableActions, + documentSubViewType, + drawerSlug, + importMap, + initialData, + initPageResult, + overrideEntityVisibility, + params, + redirect, + redirectAfterCreate, + redirectAfterDelete, + redirectAfterDuplicate, + redirectAfterRestore, + searchParams, + versions, + viewType, +}: { + drawerSlug?: string + notFound: () => never + overrideEntityVisibility?: boolean + redirect: (url: string) => never + readonly redirectAfterCreate?: boolean + readonly redirectAfterDelete?: boolean + readonly redirectAfterDuplicate?: boolean + readonly redirectAfterRestore?: boolean + versions?: RenderDocumentVersionsProperties +} & AdminViewServerProps): Promise<{ + data: Data + Document: React.ReactNode +}> => { + const { + collectionConfig, + docID: idFromArgs, + globalConfig, + locale, + permissions, + req, + req: { + i18n, + payload, + payload: { + config, + config: { + routes: { admin: adminRoute, api: apiRoute }, + }, + }, + user, + }, + visibleEntities, + } = initPageResult + + const segments = Array.isArray(params?.segments) ? params.segments : [] + const collectionSlug = collectionConfig?.slug || undefined + const globalSlug = globalConfig?.slug || undefined + let isEditing = getIsEditing({ id: idFromArgs, collectionSlug, globalSlug }) + + // Fetch the doc required for the view + let doc = + !idFromArgs && !globalSlug + ? initialData || null + : await getDocumentData({ + id: idFromArgs, + collectionSlug, + globalSlug, + locale, + payload, + req, + segments, + user, + }) + + if (isEditing && !doc) { + // If it's a collection document that doesn't exist, redirect to collection list + if (collectionSlug) { + const redirectURL = formatAdminURL({ + adminRoute, + path: `/collections/${collectionSlug}?notFound=${encodeURIComponent(idFromArgs)}`, + }) + redirect(redirectURL) + } else { + // For globals or other cases, keep the 404 behavior + throw new Error('not-found') + } + } + + const isTrashedDoc = Boolean(doc && 'deletedAt' in doc && typeof doc?.deletedAt === 'string') + + // CRITICAL FIX FOR TRANSACTION RACE CONDITION: + // When running parallel operations with Promise.all, if they share the same req object + // and one operation calls initTransaction() which MUTATES req.transactionID, that mutation + // is visible to all parallel operations. This causes: + // 1. Operation A (e.g., getDocumentPermissions → docAccessOperation) calls initTransaction() + // which sets req.transactionID = Promise, then resolves it to a UUID + // 2. Operation B (e.g., getIsLocked) running in parallel receives the SAME req with the mutated transactionID + // 3. Operation A (does not even know that Operation B even exists and is stil using the transactionID) commits/ends its transaction + // 4. Operation B tries to use the now-expired session → MongoExpiredSessionError! + // + // Solution: Use isolateObjectProperty to create a Proxy that isolates the 'transactionID' property. + // This allows each operation to have its own transactionID without affecting the parent req. + // If parent req already has a transaction, preserve it (don't isolate), since this + // issue only arises when one of the operations calls initTransaction() themselves - + // because then, that operation will also try to commit/end the transaction itself. + + // If the transactionID is already set, the parallel operations will not try to + // commit/end the transaction themselves, so we don't need to isolate the + // transactionID property. + const reqForPermissions = req.transactionID ? req : isolateObjectProperty(req, 'transactionID') + const reqForLockCheck = req.transactionID ? req : isolateObjectProperty(req, 'transactionID') + + const [ + docPreferences, + { + docPermissions, + hasDeletePermission, + hasPublishPermission, + hasSavePermission, + hasTrashPermission, + }, + { currentEditor, isLocked, lastUpdateTime }, + entityPreferences, + ] = await Promise.all([ + // Get document preferences + getDocPreferences({ + id: idFromArgs, + collectionSlug, + globalSlug, + payload, + user, + }), + + // Get permissions - isolated transactionID prevents cross-contamination + getDocumentPermissions({ + id: idFromArgs, + collectionConfig, + data: doc, + globalConfig, + req: reqForPermissions, + }), + + // Fetch document lock state - isolated transactionID prevents cross-contamination + getIsLocked({ + id: idFromArgs, + collectionConfig, + globalConfig, + isEditing, + req: reqForLockCheck, + }), + + // get entity preferences + getPreferences( + collectionSlug ? `collection-${collectionSlug}` : `global-${globalSlug}`, + payload, + req.user.id, + req.user.collection, + ), + ]) + + const operation = (collectionSlug && idFromArgs) || globalSlug ? 'update' : 'create' + + const [ + { hasPublishedDoc, mostRecentVersionIsAutosaved, unpublishedVersionCount, versionCount }, + { state: formState }, + ] = await Promise.all([ + getVersions({ + id: idFromArgs, + collectionConfig, + doc, + docPermissions, + globalConfig, + locale: locale?.code, + payload, + user, + }), + buildFormState({ + id: idFromArgs, + collectionSlug, + data: doc, + docPermissions, + docPreferences, + fallbackLocale: false, + globalSlug, + locale: locale?.code, + operation, + readOnly: isTrashedDoc || isLocked, + renderAllFields: true, + req, + schemaPath: collectionSlug || globalSlug, + skipValidation: true, + }), + ]) + + const documentViewServerProps: DocumentViewServerPropsOnly = { + doc, + hasPublishedDoc, + i18n, + initPageResult, + locale, + params, + payload, + permissions, + routeSegments: segments, + searchParams, + user, + versions, + } + + if ( + !overrideEntityVisibility && + ((collectionSlug && + !visibleEntities?.collections?.find((visibleSlug) => visibleSlug === collectionSlug)) || + (globalSlug && !visibleEntities?.globals?.find((visibleSlug) => visibleSlug === globalSlug))) + ) { + throw new Error('not-found') + } + + const formattedParams = new URLSearchParams() + + if (hasDraftsEnabled(collectionConfig || globalConfig)) { + formattedParams.append('draft', 'true') + } + + if (locale?.code) { + formattedParams.append('locale', locale.code) + } + + const apiQueryParams = `?${formattedParams.toString()}` + + const apiURL = formatAdminURL({ + apiRoute, + path: collectionSlug + ? `/${collectionSlug}/${idFromArgs}${apiQueryParams}` + : globalSlug + ? `/${globalSlug}${apiQueryParams}` + : '', + }) + + let View: ViewToRender = null + + let showHeader = true + + const RootViewOverride = + collectionConfig?.admin?.components?.views?.edit?.root && + 'Component' in collectionConfig.admin.components.views.edit.root + ? collectionConfig?.admin?.components?.views?.edit?.root?.Component + : globalConfig?.admin?.components?.views?.edit?.root && + 'Component' in globalConfig.admin.components.views.edit.root + ? globalConfig?.admin?.components?.views?.edit?.root?.Component + : null + + if (RootViewOverride) { + View = RootViewOverride + showHeader = false + } else { + ;({ View } = getDocumentView({ + collectionConfig, + config, + docPermissions, + globalConfig, + routeSegments: segments, + })) + } + + if (!View) { + View = NotFoundView + } + + /** + * Handle case where autoSave is enabled and the document is being created + * => create document and redirect + */ + const shouldAutosave = hasSavePermission && hasAutosaveEnabled(collectionConfig || globalConfig) + + const validateDraftData = + collectionConfig?.versions?.drafts && collectionConfig?.versions?.drafts?.validate + + let id = idFromArgs + + if (shouldAutosave && !validateDraftData && !idFromArgs && collectionSlug) { + doc = await payload.create({ + collection: collectionSlug, + data: initialData || {}, + depth: 0, + draft: true, + fallbackLocale: false, + locale: locale?.code, + req, + user, + }) + + if (doc?.id) { + id = doc.id + isEditing = getIsEditing({ id: doc.id, collectionSlug, globalSlug }) + + if (!drawerSlug && redirectAfterCreate !== false) { + const redirectURL = formatAdminURL({ + adminRoute, + path: `/collections/${collectionSlug}/${doc.id}`, + }) + + redirect(redirectURL) + } + } else { + throw new Error('not-found') + } + } + + const documentSlots = renderDocumentSlots({ + id, + collectionConfig, + globalConfig, + hasSavePermission, + locale, + permissions, + req, + }) + + // Extract Description from documentSlots to pass to DocumentHeader + const { Description } = documentSlots + + const clientProps: DocumentViewClientProps = { + formState, + ...documentSlots, + documentSubViewType, + viewType, + } + + const { isLivePreviewEnabled, livePreviewConfig, livePreviewURL } = await handleLivePreview({ + collectionSlug, + config, + data: doc, + globalSlug, + operation, + req, + }) + + const { isPreviewEnabled, previewURL } = await handlePreview({ + collectionSlug, + config, + data: doc, + globalSlug, + operation, + req, + }) + + return { + data: doc, + Document: ( + + + {showHeader && !drawerSlug && ( + + )} + + + {RenderServerComponent({ + clientProps, + Component: View, + importMap, + serverProps: documentViewServerProps, + })} + + + + ), + } +} diff --git a/packages/ui/src/views/Document/getDocumentView.tsx b/packages/ui/src/views/Document/getDocumentView.tsx new file mode 100644 index 00000000000..31e8901f22e --- /dev/null +++ b/packages/ui/src/views/Document/getDocumentView.tsx @@ -0,0 +1,393 @@ +import type { + PayloadComponent, + SanitizedCollectionConfig, + SanitizedCollectionPermission, + SanitizedConfig, + SanitizedGlobalConfig, + SanitizedGlobalPermission, +} from 'payload' +import type React from 'react' + +import type { ViewToRender } from './RenderDocument.js' + +import { APIView as DefaultAPIView } from '../API/index.js' +import { DefaultEditView as EditView } from '../Edit/index.js' +import { UnauthorizedViewWithGutter } from '../Unauthorized/index.js' +import { VersionView as DefaultVersionView } from '../Version/index.js' +import { VersionsView as DefaultVersionsView } from '../Versions/index.js' +import { getCustomDocumentViewByKey } from './getCustomDocumentViewByKey.js' +import { getCustomViewByRoute } from './getCustomViewByRoute.js' + +export type ViewFromConfig = { + Component?: React.FC + ComponentConfig?: PayloadComponent +} + +export const getDocumentView = ({ + collectionConfig, + config, + docPermissions, + globalConfig, + routeSegments, +}: { + collectionConfig?: SanitizedCollectionConfig + config: SanitizedConfig + docPermissions: SanitizedCollectionPermission | SanitizedGlobalPermission + globalConfig?: SanitizedGlobalConfig + routeSegments: string[] +}): { + View: ViewToRender + viewKey: string +} | null => { + // Conditionally import and lazy load the default view + let View: ViewToRender = null + let viewKey: string + + const { + routes: { admin: adminRoute }, + } = config + + const views = + (collectionConfig && collectionConfig?.admin?.components?.views) || + (globalConfig && globalConfig?.admin?.components?.views) + + if (!docPermissions?.read) { + throw new Error('not-found') + } + + if (collectionConfig) { + const [collectionEntity, collectionSlug, segment3, segment4, segment5, ...remainingSegments] = + routeSegments + + // --> /collections/:collectionSlug/:id + // --> /collections/:collectionSlug/create + switch (routeSegments.length) { + case 3: { + switch (segment3) { + // --> /collections/:collectionSlug/create + case 'create': { + if ('create' in docPermissions && docPermissions.create) { + View = getCustomDocumentViewByKey(views, 'default') || EditView + } else { + View = UnauthorizedViewWithGutter + } + break + } + + // --> /collections/:collectionSlug/:id + default: { + const baseRoute = [ + adminRoute !== '/' && adminRoute, + 'collections', + collectionSlug, + segment3, + ] + .filter(Boolean) + .join('/') + + const currentRoute = [baseRoute, segment4, segment5, ...remainingSegments] + .filter(Boolean) + .join('/') + + const { Component: CustomViewComponent, viewKey: customViewKey } = getCustomViewByRoute( + { + baseRoute, + currentRoute, + views, + }, + ) + + if (customViewKey) { + viewKey = customViewKey + View = CustomViewComponent + } else { + View = getCustomDocumentViewByKey(views, 'default') || EditView + } + + break + } + } + break + } + + // --> /collections/:collectionSlug/:id/api + // --> /collections/:collectionSlug/:id/versions + // --> /collections/:collectionSlug/:id/ + // --> /collections/:collectionSlug/trash/:id + case 4: { + // --> /collections/:collectionSlug/trash/:id + if (segment3 === 'trash' && segment4) { + View = getCustomDocumentViewByKey(views, 'default') || EditView + break + } + switch (segment4) { + // --> /collections/:collectionSlug/:id/api + case 'api': { + if (collectionConfig?.admin?.hideAPIURL !== true) { + View = getCustomDocumentViewByKey(views, 'api') || DefaultAPIView + } + break + } + + case 'versions': { + // --> /collections/:collectionSlug/:id/versions + if (docPermissions?.readVersions) { + View = getCustomDocumentViewByKey(views, 'versions') || DefaultVersionsView + } else { + View = UnauthorizedViewWithGutter + } + break + } + + // --> /collections/:collectionSlug/:id/ + default: { + const baseRoute = [ + adminRoute !== '/' && adminRoute, + 'collections', + collectionSlug, + segment3, + ] + .filter(Boolean) + .join('/') + + const currentRoute = [baseRoute, segment4, segment5, ...remainingSegments] + .filter(Boolean) + .join('/') + + const { Component: CustomViewComponent, viewKey: customViewKey } = getCustomViewByRoute( + { + baseRoute, + currentRoute, + views, + }, + ) + + if (customViewKey) { + viewKey = customViewKey + View = CustomViewComponent + } + + break + } + } + break + } + + // --> /collections/:collectionSlug/trash/:id/api + // --> /collections/:collectionSlug/trash/:id/versions + // --> /collections/:collectionSlug/trash/:id/ + // --> /collections/:collectionSlug/:id/versions/:version + case 5: { + // --> /collections/:slug/trash/:id/api + if (segment3 === 'trash') { + switch (segment5) { + case 'api': { + if (collectionConfig?.admin?.hideAPIURL !== true) { + View = getCustomDocumentViewByKey(views, 'api') || DefaultAPIView + } + break + } + // --> /collections/:slug/trash/:id/versions + case 'versions': { + if (docPermissions?.readVersions) { + View = getCustomDocumentViewByKey(views, 'versions') || DefaultVersionsView + } else { + View = UnauthorizedViewWithGutter + } + break + } + + default: { + View = getCustomDocumentViewByKey(views, 'default') || EditView + break + } + } + // --> /collections/:collectionSlug/:id/versions/:version + } else if (segment4 === 'versions') { + if (docPermissions?.readVersions) { + View = getCustomDocumentViewByKey(views, 'version') || DefaultVersionView + } else { + View = UnauthorizedViewWithGutter + } + } else { + // --> /collections/:collectionSlug/:id// + const baseRoute = [ + adminRoute !== '/' && adminRoute, + collectionEntity, + collectionSlug, + segment3, + ] + .filter(Boolean) + .join('/') + + const currentRoute = [baseRoute, segment4, segment5, ...remainingSegments] + .filter(Boolean) + .join('/') + + const { Component: CustomViewComponent, viewKey: customViewKey } = getCustomViewByRoute({ + baseRoute, + currentRoute, + views, + }) + + if (customViewKey) { + viewKey = customViewKey + View = CustomViewComponent + } + } + + break + } + + // --> /collections/:collectionSlug/trash/:id/versions/:version + // --> /collections/:collectionSlug/:id/// + default: { + // --> /collections/:collectionSlug/trash/:id/versions/:version + const isTrashedVersionView = segment3 === 'trash' && segment5 === 'versions' + + if (isTrashedVersionView) { + if (docPermissions?.readVersions) { + View = getCustomDocumentViewByKey(views, 'version') || DefaultVersionView + } else { + View = UnauthorizedViewWithGutter + } + } else { + // --> /collections/:collectionSlug/:id/// + const baseRoute = [ + adminRoute !== '/' && adminRoute, + collectionEntity, + collectionSlug, + segment3, + ] + .filter(Boolean) + .join('/') + + const currentRoute = [baseRoute, segment4, segment5, ...remainingSegments] + .filter(Boolean) + .join('/') + + const { Component: CustomViewComponent, viewKey: customViewKey } = getCustomViewByRoute({ + baseRoute, + currentRoute, + views, + }) + + if (customViewKey) { + viewKey = customViewKey + View = CustomViewComponent + } + } + + break + } + } + } + + if (globalConfig) { + const [globalEntity, globalSlug, segment3, ...remainingSegments] = routeSegments + + switch (routeSegments.length) { + // --> /globals/:globalSlug + case 2: { + View = getCustomDocumentViewByKey(views, 'default') || EditView + break + } + + case 3: { + // --> /globals/:globalSlug/api + // --> /globals/:globalSlug/versions + // --> /globals/:globalSlug/ + switch (segment3) { + // --> /globals/:globalSlug/api + case 'api': { + if (globalConfig?.admin?.hideAPIURL !== true) { + View = getCustomDocumentViewByKey(views, 'api') || DefaultAPIView + } + + break + } + + case 'versions': { + // --> /globals/:globalSlug/versions + if (docPermissions?.readVersions) { + View = getCustomDocumentViewByKey(views, 'versions') || DefaultVersionsView + } else { + View = UnauthorizedViewWithGutter + } + break + } + + // --> /globals/:globalSlug/ + default: { + if (docPermissions?.read) { + const baseRoute = [adminRoute, globalEntity, globalSlug, segment3] + .filter(Boolean) + .join('/') + + const currentRoute = [baseRoute, segment3, ...remainingSegments] + .filter(Boolean) + .join('/') + + const { Component: CustomViewComponent, viewKey: customViewKey } = + getCustomViewByRoute({ + baseRoute, + currentRoute, + views, + }) + + if (customViewKey) { + viewKey = customViewKey + + View = CustomViewComponent + } else { + View = EditView + } + } else { + View = UnauthorizedViewWithGutter + } + break + } + } + break + } + + // --> /globals/:globalSlug/versions/:version + // --> /globals/:globalSlug// + default: { + // --> /globals/:globalSlug/versions/:version + if (segment3 === 'versions') { + if (docPermissions?.readVersions) { + View = getCustomDocumentViewByKey(views, 'version') || DefaultVersionView + } else { + View = UnauthorizedViewWithGutter + } + } else { + // --> /globals/:globalSlug// + const baseRoute = [adminRoute !== '/' && adminRoute, 'globals', globalSlug] + .filter(Boolean) + .join('/') + + const currentRoute = [baseRoute, segment3, ...remainingSegments].filter(Boolean).join('/') + + const { Component: CustomViewComponent, viewKey: customViewKey } = getCustomViewByRoute({ + baseRoute, + currentRoute, + views, + }) + + if (customViewKey) { + viewKey = customViewKey + View = CustomViewComponent + } + } + + break + } + } + } + + return { + View, + viewKey, + } +} diff --git a/packages/ui/src/views/List/RenderListView.tsx b/packages/ui/src/views/List/RenderListView.tsx new file mode 100644 index 00000000000..434118b0f3c --- /dev/null +++ b/packages/ui/src/views/List/RenderListView.tsx @@ -0,0 +1,449 @@ +import type { + AdminViewServerProps, + CollectionPreferences, + Column, + ColumnPreference, + ListQuery, + ListViewClientProps, + ListViewServerPropsOnly, + PaginatedDocs, + PayloadComponent, + QueryPreset, + SanitizedCollectionPermission, +} from 'payload' + +import { + appendUploadSelectFields, + combineWhereConstraints, + formatAdminURL, + isNumber, + mergeListSearchAndWhere, + transformColumnsToPreferences, + transformColumnsToSearchParams, +} from 'payload/shared' +import React, { Fragment } from 'react' + +import { HydrateAuthProvider } from '../../elements/HydrateAuthProvider/index.js' +import { RenderServerComponent } from '../../elements/RenderServerComponent/index.js' +import { ListQueryProvider } from '../../providers/ListQuery/index.js' +import { getColumns } from '../../utilities/getColumns.js' +import { renderFilters, renderTable } from '../../utilities/renderTable.js' +import { upsertPreferences } from '../../utilities/upsertPreferences.js' +import { getDocumentPermissions } from '../Document/getDocumentPermissions.js' +import { enrichDocsWithVersionStatus } from './enrichDocsWithVersionStatus.js' +import { handleGroupBy } from './handleGroupBy.js' +import { DefaultListView } from './index.js' +import { renderListViewSlots } from './renderListViewSlots.js' +import { resolveAllFilterOptions } from './resolveAllFilterOptions.js' +import { transformColumnsToSelect } from './transformColumnsToSelect.js' + +/** + * @internal + */ +export type RenderListViewArgs = { + /** + * Allows providing your own list view component. This will override the default list view component and + * the collection's configured list view component (if any). + */ + ComponentOverride?: + | PayloadComponent + | React.ComponentType + customCellProps?: Record + disableBulkDelete?: boolean + disableBulkEdit?: boolean + disableQueryPresets?: boolean + drawerSlug?: string + enableRowSelections: boolean + overrideEntityVisibility?: boolean + /** + * If not ListQuery is provided, `req.query` will be used. + */ + query?: ListQuery + redirectAfterDelete?: boolean + redirectAfterDuplicate?: boolean + /** + * @experimental This prop is subject to change in future releases. + */ + trash?: boolean +} & AdminViewServerProps + +/** + * This function is responsible for rendering + * the list view on the server for both: + * - default list view + * - list view within drawers + * + * @internal + */ +export const renderListView = async ( + args: RenderListViewArgs, +): Promise<{ + List: React.ReactNode +}> => { + const { + clientConfig, + ComponentOverride, + customCellProps, + disableBulkDelete, + disableBulkEdit, + disableQueryPresets, + drawerSlug, + enableRowSelections, + initPageResult, + overrideEntityVisibility, + params, + query: queryFromArgs, + searchParams, + trash, + viewType, + } = args + + const { + collectionConfig, + collectionConfig: { slug: collectionSlug }, + locale: fullLocale, + permissions, + req, + req: { + i18n, + payload, + payload: { config }, + query: queryFromReq, + user, + }, + visibleEntities, + } = initPageResult + const { + routes: { admin: adminRoute }, + } = config + + if ( + !collectionConfig || + !permissions?.collections?.[collectionSlug]?.read || + (!visibleEntities.collections.includes(collectionSlug) && !overrideEntityVisibility) + ) { + throw new Error('not-found') + } + + const query: ListQuery = queryFromArgs || queryFromReq + + const columnsFromQuery: ColumnPreference[] = transformColumnsToPreferences(query?.columns) + + query.queryByGroup = + query?.queryByGroup && typeof query.queryByGroup === 'string' + ? JSON.parse(query.queryByGroup) + : query?.queryByGroup + + const collectionPreferences = await upsertPreferences({ + key: `collection-${collectionSlug}`, + req, + value: { + columns: columnsFromQuery, + groupBy: query?.groupBy, + limit: isNumber(query?.limit) ? Number(query.limit) : undefined, + preset: query?.preset, + sort: query?.sort as string, + }, + }) + + let queryPreset: QueryPreset | undefined + let queryPresetPermissions: SanitizedCollectionPermission | undefined + + if (collectionPreferences?.preset) { + try { + queryPreset = (await payload.findByID({ + id: collectionPreferences?.preset, + collection: 'payload-query-presets', + depth: 0, + overrideAccess: false, + user, + })) as QueryPreset + + if (queryPreset) { + queryPresetPermissions = ( + await getDocumentPermissions({ + id: queryPreset.id, + collectionConfig: req.payload.collections['payload-query-presets'].config, + data: queryPreset, + req, + }) + )?.docPermissions + } + } catch (err) { + req.payload.logger.error(`Error fetching query preset or preset permissions: ${err}`) + } + } + + query.preset = queryPreset?.id + if (queryPreset?.where && !query.where) { + query.where = queryPreset.where + } + query.groupBy = query.groupBy ?? queryPreset?.groupBy ?? collectionPreferences?.groupBy + + const columnPreference = query.columns + ? transformColumnsToPreferences(query.columns) + : (queryPreset?.columns ?? collectionPreferences?.columns) + query.columns = transformColumnsToSearchParams(columnPreference) + + query.page = isNumber(query?.page) ? Number(query.page) : 0 + + query.limit = collectionPreferences?.limit || collectionConfig.admin.pagination.defaultLimit + + query.sort = + collectionPreferences?.sort || + (typeof collectionConfig.defaultSort === 'string' ? collectionConfig.defaultSort : undefined) + + const baseFilterConstraint = await ( + collectionConfig.admin?.baseFilter ?? collectionConfig.admin?.baseListFilter + )?.({ + limit: query.limit, + page: query.page, + req, + sort: query.sort, + }) + + let whereWithMergedSearch = mergeListSearchAndWhere({ + collectionConfig, + search: typeof query?.search === 'string' ? query.search : undefined, + where: combineWhereConstraints([query?.where, baseFilterConstraint]), + }) + + if (trash === true) { + whereWithMergedSearch = { + and: [ + whereWithMergedSearch, + { + deletedAt: { + exists: true, + }, + }, + ], + } + } + + let Table: React.ReactNode | React.ReactNode[] = null + let columnState: Column[] = [] + let data: PaginatedDocs = { + // no results default + docs: [], + hasNextPage: false, + hasPrevPage: false, + limit: query.limit, + nextPage: null, + page: 1, + pagingCounter: 0, + prevPage: null, + totalDocs: 0, + totalPages: 0, + } + + const clientCollectionConfig = clientConfig.collections.find((c) => c.slug === collectionSlug) + + const columns = getColumns({ + clientConfig, + collectionConfig: clientCollectionConfig, + collectionSlug, + columns: columnPreference, + i18n, + permissions, + }) + + const select = collectionConfig.admin.enableListViewSelectAPI + ? transformColumnsToSelect(columns) + : undefined + + /** Force select image fields for list view thumbnails */ + appendUploadSelectFields({ + collectionConfig, + select, + }) + + try { + if (collectionConfig.admin.groupBy && query.groupBy) { + ;({ columnState, data, Table } = await handleGroupBy({ + clientCollectionConfig, + clientConfig, + collectionConfig, + collectionSlug, + columns, + customCellProps, + drawerSlug, + enableRowSelections, + fieldPermissions: permissions?.collections?.[collectionSlug]?.fields, + query, + req, + select, + trash, + user, + viewType, + where: whereWithMergedSearch, + })) + + // Enrich documents with correct display status for drafts + data = await enrichDocsWithVersionStatus({ + collectionConfig, + data, + req, + }) + } else { + data = await req.payload.find({ + collection: collectionSlug, + depth: 0, + draft: true, + fallbackLocale: false, + includeLockStatus: true, + limit: query?.limit ? Number(query.limit) : undefined, + locale: req.locale, + overrideAccess: false, + page: query?.page ? Number(query.page) : undefined, + req, + select, + sort: query?.sort, + trash, + user, + where: whereWithMergedSearch, + }) + + // Enrich documents with correct display status for drafts + data = await enrichDocsWithVersionStatus({ + collectionConfig, + data, + req, + }) + ;({ columnState, Table } = renderTable({ + clientCollectionConfig, + collectionConfig, + columns, + customCellProps, + data, + drawerSlug, + enableRowSelections, + fieldPermissions: permissions?.collections?.[collectionSlug]?.fields, + i18n: req.i18n, + orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined, + payload: req.payload, + query, + req, + useAsTitle: collectionConfig.admin.useAsTitle, + viewType, + })) + } + } catch (err) { + if (err.name !== 'QueryError') { + // QueryErrors are expected when a user filters by a field they do not have access to + req.payload.logger.error({ + err, + msg: `There was an error fetching the list view data for collection ${collectionSlug}`, + }) + throw err + } + } + + const renderedFilters = renderFilters(collectionConfig.fields, req.payload.importMap) + + const resolvedFilterOptions = await resolveAllFilterOptions({ + fields: collectionConfig.fields, + req, + }) + + const staticDescription = + typeof collectionConfig.admin.description === 'function' + ? collectionConfig.admin.description({ t: i18n.t }) + : collectionConfig.admin.description + + const newDocumentURL = formatAdminURL({ + adminRoute, + path: `/collections/${collectionSlug}/create`, + }) + + const hasCreatePermission = permissions?.collections?.[collectionSlug]?.create + + const { hasDeletePermission, hasTrashPermission } = await getDocumentPermissions({ + collectionConfig, + // Empty object serves as base for computing differentiated trash/delete permissions + data: {}, + req, + }) + + // Check if there's a notFound query parameter (document ID that wasn't found) + const notFoundDocId = typeof searchParams?.notFound === 'string' ? searchParams.notFound : null + + const serverProps: ListViewServerPropsOnly = { + collectionConfig, + data, + i18n, + limit: query.limit, + listPreferences: collectionPreferences, + listSearchableFields: collectionConfig.admin.listSearchableFields, + locale: fullLocale, + params, + payload, + permissions, + searchParams, + user, + } + + const listViewSlots = renderListViewSlots({ + clientProps: { + collectionSlug, + hasCreatePermission, + hasDeletePermission, + hasTrashPermission, + newDocumentURL, + }, + collectionConfig, + description: staticDescription, + notFoundDocId, + payload, + serverProps, + }) + + const isInDrawer = Boolean(drawerSlug) + + // Needed to prevent: Only plain objects can be passed to Client Components from Server Components. Objects with toJSON methods are not supported. Convert it manually to a simple value before passing it to props. + // Is there a way to avoid this? The `where` object is already seemingly plain, but is not bc it originates from the params. + query.where = query?.where ? JSON.parse(JSON.stringify(query?.where || {})) : undefined + + return { + List: ( + + + + {RenderServerComponent({ + clientProps: { + ...listViewSlots, + collectionSlug, + columnState, + disableBulkDelete, + disableBulkEdit: collectionConfig.disableBulkEdit ?? disableBulkEdit, + disableQueryPresets, + enableRowSelections, + hasCreatePermission, + hasDeletePermission, + hasTrashPermission, + listPreferences: collectionPreferences, + newDocumentURL, + queryPreset, + queryPresetPermissions, + renderedFilters, + resolvedFilterOptions, + Table, + viewType, + } satisfies ListViewClientProps, + Component: + ComponentOverride ?? collectionConfig?.admin?.components?.views?.list?.Component, + Fallback: DefaultListView, + importMap: payload.importMap, + serverProps, + })} + + + ), + } +} diff --git a/packages/ui/src/views/Version/index.tsx b/packages/ui/src/views/Version/index.tsx new file mode 100644 index 00000000000..c546a5e6f87 --- /dev/null +++ b/packages/ui/src/views/Version/index.tsx @@ -0,0 +1,10 @@ +import type { DocumentViewServerProps } from 'payload' +import type React from 'react' + +/** + * Stub - full implementation in RenderVersion.tsx (Task 9). + * Throws 'not-found' to be caught by outer DocumentView error handler. + */ +export function VersionView(_props: DocumentViewServerProps): React.ReactNode { + throw new Error('not-found') +} diff --git a/packages/ui/src/views/Versions/index.tsx b/packages/ui/src/views/Versions/index.tsx new file mode 100644 index 00000000000..950391e54f5 --- /dev/null +++ b/packages/ui/src/views/Versions/index.tsx @@ -0,0 +1,10 @@ +import type { DocumentViewServerProps } from 'payload' +import type React from 'react' + +/** + * Stub - full implementation in RenderVersions.tsx (Task 9). + * Throws 'not-found' to be caught by outer DocumentView error handler. + */ +export function VersionsView(_props: DocumentViewServerProps): React.ReactNode { + throw new Error('not-found') +} From bf649fd4dbc7accdb074067ce2e95be8589385a8 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 21:30:54 +0100 Subject: [PATCH 21/60] refactor(ui): move Version/Versions/Account render functions to packages/ui Co-Authored-By: Claude Sonnet 4.6 (1M context) --- packages/next/src/views/Account/index.tsx | 183 +------ packages/next/src/views/Version/index.tsx | 446 +----------------- packages/next/src/views/Versions/index.tsx | 201 +------- packages/ui/package.json | 25 + .../ui/src/views/Account/RenderAccount.tsx | 181 +++++++ .../ui/src/views/Version/Default/index.tsx | 283 +++++++++++ .../ui/src/views/Version/RenderVersion.tsx | 444 +++++++++++++++++ .../ui/src/views/Version/Restore/index.scss | 84 ++++ .../ui/src/views/Version/Restore/index.tsx | 151 ++++++ .../src/views/Version/SelectLocales/index.tsx | 42 ++ packages/ui/src/views/Version/index.tsx | 11 +- .../ui/src/views/Versions/RenderVersions.tsx | 201 ++++++++ packages/ui/src/views/Versions/index.tsx | 11 +- 13 files changed, 1424 insertions(+), 839 deletions(-) create mode 100644 packages/ui/src/views/Account/RenderAccount.tsx create mode 100644 packages/ui/src/views/Version/Default/index.tsx create mode 100644 packages/ui/src/views/Version/RenderVersion.tsx create mode 100644 packages/ui/src/views/Version/Restore/index.scss create mode 100644 packages/ui/src/views/Version/Restore/index.tsx create mode 100644 packages/ui/src/views/Version/SelectLocales/index.tsx create mode 100644 packages/ui/src/views/Versions/RenderVersions.tsx diff --git a/packages/next/src/views/Account/index.tsx b/packages/next/src/views/Account/index.tsx index e84d7cb0108..0e40995977e 100644 --- a/packages/next/src/views/Account/index.tsx +++ b/packages/next/src/views/Account/index.tsx @@ -1,180 +1,15 @@ -import type { AdminViewServerProps, DocumentViewServerPropsOnly } from 'payload' +import type { AdminViewServerProps } from 'payload' -import { DocumentInfoProvider, EditDepthProvider, HydrateAuthProvider } from '@payloadcms/ui' -import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' -import { buildFormState } from '@payloadcms/ui/utilities/buildFormState' +import { AccountView as AccountViewFromUI } from '@payloadcms/ui/views/Account/RenderAccount' import { notFound } from 'next/navigation.js' -import { formatAdminURL } from 'payload/shared' -import React from 'react' -import { DocumentHeader } from '../../elements/DocumentHeader/index.js' -import { getDocPreferences } from '../Document/getDocPreferences.js' -import { getDocumentData } from '../Document/getDocumentData.js' -import { getDocumentPermissions } from '../Document/getDocumentPermissions.js' -import { getIsLocked } from '../Document/getIsLocked.js' -import { getVersions } from '../Document/getVersions.js' -import { EditView } from '../Edit/index.js' -import { AccountClient } from './index.client.js' -import { Settings } from './Settings/index.js' - -export async function AccountView({ initPageResult, params, searchParams }: AdminViewServerProps) { - const { - languageOptions, - locale, - permissions, - req, - req: { - i18n, - payload, - payload: { config }, - user, - }, - } = initPageResult - - const { - admin: { theme, user: userSlug }, - routes: { api }, - serverURL, - } = config - - const collectionConfig = payload?.collections?.[userSlug]?.config - - if (collectionConfig && user?.id) { - // Fetch the data required for the view - const data = await getDocumentData({ - id: user.id, - collectionSlug: collectionConfig.slug, - locale, - payload, - req, - user, - }) - - if (!data) { - throw new Error('not-found') +export async function AccountView(props: AdminViewServerProps) { + try { + return await AccountViewFromUI(props) + } catch (error) { + if (error?.message === 'not-found') { + return notFound() } - - // Get document preferences - const docPreferences = await getDocPreferences({ - id: user.id, - collectionSlug: collectionConfig.slug, - payload, - user, - }) - - // Get permissions - const { - docPermissions, - hasDeletePermission, - hasPublishPermission, - hasSavePermission, - hasTrashPermission, - } = await getDocumentPermissions({ - id: user.id, - collectionConfig, - data, - req, - }) - - // Build initial form state from data - const { state: formState } = await buildFormState({ - id: user.id, - collectionSlug: collectionConfig.slug, - data, - docPermissions, - docPreferences, - locale: locale?.code, - operation: 'update', - renderAllFields: true, - req, - schemaPath: collectionConfig.slug, - skipValidation: true, - }) - - // Fetch document lock state - const { currentEditor, isLocked, lastUpdateTime } = await getIsLocked({ - id: user.id, - collectionConfig, - isEditing: true, - req, - }) - - // Get all versions required for UI - const { hasPublishedDoc, mostRecentVersionIsAutosaved, unpublishedVersionCount, versionCount } = - await getVersions({ - id: user.id, - collectionConfig, - doc: data, - docPermissions, - locale: locale?.code, - payload, - user, - }) - - return ( - - } - apiURL={formatAdminURL({ - apiRoute: api, - path: `/${userSlug}${user?.id ? `/${user.id}` : ''}`, - })} - collectionSlug={userSlug} - currentEditor={currentEditor} - docPermissions={docPermissions} - hasDeletePermission={hasDeletePermission} - hasPublishedDoc={hasPublishedDoc} - hasPublishPermission={hasPublishPermission} - hasSavePermission={hasSavePermission} - hasTrashPermission={hasTrashPermission} - id={user?.id} - initialData={data} - initialState={formState} - isEditing - isLocked={isLocked} - lastUpdateTime={lastUpdateTime} - mostRecentVersionIsAutosaved={mostRecentVersionIsAutosaved} - unpublishedVersionCount={unpublishedVersionCount} - versionCount={versionCount} - > - - - - {RenderServerComponent({ - Component: config.admin?.components?.views?.account?.Component, - Fallback: EditView, - importMap: payload.importMap, - serverProps: { - doc: data, - hasPublishedDoc, - i18n, - initPageResult, - locale, - params, - payload, - permissions, - routeSegments: [], - searchParams, - user, - } satisfies DocumentViewServerPropsOnly, - })} - - - - ) + throw error } - - return notFound() } diff --git a/packages/next/src/views/Version/index.tsx b/packages/next/src/views/Version/index.tsx index 2266821c596..7c912c15642 100644 --- a/packages/next/src/views/Version/index.tsx +++ b/packages/next/src/views/Version/index.tsx @@ -1,445 +1 @@ -import type { - DocumentViewServerProps, - Locale, - SanitizedCollectionPermission, - SanitizedGlobalPermission, - TypeWithVersion, -} from 'payload' - -import { formatDate } from '@payloadcms/ui/shared' -import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig' -import { getClientSchemaMap } from '@payloadcms/ui/utilities/getClientSchemaMap' -import { getSchemaMap } from '@payloadcms/ui/utilities/getSchemaMap' -import { notFound } from 'next/navigation.js' -import { hasDraftsEnabled } from 'payload/shared' -import React from 'react' - -import type { CompareOption } from './Default/types.js' - -import { DefaultVersionView } from './Default/index.js' -import { fetchLatestVersion, fetchVersion, fetchVersions } from './fetchVersions.js' -import { RenderDiff } from './RenderFieldsToDiff/index.js' -import { getVersionLabel } from './VersionPillLabel/getVersionLabel.js' -import { VersionPillLabel } from './VersionPillLabel/VersionPillLabel.js' - -export async function VersionView(props: DocumentViewServerProps) { - const { hasPublishedDoc, i18n, initPageResult, routeSegments, searchParams } = props - - const { - collectionConfig, - docID: id, - globalConfig, - permissions, - req, - req: { payload, payload: { config, config: { localization } } = {}, user } = {}, - } = initPageResult - - const versionToID = routeSegments[routeSegments.length - 1] - - const collectionSlug = collectionConfig?.slug - const globalSlug = globalConfig?.slug - - const draftsEnabled = hasDraftsEnabled(collectionConfig || globalConfig) - - // Resolve user's current locale for version label comparison (not 'all') - const userLocale = - (searchParams.locale as string) || - (req.locale !== 'all' ? req.locale : localization && localization.defaultLocale) - - const localeCodesFromParams = searchParams.localeCodes - ? JSON.parse(searchParams.localeCodes as string) - : null - - const versionFromIDFromParams = searchParams.versionFrom as string - - const modifiedOnly: boolean = searchParams.modifiedOnly === 'false' ? false : true - - const docPermissions: SanitizedCollectionPermission | SanitizedGlobalPermission = collectionSlug - ? permissions.collections[collectionSlug] - : permissions.globals[globalSlug] - - const versionTo = await fetchVersion<{ - _status?: string - }>({ - id: versionToID, - collectionSlug, - depth: 1, - globalSlug, - locale: 'all', - overrideAccess: false, - req, - user, - }) - - if (!versionTo) { - return notFound() - } - - const [ - previousVersionResult, - versionFromResult, - currentlyPublishedVersion, - latestDraftVersion, - previousPublishedVersionResult, - ] = await Promise.all([ - // Previous version (the one before the versionTo) - fetchVersions({ - collectionSlug, - // If versionFromIDFromParams is provided, the previous version is only used in the version comparison dropdown => depth 0 is enough. - // If it's not provided, this is used as `versionFrom` in the comparison, which expects populated data => depth 1 is needed. - depth: versionFromIDFromParams ? 0 : 1, - draft: true, - globalSlug, - limit: 1, - locale: 'all', - overrideAccess: false, - parentID: id, - req, - sort: '-updatedAt', - user, - where: { - and: [ - { - updatedAt: { - less_than: versionTo.updatedAt, - }, - }, - ], - }, - }), - // Version from ID from params - (versionFromIDFromParams - ? fetchVersion({ - id: versionFromIDFromParams, - collectionSlug, - depth: 1, - globalSlug, - locale: 'all', - overrideAccess: false, - req, - user, - }) - : Promise.resolve(null)) as Promise>, - // Currently published version - do note: currently published != latest published, as an unpublished version can be the latest published - hasPublishedDoc - ? fetchLatestVersion({ - collectionSlug, - depth: 0, - globalSlug, - locale: req.locale, - overrideAccess: false, - parentID: id, - req, - status: 'published', - user, - }) - : Promise.resolve(null), - // Latest draft version - draftsEnabled - ? fetchLatestVersion({ - collectionSlug, - depth: 0, - globalSlug, - locale: 'all', - overrideAccess: false, - parentID: id, - req, - status: 'draft', - user, - }) - : Promise.resolve(null), - // Previous published version - // Only query for published versions if drafts are enabled (since _status field only exists with drafts) - draftsEnabled - ? fetchVersions({ - collectionSlug, - depth: 0, - draft: true, - globalSlug, - limit: 1, - locale: 'all', - overrideAccess: false, - parentID: id, - req, - sort: '-updatedAt', - user, - where: { - and: [ - { - updatedAt: { - less_than: versionTo.updatedAt, - }, - }, - { - 'version._status': { - equals: 'published', - }, - }, - ], - }, - }) - : Promise.resolve(null), - ]) - - const previousVersion: null | TypeWithVersion = previousVersionResult?.docs?.[0] ?? null - - const versionFrom = - versionFromResult || - // By default, we'll compare the previous version. => versionFrom = version previous to versionTo - previousVersion - - // Previous published version before the versionTo - const previousPublishedVersion = previousPublishedVersionResult?.docs?.[0] ?? null - - let selectedLocales: string[] = [] - if (localization) { - let locales: Locale[] = [] - if (localeCodesFromParams) { - for (const code of localeCodesFromParams) { - const locale = localization.locales.find((locale) => locale.code === code) - if (!locale) { - continue - } - locales.push(locale) - } - } else { - locales = localization.locales - } - - if (localization.filterAvailableLocales) { - locales = (await localization.filterAvailableLocales({ locales, req })) || [] - } - - selectedLocales = locales.map((locale) => locale.code) - } - - const schemaMap = getSchemaMap({ - collectionSlug, - config, - globalSlug, - i18n, - }) - - const clientSchemaMap = getClientSchemaMap({ - collectionSlug, - config: getClientConfig({ - config: payload.config, - i18n, - importMap: payload.importMap, - user, - }), - globalSlug, - i18n, - payload, - schemaMap, - }) - const RenderedDiff = RenderDiff({ - clientSchemaMap, - customDiffComponents: {}, - entitySlug: collectionSlug || globalSlug, - fields: (collectionConfig || globalConfig)?.fields, - fieldsPermissions: docPermissions?.fields, - i18n, - modifiedOnly, - parentIndexPath: '', - parentIsLocalized: false, - parentPath: '', - parentSchemaPath: '', - req, - selectedLocales, - versionFromSiblingData: { - ...versionFrom?.version, - updatedAt: versionFrom?.updatedAt, - }, - versionToSiblingData: { - ...versionTo.version, - updatedAt: versionTo.updatedAt, - }, - }) - - const versionToCreatedAtFormatted = versionTo.updatedAt - ? formatDate({ - date: - typeof versionTo.updatedAt === 'string' - ? new Date(versionTo.updatedAt) - : (versionTo.updatedAt as Date), - i18n, - pattern: config.admin.dateFormat, - }) - : '' - - const formatPill = ({ - doc, - labelOverride, - labelStyle, - labelSuffix, - }: { - doc: TypeWithVersion - labelOverride?: string - labelStyle?: 'pill' | 'text' - labelSuffix?: React.ReactNode - }): React.ReactNode => { - return ( - - ) - } - - // SelectComparison Options: - // - // Previous version: always, unless doesn't exist. Can be the same as previously published - // Latest draft: only if no newer published exists (latestDraftVersion) - // Currently published: always, if exists - // Previously published: if there is a prior published version older than versionTo - // Specific Version: only if not already present under other label (= versionFrom) - - let versionFromOptions: { - doc: TypeWithVersion - labelOverride?: string - updatedAt: Date - value: string - }[] = [] - - // Previous version - if (previousVersion?.id) { - versionFromOptions.push({ - doc: previousVersion, - labelOverride: i18n.t('version:previousVersion'), - updatedAt: new Date(previousVersion.updatedAt), - value: previousVersion.id, - }) - } - - // Latest Draft - const publishedNewerThanDraft = - currentlyPublishedVersion?.updatedAt > latestDraftVersion?.updatedAt - if (latestDraftVersion && !publishedNewerThanDraft) { - versionFromOptions.push({ - doc: latestDraftVersion, - updatedAt: new Date(latestDraftVersion.updatedAt), - value: latestDraftVersion.id, - }) - } - - // Currently Published - if (currentlyPublishedVersion) { - versionFromOptions.push({ - doc: currentlyPublishedVersion, - updatedAt: new Date(currentlyPublishedVersion.updatedAt), - value: currentlyPublishedVersion.id, - }) - } - - // Previous Published - if (previousPublishedVersion && currentlyPublishedVersion?.id !== previousPublishedVersion.id) { - versionFromOptions.push({ - doc: previousPublishedVersion, - labelOverride: i18n.t('version:previouslyPublished'), - updatedAt: new Date(previousPublishedVersion.updatedAt), - value: previousPublishedVersion.id, - }) - } - - // Specific Version - if (versionFrom?.id && !versionFromOptions.some((option) => option.value === versionFrom.id)) { - // Only add "specific version" if it is not already in the options - versionFromOptions.push({ - doc: versionFrom, - labelOverride: i18n.t('version:specificVersion'), - updatedAt: new Date(versionFrom.updatedAt), - value: versionFrom.id, - }) - } - - versionFromOptions = versionFromOptions.sort((a, b) => { - // Sort by updatedAt, newest first - if (a && b) { - return b.updatedAt.getTime() - a.updatedAt.getTime() - } - return 0 - }) - - const versionToIsVersionFrom = versionFrom?.id === versionTo.id - - const versionFromComparisonOptions: CompareOption[] = [] - - for (const option of versionFromOptions) { - const isVersionTo = option.value === versionTo.id - - if (isVersionTo && !versionToIsVersionFrom) { - // Don't offer selecting a versionFrom that is the same as versionTo, unless it's already selected - continue - } - - const alreadyAdded = versionFromComparisonOptions.some( - (existingOption) => existingOption.value === option.value, - ) - if (alreadyAdded) { - continue - } - - const otherOptionsWithSameID = versionFromOptions.filter( - (existingOption) => existingOption.value === option.value && existingOption !== option, - ) - - // Merge options with same ID to the same option - const labelSuffix = otherOptionsWithSameID?.length ? ( - - {' ('} - {otherOptionsWithSameID.map((optionWithSameID, index) => { - const label = - optionWithSameID.labelOverride || - getVersionLabel({ - currentLocale: userLocale, - currentlyPublishedVersion, - latestDraftVersion, - t: i18n.t, - version: optionWithSameID.doc, - }).label - - return ( - - {index > 0 ? ', ' : ''} - {label} - - ) - })} - {')'} - - ) : undefined - - versionFromComparisonOptions.push({ - label: formatPill({ - doc: option.doc, - labelOverride: option.labelOverride, - labelSuffix, - }), - value: option.value, - }) - } - - return ( - - ) -} +export { VersionView } from '@payloadcms/ui/views/Version' diff --git a/packages/next/src/views/Versions/index.tsx b/packages/next/src/views/Versions/index.tsx index 6457373129b..a0158d980fd 100644 --- a/packages/next/src/views/Versions/index.tsx +++ b/packages/next/src/views/Versions/index.tsx @@ -1,200 +1 @@ -import { Gutter, ListQueryProvider, SetDocumentStepNav } from '@payloadcms/ui' -import { notFound } from 'next/navigation.js' -import { type DocumentViewServerProps, type PaginatedDocs, type Where } from 'payload' -import { formatAdminURL, hasDraftsEnabled, isNumber } from 'payload/shared' -import React from 'react' - -import { fetchLatestVersion, fetchVersions } from '../Version/fetchVersions.js' -import { VersionDrawerCreatedAtCell } from '../Version/SelectComparison/VersionDrawer/CreatedAtCell.js' -import { buildVersionColumns } from './buildColumns.js' -import { VersionsViewClient } from './index.client.js' -import './index.scss' - -const baseClass = 'versions' - -export async function VersionsView(props: DocumentViewServerProps) { - const { - hasPublishedDoc, - initPageResult: { - collectionConfig, - docID: id, - globalConfig, - req, - req: { - i18n, - payload: { config }, - t, - user, - }, - }, - routeSegments: segments, - searchParams: { limit, page, sort }, - versions: { disableGutter = false, useVersionDrawerCreatedAtCell = false } = {}, - } = props - - const draftsEnabled = hasDraftsEnabled(collectionConfig || globalConfig) - - const collectionSlug = collectionConfig?.slug - const globalSlug = globalConfig?.slug - - const isTrashed = segments[2] === 'trash' - - const { - localization, - routes: { api: apiRoute }, - serverURL, - } = config - - const whereQuery: { - and: Array<{ parent?: { equals: number | string }; snapshot?: { not_equals: boolean } }> - } & Where = { - and: [], - } - if (localization && draftsEnabled) { - whereQuery.and.push({ - snapshot: { - not_equals: true, - }, - }) - } - - const defaultLimit = collectionSlug ? collectionConfig?.admin?.pagination?.defaultLimit : 10 - - const limitToUse = isNumber(limit) ? Number(limit) : defaultLimit - - const versionsData: PaginatedDocs = await fetchVersions({ - collectionSlug, - depth: 0, - globalSlug, - limit: limitToUse, - locale: req.locale, - overrideAccess: false, - page: page ? parseInt(page.toString(), 10) : undefined, - parentID: id, - req, - sort: sort as string, - user, - where: whereQuery, - }) - - if (!versionsData) { - return notFound() - } - - const [currentlyPublishedVersion, latestDraftVersion] = await Promise.all([ - hasPublishedDoc - ? fetchLatestVersion({ - collectionSlug, - depth: 0, - globalSlug, - locale: req.locale, - overrideAccess: false, - parentID: id, - req, - select: { - id: true, - updatedAt: true, - version: { - _status: true, - updatedAt: true, - }, - }, - status: 'published', - user, - where: localization - ? { - snapshot: { - not_equals: true, - }, - } - : undefined, - }) - : Promise.resolve(null), - draftsEnabled - ? fetchLatestVersion({ - collectionSlug, - depth: 0, - globalSlug, - locale: req.locale, - overrideAccess: false, - parentID: id, - req, - select: { - id: true, - updatedAt: true, - version: { - _status: true, - updatedAt: true, - }, - }, - status: 'draft', - user, - where: localization - ? { - snapshot: { - not_equals: true, - }, - } - : undefined, - }) - : Promise.resolve(null), - ]) - - const fetchURL = formatAdminURL({ - apiRoute, - path: collectionSlug ? `/${collectionSlug}/versions` : `/${globalSlug}/versions`, - }) - - const columns = buildVersionColumns({ - collectionConfig, - CreatedAtCellOverride: useVersionDrawerCreatedAtCell ? VersionDrawerCreatedAtCell : undefined, - currentlyPublishedVersion, - docID: id, - docs: versionsData?.docs, - globalConfig, - i18n, - isTrashed, - latestDraftVersion, - }) - - const pluralLabel = - typeof collectionConfig?.labels?.plural === 'function' - ? collectionConfig.labels.plural({ i18n, t }) - : (collectionConfig?.labels?.plural ?? globalConfig?.label) - - const GutterComponent = disableGutter ? React.Fragment : Gutter - - return ( - - -
    - - - - - -
    -
    - ) -} +export { VersionsView } from '@payloadcms/ui/views/Versions' diff --git a/packages/ui/package.json b/packages/ui/package.json index a75e28e39a5..d2f7bed91a5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -96,6 +96,31 @@ "types": "./src/views/Document/getDocumentView.tsx", "default": "./src/views/Document/getDocumentView.tsx" }, + "./views/Version": { + "import": "./src/views/Version/index.tsx", + "types": "./src/views/Version/index.tsx", + "default": "./src/views/Version/index.tsx" + }, + "./views/Version/RenderVersion": { + "import": "./src/views/Version/RenderVersion.tsx", + "types": "./src/views/Version/RenderVersion.tsx", + "default": "./src/views/Version/RenderVersion.tsx" + }, + "./views/Versions": { + "import": "./src/views/Versions/index.tsx", + "types": "./src/views/Versions/index.tsx", + "default": "./src/views/Versions/index.tsx" + }, + "./views/Versions/RenderVersions": { + "import": "./src/views/Versions/RenderVersions.tsx", + "types": "./src/views/Versions/RenderVersions.tsx", + "default": "./src/views/Versions/RenderVersions.tsx" + }, + "./views/Account/RenderAccount": { + "import": "./src/views/Account/RenderAccount.tsx", + "types": "./src/views/Account/RenderAccount.tsx", + "default": "./src/views/Account/RenderAccount.tsx" + }, "./views/Version/fetchVersions": { "import": "./src/views/Version/fetchVersions.ts", "types": "./src/views/Version/fetchVersions.ts", diff --git a/packages/ui/src/views/Account/RenderAccount.tsx b/packages/ui/src/views/Account/RenderAccount.tsx new file mode 100644 index 00000000000..e4af614a388 --- /dev/null +++ b/packages/ui/src/views/Account/RenderAccount.tsx @@ -0,0 +1,181 @@ +import type { AdminViewServerProps, DocumentViewServerPropsOnly } from 'payload' + +import { formatAdminURL } from 'payload/shared' +import React from 'react' + +import { DocumentHeader } from '../../elements/DocumentHeader/index.js' +import { HydrateAuthProvider } from '../../elements/HydrateAuthProvider/index.js' +import { RenderServerComponent } from '../../elements/RenderServerComponent/index.js' +import { DocumentInfoProvider } from '../../providers/DocumentInfo/index.js' +import { EditDepthProvider } from '../../providers/EditDepth/index.js' +import { buildFormState } from '../../utilities/buildFormState.js' +import { getDocPreferences } from '../Document/getDocPreferences.js' +import { getDocumentData } from '../Document/getDocumentData.js' +import { getDocumentPermissions } from '../Document/getDocumentPermissions.js' +import { getIsLocked } from '../Document/getIsLocked.js' +import { getVersions } from '../Document/getVersions.js' +import { DefaultEditView as EditView } from '../Edit/index.js' +import { AccountClient } from './index.client.js' +import { Settings } from './Settings/index.js' + +export async function AccountView({ initPageResult, params, searchParams }: AdminViewServerProps) { + const { + languageOptions, + locale, + permissions, + req, + req: { + i18n, + payload, + payload: { config }, + user, + }, + } = initPageResult + + const { + admin: { theme, user: userSlug }, + routes: { api }, + serverURL, + } = config + + const collectionConfig = payload?.collections?.[userSlug]?.config + + if (collectionConfig && user?.id) { + // Fetch the data required for the view + const data = await getDocumentData({ + id: user.id, + collectionSlug: collectionConfig.slug, + locale, + payload, + req, + user, + }) + + if (!data) { + throw new Error('not-found') + } + + // Get document preferences + const docPreferences = await getDocPreferences({ + id: user.id, + collectionSlug: collectionConfig.slug, + payload, + user, + }) + + // Get permissions + const { + docPermissions, + hasDeletePermission, + hasPublishPermission, + hasSavePermission, + hasTrashPermission, + } = await getDocumentPermissions({ + id: user.id, + collectionConfig, + data, + req, + }) + + // Build initial form state from data + const { state: formState } = await buildFormState({ + id: user.id, + collectionSlug: collectionConfig.slug, + data, + docPermissions, + docPreferences, + locale: locale?.code, + operation: 'update', + renderAllFields: true, + req, + schemaPath: collectionConfig.slug, + skipValidation: true, + }) + + // Fetch document lock state + const { currentEditor, isLocked, lastUpdateTime } = await getIsLocked({ + id: user.id, + collectionConfig, + isEditing: true, + req, + }) + + // Get all versions required for UI + const { hasPublishedDoc, mostRecentVersionIsAutosaved, unpublishedVersionCount, versionCount } = + await getVersions({ + id: user.id, + collectionConfig, + doc: data, + docPermissions, + locale: locale?.code, + payload, + user, + }) + + return ( + + } + apiURL={formatAdminURL({ + apiRoute: api, + path: `/${userSlug}${user?.id ? `/${user.id}` : ''}`, + })} + collectionSlug={userSlug} + currentEditor={currentEditor} + docPermissions={docPermissions} + hasDeletePermission={hasDeletePermission} + hasPublishedDoc={hasPublishedDoc} + hasPublishPermission={hasPublishPermission} + hasSavePermission={hasSavePermission} + hasTrashPermission={hasTrashPermission} + id={user?.id} + initialData={data} + initialState={formState} + isEditing + isLocked={isLocked} + lastUpdateTime={lastUpdateTime} + mostRecentVersionIsAutosaved={mostRecentVersionIsAutosaved} + unpublishedVersionCount={unpublishedVersionCount} + versionCount={versionCount} + > + + + + {RenderServerComponent({ + Component: config.admin?.components?.views?.account?.Component, + Fallback: EditView, + importMap: payload.importMap, + serverProps: { + doc: data, + hasPublishedDoc, + i18n, + initPageResult, + locale, + params, + payload, + permissions, + routeSegments: [], + searchParams, + user, + } satisfies DocumentViewServerPropsOnly, + })} + + + + ) + } + + throw new Error('not-found') +} diff --git a/packages/ui/src/views/Version/Default/index.tsx b/packages/ui/src/views/Version/Default/index.tsx new file mode 100644 index 00000000000..cbb47156e2e --- /dev/null +++ b/packages/ui/src/views/Version/Default/index.tsx @@ -0,0 +1,283 @@ +'use client' + +import React, { type FormEventHandler, useCallback, useEffect, useMemo, useState } from 'react' + +import type { SelectablePill } from '../../../elements/PillSelector/index.js' +import type { CompareOption, DefaultVersionsViewProps } from './types.js' + +import { Gutter } from '../../../elements/Gutter/index.js' +import { Pill } from '../../../elements/Pill/index.js' +import { CheckboxInput } from '../../../fields/Checkbox/index.js' +import { ChevronIcon } from '../../../icons/Chevron/index.js' +import { useConfig } from '../../../providers/Config/index.js' +import { useDocumentInfo } from '../../../providers/DocumentInfo/index.js' +import { useLocale } from '../../../providers/Locale/index.js' +import { usePathname, useRouter, useSearchParams } from '../../../providers/Router/index.js' +import { useRouteTransition } from '../../../providers/RouteTransition/index.js' +import { useTranslation } from '../../../providers/Translation/index.js' +import { formatTimeToNow } from '../../../utilities/formatDocTitle/formatDateTitle.js' +import { Restore } from '../Restore/index.js' +import './index.scss' +import { SelectComparison } from '../SelectComparison/index.js' +import { type SelectedLocaleOnChange, SelectLocales } from '../SelectLocales/index.js' +import { SelectedLocalesContext } from './SelectedLocalesContext.js' +import { SetStepNav } from './SetStepNav.js' + +const baseClass = 'view-version' + +export const DefaultVersionView: React.FC = ({ + canUpdate, + modifiedOnly: modifiedOnlyProp, + RenderedDiff, + selectedLocales: selectedLocalesFromProps, + versionFromCreatedAt, + versionFromID, + versionFromOptions, + versionToCreatedAt, + versionToCreatedAtFormatted, + VersionToCreatedAtLabel, + versionToID, + versionToStatus, +}) => { + const { config, getEntityConfig } = useConfig() + const { code } = useLocale() + const { i18n, t } = useTranslation() + + const [locales, setLocales] = useState([]) + const [localeSelectorOpen, setLocaleSelectorOpen] = React.useState(false) + + useEffect(() => { + if (config.localization) { + const updatedLocales = config.localization.locales.map((locale) => { + let label = locale.label + if (typeof locale.label !== 'string' && locale.label[code]) { + label = locale.label[code] + } + + return { + name: locale.code, + Label: label, + selected: selectedLocalesFromProps.includes(locale.code), + } as SelectablePill + }) + setLocales(updatedLocales) + } + }, [code, config.localization, selectedLocalesFromProps]) + + const { id: originalDocID, collectionSlug, globalSlug, isTrashed } = useDocumentInfo() + const { startRouteTransition } = useRouteTransition() + + const { collectionConfig, globalConfig } = useMemo(() => { + return { + collectionConfig: getEntityConfig({ collectionSlug }), + globalConfig: getEntityConfig({ globalSlug }), + } + }, [collectionSlug, globalSlug, getEntityConfig]) + + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + const [modifiedOnly, setModifiedOnly] = useState(modifiedOnlyProp) + + const updateSearchParams = useCallback( + (args: { + modifiedOnly?: boolean + selectedLocales?: SelectablePill[] + versionFromID?: string + }) => { + // If the selected comparison doc or locales change, update URL params so that version page + // This is so that RSC can update the version comparison state + const current = new URLSearchParams(Array.from(searchParams.entries())) + + if (args?.versionFromID) { + current.set('versionFrom', args?.versionFromID) + } + + if (args?.selectedLocales) { + if (!args.selectedLocales.length) { + current.delete('localeCodes') + } else { + const selectedLocaleCodes: string[] = [] + for (const locale of args.selectedLocales) { + if (locale.selected) { + selectedLocaleCodes.push(locale.name) + } + } + current.set('localeCodes', JSON.stringify(selectedLocaleCodes)) + } + } + + if (args?.modifiedOnly === false) { + current.set('modifiedOnly', 'false') + } else if (args?.modifiedOnly === true) { + current.delete('modifiedOnly') + } + + const search = current.toString() + const query = search ? `?${search}` : '' + + startRouteTransition(() => router.push(`${pathname}${query}`)) + }, + [pathname, router, searchParams, startRouteTransition], + ) + + const onToggleModifiedOnly: FormEventHandler = useCallback( + (event) => { + const newModified = (event.target as HTMLInputElement).checked + setModifiedOnly(newModified) + updateSearchParams({ + modifiedOnly: newModified, + }) + }, + [updateSearchParams], + ) + + const onChangeSelectedLocales: SelectedLocaleOnChange = useCallback( + ({ locales }) => { + setLocales(locales) + updateSearchParams({ + selectedLocales: locales, + }) + }, + [updateSearchParams], + ) + + const onChangeVersionFrom: (val: CompareOption) => void = useCallback( + (val) => { + updateSearchParams({ + versionFromID: val.value, + }) + }, + [updateSearchParams], + ) + + const { localization } = config + + const versionToTimeAgo = useMemo( + () => + t('version:versionAgo', { + distance: formatTimeToNow({ + date: versionToCreatedAt, + i18n, + }), + }), + [versionToCreatedAt, i18n, t], + ) + + const versionFromTimeAgo = useMemo( + () => + versionFromCreatedAt + ? t('version:versionAgo', { + distance: formatTimeToNow({ + date: versionFromCreatedAt, + i18n, + }), + }) + : undefined, + [versionFromCreatedAt, i18n, t], + ) + + return ( +
    + +
    +

    {i18n.t('version:compareVersions')}

    +
    + + + + {localization && ( + } + onClick={() => setLocaleSelectorOpen((localeSelectorOpen) => !localeSelectorOpen)} + pillStyle="light" + size="small" + > + + {t('general:locales')}:{' '} + + + {locales + .filter((locale) => locale.selected) + .map((locale) => locale.name) + .join(', ')} + + + )} +
    +
    + + {localization && ( + + )} +
    + +
    +
    +
    + {t('version:comparingAgainst')} + {versionFromTimeAgo && ( + {versionFromTimeAgo} + )} +
    + +
    + +
    +
    + {t('version:currentlyViewing')} + {versionToTimeAgo} +
    +
    + {VersionToCreatedAtLabel} + {canUpdate && !isTrashed && ( + + )} +
    +
    +
    +
    + + + locale.name) }}> + {versionToCreatedAt && RenderedDiff} + + +
    + ) +} diff --git a/packages/ui/src/views/Version/RenderVersion.tsx b/packages/ui/src/views/Version/RenderVersion.tsx new file mode 100644 index 00000000000..cb645178576 --- /dev/null +++ b/packages/ui/src/views/Version/RenderVersion.tsx @@ -0,0 +1,444 @@ +import type { + DocumentViewServerProps, + Locale, + SanitizedCollectionPermission, + SanitizedGlobalPermission, + TypeWithVersion, +} from 'payload' + +import { hasDraftsEnabled } from 'payload/shared' +import React from 'react' + +import type { CompareOption } from './Default/types.js' + +import { formatDate } from '../../utilities/formatDocTitle/formatDateTitle.js' +import { getClientConfig } from '../../utilities/getClientConfig.js' +import { getClientSchemaMap } from '../../utilities/getClientSchemaMap.js' +import { getSchemaMap } from '../../utilities/getSchemaMap.js' +import { DefaultVersionView } from './Default/index.js' +import { fetchLatestVersion, fetchVersion, fetchVersions } from './fetchVersions.js' +import { RenderDiff } from './RenderFieldsToDiff/index.js' +import { getVersionLabel } from './VersionPillLabel/getVersionLabel.js' +import { VersionPillLabel } from './VersionPillLabel/VersionPillLabel.js' + +export async function VersionView(props: DocumentViewServerProps) { + const { hasPublishedDoc, i18n, initPageResult, routeSegments, searchParams } = props + + const { + collectionConfig, + docID: id, + globalConfig, + permissions, + req, + req: { payload, payload: { config, config: { localization } } = {}, user } = {}, + } = initPageResult + + const versionToID = routeSegments[routeSegments.length - 1] + + const collectionSlug = collectionConfig?.slug + const globalSlug = globalConfig?.slug + + const draftsEnabled = hasDraftsEnabled(collectionConfig || globalConfig) + + // Resolve user's current locale for version label comparison (not 'all') + const userLocale = + (searchParams.locale as string) || + (req.locale !== 'all' ? req.locale : localization && localization.defaultLocale) + + const localeCodesFromParams = searchParams.localeCodes + ? JSON.parse(searchParams.localeCodes as string) + : null + + const versionFromIDFromParams = searchParams.versionFrom as string + + const modifiedOnly: boolean = searchParams.modifiedOnly === 'false' ? false : true + + const docPermissions: SanitizedCollectionPermission | SanitizedGlobalPermission = collectionSlug + ? permissions.collections[collectionSlug] + : permissions.globals[globalSlug] + + const versionTo = await fetchVersion<{ + _status?: string + }>({ + id: versionToID, + collectionSlug, + depth: 1, + globalSlug, + locale: 'all', + overrideAccess: false, + req, + user, + }) + + if (!versionTo) { + throw new Error('not-found') + } + + const [ + previousVersionResult, + versionFromResult, + currentlyPublishedVersion, + latestDraftVersion, + previousPublishedVersionResult, + ] = await Promise.all([ + // Previous version (the one before the versionTo) + fetchVersions({ + collectionSlug, + // If versionFromIDFromParams is provided, the previous version is only used in the version comparison dropdown => depth 0 is enough. + // If it's not provided, this is used as `versionFrom` in the comparison, which expects populated data => depth 1 is needed. + depth: versionFromIDFromParams ? 0 : 1, + draft: true, + globalSlug, + limit: 1, + locale: 'all', + overrideAccess: false, + parentID: id, + req, + sort: '-updatedAt', + user, + where: { + and: [ + { + updatedAt: { + less_than: versionTo.updatedAt, + }, + }, + ], + }, + }), + // Version from ID from params + (versionFromIDFromParams + ? fetchVersion({ + id: versionFromIDFromParams, + collectionSlug, + depth: 1, + globalSlug, + locale: 'all', + overrideAccess: false, + req, + user, + }) + : Promise.resolve(null)) as Promise>, + // Currently published version - do note: currently published != latest published, as an unpublished version can be the latest published + hasPublishedDoc + ? fetchLatestVersion({ + collectionSlug, + depth: 0, + globalSlug, + locale: req.locale, + overrideAccess: false, + parentID: id, + req, + status: 'published', + user, + }) + : Promise.resolve(null), + // Latest draft version + draftsEnabled + ? fetchLatestVersion({ + collectionSlug, + depth: 0, + globalSlug, + locale: 'all', + overrideAccess: false, + parentID: id, + req, + status: 'draft', + user, + }) + : Promise.resolve(null), + // Previous published version + // Only query for published versions if drafts are enabled (since _status field only exists with drafts) + draftsEnabled + ? fetchVersions({ + collectionSlug, + depth: 0, + draft: true, + globalSlug, + limit: 1, + locale: 'all', + overrideAccess: false, + parentID: id, + req, + sort: '-updatedAt', + user, + where: { + and: [ + { + updatedAt: { + less_than: versionTo.updatedAt, + }, + }, + { + 'version._status': { + equals: 'published', + }, + }, + ], + }, + }) + : Promise.resolve(null), + ]) + + const previousVersion: null | TypeWithVersion = previousVersionResult?.docs?.[0] ?? null + + const versionFrom = + versionFromResult || + // By default, we'll compare the previous version. => versionFrom = version previous to versionTo + previousVersion + + // Previous published version before the versionTo + const previousPublishedVersion = previousPublishedVersionResult?.docs?.[0] ?? null + + let selectedLocales: string[] = [] + if (localization) { + let locales: Locale[] = [] + if (localeCodesFromParams) { + for (const code of localeCodesFromParams) { + const locale = localization.locales.find((locale) => locale.code === code) + if (!locale) { + continue + } + locales.push(locale) + } + } else { + locales = localization.locales + } + + if (localization.filterAvailableLocales) { + locales = (await localization.filterAvailableLocales({ locales, req })) || [] + } + + selectedLocales = locales.map((locale) => locale.code) + } + + const schemaMap = getSchemaMap({ + collectionSlug, + config, + globalSlug, + i18n, + }) + + const clientSchemaMap = getClientSchemaMap({ + collectionSlug, + config: getClientConfig({ + config: payload.config, + i18n, + importMap: payload.importMap, + user, + }), + globalSlug, + i18n, + payload, + schemaMap, + }) + const RenderedDiff = RenderDiff({ + clientSchemaMap, + customDiffComponents: {}, + entitySlug: collectionSlug || globalSlug, + fields: (collectionConfig || globalConfig)?.fields, + fieldsPermissions: docPermissions?.fields, + i18n, + modifiedOnly, + parentIndexPath: '', + parentIsLocalized: false, + parentPath: '', + parentSchemaPath: '', + req, + selectedLocales, + versionFromSiblingData: { + ...versionFrom?.version, + updatedAt: versionFrom?.updatedAt, + }, + versionToSiblingData: { + ...versionTo.version, + updatedAt: versionTo.updatedAt, + }, + }) + + const versionToCreatedAtFormatted = versionTo.updatedAt + ? formatDate({ + date: + typeof versionTo.updatedAt === 'string' + ? new Date(versionTo.updatedAt) + : (versionTo.updatedAt as Date), + i18n, + pattern: config.admin.dateFormat, + }) + : '' + + const formatPill = ({ + doc, + labelOverride, + labelStyle, + labelSuffix, + }: { + doc: TypeWithVersion + labelOverride?: string + labelStyle?: 'pill' | 'text' + labelSuffix?: React.ReactNode + }): React.ReactNode => { + return ( + + ) + } + + // SelectComparison Options: + // + // Previous version: always, unless doesn't exist. Can be the same as previously published + // Latest draft: only if no newer published exists (latestDraftVersion) + // Currently published: always, if exists + // Previously published: if there is a prior published version older than versionTo + // Specific Version: only if not already present under other label (= versionFrom) + + let versionFromOptions: { + doc: TypeWithVersion + labelOverride?: string + updatedAt: Date + value: string + }[] = [] + + // Previous version + if (previousVersion?.id) { + versionFromOptions.push({ + doc: previousVersion, + labelOverride: i18n.t('version:previousVersion'), + updatedAt: new Date(previousVersion.updatedAt), + value: previousVersion.id, + }) + } + + // Latest Draft + const publishedNewerThanDraft = + currentlyPublishedVersion?.updatedAt > latestDraftVersion?.updatedAt + if (latestDraftVersion && !publishedNewerThanDraft) { + versionFromOptions.push({ + doc: latestDraftVersion, + updatedAt: new Date(latestDraftVersion.updatedAt), + value: latestDraftVersion.id, + }) + } + + // Currently Published + if (currentlyPublishedVersion) { + versionFromOptions.push({ + doc: currentlyPublishedVersion, + updatedAt: new Date(currentlyPublishedVersion.updatedAt), + value: currentlyPublishedVersion.id, + }) + } + + // Previous Published + if (previousPublishedVersion && currentlyPublishedVersion?.id !== previousPublishedVersion.id) { + versionFromOptions.push({ + doc: previousPublishedVersion, + labelOverride: i18n.t('version:previouslyPublished'), + updatedAt: new Date(previousPublishedVersion.updatedAt), + value: previousPublishedVersion.id, + }) + } + + // Specific Version + if (versionFrom?.id && !versionFromOptions.some((option) => option.value === versionFrom.id)) { + // Only add "specific version" if it is not already in the options + versionFromOptions.push({ + doc: versionFrom, + labelOverride: i18n.t('version:specificVersion'), + updatedAt: new Date(versionFrom.updatedAt), + value: versionFrom.id, + }) + } + + versionFromOptions = versionFromOptions.sort((a, b) => { + // Sort by updatedAt, newest first + if (a && b) { + return b.updatedAt.getTime() - a.updatedAt.getTime() + } + return 0 + }) + + const versionToIsVersionFrom = versionFrom?.id === versionTo.id + + const versionFromComparisonOptions: CompareOption[] = [] + + for (const option of versionFromOptions) { + const isVersionTo = option.value === versionTo.id + + if (isVersionTo && !versionToIsVersionFrom) { + // Don't offer selecting a versionFrom that is the same as versionTo, unless it's already selected + continue + } + + const alreadyAdded = versionFromComparisonOptions.some( + (existingOption) => existingOption.value === option.value, + ) + if (alreadyAdded) { + continue + } + + const otherOptionsWithSameID = versionFromOptions.filter( + (existingOption) => existingOption.value === option.value && existingOption !== option, + ) + + // Merge options with same ID to the same option + const labelSuffix = otherOptionsWithSameID?.length ? ( + + {' ('} + {otherOptionsWithSameID.map((optionWithSameID, index) => { + const label = + optionWithSameID.labelOverride || + getVersionLabel({ + currentLocale: userLocale, + currentlyPublishedVersion, + latestDraftVersion, + t: i18n.t, + version: optionWithSameID.doc, + }).label + + return ( + + {index > 0 ? ', ' : ''} + {label} + + ) + })} + {')'} + + ) : undefined + + versionFromComparisonOptions.push({ + label: formatPill({ + doc: option.doc, + labelOverride: option.labelOverride, + labelSuffix, + }), + value: option.value, + }) + } + + return ( + + ) +} diff --git a/packages/ui/src/views/Version/Restore/index.scss b/packages/ui/src/views/Version/Restore/index.scss new file mode 100644 index 00000000000..d05079cbfd5 --- /dev/null +++ b/packages/ui/src/views/Version/Restore/index.scss @@ -0,0 +1,84 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .restore-version { + cursor: pointer; + display: flex; + min-width: max-content; + + .popup-button { + display: flex; + } + + &__chevron { + background-color: var(--theme-elevation-150); + border-top-left-radius: 0; + border-bottom-left-radius: 0; + cursor: pointer; + + .stroke { + stroke-width: 1px; + } + + &:hover { + background: var(--theme-elevation-100); + } + } + + .btn { + margin-block: 0; + } + + &__restore-as-draft-button { + border-top-right-radius: 0px; + border-bottom-right-radius: 0px; + margin-right: 2px; + + &:focus { + border-radius: 0; + outline-offset: 0; + } + } + + &__modal { + @include blur-bg; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + + &__toggle { + @extend %btn-reset; + } + } + + &__wrapper { + z-index: 1; + position: relative; + display: flex; + flex-direction: column; + gap: base(0.8); + padding: base(2); + max-width: base(36); + } + + &__content { + display: flex; + flex-direction: column; + gap: base(0.4); + + > * { + margin: 0; + } + } + + &__controls { + display: flex; + gap: base(0.4); + + .btn { + margin: 0; + } + } + } +} diff --git a/packages/ui/src/views/Version/Restore/index.tsx b/packages/ui/src/views/Version/Restore/index.tsx new file mode 100644 index 00000000000..dc6d9d7f3d3 --- /dev/null +++ b/packages/ui/src/views/Version/Restore/index.tsx @@ -0,0 +1,151 @@ +'use client' + +import type { ClientCollectionConfig, ClientGlobalConfig, SanitizedCollectionConfig } from 'payload' + +import { getTranslation } from '@payloadcms/translations' +import { formatAdminURL } from 'payload/shared' +import React, { Fragment, useCallback, useState } from 'react' +import { toast } from 'sonner' + +import { Button } from '../../../elements/Button/index.js' +import { ConfirmationModal } from '../../../elements/ConfirmationModal/index.js' +import { useModal } from '../../../elements/Modal/index.js' +import * as PopupList from '../../../elements/Popup/PopupButtonList/index.js' +import { useConfig } from '../../../providers/Config/index.js' +import { useRouter } from '../../../providers/Router/index.js' +import { useRouteTransition } from '../../../providers/RouteTransition/index.js' +import { useTranslation } from '../../../providers/Translation/index.js' +import { requests } from '../../../utilities/api.js' +import './index.scss' + +const baseClass = 'restore-version' +const modalSlug = 'restore-version' + +type Props = { + className?: string + collectionConfig?: ClientCollectionConfig + globalConfig?: ClientGlobalConfig + label: SanitizedCollectionConfig['labels']['singular'] + originalDocID: number | string + status?: string + versionDateFormatted: string + versionID: string +} + +export const Restore: React.FC = ({ + className, + collectionConfig, + globalConfig, + label, + originalDocID, + status, + versionDateFormatted, + versionID, +}) => { + const { + config: { + routes: { admin: adminRoute, api: apiRoute }, + }, + } = useConfig() + + const { toggleModal } = useModal() + const router = useRouter() + const { i18n, t } = useTranslation() + const [draft, setDraft] = useState(false) + const { startRouteTransition } = useRouteTransition() + + const restoreMessage = t( + globalConfig ? 'version:aboutToRestoreGlobal' : 'version:aboutToRestore', + { + label: getTranslation(label, i18n), + versionDate: versionDateFormatted, + }, + ) + + const canRestoreAsDraft = status !== 'draft' && collectionConfig?.versions?.drafts + + const handleRestore = useCallback(async () => { + let fetchURL = formatAdminURL({ + apiRoute, + path: '', + }) + let redirectURL: string + + if (collectionConfig) { + fetchURL = `${fetchURL}/${collectionConfig.slug}/versions/${versionID}?draft=${draft}` + redirectURL = formatAdminURL({ + adminRoute, + path: `/collections/${collectionConfig.slug}/${originalDocID}`, + }) + } + + if (globalConfig) { + fetchURL = `${fetchURL}/globals/${globalConfig.slug}/versions/${versionID}?draft=${draft}` + redirectURL = formatAdminURL({ + adminRoute, + path: `/globals/${globalConfig.slug}`, + }) + } + + const res = await requests.post(fetchURL, { + headers: { + 'Accept-Language': i18n.language, + }, + }) + + if (res.status === 200) { + const json = await res.json() + toast.success(json.message) + return startRouteTransition(() => router.push(redirectURL)) + } else { + toast.error(t('version:problemRestoringVersion')) + } + }, [ + apiRoute, + collectionConfig, + globalConfig, + i18n.language, + versionID, + draft, + adminRoute, + originalDocID, + startRouteTransition, + router, + t, + ]) + + return ( + +
    + +
    + +
    + ) +} diff --git a/packages/ui/src/views/Version/SelectLocales/index.tsx b/packages/ui/src/views/Version/SelectLocales/index.tsx new file mode 100644 index 00000000000..192e6729de9 --- /dev/null +++ b/packages/ui/src/views/Version/SelectLocales/index.tsx @@ -0,0 +1,42 @@ +'use client' + +import React from 'react' + +import { AnimateHeight } from '../../../elements/AnimateHeight/index.js' +import { PillSelector, type SelectablePill } from '../../../elements/PillSelector/index.js' + +const baseClass = 'select-version-locales' + +export type SelectedLocaleOnChange = (args: { locales: SelectablePill[] }) => void +export type Props = { + locales: SelectablePill[] + localeSelectorOpen: boolean + onChange: SelectedLocaleOnChange +} + +export const SelectLocales: React.FC = ({ locales, localeSelectorOpen, onChange }) => { + return ( + + { + const newLocales = locales.map((locale) => { + if (locale.name === pill.name) { + return { + ...locale, + selected: !pill.selected, + } + } else { + return locale + } + }) + onChange({ locales: newLocales }) + }} + pills={locales} + /> + + ) +} diff --git a/packages/ui/src/views/Version/index.tsx b/packages/ui/src/views/Version/index.tsx index c546a5e6f87..851e01c3fad 100644 --- a/packages/ui/src/views/Version/index.tsx +++ b/packages/ui/src/views/Version/index.tsx @@ -1,10 +1 @@ -import type { DocumentViewServerProps } from 'payload' -import type React from 'react' - -/** - * Stub - full implementation in RenderVersion.tsx (Task 9). - * Throws 'not-found' to be caught by outer DocumentView error handler. - */ -export function VersionView(_props: DocumentViewServerProps): React.ReactNode { - throw new Error('not-found') -} +export { VersionView } from './RenderVersion.js' diff --git a/packages/ui/src/views/Versions/RenderVersions.tsx b/packages/ui/src/views/Versions/RenderVersions.tsx new file mode 100644 index 00000000000..34e2408dd08 --- /dev/null +++ b/packages/ui/src/views/Versions/RenderVersions.tsx @@ -0,0 +1,201 @@ +import { type DocumentViewServerProps, type PaginatedDocs, type Where } from 'payload' +import { formatAdminURL, hasDraftsEnabled, isNumber } from 'payload/shared' +import React from 'react' + +import { Gutter } from '../../elements/Gutter/index.js' +import { ListQueryProvider } from '../../providers/ListQuery/index.js' +import { SetDocumentStepNav } from '../Edit/SetDocumentStepNav/index.js' +import { fetchLatestVersion, fetchVersions } from '../Version/fetchVersions.js' +import { VersionDrawerCreatedAtCell } from '../Version/SelectComparison/VersionDrawer/CreatedAtCell.js' +import { buildVersionColumns } from './buildColumns.js' +import { VersionsViewClient } from './index.client.js' +import './index.scss' + +const baseClass = 'versions' + +export async function VersionsView(props: DocumentViewServerProps) { + const { + hasPublishedDoc, + initPageResult: { + collectionConfig, + docID: id, + globalConfig, + req, + req: { + i18n, + payload: { config }, + t, + user, + }, + }, + routeSegments: segments, + searchParams: { limit, page, sort }, + versions: { disableGutter = false, useVersionDrawerCreatedAtCell = false } = {}, + } = props + + const draftsEnabled = hasDraftsEnabled(collectionConfig || globalConfig) + + const collectionSlug = collectionConfig?.slug + const globalSlug = globalConfig?.slug + + const isTrashed = segments[2] === 'trash' + + const { + localization, + routes: { api: apiRoute }, + serverURL, + } = config + + const whereQuery: { + and: Array<{ parent?: { equals: number | string }; snapshot?: { not_equals: boolean } }> + } & Where = { + and: [], + } + if (localization && draftsEnabled) { + whereQuery.and.push({ + snapshot: { + not_equals: true, + }, + }) + } + + const defaultLimit = collectionSlug ? collectionConfig?.admin?.pagination?.defaultLimit : 10 + + const limitToUse = isNumber(limit) ? Number(limit) : defaultLimit + + const versionsData: PaginatedDocs = await fetchVersions({ + collectionSlug, + depth: 0, + globalSlug, + limit: limitToUse, + locale: req.locale, + overrideAccess: false, + page: page ? parseInt(page.toString(), 10) : undefined, + parentID: id, + req, + sort: sort as string, + user, + where: whereQuery, + }) + + if (!versionsData) { + throw new Error('not-found') + } + + const [currentlyPublishedVersion, latestDraftVersion] = await Promise.all([ + hasPublishedDoc + ? fetchLatestVersion({ + collectionSlug, + depth: 0, + globalSlug, + locale: req.locale, + overrideAccess: false, + parentID: id, + req, + select: { + id: true, + updatedAt: true, + version: { + _status: true, + updatedAt: true, + }, + }, + status: 'published', + user, + where: localization + ? { + snapshot: { + not_equals: true, + }, + } + : undefined, + }) + : Promise.resolve(null), + draftsEnabled + ? fetchLatestVersion({ + collectionSlug, + depth: 0, + globalSlug, + locale: req.locale, + overrideAccess: false, + parentID: id, + req, + select: { + id: true, + updatedAt: true, + version: { + _status: true, + updatedAt: true, + }, + }, + status: 'draft', + user, + where: localization + ? { + snapshot: { + not_equals: true, + }, + } + : undefined, + }) + : Promise.resolve(null), + ]) + + const fetchURL = formatAdminURL({ + apiRoute, + path: collectionSlug ? `/${collectionSlug}/versions` : `/${globalSlug}/versions`, + }) + + const columns = buildVersionColumns({ + collectionConfig, + CreatedAtCellOverride: useVersionDrawerCreatedAtCell ? VersionDrawerCreatedAtCell : undefined, + currentlyPublishedVersion, + docID: id, + docs: versionsData?.docs, + globalConfig, + i18n, + isTrashed, + latestDraftVersion, + }) + + const pluralLabel = + typeof collectionConfig?.labels?.plural === 'function' + ? collectionConfig.labels.plural({ i18n, t }) + : (collectionConfig?.labels?.plural ?? globalConfig?.label) + + const GutterComponent = disableGutter ? React.Fragment : Gutter + + return ( + + +
    + + + + + +
    +
    + ) +} diff --git a/packages/ui/src/views/Versions/index.tsx b/packages/ui/src/views/Versions/index.tsx index 950391e54f5..4e9cad34461 100644 --- a/packages/ui/src/views/Versions/index.tsx +++ b/packages/ui/src/views/Versions/index.tsx @@ -1,10 +1 @@ -import type { DocumentViewServerProps } from 'payload' -import type React from 'react' - -/** - * Stub - full implementation in RenderVersions.tsx (Task 9). - * Throws 'not-found' to be caught by outer DocumentView error handler. - */ -export function VersionsView(_props: DocumentViewServerProps): React.ReactNode { - throw new Error('not-found') -} +export { VersionsView } from './RenderVersions.js' From 14af6d3f1ca2db719c2b4aa7a2a9ad9428db7c3c Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 21:42:36 +0100 Subject: [PATCH 22/60] refactor(ui): move renderDocumentHandler/renderListHandler and create shared server function dispatcher Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../src/utilities/handleServerFunctions.ts | 46 ++---- .../views/Document/handleServerFunction.tsx | 129 +--------------- .../src/views/List/handleServerFunction.tsx | 130 +--------------- packages/payload/src/admin/functions/index.ts | 4 + packages/ui/package.json | 10 ++ .../ui/src/utilities/handleServerFunctions.ts | 46 ++++++ .../views/Document/handleServerFunction.tsx | 141 ++++++++++++++++++ .../src/views/List/handleServerFunction.tsx | 133 +++++++++++++++++ 8 files changed, 347 insertions(+), 292 deletions(-) create mode 100644 packages/ui/src/utilities/handleServerFunctions.ts create mode 100644 packages/ui/src/views/Document/handleServerFunction.tsx create mode 100644 packages/ui/src/views/List/handleServerFunction.tsx diff --git a/packages/next/src/utilities/handleServerFunctions.ts b/packages/next/src/utilities/handleServerFunctions.ts index 43e503a863b..4cff82ae0ea 100644 --- a/packages/next/src/utilities/handleServerFunctions.ts +++ b/packages/next/src/utilities/handleServerFunctions.ts @@ -1,34 +1,10 @@ -import type { DefaultServerFunctionArgs, ServerFunction, ServerFunctionHandler } from 'payload' +import type { ServerFunctionHandler } from 'payload' -import { _internal_renderFieldHandler, copyDataFromLocaleHandler } from '@payloadcms/ui/rsc' -import { buildFormStateHandler } from '@payloadcms/ui/utilities/buildFormState' -import { buildTableStateHandler } from '@payloadcms/ui/utilities/buildTableState' -import { getFolderResultsComponentAndDataHandler } from '@payloadcms/ui/utilities/getFolderResultsComponentAndData' -import { schedulePublishHandler } from '@payloadcms/ui/utilities/schedulePublishHandler' -import { slugifyHandler } from '@payloadcms/ui/utilities/slugify' +import { dispatchServerFunction } from '@payloadcms/ui/utilities/handleServerFunctions' +import { notFound, redirect } from 'next/navigation.js' -import { getDefaultLayoutHandler } from '../views/Dashboard/Default/ModularDashboard/renderWidget/getDefaultLayoutServerFn.js' -import { renderWidgetHandler } from '../views/Dashboard/Default/ModularDashboard/renderWidget/renderWidgetServerFn.js' -import { renderDocumentHandler } from '../views/Document/handleServerFunction.js' -import { renderDocumentSlotsHandler } from '../views/Document/renderDocumentSlots.js' -import { renderListHandler } from '../views/List/handleServerFunction.js' import { initReq } from './initReq.js' -const baseServerFunctions: Record> = { - 'copy-data-from-locale': copyDataFromLocaleHandler, - 'form-state': buildFormStateHandler, - 'get-default-layout': getDefaultLayoutHandler, - 'get-folder-results-component-and-data': getFolderResultsComponentAndDataHandler, - 'render-document': renderDocumentHandler, - 'render-document-slots': renderDocumentSlotsHandler, - 'render-field': _internal_renderFieldHandler, - 'render-list': renderListHandler, - 'render-widget': renderWidgetHandler, - 'schedule-publish': schedulePublishHandler, - slugify: slugifyHandler, - 'table-state': buildTableStateHandler, -} - export const handleServerFunctions: ServerFunctionHandler = async (args) => { const { name: fnKey, @@ -44,20 +20,20 @@ export const handleServerFunctions: ServerFunctionHandler = async (args) => { key: 'RootLayout', }) - const augmentedArgs: DefaultServerFunctionArgs = { + const augmentedArgs = { ...fnArgs, cookies, importMap, locale, + notFound: () => notFound(), permissions, + redirect: (url: string) => redirect(url), req, } - const fn = extraServerFunctions?.[fnKey] || baseServerFunctions[fnKey] - - if (!fn) { - throw new Error(`Unknown Server Function: ${fnKey}`) - } - - return fn(augmentedArgs) + return dispatchServerFunction({ + name: fnKey, + augmentedArgs, + extraServerFunctions, + }) } diff --git a/packages/next/src/views/Document/handleServerFunction.tsx b/packages/next/src/views/Document/handleServerFunction.tsx index 593a80981e8..04958761783 100644 --- a/packages/next/src/views/Document/handleServerFunction.tsx +++ b/packages/next/src/views/Document/handleServerFunction.tsx @@ -1,128 +1 @@ -import type { RenderDocumentServerFunction } from '@payloadcms/ui' -import type { DocumentPreferences, VisibleEntities } from 'payload' - -import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig' -import { canAccessAdmin, isEntityHidden } from 'payload' -import { applyLocaleFiltering } from 'payload/shared' - -import { renderDocument } from './index.js' - -export const renderDocumentHandler: RenderDocumentServerFunction = async (args) => { - const { - collectionSlug, - cookies, - disableActions, - docID, - drawerSlug, - initialData, - locale, - overrideEntityVisibility, - paramsOverride, - permissions, - redirectAfterCreate, - redirectAfterDelete, - redirectAfterDuplicate, - req, - req: { - i18n, - payload, - payload: { config }, - user, - }, - searchParams = {}, - versions, - } = args - - await canAccessAdmin({ req }) - - const clientConfig = getClientConfig({ - config, - i18n, - importMap: req.payload.importMap, - user, - }) - await applyLocaleFiltering({ clientConfig, config, req }) - - let preferences: DocumentPreferences - - if (docID) { - const preferencesKey = `${collectionSlug}-edit-${docID}` - - preferences = await payload - .find({ - collection: 'payload-preferences', - depth: 0, - limit: 1, - where: { - and: [ - { - key: { - equals: preferencesKey, - }, - }, - { - 'user.relationTo': { - equals: user.collection, - }, - }, - { - 'user.value': { - equals: user.id, - }, - }, - ], - }, - }) - .then((res) => res.docs[0]?.value as DocumentPreferences) - } - - const visibleEntities: VisibleEntities = { - collections: payload.config.collections - .map(({ slug, admin: { hidden } }) => (!isEntityHidden({ hidden, user }) ? slug : null)) - .filter(Boolean), - globals: payload.config.globals - .map(({ slug, admin: { hidden } }) => (!isEntityHidden({ hidden, user }) ? slug : null)) - .filter(Boolean), - } - - const { data, Document } = await renderDocument({ - clientConfig, - disableActions, - documentSubViewType: 'default', - drawerSlug, - i18n, - importMap: payload.importMap, - initialData, - initPageResult: { - collectionConfig: payload?.collections?.[collectionSlug]?.config, - cookies, - docID, - globalConfig: payload.config.globals.find((global) => global.slug === collectionSlug), - languageOptions: undefined, // TODO - locale, - permissions, - req, - translations: undefined, // TODO - visibleEntities, - }, - locale, - overrideEntityVisibility, - params: paramsOverride ?? { - segments: ['collections', collectionSlug, String(docID)], - }, - payload, - permissions, - redirectAfterCreate, - redirectAfterDelete, - redirectAfterDuplicate, - searchParams, - versions, - viewType: 'document', - }) - - return { - data, - Document, - preferences, - } -} +export { renderDocumentHandler } from '@payloadcms/ui/views/Document/handleServerFunction' diff --git a/packages/next/src/views/List/handleServerFunction.tsx b/packages/next/src/views/List/handleServerFunction.tsx index 28755e5f254..df868706554 100644 --- a/packages/next/src/views/List/handleServerFunction.tsx +++ b/packages/next/src/views/List/handleServerFunction.tsx @@ -1,129 +1 @@ -import type { RenderListServerFnArgs, RenderListServerFnReturnType } from '@payloadcms/ui' -import type { CollectionPreferences, ServerFunction, VisibleEntities } from 'payload' - -import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig' -import { canAccessAdmin, isEntityHidden, UnauthorizedError } from 'payload' -import { applyLocaleFiltering } from 'payload/shared' - -import { renderListView } from './index.js' - -export const renderListHandler: ServerFunction< - RenderListServerFnArgs, - Promise -> = async (args) => { - const { - collectionSlug, - cookies, - disableActions, - disableBulkDelete, - disableBulkEdit, - disableQueryPresets, - drawerSlug, - enableRowSelections, - locale, - overrideEntityVisibility, - permissions, - query, - redirectAfterDelete, - redirectAfterDuplicate, - req, - req: { - i18n, - payload, - payload: { config }, - user, - }, - } = args - - if (!req.user) { - throw new UnauthorizedError() - } - - await canAccessAdmin({ req }) - - const clientConfig = getClientConfig({ - config, - i18n, - importMap: payload.importMap, - user, - }) - await applyLocaleFiltering({ clientConfig, config, req }) - - const preferencesKey = `collection-${collectionSlug}` - - const preferences = await payload - .find({ - collection: 'payload-preferences', - depth: 0, - limit: 1, - where: { - and: [ - { - key: { - equals: preferencesKey, - }, - }, - { - 'user.relationTo': { - equals: user.collection, - }, - }, - { - 'user.value': { - equals: user.id, - }, - }, - ], - }, - }) - .then((res) => res.docs[0]?.value as CollectionPreferences) - - const visibleEntities: VisibleEntities = { - collections: payload.config.collections - .map(({ slug, admin: { hidden } }) => (!isEntityHidden({ hidden, user }) ? slug : null)) - .filter(Boolean), - globals: payload.config.globals - .map(({ slug, admin: { hidden } }) => (!isEntityHidden({ hidden, user }) ? slug : null)) - .filter(Boolean), - } - - const { List } = await renderListView({ - clientConfig, - disableActions, - disableBulkDelete, - disableBulkEdit, - disableQueryPresets, - drawerSlug, - enableRowSelections, - i18n, - importMap: payload.importMap, - initPageResult: { - collectionConfig: payload?.collections?.[collectionSlug]?.config, - cookies, - globalConfig: payload.config.globals.find((global) => global.slug === collectionSlug), - languageOptions: undefined, // TODO - locale, - permissions, - req, - translations: undefined, // TODO - visibleEntities, - }, - locale, - overrideEntityVisibility, - params: { - segments: ['collections', collectionSlug], - }, - payload, - permissions, - query, - redirectAfterDelete, - redirectAfterDuplicate, - searchParams: {}, - viewType: 'list', - }) - - return { - List, - preferences, - } -} +export { renderListHandler } from '@payloadcms/ui/views/List/handleServerFunction' diff --git a/packages/payload/src/admin/functions/index.ts b/packages/payload/src/admin/functions/index.ts index 4cf9e54b44e..0e3c76d5986 100644 --- a/packages/payload/src/admin/functions/index.ts +++ b/packages/payload/src/admin/functions/index.ts @@ -28,6 +28,10 @@ export type InitReqResult = { export type DefaultServerFunctionArgs = { importMap: ImportMap + /** Framework-specific notFound callback, injected by the adapter's handleServerFunctions. */ + notFound?: () => never + /** Framework-specific redirect callback, injected by the adapter's handleServerFunctions. */ + redirect?: (url: string) => never } & Pick export type ServerFunctionArgs = { diff --git a/packages/ui/package.json b/packages/ui/package.json index d2f7bed91a5..628f6fd0730 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -96,6 +96,16 @@ "types": "./src/views/Document/getDocumentView.tsx", "default": "./src/views/Document/getDocumentView.tsx" }, + "./views/Document/handleServerFunction": { + "import": "./src/views/Document/handleServerFunction.tsx", + "types": "./src/views/Document/handleServerFunction.tsx", + "default": "./src/views/Document/handleServerFunction.tsx" + }, + "./views/List/handleServerFunction": { + "import": "./src/views/List/handleServerFunction.tsx", + "types": "./src/views/List/handleServerFunction.tsx", + "default": "./src/views/List/handleServerFunction.tsx" + }, "./views/Version": { "import": "./src/views/Version/index.tsx", "types": "./src/views/Version/index.tsx", diff --git a/packages/ui/src/utilities/handleServerFunctions.ts b/packages/ui/src/utilities/handleServerFunctions.ts new file mode 100644 index 00000000000..12805191077 --- /dev/null +++ b/packages/ui/src/utilities/handleServerFunctions.ts @@ -0,0 +1,46 @@ +import type { DefaultServerFunctionArgs, ServerFunction } from 'payload' + +import { _internal_renderFieldHandler } from '../forms/fieldSchemasToFormState/serverFunctions/renderFieldServerFn.js' +import { getDefaultLayoutHandler } from '../views/Dashboard/Default/ModularDashboard/renderWidget/getDefaultLayoutServerFn.js' +import { renderWidgetHandler } from '../views/Dashboard/Default/ModularDashboard/renderWidget/renderWidgetServerFn.js' +import { renderDocumentHandler } from '../views/Document/handleServerFunction.js' +import { renderDocumentSlotsHandler } from '../views/Document/renderDocumentSlots.js' +import { renderListHandler } from '../views/List/handleServerFunction.js' +import { buildFormStateHandler } from './buildFormState.js' +import { buildTableStateHandler } from './buildTableState.js' +import { copyDataFromLocaleHandler } from './copyDataFromLocale.js' +import { getFolderResultsComponentAndDataHandler } from './getFolderResultsComponentAndData.js' +import { schedulePublishHandler } from './schedulePublishHandler.js' +import { slugifyHandler } from './slugify.js' + +export const baseServerFunctions: Record = { + 'copy-data-from-locale': copyDataFromLocaleHandler, + 'form-state': buildFormStateHandler, + 'get-default-layout': getDefaultLayoutHandler, + 'get-folder-results-component-and-data': getFolderResultsComponentAndDataHandler, + 'render-document': renderDocumentHandler, + 'render-document-slots': renderDocumentSlotsHandler, + 'render-field': _internal_renderFieldHandler, + 'render-list': renderListHandler, + 'render-widget': renderWidgetHandler, + 'schedule-publish': schedulePublishHandler, + slugify: slugifyHandler, + 'table-state': buildTableStateHandler, +} + +/** + * Framework-agnostic server function dispatcher. + * Adapters call this after running initReq to get the request context. + */ +export function dispatchServerFunction(args: { + augmentedArgs: DefaultServerFunctionArgs + extraServerFunctions?: Record + name: string +}): Promise | unknown { + const { name, augmentedArgs, extraServerFunctions } = args + const fn = extraServerFunctions?.[name] || baseServerFunctions[name] + if (!fn) { + throw new Error(`Unknown Server Function: ${name}`) + } + return fn(augmentedArgs) +} diff --git a/packages/ui/src/views/Document/handleServerFunction.tsx b/packages/ui/src/views/Document/handleServerFunction.tsx new file mode 100644 index 00000000000..06187a48b01 --- /dev/null +++ b/packages/ui/src/views/Document/handleServerFunction.tsx @@ -0,0 +1,141 @@ +import type { DocumentPreferences, VisibleEntities } from 'payload' + +import { canAccessAdmin, isEntityHidden } from 'payload' +import { applyLocaleFiltering } from 'payload/shared' + +import type { RenderDocumentServerFunction } from '../../providers/ServerFunctions/index.js' + +import { getClientConfig } from '../../utilities/getClientConfig.js' +import { renderDocument } from './RenderDocument.js' + +export const renderDocumentHandler: RenderDocumentServerFunction = async (args) => { + const { + collectionSlug, + cookies, + disableActions, + docID, + drawerSlug, + initialData, + locale, + notFound, + overrideEntityVisibility, + paramsOverride, + permissions, + redirect, + redirectAfterCreate, + redirectAfterDelete, + redirectAfterDuplicate, + req, + req: { + i18n, + payload, + payload: { config }, + user, + }, + searchParams = {}, + versions, + } = args + + await canAccessAdmin({ req }) + + const clientConfig = getClientConfig({ + config, + i18n, + importMap: req.payload.importMap, + user, + }) + await applyLocaleFiltering({ clientConfig, config, req }) + + let preferences: DocumentPreferences + + if (docID) { + const preferencesKey = `${collectionSlug}-edit-${docID}` + + preferences = await payload + .find({ + collection: 'payload-preferences', + depth: 0, + limit: 1, + where: { + and: [ + { + key: { + equals: preferencesKey, + }, + }, + { + 'user.relationTo': { + equals: user.collection, + }, + }, + { + 'user.value': { + equals: user.id, + }, + }, + ], + }, + }) + .then((res) => res.docs[0]?.value as DocumentPreferences) + } + + const visibleEntities: VisibleEntities = { + collections: payload.config.collections + .map(({ slug, admin: { hidden } }) => (!isEntityHidden({ hidden, user }) ? slug : null)) + .filter(Boolean), + globals: payload.config.globals + .map(({ slug, admin: { hidden } }) => (!isEntityHidden({ hidden, user }) ? slug : null)) + .filter(Boolean), + } + + const { data, Document } = await renderDocument({ + clientConfig, + disableActions, + documentSubViewType: 'default', + drawerSlug, + i18n, + importMap: payload.importMap, + initialData, + initPageResult: { + collectionConfig: payload?.collections?.[collectionSlug]?.config, + cookies, + docID, + globalConfig: payload.config.globals.find((global) => global.slug === collectionSlug), + languageOptions: undefined, // TODO + locale, + permissions, + req, + translations: undefined, // TODO + visibleEntities, + }, + locale, + notFound: + notFound ?? + (() => { + throw new Error('notFound not provided') + }), + overrideEntityVisibility, + params: paramsOverride ?? { + segments: ['collections', collectionSlug, String(docID)], + }, + payload, + permissions, + redirect: + redirect ?? + (() => { + throw new Error('redirect not provided') + }), + redirectAfterCreate, + redirectAfterDelete, + redirectAfterDuplicate, + searchParams, + versions, + viewType: 'document', + }) + + return { + data, + Document, + preferences, + } +} diff --git a/packages/ui/src/views/List/handleServerFunction.tsx b/packages/ui/src/views/List/handleServerFunction.tsx new file mode 100644 index 00000000000..2cc5bb97e46 --- /dev/null +++ b/packages/ui/src/views/List/handleServerFunction.tsx @@ -0,0 +1,133 @@ +import type { CollectionPreferences, ServerFunction, VisibleEntities } from 'payload' + +import { canAccessAdmin, isEntityHidden, UnauthorizedError } from 'payload' +import { applyLocaleFiltering } from 'payload/shared' + +import type { + RenderListServerFnArgs, + RenderListServerFnReturnType, +} from '../../elements/ListDrawer/types.js' + +import { getClientConfig } from '../../utilities/getClientConfig.js' +import { renderListView } from './RenderListView.js' + +export const renderListHandler: ServerFunction< + RenderListServerFnArgs, + Promise +> = async (args) => { + const { + collectionSlug, + cookies, + disableActions, + disableBulkDelete, + disableBulkEdit, + disableQueryPresets, + drawerSlug, + enableRowSelections, + locale, + overrideEntityVisibility, + permissions, + query, + redirectAfterDelete, + redirectAfterDuplicate, + req, + req: { + i18n, + payload, + payload: { config }, + user, + }, + } = args + + if (!req.user) { + throw new UnauthorizedError() + } + + await canAccessAdmin({ req }) + + const clientConfig = getClientConfig({ + config, + i18n, + importMap: payload.importMap, + user, + }) + await applyLocaleFiltering({ clientConfig, config, req }) + + const preferencesKey = `collection-${collectionSlug}` + + const preferences = await payload + .find({ + collection: 'payload-preferences', + depth: 0, + limit: 1, + where: { + and: [ + { + key: { + equals: preferencesKey, + }, + }, + { + 'user.relationTo': { + equals: user.collection, + }, + }, + { + 'user.value': { + equals: user.id, + }, + }, + ], + }, + }) + .then((res) => res.docs[0]?.value as CollectionPreferences) + + const visibleEntities: VisibleEntities = { + collections: payload.config.collections + .map(({ slug, admin: { hidden } }) => (!isEntityHidden({ hidden, user }) ? slug : null)) + .filter(Boolean), + globals: payload.config.globals + .map(({ slug, admin: { hidden } }) => (!isEntityHidden({ hidden, user }) ? slug : null)) + .filter(Boolean), + } + + const { List } = await renderListView({ + clientConfig, + disableActions, + disableBulkDelete, + disableBulkEdit, + disableQueryPresets, + drawerSlug, + enableRowSelections, + i18n, + importMap: payload.importMap, + initPageResult: { + collectionConfig: payload?.collections?.[collectionSlug]?.config, + cookies, + globalConfig: payload.config.globals.find((global) => global.slug === collectionSlug), + languageOptions: undefined, // TODO + locale, + permissions, + req, + translations: undefined, // TODO + visibleEntities, + }, + locale, + overrideEntityVisibility, + params: { + segments: ['collections', collectionSlug], + }, + payload, + permissions, + query, + redirectAfterDelete, + redirectAfterDuplicate, + searchParams: {}, + viewType: 'list', + }) + + return { + List, + preferences, + } +} From c7bd3cb16d4978fe46402c0c837b9ca6f1a20dd5 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 21:56:09 +0100 Subject: [PATCH 23/60] feat(tanstack-start): implement full adapter with vinxi/http cookies and @tanstack/react-router Co-Authored-By: Claude Sonnet 4.6 (1M context) --- packages/tanstack-start/package.json | 12 +- .../src/adapter/RouterProvider.tsx | 61 +++------- packages/tanstack-start/src/adapter/index.ts | 58 ++++----- packages/tanstack-start/src/index.ts | 2 + .../src/utilities/handleServerFunctions.ts | 39 +++++++ .../tanstack-start/src/utilities/initReq.ts | 90 ++++++++++++++ packages/ui/src/views/Versions/index.scss | 110 ++++++++++++++++++ pnpm-lock.yaml | 9 ++ test/admin-adapter/tanstack-start.int.spec.ts | 30 +++++ test/package.json | 1 + 10 files changed, 328 insertions(+), 84 deletions(-) create mode 100644 packages/tanstack-start/src/utilities/handleServerFunctions.ts create mode 100644 packages/tanstack-start/src/utilities/initReq.ts create mode 100644 packages/ui/src/views/Versions/index.scss create mode 100644 test/admin-adapter/tanstack-start.int.spec.ts diff --git a/packages/tanstack-start/package.json b/packages/tanstack-start/package.json index 0ddd9058b13..4ceb3d4ea7b 100644 --- a/packages/tanstack-start/package.json +++ b/packages/tanstack-start/package.json @@ -21,6 +21,7 @@ "main": "./src/index.ts", "types": "./src/index.ts", "dependencies": { + "@payloadcms/translations": "workspace:*", "@payloadcms/ui": "workspace:*", "payload": "workspace:*", "react": "19.1.0" @@ -31,9 +32,12 @@ "react": "^19.0.0", "vinxi": ">=0.4.0" }, - "peerDependenciesOptional": { - "@tanstack/react-router": true, - "@tanstack/start": true, - "vinxi": true + "peerDependenciesMeta": { + "@tanstack/react-router": { + "optional": false + }, + "vinxi": { + "optional": false + } } } diff --git a/packages/tanstack-start/src/adapter/RouterProvider.tsx b/packages/tanstack-start/src/adapter/RouterProvider.tsx index 35545bff871..c751c5a71d6 100644 --- a/packages/tanstack-start/src/adapter/RouterProvider.tsx +++ b/packages/tanstack-start/src/adapter/RouterProvider.tsx @@ -1,51 +1,22 @@ 'use client' -/** - * TanStack Start RouterProvider — scaffold. - * - * Replace the stub hooks below with real @tanstack/react-router imports: - * import { Link, useLocation, useParams, useRouter } from '@tanstack/react-router' - */ - -import type { - RouterProvider as BaseRouterProvider, - type LinkProps, - RouterContextType, -} from '@payloadcms/ui' +import type { RouterContextType, LinkProps as RouterLinkProps } from '@payloadcms/ui' +import { RouterProvider as BaseRouterProvider } from '@payloadcms/ui' +import { Link as TanStackLink, useLocation, useParams, useRouter } from '@tanstack/react-router' import React from 'react' -// ─── Replace with real @tanstack/react-router imports ───────────────────── -type RouterStub = { - history: { back(): void; forward(): void } - invalidate(): void - navigate(o: { replace?: boolean; to: string }): void - preloadRoute(o: { to: string }): void -} -type LocationStub = { pathname: string; search: string } -function useTanStackRouter(): RouterStub { - throw new Error('Not implemented — swap in @tanstack/react-router') -} -function useTanStackLocation(): LocationStub { - throw new Error('Not implemented — swap in @tanstack/react-router') -} -function useTanStackParams(): Record { - throw new Error('Not implemented — swap in @tanstack/react-router') -} -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const TanStackLink: React.FC<{ [key: string]: any; to: string }> = () => null -// ────────────────────────────────────────────────────────────────────────── - -const AdapterLink: React.FC = ({ children, href, ...rest }) => ( - +const AdapterLink: React.FC = ({ children, href, ...rest }) => ( + {children} ) export function TanStackRouterProvider({ children }: { children: React.ReactNode }) { - const router = useTanStackRouter() - const location = useTanStackLocation() - const params = useTanStackParams() - const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location]) + const router = useRouter() + const location = useLocation() + const params = useParams({ strict: false }) + + const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]) const routerCtx: RouterContextType = React.useMemo( () => ({ @@ -56,9 +27,15 @@ export function TanStackRouterProvider({ children }: { children: React.ReactNode back: () => router.history.back(), forward: () => router.history.forward(), prefetch: (url: string) => router.preloadRoute({ to: url }), - push: (url: string) => router.navigate({ to: url }), - refresh: () => router.invalidate(), - replace: (url: string) => router.navigate({ replace: true, to: url }), + push: (url: string) => { + void router.navigate({ to: url }) + }, + refresh: () => { + void router.invalidate() + }, + replace: (url: string) => { + void router.navigate({ replace: true, to: url }) + }, }, searchParams, }), diff --git a/packages/tanstack-start/src/adapter/index.ts b/packages/tanstack-start/src/adapter/index.ts index 0a5e41cfc38..92e0be4d406 100644 --- a/packages/tanstack-start/src/adapter/index.ts +++ b/packages/tanstack-start/src/adapter/index.ts @@ -1,15 +1,16 @@ -import type { AdminAdapterResult, BaseAdminAdapter, CookieOptions, InitReqResult } from 'payload' +import type { AdminAdapterResult, BaseAdminAdapter, CookieOptions } from 'payload' +import { notFound, redirect } from '@tanstack/react-router' import { createAdminAdapter } from 'payload' +import { deleteCookie, getCookie, setCookie } from 'vinxi/http' +import { handleServerFunctions } from '../utilities/handleServerFunctions.js' +import { initReq } from '../utilities/initReq.js' import { TanStackRouterProvider } from './RouterProvider.js' /** * TanStack Start admin adapter for Payload CMS. * - * Proof-of-concept scaffold. Full implementation requires - * @tanstack/start, @tanstack/react-router, and vinxi. - * * Usage in payload.config.ts: * ```ts * import { tanstackStartAdapter } from '@payloadcms/tanstack-start' @@ -22,44 +23,25 @@ import { TanStackRouterProvider } from './RouterProvider.js' export function tanstackStartAdapter(): AdminAdapterResult { return { name: 'tanstack-start', - init: ({ payload }) => { - return createAdminAdapter({ + init: ({ payload }) => + createAdminAdapter({ name: 'tanstack-start', - createRouteHandlers: () => { - // In TanStack Start, API routes use Vinxi file-system routing. - return {} - }, - deleteCookie: (_name: string): void => { - // Implement: import { deleteCookie } from 'vinxi/http'; deleteCookie(name) - throw new Error('tanstackStartAdapter: deleteCookie not yet implemented.') - }, - getCookie: (_name: string): string | undefined => { - // Implement: import { getCookie } from 'vinxi/http'; return getCookie(name) - throw new Error('tanstackStartAdapter: getCookie not yet implemented.') - }, - handleServerFunctions: (_args): Promise => { - // Implement using @tanstack/start createServerFn() - throw new Error('tanstackStartAdapter: handleServerFunctions not yet implemented.') - }, - initReq: (_args): Promise => { - // Implement: import { getWebRequest } from 'vinxi/http'; use getWebRequest() - throw new Error('tanstackStartAdapter: initReq not yet implemented.') - }, - notFound: (): never => { - // Implement: import { notFound } from '@tanstack/react-router'; throw notFound() - throw new Error('Not found') + createRouteHandlers: () => ({}), // Vinxi handles routing via file-system + deleteCookie: (name) => deleteCookie(name), + getCookie: (name) => getCookie(name), + handleServerFunctions, + initReq: ({ config, importMap }) => initReq({ config, importMap }), + notFound: () => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw notFound() }, payload, - redirect: (url: string): never => { - // Implement: import { redirect } from '@tanstack/react-router'; throw redirect({ to: url }) - throw new Error(`Redirect to ${url}`) + redirect: (url) => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw redirect({ to: url }) }, RouterProvider: TanStackRouterProvider, - setCookie: (_name: string, _value: string, _options?: CookieOptions): void => { - // Implement: import { setCookie } from 'vinxi/http'; setCookie(name, value, options) - throw new Error('tanstackStartAdapter: setCookie not yet implemented.') - }, - } satisfies BaseAdminAdapter) - }, + setCookie: (name, value, options?: CookieOptions) => setCookie(name, value, options), + } satisfies BaseAdminAdapter), } } diff --git a/packages/tanstack-start/src/index.ts b/packages/tanstack-start/src/index.ts index 9508f1d7449..5d3c71a1491 100644 --- a/packages/tanstack-start/src/index.ts +++ b/packages/tanstack-start/src/index.ts @@ -1,2 +1,4 @@ export { tanstackStartAdapter } from './adapter/index.js' export { TanStackRouterProvider } from './adapter/RouterProvider.js' +export { handleServerFunctions } from './utilities/handleServerFunctions.js' +export { initReq } from './utilities/initReq.js' diff --git a/packages/tanstack-start/src/utilities/handleServerFunctions.ts b/packages/tanstack-start/src/utilities/handleServerFunctions.ts new file mode 100644 index 00000000000..9b5c959478a --- /dev/null +++ b/packages/tanstack-start/src/utilities/handleServerFunctions.ts @@ -0,0 +1,39 @@ +import type { ServerFunctionHandler } from 'payload' + +import { dispatchServerFunction } from '@payloadcms/ui/utilities/handleServerFunctions' +import { notFound, redirect } from '@tanstack/react-router' + +import { initReq } from './initReq.js' + +export const handleServerFunctions: ServerFunctionHandler = async (args) => { + const { name: fnKey, args: fnArgs, config, importMap, serverFunctions } = args + + const { cookies, locale, permissions, req } = await initReq({ + config, + importMap, + key: 'RootLayout', + }) + + const augmentedArgs = { + ...fnArgs, + cookies, + importMap, + locale, + notFound: () => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw notFound() + }, + permissions, + redirect: (url: string) => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw redirect({ to: url }) + }, + req, + } + + return dispatchServerFunction({ + name: fnKey, + augmentedArgs, + extraServerFunctions: serverFunctions, + }) +} diff --git a/packages/tanstack-start/src/utilities/initReq.ts b/packages/tanstack-start/src/utilities/initReq.ts new file mode 100644 index 00000000000..5484c63fa46 --- /dev/null +++ b/packages/tanstack-start/src/utilities/initReq.ts @@ -0,0 +1,90 @@ +import type { I18n, I18nClient } from '@payloadcms/translations' +import type { ImportMap, InitReqResult, PayloadRequest, SanitizedConfig } from 'payload' + +import { initI18n } from '@payloadcms/translations' +import { getRequestLocale } from '@payloadcms/ui/utilities/getRequestLocale' +import { selectiveCache } from '@payloadcms/ui/utilities/selectiveCache' +import { + createLocalReq, + executeAuthStrategies, + getAccessResults, + getPayload, + getRequestLanguage, + parseCookies, +} from 'payload' +import { getWebRequest } from 'vinxi/http' + +type PartialResult = { + i18n: I18nClient +} & Pick & + Pick + +const partialReqCache = selectiveCache('partialReq') +const reqCache = selectiveCache('req') + +export const initReq = async function ({ + config: configArg, + importMap, + key = 'adapter', +}: { + config: Promise | SanitizedConfig + importMap: ImportMap + key?: string +}): Promise { + // getWebRequest() returns the current server request from Vinxi's context store + const request = getWebRequest() + const headers = new Headers(request.headers) + const cookies = parseCookies(headers) + + const partialResult = await partialReqCache.get(async () => { + const config = await configArg + const payload = await getPayload({ config, cron: true, importMap }) + const languageCode = getRequestLanguage({ config, cookies, headers }) + + const i18n: I18nClient = await initI18n({ + config: config.i18n, + context: 'client', + language: languageCode, + }) + + const { responseHeaders, user } = await executeAuthStrategies({ + headers, + payload, + }) + + return { i18n, languageCode, payload, responseHeaders, user } + }, 'global') + + return reqCache + .get(async () => { + const { i18n, languageCode, payload, responseHeaders, user } = partialResult + + const req = await createLocalReq( + { + req: { + headers, + host: headers.get('host'), + i18n: i18n as I18n, + responseHeaders, + url: request.url, + user, + }, + }, + payload, + ) + + const locale = await getRequestLocale({ req }) + req.locale = locale?.code + + const permissions = await getAccessResults({ req }) + + return { cookies, headers, languageCode, locale, permissions, req } + }, key) + .then((result) => ({ + ...result, + req: { + ...result.req, + ...(result.req?.context ? { context: { ...result.req.context } } : {}), + }, + })) +} diff --git a/packages/ui/src/views/Versions/index.scss b/packages/ui/src/views/Versions/index.scss new file mode 100644 index 00000000000..28ef41464e5 --- /dev/null +++ b/packages/ui/src/views/Versions/index.scss @@ -0,0 +1,110 @@ +@import '~@payloadcms/ui/scss'; + +@layer payload-default { + .versions { + width: 100%; + margin-bottom: calc(var(--base) * 2); + + &__wrap { + padding-top: 0; + padding-bottom: var(--spacing-view-bottom); + margin-top: calc(var(--base) * 0.75); + } + + &__header { + margin-bottom: var(--base); + } + + &__no-versions { + margin-top: calc(var(--base) * 1.5); + } + + &__parent-doc { + .banner__content { + display: flex; + } + } + + &__parent-doc-pills { + [dir='ltr'] & { + margin-left: auto; + } + + [dir='rtl'] & { + margin-right: auto; + } + } + + .table { + table { + width: 100%; + overflow: auto; + } + } + + &__page-controls { + width: 100%; + display: flex; + align-items: center; + } + + .paginator { + margin-bottom: 0; + } + + &__page-info { + [dir='ltr'] & { + margin-right: var(--base); + margin-left: auto; + } + + [dir='rtl'] & { + margin-left: var(--base); + margin-right: auto; + } + } + + @include mid-break { + &__wrap { + padding-top: 0; + margin-top: 0; + } + + // on mobile, extend the table all the way to the viewport edges + // this is to visually indicate overflowing content + .table { + display: flex; + width: calc(100% + calc(var(--gutter-h) * 2)); + max-width: unset; + left: calc(var(--gutter-h) * -1); + position: relative; + padding-left: var(--gutter-h); + + &::after { + content: ''; + height: 1px; + padding-right: var(--gutter-h); + } + } + + &__page-controls { + flex-wrap: wrap; + } + + &__page-info { + [dir='ltr'] & { + margin-left: 0; + } + + [dir='rtl'] & { + margin-right: 0; + } + } + + .paginator { + width: 100%; + margin-bottom: var(--base); + } + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index beb71ac4911..7c7e5c3518e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1701,6 +1701,9 @@ importers: packages/tanstack-start: dependencies: + '@payloadcms/translations': + specifier: workspace:* + version: link:../translations '@payloadcms/ui': specifier: workspace:* version: link:../ui @@ -1801,6 +1804,9 @@ importers: object-to-formdata: specifier: 4.5.1 version: 4.5.1 + path-to-regexp: + specifier: 6.3.0 + version: 6.3.0 qs-esm: specifier: 8.0.1 version: 8.0.1 @@ -2487,6 +2493,9 @@ importers: '@payloadcms/storage-vercel-blob': specifier: workspace:* version: link:../packages/storage-vercel-blob + '@payloadcms/tanstack-start': + specifier: workspace:* + version: link:../packages/tanstack-start '@payloadcms/translations': specifier: workspace:* version: link:../packages/translations diff --git a/test/admin-adapter/tanstack-start.int.spec.ts b/test/admin-adapter/tanstack-start.int.spec.ts new file mode 100644 index 00000000000..f2a600e5d00 --- /dev/null +++ b/test/admin-adapter/tanstack-start.int.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' + +describe('tanstackStartAdapter', () => { + it('should export tanstackStartAdapter function', async () => { + const { tanstackStartAdapter } = await import('@payloadcms/tanstack-start') + expect(typeof tanstackStartAdapter).toBe('function') + }) + + it('should return AdminAdapterResult with name and init', async () => { + const { tanstackStartAdapter } = await import('@payloadcms/tanstack-start') + const result = tanstackStartAdapter() + expect(result.name).toBe('tanstack-start') + expect(typeof result.init).toBe('function') + }) + + it('should export TanStackRouterProvider', async () => { + const { TanStackRouterProvider } = await import('@payloadcms/tanstack-start') + expect(typeof TanStackRouterProvider).toBe('function') + }) + + it('should export handleServerFunctions', async () => { + const { handleServerFunctions } = await import('@payloadcms/tanstack-start') + expect(typeof handleServerFunctions).toBe('function') + }) + + it('should export initReq', async () => { + const { initReq } = await import('@payloadcms/tanstack-start') + expect(typeof initReq).toBe('function') + }) +}) diff --git a/test/package.json b/test/package.json index 16d70275189..225b3a80c76 100644 --- a/test/package.json +++ b/test/package.json @@ -73,6 +73,7 @@ "@payloadcms/storage-s3": "workspace:*", "@payloadcms/storage-uploadthing": "workspace:*", "@payloadcms/storage-vercel-blob": "workspace:*", + "@payloadcms/tanstack-start": "workspace:*", "@payloadcms/translations": "workspace:*", "@payloadcms/ui": "workspace:*", "@sentry/nextjs": "^8.33.1", From 6310492651b565f244eb2c5681a51427c26c30c9 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 22:29:43 +0100 Subject: [PATCH 24/60] refactor(ui): move Root view utilities and create RenderRoot.tsx - Add framework-agnostic view utilities: getRouteData, getCustomViewByRoute, getCustomViewByKey, getDocumentViewInfo, isPathMatchingRoute, attachViewActions - Create RenderRoot.tsx accepting initPageResult + notFound/redirect callbacks - Create DocumentView, ListView, BrowseByFolder, CollectionFolders, CollectionTrash framework-agnostic wrappers that don't import from next/navigation - Add views/Root/RenderRoot export to packages/ui package.json - Update packages/next RootPage to delegate to renderRootPage from packages/ui Co-Authored-By: Claude Sonnet 4.6 (1M context) --- packages/next/src/views/Root/index.tsx | 316 +------------ packages/ui/package.json | 5 + .../BrowseByFolder/BrowseByFolderView.tsx | 24 + .../ui/src/views/BrowseByFolder/buildView.tsx | 209 +++++++++ .../src/views/CollectionFolders/buildView.tsx | 194 ++++++++ .../ui/src/views/CollectionFolders/index.tsx | 24 + .../ui/src/views/CollectionTrash/index.tsx | 43 ++ .../ui/src/views/Document/DocumentView.tsx | 33 ++ packages/ui/src/views/List/ListView.tsx | 19 + packages/ui/src/views/Root/RenderRoot.tsx | 336 +++++++++++++ .../ui/src/views/Root/attachViewActions.ts | 44 ++ .../ui/src/views/Root/getCustomViewByKey.ts | 27 ++ .../ui/src/views/Root/getCustomViewByRoute.ts | 67 +++ .../ui/src/views/Root/getDocumentViewInfo.ts | 34 ++ packages/ui/src/views/Root/getRouteData.ts | 441 ++++++++++++++++++ .../ui/src/views/Root/isPathMatchingRoute.ts | 40 ++ 16 files changed, 1553 insertions(+), 303 deletions(-) create mode 100644 packages/ui/src/views/BrowseByFolder/BrowseByFolderView.tsx create mode 100644 packages/ui/src/views/BrowseByFolder/buildView.tsx create mode 100644 packages/ui/src/views/CollectionFolders/buildView.tsx create mode 100644 packages/ui/src/views/CollectionFolders/index.tsx create mode 100644 packages/ui/src/views/CollectionTrash/index.tsx create mode 100644 packages/ui/src/views/Document/DocumentView.tsx create mode 100644 packages/ui/src/views/List/ListView.tsx create mode 100644 packages/ui/src/views/Root/RenderRoot.tsx create mode 100644 packages/ui/src/views/Root/attachViewActions.ts create mode 100644 packages/ui/src/views/Root/getCustomViewByKey.ts create mode 100644 packages/ui/src/views/Root/getCustomViewByRoute.ts create mode 100644 packages/ui/src/views/Root/getDocumentViewInfo.ts create mode 100644 packages/ui/src/views/Root/getRouteData.ts create mode 100644 packages/ui/src/views/Root/isPathMatchingRoute.ts diff --git a/packages/next/src/views/Root/index.tsx b/packages/next/src/views/Root/index.tsx index 8901e61842a..0b35d993b63 100644 --- a/packages/next/src/views/Root/index.tsx +++ b/packages/next/src/views/Root/index.tsx @@ -1,33 +1,13 @@ import type { I18nClient } from '@payloadcms/translations' import type { Metadata } from 'next' -import type { - AdminViewClientProps, - AdminViewServerPropsOnly, - CollectionPreferences, - ImportMap, - SanitizedCollectionConfig, - SanitizedConfig, - SanitizedGlobalConfig, -} from 'payload' +import type { ImportMap, SanitizedConfig } from 'payload' -import { PageConfigProvider } from '@payloadcms/ui' -import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' -import { getVisibleEntities } from '@payloadcms/ui/shared' -import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig' -import { handleAuthRedirect } from '@payloadcms/ui/utilities/handleAuthRedirect' -import { isCustomAdminView } from '@payloadcms/ui/utilities/isCustomAdminView' -import { isPublicAdminRoute } from '@payloadcms/ui/utilities/isPublicAdminRoute' +import { renderRootPage } from '@payloadcms/ui/views/Root/RenderRoot' import { notFound, redirect } from 'next/navigation.js' -import { applyLocaleFiltering, formatAdminURL } from 'payload/shared' +import { formatAdminURL } from 'payload/shared' import * as qs from 'qs-esm' -import React from 'react' -import { DefaultTemplate } from '../../templates/Default/index.js' -import { MinimalTemplate } from '../../templates/Minimal/index.js' -import { getPreferences } from '../../utilities/getPreferences.js' import { initReq } from '../../utilities/initReq.js' -import { getCustomViewByRoute } from './getCustomViewByRoute.js' -import { getRouteData } from './getRouteData.js' export type GenerateViewMetadata = (args: { config: SanitizedConfig @@ -52,81 +32,22 @@ export const RootPage = async ({ }> }) => { const config = await configPromise + const params = await paramsPromise + const searchParams = await searchParamsPromise + const segments = Array.isArray(params?.segments) ? params.segments : [] const { - admin: { - routes: { createFirstUser: _createFirstUserRoute }, - user: userSlug, - }, routes: { admin: adminRoute }, } = config - const params = await paramsPromise - const currentRoute = formatAdminURL({ adminRoute, - path: Array.isArray(params.segments) ? `/${params.segments.join('/')}` : null, + path: segments.length ? `/${segments.join('/')}` : null, }) - const segments = Array.isArray(params.segments) ? params.segments : [] - const isCollectionRoute = segments[0] === 'collections' - const isGlobalRoute = segments[0] === 'globals' - let collectionConfig: SanitizedCollectionConfig = undefined - let globalConfig: SanitizedGlobalConfig = undefined - - const searchParams = await searchParamsPromise - - // Redirect `${adminRoute}/collections` to `${adminRoute}` - if (isCollectionRoute) { - if (segments.length === 1) { - const { viewKey } = getCustomViewByRoute({ - config, - currentRoute: '/collections', - }) - - // Only redirect if there's NO custom view configured for /collections - if (!viewKey) { - redirect(adminRoute) - } - } - - if (segments[1]) { - collectionConfig = config.collections.find(({ slug }) => slug === segments[1]) - } - } - - // Redirect `${adminRoute}/globals` to `${adminRoute}` - if (isGlobalRoute) { - if (segments.length === 1) { - const { viewKey } = getCustomViewByRoute({ - config, - currentRoute: '/globals', - }) - - // Only redirect if there's NO custom view configured for /globals - if (!viewKey) { - redirect(adminRoute) - } - } - - if (segments[1]) { - globalConfig = config.globals.find(({ slug }) => slug === segments[1]) - } - } - - if ((isCollectionRoute && !collectionConfig) || (isGlobalRoute && !globalConfig)) { - return notFound() - } - const queryString = `${qs.stringify(searchParams ?? {}, { addQueryPrefix: true })}` - const { - cookies, - locale, - permissions, - req, - req: { payload }, - } = await initReq({ + const initPageResult = await initReq({ configPromise: config, importMap, key: 'initPage', @@ -138,227 +59,16 @@ export const RootPage = async ({ ignoreQueryPrefix: true, }), }, - // intentionally omit `serverURL` to keep URL relative urlSuffix: `${currentRoute}${searchParams ? queryString : ''}`, }, }) - if ( - !permissions.canAccessAdmin && - !isPublicAdminRoute({ adminRoute, config: payload.config, route: currentRoute }) && - !isCustomAdminView({ adminRoute, config: payload.config, route: currentRoute }) - ) { - redirect( - handleAuthRedirect({ - config: payload.config, - route: currentRoute, - searchParams, - user: req.user, - }), - ) - } - - let collectionPreferences: CollectionPreferences = undefined - - if (collectionConfig && segments.length === 2) { - if (config.folders && collectionConfig.folders && segments[1] !== config.folders.slug) { - await getPreferences( - `collection-${collectionConfig.slug}`, - req.payload, - req.user.id, - config.admin.user, - ).then((res) => { - if (res && res.value) { - collectionPreferences = res.value - } - }) - } - } - - const { - browseByFolderSlugs, - DefaultView, - documentSubViewType, - routeParams, - templateClassName, - templateType, - viewActions, - viewType, - } = getRouteData({ - adminRoute, - collectionConfig, - collectionPreferences, - currentRoute, - globalConfig, - payload, + return renderRootPage({ + importMap, + initPageResult, + notFound: () => notFound(), + redirect: (url) => redirect(url), searchParams, segments, }) - - req.routeParams = routeParams - - const dbHasUser = - req.user || - (await req.payload.db - .findOne({ - collection: userSlug, - req, - }) - ?.then((doc) => !!doc)) - - /** - * This function is responsible for handling the case where the view is not found. - * The current route did not match any default views or custom route views. - */ - if (!DefaultView?.Component && !DefaultView?.payloadComponent) { - if (req?.user) { - notFound() - } - - if (dbHasUser) { - redirect(adminRoute) - } - } - - const usersCollection = config.collections.find(({ slug }) => slug === userSlug) - const disableLocalStrategy = usersCollection?.auth?.disableLocalStrategy - - const createFirstUserRoute = formatAdminURL({ - adminRoute, - path: _createFirstUserRoute, - }) - - if (disableLocalStrategy && currentRoute === createFirstUserRoute) { - redirect(adminRoute) - } - - if (!dbHasUser && currentRoute !== createFirstUserRoute && !disableLocalStrategy) { - redirect(createFirstUserRoute) - } - - if (dbHasUser && currentRoute === createFirstUserRoute) { - redirect(adminRoute) - } - - if (!DefaultView?.Component && !DefaultView?.payloadComponent && !dbHasUser) { - redirect(adminRoute) - } - - const clientConfig = getClientConfig({ - config, - i18n: req.i18n, - importMap, - user: viewType === 'createFirstUser' ? true : req.user, - }) - - await applyLocaleFiltering({ clientConfig, config, req }) - - // Ensure locale on req is still valid after filtering locales - if ( - clientConfig.localization && - req.locale && - !clientConfig.localization.localeCodes.includes(req.locale) - ) { - redirect( - `${currentRoute}${qs.stringify( - { - ...searchParams, - locale: clientConfig.localization.localeCodes.includes( - clientConfig.localization.defaultLocale, - ) - ? clientConfig.localization.defaultLocale - : clientConfig.localization.localeCodes[0], - }, - { addQueryPrefix: true }, - )}`, - ) - } - - const visibleEntities = getVisibleEntities({ req }) - - const folderID = routeParams.folderID - - const RenderedView = RenderServerComponent({ - clientProps: { - browseByFolderSlugs, - clientConfig, - documentSubViewType, - viewType, - } satisfies AdminViewClientProps, - Component: DefaultView.payloadComponent, - Fallback: DefaultView.Component, - importMap, - serverProps: { - clientConfig, - collectionConfig, - docID: routeParams.id, - folderID, - globalConfig, - i18n: req.i18n, - importMap, - initPageResult: { - collectionConfig, - cookies, - docID: routeParams.id, - globalConfig, - languageOptions: Object.entries(req.payload.config.i18n.supportedLanguages || {}).reduce( - (acc, [language, languageConfig]) => { - if (Object.keys(req.payload.config.i18n.supportedLanguages).includes(language)) { - acc.push({ - label: languageConfig.translations.general.thisLanguage, - value: language, - }) - } - - return acc - }, - [], - ), - locale, - permissions, - req, - translations: req.i18n.translations, - visibleEntities, - }, - params, - payload: req.payload, - searchParams, - viewActions, - } satisfies AdminViewServerPropsOnly, - }) - - return ( - - {!templateType && {RenderedView}} - {templateType === 'minimal' && ( - {RenderedView} - )} - {templateType === 'default' && ( - " error introduced in React 19 - // which this caused as soon as initPageResult.visibleEntities is passed in - collections: visibleEntities?.collections, - globals: visibleEntities?.globals, - }} - > - {RenderedView} - - )} - - ) } diff --git a/packages/ui/package.json b/packages/ui/package.json index 628f6fd0730..2f4816c0b6d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -131,6 +131,11 @@ "types": "./src/views/Account/RenderAccount.tsx", "default": "./src/views/Account/RenderAccount.tsx" }, + "./views/Root/RenderRoot": { + "import": "./src/views/Root/RenderRoot.tsx", + "types": "./src/views/Root/RenderRoot.tsx", + "default": "./src/views/Root/RenderRoot.tsx" + }, "./views/Version/fetchVersions": { "import": "./src/views/Version/fetchVersions.ts", "types": "./src/views/Version/fetchVersions.ts", diff --git a/packages/ui/src/views/BrowseByFolder/BrowseByFolderView.tsx b/packages/ui/src/views/BrowseByFolder/BrowseByFolderView.tsx new file mode 100644 index 00000000000..60abff0ed36 --- /dev/null +++ b/packages/ui/src/views/BrowseByFolder/BrowseByFolderView.tsx @@ -0,0 +1,24 @@ +import type React from 'react' + +import type { BuildFolderViewArgs } from './buildView.js' + +import { buildBrowseByFolderView } from './buildView.js' + +export type { BuildFolderViewArgs } + +/** + * Framework-agnostic BrowseByFolder server component. + * Throws Error('not-found') instead of calling framework-specific notFound(). + */ +export const BrowseByFolder: React.FC = async (args) => { + try { + const { View } = await buildBrowseByFolderView(args) + return View + } catch (error) { + if (error?.message === 'not-found') { + throw error + } + console.error(error) // eslint-disable-line no-console + throw error + } +} diff --git a/packages/ui/src/views/BrowseByFolder/buildView.tsx b/packages/ui/src/views/BrowseByFolder/buildView.tsx new file mode 100644 index 00000000000..9cb09fa4041 --- /dev/null +++ b/packages/ui/src/views/BrowseByFolder/buildView.tsx @@ -0,0 +1,209 @@ +import type { + AdminViewServerProps, + BuildCollectionFolderViewResult, + FolderListViewClientProps, + FolderListViewServerPropsOnly, + FolderSortKeys, + ListQuery, +} from 'payload' + +import { formatAdminURL, PREFERENCE_KEYS } from 'payload/shared' +import React from 'react' + +import { HydrateAuthProvider } from '../../elements/HydrateAuthProvider/index.js' +import { RenderServerComponent } from '../../elements/RenderServerComponent/index.js' +import { getFolderResultsComponentAndData } from '../../utilities/getFolderResultsComponentAndData.js' +import { upsertPreferences } from '../../utilities/upsertPreferences.js' +import { DefaultBrowseByFolderView } from './index.js' + +export type BuildFolderViewArgs = { + customCellProps?: Record + disableBulkDelete?: boolean + disableBulkEdit?: boolean + enableRowSelections: boolean + folderID?: number | string + isInDrawer?: boolean + overrideEntityVisibility?: boolean + query: ListQuery + /** Framework-specific redirect — throws framework error */ + redirect?: (url: string) => never +} & AdminViewServerProps + +export const buildBrowseByFolderView = async ( + args: BuildFolderViewArgs, +): Promise => { + const { + browseByFolderSlugs: browseByFolderSlugsFromArgs = [], + disableBulkDelete, + disableBulkEdit, + enableRowSelections, + folderID, + initPageResult, + isInDrawer, + params, + query: queryFromArgs, + redirect, + searchParams, + } = args + + const { + locale: fullLocale, + permissions, + req: { + i18n, + payload, + payload: { config }, + query: queryFromReq, + user, + }, + visibleEntities, + } = initPageResult + + if (config.folders === false || config.folders.browseByFolder === false) { + throw new Error('not-found') + } + + const foldersSlug = config.folders.slug + + const allowReadCollectionSlugs = browseByFolderSlugsFromArgs.filter( + (collectionSlug) => + permissions?.collections?.[collectionSlug]?.read && + visibleEntities.collections.includes(collectionSlug), + ) + + const query = + queryFromArgs || + ((queryFromReq + ? { + ...queryFromReq, + relationTo: + typeof queryFromReq?.relationTo === 'string' + ? JSON.parse(queryFromReq.relationTo) + : undefined, + } + : {}) as ListQuery) + + let collectionsToDisplay: string[] = [] + if (folderID && Array.isArray(query?.relationTo)) { + collectionsToDisplay = query.relationTo.filter( + (slug) => allowReadCollectionSlugs.includes(slug) || slug === foldersSlug, + ) + } else if (folderID) { + collectionsToDisplay = [...allowReadCollectionSlugs, foldersSlug] + } else { + collectionsToDisplay = [foldersSlug] + } + + const { + routes: { admin: adminRoute }, + } = config + + const browseByFolderPreferences = await upsertPreferences<{ + sort?: FolderSortKeys + viewPreference?: 'grid' | 'list' + }>({ + key: PREFERENCE_KEYS.BROWSE_BY_FOLDER, + req: initPageResult.req, + value: { + sort: query?.sort as FolderSortKeys, + }, + }) + + const sortPreference: FolderSortKeys = browseByFolderPreferences?.sort || 'name' + const viewPreference = browseByFolderPreferences?.viewPreference || 'grid' + + const { breadcrumbs, documents, folderAssignedCollections, FolderResultsComponent, subfolders } = + await getFolderResultsComponentAndData({ + browseByFolder: true, + collectionsToDisplay, + displayAs: viewPreference, + folderAssignedCollections: collectionsToDisplay.filter((slug) => slug !== foldersSlug) || [], + folderID, + req: initPageResult.req, + sort: sortPreference, + }) + + const resolvedFolderID = breadcrumbs[breadcrumbs.length - 1]?.id + + if ( + !isInDrawer && + ((resolvedFolderID && folderID && folderID !== resolvedFolderID) || + (folderID && !resolvedFolderID)) + ) { + const redirectURL = formatAdminURL({ + adminRoute, + path: config.admin.routes.browseByFolder, + }) + if (redirect) { + redirect(redirectURL) + } + } + + const serverProps: Omit = { + documents, + i18n, + locale: fullLocale, + params, + payload, + permissions, + searchParams, + subfolders, + user, + } + + const allAvailableCollectionSlugs = + folderID && Array.isArray(folderAssignedCollections) && folderAssignedCollections.length + ? allowReadCollectionSlugs.filter((slug) => folderAssignedCollections.includes(slug)) + : allowReadCollectionSlugs + + const availableActiveCollectionFolderSlugs = collectionsToDisplay.filter((slug) => { + if (slug === foldersSlug) { + return permissions?.collections?.[foldersSlug]?.read + } else { + return !folderAssignedCollections || folderAssignedCollections.includes(slug) + } + }) + + const allowCreateCollectionSlugs = ( + resolvedFolderID ? [foldersSlug, ...allAvailableCollectionSlugs] : [foldersSlug] + ).filter((collectionSlug) => { + if (collectionSlug === foldersSlug) { + return permissions?.collections?.[foldersSlug]?.create + } + return ( + permissions?.collections?.[collectionSlug]?.create && + visibleEntities.collections.includes(collectionSlug) + ) + }) + + return { + View: ( + <> + + {RenderServerComponent({ + clientProps: { + activeCollectionFolderSlugs: availableActiveCollectionFolderSlugs, + allCollectionFolderSlugs: allAvailableCollectionSlugs, + allowCreateCollectionSlugs, + baseFolderPath: `/browse-by-folder`, + breadcrumbs, + disableBulkDelete, + disableBulkEdit, + documents, + enableRowSelections, + folderAssignedCollections, + folderFieldName: config.folders.fieldName, + folderID: resolvedFolderID || null, + FolderResultsComponent, + sort: sortPreference, + subfolders, + viewPreference, + } satisfies FolderListViewClientProps, + Fallback: DefaultBrowseByFolderView, + importMap: payload.importMap, + serverProps, + })} + + ), + } +} diff --git a/packages/ui/src/views/CollectionFolders/buildView.tsx b/packages/ui/src/views/CollectionFolders/buildView.tsx new file mode 100644 index 00000000000..e63e70ce8d9 --- /dev/null +++ b/packages/ui/src/views/CollectionFolders/buildView.tsx @@ -0,0 +1,194 @@ +import type { + AdminViewServerProps, + BuildCollectionFolderViewResult, + FolderListViewClientProps, + FolderListViewServerPropsOnly, + FolderSortKeys, + ListQuery, +} from 'payload' + +import { formatAdminURL } from 'payload/shared' +import React from 'react' + +import { HydrateAuthProvider } from '../../elements/HydrateAuthProvider/index.js' +import { RenderServerComponent } from '../../elements/RenderServerComponent/index.js' +import { getFolderResultsComponentAndData } from '../../utilities/getFolderResultsComponentAndData.js' +import { upsertPreferences } from '../../utilities/upsertPreferences.js' +import { DefaultCollectionFolderView } from '../CollectionFolder/index.js' + +export type BuildCollectionFolderViewStateArgs = { + disableBulkDelete?: boolean + disableBulkEdit?: boolean + enableRowSelections: boolean + folderID?: number | string + isInDrawer?: boolean + overrideEntityVisibility?: boolean + query: ListQuery + /** Framework-specific redirect — throws framework error */ + redirect?: (url: string) => never +} & AdminViewServerProps + +/** + * Builds the entire view for collection-folder views on the server. + * Framework-agnostic version: accepts optional redirect callback instead of importing from next/navigation. + */ +export const buildCollectionFolderView = async ( + args: BuildCollectionFolderViewStateArgs, +): Promise => { + const { + disableBulkDelete, + disableBulkEdit, + enableRowSelections, + folderID, + initPageResult, + isInDrawer, + overrideEntityVisibility, + params, + query: queryFromArgs, + redirect, + searchParams, + } = args + + const { + collectionConfig, + collectionConfig: { slug: collectionSlug }, + locale: fullLocale, + permissions, + req: { + i18n, + payload, + payload: { config }, + query: queryFromReq, + user, + }, + visibleEntities, + } = initPageResult + + if (!config.folders) { + throw new Error('not-found') + } + + if ( + !permissions?.collections?.[collectionSlug]?.read || + !permissions?.collections?.[config.folders.slug].read + ) { + throw new Error('not-found') + } + + if (collectionConfig) { + if ( + (!visibleEntities.collections.includes(collectionSlug) && !overrideEntityVisibility) || + !config.folders + ) { + throw new Error('not-found') + } + + const query = queryFromArgs || queryFromReq + + const collectionFolderPreferences = await upsertPreferences<{ + sort?: FolderSortKeys + viewPreference?: 'grid' | 'list' + }>({ + key: `${collectionSlug}-collection-folder`, + req: initPageResult.req, + value: { + sort: query?.sort as FolderSortKeys, + }, + }) + + const sortPreference: FolderSortKeys = collectionFolderPreferences?.sort || 'name' + const viewPreference = collectionFolderPreferences?.viewPreference || 'grid' + + const { + routes: { admin: adminRoute }, + } = config + + const { + breadcrumbs, + documents, + folderAssignedCollections, + FolderResultsComponent, + subfolders, + } = await getFolderResultsComponentAndData({ + browseByFolder: false, + collectionsToDisplay: [config.folders.slug, collectionSlug], + displayAs: viewPreference, + folderAssignedCollections: [collectionSlug], + folderID, + req: initPageResult.req, + sort: sortPreference, + }) + + const resolvedFolderID = breadcrumbs[breadcrumbs.length - 1]?.id + + if ( + !isInDrawer && + ((resolvedFolderID && folderID && folderID !== resolvedFolderID) || + (folderID && !resolvedFolderID)) + ) { + const redirectURL = formatAdminURL({ + adminRoute, + path: `/collections/${collectionSlug}/${config.folders.slug}`, + }) + if (redirect) { + redirect(redirectURL) + } + } + + const serverProps: FolderListViewServerPropsOnly = { + collectionConfig, + documents, + i18n, + locale: fullLocale, + params, + payload, + permissions, + searchParams, + subfolders, + user, + } + + const search = query?.search as string + + return { + View: ( + <> + + {RenderServerComponent({ + clientProps: { + allCollectionFolderSlugs: [config.folders.slug, collectionSlug], + allowCreateCollectionSlugs: [ + permissions?.collections?.[config.folders.slug]?.create + ? config.folders.slug + : null, + resolvedFolderID && permissions?.collections?.[collectionSlug]?.create + ? collectionSlug + : null, + ].filter(Boolean), + baseFolderPath: `/collections/${collectionSlug}/${config.folders.slug}`, + breadcrumbs, + collectionSlug, + disableBulkDelete, + disableBulkEdit, + documents, + enableRowSelections, + folderAssignedCollections, + folderFieldName: config.folders.fieldName, + folderID: resolvedFolderID || null, + FolderResultsComponent, + search, + sort: sortPreference, + subfolders, + viewPreference, + } satisfies FolderListViewClientProps, + Fallback: DefaultCollectionFolderView, + importMap: payload.importMap, + serverProps, + })} + + ), + } + } + + throw new Error('not-found') +} diff --git a/packages/ui/src/views/CollectionFolders/index.tsx b/packages/ui/src/views/CollectionFolders/index.tsx new file mode 100644 index 00000000000..bbb857785cd --- /dev/null +++ b/packages/ui/src/views/CollectionFolders/index.tsx @@ -0,0 +1,24 @@ +import type React from 'react' + +import type { BuildCollectionFolderViewStateArgs } from './buildView.js' + +import { buildCollectionFolderView } from './buildView.js' + +export type { BuildCollectionFolderViewStateArgs } + +/** + * Framework-agnostic CollectionFolderView. + * Throws Error('not-found') instead of calling framework-specific notFound(). + */ +export const CollectionFolderView: React.FC = async (args) => { + try { + const { View } = await buildCollectionFolderView(args) + return View + } catch (error) { + if (error?.message === 'not-found') { + throw error + } + console.error(error) // eslint-disable-line no-console + throw error + } +} diff --git a/packages/ui/src/views/CollectionTrash/index.tsx b/packages/ui/src/views/CollectionTrash/index.tsx new file mode 100644 index 00000000000..b6b5543ecd6 --- /dev/null +++ b/packages/ui/src/views/CollectionTrash/index.tsx @@ -0,0 +1,43 @@ +import type { AdminViewServerProps, ListQuery } from 'payload' +import type React from 'react' + +import { renderListView } from '../List/RenderListView.js' + +type RenderTrashViewArgs = { + customCellProps?: Record + disableBulkDelete?: boolean + disableBulkEdit?: boolean + disableQueryPresets?: boolean + drawerSlug?: string + enableRowSelections: boolean + overrideEntityVisibility?: boolean + query: ListQuery + redirectAfterDelete?: boolean + redirectAfterDuplicate?: boolean + redirectAfterRestore?: boolean +} & AdminViewServerProps + +/** + * Framework-agnostic TrashView. + * Throws Error('not-found') instead of calling framework-specific notFound(). + */ +export const TrashView: React.FC> = async ( + args, +) => { + try { + const { List: TrashList } = await renderListView({ + ...args, + enableRowSelections: true, + trash: true, + viewType: 'trash', + }) + + return TrashList + } catch (error) { + if (error.message === 'not-found') { + throw error + } + console.error(error) // eslint-disable-line no-console + throw error + } +} diff --git a/packages/ui/src/views/Document/DocumentView.tsx b/packages/ui/src/views/Document/DocumentView.tsx new file mode 100644 index 00000000000..12901b3a2c8 --- /dev/null +++ b/packages/ui/src/views/Document/DocumentView.tsx @@ -0,0 +1,33 @@ +import type { AdminViewServerProps } from 'payload' + +import { renderDocument } from './RenderDocument.js' + +type DocumentViewProps = { + notFound?: () => never + redirect?: (url: string) => never +} & AdminViewServerProps + +/** + * Framework-agnostic DocumentView server component. + * Accepts optional notFound/redirect callbacks (injected via serverProps from the adapter). + * Falls back to error-throwing if callbacks are not provided. + */ +export async function DocumentView({ notFound, redirect, ...props }: DocumentViewProps) { + const _notFound: () => never = + notFound ?? + (() => { + throw new Error('not-found') + }) + const _redirect: (url: string) => never = + redirect ?? + ((url: string) => { + throw new Error(`REDIRECT:${url}`) + }) + + const { Document: RenderedDocument } = await renderDocument({ + ...props, + notFound: _notFound, + redirect: _redirect, + }) + return RenderedDocument +} diff --git a/packages/ui/src/views/List/ListView.tsx b/packages/ui/src/views/List/ListView.tsx new file mode 100644 index 00000000000..d1e17bdf77e --- /dev/null +++ b/packages/ui/src/views/List/ListView.tsx @@ -0,0 +1,19 @@ +import type React from 'react' + +import type { RenderListViewArgs } from './RenderListView.js' + +import { renderListView } from './RenderListView.js' + +export type { RenderListViewArgs } + +/** + * Framework-agnostic ListView server component. + * Wraps renderListView — throws Error('not-found') on error instead of calling framework notFound(). + */ +export const ListView: React.FC = async (args) => { + const { List: RenderedList } = await renderListView({ + ...args, + enableRowSelections: true, + }) + return RenderedList +} diff --git a/packages/ui/src/views/Root/RenderRoot.tsx b/packages/ui/src/views/Root/RenderRoot.tsx new file mode 100644 index 00000000000..6b17f4e91e3 --- /dev/null +++ b/packages/ui/src/views/Root/RenderRoot.tsx @@ -0,0 +1,336 @@ +import type { + AdminViewClientProps, + AdminViewServerPropsOnly, + CollectionPreferences, + ImportMap, + InitReqResult, + SanitizedCollectionConfig, + SanitizedGlobalConfig, +} from 'payload' + +import { applyLocaleFiltering, formatAdminURL } from 'payload/shared' +import * as qs from 'qs-esm' +import React from 'react' + +import { RenderServerComponent } from '../../elements/RenderServerComponent/index.js' +import { PageConfigProvider } from '../../providers/Config/index.js' +import { DefaultTemplate } from '../../templates/Default/index.js' +import { MinimalTemplate } from '../../templates/Minimal/index.js' +import { getClientConfig } from '../../utilities/getClientConfig.js' +import { getPreferences } from '../../utilities/getPreferences.js' +import { getVisibleEntities } from '../../utilities/getVisibleEntities.js' +import { handleAuthRedirect } from '../../utilities/handleAuthRedirect.js' +import { isCustomAdminView } from '../../utilities/isCustomAdminView.js' +import { isPublicAdminRoute } from '../../utilities/isPublicAdminRoute.js' +import { getCustomViewByRoute } from './getCustomViewByRoute.js' +import { getRouteData } from './getRouteData.js' + +export type RenderRootPageArgs = { + importMap: ImportMap + initPageResult: InitReqResult + /** Framework-specific notFound — throws framework error */ + notFound: () => never + /** Framework-specific redirect — throws framework error */ + redirect: (url: string) => never + searchParams: { [key: string]: string | string[] } + segments: string[] +} + +/** + * Framework-agnostic root page renderer. + * Receives a pre-computed initPageResult and navigation callbacks from the framework adapter. + */ +export const renderRootPage = async ({ + importMap, + initPageResult, + notFound, + redirect, + searchParams, + segments, +}: RenderRootPageArgs): Promise => { + const { + cookies, + locale, + permissions, + req, + req: { payload }, + } = initPageResult + + const config = payload.config + + const { + admin: { + routes: { createFirstUser: _createFirstUserRoute }, + user: userSlug, + }, + routes: { admin: adminRoute }, + } = config + + const currentRoute = formatAdminURL({ + adminRoute, + path: Array.isArray(segments) ? `/${segments.join('/')}` : null, + }) + + const isCollectionRoute = segments[0] === 'collections' + const isGlobalRoute = segments[0] === 'globals' + let collectionConfig: SanitizedCollectionConfig = undefined + let globalConfig: SanitizedGlobalConfig = undefined + + // Redirect `${adminRoute}/collections` to `${adminRoute}` + if (isCollectionRoute) { + if (segments.length === 1) { + const { viewKey } = getCustomViewByRoute({ + config, + currentRoute: '/collections', + }) + + if (!viewKey) { + redirect(adminRoute) + } + } + + if (segments[1]) { + collectionConfig = config.collections.find(({ slug }) => slug === segments[1]) + } + } + + // Redirect `${adminRoute}/globals` to `${adminRoute}` + if (isGlobalRoute) { + if (segments.length === 1) { + const { viewKey } = getCustomViewByRoute({ + config, + currentRoute: '/globals', + }) + + if (!viewKey) { + redirect(adminRoute) + } + } + + if (segments[1]) { + globalConfig = config.globals.find(({ slug }) => slug === segments[1]) + } + } + + if ((isCollectionRoute && !collectionConfig) || (isGlobalRoute && !globalConfig)) { + return notFound() + } + + if ( + !permissions.canAccessAdmin && + !isPublicAdminRoute({ adminRoute, config: payload.config, route: currentRoute }) && + !isCustomAdminView({ adminRoute, config: payload.config, route: currentRoute }) + ) { + redirect( + handleAuthRedirect({ + config: payload.config, + route: currentRoute, + searchParams, + user: req.user, + }), + ) + } + + let collectionPreferences: CollectionPreferences = undefined + + if (collectionConfig && segments.length === 2) { + if (config.folders && collectionConfig.folders && segments[1] !== config.folders.slug) { + await getPreferences( + `collection-${collectionConfig.slug}`, + req.payload, + req.user?.id, + config.admin.user, + ).then((res) => { + if (res && res.value) { + collectionPreferences = res.value + } + }) + } + } + + const { + browseByFolderSlugs, + DefaultView, + documentSubViewType, + routeParams, + templateClassName, + templateType, + viewActions, + viewType, + } = getRouteData({ + adminRoute, + collectionConfig, + collectionPreferences, + currentRoute, + globalConfig, + payload, + searchParams, + segments, + }) + + req.routeParams = routeParams + + const dbHasUser = + req.user || + (await req.payload.db + .findOne({ + collection: userSlug, + req, + }) + ?.then((doc) => !!doc)) + + if (!DefaultView?.Component && !DefaultView?.payloadComponent) { + if (req?.user) { + notFound() + } + + if (dbHasUser) { + redirect(adminRoute) + } + } + + const usersCollection = config.collections.find(({ slug }) => slug === userSlug) + const disableLocalStrategy = usersCollection?.auth?.disableLocalStrategy + + const createFirstUserRoute = formatAdminURL({ + adminRoute, + path: _createFirstUserRoute, + }) + + if (disableLocalStrategy && currentRoute === createFirstUserRoute) { + redirect(adminRoute) + } + + if (!dbHasUser && currentRoute !== createFirstUserRoute && !disableLocalStrategy) { + redirect(createFirstUserRoute) + } + + if (dbHasUser && currentRoute === createFirstUserRoute) { + redirect(adminRoute) + } + + if (!DefaultView?.Component && !DefaultView?.payloadComponent && !dbHasUser) { + redirect(adminRoute) + } + + const clientConfig = getClientConfig({ + config, + i18n: req.i18n, + importMap, + user: viewType === 'createFirstUser' ? true : req.user, + }) + + await applyLocaleFiltering({ clientConfig, config, req }) + + // Ensure locale on req is still valid after filtering locales + if ( + clientConfig.localization && + req.locale && + !clientConfig.localization.localeCodes.includes(req.locale) + ) { + redirect( + `${currentRoute}${qs.stringify( + { + ...searchParams, + locale: clientConfig.localization.localeCodes.includes( + clientConfig.localization.defaultLocale, + ) + ? clientConfig.localization.defaultLocale + : clientConfig.localization.localeCodes[0], + }, + { addQueryPrefix: true }, + )}`, + ) + } + + const visibleEntities = getVisibleEntities({ req }) + + const folderID = routeParams.folderID + + const params = { segments } + + const RenderedView = RenderServerComponent({ + clientProps: { + browseByFolderSlugs, + clientConfig, + documentSubViewType, + viewType, + } satisfies AdminViewClientProps, + Component: DefaultView.payloadComponent, + Fallback: DefaultView.Component, + importMap, + serverProps: { + clientConfig, + collectionConfig, + docID: routeParams.id, + folderID, + globalConfig, + i18n: req.i18n, + importMap, + initPageResult: { + collectionConfig, + cookies, + docID: routeParams.id, + globalConfig, + languageOptions: Object.entries(req.payload.config.i18n.supportedLanguages || {}).reduce( + (acc, [language, languageConfig]) => { + if (Object.keys(req.payload.config.i18n.supportedLanguages).includes(language)) { + acc.push({ + label: languageConfig.translations.general.thisLanguage, + value: language, + }) + } + + return acc + }, + [], + ), + locale, + permissions, + req, + translations: req.i18n.translations, + visibleEntities, + }, + // Inject framework-specific navigation callbacks so DocumentView and others can use them + notFound, + params, + payload: req.payload, + redirect, + searchParams, + viewActions, + } as AdminViewServerPropsOnly, + }) + + return ( + + {!templateType && {RenderedView}} + {templateType === 'minimal' && ( + {RenderedView} + )} + {templateType === 'default' && ( + + {RenderedView} + + )} + + ) +} diff --git a/packages/ui/src/views/Root/attachViewActions.ts b/packages/ui/src/views/Root/attachViewActions.ts new file mode 100644 index 00000000000..d9f7e1be1e4 --- /dev/null +++ b/packages/ui/src/views/Root/attachViewActions.ts @@ -0,0 +1,44 @@ +import type { + CustomComponent, + EditConfig, + SanitizedCollectionConfig, + SanitizedGlobalConfig, +} from 'payload' + +export function getViewActions({ + editConfig, + viewKey, +}: { + editConfig: EditConfig + viewKey: keyof EditConfig +}): CustomComponent[] { + if (editConfig && viewKey in editConfig && 'actions' in editConfig[viewKey]) { + return editConfig[viewKey].actions ?? [] + } + + return [] +} + +export function getSubViewActions({ + collectionOrGlobal, + viewKeyArg, +}: { + collectionOrGlobal: SanitizedCollectionConfig | SanitizedGlobalConfig + viewKeyArg?: keyof EditConfig +}): CustomComponent[] { + if (collectionOrGlobal?.admin?.components?.views?.edit) { + let viewKey = viewKeyArg || 'default' + if ('root' in collectionOrGlobal.admin.components.views.edit) { + viewKey = 'root' + } + + const actions = getViewActions({ + editConfig: collectionOrGlobal.admin?.components?.views?.edit, + viewKey, + }) + + return actions + } + + return [] +} diff --git a/packages/ui/src/views/Root/getCustomViewByKey.ts b/packages/ui/src/views/Root/getCustomViewByKey.ts new file mode 100644 index 00000000000..23d3aed6e5e --- /dev/null +++ b/packages/ui/src/views/Root/getCustomViewByKey.ts @@ -0,0 +1,27 @@ +import type { AdminViewServerProps, PayloadComponent, SanitizedConfig } from 'payload' + +import type { ViewFromConfig } from './getRouteData.js' + +export const getCustomViewByKey = ({ + config, + viewKey, +}: { + config: SanitizedConfig + viewKey: string +}): { + view: ViewFromConfig + viewKey: string +} | null => { + const customViewComponent = config.admin.components?.views?.[viewKey] + + if (!customViewComponent) { + return null + } + + return { + view: { + payloadComponent: customViewComponent.Component as PayloadComponent, + }, + viewKey, + } +} diff --git a/packages/ui/src/views/Root/getCustomViewByRoute.ts b/packages/ui/src/views/Root/getCustomViewByRoute.ts new file mode 100644 index 00000000000..7bc4821dca1 --- /dev/null +++ b/packages/ui/src/views/Root/getCustomViewByRoute.ts @@ -0,0 +1,67 @@ +import type { AdminViewConfig, SanitizedConfig } from 'payload' + +import type { ViewFromConfig } from './getRouteData.js' + +import { isPathMatchingRoute } from './isPathMatchingRoute.js' + +export const getCustomViewByRoute = ({ + config, + currentRoute: currentRouteWithAdmin, +}: { + config: SanitizedConfig + currentRoute: string +}): { + view: ViewFromConfig + viewConfig: AdminViewConfig + viewKey: string +} => { + const { + admin: { + components: { views }, + }, + routes: { admin: adminRoute }, + } = config + + let viewKey: string + + const currentRoute = + adminRoute === '/' ? currentRouteWithAdmin : currentRouteWithAdmin.replace(adminRoute, '') + + const foundViewConfig = + (views && + typeof views === 'object' && + Object.entries(views).find(([key, view]) => { + const isMatching = isPathMatchingRoute({ + currentRoute, + exact: view.exact, + path: view.path, + sensitive: view.sensitive, + strict: view.strict, + }) + + if (isMatching) { + viewKey = key + } + + return isMatching + })?.[1]) || + undefined + + if (!foundViewConfig) { + return { + view: { + Component: null, + }, + viewConfig: null, + viewKey: null, + } + } + + return { + view: { + payloadComponent: foundViewConfig.Component, + }, + viewConfig: foundViewConfig, + viewKey, + } +} diff --git a/packages/ui/src/views/Root/getDocumentViewInfo.ts b/packages/ui/src/views/Root/getDocumentViewInfo.ts new file mode 100644 index 00000000000..6e5296a0bda --- /dev/null +++ b/packages/ui/src/views/Root/getDocumentViewInfo.ts @@ -0,0 +1,34 @@ +import type { DocumentSubViewTypes, ViewTypes } from 'payload' + +export function getDocumentViewInfo(segments: string[]): { + documentSubViewType?: DocumentSubViewTypes + viewType: ViewTypes +} { + const [tabSegment, versionSegment] = segments + + if (versionSegment) { + if (tabSegment === 'versions') { + return { + documentSubViewType: 'version', + viewType: 'version', + } + } + } else { + if (tabSegment === 'versions') { + return { + documentSubViewType: 'versions', + viewType: 'document', + } + } else if (tabSegment === 'api') { + return { + documentSubViewType: 'api', + viewType: 'document', + } + } + } + + return { + documentSubViewType: 'default', + viewType: 'document', + } +} diff --git a/packages/ui/src/views/Root/getRouteData.ts b/packages/ui/src/views/Root/getRouteData.ts new file mode 100644 index 00000000000..39734c68980 --- /dev/null +++ b/packages/ui/src/views/Root/getRouteData.ts @@ -0,0 +1,441 @@ +import type { + AdminViewServerProps, + CollectionPreferences, + CollectionSlug, + CustomComponent, + DocumentSubViewTypes, + Payload, + PayloadComponent, + SanitizedCollectionConfig, + SanitizedConfig, + SanitizedGlobalConfig, + ViewTypes, +} from 'payload' +import type React from 'react' + +import { parseDocumentID } from 'payload' +import { formatAdminURL, isNumber } from 'payload/shared' + +import { AccountView } from '../Account/RenderAccount.js' +import { BrowseByFolder } from '../BrowseByFolder/BrowseByFolderView.js' +import { CollectionFolderView } from '../CollectionFolders/index.js' +import { TrashView } from '../CollectionTrash/index.js' +import { CreateFirstUserView } from '../CreateFirstUser/index.js' +import { DashboardView } from '../Dashboard/index.js' +import { DocumentView } from '../Document/DocumentView.js' +import { forgotPasswordBaseClass, ForgotPasswordView } from '../ForgotPassword/index.js' +import { ListView } from '../List/ListView.js' +import { loginBaseClass, LoginView } from '../Login/index.js' +import { LogoutInactivity, LogoutView } from '../Logout/index.js' +import { ResetPassword, resetPasswordBaseClass } from '../ResetPassword/index.js' +import { UnauthorizedView } from '../Unauthorized/index.js' +import { Verify, verifyBaseClass } from '../Verify/index.js' +import { getSubViewActions, getViewActions } from './attachViewActions.js' +import { getCustomViewByKey } from './getCustomViewByKey.js' +import { getCustomViewByRoute } from './getCustomViewByRoute.js' +import { getDocumentViewInfo } from './getDocumentViewInfo.js' +import { isPathMatchingRoute } from './isPathMatchingRoute.js' + +const baseClasses = { + account: 'account', + folders: 'folders', + forgot: forgotPasswordBaseClass, + login: loginBaseClass, + reset: resetPasswordBaseClass, + verify: verifyBaseClass, +} + +type OneSegmentViews = { + [K in Exclude]: React.FC +} + +export type ViewFromConfig = { + Component?: React.FC + payloadComponent?: PayloadComponent +} + +const oneSegmentViews: OneSegmentViews = { + account: AccountView, + browseByFolder: BrowseByFolder, + createFirstUser: CreateFirstUserView, + forgot: ForgotPasswordView, + inactivity: LogoutInactivity, + login: LoginView as React.FC, + logout: LogoutView, + unauthorized: UnauthorizedView, +} + +type GetRouteDataResult = { + browseByFolderSlugs: CollectionSlug[] + collectionConfig?: SanitizedCollectionConfig + DefaultView: ViewFromConfig + documentSubViewType?: DocumentSubViewTypes + globalConfig?: SanitizedGlobalConfig + routeParams: { + collection?: string + folderCollection?: string + folderID?: number | string + global?: string + id?: number | string + token?: string + versionID?: number | string + } + templateClassName: string + templateType: 'default' | 'minimal' + viewActions?: CustomComponent[] + viewType?: ViewTypes +} + +type GetRouteDataArgs = { + adminRoute: string + collectionConfig?: SanitizedCollectionConfig + /** + * User preferences for a collection. + * + * These preferences are normally undefined + * unless the user is on the list view and the + * collection is folder enabled. + */ + collectionPreferences?: CollectionPreferences + currentRoute: string + globalConfig?: SanitizedGlobalConfig + payload: Payload + searchParams: { + [key: string]: string | string[] + } + segments: string[] +} + +export const getRouteData = ({ + adminRoute, + collectionConfig, + collectionPreferences = undefined, + currentRoute, + globalConfig, + payload, + segments, +}: GetRouteDataArgs): GetRouteDataResult => { + const { config } = payload + let ViewToRender: ViewFromConfig = null + let templateClassName: string + let templateType: 'default' | 'minimal' | undefined + let documentSubViewType: DocumentSubViewTypes + let viewType: ViewTypes + const routeParams: GetRouteDataResult['routeParams'] = {} + + const [segmentOne, segmentTwo, segmentThree, segmentFour, segmentFive, segmentSix] = segments + + const isBrowseByFolderEnabled = config.folders && config.folders.browseByFolder + const browseByFolderSlugs = + (isBrowseByFolderEnabled && + config.collections.reduce((acc, { slug, folders }) => { + if (folders && folders.browseByFolder) { + return [...acc, slug] + } + return acc + }, [])) || + [] + + const viewActions: CustomComponent[] = [...(config?.admin?.components?.actions || [])] + + switch (segments.length) { + case 0: { + if (currentRoute === adminRoute) { + ViewToRender = { + Component: DashboardView, + } + templateClassName = 'dashboard' + templateType = 'default' + viewType = 'dashboard' + } + break + } + case 1: { + let viewKey: keyof typeof oneSegmentViews + + if (config.admin.routes) { + const matchedRoute = Object.entries(config.admin.routes).find(([, route]) => { + return isPathMatchingRoute({ + currentRoute, + exact: true, + path: formatAdminURL({ + adminRoute, + path: route, + }), + }) + }) + + if (matchedRoute) { + viewKey = matchedRoute[0] as keyof typeof oneSegmentViews + } + } + + const customView = + (viewKey && getCustomViewByKey({ config, viewKey })) || + getCustomViewByRoute({ config, currentRoute }) + + if (customView?.view?.payloadComponent || customView?.view?.Component) { + ViewToRender = customView.view + + if (viewKey && oneSegmentViews[viewKey]) { + viewType = viewKey as ViewTypes + templateClassName = baseClasses[viewKey] || viewKey + templateType = 'minimal' + + if (viewKey === 'account') { + templateType = 'default' + } + + if (isBrowseByFolderEnabled && viewKey === 'browseByFolder') { + templateType = 'default' + viewType = 'folders' + } + } + } else if (oneSegmentViews[viewKey]) { + ViewToRender = { + Component: oneSegmentViews[viewKey], + } + + viewType = viewKey as ViewTypes + + templateClassName = baseClasses[viewKey] + templateType = 'minimal' + + if (viewKey === 'account') { + templateType = 'default' + } + + if (isBrowseByFolderEnabled && viewKey === 'browseByFolder') { + templateType = 'default' + viewType = 'folders' + } + } + break + } + case 2: { + if (`/${segmentOne}` === config.admin.routes.reset) { + ViewToRender = { + Component: ResetPassword, + } + templateClassName = baseClasses[segmentTwo] + templateType = 'minimal' + viewType = 'reset' + } else if ( + isBrowseByFolderEnabled && + `/${segmentOne}` === config.admin.routes.browseByFolder + ) { + routeParams.folderID = segmentTwo + + ViewToRender = { + Component: oneSegmentViews.browseByFolder, + } + templateClassName = baseClasses.folders + templateType = 'default' + viewType = 'folders' + } else if (collectionConfig) { + routeParams.collection = collectionConfig.slug + + if ( + collectionPreferences?.listViewType && + collectionPreferences.listViewType === 'folders' + ) { + ViewToRender = { + Component: CollectionFolderView as React.FC, + } + + templateClassName = `collection-folders` + templateType = 'default' + viewType = 'collection-folders' + } else { + ViewToRender = { + Component: ListView, + } + + templateClassName = `${segmentTwo}-list` + templateType = 'default' + viewType = 'list' + } + + viewActions.push(...(collectionConfig.admin.components?.views?.list?.actions || [])) + } else if (globalConfig) { + routeParams.global = globalConfig.slug + + ViewToRender = { + Component: DocumentView, + } + + templateClassName = 'global-edit' + templateType = 'default' + viewType = 'document' + + viewActions.push( + ...getViewActions({ + editConfig: globalConfig.admin?.components?.views?.edit, + viewKey: 'default', + }), + ) + } + break + } + default: + if (segmentTwo === 'verify') { + routeParams.collection = segmentOne + routeParams.token = segmentThree + + ViewToRender = { + Component: Verify, + } + + templateClassName = 'verify' + templateType = 'minimal' + viewType = 'verify' + } else if (collectionConfig) { + routeParams.collection = collectionConfig.slug + + if (segmentThree === 'trash' && typeof segmentFour === 'string') { + routeParams.id = segmentFour + routeParams.versionID = segmentSix + + ViewToRender = { + Component: DocumentView, + } + + templateClassName = `collection-default-edit` + templateType = 'default' + + const viewInfo = getDocumentViewInfo([segmentFive, segmentSix]) + viewType = viewInfo.viewType + documentSubViewType = viewInfo.documentSubViewType + + viewActions.push( + ...getSubViewActions({ + collectionOrGlobal: collectionConfig, + viewKeyArg: documentSubViewType, + }), + ) + } else if (segmentThree === 'trash') { + ViewToRender = { + Component: TrashView as React.FC, + } + + templateClassName = `${segmentTwo}-trash` + templateType = 'default' + viewType = 'trash' + + viewActions.push(...(collectionConfig.admin.components?.views?.list?.actions || [])) + } else { + if (config.folders && segmentThree === config.folders.slug && collectionConfig.folders) { + routeParams.folderCollection = segmentThree + routeParams.folderID = segmentFour + + ViewToRender = { + Component: CollectionFolderView as React.FC, + } + + templateClassName = `collection-folders` + templateType = 'default' + viewType = 'collection-folders' + + viewActions.push(...(collectionConfig.admin.components?.views?.list?.actions || [])) + } else { + routeParams.id = segmentThree === 'create' ? undefined : segmentThree + routeParams.versionID = segmentFive + + ViewToRender = { + Component: DocumentView, + } + + templateClassName = `collection-default-edit` + templateType = 'default' + + const viewInfo = getDocumentViewInfo([segmentFour, segmentFive]) + viewType = viewInfo.viewType + documentSubViewType = viewInfo.documentSubViewType + + viewActions.push( + ...getSubViewActions({ + collectionOrGlobal: collectionConfig, + viewKeyArg: documentSubViewType, + }), + ) + } + } + } else if (globalConfig) { + routeParams.global = globalConfig.slug + routeParams.versionID = segmentFour + + ViewToRender = { + Component: DocumentView, + } + + templateClassName = `global-edit` + templateType = 'default' + + const viewInfo = getDocumentViewInfo([segmentThree, segmentFour]) + viewType = viewInfo.viewType + documentSubViewType = viewInfo.documentSubViewType + + viewActions.push( + ...getSubViewActions({ + collectionOrGlobal: globalConfig, + viewKeyArg: documentSubViewType, + }), + ) + } + break + } + + if (!ViewToRender) { + ViewToRender = getCustomViewByRoute({ config, currentRoute })?.view + } + + if (collectionConfig) { + if (routeParams.id) { + routeParams.id = parseDocumentID({ + id: routeParams.id, + collectionSlug: collectionConfig.slug, + payload, + }) + } + + if (routeParams.versionID) { + routeParams.versionID = parseDocumentID({ + id: routeParams.versionID, + collectionSlug: collectionConfig.slug, + payload, + }) + } + } + + if (config.folders && routeParams.folderID) { + routeParams.folderID = parseDocumentID({ + id: routeParams.folderID, + collectionSlug: config.folders.slug, + payload, + }) + } + + if (globalConfig && routeParams.versionID) { + routeParams.versionID = + payload.db.defaultIDType === 'number' && isNumber(routeParams.versionID) + ? Number(routeParams.versionID) + : routeParams.versionID + } + + if (viewActions.length) { + viewActions.reverse() + } + + return { + browseByFolderSlugs, + collectionConfig, + DefaultView: ViewToRender, + documentSubViewType, + globalConfig, + routeParams, + templateClassName, + templateType, + viewActions: viewActions.length ? viewActions : undefined, + viewType, + } +} diff --git a/packages/ui/src/views/Root/isPathMatchingRoute.ts b/packages/ui/src/views/Root/isPathMatchingRoute.ts new file mode 100644 index 00000000000..193fc0042a8 --- /dev/null +++ b/packages/ui/src/views/Root/isPathMatchingRoute.ts @@ -0,0 +1,40 @@ +import { pathToRegexp } from 'path-to-regexp' + +export const isPathMatchingRoute = ({ + currentRoute, + exact, + path: viewPath, + sensitive, + strict, +}: { + currentRoute: string + exact?: boolean + path?: string + sensitive?: boolean + strict?: boolean +}) => { + // if no path is defined, we cannot match it so return false early + if (!viewPath) { + return false + } + + const keys = [] + + // run the view path through `pathToRegexp` to resolve any dynamic segments + // i.e. `/admin/custom-view/:id` -> `/admin/custom-view/123` + const regex = pathToRegexp(viewPath, keys, { + sensitive, + strict, + }) + + const match = regex.exec(currentRoute) + const viewRoute = match?.[0] || viewPath + + if (exact) { + return currentRoute === viewRoute + } + + if (!exact) { + return viewRoute.startsWith(currentRoute) + } +} From 022409b7d7b3793866de839ae954af3fd9f1cec2 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 22:37:18 +0100 Subject: [PATCH 25/60] feat(tanstack-start): add Phase 6 app shell and dev:tanstack command - Create tanstack-app workspace with package.json, app.config.ts, tsconfig - Add tanstack-app entry points: client.tsx, ssr.tsx, router.tsx, routeTree.gen.ts - Create root layout route (__root.tsx) with server function dispatcher - Create admin (admin.$.tsx) and API (api.$.ts) routes - Add packages/tanstack-start RootLayout server component using vinxi/http - Add packages/tanstack-start RootPage delegating to packages/ui renderRootPage - Add packages/tanstack-start routes re-exporting REST handlers from packages/next - Add dev:tanstack and dev:tanstack:postgres npm scripts - Update initDevAndTest to support TanStack app importMap path (app/importMap.js) - Add tanstack-app to pnpm-workspace.yaml Co-Authored-By: Claude Sonnet 4.6 (1M context) --- package.json | 2 + packages/tanstack-start/package.json | 16 + packages/tanstack-start/src/index.ts | 1 + .../tanstack-start/src/layouts/Root/index.tsx | 102 ++++ packages/tanstack-start/src/routes/index.ts | 3 + .../src/utilities/getNavPrefs.ts | 37 ++ .../tanstack-start/src/views/Root/index.tsx | 46 ++ pnpm-lock.yaml | 450 ++++++++++++++++++ pnpm-workspace.yaml | 1 + tanstack-app/app.config.ts | 15 + tanstack-app/app/client.tsx | 14 + tanstack-app/app/importMap.ts | 3 + tanstack-app/app/routeTree.gen.ts | 9 + tanstack-app/app/router.tsx | 13 + tanstack-app/app/routes/__root.tsx | 73 +++ tanstack-app/app/routes/admin.$.tsx | 25 + tanstack-app/app/routes/api.$.ts | 17 + tanstack-app/app/ssr.tsx | 5 + tanstack-app/package.json | 24 + tanstack-app/tsconfig.json | 11 + test/dev-tanstack.ts | 53 +++ test/initDevAndTest.ts | 17 +- 22 files changed, 932 insertions(+), 5 deletions(-) create mode 100644 packages/tanstack-start/src/layouts/Root/index.tsx create mode 100644 packages/tanstack-start/src/routes/index.ts create mode 100644 packages/tanstack-start/src/utilities/getNavPrefs.ts create mode 100644 packages/tanstack-start/src/views/Root/index.tsx create mode 100644 tanstack-app/app.config.ts create mode 100644 tanstack-app/app/client.tsx create mode 100644 tanstack-app/app/importMap.ts create mode 100644 tanstack-app/app/routeTree.gen.ts create mode 100644 tanstack-app/app/router.tsx create mode 100644 tanstack-app/app/routes/__root.tsx create mode 100644 tanstack-app/app/routes/admin.$.tsx create mode 100644 tanstack-app/app/routes/api.$.ts create mode 100644 tanstack-app/app/ssr.tsx create mode 100644 tanstack-app/package.json create mode 100644 tanstack-app/tsconfig.json create mode 100644 test/dev-tanstack.ts diff --git a/package.json b/package.json index b798ce221f5..d61c08e723d 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,8 @@ "dev:generate-types": "pnpm runts ./test/generateTypes.ts", "dev:postgres": "cross-env PAYLOAD_DATABASE=postgres pnpm runts ./test/dev.ts", "dev:prod": "cross-env NODE_OPTIONS=--no-deprecation tsx ./test/dev.ts --prod", + "dev:tanstack": "cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=16384\" tsx ./test/dev-tanstack.ts", + "dev:tanstack:postgres": "cross-env PAYLOAD_DATABASE=postgres NODE_OPTIONS=\"--no-deprecation --max-old-space-size=16384\" tsx ./test/dev-tanstack.ts", "dev:vercel-postgres": "cross-env PAYLOAD_DATABASE=vercel-postgres pnpm runts ./test/dev.ts", "devsafe": "node ./scripts/delete-recursively.js '**/.next' && pnpm dev", "docker:clean": "node ./scripts/docker-clean.js", diff --git a/packages/tanstack-start/package.json b/packages/tanstack-start/package.json index 4ceb3d4ea7b..4917548913e 100644 --- a/packages/tanstack-start/package.json +++ b/packages/tanstack-start/package.json @@ -16,11 +16,27 @@ "import": "./src/index.ts", "types": "./src/index.ts", "default": "./src/index.ts" + }, + "./views": { + "import": "./src/views/Root/index.tsx", + "types": "./src/views/Root/index.tsx", + "default": "./src/views/Root/index.tsx" + }, + "./routes": { + "import": "./src/routes/index.ts", + "types": "./src/routes/index.ts", + "default": "./src/routes/index.ts" + }, + "./utilities/initReq": { + "import": "./src/utilities/initReq.ts", + "types": "./src/utilities/initReq.ts", + "default": "./src/utilities/initReq.ts" } }, "main": "./src/index.ts", "types": "./src/index.ts", "dependencies": { + "@payloadcms/next": "workspace:*", "@payloadcms/translations": "workspace:*", "@payloadcms/ui": "workspace:*", "payload": "workspace:*", diff --git a/packages/tanstack-start/src/index.ts b/packages/tanstack-start/src/index.ts index 5d3c71a1491..4a73f60ff7f 100644 --- a/packages/tanstack-start/src/index.ts +++ b/packages/tanstack-start/src/index.ts @@ -1,4 +1,5 @@ export { tanstackStartAdapter } from './adapter/index.js' export { TanStackRouterProvider } from './adapter/RouterProvider.js' +export { RootLayout } from './layouts/Root/index.js' export { handleServerFunctions } from './utilities/handleServerFunctions.js' export { initReq } from './utilities/initReq.js' diff --git a/packages/tanstack-start/src/layouts/Root/index.tsx b/packages/tanstack-start/src/layouts/Root/index.tsx new file mode 100644 index 00000000000..c723ff94c58 --- /dev/null +++ b/packages/tanstack-start/src/layouts/Root/index.tsx @@ -0,0 +1,102 @@ +import type { ImportMap, LanguageOptions, SanitizedConfig, ServerFunctionClient } from 'payload' + +import { defaultTheme, ProgressBar, RootProvider } from '@payloadcms/ui' +import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig' +import { applyLocaleFiltering } from 'payload/shared' +import React from 'react' +import { setCookie } from 'vinxi/http' + +import { TanStackRouterProvider } from '../../adapter/RouterProvider.js' +import { getNavPrefs } from '../../utilities/getNavPrefs.js' +import { initReq } from '../../utilities/initReq.js' + +import '@payloadcms/ui/scss/app.scss' + +type RootLayoutProps = { + readonly children: React.ReactNode + readonly config: Promise | SanitizedConfig + readonly importMap: ImportMap + readonly serverFunction: ServerFunctionClient +} + +function getRequestTheme(cookies: Map, config: SanitizedConfig): 'dark' | 'light' { + const themeCookie = cookies.get(`${config.cookiePrefix || 'payload'}-theme`) + if (themeCookie === 'dark' || themeCookie === 'light') { + return themeCookie + } + if (config.admin.theme !== 'all') { + return config.admin.theme + } + return defaultTheme as 'dark' | 'light' +} + +export const RootLayout = async ({ + children, + config: configPromise, + importMap, + serverFunction, +}: RootLayoutProps) => { + const { + cookies, + languageCode, + permissions, + req, + req: { + payload: { config }, + }, + } = await initReq({ config: configPromise, importMap, key: 'RootLayout' }) + + const theme = getRequestTheme(cookies, config) + + const languageOptions: LanguageOptions = Object.entries( + config.i18n.supportedLanguages || {}, + ).reduce((acc, [language, languageConfig]) => { + if (Object.keys(config.i18n.supportedLanguages).includes(language)) { + acc.push({ + label: languageConfig.translations.general.thisLanguage, + value: language, + }) + } + return acc + }, []) + + // eslint-disable-next-line @typescript-eslint/require-await + const switchLanguageServerAction = async (lang: string): Promise => { + 'use server' + setCookie(`${config.cookiePrefix || 'payload'}-lng`, lang, { path: '/' }) + } + + const navPrefs = await getNavPrefs(req) + + const clientConfig = getClientConfig({ + config, + i18n: req.i18n, + importMap, + user: req.user, + }) + + await applyLocaleFiltering({ clientConfig, config, req }) + + return ( + + + + {children} + + + ) +} diff --git a/packages/tanstack-start/src/routes/index.ts b/packages/tanstack-start/src/routes/index.ts new file mode 100644 index 00000000000..018218968a5 --- /dev/null +++ b/packages/tanstack-start/src/routes/index.ts @@ -0,0 +1,3 @@ +// REST route handlers adapted for TanStack Start's API route format. +// They accept standard Web API Request objects, same as @payloadcms/next/routes. +export { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes' diff --git a/packages/tanstack-start/src/utilities/getNavPrefs.ts b/packages/tanstack-start/src/utilities/getNavPrefs.ts new file mode 100644 index 00000000000..b6cdadacecd --- /dev/null +++ b/packages/tanstack-start/src/utilities/getNavPrefs.ts @@ -0,0 +1,37 @@ +import type { NavPreferences, PayloadRequest } from 'payload' + +import { PREFERENCE_KEYS } from 'payload/shared' +import { cache } from 'react' + +export const getNavPrefs = cache(async (req: PayloadRequest): Promise => { + return req?.user?.collection + ? await req.payload + .find({ + collection: 'payload-preferences', + depth: 0, + limit: 1, + pagination: false, + req, + where: { + and: [ + { + key: { + equals: PREFERENCE_KEYS.NAV, + }, + }, + { + 'user.relationTo': { + equals: req.user.collection, + }, + }, + { + 'user.value': { + equals: req?.user?.id, + }, + }, + ], + }, + }) + ?.then((res) => res?.docs?.[0]?.value) + : null +}) diff --git a/packages/tanstack-start/src/views/Root/index.tsx b/packages/tanstack-start/src/views/Root/index.tsx new file mode 100644 index 00000000000..4fa5f3d1b26 --- /dev/null +++ b/packages/tanstack-start/src/views/Root/index.tsx @@ -0,0 +1,46 @@ +import type { ImportMap, SanitizedConfig } from 'payload' + +import { notFound, redirect } from '@tanstack/react-router' + +import { initReq } from '../../utilities/initReq.js' + +type Props = { + config: Promise | SanitizedConfig + importMap: ImportMap + searchParams: Record + segments: string[] +} + +/** + * TanStack Start admin page renderer. + * Equivalent of RootPage from @payloadcms/next/views. + */ +export async function RootPage({ + config: configPromise, + importMap, + searchParams, + segments, +}: Props) { + const { renderRootPage } = await import('@payloadcms/ui/views/Root/RenderRoot') + + const initPageResult = await initReq({ + config: configPromise, + importMap, + key: 'initPage', + }) + + return renderRootPage({ + importMap, + initPageResult, + notFound: () => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw notFound() + }, + redirect: (url: string) => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw redirect({ to: url }) + }, + searchParams, + segments, + }) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c7e5c3518e..a480170e499 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1701,6 +1701,9 @@ importers: packages/tanstack-start: dependencies: + '@payloadcms/next': + specifier: workspace:* + version: link:../next '@payloadcms/translations': specifier: workspace:* version: link:../translations @@ -1884,6 +1887,40 @@ importers: specifier: workspace:* version: link:../payload + tanstack-app: + dependencies: + '@payloadcms/tanstack-start': + specifier: workspace:* + version: link:../packages/tanstack-start + '@payloadcms/ui': + specifier: workspace:* + version: link:../packages/ui + '@tanstack/react-router': + specifier: ^1.0.0 + version: 1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/start': + specifier: ^1.0.0 + version: 1.120.20(e1410331fba1110c79d7bc05e246b742) + payload: + specifier: workspace:* + version: link:../packages/payload + react: + specifier: 19.2.4 + version: 19.2.4 + react-dom: + specifier: 19.2.4 + version: 19.2.4(react@19.2.4) + vinxi: + specifier: ^0.4.3 + version: 0.4.3(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1) + devDependencies: + typescript: + specifier: 5.7.3 + version: 5.7.3 + vite-tsconfig-paths: + specifier: ^5.0.0 + version: 5.1.4(typescript@5.7.3)(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + templates/blank: dependencies: '@payloadcms/db-mongodb': @@ -4070,6 +4107,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -4112,6 +4155,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -4154,6 +4203,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -4196,6 +4251,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -4238,6 +4299,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -4280,6 +4347,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -4322,6 +4395,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -4364,6 +4443,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -4406,6 +4491,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -4448,6 +4539,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -4490,6 +4587,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -4532,6 +4635,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -4574,6 +4683,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -4616,6 +4731,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -4658,6 +4779,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -4700,6 +4827,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -4742,6 +4875,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -4814,6 +4953,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -4886,6 +5031,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -4952,6 +5103,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -4994,6 +5151,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -5036,6 +5199,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -5078,6 +5247,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -10097,6 +10272,14 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crossws@0.2.4: + resolution: {integrity: sha512-DAxroI2uSOgUKLz00NX6A8U/8EE3SZHmIND+10jkVSaypvyt57J5JEOxAQOL6lQxyzi/wZbTIwssU1uy69h5Vg==} + peerDependencies: + uWebSockets.js: '*' + peerDependenciesMeta: + uWebSockets.js: + optional: true + crossws@0.3.5: resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} @@ -10732,6 +10915,11 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -11673,6 +11861,9 @@ packages: resolution: {integrity: sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + h3@1.11.1: + resolution: {integrity: sha512-AbaH6IDnZN6nmbnJOH72y3c5Wwh9P97soSVdGSBbcDACRdkC0FEWf25pzx4f/NuOCK6quHmW18yF2Wx+G4Zi1A==} + h3@1.13.0: resolution: {integrity: sha512-vFEAu/yf8UMUcB4s43OaDaigcqpQd14yanmOsn+NcRX3/guSKncyE2rOYhq8RIchgJrPSs/QiIddnTTR1ddiAg==} @@ -15552,6 +15743,10 @@ packages: victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vinxi@0.4.3: + resolution: {integrity: sha512-RgJz7RWftML5h/qfPsp3QKVc2FSlvV4+HevpE0yEY2j+PS/I2ULjoSsZDXaR8Ks2WYuFFDzQr8yrox7v8aqkng==} + hasBin: true + vinxi@0.5.11: resolution: {integrity: sha512-82Qm+EG/b2PRFBvXBbz1lgWBGcd9totIL6SJhnrZYfakjloTVG9+5l6gfO6dbCCtztm5pqWFzLY0qpZ3H3ww/w==} hasBin: true @@ -15560,11 +15755,50 @@ packages: resolution: {integrity: sha512-4sL2SMrRzdzClapP44oXdGjCE1oq7/DagsbjY5A09EibmoIO4LP8ScRVdh03lfXxKRk7nCWK7n7dqKvm+fp/9w==} hasBin: true + vite-tsconfig-paths@5.1.4: + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + vite-tsconfig-paths@6.0.5: resolution: {integrity: sha512-f/WvY6ekHykUF1rWJUAbCU7iS/5QYDIugwpqJA+ttwKbxSbzNlqlE8vZSrsnxNQciUW+z6lvhlXMaEyZn9MSig==} peerDependencies: vite: '*' + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@6.4.1: resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -18525,6 +18759,9 @@ snapshots: '@esbuild/aix-ppc64@0.20.2': optional: true + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -18546,6 +18783,9 @@ snapshots: '@esbuild/android-arm64@0.20.2': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true @@ -18567,6 +18807,9 @@ snapshots: '@esbuild/android-arm@0.20.2': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.25.12': optional: true @@ -18588,6 +18831,9 @@ snapshots: '@esbuild/android-x64@0.20.2': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.25.12': optional: true @@ -18609,6 +18855,9 @@ snapshots: '@esbuild/darwin-arm64@0.20.2': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true @@ -18630,6 +18879,9 @@ snapshots: '@esbuild/darwin-x64@0.20.2': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true @@ -18651,6 +18903,9 @@ snapshots: '@esbuild/freebsd-arm64@0.20.2': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true @@ -18672,6 +18927,9 @@ snapshots: '@esbuild/freebsd-x64@0.20.2': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true @@ -18693,6 +18951,9 @@ snapshots: '@esbuild/linux-arm64@0.20.2': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true @@ -18714,6 +18975,9 @@ snapshots: '@esbuild/linux-arm@0.20.2': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true @@ -18735,6 +18999,9 @@ snapshots: '@esbuild/linux-ia32@0.20.2': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true @@ -18756,6 +19023,9 @@ snapshots: '@esbuild/linux-loong64@0.20.2': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true @@ -18777,6 +19047,9 @@ snapshots: '@esbuild/linux-mips64el@0.20.2': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true @@ -18798,6 +19071,9 @@ snapshots: '@esbuild/linux-ppc64@0.20.2': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true @@ -18819,6 +19095,9 @@ snapshots: '@esbuild/linux-riscv64@0.20.2': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true @@ -18840,6 +19119,9 @@ snapshots: '@esbuild/linux-s390x@0.20.2': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true @@ -18861,6 +19143,9 @@ snapshots: '@esbuild/linux-x64@0.20.2': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true @@ -18897,6 +19182,9 @@ snapshots: '@esbuild/netbsd-x64@0.20.2': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true @@ -18933,6 +19221,9 @@ snapshots: '@esbuild/openbsd-x64@0.20.2': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true @@ -18966,6 +19257,9 @@ snapshots: '@esbuild/sunos-x64@0.20.2': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true @@ -18987,6 +19281,9 @@ snapshots: '@esbuild/win32-arm64@0.20.2': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true @@ -19008,6 +19305,9 @@ snapshots: '@esbuild/win32-ia32@0.20.2': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true @@ -19029,6 +19329,9 @@ snapshots: '@esbuild/win32-x64@0.20.2': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true @@ -25351,6 +25654,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crossws@0.2.4: {} + crossws@0.3.5: dependencies: uncrypto: 0.1.3 @@ -25960,6 +26265,32 @@ snapshots: '@esbuild/win32-ia32': 0.20.2 '@esbuild/win32-x64': 0.20.2 + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -27397,6 +27728,21 @@ snapshots: dependencies: duplexer: 0.1.2 + h3@1.11.1: + dependencies: + cookie-es: 1.2.2 + crossws: 0.2.4 + defu: 6.1.4 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + ohash: 1.1.6 + radix3: 1.1.2 + ufo: 1.6.3 + uncrypto: 0.1.3 + unenv: 1.10.0 + transitivePeerDependencies: + - uWebSockets.js + h3@1.13.0: dependencies: cookie-es: 1.2.2 @@ -31839,6 +32185,86 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + vinxi@0.4.3(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1): + dependencies: + '@babel/core': 7.27.3 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.27.3) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.27.3) + '@types/micromatch': 4.0.10 + '@vinxi/listhen': 1.5.6 + boxen: 7.1.1 + chokidar: 3.6.0 + citty: 0.1.6 + consola: 3.4.2 + crossws: 0.2.4 + dax-sh: 0.39.2 + defu: 6.1.4 + es-module-lexer: 1.7.0 + esbuild: 0.20.2 + fast-glob: 3.3.3 + get-port-please: 3.2.0 + h3: 1.11.1 + hookable: 5.5.3 + http-proxy: 1.18.1 + micromatch: 4.0.8 + nitropack: 2.13.2(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)) + node-fetch-native: 1.6.7 + path-to-regexp: 6.3.0 + pathe: 1.1.2 + radix3: 1.1.2 + resolve: 1.22.11 + serve-placeholder: 2.0.2 + serve-static: 1.16.3 + ufo: 1.6.3 + unctx: 2.5.0 + unenv: 1.10.0 + unstorage: 1.17.5(@azure/storage-blob@12.31.0)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(ioredis@5.10.1) + vite: 5.4.21(@types/node@22.19.9)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1) + zod: 3.25.76 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bare-abort-controller + - bare-buffer + - better-sqlite3 + - db0 + - debug + - drizzle-orm + - encoding + - idb-keyval + - ioredis + - less + - lightningcss + - mysql2 + - react-native-b4a + - rolldown + - sass + - sass-embedded + - sqlite3 + - stylus + - sugarss + - supports-color + - terser + - uWebSockets.js + - uploadthing + - xml2js + vinxi@0.5.11(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: '@babel/core': 7.27.3 @@ -32003,6 +32429,17 @@ snapshots: - xml2js - yaml + vite-tsconfig-paths@5.1.4(typescript@5.7.3)(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@5.7.3) + optionalDependencies: + vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - supports-color + - typescript + vite-tsconfig-paths@6.0.5(typescript@5.7.3)(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: debug: 4.4.3 @@ -32013,6 +32450,19 @@ snapshots: - supports-color - typescript + vite@5.4.21(@types/node@22.19.9)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.8 + rollup: 4.59.0 + optionalDependencies: + '@types/node': 22.19.9 + fsevents: 2.3.3 + lightningcss: 1.30.2 + sass: 1.98.0 + sass-embedded: 1.98.0 + terser: 5.46.1 + vite@6.4.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.25.12 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b44e0eba8be..547934ab002 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - 'packages/*' + - 'tanstack-app' - 'tools/*' - 'test' - 'templates/blank' diff --git a/tanstack-app/app.config.ts b/tanstack-app/app.config.ts new file mode 100644 index 00000000000..816e4f9852c --- /dev/null +++ b/tanstack-app/app.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from '@tanstack/start/config' +import tsConfigPaths from 'vite-tsconfig-paths' + +export default defineConfig({ + tsr: { + appDirectory: './app', + }, + vite: { + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + ], + }, +}) diff --git a/tanstack-app/app/client.tsx b/tanstack-app/app/client.tsx new file mode 100644 index 00000000000..47419f50a7f --- /dev/null +++ b/tanstack-app/app/client.tsx @@ -0,0 +1,14 @@ +import { StartClient } from '@tanstack/start' +import { StrictMode } from 'react' +import { hydrateRoot } from 'react-dom/client' + +import { createRouter } from './router.js' + +const router = createRouter() + +hydrateRoot( + document, + + + , +) diff --git a/tanstack-app/app/importMap.ts b/tanstack-app/app/importMap.ts new file mode 100644 index 00000000000..e68f7450ec0 --- /dev/null +++ b/tanstack-app/app/importMap.ts @@ -0,0 +1,3 @@ +// This file is auto-generated on dev start by the runInit script. +// Do not edit manually. +export const importMap = {} diff --git a/tanstack-app/app/routeTree.gen.ts b/tanstack-app/app/routeTree.gen.ts new file mode 100644 index 00000000000..9cfe5d11952 --- /dev/null +++ b/tanstack-app/app/routeTree.gen.ts @@ -0,0 +1,9 @@ +// This file is auto-generated by @tanstack/router-plugin. +// Do not edit manually. +// See https://tanstack.com/router/latest/docs/framework/react/routing/file-based-routing + +import { rootRoute } from './routes/__root.js' + +// NOTE: This placeholder will be replaced by the TanStack Router CLI codegen. +// Until then, only the root route is registered. +export const routeTree = rootRoute diff --git a/tanstack-app/app/router.tsx b/tanstack-app/app/router.tsx new file mode 100644 index 00000000000..3a07dd67046 --- /dev/null +++ b/tanstack-app/app/router.tsx @@ -0,0 +1,13 @@ +import { createRouter as createTanStackRouter } from '@tanstack/react-router' + +import { routeTree } from './routeTree.gen.js' + +export function createRouter() { + return createTanStackRouter({ routeTree }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/tanstack-app/app/routes/__root.tsx b/tanstack-app/app/routes/__root.tsx new file mode 100644 index 00000000000..af0ab084f75 --- /dev/null +++ b/tanstack-app/app/routes/__root.tsx @@ -0,0 +1,73 @@ +import type { ServerFunctionClient } from 'payload' + +import config from '@payload-config' +import { RootLayout } from '@payloadcms/tanstack-start' +import { dispatchServerFunction } from '@payloadcms/ui/utilities/handleServerFunctions' +import { createRootRoute, HeadContent, Outlet, Scripts } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/start' +import React from 'react' + +import { importMap } from '../importMap.js' +import { initReq } from '@payloadcms/tanstack-start/utilities/initReq' + +// Server function: handles all Payload server function calls +const handleServerFn = createServerFn() + .validator((data: unknown) => data as Parameters[0]) + .handler(async ({ data: args }) => { + const { notFound, redirect } = await import('@tanstack/react-router') + const { cookies, locale, permissions, req } = await initReq({ + config, + importMap, + key: 'RootLayout', + }) + return dispatchServerFunction({ + augmentedArgs: { + ...args, + cookies, + importMap, + locale, + notFound: () => { + throw notFound() + }, + permissions, + redirect: (url: string) => { + throw redirect({ to: url }) + }, + req, + }, + extraServerFunctions: (args as any)?.serverFunctions, + name: (args as any)?.name, + }) + }) + +// Thin wrapper matching Payload's ServerFunctionClient signature +const serverFunction: ServerFunctionClient = (args) => handleServerFn({ data: args }) + +export const rootRoute = createRootRoute({ + component: RootComponent, + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + ], + }), +}) + +// Export the Route object required by TanStack Router file-based routing +export const Route = rootRoute + +function RootComponent() { + return ( + + + + + + + + + + + + ) +} diff --git a/tanstack-app/app/routes/admin.$.tsx b/tanstack-app/app/routes/admin.$.tsx new file mode 100644 index 00000000000..be36dc87ada --- /dev/null +++ b/tanstack-app/app/routes/admin.$.tsx @@ -0,0 +1,25 @@ +import config from '@payload-config' +import { RootPage } from '@payloadcms/tanstack-start/views' +import { createFileRoute } from '@tanstack/react-router' +import React from 'react' + +import { importMap } from '../importMap.js' + +export const Route = createFileRoute('/admin/$')({ + component: AdminPage, +}) + +function AdminPage() { + const params = Route.useParams() + const search = Route.useSearch() + const segments = params._splat?.split('/').filter(Boolean) ?? [] + + return ( + } + /> + ) +} diff --git a/tanstack-app/app/routes/api.$.ts b/tanstack-app/app/routes/api.$.ts new file mode 100644 index 00000000000..d00558022ca --- /dev/null +++ b/tanstack-app/app/routes/api.$.ts @@ -0,0 +1,17 @@ +import config from '@payload-config' +import { + REST_DELETE, + REST_GET, + REST_OPTIONS, + REST_PATCH, + REST_POST, +} from '@payloadcms/tanstack-start/routes' +import { createAPIFileRoute } from '@tanstack/start/api' + +export const APIRoute = createAPIFileRoute('/api/$')({ + DELETE: ({ request }) => REST_DELETE(config)(request), + GET: ({ request }) => REST_GET(config)(request), + OPTIONS: ({ request }) => REST_OPTIONS(config)(request), + PATCH: ({ request }) => REST_PATCH(config)(request), + POST: ({ request }) => REST_POST(config)(request), +}) diff --git a/tanstack-app/app/ssr.tsx b/tanstack-app/app/ssr.tsx new file mode 100644 index 00000000000..93ddfba8f1f --- /dev/null +++ b/tanstack-app/app/ssr.tsx @@ -0,0 +1,5 @@ +import { createStartHandler, defaultStreamHandler } from '@tanstack/start/server' + +import { createRouter } from './router.js' + +export default createStartHandler({ createRouter })(defaultStreamHandler) diff --git a/tanstack-app/package.json b/tanstack-app/package.json new file mode 100644 index 00000000000..9d636c671e0 --- /dev/null +++ b/tanstack-app/package.json @@ -0,0 +1,24 @@ +{ + "name": "payload-tanstack-app", + "private": true, + "type": "module", + "scripts": { + "build": "vinxi build", + "dev": "vinxi dev", + "start": "vinxi start" + }, + "dependencies": { + "@payloadcms/tanstack-start": "workspace:*", + "@payloadcms/ui": "workspace:*", + "@tanstack/react-router": "^1.0.0", + "@tanstack/start": "^1.0.0", + "payload": "workspace:*", + "react": "19.1.0", + "react-dom": "19.1.0", + "vinxi": "^0.4.3" + }, + "devDependencies": { + "typescript": "5.x", + "vite-tsconfig-paths": "^5.0.0" + } +} diff --git a/tanstack-app/tsconfig.json b/tanstack-app/tsconfig.json new file mode 100644 index 00000000000..e2417810218 --- /dev/null +++ b/tanstack-app/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "strict": false, + "baseUrl": ".", + "paths": { + "@payload-config": ["../test/_community/config.ts"] + } + }, + "include": ["app/**/*", "app.config.ts"] +} diff --git a/test/dev-tanstack.ts b/test/dev-tanstack.ts new file mode 100644 index 00000000000..729db0d41f4 --- /dev/null +++ b/test/dev-tanstack.ts @@ -0,0 +1,53 @@ +import chalk from 'chalk' +import { execa } from 'execa' +import minimist from 'minimist' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { loadEnv } from 'payload/node' + +import { runInit } from './runInit.js' + +loadEnv() + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +const { + _: [_testSuiteArg = '_community'], +} = minimist(process.argv.slice(2)) + +const testSuiteArg = _testSuiteArg + +console.log(`Selected test suite: ${testSuiteArg} [TanStack Start / Vinxi]`) + +const tanstackAppDir = path.resolve(dirname, '..', 'tanstack-app') + +// Point ROOT_DIR to the TanStack app so initDevAndTest writes to the right importMap location +process.env.ROOT_DIR = tanstackAppDir + +await runInit(testSuiteArg, true, false) + +const port = process.env.PORT ? Number(process.env.PORT) : 3100 + +const vinxiProcess = execa('pnpm', ['vinxi', 'dev', '--port', String(port)], { + cwd: tanstackAppDir, + env: { + ...process.env, + PORT: String(port), + }, + stdio: 'inherit', +}) + +console.log(chalk.green(`✓ TanStack Start dev server starting on port ${port}`)) +console.log(chalk.cyan(` Admin: http://localhost:${port}/admin`)) + +process.on('SIGINT', () => { + vinxiProcess.kill('SIGINT') + process.exit(0) +}) +process.on('SIGTERM', () => { + vinxiProcess.kill('SIGTERM') + process.exit(0) +}) + +await vinxiProcess diff --git a/test/initDevAndTest.ts b/test/initDevAndTest.ts index 5f2cf0ec9b0..7915430dabb 100644 --- a/test/initDevAndTest.ts +++ b/test/initDevAndTest.ts @@ -19,10 +19,14 @@ export async function initDevAndTest( skipGenImportMap: string, configFile?: string, ): Promise { - const importMapPath: string = path.resolve( - getNextRootDir(testSuiteArg).rootDir, - './app/(payload)/admin/importMap.js', - ) + // Determine target root dir — env var takes precedence (set by dev-tanstack.ts) + const targetRootDir = process.env.ROOT_DIR ?? getNextRootDir(testSuiteArg).rootDir + // TanStack Start apps have app.config.ts; Next.js apps have app/(payload) structure + const isTanstackApp = fs.existsSync(path.resolve(targetRootDir, 'app.config.ts')) + const importMapRelativePath = isTanstackApp + ? './app/importMap.js' + : './app/(payload)/admin/importMap.js' + const importMapPath: string = path.resolve(targetRootDir, importMapRelativePath) try { fs.writeFileSync(importMapPath, 'export const importMap = {}') @@ -48,7 +52,10 @@ export async function initDevAndTest( const configUrl = pathToFileURL(path.resolve(testDir, configFile ?? 'config.ts')).href const config: SanitizedConfig = await (await import(configUrl)).default - process.env.ROOT_DIR = getNextRootDir(testSuiteArg).rootDir + // Only set ROOT_DIR if not already set (dev-tanstack.ts may have set it) + if (!process.env.ROOT_DIR) { + process.env.ROOT_DIR = getNextRootDir(testSuiteArg).rootDir + } await generateImportMap(config, { log: true, force: true }) From 1aaf4e5cadd6bd51ce82ab6b0485960c15242bc1 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 23:23:35 +0100 Subject: [PATCH 26/60] feat(tanstack-start): fix Phase 6 for @tanstack/react-start@1.167 API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch tanstack-app from Vinxi/app.config.ts to Vite/vite.config.ts - Use @tanstack/react-start (not @tanstack/start) with @vitejs/plugin-react - Update initReq: getWebRequest() → getRequest() from @tanstack/react-start/server - Update adapter/RootLayout: vinxi/http cookies → @tanstack/react-start/server - Update createServerFn API: validator → inputValidator, new ssr.tsx signature - Add getRouter() function (replaces createRouter()) as required by plugin - Add scss tilde import alias, sharp optimizeDeps.exclude, sass devDependency - Add @payloadcms/next and @payloadcms/richtext-lexical to tanstack-app deps - Fix initDevAndTest.ts importMap path for TanStack apps - Smoke test: pnpm dev:tanstack serves HTTP 200 at /admin Co-Authored-By: Claude Sonnet 4.6 (1M context) --- packages/tanstack-start/src/adapter/index.ts | 2 +- .../tanstack-start/src/layouts/Root/index.tsx | 2 +- .../tanstack-start/src/utilities/initReq.ts | 6 +- pnpm-lock.yaml | 697 +++++------------- tanstack-app/.gitignore | 6 + tanstack-app/app.config.ts | 2 +- tanstack-app/app/client.tsx | 6 +- tanstack-app/app/routeTree.gen.ts | 5 - tanstack-app/app/router.tsx | 9 +- tanstack-app/app/routes/__root.tsx | 11 +- tanstack-app/app/ssr.tsx | 6 +- tanstack-app/package.json | 18 +- tanstack-app/tsconfig.json | 5 +- tanstack-app/vite.config.ts | 38 + test/dev-tanstack.ts | 21 +- test/initDevAndTest.ts | 8 + 16 files changed, 296 insertions(+), 546 deletions(-) create mode 100644 tanstack-app/.gitignore create mode 100644 tanstack-app/vite.config.ts diff --git a/packages/tanstack-start/src/adapter/index.ts b/packages/tanstack-start/src/adapter/index.ts index 92e0be4d406..68d4408689a 100644 --- a/packages/tanstack-start/src/adapter/index.ts +++ b/packages/tanstack-start/src/adapter/index.ts @@ -1,8 +1,8 @@ import type { AdminAdapterResult, BaseAdminAdapter, CookieOptions } from 'payload' import { notFound, redirect } from '@tanstack/react-router' +import { deleteCookie, getCookie, setCookie } from '@tanstack/react-start/server' import { createAdminAdapter } from 'payload' -import { deleteCookie, getCookie, setCookie } from 'vinxi/http' import { handleServerFunctions } from '../utilities/handleServerFunctions.js' import { initReq } from '../utilities/initReq.js' diff --git a/packages/tanstack-start/src/layouts/Root/index.tsx b/packages/tanstack-start/src/layouts/Root/index.tsx index c723ff94c58..336be4a6654 100644 --- a/packages/tanstack-start/src/layouts/Root/index.tsx +++ b/packages/tanstack-start/src/layouts/Root/index.tsx @@ -2,9 +2,9 @@ import type { ImportMap, LanguageOptions, SanitizedConfig, ServerFunctionClient import { defaultTheme, ProgressBar, RootProvider } from '@payloadcms/ui' import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig' +import { setCookie } from '@tanstack/react-start/server' import { applyLocaleFiltering } from 'payload/shared' import React from 'react' -import { setCookie } from 'vinxi/http' import { TanStackRouterProvider } from '../../adapter/RouterProvider.js' import { getNavPrefs } from '../../utilities/getNavPrefs.js' diff --git a/packages/tanstack-start/src/utilities/initReq.ts b/packages/tanstack-start/src/utilities/initReq.ts index 5484c63fa46..5ab04d3b66a 100644 --- a/packages/tanstack-start/src/utilities/initReq.ts +++ b/packages/tanstack-start/src/utilities/initReq.ts @@ -4,6 +4,7 @@ import type { ImportMap, InitReqResult, PayloadRequest, SanitizedConfig } from ' import { initI18n } from '@payloadcms/translations' import { getRequestLocale } from '@payloadcms/ui/utilities/getRequestLocale' import { selectiveCache } from '@payloadcms/ui/utilities/selectiveCache' +import { getRequest } from '@tanstack/react-start/server' import { createLocalReq, executeAuthStrategies, @@ -12,7 +13,6 @@ import { getRequestLanguage, parseCookies, } from 'payload' -import { getWebRequest } from 'vinxi/http' type PartialResult = { i18n: I18nClient @@ -31,8 +31,8 @@ export const initReq = async function ({ importMap: ImportMap key?: string }): Promise { - // getWebRequest() returns the current server request from Vinxi's context store - const request = getWebRequest() + // getRequest() returns the current server request from TanStack Start's context store + const request = getRequest() const headers = new Headers(request.headers) const cookies = parseCookies(headers) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a480170e499..39cc70a1ba7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,7 +147,7 @@ importers: version: 8.15.1(@aws-sdk/credential-providers@3.1014.0)(socks@2.8.7) next: specifier: 16.2.1 - version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + version: 16.2.1(@babel/core@7.27.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) node-gyp: specifier: 12.2.0 version: 12.2.0 @@ -1889,6 +1889,12 @@ importers: tanstack-app: dependencies: + '@payloadcms/next': + specifier: workspace:* + version: link:../packages/next + '@payloadcms/richtext-lexical': + specifier: workspace:* + version: link:../packages/richtext-lexical '@payloadcms/tanstack-start': specifier: workspace:* version: link:../packages/tanstack-start @@ -1896,11 +1902,11 @@ importers: specifier: workspace:* version: link:../packages/ui '@tanstack/react-router': - specifier: ^1.0.0 + specifier: ^1.167.0 version: 1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/start': - specifier: ^1.0.0 - version: 1.120.20(e1410331fba1110c79d7bc05e246b742) + '@tanstack/react-start': + specifier: ^1.167.0 + version: 1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.77.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12)) payload: specifier: workspace:* version: link:../packages/payload @@ -1910,16 +1916,22 @@ importers: react-dom: specifier: 19.2.4 version: 19.2.4(react@19.2.4) - vinxi: - specifier: ^0.4.3 - version: 0.4.3(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1) devDependencies: + '@vitejs/plugin-react': + specifier: ^4.0.0 + version: 4.5.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.77.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + sass: + specifier: ^1.0.0 + version: 1.77.4 typescript: specifier: 5.7.3 version: 5.7.3 + vite: + specifier: ^7.0.0 + version: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.77.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite-tsconfig-paths: specifier: ^5.0.0 - version: 5.1.4(typescript@5.7.3)(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 5.1.4(typescript@5.7.3)(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.77.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) templates/blank: dependencies: @@ -1946,7 +1958,7 @@ importers: version: 16.8.1 next: specifier: 16.2.1 - version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + version: 16.2.1(@babel/core@7.27.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) payload: specifier: workspace:* version: link:../../packages/payload @@ -2097,7 +2109,7 @@ importers: version: 0.563.0(react@19.2.4) next: specifier: 16.2.1 - version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + version: 16.2.1(@babel/core@7.27.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) next-themes: specifier: 0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -2287,7 +2299,7 @@ importers: version: 0.563.0(react@19.2.4) next: specifier: 16.2.1 - version: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + version: 16.2.1(@babel/core@7.27.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) next-sitemap: specifier: ^4.2.3 version: 4.2.3(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0)) @@ -2610,7 +2622,7 @@ importers: version: 8.15.1(@aws-sdk/credential-providers@3.1014.0)(socks@2.8.7) next: specifier: 16.2.1 - version: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4) + version: 16.2.1(@babel/core@7.27.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4) nodemailer: specifier: 7.0.12 version: 7.0.12 @@ -4107,12 +4119,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -4155,12 +4161,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -4203,12 +4203,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -4251,12 +4245,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -4299,12 +4287,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -4347,12 +4329,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -4395,12 +4371,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -4443,12 +4413,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -4491,12 +4455,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -4539,12 +4497,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -4587,12 +4539,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -4635,12 +4581,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -4683,12 +4623,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -4731,12 +4665,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -4779,12 +4707,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -4827,12 +4749,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -4875,12 +4791,6 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -4953,12 +4863,6 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -5031,12 +4935,6 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -5103,12 +5001,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -5151,12 +5043,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -5199,12 +5085,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -5247,12 +5127,6 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -6230,14 +6104,30 @@ packages: resolution: {integrity: sha512-0JT29/LaxVgRcGKvHmSrUTEvZ8BXvZhGl2LASRUgHqDTC1M5g1pLmVv56IYNyt3bG2CUjDkc67wnyZC14pbQrQ==} engines: {node: '>=8.0'} + '@oozcitak/dom@2.0.2': + resolution: {integrity: sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==} + engines: {node: '>=20.0'} + '@oozcitak/infra@1.0.8': resolution: {integrity: sha512-JRAUc9VR6IGHOL7OGF+yrvs0LO8SlqGnPAMqyzOuFZPSZSXI7Xf2O9+awQPSMXgIWGtgUf/dA6Hs6X6ySEaWTg==} engines: {node: '>=6.0'} + '@oozcitak/infra@2.0.2': + resolution: {integrity: sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA==} + engines: {node: '>=20.0'} + '@oozcitak/url@1.0.4': resolution: {integrity: sha512-kDcD8y+y3FCSOvnBI6HJgl00viO/nGbQoCINmQ0h98OhnGITrWR3bOGfwYCthgcrV8AnTJz8MzslTQbC3SOAmw==} engines: {node: '>=8.0'} + '@oozcitak/url@3.0.0': + resolution: {integrity: sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ==} + engines: {node: '>=20.0'} + + '@oozcitak/util@10.0.0': + resolution: {integrity: sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==} + engines: {node: '>=20.0'} + '@oozcitak/util@8.3.8': resolution: {integrity: sha512-T8TbSnGsxo6TDBJx/Sgv/BlVJL3tshxZP7Aq5R1mSnM5OcHY2dQaxLMu2+E8u3gN0MLOzdjurqN4ZRVuzQycOQ==} engines: {node: '>=8.0'} @@ -7261,6 +7151,9 @@ packages: '@rolldown/pluginutils@1.0.0-beta.11': resolution: {integrity: sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag==} + '@rolldown/pluginutils@1.0.0-beta.40': + resolution: {integrity: sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w==} + '@rollup/plugin-alias@6.0.0': resolution: {integrity: sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g==} engines: {node: '>=20.19.0'} @@ -8429,6 +8322,15 @@ packages: react: 19.2.4 react-dom: 19.2.4 + '@tanstack/react-start@1.167.16': + resolution: {integrity: sha512-vHIhn+FTWfAVhRus1BZEaBZPhnYL+StDuMlShslIBPEGGTCRt11BxNUfV/iDpr7zbxw36Snj7zGfI7DwfjjlDQ==} + engines: {node: '>=22.12.0'} + hasBin: true + peerDependencies: + react: 19.2.4 + react-dom: 19.2.4 + vite: '>=7.0.0' + '@tanstack/react-store@0.9.3': resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} peerDependencies: @@ -8546,6 +8448,12 @@ packages: peerDependencies: vite: '>=6.0.0' + '@tanstack/start-plugin-core@1.167.17': + resolution: {integrity: sha512-OkorpOobGOEDVr72QUmkzKjbawKC05CSz+1B3OObB/AxBIIw+lLLhTXbV45QkX2LZA7dcRvPJYZGOH1pkFqA1g==} + engines: {node: '>=22.12.0'} + peerDependencies: + vite: '>=7.0.0' + '@tanstack/start-server-core@1.131.50': resolution: {integrity: sha512-3SWwwhW2GKMhPSaqWRal6Jj1Y9ObfdWEXKFQid1LBuk5xk/Es4bmW68o++MbVgs/GxUxyeZ3TRVqb0c7RG1sog==} engines: {node: '>=12'} @@ -10272,14 +10180,6 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - crossws@0.2.4: - resolution: {integrity: sha512-DAxroI2uSOgUKLz00NX6A8U/8EE3SZHmIND+10jkVSaypvyt57J5JEOxAQOL6lQxyzi/wZbTIwssU1uy69h5Vg==} - peerDependencies: - uWebSockets.js: '*' - peerDependenciesMeta: - uWebSockets.js: - optional: true - crossws@0.3.5: resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} @@ -10915,11 +10815,6 @@ packages: engines: {node: '>=12'} hasBin: true - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} - hasBin: true - esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -11861,9 +11756,6 @@ packages: resolution: {integrity: sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - h3@1.11.1: - resolution: {integrity: sha512-AbaH6IDnZN6nmbnJOH72y3c5Wwh9P97soSVdGSBbcDACRdkC0FEWf25pzx4f/NuOCK6quHmW18yF2Wx+G4Zi1A==} - h3@1.13.0: resolution: {integrity: sha512-vFEAu/yf8UMUcB4s43OaDaigcqpQd14yanmOsn+NcRX3/guSKncyE2rOYhq8RIchgJrPSs/QiIddnTTR1ddiAg==} @@ -15743,10 +15635,6 @@ packages: victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} - vinxi@0.4.3: - resolution: {integrity: sha512-RgJz7RWftML5h/qfPsp3QKVc2FSlvV4+HevpE0yEY2j+PS/I2ULjoSsZDXaR8Ks2WYuFFDzQr8yrox7v8aqkng==} - hasBin: true - vinxi@0.5.11: resolution: {integrity: sha512-82Qm+EG/b2PRFBvXBbz1lgWBGcd9totIL6SJhnrZYfakjloTVG9+5l6gfO6dbCCtztm5pqWFzLY0qpZ3H3ww/w==} hasBin: true @@ -15768,37 +15656,6 @@ packages: peerDependencies: vite: '*' - vite@5.4.21: - resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - vite@6.4.1: resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -16184,6 +16041,10 @@ packages: resolution: {integrity: sha512-WCSfbfZnQDdLQLiMdGUQpMxxckeQ4oZNMNhLVkcekTu7xhD4tuUDyAPoY8CwXvBYE6LwBHd6QW2WZXlOWr1vCw==} engines: {node: '>=12.0'} + xmlbuilder2@4.0.3: + resolution: {integrity: sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==} + engines: {node: '>=20.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -18759,9 +18620,6 @@ snapshots: '@esbuild/aix-ppc64@0.20.2': optional: true - '@esbuild/aix-ppc64@0.21.5': - optional: true - '@esbuild/aix-ppc64@0.25.12': optional: true @@ -18783,9 +18641,6 @@ snapshots: '@esbuild/android-arm64@0.20.2': optional: true - '@esbuild/android-arm64@0.21.5': - optional: true - '@esbuild/android-arm64@0.25.12': optional: true @@ -18807,9 +18662,6 @@ snapshots: '@esbuild/android-arm@0.20.2': optional: true - '@esbuild/android-arm@0.21.5': - optional: true - '@esbuild/android-arm@0.25.12': optional: true @@ -18831,9 +18683,6 @@ snapshots: '@esbuild/android-x64@0.20.2': optional: true - '@esbuild/android-x64@0.21.5': - optional: true - '@esbuild/android-x64@0.25.12': optional: true @@ -18855,9 +18704,6 @@ snapshots: '@esbuild/darwin-arm64@0.20.2': optional: true - '@esbuild/darwin-arm64@0.21.5': - optional: true - '@esbuild/darwin-arm64@0.25.12': optional: true @@ -18879,9 +18725,6 @@ snapshots: '@esbuild/darwin-x64@0.20.2': optional: true - '@esbuild/darwin-x64@0.21.5': - optional: true - '@esbuild/darwin-x64@0.25.12': optional: true @@ -18903,9 +18746,6 @@ snapshots: '@esbuild/freebsd-arm64@0.20.2': optional: true - '@esbuild/freebsd-arm64@0.21.5': - optional: true - '@esbuild/freebsd-arm64@0.25.12': optional: true @@ -18927,9 +18767,6 @@ snapshots: '@esbuild/freebsd-x64@0.20.2': optional: true - '@esbuild/freebsd-x64@0.21.5': - optional: true - '@esbuild/freebsd-x64@0.25.12': optional: true @@ -18951,9 +18788,6 @@ snapshots: '@esbuild/linux-arm64@0.20.2': optional: true - '@esbuild/linux-arm64@0.21.5': - optional: true - '@esbuild/linux-arm64@0.25.12': optional: true @@ -18975,9 +18809,6 @@ snapshots: '@esbuild/linux-arm@0.20.2': optional: true - '@esbuild/linux-arm@0.21.5': - optional: true - '@esbuild/linux-arm@0.25.12': optional: true @@ -18999,9 +18830,6 @@ snapshots: '@esbuild/linux-ia32@0.20.2': optional: true - '@esbuild/linux-ia32@0.21.5': - optional: true - '@esbuild/linux-ia32@0.25.12': optional: true @@ -19023,9 +18851,6 @@ snapshots: '@esbuild/linux-loong64@0.20.2': optional: true - '@esbuild/linux-loong64@0.21.5': - optional: true - '@esbuild/linux-loong64@0.25.12': optional: true @@ -19047,9 +18872,6 @@ snapshots: '@esbuild/linux-mips64el@0.20.2': optional: true - '@esbuild/linux-mips64el@0.21.5': - optional: true - '@esbuild/linux-mips64el@0.25.12': optional: true @@ -19071,9 +18893,6 @@ snapshots: '@esbuild/linux-ppc64@0.20.2': optional: true - '@esbuild/linux-ppc64@0.21.5': - optional: true - '@esbuild/linux-ppc64@0.25.12': optional: true @@ -19095,9 +18914,6 @@ snapshots: '@esbuild/linux-riscv64@0.20.2': optional: true - '@esbuild/linux-riscv64@0.21.5': - optional: true - '@esbuild/linux-riscv64@0.25.12': optional: true @@ -19119,9 +18935,6 @@ snapshots: '@esbuild/linux-s390x@0.20.2': optional: true - '@esbuild/linux-s390x@0.21.5': - optional: true - '@esbuild/linux-s390x@0.25.12': optional: true @@ -19143,9 +18956,6 @@ snapshots: '@esbuild/linux-x64@0.20.2': optional: true - '@esbuild/linux-x64@0.21.5': - optional: true - '@esbuild/linux-x64@0.25.12': optional: true @@ -19182,9 +18992,6 @@ snapshots: '@esbuild/netbsd-x64@0.20.2': optional: true - '@esbuild/netbsd-x64@0.21.5': - optional: true - '@esbuild/netbsd-x64@0.25.12': optional: true @@ -19221,9 +19028,6 @@ snapshots: '@esbuild/openbsd-x64@0.20.2': optional: true - '@esbuild/openbsd-x64@0.21.5': - optional: true - '@esbuild/openbsd-x64@0.25.12': optional: true @@ -19257,9 +19061,6 @@ snapshots: '@esbuild/sunos-x64@0.20.2': optional: true - '@esbuild/sunos-x64@0.21.5': - optional: true - '@esbuild/sunos-x64@0.25.12': optional: true @@ -19281,9 +19082,6 @@ snapshots: '@esbuild/win32-arm64@0.20.2': optional: true - '@esbuild/win32-arm64@0.21.5': - optional: true - '@esbuild/win32-arm64@0.25.12': optional: true @@ -19305,9 +19103,6 @@ snapshots: '@esbuild/win32-ia32@0.20.2': optional: true - '@esbuild/win32-ia32@0.21.5': - optional: true - '@esbuild/win32-ia32@0.25.12': optional: true @@ -19329,9 +19124,6 @@ snapshots: '@esbuild/win32-x64@0.20.2': optional: true - '@esbuild/win32-x64@0.21.5': - optional: true - '@esbuild/win32-x64@0.25.12': optional: true @@ -20426,15 +20218,32 @@ snapshots: '@oozcitak/url': 1.0.4 '@oozcitak/util': 8.3.8 + '@oozcitak/dom@2.0.2': + dependencies: + '@oozcitak/infra': 2.0.2 + '@oozcitak/url': 3.0.0 + '@oozcitak/util': 10.0.0 + '@oozcitak/infra@1.0.8': dependencies: '@oozcitak/util': 8.3.8 + '@oozcitak/infra@2.0.2': + dependencies: + '@oozcitak/util': 10.0.0 + '@oozcitak/url@1.0.4': dependencies: '@oozcitak/infra': 1.0.8 '@oozcitak/util': 8.3.8 + '@oozcitak/url@3.0.0': + dependencies: + '@oozcitak/infra': 2.0.2 + '@oozcitak/util': 10.0.0 + + '@oozcitak/util@10.0.0': {} + '@oozcitak/util@8.3.8': {} '@opennextjs/aws@3.9.14(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))': @@ -20453,7 +20262,7 @@ snapshots: cookie: 1.1.1 esbuild: 0.25.4 express: 5.2.1 - next: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4) + next: 16.2.1(@babel/core@7.27.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4) path-to-regexp: 6.3.0 urlpattern-polyfill: 10.1.0 yaml: 2.8.3 @@ -20469,7 +20278,7 @@ snapshots: cloudflare: 4.5.0 enquirer: 2.4.1 glob: 12.0.0 - next: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4) + next: 16.2.1(@babel/core@7.27.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4) ts-tqdm: 0.8.6 wrangler: 4.61.1(@cloudflare/workers-types@4.20260218.0)(bufferutil@4.1.0)(utf-8-validate@6.0.6) yargs: 18.0.0 @@ -21535,6 +21344,8 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.11': {} + '@rolldown/pluginutils@1.0.0-beta.40': {} + '@rollup/plugin-alias@6.0.0(rollup@4.59.0)': optionalDependencies: rollup: 4.59.0 @@ -21934,7 +21745,7 @@ snapshots: '@sentry/vercel-edge': 8.55.0 '@sentry/webpack-plugin': 2.22.7(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12)) chalk: 3.0.0 - next: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4) + next: 16.2.1(@babel/core@7.27.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4) resolve: 1.22.8 rollup: 3.29.5 stacktrace-parser: 0.1.11 @@ -21961,7 +21772,7 @@ snapshots: '@sentry/vercel-edge': 8.55.0 '@sentry/webpack-plugin': 2.22.7(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12)) chalk: 3.0.0 - next: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + next: 16.2.1(@babel/core@7.27.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) resolve: 1.22.8 rollup: 3.29.5 stacktrace-parser: 0.1.11 @@ -21988,7 +21799,7 @@ snapshots: '@sentry/vercel-edge': 9.47.1(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0)) '@sentry/webpack-plugin': 3.6.1(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12)) chalk: 3.0.0 - next: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + next: 16.2.1(@babel/core@7.27.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) resolve: 1.22.8 rollup: 4.59.0 stacktrace-parser: 0.1.11 @@ -23147,6 +22958,26 @@ snapshots: transitivePeerDependencies: - crossws + '@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.77.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12))': + dependencies: + '@tanstack/react-router': 1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-start-client': 1.166.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-start-server': 1.166.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-utils': 1.161.6 + '@tanstack/start-client-core': 1.167.9 + '@tanstack/start-plugin-core': 1.167.17(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.77.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12)) + '@tanstack/start-server-core': 1.167.9 + pathe: 2.0.3 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.77.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - '@rsbuild/core' + - crossws + - supports-color + - vite-plugin-solid + - webpack + '@tanstack/react-store@0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/store': 0.9.3 @@ -23220,6 +23051,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@tanstack/router-plugin@1.167.12(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.77.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@tanstack/router-core': 1.168.9 + '@tanstack/router-generator': 1.166.24 + '@tanstack/router-utils': 1.161.6 + '@tanstack/virtual-file-routes': 1.161.7 + chokidar: 3.6.0 + unplugin: 2.3.11 + zod: 3.25.76 + optionalDependencies: + '@tanstack/react-router': 1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.77.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + webpack: 5.105.4(@swc/core@1.15.3)(esbuild@0.25.12) + transitivePeerDependencies: + - supports-color + '@tanstack/router-plugin@1.167.12(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12))': dependencies: '@babel/core': 7.29.0 @@ -23507,6 +23360,38 @@ snapshots: - webpack - xml2js + '@tanstack/start-plugin-core@1.167.17(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.77.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12))': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/core': 7.29.0 + '@babel/types': 7.29.0 + '@rolldown/pluginutils': 1.0.0-beta.40 + '@tanstack/router-core': 1.168.9 + '@tanstack/router-generator': 1.166.24 + '@tanstack/router-plugin': 1.167.12(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.77.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12)) + '@tanstack/router-utils': 1.161.6 + '@tanstack/start-client-core': 1.167.9 + '@tanstack/start-server-core': 1.167.9 + cheerio: 1.2.0 + exsolve: 1.0.8 + pathe: 2.0.3 + picomatch: 4.0.4 + source-map: 0.7.6 + srvx: 0.11.13 + tinyglobby: 0.2.15 + ufo: 1.6.3 + vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.77.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vitefu: 1.1.3(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.77.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + xmlbuilder2: 4.0.3 + zod: 3.25.76 + transitivePeerDependencies: + - '@rsbuild/core' + - '@tanstack/react-router' + - crossws + - supports-color + - vite-plugin-solid + - webpack + '@tanstack/start-server-core@1.131.50': dependencies: '@tanstack/history': 1.131.2 @@ -24333,6 +24218,18 @@ snapshots: untun: 0.1.3 uqr: 0.1.2 + '@vitejs/plugin-react@4.5.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.77.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.11 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.77.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - supports-color + '@vitejs/plugin-react@4.5.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 @@ -25654,8 +25551,6 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - crossws@0.2.4: {} - crossws@0.3.5: dependencies: uncrypto: 0.1.3 @@ -26265,32 +26160,6 @@ snapshots: '@esbuild/win32-ia32': 0.20.2 '@esbuild/win32-x64': 0.20.2 - esbuild@0.21.5: - optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 - esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -27478,7 +27347,7 @@ snapshots: geist@1.7.0(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0)): dependencies: - next: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + next: 16.2.1(@babel/core@7.27.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) generator-function@2.0.1: {} @@ -27728,21 +27597,6 @@ snapshots: dependencies: duplexer: 0.1.2 - h3@1.11.1: - dependencies: - cookie-es: 1.2.2 - crossws: 0.2.4 - defu: 6.1.4 - destr: 2.0.5 - iron-webcrypto: 1.2.1 - ohash: 1.1.6 - radix3: 1.1.2 - ufo: 1.6.3 - uncrypto: 0.1.3 - unenv: 1.10.0 - transitivePeerDependencies: - - uWebSockets.js - h3@1.13.0: dependencies: cookie-es: 1.2.2 @@ -28748,7 +28602,7 @@ snapshots: commander: 11.1.0 redis: 4.7.1 optionalDependencies: - next: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + next: 16.2.1(@babel/core@7.27.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) md5@2.3.0: dependencies: @@ -29252,7 +29106,7 @@ snapshots: '@next/env': 13.5.11 fast-glob: 3.3.3 minimist: 1.2.8 - next: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + next: 16.2.1(@babel/core@7.27.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: @@ -29315,88 +29169,6 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0): - dependencies: - '@next/env': 16.2.1 - '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.10 - caniuse-lite: 1.0.30001780 - postcss: 8.4.31 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) - optionalDependencies: - '@next/swc-darwin-arm64': 16.2.1 - '@next/swc-darwin-x64': 16.2.1 - '@next/swc-linux-arm64-gnu': 16.2.1 - '@next/swc-linux-arm64-musl': 16.2.1 - '@next/swc-linux-x64-gnu': 16.2.1 - '@next/swc-linux-x64-musl': 16.2.1 - '@next/swc-win32-arm64-msvc': 16.2.1 - '@next/swc-win32-x64-msvc': 16.2.1 - '@opentelemetry/api': 1.9.0 - '@playwright/test': 1.58.2 - sass: 1.98.0 - sharp: 0.34.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - - next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4): - dependencies: - '@next/env': 16.2.1 - '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.10 - caniuse-lite: 1.0.30001780 - postcss: 8.4.31 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) - optionalDependencies: - '@next/swc-darwin-arm64': 16.2.1 - '@next/swc-darwin-x64': 16.2.1 - '@next/swc-linux-arm64-gnu': 16.2.1 - '@next/swc-linux-arm64-musl': 16.2.1 - '@next/swc-linux-x64-gnu': 16.2.1 - '@next/swc-linux-x64-musl': 16.2.1 - '@next/swc-win32-arm64-msvc': 16.2.1 - '@next/swc-win32-x64-msvc': 16.2.1 - '@opentelemetry/api': 1.9.0 - '@playwright/test': 1.58.2 - babel-plugin-react-compiler: 19.1.0-rc.3 - sass: 1.77.4 - sharp: 0.34.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - - next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0): - dependencies: - '@next/env': 16.2.1 - '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.10 - caniuse-lite: 1.0.30001780 - postcss: 8.4.31 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) - optionalDependencies: - '@next/swc-darwin-arm64': 16.2.1 - '@next/swc-darwin-x64': 16.2.1 - '@next/swc-linux-arm64-gnu': 16.2.1 - '@next/swc-linux-arm64-musl': 16.2.1 - '@next/swc-linux-x64-gnu': 16.2.1 - '@next/swc-linux-x64-musl': 16.2.1 - '@next/swc-win32-arm64-msvc': 16.2.1 - '@next/swc-win32-x64-msvc': 16.2.1 - '@opentelemetry/api': 1.9.0 - '@playwright/test': 1.58.2 - sass: 1.98.0 - sharp: 0.34.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - nitropack@2.13.2(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 @@ -31408,13 +31180,6 @@ snapshots: optionalDependencies: '@babel/core': 7.27.3 - styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): - dependencies: - client-only: 0.0.1 - react: 19.2.4 - optionalDependencies: - '@babel/core': 7.29.0 - stylelint@16.26.1(typescript@5.7.3): dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) @@ -32089,7 +31854,7 @@ snapshots: optionalDependencies: express: 5.2.1 h3: 1.15.10 - next: 16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + next: 16.2.1(@babel/core@7.27.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) tailwindcss: 4.2.2 uqr@0.1.2: {} @@ -32185,86 +31950,6 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vinxi@0.4.3(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1): - dependencies: - '@babel/core': 7.27.3 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.27.3) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.27.3) - '@types/micromatch': 4.0.10 - '@vinxi/listhen': 1.5.6 - boxen: 7.1.1 - chokidar: 3.6.0 - citty: 0.1.6 - consola: 3.4.2 - crossws: 0.2.4 - dax-sh: 0.39.2 - defu: 6.1.4 - es-module-lexer: 1.7.0 - esbuild: 0.20.2 - fast-glob: 3.3.3 - get-port-please: 3.2.0 - h3: 1.11.1 - hookable: 5.5.3 - http-proxy: 1.18.1 - micromatch: 4.0.8 - nitropack: 2.13.2(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)) - node-fetch-native: 1.6.7 - path-to-regexp: 6.3.0 - pathe: 1.1.2 - radix3: 1.1.2 - resolve: 1.22.11 - serve-placeholder: 2.0.2 - serve-static: 1.16.3 - ufo: 1.6.3 - unctx: 2.5.0 - unenv: 1.10.0 - unstorage: 1.17.5(@azure/storage-blob@12.31.0)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(ioredis@5.10.1) - vite: 5.4.21(@types/node@22.19.9)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1) - zod: 3.25.76 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@electric-sql/pglite' - - '@libsql/client' - - '@netlify/blobs' - - '@planetscale/database' - - '@types/node' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - bare-abort-controller - - bare-buffer - - better-sqlite3 - - db0 - - debug - - drizzle-orm - - encoding - - idb-keyval - - ioredis - - less - - lightningcss - - mysql2 - - react-native-b4a - - rolldown - - sass - - sass-embedded - - sqlite3 - - stylus - - sugarss - - supports-color - - terser - - uWebSockets.js - - uploadthing - - xml2js - vinxi@0.5.11(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: '@babel/core': 7.27.3 @@ -32429,13 +32114,13 @@ snapshots: - xml2js - yaml - vite-tsconfig-paths@5.1.4(typescript@5.7.3)(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + vite-tsconfig-paths@5.1.4(typescript@5.7.3)(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.77.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.7.3) optionalDependencies: - vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.77.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color - typescript @@ -32450,19 +32135,6 @@ snapshots: - supports-color - typescript - vite@5.4.21(@types/node@22.19.9)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1): - dependencies: - esbuild: 0.21.5 - postcss: 8.5.8 - rollup: 4.59.0 - optionalDependencies: - '@types/node': 22.19.9 - fsevents: 2.3.3 - lightningcss: 1.30.2 - sass: 1.98.0 - sass-embedded: 1.98.0 - terser: 5.46.1 - vite@6.4.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.25.12 @@ -32539,6 +32211,10 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 + vitefu@1.1.3(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.77.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + optionalDependencies: + vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.77.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vitefu@1.1.3(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): optionalDependencies: vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) @@ -32937,6 +32613,13 @@ snapshots: '@oozcitak/util': 8.3.8 js-yaml: 3.14.1 + xmlbuilder2@4.0.3: + dependencies: + '@oozcitak/dom': 2.0.2 + '@oozcitak/infra': 2.0.2 + '@oozcitak/util': 10.0.0 + js-yaml: 4.1.1 + xmlchars@2.2.0: {} xss@1.0.15: diff --git a/tanstack-app/.gitignore b/tanstack-app/.gitignore new file mode 100644 index 00000000000..825b8e2c4ae --- /dev/null +++ b/tanstack-app/.gitignore @@ -0,0 +1,6 @@ +app.config.timestamp_*.js +*.output +.tanstack/ +.output/ +app/importMap.js +app/routeTree.gen.ts diff --git a/tanstack-app/app.config.ts b/tanstack-app/app.config.ts index 816e4f9852c..5ca0f204290 100644 --- a/tanstack-app/app.config.ts +++ b/tanstack-app/app.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from '@tanstack/start/config' +import { defineConfig } from '@tanstack/react-start/config' import tsConfigPaths from 'vite-tsconfig-paths' export default defineConfig({ diff --git a/tanstack-app/app/client.tsx b/tanstack-app/app/client.tsx index 47419f50a7f..b865e3c6b27 100644 --- a/tanstack-app/app/client.tsx +++ b/tanstack-app/app/client.tsx @@ -1,10 +1,10 @@ -import { StartClient } from '@tanstack/start' +import { StartClient } from '@tanstack/react-start' import { StrictMode } from 'react' import { hydrateRoot } from 'react-dom/client' -import { createRouter } from './router.js' +import { getRouter } from './router.js' -const router = createRouter() +const router = getRouter() hydrateRoot( document, diff --git a/tanstack-app/app/routeTree.gen.ts b/tanstack-app/app/routeTree.gen.ts index 9cfe5d11952..d16ef009811 100644 --- a/tanstack-app/app/routeTree.gen.ts +++ b/tanstack-app/app/routeTree.gen.ts @@ -1,9 +1,4 @@ // This file is auto-generated by @tanstack/router-plugin. // Do not edit manually. -// See https://tanstack.com/router/latest/docs/framework/react/routing/file-based-routing - import { rootRoute } from './routes/__root.js' - -// NOTE: This placeholder will be replaced by the TanStack Router CLI codegen. -// Until then, only the root route is registered. export const routeTree = rootRoute diff --git a/tanstack-app/app/router.tsx b/tanstack-app/app/router.tsx index 3a07dd67046..04d5eed5a8a 100644 --- a/tanstack-app/app/router.tsx +++ b/tanstack-app/app/router.tsx @@ -2,12 +2,15 @@ import { createRouter as createTanStackRouter } from '@tanstack/react-router' import { routeTree } from './routeTree.gen.js' -export function createRouter() { - return createTanStackRouter({ routeTree }) +export function getRouter() { + return createTanStackRouter({ + routeTree, + scrollRestoration: true, + }) } declare module '@tanstack/react-router' { interface Register { - router: ReturnType + router: ReturnType } } diff --git a/tanstack-app/app/routes/__root.tsx b/tanstack-app/app/routes/__root.tsx index af0ab084f75..5b8e3ccba68 100644 --- a/tanstack-app/app/routes/__root.tsx +++ b/tanstack-app/app/routes/__root.tsx @@ -4,15 +4,15 @@ import config from '@payload-config' import { RootLayout } from '@payloadcms/tanstack-start' import { dispatchServerFunction } from '@payloadcms/ui/utilities/handleServerFunctions' import { createRootRoute, HeadContent, Outlet, Scripts } from '@tanstack/react-router' -import { createServerFn } from '@tanstack/start' +import { createServerFn } from '@tanstack/react-start' +import { initReq } from '@payloadcms/tanstack-start/utilities/initReq' import React from 'react' import { importMap } from '../importMap.js' -import { initReq } from '@payloadcms/tanstack-start/utilities/initReq' // Server function: handles all Payload server function calls -const handleServerFn = createServerFn() - .validator((data: unknown) => data as Parameters[0]) +const handleServerFn = createServerFn({ method: 'POST' }) + .inputValidator((data: unknown) => data as Parameters[0]) .handler(async ({ data: args }) => { const { notFound, redirect } = await import('@tanstack/react-router') const { cookies, locale, permissions, req } = await initReq({ @@ -26,10 +26,12 @@ const handleServerFn = createServerFn() cookies, importMap, locale, + // eslint-disable-next-line @typescript-eslint/only-throw-error notFound: () => { throw notFound() }, permissions, + // eslint-disable-next-line @typescript-eslint/only-throw-error redirect: (url: string) => { throw redirect({ to: url }) }, @@ -53,7 +55,6 @@ export const rootRoute = createRootRoute({ }), }) -// Export the Route object required by TanStack Router file-based routing export const Route = rootRoute function RootComponent() { diff --git a/tanstack-app/app/ssr.tsx b/tanstack-app/app/ssr.tsx index 93ddfba8f1f..c86dc9a94f0 100644 --- a/tanstack-app/app/ssr.tsx +++ b/tanstack-app/app/ssr.tsx @@ -1,5 +1,5 @@ -import { createStartHandler, defaultStreamHandler } from '@tanstack/start/server' +import { createStartHandler } from '@tanstack/react-start/server' -import { createRouter } from './router.js' +import { getRouter } from './router.js' -export default createStartHandler({ createRouter })(defaultStreamHandler) +export default createStartHandler({ createRouter: getRouter }) diff --git a/tanstack-app/package.json b/tanstack-app/package.json index 9d636c671e0..7866f636c74 100644 --- a/tanstack-app/package.json +++ b/tanstack-app/package.json @@ -3,22 +3,26 @@ "private": true, "type": "module", "scripts": { - "build": "vinxi build", - "dev": "vinxi dev", - "start": "vinxi start" + "build": "vite build", + "dev": "vite dev", + "start": "node .output/server/index.mjs" }, "dependencies": { + "@payloadcms/next": "workspace:*", + "@payloadcms/richtext-lexical": "workspace:*", "@payloadcms/tanstack-start": "workspace:*", "@payloadcms/ui": "workspace:*", - "@tanstack/react-router": "^1.0.0", - "@tanstack/start": "^1.0.0", + "@tanstack/react-router": "^1.167.0", + "@tanstack/react-start": "^1.167.0", "payload": "workspace:*", "react": "19.1.0", - "react-dom": "19.1.0", - "vinxi": "^0.4.3" + "react-dom": "19.1.0" }, "devDependencies": { + "@vitejs/plugin-react": "^4.0.0", + "sass": "^1.0.0", "typescript": "5.x", + "vite": "^7.0.0", "vite-tsconfig-paths": "^5.0.0" } } diff --git a/tanstack-app/tsconfig.json b/tanstack-app/tsconfig.json index e2417810218..f117f06985d 100644 --- a/tanstack-app/tsconfig.json +++ b/tanstack-app/tsconfig.json @@ -2,10 +2,13 @@ "extends": "../tsconfig.base.json", "compilerOptions": { "strict": false, + "strictNullChecks": true, + "rootDir": ".", + "composite": false, "baseUrl": ".", "paths": { "@payload-config": ["../test/_community/config.ts"] } }, - "include": ["app/**/*", "app.config.ts"] + "include": ["app/**/*", "app.config.ts", "vite.config.ts"] } diff --git a/tanstack-app/vite.config.ts b/tanstack-app/vite.config.ts new file mode 100644 index 00000000000..493e0090332 --- /dev/null +++ b/tanstack-app/vite.config.ts @@ -0,0 +1,38 @@ +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import react from '@vitejs/plugin-react' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' + +const dirname = path.dirname(fileURLToPath(import.meta.url)) +const uiSrcDir = path.resolve(dirname, '../packages/ui/src') + +export default defineConfig({ + css: { + preprocessorOptions: { + scss: { + // Map ~@payloadcms/ui/scss to the actual source file + loadPaths: [path.resolve(uiSrcDir, 'scss')], + }, + }, + }, + optimizeDeps: { + exclude: ['sharp'], + }, + plugins: [ + tsConfigPaths({ + projects: ['./tsconfig.json'], + }), + tanstackStart({ + srcDirectory: 'app', + } as any), + react(), + ], + resolve: { + alias: { + // Handle tilde-prefixed node_modules imports in scss + '~@payloadcms/ui/scss': path.resolve(uiSrcDir, 'scss/styles.scss'), + }, + }, +}) diff --git a/test/dev-tanstack.ts b/test/dev-tanstack.ts index 729db0d41f4..35d92d5d43f 100644 --- a/test/dev-tanstack.ts +++ b/test/dev-tanstack.ts @@ -1,6 +1,6 @@ import chalk from 'chalk' -import { execa } from 'execa' import minimist from 'minimist' +import { spawn } from 'node:child_process' import path from 'node:path' import { fileURLToPath } from 'node:url' import { loadEnv } from 'payload/node' @@ -29,7 +29,10 @@ await runInit(testSuiteArg, true, false) const port = process.env.PORT ? Number(process.env.PORT) : 3100 -const vinxiProcess = execa('pnpm', ['vinxi', 'dev', '--port', String(port)], { +console.log(chalk.green(`✓ TanStack Start dev server starting on port ${port}`)) +console.log(chalk.cyan(` Admin: http://localhost:${port}/admin`)) + +const vinxiProcess = spawn('pnpm', ['vite', 'dev', '--port', String(port)], { cwd: tanstackAppDir, env: { ...process.env, @@ -38,9 +41,6 @@ const vinxiProcess = execa('pnpm', ['vinxi', 'dev', '--port', String(port)], { stdio: 'inherit', }) -console.log(chalk.green(`✓ TanStack Start dev server starting on port ${port}`)) -console.log(chalk.cyan(` Admin: http://localhost:${port}/admin`)) - process.on('SIGINT', () => { vinxiProcess.kill('SIGINT') process.exit(0) @@ -50,4 +50,13 @@ process.on('SIGTERM', () => { process.exit(0) }) -await vinxiProcess +await new Promise((resolve, reject) => { + vinxiProcess.on('error', (err) => reject(err)) + vinxiProcess.on('close', (code) => { + if (!code) { + resolve() + } else { + reject(new Error(`Vinxi exited with code ${code}`)) + } + }) +}) diff --git a/test/initDevAndTest.ts b/test/initDevAndTest.ts index 7915430dabb..d9c2645d60a 100644 --- a/test/initDevAndTest.ts +++ b/test/initDevAndTest.ts @@ -57,6 +57,14 @@ export async function initDevAndTest( process.env.ROOT_DIR = getNextRootDir(testSuiteArg).rootDir } + // For TanStack apps, set importMapFile explicitly so generateImportMap writes to the right path + if (isTanstackApp) { + if (!config.admin.importMap) { + ;(config.admin as any).importMap = {} + } + ;(config.admin.importMap as any).importMapFile = importMapPath + } + await generateImportMap(config, { log: true, force: true }) console.log('Done') From a2a11dd2a078e9a494519c14d9c3c53a53011af4 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 23:25:01 +0100 Subject: [PATCH 27/60] chore(tanstack-start): update peer deps to @tanstack/react-start, add as devDep Replaces @tanstack/start and vinxi peer deps with @tanstack/react-start. Adds @tanstack/react-start as devDependency so tests resolve the import. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- packages/tanstack-start/package.json | 10 +- pnpm-lock.yaml | 2828 +------------------------- 2 files changed, 101 insertions(+), 2737 deletions(-) diff --git a/packages/tanstack-start/package.json b/packages/tanstack-start/package.json index 4917548913e..ff7afe7ccd0 100644 --- a/packages/tanstack-start/package.json +++ b/packages/tanstack-start/package.json @@ -42,17 +42,19 @@ "payload": "workspace:*", "react": "19.1.0" }, + "devDependencies": { + "@tanstack/react-start": "^1.167.0" + }, "peerDependencies": { "@tanstack/react-router": ">=1.0.0", - "@tanstack/start": ">=1.0.0", - "react": "^19.0.0", - "vinxi": ">=0.4.0" + "@tanstack/react-start": ">=1.167.0", + "react": "^19.0.0" }, "peerDependenciesMeta": { "@tanstack/react-router": { "optional": false }, - "vinxi": { + "@tanstack/react-start": { "optional": false } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39cc70a1ba7..26176aa667c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1713,18 +1713,16 @@ importers: '@tanstack/react-router': specifier: '>=1.0.0' version: 1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/start': - specifier: '>=1.0.0' - version: 1.120.20(e1410331fba1110c79d7bc05e246b742) payload: specifier: workspace:* version: link:../payload react: specifier: 19.2.4 version: 19.2.4 - vinxi: - specifier: '>=0.4.0' - version: 0.5.11(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + devDependencies: + '@tanstack/react-start': + specifier: ^1.167.0 + version: 1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12)) packages/translations: dependencies: @@ -1995,7 +1993,7 @@ importers: version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: 16.2.1 - version: 16.2.1(@typescript-eslint/parser@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3))(eslint-plugin-import-x@4.6.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3) + version: 16.2.1(eslint-plugin-import-x@4.6.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3) jsdom: specifier: 28.0.0 version: 28.0.0(@noble/hashes@1.8.0) @@ -2363,7 +2361,7 @@ importers: version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: 16.2.1 - version: 16.2.1(eslint-plugin-import-x@4.6.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3) + version: 16.2.1(@typescript-eslint/parser@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3))(eslint-plugin-import-x@4.6.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.7.3) jsdom: specifier: 28.0.0 version: 28.0.0(@noble/hashes@1.8.0) @@ -3281,10 +3279,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/code-frame@7.26.2': - resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -3996,12 +3990,6 @@ packages: '@date-fns/tz@1.2.0': resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==} - '@deno/shim-deno-test@0.5.0': - resolution: {integrity: sha512-4nMhecpGlPi0cSzT67L+Tm+GOJqvuk8gqHBziqcUQOarnuIax1z96/gJHCSIz2Z0zhxE6Rzwb3IZXPtFh51j+w==} - - '@deno/shim-deno@0.19.2': - resolution: {integrity: sha512-q3VTHl44ad8T2Tw2SpeAvghdGOjlnLPDNO2cpOxwMrBE/PVas6geWpbpIgrM+czOCH0yejp0yi8OaTuB+NU40Q==} - '@discoveryjs/json-ext@0.5.7': resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} @@ -4113,12 +4101,6 @@ packages: resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} deprecated: 'Merged into tsx: https://tsx.is' - '@esbuild/aix-ppc64@0.20.2': - resolution: {integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -4143,24 +4125,12 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.27.4': - resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/android-arm64@0.18.20': resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} engines: {node: '>=12'} cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.20.2': - resolution: {integrity: sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -4185,24 +4155,12 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.27.4': - resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm@0.18.20': resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} engines: {node: '>=12'} cpu: [arm] os: [android] - '@esbuild/android-arm@0.20.2': - resolution: {integrity: sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -4227,24 +4185,12 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.27.4': - resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-x64@0.18.20': resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} engines: {node: '>=12'} cpu: [x64] os: [android] - '@esbuild/android-x64@0.20.2': - resolution: {integrity: sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -4269,24 +4215,12 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.27.4': - resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/darwin-arm64@0.18.20': resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} engines: {node: '>=12'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.20.2': - resolution: {integrity: sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -4311,24 +4245,12 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.27.4': - resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-x64@0.18.20': resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} engines: {node: '>=12'} cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.20.2': - resolution: {integrity: sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -4353,24 +4275,12 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.27.4': - resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/freebsd-arm64@0.18.20': resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} engines: {node: '>=12'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.20.2': - resolution: {integrity: sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -4395,24 +4305,12 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.27.4': - resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-x64@0.18.20': resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} engines: {node: '>=12'} cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.20.2': - resolution: {integrity: sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -4437,24 +4335,12 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.4': - resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/linux-arm64@0.18.20': resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} engines: {node: '>=12'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.20.2': - resolution: {integrity: sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -4479,24 +4365,12 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.27.4': - resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm@0.18.20': resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} engines: {node: '>=12'} cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.20.2': - resolution: {integrity: sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -4521,24 +4395,12 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.27.4': - resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-ia32@0.18.20': resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} engines: {node: '>=12'} cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.20.2': - resolution: {integrity: sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -4563,24 +4425,12 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.27.4': - resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-loong64@0.18.20': resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} engines: {node: '>=12'} cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.20.2': - resolution: {integrity: sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -4605,24 +4455,12 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.27.4': - resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-mips64el@0.18.20': resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} engines: {node: '>=12'} cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.20.2': - resolution: {integrity: sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -4647,24 +4485,12 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.27.4': - resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-ppc64@0.18.20': resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} engines: {node: '>=12'} cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.20.2': - resolution: {integrity: sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -4689,24 +4515,12 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.27.4': - resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-riscv64@0.18.20': resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} engines: {node: '>=12'} cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.20.2': - resolution: {integrity: sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -4731,24 +4545,12 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.27.4': - resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-s390x@0.18.20': resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} engines: {node: '>=12'} cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.20.2': - resolution: {integrity: sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -4773,24 +4575,12 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.27.4': - resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-x64@0.18.20': resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} engines: {node: '>=12'} cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.20.2': - resolution: {integrity: sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -4815,12 +4605,6 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.27.4': - resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -4845,24 +4629,12 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.27.4': - resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-x64@0.18.20': resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} engines: {node: '>=12'} cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.20.2': - resolution: {integrity: sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -4887,12 +4659,6 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.4': - resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -4917,24 +4683,12 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.27.4': - resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-x64@0.18.20': resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} engines: {node: '>=12'} cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.20.2': - resolution: {integrity: sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -4959,12 +4713,6 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.4': - resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -4983,24 +4731,12 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.27.4': - resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/sunos-x64@0.18.20': resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} engines: {node: '>=12'} cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.20.2': - resolution: {integrity: sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -5025,24 +4761,12 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.27.4': - resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/win32-arm64@0.18.20': resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} engines: {node: '>=12'} cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.20.2': - resolution: {integrity: sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -5067,24 +4791,12 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.27.4': - resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-ia32@0.18.20': resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} engines: {node: '>=12'} cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.20.2': - resolution: {integrity: sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -5109,24 +4821,12 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.27.4': - resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-x64@0.18.20': resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} engines: {node: '>=12'} cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.20.2': - resolution: {integrity: sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -5151,12 +4851,6 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.27.4': - resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -5811,11 +5505,6 @@ packages: cpu: [x64] os: [win32] - '@mapbox/node-pre-gyp@2.0.3': - resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==} - engines: {node: '>=18'} - hasBin: true - '@miniflare/core@2.14.4': resolution: {integrity: sha512-FMmZcC1f54YpF4pDWPtdQPIO8NXfgUxCoR9uyrhxKJdZu7M6n8QKopPVNuaxR40jcsdxb7yKoQoFWnHfzJD9GQ==} engines: {node: '>=16.13'} @@ -6100,26 +5789,14 @@ packages: resolution: {integrity: sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==} engines: {node: ^20.17.0 || >=22.9.0} - '@oozcitak/dom@1.15.10': - resolution: {integrity: sha512-0JT29/LaxVgRcGKvHmSrUTEvZ8BXvZhGl2LASRUgHqDTC1M5g1pLmVv56IYNyt3bG2CUjDkc67wnyZC14pbQrQ==} - engines: {node: '>=8.0'} - '@oozcitak/dom@2.0.2': resolution: {integrity: sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==} engines: {node: '>=20.0'} - '@oozcitak/infra@1.0.8': - resolution: {integrity: sha512-JRAUc9VR6IGHOL7OGF+yrvs0LO8SlqGnPAMqyzOuFZPSZSXI7Xf2O9+awQPSMXgIWGtgUf/dA6Hs6X6ySEaWTg==} - engines: {node: '>=6.0'} - '@oozcitak/infra@2.0.2': resolution: {integrity: sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA==} engines: {node: '>=20.0'} - '@oozcitak/url@1.0.4': - resolution: {integrity: sha512-kDcD8y+y3FCSOvnBI6HJgl00viO/nGbQoCINmQ0h98OhnGITrWR3bOGfwYCthgcrV8AnTJz8MzslTQbC3SOAmw==} - engines: {node: '>=8.0'} - '@oozcitak/url@3.0.0': resolution: {integrity: sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ==} engines: {node: '>=20.0'} @@ -6128,10 +5805,6 @@ packages: resolution: {integrity: sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==} engines: {node: '>=20.0'} - '@oozcitak/util@8.3.8': - resolution: {integrity: sha512-T8TbSnGsxo6TDBJx/Sgv/BlVJL3tshxZP7Aq5R1mSnM5OcHY2dQaxLMu2+E8u3gN0MLOzdjurqN4ZRVuzQycOQ==} - engines: {node: '>=8.0'} - '@opennextjs/aws@3.9.14': resolution: {integrity: sha512-ZNUY+r3FXr393Jli+wYqNjMllfQ0k7ZBIm8nDz6wIrrCuxX8FsNC4pioLY4ZySQfPGmiKWE6M0IyB7sOBnm58g==} hasBin: true @@ -6655,18 +6328,6 @@ packages: cpu: [x64] os: [linux] - '@parcel/watcher-wasm@2.3.0': - resolution: {integrity: sha512-ejBAX8H0ZGsD8lSICDNyMbSEtPMWgDL0WFCt/0z7hyf5v8Imz4rAM8xY379mBsECkq/Wdqa5WEDLqtjZ+6NxfA==} - engines: {node: '>= 10.0.0'} - bundledDependencies: - - napi-wasm - - '@parcel/watcher-wasm@2.5.6': - resolution: {integrity: sha512-byAiBZ1t3tXQvc8dMD/eoyE7lTXYorhn+6uVW5AC+JGI1KtJC/LvDche5cfUE+qiefH+Ybq0bUCJU0aB1cSHUA==} - engines: {node: '>= 10.0.0'} - bundledDependencies: - - napi-wasm - '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} engines: {node: '>= 10.0.0'} @@ -6723,9 +6384,6 @@ packages: '@poppinss/dumper@0.6.5': resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} - '@poppinss/dumper@0.7.0': - resolution: {integrity: sha512-0UTYalzk2t6S4rA2uHOz5bSSW2CHdv4vggJI6Alg90yvl0UgXs6XSXpH96OH+bRkX4J/06djv29pqXJ0lq5Kag==} - '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} @@ -7154,15 +6812,6 @@ packages: '@rolldown/pluginutils@1.0.0-beta.40': resolution: {integrity: sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w==} - '@rollup/plugin-alias@6.0.0': - resolution: {integrity: sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g==} - engines: {node: '>=20.19.0'} - peerDependencies: - rollup: '>=4.0.0' - peerDependenciesMeta: - rollup: - optional: true - '@rollup/plugin-commonjs@28.0.1': resolution: {integrity: sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==} engines: {node: '>=16.0.0 || 14 >= 14.17'} @@ -7172,17 +6821,8 @@ packages: rollup: optional: true - '@rollup/plugin-commonjs@29.0.2': - resolution: {integrity: sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==} - engines: {node: '>=16.0.0 || 14 >= 14.17'} - peerDependencies: - rollup: ^2.68.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - - '@rollup/plugin-inject@5.0.5': - resolution: {integrity: sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==} + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} engines: {node: '>=14.0.0'} peerDependencies: rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 @@ -7190,55 +6830,10 @@ packages: rollup: optional: true - '@rollup/plugin-json@6.1.0': - resolution: {integrity: sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - - '@rollup/plugin-node-resolve@16.0.3': - resolution: {integrity: sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^2.78.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - - '@rollup/plugin-replace@6.0.3': - resolution: {integrity: sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - - '@rollup/plugin-terser@1.0.0': - resolution: {integrity: sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==} - engines: {node: '>=20.0.0'} - peerDependencies: - rollup: ^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - - '@rollup/pluginutils@5.3.0': - resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - - '@rollup/rollup-android-arm-eabi@4.59.0': - resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} - cpu: [arm] - os: [android] + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] '@rollup/rollup-android-arm64@4.59.0': resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} @@ -7664,10 +7259,6 @@ packages: resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} - '@sindresorhus/merge-streams@4.0.0': - resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} - engines: {node: '>=18'} - '@sindresorhus/slugify@1.1.2': resolution: {integrity: sha512-V9nR/W0Xd9TSGXpZ4iFUcFGhuOJtZX82Fzxj1YISlbSgKvIiNa7eLEZrT0vAraPOt++KHauIVNYgGRgjc13dXA==} engines: {node: '>=10'} @@ -8270,22 +7861,6 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' - '@tanstack/directive-functions-plugin@1.131.2': - resolution: {integrity: sha512-5Pz6aVPS0BW+0bLvMzWsoajfjI6ZeWqkbVBaQfIbSTm4DOBO05JuQ/pb7W7m3GbCb5TK1a/SKDhuTX6Ag5I7UQ==} - engines: {node: '>=12'} - peerDependencies: - vite: '>=6.0.0' - - '@tanstack/directive-functions-plugin@1.142.1': - resolution: {integrity: sha512-k4HhAaitobp+z2pXBkmoWgE8Ollhx7fQXpVL+PQ7HeHZc2PilrQtC3ysxvoPunufrztIxweSE9HAWkZ2AFNaLw==} - engines: {node: '>=12'} - peerDependencies: - vite: '>=6.0.0 || >=7.0.0' - - '@tanstack/history@1.131.2': - resolution: {integrity: sha512-cs1WKawpXIe+vSTeiZUuSBy8JFjEuDgdMKZFRLKwQysKo8y2q6Q1HvS74Yw+m5IhOW1nTZooa6rlgdfXcgFAaw==} - engines: {node: '>=12'} - '@tanstack/history@1.161.6': resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==} engines: {node: '>=20.19'} @@ -8304,17 +7879,6 @@ packages: react: 19.2.4 react-dom: 19.2.4 - '@tanstack/react-start-plugin@1.131.50': - resolution: {integrity: sha512-ys+sGvnnE8BUNjGsngg+MGn3F5lV4okL5CWEKFzjBSjQsrTN7apGfmqvBP3O6PkRPHpXZ8X3Z5QsFvSc0CaDRQ==} - engines: {node: '>=12'} - peerDependencies: - '@vitejs/plugin-react': '>=4.3.4' - vite: '>=6.0.0' - - '@tanstack/react-start-router-manifest@1.120.19': - resolution: {integrity: sha512-z+4YL6shTtsHjk32yaIemQwgkx6FcqwPBYfeNt7Co2eOpWrvsoo/Fe9869/oIY2sPyhiWDs1rDb3e0qnAy8Cag==} - engines: {node: '>=12'} - '@tanstack/react-start-server@1.166.25': resolution: {integrity: sha512-bPLADxlplvcnAcnZvBjJl2MzgUnB85d7Mu5aEkYoOFxhz0WiG6mZp7BDadIJuCd33NYMirsd3XrjfCHNzrMTyg==} engines: {node: '>=22.12.0'} @@ -8337,44 +7901,15 @@ packages: react: 19.2.4 react-dom: 19.2.4 - '@tanstack/router-core@1.131.50': - resolution: {integrity: sha512-eojd4JZ5ziUhGEmXZ4CaVX5mQdiTMiz56Sp8ZQ6r7deb55Q+5G4JQDkeuXpI7HMAvzr+4qlsFeLaDRXXjXyOqQ==} - engines: {node: '>=12'} - '@tanstack/router-core@1.168.9': resolution: {integrity: sha512-18oeEwEDyXOIuO1VBP9ACaK7tYHZUjynGDCoUh/5c/BNhia9vCJCp9O0LfhZXOorDc/PmLSgvmweFhVmIxF10g==} engines: {node: '>=20.19'} hasBin: true - '@tanstack/router-generator@1.131.50': - resolution: {integrity: sha512-zlMBw5l88GIg3v+378JsfDYq3ejEaJmD3P1R+m0yEPxh0N//Id1FjKNSS7yJbejlK2WGVm9DUG46iBdTDMQM+Q==} - engines: {node: '>=12'} - '@tanstack/router-generator@1.166.24': resolution: {integrity: sha512-vdaGKwuH+r+DPe6R1mjk+TDDmDH6NTG7QqwxHqGEvOH4aGf9sPjhmRKNJZqQr8cPIbfp6u5lXyZ1TeDcSNMVEA==} engines: {node: '>=20.19'} - '@tanstack/router-plugin@1.131.50': - resolution: {integrity: sha512-gdEBPGzx7llQNRnaqfPJ1iaPS3oqB8SlvKRG5l7Fxp4q4yINgkeowFYSKEhPOc9bjoNhGrIHOlvPTPXEzAQXzQ==} - engines: {node: '>=12'} - peerDependencies: - '@rsbuild/core': '>=1.0.2' - '@tanstack/react-router': ^1.131.50 - vite: '>=5.0.0 || >=6.0.0' - vite-plugin-solid: ^2.11.2 - webpack: '>=5.92.0' - peerDependenciesMeta: - '@rsbuild/core': - optional: true - '@tanstack/react-router': - optional: true - vite: - optional: true - vite-plugin-solid: - optional: true - webpack: - optional: true - '@tanstack/router-plugin@1.167.12': resolution: {integrity: sha512-StEHcctCuFI5taSjO+lhR/yQ+EK63BdyYa+ne6FoNQPB3MMrOUrz2ZVnbqILRLkh2b+p2EfBKt65sgAKdKygPQ==} engines: {node: '>=20.19'} @@ -8397,114 +7932,37 @@ packages: webpack: optional: true - '@tanstack/router-utils@1.131.2': - resolution: {integrity: sha512-sr3x0d2sx9YIJoVth0QnfEcAcl+39sQYaNQxThtHmRpyeFYNyM2TTH+Ud3TNEnI3bbzmLYEUD+7YqB987GzhDA==} - engines: {node: '>=12'} - - '@tanstack/router-utils@1.141.0': - resolution: {integrity: sha512-/eFGKCiix1SvjxwgzrmH4pHjMiMxc+GA4nIbgEkG2RdAJqyxLcRhd7RPLG0/LZaJ7d0ad3jrtRqsHLv2152Vbw==} - engines: {node: '>=12'} - '@tanstack/router-utils@1.161.6': resolution: {integrity: sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw==} engines: {node: '>=20.19'} - '@tanstack/server-functions-plugin@1.131.2': - resolution: {integrity: sha512-hWsaSgEZAVyzHg8+IcJWCEtfI9ZSlNELErfLiGHG9XCHEXMegFWsrESsKHlASzJqef9RsuOLDl+1IMPIskwdDw==} - engines: {node: '>=12'} - - '@tanstack/server-functions-plugin@1.142.1': - resolution: {integrity: sha512-ltTOj6dIDlRV3M8+PzontDYFMnIQ+icUnD+OKzIRfKo6bbvC0qvy8ttuWmVJxmqHy9xsWgkNt4gZrKVjtWXIhQ==} - engines: {node: '>=12'} - - '@tanstack/start-api-routes@1.120.19': - resolution: {integrity: sha512-zvMI9Rfwsm3CCLTLqdvUfteDRMdPKTOO05O3L8vp49BrYYsLrT0OplhounzdRMgGMnKd4qCXUC9Pj4UOUOodTw==} - engines: {node: '>=12'} - - '@tanstack/start-client-core@1.131.50': - resolution: {integrity: sha512-8fbwYca1NAu/5WyGvO3e341/FPpsiqdPrrzkoc0cXQimMN1DligoRjvHgP13q3n5w1tFMSqChGzXfOVJP9ndSw==} - engines: {node: '>=12'} - '@tanstack/start-client-core@1.167.9': resolution: {integrity: sha512-2ETQO/bxiZGsoTdPxZb7xR8YqCy5l4kv/QPkwIXuvx/A4BjufngXfgISjXUicXsFRIBZeiFnBzp9A38UMsS2iA==} engines: {node: '>=22.12.0'} hasBin: true - '@tanstack/start-config@1.120.20': - resolution: {integrity: sha512-oH/mfTSHV8Qbil74tWicPLW6+kKmT3esXCnDzvrkhi3+N8ZuVUDr01Qpil0Wxf9lLPfM5L6VX03nF4hSU8vljg==} - engines: {node: '>=12'} - peerDependencies: - react: 19.2.4 - react-dom: 19.2.4 - vite: ^6.0.0 - '@tanstack/start-fn-stubs@1.161.6': resolution: {integrity: sha512-Y6QSlGiLga8cHfvxGGaonXIlt2bIUTVdH6AMjmpMp7+ANNCp+N96GQbjjhLye3JkaxDfP68x5iZA8NK4imgRig==} engines: {node: '>=22.12.0'} - '@tanstack/start-plugin-core@1.131.50': - resolution: {integrity: sha512-eFvMA0chqLtHbq+8ojp1fXN7AQjhmeoOpQaZaU1d51wb7ugetrn0k3OuHblxtE/O0L4HEC9s4X5zmFJt0vLh0w==} - engines: {node: '>=12'} - peerDependencies: - vite: '>=6.0.0' - '@tanstack/start-plugin-core@1.167.17': resolution: {integrity: sha512-OkorpOobGOEDVr72QUmkzKjbawKC05CSz+1B3OObB/AxBIIw+lLLhTXbV45QkX2LZA7dcRvPJYZGOH1pkFqA1g==} engines: {node: '>=22.12.0'} peerDependencies: vite: '>=7.0.0' - '@tanstack/start-server-core@1.131.50': - resolution: {integrity: sha512-3SWwwhW2GKMhPSaqWRal6Jj1Y9ObfdWEXKFQid1LBuk5xk/Es4bmW68o++MbVgs/GxUxyeZ3TRVqb0c7RG1sog==} - engines: {node: '>=12'} - '@tanstack/start-server-core@1.167.9': resolution: {integrity: sha512-vKkslQIihoDDVumF73VXT7PVFmN7Nea0nKhZx7gMbc0m09yPQYYR1dn86/dz14k6/7cDkJ+qKXa09rlVlN/i9Q==} engines: {node: '>=22.12.0'} hasBin: true - '@tanstack/start-server-functions-client@1.131.50': - resolution: {integrity: sha512-4aM17fFdVAFH6uLPswKJxzrhhIjcCwKqzfTcgY3OnhUKnaZBTQwJA+nUHQCI6IWvEvrcrNVtFTtv13TkDk3YMw==} - engines: {node: '>=12'} - - '@tanstack/start-server-functions-fetcher@1.131.50': - resolution: {integrity: sha512-yeZekr84BkyLaNaZ4llKbDBb+CJPVESP881iJijP++SuRmvetivUs75KiV9VFIf7MhdefICmRcCdff/KbK5QnQ==} - engines: {node: '>=12'} - - '@tanstack/start-server-functions-handler@1.120.19': - resolution: {integrity: sha512-Ow8HkNieoqHumD3QK4YUDIhzBtFX9mMEDrxFYtbVBgxP1C9Rm/YDuwnUNP49q1tTOZ22Bs4wSDjBXvu+OgSSfA==} - engines: {node: '>=12'} - - '@tanstack/start-server-functions-server@1.131.2': - resolution: {integrity: sha512-u67d6XspczlC/dYki/Id28oWsTjkZMJhDqO4E23U3rHs8eYgxvMBHKqdeqWgOyC+QWT9k6ze1pJmbv+rmc3wOQ==} - engines: {node: '>=12'} - - '@tanstack/start-server-functions-ssr@1.120.19': - resolution: {integrity: sha512-D4HGvJXWvVUssgkLDtdSJTFfWuT+nVv9GauPfVQTtMUUy+NbExNkFWKvF+XvCS81lBqnCKL7VrWqZMXiod0gTA==} - engines: {node: '>=12'} - - '@tanstack/start-storage-context@1.131.50': - resolution: {integrity: sha512-qbVFdx/B5URJXzWjguaiCcQhJw2NL8qFGtSzLSGilxQnvtJdM+V9VBMizKIxhm9oiYnfqGsVfyMOBD7q9f8Y1Q==} - engines: {node: '>=12'} - '@tanstack/start-storage-context@1.166.23': resolution: {integrity: sha512-3vEdiYRMx+r+Q7Xqxj3YmADPIpMm7fkKxDa8ITwodGXiw+SBJCGkpBXGUWjOXyXkIyqGHKM5UrReTcVUTkmaug==} engines: {node: '>=22.12.0'} - '@tanstack/start@1.120.20': - resolution: {integrity: sha512-fQO+O/5xJpli5KlV6pwDz6DtpbqO/0atdVSyVnkemzk0Mej9azm4HXtw+cKkIPtsSplWs4B1EbMtgGMb9ADhSA==} - engines: {node: '>=12'} - - '@tanstack/store@0.7.7': - resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==} - '@tanstack/store@0.9.3': resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} - '@tanstack/virtual-file-routes@1.131.2': - resolution: {integrity: sha512-VEEOxc4mvyu67O+Bl0APtYjwcNRcL9it9B4HKbNgcBTIOEalhk+ufBl4kiqc8WP1sx1+NAaiS+3CcJBhrqaSRg==} - engines: {node: '>=12'} - '@tanstack/virtual-file-routes@1.161.7': resolution: {integrity: sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==} engines: {node: '>=20.19'} @@ -8585,9 +8043,6 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} - '@types/babel__code-frame@7.27.0': - resolution: {integrity: sha512-Dwlo+LrxDx/0SpfmJ/BKveHf7QXWvLBLc+x03l5sbzykj3oB9nHygCpSECF1a+s+QIxbghe+KHqC90vGtxLRAA==} - '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -8603,9 +8058,6 @@ packages: '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} - '@types/braces@3.0.5': - resolution: {integrity: sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==} - '@types/busboy@1.5.4': resolution: {integrity: sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==} @@ -8717,9 +8169,6 @@ packages: '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} - '@types/micromatch@4.0.10': - resolution: {integrity: sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ==} - '@types/minimatch@6.0.0': resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==} deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed. @@ -8800,9 +8249,6 @@ packages: '@types/request@2.48.13': resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==} - '@types/resolve@1.20.2': - resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} - '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} @@ -9066,11 +8512,6 @@ packages: '@vercel/git-hooks@1.0.0': resolution: {integrity: sha512-OxDFAAdyiJ/H0b8zR9rFCu3BIb78LekBXOphOYG3snV4ULhKFX387pBPpqZ9HLiRTejBWBxYEahkw79tuIgdAA==} - '@vercel/nft@1.5.0': - resolution: {integrity: sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==} - engines: {node: '>=20'} - hasBin: true - '@vercel/oidc@3.1.0': resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} engines: {node: '>= 20'} @@ -9080,10 +8521,6 @@ packages: engines: {node: '>=14.6'} deprecated: '@vercel/postgres is deprecated. If you are setting up a new database, you can choose an alternate storage solution from the Vercel Marketplace. If you had an existing Vercel Postgres database, it should have been migrated to Neon as a native Vercel integration. You can find more details and the guide to migrate to Neon''s SDKs here: https://neon.com/docs/guides/vercel-postgres-transition-guide' - '@vinxi/listhen@1.5.6': - resolution: {integrity: sha512-WSN1z931BtasZJlgPp704zJFnQFRg7yzSjkm3MzAWQYe4uXFXlFr1hc5Ac2zae5/HDOz5x1/zDM5Cb54vTCnWw==} - hasBin: true - '@vitejs/plugin-react@4.5.2': resolution: {integrity: sha512-QNVT3/Lxx99nMQWJWF7K4N6apUEuT0KlZA3mx/mVaoGj3smm/8rc8ezz15J1pcbcjDK0V15rpHetVfya08r76Q==} engines: {node: ^14.18.0 || >=16.0.0} @@ -9289,10 +8726,6 @@ packages: abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} - abbrev@3.0.1: - resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} - engines: {node: ^18.17.0 || >=20.5.0} - abbrev@4.0.0: resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} engines: {node: ^20.17.0 || >=22.9.0} @@ -9382,9 +8815,6 @@ packages: amazon-cognito-identity-js@6.3.16: resolution: {integrity: sha512-HPGSBGD6Q36t99puWh0LnptxO/4icnk2kqIQ9cTJ2tFQo5NMUnWQIgtrTAk8nm+caqUbjDzXzG56GBjI2tS6jQ==} - ansi-align@3.0.1: - resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} - ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -9443,9 +8873,6 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -9535,9 +8962,6 @@ packages: async-retry@1.3.3: resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} - async-sema@3.1.1: - resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} - async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -9717,14 +9141,6 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - boxen@7.1.1: - resolution: {integrity: sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==} - engines: {node: '>=14.16'} - - boxen@8.0.1: - resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} - engines: {node: '>=18'} - brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -9838,14 +9254,6 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - camelcase@7.0.1: - resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} - engines: {node: '>=14.16'} - - camelcase@8.0.0: - resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} - engines: {node: '>=16'} - caniuse-lite@1.0.30001780: resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} @@ -9945,10 +9353,6 @@ packages: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} - cli-boxes@3.0.0: - resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} - engines: {node: '>=10'} - cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -9960,10 +9364,6 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} - clipboardy@4.0.0: - resolution: {integrity: sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w==} - engines: {node: '>=18'} - cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -10055,9 +9455,6 @@ packages: compare-versions@6.1.1: resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} - compatx@0.2.0: - resolution: {integrity: sha512-6gLRNt4ygsi5NyMVhceOCFv14CIdDFN7fQjX1U4+47qVE/+kjPoXMK65KWK+dWxmFzMTuKazoQ9sch6pM0p5oA==} - compress-commons@6.0.2: resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} engines: {node: '>= 14'} @@ -10072,9 +9469,6 @@ packages: resolution: {integrity: sha512-Bi6v586cy1CoTFViVO4lGTtx780lfF96fUmS1lSX6wpZf6330NvHUu6fReVuDP1de8Mg0nkZb01c8tAQdz1o3w==} engines: {node: '>=18'} - confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} @@ -10112,9 +9506,6 @@ packages: cookie-es@2.0.0: resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==} - cookie-es@3.1.1: - resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} - cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -10163,10 +9554,6 @@ packages: resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} engines: {node: '>= 14'} - croner@10.0.1: - resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==} - engines: {node: '>=18.0'} - croner@9.1.0: resolution: {integrity: sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g==} engines: {node: '>=18.0'} @@ -10308,37 +9695,6 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} - dax-sh@0.39.2: - resolution: {integrity: sha512-gpuGEkBQM+5y6p4cWaw9+ePy5TNon+fdwFVtTI8leU3UhwhsBfPewRxMXGuQNC+M2b/MDGMlfgpqynkcd0C3FQ==} - deprecated: This package has moved to simply be 'dax' instead of 'dax-sh' - - dax-sh@0.43.2: - resolution: {integrity: sha512-uULa1sSIHgXKGCqJ/pA0zsnzbHlVnuq7g8O2fkHokWFNwEGIhh5lAJlxZa1POG5En5ba7AU4KcBAvGQWMMf8rg==} - deprecated: This package has moved to simply be 'dax' instead of 'dax-sh' - - db0@0.3.4: - resolution: {integrity: sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw==} - peerDependencies: - '@electric-sql/pglite': '*' - '@libsql/client': '*' - better-sqlite3: '*' - drizzle-orm: '*' - mysql2: '*' - sqlite3: '*' - peerDependenciesMeta: - '@electric-sql/pglite': - optional: true - '@libsql/client': - optional: true - better-sqlite3: - optional: true - drizzle-orm: - optional: true - mysql2: - optional: true - sqlite3: - optional: true - debounce-fn@6.0.0: resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==} engines: {node: '>=18'} @@ -10346,14 +9702,6 @@ packages: debounce@1.2.1: resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -10462,10 +9810,6 @@ packages: destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} - destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - detect-file@1.0.0: resolution: {integrity: sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==} engines: {node: '>=0.10.0'} @@ -10537,10 +9881,6 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} - dot-prop@10.1.0: - resolution: {integrity: sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==} - engines: {node: '>=20'} - dot-prop@9.0.0: resolution: {integrity: sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==} engines: {node: '>=18'} @@ -10810,11 +10150,6 @@ packages: engines: {node: '>=12'} hasBin: true - esbuild@0.20.2: - resolution: {integrity: sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==} - engines: {node: '>=12'} - hasBin: true - esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -10835,11 +10170,6 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.27.4: - resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} - engines: {node: '>=18'} - hasBin: true - escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -11208,9 +10538,6 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} - eventemitter3@4.0.7: - resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} - eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -11484,10 +10811,6 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} - fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -11574,9 +10897,6 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} - get-port-please@3.2.0: - resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} - get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -11693,10 +11013,6 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} - globby@16.2.0: - resolution: {integrity: sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==} - engines: {node: '>=20'} - globjoin@0.1.4: resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} @@ -11752,19 +11068,9 @@ packages: resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} engines: {node: '>=10'} - gzip-size@7.0.0: - resolution: {integrity: sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - h3@1.13.0: - resolution: {integrity: sha512-vFEAu/yf8UMUcB4s43OaDaigcqpQd14yanmOsn+NcRX3/guSKncyE2rOYhq8RIchgJrPSs/QiIddnTTR1ddiAg==} - h3@1.15.10: resolution: {integrity: sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg==} - h3@1.15.3: - resolution: {integrity: sha512-z6GknHqyX0h9aQaTx22VZDf6QyZn+0Nh+Ym8O/u0SGSkyF5cuTJYKlc8MkzW3Nzf9LE1ivcpmYC3FUGpywhuUQ==} - h3@2.0.1-rc.16: resolution: {integrity: sha512-h+pjvyujdo9way8qj6FUbhaQcHlR8FEq65EhTX9ViT5pK8aLj68uFl4hBkF+hsTJAH+H1END2Yv6hTIsabGfag==} engines: {node: '>=20.11.1'} @@ -11838,9 +11144,6 @@ packages: resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==} engines: {node: '>=16.9.0'} - hookable@5.5.3: - resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} - hookified@1.15.1: resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} @@ -11882,14 +11185,6 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} - http-proxy@1.18.1: - resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} - engines: {node: '>=8.0.0'} - - http-shutdown@1.2.2: - resolution: {integrity: sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw==} - engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} - http-status@2.1.0: resolution: {integrity: sha512-O5kPr7AW7wYd/BBiOezTwnVAnmSNFY+J7hlZD2X5IOxVBetjcHAiTXhzj0gMrnojQlwy+UT1/Y3H3vJ3UlmvLA==} engines: {node: '>= 0.4.0'} @@ -11906,9 +11201,6 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - httpxy@0.3.1: - resolution: {integrity: sha512-XjG/CEoofEisMrnFr0D6U6xOZ4mRfnwcYQ9qvvnT4lvnX8BoeA3x3WofB75D+vZwpaobFVkBIHrZzoK40w8XSw==} - human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -11978,9 +11270,6 @@ packages: import-in-the-middle@1.15.0: resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==} - import-meta-resolve@4.2.0: - resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} - imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -12142,10 +11431,6 @@ packages: eslint: '*' typescript: 5.7.3 - is-in-ssh@1.0.0: - resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} - engines: {node: '>=20'} - is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} @@ -12155,9 +11440,6 @@ packages: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} - is-module@1.0.0: - resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} - is-negative-zero@2.0.3: resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} engines: {node: '>= 0.4'} @@ -12181,10 +11463,6 @@ packages: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} - is-path-inside@4.0.0: - resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} - engines: {node: '>=12'} - is-plain-obj@1.1.0: resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} engines: {node: '>=0.10.0'} @@ -12262,10 +11540,6 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} - is64bit@2.0.0: - resolution: {integrity: sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==} - engines: {node: '>=18'} - isarray@0.0.1: resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} @@ -12311,10 +11585,6 @@ packages: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} - jiti@1.21.7: - resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} - hasBin: true - jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -12341,13 +11611,6 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-tokens@9.0.1: - resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - - js-yaml@3.14.1: - resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} - hasBin: true - js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -12469,13 +11732,6 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} - klona@2.0.6: - resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} - engines: {node: '>= 8'} - - knitwork@1.3.0: - resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==} - known-css-properties@0.37.0: resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} @@ -12596,10 +11852,6 @@ packages: engines: {node: '>=18.12.0'} hasBin: true - listhen@1.9.0: - resolution: {integrity: sha512-I8oW2+QL5KJo8zXNWX046M134WchxsXC7SawLPvRQpogCbkyQIaFxPE89A2HiwR7vAK2Dm2ERBAmyjTYGYEpBg==} - hasBin: true - listr2@8.2.5: resolution: {integrity: sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==} engines: {node: '>=18.0.0'} @@ -12608,10 +11860,6 @@ packages: resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} engines: {node: '>=6.11.5'} - local-pkg@1.1.2: - resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} - engines: {node: '>=14'} - localforage@1.10.0: resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==} @@ -12888,21 +12136,11 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} - mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true - mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} hasBin: true - mime@4.1.0: - resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} - engines: {node: '>=16'} - hasBin: true - mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -13003,9 +12241,6 @@ packages: engines: {node: '>=10'} hasBin: true - mlly@1.8.2: - resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} - mnemonist@0.38.3: resolution: {integrity: sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==} @@ -13077,9 +12312,6 @@ packages: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -13150,16 +12382,6 @@ packages: sass: optional: true - nitropack@2.13.2: - resolution: {integrity: sha512-R5TMzSBoTDG4gi6Y+pvvyCNnooShHePHsHxMLP9EXDGdrlR5RvNdSd4e5k8z0/EzP9Ske7ABRMDWg6O7Dm2OYw==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - xml2js: ^0.6.2 - peerDependenciesMeta: - xml2js: - optional: true - node-abi@3.89.0: resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} engines: {node: '>=10'} @@ -13195,10 +12417,6 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-forge@1.4.0: - resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} - engines: {node: '>= 6.13.0'} - node-gyp-build@4.8.4: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true @@ -13225,11 +12443,6 @@ packages: resolution: {integrity: sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==} hasBin: true - nopt@8.1.0: - resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} - engines: {node: ^18.17.0 || >=20.5.0} - hasBin: true - nopt@9.0.0: resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -13319,9 +12532,6 @@ packages: ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} - ohash@1.1.6: - resolution: {integrity: sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==} - ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} @@ -13352,10 +12562,6 @@ packages: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} - open@11.0.0: - resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} - engines: {node: '>=20'} - openapi-fetch@0.15.0: resolution: {integrity: sha512-OjQUdi61WO4HYhr9+byCPMj0+bgste/LtSBEcV6FzDdONTs7x0fWn8/ndoYwzqCsKWIxEZwo4FN/TG1c1rI8IQ==} @@ -13506,9 +12712,6 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} - pathe@1.1.2: - resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} - pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -13613,9 +12816,6 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} - pkg-types@1.3.1: - resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} @@ -13706,10 +12906,6 @@ packages: postgres-range@1.1.4: resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} - powershell-utils@0.1.0: - resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} - engines: {node: '>=20'} - prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -13786,10 +12982,6 @@ packages: engines: {node: '>=14'} hasBin: true - pretty-bytes@7.1.0: - resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==} - engines: {node: '>=20'} - pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -13860,9 +13052,6 @@ packages: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} - quansync@0.2.11: - resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} - queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -14117,9 +13306,6 @@ packages: resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==} engines: {node: '>=8.6.0'} - requires-port@1.0.0: - resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} - reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} @@ -14195,19 +13381,6 @@ packages: rollup: ^3.29.4 || ^4 typescript: 5.7.3 - rollup-plugin-visualizer@7.0.1: - resolution: {integrity: sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==} - engines: {node: '>=22'} - hasBin: true - peerDependencies: - rolldown: 1.x || ^1.0.0-beta || ^1.0.0-rc - rollup: 2.x || 3.x || 4.x - peerDependenciesMeta: - rolldown: - optional: true - rollup: - optional: true - rollup@3.29.5: resolution: {integrity: sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==} engines: {node: '>=14.18.0', npm: '>=8.0.0'} @@ -14437,18 +13610,10 @@ packages: engines: {node: '>=10'} hasBin: true - send@0.19.2: - resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} - engines: {node: '>= 0.8.0'} - send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} - serialize-javascript@7.0.5: - resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==} - engines: {node: '>=20.0.0'} - seroval-plugins@1.5.1: resolution: {integrity: sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==} engines: {node: '>=10'} @@ -14459,13 +13624,6 @@ packages: resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} engines: {node: '>=10'} - serve-placeholder@2.0.2: - resolution: {integrity: sha512-/TMG8SboeiQbZJWRlfTCqMs2DD3SZgWp0kDQePz9yUuCnDfDh/92gf7/PxGhzXTKBIPASIHxFcZndoNbp6QOLQ==} - - serve-static@1.16.3: - resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} - engines: {node: '>= 0.8.0'} - serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -14579,10 +13737,6 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - slash@5.1.0: - resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} - engines: {node: '>=14.16'} - slate-history@0.86.0: resolution: {integrity: sha512-OxObL9tbhgwvSlnKSCpGIh7wnuaqvOj5jRExGjEyCU2Ke8ctf22HjT+jw7GEi9ttLzNTUmTEU3YIzqKGeqN+og==} peerDependencies: @@ -14622,10 +13776,6 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - smob@1.6.1: - resolution: {integrity: sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==} - engines: {node: '>=20.0.0'} - socks-proxy-agent@8.0.5: resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} engines: {node: '>= 14'} @@ -14705,9 +13855,6 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - sqids@0.3.0: resolution: {integrity: sha512-lOQK1ucVg+W6n3FhRwwSeUijxe93b51Bfz5PMRMihVf1iVkl82ePQG7V5vwrhzB11v0NtsR25PSZRGiSomJaJw==} @@ -14746,9 +13893,6 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - std-env@4.0.0: - resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} - stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -14858,9 +14002,6 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} - strip-literal@3.1.0: - resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} - stripe@10.17.0: resolution: {integrity: sha512-JHV2KoL+nMQRXu3m9ervCZZvi4DDCJfzHUE6CmtJxR9TmizyYfrVuhGvnsZLLnheby9Qrnf4Hq6iOEcejGwnGQ==} engines: {node: ^8.1 || >=10.*} @@ -14976,10 +14117,6 @@ packages: resolution: {integrity: sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==} engines: {node: '>=16.0.0'} - system-architecture@0.1.0: - resolution: {integrity: sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==} - engines: {node: '>=18'} - tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} @@ -14987,10 +14124,6 @@ packages: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} - tagged-tag@1.0.0: - resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} - engines: {node: '>=20'} - tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} @@ -15256,18 +14389,10 @@ packages: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} - type-fest@2.19.0: - resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} - engines: {node: '>=12.20'} - type-fest@4.41.0: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} - type-fest@5.5.0: - resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} - engines: {node: '>=20'} - type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -15314,9 +14439,6 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} - ultrahtml@1.6.0: - resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} - unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -15327,9 +14449,6 @@ packages: uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} - unctx@2.5.0: - resolution: {integrity: sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==} - undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} @@ -15352,9 +14471,6 @@ packages: resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==} engines: {node: '>=20.18.1'} - unenv@1.10.0: - resolution: {integrity: sha512-wY5bskBQFL9n3Eca5XnhH6KbUo/tfvkwm9OpcdCvLaeA7piBNbavbOKJySEwQ1V0RH6HvNlSAFRTpvTqgKRQXQ==} - unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -15377,14 +14493,6 @@ packages: resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} engines: {node: '>=4'} - unicorn-magic@0.4.0: - resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} - engines: {node: '>=20'} - - unimport@6.0.2: - resolution: {integrity: sha512-ZSOkrDw380w+KIPniY3smyXh2h7H9v2MNr9zejDuh239o5sdea44DRAYrv+rfUi2QGT186P2h0GPGKvy8avQ5g==} - engines: {node: '>=18.12.0'} - unique-string@2.0.0: resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} engines: {node: '>=8'} @@ -15412,10 +14520,6 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - unplugin-utils@0.3.1: - resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} - engines: {node: '>=20.19.0'} - unplugin@1.0.1: resolution: {integrity: sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==} @@ -15423,90 +14527,13 @@ packages: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} - unplugin@3.0.0: - resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==} - engines: {node: ^20.19.0 || >=22.12.0} - unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} - unstorage@1.17.5: - resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} - peerDependencies: - '@azure/app-configuration': ^1.8.0 - '@azure/cosmos': ^4.2.0 - '@azure/data-tables': ^13.3.0 - '@azure/identity': ^4.6.0 - '@azure/keyvault-secrets': ^4.9.0 - '@azure/storage-blob': ^12.26.0 - '@capacitor/preferences': ^6 || ^7 || ^8 - '@deno/kv': '>=0.9.0' - '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 - '@planetscale/database': ^1.19.0 - '@upstash/redis': ^1.34.3 - '@vercel/blob': '>=0.27.1' - '@vercel/functions': ^2.2.12 || ^3.0.0 - '@vercel/kv': ^1 || ^2 || ^3 - aws4fetch: ^1.0.20 - db0: '>=0.2.1' - idb-keyval: ^6.2.1 - ioredis: ^5.4.2 - uploadthing: ^7.4.4 - peerDependenciesMeta: - '@azure/app-configuration': - optional: true - '@azure/cosmos': - optional: true - '@azure/data-tables': - optional: true - '@azure/identity': - optional: true - '@azure/keyvault-secrets': - optional: true - '@azure/storage-blob': - optional: true - '@capacitor/preferences': - optional: true - '@deno/kv': - optional: true - '@netlify/blobs': - optional: true - '@planetscale/database': - optional: true - '@upstash/redis': - optional: true - '@vercel/blob': - optional: true - '@vercel/functions': - optional: true - '@vercel/kv': - optional: true - aws4fetch: - optional: true - db0: - optional: true - idb-keyval: - optional: true - ioredis: - optional: true - uploadthing: - optional: true - untildify@4.0.0: resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} engines: {node: '>=8'} - untun@0.1.3: - resolution: {integrity: sha512-4luGP9LMYszMRZwsvyUd9MrxgEGZdZuZgpVQHEEX0lCYFESasVRvZd0EYpCkOIbJKHMuv0LskpXc/8Un+MJzEQ==} - hasBin: true - - untyped@2.0.0: - resolution: {integrity: sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==} - hasBin: true - - unwasm@0.5.3: - resolution: {integrity: sha512-keBgTSfp3r6+s9ZcSma+0chwxQdmLbB5+dAD9vjtB21UTMYuKAxHXCU1K2CbCtnP09EaWeRvACnXk0EJtUx+hw==} - update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -15534,9 +14561,6 @@ packages: tailwindcss: optional: true - uqr@0.1.2: - resolution: {integrity: sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==} - uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -15635,14 +14659,6 @@ packages: victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} - vinxi@0.5.11: - resolution: {integrity: sha512-82Qm+EG/b2PRFBvXBbz1lgWBGcd9totIL6SJhnrZYfakjloTVG9+5l6gfO6dbCCtztm5pqWFzLY0qpZ3H3ww/w==} - hasBin: true - - vinxi@0.5.3: - resolution: {integrity: sha512-4sL2SMrRzdzClapP44oXdGjCE1oq7/DagsbjY5A09EibmoIO4LP8ScRVdh03lfXxKRk7nCWK7n7dqKvm+fp/9w==} - hasBin: true - vite-tsconfig-paths@5.1.4: resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} peerDependencies: @@ -15656,46 +14672,6 @@ packages: peerDependencies: vite: '*' - vite@6.4.1: - resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - jiti: '>=1.21.0' - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -15943,14 +14919,6 @@ packages: engines: {node: '>=8'} hasBin: true - widest-line@4.0.1: - resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} - engines: {node: '>=12'} - - widest-line@5.0.0: - resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} - engines: {node: '>=18'} - word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -16029,18 +14997,10 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} - wsl-utils@0.3.1: - resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} - engines: {node: '>=20'} - xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} - xmlbuilder2@3.1.1: - resolution: {integrity: sha512-WCSfbfZnQDdLQLiMdGUQpMxxckeQ4oZNMNhLVkcekTu7xhD4tuUDyAPoY8CwXvBYE6LwBHd6QW2WZXlOWr1vCw==} - engines: {node: '>=12.0'} - xmlbuilder2@4.0.3: resolution: {integrity: sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==} engines: {node: '>=20.0'} @@ -16123,9 +15083,6 @@ packages: youch@4.1.0-beta.10: resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} - youch@4.1.1: - resolution: {integrity: sha512-mxW3qiSnl+GRxXsaUMzv2Mbada1Y8CDltET9UxejDQe6DBYlSekghl5U5K0ReAikcHDi0G1vKZEmmo/NWAGKLA==} - zip-stream@6.0.1: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} @@ -17511,12 +16468,6 @@ snapshots: '@nicolo-ribaudo/chokidar-2': 2.1.8-no-fsevents.3 chokidar: 3.6.0 - '@babel/code-frame@7.26.2': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -17604,19 +16555,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-member-expression-to-functions': 7.28.5 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.29.0 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.27.3)': dependencies: '@babel/core': 7.27.3 @@ -17693,15 +16631,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-member-expression-to-functions': 7.28.5 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.29.0 @@ -17975,14 +16904,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 - transitivePeerDependencies: - - supports-color - '@babel/plugin-transform-modules-systemjs@7.29.0(@babel/core@7.27.3)': dependencies: '@babel/core': 7.27.3 @@ -18175,17 +17096,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) - transitivePeerDependencies: - - supports-color - '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.27.3)': dependencies: '@babel/core': 7.27.3 @@ -18314,17 +17224,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) - transitivePeerDependencies: - - supports-color - '@babel/runtime@7.29.2': {} '@babel/template@7.28.6': @@ -18460,13 +17359,6 @@ snapshots: '@date-fns/tz@1.2.0': {} - '@deno/shim-deno-test@0.5.0': {} - - '@deno/shim-deno@0.19.2': - dependencies: - '@deno/shim-deno-test': 0.5.0 - which: 4.0.0 - '@discoveryjs/json-ext@0.5.7': {} '@dnd-kit/accessibility@3.1.1(react@19.2.4)': @@ -18617,9 +17509,6 @@ snapshots: '@esbuild-kit/core-utils': 3.3.2 get-tsconfig: 4.8.1 - '@esbuild/aix-ppc64@0.20.2': - optional: true - '@esbuild/aix-ppc64@0.25.12': optional: true @@ -18632,15 +17521,9 @@ snapshots: '@esbuild/aix-ppc64@0.27.1': optional: true - '@esbuild/aix-ppc64@0.27.4': - optional: true - '@esbuild/android-arm64@0.18.20': optional: true - '@esbuild/android-arm64@0.20.2': - optional: true - '@esbuild/android-arm64@0.25.12': optional: true @@ -18653,15 +17536,9 @@ snapshots: '@esbuild/android-arm64@0.27.1': optional: true - '@esbuild/android-arm64@0.27.4': - optional: true - '@esbuild/android-arm@0.18.20': optional: true - '@esbuild/android-arm@0.20.2': - optional: true - '@esbuild/android-arm@0.25.12': optional: true @@ -18674,15 +17551,9 @@ snapshots: '@esbuild/android-arm@0.27.1': optional: true - '@esbuild/android-arm@0.27.4': - optional: true - '@esbuild/android-x64@0.18.20': optional: true - '@esbuild/android-x64@0.20.2': - optional: true - '@esbuild/android-x64@0.25.12': optional: true @@ -18695,15 +17566,9 @@ snapshots: '@esbuild/android-x64@0.27.1': optional: true - '@esbuild/android-x64@0.27.4': - optional: true - '@esbuild/darwin-arm64@0.18.20': optional: true - '@esbuild/darwin-arm64@0.20.2': - optional: true - '@esbuild/darwin-arm64@0.25.12': optional: true @@ -18716,15 +17581,9 @@ snapshots: '@esbuild/darwin-arm64@0.27.1': optional: true - '@esbuild/darwin-arm64@0.27.4': - optional: true - '@esbuild/darwin-x64@0.18.20': optional: true - '@esbuild/darwin-x64@0.20.2': - optional: true - '@esbuild/darwin-x64@0.25.12': optional: true @@ -18737,15 +17596,9 @@ snapshots: '@esbuild/darwin-x64@0.27.1': optional: true - '@esbuild/darwin-x64@0.27.4': - optional: true - '@esbuild/freebsd-arm64@0.18.20': optional: true - '@esbuild/freebsd-arm64@0.20.2': - optional: true - '@esbuild/freebsd-arm64@0.25.12': optional: true @@ -18758,15 +17611,9 @@ snapshots: '@esbuild/freebsd-arm64@0.27.1': optional: true - '@esbuild/freebsd-arm64@0.27.4': - optional: true - '@esbuild/freebsd-x64@0.18.20': optional: true - '@esbuild/freebsd-x64@0.20.2': - optional: true - '@esbuild/freebsd-x64@0.25.12': optional: true @@ -18779,15 +17626,9 @@ snapshots: '@esbuild/freebsd-x64@0.27.1': optional: true - '@esbuild/freebsd-x64@0.27.4': - optional: true - '@esbuild/linux-arm64@0.18.20': optional: true - '@esbuild/linux-arm64@0.20.2': - optional: true - '@esbuild/linux-arm64@0.25.12': optional: true @@ -18800,15 +17641,9 @@ snapshots: '@esbuild/linux-arm64@0.27.1': optional: true - '@esbuild/linux-arm64@0.27.4': - optional: true - '@esbuild/linux-arm@0.18.20': optional: true - '@esbuild/linux-arm@0.20.2': - optional: true - '@esbuild/linux-arm@0.25.12': optional: true @@ -18821,15 +17656,9 @@ snapshots: '@esbuild/linux-arm@0.27.1': optional: true - '@esbuild/linux-arm@0.27.4': - optional: true - '@esbuild/linux-ia32@0.18.20': optional: true - '@esbuild/linux-ia32@0.20.2': - optional: true - '@esbuild/linux-ia32@0.25.12': optional: true @@ -18842,15 +17671,9 @@ snapshots: '@esbuild/linux-ia32@0.27.1': optional: true - '@esbuild/linux-ia32@0.27.4': - optional: true - '@esbuild/linux-loong64@0.18.20': optional: true - '@esbuild/linux-loong64@0.20.2': - optional: true - '@esbuild/linux-loong64@0.25.12': optional: true @@ -18863,15 +17686,9 @@ snapshots: '@esbuild/linux-loong64@0.27.1': optional: true - '@esbuild/linux-loong64@0.27.4': - optional: true - '@esbuild/linux-mips64el@0.18.20': optional: true - '@esbuild/linux-mips64el@0.20.2': - optional: true - '@esbuild/linux-mips64el@0.25.12': optional: true @@ -18884,15 +17701,9 @@ snapshots: '@esbuild/linux-mips64el@0.27.1': optional: true - '@esbuild/linux-mips64el@0.27.4': - optional: true - '@esbuild/linux-ppc64@0.18.20': optional: true - '@esbuild/linux-ppc64@0.20.2': - optional: true - '@esbuild/linux-ppc64@0.25.12': optional: true @@ -18905,15 +17716,9 @@ snapshots: '@esbuild/linux-ppc64@0.27.1': optional: true - '@esbuild/linux-ppc64@0.27.4': - optional: true - '@esbuild/linux-riscv64@0.18.20': optional: true - '@esbuild/linux-riscv64@0.20.2': - optional: true - '@esbuild/linux-riscv64@0.25.12': optional: true @@ -18926,15 +17731,9 @@ snapshots: '@esbuild/linux-riscv64@0.27.1': optional: true - '@esbuild/linux-riscv64@0.27.4': - optional: true - '@esbuild/linux-s390x@0.18.20': optional: true - '@esbuild/linux-s390x@0.20.2': - optional: true - '@esbuild/linux-s390x@0.25.12': optional: true @@ -18947,15 +17746,9 @@ snapshots: '@esbuild/linux-s390x@0.27.1': optional: true - '@esbuild/linux-s390x@0.27.4': - optional: true - '@esbuild/linux-x64@0.18.20': optional: true - '@esbuild/linux-x64@0.20.2': - optional: true - '@esbuild/linux-x64@0.25.12': optional: true @@ -18968,9 +17761,6 @@ snapshots: '@esbuild/linux-x64@0.27.1': optional: true - '@esbuild/linux-x64@0.27.4': - optional: true - '@esbuild/netbsd-arm64@0.25.12': optional: true @@ -18983,15 +17773,9 @@ snapshots: '@esbuild/netbsd-arm64@0.27.1': optional: true - '@esbuild/netbsd-arm64@0.27.4': - optional: true - '@esbuild/netbsd-x64@0.18.20': optional: true - '@esbuild/netbsd-x64@0.20.2': - optional: true - '@esbuild/netbsd-x64@0.25.12': optional: true @@ -19004,9 +17788,6 @@ snapshots: '@esbuild/netbsd-x64@0.27.1': optional: true - '@esbuild/netbsd-x64@0.27.4': - optional: true - '@esbuild/openbsd-arm64@0.25.12': optional: true @@ -19019,15 +17800,9 @@ snapshots: '@esbuild/openbsd-arm64@0.27.1': optional: true - '@esbuild/openbsd-arm64@0.27.4': - optional: true - '@esbuild/openbsd-x64@0.18.20': optional: true - '@esbuild/openbsd-x64@0.20.2': - optional: true - '@esbuild/openbsd-x64@0.25.12': optional: true @@ -19040,9 +17815,6 @@ snapshots: '@esbuild/openbsd-x64@0.27.1': optional: true - '@esbuild/openbsd-x64@0.27.4': - optional: true - '@esbuild/openharmony-arm64@0.25.12': optional: true @@ -19052,15 +17824,9 @@ snapshots: '@esbuild/openharmony-arm64@0.27.1': optional: true - '@esbuild/openharmony-arm64@0.27.4': - optional: true - '@esbuild/sunos-x64@0.18.20': optional: true - '@esbuild/sunos-x64@0.20.2': - optional: true - '@esbuild/sunos-x64@0.25.12': optional: true @@ -19073,15 +17839,9 @@ snapshots: '@esbuild/sunos-x64@0.27.1': optional: true - '@esbuild/sunos-x64@0.27.4': - optional: true - '@esbuild/win32-arm64@0.18.20': optional: true - '@esbuild/win32-arm64@0.20.2': - optional: true - '@esbuild/win32-arm64@0.25.12': optional: true @@ -19094,15 +17854,9 @@ snapshots: '@esbuild/win32-arm64@0.27.1': optional: true - '@esbuild/win32-arm64@0.27.4': - optional: true - '@esbuild/win32-ia32@0.18.20': optional: true - '@esbuild/win32-ia32@0.20.2': - optional: true - '@esbuild/win32-ia32@0.25.12': optional: true @@ -19115,15 +17869,9 @@ snapshots: '@esbuild/win32-ia32@0.27.1': optional: true - '@esbuild/win32-ia32@0.27.4': - optional: true - '@esbuild/win32-x64@0.18.20': optional: true - '@esbuild/win32-x64@0.20.2': - optional: true - '@esbuild/win32-x64@0.25.12': optional: true @@ -19136,9 +17884,6 @@ snapshots: '@esbuild/win32-x64@0.27.1': optional: true - '@esbuild/win32-x64@0.27.4': - optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': dependencies: eslint: 9.39.2(jiti@2.6.1) @@ -19938,19 +18683,6 @@ snapshots: '@libsql/win32-x64-msvc@0.4.7': optional: true - '@mapbox/node-pre-gyp@2.0.3': - dependencies: - consola: 3.4.2 - detect-libc: 2.1.2 - https-proxy-agent: 7.0.6 - node-fetch: 2.7.0 - nopt: 8.1.0 - semver: 7.7.4 - tar: 7.5.12 - transitivePeerDependencies: - - encoding - - supports-color - '@miniflare/core@2.14.4': dependencies: '@iarna/toml': 2.2.5 @@ -20212,31 +18944,16 @@ snapshots: '@npmcli/redact@4.0.0': {} - '@oozcitak/dom@1.15.10': - dependencies: - '@oozcitak/infra': 1.0.8 - '@oozcitak/url': 1.0.4 - '@oozcitak/util': 8.3.8 - '@oozcitak/dom@2.0.2': dependencies: '@oozcitak/infra': 2.0.2 '@oozcitak/url': 3.0.0 '@oozcitak/util': 10.0.0 - '@oozcitak/infra@1.0.8': - dependencies: - '@oozcitak/util': 8.3.8 - '@oozcitak/infra@2.0.2': dependencies: '@oozcitak/util': 10.0.0 - '@oozcitak/url@1.0.4': - dependencies: - '@oozcitak/infra': 1.0.8 - '@oozcitak/util': 8.3.8 - '@oozcitak/url@3.0.0': dependencies: '@oozcitak/infra': 2.0.2 @@ -20244,8 +18961,6 @@ snapshots: '@oozcitak/util@10.0.0': {} - '@oozcitak/util@8.3.8': {} - '@opennextjs/aws@3.9.14(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))': dependencies: '@ast-grep/napi': 0.40.0 @@ -20854,16 +19569,6 @@ snapshots: '@parcel/watcher-linux-x64-musl@2.5.6': optional: true - '@parcel/watcher-wasm@2.3.0': - dependencies: - is-glob: 4.0.3 - micromatch: 4.0.8 - - '@parcel/watcher-wasm@2.5.6': - dependencies: - is-glob: 4.0.3 - picomatch: 4.0.4 - '@parcel/watcher-win32-arm64@2.5.6': optional: true @@ -20893,6 +19598,7 @@ snapshots: '@parcel/watcher-win32-arm64': 2.5.6 '@parcel/watcher-win32-ia32': 2.5.6 '@parcel/watcher-win32-x64': 2.5.6 + optional: true '@payloadcms/figma@0.0.1-alpha.58(@payloadcms/plugin-cloud-storage@packages+plugin-cloud-storage)(@payloadcms/richtext-lexical@packages+richtext-lexical)(payload@packages+payload)': dependencies: @@ -20943,12 +19649,6 @@ snapshots: '@sindresorhus/is': 7.2.0 supports-color: 10.2.2 - '@poppinss/dumper@0.7.0': - dependencies: - '@poppinss/colors': 4.1.6 - '@sindresorhus/is': 7.2.0 - supports-color: 10.2.2 - '@poppinss/exception@1.2.3': {} '@preact/signals-core@1.14.0': {} @@ -21346,10 +20046,6 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.40': {} - '@rollup/plugin-alias@6.0.0(rollup@4.59.0)': - optionalDependencies: - rollup: 4.59.0 - '@rollup/plugin-commonjs@28.0.1(rollup@3.29.5)': dependencies: '@rollup/pluginutils': 5.3.0(rollup@3.29.5) @@ -21374,57 +20070,6 @@ snapshots: optionalDependencies: rollup: 4.59.0 - '@rollup/plugin-commonjs@29.0.2(rollup@4.59.0)': - dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.59.0) - commondir: 1.0.1 - estree-walker: 2.0.2 - fdir: 6.5.0(picomatch@4.0.4) - is-reference: 1.2.1 - magic-string: 0.30.21 - picomatch: 4.0.4 - optionalDependencies: - rollup: 4.59.0 - - '@rollup/plugin-inject@5.0.5(rollup@4.59.0)': - dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.59.0) - estree-walker: 2.0.2 - magic-string: 0.30.21 - optionalDependencies: - rollup: 4.59.0 - - '@rollup/plugin-json@6.1.0(rollup@4.59.0)': - dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.59.0) - optionalDependencies: - rollup: 4.59.0 - - '@rollup/plugin-node-resolve@16.0.3(rollup@4.59.0)': - dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.59.0) - '@types/resolve': 1.20.2 - deepmerge: 4.3.1 - is-module: 1.0.0 - resolve: 1.22.11 - optionalDependencies: - rollup: 4.59.0 - - '@rollup/plugin-replace@6.0.3(rollup@4.59.0)': - dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.59.0) - magic-string: 0.30.21 - optionalDependencies: - rollup: 4.59.0 - - '@rollup/plugin-terser@1.0.0(rollup@4.59.0)': - dependencies: - serialize-javascript: 7.0.5 - smob: 1.6.1 - terser: 5.46.1 - optionalDependencies: - rollup: 4.59.0 - '@rollup/pluginutils@5.3.0(rollup@3.29.5)': dependencies: '@types/estree': 1.0.8 @@ -22005,8 +20650,6 @@ snapshots: '@sindresorhus/is@7.2.0': {} - '@sindresorhus/merge-streams@4.0.0': {} - '@sindresorhus/slugify@1.1.2': dependencies: '@sindresorhus/transliterate': 0.1.2 @@ -22804,35 +21447,6 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.2.2 - '@tanstack/directive-functions-plugin@1.131.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/core': 7.29.0 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@tanstack/router-utils': 1.131.2 - babel-dead-code-elimination: 1.0.12 - tiny-invariant: 1.3.3 - vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - transitivePeerDependencies: - - supports-color - - '@tanstack/directive-functions-plugin@1.142.1(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/core': 7.29.0 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@tanstack/router-utils': 1.141.0 - babel-dead-code-elimination: 1.0.12 - pathe: 2.0.3 - tiny-invariant: 1.3.3 - vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - transitivePeerDependencies: - - supports-color - - '@tanstack/history@1.131.2': {} - '@tanstack/history@1.161.6': {} '@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': @@ -22852,100 +21466,6 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@tanstack/react-start-plugin@1.131.50(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@vercel/blob@2.3.1)(@vitejs/plugin-react@4.5.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12))': - dependencies: - '@tanstack/start-plugin-core': 1.131.50(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12)) - '@vitejs/plugin-react': 4.5.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - pathe: 2.0.3 - vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - zod: 3.25.76 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@electric-sql/pglite' - - '@libsql/client' - - '@netlify/blobs' - - '@planetscale/database' - - '@rsbuild/core' - - '@tanstack/react-router' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - bare-abort-controller - - bare-buffer - - better-sqlite3 - - drizzle-orm - - encoding - - idb-keyval - - mysql2 - - react-native-b4a - - rolldown - - sqlite3 - - supports-color - - uploadthing - - vite-plugin-solid - - webpack - - xml2js - - '@tanstack/react-start-router-manifest@1.120.19(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)': - dependencies: - '@tanstack/router-core': 1.168.9 - tiny-invariant: 1.3.3 - vinxi: 0.5.3(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@electric-sql/pglite' - - '@libsql/client' - - '@netlify/blobs' - - '@planetscale/database' - - '@types/node' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - bare-abort-controller - - bare-buffer - - better-sqlite3 - - db0 - - debug - - drizzle-orm - - encoding - - idb-keyval - - ioredis - - jiti - - less - - lightningcss - - mysql2 - - react-native-b4a - - rolldown - - sass - - sass-embedded - - sqlite3 - - stylus - - sugarss - - supports-color - - terser - - tsx - - uploadthing - - xml2js - - yaml - '@tanstack/react-start-server@1.166.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/history': 1.161.6 @@ -22978,6 +21498,26 @@ snapshots: - vite-plugin-solid - webpack + '@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12))': + dependencies: + '@tanstack/react-router': 1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-start-client': 1.166.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-start-server': 1.166.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-utils': 1.161.6 + '@tanstack/start-client-core': 1.167.9 + '@tanstack/start-plugin-core': 1.167.17(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12)) + '@tanstack/start-server-core': 1.167.9 + pathe: 2.0.3 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - '@rsbuild/core' + - crossws + - supports-color + - vite-plugin-solid + - webpack + '@tanstack/react-store@0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/store': 0.9.3 @@ -22985,16 +21525,6 @@ snapshots: react-dom: 19.2.4(react@19.2.4) use-sync-external-store: 1.6.0(react@19.2.4) - '@tanstack/router-core@1.131.50': - dependencies: - '@tanstack/history': 1.131.2 - '@tanstack/store': 0.7.7 - cookie-es: 1.2.2 - seroval: 1.5.1 - seroval-plugins: 1.5.1(seroval@1.5.1) - tiny-invariant: 1.3.3 - tiny-warning: 1.0.3 - '@tanstack/router-core@1.168.9': dependencies: '@tanstack/history': 1.161.6 @@ -23002,19 +21532,6 @@ snapshots: seroval: 1.5.1 seroval-plugins: 1.5.1(seroval@1.5.1) - '@tanstack/router-generator@1.131.50': - dependencies: - '@tanstack/router-core': 1.131.50 - '@tanstack/router-utils': 1.131.2 - '@tanstack/virtual-file-routes': 1.131.2 - prettier: 3.5.3 - recast: 0.23.11 - source-map: 0.7.6 - tsx: 4.21.0 - zod: 3.25.76 - transitivePeerDependencies: - - supports-color - '@tanstack/router-generator@1.166.24': dependencies: '@tanstack/router-core': 1.168.9 @@ -23028,29 +21545,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.131.50(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12))': - dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@tanstack/router-core': 1.131.50 - '@tanstack/router-generator': 1.131.50 - '@tanstack/router-utils': 1.131.2 - '@tanstack/virtual-file-routes': 1.131.2 - babel-dead-code-elimination: 1.0.12 - chokidar: 3.6.0 - unplugin: 2.3.11 - zod: 3.25.76 - optionalDependencies: - '@tanstack/react-router': 1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - webpack: 5.105.4(@swc/core@1.15.3)(esbuild@0.25.12) - transitivePeerDependencies: - - supports-color - '@tanstack/router-plugin@1.167.12(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.77.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12))': dependencies: '@babel/core': 7.29.0 @@ -23095,30 +21589,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-utils@1.131.2': - dependencies: - '@babel/core': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/parser': 7.29.2 - '@babel/preset-typescript': 7.27.1(@babel/core@7.29.0) - ansis: 4.2.0 - diff: 8.0.4 - transitivePeerDependencies: - - supports-color - - '@tanstack/router-utils@1.141.0': - dependencies: - '@babel/core': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/parser': 7.29.2 - '@babel/preset-typescript': 7.27.1(@babel/core@7.29.0) - ansis: 4.2.0 - diff: 8.0.4 - pathe: 2.0.3 - tinyglobby: 0.2.15 - transitivePeerDependencies: - - supports-color - '@tanstack/router-utils@1.161.6': dependencies: '@babel/core': 7.29.0 @@ -23133,98 +21603,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/server-functions-plugin@1.131.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/core': 7.29.0 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@tanstack/directive-functions-plugin': 1.131.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - babel-dead-code-elimination: 1.0.12 - tiny-invariant: 1.3.3 - transitivePeerDependencies: - - supports-color - - vite - - '@tanstack/server-functions-plugin@1.142.1(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/core': 7.29.0 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@tanstack/directive-functions-plugin': 1.142.1(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - babel-dead-code-elimination: 1.0.12 - tiny-invariant: 1.3.3 - transitivePeerDependencies: - - supports-color - - vite - - '@tanstack/start-api-routes@1.120.19(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)': - dependencies: - '@tanstack/router-core': 1.168.9 - '@tanstack/start-server-core': 1.167.9 - vinxi: 0.5.3(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@electric-sql/pglite' - - '@libsql/client' - - '@netlify/blobs' - - '@planetscale/database' - - '@types/node' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - bare-abort-controller - - bare-buffer - - better-sqlite3 - - crossws - - db0 - - debug - - drizzle-orm - - encoding - - idb-keyval - - ioredis - - jiti - - less - - lightningcss - - mysql2 - - react-native-b4a - - rolldown - - sass - - sass-embedded - - sqlite3 - - stylus - - sugarss - - supports-color - - terser - - tsx - - uploadthing - - xml2js - - yaml - - '@tanstack/start-client-core@1.131.50': - dependencies: - '@tanstack/router-core': 1.131.50 - '@tanstack/start-storage-context': 1.131.50 - cookie-es: 1.2.2 - tiny-invariant: 1.3.3 - tiny-warning: 1.0.3 - '@tanstack/start-client-core@1.167.9': dependencies: '@tanstack/router-core': 1.168.9 @@ -23232,134 +21610,8 @@ snapshots: '@tanstack/start-storage-context': 1.166.23 seroval: 1.5.1 - '@tanstack/start-config@1.120.20(e1410331fba1110c79d7bc05e246b742)': - dependencies: - '@tanstack/react-router': 1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/react-start-plugin': 1.131.50(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@vercel/blob@2.3.1)(@vitejs/plugin-react@4.5.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12)) - '@tanstack/router-generator': 1.166.24 - '@tanstack/router-plugin': 1.167.12(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12)) - '@tanstack/server-functions-plugin': 1.142.1(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - '@tanstack/start-server-functions-handler': 1.120.19 - '@vitejs/plugin-react': 4.5.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - import-meta-resolve: 4.2.0 - nitropack: 2.13.2(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)) - ofetch: 1.5.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - vinxi: 0.5.3(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - zod: 3.25.76 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@electric-sql/pglite' - - '@libsql/client' - - '@netlify/blobs' - - '@planetscale/database' - - '@rsbuild/core' - - '@types/node' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - bare-abort-controller - - bare-buffer - - better-sqlite3 - - crossws - - db0 - - debug - - drizzle-orm - - encoding - - idb-keyval - - ioredis - - jiti - - less - - lightningcss - - mysql2 - - react-native-b4a - - rolldown - - sass - - sass-embedded - - sqlite3 - - stylus - - sugarss - - supports-color - - terser - - tsx - - uploadthing - - vite-plugin-solid - - webpack - - xml2js - - yaml - '@tanstack/start-fn-stubs@1.161.6': {} - '@tanstack/start-plugin-core@1.131.50(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12))': - dependencies: - '@babel/code-frame': 7.26.2 - '@babel/core': 7.27.3 - '@babel/types': 7.29.0 - '@tanstack/router-core': 1.131.50 - '@tanstack/router-generator': 1.131.50 - '@tanstack/router-plugin': 1.131.50(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12)) - '@tanstack/router-utils': 1.131.2 - '@tanstack/server-functions-plugin': 1.131.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - '@tanstack/start-server-core': 1.131.50 - '@types/babel__code-frame': 7.27.0 - '@types/babel__core': 7.20.5 - babel-dead-code-elimination: 1.0.12 - cheerio: 1.2.0 - h3: 1.13.0 - nitropack: 2.13.2(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)) - pathe: 2.0.3 - ufo: 1.6.3 - vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vitefu: 1.1.3(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - xmlbuilder2: 3.1.1 - zod: 3.25.76 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@electric-sql/pglite' - - '@libsql/client' - - '@netlify/blobs' - - '@planetscale/database' - - '@rsbuild/core' - - '@tanstack/react-router' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - bare-abort-controller - - bare-buffer - - better-sqlite3 - - drizzle-orm - - encoding - - idb-keyval - - mysql2 - - react-native-b4a - - rolldown - - sqlite3 - - supports-color - - uploadthing - - vite-plugin-solid - - webpack - - xml2js - '@tanstack/start-plugin-core@1.167.17(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.77.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12))': dependencies: '@babel/code-frame': 7.27.1 @@ -23392,149 +21644,55 @@ snapshots: - vite-plugin-solid - webpack - '@tanstack/start-server-core@1.131.50': - dependencies: - '@tanstack/history': 1.131.2 - '@tanstack/router-core': 1.131.50 - '@tanstack/start-client-core': 1.131.50 - '@tanstack/start-storage-context': 1.131.50 - h3: 1.13.0 - isbot: 5.1.37 - tiny-invariant: 1.3.3 - tiny-warning: 1.0.3 - unctx: 2.5.0 - - '@tanstack/start-server-core@1.167.9': - dependencies: - '@tanstack/history': 1.161.6 - '@tanstack/router-core': 1.168.9 - '@tanstack/start-client-core': 1.167.9 - '@tanstack/start-storage-context': 1.166.23 - h3-v2: h3@2.0.1-rc.16 - seroval: 1.5.1 - transitivePeerDependencies: - - crossws - - '@tanstack/start-server-functions-client@1.131.50(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': - dependencies: - '@tanstack/server-functions-plugin': 1.131.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - '@tanstack/start-server-functions-fetcher': 1.131.50 - transitivePeerDependencies: - - supports-color - - vite - - '@tanstack/start-server-functions-fetcher@1.131.50': - dependencies: - '@tanstack/router-core': 1.131.50 - '@tanstack/start-client-core': 1.131.50 - - '@tanstack/start-server-functions-handler@1.120.19': + '@tanstack/start-plugin-core@1.167.17(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12))': dependencies: + '@babel/code-frame': 7.27.1 + '@babel/core': 7.29.0 + '@babel/types': 7.29.0 + '@rolldown/pluginutils': 1.0.0-beta.40 '@tanstack/router-core': 1.168.9 + '@tanstack/router-generator': 1.166.24 + '@tanstack/router-plugin': 1.167.12(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(@swc/core@1.15.3)(esbuild@0.25.12)) + '@tanstack/router-utils': 1.161.6 '@tanstack/start-client-core': 1.167.9 '@tanstack/start-server-core': 1.167.9 - tiny-invariant: 1.3.3 + cheerio: 1.2.0 + exsolve: 1.0.8 + pathe: 2.0.3 + picomatch: 4.0.4 + source-map: 0.7.6 + srvx: 0.11.13 + tinyglobby: 0.2.15 + ufo: 1.6.3 + vite: 7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vitefu: 1.1.3(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + xmlbuilder2: 4.0.3 + zod: 3.25.76 transitivePeerDependencies: + - '@rsbuild/core' + - '@tanstack/react-router' - crossws - - '@tanstack/start-server-functions-server@1.131.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': - dependencies: - '@tanstack/server-functions-plugin': 1.131.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - tiny-invariant: 1.3.3 - transitivePeerDependencies: - supports-color - - vite + - vite-plugin-solid + - webpack - '@tanstack/start-server-functions-ssr@1.120.19(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + '@tanstack/start-server-core@1.167.9': dependencies: - '@tanstack/server-functions-plugin': 1.142.1(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@tanstack/history': 1.161.6 + '@tanstack/router-core': 1.168.9 '@tanstack/start-client-core': 1.167.9 - '@tanstack/start-server-core': 1.167.9 - '@tanstack/start-server-functions-fetcher': 1.131.50 - tiny-invariant: 1.3.3 + '@tanstack/start-storage-context': 1.166.23 + h3-v2: h3@2.0.1-rc.16 + seroval: 1.5.1 transitivePeerDependencies: - crossws - - supports-color - - vite - - '@tanstack/start-storage-context@1.131.50': - dependencies: - '@tanstack/router-core': 1.131.50 '@tanstack/start-storage-context@1.166.23': dependencies: '@tanstack/router-core': 1.168.9 - '@tanstack/start@1.120.20(e1410331fba1110c79d7bc05e246b742)': - dependencies: - '@tanstack/react-start-client': 1.166.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/react-start-router-manifest': 1.120.19(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - '@tanstack/react-start-server': 1.166.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/start-api-routes': 1.120.19(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - '@tanstack/start-config': 1.120.20(e1410331fba1110c79d7bc05e246b742) - '@tanstack/start-server-functions-client': 1.131.50(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - '@tanstack/start-server-functions-handler': 1.120.19 - '@tanstack/start-server-functions-server': 1.131.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - '@tanstack/start-server-functions-ssr': 1.120.19(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@electric-sql/pglite' - - '@libsql/client' - - '@netlify/blobs' - - '@planetscale/database' - - '@rsbuild/core' - - '@types/node' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - bare-abort-controller - - bare-buffer - - better-sqlite3 - - crossws - - db0 - - debug - - drizzle-orm - - encoding - - idb-keyval - - ioredis - - jiti - - less - - lightningcss - - mysql2 - - react - - react-dom - - react-native-b4a - - rolldown - - sass - - sass-embedded - - sqlite3 - - stylus - - sugarss - - supports-color - - terser - - tsx - - uploadthing - - vite - - vite-plugin-solid - - webpack - - xml2js - - yaml - - '@tanstack/store@0.7.7': {} - '@tanstack/store@0.9.3': {} - '@tanstack/virtual-file-routes@1.131.2': {} - '@tanstack/virtual-file-routes@1.161.7': {} '@testing-library/dom@10.4.1': @@ -23608,8 +21766,6 @@ snapshots: '@types/aria-query@5.0.4': {} - '@types/babel__code-frame@7.27.0': {} - '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.2 @@ -23635,8 +21791,6 @@ snapshots: dependencies: '@types/node': 22.19.9 - '@types/braces@3.0.5': {} - '@types/busboy@1.5.4': dependencies: '@types/node': 22.19.9 @@ -23757,10 +21911,6 @@ snapshots: dependencies: '@types/unist': 3.0.3 - '@types/micromatch@4.0.10': - dependencies: - '@types/braces': 3.0.5 - '@types/minimatch@6.0.0': dependencies: minimatch: 10.2.4 @@ -23875,8 +22025,6 @@ snapshots: '@types/tough-cookie': 4.0.5 form-data: 2.5.5 - '@types/resolve@1.20.2': {} - '@types/semver@7.7.1': {} '@types/shelljs@0.8.15': @@ -24170,25 +22318,6 @@ snapshots: '@vercel/git-hooks@1.0.0': {} - '@vercel/nft@1.5.0(rollup@4.59.0)': - dependencies: - '@mapbox/node-pre-gyp': 2.0.3 - '@rollup/pluginutils': 5.3.0(rollup@4.59.0) - acorn: 8.16.0 - acorn-import-attributes: 1.9.5(acorn@8.16.0) - async-sema: 3.1.1 - bindings: 1.5.0 - estree-walker: 2.0.2 - glob: 13.0.6 - graceful-fs: 4.2.11 - node-gyp-build: 4.8.4 - picomatch: 4.0.4 - resolve-from: 5.0.0 - transitivePeerDependencies: - - encoding - - rollup - - supports-color - '@vercel/oidc@3.1.0': {} '@vercel/postgres@0.9.0': @@ -24198,26 +22327,6 @@ snapshots: utf-8-validate: 6.0.6 ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - '@vinxi/listhen@1.5.6': - dependencies: - '@parcel/watcher': 2.5.6 - '@parcel/watcher-wasm': 2.3.0 - citty: 0.1.6 - clipboardy: 4.0.0 - consola: 3.4.2 - defu: 6.1.4 - get-port-please: 3.2.0 - h3: 1.15.3 - http-shutdown: 1.2.2 - jiti: 1.21.7 - mlly: 1.8.2 - node-forge: 1.4.0 - pathe: 1.1.2 - std-env: 3.10.0 - ufo: 1.6.3 - untun: 0.1.3 - uqr: 0.1.2 - '@vitejs/plugin-react@4.5.2(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.77.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 @@ -24579,8 +22688,6 @@ snapshots: abbrev@1.1.1: {} - abbrev@3.0.1: {} - abbrev@4.0.0: {} abort-controller@3.0.0: @@ -24672,10 +22779,6 @@ snapshots: transitivePeerDependencies: - encoding - ansi-align@3.0.1: - dependencies: - string-width: 4.2.3 - ansi-colors@4.1.3: {} ansi-escapes@4.3.2: @@ -24737,10 +22840,6 @@ snapshots: arg@5.0.2: {} - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 - argparse@2.0.1: {} aria-hidden@1.2.6: @@ -24850,8 +22949,6 @@ snapshots: dependencies: retry: 0.13.1 - async-sema@3.1.1: {} - async@3.2.6: {} asynckit@0.4.0: {} @@ -25034,28 +23131,6 @@ snapshots: bowser@2.14.1: {} - boxen@7.1.1: - dependencies: - ansi-align: 3.0.1 - camelcase: 7.0.1 - chalk: 5.6.2 - cli-boxes: 3.0.0 - string-width: 5.1.2 - type-fest: 2.19.0 - widest-line: 4.0.1 - wrap-ansi: 8.1.0 - - boxen@8.0.1: - dependencies: - ansi-align: 3.0.1 - camelcase: 8.0.0 - chalk: 5.6.2 - cli-boxes: 3.0.0 - string-width: 7.2.0 - type-fest: 4.41.0 - widest-line: 5.0.0 - wrap-ansi: 9.0.2 - brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -25203,10 +23278,6 @@ snapshots: camelcase@6.3.0: {} - camelcase@7.0.1: {} - - camelcase@8.0.0: {} - caniuse-lite@1.0.30001780: {} ccount@2.0.1: {} @@ -25299,6 +23370,7 @@ snapshots: chokidar@4.0.3: dependencies: readdirp: 4.1.2 + optional: true chokidar@5.0.0: dependencies: @@ -25326,8 +23398,6 @@ snapshots: clean-stack@2.2.0: {} - cli-boxes@3.0.0: {} - cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -25339,12 +23409,6 @@ snapshots: client-only@0.0.1: {} - clipboardy@4.0.0: - dependencies: - execa: 8.0.1 - is-wsl: 3.1.1 - is64bit: 2.0.0 - cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -25430,8 +23494,6 @@ snapshots: compare-versions@6.1.1: {} - compatx@0.2.0: {} - compress-commons@6.0.2: dependencies: crc-32: 1.2.2 @@ -25456,8 +23518,6 @@ snapshots: semver: 7.7.4 uint8array-extras: 1.5.0 - confbox@0.1.8: {} - confbox@0.2.4: {} consola@3.4.2: {} @@ -25480,12 +23540,11 @@ snapshots: convert-source-map@2.0.0: {} - cookie-es@1.2.2: {} + cookie-es@1.2.2: + optional: true cookie-es@2.0.0: {} - cookie-es@3.1.1: {} - cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -25537,8 +23596,6 @@ snapshots: crc-32: 1.2.2 readable-stream: 4.7.0 - croner@10.0.1: {} - croner@9.1.0: {} cross-env@7.0.3: @@ -25554,6 +23611,7 @@ snapshots: crossws@0.3.5: dependencies: uncrypto: 0.1.3 + optional: true crypt@0.0.2: {} @@ -25670,32 +23728,12 @@ snapshots: dateformat@4.6.3: {} - dax-sh@0.39.2: - dependencies: - '@deno/shim-deno': 0.19.2 - undici-types: 5.26.5 - - dax-sh@0.43.2: - dependencies: - '@deno/shim-deno': 0.19.2 - undici-types: 5.26.5 - - db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)): - optionalDependencies: - '@libsql/client': 0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - better-sqlite3: 11.10.0 - drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3) - debounce-fn@6.0.0: dependencies: mimic-function: 5.0.1 debounce@1.2.1: {} - debug@2.6.9: - dependencies: - ms: 2.0.0 - debug@3.2.7: dependencies: ms: 2.1.3 @@ -25776,8 +23814,6 @@ snapshots: destr@2.0.5: {} - destroy@1.2.0: {} - detect-file@1.0.0: {} detect-indent@7.0.2: {} @@ -25844,10 +23880,6 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 - dot-prop@10.1.0: - dependencies: - type-fest: 5.5.0 - dot-prop@9.0.0: dependencies: type-fest: 4.41.0 @@ -26134,32 +24166,6 @@ snapshots: '@esbuild/win32-ia32': 0.18.20 '@esbuild/win32-x64': 0.18.20 - esbuild@0.20.2: - optionalDependencies: - '@esbuild/aix-ppc64': 0.20.2 - '@esbuild/android-arm': 0.20.2 - '@esbuild/android-arm64': 0.20.2 - '@esbuild/android-x64': 0.20.2 - '@esbuild/darwin-arm64': 0.20.2 - '@esbuild/darwin-x64': 0.20.2 - '@esbuild/freebsd-arm64': 0.20.2 - '@esbuild/freebsd-x64': 0.20.2 - '@esbuild/linux-arm': 0.20.2 - '@esbuild/linux-arm64': 0.20.2 - '@esbuild/linux-ia32': 0.20.2 - '@esbuild/linux-loong64': 0.20.2 - '@esbuild/linux-mips64el': 0.20.2 - '@esbuild/linux-ppc64': 0.20.2 - '@esbuild/linux-riscv64': 0.20.2 - '@esbuild/linux-s390x': 0.20.2 - '@esbuild/linux-x64': 0.20.2 - '@esbuild/netbsd-x64': 0.20.2 - '@esbuild/openbsd-x64': 0.20.2 - '@esbuild/sunos-x64': 0.20.2 - '@esbuild/win32-arm64': 0.20.2 - '@esbuild/win32-ia32': 0.20.2 - '@esbuild/win32-x64': 0.20.2 - esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -26275,35 +24281,6 @@ snapshots: '@esbuild/win32-ia32': 0.27.1 '@esbuild/win32-x64': 0.27.1 - esbuild@0.27.4: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.4 - '@esbuild/android-arm': 0.27.4 - '@esbuild/android-arm64': 0.27.4 - '@esbuild/android-x64': 0.27.4 - '@esbuild/darwin-arm64': 0.27.4 - '@esbuild/darwin-x64': 0.27.4 - '@esbuild/freebsd-arm64': 0.27.4 - '@esbuild/freebsd-x64': 0.27.4 - '@esbuild/linux-arm': 0.27.4 - '@esbuild/linux-arm64': 0.27.4 - '@esbuild/linux-ia32': 0.27.4 - '@esbuild/linux-loong64': 0.27.4 - '@esbuild/linux-mips64el': 0.27.4 - '@esbuild/linux-ppc64': 0.27.4 - '@esbuild/linux-riscv64': 0.27.4 - '@esbuild/linux-s390x': 0.27.4 - '@esbuild/linux-x64': 0.27.4 - '@esbuild/netbsd-arm64': 0.27.4 - '@esbuild/netbsd-x64': 0.27.4 - '@esbuild/openbsd-arm64': 0.27.4 - '@esbuild/openbsd-x64': 0.27.4 - '@esbuild/openharmony-arm64': 0.27.4 - '@esbuild/sunos-x64': 0.27.4 - '@esbuild/win32-arm64': 0.27.4 - '@esbuild/win32-ia32': 0.27.4 - '@esbuild/win32-x64': 0.27.4 - escalade@3.2.0: {} escape-html@1.0.3: {} @@ -26937,8 +24914,6 @@ snapshots: event-target-shim@5.0.1: {} - eventemitter3@4.0.7: {} - eventemitter3@5.0.4: {} events-universal@1.0.1: @@ -27279,8 +25254,6 @@ snapshots: fraction.js@5.3.4: {} - fresh@0.5.2: {} - fresh@2.0.0: {} fs-constants@1.0.0: {} @@ -27374,8 +25347,6 @@ snapshots: get-nonce@1.0.1: {} - get-port-please@3.2.0: {} - get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -27519,15 +25490,6 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 - globby@16.2.0: - dependencies: - '@sindresorhus/merge-streams': 4.0.0 - fast-glob: 3.3.3 - ignore: 7.0.5 - is-path-inside: 4.0.0 - slash: 5.1.0 - unicorn-magic: 0.4.0 - globjoin@0.1.4: {} globrex@0.1.2: {} @@ -27593,23 +25555,6 @@ snapshots: dependencies: duplexer: 0.1.2 - gzip-size@7.0.0: - dependencies: - duplexer: 0.1.2 - - h3@1.13.0: - dependencies: - cookie-es: 1.2.2 - crossws: 0.3.5 - defu: 6.1.4 - destr: 2.0.5 - iron-webcrypto: 1.2.1 - ohash: 1.1.6 - radix3: 1.1.2 - ufo: 1.6.3 - uncrypto: 0.1.3 - unenv: 1.10.0 - h3@1.15.10: dependencies: cookie-es: 1.2.2 @@ -27621,18 +25566,7 @@ snapshots: radix3: 1.1.2 ufo: 1.6.3 uncrypto: 0.1.3 - - h3@1.15.3: - dependencies: - cookie-es: 1.2.2 - crossws: 0.3.5 - defu: 6.1.4 - destr: 2.0.5 - iron-webcrypto: 1.2.1 - node-mock-http: 1.0.4 - radix3: 1.1.2 - ufo: 1.6.3 - uncrypto: 0.1.3 + optional: true h3@2.0.1-rc.16: dependencies: @@ -27699,8 +25633,6 @@ snapshots: hono@4.12.8: {} - hookable@5.5.3: {} - hookified@1.15.1: {} hookified@2.1.0: {} @@ -27751,16 +25683,6 @@ snapshots: transitivePeerDependencies: - supports-color - http-proxy@1.18.1: - dependencies: - eventemitter3: 4.0.7 - follow-redirects: 1.15.11(debug@4.4.3) - requires-port: 1.0.0 - transitivePeerDependencies: - - debug - - http-shutdown@1.2.2: {} - http-status@2.1.0: {} http2-wrapper@2.2.1: @@ -27782,8 +25704,6 @@ snapshots: transitivePeerDependencies: - supports-color - httpxy@0.3.1: {} - human-signals@2.1.0: {} human-signals@3.0.1: {} @@ -27836,8 +25756,6 @@ snapshots: cjs-module-lexer: 1.4.3 module-details-from-path: 1.0.4 - import-meta-resolve@4.2.0: {} - imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -27885,7 +25803,8 @@ snapshots: ipaddr.js@2.2.0: {} - iron-webcrypto@1.2.1: {} + iron-webcrypto@1.2.1: + optional: true is-alphabetical@2.0.1: {} @@ -27996,16 +25915,12 @@ snapshots: transitivePeerDependencies: - supports-color - is-in-ssh@1.0.0: {} - is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 is-map@2.0.3: {} - is-module@1.0.0: {} - is-negative-zero@2.0.3: {} is-node-process@1.2.0: {} @@ -28021,8 +25936,6 @@ snapshots: is-path-inside@3.0.3: {} - is-path-inside@4.0.0: {} - is-plain-obj@1.1.0: {} is-plain-obj@4.1.0: {} @@ -28088,10 +26001,6 @@ snapshots: dependencies: is-inside-container: 1.0.0 - is64bit@2.0.0: - dependencies: - system-architecture: 0.1.0 - isarray@0.0.1: {} isarray@1.0.0: {} @@ -28140,8 +26049,6 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jiti@1.21.7: {} - jiti@2.6.1: {} jose@5.9.6: {} @@ -28158,13 +26065,6 @@ snapshots: js-tokens@4.0.0: {} - js-tokens@9.0.1: {} - - js-yaml@3.14.1: - dependencies: - argparse: 1.0.10 - esprima: 4.0.1 - js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -28312,10 +26212,6 @@ snapshots: kleur@4.1.5: {} - klona@2.0.6: {} - - knitwork@1.3.0: {} - known-css-properties@0.37.0: {} language-subtag-registry@0.3.23: {} @@ -28439,27 +26335,6 @@ snapshots: transitivePeerDependencies: - supports-color - listhen@1.9.0: - dependencies: - '@parcel/watcher': 2.5.6 - '@parcel/watcher-wasm': 2.5.6 - citty: 0.1.6 - clipboardy: 4.0.0 - consola: 3.4.2 - crossws: 0.3.5 - defu: 6.1.4 - get-port-please: 3.2.0 - h3: 1.15.10 - http-shutdown: 1.2.2 - jiti: 2.6.1 - mlly: 1.8.2 - node-forge: 1.4.0 - pathe: 1.1.2 - std-env: 3.10.0 - ufo: 1.6.3 - untun: 0.1.3 - uqr: 0.1.2 - listr2@8.2.5: dependencies: cli-truncate: 4.0.0 @@ -28471,12 +26346,6 @@ snapshots: loader-runner@4.3.1: {} - local-pkg@1.1.2: - dependencies: - mlly: 1.8.2 - pkg-types: 2.3.0 - quansync: 0.2.11 - localforage@1.10.0: dependencies: lie: 3.1.1 @@ -28562,6 +26431,7 @@ snapshots: '@babel/parser': 7.29.2 '@babel/types': 7.29.0 source-map-js: 1.2.1 + optional: true make-dir@2.1.0: dependencies: @@ -28869,12 +26739,8 @@ snapshots: dependencies: mime-db: 1.54.0 - mime@1.6.0: {} - mime@3.0.0: {} - mime@4.1.0: {} - mimic-fn@2.1.0: {} mimic-fn@4.0.0: {} @@ -28965,13 +26831,6 @@ snapshots: mkdirp@3.0.1: {} - mlly@1.8.2: - dependencies: - acorn: 8.16.0 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.6.3 - mnemonist@0.38.3: dependencies: obliterator: 1.6.1 @@ -29074,8 +26933,6 @@ snapshots: mrmime@2.0.1: {} - ms@2.0.0: {} - ms@2.1.3: {} multipasta@0.2.7: {} @@ -29153,124 +27010,21 @@ snapshots: styled-jsx: 5.1.6(@babel/core@7.27.3)(react@19.2.4) optionalDependencies: '@next/swc-darwin-arm64': 16.2.1 - '@next/swc-darwin-x64': 16.2.1 - '@next/swc-linux-arm64-gnu': 16.2.1 - '@next/swc-linux-arm64-musl': 16.2.1 - '@next/swc-linux-x64-gnu': 16.2.1 - '@next/swc-linux-x64-musl': 16.2.1 - '@next/swc-win32-arm64-msvc': 16.2.1 - '@next/swc-win32-x64-msvc': 16.2.1 - '@opentelemetry/api': 1.9.0 - '@playwright/test': 1.58.2 - babel-plugin-react-compiler: 19.1.0-rc.3 - sass: 1.98.0 - sharp: 0.34.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - - nitropack@2.13.2(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)): - dependencies: - '@cloudflare/kv-asset-handler': 0.4.2 - '@rollup/plugin-alias': 6.0.0(rollup@4.59.0) - '@rollup/plugin-commonjs': 29.0.2(rollup@4.59.0) - '@rollup/plugin-inject': 5.0.5(rollup@4.59.0) - '@rollup/plugin-json': 6.1.0(rollup@4.59.0) - '@rollup/plugin-node-resolve': 16.0.3(rollup@4.59.0) - '@rollup/plugin-replace': 6.0.3(rollup@4.59.0) - '@rollup/plugin-terser': 1.0.0(rollup@4.59.0) - '@vercel/nft': 1.5.0(rollup@4.59.0) - archiver: 7.0.1 - c12: 3.3.3(magicast@0.5.2) - chokidar: 5.0.0 - citty: 0.2.1 - compatx: 0.2.0 - confbox: 0.2.4 - consola: 3.4.2 - cookie-es: 2.0.0 - croner: 10.0.1 - crossws: 0.3.5 - db0: 0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)) - defu: 6.1.4 - destr: 2.0.5 - dot-prop: 10.1.0 - esbuild: 0.27.4 - escape-string-regexp: 5.0.0 - etag: 1.8.1 - exsolve: 1.0.8 - globby: 16.2.0 - gzip-size: 7.0.0 - h3: 1.15.10 - hookable: 5.5.3 - httpxy: 0.3.1 - ioredis: 5.10.1 - jiti: 2.6.1 - klona: 2.0.6 - knitwork: 1.3.0 - listhen: 1.9.0 - magic-string: 0.30.21 - magicast: 0.5.2 - mime: 4.1.0 - mlly: 1.8.2 - node-fetch-native: 1.6.7 - node-mock-http: 1.0.4 - ofetch: 1.5.1 - ohash: 2.0.11 - pathe: 2.0.3 - perfect-debounce: 2.1.0 - pkg-types: 2.3.0 - pretty-bytes: 7.1.0 - radix3: 1.1.2 - rollup: 4.59.0 - rollup-plugin-visualizer: 7.0.1(rollup@4.59.0) - scule: 1.3.0 - semver: 7.7.4 - serve-placeholder: 2.0.2 - serve-static: 2.2.1 - source-map: 0.7.6 - std-env: 4.0.0 - ufo: 1.6.3 - ultrahtml: 1.6.0 - uncrypto: 0.1.3 - unctx: 2.5.0 - unenv: 2.0.0-rc.24 - unimport: 6.0.2 - unplugin-utils: 0.3.1 - unstorage: 1.17.5(@azure/storage-blob@12.31.0)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(ioredis@5.10.1) - untyped: 2.0.0 - unwasm: 0.5.3 - youch: 4.1.1 - youch-core: 0.3.3 + '@next/swc-darwin-x64': 16.2.1 + '@next/swc-linux-arm64-gnu': 16.2.1 + '@next/swc-linux-arm64-musl': 16.2.1 + '@next/swc-linux-x64-gnu': 16.2.1 + '@next/swc-linux-x64-musl': 16.2.1 + '@next/swc-win32-arm64-msvc': 16.2.1 + '@next/swc-win32-x64-msvc': 16.2.1 + '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.58.2 + babel-plugin-react-compiler: 19.1.0-rc.3 + sass: 1.98.0 + sharp: 0.34.5 transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@electric-sql/pglite' - - '@libsql/client' - - '@netlify/blobs' - - '@planetscale/database' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - bare-abort-controller - - bare-buffer - - better-sqlite3 - - drizzle-orm - - encoding - - idb-keyval - - mysql2 - - react-native-b4a - - rolldown - - sqlite3 - - supports-color - - uploadthing + - '@babel/core' + - babel-plugin-macros node-abi@3.89.0: dependencies: @@ -29278,7 +27032,8 @@ snapshots: node-addon-api@6.1.0: {} - node-addon-api@7.1.1: {} + node-addon-api@7.1.1: + optional: true node-domexception@1.0.0: {} @@ -29301,8 +27056,6 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-forge@1.4.0: {} - node-gyp-build@4.8.4: {} node-gyp@12.2.0: @@ -29320,7 +27073,8 @@ snapshots: transitivePeerDependencies: - supports-color - node-mock-http@1.0.4: {} + node-mock-http@1.0.4: + optional: true node-releases@2.0.36: {} @@ -29336,10 +27090,6 @@ snapshots: abbrev: 1.1.1 osenv: 0.1.5 - nopt@8.1.0: - dependencies: - abbrev: 3.0.1 - nopt@9.0.0: dependencies: abbrev: 4.0.0 @@ -29440,8 +27190,6 @@ snapshots: node-fetch-native: 1.6.7 ufo: 1.6.3 - ohash@1.1.6: {} - ohash@2.0.11: {} on-exit-leak-free@2.1.2: {} @@ -29473,15 +27221,6 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 - open@11.0.0: - dependencies: - default-browser: 5.5.0 - define-lazy-prop: 3.0.0 - is-in-ssh: 1.0.0 - is-inside-container: 1.0.0 - powershell-utils: 0.1.0 - wsl-utils: 0.3.1 - openapi-fetch@0.15.0: dependencies: openapi-typescript-helpers: 0.0.15 @@ -29643,8 +27382,6 @@ snapshots: path-type@4.0.0: {} - pathe@1.1.2: {} - pathe@2.0.3: {} peek-readable@5.4.2: {} @@ -29758,12 +27495,6 @@ snapshots: dependencies: find-up: 4.1.0 - pkg-types@1.3.1: - dependencies: - confbox: 0.1.8 - mlly: 1.8.2 - pathe: 2.0.3 - pkg-types@2.3.0: dependencies: confbox: 0.2.4 @@ -29838,8 +27569,6 @@ snapshots: postgres-range@1.1.4: {} - powershell-utils@0.1.0: {} - prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -29863,8 +27592,6 @@ snapshots: prettier@3.5.3: {} - pretty-bytes@7.1.0: {} - pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -29928,15 +27655,14 @@ snapshots: dependencies: side-channel: 1.1.0 - quansync@0.2.11: {} - queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} quick-lru@5.1.1: {} - radix3@1.1.2: {} + radix3@1.1.2: + optional: true range-parser@1.2.1: {} @@ -30123,7 +27849,8 @@ snapshots: dependencies: picomatch: 2.3.2 - readdirp@4.1.2: {} + readdirp@4.1.2: + optional: true readdirp@5.0.0: {} @@ -30244,8 +27971,6 @@ snapshots: transitivePeerDependencies: - supports-color - requires-port@1.0.0: {} - reselect@5.1.1: {} resolve-alpn@1.2.1: {} @@ -30323,15 +28048,6 @@ snapshots: optionalDependencies: '@babel/code-frame': 7.29.0 - rollup-plugin-visualizer@7.0.1(rollup@4.59.0): - dependencies: - open: 11.0.0 - picomatch: 4.0.4 - source-map: 0.7.6 - yargs: 18.0.0 - optionalDependencies: - rollup: 4.59.0 - rollup@3.29.5: optionalDependencies: fsevents: 2.3.3 @@ -30569,24 +28285,6 @@ snapshots: semver@7.7.4: {} - send@0.19.2: - dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 0.5.2 - http-errors: 2.0.1 - mime: 1.6.0 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - send@1.2.1: dependencies: debug: 4.4.3 @@ -30603,27 +28301,12 @@ snapshots: transitivePeerDependencies: - supports-color - serialize-javascript@7.0.5: {} - seroval-plugins@1.5.1(seroval@1.5.1): dependencies: seroval: 1.5.1 seroval@1.5.1: {} - serve-placeholder@2.0.2: - dependencies: - defu: 6.1.4 - - serve-static@1.16.3: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 0.19.2 - transitivePeerDependencies: - - supports-color - serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -30817,8 +28500,6 @@ snapshots: slash@3.0.0: {} - slash@5.1.0: {} - slate-history@0.86.0(slate@0.91.4): dependencies: is-plain-object: 5.0.0 @@ -30870,8 +28551,6 @@ snapshots: smart-buffer@4.2.0: {} - smob@1.6.1: {} - socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 @@ -30962,8 +28641,6 @@ snapshots: split2@4.2.0: {} - sprintf-js@1.0.3: {} - sqids@0.3.0: {} srvx@0.11.13: {} @@ -30990,8 +28667,6 @@ snapshots: std-env@3.10.0: {} - std-env@4.0.0: {} - stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -31131,10 +28806,6 @@ snapshots: strip-json-comments@5.0.3: {} - strip-literal@3.1.0: - dependencies: - js-tokens: 9.0.1 - stripe@10.17.0: dependencies: '@types/node': 22.19.9 @@ -31270,8 +28941,6 @@ snapshots: sync-message-port@1.2.0: {} - system-architecture@0.1.0: {} - tabbable@6.4.0: {} table@6.9.0: @@ -31282,8 +28951,6 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - tagged-tag@1.0.0: {} - tailwind-merge@3.5.0: {} tailwindcss@4.1.18: {} @@ -31562,14 +29229,8 @@ snapshots: type-fest@0.7.1: {} - type-fest@2.19.0: {} - type-fest@4.41.0: {} - type-fest@5.5.0: - dependencies: - tagged-tag: 1.0.0 - type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -31636,8 +29297,6 @@ snapshots: uint8array-extras@1.5.0: {} - ultrahtml@1.6.0: {} - unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -31650,14 +29309,8 @@ snapshots: buffer: 5.7.1 through: 2.3.8 - uncrypto@0.1.3: {} - - unctx@2.5.0: - dependencies: - acorn: 8.16.0 - estree-walker: 3.0.3 - magic-string: 0.30.21 - unplugin: 2.3.11 + uncrypto@0.1.3: + optional: true undici-types@5.26.5: {} @@ -31673,14 +29326,6 @@ snapshots: undici@7.24.4: {} - unenv@1.10.0: - dependencies: - consola: 3.4.2 - defu: 6.1.4 - mime: 3.0.0 - node-fetch-native: 1.6.7 - pathe: 1.1.2 - unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -31698,25 +29343,6 @@ snapshots: unicode-property-aliases-ecmascript@2.2.0: {} - unicorn-magic@0.4.0: {} - - unimport@6.0.2: - dependencies: - acorn: 8.16.0 - escape-string-regexp: 5.0.0 - estree-walker: 3.0.3 - local-pkg: 1.1.2 - magic-string: 0.30.21 - mlly: 1.8.2 - pathe: 2.0.3 - picomatch: 4.0.4 - pkg-types: 2.3.0 - scule: 1.3.0 - strip-literal: 3.1.0 - tinyglobby: 0.2.15 - unplugin: 3.0.0 - unplugin-utils: 0.3.1 - unique-string@2.0.0: dependencies: crypto-random-string: 2.0.0 @@ -31748,11 +29374,6 @@ snapshots: unpipe@1.0.0: {} - unplugin-utils@0.3.1: - dependencies: - pathe: 2.0.3 - picomatch: 4.0.4 - unplugin@1.0.1: dependencies: acorn: 8.16.0 @@ -31767,12 +29388,6 @@ snapshots: picomatch: 4.0.4 webpack-virtual-modules: 0.6.2 - unplugin@3.0.0: - dependencies: - '@jridgewell/remapping': 2.3.5 - picomatch: 4.0.4 - webpack-virtual-modules: 0.6.2 - unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.4 @@ -31797,48 +29412,8 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - unstorage@1.17.5(@azure/storage-blob@12.31.0)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(ioredis@5.10.1): - dependencies: - anymatch: 3.1.3 - chokidar: 5.0.0 - destr: 2.0.5 - h3: 1.15.10 - lru-cache: 11.2.7 - node-fetch-native: 1.6.7 - ofetch: 1.5.1 - ufo: 1.6.3 - optionalDependencies: - '@azure/storage-blob': 12.31.0 - '@vercel/blob': 2.3.1 - aws4fetch: 1.0.20 - db0: 0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)) - ioredis: 5.10.1 - untildify@4.0.0: {} - untun@0.1.3: - dependencies: - citty: 0.1.6 - consola: 3.4.2 - pathe: 1.1.2 - - untyped@2.0.0: - dependencies: - citty: 0.1.6 - defu: 6.1.4 - jiti: 2.6.1 - knitwork: 1.3.0 - scule: 1.3.0 - - unwasm@0.5.3: - dependencies: - exsolve: 1.0.8 - knitwork: 1.3.0 - magic-string: 0.30.21 - mlly: 1.8.2 - pathe: 2.0.3 - pkg-types: 2.3.0 - update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -31857,8 +29432,6 @@ snapshots: next: 16.2.1(@babel/core@7.27.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) tailwindcss: 4.2.2 - uqr@0.1.2: {} - uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -31950,170 +29523,6 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vinxi@0.5.11(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): - dependencies: - '@babel/core': 7.27.3 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.27.3) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.27.3) - '@types/micromatch': 4.0.10 - '@vinxi/listhen': 1.5.6 - boxen: 8.0.1 - chokidar: 4.0.3 - citty: 0.1.6 - consola: 3.4.2 - crossws: 0.3.5 - dax-sh: 0.43.2 - defu: 6.1.4 - es-module-lexer: 1.7.0 - esbuild: 0.25.12 - get-port-please: 3.2.0 - h3: 1.15.3 - hookable: 5.5.3 - http-proxy: 1.18.1 - micromatch: 4.0.8 - nitropack: 2.13.2(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)) - node-fetch-native: 1.6.7 - path-to-regexp: 6.3.0 - pathe: 1.1.2 - radix3: 1.1.2 - resolve: 1.22.11 - serve-placeholder: 2.0.2 - serve-static: 1.16.3 - tinyglobby: 0.2.15 - ufo: 1.6.3 - unctx: 2.5.0 - unenv: 1.10.0 - unstorage: 1.17.5(@azure/storage-blob@12.31.0)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(ioredis@5.10.1) - vite: 6.4.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - zod: 4.3.6 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@electric-sql/pglite' - - '@libsql/client' - - '@netlify/blobs' - - '@planetscale/database' - - '@types/node' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - bare-abort-controller - - bare-buffer - - better-sqlite3 - - db0 - - debug - - drizzle-orm - - encoding - - idb-keyval - - ioredis - - jiti - - less - - lightningcss - - mysql2 - - react-native-b4a - - rolldown - - sass - - sass-embedded - - sqlite3 - - stylus - - sugarss - - supports-color - - terser - - tsx - - uploadthing - - xml2js - - yaml - - vinxi@0.5.3(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/node@22.19.9)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3))(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): - dependencies: - '@babel/core': 7.27.3 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.27.3) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.27.3) - '@types/micromatch': 4.0.10 - '@vinxi/listhen': 1.5.6 - boxen: 7.1.1 - chokidar: 3.6.0 - citty: 0.1.6 - consola: 3.4.2 - crossws: 0.3.5 - dax-sh: 0.39.2 - defu: 6.1.4 - es-module-lexer: 1.7.0 - esbuild: 0.20.2 - fast-glob: 3.3.3 - get-port-please: 3.2.0 - h3: 1.13.0 - hookable: 5.5.3 - http-proxy: 1.18.1 - micromatch: 4.0.8 - nitropack: 2.13.2(@azure/storage-blob@12.31.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)) - node-fetch-native: 1.6.7 - path-to-regexp: 6.3.0 - pathe: 1.1.2 - radix3: 1.1.2 - resolve: 1.22.11 - serve-placeholder: 2.0.2 - serve-static: 1.16.3 - ufo: 1.6.3 - unctx: 2.5.0 - unenv: 1.10.0 - unstorage: 1.17.5(@azure/storage-blob@12.31.0)(@vercel/blob@2.3.1)(aws4fetch@1.0.20)(db0@0.3.4(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260218.0)(@libsql/client@0.14.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.11.6)(@vercel/postgres@0.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)))(ioredis@5.10.1) - vite: 6.4.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - zod: 3.25.76 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@electric-sql/pglite' - - '@libsql/client' - - '@netlify/blobs' - - '@planetscale/database' - - '@types/node' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - bare-abort-controller - - bare-buffer - - better-sqlite3 - - db0 - - debug - - drizzle-orm - - encoding - - idb-keyval - - ioredis - - jiti - - less - - lightningcss - - mysql2 - - react-native-b4a - - rolldown - - sass - - sass-embedded - - sqlite3 - - stylus - - sugarss - - supports-color - - terser - - tsx - - uploadthing - - xml2js - - yaml - vite-tsconfig-paths@5.1.4(typescript@5.7.3)(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.77.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: debug: 4.4.3 @@ -32135,25 +29544,6 @@ snapshots: - supports-color - typescript - vite@6.4.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.8 - rollup: 4.59.0 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 22.19.9 - fsevents: 2.3.3 - jiti: 2.6.1 - lightningcss: 1.30.2 - sass: 1.98.0 - sass-embedded: 1.98.0 - terser: 5.46.1 - tsx: 4.21.0 - yaml: 2.8.3 - vite@7.3.1(@types/node@22.15.30)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.27.1 @@ -32520,14 +29910,6 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - widest-line@4.0.1: - dependencies: - string-width: 5.1.2 - - widest-line@5.0.0: - dependencies: - string-width: 7.2.0 - word-wrap@1.2.5: {} workerd@1.20260128.0: @@ -32599,20 +29981,8 @@ snapshots: dependencies: is-wsl: 3.1.1 - wsl-utils@0.3.1: - dependencies: - is-wsl: 3.1.1 - powershell-utils: 0.1.0 - xml-name-validator@5.0.0: {} - xmlbuilder2@3.1.1: - dependencies: - '@oozcitak/dom': 1.15.10 - '@oozcitak/infra': 1.0.8 - '@oozcitak/util': 8.3.8 - js-yaml: 3.14.1 - xmlbuilder2@4.0.3: dependencies: '@oozcitak/dom': 2.0.2 @@ -32692,14 +30062,6 @@ snapshots: cookie: 1.1.1 youch-core: 0.3.3 - youch@4.1.1: - dependencies: - '@poppinss/colors': 4.1.6 - '@poppinss/dumper': 0.7.0 - '@speed-highlight/core': 1.2.15 - cookie-es: 3.1.1 - youch-core: 0.3.3 - zip-stream@6.0.1: dependencies: archiver-utils: 5.0.2 From 984c8141b69c40e546c16b49b8b9723623dc9ec6 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Wed, 1 Apr 2026 23:33:24 +0100 Subject: [PATCH 28/60] fix(tanstack-start): dashboard render and virtual module compat patches - Add tanstackStartCompatPlugin() to stub tanstack-start-injected-head-scripts:v virtual module missing from @tanstack/start-server-core@1.167.9 - Add admin.index.tsx route for /admin base path (splat doesn't match empty) - Fix RenderRoot.tsx: empty segments must pass null path to formatAdminURL to match adminRoute without trailing slash Dashboard, collections list, and create document all working. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- packages/ui/src/views/Root/RenderRoot.tsx | 2 +- tanstack-app/app/routes/admin.index.tsx | 23 ++++++++++++++++ tanstack-app/vite.config.ts | 33 ++++++++++++++++------- 3 files changed, 48 insertions(+), 10 deletions(-) create mode 100644 tanstack-app/app/routes/admin.index.tsx diff --git a/packages/ui/src/views/Root/RenderRoot.tsx b/packages/ui/src/views/Root/RenderRoot.tsx index 6b17f4e91e3..b219fcb956e 100644 --- a/packages/ui/src/views/Root/RenderRoot.tsx +++ b/packages/ui/src/views/Root/RenderRoot.tsx @@ -68,7 +68,7 @@ export const renderRootPage = async ({ const currentRoute = formatAdminURL({ adminRoute, - path: Array.isArray(segments) ? `/${segments.join('/')}` : null, + path: Array.isArray(segments) && segments.length > 0 ? `/${segments.join('/')}` : null, }) const isCollectionRoute = segments[0] === 'collections' diff --git a/tanstack-app/app/routes/admin.index.tsx b/tanstack-app/app/routes/admin.index.tsx new file mode 100644 index 00000000000..3ffb51b449c --- /dev/null +++ b/tanstack-app/app/routes/admin.index.tsx @@ -0,0 +1,23 @@ +import config from '@payload-config' +import { RootPage } from '@payloadcms/tanstack-start/views' +import { createFileRoute } from '@tanstack/react-router' +import React from 'react' + +import { importMap } from '../importMap.js' + +export const Route = createFileRoute('/admin/')({ + component: AdminIndexPage, +}) + +function AdminIndexPage() { + const search = Route.useSearch() + + return ( + } + /> + ) +} diff --git a/tanstack-app/vite.config.ts b/tanstack-app/vite.config.ts index 493e0090332..5d9fec9e99e 100644 --- a/tanstack-app/vite.config.ts +++ b/tanstack-app/vite.config.ts @@ -2,17 +2,36 @@ import { tanstackStart } from '@tanstack/react-start/plugin/vite' import react from '@vitejs/plugin-react' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { defineConfig } from 'vite' +import { defineConfig, type Plugin } from 'vite' import tsConfigPaths from 'vite-tsconfig-paths' const dirname = path.dirname(fileURLToPath(import.meta.url)) const uiSrcDir = path.resolve(dirname, '../packages/ui/src') +// Compatibility shim: @tanstack/start-server-core@1.167 expects this virtual module +// but it's not provided by the currently published Vite plugin version. +function tanstackStartCompatPlugin(): Plugin { + const virtualModuleId = 'tanstack-start-injected-head-scripts:v' + const resolvedId = '\0' + virtualModuleId + return { + name: 'tanstack-start-compat', + load(id) { + if (id === resolvedId) { + return 'export const injectedHeadScripts = undefined' + } + }, + resolveId(id) { + if (id === virtualModuleId) { + return resolvedId + } + }, + } +} + export default defineConfig({ css: { preprocessorOptions: { scss: { - // Map ~@payloadcms/ui/scss to the actual source file loadPaths: [path.resolve(uiSrcDir, 'scss')], }, }, @@ -21,17 +40,13 @@ export default defineConfig({ exclude: ['sharp'], }, plugins: [ - tsConfigPaths({ - projects: ['./tsconfig.json'], - }), - tanstackStart({ - srcDirectory: 'app', - } as any), + tanstackStart({ srcDirectory: 'app' } as any), react(), + tsConfigPaths({ projects: ['./tsconfig.json'] }), + tanstackStartCompatPlugin(), ], resolve: { alias: { - // Handle tilde-prefixed node_modules imports in scss '~@payloadcms/ui/scss': path.resolve(uiSrcDir, 'scss/styles.scss'), }, }, From b0c2fc82806252b43567cebfa64351dcecb65c66 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Thu, 2 Apr 2026 00:32:15 +0100 Subject: [PATCH 29/60] feat(tanstack-start): Phase 6 e2e improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Port 3000 for dev:tanstack (same as Next.js, enables running e2e suites) - Auth redirect moved to route loader (loader redirects work in TanStack Router) - admin.index.tsx loader handles unauthenticated access redirect - admin.$.tsx loader handles /collections, /globals → /admin redirect - server-only-stub.js: ESM stub for server-only packages (file-type, pino, mongoose, util) - serverOnlyStubPlugin in vite.config.ts intercepts client bundle for server packages - tanstackStartCompatPlugin stubs tanstack-start-injected-head-scripts:v virtual module - Fix StartClient import: @tanstack/react-start/client (not @tanstack/react-start) - Fix RenderRoot.tsx: empty segments → null path (prevents /admin/ trailing slash) - admin.index.tsx route added for /admin base path Known issue: TanStack Start is isomorphic (all code runs client+server). Server-only Payload code (DB adapters, sharp, etc.) leaks into client bundle. Full RSC isolation requires createServerFn wrappers — tracked for future work. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- tanstack-app/app/client.tsx | 2 +- tanstack-app/app/routeTree.gen.ts | 90 +++++++++++++++++++++++-- tanstack-app/app/routes/admin.$.tsx | 40 ++++++++++- tanstack-app/app/routes/admin.index.tsx | 25 ++++++- tanstack-app/server-only-stub.cjs | 37 ++++++++++ tanstack-app/server-only-stub.js | 56 +++++++++++++++ tanstack-app/tsconfig.json | 2 +- tanstack-app/vite.config.ts | 42 +++++++++++- test/dev-tanstack.ts | 2 +- 9 files changed, 284 insertions(+), 12 deletions(-) create mode 100644 tanstack-app/server-only-stub.cjs create mode 100644 tanstack-app/server-only-stub.js diff --git a/tanstack-app/app/client.tsx b/tanstack-app/app/client.tsx index b865e3c6b27..89d8446ef6d 100644 --- a/tanstack-app/app/client.tsx +++ b/tanstack-app/app/client.tsx @@ -1,4 +1,4 @@ -import { StartClient } from '@tanstack/react-start' +import { StartClient } from '@tanstack/react-start/client' import { StrictMode } from 'react' import { hydrateRoot } from 'react-dom/client' diff --git a/tanstack-app/app/routeTree.gen.ts b/tanstack-app/app/routeTree.gen.ts index d16ef009811..701d3a6886a 100644 --- a/tanstack-app/app/routeTree.gen.ts +++ b/tanstack-app/app/routeTree.gen.ts @@ -1,4 +1,86 @@ -// This file is auto-generated by @tanstack/router-plugin. -// Do not edit manually. -import { rootRoute } from './routes/__root.js' -export const routeTree = rootRoute +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as AdminIndexRouteImport } from './routes/admin.index' +import { Route as AdminSplatRouteImport } from './routes/admin.$' + +const AdminIndexRoute = AdminIndexRouteImport.update({ + id: '/admin/', + path: '/admin/', + getParentRoute: () => rootRouteImport, +} as any) +const AdminSplatRoute = AdminSplatRouteImport.update({ + id: '/admin/$', + path: '/admin/$', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/admin/$': typeof AdminSplatRoute + '/admin/': typeof AdminIndexRoute +} +export interface FileRoutesByTo { + '/admin/$': typeof AdminSplatRoute + '/admin': typeof AdminIndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/admin/$': typeof AdminSplatRoute + '/admin/': typeof AdminIndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/admin/$' | '/admin/' + fileRoutesByTo: FileRoutesByTo + to: '/admin/$' | '/admin' + id: '__root__' | '/admin/$' | '/admin/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + AdminSplatRoute: typeof AdminSplatRoute + AdminIndexRoute: typeof AdminIndexRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/admin/': { + id: '/admin/' + path: '/admin' + fullPath: '/admin/' + preLoaderRoute: typeof AdminIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/admin/$': { + id: '/admin/$' + path: '/admin/$' + fullPath: '/admin/$' + preLoaderRoute: typeof AdminSplatRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + AdminSplatRoute: AdminSplatRoute, + AdminIndexRoute: AdminIndexRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/tanstack-app/app/routes/admin.$.tsx b/tanstack-app/app/routes/admin.$.tsx index be36dc87ada..db06c71790f 100644 --- a/tanstack-app/app/routes/admin.$.tsx +++ b/tanstack-app/app/routes/admin.$.tsx @@ -1,18 +1,52 @@ import config from '@payload-config' import { RootPage } from '@payloadcms/tanstack-start/views' -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, redirect } from '@tanstack/react-router' +import { initReq } from '@payloadcms/tanstack-start/utilities/initReq' import React from 'react' import { importMap } from '../importMap.js' export const Route = createFileRoute('/admin/$')({ + loader: async ({ params }) => { + const segments = params._splat?.split('/').filter(Boolean) ?? [] + const resolvedConfig = await config + const { + routes: { admin: adminRoute }, + } = resolvedConfig + + // Handle known redirect patterns in the loader (TanStack redirect works correctly in loaders) + if (segments.length === 1 && (segments[0] === 'collections' || segments[0] === 'globals')) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw redirect({ to: adminRoute }) + } + + // Auth check in loader so TanStack Router handles redirects correctly + const { isPublicAdminRoute } = await import('@payloadcms/ui/utilities/isPublicAdminRoute') + const { isCustomAdminView } = await import('@payloadcms/ui/utilities/isCustomAdminView') + const currentRoute = `${adminRoute}/${segments.join('/')}` + + if ( + !isPublicAdminRoute({ adminRoute, config: resolvedConfig, route: currentRoute }) && + !isCustomAdminView({ adminRoute, config: resolvedConfig, route: currentRoute }) + ) { + const { permissions, req } = await initReq({ config, importMap, key: 'adminLoader' }) + if (!permissions.canAccessAdmin) { + const { handleAuthRedirect } = await import('@payloadcms/ui/utilities/handleAuthRedirect') + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw redirect({ + to: handleAuthRedirect({ config: resolvedConfig, route: currentRoute, user: req.user }), + }) + } + } + + return { segments } + }, component: AdminPage, }) function AdminPage() { - const params = Route.useParams() + const { segments } = Route.useLoaderData() const search = Route.useSearch() - const segments = params._splat?.split('/').filter(Boolean) ?? [] return ( { + const resolvedConfig = await config + const { + routes: { admin: adminRoute }, + } = resolvedConfig + + // Auth check in loader so TanStack Router handles redirects correctly + const { permissions, req } = await initReq({ config, importMap, key: 'adminIndexLoader' }) + + if (!permissions.canAccessAdmin) { + const { isPublicAdminRoute } = await import('@payloadcms/ui/utilities/isPublicAdminRoute') + if (!isPublicAdminRoute({ adminRoute, config: resolvedConfig, route: adminRoute })) { + const { handleAuthRedirect } = await import('@payloadcms/ui/utilities/handleAuthRedirect') + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw redirect({ + to: handleAuthRedirect({ config: resolvedConfig, route: adminRoute, user: req.user }), + }) + } + } + + return null + }, component: AdminIndexPage, }) diff --git a/tanstack-app/server-only-stub.cjs b/tanstack-app/server-only-stub.cjs new file mode 100644 index 00000000000..cdd0c2513c5 --- /dev/null +++ b/tanstack-app/server-only-stub.cjs @@ -0,0 +1,37 @@ +// Stub for server-only Node.js packages when loaded in the browser. +// Payload admin runs as RSC in Next.js; in TanStack Start these modules +// get pulled into the client bundle. This stub prevents hard crashes. +const noop = function () {} +noop.prototype = {} + +// Mongoose-style Types object +const Types = { + ObjectId: noop, + Decimal128: noop, + Buffer: noop, + Map: noop, + Mixed: noop, +} + +const stub = new Proxy(noop, { + get(_, prop) { + if (prop === '__esModule') return true + if (prop === 'default') return stub + if (prop === 'Types') return Types + if (prop === 'Schema') return noop + if (prop === 'model') return noop + if (prop === 'connect') return noop + if (prop === 'pino') return noop + if (prop === 'levels') return {} + if (prop === 'fileTypeFromFile') return undefined + if (prop === 'fileTypeFromBuffer') return undefined + if (prop === 'fileTypeFromStream') return undefined + // For anything else, return a no-op or empty object + return noop + }, + apply() { + return stub + }, +}) + +module.exports = stub diff --git a/tanstack-app/server-only-stub.js b/tanstack-app/server-only-stub.js new file mode 100644 index 00000000000..29baa7e741e --- /dev/null +++ b/tanstack-app/server-only-stub.js @@ -0,0 +1,56 @@ +// Stub for server-only Node.js packages when loaded in the browser. +// Payload admin is RSC-based; in TanStack Start's isomorphic environment +// these server-only packages get pulled into the client bundle. +// This stub prevents hard crashes while preserving the module shape. + +const noop = () => {} + +// ─── file-type ──────────────────────────────────────────────────────────────── +export const fileTypeFromFile = undefined +export const fileTypeFromBuffer = undefined +export const fileTypeFromStream = undefined + +// ─── pino ───────────────────────────────────────────────────────────────────── +export const pino = noop +export const levels = {} +export const stdSerializers = {} + +// ─── mongoose ───────────────────────────────────────────────────────────────── +export const Types = { + ObjectId: noop, + Decimal128: noop, + Buffer: noop, + Map: noop, + Mixed: noop, +} +export const Schema = noop +export const model = noop +export const models = {} +export const connect = noop +export const connection = { on: noop, once: noop } +export const set = noop + +// ─── sharp ──────────────────────────────────────────────────────────────────── +// (sharp is default-export only — handled by the default export below) + +// ─── mongodb ────────────────────────────────────────────────────────────────── +export const MongoClient = noop +export const ObjectId = noop + +// ─── @aws-sdk/client-s3, etc. ───────────────────────────────────────────────── +export const S3Client = noop +export const GetObjectCommand = noop +export const PutObjectCommand = noop +export const DeleteObjectCommand = noop + +// ─── nodemailer ──────────────────────────────────────────────────────────────── +export const createTransport = noop + +// ─── Node.js util built-in (used for isDeepStrictEqual in getEntityPermissions) ── +export const isDeepStrictEqual = () => false +export const promisify = () => noop +export const inspect = () => '' +export const format = () => '' + +// ─── Default catch-all ──────────────────────────────────────────────────────── +export default noop diff --git a/tanstack-app/tsconfig.json b/tanstack-app/tsconfig.json index f117f06985d..d25177e9dee 100644 --- a/tanstack-app/tsconfig.json +++ b/tanstack-app/tsconfig.json @@ -10,5 +10,5 @@ "@payload-config": ["../test/_community/config.ts"] } }, - "include": ["app/**/*", "app.config.ts", "vite.config.ts"] + "include": ["app/**/*", "app.config.ts", "vite.config.ts", "server-only-stub.js"] } diff --git a/tanstack-app/vite.config.ts b/tanstack-app/vite.config.ts index 5d9fec9e99e..e77d198a40a 100644 --- a/tanstack-app/vite.config.ts +++ b/tanstack-app/vite.config.ts @@ -1,5 +1,6 @@ import { tanstackStart } from '@tanstack/react-start/plugin/vite' import react from '@vitejs/plugin-react' +import { readFileSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { defineConfig, type Plugin } from 'vite' @@ -28,6 +29,44 @@ function tanstackStartCompatPlugin(): Plugin { } } +// Packages that are Node.js-only and must not be bundled for the browser. +// Payload admin is RSC-based; in TanStack Start (isomorphic by default) we need +// to stub these so client-side hydration doesn't break when these are in the +// module graph. +const SERVER_ONLY_PACKAGES = [ + 'sharp', + 'file-type', + 'mongoose', + 'mongodb', + '@aws-sdk', + 'nodemailer', + 'pino', + 'better-sqlite3', + // Node.js util built-in — used for isDeepStrictEqual in server-side Payload code + 'util', + 'node:util', +] + +function serverOnlyStubPlugin(): Plugin { + return { + name: 'server-only-stub', + enforce: 'pre', + load(id) { + if (id.startsWith('\0server-only-stub:')) { + return readFileSync(path.resolve(dirname, 'server-only-stub.js'), 'utf-8') + } + }, + resolveId(id, _importer, options) { + if ( + !options?.ssr && + SERVER_ONLY_PACKAGES.some((pkg) => id === pkg || id.startsWith(pkg + '/')) + ) { + return '\0server-only-stub:' + id + } + }, + } +} + export default defineConfig({ css: { preprocessorOptions: { @@ -37,13 +76,14 @@ export default defineConfig({ }, }, optimizeDeps: { - exclude: ['sharp'], + exclude: ['sharp', 'file-type'], }, plugins: [ tanstackStart({ srcDirectory: 'app' } as any), react(), tsConfigPaths({ projects: ['./tsconfig.json'] }), tanstackStartCompatPlugin(), + serverOnlyStubPlugin(), ], resolve: { alias: { diff --git a/test/dev-tanstack.ts b/test/dev-tanstack.ts index 35d92d5d43f..73cc9b29f3b 100644 --- a/test/dev-tanstack.ts +++ b/test/dev-tanstack.ts @@ -27,7 +27,7 @@ process.env.ROOT_DIR = tanstackAppDir await runInit(testSuiteArg, true, false) -const port = process.env.PORT ? Number(process.env.PORT) : 3100 +const port = process.env.PORT ? Number(process.env.PORT) : 3000 console.log(chalk.green(`✓ TanStack Start dev server starting on port ${port}`)) console.log(chalk.cyan(` Admin: http://localhost:${port}/admin`)) From 1d3494894145c8297348624f20a3e92cfab4a5b9 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Thu, 2 Apr 2026 01:30:25 +0100 Subject: [PATCH 30/60] docs: add TanStack Start client rendering design Co-Authored-By: Claude Sonnet 4.6 --- ...6-04-02-tanstack-start-client-rendering.md | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 docs/plans/2026-04-02-tanstack-start-client-rendering.md diff --git a/docs/plans/2026-04-02-tanstack-start-client-rendering.md b/docs/plans/2026-04-02-tanstack-start-client-rendering.md new file mode 100644 index 00000000000..a3eafd864ae --- /dev/null +++ b/docs/plans/2026-04-02-tanstack-start-client-rendering.md @@ -0,0 +1,187 @@ +# TanStack Start: Serializable Page State + Client Rendering + +## Goal + +Fix the isomorphic rendering problem in TanStack Start by replacing `renderRootPage` (RSC, returns React nodes) with a serializable page state server function + a client-only component tree. + +**Constraint:** Next.js is untouched. It keeps full RSC support. TanStack Start is its own independent rendering path. + +--- + +## Problem + +TanStack Start v1.167 is isomorphic by default — route components execute on both server (SSR) and client (hydration). `RootPage` is an async function that imports `renderRootPage`, which transitively imports server-only modules (mongoose, pino, util, sharp, etc.). These get bundled for the client. + +The current `serverOnlyStubPlugin` stubs these out, but each new missing export requires a manual addition. It's a maintenance leak, not a fix. + +--- + +## Design + +### Rendering model + +**Next.js:** RSC-first. `renderRootPage` returns `React.ReactNode`. Server components render server-only code. Unchanged. + +**TanStack Start:** + +- Loader runs server-side → `getPageState` server function → serializable data only +- `TanStackAdminPage` is a `'use client'` component — no server-only imports anywhere in the component tree +- View components (DashboardView, ListView, DocumentView, etc.) mount client-side and fetch their data via `buildFormState` / `buildTableState` / other server functions +- SSR output: template shell + loading skeleton. View content loads after hydration. + +This is intentional. Payload admin is behind auth — SEO doesn't matter. The auth/permissions security boundary is maintained by the server-side loader. + +--- + +## Components + +### 1. `getPageState` server function + +**File:** `packages/tanstack-start/src/views/Root/getPageState.ts` + +Calls `initReq` then `getRouteData`. Handles auth redirects and notFound. Returns: + +```typescript +type SerializablePageState = { + viewType: ViewTypes | undefined + templateType: 'default' | 'minimal' | undefined + templateClassName: string + routeParams: { + collection?: string + folderCollection?: string + folderID?: number | string + global?: string + id?: number | string + token?: string + versionID?: number | string + } + documentSubViewType?: DocumentSubViewTypes + browseByFolderSlugs: string[] + clientConfig: ClientConfig // already serializable + permissions: Permissions // already serializable + locale?: Locale // already serializable + viewActions?: PayloadComponent[] // serializable; React.FC actions omitted + customViewPath?: string // path string for custom views, resolved via importMap +} +``` + +`DefaultView.Component` (a React function) is discarded. The client derives the component from `viewType` via a fixed registry. Custom views use `customViewPath` (the `PayloadComponent` path string) resolved via `importMap` on the client. + +### 2. Updated route loader + +**File:** `tanstack-app/app/routes/admin.$.tsx` + +```typescript +export const Route = createFileRoute('/admin/$')({ + loader: async ({ params }) => { + const segments = params._splat?.split('/').filter(Boolean) ?? [] + return getPageState({ data: { segments } }) + // redirects and notFound thrown inside getPageState + }, + component: AdminPage, +}) + +function AdminPage() { + const pageState = Route.useLoaderData() + const search = Route.useSearch() + return ( + }} + /> + ) +} +``` + +The inline auth checks and `` are removed entirely. + +### 3. `TanStackAdminPage` client component + +**File:** `packages/tanstack-start/src/views/Root/TanStackAdminPage.tsx` + +``` +'use client' +``` + +Receives `pageState + importMap`. Responsibilities: + +1. Wrap in `` +2. Map `viewType` → built-in view component via a local registry; custom views via `importMap` +3. Render `` or `` with client-safe props only (no `req`, no `payload`, no `i18n` object) +4. Pass `clientConfig`, `permissions`, `routeParams`, `searchParams`, `locale`, `documentSubViewType`, `browseByFolderSlugs` as client props to the view + +**View props type:** + +```typescript +type TanStackViewProps = { + clientConfig: ClientConfig + permissions: Permissions + routeParams: SerializablePageState['routeParams'] + searchParams: Record + locale?: Locale + documentSubViewType?: DocumentSubViewTypes + browseByFolderSlugs: string[] + viewType: ViewTypes + importMap: ImportMap +} +``` + +### 4. `render-document` / `render-list` in TanStack Start + +These server functions return `React.ReactNode` — not serializable. TanStack Start's `handleServerFunctions` intercepts them: + +```typescript +const TANSTACK_UNSUPPORTED_FNS = new Set(['render-document', 'render-list']) + +export const handleServerFunctions: ServerFunctionHandler = async (args) => { + if (TANSTACK_UNSUPPORTED_FNS.has(args.name)) { + return { __tanstack_invalidate: true } + } + // ...existing dispatch logic +} +``` + +`TanStackRouterProvider` (or a wrapper) intercepts `__tanstack_invalidate: true` responses and calls `router.invalidate()`, triggering a full loader re-run. This is the TanStack-native pattern for refreshing route data. + +### 5. Remove `serverOnlyStubPlugin` + +Once `TanStackAdminPage` has no server-only imports, `serverOnlyStubPlugin` in `tanstack-app/vite.config.ts` can be removed. The `tanstackStartCompatPlugin` for the virtual module shim stays. + +--- + +## What does NOT change + +- `packages/next` — zero modifications +- `packages/ui` — zero modifications (existing views, templates, utilities all stay) +- `renderRootPage` — stays, used by Next.js only +- `baseServerFunctions` dispatcher — stays; TanStack Start overrides only `render-document`/`render-list` +- All REST API routes, auth, collections, globals — unchanged + +--- + +## Trade-offs + +| | Next.js | TanStack Start | +| -------------------------- | ---------------------- | ---------------------------------- | +| SSR content | Full view pre-rendered | Shell + skeleton | +| Server-only deps in bundle | None (RSC) | None (client component) | +| Client-side navigation | RSC re-render | Loader re-run + client mount | +| View data fetch | Server (RSC) | Client (server functions on mount) | +| Stub maintenance | N/A | None needed | + +--- + +## Testing + +**Integration** (`test/admin-adapter/tanstack-start.int.spec.ts`): + +- `getPageState` returns a plain serializable object for each `viewType` +- `render-document` / `render-list` return `{ __tanstack_invalidate: true }` +- All other server functions dispatch normally + +**E2E** (`test/admin-adapter/tanstack-start.e2e.spec.ts`): + +- Dashboard, list view, document view render without server-only errors in browser console +- Client-side navigation between routes works +- Document save triggers route invalidation +- Next.js e2e suite: no regressions From 3f689c8e26b43205c1582e8e8bea4c0822a8f692 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Thu, 2 Apr 2026 01:48:03 +0100 Subject: [PATCH 31/60] docs: add TanStack Start client rendering implementation plan Co-Authored-By: Claude Sonnet 4.6 --- ...02-tanstack-start-client-rendering-plan.md | 1260 +++++++++++++++++ 1 file changed, 1260 insertions(+) create mode 100644 docs/plans/2026-04-02-tanstack-start-client-rendering-plan.md diff --git a/docs/plans/2026-04-02-tanstack-start-client-rendering-plan.md b/docs/plans/2026-04-02-tanstack-start-client-rendering-plan.md new file mode 100644 index 00000000000..f1d95966623 --- /dev/null +++ b/docs/plans/2026-04-02-tanstack-start-client-rendering-plan.md @@ -0,0 +1,1260 @@ +# TanStack Start Client Rendering Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace `RootPage` (server component, imports server-only modules) with a `createServerFn`-based `getPageState` + a fully client-side `TanStackAdminPage`, eliminating the need for `serverOnlyStubPlugin`. + +**Architecture:** Loader calls `getPageState` server function → returns serializable page state → `TanStackAdminPage` (client component) renders template shell + view components. View components call `useServerFunctions()` on mount to fetch their data. Next.js is untouched. TanStack Start is its own rendering path. + +**Tech Stack:** TypeScript, React 19, `@tanstack/react-start` (`createServerFn`), `@tanstack/react-router`, Payload CMS monorepo (pnpm + Turbo) + +--- + +## Background / Key Concepts + +Before starting, read these files to understand the existing architecture: + +- `tanstack-app/app/routes/admin.$.tsx` — current route: calls `RootPage` in component +- `tanstack-app/app/routes/__root.tsx` — root layout: shows how `createServerFn` + `dispatchServerFunction` is wired +- `packages/tanstack-start/src/views/Root/index.tsx` — current `RootPage` (replaces with new approach) +- `packages/ui/src/views/Root/RenderRoot.tsx` — `renderRootPage` (Next.js uses this; TanStack Start will NOT) +- `packages/ui/src/views/Root/getRouteData.ts` — `getRouteData` function (we call this server-side in `getPageState`) +- `packages/ui/src/templates/Default/index.tsx` — `DefaultTemplate` (needs `payload: Payload`; we create a client version) +- `packages/ui/src/elements/Nav/index.tsx` — `DefaultNav` (async server component; client version is `DefaultNavClient`) +- `packages/ui/src/elements/Nav/index.client.tsx` — `DefaultNavClient` (client, takes `groups` + `navPreferences`) +- `packages/payload/src/admin/views/index.ts` — `ViewTypes`, `AdminViewClientProps` + +**Important constraint:** Do NOT modify any file in `packages/next/` or `packages/ui/`. All changes are in `packages/tanstack-start/` and `tanstack-app/`. + +--- + +## Phase 1: Serializable Page State + +### Task 1: Define `SerializablePageState` type and create `getPageState` server function + +**Files:** + +- Create: `packages/tanstack-start/src/views/Root/getPageState.ts` + +**What this does:** Calls `initReq` + `getRouteData` + `getNavPrefs` + (for dashboard) `getGlobalData` server-side. Returns a plain serializable object. Handles auth redirects and notFound by throwing TanStack Router errors. + +**Step 1: Create the file** + +```typescript +// packages/tanstack-start/src/views/Root/getPageState.ts +import type { + ClientConfig, + DocumentSubViewTypes, + ImportMap, + Locale, + NavPreferences, + PayloadComponent, + SanitizedCollectionConfig, + SanitizedConfig, + SanitizedGlobalConfig, + SanitizedPermissions, + ViewTypes, +} from 'payload' + +import { notFound, redirect } from '@tanstack/react-router' +import { + applyLocaleFiltering, + formatAdminURL, + getVisibleEntities, +} from 'payload/shared' +import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig' +import { getNavPrefs } from '@payloadcms/ui/elements/Nav/getNavPrefs' +import { handleAuthRedirect } from '@payloadcms/ui/utilities/handleAuthRedirect' +import { isCustomAdminView } from '@payloadcms/ui/utilities/isCustomAdminView' +import { isPublicAdminRoute } from '@payloadcms/ui/utilities/isPublicAdminRoute' +import { getGlobalData } from '@payloadcms/ui/utilities/getGlobalData' +import { getRouteData } from '@payloadcms/ui/views/Root/getRouteData' + +import { initReq } from '../../utilities/initReq.js' + +export type SerializablePageState = { + browseByFolderSlugs: string[] + clientConfig: ClientConfig + // Only populated when viewType === 'dashboard' + globalData?: Awaited> + locale?: Locale + navPreferences?: NavPreferences + permissions: SanitizedPermissions + routeParams: { + collection?: string + folderCollection?: string + folderID?: number | string + global?: string + id?: number | string + token?: string + versionID?: number | string + } + segments: string[] + templateClassName: string + templateType: 'default' | 'minimal' | undefined + // For custom views resolved via importMap + customViewPath?: string + documentSubViewType?: DocumentSubViewTypes + viewActions?: PayloadComponent[] + viewType: ViewTypes | undefined + visibleEntities: { + collections: string[] + globals: string[] + } +} + +export async function getPageState({ + config: configArg, + importMap, + searchParams, + segments, +}: { + config: Promise | SanitizedConfig + importMap: ImportMap + searchParams?: Record + segments: string[] +}): Promise { + const initPageResult = await initReq({ + config: configArg, + importMap, + key: 'getPageState', + }) + const { locale, permissions, req } = initPageResult + const { payload } = req + const config = payload.config + const { + routes: { admin: adminRoute }, + } = config + + const currentRoute = formatAdminURL({ + adminRoute, + path: segments.length > 0 ? `/${segments.join('/')}` : null, + }) + + // Auth check + if ( + !permissions.canAccessAdmin && + !isPublicAdminRoute({ adminRoute, config, route: currentRoute }) && + !isCustomAdminView({ adminRoute, config, route: currentRoute }) + ) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw redirect({ + to: handleAuthRedirect({ + config, + route: currentRoute, + searchParams, + user: req.user, + }), + }) + } + + // Compute collection/global config from segments + let collectionConfig: SanitizedCollectionConfig | undefined + let globalConfig: SanitizedGlobalConfig | undefined + + if (segments[0] === 'collections' && segments[1]) { + collectionConfig = config.collections.find( + ({ slug }) => slug === segments[1], + ) + if (!collectionConfig) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw notFound() + } + } else if (segments[0] === 'globals' && segments[1]) { + globalConfig = config.globals.find(({ slug }) => slug === segments[1]) + if (!globalConfig) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw notFound() + } + } + + const routeData = getRouteData({ + adminRoute, + collectionConfig, + currentRoute, + globalConfig, + payload, + searchParams: searchParams ?? {}, + segments, + }) + + const { + browseByFolderSlugs, + documentSubViewType, + routeParams, + templateClassName, + templateType, + viewActions, + viewType, + } = routeData + + // Serialize viewActions: keep only PayloadComponent references (path strings), drop React.FC + const serializableViewActions: PayloadComponent[] = ( + viewActions ?? [] + ).filter( + (a): a is PayloadComponent => + typeof a === 'string' || (typeof a === 'object' && 'path' in a), + ) + + // Get nav preferences + const navPreferences = await getNavPrefs(req) + + // Get client config + const clientConfig = getClientConfig({ + config, + i18n: req.i18n, + importMap, + user: req.user, + }) + + await applyLocaleFiltering({ clientConfig, config, req }) + + const visibleEntities = getVisibleEntities({ req }) + + // For dashboard: pre-fetch global data + let globalData: SerializablePageState['globalData'] + if (viewType === 'dashboard') { + globalData = await getGlobalData(req) + } + + // Custom view path: if DefaultView has a payloadComponent, pass its path + const defaultView = routeData.DefaultView + const customViewPath = defaultView?.payloadComponent + ? typeof defaultView.payloadComponent === 'string' + ? defaultView.payloadComponent + : (defaultView.payloadComponent as { path: string }).path + : undefined + + return { + browseByFolderSlugs, + clientConfig, + customViewPath, + documentSubViewType, + globalData, + locale, + navPreferences, + permissions, + routeParams, + segments, + templateClassName, + templateType, + viewActions: + serializableViewActions.length > 0 ? serializableViewActions : undefined, + viewType, + visibleEntities: { + collections: visibleEntities.collections, + globals: visibleEntities.globals, + }, + } +} +``` + +**Step 2: Verify the import paths exist** + +Run these to confirm the imports resolve: + +```bash +grep -r "export.*getGlobalData" packages/ui/src/utilities/getGlobalData.ts +grep -r "export.*getRouteData" packages/ui/src/views/Root/getRouteData.ts +grep -r "export.*getNavPrefs" packages/ui/src/elements/Nav/getNavPrefs.ts +grep -r "export.*getClientConfig" packages/ui/src/utilities/getClientConfig.ts +grep -r "export.*getVisibleEntities" packages/ui/src/utilities/getVisibleEntities.ts +``` + +If any import path is wrong, check `packages/ui/src/exports/` for the correct path. + +**Step 3: TypeScript check (tanstack-start only)** + +```bash +cd packages/tanstack-start && pnpm tsc --noEmit 2>&1 | head -50 +``` + +Expected: no errors on the new file. Fix any import path issues. + +**Step 4: Commit** + +```bash +git add packages/tanstack-start/src/views/Root/getPageState.ts +git commit -m "feat(tanstack-start): add getPageState server function returning serializable page state" +``` + +--- + +## Phase 2: Client Template (No Server Deps) + +### Task 2: Create `TanStackDefaultTemplate` client component + +**Context:** The existing `DefaultTemplate` in packages/ui takes `payload: Payload` (server-only). We need a client version that takes `clientConfig: ClientConfig` instead. The nav uses `DefaultNavClient` (already client-side) with groups computed from `clientConfig`. + +**Files:** + +- Create: `packages/tanstack-start/src/templates/Default/index.tsx` + +**Step 1: Read the existing components** + +Before writing, read these files: + +- `packages/ui/src/templates/Default/index.tsx` — structure to mirror +- `packages/ui/src/elements/Nav/index.client.tsx` — `DefaultNavClient` props +- `packages/ui/src/utilities/groupNavItems.ts` — `groupNavItems` function signature + +**Step 2: Create the file** + +```typescript +// packages/tanstack-start/src/templates/Default/index.tsx +'use client' + +import type { + ClientConfig, + DocumentSubViewTypes, + NavPreferences, + PayloadComponent, + SanitizedPermissions, + ViewTypes, + VisibleEntities, +} from 'payload' + +import { EntityType, groupNavItems } from '@payloadcms/ui/utilities/groupNavItems' +import { ActionsProvider } from '@payloadcms/ui/providers/Actions' +import { EntityVisibilityProvider } from '@payloadcms/ui/providers/EntityVisibility' +import { BulkUploadProvider } from '@payloadcms/ui/elements/BulkUpload' +import { AppHeader } from '@payloadcms/ui/elements/AppHeader' +import { NavToggler } from '@payloadcms/ui/elements/Nav/NavToggler' +import { DefaultNavClient } from '@payloadcms/ui/elements/Nav/index.client' +import { NavWrapper } from '@payloadcms/ui/elements/Nav/NavWrapper' +import { NavHamburger as DefaultNavHamburger } from '@payloadcms/ui/templates/Default/NavHamburger' +import { Wrapper } from '@payloadcms/ui/templates/Default/Wrapper' +import { RenderCustomComponent } from '@payloadcms/ui/elements/RenderCustomComponent' +import { useTranslation } from '@payloadcms/ui/providers/Translation' +import React from 'react' + +const baseClass = 'template-default' +const navBaseClass = 'nav' + +export type TanStackDefaultTemplateProps = { + children?: React.ReactNode + className?: string + clientConfig: ClientConfig + collectionSlug?: string + docID?: number | string + documentSubViewType?: DocumentSubViewTypes + globalSlug?: string + navPreferences?: NavPreferences + permissions: SanitizedPermissions + viewActions?: PayloadComponent[] + viewType?: ViewTypes + visibleEntities: VisibleEntities +} + +export function TanStackDefaultTemplate({ + children, + clientConfig, + collectionSlug, + documentSubViewType, + navPreferences, + permissions, + viewType, + visibleEntities, +}: TanStackDefaultTemplateProps) { + const { i18n } = useTranslation() + + const groups = React.useMemo( + () => + groupNavItems( + [ + ...clientConfig.collections + .filter(({ slug }) => visibleEntities.collections.includes(slug)) + .map((collection) => ({ type: EntityType.collection, entity: collection })), + ...clientConfig.globals + .filter(({ slug }) => visibleEntities.globals.includes(slug)) + .map((global) => ({ type: EntityType.global, entity: global })), + ], + permissions, + i18n, + ), + [clientConfig, visibleEntities, permissions, i18n], + ) + + return ( + + + +
    + + + + + +
    + + {children} +
    +
    +
    +
    +
    +
    + ) +} +``` + +**Note:** The import paths for internal UI components (`@payloadcms/ui/elements/Nav/NavToggler`, etc.) need to be verified against `packages/ui/package.json` exports. Run: + +```bash +cat packages/ui/package.json | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps(list(d.get('exports',{}).keys()), indent=2))" | grep -i "nav\|template\|appheader\|actions\|entity" +``` + +Adjust import paths to match actual exports. + +**Step 3: TypeScript check** + +```bash +cd packages/tanstack-start && pnpm tsc --noEmit 2>&1 | head -50 +``` + +**Step 4: Commit** + +```bash +git add packages/tanstack-start/src/templates/ +git commit -m "feat(tanstack-start): add TanStackDefaultTemplate client component" +``` + +--- + +## Phase 3: Client View Components + +### Task 3: Create client-side view wrappers + +**Context:** Each admin view type needs a client component for TanStack Start. These components: + +1. Receive serializable props from `TanStackAdminPage` +2. Call the appropriate server function on mount via `useServerFunctions()` +3. Render the result (React.ReactNode for table/form, or built-in client components for auth views) + +The pattern for data-fetching views: + +``` +mount → call serverFunction → receive result → render result (React.ReactNode) +``` + +`useServerFunctions()` is from `@payloadcms/ui/providers/ServerFunctions`. It provides `getTableState`, `getFormState`, etc. + +**Files:** + +- Create: `packages/tanstack-start/src/views/List/TanStackListView.tsx` +- Create: `packages/tanstack-start/src/views/Document/TanStackDocumentView.tsx` +- Create: `packages/tanstack-start/src/views/Dashboard/TanStackDashboardView.tsx` + +**Step 1: Read relevant types** + +```bash +# Check what getTableState/getFormState return and their arg types +grep -n "GetFormStateClient\|GetTableStateClient\|getFormState\|getTableState" packages/ui/src/providers/ServerFunctions/index.tsx | head -20 +grep -n "BuildFormStateArgs\|BuildTableStateArgs" packages/payload/src/types/*.ts packages/ui/src/**/*.ts 2>/dev/null | grep "^packages/payload" | head -10 +``` + +**Step 2: Create `TanStackListView`** + +```typescript +// packages/tanstack-start/src/views/List/TanStackListView.tsx +'use client' + +import type { SanitizedPermissions, ViewTypes } from 'payload' + +import { useServerFunctions } from '@payloadcms/ui/providers/ServerFunctions' +import React, { useEffect, useState } from 'react' + +type Props = { + collectionSlug: string + permissions: SanitizedPermissions + searchParams: Record + viewType?: ViewTypes +} + +export function TanStackListView({ collectionSlug, searchParams }: Props) { + const { getTableState } = useServerFunctions() + const [tableContent, setTableContent] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + setTableContent(null) + setError(null) + + getTableState({ + collectionSlug, + enableRowSelections: true, + query: searchParams as Record, + renderRowTypes: true, + }) + .then((result) => { + if (cancelled) return + if ('errors' in result || 'message' in result) { + setError('message' in result ? result.message : 'Failed to load list') + } else if (result) { + setTableContent(result.Table) + } + }) + .catch((err: Error) => { + if (!cancelled) setError(err.message) + }) + + return () => { cancelled = true } + }, [collectionSlug, JSON.stringify(searchParams)]) // eslint-disable-line react-hooks/exhaustive-deps + + if (error) return
    {error}
    + if (!tableContent) return
    + return <>{tableContent} +} +``` + +**Step 3: Create `TanStackDocumentView`** + +```typescript +// packages/tanstack-start/src/views/Document/TanStackDocumentView.tsx +'use client' + +import type { DocumentSubViewTypes, SanitizedPermissions } from 'payload' + +import { useServerFunctions } from '@payloadcms/ui/providers/ServerFunctions' +import React, { useEffect, useState } from 'react' + +type Props = { + collectionSlug?: string + documentSubViewType?: DocumentSubViewTypes + docID?: number | string + globalSlug?: string + permissions: SanitizedPermissions + searchParams: Record +} + +export function TanStackDocumentView({ + collectionSlug, + docID, + globalSlug, + searchParams, +}: Props) { + const { getFormState } = useServerFunctions() + const [content, setContent] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + setContent(null) + setError(null) + + const schemaPath = collectionSlug ?? globalSlug ?? '' + getFormState({ + collectionSlug, + docID, + globalSlug, + schemaPath, + // docPreferences and other args will use defaults + }) + .then((result) => { + if (cancelled) return + if (result && 'errors' in result) { + setError('Failed to load document') + } else if (result && 'state' in result) { + // The form state is returned; the admin renders the form using + // the DocumentView from the RenderDocument server fn slot. + // For TanStack Start we trigger router invalidation to refresh. + // TODO: render DocumentViewClient directly with the form state + setContent(
    ) + } + }) + .catch((err: Error) => { + if (!cancelled) setError(err.message) + }) + + return () => { cancelled = true } + }, [collectionSlug, String(docID), globalSlug]) // eslint-disable-line react-hooks/exhaustive-deps + + if (error) return
    {error}
    + if (!content) return
    + return <>{content} +} +``` + +**Note:** The document view stub above is intentionally minimal. In practice, the document form is initially rendered via SSR (`RootPage` during SSR still renders). Client-side navigation to document pages triggers a new SSR pass. The `getFormState` call here is for ensuring the form is hydrated with current data after SSR. Expand this in a follow-up once the full `DocumentViewClient` integration is understood by reading `packages/ui/src/views/Document/` more deeply. + +**Step 4: Create `TanStackDashboardView`** + +```typescript +// packages/tanstack-start/src/views/Dashboard/TanStackDashboardView.tsx +'use client' + +import type { ClientConfig, NavGroup, SanitizedPermissions } from 'payload' + +import { DefaultDashboard } from '@payloadcms/ui/views/Dashboard/Default' +import { getNavGroups } from '@payloadcms/ui/utilities/getNavGroups' +import { useTranslation } from '@payloadcms/ui/providers/Translation' +import React from 'react' + +type GlobalDataEntry = { + data: Record + global: { slug: string } +} + +type Props = { + clientConfig: ClientConfig + globalData?: GlobalDataEntry[] + permissions: SanitizedPermissions +} + +export function TanStackDashboardView({ clientConfig, globalData, permissions }: Props) { + const { i18n } = useTranslation() + + const navGroups: NavGroup[] = React.useMemo( + () => getNavGroups(permissions, { collections: clientConfig.collections.map(c => c.slug), globals: clientConfig.globals.map(g => g.slug) }, clientConfig, i18n), + [clientConfig, permissions, i18n], + ) + + return ( + + ) +} +``` + +**Note:** `DefaultDashboard` props — verify by reading `packages/ui/src/views/Dashboard/Default/index.tsx`: + +```bash +head -30 packages/ui/src/views/Dashboard/Default/index.tsx +``` + +Adjust props to match the actual `DefaultDashboard` component signature. + +**Step 5: TypeScript check** + +```bash +cd packages/tanstack-start && pnpm tsc --noEmit 2>&1 | head -50 +``` + +Fix any type errors. The view components will have stubs that expand later. + +**Step 6: Commit** + +```bash +git add packages/tanstack-start/src/views/ +git commit -m "feat(tanstack-start): add TanStack client view components (list, document, dashboard)" +``` + +--- + +### Task 4: Create `TanStackAdminPage` — the top-level client component + +**Files:** + +- Create: `packages/tanstack-start/src/views/Root/TanStackAdminPage.tsx` + +**Step 1: Read auth view client components** + +```bash +# These already exist as client components we can import directly +ls packages/ui/src/views/Login/ +ls packages/ui/src/views/CreateFirstUser/ +ls packages/ui/src/views/Account/ +ls packages/ui/src/views/ForgotPassword/ +ls packages/ui/src/views/ResetPassword/ +ls packages/ui/src/views/Verify/ +# Check which are already client components: +head -1 packages/ui/src/views/Login/index.tsx +head -1 packages/ui/src/views/CreateFirstUser/index.client.tsx +``` + +**Step 2: Create the component** + +```typescript +// packages/tanstack-start/src/views/Root/TanStackAdminPage.tsx +'use client' + +import type { ImportMap } from 'payload' + +import { MinimalTemplate } from '@payloadcms/ui/templates/Minimal' +import { PageConfigProvider } from '@payloadcms/ui/providers/Config' +import { HydrateAuthProvider } from '@payloadcms/ui/elements/HydrateAuthProvider' +import React from 'react' + +import type { SerializablePageState } from './getPageState.js' +import { TanStackDefaultTemplate } from '../../templates/Default/index.js' +import { TanStackDashboardView } from '../Dashboard/TanStackDashboardView.js' +import { TanStackDocumentView } from '../Document/TanStackDocumentView.js' +import { TanStackListView } from '../List/TanStackListView.js' + +type Props = { + importMap: ImportMap + pageState: SerializablePageState & { searchParams: Record } +} + +function renderView( + pageState: SerializablePageState & { searchParams: Record }, +): React.ReactNode { + const { viewType, routeParams, permissions, searchParams, clientConfig, globalData, documentSubViewType } = pageState + + switch (viewType) { + case 'dashboard': + return ( + + ) + + case 'list': + case 'trash': + case 'collection-folders': + case 'folders': + return ( + + ) + + case 'document': + case 'version': + return ( + + ) + + // Auth / minimal template views — these are client-side already and don't need data fetching + case 'login': + case 'logout': + case 'createFirstUser': + case 'forgot': + case 'reset': + case 'verify': + case 'account': + default: + // For auth and other minimal views: the SSR pass from loader renders them. + // On client navigation these views are light and don't need separate data fetching. + return null + } +} + +export function TanStackAdminPage({ pageState }: Props) { + const { clientConfig, permissions, visibleEntities, navPreferences, templateType, templateClassName, routeParams, documentSubViewType, viewType } = pageState + + const view = renderView(pageState) + + if (templateType === 'minimal') { + return ( + + + + {view} + + + ) + } + + return ( + + + + {view} + + + ) +} +``` + +**Step 3: TypeScript check** + +```bash +cd packages/tanstack-start && pnpm tsc --noEmit 2>&1 | head -50 +``` + +**Step 4: Commit** + +```bash +git add packages/tanstack-start/src/views/Root/TanStackAdminPage.tsx +git commit -m "feat(tanstack-start): add TanStackAdminPage client component" +``` + +--- + +## Phase 4: Wire Routes + +### Task 5: Update `admin.$.tsx` and `admin.index.tsx` to use `getPageState` + `TanStackAdminPage` + +**Files:** + +- Modify: `tanstack-app/app/routes/admin.$.tsx` +- Modify: `tanstack-app/app/routes/admin.index.tsx` + +**Step 1: Read both files** + +Read them in full first (`admin.$.tsx` and `admin.index.tsx`) to understand what needs to change. + +**Step 2: Rewrite `admin.$.tsx`** + +```typescript +// tanstack-app/app/routes/admin.$.tsx +import config from '@payload-config' +import { TanStackAdminPage } from '@payloadcms/tanstack-start/views' +import { getPageState } from '@payloadcms/tanstack-start/views/Root/getPageState' +import { createFileRoute } from '@tanstack/react-router' +import React from 'react' + +import { importMap } from '../importMap.js' + +export const Route = createFileRoute('/admin/$')({ + loader: async ({ params, context }) => { + const segments = params._splat?.split('/').filter(Boolean) ?? [] + const searchParams = (context as any)?.searchParams ?? {} + return getPageState({ config, importMap, searchParams, segments }) + // Auth redirects and notFound are thrown inside getPageState + }, + component: AdminPage, +}) + +function AdminPage() { + const pageState = Route.useLoaderData() + const search = Route.useSearch() + return ( + }} + /> + ) +} +``` + +**Step 3: Rewrite `admin.index.tsx`** + +```typescript +// tanstack-app/app/routes/admin.index.tsx +import config from '@payload-config' +import { TanStackAdminPage } from '@payloadcms/tanstack-start/views' +import { getPageState } from '@payloadcms/tanstack-start/views/Root/getPageState' +import { createFileRoute } from '@tanstack/react-router' +import React from 'react' + +import { importMap } from '../importMap.js' + +export const Route = createFileRoute('/admin/')({ + loader: async () => { + return getPageState({ config, importMap, segments: [] }) + }, + component: AdminIndexPage, +}) + +function AdminIndexPage() { + const pageState = Route.useLoaderData() + const search = Route.useSearch() + return ( + }} + /> + ) +} +``` + +**Step 4: TypeScript check on the app** + +```bash +cd tanstack-app && npx tsc --noEmit 2>&1 | head -50 +``` + +Fix import path issues. `@payloadcms/tanstack-start/views/Root/getPageState` needs to be a valid export — check step 5 first. + +**Step 5: Update exports in packages/tanstack-start** + +Read `packages/tanstack-start/src/index.ts` and `packages/tanstack-start/package.json`. Add: + +- Export `TanStackAdminPage` from `packages/tanstack-start/src/index.ts` +- Export `getPageState` and `SerializablePageState` from `packages/tanstack-start/src/index.ts` +- Add export path `./views/Root/getPageState` to `packages/tanstack-start/package.json` exports if needed + +Then re-run TypeScript check. + +**Step 6: Start dev server and visually verify** + +```bash +pnpm run dev:tanstack +``` + +Open `http://localhost:3000/admin`. Verify: + +- Dashboard renders (might show loading state for view content) +- Nav appears +- No `server-only` import errors in browser console + +**Step 7: Commit** + +```bash +git add tanstack-app/app/routes/admin.$.tsx tanstack-app/app/routes/admin.index.tsx packages/tanstack-start/src/ +git commit -m "feat(tanstack-start): wire routes to use getPageState + TanStackAdminPage" +``` + +--- + +## Phase 5: Server Function Interceptor + Router Invalidation + +### Task 6: Intercept `render-document`/`render-list` and trigger router invalidation + +**Files:** + +- Modify: `packages/tanstack-start/src/utilities/handleServerFunctions.ts` +- Modify: `tanstack-app/app/routes/__root.tsx` +- Modify: `packages/tanstack-start/src/adapter/RouterProvider.tsx` + +**Context:** In Next.js, `render-document` and `render-list` return React nodes via the RSC protocol. In TanStack Start, these are called via `handleServerFn` (a `createServerFn`). They return React nodes, which are serialized via seroval. Instead of trying to serialize them, we return a signal `{ __tanstack_invalidate: true }` that tells the client to call `router.invalidate()` to refresh the page via a new loader run. + +**Step 1: Read current `handleServerFunctions.ts`** + +Read `packages/tanstack-start/src/utilities/handleServerFunctions.ts` to understand current structure. + +**Step 2: Add the interceptor** + +```typescript +// packages/tanstack-start/src/utilities/handleServerFunctions.ts +// Add at the top: +const TANSTACK_INVALIDATE_FNS = new Set([ + 'render-document', + 'render-list', + 'render-document-slots', +]) + +// In handleServerFunctions, before the dispatch call: +if (TANSTACK_INVALIDATE_FNS.has(fnKey)) { + return { __tanstack_invalidate: true } +} +``` + +**Step 3: Update `__root.tsx` to intercept the response** + +In `tanstack-app/app/routes/__root.tsx`, the `serverFunction` wrapper calls `handleServerFn`. Update the wrapper: + +```typescript +// After: const serverFunction: ServerFunctionClient = (args) => handleServerFn({ data: args }) +// Change to: +const serverFunction: ServerFunctionClient = async (args) => { + const result = await handleServerFn({ data: args }) + if ( + result && + typeof result === 'object' && + '__tanstack_invalidate' in result + ) { + // Signal handled by RouterProvider + return result + } + return result +} +``` + +**Step 4: Update `TanStackRouterProvider` to handle `__tanstack_invalidate`** + +Read `packages/tanstack-start/src/adapter/RouterProvider.tsx`. The RouterProvider wraps `BaseRouterProvider`. We need to intercept server function calls that return `{ __tanstack_invalidate: true }`. + +The place to intercept is in the `router.refresh` path — or we can override the server function wrapper at the `ServerFunctionsProvider` level. + +Actually, the cleanest approach: in `__root.tsx`, the `serverFunction` already calls `handleServerFn`. If we return `{ __tanstack_invalidate: true }`, the calling code in the admin UI will receive it. The admin UI calls server functions via `useServerFunctions()` hooks. These hooks need to detect the signal and call `router.invalidate()`. + +The `ServerFunctionsProvider` provides the `serverFunction` callback. We can wrap it: + +```typescript +// In tanstack-app/app/routes/__root.tsx, inside RootComponent: +const router = useRouter() + +const tanstackAwareServerFunction: ServerFunctionClient = React.useCallback( + async (args) => { + const result = await serverFunction(args) + if ( + result && + typeof result === 'object' && + '__tanstack_invalidate' in result + ) { + void router.invalidate() + return null + } + return result + }, + [router], +) + +// Then pass tanstackAwareServerFunction to RootLayout instead of serverFunction +``` + +Read `RootLayout` to understand where `serverFunction` is threaded through. + +**Step 5: Verify no TypeScript errors** + +```bash +cd packages/tanstack-start && pnpm tsc --noEmit 2>&1 | head -50 +cd tanstack-app && npx tsc --noEmit 2>&1 | head -50 +``` + +**Step 6: Commit** + +```bash +git add packages/tanstack-start/src/utilities/handleServerFunctions.ts tanstack-app/app/routes/__root.tsx +git commit -m "feat(tanstack-start): intercept render-document/render-list with router.invalidate()" +``` + +--- + +## Phase 6: Cleanup + +### Task 7: Remove `serverOnlyStubPlugin` from vite.config.ts + +**Prerequisite:** Verify in browser that no `import error` / `module not found` errors appear for server-only modules. If errors appear, the component tree still has server-only imports — debug before removing the plugin. + +**Files:** + +- Modify: `tanstack-app/vite.config.ts` + +**Step 1: Read the current vite.config.ts** + +Check `tanstack-app/vite.config.ts`. Remove: + +- The `SERVER_ONLY_PACKAGES` constant +- The `serverOnlyStubPlugin` function +- The `serverOnlyStubPlugin()` call in the `plugins` array +- The `readFileSync` import if no longer needed + +Keep `tanstackStartCompatPlugin` (still needed for the virtual module shim). + +**Step 2: Remove `server-only-stub.js` and `server-only-stub.cjs`** + +```bash +rm tanstack-app/server-only-stub.js tanstack-app/server-only-stub.cjs +``` + +**Step 3: Start dev server and verify** + +```bash +pnpm run dev:tanstack +``` + +Navigate through admin routes. If `ERR_MODULE_NOT_FOUND` or similar errors appear in the browser console, there's still a server-only import somewhere. Trace it: + +1. Open browser DevTools → Console +2. Find the module that failed to load +3. Find which file imports it: `grep -rn "import.*" tanstack-app/ packages/tanstack-start/src/` +4. Fix the import (move to a server function or remove) + +**Step 4: Commit** + +```bash +git add tanstack-app/vite.config.ts +git commit -m "chore(tanstack-start): remove serverOnlyStubPlugin after client-only component tree" +``` + +--- + +### Task 8: Remove `RootPage` from `packages/tanstack-start` + +**Files:** + +- Delete: `packages/tanstack-start/src/views/Root/index.tsx` +- Modify: `packages/tanstack-start/src/index.ts` (remove `RootPage` export) +- Modify: `packages/tanstack-start/package.json` (remove `./views` export if it only exported `RootPage`) + +**Step 1: Read current exports** + +```bash +cat packages/tanstack-start/src/index.ts +cat packages/tanstack-start/package.json | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps(d.get('exports',{}), indent=2))" +``` + +**Step 2: Remove the file and update exports** + +```bash +rm packages/tanstack-start/src/views/Root/index.tsx +``` + +Update `src/index.ts` to remove `RootPage` export. Update `package.json` if the `./views` export path pointed to the file with `RootPage`. + +**Step 3: TypeScript check** + +```bash +cd packages/tanstack-start && pnpm tsc --noEmit 2>&1 | head -50 +``` + +**Step 4: Commit** + +```bash +git add packages/tanstack-start/ +git commit -m "chore(tanstack-start): remove RootPage — replaced by TanStackAdminPage + getPageState" +``` + +--- + +## Phase 7: Tests + +### Task 9: Update integration tests for the new adapter shape + +**Files:** + +- Modify: `test/admin-adapter/tanstack-start.int.spec.ts` + +**Step 1: Run existing tests to see current state** + +```bash +pnpm run test:int admin-adapter 2>&1 | tail -30 +``` + +**Step 2: Add new tests for `getPageState` and `TanStackAdminPage` exports** + +```typescript +// Add to test/admin-adapter/tanstack-start.int.spec.ts: + +it('should export TanStackAdminPage', async () => { + const { TanStackAdminPage } = await import('@payloadcms/tanstack-start') + expect(typeof TanStackAdminPage).toBe('function') +}) + +it('should export getPageState', async () => { + const { getPageState } = await import('@payloadcms/tanstack-start') + expect(typeof getPageState).toBe('function') +}) + +it('should NOT export RootPage (removed)', async () => { + const mod = await import('@payloadcms/tanstack-start') + expect('RootPage' in mod).toBe(false) +}) + +it('render-document returns __tanstack_invalidate signal', async () => { + const { handleServerFunctions } = await import('@payloadcms/tanstack-start') + // handleServerFunctions needs full payload context to run + // Just verify it's the updated version by checking its source behavior via a mock + // (Full integration test requires a running Payload instance) + expect(typeof handleServerFunctions).toBe('function') +}) +``` + +**Step 3: Run tests** + +```bash +pnpm run test:int admin-adapter 2>&1 | tail -30 +``` + +Expected: All tests pass. + +**Step 4: Commit** + +```bash +git add test/admin-adapter/tanstack-start.int.spec.ts +git commit -m "test: update TanStack Start adapter integration tests for new client rendering" +``` + +--- + +### Task 10: E2E smoke test with `pnpm dev:tanstack` + +This task verifies the full flow works end-to-end before declaring victory. + +**Step 1: Start the TanStack dev server** + +```bash +pnpm run dev:tanstack +``` + +Wait for it to be ready (look for "ready" or "listening on port" message). + +**Step 2: Use Playwright MCP to verify** + +With the dev server running on `http://localhost:3000`: + +1. Navigate to `http://localhost:3000/admin` — should redirect to login +2. Log in with `dev@payloadcms.com` / `test` +3. Should see dashboard with collections/globals visible +4. Navigate to a collection list +5. Navigate to create a new document +6. Check browser console for `server-only` import errors — should be zero + +**Step 3: Run Next.js tests to verify no regressions** + +```bash +pnpm run test:int admin-root 2>&1 | tail -20 +``` + +Expected: All passing (11 tests as confirmed before this change). + +**Step 4: Fix any issues found** + +If browser console shows errors: + +- Identify the module that failed +- Find where it's imported in the TanStack client component tree +- Move to server function or remove + +**Step 5: Final commit** + +```bash +git add -A +git commit -m "chore(tanstack-start): verify e2e after client rendering migration" +``` + +--- + +## Dependency Graph + +``` +Task 1 (getPageState) + ↓ +Task 2 (TanStackDefaultTemplate) + ↓ +Tasks 3-4 (view components + TanStackAdminPage) + ↓ +Task 5 (wire routes) + ↓ +Task 6 (handleServerFunctions interceptor) + ↓ +Task 7 (remove serverOnlyStubPlugin) ← verify no more server-only imports first + ↓ +Task 8 (remove RootPage) + ↓ +Tasks 9-10 (tests + e2e) +``` + +--- + +## Key Things to Watch For + +1. **`groupNavItems` signature** — verify it accepts `ClientConfig` collections/globals, not `SanitizedConfig`. Run: `grep -n "groupNavItems\|EntityToGroup" packages/ui/src/utilities/groupNavItems.ts | head -20` + +2. **Import paths in `packages/ui`** — `@payloadcms/ui/elements/Nav/index.client` may not be a valid export path. Check `packages/ui/package.json` exports. You may need to use relative paths or find the correct export. + +3. **`getNavGroups` vs `groupNavItems`** — check which one is exported from packages/ui and is usable client-side. + +4. **`HydrateAuthProvider`** — check if it needs `permissions` or gets them from context. Read: `head -20 packages/ui/src/elements/HydrateAuthProvider/index.tsx` + +5. **`getPageState` import path** — since `getPageState.ts` is a new file, its import path in the route files needs a matching export entry in `packages/tanstack-start/package.json`. From 790185b334efa445fc165e074dbfce443664d37f Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Thu, 2 Apr 2026 08:23:33 +0100 Subject: [PATCH 32/60] =?UTF-8?q?chore(tanstack-start):=20update=20client?= =?UTF-8?q?=20rendering=20plan=20=E2=80=94=20add=20Phase=200=20buildTableD?= =?UTF-8?q?ata=20split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phase 0: extract buildTableData from buildTableState in packages/ui (RSC-free list data) - Task 1: getPageState calls buildTableData for list views, returns listData in SerializablePageState - Task 3: TanStackListView uses ListQueryProvider + SelectionProvider + DefaultListView directly (no server fn call on mount, no Table:ReactNode) - Task 4: TanStackAdminPage passes listData to TanStackListView - Constraint note: packages/ui change is one new file + one export line only Co-Authored-By: Claude Sonnet 4.6 --- ...02-tanstack-start-client-rendering-plan.md | 371 ++++++++++++++++-- 1 file changed, 333 insertions(+), 38 deletions(-) diff --git a/docs/plans/2026-04-02-tanstack-start-client-rendering-plan.md b/docs/plans/2026-04-02-tanstack-start-client-rendering-plan.md index f1d95966623..80d4dddfb91 100644 --- a/docs/plans/2026-04-02-tanstack-start-client-rendering-plan.md +++ b/docs/plans/2026-04-02-tanstack-start-client-rendering-plan.md @@ -24,7 +24,244 @@ Before starting, read these files to understand the existing architecture: - `packages/ui/src/elements/Nav/index.client.tsx` — `DefaultNavClient` (client, takes `groups` + `navPreferences`) - `packages/payload/src/admin/views/index.ts` — `ViewTypes`, `AdminViewClientProps` -**Important constraint:** Do NOT modify any file in `packages/next/` or `packages/ui/`. All changes are in `packages/tanstack-start/` and `tanstack-app/`. +**Important constraint:** Changes to `packages/ui/` are limited to Phase 0 (one new utility file + one export line). Do NOT touch `packages/next/` or any existing `packages/ui/` files. All other changes are in `packages/tanstack-start/` and `tanstack-app/`. + +--- + +## Phase 0: packages/ui — Extract `buildTableData` + +### Task 0: Create `buildTableData` — RSC-free list data fetcher + +**Context:** `buildTableState` bakes RSC rendering into the data layer — it calls `renderTable` (returns `Table: React.ReactNode`) and `renderFilters` (returns `Map`). These React nodes can't be serialized through `createServerFn`. We need a variant that returns only plain serializable data. `buildFormState` already returns pure serializable state — this brings the list view in line with the same pattern. + +**What does NOT change:** `buildTableState` (and its handler) are untouched. Next.js still calls them. No regressions. + +**Files:** + +- Create: `packages/ui/src/utilities/buildTableData.ts` +- Modify: `packages/ui/src/exports/utilities.ts` (one line: add export) + +**Step 1: Read the source** + +Read `packages/ui/src/utilities/buildTableState.ts` in full. Note: + +- Lines ~75–201: data-fetching logic (serializable: `data`, `preferences`) +- Line ~203–231: `renderTable(...)` — RSC, returns `{ columnState: Column[], Table: React.ReactNode }` — we skip this +- Line ~236: `renderFilters(...)` — RSC, returns `Map` — we skip this +- `getColumns(...)` is the call inside `renderTable` that returns `Column[]` — we call it directly instead + +**Step 2: Create the file** + +```typescript +// packages/ui/src/utilities/buildTableData.ts +import type { + BuildTableStateArgs, + ClientCollectionConfig, + CollectionPreferences, + Column, + PaginatedDocs, + SanitizedCollectionConfig, + ServerFunction, + Where, +} from 'payload' + +import { APIError, canAccessAdmin } from 'payload' +import { applyLocaleFiltering, isNumber } from 'payload/shared' + +import { getClientConfig } from './getClientConfig.js' +import { getColumns } from './getColumns.js' +import { upsertPreferences } from './upsertPreferences.js' + +export type BuildTableDataResult = { + columns: Column[] + data: PaginatedDocs + preferences: CollectionPreferences +} + +/** + * Serializable variant of buildTableState — returns only plain data, no React nodes. + * Use in non-RSC adapters (e.g. TanStack Start) where React.ReactNode cannot be serialized. + * Next.js continues to use buildTableState (with RSC table rendering). + */ +export const buildTableData: ServerFunction< + BuildTableStateArgs, + Promise +> = async (args) => { + const { + collectionSlug, + columns: columnsFromArgs, + data: dataFromArgs, + orderableFieldName, + parent, + permissions, + query, + req, + req: { + i18n, + payload, + payload: { config }, + user, + }, + } = args + + await canAccessAdmin({ req }) + + const clientConfig = getClientConfig({ + config, + i18n, + importMap: payload.importMap, + user, + }) + + await applyLocaleFiltering({ clientConfig, config, req }) + + let collectionConfig: SanitizedCollectionConfig + let clientCollectionConfig: ClientCollectionConfig + + if (!Array.isArray(collectionSlug)) { + if (req.payload.collections[collectionSlug]) { + collectionConfig = req.payload.collections[collectionSlug].config + clientCollectionConfig = clientConfig.collections.find( + (collection) => collection.slug === collectionSlug, + ) + } + } + + const preferencesKey = parent + ? `${parent.collectionSlug}-${parent.joinPath}` + : `collection-${collectionSlug}` + + const collectionPreferences = await upsertPreferences({ + key: preferencesKey, + req, + value: { + columns: columnsFromArgs, + limit: isNumber(query?.limit) ? Number(query.limit) : undefined, + sort: query?.sort as string, + }, + }) + + let data: PaginatedDocs = dataFromArgs + + if (!data?.docs || query) { + if (Array.isArray(collectionSlug)) { + if (!parent) { + throw new APIError( + 'Unexpected array of collectionSlug, parent must be provided', + ) + } + + const select = {} + let currentSelectRef = select + const segments = parent.joinPath.split('.') + for (let i = 0; i < segments.length; i++) { + currentSelectRef[segments[i]] = i === segments.length - 1 ? true : {} + currentSelectRef = currentSelectRef[segments[i]] + } + + const joinQuery: { + limit?: number + page?: number + sort?: string + where?: Where + } = { + sort: query?.sort as string, + where: query?.where, + } + if (query) { + if (!Number.isNaN(Number(query.limit))) + joinQuery.limit = Number(query.limit) + if (!Number.isNaN(Number(query.page))) + joinQuery.page = Number(query.page) + } + + let parentDoc = await payload.findByID({ + id: parent.id, + collection: parent.collectionSlug, + depth: 1, + joins: { [parent.joinPath]: joinQuery }, + overrideAccess: false, + select, + user: req.user, + }) + for (let i = 0; i < segments.length; i++) { + if (i === segments.length - 1) { + data = parentDoc[segments[i]] + } else { + parentDoc = parentDoc[segments[i]] + } + } + } else { + data = await payload.find({ + collection: collectionSlug, + depth: 0, + draft: true, + limit: query?.limit, + locale: req.locale, + overrideAccess: false, + page: query?.page, + sort: query?.sort, + user: req.user, + where: query?.where, + }) + } + } + + // Call getColumns directly — no RSC rendering, no Table: React.ReactNode + const columns = getColumns({ + clientConfig, + collectionConfig: clientCollectionConfig, + collectionSlug, + columns: columnsFromArgs, + i18n: req.i18n, + permissions, + }) + + return { + columns, + data, + preferences: collectionPreferences, + } +} +``` + +**Step 3: Export it** + +Find where `buildTableState` is exported from `packages/ui/src/exports/utilities.ts` (or `packages/ui/src/index.ts`): + +```bash +grep -n "buildTableState" packages/ui/src/exports/utilities.ts +``` + +Add one line next to the existing `buildTableState` export: + +```typescript +export { buildTableData } from '../utilities/buildTableData.js' +export type { BuildTableDataResult } from '../utilities/buildTableData.js' +``` + +**Step 4: Verify `getColumns` signature matches** + +```bash +grep -n "export.*getColumns\|function getColumns" packages/ui/src/utilities/getColumns.ts | head -5 +``` + +If the signature differs from what `renderTable` passes (e.g. missing `orderableFieldName`), add the missing args. The goal is identical column output to what `buildTableState` returns in `columnState`. + +**Step 5: TypeScript check** + +```bash +cd packages/ui && pnpm tsc --noEmit 2>&1 | head -50 +``` + +Fix any type errors. The new file should have zero new errors. + +**Step 6: Commit** + +```bash +git add packages/ui/src/utilities/buildTableData.ts packages/ui/src/exports/utilities.ts +git commit -m "feat(ui): add buildTableData — serializable list data without RSC rendering" +``` --- @@ -44,10 +281,13 @@ Before starting, read these files to understand the existing architecture: // packages/tanstack-start/src/views/Root/getPageState.ts import type { ClientConfig, + CollectionPreferences, + Column, DocumentSubViewTypes, ImportMap, Locale, NavPreferences, + PaginatedDocs, PayloadComponent, SanitizedCollectionConfig, SanitizedConfig, @@ -62,6 +302,7 @@ import { formatAdminURL, getVisibleEntities, } from 'payload/shared' +import { buildTableData } from '@payloadcms/ui/utilities/buildTableData' import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig' import { getNavPrefs } from '@payloadcms/ui/elements/Nav/getNavPrefs' import { handleAuthRedirect } from '@payloadcms/ui/utilities/handleAuthRedirect' @@ -95,6 +336,12 @@ export type SerializablePageState = { // For custom views resolved via importMap customViewPath?: string documentSubViewType?: DocumentSubViewTypes + // Populated when viewType === 'list' — serializable list data (no React nodes) + listData?: { + columns: Column[] + data: PaginatedDocs + preferences: CollectionPreferences + } viewActions?: PayloadComponent[] viewType: ViewTypes | undefined visibleEntities: { @@ -217,6 +464,25 @@ export async function getPageState({ globalData = await getGlobalData(req) } + // For list views: fetch serializable table data (no RSC rendering) + let listData: SerializablePageState['listData'] + if ( + viewType === 'list' || + viewType === 'trash' || + viewType === 'collection-folders' + ) { + const collectionSlug = routeParams.collection + if (collectionSlug) { + listData = await buildTableData({ + collectionSlug, + enableRowSelections: true, + query: searchParams as Record, + req, + renderRowTypes: true, + }) + } + } + // Custom view path: if DefaultView has a payloadComponent, pass its path const defaultView = routeData.DefaultView const customViewPath = defaultView?.payloadComponent @@ -231,6 +497,7 @@ export async function getPageState({ customViewPath, documentSubViewType, globalData, + listData, locale, navPreferences, permissions, @@ -466,59 +733,81 @@ grep -n "BuildFormStateArgs\|BuildTableStateArgs" packages/payload/src/types/*.t **Step 2: Create `TanStackListView`** +**Key insight:** `DefaultListView` is already `'use client'` and accepts `ListViewClientProps`. It uses `ListQueryProvider` and `TableColumnsProvider` internally via hooks. We set those providers up here with the serializable data from the loader (`listData`). No server function calls on mount — the data came from the loader. + +`DefaultListView` uses `Table` prop (`React.ReactNode`) to render the actual table rows. Since we don't have a rendered `Table` node (we have `columns: Column[]` instead), we pass `Table={null}` and rely on the `TableColumnsProvider` + `SelectionProvider` that `DefaultListView` sets up internally. Check the actual `DefaultListView` source to confirm — it may render a table from `TableColumnsProvider` context rather than the `Table` prop directly. If `Table` is required, we render a basic placeholder; a full table renderer can be added in a follow-up. + ```typescript // packages/tanstack-start/src/views/List/TanStackListView.tsx 'use client' -import type { SanitizedPermissions, ViewTypes } from 'payload' +import type { CollectionPreferences, Column, PaginatedDocs, SanitizedPermissions, ViewTypes } from 'payload' -import { useServerFunctions } from '@payloadcms/ui/providers/ServerFunctions' -import React, { useEffect, useState } from 'react' +import { DefaultListView } from '@payloadcms/ui/views/List' +import { ListQueryProvider } from '@payloadcms/ui/providers/ListQuery' +import { SelectionProvider } from '@payloadcms/ui/providers/Selection' +import React from 'react' type Props = { collectionSlug: string + listData: { + columns: Column[] + data: PaginatedDocs + preferences: CollectionPreferences + } permissions: SanitizedPermissions searchParams: Record viewType?: ViewTypes } -export function TanStackListView({ collectionSlug, searchParams }: Props) { - const { getTableState } = useServerFunctions() - const [tableContent, setTableContent] = useState(null) - const [error, setError] = useState(null) - - useEffect(() => { - let cancelled = false - setTableContent(null) - setError(null) +export function TanStackListView({ collectionSlug, listData, searchParams }: Props) { + const { columns, data, preferences } = listData - getTableState({ - collectionSlug, - enableRowSelections: true, - query: searchParams as Record, - renderRowTypes: true, - }) - .then((result) => { - if (cancelled) return - if ('errors' in result || 'message' in result) { - setError('message' in result ? result.message : 'Failed to load list') - } else if (result) { - setTableContent(result.Table) - } - }) - .catch((err: Error) => { - if (!cancelled) setError(err.message) - }) - - return () => { cancelled = true } - }, [collectionSlug, JSON.stringify(searchParams)]) // eslint-disable-line react-hooks/exhaustive-deps + // Build initial query from searchParams (limit, sort, page, where) + const query = { + limit: searchParams.limit ? Number(searchParams.limit) : preferences.limit, + page: searchParams.page ? Number(searchParams.page) : undefined, + sort: (searchParams.sort as string) ?? preferences.sort, + where: searchParams.where ? JSON.parse(searchParams.where as string) : undefined, + } - if (error) return
    {error}
    - if (!tableContent) return
    - return <>{tableContent} + return ( + + + + + + ) } ``` +**Note:** The `DefaultListView` props above are intentionally minimal. Read `packages/payload/src/admin/views/list.ts` for the full `ListViewClientProps` type and fill in any additional required props. Specifically: + +- `newDocumentURL` — compute from `clientConfig.routes.admin` + `collectionSlug` +- `hasCreatePermission` — derive from `permissions.collections[collectionSlug].create.permission` +- `hasDeletePermission` — derive from `permissions.collections[collectionSlug].delete.permission` +- `renderedFilters` — pass `undefined` for now; filter rendering in TanStack Start is deferred (filters are client-only, driven by URL params via `ListQueryProvider`) + +```` + **Step 3: Create `TanStackDocumentView`** ```typescript @@ -585,7 +874,7 @@ export function TanStackDocumentView({ if (!content) return
    return <>{content} } -``` +```` **Note:** The document view stub above is intentionally minimal. In practice, the document form is initially rendered via SSR (`RootPage` during SSR still renders). Client-side navigation to document pages triggers a new SSR pass. The `getFormState` call here is for ensuring the form is hydrated with current data after SSR. Expand this in a follow-up once the full `DocumentViewClient` integration is understood by reading `packages/ui/src/views/Document/` more deeply. @@ -719,9 +1008,11 @@ function renderView( case 'trash': case 'collection-folders': case 'folders': + if (!pageState.listData) return
    return ( Date: Thu, 2 Apr 2026 09:01:49 +0100 Subject: [PATCH 33/60] docs: rewrite tanstack start client rendering plan Co-authored-by: Codex --- ...02-tanstack-start-client-rendering-plan.md | 1663 +++-------------- ...6-04-02-tanstack-start-client-rendering.md | 415 ++-- 2 files changed, 600 insertions(+), 1478 deletions(-) diff --git a/docs/plans/2026-04-02-tanstack-start-client-rendering-plan.md b/docs/plans/2026-04-02-tanstack-start-client-rendering-plan.md index 80d4dddfb91..bd66b64bdcb 100644 --- a/docs/plans/2026-04-02-tanstack-start-client-rendering-plan.md +++ b/docs/plans/2026-04-02-tanstack-start-client-rendering-plan.md @@ -1,325 +1,98 @@ # TanStack Start Client Rendering Implementation Plan -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. +## Goal -**Goal:** Replace `RootPage` (server component, imports server-only modules) with a `createServerFn`-based `getPageState` + a fully client-side `TanStackAdminPage`, eliminating the need for `serverOnlyStubPlugin`. +Replace the current TanStack Start admin page path, which renders the server-side `RootPage`, with: -**Architecture:** Loader calls `getPageState` server function → returns serializable page state → `TanStackAdminPage` (client component) renders template shell + view components. View components call `useServerFunctions()` on mount to fetch their data. Next.js is untouched. TanStack Start is its own rendering path. +1. A serializable server-side page-state function +2. A client-only TanStack admin page shell +3. Client-side view wrappers that reuse existing client UI pieces from `packages/ui` -**Tech Stack:** TypeScript, React 19, `@tanstack/react-start` (`createServerFn`), `@tanstack/react-router`, Payload CMS monorepo (pnpm + Turbo) +This removes the need for `serverOnlyStubPlugin` in the TanStack app while leaving the Next.js adapter untouched. ---- +## Verified Current State -## Background / Key Concepts +These points are based on the current repo, not assumptions: -Before starting, read these files to understand the existing architecture: +- [tanstack-app/app/routes/admin.$.tsx](/Users/orakhmatulin/work/payload/tanstack-app/app/routes/admin.$.tsx) still returns `segments` from the loader and renders `RootPage`. +- [packages/tanstack-start/src/views/Root/index.tsx](/Users/orakhmatulin/work/payload/packages/tanstack-start/src/views/Root/index.tsx) imports `@payloadcms/ui/views/Root/RenderRoot`, which keeps TanStack on the RSC path. +- [packages/tanstack-start/src/layouts/Root/index.tsx](/Users/orakhmatulin/work/payload/packages/tanstack-start/src/layouts/Root/index.tsx) already mounts `RootProvider`, `TanStackRouterProvider`, auth, translations, config, and server functions. The new page path must fit inside this provider tree. +- There is no existing TanStack-specific client page/view layer in `packages/tanstack-start/src/views` beyond the server `RootPage`. +- Existing reusable client view pieces already live in `packages/ui`, notably: + - [packages/ui/src/views/List/index.tsx](/Users/orakhmatulin/work/payload/packages/ui/src/views/List/index.tsx) + - [packages/ui/src/views/Edit/index.tsx](/Users/orakhmatulin/work/payload/packages/ui/src/views/Edit/index.tsx) + - [packages/ui/src/views/CreateFirstUser/index.client.tsx](/Users/orakhmatulin/work/payload/packages/ui/src/views/CreateFirstUser/index.client.tsx) + - [packages/ui/src/views/Dashboard/Default/ModularDashboard/index.client.tsx](/Users/orakhmatulin/work/payload/packages/ui/src/views/Dashboard/Default/ModularDashboard/index.client.tsx) +- Existing server functions `render-document` and `render-list` still return React nodes: + - [packages/ui/src/views/Document/handleServerFunction.tsx](/Users/orakhmatulin/work/payload/packages/ui/src/views/Document/handleServerFunction.tsx) + - [packages/ui/src/views/List/handleServerFunction.tsx](/Users/orakhmatulin/work/payload/packages/ui/src/views/List/handleServerFunction.tsx) +- The current default template/nav path is server-oriented: + - [packages/ui/src/templates/Default/index.tsx](/Users/orakhmatulin/work/payload/packages/ui/src/templates/Default/index.tsx) + - [packages/ui/src/elements/Nav/index.tsx](/Users/orakhmatulin/work/payload/packages/ui/src/elements/Nav/index.tsx) -- `tanstack-app/app/routes/admin.$.tsx` — current route: calls `RootPage` in component -- `tanstack-app/app/routes/__root.tsx` — root layout: shows how `createServerFn` + `dispatchServerFunction` is wired -- `packages/tanstack-start/src/views/Root/index.tsx` — current `RootPage` (replaces with new approach) -- `packages/ui/src/views/Root/RenderRoot.tsx` — `renderRootPage` (Next.js uses this; TanStack Start will NOT) -- `packages/ui/src/views/Root/getRouteData.ts` — `getRouteData` function (we call this server-side in `getPageState`) -- `packages/ui/src/templates/Default/index.tsx` — `DefaultTemplate` (needs `payload: Payload`; we create a client version) -- `packages/ui/src/elements/Nav/index.tsx` — `DefaultNav` (async server component; client version is `DefaultNavClient`) -- `packages/ui/src/elements/Nav/index.client.tsx` — `DefaultNavClient` (client, takes `groups` + `navPreferences`) -- `packages/payload/src/admin/views/index.ts` — `ViewTypes`, `AdminViewClientProps` +## Constraints -**Important constraint:** Changes to `packages/ui/` are limited to Phase 0 (one new utility file + one export line). Do NOT touch `packages/next/` or any existing `packages/ui/` files. All other changes are in `packages/tanstack-start/` and `tanstack-app/`. +- Do not modify `packages/next`. +- Keep Next.js behavior unchanged. +- Minimize changes in `packages/ui`. +- A small `packages/ui` exception is acceptable where required for serialization support. The main justified example is extracting a serializable `buildTableData`. +- Do not attempt to serialize React nodes across TanStack server functions. +- Keep `RootLayout` and `RootProvider` as the shared TanStack layout foundation. ---- +## Architecture -## Phase 0: packages/ui — Extract `buildTableData` +### Next.js path -### Task 0: Create `buildTableData` — RSC-free list data fetcher +Unchanged. It continues to use: -**Context:** `buildTableState` bakes RSC rendering into the data layer — it calls `renderTable` (returns `Table: React.ReactNode`) and `renderFilters` (returns `Map`). These React nodes can't be serialized through `createServerFn`. We need a variant that returns only plain serializable data. `buildFormState` already returns pure serializable state — this brings the list view in line with the same pattern. +- `renderRootPage` +- Server-rendered templates +- Server-rendered view entry points +- Existing `render-document` and `render-list` behavior -**What does NOT change:** `buildTableState` (and its handler) are untouched. Next.js still calls them. No regressions. +### TanStack Start path -**Files:** +New flow: -- Create: `packages/ui/src/utilities/buildTableData.ts` -- Modify: `packages/ui/src/exports/utilities.ts` (one line: add export) +1. Route loader calls `getPageState` +2. `getPageState` runs server-side and returns only serializable data +3. Route component renders a client-only `TanStackAdminPage` +4. `TanStackAdminPage` picks a built-in client wrapper by `viewType` +5. Client wrappers reuse existing client components from `packages/ui` +6. Any view-specific mutations that currently depend on `render-document` / `render-list` trigger route invalidation instead of returning React nodes -**Step 1: Read the source** +### Important scope correction -Read `packages/ui/src/utilities/buildTableState.ts` in full. Note: +The implementation should not try to make the existing `packages/ui` server view entry points directly serializable. Instead: -- Lines ~75–201: data-fetching logic (serializable: `data`, `preferences`) -- Line ~203–231: `renderTable(...)` — RSC, returns `{ columnState: Column[], Table: React.ReactNode }` — we skip this -- Line ~236: `renderFilters(...)` — RSC, returns `Map` — we skip this -- `getColumns(...)` is the call inside `renderTable` that returns `Column[]` — we call it directly instead +- Keep those server entry points for Next.js +- Add TanStack-specific client wrappers in `packages/tanstack-start` +- Reuse existing client components from `packages/ui` where they already exist -**Step 2: Create the file** +## Serializable Page State -```typescript -// packages/ui/src/utilities/buildTableData.ts -import type { - BuildTableStateArgs, - ClientCollectionConfig, - CollectionPreferences, - Column, - PaginatedDocs, - SanitizedCollectionConfig, - ServerFunction, - Where, -} from 'payload' +Create a new server-side page-state function in: -import { APIError, canAccessAdmin } from 'payload' -import { applyLocaleFiltering, isNumber } from 'payload/shared' +- `packages/tanstack-start/src/views/Root/getPageState.ts` -import { getClientConfig } from './getClientConfig.js' -import { getColumns } from './getColumns.js' -import { upsertPreferences } from './upsertPreferences.js' +Responsibilities: -export type BuildTableDataResult = { - columns: Column[] - data: PaginatedDocs - preferences: CollectionPreferences -} - -/** - * Serializable variant of buildTableState — returns only plain data, no React nodes. - * Use in non-RSC adapters (e.g. TanStack Start) where React.ReactNode cannot be serialized. - * Next.js continues to use buildTableState (with RSC table rendering). - */ -export const buildTableData: ServerFunction< - BuildTableStateArgs, - Promise -> = async (args) => { - const { - collectionSlug, - columns: columnsFromArgs, - data: dataFromArgs, - orderableFieldName, - parent, - permissions, - query, - req, - req: { - i18n, - payload, - payload: { config }, - user, - }, - } = args - - await canAccessAdmin({ req }) - - const clientConfig = getClientConfig({ - config, - i18n, - importMap: payload.importMap, - user, - }) - - await applyLocaleFiltering({ clientConfig, config, req }) - - let collectionConfig: SanitizedCollectionConfig - let clientCollectionConfig: ClientCollectionConfig - - if (!Array.isArray(collectionSlug)) { - if (req.payload.collections[collectionSlug]) { - collectionConfig = req.payload.collections[collectionSlug].config - clientCollectionConfig = clientConfig.collections.find( - (collection) => collection.slug === collectionSlug, - ) - } - } - - const preferencesKey = parent - ? `${parent.collectionSlug}-${parent.joinPath}` - : `collection-${collectionSlug}` - - const collectionPreferences = await upsertPreferences({ - key: preferencesKey, - req, - value: { - columns: columnsFromArgs, - limit: isNumber(query?.limit) ? Number(query.limit) : undefined, - sort: query?.sort as string, - }, - }) - - let data: PaginatedDocs = dataFromArgs - - if (!data?.docs || query) { - if (Array.isArray(collectionSlug)) { - if (!parent) { - throw new APIError( - 'Unexpected array of collectionSlug, parent must be provided', - ) - } - - const select = {} - let currentSelectRef = select - const segments = parent.joinPath.split('.') - for (let i = 0; i < segments.length; i++) { - currentSelectRef[segments[i]] = i === segments.length - 1 ? true : {} - currentSelectRef = currentSelectRef[segments[i]] - } - - const joinQuery: { - limit?: number - page?: number - sort?: string - where?: Where - } = { - sort: query?.sort as string, - where: query?.where, - } - if (query) { - if (!Number.isNaN(Number(query.limit))) - joinQuery.limit = Number(query.limit) - if (!Number.isNaN(Number(query.page))) - joinQuery.page = Number(query.page) - } - - let parentDoc = await payload.findByID({ - id: parent.id, - collection: parent.collectionSlug, - depth: 1, - joins: { [parent.joinPath]: joinQuery }, - overrideAccess: false, - select, - user: req.user, - }) - for (let i = 0; i < segments.length; i++) { - if (i === segments.length - 1) { - data = parentDoc[segments[i]] - } else { - parentDoc = parentDoc[segments[i]] - } - } - } else { - data = await payload.find({ - collection: collectionSlug, - depth: 0, - draft: true, - limit: query?.limit, - locale: req.locale, - overrideAccess: false, - page: query?.page, - sort: query?.sort, - user: req.user, - where: query?.where, - }) - } - } - - // Call getColumns directly — no RSC rendering, no Table: React.ReactNode - const columns = getColumns({ - clientConfig, - collectionConfig: clientCollectionConfig, - collectionSlug, - columns: columnsFromArgs, - i18n: req.i18n, - permissions, - }) - - return { - columns, - data, - preferences: collectionPreferences, - } -} -``` - -**Step 3: Export it** - -Find where `buildTableState` is exported from `packages/ui/src/exports/utilities.ts` (or `packages/ui/src/index.ts`): - -```bash -grep -n "buildTableState" packages/ui/src/exports/utilities.ts -``` +- Call `initReq` +- Recreate the route-resolution logic currently buried in `renderRootPage` +- Handle redirects and notFound via TanStack Router errors +- Return only serializable data +- Recompute any page-level data the client shell cannot derive from context alone -Add one line next to the existing `buildTableState` export: +### Required fields -```typescript -export { buildTableData } from '../utilities/buildTableData.js' -export type { BuildTableDataResult } from '../utilities/buildTableData.js' -``` - -**Step 4: Verify `getColumns` signature matches** - -```bash -grep -n "export.*getColumns\|function getColumns" packages/ui/src/utilities/getColumns.ts | head -5 -``` +The page state should include at least: -If the signature differs from what `renderTable` passes (e.g. missing `orderableFieldName`), add the missing args. The goal is identical column output to what `buildTableState` returns in `columnState`. - -**Step 5: TypeScript check** - -```bash -cd packages/ui && pnpm tsc --noEmit 2>&1 | head -50 -``` - -Fix any type errors. The new file should have zero new errors. - -**Step 6: Commit** - -```bash -git add packages/ui/src/utilities/buildTableData.ts packages/ui/src/exports/utilities.ts -git commit -m "feat(ui): add buildTableData — serializable list data without RSC rendering" -``` - ---- - -## Phase 1: Serializable Page State - -### Task 1: Define `SerializablePageState` type and create `getPageState` server function - -**Files:** - -- Create: `packages/tanstack-start/src/views/Root/getPageState.ts` - -**What this does:** Calls `initReq` + `getRouteData` + `getNavPrefs` + (for dashboard) `getGlobalData` server-side. Returns a plain serializable object. Handles auth redirects and notFound by throwing TanStack Router errors. - -**Step 1: Create the file** - -```typescript -// packages/tanstack-start/src/views/Root/getPageState.ts -import type { - ClientConfig, - CollectionPreferences, - Column, - DocumentSubViewTypes, - ImportMap, - Locale, - NavPreferences, - PaginatedDocs, - PayloadComponent, - SanitizedCollectionConfig, - SanitizedConfig, - SanitizedGlobalConfig, - SanitizedPermissions, - ViewTypes, -} from 'payload' - -import { notFound, redirect } from '@tanstack/react-router' -import { - applyLocaleFiltering, - formatAdminURL, - getVisibleEntities, -} from 'payload/shared' -import { buildTableData } from '@payloadcms/ui/utilities/buildTableData' -import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig' -import { getNavPrefs } from '@payloadcms/ui/elements/Nav/getNavPrefs' -import { handleAuthRedirect } from '@payloadcms/ui/utilities/handleAuthRedirect' -import { isCustomAdminView } from '@payloadcms/ui/utilities/isCustomAdminView' -import { isPublicAdminRoute } from '@payloadcms/ui/utilities/isPublicAdminRoute' -import { getGlobalData } from '@payloadcms/ui/utilities/getGlobalData' -import { getRouteData } from '@payloadcms/ui/views/Root/getRouteData' - -import { initReq } from '../../utilities/initReq.js' - -export type SerializablePageState = { +```ts +type SerializablePageState = { browseByFolderSlugs: string[] clientConfig: ClientConfig - // Only populated when viewType === 'dashboard' - globalData?: Awaited> + documentSubViewType?: DocumentSubViewTypes locale?: Locale - navPreferences?: NavPreferences permissions: SanitizedPermissions routeParams: { collection?: string @@ -330,1226 +103,386 @@ export type SerializablePageState = { token?: string versionID?: number | string } - segments: string[] - templateClassName: string - templateType: 'default' | 'minimal' | undefined - // For custom views resolved via importMap - customViewPath?: string - documentSubViewType?: DocumentSubViewTypes - // Populated when viewType === 'list' — serializable list data (no React nodes) - listData?: { - columns: Column[] - data: PaginatedDocs - preferences: CollectionPreferences - } - viewActions?: PayloadComponent[] - viewType: ViewTypes | undefined - visibleEntities: { - collections: string[] - globals: string[] - } -} - -export async function getPageState({ - config: configArg, - importMap, - searchParams, - segments, -}: { - config: Promise | SanitizedConfig - importMap: ImportMap searchParams?: Record segments: string[] -}): Promise { - const initPageResult = await initReq({ - config: configArg, - importMap, - key: 'getPageState', - }) - const { locale, permissions, req } = initPageResult - const { payload } = req - const config = payload.config - const { - routes: { admin: adminRoute }, - } = config - - const currentRoute = formatAdminURL({ - adminRoute, - path: segments.length > 0 ? `/${segments.join('/')}` : null, - }) - - // Auth check - if ( - !permissions.canAccessAdmin && - !isPublicAdminRoute({ adminRoute, config, route: currentRoute }) && - !isCustomAdminView({ adminRoute, config, route: currentRoute }) - ) { - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw redirect({ - to: handleAuthRedirect({ - config, - route: currentRoute, - searchParams, - user: req.user, - }), - }) - } - - // Compute collection/global config from segments - let collectionConfig: SanitizedCollectionConfig | undefined - let globalConfig: SanitizedGlobalConfig | undefined - - if (segments[0] === 'collections' && segments[1]) { - collectionConfig = config.collections.find( - ({ slug }) => slug === segments[1], - ) - if (!collectionConfig) { - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw notFound() - } - } else if (segments[0] === 'globals' && segments[1]) { - globalConfig = config.globals.find(({ slug }) => slug === segments[1]) - if (!globalConfig) { - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw notFound() - } - } - - const routeData = getRouteData({ - adminRoute, - collectionConfig, - currentRoute, - globalConfig, - payload, - searchParams: searchParams ?? {}, - segments, - }) - - const { - browseByFolderSlugs, - documentSubViewType, - routeParams, - templateClassName, - templateType, - viewActions, - viewType, - } = routeData - - // Serialize viewActions: keep only PayloadComponent references (path strings), drop React.FC - const serializableViewActions: PayloadComponent[] = ( - viewActions ?? [] - ).filter( - (a): a is PayloadComponent => - typeof a === 'string' || (typeof a === 'object' && 'path' in a), - ) - - // Get nav preferences - const navPreferences = await getNavPrefs(req) - - // Get client config - const clientConfig = getClientConfig({ - config, - i18n: req.i18n, - importMap, - user: req.user, - }) - - await applyLocaleFiltering({ clientConfig, config, req }) - - const visibleEntities = getVisibleEntities({ req }) - - // For dashboard: pre-fetch global data - let globalData: SerializablePageState['globalData'] - if (viewType === 'dashboard') { - globalData = await getGlobalData(req) - } - - // For list views: fetch serializable table data (no RSC rendering) - let listData: SerializablePageState['listData'] - if ( - viewType === 'list' || - viewType === 'trash' || - viewType === 'collection-folders' - ) { - const collectionSlug = routeParams.collection - if (collectionSlug) { - listData = await buildTableData({ - collectionSlug, - enableRowSelections: true, - query: searchParams as Record, - req, - renderRowTypes: true, - }) - } - } - - // Custom view path: if DefaultView has a payloadComponent, pass its path - const defaultView = routeData.DefaultView - const customViewPath = defaultView?.payloadComponent - ? typeof defaultView.payloadComponent === 'string' - ? defaultView.payloadComponent - : (defaultView.payloadComponent as { path: string }).path - : undefined - - return { - browseByFolderSlugs, - clientConfig, - customViewPath, - documentSubViewType, - globalData, - listData, - locale, - navPreferences, - permissions, - routeParams, - segments, - templateClassName, - templateType, - viewActions: - serializableViewActions.length > 0 ? serializableViewActions : undefined, - viewType, - visibleEntities: { - collections: visibleEntities.collections, - globals: visibleEntities.globals, - }, - } -} -``` - -**Step 2: Verify the import paths exist** - -Run these to confirm the imports resolve: - -```bash -grep -r "export.*getGlobalData" packages/ui/src/utilities/getGlobalData.ts -grep -r "export.*getRouteData" packages/ui/src/views/Root/getRouteData.ts -grep -r "export.*getNavPrefs" packages/ui/src/elements/Nav/getNavPrefs.ts -grep -r "export.*getClientConfig" packages/ui/src/utilities/getClientConfig.ts -grep -r "export.*getVisibleEntities" packages/ui/src/utilities/getVisibleEntities.ts -``` - -If any import path is wrong, check `packages/ui/src/exports/` for the correct path. - -**Step 3: TypeScript check (tanstack-start only)** - -```bash -cd packages/tanstack-start && pnpm tsc --noEmit 2>&1 | head -50 -``` - -Expected: no errors on the new file. Fix any import path issues. - -**Step 4: Commit** - -```bash -git add packages/tanstack-start/src/views/Root/getPageState.ts -git commit -m "feat(tanstack-start): add getPageState server function returning serializable page state" -``` - ---- - -## Phase 2: Client Template (No Server Deps) - -### Task 2: Create `TanStackDefaultTemplate` client component - -**Context:** The existing `DefaultTemplate` in packages/ui takes `payload: Payload` (server-only). We need a client version that takes `clientConfig: ClientConfig` instead. The nav uses `DefaultNavClient` (already client-side) with groups computed from `clientConfig`. - -**Files:** - -- Create: `packages/tanstack-start/src/templates/Default/index.tsx` - -**Step 1: Read the existing components** - -Before writing, read these files: - -- `packages/ui/src/templates/Default/index.tsx` — structure to mirror -- `packages/ui/src/elements/Nav/index.client.tsx` — `DefaultNavClient` props -- `packages/ui/src/utilities/groupNavItems.ts` — `groupNavItems` function signature - -**Step 2: Create the file** - -```typescript -// packages/tanstack-start/src/templates/Default/index.tsx -'use client' - -import type { - ClientConfig, - DocumentSubViewTypes, - NavPreferences, - PayloadComponent, - SanitizedPermissions, - ViewTypes, - VisibleEntities, -} from 'payload' - -import { EntityType, groupNavItems } from '@payloadcms/ui/utilities/groupNavItems' -import { ActionsProvider } from '@payloadcms/ui/providers/Actions' -import { EntityVisibilityProvider } from '@payloadcms/ui/providers/EntityVisibility' -import { BulkUploadProvider } from '@payloadcms/ui/elements/BulkUpload' -import { AppHeader } from '@payloadcms/ui/elements/AppHeader' -import { NavToggler } from '@payloadcms/ui/elements/Nav/NavToggler' -import { DefaultNavClient } from '@payloadcms/ui/elements/Nav/index.client' -import { NavWrapper } from '@payloadcms/ui/elements/Nav/NavWrapper' -import { NavHamburger as DefaultNavHamburger } from '@payloadcms/ui/templates/Default/NavHamburger' -import { Wrapper } from '@payloadcms/ui/templates/Default/Wrapper' -import { RenderCustomComponent } from '@payloadcms/ui/elements/RenderCustomComponent' -import { useTranslation } from '@payloadcms/ui/providers/Translation' -import React from 'react' - -const baseClass = 'template-default' -const navBaseClass = 'nav' - -export type TanStackDefaultTemplateProps = { - children?: React.ReactNode - className?: string - clientConfig: ClientConfig - collectionSlug?: string - docID?: number | string - documentSubViewType?: DocumentSubViewTypes - globalSlug?: string - navPreferences?: NavPreferences - permissions: SanitizedPermissions - viewActions?: PayloadComponent[] + templateClassName: string + templateType?: 'default' | 'minimal' viewType?: ViewTypes visibleEntities: VisibleEntities + navPreferences?: NavPreferences + customView?: PayloadComponent | undefined + viewActions?: PayloadComponent[] } - -export function TanStackDefaultTemplate({ - children, - clientConfig, - collectionSlug, - documentSubViewType, - navPreferences, - permissions, - viewType, - visibleEntities, -}: TanStackDefaultTemplateProps) { - const { i18n } = useTranslation() - - const groups = React.useMemo( - () => - groupNavItems( - [ - ...clientConfig.collections - .filter(({ slug }) => visibleEntities.collections.includes(slug)) - .map((collection) => ({ type: EntityType.collection, entity: collection })), - ...clientConfig.globals - .filter(({ slug }) => visibleEntities.globals.includes(slug)) - .map((global) => ({ type: EntityType.global, entity: global })), - ], - permissions, - i18n, - ), - [clientConfig, visibleEntities, permissions, i18n], - ) - - return ( - - - -
    - - - - - -
    - - {children} -
    -
    -
    -
    -
    -
    - ) -} -``` - -**Note:** The import paths for internal UI components (`@payloadcms/ui/elements/Nav/NavToggler`, etc.) need to be verified against `packages/ui/package.json` exports. Run: - -```bash -cat packages/ui/package.json | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps(list(d.get('exports',{}).keys()), indent=2))" | grep -i "nav\|template\|appheader\|actions\|entity" -``` - -Adjust import paths to match actual exports. - -**Step 3: TypeScript check** - -```bash -cd packages/tanstack-start && pnpm tsc --noEmit 2>&1 | head -50 -``` - -**Step 4: Commit** - -```bash -git add packages/tanstack-start/src/templates/ -git commit -m "feat(tanstack-start): add TanStackDefaultTemplate client component" -``` - ---- - -## Phase 3: Client View Components - -### Task 3: Create client-side view wrappers - -**Context:** Each admin view type needs a client component for TanStack Start. These components: - -1. Receive serializable props from `TanStackAdminPage` -2. Call the appropriate server function on mount via `useServerFunctions()` -3. Render the result (React.ReactNode for table/form, or built-in client components for auth views) - -The pattern for data-fetching views: - -``` -mount → call serverFunction → receive result → render result (React.ReactNode) -``` - -`useServerFunctions()` is from `@payloadcms/ui/providers/ServerFunctions`. It provides `getTableState`, `getFormState`, etc. - -**Files:** - -- Create: `packages/tanstack-start/src/views/List/TanStackListView.tsx` -- Create: `packages/tanstack-start/src/views/Document/TanStackDocumentView.tsx` -- Create: `packages/tanstack-start/src/views/Dashboard/TanStackDashboardView.tsx` - -**Step 1: Read relevant types** - -```bash -# Check what getTableState/getFormState return and their arg types -grep -n "GetFormStateClient\|GetTableStateClient\|getFormState\|getTableState" packages/ui/src/providers/ServerFunctions/index.tsx | head -20 -grep -n "BuildFormStateArgs\|BuildTableStateArgs" packages/payload/src/types/*.ts packages/ui/src/**/*.ts 2>/dev/null | grep "^packages/payload" | head -10 ``` -**Step 2: Create `TanStackListView`** - -**Key insight:** `DefaultListView` is already `'use client'` and accepts `ListViewClientProps`. It uses `ListQueryProvider` and `TableColumnsProvider` internally via hooks. We set those providers up here with the serializable data from the loader (`listData`). No server function calls on mount — the data came from the loader. - -`DefaultListView` uses `Table` prop (`React.ReactNode`) to render the actual table rows. Since we don't have a rendered `Table` node (we have `columns: Column[]` instead), we pass `Table={null}` and rely on the `TableColumnsProvider` + `SelectionProvider` that `DefaultListView` sets up internally. Check the actual `DefaultListView` source to confirm — it may render a table from `TableColumnsProvider` context rather than the `Table` prop directly. If `Table` is required, we render a basic placeholder; a full table renderer can be added in a follow-up. - -```typescript -// packages/tanstack-start/src/views/List/TanStackListView.tsx -'use client' - -import type { CollectionPreferences, Column, PaginatedDocs, SanitizedPermissions, ViewTypes } from 'payload' - -import { DefaultListView } from '@payloadcms/ui/views/List' -import { ListQueryProvider } from '@payloadcms/ui/providers/ListQuery' -import { SelectionProvider } from '@payloadcms/ui/providers/Selection' -import React from 'react' - -type Props = { - collectionSlug: string - listData: { - columns: Column[] - data: PaginatedDocs - preferences: CollectionPreferences - } - permissions: SanitizedPermissions - searchParams: Record - viewType?: ViewTypes -} - -export function TanStackListView({ collectionSlug, listData, searchParams }: Props) { - const { columns, data, preferences } = listData +### Serialization rules - // Build initial query from searchParams (limit, sort, page, where) - const query = { - limit: searchParams.limit ? Number(searchParams.limit) : preferences.limit, - page: searchParams.page ? Number(searchParams.page) : undefined, - sort: (searchParams.sort as string) ?? preferences.sort, - where: searchParams.where ? JSON.parse(searchParams.where as string) : undefined, - } +- Built-in views should be represented by `viewType`, not by a React function. +- `viewActions` should only include import-map-addressable payload components. +- Any direct React function references must be dropped from the serialized output. +- `visibleEntities` must be present unless the implementation instead serializes fully prepared nav groups. - return ( - - - - - - ) -} -``` +### Auth and route handling -**Note:** The `DefaultListView` props above are intentionally minimal. Read `packages/payload/src/admin/views/list.ts` for the full `ListViewClientProps` type and fill in any additional required props. Specifically: +Move the route/auth checks into `getPageState` so the route loader becomes thin. -- `newDocumentURL` — compute from `clientConfig.routes.admin` + `collectionSlug` -- `hasCreatePermission` — derive from `permissions.collections[collectionSlug].create.permission` -- `hasDeletePermission` — derive from `permissions.collections[collectionSlug].delete.permission` -- `renderedFilters` — pass `undefined` for now; filter rendering in TanStack Start is deferred (filters are client-only, driven by URL params via `ListQueryProvider`) +Reuse logic from: -```` +- [packages/ui/src/views/Root/getRouteData.ts](/Users/orakhmatulin/work/payload/packages/ui/src/views/Root/getRouteData.ts) +- [packages/ui/src/utilities/handleAuthRedirect.ts](/Users/orakhmatulin/work/payload/packages/ui/src/utilities/handleAuthRedirect.ts) +- [packages/ui/src/utilities/isPublicAdminRoute.ts](/Users/orakhmatulin/work/payload/packages/ui/src/utilities/isPublicAdminRoute.ts) +- [packages/ui/src/utilities/isCustomAdminView.ts](/Users/orakhmatulin/work/payload/packages/ui/src/utilities/isCustomAdminView.ts) -**Step 3: Create `TanStackDocumentView`** +Do not call `renderRootPage` from `getPageState`. -```typescript -// packages/tanstack-start/src/views/Document/TanStackDocumentView.tsx -'use client' +## Route Rewrite -import type { DocumentSubViewTypes, SanitizedPermissions } from 'payload' +Replace the current route flow in: -import { useServerFunctions } from '@payloadcms/ui/providers/ServerFunctions' -import React, { useEffect, useState } from 'react' +- `tanstack-app/app/routes/admin.$.tsx` -type Props = { - collectionSlug?: string - documentSubViewType?: DocumentSubViewTypes - docID?: number | string - globalSlug?: string - permissions: SanitizedPermissions - searchParams: Record -} - -export function TanStackDocumentView({ - collectionSlug, - docID, - globalSlug, - searchParams, -}: Props) { - const { getFormState } = useServerFunctions() - const [content, setContent] = useState(null) - const [error, setError] = useState(null) - - useEffect(() => { - let cancelled = false - setContent(null) - setError(null) - - const schemaPath = collectionSlug ?? globalSlug ?? '' - getFormState({ - collectionSlug, - docID, - globalSlug, - schemaPath, - // docPreferences and other args will use defaults - }) - .then((result) => { - if (cancelled) return - if (result && 'errors' in result) { - setError('Failed to load document') - } else if (result && 'state' in result) { - // The form state is returned; the admin renders the form using - // the DocumentView from the RenderDocument server fn slot. - // For TanStack Start we trigger router invalidation to refresh. - // TODO: render DocumentViewClient directly with the form state - setContent(
    ) - } - }) - .catch((err: Error) => { - if (!cancelled) setError(err.message) - }) - - return () => { cancelled = true } - }, [collectionSlug, String(docID), globalSlug]) // eslint-disable-line react-hooks/exhaustive-deps - - if (error) return
    {error}
    - if (!content) return
    - return <>{content} -} -```` +Current: -**Note:** The document view stub above is intentionally minimal. In practice, the document form is initially rendered via SSR (`RootPage` during SSR still renders). Client-side navigation to document pages triggers a new SSR pass. The `getFormState` call here is for ensuring the form is hydrated with current data after SSR. Expand this in a follow-up once the full `DocumentViewClient` integration is understood by reading `packages/ui/src/views/Document/` more deeply. +- Loader returns `{ segments }` +- Component renders `` -**Step 4: Create `TanStackDashboardView`** +Target: -```typescript -// packages/tanstack-start/src/views/Dashboard/TanStackDashboardView.tsx -'use client' +- Loader calls `getPageState` +- Component renders `` -import type { ClientConfig, NavGroup, SanitizedPermissions } from 'payload' +The route should pass `search` from TanStack Router into the client page as plain search params. -import { DefaultDashboard } from '@payloadcms/ui/views/Dashboard/Default' -import { getNavGroups } from '@payloadcms/ui/utilities/getNavGroups' -import { useTranslation } from '@payloadcms/ui/providers/Translation' -import React from 'react' +## Client Page Shell -type GlobalDataEntry = { - data: Record - global: { slug: string } -} +Create: -type Props = { - clientConfig: ClientConfig - globalData?: GlobalDataEntry[] - permissions: SanitizedPermissions -} +- `packages/tanstack-start/src/views/Root/TanStackAdminPage.tsx` -export function TanStackDashboardView({ clientConfig, globalData, permissions }: Props) { - const { i18n } = useTranslation() +Requirements: - const navGroups: NavGroup[] = React.useMemo( - () => getNavGroups(permissions, { collections: clientConfig.collections.map(c => c.slug), globals: clientConfig.globals.map(g => g.slug) }, clientConfig, i18n), - [clientConfig, permissions, i18n], - ) +- `'use client'` +- Must not import server-only modules +- Must sit inside the existing layout/provider tree from `RootLayout` +- Should use `PageConfigProvider`, not replace `RootProvider` - return ( - - ) -} -``` +Responsibilities: -**Note:** `DefaultDashboard` props — verify by reading `packages/ui/src/views/Dashboard/Default/index.tsx`: +1. Apply page-level config shadowing with `PageConfigProvider` +2. Pick template by `templateType` +3. Pick the built-in TanStack view wrapper by `viewType` +4. Resolve import-map based custom views if they are safe client components +5. Pass route/search/page state into the selected client wrapper -```bash -head -30 packages/ui/src/views/Dashboard/Default/index.tsx -``` +## Template Strategy -Adjust props to match the actual `DefaultDashboard` component signature. +Do not try to render [packages/ui/src/templates/Default/index.tsx](/Users/orakhmatulin/work/payload/packages/ui/src/templates/Default/index.tsx) directly in the client path. It depends on server-side `RenderServerComponent`, `payload`, and server props. -**Step 5: TypeScript check** +Instead create TanStack-safe client templates in `packages/tanstack-start`, likely: -```bash -cd packages/tanstack-start && pnpm tsc --noEmit 2>&1 | head -50 -``` +- `packages/tanstack-start/src/templates/Default/TanStackDefaultTemplate.tsx` +- `packages/tanstack-start/src/templates/Minimal/TanStackMinimalTemplate.tsx` -Fix any type errors. The view components will have stubs that expand later. +These should reuse client-safe UI pieces from `packages/ui` where possible. -**Step 6: Commit** +### Default template requirements -```bash -git add packages/tanstack-start/src/views/ -git commit -m "feat(tanstack-start): add TanStack client view components (list, document, dashboard)" -``` +- Use `visibleEntities` and/or precomputed nav groups +- Use `DefaultNavClient` instead of `DefaultNav` +- Preserve core layout structure, app header behavior, nav toggling, and wrappers where client-safe +- Avoid `RenderServerComponent` ---- +### Minimal template requirements -### Task 4: Create `TanStackAdminPage` — the top-level client component +- Mirror the minimal shell used for login/reset/verify-like routes +- Avoid any server component hooks -**Files:** +## View Wrapper Strategy -- Create: `packages/tanstack-start/src/views/Root/TanStackAdminPage.tsx` +Add TanStack-specific client wrappers under `packages/tanstack-start/src/views`. -**Step 1: Read auth view client components** +These wrappers should map built-in `viewType` values to existing client functionality in `packages/ui`. -```bash -# These already exist as client components we can import directly -ls packages/ui/src/views/Login/ -ls packages/ui/src/views/CreateFirstUser/ -ls packages/ui/src/views/Account/ -ls packages/ui/src/views/ForgotPassword/ -ls packages/ui/src/views/ResetPassword/ -ls packages/ui/src/views/Verify/ -# Check which are already client components: -head -1 packages/ui/src/views/Login/index.tsx -head -1 packages/ui/src/views/CreateFirstUser/index.client.tsx -``` +### Views that can reuse existing client components -**Step 2: Create the component** +- `list` + - Reuse `DefaultListView` +- `document` + - Reuse `DefaultEditView` and surrounding client providers required by the edit path +- `createFirstUser` + - Reuse `CreateFirstUserClient` +- `account` + - Reuse `AccountClient` +- Dashboard internals + - Reuse the modular dashboard client pieces where feasible -```typescript -// packages/tanstack-start/src/views/Root/TanStackAdminPage.tsx -'use client' +### Views that still need server-derived state -import type { ImportMap } from 'payload' +If a view currently has only a server entry point, add a thin TanStack client wrapper that: -import { MinimalTemplate } from '@payloadcms/ui/templates/Minimal' -import { PageConfigProvider } from '@payloadcms/ui/providers/Config' -import { HydrateAuthProvider } from '@payloadcms/ui/elements/HydrateAuthProvider' -import React from 'react' +- fetches serializable state from server functions on mount, or +- consumes state already prepared by `getPageState` -import type { SerializablePageState } from './getPageState.js' -import { TanStackDefaultTemplate } from '../../templates/Default/index.js' -import { TanStackDashboardView } from '../Dashboard/TanStackDashboardView.js' -import { TanStackDocumentView } from '../Document/TanStackDocumentView.js' -import { TanStackListView } from '../List/TanStackListView.js' +Do not pass server-rendered React nodes through page state. -type Props = { - importMap: ImportMap - pageState: SerializablePageState & { searchParams: Record } -} +### Custom views -function renderView( - pageState: SerializablePageState & { searchParams: Record }, -): React.ReactNode { - const { viewType, routeParams, permissions, searchParams, clientConfig, globalData, documentSubViewType } = pageState - - switch (viewType) { - case 'dashboard': - return ( - - ) - - case 'list': - case 'trash': - case 'collection-folders': - case 'folders': - if (!pageState.listData) return
    - return ( - - ) - - case 'document': - case 'version': - return ( - - ) - - // Auth / minimal template views — these are client-side already and don't need data fetching - case 'login': - case 'logout': - case 'createFirstUser': - case 'forgot': - case 'reset': - case 'verify': - case 'account': - default: - // For auth and other minimal views: the SSR pass from loader renders them. - // On client navigation these views are light and don't need separate data fetching. - return null - } -} +Support only import-map-addressable custom views in the serialized route state. -export function TanStackAdminPage({ pageState }: Props) { - const { clientConfig, permissions, visibleEntities, navPreferences, templateType, templateClassName, routeParams, documentSubViewType, viewType } = pageState +Rules: - const view = renderView(pageState) +- If `PayloadComponent` is a path/object reference, resolve it through the import map on the client. +- If the current route resolves to a direct React function that is not safely serializable, do not attempt to pass it through page state. +- If needed, document that TanStack custom views require import-map-addressable components for this path. - if (templateType === 'minimal') { - return ( - - - - {view} - - - ) - } +## List View Data - return ( - - - - {view} - - - ) -} -``` +The current plan to extract `buildTableData` is valid, but it needs one correction: -**Step 3: TypeScript check** +- It should be framed as a small, deliberate `packages/ui` exception to support a serializable TanStack path. -```bash -cd packages/tanstack-start && pnpm tsc --noEmit 2>&1 | head -50 -``` +### New utility -**Step 4: Commit** +Add: -```bash -git add packages/tanstack-start/src/views/Root/TanStackAdminPage.tsx -git commit -m "feat(tanstack-start): add TanStackAdminPage client component" -``` +- `packages/ui/src/utilities/buildTableData.ts` ---- +Export it from: -## Phase 4: Wire Routes +- `packages/ui/src/exports/utilities.ts` -### Task 5: Update `admin.$.tsx` and `admin.index.tsx` to use `getPageState` + `TanStackAdminPage` +### Behavior -**Files:** +This helper should: -- Modify: `tanstack-app/app/routes/admin.$.tsx` -- Modify: `tanstack-app/app/routes/admin.index.tsx` +- Share the data-fetching and column-building logic from `buildTableState` +- Return only serializable data: + - `columns` + - `data` + - `preferences` +- Not render: + - `Table` + - `renderedFilters` -**Step 1: Read both files** +### Consistency requirement -Read them in full first (`admin.$.tsx` and `admin.index.tsx`) to understand what needs to change. +Do not introduce a TanStack-only behavior fork in the shared fetch logic. -**Step 2: Rewrite `admin.$.tsx`** +Note: -```typescript -// tanstack-app/app/routes/admin.$.tsx -import config from '@payload-config' -import { TanStackAdminPage } from '@payloadcms/tanstack-start/views' -import { getPageState } from '@payloadcms/tanstack-start/views/Root/getPageState' -import { createFileRoute } from '@tanstack/react-router' -import React from 'react' +- [packages/ui/src/utilities/buildTableState.ts](/Users/orakhmatulin/work/payload/packages/ui/src/utilities/buildTableState.ts) currently contains a `joinQuery.page` / `joinQuery.limit` inconsistency in the array-of-collections branch. -import { importMap } from '../importMap.js' +If that logic is touched, either: -export const Route = createFileRoute('/admin/$')({ - loader: async ({ params, context }) => { - const segments = params._splat?.split('/').filter(Boolean) ?? [] - const searchParams = (context as any)?.searchParams ?? {} - return getPageState({ config, importMap, searchParams, segments }) - // Auth redirects and notFound are thrown inside getPageState - }, - component: AdminPage, -}) +- preserve the current behavior exactly in both paths, or +- fix the bug intentionally for both shared consumers -function AdminPage() { - const pageState = Route.useLoaderData() - const search = Route.useSearch() - return ( - }} - /> - ) -} -``` +Do not silently make TanStack behave differently from Next.js. -**Step 3: Rewrite `admin.index.tsx`** - -```typescript -// tanstack-app/app/routes/admin.index.tsx -import config from '@payload-config' -import { TanStackAdminPage } from '@payloadcms/tanstack-start/views' -import { getPageState } from '@payloadcms/tanstack-start/views/Root/getPageState' -import { createFileRoute } from '@tanstack/react-router' -import React from 'react' - -import { importMap } from '../importMap.js' - -export const Route = createFileRoute('/admin/')({ - loader: async () => { - return getPageState({ config, importMap, segments: [] }) - }, - component: AdminIndexPage, -}) - -function AdminIndexPage() { - const pageState = Route.useLoaderData() - const search = Route.useSearch() - return ( - }} - /> - ) -} -``` +## List Wrapper -**Step 4: TypeScript check on the app** +Add a TanStack list wrapper that: -```bash -cd tanstack-app && npx tsc --noEmit 2>&1 | head -50 -``` +1. Obtains serializable list data +2. Builds client-safe list props +3. Reuses `DefaultListView` -Fix import path issues. `@payloadcms/tanstack-start/views/Root/getPageState` needs to be a valid export — check step 5 first. +Prefer: -**Step 5: Update exports in packages/tanstack-start** +- server-side serializable data from `buildTableData` +- client-side `ListQueryProvider` +- client-side `TableColumnsProvider` +- client-side filters/table behavior only where the underlying props are serializable -Read `packages/tanstack-start/src/index.ts` and `packages/tanstack-start/package.json`. Add: +Do not depend on `renderListView` or `render-list` for page rendering. -- Export `TanStackAdminPage` from `packages/tanstack-start/src/index.ts` -- Export `getPageState` and `SerializablePageState` from `packages/tanstack-start/src/index.ts` -- Add export path `./views/Root/getPageState` to `packages/tanstack-start/package.json` exports if needed +## Document Wrapper -Then re-run TypeScript check. +Add a TanStack document wrapper that: -**Step 6: Start dev server and visually verify** +1. Fetches or derives serializable document state +2. Reuses `DefaultEditView` +3. Provides the same client providers the edit view expects -```bash -pnpm run dev:tanstack -``` +The wrapper must not rely on `renderDocument` returning a React node. -Open `http://localhost:3000/admin`. Verify: +Reuse existing document-related server utilities where possible for data, permissions, preferences, lock state, and form state, but return plain data only. -- Dashboard renders (might show loading state for view content) -- Nav appears -- No `server-only` import errors in browser console +## Dashboard Wrapper -**Step 7: Commit** +The dashboard path needs special care because the existing server view still resolves server-rendered content around modular dashboard widgets. -```bash -git add tanstack-app/app/routes/admin.$.tsx tanstack-app/app/routes/admin.index.tsx packages/tanstack-start/src/ -git commit -m "feat(tanstack-start): wire routes to use getPageState + TanStackAdminPage" -``` +Recommended approach: ---- +1. Start with a minimal TanStack dashboard wrapper that renders the core dashboard client shell and widget area without server-only imports. +2. Reuse the modular dashboard client pieces from `packages/ui`. +3. If some dashboard extensions remain server-only, explicitly leave them unsupported in the first TanStack slice rather than sneaking server imports back into the client tree. -## Phase 5: Server Function Interceptor + Router Invalidation +The first implementation should optimize for removing the stub plugin and keeping the default dashboard functional. -### Task 6: Intercept `render-document`/`render-list` and trigger router invalidation +## Server Function Invalidation -**Files:** +Keep the existing shared dispatcher in `packages/ui` intact. -- Modify: `packages/tanstack-start/src/utilities/handleServerFunctions.ts` -- Modify: `tanstack-app/app/routes/__root.tsx` -- Modify: `packages/tanstack-start/src/adapter/RouterProvider.tsx` +Change TanStack-specific handling in: -**Context:** In Next.js, `render-document` and `render-list` return React nodes via the RSC protocol. In TanStack Start, these are called via `handleServerFn` (a `createServerFn`). They return React nodes, which are serialized via seroval. Instead of trying to serialize them, we return a signal `{ __tanstack_invalidate: true }` that tells the client to call `router.invalidate()` to refresh the page via a new loader run. +- `packages/tanstack-start/src/utilities/handleServerFunctions.ts` -**Step 1: Read current `handleServerFunctions.ts`** +### Required behavior -Read `packages/tanstack-start/src/utilities/handleServerFunctions.ts` to understand current structure. +Intercept: -**Step 2: Add the interceptor** +- `render-document` +- `render-list` -```typescript -// packages/tanstack-start/src/utilities/handleServerFunctions.ts -// Add at the top: -const TANSTACK_INVALIDATE_FNS = new Set([ - 'render-document', - 'render-list', - 'render-document-slots', -]) +Return a TanStack-specific invalidation sentinel, for example: -// In handleServerFunctions, before the dispatch call: -if (TANSTACK_INVALIDATE_FNS.has(fnKey)) { - return { __tanstack_invalidate: true } +```ts +{ + __tanstack_invalidate: true } ``` -**Step 3: Update `__root.tsx` to intercept the response** - -In `tanstack-app/app/routes/__root.tsx`, the `serverFunction` wrapper calls `handleServerFn`. Update the wrapper: - -```typescript -// After: const serverFunction: ServerFunctionClient = (args) => handleServerFn({ data: args }) -// Change to: -const serverFunction: ServerFunctionClient = async (args) => { - const result = await handleServerFn({ data: args }) - if ( - result && - typeof result === 'object' && - '__tanstack_invalidate' in result - ) { - // Signal handled by RouterProvider - return result - } - return result -} -``` +All other server functions should continue to dispatch normally through `dispatchServerFunction`. -**Step 4: Update `TanStackRouterProvider` to handle `__tanstack_invalidate`** +## Router Provider Invalidation Hook -Read `packages/tanstack-start/src/adapter/RouterProvider.tsx`. The RouterProvider wraps `BaseRouterProvider`. We need to intercept server function calls that return `{ __tanstack_invalidate: true }`. +Update: -The place to intercept is in the `router.refresh` path — or we can override the server function wrapper at the `ServerFunctionsProvider` level. +- `packages/tanstack-start/src/adapter/RouterProvider.tsx` -Actually, the cleanest approach: in `__root.tsx`, the `serverFunction` already calls `handleServerFn`. If we return `{ __tanstack_invalidate: true }`, the calling code in the admin UI will receive it. The admin UI calls server functions via `useServerFunctions()` hooks. These hooks need to detect the signal and call `router.invalidate()`. +So that the client-side router/server-function flow recognizes the TanStack invalidation sentinel and calls: -The `ServerFunctionsProvider` provides the `serverFunction` callback. We can wrap it: +- `router.invalidate()` -```typescript -// In tanstack-app/app/routes/__root.tsx, inside RootComponent: -const router = useRouter() +This should refresh route data and allow the client page shell to rebuild from fresh serialized page state. -const tanstackAwareServerFunction: ServerFunctionClient = React.useCallback( - async (args) => { - const result = await serverFunction(args) - if ( - result && - typeof result === 'object' && - '__tanstack_invalidate' in result - ) { - void router.invalidate() - return null - } - return result - }, - [router], -) +## Export Surface -// Then pass tanstackAwareServerFunction to RootLayout instead of serverFunction -``` - -Read `RootLayout` to understand where `serverFunction` is threaded through. - -**Step 5: Verify no TypeScript errors** - -```bash -cd packages/tanstack-start && pnpm tsc --noEmit 2>&1 | head -50 -cd tanstack-app && npx tsc --noEmit 2>&1 | head -50 -``` - -**Step 6: Commit** - -```bash -git add packages/tanstack-start/src/utilities/handleServerFunctions.ts tanstack-app/app/routes/__root.tsx -git commit -m "feat(tanstack-start): intercept render-document/render-list with router.invalidate()" -``` - ---- - -## Phase 6: Cleanup - -### Task 7: Remove `serverOnlyStubPlugin` from vite.config.ts - -**Prerequisite:** Verify in browser that no `import error` / `module not found` errors appear for server-only modules. If errors appear, the component tree still has server-only imports — debug before removing the plugin. - -**Files:** +Update TanStack exports as needed so the new view/page helpers are available from: -- Modify: `tanstack-app/vite.config.ts` +- `packages/tanstack-start/src/index.ts` -**Step 1: Read the current vite.config.ts** +If `RootPage` is removed or superseded, update the export surface accordingly and adjust the route import sites. -Check `tanstack-app/vite.config.ts`. Remove: +## Vite Cleanup -- The `SERVER_ONLY_PACKAGES` constant -- The `serverOnlyStubPlugin` function -- The `serverOnlyStubPlugin()` call in the `plugins` array -- The `readFileSync` import if no longer needed +After the new page path is fully client-safe, remove: -Keep `tanstackStartCompatPlugin` (still needed for the virtual module shim). +- `serverOnlyStubPlugin` -**Step 2: Remove `server-only-stub.js` and `server-only-stub.cjs`** +from: -```bash -rm tanstack-app/server-only-stub.js tanstack-app/server-only-stub.cjs -``` +- [tanstack-app/vite.config.ts](/Users/orakhmatulin/work/payload/tanstack-app/vite.config.ts) -**Step 3: Start dev server and verify** +Keep: -```bash -pnpm run dev:tanstack -``` +- `tanstackStartCompatPlugin` -Navigate through admin routes. If `ERR_MODULE_NOT_FOUND` or similar errors appear in the browser console, there's still a server-only import somewhere. Trace it: +Do not remove the stub plugin before the new route path is verified to build and hydrate cleanly. -1. Open browser DevTools → Console -2. Find the module that failed to load -3. Find which file imports it: `grep -rn "import.*" tanstack-app/ packages/tanstack-start/src/` -4. Fix the import (move to a server function or remove) +## Implementation Order -**Step 4: Commit** +Implement in this order: -```bash -git add tanstack-app/vite.config.ts -git commit -m "chore(tanstack-start): remove serverOnlyStubPlugin after client-only component tree" -``` +### Phase 0 ---- +- Rewrite the TanStack plan and align it with repo reality -### Task 8: Remove `RootPage` from `packages/tanstack-start` +### Phase 1 -**Files:** +- Add `buildTableData` in `packages/ui` +- Export it +- Typecheck `packages/ui` -- Delete: `packages/tanstack-start/src/views/Root/index.tsx` -- Modify: `packages/tanstack-start/src/index.ts` (remove `RootPage` export) -- Modify: `packages/tanstack-start/package.json` (remove `./views` export if it only exported `RootPage`) +### Phase 2 -**Step 1: Read current exports** +- Add `getPageState` +- Move route/auth/page-state logic into it +- Add tests for the serialized page-state shape -```bash -cat packages/tanstack-start/src/index.ts -cat packages/tanstack-start/package.json | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps(d.get('exports',{}), indent=2))" -``` +### Phase 3 -**Step 2: Remove the file and update exports** +- Replace `RootPage` route usage with `TanStackAdminPage` +- Keep the first shell minimal but functional +- Verify there are no server-only imports in the page client graph -```bash -rm packages/tanstack-start/src/views/Root/index.tsx -``` +### Phase 4 -Update `src/index.ts` to remove `RootPage` export. Update `package.json` if the `./views` export path pointed to the file with `RootPage`. +- Add TanStack-safe default/minimal templates +- Add nav handling using client-safe pieces -**Step 3: TypeScript check** +### Phase 5 -```bash -cd packages/tanstack-start && pnpm tsc --noEmit 2>&1 | head -50 -``` +- Add built-in client wrappers for: + - list + - document + - dashboard + - account + - createFirstUser + - remaining simple auth/minimal views as needed -**Step 4: Commit** +### Phase 6 -```bash -git add packages/tanstack-start/ -git commit -m "chore(tanstack-start): remove RootPage — replaced by TanStackAdminPage + getPageState" -``` +- Add TanStack invalidation handling for `render-document` and `render-list` +- Wire router invalidation in the TanStack adapter/provider ---- +### Phase 7 -## Phase 7: Tests +- Remove `serverOnlyStubPlugin` +- Run targeted tests/build verification -### Task 9: Update integration tests for the new adapter shape +## Testing -**Files:** +### Integration -- Modify: `test/admin-adapter/tanstack-start.int.spec.ts` +Expand: -**Step 1: Run existing tests to see current state** +- `test/admin-adapter/tanstack-start.int.spec.ts` -```bash -pnpm run test:int admin-adapter 2>&1 | tail -30 -``` - -**Step 2: Add new tests for `getPageState` and `TanStackAdminPage` exports** - -```typescript -// Add to test/admin-adapter/tanstack-start.int.spec.ts: - -it('should export TanStackAdminPage', async () => { - const { TanStackAdminPage } = await import('@payloadcms/tanstack-start') - expect(typeof TanStackAdminPage).toBe('function') -}) - -it('should export getPageState', async () => { - const { getPageState } = await import('@payloadcms/tanstack-start') - expect(typeof getPageState).toBe('function') -}) - -it('should NOT export RootPage (removed)', async () => { - const mod = await import('@payloadcms/tanstack-start') - expect('RootPage' in mod).toBe(false) -}) - -it('render-document returns __tanstack_invalidate signal', async () => { - const { handleServerFunctions } = await import('@payloadcms/tanstack-start') - // handleServerFunctions needs full payload context to run - // Just verify it's the updated version by checking its source behavior via a mock - // (Full integration test requires a running Payload instance) - expect(typeof handleServerFunctions).toBe('function') -}) -``` +Add coverage for: -**Step 3: Run tests** +- `getPageState` exports and returns a plain serializable object +- built-in view types resolve to expected serializable state +- auth redirect/notFound behavior is preserved +- TanStack `handleServerFunctions` returns invalidation for: + - `render-document` + - `render-list` +- other server functions still dispatch normally -```bash -pnpm run test:int admin-adapter 2>&1 | tail -30 -``` - -Expected: All tests pass. +### E2E -**Step 4: Commit** +Add or extend TanStack app coverage in the appropriate test location for: -```bash -git add test/admin-adapter/tanstack-start.int.spec.ts -git commit -m "test: update TanStack Start adapter integration tests for new client rendering" -``` - ---- - -### Task 10: E2E smoke test with `pnpm dev:tanstack` - -This task verifies the full flow works end-to-end before declaring victory. - -**Step 1: Start the TanStack dev server** - -```bash -pnpm run dev:tanstack -``` - -Wait for it to be ready (look for "ready" or "listening on port" message). - -**Step 2: Use Playwright MCP to verify** - -With the dev server running on `http://localhost:3000`: - -1. Navigate to `http://localhost:3000/admin` — should redirect to login -2. Log in with `dev@payloadcms.com` / `test` -3. Should see dashboard with collections/globals visible -4. Navigate to a collection list -5. Navigate to create a new document -6. Check browser console for `server-only` import errors — should be zero - -**Step 3: Run Next.js tests to verify no regressions** - -```bash -pnpm run test:int admin-root 2>&1 | tail -20 -``` - -Expected: All passing (11 tests as confirmed before this change). - -**Step 4: Fix any issues found** - -If browser console shows errors: - -- Identify the module that failed -- Find where it's imported in the TanStack client component tree -- Move to server function or remove - -**Step 5: Final commit** - -```bash -git add -A -git commit -m "chore(tanstack-start): verify e2e after client rendering migration" -``` - ---- - -## Dependency Graph - -``` -Task 0 (packages/ui: buildTableData) - ↓ -Task 1 (getPageState — calls buildTableData for list views) - ↓ -Task 2 (TanStackDefaultTemplate) - ↓ -Tasks 3-4 (view components + TanStackAdminPage) - ↓ -Task 5 (wire routes) - ↓ -Task 6 (handleServerFunctions interceptor) - ↓ -Task 7 (remove serverOnlyStubPlugin) ← verify no more server-only imports first - ↓ -Task 8 (remove RootPage) - ↓ -Tasks 9-10 (tests + e2e) -``` +- dashboard loads without server-only import/runtime errors +- list view loads and navigates +- document view loads and saves +- client-side navigation between admin routes works +- document/list mutations trigger route invalidation and refresh +- browser console does not show server-only module failures ---- +### Manual verification -## Key Things to Watch For +At minimum verify: -0. **`buildTableData` vs `buildTableState` divergence** — `buildTableState` calls `renderTable` which calls `getColumns` internally. If `getColumns` signature differs from the public call (e.g. requires `tableAppearance`, `orderableFieldName`, etc.), add those args to `buildTableData` to keep column output identical. Run: `grep -n "function getColumns\|export.*getColumns" packages/ui/src/utilities/getColumns.ts` +1. TanStack app builds without relying on `serverOnlyStubPlugin` +2. Admin root route loads +3. List route loads +4. Document route loads +5. Save/delete flows refresh correctly -1. **`groupNavItems` signature** — verify it accepts `ClientConfig` collections/globals, not `SanitizedConfig`. Run: `grep -n "groupNavItems\|EntityToGroup" packages/ui/src/utilities/groupNavItems.ts | head -20` +## Non-Goals -2. **Import paths in `packages/ui`** — `@payloadcms/ui/elements/Nav/index.client` may not be a valid export path. Check `packages/ui/package.json` exports. You may need to use relative paths or find the correct export. +- Reworking the Next.js adapter +- Converting all existing `packages/ui` server views into dual server/client implementations +- Full parity for every custom server-rendered extension on day one if that would reintroduce server-only imports into the TanStack client graph -3. **`getNavGroups` vs `groupNavItems`** — check which one is exported from packages/ui and is usable client-side. +## Deliverable -4. **`HydrateAuthProvider`** — check if it needs `permissions` or gets them from context. Read: `head -20 packages/ui/src/elements/HydrateAuthProvider/index.tsx` +The final implementation is successful when: -5. **`getPageState` import path** — since `getPageState.ts` is a new file, its import path in the route files needs a matching export entry in `packages/tanstack-start/package.json`. +- `tanstack-app/app/routes/admin.$.tsx` no longer renders `RootPage` +- TanStack admin rendering no longer imports `RenderRoot` into the page path +- the TanStack app works without `serverOnlyStubPlugin` +- Next.js behavior remains unchanged +- the new plan is accurately reflected in code and tests diff --git a/docs/plans/2026-04-02-tanstack-start-client-rendering.md b/docs/plans/2026-04-02-tanstack-start-client-rendering.md index a3eafd864ae..451385d94f6 100644 --- a/docs/plans/2026-04-02-tanstack-start-client-rendering.md +++ b/docs/plans/2026-04-02-tanstack-start-client-rendering.md @@ -2,50 +2,108 @@ ## Goal -Fix the isomorphic rendering problem in TanStack Start by replacing `renderRootPage` (RSC, returns React nodes) with a serializable page state server function + a client-only component tree. +Fix the TanStack Start admin rendering path by removing the dependency on `renderRootPage` and replacing it with: -**Constraint:** Next.js is untouched. It keeps full RSC support. TanStack Start is its own independent rendering path. +- a serializable server-side page-state function +- a client-only TanStack admin page shell +- TanStack-specific client view wrappers that reuse existing client UI pieces from `packages/ui` ---- +Next.js remains unchanged and continues using the existing RSC-based admin rendering path. ## Problem -TanStack Start v1.167 is isomorphic by default — route components execute on both server (SSR) and client (hydration). `RootPage` is an async function that imports `renderRootPage`, which transitively imports server-only modules (mongoose, pino, util, sharp, etc.). These get bundled for the client. +TanStack Start is isomorphic by default. The current admin route still renders `RootPage`, and `RootPage` imports `@payloadcms/ui/views/Root/RenderRoot`. That pulls the server-rendered admin path into the TanStack route graph. -The current `serverOnlyStubPlugin` stubs these out, but each new missing export requires a manual addition. It's a maintenance leak, not a fix. +Because `renderRootPage` and the current server view/template chain transitively depend on server-only modules, TanStack’s client build currently needs `serverOnlyStubPlugin` in `tanstack-app/vite.config.ts`. ---- +That plugin is only masking the real issue. Each new server-only dependency can create a new browser-bundle breakage point. + +## Verified Current State + +- `tanstack-app/app/routes/admin.$.tsx` still returns `segments` from the loader and renders `RootPage`. +- `packages/tanstack-start/src/views/Root/index.tsx` imports `@payloadcms/ui/views/Root/RenderRoot`. +- `packages/tanstack-start/src/layouts/Root/index.tsx` already sets up `RootProvider`, TanStack router integration, auth, translations, config, and server functions. +- `packages/ui` already contains reusable client pieces such as `DefaultListView`, `DefaultEditView`, `CreateFirstUserClient`, and dashboard client modules. +- `render-document` and `render-list` still return React nodes today, so they are not suitable as serializable TanStack page data primitives. +- The current default template and nav are server-oriented and rely on `RenderServerComponent`. ## Design ### Rendering model -**Next.js:** RSC-first. `renderRootPage` returns `React.ReactNode`. Server components render server-only code. Unchanged. +**Next.js** + +- Keeps `renderRootPage` +- Keeps RSC views and templates +- No behavior change + +**TanStack Start** + +- Loader calls `getPageState` +- `getPageState` returns serializable data only +- Route component renders `TanStackAdminPage` +- `TanStackAdminPage` is client-only +- Client wrappers map `viewType` to reusable client implementations +- Mutations that currently depend on `render-document` / `render-list` invalidate the route instead of returning React nodes + +This is acceptable because Payload admin is authenticated application UI, not an SEO page. + +## Core Principles + +### 1. Do not serialize React nodes + +No TanStack loader or server function used for route rendering should return: + +- `React.ReactNode` +- rendered server components +- server-only component references + +Only plain serializable data should cross the route boundary. + +### 2. Do not repurpose the existing `packages/ui` server views as-is + +The current server view entry points in `packages/ui` stay in place for Next.js. -**TanStack Start:** +TanStack should instead: -- Loader runs server-side → `getPageState` server function → serializable data only -- `TanStackAdminPage` is a `'use client'` component — no server-only imports anywhere in the component tree -- View components (DashboardView, ListView, DocumentView, etc.) mount client-side and fetch their data via `buildFormState` / `buildTableState` / other server functions -- SSR output: template shell + loading skeleton. View content loads after hydration. +- add a page-state layer in `packages/tanstack-start` +- add client wrappers in `packages/tanstack-start` +- reuse client components from `packages/ui` where those already exist -This is intentional. Payload admin is behind auth — SEO doesn't matter. The auth/permissions security boundary is maintained by the server-side loader. +### 3. Keep the existing TanStack root layout ---- +The current `RootLayout` in `packages/tanstack-start` already provides the base app shell and providers. The new page flow should fit inside that tree. -## Components +`TanStackAdminPage` should use `PageConfigProvider` at the page level, not replace `RootProvider`. -### 1. `getPageState` server function +## Main Components + +### 1. `getPageState` **File:** `packages/tanstack-start/src/views/Root/getPageState.ts` -Calls `initReq` then `getRouteData`. Handles auth redirects and notFound. Returns: +This is the new server-side entry point for route rendering. + +Responsibilities: + +- call `initReq` +- perform auth/public-route/custom-route checks +- reproduce route resolution currently handled before rendering +- derive serializable page state from `getRouteData` +- throw TanStack `redirect` / `notFound` as needed + +### Serializable state shape -```typescript +At minimum: + +```ts type SerializablePageState = { - viewType: ViewTypes | undefined - templateType: 'default' | 'minimal' | undefined - templateClassName: string + browseByFolderSlugs: string[] + clientConfig: ClientConfig + documentSubViewType?: DocumentSubViewTypes + locale?: Locale + navPreferences?: NavPreferences + permissions: SanitizedPermissions routeParams: { collection?: string folderCollection?: string @@ -55,133 +113,264 @@ type SerializablePageState = { token?: string versionID?: number | string } - documentSubViewType?: DocumentSubViewTypes - browseByFolderSlugs: string[] - clientConfig: ClientConfig // already serializable - permissions: Permissions // already serializable - locale?: Locale // already serializable - viewActions?: PayloadComponent[] // serializable; React.FC actions omitted - customViewPath?: string // path string for custom views, resolved via importMap + searchParams?: Record + segments: string[] + templateClassName: string + templateType?: 'default' | 'minimal' + viewType?: ViewTypes + visibleEntities: VisibleEntities + customView?: PayloadComponent + viewActions?: PayloadComponent[] } ``` -`DefaultView.Component` (a React function) is discarded. The client derives the component from `viewType` via a fixed registry. Custom views use `customViewPath` (the `PayloadComponent` path string) resolved via `importMap` on the client. +### Important notes -### 2. Updated route loader +- Built-in views should be represented by `viewType`, not by serializing a React function. +- `viewActions` and `customView` are only safe when they are import-map-addressable payload components. +- Direct function components should not be serialized through page state. +- `visibleEntities` or precomputed nav groups must be present so the client template can build navigation without server-only helpers. + +### 2. Route loader rewrite **File:** `tanstack-app/app/routes/admin.$.tsx` -```typescript -export const Route = createFileRoute('/admin/$')({ - loader: async ({ params }) => { - const segments = params._splat?.split('/').filter(Boolean) ?? [] - return getPageState({ data: { segments } }) - // redirects and notFound thrown inside getPageState - }, - component: AdminPage, -}) - -function AdminPage() { - const pageState = Route.useLoaderData() - const search = Route.useSearch() - return ( - }} - /> - ) -} -``` +Current route behavior: + +- resolves `segments` +- performs some auth logic inline +- renders `` -The inline auth checks and `` are removed entirely. +Target route behavior: -### 3. `TanStackAdminPage` client component +- resolves `segments` +- delegates page-state work to `getPageState` +- renders `` + +The route component should pass TanStack search params as plain route search state into the page shell. + +### 3. `TanStackAdminPage` **File:** `packages/tanstack-start/src/views/Root/TanStackAdminPage.tsx` -``` -'use client' -``` +Requirements: -Receives `pageState + importMap`. Responsibilities: +- `'use client'` +- no server-only imports +- uses `PageConfigProvider` +- chooses template by `templateType` +- chooses built-in view wrapper by `viewType` +- resolves supported import-map custom views on the client -1. Wrap in `` -2. Map `viewType` → built-in view component via a local registry; custom views via `importMap` -3. Render `` or `` with client-safe props only (no `req`, no `payload`, no `i18n` object) -4. Pass `clientConfig`, `permissions`, `routeParams`, `searchParams`, `locale`, `documentSubViewType`, `browseByFolderSlugs` as client props to the view +This component is the top of the new TanStack page tree. -**View props type:** +## Templates -```typescript -type TanStackViewProps = { - clientConfig: ClientConfig - permissions: Permissions - routeParams: SerializablePageState['routeParams'] - searchParams: Record - locale?: Locale - documentSubViewType?: DocumentSubViewTypes - browseByFolderSlugs: string[] - viewType: ViewTypes - importMap: ImportMap -} -``` +The existing `packages/ui` templates are server-oriented and should not be reused directly in the TanStack page path. -### 4. `render-document` / `render-list` in TanStack Start +Instead create TanStack-safe client templates under `packages/tanstack-start`. -These server functions return `React.ReactNode` — not serializable. TanStack Start's `handleServerFunctions` intercepts them: +### Default template -```typescript -const TANSTACK_UNSUPPORTED_FNS = new Set(['render-document', 'render-list']) +Should: -export const handleServerFunctions: ServerFunctionHandler = async (args) => { - if (TANSTACK_UNSUPPORTED_FNS.has(args.name)) { - return { __tanstack_invalidate: true } - } - // ...existing dispatch logic +- use client-safe wrappers and layout primitives +- use `DefaultNavClient` rather than `DefaultNav` +- rely on `visibleEntities` or precomputed nav groups +- preserve the admin shell structure where possible +- avoid `RenderServerComponent` + +### Minimal template + +Should: + +- cover login/reset/verify-like minimal routes +- remain client-safe +- avoid server-render helpers + +## View Strategy + +TanStack needs its own client wrappers for built-in views. + +### Reuse from existing client components + +Reuse existing client components in `packages/ui` where practical: + +- list: `DefaultListView` +- document: `DefaultEditView` +- create first user: `CreateFirstUserClient` +- account: `AccountClient` +- dashboard internals: modular dashboard client pieces + +### Views with server-only current entry points + +If a view still only exists as a server entry point, add a TanStack client wrapper that: + +- consumes serializable state from `getPageState`, or +- fetches serializable data via existing server functions or new data-only helpers + +Do not route page rendering through the server view entry point. + +### Custom views + +TanStack should only support custom views that can be addressed through the import map on the client. + +If a custom view is only represented by a direct React function and cannot be safely serialized or resolved client-side, it should not be passed through route state. + +## List Data + +The list page needs a data-only path instead of the current RSC rendering path. + +### Shared utility + +Add a small `packages/ui` exception: + +- `packages/ui/src/utilities/buildTableData.ts` + +Export it from: + +- `packages/ui/src/exports/utilities.ts` + +This helper should: + +- share data-fetching and column-building behavior with `buildTableState` +- return serializable data only +- not render `Table` +- not render filters + +### Consistency requirement + +Do not let TanStack and Next.js diverge accidentally. + +If shared list-fetch logic is adjusted, either: + +- preserve existing behavior in both paths, or +- intentionally fix the behavior in both places + +## Document Data + +The document page also needs a serializable, non-RSC path. + +TanStack should not use `renderDocument` for route rendering because it returns React nodes. + +Instead: + +- derive document state with existing server-side utilities where possible +- return plain data, permissions, preferences, locks, form state, and route metadata +- mount a client wrapper that reuses `DefaultEditView` and the required client providers + +## Dashboard + +Dashboard is the highest-risk view because the current server path still wraps modular dashboard pieces with server rendering behavior. + +Recommended initial scope: + +- make the default dashboard functional in TanStack +- reuse modular dashboard client pieces +- avoid dragging server-only dashboard extensions into the TanStack client graph + +If some advanced dashboard customizations are still server-only, keep the first pass conservative instead of reintroducing the underlying problem. + +## Server Functions and Invalidation + +The shared dispatcher in `packages/ui` should remain unchanged. + +TanStack-specific behavior belongs in: + +- `packages/tanstack-start/src/utilities/handleServerFunctions.ts` + +### Special-case invalidation + +Intercept: + +- `render-document` +- `render-list` + +Instead of returning React nodes, return an invalidation sentinel such as: + +```ts +{ + __tanstack_invalidate: true } ``` -`TanStackRouterProvider` (or a wrapper) intercepts `__tanstack_invalidate: true` responses and calls `router.invalidate()`, triggering a full loader re-run. This is the TanStack-native pattern for refreshing route data. +All other server functions should continue dispatching through the existing shared handler. -### 5. Remove `serverOnlyStubPlugin` +## Router Invalidation -Once `TanStackAdminPage` has no server-only imports, `serverOnlyStubPlugin` in `tanstack-app/vite.config.ts` can be removed. The `tanstackStartCompatPlugin` for the virtual module shim stays. +The TanStack adapter/router path should recognize the invalidation sentinel and call: ---- +- `router.invalidate()` -## What does NOT change +This keeps the route data model TanStack-native: -- `packages/next` — zero modifications -- `packages/ui` — zero modifications (existing views, templates, utilities all stay) -- `renderRootPage` — stays, used by Next.js only -- `baseServerFunctions` dispatcher — stays; TanStack Start overrides only `render-document`/`render-list` -- All REST API routes, auth, collections, globals — unchanged +- route state comes from the loader +- updates trigger loader refresh +- page state rehydrates from fresh serialized data ---- +## Vite Cleanup -## Trade-offs +Once the TanStack admin route no longer pulls server-only code into the client graph: + +- remove `serverOnlyStubPlugin` from `tanstack-app/vite.config.ts` -| | Next.js | TanStack Start | -| -------------------------- | ---------------------- | ---------------------------------- | -| SSR content | Full view pre-rendered | Shell + skeleton | -| Server-only deps in bundle | None (RSC) | None (client component) | -| Client-side navigation | RSC re-render | Loader re-run + client mount | -| View data fetch | Server (RSC) | Client (server functions on mount) | -| Stub maintenance | N/A | None needed | +Keep the compatibility shim plugin that is unrelated to this issue. ---- +## What Does Not Change + +- `packages/next` +- `renderRootPage` +- the shared server-function dispatcher in `packages/ui` +- REST and auth semantics +- the Next.js admin rendering path + +## Trade-offs + +| Aspect | Next.js | TanStack Start | +| -------------------------------- | ------------------- | ------------------------------ | +| View rendering | RSC-first | client wrappers + loader state | +| Route payload | React nodes allowed | serializable data only | +| Route refresh | framework RSC flow | loader invalidation | +| Server-only deps in client graph | none | none after migration | +| Stub maintenance | not needed | removed | ## Testing -**Integration** (`test/admin-adapter/tanstack-start.int.spec.ts`): +### Integration + +Extend `test/admin-adapter/tanstack-start.int.spec.ts` to cover: + +- `getPageState` export and shape +- redirect/notFound behavior +- invalidation responses for `render-document` and `render-list` +- normal dispatch behavior for other server functions + +### E2E + +Add or extend TanStack app coverage for: + +- dashboard route loads +- list route loads +- document route loads +- client-side navigation works +- mutations refresh the route via invalidation +- browser console has no server-only module errors + +### Manual verification + +Verify at minimum: + +1. TanStack app builds without `serverOnlyStubPlugin` +2. admin root route renders +3. list route renders +4. document route renders +5. save/delete style flows refresh correctly -- `getPageState` returns a plain serializable object for each `viewType` -- `render-document` / `render-list` return `{ __tanstack_invalidate: true }` -- All other server functions dispatch normally +## Outcome -**E2E** (`test/admin-adapter/tanstack-start.e2e.spec.ts`): +This design succeeds when TanStack admin rendering: -- Dashboard, list view, document view render without server-only errors in browser console -- Client-side navigation between routes works -- Document save triggers route invalidation -- Next.js e2e suite: no regressions +- no longer routes through `RootPage` / `RenderRoot` +- no longer needs `serverOnlyStubPlugin` +- remains compatible with the existing `RootLayout` provider tree +- keeps Next.js untouched From 1a3f67cfa19534e69386b273bf157609d5ad4afd Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Thu, 2 Apr 2026 10:08:35 +0100 Subject: [PATCH 34/60] fix(test): wire tanstack suite selection into dev and e2e Rewrite the TanStack app @payload-config alias before Vite starts, support suite#config overrides in dev:tanstack, and make e2e runs launch dev:tanstack when PAYLOAD_FRAMEWORK=tanstack-start. --- test/dev-tanstack.ts | 27 +++++++++++++++++++++++++-- test/runE2E.ts | 4 +++- test/testHooks.ts | 2 +- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/test/dev-tanstack.ts b/test/dev-tanstack.ts index 73cc9b29f3b..018c4009aa4 100644 --- a/test/dev-tanstack.ts +++ b/test/dev-tanstack.ts @@ -1,11 +1,13 @@ import chalk from 'chalk' import minimist from 'minimist' import { spawn } from 'node:child_process' +import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { loadEnv } from 'payload/node' import { runInit } from './runInit.js' +import { replacePayloadConfigPath } from './testHooks.js' loadEnv() @@ -16,19 +18,40 @@ const { _: [_testSuiteArg = '_community'], } = minimist(process.argv.slice(2)) -const testSuiteArg = _testSuiteArg +let testSuiteArg: string | undefined +let testSuiteConfigOverride: string | undefined +if (_testSuiteArg.includes('#')) { + ;[testSuiteArg, testSuiteConfigOverride] = _testSuiteArg.split('#') +} else { + testSuiteArg = _testSuiteArg +} + +if (!testSuiteArg || !fs.existsSync(path.resolve(dirname, testSuiteArg))) { + console.log(chalk.red(`ERROR: The test folder "${testSuiteArg}" does not exist`)) + process.exit(0) +} console.log(`Selected test suite: ${testSuiteArg} [TanStack Start / Vinxi]`) const tanstackAppDir = path.resolve(dirname, '..', 'tanstack-app') +const configPath = `../test/${testSuiteArg}/${testSuiteConfigOverride ?? 'config.ts'}` // Point ROOT_DIR to the TanStack app so initDevAndTest writes to the right importMap location process.env.ROOT_DIR = tanstackAppDir +process.env.PAYLOAD_CONFIG_PATH = path.resolve( + dirname, + testSuiteArg, + testSuiteConfigOverride ?? 'config.ts', +) -await runInit(testSuiteArg, true, false) +await replacePayloadConfigPath(tanstackAppDir, configPath) + +await runInit(testSuiteArg, true, false, testSuiteConfigOverride) const port = process.env.PORT ? Number(process.env.PORT) : 3000 +process.env.PAYLOAD_DROP_DATABASE = process.env.PAYLOAD_DROP_DATABASE === 'false' ? 'false' : 'true' + console.log(chalk.green(`✓ TanStack Start dev server starting on port ${port}`)) console.log(chalk.cyan(` Admin: http://localhost:${port}/admin`)) diff --git a/test/runE2E.ts b/test/runE2E.ts index ca3a70039f5..7f1c006f76e 100644 --- a/test/runE2E.ts +++ b/test/runE2E.ts @@ -22,6 +22,8 @@ if (prod) { } const turbo = process.argv.includes('--no-turbo') ? false : true +const framework = process.env.PAYLOAD_FRAMEWORK ?? 'next' +const isTanstackStart = framework === 'tanstack-start' process.argv = process.argv.filter((arg) => arg !== '--prod' && arg !== '--no-turbo') @@ -148,7 +150,7 @@ async function executePlaywright( ) const spawnDevArgs: string[] = [ - 'dev', + isTanstackStart ? 'dev:tanstack' : 'dev', suiteConfigPath ? `${baseTestFolder}#${suiteConfigPath}` : baseTestFolder, ] if (prod) { diff --git a/test/testHooks.ts b/test/testHooks.ts index a734093b950..b3275753670 100644 --- a/test/testHooks.ts +++ b/test/testHooks.ts @@ -12,7 +12,7 @@ const dirname = path.dirname(filename) * Replace the @payload-config path in tsconfig.base.json using string replacement * to avoid reformatting the entire file. */ -async function replacePayloadConfigPath(rootDir: string, configPath: string) { +export async function replacePayloadConfigPath(rootDir: string, configPath: string) { const tsConfigBasePath = path.resolve(rootDir, './tsconfig.base.json') const tsConfigPath = existsSync(tsConfigBasePath) ? tsConfigBasePath From 110071c5d4aedfb223acfe7f60f3283eddf708f1 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Thu, 2 Apr 2026 10:09:32 +0100 Subject: [PATCH 35/60] feat(tanstack-start): add serializable admin page rendering Replace the server-only TanStack admin route flow with serializable page state, client-side admin rendering, and supporting TanStack root/server-function wiring. Remove the old server-only stubs and update related exports, app routes, and test types. --- packages/tanstack-start/package.json | 11 +- .../src/layouts/Root/TanStackRootProvider.tsx | 36 ++ .../tanstack-start/src/layouts/Root/index.tsx | 7 +- .../src/utilities/handleServerFunctions.ts | 13 + .../src/views/Root/getPageState.ts | 459 ++++++++++++++ .../tanstack-start/src/views/Root/index.ts | 2 + .../tanstack-start/src/views/Root/index.tsx | 46 -- .../src/views/Root/serverFunctions.ts | 350 +++++++++++ .../tanstack-start/src/views/Root/types.ts | 65 ++ .../src/views/TanStackAdminPage.tsx | 567 ++++++++++++++++++ packages/tanstack-start/src/views/index.ts | 1 + packages/ui/package.json | 40 ++ packages/ui/src/exports/client/index.ts | 7 + tanstack-app/app/routes/__root.tsx | 39 +- tanstack-app/app/routes/admin.$.tsx | 65 +- tanstack-app/app/routes/admin.index.tsx | 52 +- tanstack-app/app/utilities/searchParams.ts | 22 + tanstack-app/server-only-stub.cjs | 37 -- tanstack-app/server-only-stub.js | 56 -- tanstack-app/tsconfig.json | 2 +- tanstack-app/vite.config.ts | 40 -- test/_community/payload-types.ts | 13 + test/admin-adapter/tanstack-start.int.spec.ts | 10 + test/admin-root/payload-types.ts | 13 + 24 files changed, 1667 insertions(+), 286 deletions(-) create mode 100644 packages/tanstack-start/src/layouts/Root/TanStackRootProvider.tsx create mode 100644 packages/tanstack-start/src/views/Root/getPageState.ts create mode 100644 packages/tanstack-start/src/views/Root/index.ts delete mode 100644 packages/tanstack-start/src/views/Root/index.tsx create mode 100644 packages/tanstack-start/src/views/Root/serverFunctions.ts create mode 100644 packages/tanstack-start/src/views/Root/types.ts create mode 100644 packages/tanstack-start/src/views/TanStackAdminPage.tsx create mode 100644 packages/tanstack-start/src/views/index.ts create mode 100644 tanstack-app/app/utilities/searchParams.ts delete mode 100644 tanstack-app/server-only-stub.cjs delete mode 100644 tanstack-app/server-only-stub.js diff --git a/packages/tanstack-start/package.json b/packages/tanstack-start/package.json index ff7afe7ccd0..be53839435c 100644 --- a/packages/tanstack-start/package.json +++ b/packages/tanstack-start/package.json @@ -18,9 +18,14 @@ "default": "./src/index.ts" }, "./views": { - "import": "./src/views/Root/index.tsx", - "types": "./src/views/Root/index.tsx", - "default": "./src/views/Root/index.tsx" + "import": "./src/views/index.ts", + "types": "./src/views/index.ts", + "default": "./src/views/index.ts" + }, + "./views/getPageState": { + "import": "./src/views/Root/getPageState.ts", + "types": "./src/views/Root/getPageState.ts", + "default": "./src/views/Root/getPageState.ts" }, "./routes": { "import": "./src/routes/index.ts", diff --git a/packages/tanstack-start/src/layouts/Root/TanStackRootProvider.tsx b/packages/tanstack-start/src/layouts/Root/TanStackRootProvider.tsx new file mode 100644 index 00000000000..db78818065a --- /dev/null +++ b/packages/tanstack-start/src/layouts/Root/TanStackRootProvider.tsx @@ -0,0 +1,36 @@ +'use client' + +import { RootProvider } from '@payloadcms/ui' +import { useRouter } from '@tanstack/react-router' +import React from 'react' + +type TanStackInvalidateResult = { + __tanstack_invalidate?: boolean +} + +type TanStackRootProviderProps = React.ComponentProps + +export const TanStackRootProvider: React.FC = ({ + serverFunction, + ...props +}) => { + const router = useRouter() + + const wrappedServerFunction = React.useCallback< + React.ComponentProps['serverFunction'] + >( + async (args) => { + const result = (await serverFunction(args)) as TanStackInvalidateResult + + if (result?.__tanstack_invalidate === true) { + await router.invalidate() + return null + } + + return result + }, + [router, serverFunction], + ) + + return +} diff --git a/packages/tanstack-start/src/layouts/Root/index.tsx b/packages/tanstack-start/src/layouts/Root/index.tsx index 336be4a6654..447f96b3932 100644 --- a/packages/tanstack-start/src/layouts/Root/index.tsx +++ b/packages/tanstack-start/src/layouts/Root/index.tsx @@ -1,6 +1,6 @@ import type { ImportMap, LanguageOptions, SanitizedConfig, ServerFunctionClient } from 'payload' -import { defaultTheme, ProgressBar, RootProvider } from '@payloadcms/ui' +import { defaultTheme, ProgressBar } from '@payloadcms/ui' import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig' import { setCookie } from '@tanstack/react-start/server' import { applyLocaleFiltering } from 'payload/shared' @@ -9,6 +9,7 @@ import React from 'react' import { TanStackRouterProvider } from '../../adapter/RouterProvider.js' import { getNavPrefs } from '../../utilities/getNavPrefs.js' import { initReq } from '../../utilities/initReq.js' +import { TanStackRootProvider } from './TanStackRootProvider.js' import '@payloadcms/ui/scss/app.scss' @@ -79,7 +80,7 @@ export const RootLayout = async ({ return ( - {children} - + ) } diff --git a/packages/tanstack-start/src/utilities/handleServerFunctions.ts b/packages/tanstack-start/src/utilities/handleServerFunctions.ts index 9b5c959478a..fa33d963d02 100644 --- a/packages/tanstack-start/src/utilities/handleServerFunctions.ts +++ b/packages/tanstack-start/src/utilities/handleServerFunctions.ts @@ -3,10 +3,12 @@ import type { ServerFunctionHandler } from 'payload' import { dispatchServerFunction } from '@payloadcms/ui/utilities/handleServerFunctions' import { notFound, redirect } from '@tanstack/react-router' +import { TANSTACK_INVALIDATE, tanstackDocumentStateHandler } from '../views/Root/serverFunctions.js' import { initReq } from './initReq.js' export const handleServerFunctions: ServerFunctionHandler = async (args) => { const { name: fnKey, args: fnArgs, config, importMap, serverFunctions } = args + const invalidateArgs = fnArgs as { __tanstackInvalidate?: boolean } | undefined const { cookies, locale, permissions, req } = await initReq({ config, @@ -31,6 +33,17 @@ export const handleServerFunctions: ServerFunctionHandler = async (args) => { req, } + if ( + (fnKey === 'render-document' || fnKey === 'render-list') && + invalidateArgs?.__tanstackInvalidate === true + ) { + return TANSTACK_INVALIDATE + } + + if (fnKey === 'tanstack-document-state') { + return tanstackDocumentStateHandler(augmentedArgs as never) + } + return dispatchServerFunction({ name: fnKey, augmentedArgs, diff --git a/packages/tanstack-start/src/views/Root/getPageState.ts b/packages/tanstack-start/src/views/Root/getPageState.ts new file mode 100644 index 00000000000..a7d4bf8db5d --- /dev/null +++ b/packages/tanstack-start/src/views/Root/getPageState.ts @@ -0,0 +1,459 @@ +import type { + CollectionPreferences, + ImportMap, + PayloadComponent, + SanitizedCollectionConfig, + SanitizedConfig, + SanitizedGlobalConfig, +} from 'payload' + +import { reduceToSerializableFields } from '@payloadcms/ui/shared' +import { buildFormState } from '@payloadcms/ui/utilities/buildFormState' +import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig' +import { getPreferences } from '@payloadcms/ui/utilities/getPreferences' +import { getVisibleEntities } from '@payloadcms/ui/utilities/getVisibleEntities' +import { handleAuthRedirect } from '@payloadcms/ui/utilities/handleAuthRedirect' +import { isCustomAdminView } from '@payloadcms/ui/utilities/isCustomAdminView' +import { isPublicAdminRoute } from '@payloadcms/ui/utilities/isPublicAdminRoute' +import { getDocPreferences } from '@payloadcms/ui/views/Document/getDocPreferences' +import { getDocumentData } from '@payloadcms/ui/views/Document/getDocumentData' +import { getRouteData } from '@payloadcms/ui/views/Root/getRouteData' +import { applyLocaleFiltering, formatAdminURL } from 'payload/shared' + +import type { SerializablePageData, SerializablePageState } from './types.js' + +import { getNavPrefs } from '../../utilities/getNavPrefs.js' +import { initReq } from '../../utilities/initReq.js' + +const isSerializablePayloadComponent = ( + value: PayloadComponent | undefined, +): value is Exclude => { + return Boolean(value && (typeof value === 'string' || typeof value === 'object')) +} + +const serializePayloadComponents = ( + components: PayloadComponent[] | undefined, +): PayloadComponent[] | undefined => { + const safeComponents = components?.filter(isSerializablePayloadComponent) + return safeComponents?.length ? safeComponents : undefined +} + +const getRouteEntityConfigs = ({ + config, + currentRoute, + segments, +}: { + config: SanitizedConfig + currentRoute: string + segments: string[] +}): { + collectionConfig?: SanitizedCollectionConfig + globalConfig?: SanitizedGlobalConfig +} => { + const isCollectionRoute = segments[0] === 'collections' + const isGlobalRoute = segments[0] === 'globals' + + let collectionConfig: SanitizedCollectionConfig = undefined + let globalConfig: SanitizedGlobalConfig = undefined + + if (isCollectionRoute && segments[1]) { + collectionConfig = config.collections.find(({ slug }) => slug === segments[1]) + } + + if (isGlobalRoute && segments[1]) { + globalConfig = config.globals.find(({ slug }) => slug === segments[1]) + } + + if (isCollectionRoute && segments.length === 1) { + const hasCollectionsCustomView = isCustomAdminView({ + adminRoute: config.routes.admin, + config, + route: currentRoute, + }) + + if (!hasCollectionsCustomView) { + return {} + } + } + + if (isGlobalRoute && segments.length === 1) { + const hasGlobalsCustomView = isCustomAdminView({ + adminRoute: config.routes.admin, + config, + route: currentRoute, + }) + + if (!hasGlobalsCustomView) { + return {} + } + } + + return { + collectionConfig, + globalConfig, + } +} + +const getLoginPageData = (config: SanitizedConfig): SerializablePageData['login'] => { + const prefillAutoLogin = + typeof config.admin?.autoLogin === 'object' && config.admin?.autoLogin.prefillOnly + + if (!prefillAutoLogin || typeof config.admin?.autoLogin !== 'object') { + return undefined + } + + return { + prefillEmail: config.admin.autoLogin.email, + prefillPassword: config.admin.autoLogin.password, + prefillUsername: config.admin.autoLogin.username, + } +} + +const buildRedirectSearch = (searchParams: Record) => { + const params = new URLSearchParams() + + for (const [key, value] of Object.entries(searchParams)) { + if (Array.isArray(value)) { + for (const entry of value) { + params.append(key, entry) + } + } else if (value !== undefined) { + params.set(key, value) + } + } + + const result = params.toString() + + return result ? `?${result}` : '' +} + +const getCreateFirstUserPageData = async ({ + localeCode, + req, +}: { + localeCode?: string + req: Awaited>['req'] +}): Promise => { + const { + payload, + payload: { + collections, + config: { + admin: { user: userSlug }, + }, + }, + user, + } = req + + const collectionConfig = collections?.[userSlug]?.config + + if (!collectionConfig) { + return undefined + } + + const data = await getDocumentData({ + collectionSlug: collectionConfig.slug, + locale: req.locale, + payload, + req, + user, + }) + + const docPreferences = await getDocPreferences({ + collectionSlug: collectionConfig.slug, + payload, + user, + }) + + const docPermissions = { + create: true, + delete: true, + fields: Object.fromEntries( + collectionConfig.fields + .filter((field): field is { name: string } & typeof field => 'name' in field) + .map((field) => [field.name, { create: true, read: true, update: true }]), + ), + read: true, + readVersions: true, + update: true, + } + + const { state } = await buildFormState({ + collectionSlug: collectionConfig.slug, + data, + docPermissions, + docPreferences, + locale: localeCode, + operation: 'create', + renderAllFields: true, + req, + schemaPath: collectionConfig.slug, + skipClientConfigAuth: true, + skipValidation: true, + }) + + return { + docPermissions, + docPreferences, + initialState: reduceToSerializableFields(state), + loginWithUsername: collectionConfig.auth?.loginWithUsername, + userSlug, + } +} + +const getVerifyPageData = async ({ + collection, + req, + token, +}: { + collection?: string + req: Awaited>['req'] + token?: string +}): Promise => { + let isVerified = false + let message = req.t('authentication:unableToVerify') + + if (!collection || !token) { + return { isVerified, message } + } + + try { + await req.payload.verifyEmail({ + collection, + token, + }) + isVerified = true + message = req.t('authentication:emailVerified') + } catch { + // Keep the default failure message + } + + return { isVerified, message } +} + +export async function getPageState(args: { + config: Promise | SanitizedConfig + importMap: ImportMap + searchParams: Record + segments: string[] +}): Promise { + const { config: configPromise, importMap, searchParams, segments } = args + + const initPageResult = await initReq({ + config: configPromise, + importMap, + key: 'getPageState', + }) + + const { + locale, + permissions, + req, + req: { payload }, + } = initPageResult + + const config = payload.config + const { + admin: { + routes: { createFirstUser: createFirstUserRouteValue }, + user: userSlug, + }, + routes: { admin: adminRoute }, + } = config + + const currentRoute = formatAdminURL({ + adminRoute, + path: Array.isArray(segments) && segments.length > 0 ? `/${segments.join('/')}` : null, + }) + + const { collectionConfig, globalConfig } = getRouteEntityConfigs({ + config, + currentRoute, + segments, + }) + + const isCollectionRoute = segments[0] === 'collections' + const isGlobalRoute = segments[0] === 'globals' + + if ( + (isCollectionRoute && segments[1] && !collectionConfig) || + (isGlobalRoute && segments[1] && !globalConfig) + ) { + throw new Error('not-found') + } + + if ( + !permissions.canAccessAdmin && + !isPublicAdminRoute({ adminRoute, config, route: currentRoute }) && + !isCustomAdminView({ adminRoute, config, route: currentRoute }) + ) { + throw new Error( + `REDIRECT:${handleAuthRedirect({ + config, + route: currentRoute, + searchParams, + user: req.user, + })}`, + ) + } + + let collectionPreferences: CollectionPreferences = undefined + + if (collectionConfig && segments.length === 2) { + if (config.folders && collectionConfig.folders && segments[1] !== config.folders.slug) { + collectionPreferences = await getPreferences( + `collection-${collectionConfig.slug}`, + req.payload, + req.user?.id, + config.admin.user, + ).then((res) => res?.value) + } + } + + const { + browseByFolderSlugs, + DefaultView, + documentSubViewType, + routeParams, + templateClassName, + templateType, + viewActions, + viewType, + } = getRouteData({ + adminRoute, + collectionConfig, + collectionPreferences, + currentRoute, + globalConfig, + payload, + searchParams, + segments, + }) + + req.routeParams = routeParams + + const dbHasUser = + req.user || + (await req.payload.db + .findOne({ + collection: userSlug, + req, + }) + ?.then((doc) => Boolean(doc))) + + if (!DefaultView?.Component && !DefaultView?.payloadComponent) { + if (req.user) { + throw new Error('not-found') + } + + if (dbHasUser) { + throw new Error(`REDIRECT:${adminRoute}`) + } + } + + const usersCollection = config.collections.find(({ slug }) => slug === userSlug) + const disableLocalStrategy = usersCollection?.auth?.disableLocalStrategy + const createFirstUserRoute = formatAdminURL({ + adminRoute, + path: createFirstUserRouteValue, + }) + + if (disableLocalStrategy && currentRoute === createFirstUserRoute) { + throw new Error(`REDIRECT:${adminRoute}`) + } + + if (!dbHasUser && currentRoute !== createFirstUserRoute && !disableLocalStrategy) { + throw new Error(`REDIRECT:${createFirstUserRoute}`) + } + + if (dbHasUser && currentRoute === createFirstUserRoute) { + throw new Error(`REDIRECT:${adminRoute}`) + } + + if (!DefaultView?.Component && !DefaultView?.payloadComponent && !dbHasUser) { + throw new Error(`REDIRECT:${adminRoute}`) + } + + const clientConfig = getClientConfig({ + config, + i18n: req.i18n, + importMap, + user: viewType === 'createFirstUser' ? true : req.user, + }) + + await applyLocaleFiltering({ clientConfig, config, req }) + + if ( + clientConfig.localization && + req.locale && + !clientConfig.localization.localeCodes.includes(req.locale) + ) { + const redirectSearch = buildRedirectSearch({ + ...searchParams, + locale: clientConfig.localization.localeCodes.includes( + clientConfig.localization.defaultLocale, + ) + ? clientConfig.localization.defaultLocale + : clientConfig.localization.localeCodes[0], + }) + + throw new Error(`REDIRECT:${currentRoute}${redirectSearch}`) + } + + const visibleEntities = getVisibleEntities({ req }) + const navPreferences = await getNavPrefs(req) + + let pageData: SerializablePageData = undefined + + if (viewType === 'login') { + pageData = { + ...pageData, + login: getLoginPageData(config), + } + } + + if (viewType === 'createFirstUser') { + pageData = { + ...pageData, + createFirstUser: await getCreateFirstUserPageData({ + localeCode: locale?.code, + req, + }), + } + } + + if (viewType === 'verify') { + pageData = { + ...pageData, + verify: await getVerifyPageData({ + collection: routeParams.collection, + req, + token: routeParams.token, + }), + } + } + + return { + browseByFolderSlugs, + clientConfig, + customView: isSerializablePayloadComponent(DefaultView?.payloadComponent) + ? DefaultView.payloadComponent + : undefined, + documentSubViewType, + locale, + navPreferences, + pageData, + permissions, + routeParams, + searchParams, + segments, + templateClassName, + templateType, + unsupportedCustomView: Boolean( + DefaultView?.Component && !viewType && !DefaultView?.payloadComponent, + ), + viewActions: serializePayloadComponents(viewActions), + viewType, + visibleEntities, + } +} diff --git a/packages/tanstack-start/src/views/Root/index.ts b/packages/tanstack-start/src/views/Root/index.ts new file mode 100644 index 00000000000..78a29eae42e --- /dev/null +++ b/packages/tanstack-start/src/views/Root/index.ts @@ -0,0 +1,2 @@ +export { getPageState } from './getPageState.js' +export type { SerializablePageState } from './types.js' diff --git a/packages/tanstack-start/src/views/Root/index.tsx b/packages/tanstack-start/src/views/Root/index.tsx deleted file mode 100644 index 4fa5f3d1b26..00000000000 --- a/packages/tanstack-start/src/views/Root/index.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import type { ImportMap, SanitizedConfig } from 'payload' - -import { notFound, redirect } from '@tanstack/react-router' - -import { initReq } from '../../utilities/initReq.js' - -type Props = { - config: Promise | SanitizedConfig - importMap: ImportMap - searchParams: Record - segments: string[] -} - -/** - * TanStack Start admin page renderer. - * Equivalent of RootPage from @payloadcms/next/views. - */ -export async function RootPage({ - config: configPromise, - importMap, - searchParams, - segments, -}: Props) { - const { renderRootPage } = await import('@payloadcms/ui/views/Root/RenderRoot') - - const initPageResult = await initReq({ - config: configPromise, - importMap, - key: 'initPage', - }) - - return renderRootPage({ - importMap, - initPageResult, - notFound: () => { - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw notFound() - }, - redirect: (url: string) => { - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw redirect({ to: url }) - }, - searchParams, - segments, - }) -} diff --git a/packages/tanstack-start/src/views/Root/serverFunctions.ts b/packages/tanstack-start/src/views/Root/serverFunctions.ts new file mode 100644 index 00000000000..61172c0c2b2 --- /dev/null +++ b/packages/tanstack-start/src/views/Root/serverFunctions.ts @@ -0,0 +1,350 @@ +import type { + Data, + DefaultServerFunctionArgs, + DocumentSubViewTypes, + DocumentViewClientProps, + RenderDocumentVersionsProperties, + ServerFunction, + VisibleEntities, +} from 'payload' + +import { reduceToSerializableFields } from '@payloadcms/ui/shared' +import { buildFormState } from '@payloadcms/ui/utilities/buildFormState' +import { handleLivePreview } from '@payloadcms/ui/utilities/handleLivePreview' +import { handlePreview } from '@payloadcms/ui/utilities/handlePreview' +import { isEditing as getIsEditing } from '@payloadcms/ui/utilities/isEditing' +import { getDocPreferences } from '@payloadcms/ui/views/Document/getDocPreferences' +import { getDocumentData } from '@payloadcms/ui/views/Document/getDocumentData' +import { getDocumentPermissions } from '@payloadcms/ui/views/Document/getDocumentPermissions' +import { getIsLocked } from '@payloadcms/ui/views/Document/getIsLocked' +import { getVersions } from '@payloadcms/ui/views/Document/getVersions' +import { canAccessAdmin, isEntityHidden } from 'payload' +import { formatAdminURL, hasAutosaveEnabled, hasDraftsEnabled } from 'payload/shared' + +export const TANSTACK_INVALIDATE = { + __tanstack_invalidate: true as const, +} + +export type TanStackInvalidateResult = typeof TANSTACK_INVALIDATE + +type TanStackDocumentStateArgs = { + account?: boolean + collectionSlug?: string + docID?: number | string + documentSubViewType?: DocumentSubViewTypes + globalSlug?: string + redirectAfterCreate?: boolean + redirectAfterDelete?: boolean + redirectAfterDuplicate?: boolean + redirectAfterRestore?: boolean + searchParams?: Record + segments: string[] + versions?: RenderDocumentVersionsProperties +} & DefaultServerFunctionArgs + +export type TanStackDocumentStateResult = { + apiURL?: string + collectionSlug?: string + currentEditor?: unknown + docPermissions?: Record + globalSlug?: string + hasDeletePermission?: boolean + hasPublishedDoc: boolean + hasPublishPermission?: boolean + hasSavePermission?: boolean + hasTrashPermission?: boolean + hideTabs?: boolean + id?: number | string + initialData?: Data + initialState?: DocumentViewClientProps['formState'] + isEditing?: boolean + isLivePreviewEnabled?: boolean + isLocked: boolean + isPreviewEnabled?: boolean + isTrashed?: boolean + lastUpdateTime: number + livePreviewBreakpoints?: unknown[] + livePreviewURL?: string + mostRecentVersionIsAutosaved: boolean + previewURL?: string + redirectURL?: string + typeofLivePreviewURL?: 'function' | 'string' + unpublishedVersionCount: number + versionCount: number +} + +const getVisibleEntities = ({ + payload, + user, +}: { + payload: DefaultServerFunctionArgs['req']['payload'] + user: DefaultServerFunctionArgs['req']['user'] +}): VisibleEntities => ({ + collections: payload.config.collections + .map(({ slug, admin: { hidden } }) => (!isEntityHidden({ hidden, user }) ? slug : null)) + .filter(Boolean), + globals: payload.config.globals + .map(({ slug, admin: { hidden } }) => (!isEntityHidden({ hidden, user }) ? slug : null)) + .filter(Boolean), +}) + +export const tanstackDocumentStateHandler: ServerFunction< + TanStackDocumentStateArgs, + Promise +> = async (args) => { + const { + account, + collectionSlug: collectionSlugFromArgs, + docID: docIDFromArgs, + documentSubViewType = 'default', + globalSlug: globalSlugFromArgs, + redirectAfterCreate, + req, + req: { + payload, + payload: { + config, + config: { + admin: { user: userSlug }, + routes: { admin: adminRoute, api: apiRoute }, + }, + }, + user, + }, + searchParams: _searchParams = {}, + segments, + versions: _versions, + } = args + + await canAccessAdmin({ req }) + + const collectionSlug = + account || (!collectionSlugFromArgs && !globalSlugFromArgs && documentSubViewType === 'default') + ? userSlug + : collectionSlugFromArgs + + const globalSlug = account ? undefined : globalSlugFromArgs + const docID = account ? user?.id : docIDFromArgs + + const collectionConfig = collectionSlug ? payload.collections[collectionSlug]?.config : undefined + const globalConfig = globalSlug + ? payload.config.globals.find((global) => global.slug === globalSlug) + : undefined + + if (!collectionConfig && !globalConfig) { + throw new Error('not-found') + } + + const visibleEntities = getVisibleEntities({ payload, user }) + + if ( + (collectionSlug && !visibleEntities.collections.includes(collectionSlug)) || + (globalSlug && !visibleEntities.globals.includes(globalSlug)) + ) { + throw new Error('not-found') + } + + const id = docID + const isEditing = getIsEditing({ id, collectionSlug, globalSlug }) + + const data = await getDocumentData({ + id, + collectionSlug, + globalSlug, + locale: req.locale, + payload, + req, + segments, + user, + }) + + if (isEditing && !data) { + if (collectionSlug) { + return { + hasPublishedDoc: false, + isLocked: false, + lastUpdateTime: Date.now(), + mostRecentVersionIsAutosaved: false, + redirectURL: formatAdminURL({ + adminRoute, + path: `/collections/${collectionSlug}?notFound=${encodeURIComponent(String(id))}`, + }), + unpublishedVersionCount: 0, + versionCount: 0, + } + } + + throw new Error('not-found') + } + + const docPreferences = await getDocPreferences({ + id, + collectionSlug, + globalSlug, + payload, + user, + }) + + const { + docPermissions, + hasDeletePermission, + hasPublishPermission, + hasSavePermission, + hasTrashPermission, + } = await getDocumentPermissions({ + id, + collectionConfig, + data, + globalConfig, + req, + }) + + let resolvedData = data + let resolvedID = id + + const shouldAutosave = + hasSavePermission && hasAutosaveEnabled(collectionConfig || globalConfig) && collectionSlug + const validateDraftData = + typeof collectionConfig?.versions?.drafts === 'object' && + collectionConfig.versions.drafts?.validate + + if (shouldAutosave && !validateDraftData && !id && collectionSlug) { + const createdDoc = await payload.create({ + collection: collectionSlug, + data: resolvedData || {}, + depth: 0, + draft: true, + fallbackLocale: false, + locale: req.locale, + req, + user, + }) + + if (createdDoc?.id) { + resolvedData = createdDoc + resolvedID = createdDoc.id + + if (redirectAfterCreate !== false) { + return { + hasPublishedDoc: false, + isLocked: false, + lastUpdateTime: Date.now(), + mostRecentVersionIsAutosaved: false, + redirectURL: formatAdminURL({ + adminRoute, + path: `/collections/${collectionSlug}/${createdDoc.id}`, + }), + unpublishedVersionCount: 0, + versionCount: 0, + } + } + } + } + + const operation = (collectionSlug && resolvedID) || globalSlug ? 'update' : 'create' + + const { state } = await buildFormState({ + id: resolvedID, + collectionSlug, + data: resolvedData, + docPermissions, + docPreferences, + fallbackLocale: false, + globalSlug, + locale: req.locale, + operation, + readOnly: false, + renderAllFields: true, + req, + schemaPath: collectionSlug || globalSlug, + skipValidation: true, + }) + + const { currentEditor, isLocked, lastUpdateTime } = await getIsLocked({ + id: resolvedID, + collectionConfig, + globalConfig, + isEditing: Boolean((collectionSlug && resolvedID) || globalSlug), + req, + }) + + const { hasPublishedDoc, mostRecentVersionIsAutosaved, unpublishedVersionCount, versionCount } = + await getVersions({ + id: resolvedID, + collectionConfig, + doc: resolvedData, + docPermissions, + globalConfig, + locale: req.locale, + payload, + user, + }) + + const formattedParams = new URLSearchParams() + + if (hasDraftsEnabled(collectionConfig || globalConfig)) { + formattedParams.append('draft', 'true') + } + + if (req.locale) { + formattedParams.append('locale', req.locale) + } + + const apiQueryParams = `?${formattedParams.toString()}` + const apiURL = formatAdminURL({ + apiRoute, + path: collectionSlug + ? `/${collectionSlug}/${resolvedID}${apiQueryParams}` + : globalSlug + ? `/${globalSlug}${apiQueryParams}` + : '', + }) + + const { isLivePreviewEnabled, livePreviewConfig, livePreviewURL } = await handleLivePreview({ + collectionSlug, + config, + data: resolvedData, + globalSlug, + operation, + req, + }) + + const { isPreviewEnabled, previewURL } = await handlePreview({ + collectionSlug, + config, + data: resolvedData, + globalSlug, + operation, + req, + }) + + return { + id: resolvedID, + apiURL, + collectionSlug, + currentEditor, + docPermissions, + globalSlug, + hasDeletePermission, + hasPublishedDoc, + hasPublishPermission, + hasSavePermission, + hasTrashPermission, + hideTabs: account, + initialData: resolvedData, + initialState: reduceToSerializableFields(state) as DocumentViewClientProps['formState'], + isEditing: Boolean((collectionSlug && resolvedID) || globalSlug), + isLivePreviewEnabled: isLivePreviewEnabled && operation !== 'create', + isLocked, + isPreviewEnabled: Boolean(isPreviewEnabled), + isTrashed: Boolean(resolvedData && 'deletedAt' in resolvedData && resolvedData.deletedAt), + lastUpdateTime, + livePreviewBreakpoints: livePreviewConfig?.breakpoints, + livePreviewURL, + mostRecentVersionIsAutosaved, + previewURL, + typeofLivePreviewURL: typeof livePreviewConfig?.url as 'function' | 'string' | undefined, + unpublishedVersionCount, + versionCount, + } +} diff --git a/packages/tanstack-start/src/views/Root/types.ts b/packages/tanstack-start/src/views/Root/types.ts new file mode 100644 index 00000000000..ac3ba77fba0 --- /dev/null +++ b/packages/tanstack-start/src/views/Root/types.ts @@ -0,0 +1,65 @@ +import type { + ClientConfig, + DocumentSubViewTypes, + Locale, + NavPreferences, + PayloadComponent, + SanitizedPermissions, + ViewTypes, + VisibleEntities, +} from 'payload' + +export type SerializableRouteParams = { + collection?: string + folderCollection?: string + folderID?: number | string + global?: string + id?: number | string + token?: string + versionID?: number | string +} + +export type LoginPageData = { + prefillEmail?: string + prefillPassword?: string + prefillUsername?: string +} + +export type CreateFirstUserPageData = { + docPermissions: Record + docPreferences: Record + initialState: Record + loginWithUsername?: false | Record + userSlug: string +} + +export type VerifyPageData = { + isVerified: boolean + message: string +} + +export type SerializablePageData = { + createFirstUser?: CreateFirstUserPageData + login?: LoginPageData + verify?: VerifyPageData +} + +export type SerializablePageState = { + browseByFolderSlugs: string[] + clientConfig: ClientConfig + customView?: PayloadComponent + documentSubViewType?: DocumentSubViewTypes + locale?: Locale + navPreferences?: NavPreferences + pageData?: SerializablePageData + permissions: SanitizedPermissions + routeParams: SerializableRouteParams + searchParams?: Record + segments: string[] + templateClassName: string + templateType?: 'default' | 'minimal' + unsupportedCustomView?: boolean + viewActions?: PayloadComponent[] + viewType?: ViewTypes + visibleEntities: VisibleEntities +} diff --git a/packages/tanstack-start/src/views/TanStackAdminPage.tsx b/packages/tanstack-start/src/views/TanStackAdminPage.tsx new file mode 100644 index 00000000000..eb98410b43e --- /dev/null +++ b/packages/tanstack-start/src/views/TanStackAdminPage.tsx @@ -0,0 +1,567 @@ +'use client' + +import type { Column } from 'payload' + +import { + AccountClient, + ActionsProvider, + APIViewClient, + AppHeader, + BulkUploadProvider, + Button, + CreateFirstUserClient, + DefaultEditView, + DefaultListView, + DocumentInfoProvider, + EditDepthProvider, + EntityVisibilityProvider, + ForgotPasswordForm, + Gutter, + HydrateAuthProvider, + Link, + ListQueryProvider, + LivePreviewProvider, + LoadingOverlay, + LoginForm, + LogoutClient, + OperationProvider, + PageConfigProvider, + parseSearchParams, + ResetPasswordForm, + useConfig, + useRouter, + useSearchParams, + useServerFunctions, + useStepNav, + useTranslation, +} from '@payloadcms/ui' +import { FormHeader } from '@payloadcms/ui/elements/FormHeader' +import { EntityType, groupNavItems } from '@payloadcms/ui/shared' +import { MinimalTemplate } from '@payloadcms/ui/templates/Minimal' +import { formatAdminURL } from 'payload/shared' +import React from 'react' + +import type { TanStackDocumentStateResult } from './Root/serverFunctions.js' +import type { SerializablePageState } from './Root/types.js' + +function TanStackDefaultTemplate({ + children, + pageState, +}: { + children: React.ReactNode + pageState: SerializablePageState +}) { + const { i18n } = useTranslation() + + const groups = React.useMemo( + () => + groupNavItems( + [ + ...pageState.clientConfig.collections + .filter((collection) => pageState.visibleEntities.collections.includes(collection.slug)) + .map((entity) => ({ + type: EntityType.collection, + entity: entity as never, + })), + ...pageState.clientConfig.globals + .filter((global) => pageState.visibleEntities.globals.includes(global.slug)) + .map((entity) => ({ + type: EntityType.global, + entity: entity as never, + })), + ], + pageState.permissions, + i18n, + ), + [ + i18n, + pageState.clientConfig.collections, + pageState.clientConfig.globals, + pageState.permissions, + pageState.visibleEntities.collections, + pageState.visibleEntities.globals, + ], + ) + + return ( + + + +
    + +
    + + {children} +
    +
    +
    +
    +
    + ) +} + +function UnsupportedView(props: { description: string; title: string }) { + return ( + + + + ) +} + +function DashboardView({ pageState }: { pageState: SerializablePageState }) { + const { i18n, t } = useTranslation() + const { setStepNav } = useStepNav() + + React.useEffect(() => { + setStepNav([]) + }, [setStepNav]) + + return ( + +

    {t('general:dashboard')}

    +
    + {pageState.clientConfig.collections + .filter((collection) => pageState.visibleEntities.collections.includes(collection.slug)) + .filter((collection) => pageState.permissions.collections?.[collection.slug]?.read) + .map((collection) => ( + + {typeof collection.labels.plural === 'function' + ? collection.labels.plural({ i18n, t: i18n.t }) + : collection.labels.plural} + + ))} + {pageState.clientConfig.globals + .filter((global) => pageState.visibleEntities.globals.includes(global.slug)) + .filter((global) => pageState.permissions.globals?.[global.slug]?.read) + .map((global) => ( + + {typeof global.label === 'function' + ? global.label({ i18n, t: i18n.t }) + : global.label} + + ))} +
    +
    + ) +} + +function LoginView({ pageState }: { pageState: SerializablePageState }) { + return ( +
    + +
    + ) +} + +function ForgotPasswordView({ pageState }: { pageState: SerializablePageState }) { + const { t } = useTranslation() + + return ( + <> + + + {t('authentication:backToLogin')} + + + ) +} + +function ResetPasswordView({ pageState }: { pageState: SerializablePageState }) { + const { t } = useTranslation() + + return ( +
    + + +
    + ) +} + +function UnauthorizedView({ pageState }: { pageState: SerializablePageState }) { + const { t } = useTranslation() + + return ( + + + + + ) +} + +function LogoutView({ pageState }: { pageState: SerializablePageState }) { + return ( +
    + +
    + ) +} + +function VerifyView({ pageState }: { pageState: SerializablePageState }) { + return ( + +

    {pageState.pageData?.verify?.message}

    +
    + ) +} + +function CreateFirstUserView({ pageState }: { pageState: SerializablePageState }) { + const { t } = useTranslation() + const data = pageState.pageData?.createFirstUser + + if (!data) { + return + } + + return ( +
    +

    {t('general:welcome')}

    +

    {t('authentication:beginCreateFirstUser')}

    + +
    + ) +} + +function useListState(pageState: SerializablePageState) { + const { serverFunction } = useServerFunctions() + const searchParams = useSearchParams() + const [state, setState] = React.useState<{ + data: null | Record + renderedFilters?: Map + state: Column[] + Table: React.ReactNode + } | null>(null) + + React.useEffect(() => { + if (!pageState.routeParams.collection) { + return + } + + let cancelled = false + + const run = async () => { + const result = (await serverFunction({ + name: 'table-state', + args: { + collectionSlug: pageState.routeParams.collection, + enableRowSelections: true, + orderableFieldName: '_order', + permissions: pageState.permissions, + query: parseSearchParams(searchParams) as Record, + }, + })) as typeof state + + if (!cancelled) { + setState(result) + } + } + + void run() + + return () => { + cancelled = true + } + }, [pageState.permissions, pageState.routeParams.collection, searchParams, serverFunction]) + + return state +} + +function ListView({ pageState }: { pageState: SerializablePageState }) { + const { getEntityConfig } = useConfig() + const searchParams = useSearchParams() + const listState = useListState(pageState) + + if (!pageState.routeParams.collection || !listState) { + return + } + + const collectionSlug = pageState.routeParams.collection + const collectionConfig = getEntityConfig({ collectionSlug }) + const query = parseSearchParams(searchParams) as Record + + return ( + <> + + + + + + ) +} + +function useDocumentState(args: { account?: boolean; pageState: SerializablePageState }) { + const { serverFunction } = useServerFunctions() + const router = useRouter() + const [state, setState] = React.useState(null) + + React.useEffect(() => { + let cancelled = false + + const run = async () => { + const result = (await serverFunction({ + name: 'tanstack-document-state', + args: { + account: args.account, + collectionSlug: args.pageState.routeParams.collection, + docID: args.pageState.routeParams.id, + documentSubViewType: args.pageState.documentSubViewType, + globalSlug: args.pageState.routeParams.global, + searchParams: args.pageState.searchParams, + segments: args.pageState.segments, + }, + })) as TanStackDocumentStateResult + + if (result?.redirectURL) { + router.replace(result.redirectURL) + return + } + + if (!cancelled) { + setState(result) + } + } + + void run() + + return () => { + cancelled = true + } + }, [ + args.account, + args.pageState.documentSubViewType, + args.pageState.routeParams.collection, + args.pageState.routeParams.global, + args.pageState.routeParams.id, + args.pageState.searchParams, + args.pageState.segments, + router, + serverFunction, + ]) + + return state +} + +function DocumentView({ + account, + pageState, +}: { + account?: boolean + pageState: SerializablePageState +}) { + const documentState = useDocumentState({ account, pageState }) + + if (!documentState) { + return + } + + if (pageState.documentSubViewType === 'versions' || pageState.documentSubViewType === 'version') { + return ( + + ) + } + + const operation = + (documentState.collectionSlug && documentState.id) || documentState.globalSlug + ? 'update' + : 'create' + + return ( + + + + + + {pageState.documentSubViewType === 'api' ? : } + + {account && } + + + + ) +} + +function renderView(pageState: SerializablePageState): React.ReactNode { + if (pageState.unsupportedCustomView || pageState.customView) { + return ( + + ) + } + + switch (pageState.viewType as string | undefined) { + case 'account': + return + case 'createFirstUser': + return + case 'dashboard': + return + case 'document': + return + case 'forgot': + return + case 'inactivity': + case 'logout': + return + case 'list': + case 'trash': + return + case 'login': + return + case 'reset': + return + case 'unauthorized': + return + case 'verify': + return + default: + return ( + + ) + } +} + +export function TanStackAdminPage({ pageState }: { pageState: SerializablePageState }) { + const content = renderView(pageState) + + return ( + + {!pageState.templateType && <>{content}} + {pageState.templateType === 'minimal' && ( + {content} + )} + {pageState.templateType === 'default' && ( + {content} + )} + + ) +} diff --git a/packages/tanstack-start/src/views/index.ts b/packages/tanstack-start/src/views/index.ts new file mode 100644 index 00000000000..e17d5db2295 --- /dev/null +++ b/packages/tanstack-start/src/views/index.ts @@ -0,0 +1 @@ +export { TanStackAdminPage } from './TanStackAdminPage.js' diff --git a/packages/ui/package.json b/packages/ui/package.json index 2f4816c0b6d..827bd8b3ebe 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -56,6 +56,21 @@ "types": "./src/views/*/index.tsx", "default": "./src/views/*/index.tsx" }, + "./views/Root/getRouteData": { + "import": "./src/views/Root/getRouteData.ts", + "types": "./src/views/Root/getRouteData.ts", + "default": "./src/views/Root/getRouteData.ts" + }, + "./views/Document/getDocumentData": { + "import": "./src/views/Document/getDocumentData.ts", + "types": "./src/views/Document/getDocumentData.ts", + "default": "./src/views/Document/getDocumentData.ts" + }, + "./views/Document/getDocPreferences": { + "import": "./src/views/Document/getDocPreferences.ts", + "types": "./src/views/Document/getDocPreferences.ts", + "default": "./src/views/Document/getDocPreferences.ts" + }, "./views/Document/getDocumentPermissions": { "import": "./src/views/Document/getDocumentPermissions.tsx", "types": "./src/views/Document/getDocumentPermissions.tsx", @@ -421,6 +436,16 @@ "types": "./src/utilities/getClientConfig.ts", "default": "./src/utilities/getClientConfig.ts" }, + "./utilities/getPreferences": { + "import": "./src/utilities/getPreferences.ts", + "types": "./src/utilities/getPreferences.ts", + "default": "./src/utilities/getPreferences.ts" + }, + "./utilities/getVisibleEntities": { + "import": "./src/utilities/getVisibleEntities.ts", + "types": "./src/utilities/getVisibleEntities.ts", + "default": "./src/utilities/getVisibleEntities.ts" + }, "./utilities/buildFieldSchemaMap/traverseFields": { "import": "./src/utilities/buildFieldSchemaMap/traverseFields.ts", "types": "./src/utilities/buildFieldSchemaMap/traverseFields.ts", @@ -441,11 +466,26 @@ "types": "./src/utilities/handleAuthRedirect.ts", "default": "./src/utilities/handleAuthRedirect.ts" }, + "./utilities/handleLivePreview": { + "import": "./src/utilities/handleLivePreview.ts", + "types": "./src/utilities/handleLivePreview.ts", + "default": "./src/utilities/handleLivePreview.ts" + }, + "./utilities/handlePreview": { + "import": "./src/utilities/handlePreview.ts", + "types": "./src/utilities/handlePreview.ts", + "default": "./src/utilities/handlePreview.ts" + }, "./utilities/isCustomAdminView": { "import": "./src/utilities/isCustomAdminView.ts", "types": "./src/utilities/isCustomAdminView.ts", "default": "./src/utilities/isCustomAdminView.ts" }, + "./utilities/isEditing": { + "import": "./src/utilities/isEditing.ts", + "types": "./src/utilities/isEditing.ts", + "default": "./src/utilities/isEditing.ts" + }, "./utilities/isPublicAdminRoute": { "import": "./src/utilities/isPublicAdminRoute.ts", "types": "./src/utilities/isPublicAdminRoute.ts", diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts index 923ef77bc9e..15c542776b2 100644 --- a/packages/ui/src/exports/client/index.ts +++ b/packages/ui/src/exports/client/index.ts @@ -393,6 +393,13 @@ export { SelectMany } from '../../elements/SelectMany/index.js' export { DefaultListView } from '../../views/List/index.js' export { DefaultCollectionFolderView } from '../../views/CollectionFolder/index.js' export { DefaultBrowseByFolderView } from '../../views/BrowseByFolder/index.js' +export { LoginForm } from '../../views/Login/LoginForm/index.js' +export { ForgotPasswordForm } from '../../views/ForgotPassword/ForgotPasswordForm/index.js' +export { ResetPasswordForm } from '../../views/ResetPassword/ResetPasswordForm/index.js' +export { LogoutClient } from '../../views/Logout/LogoutClient.js' +export { CreateFirstUserClient } from '../../views/CreateFirstUser/index.client.js' +export { APIViewClient } from '../../views/API/index.client.js' +export { AccountClient } from '../../views/Account/index.client.js' export type { /** diff --git a/tanstack-app/app/routes/__root.tsx b/tanstack-app/app/routes/__root.tsx index 5b8e3ccba68..f7ac914777b 100644 --- a/tanstack-app/app/routes/__root.tsx +++ b/tanstack-app/app/routes/__root.tsx @@ -1,48 +1,25 @@ import type { ServerFunctionClient } from 'payload' import config from '@payload-config' -import { RootLayout } from '@payloadcms/tanstack-start' -import { dispatchServerFunction } from '@payloadcms/ui/utilities/handleServerFunctions' +import { RootLayout, handleServerFunctions } from '@payloadcms/tanstack-start' import { createRootRoute, HeadContent, Outlet, Scripts } from '@tanstack/react-router' import { createServerFn } from '@tanstack/react-start' -import { initReq } from '@payloadcms/tanstack-start/utilities/initReq' import React from 'react' -import { importMap } from '../importMap.js' - -// Server function: handles all Payload server function calls const handleServerFn = createServerFn({ method: 'POST' }) .inputValidator((data: unknown) => data as Parameters[0]) .handler(async ({ data: args }) => { - const { notFound, redirect } = await import('@tanstack/react-router') - const { cookies, locale, permissions, req } = await initReq({ + const { importMap } = await import('../importMap.js') + + return handleServerFunctions({ + args: (args as any)?.args, config, importMap, - key: 'RootLayout', - }) - return dispatchServerFunction({ - augmentedArgs: { - ...args, - cookies, - importMap, - locale, - // eslint-disable-next-line @typescript-eslint/only-throw-error - notFound: () => { - throw notFound() - }, - permissions, - // eslint-disable-next-line @typescript-eslint/only-throw-error - redirect: (url: string) => { - throw redirect({ to: url }) - }, - req, - }, - extraServerFunctions: (args as any)?.serverFunctions, name: (args as any)?.name, + serverFunctions: (args as any)?.serverFunctions, }) }) -// Thin wrapper matching Payload's ServerFunctionClient signature const serverFunction: ServerFunctionClient = (args) => handleServerFn({ data: args }) export const rootRoute = createRootRoute({ @@ -57,7 +34,9 @@ export const rootRoute = createRootRoute({ export const Route = rootRoute -function RootComponent() { +async function RootComponent() { + const { importMap } = await import('../importMap.js') + return ( diff --git a/tanstack-app/app/routes/admin.$.tsx b/tanstack-app/app/routes/admin.$.tsx index db06c71790f..9453592a3da 100644 --- a/tanstack-app/app/routes/admin.$.tsx +++ b/tanstack-app/app/routes/admin.$.tsx @@ -1,59 +1,42 @@ import config from '@payload-config' -import { RootPage } from '@payloadcms/tanstack-start/views' -import { createFileRoute, redirect } from '@tanstack/react-router' -import { initReq } from '@payloadcms/tanstack-start/utilities/initReq' +import { TanStackAdminPage } from '@payloadcms/tanstack-start/views' +import { getPageState } from '@payloadcms/tanstack-start/views/getPageState' +import { createFileRoute, notFound, redirect } from '@tanstack/react-router' import React from 'react' -import { importMap } from '../importMap.js' +import { searchParamsToRecord } from '../utilities/searchParams.js' export const Route = createFileRoute('/admin/$')({ - loader: async ({ params }) => { + loader: async ({ location, params }) => { + const { importMap } = await import('../importMap.js') const segments = params._splat?.split('/').filter(Boolean) ?? [] - const resolvedConfig = await config - const { - routes: { admin: adminRoute }, - } = resolvedConfig - // Handle known redirect patterns in the loader (TanStack redirect works correctly in loaders) - if (segments.length === 1 && (segments[0] === 'collections' || segments[0] === 'globals')) { - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw redirect({ to: adminRoute }) - } - - // Auth check in loader so TanStack Router handles redirects correctly - const { isPublicAdminRoute } = await import('@payloadcms/ui/utilities/isPublicAdminRoute') - const { isCustomAdminView } = await import('@payloadcms/ui/utilities/isCustomAdminView') - const currentRoute = `${adminRoute}/${segments.join('/')}` + try { + return await getPageState({ + config, + importMap, + searchParams: searchParamsToRecord(location.search), + segments, + }) + } catch (error) { + if (error instanceof Error && error.message.startsWith('REDIRECT:')) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw redirect({ to: error.message.replace('REDIRECT:', '') }) + } - if ( - !isPublicAdminRoute({ adminRoute, config: resolvedConfig, route: currentRoute }) && - !isCustomAdminView({ adminRoute, config: resolvedConfig, route: currentRoute }) - ) { - const { permissions, req } = await initReq({ config, importMap, key: 'adminLoader' }) - if (!permissions.canAccessAdmin) { - const { handleAuthRedirect } = await import('@payloadcms/ui/utilities/handleAuthRedirect') + if (error instanceof Error && error.message === 'not-found') { // eslint-disable-next-line @typescript-eslint/only-throw-error - throw redirect({ - to: handleAuthRedirect({ config: resolvedConfig, route: currentRoute, user: req.user }), - }) + throw notFound() } - } - return { segments } + throw error + } }, component: AdminPage, }) function AdminPage() { - const { segments } = Route.useLoaderData() - const search = Route.useSearch() + const pageState = Route.useLoaderData() - return ( - } - /> - ) + return } diff --git a/tanstack-app/app/routes/admin.index.tsx b/tanstack-app/app/routes/admin.index.tsx index a4ebf74bd9f..abd64f4b3b1 100644 --- a/tanstack-app/app/routes/admin.index.tsx +++ b/tanstack-app/app/routes/admin.index.tsx @@ -1,46 +1,40 @@ import config from '@payload-config' -import { RootPage } from '@payloadcms/tanstack-start/views' +import { TanStackAdminPage } from '@payloadcms/tanstack-start/views' +import { getPageState } from '@payloadcms/tanstack-start/views/getPageState' import { createFileRoute, redirect } from '@tanstack/react-router' -import { initReq } from '@payloadcms/tanstack-start/utilities/initReq' import React from 'react' -import { importMap } from '../importMap.js' +import { searchParamsToRecord } from '../utilities/searchParams.js' export const Route = createFileRoute('/admin/')({ - loader: async () => { - const resolvedConfig = await config - const { - routes: { admin: adminRoute }, - } = resolvedConfig + loader: async ({ location }) => { + const { importMap } = await import('../importMap.js') - // Auth check in loader so TanStack Router handles redirects correctly - const { permissions, req } = await initReq({ config, importMap, key: 'adminIndexLoader' }) - - if (!permissions.canAccessAdmin) { - const { isPublicAdminRoute } = await import('@payloadcms/ui/utilities/isPublicAdminRoute') - if (!isPublicAdminRoute({ adminRoute, config: resolvedConfig, route: adminRoute })) { - const { handleAuthRedirect } = await import('@payloadcms/ui/utilities/handleAuthRedirect') + try { + return await getPageState({ + config, + importMap, + searchParams: searchParamsToRecord(location.search), + segments: [], + }) + } catch (error) { + if (error instanceof Error && error.message.startsWith('REDIRECT:')) { // eslint-disable-next-line @typescript-eslint/only-throw-error - throw redirect({ - to: handleAuthRedirect({ config: resolvedConfig, route: adminRoute, user: req.user }), - }) + throw redirect({ to: error.message.replace('REDIRECT:', '') }) } - } - return null + if (error instanceof Error && error.message === 'not-found') { + throw error + } + + throw error + } }, component: AdminIndexPage, }) function AdminIndexPage() { - const search = Route.useSearch() + const pageState = Route.useLoaderData() - return ( - } - /> - ) + return } diff --git a/tanstack-app/app/utilities/searchParams.ts b/tanstack-app/app/utilities/searchParams.ts new file mode 100644 index 00000000000..e9e7eddd339 --- /dev/null +++ b/tanstack-app/app/utilities/searchParams.ts @@ -0,0 +1,22 @@ +export function searchParamsToRecord(search: string): Record { + const params = new URLSearchParams(search) + const result: Record = {} + + for (const [key, value] of params.entries()) { + const existingValue = result[key] + + if (existingValue === undefined) { + result[key] = value + continue + } + + if (Array.isArray(existingValue)) { + existingValue.push(value) + continue + } + + result[key] = [existingValue, value] + } + + return result +} diff --git a/tanstack-app/server-only-stub.cjs b/tanstack-app/server-only-stub.cjs deleted file mode 100644 index cdd0c2513c5..00000000000 --- a/tanstack-app/server-only-stub.cjs +++ /dev/null @@ -1,37 +0,0 @@ -// Stub for server-only Node.js packages when loaded in the browser. -// Payload admin runs as RSC in Next.js; in TanStack Start these modules -// get pulled into the client bundle. This stub prevents hard crashes. -const noop = function () {} -noop.prototype = {} - -// Mongoose-style Types object -const Types = { - ObjectId: noop, - Decimal128: noop, - Buffer: noop, - Map: noop, - Mixed: noop, -} - -const stub = new Proxy(noop, { - get(_, prop) { - if (prop === '__esModule') return true - if (prop === 'default') return stub - if (prop === 'Types') return Types - if (prop === 'Schema') return noop - if (prop === 'model') return noop - if (prop === 'connect') return noop - if (prop === 'pino') return noop - if (prop === 'levels') return {} - if (prop === 'fileTypeFromFile') return undefined - if (prop === 'fileTypeFromBuffer') return undefined - if (prop === 'fileTypeFromStream') return undefined - // For anything else, return a no-op or empty object - return noop - }, - apply() { - return stub - }, -}) - -module.exports = stub diff --git a/tanstack-app/server-only-stub.js b/tanstack-app/server-only-stub.js deleted file mode 100644 index 29baa7e741e..00000000000 --- a/tanstack-app/server-only-stub.js +++ /dev/null @@ -1,56 +0,0 @@ -// Stub for server-only Node.js packages when loaded in the browser. -// Payload admin is RSC-based; in TanStack Start's isomorphic environment -// these server-only packages get pulled into the client bundle. -// This stub prevents hard crashes while preserving the module shape. - -const noop = () => {} - -// ─── file-type ──────────────────────────────────────────────────────────────── -export const fileTypeFromFile = undefined -export const fileTypeFromBuffer = undefined -export const fileTypeFromStream = undefined - -// ─── pino ───────────────────────────────────────────────────────────────────── -export const pino = noop -export const levels = {} -export const stdSerializers = {} - -// ─── mongoose ───────────────────────────────────────────────────────────────── -export const Types = { - ObjectId: noop, - Decimal128: noop, - Buffer: noop, - Map: noop, - Mixed: noop, -} -export const Schema = noop -export const model = noop -export const models = {} -export const connect = noop -export const connection = { on: noop, once: noop } -export const set = noop - -// ─── sharp ──────────────────────────────────────────────────────────────────── -// (sharp is default-export only — handled by the default export below) - -// ─── mongodb ────────────────────────────────────────────────────────────────── -export const MongoClient = noop -export const ObjectId = noop - -// ─── @aws-sdk/client-s3, etc. ───────────────────────────────────────────────── -export const S3Client = noop -export const GetObjectCommand = noop -export const PutObjectCommand = noop -export const DeleteObjectCommand = noop - -// ─── nodemailer ──────────────────────────────────────────────────────────────── -export const createTransport = noop - -// ─── Node.js util built-in (used for isDeepStrictEqual in getEntityPermissions) ── -export const isDeepStrictEqual = () => false -export const promisify = () => noop -export const inspect = () => '' -export const format = () => '' - -// ─── Default catch-all ──────────────────────────────────────────────────────── -export default noop diff --git a/tanstack-app/tsconfig.json b/tanstack-app/tsconfig.json index d25177e9dee..f117f06985d 100644 --- a/tanstack-app/tsconfig.json +++ b/tanstack-app/tsconfig.json @@ -10,5 +10,5 @@ "@payload-config": ["../test/_community/config.ts"] } }, - "include": ["app/**/*", "app.config.ts", "vite.config.ts", "server-only-stub.js"] + "include": ["app/**/*", "app.config.ts", "vite.config.ts"] } diff --git a/tanstack-app/vite.config.ts b/tanstack-app/vite.config.ts index e77d198a40a..666d268e4f1 100644 --- a/tanstack-app/vite.config.ts +++ b/tanstack-app/vite.config.ts @@ -1,6 +1,5 @@ import { tanstackStart } from '@tanstack/react-start/plugin/vite' import react from '@vitejs/plugin-react' -import { readFileSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { defineConfig, type Plugin } from 'vite' @@ -29,44 +28,6 @@ function tanstackStartCompatPlugin(): Plugin { } } -// Packages that are Node.js-only and must not be bundled for the browser. -// Payload admin is RSC-based; in TanStack Start (isomorphic by default) we need -// to stub these so client-side hydration doesn't break when these are in the -// module graph. -const SERVER_ONLY_PACKAGES = [ - 'sharp', - 'file-type', - 'mongoose', - 'mongodb', - '@aws-sdk', - 'nodemailer', - 'pino', - 'better-sqlite3', - // Node.js util built-in — used for isDeepStrictEqual in server-side Payload code - 'util', - 'node:util', -] - -function serverOnlyStubPlugin(): Plugin { - return { - name: 'server-only-stub', - enforce: 'pre', - load(id) { - if (id.startsWith('\0server-only-stub:')) { - return readFileSync(path.resolve(dirname, 'server-only-stub.js'), 'utf-8') - } - }, - resolveId(id, _importer, options) { - if ( - !options?.ssr && - SERVER_ONLY_PACKAGES.some((pkg) => id === pkg || id.startsWith(pkg + '/')) - ) { - return '\0server-only-stub:' + id - } - }, - } -} - export default defineConfig({ css: { preprocessorOptions: { @@ -83,7 +44,6 @@ export default defineConfig({ react(), tsConfigPaths({ projects: ['./tsconfig.json'] }), tanstackStartCompatPlugin(), - serverOnlyStubPlugin(), ], resolve: { alias: { diff --git a/test/_community/payload-types.ts b/test/_community/payload-types.ts index 944c99099f4..d4b81957515 100644 --- a/test/_community/payload-types.ts +++ b/test/_community/payload-types.ts @@ -96,6 +96,9 @@ export interface Config { menu: MenuSelect | MenuSelect; }; locale: null; + widgets: { + collections: CollectionsWidget; + }; user: User; jobs: { tasks: unknown; @@ -435,6 +438,16 @@ export interface MenuSelect { createdAt?: T; globalType?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "collections_widget". + */ +export interface CollectionsWidget { + data?: { + [k: string]: unknown; + }; + width: 'full'; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "auth". diff --git a/test/admin-adapter/tanstack-start.int.spec.ts b/test/admin-adapter/tanstack-start.int.spec.ts index f2a600e5d00..acbab98f7d6 100644 --- a/test/admin-adapter/tanstack-start.int.spec.ts +++ b/test/admin-adapter/tanstack-start.int.spec.ts @@ -27,4 +27,14 @@ describe('tanstackStartAdapter', () => { const { initReq } = await import('@payloadcms/tanstack-start') expect(typeof initReq).toBe('function') }) + + it('should export TanStackAdminPage from views', async () => { + const { TanStackAdminPage } = await import('@payloadcms/tanstack-start/views') + expect(typeof TanStackAdminPage).toBe('function') + }) + + it('should export getPageState from the views entrypoint', async () => { + const { getPageState } = await import('@payloadcms/tanstack-start/views/getPageState') + expect(typeof getPageState).toBe('function') + }) }) diff --git a/test/admin-root/payload-types.ts b/test/admin-root/payload-types.ts index 5823bcc2f9c..d68583808a1 100644 --- a/test/admin-root/payload-types.ts +++ b/test/admin-root/payload-types.ts @@ -94,6 +94,9 @@ export interface Config { menu: MenuSelect | MenuSelect; }; locale: null; + widgets: { + collections: CollectionsWidget; + }; user: User; jobs: { tasks: unknown; @@ -320,6 +323,16 @@ export interface MenuSelect { createdAt?: T; globalType?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "collections_widget". + */ +export interface CollectionsWidget { + data?: { + [k: string]: unknown; + }; + width: 'full'; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "auth". From 05fbe554516ea98a7afe0da053d704006df37cd3 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Thu, 2 Apr 2026 10:38:38 +0100 Subject: [PATCH 36/60] fix: keep tanstack sass suppression and dynamic import guard Co-authored-by: Codex --- packages/payload/src/utilities/dynamicImport.ts | 6 +++++- tanstack-app/vite.config.ts | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/payload/src/utilities/dynamicImport.ts b/packages/payload/src/utilities/dynamicImport.ts index 4235fbc8bc9..470c9a8f481 100644 --- a/packages/payload/src/utilities/dynamicImport.ts +++ b/packages/payload/src/utilities/dynamicImport.ts @@ -18,7 +18,11 @@ export async function dynamicImport(modulePathOrSpecifier: string): // Vitest runs tests in a VM context where eval'd dynamic imports fail with // ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING. Use direct import in test environment. if (process.env.VITEST) { - return await import(/* webpackIgnore: true */ importPath) + return await import( + /* webpackIgnore: true */ + /* @vite-ignore */ + importPath + ) } // Without the eval, the Next.js bundler will throw this error when encountering the import statement: diff --git a/tanstack-app/vite.config.ts b/tanstack-app/vite.config.ts index 666d268e4f1..15781290452 100644 --- a/tanstack-app/vite.config.ts +++ b/tanstack-app/vite.config.ts @@ -33,6 +33,7 @@ export default defineConfig({ preprocessorOptions: { scss: { loadPaths: [path.resolve(uiSrcDir, 'scss')], + silenceDeprecations: ['import'], }, }, }, From ec9988669c8a5ecee98ab9a2d5d5282aaa99d901 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Thu, 2 Apr 2026 10:39:23 +0100 Subject: [PATCH 37/60] docs: add tanstack server-client boundary rework plan Co-authored-by: Codex --- ...ack-start-server-client-boundary-rework.md | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 docs/plans/2026-04-02-tanstack-start-server-client-boundary-rework.md diff --git a/docs/plans/2026-04-02-tanstack-start-server-client-boundary-rework.md b/docs/plans/2026-04-02-tanstack-start-server-client-boundary-rework.md new file mode 100644 index 00000000000..acedf32af89 --- /dev/null +++ b/docs/plans/2026-04-02-tanstack-start-server-client-boundary-rework.md @@ -0,0 +1,182 @@ +# TanStack Start Server/Client Boundary Rework Plan + +## Goal + +Fix TanStack Start `admin-root` e2e by removing server-only code from the client route graph. + +This plan explicitly rejects browser-side module shims and mock Node builtins. The correct fix is: + +1. keep client bundles client-only +2. keep server logic behind TanStack server functions or server loaders +3. split mixed entrypoints so route-tree imports never pull server modules into the browser + +## Keep vs Revert + +### Keep + +- Keep the Sass warning suppression in [tanstack-app/vite.config.ts](/Users/orakhmatulin/work/payload/tanstack-app/vite.config.ts) (`silenceDeprecations: ['import']`) +- Keep the `@vite-ignore` update in [packages/payload/src/utilities/dynamicImport.ts](/Users/orakhmatulin/work/payload/packages/payload/src/utilities/dynamicImport.ts) +- Keep the route-level move away from static `@payload-config` imports as part of the rework direction + +### Revert + +- Revert all browser shim work in [tanstack-app/vite.config.ts](/Users/orakhmatulin/work/payload/tanstack-app/vite.config.ts) +- Do not add more client aliases for `node:*`, `react-dom/server`, or other server modules + +## Verified Problem + +The browser failures were not isolated package bugs. They were repeated proof that the client graph still imports server code. + +Observed breakages included: + +- `file-type` +- `sharp` +- `node:stream/web` +- `react-dom/server` +- `node:async_hooks` +- `util.isDeepStrictEqual` +- `ieee754` + +These were downstream symptoms of one root issue: TanStack route modules imported server-oriented entrypoints that are also pulled into the client by [tanstack-app/app/routeTree.gen.ts](/Users/orakhmatulin/work/payload/tanstack-app/app/routeTree.gen.ts). + +## Root Cause + +### 1. Route-tree modules are client-visible + +[tanstack-app/app/routeTree.gen.ts](/Users/orakhmatulin/work/payload/tanstack-app/app/routeTree.gen.ts) imports: + +- [tanstack-app/app/routes/\_\_root.tsx](/Users/orakhmatulin/work/payload/tanstack-app/app/routes/__root.tsx) +- [tanstack-app/app/routes/admin.index.tsx](/Users/orakhmatulin/work/payload/tanstack-app/app/routes/admin.index.tsx) +- [tanstack-app/app/routes/admin.$.tsx](/Users/orakhmatulin/work/payload/tanstack-app/app/routes/admin.$.tsx) + +Anything statically imported by those files is at risk of entering the client graph. + +### 2. `__root.tsx` imports a server-oriented layout entrypoint + +[tanstack-app/app/routes/\_\_root.tsx](/Users/orakhmatulin/work/payload/tanstack-app/app/routes/__root.tsx) currently depends on `RootLayout` behavior from `@payloadcms/tanstack-start`. + +[packages/tanstack-start/src/layouts/Root/index.tsx](/Users/orakhmatulin/work/payload/packages/tanstack-start/src/layouts/Root/index.tsx) imports and executes server-oriented concerns: + +- `initReq` +- nav preference lookup +- request/theme/cookie derivation +- client config generation +- locale filtering +- server action wiring + +That file is not safe as a static route-tree import. + +### 3. Package root exports are too mixed + +`@payloadcms/tanstack-start` currently exposes both client-safe and server-oriented entrypoints from the same package root. + +Using the package root from route files makes it too easy to drag server code into the client graph. + +## Rework Direction + +## Phase 1: Remove the Wrong Fix + +1. Remove the browser shim plugin from [tanstack-app/vite.config.ts](/Users/orakhmatulin/work/payload/tanstack-app/vite.config.ts) +2. Keep only the Sass suppression in that file +3. Re-run focused TanStack `admin-root` e2e to confirm the route graph still fails without shims + +## Phase 2: Split the Root Route into Client Shell + Server Data + +### Target state + +[tanstack-app/app/routes/\_\_root.tsx](/Users/orakhmatulin/work/payload/tanstack-app/app/routes/__root.tsx) should import only client-safe UI and TanStack router components at module scope. + +It should not statically import: + +- `RootLayout` +- `initReq` +- `@payload-config` +- server-only request helpers +- mixed package-root exports that transitively pull server modules + +### Required refactor + +Extract the server work currently embedded in `RootLayout` into a data function that returns only serializable props. + +Suggested split: + +1. Add a server-only helper in `packages/tanstack-start` + + - example shape: `getRootLayoutData({ config, importMap })` + - responsibility: run `initReq`, nav prefs lookup, client config generation, locale filtering, translation/theme derivation + - output: plain data needed by the client root shell + +2. Add a client-safe root-shell export in `packages/tanstack-start` + + - example shape: `TanStackRootShell` + - responsibility: compose `TanStackRouterProvider`, `TanStackRootProvider`, `ProgressBar`, and `Outlet` + - input: serialized root layout data, plus the TanStack server function clients + +3. Update `__root.tsx` + - route loader or `createServerFn` fetches root layout data + - component renders the client shell with loader data + - server functions stay behind `createServerFn` + +## Phase 3: Add Explicit Client-Safe Exports + +Avoid importing the mixed `@payloadcms/tanstack-start` package root from route files. + +Add explicit client-safe subpath exports for pieces that are allowed in the client route graph, for example: + +- router provider +- root provider shell +- client page shell + +Add explicit server-only subpaths for: + +- root layout data builder +- page-state loader helpers +- server function dispatcher + +## Phase 4: Keep Admin Routes on the Same Pattern + +[tanstack-app/app/routes/admin.index.tsx](/Users/orakhmatulin/work/payload/tanstack-app/app/routes/admin.index.tsx) and [tanstack-app/app/routes/admin.$.tsx](/Users/orakhmatulin/work/payload/tanstack-app/app/routes/admin.$.tsx) should keep page-state fetching behind a TanStack server function. + +Follow through on that pattern: + +1. keep page-state fetching behind a TanStack server function +2. keep route modules free of server-only static imports +3. keep [packages/tanstack-start/src/views/TanStackAdminPage.tsx](/Users/orakhmatulin/work/payload/packages/tanstack-start/src/views/TanStackAdminPage.tsx) client-only + +## Phase 5: Re-evaluate Payload-Side Workarounds + +Some Payload changes made during triage may still be valid independently of TanStack. + +Rule: + +- do not keep a Payload change just because it delayed the next client import crash +- keep only changes that are correct on their own merits after the route boundary is fixed + +## Phase 6: Resolve the API Route Warning Separately + +[tanstack-app/app/routes/api.$.ts](/Users/orakhmatulin/work/payload/tanstack-app/app/routes/api.$.ts) still produces the route-tree warning that it does not export `Route`. + +Treat that as a separate cleanup task: + +1. verify the correct TanStack Start API route file convention for this app version +2. move or rename the file to the framework-approved location/pattern +3. ensure the fix does not put the API handler back into the client route graph + +## Validation + +After the rework: + +1. run focused lint on the touched TanStack and Payload files +2. run `PAYLOAD_FRAMEWORK=tanstack-start pnpm run test:e2e admin-root --workers=1` +3. confirm the browser no longer crashes on server-only imports +4. only then debug real UI/test assertion failures + +## Suggested Implementation Order + +1. remove the browser shims +2. extract root layout server data from `RootLayout` +3. add client-safe TanStack root-shell exports/subpaths +4. update `__root.tsx` to use only client-safe static imports +5. rerun focused e2e +6. prune any Payload-side workaround that is no longer justified +7. fix the API route warning separately From d89e0c9a5d666fb3b88dd90aa43b249a8e18bba7 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Thu, 2 Apr 2026 11:02:09 +0100 Subject: [PATCH 38/60] docs: add ui framework-agnostic views plan Made-with: Cursor --- .../2026-04-02-ui-framework-agnostic-views.md | 328 ++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 docs/plans/2026-04-02-ui-framework-agnostic-views.md diff --git a/docs/plans/2026-04-02-ui-framework-agnostic-views.md b/docs/plans/2026-04-02-ui-framework-agnostic-views.md new file mode 100644 index 00000000000..e9d703d1f1e --- /dev/null +++ b/docs/plans/2026-04-02-ui-framework-agnostic-views.md @@ -0,0 +1,328 @@ +# UI Framework-Agnostic Views + +## Goal + +Move shared admin view logic toward a framework-agnostic layer in `@payloadcms/ui`, while moving Next-specific request, routing, and React Server Component behavior into `@payloadcms/next`. + +This document supersedes the recent TanStack-specific client-shell direction from: + +- `docs/plans/2026-04-02-tanstack-start-client-rendering.md` +- `docs/plans/2026-04-02-tanstack-start-client-rendering-plan.md` + +Those documents assumed the right answer was to add a TanStack-only page shell beside the existing Next path. The better direction is to make the shared admin view layer portable first, then let each framework package provide its own runtime wrapper. + +## Why The Previous Direction Is Wrong + +The recent TanStack work correctly identified that the current `ui` admin rendering path is not safely consumable by an isomorphic route graph, but it pushed the fix too far into `packages/tanstack-start`. + +That approach has two problems: + +1. It duplicates admin view and template behavior in a second package instead of fixing the boundary in the shared layer. +2. It preserves the assumption that Next keeps the "real" admin path and other frameworks must adapt around it with client-only substitutes. + +If `@payloadcms/ui` is truly the shared admin UI package, it should own the framework-agnostic view contracts and composition rules. `@payloadcms/next` should own the Next-specific runtime and RSC integration. + +## Verified Current Boundary Problem + +### Shared `ui` still owns framework-shaped rendering + +`packages/ui/src/views/Root/RenderRoot.tsx` is described as framework-agnostic, but it still owns: + +- route-to-view orchestration +- auth redirects +- template selection +- direct rendering of payload components through `RenderServerComponent` + +`packages/ui/src/views/Dashboard/index.tsx` is also not just a shared dashboard view. It currently mixes: + +- request-bound data fetches like `getGlobalData(req)` +- server props assembly +- `RenderServerComponent` invocation for dashboard component resolution + +### `RenderServerComponent` is the hidden framework seam + +`packages/ui/src/elements/RenderServerComponent/index.tsx` currently decides: + +- how `PayloadComponent` values are resolved from the import map +- whether a resolved component is treated as an RSC-like server component +- when `serverProps` are merged into the render call + +That is the main coupling point between: + +- shared view and template description in `ui` +- framework-specific server rendering behavior + +### `ui` still contains Next-specific assumptions + +The clearest example is `packages/ui/src/elements/Link/index.tsx`, which imports `next/link.js` directly. That means `ui` still embeds a runtime-specific navigation primitive instead of consuming an adapter-provided one. + +## Current Layering + +```mermaid +flowchart LR + nextEntry[NextRootPage] --> nextInit[NextInitReq] + nextEntry --> uiRoot[ui renderRootPage] + uiRoot --> uiRoute[getRouteData] + uiRoot --> uiTemplate[DefaultTemplate or MinimalTemplate] + uiRoot --> uiRenderer[RenderServerComponent] + uiTemplate --> uiRenderer + uiDashboard[DashboardView] --> uiRenderer +``` + +Today, `packages/next` is mostly a wrapper around a rendering model that still lives in `ui`. + +That is backward for a portable architecture. + +## Target Architecture + +```mermaid +flowchart LR + uiRoute[UiRouteDescriptorBuilder] --> uiView[UiViewDescriptor] + uiView --> uiTemplate[UiTemplateDescriptor] + uiView --> uiSlots[UiSlotDeclarations] + uiDashboard[UiDashboardDescriptor] --> uiView + + nextRuntime[NextRuntime] --> nextAdapter[NextViewAdapter] + nextAdapter --> nextRenderer[NextComponentRenderer] + nextAdapter --> uiRoute + nextRenderer --> uiTemplate + nextRenderer --> uiSlots + + tanstackRuntime[TanStackRuntime] --> tanstackAdapter[TanStackViewAdapter] + tanstackAdapter --> tanstackRenderer[TanStackComponentRenderer] + tanstackAdapter --> uiRoute + tanstackRenderer --> uiTemplate + tanstackRenderer --> uiSlots +``` + +In the target state: + +- `@payloadcms/ui` defines route resolution, shared view descriptors, template contracts, slot declarations, and dashboard behavior. +- `@payloadcms/next` owns Next request initialization, `redirect` / `notFound`, server actions, `next/navigation`, document shell wiring, and RSC-aware component rendering. +- `@payloadcms/tanstack-start` consumes the same shared descriptors through its own runtime adapter instead of recreating view/template logic. + +## Boundary Rules + +### `@payloadcms/ui` should own + +- route and view descriptors +- template and slot contracts +- shared dashboard view composition +- framework-neutral router interfaces and provider contracts +- framework-neutral component resolution interfaces + +### `@payloadcms/next` should own + +- `initReq` built on `next/headers` +- `redirect` / `notFound` +- server actions and cookie writes +- `next/navigation` router integration +- `next/link` integration +- RSC-aware payload component execution +- app-router document shell concerns + +### `@payloadcms/tanstack-start` should own + +- TanStack route loaders and server functions +- TanStack router integration +- TanStack-specific request/runtime adapters +- use of shared `ui` descriptors instead of placeholder templates or duplicate views + +## Renderer Boundary Proposal + +The current `RenderServerComponent` API is too concrete. It renders immediately and embeds framework behavior in the shared layer. + +The first architectural step should be to replace "render now" with "describe what must be rendered." + +### Proposed split + +1. Keep import-map and payload-component concepts in `ui`, but stop coupling them directly to a framework rendering decision. +2. Introduce a renderer boundary that can be implemented by `next` first and by TanStack later. + +### Proposed shared types + +```ts +type ViewComponentSpec = { + component?: PayloadComponent | React.ComponentType + fallback?: React.ComponentType + clientProps?: object + serverProps?: object +} + +type ViewSlotSpec = { + key: string + spec: ViewComponentSpec +} + +type FrameworkViewRenderer = { + renderComponent: (spec: ViewComponentSpec) => React.ReactNode + renderSlots: (specs: ViewSlotSpec[]) => Record +} +``` + +This is intentionally small. + +The important shift is: + +- `ui` produces `ViewComponentSpec` and `ViewSlotSpec` +- the framework adapter decides how to execute them + +### What changes in `ui` + +The following files should stop invoking rendering directly and instead produce shared declarations or consume an injected renderer: + +- `packages/ui/src/elements/RenderServerComponent/index.tsx` +- `packages/ui/src/views/Root/RenderRoot.tsx` +- `packages/ui/src/templates/Default/index.tsx` +- `packages/ui/src/views/Dashboard/index.tsx` + +### What changes in `next` + +`packages/next` should own the first renderer implementation for the new boundary. + +That implementation should preserve current behavior by continuing to: + +- resolve payload components through the import map +- detect RSC/server component behavior where needed +- merge server props only in the Next runtime + +## Root Refactor: Shared Descriptor + Framework Wrapper + +The current `renderRootPage` path should be split in two layers. + +### Shared `ui` layer + +The `ui` layer should build a root-level descriptor from: + +- `getRouteData` +- auth/public-route decisions +- visible entities +- client config +- template selection +- slot/component specifications + +The output should be a structured description of what the page is, not an already-rendered tree. + +### Framework wrapper layer + +The framework package should provide: + +- request initialization +- framework redirects and not-found behavior +- renderer implementation +- final page/tree assembly + +For Next, this means `packages/next/src/views/Root/index.tsx` becomes a true adapter entrypoint instead of a thin passthrough to a monolithic `ui` renderer. + +## Dashboard-First Migration Slice + +The first implementation slice should prove the architecture with `Root` and `Dashboard`. + +### Scope + +Included: + +- root descriptor boundary +- renderer abstraction +- dashboard view migration +- Next adapter ownership of RSC execution for this slice + +Deferred: + +- list rendering +- document rendering +- full template cleanup +- broader custom-view support + +### Why Dashboard First + +`Dashboard` is the best proving path because it exercises: + +- route selection from `getRouteData` +- template composition +- visible-entity and nav-group behavior +- configurable payload components +- server-derived data without immediately requiring full list/document form-state complexity + +### Dashboard target shape + +`packages/ui/src/views/Dashboard/index.tsx` should stop being the place where request-bound data fetching and direct server-component rendering are fused together. + +Instead, it should move toward: + +1. shared dashboard descriptor or shared dashboard composition in `ui` +2. renderer-driven execution of dashboard component specs in the framework package +3. Next preserving current behavior through its adapter implementation + +## Next-Specific Ownership To Expand + +The following `next` files already sit near the correct boundary and should continue moving in that direction: + +- `packages/next/src/layouts/Root/index.tsx` +- `packages/next/src/views/Root/index.tsx` +- `packages/next/src/adapter/RouterProvider.tsx` +- `packages/next/src/utilities/initReq.ts` + +In addition, navigation primitives currently embedded in `ui` should move behind adapter-owned implementations, starting with: + +- `packages/ui/src/elements/Link/index.tsx` + +The long-term goal is that `ui` consumes only framework-neutral router context, while `next` and TanStack each provide their own `Link`, pathname, search-params, and router operations through adapter wiring. + +## Deferred Follow-Up Phases + +### Phase 2: List + +Apply the same pattern to list rendering by separating: + +- shared list descriptor and slot declarations in `ui` +- framework rendering and request/runtime behavior in the adapter package + +Likely touchpoints: + +- `packages/ui/src/views/List/RenderListView.tsx` +- `packages/ui/src/views/List/renderListViewSlots.tsx` + +### Phase 3: Document + +Apply the same pattern to document rendering by separating: + +- shared document descriptor and composition rules in `ui` +- framework execution of document-level payload components and server data in the adapter package + +Likely touchpoints: + +- `packages/ui/src/views/Document/RenderDocument.tsx` +- `packages/ui/src/views/Document/renderDocumentSlots.tsx` + +### Phase 4: Templates And Custom Views + +After dashboard, list, and document boundaries are stable: + +- revisit `DefaultTemplate` and `MinimalTemplate` +- move more slot rendering to descriptor-driven contracts +- define the supported boundary for custom admin views across frameworks + +## Non-Goals For Phase 1 + +- Do not finish the full TanStack admin implementation in this slice. +- Do not redesign list or document rendering yet. +- Do not preserve the TanStack placeholder-shell direction just because it exists. +- Do not keep Next-only assumptions in `ui` when they can be moved behind an adapter boundary. + +## Success Criteria + +Phase 1 is successful when: + +1. the root/dashboard architecture can be described without requiring a TanStack-only template workaround +2. `ui` owns shared route/view/template contracts rather than direct framework rendering decisions +3. `next` clearly owns RSC and request/runtime execution for the migrated slice +4. the follow-up work for list and document is clearly staged behind the same boundary model + +## Recommended First Implementation Steps + +1. introduce a renderer abstraction beside the current `RenderServerComponent` +2. refactor root rendering so `ui` builds a shared page descriptor +3. implement the first Next renderer/adapter for that descriptor +4. migrate `Dashboard` to the new boundary +5. only then resume TanStack work against the shared `ui` contracts From c21111aac34f7ccfe4761e9bb9ca1583a7d0e66b Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Thu, 2 Apr 2026 11:20:14 +0100 Subject: [PATCH 39/60] refactor(ui): introduce shared view renderer boundary Decouple root, dashboard, and default template rendering from direct server-component resolution so framework adapters can provide their own renderer. This keeps Next on the current RSC path while establishing the shared UI boundary needed for TanStack support. --- .../src/views/Root/createNextViewRenderer.tsx | 20 +++++ packages/next/src/views/Root/index.tsx | 2 + packages/ui/package.json | 5 ++ .../src/providers/ViewRenderer/index.spec.ts | 40 +++++++++ .../ui/src/providers/ViewRenderer/index.tsx | 19 +++++ packages/ui/src/templates/Default/index.tsx | 19 ++--- .../ui/src/utilities/createViewRenderer.ts | 34 ++++++++ packages/ui/src/views/Dashboard/index.tsx | 13 ++- packages/ui/src/views/Root/RenderRoot.tsx | 85 +++++++++++-------- 9 files changed, 187 insertions(+), 50 deletions(-) create mode 100644 packages/next/src/views/Root/createNextViewRenderer.tsx create mode 100644 packages/ui/src/providers/ViewRenderer/index.spec.ts create mode 100644 packages/ui/src/providers/ViewRenderer/index.tsx create mode 100644 packages/ui/src/utilities/createViewRenderer.ts diff --git a/packages/next/src/views/Root/createNextViewRenderer.tsx b/packages/next/src/views/Root/createNextViewRenderer.tsx new file mode 100644 index 00000000000..9e9bf8f1bf3 --- /dev/null +++ b/packages/next/src/views/Root/createNextViewRenderer.tsx @@ -0,0 +1,20 @@ +import type { ViewComponentRenderer } from '@payloadcms/ui/utilities/createViewRenderer' +import type { ImportMap } from 'payload' + +import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' + +export const createNextViewRenderer = ({ + importMap, +}: { + importMap: ImportMap +}): ViewComponentRenderer => { + return ({ clientProps, Component, Fallback, key, serverProps }) => + RenderServerComponent({ + clientProps, + Component, + Fallback, + importMap, + key, + serverProps, + }) +} diff --git a/packages/next/src/views/Root/index.tsx b/packages/next/src/views/Root/index.tsx index 0b35d993b63..391932c3d77 100644 --- a/packages/next/src/views/Root/index.tsx +++ b/packages/next/src/views/Root/index.tsx @@ -8,6 +8,7 @@ import { formatAdminURL } from 'payload/shared' import * as qs from 'qs-esm' import { initReq } from '../../utilities/initReq.js' +import { createNextViewRenderer } from './createNextViewRenderer.js' export type GenerateViewMetadata = (args: { config: SanitizedConfig @@ -70,5 +71,6 @@ export const RootPage = async ({ redirect: (url) => redirect(url), searchParams, segments, + viewRenderer: createNextViewRenderer({ importMap }), }) } diff --git a/packages/ui/package.json b/packages/ui/package.json index 827bd8b3ebe..6b2d0aaf08a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -436,6 +436,11 @@ "types": "./src/utilities/getClientConfig.ts", "default": "./src/utilities/getClientConfig.ts" }, + "./utilities/createViewRenderer": { + "import": "./src/utilities/createViewRenderer.ts", + "types": "./src/utilities/createViewRenderer.ts", + "default": "./src/utilities/createViewRenderer.ts" + }, "./utilities/getPreferences": { "import": "./src/utilities/getPreferences.ts", "types": "./src/utilities/getPreferences.ts", diff --git a/packages/ui/src/providers/ViewRenderer/index.spec.ts b/packages/ui/src/providers/ViewRenderer/index.spec.ts new file mode 100644 index 00000000000..e2278de5e03 --- /dev/null +++ b/packages/ui/src/providers/ViewRenderer/index.spec.ts @@ -0,0 +1,40 @@ +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' + +import type { ViewComponentRenderer } from '../../utilities/createViewRenderer.js' + +import { ViewRendererProvider, useViewRenderer } from './index.js' + +const RendererProbe = () => { + const renderView = useViewRenderer() + + if (!renderView) { + return null + } + + return renderView({ + Fallback: () => React.createElement('span', null, 'fallback'), + }) +} + +describe('ViewRendererProvider', () => { + it('should expose the configured renderer to descendants', () => { + const renderer: ViewComponentRenderer = () => React.createElement('span', null, 'provided') + + const markup = renderToStaticMarkup( + React.createElement(ViewRendererProvider, { + children: React.createElement(RendererProbe), + renderer, + }), + ) + + expect(markup).toBe('provided') + }) + + it('should return null when no renderer is configured', () => { + const markup = renderToStaticMarkup(React.createElement(RendererProbe)) + + expect(markup).toBe('') + }) +}) diff --git a/packages/ui/src/providers/ViewRenderer/index.tsx b/packages/ui/src/providers/ViewRenderer/index.tsx new file mode 100644 index 00000000000..ad7e6a461ce --- /dev/null +++ b/packages/ui/src/providers/ViewRenderer/index.tsx @@ -0,0 +1,19 @@ +import React from 'react' + +import type { ViewComponentRenderer } from '../../utilities/createViewRenderer.js' + +const ViewRendererContext = React.createContext(null) + +export const ViewRendererProvider = ({ + children, + renderer, +}: { + children: React.ReactNode + renderer: ViewComponentRenderer +}) => { + return {children} +} + +export const useViewRenderer = (): null | ViewComponentRenderer => { + return React.use(ViewRendererContext) +} diff --git a/packages/ui/src/templates/Default/index.tsx b/packages/ui/src/templates/Default/index.tsx index 456e4f612d1..da73f3fcbce 100644 --- a/packages/ui/src/templates/Default/index.tsx +++ b/packages/ui/src/templates/Default/index.tsx @@ -13,10 +13,11 @@ import { AppHeader } from '../../elements/AppHeader/index.js' import { BulkUploadProvider } from '../../elements/BulkUpload/index.js' import { DefaultNav } from '../../elements/Nav/index.js' import { NavToggler } from '../../elements/Nav/NavToggler/index.js' -import { RenderServerComponent } from '../../elements/RenderServerComponent/index.js' import './index.scss' import { ActionsProvider } from '../../providers/Actions/index.js' import { EntityVisibilityProvider } from '../../providers/EntityVisibility/index.js' +import { useViewRenderer } from '../../providers/ViewRenderer/index.js' +import { createViewRenderer } from '../../utilities/createViewRenderer.js' import { NavHamburger } from './NavHamburger/index.js' import { Wrapper } from './Wrapper/index.js' @@ -64,6 +65,7 @@ export const DefaultTemplate: React.FC = ({ }, } = {}, } = payload.config || {} + const renderView = useViewRenderer() ?? createViewRenderer({ importMap: payload.importMap }) const clientProps = { documentSubViewType, @@ -96,19 +98,17 @@ export const DefaultTemplate: React.FC = ({ continue } const key = typeof action === 'object' ? action.path : action - Actions[key] = RenderServerComponent({ + Actions[key] = renderView({ clientProps, Component: action, - importMap: payload.importMap, serverProps, }) } - const NavComponent = RenderServerComponent({ + const NavComponent = renderView({ clientProps, Component: CustomNav, Fallback: DefaultNav, - importMap: payload.importMap, serverProps, }) @@ -116,10 +116,9 @@ export const DefaultTemplate: React.FC = ({ - {RenderServerComponent({ + {renderView({ clientProps, Component: CustomHeader, - importMap: payload.importMap, serverProps, })}
    @@ -136,18 +135,16 @@ export const DefaultTemplate: React.FC = ({ React.ReactNode + +export const createViewRenderer = ({ + importMap, +}: { + importMap: ImportMap +}): ViewComponentRenderer => { + return ({ clientProps, Component, Fallback, key, serverProps }) => + RenderServerComponent({ + clientProps, + Component, + Fallback, + importMap, + key, + serverProps, + }) +} diff --git a/packages/ui/src/views/Dashboard/index.tsx b/packages/ui/src/views/Dashboard/index.tsx index a54946479ba..ba5af5c41d1 100644 --- a/packages/ui/src/views/Dashboard/index.tsx +++ b/packages/ui/src/views/Dashboard/index.tsx @@ -2,16 +2,21 @@ import type { AdminViewServerProps } from 'payload' import React, { Fragment } from 'react' +import type { ViewComponentRenderer } from '../../utilities/createViewRenderer.js' import type { DashboardViewClientProps, DashboardViewServerPropsOnly } from './Default/index.js' import { HydrateAuthProvider } from '../../elements/HydrateAuthProvider/index.js' -import { RenderServerComponent } from '../../elements/RenderServerComponent/index.js' import { SetStepNav } from '../../elements/StepNav/SetStepNav.js' +import { createViewRenderer } from '../../utilities/createViewRenderer.js' import { getGlobalData } from '../../utilities/getGlobalData.js' import { getNavGroups } from '../../utilities/getNavGroups.js' import { DefaultDashboard } from './Default/index.js' -export async function DashboardView(props: AdminViewServerProps) { +export async function DashboardView( + props: { + viewRenderer?: ViewComponentRenderer + } & AdminViewServerProps, +) { const { locale, permissions, @@ -27,18 +32,18 @@ export async function DashboardView(props: AdminViewServerProps) { const globalData = await getGlobalData(req) const navGroups = getNavGroups(permissions, visibleEntities, config, i18n) + const renderView = props.viewRenderer ?? createViewRenderer({ importMap: payload.importMap }) return ( - {RenderServerComponent({ + {renderView({ clientProps: { locale, } satisfies DashboardViewClientProps, Component: config.admin?.components?.views?.dashboard?.Component, Fallback: DefaultDashboard, - importMap: payload.importMap, serverProps: { ...props, globalData, diff --git a/packages/ui/src/views/Root/RenderRoot.tsx b/packages/ui/src/views/Root/RenderRoot.tsx index b219fcb956e..160f7a3e63a 100644 --- a/packages/ui/src/views/Root/RenderRoot.tsx +++ b/packages/ui/src/views/Root/RenderRoot.tsx @@ -12,10 +12,13 @@ import { applyLocaleFiltering, formatAdminURL } from 'payload/shared' import * as qs from 'qs-esm' import React from 'react' -import { RenderServerComponent } from '../../elements/RenderServerComponent/index.js' +import type { ViewComponentRenderer } from '../../utilities/createViewRenderer.js' + import { PageConfigProvider } from '../../providers/Config/index.js' +import { ViewRendererProvider } from '../../providers/ViewRenderer/index.js' import { DefaultTemplate } from '../../templates/Default/index.js' import { MinimalTemplate } from '../../templates/Minimal/index.js' +import { createViewRenderer } from '../../utilities/createViewRenderer.js' import { getClientConfig } from '../../utilities/getClientConfig.js' import { getPreferences } from '../../utilities/getPreferences.js' import { getVisibleEntities } from '../../utilities/getVisibleEntities.js' @@ -34,6 +37,7 @@ export type RenderRootPageArgs = { redirect: (url: string) => never searchParams: { [key: string]: string | string[] } segments: string[] + viewRenderer?: ViewComponentRenderer } /** @@ -47,6 +51,7 @@ export const renderRootPage = async ({ redirect, searchParams, segments, + viewRenderer, }: RenderRootPageArgs): Promise => { const { cookies, @@ -248,8 +253,16 @@ export const renderRootPage = async ({ const folderID = routeParams.folderID const params = { segments } - - const RenderedView = RenderServerComponent({ + const resolvedViewRenderer = viewRenderer ?? createViewRenderer({ importMap }) + type RootViewServerProps = { + notFound: () => never + payload: typeof req.payload + redirect: (url: string) => never + searchParams: { [key: string]: string | string[] } + viewRenderer?: ViewComponentRenderer + } & AdminViewServerPropsOnly + + const RenderedView = resolvedViewRenderer({ clientProps: { browseByFolderSlugs, clientConfig, @@ -258,7 +271,6 @@ export const renderRootPage = async ({ } satisfies AdminViewClientProps, Component: DefaultView.payloadComponent, Fallback: DefaultView.Component, - importMap, serverProps: { clientConfig, collectionConfig, @@ -298,39 +310,42 @@ export const renderRootPage = async ({ redirect, searchParams, viewActions, - } as AdminViewServerPropsOnly, + viewRenderer: resolvedViewRenderer, + } satisfies RootViewServerProps, }) return ( - - {!templateType && {RenderedView}} - {templateType === 'minimal' && ( - {RenderedView} - )} - {templateType === 'default' && ( - - {RenderedView} - - )} - + + + {!templateType && {RenderedView}} + {templateType === 'minimal' && ( + {RenderedView} + )} + {templateType === 'default' && ( + + {RenderedView} + + )} + + ) } From 1a8841a385e70d032f91f984896881db3773414e Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Thu, 2 Apr 2026 11:29:37 +0100 Subject: [PATCH 40/60] refactor(ui): extend shared renderer through admin views Continue moving admin composition behind the injected renderer so shared UI no longer owns direct component execution in nav, dashboard, list, and document paths. This keeps the Next runtime behavior intact while reducing the work TanStack needs to replace duplicated admin rendering. --- packages/ui/src/elements/Nav/index.tsx | 96 ++++++------------- packages/ui/src/templates/Default/index.tsx | 6 +- .../ui/src/utilities/createViewRenderer.ts | 4 + .../ui/src/views/Dashboard/Default/index.tsx | 43 ++++----- packages/ui/src/views/Dashboard/index.tsx | 8 +- .../ui/src/views/Document/RenderDocument.tsx | 13 ++- .../views/Document/renderDocumentSlots.tsx | 53 +++++----- packages/ui/src/views/List/RenderListView.tsx | 15 ++- .../ui/src/views/List/renderListViewSlots.tsx | 25 +++-- packages/ui/src/views/Root/RenderRoot.tsx | 6 +- 10 files changed, 119 insertions(+), 150 deletions(-) diff --git a/packages/ui/src/elements/Nav/index.tsx b/packages/ui/src/elements/Nav/index.tsx index f98f44c9d73..ee3e6a3b442 100644 --- a/packages/ui/src/elements/Nav/index.tsx +++ b/packages/ui/src/elements/Nav/index.tsx @@ -2,11 +2,12 @@ import type { PayloadRequest, ServerProps } from 'payload' import React from 'react' +import type { WithViewRenderer } from '../../utilities/createViewRenderer.js' import type { EntityToGroup } from '../../utilities/groupNavItems.js' +import { createViewRenderer } from '../../utilities/createViewRenderer.js' import { EntityType, groupNavItems } from '../../utilities/groupNavItems.js' import { Logout } from '../Logout/index.js' -import { RenderServerComponent } from '../RenderServerComponent/index.js' import { NavHamburger } from './NavHamburger/index.js' import { NavWrapper } from './NavWrapper/index.js' import { SettingsMenuButton } from './SettingsMenuButton/index.js' @@ -19,7 +20,8 @@ import { DefaultNavClient } from './index.client.js' export type NavProps = { req?: PayloadRequest -} & ServerProps +} & ServerProps & + WithViewRenderer export const DefaultNav: React.FC = async (props) => { const { @@ -40,6 +42,8 @@ export const DefaultNav: React.FC = async (props) => { return null } + const renderView = props.viewRenderer ?? createViewRenderer({ importMap: payload.importMap }) + const { admin: { components: { afterNav, afterNavLinks, beforeNav, beforeNavLinks, logout, settingsMenu }, @@ -74,120 +78,76 @@ export const DefaultNav: React.FC = async (props) => { ) const navPreferences = await getNavPrefs(req) + const serverProps = { + i18n, + locale, + params, + payload, + permissions, + searchParams, + user, + viewRenderer: renderView, + } satisfies ServerProps & WithViewRenderer - const LogoutComponent = RenderServerComponent({ + const LogoutComponent = renderView({ clientProps: { documentSubViewType, viewType, }, Component: logout?.Button, Fallback: Logout, - importMap: payload.importMap, - serverProps: { - i18n, - locale, - params, - payload, - permissions, - searchParams, - user, - }, + serverProps, }) const RenderedSettingsMenu = settingsMenu && Array.isArray(settingsMenu) ? settingsMenu.map((item, index) => - RenderServerComponent({ + renderView({ clientProps: { documentSubViewType, viewType, }, Component: item, - importMap: payload.importMap, key: `settings-menu-item-${index}`, - serverProps: { - i18n, - locale, - params, - payload, - permissions, - searchParams, - user, - }, + serverProps, }), ) : [] - const RenderedBeforeNav = RenderServerComponent({ + const RenderedBeforeNav = renderView({ clientProps: { documentSubViewType, viewType, }, Component: beforeNav, - importMap: payload.importMap, - serverProps: { - i18n, - locale, - params, - payload, - permissions, - searchParams, - user, - }, + serverProps, }) - const RenderedBeforeNavLinks = RenderServerComponent({ + const RenderedBeforeNavLinks = renderView({ clientProps: { documentSubViewType, viewType, }, Component: beforeNavLinks, - importMap: payload.importMap, - serverProps: { - i18n, - locale, - params, - payload, - permissions, - searchParams, - user, - }, + serverProps, }) - const RenderedAfterNavLinks = RenderServerComponent({ + const RenderedAfterNavLinks = renderView({ clientProps: { documentSubViewType, viewType, }, Component: afterNavLinks, - importMap: payload.importMap, - serverProps: { - i18n, - locale, - params, - payload, - permissions, - searchParams, - user, - }, + serverProps, }) - const RenderedAfterNav = RenderServerComponent({ + const RenderedAfterNav = renderView({ clientProps: { documentSubViewType, viewType, }, Component: afterNav, - importMap: payload.importMap, - serverProps: { - i18n, - locale, - params, - payload, - permissions, - searchParams, - user, - }, + serverProps, }) return ( diff --git a/packages/ui/src/templates/Default/index.tsx b/packages/ui/src/templates/Default/index.tsx index da73f3fcbce..bb0cb0bf6a6 100644 --- a/packages/ui/src/templates/Default/index.tsx +++ b/packages/ui/src/templates/Default/index.tsx @@ -9,6 +9,8 @@ import type { import React from 'react' +import type { WithViewRenderer } from '../../utilities/createViewRenderer.js' + import { AppHeader } from '../../elements/AppHeader/index.js' import { BulkUploadProvider } from '../../elements/BulkUpload/index.js' import { DefaultNav } from '../../elements/Nav/index.js' @@ -78,7 +80,8 @@ export const DefaultTemplate: React.FC = ({ docID: number | string globalSlug: string req: PayloadRequest - } & ServerProps = { + } & ServerProps & + WithViewRenderer = { collectionSlug, docID, globalSlug, @@ -90,6 +93,7 @@ export const DefaultTemplate: React.FC = ({ req, searchParams, user, + viewRenderer: renderView, } const Actions: Record = {} diff --git a/packages/ui/src/utilities/createViewRenderer.ts b/packages/ui/src/utilities/createViewRenderer.ts index cebd11b5c88..c5e0c8a1f86 100644 --- a/packages/ui/src/utilities/createViewRenderer.ts +++ b/packages/ui/src/utilities/createViewRenderer.ts @@ -17,6 +17,10 @@ export type ViewComponentRendererArgs = { export type ViewComponentRenderer = (args: ViewComponentRendererArgs) => React.ReactNode +export type WithViewRenderer = { + readonly viewRenderer?: ViewComponentRenderer +} + export const createViewRenderer = ({ importMap, }: { diff --git a/packages/ui/src/views/Dashboard/Default/index.tsx b/packages/ui/src/views/Dashboard/Default/index.tsx index 5b12a408c0c..04e6697d708 100644 --- a/packages/ui/src/views/Dashboard/Default/index.tsx +++ b/packages/ui/src/views/Dashboard/Default/index.tsx @@ -2,10 +2,11 @@ import type { AdminViewServerPropsOnly, ClientUser, Locale, ServerProps } from ' import React from 'react' +import type { WithViewRenderer } from '../../../utilities/createViewRenderer.js' import type { groupNavItems } from '../../../utilities/groupNavItems.js' import { Gutter } from '../../../elements/Gutter/index.js' -import { RenderServerComponent } from '../../../elements/RenderServerComponent/index.js' +import { createViewRenderer } from '../../../utilities/createViewRenderer.js' import { ModularDashboard } from './ModularDashboard/index.js' const baseClass = 'dashboard' @@ -36,44 +37,38 @@ export type DashboardViewServerPropsOnly = { */ Link?: React.ComponentType navGroups?: ReturnType -} & AdminViewServerPropsOnly +} & AdminViewServerPropsOnly & + WithViewRenderer export type DashboardViewServerProps = DashboardViewClientProps & DashboardViewServerPropsOnly export function DefaultDashboard(props: DashboardViewServerProps) { const { i18n, locale, params, payload, permissions, searchParams, user } = props const { afterDashboard, beforeDashboard } = payload.config.admin.components + const renderView = props.viewRenderer ?? createViewRenderer({ importMap: payload.importMap }) + const serverProps = { + i18n, + locale, + params, + payload, + permissions, + searchParams, + user, + viewRenderer: renderView, + } satisfies ServerProps & WithViewRenderer return ( {beforeDashboard && - RenderServerComponent({ + renderView({ Component: beforeDashboard, - importMap: payload.importMap, - serverProps: { - i18n, - locale, - params, - payload, - permissions, - searchParams, - user, - } satisfies ServerProps, + serverProps, })} {afterDashboard && - RenderServerComponent({ + renderView({ Component: afterDashboard, - importMap: payload.importMap, - serverProps: { - i18n, - locale, - params, - payload, - permissions, - searchParams, - user, - } satisfies ServerProps, + serverProps, })} ) diff --git a/packages/ui/src/views/Dashboard/index.tsx b/packages/ui/src/views/Dashboard/index.tsx index ba5af5c41d1..6a3de0faf22 100644 --- a/packages/ui/src/views/Dashboard/index.tsx +++ b/packages/ui/src/views/Dashboard/index.tsx @@ -2,7 +2,7 @@ import type { AdminViewServerProps } from 'payload' import React, { Fragment } from 'react' -import type { ViewComponentRenderer } from '../../utilities/createViewRenderer.js' +import type { WithViewRenderer } from '../../utilities/createViewRenderer.js' import type { DashboardViewClientProps, DashboardViewServerPropsOnly } from './Default/index.js' import { HydrateAuthProvider } from '../../elements/HydrateAuthProvider/index.js' @@ -12,11 +12,7 @@ import { getGlobalData } from '../../utilities/getGlobalData.js' import { getNavGroups } from '../../utilities/getNavGroups.js' import { DefaultDashboard } from './Default/index.js' -export async function DashboardView( - props: { - viewRenderer?: ViewComponentRenderer - } & AdminViewServerProps, -) { +export async function DashboardView(props: AdminViewServerProps & WithViewRenderer) { const { locale, permissions, diff --git a/packages/ui/src/views/Document/RenderDocument.tsx b/packages/ui/src/views/Document/RenderDocument.tsx index 524e1298e96..6d3bd214dbc 100644 --- a/packages/ui/src/views/Document/RenderDocument.tsx +++ b/packages/ui/src/views/Document/RenderDocument.tsx @@ -14,13 +14,15 @@ import { isolateObjectProperty } from 'payload' import { formatAdminURL, hasAutosaveEnabled, hasDraftsEnabled } from 'payload/shared' import React from 'react' +import type { WithViewRenderer } from '../../utilities/createViewRenderer.js' + import { DocumentHeader } from '../../elements/DocumentHeader/index.js' import { HydrateAuthProvider } from '../../elements/HydrateAuthProvider/index.js' -import { RenderServerComponent } from '../../elements/RenderServerComponent/index.js' import { DocumentInfoProvider } from '../../providers/DocumentInfo/index.js' import { EditDepthProvider } from '../../providers/EditDepth/index.js' import { LivePreviewProvider } from '../../providers/LivePreview/index.js' import { buildFormState } from '../../utilities/buildFormState.js' +import { createViewRenderer } from '../../utilities/createViewRenderer.js' import { getPreferences } from '../../utilities/getPreferences.js' import { handleLivePreview } from '../../utilities/handleLivePreview.js' import { handlePreview } from '../../utilities/handlePreview.js' @@ -62,6 +64,7 @@ export const renderDocument = async ({ redirectAfterRestore, searchParams, versions, + viewRenderer, viewType, }: { drawerSlug?: string @@ -73,7 +76,8 @@ export const renderDocument = async ({ readonly redirectAfterDuplicate?: boolean readonly redirectAfterRestore?: boolean versions?: RenderDocumentVersionsProperties -} & AdminViewServerProps): Promise<{ +} & AdminViewServerProps & + WithViewRenderer): Promise<{ data: Data Document: React.ReactNode }> => { @@ -97,6 +101,7 @@ export const renderDocument = async ({ }, visibleEntities, } = initPageResult + const renderView = viewRenderer ?? createViewRenderer({ importMap }) const segments = Array.isArray(params?.segments) ? params.segments : [] const collectionSlug = collectionConfig?.slug || undefined @@ -361,6 +366,7 @@ export const renderDocument = async ({ locale, permissions, req, + viewRenderer: renderView, }) // Extract Description from documentSlots to pass to DocumentHeader @@ -444,10 +450,9 @@ export const renderDocument = async ({ )} - {RenderServerComponent({ + {renderView({ clientProps, Component: View, - importMap, serverProps: documentViewServerProps, })} diff --git a/packages/ui/src/views/Document/renderDocumentSlots.tsx b/packages/ui/src/views/Document/renderDocumentSlots.tsx index 9bfdb904d0c..91fe6d941fe 100644 --- a/packages/ui/src/views/Document/renderDocumentSlots.tsx +++ b/packages/ui/src/views/Document/renderDocumentSlots.tsx @@ -21,8 +21,10 @@ import type { import { hasDraftsEnabled } from 'payload/shared' -import { RenderServerComponent } from '../../elements/RenderServerComponent/index.js' +import type { WithViewRenderer } from '../../utilities/createViewRenderer.js' + import { ViewDescription } from '../../elements/ViewDescription/index.js' +import { createViewRenderer } from '../../utilities/createViewRenderer.js' import { getDocumentPermissions } from './getDocumentPermissions.js' export const renderDocumentSlots: (args: { @@ -33,10 +35,21 @@ export const renderDocumentSlots: (args: { locale: Locale permissions: SanitizedPermissions req: PayloadRequest + viewRenderer?: WithViewRenderer['viewRenderer'] }) => DocumentSlots = (args) => { - const { id, collectionConfig, globalConfig, hasSavePermission, locale, permissions, req } = args + const { + id, + collectionConfig, + globalConfig, + hasSavePermission, + locale, + permissions, + req, + viewRenderer, + } = args const components: DocumentSlots = {} as DocumentSlots + const renderView = viewRenderer ?? createViewRenderer({ importMap: req.payload.importMap }) const unsavedDraftWithValidations = undefined @@ -57,9 +70,8 @@ export const renderDocumentSlots: (args: { globalConfig?.admin?.components?.elements?.beforeDocumentControls if (BeforeDocumentControls) { - components.BeforeDocumentControls = RenderServerComponent({ + components.BeforeDocumentControls = renderView({ Component: BeforeDocumentControls, - importMap: req.payload.importMap, serverProps: serverProps satisfies BeforeDocumentControlsServerPropsOnly, }) } @@ -67,9 +79,8 @@ export const renderDocumentSlots: (args: { const EditMenuItems = collectionConfig?.admin?.components?.edit?.editMenuItems if (EditMenuItems) { - components.EditMenuItems = RenderServerComponent({ + components.EditMenuItems = renderView({ Component: EditMenuItems, - importMap: req.payload.importMap, serverProps: serverProps satisfies EditMenuItemsServerPropsOnly, }) } @@ -79,9 +90,8 @@ export const renderDocumentSlots: (args: { globalConfig?.admin?.components?.elements?.PreviewButton if (isPreviewEnabled && CustomPreviewButton) { - components.PreviewButton = RenderServerComponent({ + components.PreviewButton = renderView({ Component: CustomPreviewButton, - importMap: req.payload.importMap, serverProps: serverProps satisfies PreviewButtonServerPropsOnly, }) } @@ -91,9 +101,8 @@ export const renderDocumentSlots: (args: { globalConfig?.admin?.components?.views?.edit?.livePreview if (LivePreview?.Component) { - components.LivePreview = RenderServerComponent({ + components.LivePreview = renderView({ Component: LivePreview.Component, - importMap: req.payload.importMap, serverProps, }) } @@ -113,14 +122,13 @@ export const renderDocumentSlots: (args: { const hasDescription = CustomDescription || staticDescription if (hasDescription) { - components.Description = RenderServerComponent({ + components.Description = renderView({ clientProps: { collectionSlug: collectionConfig?.slug, description: staticDescription, } satisfies ViewDescriptionClientProps, Component: CustomDescription, Fallback: ViewDescription, - importMap: req.payload.importMap, serverProps: serverProps satisfies ViewDescriptionServerPropsOnly, }) } @@ -131,9 +139,8 @@ export const renderDocumentSlots: (args: { globalConfig?.admin?.components?.elements?.Status if (CustomStatus) { - components.Status = RenderServerComponent({ + components.Status = renderView({ Component: CustomStatus, - importMap: req.payload.importMap, serverProps, }) } @@ -146,9 +153,8 @@ export const renderDocumentSlots: (args: { globalConfig?.admin?.components?.elements?.PublishButton if (CustomPublishButton) { - components.PublishButton = RenderServerComponent({ + components.PublishButton = renderView({ Component: CustomPublishButton, - importMap: req.payload.importMap, serverProps: serverProps satisfies PublishButtonServerPropsOnly, }) } @@ -158,9 +164,8 @@ export const renderDocumentSlots: (args: { globalConfig?.admin?.components?.elements?.UnpublishButton if (CustomUnpublishButton) { - components.UnpublishButton = RenderServerComponent({ + components.UnpublishButton = renderView({ Component: CustomUnpublishButton, - importMap: req.payload.importMap, serverProps: serverProps satisfies UnpublishButtonServerPropsOnly, }) } @@ -172,9 +177,8 @@ export const renderDocumentSlots: (args: { const draftsEnabled = hasDraftsEnabled(collectionConfig || globalConfig) if ((draftsEnabled || unsavedDraftWithValidations) && CustomSaveDraftButton) { - components.SaveDraftButton = RenderServerComponent({ + components.SaveDraftButton = renderView({ Component: CustomSaveDraftButton, - importMap: req.payload.importMap, serverProps: serverProps satisfies SaveDraftButtonServerPropsOnly, }) } @@ -184,9 +188,8 @@ export const renderDocumentSlots: (args: { globalConfig?.admin?.components?.elements?.SaveButton if (CustomSaveButton) { - components.SaveButton = RenderServerComponent({ + components.SaveButton = renderView({ Component: CustomSaveButton, - importMap: req.payload.importMap, serverProps: serverProps satisfies SaveButtonServerPropsOnly, }) } @@ -194,17 +197,15 @@ export const renderDocumentSlots: (args: { } if (collectionConfig?.upload && collectionConfig?.admin?.components?.edit?.Upload) { - components.Upload = RenderServerComponent({ + components.Upload = renderView({ Component: collectionConfig.admin.components.edit.Upload, - importMap: req.payload.importMap, serverProps, }) } if (collectionConfig?.upload && collectionConfig.upload.admin?.components?.controls) { - components.UploadControls = RenderServerComponent({ + components.UploadControls = renderView({ Component: collectionConfig.upload.admin.components.controls, - importMap: req.payload.importMap, serverProps, }) } diff --git a/packages/ui/src/views/List/RenderListView.tsx b/packages/ui/src/views/List/RenderListView.tsx index 434118b0f3c..c1f34c656fd 100644 --- a/packages/ui/src/views/List/RenderListView.tsx +++ b/packages/ui/src/views/List/RenderListView.tsx @@ -23,9 +23,11 @@ import { } from 'payload/shared' import React, { Fragment } from 'react' +import type { WithViewRenderer } from '../../utilities/createViewRenderer.js' + import { HydrateAuthProvider } from '../../elements/HydrateAuthProvider/index.js' -import { RenderServerComponent } from '../../elements/RenderServerComponent/index.js' import { ListQueryProvider } from '../../providers/ListQuery/index.js' +import { createViewRenderer } from '../../utilities/createViewRenderer.js' import { getColumns } from '../../utilities/getColumns.js' import { renderFilters, renderTable } from '../../utilities/renderTable.js' import { upsertPreferences } from '../../utilities/upsertPreferences.js' @@ -48,7 +50,7 @@ export type RenderListViewArgs = { ComponentOverride?: | PayloadComponent | React.ComponentType - customCellProps?: Record + customCellProps?: Record disableBulkDelete?: boolean disableBulkEdit?: boolean disableQueryPresets?: boolean @@ -65,7 +67,8 @@ export type RenderListViewArgs = { * @experimental This prop is subject to change in future releases. */ trash?: boolean -} & AdminViewServerProps +} & AdminViewServerProps & + WithViewRenderer /** * This function is responsible for rendering @@ -95,6 +98,7 @@ export const renderListView = async ( query: queryFromArgs, searchParams, trash, + viewRenderer, viewType, } = args @@ -116,6 +120,7 @@ export const renderListView = async ( const { routes: { admin: adminRoute }, } = config + const renderView = viewRenderer ?? createViewRenderer({ importMap: payload.importMap }) if ( !collectionConfig || @@ -396,6 +401,7 @@ export const renderListView = async ( notFoundDocId, payload, serverProps, + viewRenderer: renderView, }) const isInDrawer = Boolean(drawerSlug) @@ -415,7 +421,7 @@ export const renderListView = async ( orderableFieldName={collectionConfig.orderable === true ? '_order' : undefined} query={query} > - {RenderServerComponent({ + {renderView({ clientProps: { ...listViewSlots, collectionSlug, @@ -439,7 +445,6 @@ export const renderListView = async ( Component: ComponentOverride ?? collectionConfig?.admin?.components?.views?.list?.Component, Fallback: DefaultListView, - importMap: payload.importMap, serverProps, })} diff --git a/packages/ui/src/views/List/renderListViewSlots.tsx b/packages/ui/src/views/List/renderListViewSlots.tsx index b2ae563d9b4..f47bf70c2fc 100644 --- a/packages/ui/src/views/List/renderListViewSlots.tsx +++ b/packages/ui/src/views/List/renderListViewSlots.tsx @@ -18,8 +18,10 @@ import type { import React from 'react' +import type { WithViewRenderer } from '../../utilities/createViewRenderer.js' + import { Banner } from '../../elements/Banner/index.js' -import { RenderServerComponent } from '../../elements/RenderServerComponent/index.js' +import { createViewRenderer } from '../../utilities/createViewRenderer.js' type Args = { clientProps: ListViewSlotSharedClientProps @@ -28,6 +30,7 @@ type Args = { notFoundDocId?: null | string payload: Payload serverProps: ListViewServerPropsOnly + viewRenderer?: WithViewRenderer['viewRenderer'] } export const renderListViewSlots = ({ @@ -37,14 +40,15 @@ export const renderListViewSlots = ({ notFoundDocId, payload, serverProps, + viewRenderer, }: Args): ListViewSlots => { const result: ListViewSlots = {} as ListViewSlots + const renderView = viewRenderer ?? createViewRenderer({ importMap: payload.importMap }) if (collectionConfig.admin.components?.afterList) { - result.AfterList = RenderServerComponent({ + result.AfterList = renderView({ clientProps: clientProps satisfies AfterListClientProps, Component: collectionConfig.admin.components.afterList, - importMap: payload.importMap, serverProps: serverProps satisfies AfterListTableServerPropsOnly, }) } @@ -53,39 +57,35 @@ export const renderListViewSlots = ({ if (Array.isArray(listMenuItems)) { result.listMenuItems = [ - RenderServerComponent({ + renderView({ clientProps, Component: listMenuItems, - importMap: payload.importMap, serverProps, }), ] } if (collectionConfig.admin.components?.afterListTable) { - result.AfterListTable = RenderServerComponent({ + result.AfterListTable = renderView({ clientProps: clientProps satisfies AfterListTableClientProps, Component: collectionConfig.admin.components.afterListTable, - importMap: payload.importMap, serverProps: serverProps satisfies AfterListTableServerPropsOnly, }) } if (collectionConfig.admin.components?.beforeList) { - result.BeforeList = RenderServerComponent({ + result.BeforeList = renderView({ clientProps: clientProps satisfies BeforeListClientProps, Component: collectionConfig.admin.components.beforeList, - importMap: payload.importMap, serverProps: serverProps satisfies BeforeListServerPropsOnly, }) } // Handle beforeListTable with optional banner const existingBeforeListTable = collectionConfig.admin.components?.beforeListTable - ? RenderServerComponent({ + ? renderView({ clientProps: clientProps satisfies BeforeListTableClientProps, Component: collectionConfig.admin.components.beforeListTable, - importMap: payload.importMap, serverProps: serverProps satisfies BeforeListTableServerPropsOnly, }) : null @@ -108,13 +108,12 @@ export const renderListViewSlots = ({ } if (collectionConfig.admin.components?.Description) { - result.Description = RenderServerComponent({ + result.Description = renderView({ clientProps: { collectionSlug: collectionConfig.slug, description, } satisfies ViewDescriptionClientProps, Component: collectionConfig.admin.components.Description, - importMap: payload.importMap, serverProps: serverProps satisfies ViewDescriptionServerPropsOnly, }) } diff --git a/packages/ui/src/views/Root/RenderRoot.tsx b/packages/ui/src/views/Root/RenderRoot.tsx index 160f7a3e63a..5f05311c407 100644 --- a/packages/ui/src/views/Root/RenderRoot.tsx +++ b/packages/ui/src/views/Root/RenderRoot.tsx @@ -12,7 +12,7 @@ import { applyLocaleFiltering, formatAdminURL } from 'payload/shared' import * as qs from 'qs-esm' import React from 'react' -import type { ViewComponentRenderer } from '../../utilities/createViewRenderer.js' +import type { ViewComponentRenderer, WithViewRenderer } from '../../utilities/createViewRenderer.js' import { PageConfigProvider } from '../../providers/Config/index.js' import { ViewRendererProvider } from '../../providers/ViewRenderer/index.js' @@ -259,8 +259,8 @@ export const renderRootPage = async ({ payload: typeof req.payload redirect: (url: string) => never searchParams: { [key: string]: string | string[] } - viewRenderer?: ViewComponentRenderer - } & AdminViewServerPropsOnly + } & AdminViewServerPropsOnly & + WithViewRenderer const RenderedView = resolvedViewRenderer({ clientProps: { From fc9cde85e3c85f938fc8c3bf75c1fdb812db85fd Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Thu, 2 Apr 2026 11:41:14 +0100 Subject: [PATCH 41/60] docs: extend ui framework-agnostic views plan Clarify that the next TanStack step is to collapse `TanStackAdminPage` onto shared template and view contracts instead of extending its custom wrappers. --- .../2026-04-02-ui-framework-agnostic-views.md | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-04-02-ui-framework-agnostic-views.md b/docs/plans/2026-04-02-ui-framework-agnostic-views.md index e9d703d1f1e..9b6029c4192 100644 --- a/docs/plans/2026-04-02-ui-framework-agnostic-views.md +++ b/docs/plans/2026-04-02-ui-framework-agnostic-views.md @@ -269,6 +269,66 @@ In addition, navigation primitives currently embedded in `ui` should move behind The long-term goal is that `ui` consumes only framework-neutral router context, while `next` and TanStack each provide their own `Link`, pathname, search-params, and router operations through adapter wiring. +## Immediate Follow-Through: Collapse `TanStackAdminPage` + +Once the root/dashboard boundary exists, the next TanStack step should not be to keep extending `packages/tanstack-start/src/views/TanStackAdminPage.tsx` as a parallel admin implementation. + +That file is now the main duplication hotspot. It already re-implements: + +- default template composition through `TanStackDefaultTemplate` +- dashboard composition through `DashboardView` +- list state + rendering through `useListState` and `ListView` +- document state + rendering through `useDocumentState` and `DocumentView` +- top-level view selection through a TanStack-only `renderView` switch + +At the same time, TanStack server state is already leaning on shared `ui` internals in: + +- `packages/tanstack-start/src/views/Root/getPageState.ts` +- `packages/tanstack-start/src/views/Root/serverFunctions.ts` + +That means the next architectural payoff is not another TanStack-only wrapper. It is to finish turning those shared `ui` paths into adapter-consumable contracts, then delete the matching custom branches from `TanStackAdminPage.tsx`. + +### Target shape + +`TanStackAdminPage.tsx` should shrink toward a thin adapter entrypoint that owns only: + +1. TanStack runtime providers and router bindings +2. handoff from serialized page/server-function state into shared `ui` view entrypoints +3. adapter-specific renderer execution for payload component slots +4. temporary unsupported fallbacks only where the shared contract does not exist yet + +It should stop owning bespoke template markup or hand-maintained dashboard/list/document wrappers once the shared contracts are available. + +### Required shared extractions + +To make that collapse possible, the shared layer needs adapter-facing entrypoints that TanStack can consume without copying behavior: + +- template contracts that let TanStack use shared default/minimal composition instead of `TanStackDefaultTemplate` +- dashboard descriptor/state helpers that replace the current client-only `DashboardView` +- list descriptor/state builders that replace the TanStack-specific `table-state` -> `DefaultListView` bridge +- document descriptor/state builders that replace the TanStack-specific `tanstack-document-state` -> `DefaultEditView` bridge + +The important constraint is that these should be shared `ui` contracts, not TanStack-specific forks of `renderListView` or `renderDocument`. + +### Collapse order + +1. route TanStack default/minimal template rendering through shared template contracts +2. replace the custom TanStack dashboard implementation with the migrated shared dashboard path +3. move list server-function output toward the same shared list descriptor/state model used by `ui` +4. move document server-function output toward the same shared document descriptor/state model used by `ui` +5. reduce `renderView` to a thin dispatcher over shared view entrypoints instead of a second admin implementation + +### Likely touchpoints for this follow-through + +- `packages/tanstack-start/src/views/TanStackAdminPage.tsx` +- `packages/tanstack-start/src/views/Root/getPageState.ts` +- `packages/tanstack-start/src/views/Root/serverFunctions.ts` +- `packages/tanstack-start/src/views/Root/types.ts` +- `packages/ui/src/templates/Default/index.tsx` +- `packages/ui/src/views/Dashboard/index.tsx` +- `packages/ui/src/views/List/RenderListView.tsx` +- `packages/ui/src/views/Document/RenderDocument.tsx` + ## Deferred Follow-Up Phases ### Phase 2: List @@ -325,4 +385,5 @@ Phase 1 is successful when: 2. refactor root rendering so `ui` builds a shared page descriptor 3. implement the first Next renderer/adapter for that descriptor 4. migrate `Dashboard` to the new boundary -5. only then resume TanStack work against the shared `ui` contracts +5. collapse `packages/tanstack-start/src/views/TanStackAdminPage.tsx` onto the shared template/dashboard contracts instead of extending its custom wrappers +6. continue the same collapse for list and document as their shared adapter-facing contracts land From 625aa176aa84b24cffebfaef349dafe494ac02a7 Mon Sep 17 00:00:00 2001 From: Sasha Rakhmatulin Date: Thu, 2 Apr 2026 12:19:34 +0100 Subject: [PATCH 42/60] chore: collapse tanstack admin page onto shared ui views Route the TanStack admin dashboard, nav, list, and document paths through shared ui entrypoints so adapter-specific code stays thin and framework behavior converges around the same contracts. --- .../src/views/TanStackAdminPage.tsx | 334 ++++++++++-------- .../src/views/buildRenderViewArgs.spec.ts | 68 ++++ .../src/views/buildRenderViewArgs.ts | 38 ++ packages/ui/src/elements/ListDrawer/types.ts | 5 + .../ui/src/elements/Nav/AdminNavLinks.spec.ts | 98 +++++ .../ui/src/elements/Nav/AdminNavLinks.tsx | 95 +++++ packages/ui/src/elements/Nav/index.client.tsx | 85 +---- packages/ui/src/exports/client/index.ts | 1 + packages/ui/src/exports/shared/index.ts | 1 + .../src/providers/ServerFunctions/index.tsx | 15 +- .../buildDashboardEntityLinks.spec.ts | 84 +++++ .../utilities/buildDashboardEntityLinks.ts | 75 ++++ .../views/Document/handleServerFunction.tsx | 6 +- .../src/views/List/handleServerFunction.tsx | 8 +- 14 files changed, 683 insertions(+), 230 deletions(-) create mode 100644 packages/tanstack-start/src/views/buildRenderViewArgs.spec.ts create mode 100644 packages/tanstack-start/src/views/buildRenderViewArgs.ts create mode 100644 packages/ui/src/elements/Nav/AdminNavLinks.spec.ts create mode 100644 packages/ui/src/elements/Nav/AdminNavLinks.tsx create mode 100644 packages/ui/src/utilities/buildDashboardEntityLinks.spec.ts create mode 100644 packages/ui/src/utilities/buildDashboardEntityLinks.ts diff --git a/packages/tanstack-start/src/views/TanStackAdminPage.tsx b/packages/tanstack-start/src/views/TanStackAdminPage.tsx index eb98410b43e..d57a39a27c5 100644 --- a/packages/tanstack-start/src/views/TanStackAdminPage.tsx +++ b/packages/tanstack-start/src/views/TanStackAdminPage.tsx @@ -1,17 +1,17 @@ 'use client' -import type { Column } from 'payload' +import type { ListQuery } from 'payload' import { AccountClient, ActionsProvider, + AdminNavLinks, APIViewClient, AppHeader, BulkUploadProvider, Button, CreateFirstUserClient, DefaultEditView, - DefaultListView, DocumentInfoProvider, EditDepthProvider, EntityVisibilityProvider, @@ -19,7 +19,6 @@ import { Gutter, HydrateAuthProvider, Link, - ListQueryProvider, LivePreviewProvider, LoadingOverlay, LoginForm, @@ -28,7 +27,6 @@ import { PageConfigProvider, parseSearchParams, ResetPasswordForm, - useConfig, useRouter, useSearchParams, useServerFunctions, @@ -36,14 +34,36 @@ import { useTranslation, } from '@payloadcms/ui' import { FormHeader } from '@payloadcms/ui/elements/FormHeader' -import { EntityType, groupNavItems } from '@payloadcms/ui/shared' +import { buildDashboardEntityLinks, EntityType, groupNavItems } from '@payloadcms/ui/shared' import { MinimalTemplate } from '@payloadcms/ui/templates/Minimal' +import { notFound } from '@tanstack/react-router' import { formatAdminURL } from 'payload/shared' import React from 'react' import type { TanStackDocumentStateResult } from './Root/serverFunctions.js' import type { SerializablePageState } from './Root/types.js' +import { buildRenderDocumentArgs, buildRenderListArgs } from './buildRenderViewArgs.js' + +const getRedirectURL = (error: unknown): null | string => { + if (!error || typeof error !== 'object') { + return null + } + + if ('href' in error && typeof error.href === 'string') { + return error.href + } + + if ('to' in error && typeof error.to === 'string') { + return error.to + } + + return null +} + +const isNotFoundError = (error: unknown): boolean => + error instanceof Error && error.message === 'not-found' + function TanStackDefaultTemplate({ children, pageState, @@ -89,28 +109,9 @@ function TanStackDefaultTemplate({
    @@ -134,6 +135,16 @@ function UnsupportedView(props: { description: string; title: string }) { function DashboardView({ pageState }: { pageState: SerializablePageState }) { const { i18n, t } = useTranslation() const { setStepNav } = useStepNav() + const links = React.useMemo( + () => + buildDashboardEntityLinks({ + clientConfig: pageState.clientConfig, + i18n, + permissions: pageState.permissions, + visibleEntities: pageState.visibleEntities, + }), + [i18n, pageState.clientConfig, pageState.permissions, pageState.visibleEntities], + ) React.useEffect(() => { setStepNav([]) @@ -143,40 +154,11 @@ function DashboardView({ pageState }: { pageState: SerializablePageState }) {

    {t('general:dashboard')}

    - {pageState.clientConfig.collections - .filter((collection) => pageState.visibleEntities.collections.includes(collection.slug)) - .filter((collection) => pageState.permissions.collections?.[collection.slug]?.read) - .map((collection) => ( - - {typeof collection.labels.plural === 'function' - ? collection.labels.plural({ i18n, t: i18n.t }) - : collection.labels.plural} - - ))} - {pageState.clientConfig.globals - .filter((global) => pageState.visibleEntities.globals.includes(global.slug)) - .filter((global) => pageState.permissions.globals?.[global.slug]?.read) - .map((global) => ( - - {typeof global.label === 'function' - ? global.label({ i18n, t: i18n.t }) - : global.label} - - ))} + {links.map((link) => ( + + {link.label} + + ))}
    ) @@ -292,15 +274,18 @@ function CreateFirstUserView({ pageState }: { pageState: SerializablePageState } ) } -function useListState(pageState: SerializablePageState) { +function ListView({ pageState }: { pageState: SerializablePageState }) { const { serverFunction } = useServerFunctions() + const router = useRouter() const searchParams = useSearchParams() - const [state, setState] = React.useState<{ - data: null | Record - renderedFilters?: Map - state: Column[] - Table: React.ReactNode - } | null>(null) + const [isLoading, setIsLoading] = React.useState(true) + const [isNotFoundRoute, setIsNotFoundRoute] = React.useState(false) + const [listView, setListView] = React.useState(null) + + if (isNotFoundRoute) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw notFound() + } React.useEffect(() => { if (!pageState.routeParams.collection) { @@ -310,19 +295,43 @@ function useListState(pageState: SerializablePageState) { let cancelled = false const run = async () => { - const result = (await serverFunction({ - name: 'table-state', - args: { - collectionSlug: pageState.routeParams.collection, - enableRowSelections: true, - orderableFieldName: '_order', - permissions: pageState.permissions, - query: parseSearchParams(searchParams) as Record, - }, - })) as typeof state - - if (!cancelled) { - setState(result) + try { + const result = (await serverFunction({ + name: 'render-list', + args: buildRenderListArgs({ + pageState, + query: parseSearchParams(searchParams) as ListQuery, + }), + })) as { List?: React.ReactNode } + + if (!cancelled) { + setListView(result?.List ?? null) + setIsLoading(false) + } + } catch (error) { + if (cancelled) { + return + } + + const redirectURL = getRedirectURL(error) + + if (redirectURL) { + router.replace(redirectURL) + return + } + + if (isNotFoundError(error)) { + setIsNotFoundRoute(true) + return + } + + setListView( + , + ) + setIsLoading(false) } } @@ -331,57 +340,16 @@ function useListState(pageState: SerializablePageState) { return () => { cancelled = true } - }, [pageState.permissions, pageState.routeParams.collection, searchParams, serverFunction]) + }, [pageState, router, searchParams, serverFunction]) - return state -} - -function ListView({ pageState }: { pageState: SerializablePageState }) { - const { getEntityConfig } = useConfig() - const searchParams = useSearchParams() - const listState = useListState(pageState) - - if (!pageState.routeParams.collection || !listState) { + if (!pageState.routeParams.collection || isLoading) { return } - const collectionSlug = pageState.routeParams.collection - const collectionConfig = getEntityConfig({ collectionSlug }) - const query = parseSearchParams(searchParams) as Record - - return ( - <> - - - - - - ) + return listView } -function useDocumentState(args: { account?: boolean; pageState: SerializablePageState }) { +function useAccountDocumentState(args: { pageState: SerializablePageState }) { const { serverFunction } = useServerFunctions() const router = useRouter() const [state, setState] = React.useState(null) @@ -393,7 +361,7 @@ function useDocumentState(args: { account?: boolean; pageState: SerializablePage const result = (await serverFunction({ name: 'tanstack-document-state', args: { - account: args.account, + account: true, collectionSlug: args.pageState.routeParams.collection, docID: args.pageState.routeParams.id, documentSubViewType: args.pageState.documentSubViewType, @@ -419,7 +387,6 @@ function useDocumentState(args: { account?: boolean; pageState: SerializablePage cancelled = true } }, [ - args.account, args.pageState.documentSubViewType, args.pageState.routeParams.collection, args.pageState.routeParams.global, @@ -433,14 +400,8 @@ function useDocumentState(args: { account?: boolean; pageState: SerializablePage return state } -function DocumentView({ - account, - pageState, -}: { - account?: boolean - pageState: SerializablePageState -}) { - const documentState = useDocumentState({ account, pageState }) +function AccountView({ pageState }: { pageState: SerializablePageState }) { + const documentState = useAccountDocumentState({ pageState }) if (!documentState) { return @@ -496,15 +457,110 @@ function DocumentView({ - {pageState.documentSubViewType === 'api' ? : } + {pageState.documentSubViewType === 'api' ? ( + + ) : ( + + )} - {account && } + ) } +function DocumentView({ pageState }: { pageState: SerializablePageState }) { + const { renderDocument } = useServerFunctions() + const router = useRouter() + const [documentView, setDocumentView] = React.useState(null) + const [isLoading, setIsLoading] = React.useState(true) + const [isNotFoundRoute, setIsNotFoundRoute] = React.useState(false) + + if (isNotFoundRoute) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw notFound() + } + + React.useEffect(() => { + let cancelled = false + + const run = async () => { + try { + const result = await renderDocument( + buildRenderDocumentArgs({ + pageState, + }), + ) + + if (cancelled) { + return + } + + if ( + pageState.routeParams.collection && + !pageState.routeParams.id && + result?.data && + typeof result.data === 'object' && + 'id' in result.data && + result.data.id + ) { + router.replace( + formatAdminURL({ + adminRoute: pageState.clientConfig.routes.admin, + path: `/collections/${pageState.routeParams.collection}/${String(result.data.id)}`, + }), + ) + return + } + + setDocumentView(result?.Document ?? null) + setIsLoading(false) + } catch (error) { + if (cancelled) { + return + } + + const redirectURL = getRedirectURL(error) + + if (redirectURL) { + router.replace(redirectURL) + return + } + + if (isNotFoundError(error)) { + setIsNotFoundRoute(true) + return + } + + setDocumentView( + , + ) + setIsLoading(false) + } + } + + void run() + + return () => { + cancelled = true + } + }, [pageState, renderDocument, router]) + + if (isLoading) { + return + } + + return documentView +} + function renderView(pageState: SerializablePageState): React.ReactNode { if (pageState.unsupportedCustomView || pageState.customView) { return ( @@ -517,7 +573,7 @@ function renderView(pageState: SerializablePageState): React.ReactNode { switch (pageState.viewType as string | undefined) { case 'account': - return + return case 'createFirstUser': return case 'dashboard': diff --git a/packages/tanstack-start/src/views/buildRenderViewArgs.spec.ts b/packages/tanstack-start/src/views/buildRenderViewArgs.spec.ts new file mode 100644 index 00000000000..489cd2e986d --- /dev/null +++ b/packages/tanstack-start/src/views/buildRenderViewArgs.spec.ts @@ -0,0 +1,68 @@ +import type { ListQuery } from 'payload' + +import { describe, expect, it } from 'vitest' + +import { buildRenderDocumentArgs, buildRenderListArgs } from './buildRenderViewArgs.js' + +describe('buildRenderViewArgs', () => { + it('should build render-list args for trash views', () => { + const query = { + limit: 10, + sort: '-createdAt', + } satisfies ListQuery + + expect( + buildRenderListArgs({ + pageState: { + searchParams: { + notFound: '123', + }, + routeParams: { + collection: 'posts', + }, + viewType: 'trash', + } as any, + query, + }), + ).toEqual({ + collectionSlug: 'posts', + enableRowSelections: true, + query, + searchParams: { + notFound: '123', + }, + trash: true, + viewType: 'trash', + }) + }) + + it('should build render-document args for global api views', () => { + expect( + buildRenderDocumentArgs({ + pageState: { + documentSubViewType: 'api', + routeParams: { + global: 'settings', + }, + searchParams: { + locale: 'en', + }, + segments: ['globals', 'settings', 'api'], + viewType: 'document', + } as any, + }), + ).toEqual({ + collectionSlug: 'settings', + docID: undefined, + documentSubViewType: 'api', + paramsOverride: { + segments: ['globals', 'settings', 'api'], + }, + redirectAfterCreate: false, + searchParams: { + locale: 'en', + }, + viewType: 'document', + }) + }) +}) diff --git a/packages/tanstack-start/src/views/buildRenderViewArgs.ts b/packages/tanstack-start/src/views/buildRenderViewArgs.ts new file mode 100644 index 00000000000..2cf49150a55 --- /dev/null +++ b/packages/tanstack-start/src/views/buildRenderViewArgs.ts @@ -0,0 +1,38 @@ +import type { RenderListServerFnArgs, ServerFunctionsContextType } from '@payloadcms/ui' +import type { ListQuery } from 'payload' + +import type { SerializablePageState } from './Root/types.js' + +export const buildRenderListArgs = (args: { + pageState: SerializablePageState + query: ListQuery +}): RenderListServerFnArgs => { + const { pageState, query } = args + + return { + collectionSlug: pageState.routeParams.collection, + enableRowSelections: true, + query, + searchParams: pageState.searchParams, + trash: pageState.viewType === 'trash', + viewType: pageState.viewType, + } +} + +export const buildRenderDocumentArgs = (args: { + pageState: SerializablePageState +}): Parameters[0] => { + const { pageState } = args + + return { + collectionSlug: pageState.routeParams.collection || pageState.routeParams.global, + docID: pageState.routeParams.id, + documentSubViewType: pageState.documentSubViewType, + paramsOverride: { + segments: pageState.segments, + }, + redirectAfterCreate: false, + searchParams: pageState.searchParams, + viewType: pageState.viewType, + } +} diff --git a/packages/ui/src/elements/ListDrawer/types.ts b/packages/ui/src/elements/ListDrawer/types.ts index f9f54b906b2..b9059874d8a 100644 --- a/packages/ui/src/elements/ListDrawer/types.ts +++ b/packages/ui/src/elements/ListDrawer/types.ts @@ -2,7 +2,9 @@ import type { CollectionPreferences, FilterOptionsResult, ListQuery, + Params, SanitizedCollectionConfig, + ViewTypes, } from 'payload' import type React from 'react' import type { HTMLAttributes } from 'react' @@ -24,6 +26,9 @@ export type RenderListServerFnArgs = { query: ListQuery redirectAfterDelete?: boolean redirectAfterDuplicate?: boolean + searchParams?: Params + trash?: boolean + viewType?: ViewTypes } /** diff --git a/packages/ui/src/elements/Nav/AdminNavLinks.spec.ts b/packages/ui/src/elements/Nav/AdminNavLinks.spec.ts new file mode 100644 index 00000000000..0f70037435a --- /dev/null +++ b/packages/ui/src/elements/Nav/AdminNavLinks.spec.ts @@ -0,0 +1,98 @@ +import type { NavPreferences } from 'payload' + +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' + +import { EntityType } from '../../utilities/groupNavItems.js' + +import { AdminNavLinks } from './AdminNavLinks' + +vi.mock('../../providers/Config/index.js', () => ({ + useConfig: () => ({ + config: { + admin: { + routes: { + browseByFolder: '/browse-by-folder', + }, + }, + folders: undefined, + routes: { + admin: '/admin', + }, + }, + }), +})) + +vi.mock('../../providers/Router/index.js', () => ({ + Link: ({ children, href, ...props }) => React.createElement('a', { href, ...props }, children), + usePathname: () => '/admin/collections/posts', +})) + +vi.mock('../../providers/Translation/index.js', () => ({ + useTranslation: () => ({ + i18n: { + language: 'en', + t: (key: string) => key, + translations: {}, + }, + }), +})) + +vi.mock('../FolderView/BrowseByFolderButton/index.js', () => ({ + BrowseByFolderButton: () => null, +})) + +vi.mock('../NavGroup/index.js', () => ({ + NavGroup: ({ + children, + isOpen, + label, + }: { + children: React.ReactNode + isOpen?: boolean + label: string + }) => + React.createElement( + 'div', + { + className: ['nav-group', isOpen === false && 'nav-group--collapsed'] + .filter(Boolean) + .join(' '), + 'data-label': label, + }, + children, + ), +})) + +describe('AdminNavLinks', () => { + it('should render collapsed groups from nav preferences', () => { + const markup = renderToStaticMarkup( + React.createElement(AdminNavLinks, { + groups: [ + { + entities: [ + { + label: 'Posts', + slug: 'posts', + type: EntityType.collection, + }, + ], + label: 'Content', + }, + ], + navPreferences: { + groups: { + Content: { + open: false, + }, + }, + } as unknown as NavPreferences, + }), + ) + + expect(markup).toContain('nav-group--collapsed') + expect(markup).toContain('nav__link-indicator') + expect(markup).toContain('