Add Post and Tag collections for news and publications

- Add Post + Tag collections (notícias/publicações) with tag-based
  "Esconder" visibility control for electoral-law compliance
- Add static SEO routes /[type]/[category]/[slug] plus /[type] and
  /[type]/[category] listings, with shared header/breadcrumb and PostCard
- Recreate the homepage and refresh the site color palette (styles.css)
- Add posts/slug/seo utilities and revalidation wiring (documents.ts)
- Add live-fetch seed script (scripts/seed-posts.mjs + seed-loader.mjs,
  db:seed:posts)
- Upgrade Payload 3.79.0 -> 3.82.0 (fixes admin relationship-filter crash)
- Remove obsolete Header component; add shadcn card/badge/breadcrumb;
  update AGENTS.md and components rule docs
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-15 19:12:11 -03:00
parent 712649eecf
commit 28647e4760
33 changed files with 10690 additions and 207 deletions

View file

@ -44,6 +44,8 @@ These conventions apply to all first-party components we author. The only except
const [open, setOpen] = React.useState(false)
```
3. **All code identifiers are in English.** This covers variable, function, parameter, type/interface names, local constants, and Next.js dynamic route segment folder names used as param keys (e.g. `[type]/[category]/[slug]`, not `[tipo]/[categoria]`). Portuguese is allowed ONLY in: user-visible string literals (JSX text, button/labels), image `alt` text, SEO/metadata values (title/description/keywords), Payload admin config text (`labels`/`singular`/`plural`/`admin.description`/field `label`), and intentional URL slug/enum VALUES kept in Portuguese for SEO (e.g. `post.type` values `noticia|campanha|artigo|evento`, tag/category slugs like `saude`). Never translate those data/URL values or admin labels. Renaming a route segment folder changes only the param KEY, never the public URL.
## Component Types
There are four main types of Custom Components:

View file

@ -20,6 +20,7 @@ Teqo is the digital platform for deputado federal Jorge Solla (PT-BA): public si
- Cache invalidation: collections/globals call `revalidateDocumentById` (`src/utilities/documents.ts`) / `revalidateGlobal` (`src/utilities/globals.ts`) in an `afterChange` hook. Add the same hook to any new collection/global that backs a public page.
- Admin UI is organized via `admin.group` (e.g. `'Abaixo-assinados'`, `'Contatos'`, `'Configurações'`, `'Paginas'`) — group new collections consistently instead of leaving them ungrouped.
- i18n: Payload admin defaults to `pt` (not `pt-BR` — see `payload.config.ts`, `payload/i18n/pt`).
- Language & naming conventions: ALL code identifiers are in English — variable, function, parameter, type/interface names, local constants, and Next.js dynamic route segment folder names used as param keys (e.g. `[type]/[category]/[slug]`, not `[tipo]/[categoria]`). Portuguese is allowed ONLY in: user-visible string literals (JSX text, button/labels), image `alt` text, SEO/metadata values (title/description/keywords), Payload admin config text (`labels`/`singular`/`plural`/`admin.description`/field `label`), and intentional URL slug/enum VALUES kept in Portuguese for SEO (e.g. the `post.type` enum values `noticia|campanha|artigo|evento`, tag/category slugs like `saude`, `eleitoral`). Never translate those data/URL values or admin labels. Renaming a route segment folder changes only the param KEY, never the public URL (segment values come from the data).
**Database & local development (safety-critical — production is a live Neon Postgres with real citizens' PII):**

View file

@ -25,6 +25,7 @@
"db:start": "docker compose up -d postgres",
"db:stop": "docker compose down",
"db:pull": "node scripts/db-pull.mjs",
"db:seed:posts": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-posts.mjs",
"dev": "node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
"devsafe": "rm -rf .next && node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
@ -43,12 +44,12 @@
"dependencies": {
"@base-ui/react": "^1.6.0",
"@hookform/resolvers": "^5.2.2",
"@payloadcms/db-postgres": "3.79.0",
"@payloadcms/live-preview-react": "3.79.0",
"@payloadcms/next": "3.79.0",
"@payloadcms/richtext-lexical": "3.79.0",
"@payloadcms/storage-vercel-blob": "3.79.0",
"@payloadcms/ui": "3.79.0",
"@payloadcms/db-postgres": "3.82.0",
"@payloadcms/live-preview-react": "3.82.0",
"@payloadcms/next": "3.82.0",
"@payloadcms/richtext-lexical": "3.82.0",
"@payloadcms/storage-vercel-blob": "3.82.0",
"@payloadcms/ui": "3.82.0",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
@ -63,7 +64,7 @@
"lucide-react": "^0.570.0",
"next": "15.4.11",
"next-themes": "^0.4.6",
"payload": "3.79.0",
"payload": "3.82.0",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",

View file

@ -15,23 +15,23 @@ importers:
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.71.1(react@19.2.4))
'@payloadcms/db-postgres':
specifier: 3.79.0
version: 3.79.0(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))
specifier: 3.82.0
version: 3.82.0(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))
'@payloadcms/live-preview-react':
specifier: 3.79.0
version: 3.79.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
specifier: 3.82.0
version: 3.82.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@payloadcms/next':
specifier: 3.79.0
version: 3.79.0(@types/react@19.2.14)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)
specifier: 3.82.0
version: 3.82.0(@types/react@19.2.14)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)
'@payloadcms/richtext-lexical':
specifier: 3.79.0
version: 3.79.0(@faceless-ui/modal@3.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@faceless-ui/scroll-info@2.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@payloadcms/next@3.79.0(@types/react@19.2.14)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3))(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)(yjs@13.6.29)
specifier: 3.82.0
version: 3.82.0(@faceless-ui/modal@3.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@faceless-ui/scroll-info@2.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@payloadcms/next@3.82.0(@types/react@19.2.14)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3))(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)(yjs@13.6.29)
'@payloadcms/storage-vercel-blob':
specifier: 3.79.0
version: 3.79.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)
specifier: 3.82.0
version: 3.82.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)
'@payloadcms/ui':
specifier: 3.79.0
version: 3.79.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)
specifier: 3.82.0
version: 3.82.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)
'@radix-ui/react-label':
specifier: ^2.1.8
version: 2.1.8(@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)
@ -75,8 +75,8 @@ importers:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
payload:
specifier: 3.79.0
version: 3.79.0(graphql@16.12.0)(typescript@5.7.3)
specifier: 3.82.0
version: 3.82.0(graphql@16.12.0)(typescript@5.7.3)
radix-ui:
specifier: ^1.4.3
version: 1.4.3(@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)
@ -504,11 +504,11 @@ packages:
'@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
deprecated: 'Merged into tsx: https://tsx.is'
deprecated: 'Merged into tsx: https://tsx.hirok.io'
'@esbuild-kit/esm-loader@2.6.5':
resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
deprecated: 'Merged into tsx: https://tsx.is'
deprecated: 'Merged into tsx: https://tsx.hirok.io'
'@esbuild/aix-ppc64@0.25.12':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
@ -1019,10 +1019,6 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@fastify/busboy@2.1.1':
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
engines: {node: '>=14'}
'@floating-ui/core@1.7.4':
resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==}
@ -1624,73 +1620,73 @@ packages:
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
'@payloadcms/db-postgres@3.79.0':
resolution: {integrity: sha512-FvuKl/RSALjpXk4/f2pIEGm3jS9frndwtMei8XZtmY6VhwIwjfKFclZc2zNeUBZuQdXpLNRDXNvSr91Pw8+nQw==}
'@payloadcms/db-postgres@3.82.0':
resolution: {integrity: sha512-FzIRFpytQc8j6UjMpCYKN5Vz+sjw6EgOgsLzVtae6ihbJcHnEWeLXsV9ELd/r1349nNNqyFycRbJVpXbb+rhhw==}
peerDependencies:
payload: 3.79.0
payload: 3.82.0
'@payloadcms/drizzle@3.79.0':
resolution: {integrity: sha512-rH34esN4W6JZi8r8tj6kwjdbkfZan1nQjr94TbxlPYPVI58R+2e8/yTw0cCchWPT6VAH4TNyk/Wvgq64UOqwkQ==}
'@payloadcms/drizzle@3.82.0':
resolution: {integrity: sha512-/LwsbBbMvrUXBzEnQ+jNwGWW/U1BiyYCsj5Y++rU3sraiA2nr8mNDXeDksRH/Um0aytkLbmJNEFcgcJwgucz7w==}
peerDependencies:
payload: 3.79.0
payload: 3.82.0
'@payloadcms/graphql@3.79.0':
resolution: {integrity: sha512-q/fMw3JnUn+dMp7F4qsvoB5KfcdGjUVpttHI8+2o3+SbwQF2d26fHrz4D5Wz5Sm3ZTQg7XsCZzFKA3a9FL/sAg==}
'@payloadcms/graphql@3.82.0':
resolution: {integrity: sha512-RfDOOzPxqjiuOrKCBaqn2Dxiyc97uSv0DWZMQ9yvmPDmA9gTRafqkyg0BMerQxlopGGASLzfbmmdskNDuZBa8g==}
hasBin: true
peerDependencies:
graphql: ^16.8.1
payload: 3.79.0
payload: 3.82.0
'@payloadcms/live-preview-react@3.79.0':
resolution: {integrity: sha512-1JY/QzfIy6iOijpbdRdnV+TuK0B0vo8nTgH+WkfCWxayXf30FlykWW8sdBc44wn4CxmLry36cEeneqAbrasiWQ==}
'@payloadcms/live-preview-react@3.82.0':
resolution: {integrity: sha512-ibGpzaSd/fEuDiN0RsWzV29cYBZzIcEj/Hr56O19iDrE3NYvUwMWP0sIPa8nNH471soFXD0uUZ/yQGtvsX2hVA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.1 || ^19.1.2 || ^19.2.1
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.1 || ^19.1.2 || ^19.2.1
'@payloadcms/live-preview@3.79.0':
resolution: {integrity: sha512-Y2t6NACgeC4gZV/mwzD1L+OhJHematK0RQ8xK4P/m+8fhO+sgS4gtjpA/m6yqAkhruRTni5izZJK6SWTB9pXPw==}
'@payloadcms/live-preview@3.82.0':
resolution: {integrity: sha512-RJoc+uWY0sBz9fHwKGxd6znrIW/IzDepNAjHtHeJ0zR5KYZUm6H8AeWkGzWZRpzpe7Rkq50y4/REl2vdVGMMaQ==}
'@payloadcms/next@3.79.0':
resolution: {integrity: sha512-I3AZrITgQ6URD0onOHpLiOQeMAQ4mtCIBkUTkoc7n0EScwr4rllprjMTfZnZWSQ0kgCbbPYzxuNFTU3wzf/rsQ==}
'@payloadcms/next@3.82.0':
resolution: {integrity: sha512-5GdFrwok6VdqX5IefTO0bkmhSyLeLm52b4guYsKC7LbRbRsUJay3BebreNifBkzUGYrEi47OgBjLjA7ln7lo9A==}
engines: {node: ^18.20.2 || >=20.9.0}
peerDependencies:
graphql: ^16.8.1
next: '>=15.2.9 <15.3.0 || >=15.3.9 <15.4.0 || >=15.4.11 <15.5.0 || >=16.2.0-canary.10 <17.0.0'
payload: 3.79.0
next: '>=15.2.9 <15.3.0 || >=15.3.9 <15.4.0 || >=15.4.11 <15.5.0 || >=16.2.2 <17.0.0'
payload: 3.82.0
'@payloadcms/plugin-cloud-storage@3.79.0':
resolution: {integrity: sha512-WgQgiOamL5xJblLCqHTqspc+nl2lug8TxYeQ0VtL7ostjHwuSHJHj3lRuV5Rw3cvtW2HcMVTUqiUhBIRMEM0BA==}
'@payloadcms/plugin-cloud-storage@3.82.0':
resolution: {integrity: sha512-lWFQlMBMuc1v7Edu7oVgBKRh+Q2OfRVI5fcaYWyuXXBwog2mPfSavIT38bGCMosZZ2YlM129M5vGJseQtI8BQw==}
peerDependencies:
payload: 3.79.0
payload: 3.82.0
react: ^19.0.1 || ^19.1.2 || ^19.2.1
react-dom: ^19.0.1 || ^19.1.2 || ^19.2.1
'@payloadcms/richtext-lexical@3.79.0':
resolution: {integrity: sha512-SPoF44Lf3SwuQtWn51VsqHHSteIoymeANF+T+iGk6g9ji2QoVMWaYu8g6lp8TjNtGd0oLvYhAuTBnKXMIpCavA==}
'@payloadcms/richtext-lexical@3.82.0':
resolution: {integrity: sha512-hdLeG7C1fXe7xUJcYf2Nwhq1OzMl7bt5qCqp6ynnXFuFlCJwUJA0lcCxNeJWCtgABFC7BiSXGnCvIYrk1DKN1Q==}
engines: {node: ^18.20.2 || >=20.9.0}
peerDependencies:
'@faceless-ui/modal': 3.0.0
'@faceless-ui/scroll-info': 2.0.0
'@payloadcms/next': 3.79.0
payload: 3.79.0
'@payloadcms/next': 3.82.0
payload: 3.82.0
react: ^19.0.1 || ^19.1.2 || ^19.2.1
react-dom: ^19.0.1 || ^19.1.2 || ^19.2.1
'@payloadcms/storage-vercel-blob@3.79.0':
resolution: {integrity: sha512-kgpigI+LCWkZNN00q7iVpm2p4I7c8oDsLNjnXaS298ZwM0OwgT+kqEZHv7cu7kT05K6hrefEd90qeEO0B/8Zww==}
'@payloadcms/storage-vercel-blob@3.82.0':
resolution: {integrity: sha512-Ey8MvW8ZKl54wtCTh6CmoLU/Ov9oRA4NSICzEuxNrmSJfEnyllxG6GW2G0sx/0Ta7eO2xCWeGWTTnK17u5HHKw==}
engines: {node: ^18.20.2 || >=20.9.0}
peerDependencies:
payload: 3.79.0
payload: 3.82.0
'@payloadcms/translations@3.79.0':
resolution: {integrity: sha512-xDJ5tjTDQIYebZ3HKZikktsBiWAqk80sN0EY50L990oWtW3hSVj6ervE39wlbK9prNuOiIhSS8NGV4nVYDxspg==}
'@payloadcms/translations@3.82.0':
resolution: {integrity: sha512-6mgFZE1sKGQg/dwU3wX+Yz7HLHaOcBixJ9vMQ+LlV+LPIk60fsU7LFAcbcUaCektv2/X4LTt05VFtKK0D2zufA==}
'@payloadcms/ui@3.79.0':
resolution: {integrity: sha512-wHU9zZkA9/Epyt/P8WpFv9jDY10fKUv04/ckGxVqguSEVhr1ptuZVTY3wr6TX4XU0vU2wg5Riix4IDKi6+AEEQ==}
'@payloadcms/ui@3.82.0':
resolution: {integrity: sha512-TzcYmZAvx7ILfaa7EkdybmZ0Pl0YO1wYlH9klkWjRD8wTVQTVGnR9GEE1YTgiK6hM+3q/ERjst9ZR4Hr0oBPDw==}
engines: {node: ^18.20.2 || >=20.9.0}
peerDependencies:
next: '>=15.2.9 <15.3.0 || >=15.3.9 <15.4.0 || >=15.4.11 <15.5.0 || >=16.2.0-canary.10 <17.0.0'
payload: 3.79.0
next: '>=15.2.9 <15.3.0 || >=15.3.9 <15.4.0 || >=15.4.11 <15.5.0 || >=16.2.2 <17.0.0'
payload: 3.82.0
react: ^19.0.1 || ^19.1.2 || ^19.2.1
react-dom: ^19.0.1 || ^19.1.2 || ^19.2.1
@ -2704,6 +2700,10 @@ packages:
'@types/react-dom':
optional: true
'@tokenizer/inflate@0.4.1':
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
engines: {node: '>=18'}
'@tokenizer/token@0.3.0':
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
@ -3001,9 +3001,9 @@ packages:
vue-router:
optional: true
'@vercel/blob@0.22.3':
resolution: {integrity: sha512-l0t2KhbOO/I8ZNOl9zypYf1NE0837aO4/CPQNGR/RAxtj8FpdYKjhyUADUXj2gERLQmnhun+teaVs/G7vZJ/TQ==}
engines: {node: '>=16.14'}
'@vercel/blob@2.3.1':
resolution: {integrity: sha512-6f9oWC+DbWxIgBLOdqjjn2/REpFrPDB7y5B5HA1ptYkzZaBgL6E34kWrptJvJ7teApJdbAs3I1a5A7z1y8SDHw==}
engines: {node: '>=20.0.0'}
'@vercel/speed-insights@1.3.1':
resolution: {integrity: sha512-PbEr7FrMkUrGYvlcLHGkXdCkxnylCWePx7lPxxq36DNdfo9mcUjLOmqOyPDHAOgnfqgGGdmE3XI9L/4+5fr+vQ==}
@ -3103,6 +3103,9 @@ packages:
ajv@8.17.1:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
ajv@8.18.0:
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
@ -4049,9 +4052,9 @@ packages:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
file-type@19.3.0:
resolution: {integrity: sha512-mROwiKLZf/Kwa/2Rol+OOZQn1eyTkPB3ZTwC0ExY6OLFCbgxHYZvBm7xI77NvfZFMKBsmuXfmLJnD4eEftEhrA==}
engines: {node: '>=18'}
file-type@21.3.4:
resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==}
engines: {node: '>=20'}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
@ -4583,8 +4586,8 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
jose@5.9.6:
resolution: {integrity: sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==}
jose@5.10.0:
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
jose@6.2.1:
resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==}
@ -5203,17 +5206,13 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
payload@3.79.0:
resolution: {integrity: sha512-Pey2gBhFL5QkAmN2KMkzXdiBS4QOi5IiQF4Ji9hCNu7kaDcNYtgk75EeWRGP4hOcb22+dqYnw2TZm5Zs/0KBKw==}
payload@3.82.0:
resolution: {integrity: sha512-wUPyi39vxdMUAil/O8egIOAqOjSB8bTEbpWassftUkfiwYN9KufegO0YNOcIPk28fjZizzDZHzrPJaTcV3jTRQ==}
engines: {node: ^18.20.2 || >=20.9.0}
hasBin: true
peerDependencies:
graphql: ^16.8.1
peek-readable@5.4.2:
resolution: {integrity: sha512-peBp3qZyuS6cNIJ2akRNG1uo1WJ1d0wTxg/fxMdZ0BqCVhx242bSFHM9eNqflfJVS9SsgkzgT/1UgnsurBOTMg==}
engines: {node: '>=14.16'}
pg-cloudflare@1.3.0:
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
@ -5396,8 +5395,8 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qs-esm@7.0.2:
resolution: {integrity: sha512-D8NAthKSD7SGn748v+GLaaO6k08Mvpoqroa35PqIQC4gtUa8/Pb/k+r0m0NnGBVbHDP1gKZ2nVywqfMisRhV5A==}
qs-esm@8.0.1:
resolution: {integrity: sha512-eZ7l+0ZVy3He9c85pEM9KEBR9DFA4jrmWvIjm9wpcHvScwc/vgZDl2TNOF0pm0JsWKw24XBUZOY0Wxn7/nvJnw==}
engines: {node: '>=18'}
qs@6.15.0:
@ -5862,9 +5861,9 @@ packages:
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
engines: {node: '>=14.16'}
strtok3@8.1.0:
resolution: {integrity: sha512-ExzDvHYPj6F6QkSNe/JxSlBxTh3OrI6wrAIz53ulxo1c4hBJ1bT9C/JrAthEKHWG9riVH3Xzg7B03Oxty6S2Lw==}
engines: {node: '>=16'}
strtok3@10.3.5:
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
engines: {node: '>=18'}
styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
@ -5918,6 +5917,10 @@ packages:
thread-stream@3.1.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
throttleit@2.1.0:
resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==}
engines: {node: '>=18'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@ -6071,18 +6074,18 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici@5.29.0:
resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==}
engines: {node: '>=14.0'}
undici@7.18.2:
resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==}
engines: {node: '>=20.18.1'}
undici@6.27.0:
resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==}
engines: {node: '>=18.17'}
undici@7.21.0:
resolution: {integrity: sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==}
engines: {node: '>=20.18.1'}
undici@7.24.4:
resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==}
engines: {node: '>=20.18.1'}
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
@ -6446,7 +6449,7 @@ snapshots:
dependencies:
'@jsdevtools/ono': 7.1.3
'@types/json-schema': 7.0.15
js-yaml: 4.1.1
js-yaml: 4.3.0
'@asamuzakjp/css-color@4.1.2':
dependencies:
@ -6786,7 +6789,7 @@ snapshots:
'@emotion/babel-plugin@11.13.5':
dependencies:
'@babel/helper-module-imports': 7.28.6
'@babel/runtime': 7.28.6
'@babel/runtime': 7.29.7
'@emotion/hash': 0.9.2
'@emotion/memoize': 0.9.0
'@emotion/serialize': 1.3.3
@ -6813,7 +6816,7 @@ snapshots:
'@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.29.7
'@emotion/babel-plugin': 11.13.5
'@emotion/cache': 11.14.0
'@emotion/serialize': 1.3.3
@ -7147,8 +7150,6 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@fastify/busboy@2.1.1': {}
'@floating-ui/core@1.7.4':
dependencies:
'@floating-ui/utils': 0.2.10
@ -7181,8 +7182,8 @@ snapshots:
'@floating-ui/react@0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/utils': 0.2.10
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/utils': 0.2.12
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
tabbable: 6.4.0
@ -7717,14 +7718,14 @@ snapshots:
'@open-draft/until@2.1.0': {}
'@payloadcms/db-postgres@3.79.0(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))':
'@payloadcms/db-postgres@3.82.0(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))':
dependencies:
'@payloadcms/drizzle': 3.79.0(@types/pg@8.10.2)(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(pg@8.16.3)
'@payloadcms/drizzle': 3.82.0(@types/pg@8.10.2)(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(pg@8.16.3)
'@types/pg': 8.10.2
console-table-printer: 2.12.1
drizzle-kit: 0.31.7
drizzle-orm: 0.44.7(@types/pg@8.10.2)(pg@8.16.3)
payload: 3.79.0(graphql@16.12.0)(typescript@5.7.3)
payload: 3.82.0(graphql@16.12.0)(typescript@5.7.3)
pg: 8.16.3
prompts: 2.4.2
to-snake-case: 1.0.0
@ -7760,12 +7761,12 @@ snapshots:
- sqlite3
- supports-color
'@payloadcms/drizzle@3.79.0(@types/pg@8.10.2)(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(pg@8.16.3)':
'@payloadcms/drizzle@3.82.0(@types/pg@8.10.2)(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(pg@8.16.3)':
dependencies:
console-table-printer: 2.12.1
dequal: 2.0.3
drizzle-orm: 0.44.7(@types/pg@8.10.2)(pg@8.16.3)
payload: 3.79.0(graphql@16.12.0)(typescript@5.7.3)
payload: 3.82.0(graphql@16.12.0)(typescript@5.7.3)
prompts: 2.4.2
to-snake-case: 1.0.0
uuid: 9.0.0
@ -7800,44 +7801,44 @@ snapshots:
- sql.js
- sqlite3
'@payloadcms/graphql@3.79.0(graphql@16.12.0)(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(typescript@5.7.3)':
'@payloadcms/graphql@3.82.0(graphql@16.12.0)(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(typescript@5.7.3)':
dependencies:
graphql: 16.12.0
graphql-scalars: 1.22.2(graphql@16.12.0)
payload: 3.79.0(graphql@16.12.0)(typescript@5.7.3)
payload: 3.82.0(graphql@16.12.0)(typescript@5.7.3)
pluralize: 8.0.0
ts-essentials: 10.0.3(typescript@5.7.3)
tsx: 4.21.0
transitivePeerDependencies:
- typescript
'@payloadcms/live-preview-react@3.79.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
'@payloadcms/live-preview-react@3.82.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@payloadcms/live-preview': 3.79.0
'@payloadcms/live-preview': 3.82.0
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@payloadcms/live-preview@3.79.0': {}
'@payloadcms/live-preview@3.82.0': {}
'@payloadcms/next@3.79.0(@types/react@19.2.14)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)':
'@payloadcms/next@3.82.0(@types/react@19.2.14)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)':
dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@dnd-kit/modifiers': 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': 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)
'@payloadcms/graphql': 3.79.0(graphql@16.12.0)(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(typescript@5.7.3)
'@payloadcms/translations': 3.79.0
'@payloadcms/ui': 3.79.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)
'@payloadcms/graphql': 3.82.0(graphql@16.12.0)(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(typescript@5.7.3)
'@payloadcms/translations': 3.82.0
'@payloadcms/ui': 3.82.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)
busboy: 1.6.0
dequal: 2.0.3
file-type: 19.3.0
file-type: 21.3.4
graphql: 16.12.0
graphql-http: 1.22.4(graphql@16.12.0)
graphql-playground-html: 1.6.30
http-status: 2.1.0
next: 15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4)
path-to-regexp: 6.3.0
payload: 3.79.0(graphql@16.12.0)(typescript@5.7.3)
qs-esm: 7.0.2
payload: 3.82.0(graphql@16.12.0)(typescript@5.7.3)
qs-esm: 8.0.1
sass: 1.77.4
uuid: 10.0.0
transitivePeerDependencies:
@ -7848,11 +7849,11 @@ snapshots:
- supports-color
- typescript
'@payloadcms/plugin-cloud-storage@3.79.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)':
'@payloadcms/plugin-cloud-storage@3.82.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)':
dependencies:
'@payloadcms/ui': 3.79.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)
'@payloadcms/ui': 3.82.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)
find-node-modules: 2.1.3
payload: 3.79.0(graphql@16.12.0)(typescript@5.7.3)
payload: 3.82.0(graphql@16.12.0)(typescript@5.7.3)
range-parser: 1.2.1
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
@ -7863,7 +7864,7 @@ snapshots:
- supports-color
- typescript
'@payloadcms/richtext-lexical@3.79.0(@faceless-ui/modal@3.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@faceless-ui/scroll-info@2.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@payloadcms/next@3.79.0(@types/react@19.2.14)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3))(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)(yjs@13.6.29)':
'@payloadcms/richtext-lexical@3.82.0(@faceless-ui/modal@3.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@faceless-ui/scroll-info@2.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@payloadcms/next@3.82.0(@types/react@19.2.14)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3))(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)(yjs@13.6.29)':
dependencies:
'@faceless-ui/modal': 3.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@faceless-ui/scroll-info': 2.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -7878,9 +7879,9 @@ snapshots:
'@lexical/selection': 0.41.0
'@lexical/table': 0.41.0
'@lexical/utils': 0.41.0
'@payloadcms/next': 3.79.0(@types/react@19.2.14)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)
'@payloadcms/translations': 3.79.0
'@payloadcms/ui': 3.79.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)
'@payloadcms/next': 3.82.0(@types/react@19.2.14)(graphql@16.12.0)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)
'@payloadcms/translations': 3.82.0
'@payloadcms/ui': 3.82.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)
'@types/uuid': 10.0.0
acorn: 8.16.0
bson-objectid: 2.0.4
@ -7892,8 +7893,8 @@ snapshots:
mdast-util-from-markdown: 2.0.2
mdast-util-mdx-jsx: 3.1.3
micromark-extension-mdx-jsx: 3.0.1
payload: 3.79.0(graphql@16.12.0)(typescript@5.7.3)
qs-esm: 7.0.2
payload: 3.82.0(graphql@16.12.0)(typescript@5.7.3)
qs-esm: 8.0.1
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-error-boundary: 4.1.2(react@19.2.4)
@ -7909,11 +7910,11 @@ snapshots:
- utf-8-validate
- yjs
'@payloadcms/storage-vercel-blob@3.79.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)':
'@payloadcms/storage-vercel-blob@3.82.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)':
dependencies:
'@payloadcms/plugin-cloud-storage': 3.79.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)
'@vercel/blob': 0.22.3
payload: 3.79.0(graphql@16.12.0)(typescript@5.7.3)
'@payloadcms/plugin-cloud-storage': 3.82.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)
'@vercel/blob': 2.3.1
payload: 3.82.0(graphql@16.12.0)(typescript@5.7.3)
transitivePeerDependencies:
- '@types/react'
- monaco-editor
@ -7923,11 +7924,11 @@ snapshots:
- supports-color
- typescript
'@payloadcms/translations@3.79.0':
'@payloadcms/translations@3.82.0':
dependencies:
date-fns: 4.1.0
'@payloadcms/ui@3.79.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.79.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)':
'@payloadcms/ui@3.82.0(@types/react@19.2.14)(monaco-editor@0.55.1)(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(payload@3.82.0(graphql@16.12.0)(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)':
dependencies:
'@date-fns/tz': 1.2.0
'@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -7937,15 +7938,15 @@ snapshots:
'@faceless-ui/scroll-info': 2.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@faceless-ui/window-info': 3.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@monaco-editor/react': 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@payloadcms/translations': 3.79.0
'@payloadcms/translations': 3.82.0
bson-objectid: 2.0.4
date-fns: 4.1.0
dequal: 2.0.3
md5: 2.3.0
next: 15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4)
object-to-formdata: 4.5.1
payload: 3.79.0(graphql@16.12.0)(typescript@5.7.3)
qs-esm: 7.0.2
payload: 3.82.0(graphql@16.12.0)(typescript@5.7.3)
qs-esm: 8.0.1
react: 19.2.4
react-datepicker: 7.6.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react-dom: 19.2.4(react@19.2.4)
@ -8916,7 +8917,7 @@ snapshots:
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/runtime': 7.28.6
'@babel/runtime': 7.29.7
'@types/aria-query': 5.0.4
aria-query: 5.3.0
dom-accessibility-api: 0.5.16
@ -8934,6 +8935,13 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@tokenizer/inflate@0.4.1':
dependencies:
debug: 4.4.3
token-types: 6.1.2
transitivePeerDependencies:
- supports-color
'@tokenizer/token@0.3.0': {}
'@ts-morph/common@0.27.0':
@ -9209,12 +9217,13 @@ snapshots:
next: 15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4)
react: 19.2.4
'@vercel/blob@0.22.3':
'@vercel/blob@2.3.1':
dependencies:
async-retry: 1.3.3
bytes: 3.1.2
is-buffer: 2.0.5
undici: 5.29.0
is-node-process: 1.2.0
throttleit: 2.1.0
undici: 6.27.0
'@vercel/speed-insights@1.3.1(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(react@19.2.4)':
optionalDependencies:
@ -9313,6 +9322,13 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
ajv@8.18.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.0
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
@ -9435,7 +9451,7 @@ snapshots:
babel-plugin-macros@3.1.0:
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.29.7
cosmiconfig: 7.1.0
resolve: 1.22.11
@ -9790,7 +9806,7 @@ snapshots:
dom-helpers@5.2.1:
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.29.7
csstype: 3.2.3
dompurify@3.2.7:
@ -10399,11 +10415,14 @@ snapshots:
dependencies:
flat-cache: 4.0.1
file-type@19.3.0:
file-type@21.3.4:
dependencies:
strtok3: 8.1.0
'@tokenizer/inflate': 0.4.1
strtok3: 10.3.5
token-types: 6.1.2
uint8array-extras: 1.5.0
transitivePeerDependencies:
- supports-color
fill-range@7.1.1:
dependencies:
@ -10903,7 +10922,7 @@ snapshots:
jiti@2.6.1: {}
jose@5.9.6: {}
jose@5.10.0: {}
jose@6.2.1: {}
@ -10957,7 +10976,7 @@ snapshots:
'@types/json-schema': 7.0.15
'@types/lodash': 4.17.23
is-glob: 4.0.3
js-yaml: 4.1.1
js-yaml: 4.3.0
lodash: 4.17.23
minimist: 1.2.8
prettier: 3.8.1
@ -11642,12 +11661,12 @@ snapshots:
pathe@2.0.3: {}
payload@3.79.0(graphql@16.12.0)(typescript@5.7.3):
payload@3.82.0(graphql@16.12.0)(typescript@5.7.3):
dependencies:
'@next/env': 15.5.12
'@payloadcms/translations': 3.79.0
'@payloadcms/translations': 3.82.0
'@types/busboy': 1.5.4
ajv: 8.17.1
ajv: 8.18.0
bson-objectid: 2.0.4
busboy: 1.6.0
ci-info: 4.4.0
@ -11655,34 +11674,33 @@ snapshots:
croner: 9.1.0
dataloader: 2.2.3
deepmerge: 4.3.1
file-type: 19.3.0
file-type: 21.3.4
get-tsconfig: 4.8.1
graphql: 16.12.0
http-status: 2.1.0
image-size: 2.0.2
ipaddr.js: 2.2.0
jose: 5.9.6
jose: 5.10.0
json-schema-to-typescript: 15.0.3
minimist: 1.2.8
path-to-regexp: 6.3.0
pino: 9.14.0
pino-pretty: 13.1.2
pluralize: 8.0.0
qs-esm: 7.0.2
qs-esm: 8.0.1
range-parser: 1.2.1
sanitize-filename: 1.6.3
ts-essentials: 10.0.3(typescript@5.7.3)
tsx: 4.21.0
undici: 7.18.2
undici: 7.24.4
uuid: 10.0.0
ws: 8.19.0
transitivePeerDependencies:
- bufferutil
- supports-color
- typescript
- utf-8-validate
peek-readable@5.4.2: {}
pg-cloudflare@1.3.0:
optional: true
@ -11868,7 +11886,7 @@ snapshots:
punycode@2.3.1: {}
qs-esm@7.0.2: {}
qs-esm@8.0.1: {}
qs@6.15.0:
dependencies:
@ -11965,7 +11983,7 @@ snapshots:
react-error-boundary@4.1.2(react@19.2.4):
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.29.7
react: 19.2.4
react-error-boundary@6.1.1(react@19.2.4):
@ -12007,10 +12025,10 @@ snapshots:
react-select@5.9.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.29.7
'@emotion/cache': 11.14.0
'@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.4)
'@floating-ui/dom': 1.7.5
'@floating-ui/dom': 1.8.0
'@types/react-transition-group': 4.4.12(@types/react@19.2.14)
memoize-one: 6.0.0
prop-types: 15.8.1
@ -12032,7 +12050,7 @@ snapshots:
react-transition-group@4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.29.7
dom-helpers: 5.2.1
loose-envify: 1.4.0
prop-types: 15.8.1
@ -12552,10 +12570,9 @@ snapshots:
strip-json-comments@5.0.3: {}
strtok3@8.1.0:
strtok3@10.3.5:
dependencies:
'@tokenizer/token': 0.3.0
peek-readable: 5.4.2
styled-jsx@5.1.6(@babel/core@7.29.0)(babel-plugin-macros@3.1.0)(react@19.2.4):
dependencies:
@ -12593,6 +12610,8 @@ snapshots:
dependencies:
real-require: 0.2.0
throttleit@2.1.0: {}
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
@ -12758,14 +12777,12 @@ snapshots:
undici-types@6.21.0: {}
undici@5.29.0:
dependencies:
'@fastify/busboy': 2.1.1
undici@7.18.2: {}
undici@6.27.0: {}
undici@7.21.0: {}
undici@7.24.4: {}
unicorn-magic@0.3.0: {}
unist-util-is@6.0.1:

38
scripts/seed-loader.mjs Normal file
View file

@ -0,0 +1,38 @@
/**
* Seed-only Node module loader: stubs `next/cache` with no-ops.
*
* The `post` and `tag` collections call `revalidateTag()` (via
* `src/utilities/documents.ts`) inside their `afterChange` hooks. `revalidateTag`
* throws "static generation store missing" when called outside a Next.js request
* scope — which is exactly what happens in a standalone seed script, aborting
* every `payload.create`. Rather than editing the collections (owned elsewhere),
* we replace `next/cache` with harmless no-ops for the duration of the seed.
*
* Registered via `--import=./scripts/seed-loader.mjs` in the `db:seed:posts`
* npm script; it affects only that process.
*/
import { register } from 'node:module'
const shim =
'data:text/javascript,' +
encodeURIComponent(
[
'export const revalidateTag = () => {};',
'export const revalidatePath = () => {};',
'export const unstable_cache = (fn) => fn;',
'export const unstable_noStore = () => {};',
].join('\n'),
)
const hooks =
'data:text/javascript,' +
encodeURIComponent(
`export function resolve(specifier, context, nextResolve) {
if (specifier === 'next/cache') {
return { url: ${JSON.stringify(shim)}, shortCircuit: true }
}
return nextResolve(specifier, context)
}`,
)
register(hooks)

561
scripts/seed-posts.mjs Normal file
View file

@ -0,0 +1,561 @@
/**
* Seeds the `post` / `tag` / `media` collections from a LIVE fetch of
* jorgesolla.com.br, classifying each article by category + tags.
*
* Source of truth is the live site (fetched when the script runs), NOT the
* reference `noticias-jorgesolla.md`. Primary source is the public WordPress
* REST API (`/wp-json/wp/v2/posts`), with an HTML category-archive crawl as a
* fallback if the API is blocked/unavailable.
*
* Safety model (mirrors scripts/guard-dev-db.mjs and scripts/db-pull.mjs):
* - This script WRITES (creates posts/tags/media). It refuses to run against
* a non-local DATABASE_URL unless ALLOW_REMOTE_DB=true is set explicitly.
* - Idempotent: re-running looks up posts/tags/media by slug/filename and
* skips (or reuses) what already exists, so it never duplicates.
*
* HTML -> Lexical: the WordPress `content.rendered` HTML is converted to
* Payload Lexical richText with `convertHTMLToLexical` from
* `@payloadcms/richtext-lexical`, using the project's default editor config
* (via `editorConfigFactory.default`) and `jsdom` as the DOM implementation.
* The frontend renders `body` with `convertLexicalToHTML`, so this round-trips.
*
* Usage:
* pnpm db:seed:posts
* (against a remote DB, on purpose:) ALLOW_REMOTE_DB=true pnpm db:seed:posts
*/
import { config as loadEnv } from 'dotenv'
import { JSDOM } from 'jsdom'
import { convertHTMLToLexical, editorConfigFactory } from '@payloadcms/richtext-lexical'
import { getPayload } from 'payload'
// Mirror Next.js precedence (.env.local wins over .env) without clobbering a
// DATABASE_URL already set in the real environment.
loadEnv({ path: '.env.local' })
loadEnv({ path: '.env' })
const config = (await import('../src/payload.config.ts')).default
// ---------------------------------------------------------------------------
// DB-safety guard (same shape as scripts/guard-dev-db.mjs)
// ---------------------------------------------------------------------------
const OVERRIDE_FLAG = 'ALLOW_REMOTE_DB'
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', 'postgres'])
const die = (message) => {
console.error(`\n[seed:posts] ${message}\n`)
process.exit(1)
}
const assertLocalDatabase = () => {
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) die('DATABASE_URL is not set. Refusing to seed.')
if (process.env[OVERRIDE_FLAG] === 'true' || process.env[OVERRIDE_FLAG] === '1') {
console.warn(
`\n[seed:posts] ${OVERRIDE_FLAG} is set — seeding a remote database on purpose. Be careful.\n`,
)
return
}
let host
try {
host = new URL(databaseUrl).hostname
} catch {
die('DATABASE_URL is not a valid connection string.')
}
if (!LOCAL_HOSTS.has(host)) {
die(
`DATABASE_URL points at a non-local host ("${host}").\n` +
'This seed WRITES data (posts/tags/media) and is meant for the local Postgres.\n\n' +
' 1. pnpm db:start\n' +
' 2. set DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo in .env.local\n\n' +
`If you REALLY mean to seed a remote database, re-run with:\n ${OVERRIDE_FLAG}=true pnpm db:seed:posts`,
)
}
console.log(`[seed:posts] OK — using local database host "${host}".`)
}
// ---------------------------------------------------------------------------
// Taxonomy (Section 7): the categories + control tag, and the per-article map.
// Keys are the WordPress slugs of the live articles. Every post gets exactly
// one required `category`; `tags` is optional (currently only `eleitoral`).
// The `eleitoral` tag marks pre-candidacy / campaign / PGP / party-event
// content so Francisco can hide it all with one toggle in the electoral period.
// ---------------------------------------------------------------------------
const TAXONOMY_TAGS = [
{ slug: 'saude', name: 'Saúde' },
{ slug: 'cultura', name: 'Cultura' },
{ slug: 'desenvolvimento', name: 'Desenvolvimento' },
{ slug: 'politica', name: 'Política' },
{ slug: 'educacao', name: 'Educação' },
{ slug: 'seguranca', name: 'Segurança' },
{ slug: 'direitos-trabalhistas', name: 'Direitos trabalhistas' },
{ slug: 'eleitoral', name: 'Eleitoral' },
]
const DEFAULT_CATEGORY = 'politica'
/** WP slug -> { category, tags }. See report/plan Section 7. */
const CLASSIFICATION = {
// Health
'lula-jeronimo-e-solla-inauguram-hospital-do-litoral-norte-da-bahia': { category: 'saude' },
'solla-reforca-luta-por-cumprimento-do-piso-da-enfermagem-em-audiencia-na-camara': {
category: 'saude',
},
'emenda-de-solla-garante-maquinas-de-costura-a-associacao-de-fibromialgia': { category: 'saude' },
'solla-comemora-25-anos-da-politica-nacional-de-sangue-neste-16-de-abril': { category: 'saude' },
'fora-trump-em-defesa-da-saude-e-da-paz': { category: 'saude' },
'portaria-autoriza-retorno-de-mais-de-300-servidores-a-funasa': { category: 'saude' },
'missao-internacional-com-solla-lula-e-jeronimo-fortalece-producao-de-medicamentos-estrategicos-e-projeta-a-bahia-como-polo-de-biofarmacos':
{ category: 'saude' },
'solla-desembarca-na-india-em-missao-oficial-com-lula-pela-asia': { category: 'saude' },
// Culture
'solla-exalta-primeiro-ano-de-salvador-como-capital-do-brasil-no-2-de-julho': {
category: 'cultura',
},
'audiencia-publica-debate-plano-nacional-de-cultura': { category: 'cultura' },
// Development
'solla-e-jeronimo-inauguram-mercado-publico-de-ubata': { category: 'desenvolvimento' },
'governo-da-bahia-anuncia-pacote-de-quase-r-2-bi': { category: 'desenvolvimento' },
'com-solla-comissao-acompanha-retomada-da-producao-no-enseada-em-maragogipe': {
category: 'desenvolvimento',
},
'recomprar-a-refinaria-de-mataripe-e-defender-a-bahia-e-a-soberania-do-brasil': {
category: 'desenvolvimento',
},
'solla-protocola-pec-para-garantir-controle-estatal-sobre-petroleo-e-derivados-no-brasil': {
category: 'desenvolvimento',
},
'solla-leva-comissao-da-camara-para-nova-visita-tecnica-ao-estaleiro-enseada': {
category: 'desenvolvimento',
},
'entrevista-solla-fala-sobre-a-retomada-dos-investimentos-publicos-no-pais': {
category: 'desenvolvimento',
},
'obra-de-ampliacao-do-sistema-de-abastecimento-do-rio-paraguacu-itirucu-jaguaquara': {
category: 'desenvolvimento',
},
'governo-lula-corrige-erro-de-antecessor-diz-solla-sobre-suspensao-da-importacao-de-cacau': {
category: 'desenvolvimento',
},
// Politics (with electoral control where applicable)
'programa-de-governo-participativo-reune-multidao-em-periperi': {
category: 'politica',
tags: ['eleitoral'],
},
'clube-2004-celebra-a-democracia-com-o-lancamento-da-pre-candidatura-de-jorge-solla-a-deputado-federal':
{ category: 'politica', tags: ['eleitoral'] },
'articulada-por-solla-filiacao-de-zilar-portela-ao-pt-marca-lancamento-de-pre-candidatura-a-deputada-estadual':
{ category: 'politica', tags: ['eleitoral'] },
'articulacao-de-solla-fortalece-pt-com-filiacao-da-lideranca-sindical-zilar-portela': {
category: 'politica',
tags: ['eleitoral'],
},
'federacao-brasil-esperanca-exige-defesa-do-nosso-projeto-politico': {
category: 'politica',
tags: ['eleitoral'],
},
'com-presenca-de-solla-e-aliados-aniversario-do-pt-de-jacobina-reune-liderancas-e-reafirma-compromisso-o-desenvolvimento-da-bahia':
{ category: 'politica', tags: ['eleitoral'] },
'pt-de-jacobina-celebra-aniversario-com-presenca-de-jorge-solla-e-do-secretario-do-governo-lula-julio-pinheiro':
{ category: 'politica', tags: ['eleitoral'] },
'solla-e-indicado-em-estudo-da-arko-advice-como-um-politico-da-elite-parlamentar': {
category: 'politica',
},
'solla-rebate-ataques-de-ze-coca-e-destaca-investimentos-do-mandato-em-itirucu-e-na-regiao-de-jequie':
{ category: 'politica' },
// Education
'solla-conduz-cffc-em-visita-tecnica-a-primeira-faculdade-de-medicina-do-brasil': {
category: 'educacao',
},
'ao-lado-de-camilo-santana-jorge-solla-acompanha-avancos-da-educacao-federal-em-barreiras-e-pocoes':
{ category: 'educacao' },
'solla-articula-no-mec-avancos-para-expansao-da-educacao-superior-e-da-educacao-profissional-na-bahia':
{ category: 'educacao' },
'solla-comemora-anuncio-de-construcao-de-novo-campus-federal-em-jequie': { category: 'educacao' },
'apresentacao-do-plano-de-restauracao-arquitetonica-da-famed-ufba': { category: 'educacao' },
'solla-participa-das-celebracoes-pelos-218-anos-da-faculdade-de-medicina-da-ufba': {
category: 'educacao',
},
'solla-articula-agenda-de-dirigentes-da-ufba-no-ministerio-da-cultura-para-viabilizar-reforma-de-alas-da-faculdade-de-medicina':
{ category: 'educacao' },
// Security
'camara-aprova-pec-da-seguranca-publica-e-solla-destaca-avanco-da-proposta-do-governo-lula': {
category: 'seguranca',
},
// Labor rights
'fim-da-escala-6x1-tambem-e-questao-de-saude': { category: 'direitos-trabalhistas' },
'jorge-solla-apresenta-pec-que-eleva-pensao-por-morte': { category: 'direitos-trabalhistas' },
'da-asia-solla-manifesta-apoio-a-servidores-demitidos-por-bruno-reis': {
category: 'direitos-trabalhistas',
},
}
// ---------------------------------------------------------------------------
// Live fetch — WordPress REST API (primary)
// ---------------------------------------------------------------------------
const BASE_URL = 'https://jorgesolla.com.br'
const USER_AGENT = 'teqo-content-migration-bot/1.0 (+https://jorgesolla.com.br)'
const stripHtml = (html) =>
new JSDOM(`<!DOCTYPE html><body>${html || ''}</body>`).window.document.body.textContent
.replace(/\s+/g, ' ')
.trim()
/** Normalized article shape used by both the REST and HTML strategies. */
/** @typedef {{ slug: string, title: string, date: string|null, html: string, coverUrl: string|null, coverAlt: string|null }} Article */
async function fetchViaRestApi() {
/** @type {Article[]} */
const articles = []
let page = 1
let totalPages = 1
do {
const url = `${BASE_URL}/wp-json/wp/v2/posts?per_page=100&page=${page}&_embed`
const res = await fetch(url, { headers: { 'User-Agent': USER_AGENT } })
if (!res.ok) throw new Error(`REST API returned ${res.status} for page ${page}`)
if (page === 1) {
totalPages = Number(res.headers.get('x-wp-totalpages') || '1') || 1
}
const data = await res.json()
if (!Array.isArray(data) || data.length === 0) break
for (const item of data) {
const embeddedMedia = item?._embedded?.['wp:featuredmedia']?.[0]
articles.push({
slug: item.slug,
title: stripHtml(item?.title?.rendered) || item.slug,
date: item.date || null,
html: item?.content?.rendered || '',
coverUrl: embeddedMedia?.source_url || null,
coverAlt: embeddedMedia?.alt_text || null,
})
}
page += 1
} while (page <= totalPages)
return articles
}
// ---------------------------------------------------------------------------
// Live fetch — HTML category-archive crawl (fallback)
// Mirrors conteudo-atual-scraping/scrape_jorgesolla.py.
// ---------------------------------------------------------------------------
const FALLBACK_CATEGORIES = ['noticias', 'uncategorized']
const PERMALINK_RE = new RegExp(`^${BASE_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/\\d{4}/\\d{2}/\\d{2}/[^/]+/?$`)
async function fetchViaHtmlArchive() {
/** @type {Article[]} */
const articles = []
const seen = new Set()
for (const category of FALLBACK_CATEGORIES) {
let pageNum = 1
while (pageNum <= 30) {
const url =
pageNum === 1
? `${BASE_URL}/category/${category}/`
: `${BASE_URL}/category/${category}/page/${pageNum}/`
const res = await fetch(url, { headers: { 'User-Agent': USER_AGENT } })
if (res.status === 404) break
if (!res.ok) throw new Error(`archive ${url} returned ${res.status}`)
const doc = new JSDOM(await res.text()).window.document
const links = new Set()
for (const heading of doc.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
const a = heading.querySelector('a[href]')
if (!a) continue
const href = a.href.replace(/\/?$/, '/')
if (PERMALINK_RE.test(href)) links.add(href)
}
const fresh = [...links].filter((href) => !seen.has(href))
if (fresh.length === 0) break
for (const href of fresh) {
seen.add(href)
const article = await fetchSinglePage(href)
if (article) articles.push(article)
}
pageNum += 1
}
}
return articles
}
async function fetchSinglePage(url) {
const res = await fetch(url, { headers: { 'User-Agent': USER_AGENT } })
if (!res.ok) return null
const doc = new JSDOM(await res.text()).window.document
const slug = url.replace(/\/$/, '').split('/').pop()
const contentEl =
doc.querySelector('.entry-content') || doc.querySelector('article') || doc.body
const time = doc.querySelector('time[datetime]')
const firstImg = contentEl.querySelector('img')
return {
slug,
title: stripHtml(doc.querySelector('h1')?.innerHTML || slug),
date: time?.getAttribute('datetime') || null,
html: contentEl.innerHTML || '',
coverUrl: firstImg ? new URL(firstImg.getAttribute('src'), url).href : null,
coverAlt: firstImg?.getAttribute('alt') || null,
}
}
// ---------------------------------------------------------------------------
// Content processing: split WP content into { subtitle, coverUrl, bodyHtml }
// ---------------------------------------------------------------------------
/**
* WordPress articles on this site follow a consistent shape:
* <p>dek/subtitle (optional)</p>
* <figure class="wp-block-image"><img cover></figure>
* ...body...
* The dek and the cover image are lifted out so they don't render twice, and
* the remaining HTML becomes the Lexical body.
*/
function processContent(article) {
const dom = new JSDOM(`<!DOCTYPE html><body><div id="root">${article.html}</div></body>`)
const doc = dom.window.document
const root = doc.getElementById('root')
// Drop empty paragraphs and empty gallery figures (WP leaves placeholders).
for (const el of root.querySelectorAll('p, figure')) {
if (el.querySelector('img')) continue
if (el.textContent.replace(/\u00a0/g, ' ').trim() === '') el.remove()
}
// Cover: prefer the REST featured media; else the first inline image.
const firstImg = root.querySelector('img')
const coverUrl = article.coverUrl || firstImg?.getAttribute('src') || null
// Subtitle (dek): this site places the dek as a first <p> immediately followed
// by the cover-image figure. Only treat the first paragraph as the subtitle
// when that exact shape holds — otherwise the first paragraph is real body.
let subtitle = null
const firstBlock = root.firstElementChild
if (firstBlock && firstBlock.tagName === 'P') {
const next = firstBlock.nextElementSibling
const nextIsImage = !!next && (next.tagName === 'IMG' || !!next.querySelector?.('img'))
const text = firstBlock.textContent.replace(/\s+/g, ' ').trim()
if (nextIsImage && text && !/^por\s+jorge\s+solla/i.test(text)) {
subtitle = text
firstBlock.remove()
}
}
// Strip ALL images (and their figure wrappers): the cover is stored separately
// on the post, and inline external images can't be resolved to `media` docs, so
// `convertHTMLToLexical` would emit invalid "pending" upload nodes. Dropping
// them keeps the body as valid text/heading/list/link/quote content.
for (const img of root.querySelectorAll('img')) {
;(img.closest('figure') || img).remove()
}
// Remove any figures/paragraphs left empty by the image removal.
for (const el of root.querySelectorAll('figure, p')) {
if (el.querySelector('img')) continue
if (el.textContent.replace(/\u00a0/g, ' ').trim() === '') el.remove()
}
return { subtitle, coverUrl, bodyHtml: root.innerHTML.trim() }
}
// ---------------------------------------------------------------------------
// Media upload (idempotent by filename)
// ---------------------------------------------------------------------------
const EXT_BY_MIME = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'image/gif': '.gif',
'image/avif': '.avif',
}
/** @returns {Promise<{ id: string|number, created: boolean } | null>} */
async function ensureCoverMedia(payload, { slug, coverUrl, alt }) {
if (!coverUrl) return null
const res = await fetch(coverUrl, { headers: { 'User-Agent': USER_AGENT } })
if (!res.ok) {
console.warn(`[seed:posts] cover download failed (${res.status}) for ${slug}: ${coverUrl}`)
return null
}
const mimetype = (res.headers.get('content-type') || 'image/jpeg').split(';')[0].trim()
const urlExt = new URL(coverUrl).pathname.match(/\.[a-z0-9]+$/i)?.[0]
const ext = EXT_BY_MIME[mimetype] || urlExt || '.jpg'
const filename = `${slug}${ext}`
// Idempotency: reuse an existing media doc with the same (stable) filename
// if it's already there (e.g. from a previous partial run).
const existing = await payload.find({
collection: 'media',
where: { filename: { equals: filename } },
limit: 1,
depth: 0,
})
if (existing.docs.length > 0) return { id: existing.docs[0].id, created: false }
const buffer = Buffer.from(await res.arrayBuffer())
const media = await payload.create({
collection: 'media',
data: { alt: alt || slug },
file: { data: buffer, mimetype, name: filename, size: buffer.length },
})
return { id: media.id, created: true }
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
assertLocalDatabase()
const payload = await getPayload({ config })
const editorConfig = await editorConfigFactory.default({ config: payload.config })
const htmlToLexical = (html) => convertHTMLToLexical({ editorConfig, html: html || '', JSDOM })
// 1. Ensure taxonomy tags exist (idempotent by slug) -> slug -> id.
console.log('[seed:posts] Ensuring taxonomy tags...')
const tagIdBySlug = {}
let tagsCreated = 0
for (const tag of TAXONOMY_TAGS) {
const found = await payload.find({
collection: 'tag',
where: { slug: { equals: tag.slug } },
limit: 1,
depth: 0,
})
if (found.docs.length > 0) {
tagIdBySlug[tag.slug] = found.docs[0].id
continue
}
const created = await payload.create({
collection: 'tag',
data: { name: tag.name, slug: tag.slug, hidden: false },
})
tagIdBySlug[tag.slug] = created.id
tagsCreated += 1
}
console.log(`[seed:posts] ${tagsCreated} tag(s) created, ${TAXONOMY_TAGS.length} total.`)
// 2. Live fetch (REST API primary, HTML crawl fallback).
console.log('[seed:posts] Fetching articles from jorgesolla.com.br (WP REST API)...')
let articles = []
try {
articles = await fetchViaRestApi()
console.log(`[seed:posts] REST API returned ${articles.length} article(s).`)
} catch (err) {
console.warn(`[seed:posts] REST API failed (${err.message}). Falling back to HTML crawl...`)
}
if (articles.length === 0) {
articles = await fetchViaHtmlArchive()
console.log(`[seed:posts] HTML crawl returned ${articles.length} article(s).`)
}
if (articles.length === 0) die('No articles fetched from the live site. Aborting.')
// 3. Upsert each article.
let postsCreated = 0
let postsSkipped = 0
let mediaCreated = 0
const unmapped = []
for (const article of articles) {
const { slug, title } = article
const mapping = CLASSIFICATION[slug]
if (!mapping) unmapped.push(slug)
const categorySlug = mapping?.category || DEFAULT_CATEGORY
const tagSlugs = mapping?.tags || []
const categoryId = tagIdBySlug[categorySlug]
const tagIds = tagSlugs.map((s) => tagIdBySlug[s]).filter(Boolean)
const existing = await payload.find({
collection: 'post',
where: { slug: { equals: slug } },
limit: 1,
depth: 0,
})
if (existing.docs.length > 0) {
postsSkipped += 1
continue
}
const { subtitle, coverUrl, bodyHtml } = processContent(article)
const cover = await ensureCoverMedia(payload, {
slug,
coverUrl,
alt: article.coverAlt || title,
})
if (cover?.created) mediaCreated += 1
await payload.create({
collection: 'post',
data: {
title,
slug,
type: 'noticia',
category: categoryId,
tags: tagIds,
subtitle: subtitle || undefined,
coverImage: cover?.id || undefined,
publishedDate: article.date || undefined,
body: htmlToLexical(bodyHtml),
_status: 'published',
},
})
postsCreated += 1
console.log(
`[seed:posts] + ${slug} [${categorySlug}${tagSlugs.length ? ' | ' + tagSlugs.join(',') : ''}]`,
)
}
// 4. Report.
console.log('\n[seed:posts] ==================== SUMMARY ====================')
console.log(`[seed:posts] Articles fetched : ${articles.length}`)
console.log(`[seed:posts] Posts created : ${postsCreated}`)
console.log(`[seed:posts] Posts skipped : ${postsSkipped} (already existed)`)
console.log(`[seed:posts] Media created : ${mediaCreated}`)
console.log(`[seed:posts] Tags created : ${tagsCreated} (of ${TAXONOMY_TAGS.length})`)
if (unmapped.length > 0) {
console.warn(
`\n[seed:posts] WARNING: ${unmapped.length} live slug(s) not in the taxonomy map ` +
`(defaulted to "${DEFAULT_CATEGORY}"):`,
)
for (const slug of unmapped) console.warn(`[seed:posts] - ${slug}`)
} else {
console.log('[seed:posts] All fetched slugs were mapped in the taxonomy.')
}
console.log('[seed:posts] =================================================\n')
process.exit(0)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})

View file

@ -1,16 +1,19 @@
import { Footer } from '@/components/Footer'
import { Header } from '@/components/Header'
import { SiteHeader } from '@/components/SiteHeader'
import React from 'react'
import '../styles.css'
export default async function RootLayout(props: { children: React.ReactNode }) {
const { children } = props
// The root `(frontend)` layout locks the document with `overflow-hidden`, so
// the home route owns an internal scroll container. `relative` anchors the
// overlay header over the hero.
return (
<>
<Header />
<div className="relative h-dvh w-full overflow-y-auto">
<SiteHeader variant="overlay" />
{children}
<Footer />
</>
</div>
)
}

View file

@ -1,30 +1,82 @@
import { PostCard } from '@/components/PostCard'
import { Media } from '@/payload-types'
import { getCachedDocumentById } from '@/utilities/documents'
import { getCachedGlobal } from '@/utilities/globals'
import { getTypePath, getVisiblePosts } from '@/utilities/posts'
import Image from 'next/image'
import Link from 'next/link'
const LATEST_NEWS_LIMIT = 6
export default async function HomePage() {
const payload = await getCachedGlobal('home', 2)()
const home = await getCachedGlobal('home', 2)()
let image: Media
if (typeof payload.image === 'number') {
image = await getCachedDocumentById('media', `${payload.image}`)()
let heroImage: Media | null = null
if (typeof home.image === 'number') {
heroImage = await getCachedDocumentById('media', `${home.image}`)()
} else {
image = payload.image
heroImage = home.image ?? null
}
const latestNews = (await getVisiblePosts())
.filter((post) => post.type === 'noticia')
.slice(0, LATEST_NEWS_LIMIT)
return (
<main className="w-screen">
{image?.url ? (
<Image
className="w-screen"
src={image.url}
alt={image.alt}
width={image.width!}
height={image.height!}
/>
) : null}
<main className="w-full">
<section className="relative flex min-h-[68vh] w-full flex-col items-center justify-center gap-8 px-4 pt-24 pb-16 text-center text-(--site-header-foreground) sm:px-6 sm:pt-28 lg:px-8">
<div className="flex max-w-4xl flex-col items-center gap-5">
<h1 className="text-4xl font-extrabold leading-tight tracking-tight text-balance sm:text-5xl lg:text-6xl">
O mandato do tamanho da Bahia
</h1>
<p className="max-w-2xl text-lg leading-relaxed text-(--site-header-muted) sm:text-xl">
Notícias, campanhas e a atuação do deputado Jorge Solla por uma Bahia mais justa.
</p>
<Link
href={getTypePath('noticia')}
className="inline-flex items-center justify-center rounded-md bg-white/15 px-5 py-2.5 text-sm font-semibold text-(--site-header-foreground) ring-1 ring-white/25 backdrop-blur transition-colors hover:bg-white/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
>
Ver notícias
</Link>
</div>
{heroImage?.url ? (
<div className="w-full max-w-3xl overflow-hidden rounded-2xl shadow-2xl ring-1 ring-white/15">
<Image
className="h-auto w-full object-cover"
src={heroImage.url}
alt={heroImage.alt}
width={heroImage.width ?? 1200}
height={heroImage.height ?? 675}
priority
/>
</div>
) : null}
</section>
<section data-theme="editorial" className="w-full bg-background text-foreground">
<div className="mx-auto w-full max-w-5xl px-4 py-14 sm:px-6 lg:px-8 lg:py-16">
<div className="mb-8 flex flex-wrap items-end justify-between gap-3">
<h2 className="border-none text-left text-3xl font-bold tracking-tight sm:text-4xl">
Últimas notícias
</h2>
<Link
href={getTypePath('noticia')}
className="text-sm font-semibold text-primary underline-offset-4 hover:underline"
>
Ver todas
</Link>
</div>
{latestNews.length ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{latestNews.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
) : (
<p className="text-muted-foreground">Nenhuma notícia publicada ainda.</p>
)}
</div>
</section>
</main>
)
}

View file

@ -0,0 +1,262 @@
import { getCachedDocumentById } from '@/utilities/documents'
import { getCachedGlobal } from '@/utilities/globals'
import { extractFirstImageFromLexical } from '@/utilities/extractFirstImageFromLexical'
import { stripTrailingSlash, toAbsoluteUrl, truncate } from '@/utilities/seo'
import {
POST_TYPE_LABELS,
formatPostDate,
getCachedPostBySlug,
getCategoryListingPath,
getCategoryName,
getCategorySlug,
getPostCanonicalPath,
getTypePath,
getVisiblePosts,
isPostType,
isPostVisible,
} from '@/utilities/posts'
import { SiteHeader } from '@/components/SiteHeader'
import type { Crumb } from '@/components/SiteHeader'
import { Badge } from '@/components/ui/badge'
import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html'
import type { Media, Post } from '@/payload-types'
import type { Metadata } from 'next'
import type { Article, WithContext } from 'schema-dts'
import Image from 'next/image'
import { notFound, redirect } from 'next/navigation'
export const dynamicParams = true
const MAX_DESCRIPTION_LENGTH = 200
type RouteParams = { type: string; category: string; slug: string }
export async function generateStaticParams(): Promise<RouteParams[]> {
const posts = await getVisiblePosts()
return posts
.map((post): RouteParams | null => {
const category = getCategorySlug(post)
if (!category || !post.slug) return null
return { type: post.type, category, slug: post.slug }
})
.filter((entry): entry is RouteParams => entry !== null)
}
type Metadatum = Awaited<ReturnType<ReturnType<typeof getCachedGlobal<'metadata'>>>>
/** Resolve the OG image: cover image first, then first body image, then the global fallback. */
async function resolveOgImage(post: Post, globalImage: Metadatum['image']): Promise<Media | null> {
const cover =
typeof post.coverImage === 'object' && post.coverImage !== null ? post.coverImage : null
if (cover) return cover
const bodyImage = post.body ? extractFirstImageFromLexical(post.body) : null
if (bodyImage) return bodyImage
if (globalImage) {
return typeof globalImage === 'number'
? await getCachedDocumentById('media', String(globalImage))()
: globalImage
}
return null
}
export async function generateMetadata({
params,
}: {
params: Promise<RouteParams>
}): Promise<Metadata> {
const { slug } = await params
const post = await getCachedPostBySlug(slug)()
if (!post || !isPostVisible(post)) return {}
const globalMetadata = await getCachedGlobal('metadata')()
const siteUrl = stripTrailingSlash(globalMetadata.URL)
const canonicalPath = getPostCanonicalPath(post)
const canonicalUrl = canonicalPath ? `${siteUrl}${canonicalPath}` : siteUrl
const image = await resolveOgImage(post, globalMetadata.image)
const imageUrl = image?.url ? toAbsoluteUrl(image.url, siteUrl) : undefined
const description = post.subtitle
? truncate(post.subtitle, MAX_DESCRIPTION_LENGTH)
: globalMetadata.description
const title = `${post.title} | ${globalMetadata.openGraph.siteName}`
const categoryName = getCategoryName(post)
const keywords = [
...globalMetadata.keywords
.map((k) => (typeof k === 'string' ? k : k.keyword))
.filter((k): k is string => typeof k === 'string'),
post.title,
...(categoryName ? [categoryName] : []),
]
const ogImages = imageUrl
? [
{
url: imageUrl,
secureUrl: imageUrl,
width: image?.width ?? undefined,
height: image?.height ?? undefined,
alt: image?.alt,
type: image?.mimeType ?? undefined,
},
]
: []
return {
title,
description,
keywords,
alternates: {
canonical: canonicalUrl,
},
openGraph: {
type: 'article',
locale: 'pt-BR',
url: canonicalUrl,
siteName: globalMetadata.openGraph.siteName,
title,
description,
images: ogImages,
publishedTime: post.publishedDate ?? post.createdAt,
modifiedTime: post.updatedAt,
},
twitter: {
card: 'summary_large_image',
title,
description,
creator: globalMetadata.twitter.creator,
images: ogImages.map((img) => img.url),
},
}
}
export default async function Page({ params }: { params: Promise<RouteParams> }) {
const { type, category, slug } = await params
if (!isPostType(type)) return notFound()
const post = await getCachedPostBySlug(slug)()
if (!post || !isPostVisible(post)) return notFound()
const canonicalPath = getPostCanonicalPath(post)
if (!canonicalPath) return notFound()
// Redirect stale/mismatched URLs to the canonical type + category slug.
if (canonicalPath !== `/${type}/${category}/${slug}`) {
return redirect(canonicalPath)
}
const globalMetadata = await getCachedGlobal('metadata')()
const siteUrl = stripTrailingSlash(globalMetadata.URL)
const canonicalUrl = `${siteUrl}${canonicalPath}`
const ogImage = await resolveOgImage(post, globalMetadata.image)
const ogImageUrl = ogImage?.url ? toAbsoluteUrl(ogImage.url, siteUrl) : undefined
const categoryName = getCategoryName(post)
const categorySlug = getCategorySlug(post)
const publishedDate = formatPostDate(post.publishedDate)
const coverImage =
typeof post.coverImage === 'object' && post.coverImage !== null ? post.coverImage : null
const breadcrumbs: Crumb[] = [
{ label: POST_TYPE_LABELS[post.type], href: getTypePath(post.type) },
...(categoryName && categorySlug
? [{ label: categoryName, href: getCategoryListingPath(post.type, categorySlug) }]
: []),
{ label: post.title },
]
const jsonLd: WithContext<Article> = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
...(post.subtitle ? { description: truncate(post.subtitle, MAX_DESCRIPTION_LENGTH) } : {}),
inLanguage: 'pt-BR',
url: canonicalUrl,
mainEntityOfPage: { '@type': 'WebPage', '@id': canonicalUrl },
datePublished: post.publishedDate ?? post.createdAt,
dateModified: post.updatedAt,
...(ogImageUrl ? { image: [ogImageUrl] } : {}),
author: {
'@type': 'Organization',
name: globalMetadata.openGraph.siteName,
url: siteUrl,
},
publisher: {
'@type': 'Organization',
name: globalMetadata.openGraph.siteName,
url: siteUrl,
},
}
const bodyHTML = post.body ? convertLexicalToHTML({ data: post.body }) : ''
// Escape "<" so no field value (e.g. a title containing "</script>") can break out of the JSON-LD script tag.
const jsonLdString = JSON.stringify(jsonLd).replace(/</g, '\\u003c')
return (
<>
<SiteHeader breadcrumbs={breadcrumbs} />
<main className="w-full">
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdString }} />
<section className="border-b border-border bg-muted/30">
<div className="mx-auto w-full max-w-3xl px-4 py-14 sm:px-6 lg:px-8 lg:py-16">
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3 text-xs font-medium text-muted-foreground">
{categoryName ? <Badge variant="secondary">{categoryName}</Badge> : null}
{publishedDate ? (
<time
className="uppercase tracking-wide"
dateTime={post.publishedDate ?? undefined}
>
{publishedDate}
</time>
) : null}
</div>
<h1 className="text-3xl font-bold leading-tight tracking-tight text-balance sm:text-4xl lg:text-5xl">
{post.title}
</h1>
{post.subtitle ? (
<p className="max-w-2xl text-lg leading-relaxed text-muted-foreground">
{post.subtitle}
</p>
) : null}
</div>
</div>
</section>
{coverImage?.url ? (
<figure className="mx-auto w-full max-w-4xl px-4 pt-10 sm:px-6 lg:px-8 lg:pt-12">
<div className="relative aspect-[16/9] w-full overflow-hidden rounded-xl bg-muted ring-1 ring-border">
<Image
src={coverImage.url}
alt={coverImage.alt ?? post.title}
fill
priority
sizes="(max-width: 1024px) 100vw, 896px"
className="object-cover"
/>
</div>
</figure>
) : null}
<section className="mx-auto w-full max-w-3xl px-4 py-12 sm:px-6 lg:px-8 lg:py-14">
{bodyHTML ? (
<article
className="space-y-6 text-lg leading-8 text-foreground/90 [&_a]:font-medium [&_a]:text-primary [&_a]:underline-offset-4 [&_a:hover]:underline [&_blockquote]:border-l-4 [&_blockquote]:border-primary/40 [&_blockquote]:pl-4 [&_h1]:text-3xl [&_h1]:font-bold [&_h2]:border-none [&_h2]:text-2xl [&_h2]:font-semibold [&_h2]:text-primary [&_h3]:text-xl [&_h3]:font-semibold [&_img]:rounded-lg [&_li]:ml-5 [&_li]:list-disc [&_p]:text-lg [&_strong]:font-semibold [&_strong]:text-foreground"
dangerouslySetInnerHTML={{ __html: bodyHTML }}
/>
) : null}
</section>
</main>
</>
)
}

View file

@ -0,0 +1,103 @@
import { PostCard } from '@/components/PostCard'
import { SiteHeader } from '@/components/SiteHeader'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { getCachedGlobal } from '@/utilities/globals'
import { stripTrailingSlash } from '@/utilities/seo'
import {
POST_TYPE_LABELS,
getCategoryName,
getCategorySlug,
getVisiblePosts,
isPostType,
} from '@/utilities/posts'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
export const dynamicParams = true
type RouteParams = { type: string; category: string }
export async function generateStaticParams(): Promise<RouteParams[]> {
const posts = await getVisiblePosts()
const combos = new Map<string, RouteParams>()
for (const post of posts) {
const category = getCategorySlug(post)
if (!category) continue
combos.set(`${post.type}/${category}`, { type: post.type, category })
}
return [...combos.values()]
}
export async function generateMetadata({
params,
}: {
params: Promise<RouteParams>
}): Promise<Metadata> {
const { type, category } = await params
if (!isPostType(type)) return {}
const posts = (await getVisiblePosts()).filter(
(post) => post.type === type && getCategorySlug(post) === category,
)
if (!posts.length) return {}
const globalMetadata = await getCachedGlobal('metadata')()
const siteUrl = stripTrailingSlash(globalMetadata.URL)
const categoryName = getCategoryName(posts[0]) ?? category
const label = POST_TYPE_LABELS[type]
const title = `${categoryName} | ${label} | ${globalMetadata.openGraph.siteName}`
const description = `${label} sobre ${categoryName} de ${globalMetadata.openGraph.siteName}.`
const canonicalUrl = `${siteUrl}/${type}/${category}`
return {
title,
description,
alternates: { canonical: canonicalUrl },
openGraph: {
type: 'website',
locale: 'pt-BR',
url: canonicalUrl,
siteName: globalMetadata.openGraph.siteName,
title,
description,
},
}
}
export default async function Page({ params }: { params: Promise<RouteParams> }) {
const { type, category } = await params
if (!isPostType(type)) return notFound()
const posts = (await getVisiblePosts()).filter(
(post) => post.type === type && getCategorySlug(post) === category,
)
if (!posts.length) return notFound()
const categoryName = getCategoryName(posts[0]) ?? category
const label = POST_TYPE_LABELS[type]
return (
<>
<SiteHeader breadcrumbs={[{ label, href: `/${type}` }, { label: categoryName }]} />
<main className="mx-auto w-full max-w-5xl px-4 py-10 sm:px-6 lg:px-8 lg:py-14">
<header className="mb-6 flex flex-col items-start gap-3">
<Badge className="bg-primary/10 text-primary uppercase tracking-wide">{label}</Badge>
<h1 className="text-3xl font-bold leading-tight tracking-tight sm:text-4xl">
{categoryName}
</h1>
</header>
<Separator className="mb-8" />
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
</main>
</>
)
}

View file

@ -0,0 +1,21 @@
import React from 'react'
/**
* Shared shell for the public Posts pages (`/[type]`, `/[type]/[category]`,
* `/[type]/[category]/[slug]`).
*
* The root `(frontend)` layout locks the document with `overflow-hidden`, so
* every full-viewport page must own an internal scroll container. This wrapper
* provides that (fixed viewport height + `overflow-y-auto`) and applies the
* light `editorial` palette so the content area reads as a standard document.
*/
export default function PostsLayout({ children }: { children: React.ReactNode }) {
return (
<div
data-theme="editorial"
className="h-dvh w-full overflow-y-auto bg-background text-foreground"
>
{children}
</div>
)
}

View file

@ -0,0 +1,79 @@
import { PostCard } from '@/components/PostCard'
import { SiteHeader } from '@/components/SiteHeader'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { getCachedGlobal } from '@/utilities/globals'
import { stripTrailingSlash } from '@/utilities/seo'
import { POST_TYPE_LABELS, getVisiblePosts, isPostType } from '@/utilities/posts'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
export const dynamicParams = true
type RouteParams = { type: string }
export async function generateStaticParams(): Promise<RouteParams[]> {
const posts = await getVisiblePosts()
const types = new Set(posts.map((post) => post.type))
return [...types].map((type) => ({ type }))
}
export async function generateMetadata({
params,
}: {
params: Promise<RouteParams>
}): Promise<Metadata> {
const { type } = await params
if (!isPostType(type)) return {}
const globalMetadata = await getCachedGlobal('metadata')()
const siteUrl = stripTrailingSlash(globalMetadata.URL)
const label = POST_TYPE_LABELS[type]
const title = `${label} | ${globalMetadata.openGraph.siteName}`
const description = `${label} de ${globalMetadata.openGraph.siteName}.`
return {
title,
description,
alternates: { canonical: `${siteUrl}/${type}` },
openGraph: {
type: 'website',
locale: 'pt-BR',
url: `${siteUrl}/${type}`,
siteName: globalMetadata.openGraph.siteName,
title,
description,
},
}
}
export default async function Page({ params }: { params: Promise<RouteParams> }) {
const { type } = await params
if (!isPostType(type)) return notFound()
const posts = (await getVisiblePosts()).filter((post) => post.type === type)
const label = POST_TYPE_LABELS[type]
return (
<>
<SiteHeader breadcrumbs={[{ label }]} />
<main className="mx-auto w-full max-w-5xl px-4 py-10 sm:px-6 lg:px-8 lg:py-14">
<header className="mb-6 flex flex-col items-start gap-3">
<Badge className="bg-primary/10 text-primary uppercase tracking-wide">Publicações</Badge>
<h1 className="text-3xl font-bold leading-tight tracking-tight sm:text-4xl">{label}</h1>
</header>
<Separator className="mb-8" />
{posts.length ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
) : (
<p className="text-muted-foreground">Nenhuma publicação por aqui ainda.</p>
)}
</main>
</>
)
}

View file

@ -111,9 +111,38 @@ body,
--field-disabled-background: rgb(255 255 255 / 70%);
--field-disabled-foreground: rgb(0 0 0 / 70%);
--field-icon: rgb(0 0 0 / 60%);
/* Brand header bar. Defined on :root so it is inherited by every theme
(including "editorial") and stays the same red regardless of palette. */
--site-header: #ae1603;
--site-header-foreground: #fff8f2;
--site-header-muted: rgb(255 248 242 / 72%);
--site-header-border: rgb(255 255 255 / 18%);
}
[data-theme="petition"] {
/* Light, neutral editorial palette for the public Posts pages (listings +
article). Mirrors the shadcn token contract with a brand-red primary. */
[data-theme='editorial'] {
--background: #f6f4f3;
--foreground: #201b1a;
--card: #ffffff;
--card-foreground: #201b1a;
--popover: #ffffff;
--popover-foreground: #201b1a;
--primary: #c51414;
--primary-foreground: #ffffff;
--secondary: #efe9e7;
--secondary-foreground: #4a3f3d;
--muted: #efe9e7;
--muted-foreground: #6c615e;
--accent: #f3edeb;
--accent-foreground: #201b1a;
--destructive: #b42318;
--border: rgb(32 27 26 / 12%);
--input: rgb(32 27 26 / 16%);
--ring: #c51414;
}
[data-theme='petition'] {
--background: #fff8f2;
--foreground: #2c2522;
--card: #ffffff;

112
src/collections/Post.ts Normal file
View file

@ -0,0 +1,112 @@
import { revalidateDocumentById, revalidatePostsListing } from '@/utilities/documents'
import { slugify } from '@/utilities/slug'
import type { CollectionConfig } from 'payload'
const slug = 'post'
export const Post: CollectionConfig<typeof slug> = {
slug,
labels: {
singular: 'Publicação',
plural: 'Publicações',
},
admin: {
group: 'Publicações',
useAsTitle: 'title',
},
versions: {
drafts: {
schedulePublish: true,
},
maxPerDoc: 5,
},
access: {
read: () => true,
},
hooks: {
afterChange: [
({ doc, previousDoc, operation }) => {
// Admin create view creates the initial draft during render.
// Skip cache invalidation only for that first draft creation.
if (operation === 'create' && doc._status !== 'published') return
revalidateDocumentById(slug, previousDoc?.id ?? doc.id)
revalidatePostsListing()
},
],
},
fields: [
{
name: 'title',
type: 'text',
label: 'Título',
required: true,
},
{
name: 'slug',
type: 'text',
label: 'Slug',
unique: true,
index: true,
admin: {
description: 'Gerado automaticamente a partir do título quando vazio.',
},
hooks: {
beforeValidate: [
({ value, siblingData }) => {
if (value) return slugify(value as string)
if (siblingData?.title) return slugify(siblingData.title as string)
return value
},
],
},
},
{
name: 'type',
type: 'select',
label: 'Tipo',
required: true,
options: [
{ label: 'Notícia', value: 'noticia' },
{ label: 'Campanha', value: 'campanha' },
{ label: 'Artigo', value: 'artigo' },
{ label: 'Evento', value: 'evento' },
],
},
{
name: 'category',
type: 'relationship',
label: 'Categoria',
relationTo: 'tag',
required: true,
},
{
name: 'tags',
type: 'relationship',
label: 'Tags',
relationTo: 'tag',
hasMany: true,
},
{
name: 'subtitle',
type: 'text',
label: 'Subtítulo',
},
{
name: 'coverImage',
type: 'upload',
label: 'Imagem de capa',
relationTo: 'media',
},
{
name: 'publishedDate',
type: 'date',
label: 'Data de publicação',
},
{
name: 'body',
type: 'richText',
label: 'Corpo',
},
],
}

79
src/collections/Tag.ts Normal file
View file

@ -0,0 +1,79 @@
import { revalidateDocumentById, revalidatePostsListing } from '@/utilities/documents'
import { slugify } from '@/utilities/slug'
import type { CollectionConfig } from 'payload'
const slug = 'tag'
export const Tag: CollectionConfig<typeof slug> = {
slug,
labels: {
singular: 'Tag',
plural: 'Tags',
},
admin: {
group: 'Publicações',
useAsTitle: 'name',
},
access: {
read: () => true,
},
hooks: {
afterChange: [
async ({ doc, req }) => {
// Toggling a tag's visibility (e.g. the `eleitoral` tag) affects every
// post that references it, so revalidate all of them plus the listing.
const posts = await req.payload.find({
collection: 'post',
depth: 0,
limit: 0,
pagination: false,
where: {
or: [{ category: { equals: doc.id } }, { tags: { in: [doc.id] } }],
},
req,
})
for (const post of posts.docs) {
revalidateDocumentById('post', post.id)
}
revalidatePostsListing()
},
],
},
fields: [
{
name: 'name',
type: 'text',
label: 'Nome',
required: true,
},
{
name: 'slug',
type: 'text',
label: 'Slug',
unique: true,
index: true,
admin: {
description: 'Gerado automaticamente a partir do nome quando vazio.',
},
hooks: {
beforeValidate: [
({ value, siblingData }) => {
if (value) return slugify(value as string)
if (siblingData?.name) return slugify(siblingData.name as string)
return value
},
],
},
},
{
name: 'hidden',
type: 'checkbox',
label: 'Esconder',
defaultValue: false,
admin: {
description: 'Esconde do site todas as publicações com esta tag.',
},
},
],
}

View file

@ -1,13 +0,0 @@
import { getCachedGlobal } from '@/utilities/globals'
import { SocialLinks } from './socialLinks'
export const Header = async () => {
const settings = await getCachedGlobal('site-settings', 2)()
return (
<header className="flex items-center justify-between p-4 absolute top-0 left-0 right-0">
{settings.headerTitle ? <h4>{settings.headerTitle}</h4> : null}
<SocialLinks ariaLabel="Redes sociais" />
</header>
)
}

View file

@ -0,0 +1,51 @@
import type { Post } from '@/payload-types'
import { Badge } from '@/components/ui/badge'
import { Card } from '@/components/ui/card'
import { formatPostDate, getCategoryName, getPostCanonicalPath } from '@/utilities/posts'
import Image from 'next/image'
import Link from 'next/link'
export const PostCard = ({ post }: { post: Post }) => {
const href = getPostCanonicalPath(post)
if (!href) return null
const cover =
typeof post.coverImage === 'object' && post.coverImage !== null ? post.coverImage : null
const categoryName = getCategoryName(post)
const publishedDate = formatPostDate(post.publishedDate)
return (
<Card className="gap-0 border border-border bg-card py-0 shadow-sm transition-shadow hover:shadow-md">
<Link
href={href}
className="group/link flex h-full flex-col focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
{cover?.url ? (
<div className="relative aspect-video w-full overflow-hidden bg-muted">
<Image
src={cover.url}
alt={cover.alt ?? post.title}
fill
sizes="(max-width: 768px) 100vw, 33vw"
className="object-cover transition-transform duration-300 group-hover/link:scale-[1.03]"
/>
</div>
) : null}
<div className="flex flex-1 flex-col gap-2 p-5">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{categoryName ? <Badge variant="secondary">{categoryName}</Badge> : null}
{publishedDate ? (
<time dateTime={post.publishedDate ?? undefined}>{publishedDate}</time>
) : null}
</div>
<h3 className="text-lg font-semibold leading-snug text-foreground group-hover/link:text-primary">
{post.title}
</h3>
{post.subtitle ? (
<p className="line-clamp-3 text-sm leading-6 text-muted-foreground">{post.subtitle}</p>
) : null}
</div>
</Link>
</Card>
)
}

View file

@ -0,0 +1,98 @@
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { getCachedGlobal } from '@/utilities/globals'
import Link from 'next/link'
import { Fragment } from 'react'
import { SocialLinks } from './socialLinks'
export type Crumb = {
label: string
/** Omit on the current (last) crumb so it renders as plain text. */
href?: string
}
type SiteHeaderProps = {
/**
* Breadcrumb trail rendered on the light sub-bar. When empty (e.g. on the
* home page) no breadcrumb bar is shown.
*/
breadcrumbs?: Crumb[]
/**
* `solid` renders a brand-red bar that participates in the layout flow and
* sticks to the top of its scroll container (used on the Posts pages).
* `overlay` floats transparently over hero content (used on the home page).
*/
variant?: 'solid' | 'overlay'
}
const HOME_LABEL = 'Início'
export const SiteHeader = async ({ breadcrumbs = [], variant = 'solid' }: SiteHeaderProps) => {
const settings = await getCachedGlobal('site-settings', 2)()
const title = settings.headerTitle?.trim() || 'Jorge Solla'
if (variant === 'overlay') {
return (
<header className="absolute top-0 right-0 left-0 z-30 flex items-center justify-between p-4">
<Link href="/" className="text-inherit no-underline">
<h4>{title}</h4>
</Link>
<SocialLinks ariaLabel="Redes sociais" />
</header>
)
}
const trail: Crumb[] = [{ label: HOME_LABEL, href: '/' }, ...breadcrumbs]
return (
<header className="sticky top-0 z-30">
<div className="bg-(--site-header) text-(--site-header-foreground)">
<div className="mx-auto flex w-full max-w-5xl items-center justify-between gap-4 px-4 py-3 sm:px-6 lg:px-8">
<Link
href="/"
className="text-lg font-semibold tracking-tight text-(--site-header-foreground) no-underline transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
>
{title}
</Link>
<SocialLinks
ariaLabel="Redes sociais"
className="text-(--site-header-muted) [&_a]:text-(--site-header-muted) [&_a:hover]:text-(--site-header-foreground)"
/>
</div>
</div>
{trail.length > 1 ? (
<div className="border-b border-border bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/80">
<div className="mx-auto w-full max-w-5xl px-4 py-2.5 sm:px-6 lg:px-8">
<Breadcrumb>
<BreadcrumbList>
{trail.map((crumb, index) => {
const isLast = index === trail.length - 1
return (
<Fragment key={`${crumb.label}-${index}`}>
<BreadcrumbItem>
{isLast || !crumb.href ? (
<BreadcrumbPage className="line-clamp-1">{crumb.label}</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link href={crumb.href}>{crumb.label}</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
{isLast ? null : <BreadcrumbSeparator />}
</Fragment>
)
})}
</BreadcrumbList>
</Breadcrumb>
</div>
</div>
) : null}
</header>
)
}

View file

@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View file

@ -0,0 +1,122 @@
import * as React from "react"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
)
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? (
<ChevronRightIcon />
)}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"flex size-5 items-center justify-center [&>svg]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

103
src/components/ui/card.tsx Normal file
View file

@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,136 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_post_type" AS ENUM('noticia', 'campanha', 'artigo', 'evento');
CREATE TYPE "public"."enum_post_status" AS ENUM('draft', 'published');
CREATE TYPE "public"."enum__post_v_version_type" AS ENUM('noticia', 'campanha', 'artigo', 'evento');
CREATE TYPE "public"."enum__post_v_version_status" AS ENUM('draft', 'published');
CREATE TABLE "post" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar,
"slug" varchar,
"type" "enum_post_type",
"category_id" integer,
"subtitle" varchar,
"cover_image_id" integer,
"published_date" timestamp(3) with time zone,
"body" jsonb,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"_status" "enum_post_status" DEFAULT 'draft'
);
CREATE TABLE "post_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"tag_id" integer
);
CREATE TABLE "_post_v" (
"id" serial PRIMARY KEY NOT NULL,
"parent_id" integer,
"version_title" varchar,
"version_slug" varchar,
"version_type" "enum__post_v_version_type",
"version_category_id" integer,
"version_subtitle" varchar,
"version_cover_image_id" integer,
"version_published_date" timestamp(3) with time zone,
"version_body" jsonb,
"version_updated_at" timestamp(3) with time zone,
"version_created_at" timestamp(3) with time zone,
"version__status" "enum__post_v_version_status" DEFAULT 'draft',
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"latest" boolean
);
CREATE TABLE "_post_v_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"tag_id" integer
);
CREATE TABLE "tag" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"slug" varchar,
"visible" boolean DEFAULT true,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "post_id" integer;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "tag_id" integer;
ALTER TABLE "post" ADD CONSTRAINT "post_category_id_tag_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."tag"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "post" ADD CONSTRAINT "post_cover_image_id_media_id_fk" FOREIGN KEY ("cover_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "post_rels" ADD CONSTRAINT "post_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."post"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "post_rels" ADD CONSTRAINT "post_rels_tag_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tag"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_post_v" ADD CONSTRAINT "_post_v_parent_id_post_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."post"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "_post_v" ADD CONSTRAINT "_post_v_version_category_id_tag_id_fk" FOREIGN KEY ("version_category_id") REFERENCES "public"."tag"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "_post_v" ADD CONSTRAINT "_post_v_version_cover_image_id_media_id_fk" FOREIGN KEY ("version_cover_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "_post_v_rels" ADD CONSTRAINT "_post_v_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."_post_v"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_post_v_rels" ADD CONSTRAINT "_post_v_rels_tag_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tag"("id") ON DELETE cascade ON UPDATE no action;
CREATE UNIQUE INDEX "post_slug_idx" ON "post" USING btree ("slug");
CREATE INDEX "post_category_idx" ON "post" USING btree ("category_id");
CREATE INDEX "post_cover_image_idx" ON "post" USING btree ("cover_image_id");
CREATE INDEX "post_updated_at_idx" ON "post" USING btree ("updated_at");
CREATE INDEX "post_created_at_idx" ON "post" USING btree ("created_at");
CREATE INDEX "post__status_idx" ON "post" USING btree ("_status");
CREATE INDEX "post_rels_order_idx" ON "post_rels" USING btree ("order");
CREATE INDEX "post_rels_parent_idx" ON "post_rels" USING btree ("parent_id");
CREATE INDEX "post_rels_path_idx" ON "post_rels" USING btree ("path");
CREATE INDEX "post_rels_tag_id_idx" ON "post_rels" USING btree ("tag_id");
CREATE INDEX "_post_v_parent_idx" ON "_post_v" USING btree ("parent_id");
CREATE INDEX "_post_v_version_version_slug_idx" ON "_post_v" USING btree ("version_slug");
CREATE INDEX "_post_v_version_version_category_idx" ON "_post_v" USING btree ("version_category_id");
CREATE INDEX "_post_v_version_version_cover_image_idx" ON "_post_v" USING btree ("version_cover_image_id");
CREATE INDEX "_post_v_version_version_updated_at_idx" ON "_post_v" USING btree ("version_updated_at");
CREATE INDEX "_post_v_version_version_created_at_idx" ON "_post_v" USING btree ("version_created_at");
CREATE INDEX "_post_v_version_version__status_idx" ON "_post_v" USING btree ("version__status");
CREATE INDEX "_post_v_created_at_idx" ON "_post_v" USING btree ("created_at");
CREATE INDEX "_post_v_updated_at_idx" ON "_post_v" USING btree ("updated_at");
CREATE INDEX "_post_v_latest_idx" ON "_post_v" USING btree ("latest");
CREATE INDEX "_post_v_rels_order_idx" ON "_post_v_rels" USING btree ("order");
CREATE INDEX "_post_v_rels_parent_idx" ON "_post_v_rels" USING btree ("parent_id");
CREATE INDEX "_post_v_rels_path_idx" ON "_post_v_rels" USING btree ("path");
CREATE INDEX "_post_v_rels_tag_id_idx" ON "_post_v_rels" USING btree ("tag_id");
CREATE UNIQUE INDEX "tag_slug_idx" ON "tag" USING btree ("slug");
CREATE INDEX "tag_updated_at_idx" ON "tag" USING btree ("updated_at");
CREATE INDEX "tag_created_at_idx" ON "tag" USING btree ("created_at");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_post_fk" FOREIGN KEY ("post_id") REFERENCES "public"."post"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_tag_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tag"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_post_id_idx" ON "payload_locked_documents_rels" USING btree ("post_id");
CREATE INDEX "payload_locked_documents_rels_tag_id_idx" ON "payload_locked_documents_rels" USING btree ("tag_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "post" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "post_rels" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "_post_v" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "_post_v_rels" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "tag" DISABLE ROW LEVEL SECURITY;
DROP TABLE "post" CASCADE;
DROP TABLE "post_rels" CASCADE;
DROP TABLE "_post_v" CASCADE;
DROP TABLE "_post_v_rels" CASCADE;
DROP TABLE "tag" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_post_fk";
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_tag_fk";
DROP INDEX "payload_locked_documents_rels_post_id_idx";
DROP INDEX "payload_locked_documents_rels_tag_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "post_id";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "tag_id";
DROP TYPE "public"."enum_post_type";
DROP TYPE "public"."enum_post_status";
DROP TYPE "public"."enum__post_v_version_type";
DROP TYPE "public"."enum__post_v_version_status";`)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,24 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
/**
* Inverts the tag visibility flag: the old `visible` (default true, "shown")
* checkbox becomes `hidden` (default false, "Esconder"). This is DATA-PRESERVING
* on purpose — a naive generated migration would drop `visible` and add `hidden`,
* discarding which tags were toggled. Instead we add `hidden`, backfill it as the
* logical inverse of `visible` (`hidden = NOT visible`), then drop `visible`.
*/
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "tag" ADD COLUMN "hidden" boolean DEFAULT false;
UPDATE "tag" SET "hidden" = NOT COALESCE("visible", true);
ALTER TABLE "tag" DROP COLUMN "visible";
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "tag" ADD COLUMN "visible" boolean DEFAULT true;
UPDATE "tag" SET "visible" = NOT COALESCE("hidden", false);
ALTER TABLE "tag" DROP COLUMN "hidden";
`)
}

View file

@ -1,5 +1,7 @@
import * as migration_20260715_163458_initial from './20260715_163458_initial'
import * as migration_20260715_163500_consent_text_to_jsonb from './20260715_163500_consent_text_to_jsonb'
import * as migration_20260715_163458_initial from './20260715_163458_initial';
import * as migration_20260715_163500_consent_text_to_jsonb from './20260715_163500_consent_text_to_jsonb';
import * as migration_20260715_181058_add_post_and_tag from './20260715_181058_add_post_and_tag';
import * as migration_20260715_215834_rename_tag_visible_to_hidden from './20260715_215834_rename_tag_visible_to_hidden';
export const migrations = [
{
@ -12,4 +14,14 @@ export const migrations = [
down: migration_20260715_163500_consent_text_to_jsonb.down,
name: '20260715_163500_consent_text_to_jsonb',
},
]
{
up: migration_20260715_181058_add_post_and_tag.up,
down: migration_20260715_181058_add_post_and_tag.down,
name: '20260715_181058_add_post_and_tag',
},
{
up: migration_20260715_215834_rename_tag_visible_to_hidden.up,
down: migration_20260715_215834_rename_tag_visible_to_hidden.down,
name: '20260715_215834_rename_tag_visible_to_hidden'
},
];

View file

@ -74,6 +74,8 @@ export interface Config {
consent: Consent;
signature: Signature;
subscription: Subscription;
post: Post;
tag: Tag;
'payload-kv': PayloadKv;
'payload-jobs': PayloadJob;
'payload-locked-documents': PayloadLockedDocument;
@ -89,6 +91,8 @@ export interface Config {
consent: ConsentSelect<false> | ConsentSelect<true>;
signature: SignatureSelect<false> | SignatureSelect<true>;
subscription: SubscriptionSelect<false> | SubscriptionSelect<true>;
post: PostSelect<false> | PostSelect<true>;
tag: TagSelect<false> | TagSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
'payload-jobs': PayloadJobsSelect<false> | PayloadJobsSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
@ -314,6 +318,60 @@ export interface Subscription {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "post".
*/
export interface Post {
id: number;
title: string;
/**
* Gerado automaticamente a partir do título quando vazio.
*/
slug?: string | null;
type: 'noticia' | 'campanha' | 'artigo' | 'evento';
category: number | Tag;
tags?: (number | Tag)[] | null;
subtitle?: string | null;
coverImage?: (number | null) | Media;
publishedDate?: string | null;
body?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tag".
*/
export interface Tag {
id: number;
name: string;
/**
* Gerado automaticamente a partir do nome quando vazio.
*/
slug?: string | null;
/**
* Esconde do site todas as publicações com esta tag.
*/
hidden?: boolean | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv".
@ -457,6 +515,14 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'subscription';
value: number | Subscription;
} | null)
| ({
relationTo: 'post';
value: number | Post;
} | null)
| ({
relationTo: 'tag';
value: number | Tag;
} | null);
globalSlug?: string | null;
user: {
@ -607,6 +673,35 @@ export interface SubscriptionSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "post_select".
*/
export interface PostSelect<T extends boolean = true> {
title?: T;
slug?: T;
type?: T;
category?: T;
tags?: T;
subtitle?: T;
coverImage?: T;
publishedDate?: T;
body?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tag_select".
*/
export interface TagSelect<T extends boolean = true> {
name?: T;
slug?: T;
hidden?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv_select".
@ -815,10 +910,15 @@ export interface TaskSchedulePublish {
input: {
type?: ('publish' | 'unpublish') | null;
locale?: string | null;
doc?: {
relationTo: 'petition';
value: string | Petition;
} | null;
doc?:
| ({
relationTo: 'petition';
value: string | Petition;
} | null)
| ({
relationTo: 'post';
value: number | Post;
} | null);
global?: 'site-settings' | null;
user?: (number | null) | User;
};

View file

@ -11,8 +11,10 @@ import { Consent } from './collections/Consent'
import { Contact } from './collections/Contact'
import { Media } from './collections/Media'
import { Petition } from './collections/Petition'
import { Post } from './collections/Post'
import { Signature } from './collections/Signature'
import { Subscription } from './collections/Subscription'
import { Tag } from './collections/Tag'
import { Users } from './collections/Users'
import { HomePage } from './globals/HomePage'
import { Metadata } from './globals/Metadata'
@ -32,7 +34,7 @@ export default buildConfig({
globals: [SiteSettings.slug, HomePage.slug],
},
},
collections: [Users, Media, Petition, Contact, Consent, Signature, Subscription],
collections: [Users, Media, Petition, Contact, Consent, Signature, Subscription, Post, Tag],
globals: [SiteSettings, HomePage, Metadata],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '',

View file

@ -11,24 +11,33 @@ export const getDocuments = <Slug extends Collection>(collection: Slug, depth?:
export const getDocumentById = <Slug extends Collection>(
collection: Slug,
id: string,
id: string | number,
depth?: number,
) =>
getPayload({ config: configPromise }).then((payload) =>
payload.findByID({ collection, id, depth }),
)
const getTag = <Slug extends Collection>(collection: Slug, id: string) =>
const getTag = <Slug extends Collection>(collection: Slug, id: string | number) =>
`document_${collection}:${id}`
export const getCachedDocumentById = <Slug extends Collection>(
collection: Slug,
id: string,
id: string | number,
depth?: number,
) =>
unstable_cache(() => getDocumentById(collection, id, depth), [getTag(collection, id)], {
tags: [getTag(collection, id)],
})
export const revalidateDocumentById = <Slug extends Collection>(collection: Slug, id: string) =>
revalidateTag(getTag(collection, id))
export const revalidateDocumentById = <Slug extends Collection>(
collection: Slug,
id: string | number,
) => revalidateTag(getTag(collection, id))
const getListingTag = <Slug extends Collection>(collection: Slug) => `${collection}s`
export const revalidateCollectionListing = <Slug extends Collection>(collection: Slug) =>
revalidateTag(getListingTag(collection))
export const revalidatePostsListing = () => revalidateCollectionListing('post')

118
src/utilities/posts.ts Normal file
View file

@ -0,0 +1,118 @@
import type { Post, Tag } from '@/payload-types'
import configPromise from '@payload-config'
import { unstable_cache } from 'next/cache'
import { getPayload } from 'payload'
export const POST_TYPES = ['noticia', 'campanha', 'artigo', 'evento'] as const
export type PostType = (typeof POST_TYPES)[number]
export const POST_TYPE_LABELS: Record<PostType, string> = {
noticia: 'Notícias',
campanha: 'Campanha',
artigo: 'Artigos',
evento: 'Eventos',
}
export const isPostType = (value: string): value is PostType =>
(POST_TYPES as readonly string[]).includes(value)
/**
* A related tag hides its posts when it is explicitly marked `hidden`, OR when
* the relation is unpopulated (a bare numeric id). Unpopulated relations fail
* closed so we never leak a post whose electoral-control tags we could not
* verify.
*/
const isTagHiding = (tag: number | Tag): boolean =>
typeof tag !== 'object' || tag === null || tag.hidden === true
/**
* A post is visible on the site only if it is published AND NONE of its tags —
* including the required `category` tag — is `hidden`. Requires the
* `category`/`tags` relations to be populated (fetch with depth >= 1); an
* unpopulated tag fails closed and hides the post.
*/
export const isPostVisible = (post: Post): boolean => {
if (post._status !== 'published') return false
const relatedTags: (number | Tag)[] = [post.category, ...(post.tags ?? [])]
return !relatedTags.some(isTagHiding)
}
export const getCategorySlug = (post: Post): string | null =>
typeof post.category === 'object' && post.category !== null ? (post.category.slug ?? null) : null
export const getCategoryName = (post: Post): string | null =>
typeof post.category === 'object' && post.category !== null ? post.category.name : null
/** Listing path for a post type, e.g. `/noticia`. */
export const getTypePath = (type: PostType): string => `/${type}`
/** Listing path for a category within a type, e.g. `/noticia/saude`. */
export const getCategoryListingPath = (type: PostType, categorySlug: string): string =>
`${getTypePath(type)}/${categorySlug}`
/** Canonical article path derived from the post's CURRENT type + category slug. */
export const getPostCanonicalPath = (post: Post): string | null => {
const categorySlug = getCategorySlug(post)
if (!post.slug || !categorySlug) return null
return `${getCategoryListingPath(post.type, categorySlug)}/${post.slug}`
}
export const formatPostDate = (date?: string | null): string | null => {
if (!date) return null
return new Intl.DateTimeFormat('pt-BR', { dateStyle: 'long' }).format(new Date(date))
}
const findPublishedPosts = (depth: number) =>
getPayload({ config: configPromise }).then((payload) =>
payload.find({
collection: 'post',
depth,
limit: 0,
sort: '-publishedDate',
where: { _status: { equals: 'published' } },
}),
)
const findPublishedPostBySlug = (slug: string, depth: number) =>
getPayload({ config: configPromise })
.then((payload) =>
payload.find({
collection: 'post',
depth,
limit: 1,
where: {
and: [{ slug: { equals: slug } }, { _status: { equals: 'published' } }],
},
}),
)
.then((result) => result.docs[0] ?? null)
/**
* Cached list of every published post. Tagged with the shared `posts` listing
* tag so `revalidatePostsListing()` (called from Post/Tag `afterChange`) busts it.
*/
export const getCachedPublishedPosts = (depth = 1) =>
unstable_cache(() => findPublishedPosts(depth), ['posts', String(depth)], {
tags: ['posts'],
})
export const getCachedPostBySlug = (slug: string, depth = 2) =>
unstable_cache(
() => findPublishedPostBySlug(slug, depth),
['post-by-slug', slug, String(depth)],
{ tags: ['posts'] },
)
/**
* Every published post that also passes the site visibility predicate, newest
* first. Shared by the home page and every listing/article route so the
* `getCachedPublishedPosts(...).docs.filter(isPostVisible)` boilerplate lives once.
*/
export const getVisiblePosts = async (depth = 1): Promise<Post[]> => {
const { docs } = await getCachedPublishedPosts(depth)()
return docs.filter(isPostVisible)
}

12
src/utilities/seo.ts Normal file
View file

@ -0,0 +1,12 @@
/** Remove any trailing slash(es) so URLs concatenate predictably. */
export const stripTrailingSlash = (url: string): string => url.replace(/\/+$/, '')
/** Truncate `text` to `max` characters, appending an ellipsis when cut. */
export const truncate = (text: string, max: number): string =>
text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`
/** Resolve a possibly-relative URL against the site's base URL. */
export const toAbsoluteUrl = (url: string, siteUrl: string): string =>
/^https?:\/\//i.test(url)
? url
: `${stripTrailingSlash(siteUrl)}${url.startsWith('/') ? '' : '/'}${url}`

8
src/utilities/slug.ts Normal file
View file

@ -0,0 +1,8 @@
export const slugify = (value: string): string =>
value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // strip accents
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-') // non-alphanumeric -> hyphen
.replace(/-+/g, '-') // collapse hyphens
.replace(/^-|-$/g, '') // trim leading/trailing hyphens

View file

@ -33,7 +33,7 @@ describe('petition page layout', () => {
it('scopes the petition color system to the petition page', () => {
expect(pageSource).toContain('data-theme="petition"')
expect(stylesSource).toContain('[data-theme="petition"]')
expect(stylesSource).toMatch(/\[data-theme=['"]petition['"]\]/)
expect(stylesSource).toContain('--petition-hero')
expect(stylesSource).toContain('--petition-form-section')
})