diff --git a/.gitignore b/.gitignore index d7f95c1320..3497e4e763 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,4 @@ apps/*/public/fonts/**/*.otf !apps/*/public/fonts/rhymes-display/*.woff2 mcp.json +.env*.local diff --git a/AGENTS.md b/AGENTS.md index 684f961fb1..4eb2ed8279 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,8 @@ pnpm generate-types # cms only — regenerates packages/types/src/payload.ts - **Every clickable element gets `cursor-pointer`** in its Tailwind className: buttons, `onClick` handlers, anchors, clickable cards. - **Visuals match Figma 1:1.** Pull the spec (font sizes, fills, gaps, padding) from Figma before implementing. See `docs/components.md` for canonical node IDs and `docs/web-pages.md` for per-page references. - **Types on public APIs.** Exported functions, shared utilities, component props. Use `interface` for object shapes, `type` for unions/intersections. Avoid `any`; use `unknown` + narrowing for external input. +- **Keep reusable code out of generated or oversized files.** If a type, constant list, validator, or UI helper is used in more than one place, move it into a focused module and import it. Do not let generated files or collection configs absorb long literal unions, large option arrays, or repeated validation logic. +- **Split files by feature when they grow.** Prefer small folders with focused files (types, constants, validators, components) over large catch-all modules. Create a feature folder once a file mixes multiple responsibilities or becomes hard to scan. - **Immutability.** Spread/copy, never mutate. `Readonly` on inputs where it clarifies intent. - **No `console.log` in committed code.** - **Follow existing file organization.** Many small focused files over large ones. Routes live under `apps/web/app/[locale]//`; section components under `apps/web/components/sections/
/`. diff --git a/apps/cms/.env.example b/apps/cms/.env.example index bad216be90..d489321422 100644 --- a/apps/cms/.env.example +++ b/apps/cms/.env.example @@ -30,6 +30,10 @@ DATABASE_URL=postgresql://user:password@host:6543/postgres # the same Postgres database hosts multiple environments (e.g. "payload_dev"). # PAYLOAD_DB_SCHEMA=payload +# Max Postgres clients opened by the CMS process. Keep local dev small when +# using Supabase's session pooler, where pool_size is often limited. +# PAYLOAD_DB_POOL_MAX=3 + # Schema auto-sync. Defaults to true (Phase 1 bootstrap). Flip to "false" # once SQL migrations are wired up so production no longer mutates schema on # every boot. See payload.config.ts for the full rationale. diff --git a/apps/cms/payload.config.ts b/apps/cms/payload.config.ts index fd740834b4..78b9245702 100644 --- a/apps/cms/payload.config.ts +++ b/apps/cms/payload.config.ts @@ -69,6 +69,14 @@ if (isProduction && !payloadSecret) { ) } +const databasePoolMax = Number.parseInt( + process.env.PAYLOAD_DB_POOL_MAX || (isProduction ? '10' : '3'), + 10 +) +if (!Number.isInteger(databasePoolMax) || databasePoolMax < 1) { + throw new Error('PAYLOAD_DB_POOL_MAX must be a positive integer') +} + export default buildConfig({ admin: { components: { @@ -97,6 +105,7 @@ export default buildConfig({ db: postgresAdapter({ pool: { connectionString: databaseUrl, + max: databasePoolMax, }, // Isolate Payload tables from any other app sharing the database. schemaName: process.env.PAYLOAD_DB_SCHEMA || 'payload', diff --git a/apps/cms/src/app/(payload)/admin/importMap.js b/apps/cms/src/app/(payload)/admin/importMap.js index bb1bdb4eda..91c9b891dc 100644 --- a/apps/cms/src/app/(payload)/admin/importMap.js +++ b/apps/cms/src/app/(payload)/admin/importMap.js @@ -25,6 +25,7 @@ import { RfpLockBanner as RfpLockBanner_24e027e9ff0225667bcf33133ddb2c82 } from import { SaveRfpPrButton as SaveRfpPrButton_4ee2b20d9808739a2dc76b5b7d69dc52 } from '@/components/admin/save-pr-button.tsx' import { IdeaLockBanner as IdeaLockBanner_24e027e9ff0225667bcf33133ddb2c82 } from '@/components/admin/lock-banner.tsx' import { SaveIdeaPrButton as SaveIdeaPrButton_4ee2b20d9808739a2dc76b5b7d69dc52 } from '@/components/admin/save-pr-button.tsx' +import { TimezoneField as TimezoneField_675c144de479f7a98214bb815213c778 } from '@/components/admin/timezone-field.tsx' import { SaveCirclePrButton as SaveCirclePrButton_4ee2b20d9808739a2dc76b5b7d69dc52 } from '@/components/admin/save-pr-button.tsx' import { SaveCircleEventPrButton as SaveCircleEventPrButton_4ee2b20d9808739a2dc76b5b7d69dc52 } from '@/components/admin/save-pr-button.tsx' import { SaveCircleInitiativePrButton as SaveCircleInitiativePrButton_4ee2b20d9808739a2dc76b5b7d69dc52 } from '@/components/admin/save-pr-button.tsx' @@ -60,6 +61,7 @@ export const importMap = { "@/components/admin/save-pr-button.tsx#SaveRfpPrButton": SaveRfpPrButton_4ee2b20d9808739a2dc76b5b7d69dc52, "@/components/admin/lock-banner.tsx#IdeaLockBanner": IdeaLockBanner_24e027e9ff0225667bcf33133ddb2c82, "@/components/admin/save-pr-button.tsx#SaveIdeaPrButton": SaveIdeaPrButton_4ee2b20d9808739a2dc76b5b7d69dc52, + "@/components/admin/timezone-field.tsx#TimezoneField": TimezoneField_675c144de479f7a98214bb815213c778, "@/components/admin/save-pr-button.tsx#SaveCirclePrButton": SaveCirclePrButton_4ee2b20d9808739a2dc76b5b7d69dc52, "@/components/admin/save-pr-button.tsx#SaveCircleEventPrButton": SaveCircleEventPrButton_4ee2b20d9808739a2dc76b5b7d69dc52, "@/components/admin/save-pr-button.tsx#SaveCircleInitiativePrButton": SaveCircleInitiativePrButton_4ee2b20d9808739a2dc76b5b7d69dc52, diff --git a/apps/cms/src/collections/Circles.ts b/apps/cms/src/collections/Circles.ts index b30498882c..52bd42a97b 100644 --- a/apps/cms/src/collections/Circles.ts +++ b/apps/cms/src/collections/Circles.ts @@ -1,11 +1,6 @@ import type { CollectionConfig, Field } from 'payload' -const timeZoneOptions = ['UTC', ...Intl.supportedValuesOf('timeZone')].map( - (timeZone) => ({ - label: timeZone, - value: timeZone, - }) -) +import { isValidIanaTimeZone } from '@/lib/timezones' const statusField: Field = { name: 'status', @@ -37,10 +32,24 @@ const slugField: Field = { const createTimeZoneField = (width: string): Field => ({ name: 'timezone', - type: 'select', + type: 'text', required: true, - options: timeZoneOptions, - admin: { width }, + admin: { + components: { + Field: '@/components/admin/timezone-field.tsx#TimezoneField', + }, + width, + }, + validate: (value) => { + if (typeof value !== 'string' || value.length === 0) { + return 'A timezone is required.' + } + + return ( + isValidIanaTimeZone(value) || + 'Must be a valid IANA timezone, e.g. "America/Los_Angeles".' + ) + }, }) export const Circles: CollectionConfig = { diff --git a/apps/cms/src/components/admin/timezone-field.tsx b/apps/cms/src/components/admin/timezone-field.tsx new file mode 100644 index 0000000000..87fbdab6d4 --- /dev/null +++ b/apps/cms/src/components/admin/timezone-field.tsx @@ -0,0 +1,91 @@ +'use client' + +import { SelectInput, useField } from '@payloadcms/ui' +import { useMemo } from 'react' + +import { IANA_TIME_ZONE_OPTIONS } from '@/lib/timezones' +import type { IanaTimeZone } from '@/types/timezones' + +interface TimezoneFieldProps { + field: { + admin?: { + description?: string + isClearable?: boolean + placeholder?: string + } + label?: string + name: string + required?: boolean + } + path: string + readOnly?: boolean +} + +const getSelectedValue = (selectedOption: unknown): IanaTimeZone | null => { + if ( + typeof selectedOption === 'object' && + selectedOption !== null && + 'value' in selectedOption && + typeof selectedOption.value === 'string' + ) { + return selectedOption.value + } + + return null +} + +export function TimezoneField({ + field, + path: pathFromProps, + readOnly, +}: TimezoneFieldProps) { + const { + customComponents: { + AfterInput, + BeforeInput, + Description, + Error, + Label, + } = {}, + disabled, + path, + setValue, + showError, + value, + } = useField({ + potentiallyStalePath: pathFromProps, + }) + + const optionByValue = useMemo( + () => new Set(IANA_TIME_ZONE_OPTIONS.map((option) => option.value)), + [] + ) + + return ( + + optionByValue.has(optionValue) && + label.toLowerCase().includes(search.toLowerCase()) + } + isClearable={field.admin?.isClearable ?? false} + Label={Label} + label={field.label} + name={field.name} + onChange={(selectedOption) => { + setValue(getSelectedValue(selectedOption)) + }} + options={IANA_TIME_ZONE_OPTIONS} + path={path} + placeholder={field.admin?.placeholder ?? 'Select a timezone'} + readOnly={readOnly || disabled} + required={field.required} + showError={showError} + value={typeof value === 'string' ? value : undefined} + /> + ) +} diff --git a/apps/cms/src/lib/timezones.ts b/apps/cms/src/lib/timezones.ts new file mode 100644 index 0000000000..f58d498c56 --- /dev/null +++ b/apps/cms/src/lib/timezones.ts @@ -0,0 +1,18 @@ +import type { IanaTimeZone, TimeZoneOption } from '@/types/timezones' + +export const IANA_TIME_ZONE_OPTIONS: TimeZoneOption[] = [ + 'UTC', + ...Intl.supportedValuesOf('timeZone'), +].map((timeZone) => ({ + label: timeZone, + value: timeZone, +})) + +export const isValidIanaTimeZone = (value: string): value is IanaTimeZone => { + try { + new Intl.DateTimeFormat('en-US', { timeZone: value }).format(new Date()) + return true + } catch { + return false + } +} diff --git a/apps/cms/src/types/timezones.ts b/apps/cms/src/types/timezones.ts new file mode 100644 index 0000000000..cce5b3f643 --- /dev/null +++ b/apps/cms/src/types/timezones.ts @@ -0,0 +1,6 @@ +export type IanaTimeZone = string + +export interface TimeZoneOption { + label: IanaTimeZone + value: IanaTimeZone +}