feat(C233): álbum público de fotos com busca por data, local, atividade e pessoa pública

This commit is contained in:
Francisco Solla 2026-09-26 15:03:06 -03:00
parent 671af1e21f
commit 1dd70b0647
53 changed files with 24502 additions and 195 deletions

View file

@ -1,5 +1,11 @@
# Public site domain (AGENTS layer — load when touching `(frontend)`)
## Photo album (`/fotos`) — C233
The public album over the private archive (`archivePhoto`, C231/C232): **only `publicationStatus: 'approved'` photos** are read (`getApprovedArchivePhotoItems`, `src/utilities/archivePhotos/archivePhotoReads.ts`), cached under the collection listing tag `archivePhotos` and busted by the collection's `afterChange`/`afterDelete` — an `approved→removed` edit pulls the photo and its media down **without a deploy**. The route canonicalizes to `/fotos`; the single-value facets are `data` (day, `YYYY-MM-DD`), `municipio` (catalog slug), `atividade` (an `ARCHIVE_PHOTO_SCENES` value), `pessoa` (slug of a canonical `publicFigureCatalog` name) plus `q`/`pagina`; `?foto=<id>` is the non-canonical overlay and `/fotos/<id>` keeps 404ing (no page per photo). Media goes through the same-origin proxy `/fotos/<id>/midia` (`?tamanho=grade` resizes on the fly, `?download=1` names `jorge-solla-1313-foto-<id>.<ext>`, `X-Robots-Tag: noindex`); the municipality name/slug are snapshotted on the row (`municipalityName`/`municipalitySlug`) because the `municipality` collection is campaign-only.
The album global `photoAlbum` holds the two product knobs: `published` (kill switch — **only `false` closes**; an absent row means the schema default `true`) and `removalChannelUrl` (the visible "é você nesta foto? peça a remoção" channel, never invented in code). Publishing the album or approving a photo **requires a valid channel** (fail-closed guard in the global and in the collection); the album has no `loading.tsx` on purpose — a Suspense boundary would turn the closed album's `notFound()` into a `200`. The "Fotos" discovery link in `CampaignFooter` appears while there are approved photos (and always on `/fotos` itself).
## Posts & Tags (news / publications)
The public news/publications system is backed by two collections (both in the `Publicações` admin group) plus the helpers in `src/utilities/posts.ts`.
@ -37,7 +43,7 @@ curl -X POST "https://<prod-domain>/api/revalidate?tag=global_privacy-policy" \
- **Auth:** send the secret via `x-revalidate-secret` or `Authorization: Bearer <secret>`; it is compared to `REVALIDATE_SECRET` with a constant-time check.
- `REVALIDATE_SECRET` must be set in the **production** env (and in your local env if you want to test the endpoint). It is documented in `.env.example`.
- **Tag:** optional `?tag=` query or JSON body `{ "tag": "..." }` (query wins). Allowlist: `posts` (default), `global_privacy-policy`, `election-tse` (bust after re-running `pnpm db:seed:tse` so the cached elections-tab reads refresh), `municipality-catalog` (bust after a migration/seed that adds or renames a município so chip indexes and `unstable_cache` loaders refresh), `social-feed` (bust after a direct-DB write to the `social-feed-settings` global — e.g. an admin-less seed of the YouTube/Instagram feed config; admin edits self-revalidate). Unknown tag → `400`.
- **Tag:** optional `?tag=` query or JSON body `{ "tag": "..." }` (query wins). Allowlist: `posts` (default), `global_privacy-policy`, `election-tse` (bust after re-running `pnpm db:seed:tse` so the cached elections-tab reads refresh), `municipality-catalog` (bust after a migration/seed that adds or renames a município so chip indexes and `unstable_cache` loaders refresh), `social-feed` (bust after a direct-DB write to the `social-feed-settings` global — e.g. an admin-less seed of the YouTube/Instagram feed config; admin edits self-revalidate), `archivePhotos` (bust after a direct-DB write to `archivePhoto` rows — admin edits self-revalidate via the collection hook; the public album `/fotos` caches under it). Unknown tag → `400`.
- **Instagram sync status (S11):** the global's `instagramSyncStatus` jsonb (hidden) is written by the render path, by `afterChange` (re-sync only when IG credentials changed), and by the admin button `POST /api/social-feed/sync` (admin session via `payload.auth` + same-origin guard); the `InstagramSyncStatusPanel` ui field renders it. Runbook de credenciais: `docs/ops/instagram-feed-token-runbook.md`.
- **Responses:** `200 { revalidated: true, tag: '...' }` on success; `401` if the secret is missing/wrong; `500` if `REVALIDATE_SECRET` is not configured on the server.
- This POST is the required last step of the post-seed / direct-DB-change runbook.

View file

@ -290,6 +290,8 @@ Every `/campanha` list surface composes the shared pieces in `src/components/cam
This file is the living owner of Teqo's visual decisions — a working document, not a snapshot. The old single-source-of-brand guardrail (kit 1313 as the only authority; "never re-derive lockups or palette") is retired. The official kit assets in `public/campaign-kit/` stay available — and their technical usage quirks still apply when used — but they no longer bind new design work.
**Public campaign archive actions (C233, 2026-09-26).** On campaign-branded public archive/read surfaces, the primary action is campaign red with white text. Kit yellow is an orientation and keyboard-focus signal — hero edge, restrained eyebrow, current-location underline, and high-contrast focus outline — never the competing CTA fill. This keeps one action dominant, separates public campaign expression from Petition Gold, and makes keyboard location unmistakable over both light and dark media surfaces.
How rules change:
- **Decide, then update the owner.** When a better pattern wins — anti-slop review (`design-taste-frontend` / `gpt-taste`), a reference study (`awesome-design-md`), a redesign pass (`redesign-existing-projects`), or implementation judgment — update this file in the **same change** and say why. A pattern that only lives in code or in an agent's head is drift, not design.

View file

@ -0,0 +1 @@
**C233 — Álbum público de fotos com busca por data, local, atividade e pessoa pública (2026-09-26):** o acervo privado do C231/C232 ganha a porta pública `/fotos` — só a `publicationStatus: 'approved'` (novo select `draft|approved|removed`, default `draft`: a migration não publica nada), com o read anônimo cacheado em `src/utilities/archivePhotos/archivePhotoReads.ts` sob a tag `archivePhotos` (bustada pelo `afterChange`/`afterDelete` da collection: `approved→removed` some do índice e a mídia dela passa a 404 sem deploy) e o município denormalizado na própria linha (`municipalityName`/`municipalitySlug` gravados no hook do C232, que já lia a geografia com bypass documentado — backfill idempotente na migration `add_archive_photo_publication`, junto do global novo). O contrato puro `src/lib/archivePhotoPublicCatalog.ts` define a URL canônica (`/fotos`), as facetas single-value `data` (dia), `municipio` (slug do catálogo), `atividade` (cena do C232), `pessoa` (nome canônico do `publicFigureCatalog`), `q` e `pagina` (24/página, `pagina=1` dropado), o overlay não-canônico `?foto=<id>` (sem página por foto) e o heading contextual (município→atividade→pessoa→data→termo). A mídia same-origin `/fotos/<id>/midia` reusa o proxy privado com `?tamanho=grade` (resize em streaming com sharp, sem variante armazenada — aposta do v1 com gatilho de revisita) e `?download=1` (`jorge-solla-1313-foto-<id>.<ext>`), com `X-Robots-Tag: noindex` e o mesmo 404 para draft/removida/inexistente. O global `photoAlbum` (admin-only) segura o kill switch `published` (default `true` por decisão explícita do gate de 2026-09-26: abrir agora, sem esperar a eleição) e o `removalChannelUrl`; **publicar o álbum ou aprovar foto exige canal válido** (guard fail-closed no global e na collection, que só guarda a transição para `approved`), e a banda "é você nesta foto? peça a remoção" some sem canal. UI portada classe-a-classe do design estendido (header com nav desktop, hero sem CTA do C234, busca GET sem JS, chips + menus `<details>` no desktop e sheet com selects no mobile, grade 2/3 colunas com hover/foco, overlay com trap de Tab e foco devolvido ao card, dois vazios honestos e rodapé com "Fotos" condicional) e **`Design tier: openai/gpt-5.6-sol` com paridade certificada em 3 passadas** contra o app renderizado; o lockup S36 virou dono único `CampaignLogoLink` e o rodapé ganhou alvos de 44px. Doc do contrato em `AGENTS-public.md` (tag `archivePhotos` na allowlist). Testes: unit do contrato puro (parse/href/facetas/filtro/view model/paginação + vocabulário de publicação), int do read/guard/rota de mídia sobre `teqo_test`, e2e novo `frontendFotos` (manifest + curado + projeto; filtros, overlay, download, pull-down imediato e kill switch por HTTP real). Hard-stops aprovados na sessão (schema, URL público, bypasses, miniatura, timing); defers com gatilho no impl plan. Impl plan: `docs/plans/album-fotos-publico-busca-impl.md`.

View file

@ -0,0 +1,195 @@
# Impl: C233 — Álbum público de fotos com busca por data, local, atividade e pessoa pública
Status: aprovado (modo `--auto`) — hard-stops aprovados explicitamente pelo humano na sessão (2026-09-26); timing eleitoral decidido: **abrir agora**
Atualizado em: 2026-09-26
Issue: #1368
Intenção: docs/plans/album-fotos-publico-busca.md
Design UI (gate): docs/plans/album-fotos-publico-busca-ui-design.html
Appetite restante: herdado (~1–2 dias eng) — **uma porta pública nova (`/fotos`) sobre o catálogo do C232**, sem segunda curadoria. Corte explícito para caber: capítulos/álbuns por evento viram heading derivado do filtro + ordenação; sem página própria por foto; miniatura on-the-fly (sem pipeline de variantes armazenadas); sem workflow de atendimento (staff marca `removed`); busca por selfie é C234.
> Aprovado pelo próprio agente via `--auto` (modo autônomo do `work-issue`); a pausa do GATE virou apresentação-no-chat. Os hard-stops exigiram aprovação humana explícita e foram aprovados na sessão (`--auto` aprova o plano, não o schema nem o contrato público): schema/migration ✅, contrato `/fotos` ✅, bypasses intencionais ✅, miniatura on-the-fly ✅. **Timing eleitoral: o humano decidiu "implementar e abrir agora" (2026-09-26)** — sobrepõe a recomendação de pós-eleição do plano de intenção; o album nasce aberto (`photoAlbum.published` default `true`), com kill switch e pull-down imediatos.
## Leitura da intenção
- **Outcome:** o cidadão chega à foto certa por data, município, atividade, pessoa pública ou termo — e só vê o que a curadoria aprovou; toda foto mostra contexto real (data, local, atividade, quem aparece), tem vazio honesto e um canal visível de remoção cujo efeito é imediato e fail-closed.
- **O que NÃO negociar:**
- Só aprovadas aparecem; não aprovada não é acessível nem por URL direta (a mídia responde 404 igual).
- Pessoa pública só pelo catálogo curado (`publicFigureCatalog`), por nome canônico — nunca `Contact`, nunca rosto/biometria.
- Remoção a pedido: foto removida some do público **e do índice** sem deploy; sem registro humano não republica.
- Sem contador/ranking/analytics de vaidade; sem like/comentário/share com PII; imagens same-origin pelo proxy privado (bucket fechado).
- URL pública é contrato congelado; tag nova entra na allowlist fechada.
- **O que reavaliar (hipóteses da "Direção no codebase"):**
- "Só a leitura cacheada da lista inteira" — vale no v1 porque o conjunto **aprovado** é curadoria manual (começa vazio), com gatilho de revisita registrado (D2).
- "O read público resolve o município" — confirmado, mas **não** lendo a collection `municipality` (campaign-only): o hook existente denormaliza nome/slug na própria linha (D2).
- "A rota `/fotos` existe ao deployar" — risco eleitoral assumido por decisão do gate (D9): o v1 nasce **aberto** com kill switch global e pull-down por foto.
- "O design aprovado pode ser seguido como está" — não: o CTA "Encontre você nas fotos" é a busca por selfie (C234) e há heading/controles sem comportamento definido; o `designer` estende o artefato antes do markup (D8).
## Abordagem recomendada
```mermaid
flowchart LR
FICHA["/admin · Foto do acervo\npublicationStatus draft|approved|removed"] --> HOOK["beforeValidate\nderiveArchivePhotoCatalogIndex (C232)"]
HOOK --> MUNI["municipality (read-only geography)\nbypass documentado já existente"]
HOOK --> ROW[("archivePhoto\n+ municipalityName/Slug snapshot")]
ROW -- "afterChange / afterDelete" --> REV["revalidateArchivePhotosListing()\ntag archivePhotos"]
ROW --> READS["archivePhotoReads (unstable_cache)\nwhere approved · select explícito · depth 0"]
REV -. busta .-> READS
READS --> PURE["lib/archivePhotoPublicCatalog\nparse/href/facetas/filtro/view model"]
PURE --> PAGE["/fotos · kill switch global photoAlbum.published\n(?data&municipio&atividade&pessoa&q&pagina)"]
PAGE --> MEDIA["/fotos/[id]/midia\ngrade (sharp) · download"]
READS --> MEDIA
PAGE -. "?foto=id (não canônico)" .-> DET["overlay detalhe\nVoltar/Fechar + canal de remoção"]
```
**Opções consideradas (abordagem geral):** A) rota nova `/fotos` sobre o catálogo C232, com estado de publicação, read público cacheado por tag, contrato puro próprio, mídia same-origin e gate de timing por global; B) reusar `/conteudos` com uma faceta "foto"; C) construir álbuns/coleções curadas + página por foto.
**Recomendação:** **A** — é o contrato da intenção (URL `/fotos`, acervo ≠ peça editorial), reusa o dono de cada mecanismo (C232 para campos/vocabulário, `contentPieceReads` para o padrão de cache, `privateMediaResponse` para o I/O, `documents.ts`+allowlist para a tag) e cabe no appetite sem collection nova.
**Rejeitadas:** **B** porque mistura dois produtos e faria a Central carregar 6,5k fotos como "peças"; **C** porque exige segunda curadoria, página por foto e explosão de sitemap — os cortes literais da intenção.
### Componentes / mudanças
- **`src/lib/archivePhotoCatalog.ts`** (editar): vocabulário de publicação `ARCHIVE_PHOTO_PUBLICATION_STATUSES = ['draft','approved','removed']` + labels + `isArchivePhotoPublicationStatus` + predicado `archivePhotoIsPublic(record)` (espelho de `contentPieceIsPublic`); permanece a fonte de cenas/pessoas/datas. `publicationStatus` **não** entra em `ARCHIVE_PHOTO_CURATED_FIELDS` (`:61-86`) — é decisão editorial, não campo que o job disputa.
- **`src/lib/archivePhotoPublicCatalog.ts`** (novo, puro): `ARCHIVE_PHOTO_ALBUM_PATH='/fotos'`, `ARCHIVE_PHOTO_ALBUM_PAGE_SIZE=24`, `ARCHIVE_PHOTO_THUMB_WIDTH`, params/facetas/href canônico/parse fail-closed, `archivePhotoPublicPeople`, facetas derivadas, filtro AND+termo, heading derivado, labels de data (UTC) e view model (`mediaPath`, `thumbnailPath`, `downloadPath`).
- **`src/collections/ArchivePhoto.ts`** (editar): campo `publicationStatus` (select, `defaultValue:'draft'`, `required`, `index`), `municipalityName`/`municipalitySlug` (text, `readOnly`, slug `index`); `deriveArchivePhotoCatalogIndex` (`:150-211`) estende o `select` da leitura de município (`:183`) para `{ name: true, slug: true }` e grava os snapshots; `defaultColumns` (`:224`) ganha o status; hooks novos de revalidação (`afterChange`/`afterDelete`, espelho `ContentPiece.ts:92-106,271-272`).
- **`src/globals/PhotoAlbum.ts`** (novo): global `photoAlbum` (grupo `Configurações`), `read: () => true`, `update: payloadAdminOnly`, `published` (checkbox, default `true` por decisão do gate de 2026-09-26 — a rota nasce aberta; é o kill switch da superfície), `removalChannelUrl` (text, valida `https?://`/`mailto:`), hook `beforeValidate` fail-closed (publicar sem canal válido recusa) e `afterChange` → revalidar a tag do global. Registrar em `src/payload.config.ts` (`globals`, `:161`).
- **`src/utilities/archivePhotos/archivePhotoReads.ts`** (novo, `server-only`): `select` explícito (`id`, `alt`, `catalog`, `municipalityName`, `municipalitySlug`, `takenOn`, `searchText`, `filename`, `mimeType`), `where publicationStatus=approved`, `sort ['-takenOn','-id']`, `depth 0`, `unstable_cache` na tag da collection; `getApprovedArchivePhotoItems/ById` e `hasPublishedArchivePhotos` (descoberta).
- **`src/utilities/privateMedia/privateMediaResponse.ts`** (editar): novo `buildPrivateMediaImageResponse` — mesma abertura S3/disco de `:107-160`, mas quando há `width` faz o resize **em streaming** (`sharp(stream).resize(...)`) sem temp-file; sem `width`, delega ao `buildPrivateMediaResponse` (`:175-212`) que **já suporta** `download`/`dispositionFilename` (nenhuma mudança nele).
- **Routes:** `src/app/(frontend)/fotos/page.tsx` (sem `loading.tsx` de propósito: o boundary de Suspense flusha o shell antes da página e o `notFound()` do kill switch viraria `200` com a UI de not-found; o gate é checado no `generateMetadata` e na página), `src/app/(frontend)/fotos/[id]/midia/route.ts` (`force-dynamic`; `?tamanho=grade`, `?download=1`; `X-Robots-Tag: noindex`).
- **UI:** `src/components/fotos/` — `ArchivePhotoPageHeader`, `ArchivePhotoHero`, `ArchivePhotoFilters`, `ArchivePhotoCard/Grid`, `ArchivePhotoLightbox` (client mínimo), `ArchivePhotoStates`, `ArchivePhotoRemovalBand`; `src/components/CampaignFooter.tsx` ganha `showFotos` + `current='fotos'` (padrão dos flags existentes, `:27-95`).
- **Revalidação:** `src/utilities/documents.ts` ganha `revalidateArchivePhotosListing()` (usa o `revalidateTagSafely` de `:27-34`); `src/utilities/revalidateRequest.ts` ganha a tag `archivePhotos` na allowlist (`:16-26`) e `AGENTS-public.md` documenta a tag.
- **Migration:** `pnpm migrate:create add_archive_photo_publication` — aditiva: coluna `publication_status` (`default 'draft'`), colunas `municipality_name`/`municipality_slug`, tabela do global `photo_album`, **backfill** dos snapshots de município por `UPDATE ... FROM municipality` (SQL revisado à mão; nenhuma migration existente tocada). `pnpm generate:types`.
- **Testes/manifest:** ver D10.
- **Access / Consent:** nenhum `Consent` novo; nenhuma pessoa nova; `update` da collection e do global seguem `payloadAdminOnly`; read anônimo e `overrideAccess` da mídia reusam os precedentes documentados (hard-stop 4).
### Dados → forma (se aplicável)
N/A — a intenção declara: nenhum número, contador ou ranking na superfície; a lista é acervo com metadado editorial, não apresentação de dado (prompt 3 de data-presentation não se aplica).
## Decisões de engenharia
### D1 — Estado de publicação (schema + migration + backfill) (caro)
**Opções:** A) checkbox `published`; B) select `publicationStatus: draft | approved | removed` (default `draft`, indexado); C) reusar `curatedFields`/`catalog.source` como marcador.
**Recomendação:** **B** — o aceite exige "remoção não republica": `removed` é estado sticky que o pipeline nunca escreve e que só a staff reverte; A não distingue "nunca curada" de "removida a pedido" e um clique re-publicaria. Backfill: a migration cria a coluna com default `draft` — **nenhuma** das fotos já catalogadas nasce pública; a assessoria aprova deliberadamente. A edição ocorre na mesma ficha do `/admin` (grupo Comunicação; `access.update = payloadAdminOnly`, `ArchivePhoto.ts:227-232`); abrir a curadoria para a sessão `communicator` é item separado (gatilho registrado).
**Rejeitadas:** **A** porque não preserva a remoção; **C** porque `curatedFields` arbitra job×curadoria de campos de catálogo e `source` é o resultado da pré-catalogação — categoria errada.
### D2 — Read público cacheado e resolução do município (caro)
**Opções:** A) módulo de domínio `src/utilities/archivePhotos/archivePhotoReads.ts` + snapshot `municipalityName/Slug` na linha (gravado pelo hook, backfill na migration); B) módulo top-level registrado no pin; C) ler a relação no caminho público (depth 1 ou query extra com bypass).
**Recomendação:** **A** — o hook **já** lê o município a cada save com o bypass documentado de geografia (`ArchivePhoto.ts:177-190`); denormalizar ali mantém o read público 100% dentro da própria linha (`depth 0`, `select` explícito, sem Flickr/geo/EXIF), sem novo bypass e filtrando `publicationStatus === 'approved'` **e** `archivePhotoIsPublic` no mapper (fail-closed em duas camadas). A subpasta `archivePhotos/` evita o pin top-level (`codebaseConventions.unit.spec.ts:421-573`); `flickr/` continua o dono da ingestão (fonte), não do álbum. Cache: `unstable_cache(['archive-photos'], { tags:[getCollectionListingTag('archivePhoto')] })`, espelhando `contentPieceReads.ts:26-65`; lista inteira das aprovadas filtrada em memória — **gatilho de revisita** quando as aprovadas passarem de ~1k (consultas por página + snapshot de facetas).
**Rejeitadas:** **B** (pin novo para uma leitura de domínio); **C** (o read de `municipality` é campaign-only — `municipalities.ts:192` — e um bypass extra no caminho público vaza superfície por dado que é imutável e o hook já tem em mãos).
### D3 — Contrato de URL, facetas e paginação (caro)
**Opções:** A) `data` (dia exato `YYYY-MM-DD`), `municipio` (slug do `municipalityCatalog`), `atividade` (valor de `ARCHIVE_PHOTO_SCENES`), `pessoa` (slug do nome canônico), `q`, `pagina` (1-based, canônico dropa 1) e `foto` (id numérico, **não** canônico) — puros em `src/lib/archivePhotoPublicCatalog.ts`; B) `mes`/`ano` + `tema`; C) multi-value por facet.
**Recomendação:** **A** — single-value no vocabulário existente, no padrão `contentPieceCatalog.ts:105-156`; o dia casa com `takenOn` (meio-dia UTC; parse reusa `archivePhotoTakenOn`, `archivePhotoCatalog.ts:256-271`) e é a pergunta real ("a foto da agenda"); as opções do controle de Data derivam **só** dos dias com foto aprovada (vocabulário fechado, não um calendário completo) e o controle (select × menu) é confirmado pelo `designer` em D8; `ARCHIVE_PHOTO_ALBUM_PAGE_SIZE = 24` mantém a grade leve (designer confirma 2/3 colunas); `buildArchivePhotoAlbumHref` serializa na ordem fixa `data,municipio,atividade,pessoa,q,pagina` e só inclui `foto` a pedido; a metadata canônica é `/fotos` sem filtros e `robots:noindex` quando zero aprovadas (espelho `page.tsx:45-81`); facetas derivam **só** dos itens aprovados (filtro que não casa não renderiza); `pessoa` casa `slugify(resolvePublicFigureName(nome) ?? nome)` (`publicFigureCatalog.ts:77-85`); `municipio` casa `municipalitySlug`; `atividade` só com cenas presentes, ordenadas pelo vocabulário.
**Rejeitadas:** **B** (mês/ano não é a pergunta e "capítulo" é heading derivado, não entidade; `tema` não existe neste acervo); **C** (quebra o contrato single-value do repo e faz a URL explodir com 8 pessoas).
### D4 — Porta pública de mídia, miniatura e download (caro)
**Opções:** A) `/fotos/[id]/midia` com `buildPrivateMediaResponse` para a imagem cheia e `?tamanho=grade` com resize on-the-fly (sharp, uma imagem, sem storage), `?download=1` para "Baixar foto"; B) `imageSizes` no upload + regeneração/backfill das aprovadas; C) servir o original em tudo.
**Recomendação:** **A** — o repo nunca usou `imageSizes` e as 6,5k fotos existentes não têm variantes; sharp já é dependência e o domínio já faz exatamente esse resize (`flickr/archivePhotoCatalog.ts:177-186`). O resize é **em streaming** (`sharp(stream)` sobre o objeto aberto do S3/disco, sem temp-file; sharp é o mesmo dono que o C232 já usa), dentro do `privateMediaResponse` (I/O num dono só), com os headers privados intocados (`no-store`, `privateMedia.ts:41`) e `X-Robots-Tag: noindex` nas duas variantes (evita 6,5k imagens indexáveis). O gate é o read cacheado (`getApprovedArchivePhotoById`) → 404 idêntico para draft/removida/inexistente; a rota re-lê a linha com o `overrideAccess` documentado do precedente (`conteudos/[slug]/midia/route.ts:40-51`) para o filename fresco; disposição `inline` para exibir e `attachment` no download (o `buildPrivateMediaResponse` já aceita `download` + `dispositionFilename`), nome legível por helper puro (`jorge-solla-1313-foto-<id>.<ext>`). O `no-store` é deliberado: pull-down imediato (right to erasure) não convive com cache público de derivada.
**Rejeitadas:** **B** (pipeline/backfill novo para um acervo majoritariamente não-público e sem precedente de `imageSizes`); **C** (contraria o aceite "miniaturas" — original de vários MB por card).
**Risco assumido:** CPU do resize por request (24/página, sem cache) — medir no e2e local; gatilho de revisita: p95 da página acima do aceitável ou aprovadas >~1k ⇒ variante armazenada (decisão explícita, hard-stop 5).
### D5 — Tag de revalidação, hook e allowlist (caro)
**Opções:** A) tag `archivePhotos` **derivada** (`getCollectionListingTag('archivePhoto')`) + allowlist + `revalidateArchivePhotosListing()` + `afterChange`/`afterDelete` na collection (espelho `ContentPiece.ts:92-106`); B) tag literal `archive-photos`; C) sem hook (TTL/deploy).
**Recomendação:** **A** — uma edição de curadoria (inclusive `approved→removed`) busta a lista e o `byId` do overlay: a foto some do índice e a mídia dela passa a 404 **sem deploy**; o helper seguro já engole o `revalidateTag` fora de request (`documents.ts:27-34`), então o ingest CLI não quebra; `documents.ts` continua sem importar `@payload-config`.
**Rejeitadas:** **B** (duas fontes para o mesmo nome); **C** (contraria o aceite de pull-down imediato).
### D6 — Detalhe da foto em contexto (caro)
**Opções:** A) overlay server-rendered na própria `/fotos` via `?foto=<id>` (não canônico), id fora das aprovadas → sem overlay (fail-closed), "Voltar ao álbum"/"Fechar" apontando para o href filtrado sem `foto`, com um shell client mínimo para foco inicial e Esc; B) rota interceptada `/fotos/[id]`; C) página própria `/fotos/[id]` noindex.
**Recomendação:** **A** — preserva o corte "foto avulsa não ganha página própria" (`/fotos/<id>` segue 404) e o voltar/fechar é navegação real que funciona sem JS; o overlay reusa o mesmo item do read cacheado, sem segunda query, e mostra o contexto curado (Data, Local, Atividade, Quem aparece) + Baixar + canal de remoção.
**Rejeitadas:** **B** (maquinário de rota interceptada sem ganho e cria um path por foto); **C** (contraria o corte de sitemap/6,5k páginas).
### D7 — Canal de remoção (caro)
**Opções:** A) global `photoAlbum` com `removalChannelUrl` (http(s)/mailto validado) e links diretos/pré-preenchidos (`mailto:` com assunto + link da foto), sem formulário; staff marca `removed` na ficha; publicar exige canal válido; B) link para `/contato`; C) form/collection de pedidos com `Consent`.
**Recomendação:** **A** — zero captura de dado e zero `Consent` novo; o destino é dado editável no `/admin` (nunca inventado no código — o gate fornece e o hard-stop 4 registra); "atendimento registrado" = `publicationStatus: removed` na ficha, sem workflow; a banda de rodapé (cena 05) e o detalhe consomem o mesmo campo.
**Rejeitadas:** **B** (rota inexistente no site — inventaria uma página de contato); **C** (collection/Consent/PII novos, fora do appetite e do invariante).
### D8 — UI e extensão do design (caro — trigger (b) do designer)
**Opções:** A) port classe-a-classe com o `designer` estendendo o artefato **antes** do markup; B) implementar o artefato literal; C) redesenhar.
**Recomendação:** **A** — superfícies e estados: header page-local no molde `ContentPiecePageHeader`; hero; busca GET sem JS + chips `Data`/`Município`/`Atividade`/`Pessoa pública` com `<details>` e chip ativo × (padrão `ContentPieceFilters.tsx:95-196`); grade 2/3 colunas com `<img loading="lazy" decoding="async">` (sem `next/image`: proxy `no-store` fora da allowlist — `next.config.mjs:9-30`; precedente `ContentPieceMedia.tsx:184-193`); card com título (`catalog.caption ?? alt`), meta `data · município · atividade` e linha "Quem aparece"; paginação anterior/próxima preservando facetas; overlay (D6); vazios honestos no molde `ContentPieceStates.tsx:19-72`; banda de remoção (cena 05) + `CampaignFooter`; a11y: `alt` curado, teclado, foco visível, contraste, mobile-first. O `designer` precisa entregar **antes do markup**: (i) remoção/substituição do CTA "Encontre você nas fotos" (depende do C234 — **não pode ser seguido como está**); (ii) comportamento do heading "Agenda em destaque" (helper derivado do filtro ativo); (iii) padrão mobile das facetas (bottom sheet × linha de `<details>`); (iv) foco/fechar do overlay; (v) confirmação do "Baixar foto" no v1; (vi) controle de Data (drop-down dos dias existentes × outro padrão — o artefato só mostra "Qualquer data"); (vii) controle de paginação (o artefato não desenha um).
**Rejeitadas:** **B** (o artefato contém um CTA impossível nesta fatia — seguir literal é defeito); **C** (design aprovado existe; redesign é desperdício).
### D9 — Timing eleitoral (decidido pelo humano — abrir agora)
**Opções:** A) rota fechada por padrão (`photoAlbum.published=false` ⇒ `/fotos` 404 + noindex) com flip pós-eleição; B) env `ARCHIVE_PHOTO_ALBUM_PUBLIC`; C) deferir o item; D) **abrir agora** — `published` default `true`, rota viva no merge, com kill switch global e remoção por foto.
**Decisão do gate (2026-09-26):** **D** — o humano decidiu explicitamente implementar e abrir agora, sobrepondo a recomendação (assumida) de pós-eleição do plano de intenção. Consequências registradas: a proteção contra conteúdo segue sendo a curadoria (nenhuma foto nasce aprovada) + o estado `removed` por foto + o kill switch do global (flip sem deploy) + `noindex` enquanto não houver aprovadas; o canal de remoção é pré-requisito para fotos irem ao público (guard do global).
**Rejeitadas:** **A** (preferida do plano de intenção, preterida pelo gate); **B** (flip exigiria deploy/restart, invisível para o produto e sem trilha); **C** (a intenção pede o item e o gate decidiu não adiar).
### D10 — Verificação por camada (barato, mas explícito)
**Opções:** A) unit do contrato puro + unit dos pins existentes + int do read/rota/hook/global + e2e novo `frontendFotos` no manifest, no projeto Playwright e no curado; B) só int; C) e2e de browser para tudo.
**Recomendação:** **A** — unit `tests/unit/archivePhotoPublicCatalog.unit.spec.ts` (parse fail-closed de cada param, href canônico preservando facetas e dropando `foto`/`pagina=1`, facetas só de aprovadas, filtro AND + termo + dia, view model/heading/labels de data, slice de página); estende `tests/unit/archivePhotoCatalog.unit.spec.ts` (vocabulário/predicado de publicação); int `tests/int/archivePhotoPublicRead.int.spec.ts` (aprovada aparece; draft/removida não; escrita de sistema não muda `removed`; município resolvido pelo snapshot; `getApprovedArchivePhotoById`; hook busta; guard do global; importa `GET` da rota de mídia — precedente `contentEvents.int.spec.ts:40` e `contentPiece.int.spec.ts:48-49` — para 200/404 e download); e2e `tests/e2e/frontendFotos.e2e.spec.ts` (serial, REST admin com `adminHeaders`; seeda foto por multipart + flipa o global; filtra por facetas/termo, abre overlay, mídia 200/404, `removed` some; restaura o global no `afterAll` e limpa as linhas). Pins: entrada nova em `scripts/lib/e2e-affected-manifest.mjs` (prefixos da rota/componentes/contrato/utilities/collection/global/documents), `frontendFotos` em `E2E_CURATED_SPECS` (`:24-54`, obrigatório: a migration torna o PR high-risk) e no pin `tests/unit/e2eAffectedManifest.unit.spec.ts:51-103`; projeto `frontendFotos` em `playwright.config.ts:170-210` (testMatch próprio + `dependencies: isProdMode ? [] : ['frontend']`); `src/components/CampaignFooter.tsx` no manifest ganha `frontendFotos`. `codebaseConventions.unit.spec.ts:421-573` intocado (subpasta de domínio).
**Rejeitadas:** **B** (o contrato HTTP público e o kill switch pedem prova real); **C** (browser para lógica pura é caro e frágil).
## Fases verificáveis
1. **Tracer / schema+server — quota ~45%:** vocabulário/predicado + campos da collection + snapshot de município no hook + global + migration com backfill + `pnpm generate:types`; contrato puro; read cacheado; tag/helper/allowlist e hooks; unit + int verdes antes de seguir.
2. **UI (dispatch do designer primeiro) — quota ~35%:** extensão do artefato (D8), componentes `src/components/fotos/*`, página + metadata + `loading`, rota de mídia (grade/download), overlay, `CampaignFooter`.
3. **Gates — quota ~20%:** `pnpm gate:fast`; `pnpm test:int`; e2e local `pnpm test:e2e --no-deps --project=frontendFotos`; manifest/curado/pin/projeto; `pnpm push`; no fechamento, changelog `docs/changelog/2026-09-26-c233.md` e `AGENTS-public.md` (tag nova na allowlist + contrato do álbum).
## Rabbit holes / Não escopo (engenharia)
- **Página própria por foto** (`/fotos/[id]` page, sitemap de 6,5k) — corte literal; o `/fotos/<id>` continua 404.
- **Álbuns/coleções por evento com segunda curadoria** — heading derivado do filtro + ordenação; sem entidade nova.
- **Selfie/reconhecimento facial (C234), busca por rosto ou nome inferido** — fora; o CTA do design é removido/substituído.
- **Like/comentário/share com PII, contador de views, "mais vistas"** — vedados pela intenção.
- **`Contact`/collection de pessoas, upload público, edição de imagem** — proibidos.
- **`Consent` novo ou reuse de opt-in para remoção** — proibido; o canal é link, sem captura.
- **`imageSizes`/variantes armazenadas e scripts de regeneração** — v1 é on-the-fly; gatilho: CPU/tempo da grade.
- **Abrir a curadoria para `communicator` (access)** — fora; a ficha segue no `/admin` com `payloadAdminOnly`.
- **Ler `municipality` publicamente / `depth 1` no read** — rejeitado em D2.
- **`ALTER TYPE` de `curatedFields`** — morto; `publicationStatus` não é campo de catalogação.
- **Busca semântica/tema no álbum** — não existe vocabulário; `q` é literal sobre `searchText`.
- **Prefetch agressivo/client router cache nas páginas filtradas** — manter `prefetch` padrão do Link; sem infinite scroll.
## Riscos e mitigação
- **Original pesado vazando para a grade:** miniatura on-the-fly em streaming (sharp) + página de 24 + `loading="lazy"`; e2e/int conferem a variante; gatilho para variante armazenada se CPU incomodar.
- **Custo da leitura da lista inteira a cada bust:** só aprovadas entram no `where`; o bust acompanha edições de curadoria (baixa frequência); gatilho >~1k aprovadas.
- **Backfill dos snapshots de município incorreto:** SQL revisado à mão + int com município seedado e foto catalogada antes da migration; a migration é aditiva e nunca substitui as existentes.
- **Remoção que "volta":** pipeline nunca escreve `publicationStatus`; `removed` só sai por edição humana; int cobre escrita de sistema com `context` preservando o estado.
- **Canal de remoção vazio em produção:** guard do global recusa publicar sem canal válido; links só existem com o canal preenchido; e2e cobre o guard.
- **Superfície pública nova em período eleitoral:** risco assumido explicitamente pelo humano (2026-09-26, D9 — abrir agora); mitigado por curadoria (nada nasce aprovado), `removed` fail-closed, kill switch global e `noindex` enquanto vazio.
- **`revalidateTag` fora de request (CLI/e2e):** helper seguro existente; int cobre o write sem request.
- **S3 parcial/derrubada de boot:** guard existente de `mediaStorage` intocado; a nova rota usa o mesmo `resolvePrivateMediaStaticDir` de dev/test.
- **e2e com estado global compartilhado:** spec serial + `beforeAll`/`afterAll` restaurando `photoAlbum`; a suíte roda `fullyParallel` com 2 workers — mitigar não afirmando o link novo em specs alheios e revisar ao adicionar o projeto.
- **PR high-risk (migration):** curado com `frontendFotos` + pin do curado atualizado no mesmo PR; `pnpm test:e2e --no-deps --project=frontendFotos` local.
## Hard-stops (aprovação humana explícita — valem mesmo em `--auto`)
1. **Schema/migration:** `publicationStatus` + snapshots de município + global `photoAlbum` + backfill SQL — `pnpm migrate:create add_archive_photo_publication` e `pnpm migrate` só no banco local do worktree (`teqo_wt233`/`teqo_wt233_test`); aditiva, sem tocar migration existente; revisar o SQL; `pnpm generate:types`. **✅ Aprovada na sessão `--auto` (2026-09-26).**
2. **Contrato público/URL:** `/fotos` + params `data|municipio|atividade|pessoa|q|pagina|foto`, rota `/fotos/[id]/midia` (variantes grade/download), canonical `/fotos`, noindex quando vazio, e a tag `archivePhotos` entrando na allowlist fechada de `revalidateRequest.ts` (com atualização de `AGENTS-public.md`). **✅ Aprovado na sessão `--auto` (2026-09-26).**
3. **Timing eleitoral (produto): ✅ decidido pelo humano (2026-09-26) — abrir agora.** `photoAlbum.published` nasce `true`; a rota abre no merge/deploy. Nenhuma foto existente é aprovada pela migration; o canal de remoção é pré-requisito para aprovar fotos.
4. **Bypasses intencionais:** (a) leitura anônima da lista aprovada via Local API (padrão documentado de `contentPieceReads.ts:13-24`); (b) `overrideAccess: true` na rota de mídia só para o filename fresco (precedente `conteudos/[slug]/midia/route.ts:40-51`); (c) o bypass de geografia do hook C232 (`ArchivePhoto.ts:177-190`) é **estendido, não criado**. O destino do canal de remoção é dado de produto fornecido pelo gate — **nunca inventar endereço/URL no código**. **✅ Aprovados na sessão `--auto` (2026-09-26).**
5. **Miniatura on-the-fly:** decisão de performance deliberada do v1 (sharp em streaming por request, sem storage); se o gate de produto preferir variante armazenada, a troca é decisão explícita (afeta D4) antes do push. **✅ Aprovada na sessão `--auto` (2026-09-26).**
## Adiado com gatilho (triagem do /simplify, 2026-09-26)
Nenhum achado virou Issue nova (zero `expensive_lock` ≥4). Os baratos ficaram registrados aqui:
- **Forma do nome de download (4ª cópia)** — `archivePhotoDownloadFilename` repete `contentPieceDownloadFilename`/jingle/webSpeech; já absorvido pelo defer do S27 (`escala-dry-pos-s27.md`). Gatilho: próximo toque em `src/lib/contentPieceCatalog.ts:571` ou `src/lib/jingle.ts` (unificar com a 4ª cópia).
- **`FACET_SLUG_PATTERN` residual** em `contentPieceCatalog.ts:99` e `shareLink.ts:27` (a C233 usa `SLUG_PATTERN` de `lib/slug.ts`). Gatilho: tocar qualquer um dos dois donos.
- **Chassi do bottom sheet** duplicado com `ContentPieceShareSheet` (portal + tema + Esc + backdrop). Gatilho: 3º overlay/sheet com o mesmo chassi.
- **`waitForSettledPage` duplicado** nos e2e (dono existente: `tests/e2e/fixtures/campaignE2EFixtures.ts` `waitForStreamSettled`; cópias pré-C233 em `frontendConteudos`/`campaignPeople`/`campaignConcepts`). Gatilho: próximo toque nos specs e2e.
- **`ArchivePhotoPublicSource` espelha o `select` à mão** + cast no read. Gatilho: `select` mudar sem o type acompanhar (derivar de `Pick<ArchivePhoto, …>`).
- **Sem trap de foco/`inert`** nos overlays (só foco inicial + Esc, como o `ContentPieceShareSheet`). Gatilho: requisito de a11y de modal pleno ou 3º overlay.
- **Acúmulo manual de chunks** no resize (`privateMediaResponse.ts`) em vez de `pipeline` + `toBuffer()`. Gatilho: próxima mexida no helper (avaliar com e2e da rota).
- **`RAISE NOTICE` do backfill** pode não aparecer no log do deploy. Gatilho: se o count não aparecer no 1º deploy real, trocar por log do migrator.
- **Resize on-the-fly sem cache** — decisão aprovada (hard-stop 5); revisita já condicionada a p95/>~1k aprovadas.
## Aceite de engenharia
- [ ] Aceite de produto coberto: busca por data/município/atividade/pessoa + termo; só aprovadas (nem por URL direta); contexto real; vazio honesto; canal de remoção visível com efeito imediato e `removed` não republicável pelo pipeline; cache por tag com pull-down imediato; a11y; grade leve mobile; same-origin/privado.
- [ ] Invariantes AGENTS/engineering-standards: sem cadastro paralelo/PII/`Consent`; `overrideAccess` só nos precedentes documentados; migração aditiva com SQL revisado; identificadores em inglês e copy pt-BR; nada de `next/image` em rota `no-store`.
- [ ] Testes de domínio previstos (unit/int) onde o read/gate/write mudam; manifest + curado + pin + projeto Playwright atualizados; e2e novo existe no disco.
- [ ] `pnpm gate:fast` verde; int do caminho verde; e2e `frontendFotos` verde local; `pnpm push` via GitHub; changelog + doc do contrato no fechamento.
## Self-score
**Self-score decision-quality: 4/5.** (1) todas as decisões caras (estado de publicação, read/denormalização, contrato de URL, mídia/miniatura, tag/hook, detalhe, remoção, timing, verificação) têm Opções + Recomendação + Rejeitadas; (2) cabe no appetite herdado: sem collection nova, uma migration aditiva com backfill, reusa o hook do C232, o padrão de read do S27, `privateMediaResponse`, o vocabulário de cenas/pessoas e o padrão de estados do catálogo; (3) rabbit holes nomeados (página por foto, álbuns, selfie, PII, `imageSizes`, access da curadoria, leitura pública de município, busca semântica); (4) depth check: donos existentes editados (collection/hook, documentos/tag, allowlist, footer, config do Playwright) e módulos novos só onde não há dono (contrato público, read do álbum, global, componentes `fotos/`); (5) outcome preservado — fail-closed, curadoria manda, sem PII, sem reescrever o aceite. O que impede o 5: a **miniatura on-the-fly** é aposta de performance não medida (CPU por request, sem storage) e o **timing eleitoral + o destino do canal de remoção** são decisões de produto (o timing foi decidido pelo humano em 2026-09-26; o destino do canal é dado do global, nunca inventado no código).

View file

@ -51,6 +51,28 @@
outline: 3px solid var(--brand-yellow);
outline-offset: 3px;
}
.photo-card {
display: block;
border-radius: 12px;
color: inherit;
text-decoration: none;
transition:
transform 160ms ease,
box-shadow 160ms ease;
}
.photo-card:hover {
transform: translateY(-2px);
}
.photo-card:hover .photo-frame {
box-shadow: 0 12px 28px rgb(32 27 26 / 16%);
}
.photo-card:focus-visible {
outline: 3px solid var(--brand-yellow);
outline-offset: 4px;
}
.photo-frame {
transition: box-shadow 160ms ease;
}
.photo-placeholder {
position: relative;
overflow: hidden;
@ -198,6 +220,9 @@
<span class="rounded-full border border-border bg-white px-3 py-1 text-xs font-bold"
>Rota pública · /fotos</span
>
<span class="rounded-full border border-border bg-white px-3 py-1 text-xs font-bold"
>Abre agora · acervo começa sem aprovações</span
>
</div>
<h1 class="display mt-4 text-3xl font-black tracking-[-0.03em]">
Álbum público: encontrar a foto certa sem virar rede social
@ -206,6 +231,16 @@
Cidadão procura por termo, data, município, atividade ou pessoa pública; vê somente fotos
aprovadas e entende o contexto antes de guardar a imagem.
</p>
<div class="mt-5 rounded-xl border border-[#184e92]/20 bg-[#f0f5fb] p-4 text-sm leading-6">
<p class="font-extrabold text-[#184e92]">Extensão do gate · 26 set 2026</p>
<p class="mt-1">
O hero fica sem CTA: “Encontre você nas fotos” pressupunha selfie/reconhecimento facial
(C234), fora desta entrega. A busca logo abaixo já é a ação primária honesta; duplicá-la
com “Ver as fotos” criaria competição sem acrescentar destino. Data usa menu de dias
publicados no desktop e select no sheet mobile. O detalhe é sempre um dialog sobre a
grade, nunca uma página própria.
</p>
</div>
</header>
<!-- CENA 01 -->
@ -253,11 +288,6 @@
data e contexto.
</p>
</div>
<a
href="#"
class="secondary relative shrink-0 border-white/40 bg-white/10 text-white"
>Encontre você nas fotos <svg class="icon"><use href="#chev" /></svg
></a>
</div>
</section>
<div class="mx-auto max-w-6xl px-8 py-8">
@ -278,11 +308,37 @@
</div>
<button class="primary min-w-28">Buscar fotos</button>
</div>
<div class="mt-4 flex flex-wrap gap-2" aria-label="Filtros">
<button class="chip">
<svg class="icon size-4"><use href="#calendar" /></svg>Data
<svg class="icon size-3.5"><use href="#down" /></svg>
</button>
<div class="mt-4 flex flex-wrap items-start gap-2" aria-label="Filtros">
<div class="relative">
<button class="chip border-[#184e92]/35 text-[#184e92]" aria-expanded="true">
<svg class="icon size-4"><use href="#calendar" /></svg>Data
<svg class="icon size-3.5 rotate-180"><use href="#down" /></svg>
</button>
<div
class="absolute top-12 left-0 z-10 w-56 rounded-xl border border-border bg-white p-1.5 shadow-[0_14px_34px_rgb(0_0_0/16%)]"
>
<p
class="px-3 py-1.5 text-[10px] font-black tracking-[.08em] text-muted-foreground uppercase"
>
Dias com fotos publicadas
</p>
<a
href="#"
class="focus-ring block rounded-lg bg-[#f0f5fb] px-3 py-2 text-sm font-bold text-[#184e92]"
>12 de setembro de 2026</a
>
<a
href="#"
class="focus-ring block rounded-lg px-3 py-2 text-sm font-medium hover:bg-[#ebe9e9]"
>8 de setembro de 2026</a
>
<a
href="#"
class="focus-ring block rounded-lg px-3 py-2 text-sm font-medium hover:bg-[#ebe9e9]"
>3 de setembro de 2026</a
>
</div>
</div>
<button class="chip chip-active">
<svg class="icon size-4"><use href="#map" /></svg>Município · selecionado
<svg class="icon size-3.5"><use href="#x" /></svg>
@ -298,21 +354,28 @@
</button>
</div>
</div>
<div class="mt-8 flex items-end justify-between border-b border-border pb-4">
<div class="mt-24 flex items-end justify-between border-b border-border pb-4">
<div>
<p class="text-xs font-black tracking-[.1em] text-[#e4102f] uppercase">
Agenda em destaque
Álbum público
</p>
<h3 class="display mt-1 text-2xl font-black">Fotos em Município selecionado</h3>
<p class="mt-1 text-xs text-muted-foreground">
Com mais de um filtro, município define o título; os demais permanecem nos
chips.
</p>
<h3 class="display mt-1 text-2xl font-black">
Plenárias e encontros no município
</h3>
</div>
<span class="text-sm text-muted-foreground">Mais recentes primeiro</span>
</div>
<div class="mt-5 grid grid-cols-3 gap-x-4 gap-y-7">
<article class="group">
<button class="focus-ring block w-full rounded-xl text-left">
<div class="photo-placeholder aspect-[4/3] rounded-xl"></div>
<a
href="/fotos?municipio=selecionado&amp;foto=101"
class="photo-card group block w-full text-left"
>
<div
class="photo-frame photo-placeholder aspect-[4/3] rounded-xl ring-2 ring-[#ffeb00] ring-offset-2"
></div>
<div class="pt-3">
<p class="display text-base font-black">Plenária pela saúde pública</p>
<p class="mt-1 text-sm leading-5 text-muted-foreground">
@ -320,11 +383,16 @@
</p>
<p class="mt-1 text-sm">Jorge Solla e pessoa pública convidada</p>
</div>
</button>
</a>
</article>
<article>
<button class="focus-ring block w-full rounded-xl text-left">
<div class="photo-placeholder photo-b aspect-[4/3] rounded-xl"></div>
<a
href="/fotos?municipio=selecionado&amp;foto=102"
class="photo-card block w-full text-left"
>
<div
class="photo-frame photo-placeholder photo-b aspect-[4/3] rounded-xl"
></div>
<div class="pt-3">
<p class="display text-base font-black">Visita à unidade de saúde</p>
<p class="mt-1 text-sm leading-5 text-muted-foreground">
@ -332,11 +400,16 @@
</p>
<p class="mt-1 text-sm">Jorge Solla</p>
</div>
</button>
</a>
</article>
<article>
<button class="focus-ring block w-full rounded-xl text-left">
<div class="photo-placeholder photo-c aspect-[4/3] rounded-xl"></div>
<a
href="/fotos?municipio=selecionado&amp;foto=103"
class="photo-card block w-full text-left"
>
<div
class="photo-frame photo-placeholder photo-c aspect-[4/3] rounded-xl"
></div>
<div class="pt-3">
<p class="display text-base font-black">Encontro com trabalhadores</p>
<p class="mt-1 text-sm leading-5 text-muted-foreground">
@ -344,11 +417,16 @@
</p>
<p class="mt-1 text-sm">Jorge Solla e pessoa pública convidada</p>
</div>
</button>
</a>
</article>
<article>
<button class="focus-ring block w-full rounded-xl text-left">
<div class="photo-placeholder photo-d aspect-[4/3] rounded-xl"></div>
<a
href="/fotos?municipio=selecionado&amp;foto=104"
class="photo-card block w-full text-left"
>
<div
class="photo-frame photo-placeholder photo-d aspect-[4/3] rounded-xl"
></div>
<div class="pt-3">
<p class="display text-base font-black">Caminhada no centro</p>
<p class="mt-1 text-sm leading-5 text-muted-foreground">
@ -356,11 +434,14 @@
</p>
<p class="mt-1 text-sm">Jorge Solla</p>
</div>
</button>
</a>
</article>
<article>
<button class="focus-ring block w-full rounded-xl text-left">
<div class="photo-placeholder aspect-[4/3] rounded-xl"></div>
<a
href="/fotos?municipio=selecionado&amp;foto=105"
class="photo-card block w-full text-left"
>
<div class="photo-frame photo-placeholder aspect-[4/3] rounded-xl"></div>
<div class="pt-3">
<p class="display text-base font-black">Conversa sobre educação</p>
<p class="mt-1 text-sm leading-5 text-muted-foreground">
@ -368,11 +449,16 @@
</p>
<p class="mt-1 text-sm">Jorge Solla e pessoa pública convidada</p>
</div>
</button>
</a>
</article>
<article>
<button class="focus-ring block w-full rounded-xl text-left">
<div class="photo-placeholder photo-b aspect-[4/3] rounded-xl"></div>
<a
href="/fotos?municipio=selecionado&amp;foto=106"
class="photo-card block w-full text-left"
>
<div
class="photo-frame photo-placeholder photo-b aspect-[4/3] rounded-xl"
></div>
<div class="pt-3">
<p class="display text-base font-black">Reunião com a comunidade</p>
<p class="mt-1 text-sm leading-5 text-muted-foreground">
@ -380,9 +466,24 @@
</p>
<p class="mt-1 text-sm">Jorge Solla</p>
</div>
</button>
</a>
</article>
</div>
<nav
aria-label="Paginação das fotos"
class="mt-9 flex items-center justify-between border-t border-border pt-5"
>
<span
class="inline-flex min-h-11 items-center gap-2 rounded-[10px] border border-border px-4 text-sm font-bold text-muted-foreground opacity-55"
aria-disabled="true"
>
<svg class="icon"><use href="#arrow" /></svg>Anterior
</span>
<span class="text-sm font-bold">Página 1</span>
<a href="/fotos?municipio=selecionado&amp;pagina=2" class="secondary focus-ring"
>Próxima<svg class="icon rotate-180"><use href="#arrow" /></svg
></a>
</nav>
</div>
</div>
</div>
@ -418,11 +519,6 @@
<p class="mt-2 text-sm leading-5 text-white/85">
Busque pela agenda, pelo lugar ou por quem apareceu.
</p>
<a
href="#"
class="mt-4 inline-flex min-h-10 items-center gap-2 rounded-lg border border-white/40 px-3 text-sm font-bold"
>Encontre você nas fotos <svg class="icon size-4"><use href="#chev" /></svg
></a>
</div>
<div class="p-4 pb-8">
<div class="flex h-11 items-center gap-2 rounded-[10px] border border-black/20 px-3">
@ -437,40 +533,80 @@
><button class="chip shrink-0">Data</button
><button class="chip shrink-0">Atividade</button>
</div>
<div class="mt-5 border-b border-border pb-3">
<p class="text-[10px] font-black tracking-[.1em] text-[#e4102f] uppercase">
Álbum público
</p>
<h3 class="display mt-1 text-lg font-black">Fotos em Município</h3>
</div>
<div class="mt-5 grid grid-cols-2 gap-x-3 gap-y-5">
<article>
<div class="photo-placeholder aspect-square rounded-lg"></div>
<p class="mt-2 text-sm font-extrabold leading-4">Plenária pela saúde</p>
<p class="mt-1 text-[11px] leading-4 text-muted-foreground">
12 set · Município · Plenária
</p>
<p class="mt-1 text-[11px] leading-4">Jorge Solla e pessoa convidada</p>
<a href="/fotos?municipio=selecionado&amp;foto=101" class="photo-card block">
<div
class="photo-frame photo-placeholder aspect-square rounded-lg ring-2 ring-[#ffeb00] ring-offset-2"
></div>
<p class="mt-2 text-sm font-extrabold leading-4">Plenária pela saúde</p>
<p class="mt-1 text-[11px] leading-4 text-muted-foreground">
12 set · Município · Plenária
</p>
<p class="mt-1 text-[11px] leading-4">Jorge Solla e pessoa convidada</p>
</a>
</article>
<article>
<div class="photo-placeholder photo-b aspect-square rounded-lg"></div>
<p class="mt-2 text-sm font-extrabold leading-4">Visita à unidade</p>
<p class="mt-1 text-[11px] leading-4 text-muted-foreground">
08 set · Município · Visita
</p>
<p class="mt-1 text-[11px] leading-4">Jorge Solla</p>
<a href="/fotos?municipio=selecionado&amp;foto=102" class="photo-card block">
<div
class="photo-frame photo-placeholder photo-b aspect-square rounded-lg"
></div>
<p class="mt-2 text-sm font-extrabold leading-4">Visita à unidade</p>
<p class="mt-1 text-[11px] leading-4 text-muted-foreground">
08 set · Município · Visita
</p>
<p class="mt-1 text-[11px] leading-4">Jorge Solla</p>
</a>
</article>
<article>
<div class="photo-placeholder photo-c aspect-square rounded-lg"></div>
<p class="mt-2 text-sm font-extrabold leading-4">Encontro local</p>
<p class="mt-1 text-[11px] leading-4 text-muted-foreground">
03 set · Município · Reunião
</p>
<p class="mt-1 text-[11px] leading-4">Jorge Solla e pessoa convidada</p>
<a href="/fotos?municipio=selecionado&amp;foto=103" class="photo-card block">
<div
class="photo-frame photo-placeholder photo-c aspect-square rounded-lg"
></div>
<p class="mt-2 text-sm font-extrabold leading-4">Encontro local</p>
<p class="mt-1 text-[11px] leading-4 text-muted-foreground">
03 set · Município · Reunião
</p>
<p class="mt-1 text-[11px] leading-4">Jorge Solla e pessoa convidada</p>
</a>
</article>
<article>
<div class="photo-placeholder photo-d aspect-square rounded-lg"></div>
<p class="mt-2 text-sm font-extrabold leading-4">Caminhada no centro</p>
<p class="mt-1 text-[11px] leading-4 text-muted-foreground">
28 ago · Município · Caminhada
</p>
<p class="mt-1 text-[11px] leading-4">Jorge Solla</p>
<a href="/fotos?municipio=selecionado&amp;foto=104" class="photo-card block">
<div
class="photo-frame photo-placeholder photo-d aspect-square rounded-lg"
></div>
<p class="mt-2 text-sm font-extrabold leading-4">Caminhada no centro</p>
<p class="mt-1 text-[11px] leading-4 text-muted-foreground">
28 ago · Município · Caminhada
</p>
<p class="mt-1 text-[11px] leading-4">Jorge Solla</p>
</a>
</article>
</div>
<nav
aria-label="Paginação das fotos"
class="mt-7 flex items-center justify-between border-t border-border pt-4"
>
<span
class="grid size-11 place-items-center rounded-[10px] border border-border text-muted-foreground opacity-50"
aria-label="Página anterior indisponível"
aria-disabled="true"
><svg class="icon"><use href="#arrow" /></svg
></span>
<span class="text-xs font-bold">Página 1</span>
<a
href="/fotos?municipio=selecionado&amp;pagina=2"
aria-label="Próxima página"
class="focus-ring grid size-11 place-items-center rounded-[10px] border border-border bg-white"
><svg class="icon rotate-180"><use href="#arrow" /></svg
></a>
</nav>
</div>
</div>
@ -504,8 +640,14 @@
class="mt-1.5 h-11 w-full rounded-[10px] border border-black/20 bg-white px-3 font-normal"
>
<option>Qualquer data</option>
<option>12 de setembro de 2026</option>
<option>8 de setembro de 2026</option>
<option>3 de setembro de 2026</option>
</select></label
>
<p class="-mt-2 text-xs leading-5 text-muted-foreground">
Somente dias que têm fotos publicadas.
</p>
<label class="text-sm font-bold"
>Município<select
class="mt-1.5 h-11 w-full rounded-[10px] border border-[#184e92]/45 bg-white px-3 font-normal text-[#184e92]"
@ -541,63 +683,147 @@
<section aria-labelledby="scene-3">
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 03</p>
<h2 id="scene-3" class="display mt-1 text-xl font-black">
Foto aberta em contexto · detalhe sem mecânicas sociais
Overlay em /fotos?foto=&lt;id&gt; · a grade permanece atrás
</h2>
<div class="stage mt-4 overflow-x-auto rounded-2xl border border-border bg-[#171412]">
<div class="min-w-[1080px] p-8 text-white">
<div class="mb-5 flex items-center justify-between">
<button
class="inline-flex h-11 items-center gap-2 rounded-lg border border-white/25 px-4 text-sm font-bold"
>
<svg class="icon"><use href="#arrow" /></svg>Voltar ao álbum</button
><button
aria-label="Fechar"
class="grid size-11 place-items-center rounded-full bg-white/10"
>
<svg class="icon"><use href="#x" /></svg>
</button>
<p class="mt-2 text-sm text-muted-foreground">
“Voltar ao álbum” e “Fechar” são links para a mesma URL filtrada sem <code>foto</code>. O
foco inicial vai para “Voltar”; Esc aciona o mesmo fechamento.
</p>
<div
class="stage relative mt-4 h-[820px] min-w-[1080px] overflow-hidden rounded-2xl border border-border bg-white"
>
<div aria-hidden="true" class="p-8">
<div class="h-12 rounded-xl bg-[#fff8f2]"></div>
<div class="mt-7 grid grid-cols-3 gap-4 opacity-80">
<div class="photo-placeholder aspect-[4/3] rounded-xl"></div>
<div class="photo-placeholder photo-b aspect-[4/3] rounded-xl"></div>
<div class="photo-placeholder photo-c aspect-[4/3] rounded-xl"></div>
<div class="photo-placeholder photo-d aspect-[4/3] rounded-xl"></div>
<div class="photo-placeholder aspect-[4/3] rounded-xl"></div>
<div class="photo-placeholder photo-b aspect-[4/3] rounded-xl"></div>
</div>
<div
class="grid grid-cols-[minmax(0,1fr)_340px] overflow-hidden rounded-2xl bg-[#211d1b]"
>
<div class="photo-placeholder photo-d min-h-[650px] rounded-none"></div>
<aside class="flex flex-col p-7">
<p class="text-xs font-black tracking-[.1em] text-[#ffeb00] uppercase">
</div>
<div class="absolute inset-0 bg-[#171412]/82 backdrop-blur-[2px]"></div>
<div
role="dialog"
aria-modal="true"
aria-labelledby="photo-dialog-title"
class="absolute inset-0 overflow-y-auto p-8 text-white"
>
<div class="mx-auto max-w-6xl">
<div class="mb-5 flex items-center justify-between">
<a
href="/fotos?municipio=selecionado"
autofocus
class="inline-flex h-11 items-center gap-2 rounded-lg border border-[#ffeb00] px-4 text-sm font-bold outline-3 outline-offset-3 outline-[#ffeb00]"
>
<svg class="icon"><use href="#arrow" /></svg>Voltar ao álbum</a
>
<a
href="/fotos?municipio=selecionado"
aria-label="Fechar foto e voltar à grade"
class="focus-ring grid size-11 place-items-center rounded-full bg-white/10 hover:bg-white/20"
>
<svg class="icon"><use href="#x" /></svg>
</a>
</div>
<div
class="grid grid-cols-[minmax(0,1fr)_340px] overflow-hidden rounded-2xl bg-[#211d1b] shadow-[0_28px_80px_rgb(0_0_0/40%)]"
>
<div class="photo-placeholder photo-d min-h-[650px] rounded-none"></div>
<aside class="flex flex-col p-7">
<p class="text-xs font-black tracking-[.1em] text-[#ffeb00] uppercase">
Registro aprovado
</p>
<h3
id="photo-dialog-title"
class="display mt-3 text-2xl leading-tight font-black"
>
Plenária pela saúde pública
</h3>
<dl class="mt-6 grid gap-5 text-sm">
<div>
<dt class="font-bold text-white/55">Data</dt>
<dd class="mt-1">12 de setembro de 2026</dd>
</div>
<div>
<dt class="font-bold text-white/55">Local</dt>
<dd class="mt-1">Município selecionado · Bahia</dd>
</div>
<div>
<dt class="font-bold text-white/55">Atividade</dt>
<dd class="mt-1">Plenária</dd>
</div>
<div>
<dt class="font-bold text-white/55">Quem aparece</dt>
<dd class="mt-1">Jorge Solla e pessoa pública convidada</dd>
</div>
</dl>
<a href="/fotos/101/midia?download=1" class="primary focus-ring mt-7 w-full">
<svg class="icon"><use href="#download" /></svg>Baixar foto
</a>
<div class="mt-auto border-t border-white/15 pt-5">
<p class="m-0 text-sm font-bold">É você nesta foto?</p>
<a
class="mt-1 inline-block text-sm text-white/70 underline underline-offset-4"
href="mailto:canal-configurado@example.invalid"
>Peça a remoção desta imagem</a
>
</div>
</aside>
</div>
</div>
</div>
</div>
<div
class="stage relative mt-8 h-[760px] w-[390px] overflow-hidden rounded-[30px] border border-border bg-white"
>
<div aria-hidden="true" class="grid grid-cols-2 gap-3 p-4 pt-20 opacity-70">
<div class="photo-placeholder aspect-square rounded-lg"></div>
<div class="photo-placeholder photo-b aspect-square rounded-lg"></div>
<div class="photo-placeholder photo-c aspect-square rounded-lg"></div>
<div class="photo-placeholder photo-d aspect-square rounded-lg"></div>
</div>
<div class="absolute inset-0 bg-[#171412]/84"></div>
<div
role="dialog"
aria-modal="true"
aria-label="Detalhe da foto"
class="absolute inset-0 flex flex-col overflow-y-auto p-4 text-white"
>
<div class="flex items-center justify-between">
<a
href="/fotos?municipio=selecionado"
class="inline-flex min-h-11 items-center gap-2 rounded-lg border border-[#ffeb00] px-3 text-sm font-bold outline-3 outline-offset-2 outline-[#ffeb00]"
><svg class="icon"><use href="#arrow" /></svg>Voltar</a
>
<a
href="/fotos?municipio=selecionado"
aria-label="Fechar foto"
class="grid size-11 place-items-center rounded-full bg-white/10"
><svg class="icon"><use href="#x" /></svg
></a>
</div>
<div class="mt-4 overflow-hidden rounded-xl bg-[#211d1b]">
<div class="photo-placeholder photo-d aspect-[4/3]"></div>
<div class="p-5">
<p class="text-[10px] font-black tracking-[.1em] text-[#ffeb00] uppercase">
Registro aprovado
</p>
<h3 class="display mt-3 text-2xl leading-tight font-black">
Plenária pela saúde pública
</h3>
<dl class="mt-6 grid gap-5 text-sm">
<div>
<dt class="font-bold text-white/55">Data</dt>
<dd class="mt-1">12 de setembro de 2026</dd>
</div>
<div>
<dt class="font-bold text-white/55">Local</dt>
<dd class="mt-1">Município selecionado · Bahia</dd>
</div>
<div>
<dt class="font-bold text-white/55">Atividade</dt>
<dd class="mt-1">Plenária</dd>
</div>
<div>
<dt class="font-bold text-white/55">Quem aparece</dt>
<dd class="mt-1">Jorge Solla e pessoa pública convidada</dd>
</div>
</dl>
<button class="primary mt-7 w-full">
<svg class="icon"><use href="#download" /></svg>Baixar foto
</button>
<div class="mt-auto border-t border-white/15 pt-5">
<p class="m-0 text-sm font-bold">É você nesta foto?</p>
<a
class="mt-1 inline-block text-sm text-white/70 underline underline-offset-4"
href="#"
>Peça a remoção desta imagem</a
>
</div>
</aside>
<h3 class="display mt-2 text-xl font-black">Plenária pela saúde pública</h3>
<p class="mt-3 text-sm leading-6 text-white/75">
12 de setembro de 2026 · Município selecionado · Plenária
</p>
<p class="mt-2 text-sm">Jorge Solla e pessoa pública convidada</p>
<a href="/fotos/101/midia?download=1" class="primary mt-5 w-full"
><svg class="icon"><use href="#download" /></svg>Baixar foto</a
>
<a
href="mailto:canal-configurado@example.invalid"
class="mt-4 block min-h-11 pt-2 text-center text-sm font-bold underline underline-offset-4"
>Peça a remoção desta imagem</a
>
</div>
</div>
</div>
</div>
@ -607,29 +833,51 @@
<section aria-labelledby="scene-4">
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 04</p>
<h2 id="scene-4" class="display mt-1 text-xl font-black">
Vazio honesto · nada parecido ocupa a grade
Vazios honestos · acervo sem publicação × filtros sem resultado
</h2>
<div class="stage mt-4 max-w-[900px] rounded-2xl border border-border bg-white p-8 sm:p-12">
<div class="rounded-2xl border border-[#184e92]/15 bg-[#f9faff] px-6 py-14 text-center">
<div
class="mx-auto grid size-12 place-items-center rounded-full border border-[#184e92]/20 bg-white text-[#184e92]"
>
<svg class="icon size-6"><use href="#search" /></svg>
</div>
<h3 class="display mt-4 text-2xl font-black">Nada encontrado com esses filtros</h3>
<p class="mx-auto mt-2 max-w-md text-sm leading-6 text-muted-foreground">
Tente outro termo ou volte a ver todas as fotos aprovadas do álbum.
<div class="mt-4 grid gap-6 lg:grid-cols-2">
<div class="stage rounded-2xl border border-border bg-white p-6 sm:p-8">
<p class="mb-3 text-xs font-black tracking-[.08em] text-[#184e92] uppercase">
A · nenhuma foto aprovada ainda
</p>
<div class="mt-4 flex flex-wrap justify-center gap-2 text-xs">
<span class="rounded-full border border-border bg-white px-3 py-1.5"
>Busca: “termo informado”</span
><span class="rounded-full border border-border bg-white px-3 py-1.5"
>Município selecionado</span
><span class="rounded-full border border-border bg-white px-3 py-1.5"
>Atividade selecionada</span
<div class="rounded-2xl border border-border bg-[#fff8f2] px-6 py-14 text-center">
<div
class="mx-auto grid size-12 place-items-center rounded-full border border-border bg-white text-muted-foreground"
>
<svg class="icon size-6"><use href="#image" /></svg>
</div>
<h3 class="display mt-4 text-2xl font-black">O álbum ainda está sendo preparado</h3>
<p class="mx-auto mt-2 max-w-md text-sm leading-6 text-muted-foreground">
Ainda não há fotos aprovadas para publicação. Quando houver registros disponíveis,
eles aparecerão aqui.
</p>
</div>
</div>
<div class="stage rounded-2xl border border-border bg-white p-6 sm:p-8">
<p class="mb-3 text-xs font-black tracking-[.08em] text-[#184e92] uppercase">
B · combinação sem resultado
</p>
<div class="rounded-2xl border border-[#184e92]/15 bg-[#f9faff] px-6 py-14 text-center">
<div
class="mx-auto grid size-12 place-items-center rounded-full border border-[#184e92]/20 bg-white text-[#184e92]"
>
<svg class="icon size-6"><use href="#search" /></svg>
</div>
<h3 class="display mt-4 text-2xl font-black">Nada encontrado com esses filtros</h3>
<p class="mx-auto mt-2 max-w-md text-sm leading-6 text-muted-foreground">
Tente outro termo ou volte a ver todas as fotos aprovadas do álbum.
</p>
<div class="mt-4 flex flex-wrap justify-center gap-2 text-xs">
<span class="rounded-full border border-border bg-white px-3 py-1.5"
>Busca: “termo informado”</span
><span class="rounded-full border border-border bg-white px-3 py-1.5"
>Município selecionado</span
><span class="rounded-full border border-border bg-white px-3 py-1.5"
>Atividade selecionada</span
>
</div>
<a href="/fotos" class="primary focus-ring mt-6 min-w-40">Limpar filtros</a>
</div>
<button class="primary mt-6 min-w-40">Limpar filtros</button>
</div>
</div>
</section>
@ -640,18 +888,28 @@
<h2 id="scene-5" class="display mt-1 text-xl font-black">
Canal de remoção discreto · rodapé do álbum
</h2>
<p class="mt-2 text-sm text-muted-foreground">
Esta banda representa o estado configurado. Sem <code>removalChannelUrl</code>, toda a
banda é omitida.
</p>
<div class="stage mt-4 overflow-hidden rounded-2xl border border-border bg-white">
<div class="mx-auto flex max-w-6xl items-center justify-between gap-6 px-8 py-7">
<div
class="mx-auto flex max-w-6xl flex-col items-start justify-between gap-5 px-5 py-7 sm:flex-row sm:items-center sm:px-8"
>
<div>
<p class="display text-base font-black">Aparece em alguma foto?</p>
<p class="mt-1 text-sm text-muted-foreground">
Se você não quiser que ela fique pública, fale com a equipe responsável pelo acervo.
</p>
</div>
<a href="#" class="secondary shrink-0">Pedir remoção de uma foto</a>
<a
href="mailto:canal-configurado@example.invalid"
class="secondary focus-ring w-full shrink-0 sm:w-auto"
>Pedir remoção de uma foto</a
>
</div>
<footer class="bg-[#180a09] px-8 py-8 text-sm text-white/70">
<div class="mx-auto flex max-w-6xl justify-between">
<div class="mx-auto flex max-w-6xl flex-col justify-between gap-5 sm:flex-row">
<div>
<b class="display text-white">Jorge Solla 1313</b>
<p class="mt-1">Deputado Federal · PT · Bahia</p>
@ -663,6 +921,104 @@
</footer>
</div>
</section>
<!-- CENA 06 -->
<section aria-labelledby="scene-6">
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">
Cena 06 · contrato de conteúdo
</p>
<h2 id="scene-6" class="display mt-1 text-xl font-black">
Heading contextual sem “agenda em destaque” inventada
</h2>
<div class="stage mt-4 overflow-hidden rounded-2xl border border-border bg-white">
<div class="grid gap-px bg-border md:grid-cols-2">
<div class="bg-white p-5">
<p class="text-xs font-bold text-muted-foreground">Sem filtros</p>
<p class="display mt-1 text-xl font-black">Fotos da nossa caminhada</p>
</div>
<div class="bg-white p-5">
<p class="text-xs font-bold text-muted-foreground">Município</p>
<p class="display mt-1 text-xl font-black">Fotos em &lt;município&gt;</p>
</div>
<div class="bg-white p-5">
<p class="text-xs font-bold text-muted-foreground">Atividade</p>
<p class="display mt-1 text-xl font-black">Fotos de &lt;atividade&gt;</p>
</div>
<div class="bg-white p-5">
<p class="text-xs font-bold text-muted-foreground">Pessoa pública</p>
<p class="display mt-1 text-xl font-black">Fotos com &lt;pessoa&gt;</p>
</div>
<div class="bg-white p-5">
<p class="text-xs font-bold text-muted-foreground">Data</p>
<p class="display mt-1 text-xl font-black">Fotos de &lt;data por extenso&gt;</p>
</div>
<div class="bg-white p-5">
<p class="text-xs font-bold text-muted-foreground">Termo</p>
<p class="display mt-1 text-xl font-black">Resultados para “&lt;termo&gt;”</p>
</div>
</div>
<div class="border-t border-border bg-[#fff8f2] p-5 text-sm leading-6">
<b>Regra de combinação:</b> prevalece município → atividade → pessoa → data → termo; os
demais critérios continuam visíveis como chips removíveis. Assim o título fica curto e
não tenta narrar uma consulta inteira.
</div>
</div>
</section>
<!-- CENA 07 -->
<section aria-labelledby="scene-7">
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">
Cena 07 · estados de interação
</p>
<h2 id="scene-7" class="display mt-1 text-xl font-black">
Card inteiro acionável · hover, foco e alt curado
</h2>
<div class="mt-4 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
<div class="stage rounded-2xl border border-border bg-white p-5">
<p class="mb-3 text-xs font-black text-muted-foreground uppercase">Repouso</p>
<a href="/fotos?foto=101" class="photo-card block"
><div class="photo-placeholder aspect-[4/3] rounded-xl"></div>
<p class="display mt-3 font-black">Plenária pela saúde pública</p></a
>
</div>
<div
class="stage -translate-y-0.5 rounded-2xl border border-border bg-white p-5 shadow-[0_12px_28px_rgb(32_27_26/16%)]"
>
<p class="mb-3 text-xs font-black text-muted-foreground uppercase">Hover</p>
<a href="/fotos?foto=101" class="block rounded-xl"
><div
class="photo-placeholder aspect-[4/3] rounded-xl shadow-[0_12px_28px_rgb(32_27_26/16%)]"
></div>
<p class="display mt-3 font-black">Plenária pela saúde pública</p></a
>
</div>
<div class="stage rounded-2xl border border-border bg-white p-5">
<p class="mb-3 text-xs font-black text-muted-foreground uppercase">Foco por teclado</p>
<a
href="/fotos?foto=101"
class="block rounded-xl outline-3 outline-offset-4 outline-[#ffeb00]"
><div class="photo-placeholder aspect-[4/3] rounded-xl"></div>
<p class="display mt-3 font-black">Plenária pela saúde pública</p></a
>
</div>
</div>
<div class="mt-5 rounded-xl border border-border bg-white p-5 text-sm leading-6">
<p>
<b>Imagem real:</b> recebe o <code>alt</code> curado que descreve o que é visível (ação,
ambiente e pessoas quando pertinente), não o filename e não uma repetição automática do
título.
</p>
<p class="mt-2">
<b>Teclado:</b> um único tab stop por card; Enter abre <code>?foto=&lt;id&gt;</code>. No
dialog, o foco inicial e o foco visível ficam em “Voltar”; fechar devolve à mesma grade
filtrada. Todos os alvos mobile mantêm ao menos 44px.
</p>
<p class="mt-2">
<b>Paginação:</b> 24 itens por página; anterior/próxima preservam todas as facetas, e a
primeira página não exibe <code>pagina=1</code> na URL.
</p>
</div>
</section>
</main>
</body>
</html>

View file

@ -208,6 +208,16 @@ export default defineConfig({
dependencies: isProdMode ? [] : ['frontend'],
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
},
{
// C233 — the public photo album (`/fotos`, approval gate, overlay, media
// route, removal channel). Owns its archive photo rows and flips the
// `photoAlbum` global, so it serializes behind frontend in dev (shared
// dev-server boot) and runs parallel to it in prod.
name: 'frontendFotos',
testMatch: /frontendFotos\.e2e\.spec\.ts/,
dependencies: isProdMode ? [] : ['frontend'],
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
},
{
name: 'admin',
testMatch: /admin\.e2e\.spec\.ts/,

View file

@ -51,6 +51,10 @@ export const E2E_CURATED_SPECS = [
// contract and the migration makes every PR of this delivery high-risk
// (curated only).
'campaignSpeechAcervo',
// C233 — deliberate: the public photo album (approval gate, facets, overlay,
// media route) is a new contract and the migration makes every PR of this
// delivery high-risk (curated only).
'frontendFotos',
]
/**
@ -197,13 +201,42 @@ export const E2E_AFFECTED_MANIFEST = [
],
specs: ['frontendConteudos'],
},
{
// C233 — the public photo album: the `/fotos` catalogue, the overlay, the
// public media route (thumbnail/download) and the removal channel over the
// C231/C232 archive. The migration makes every PR of this delivery
// high-risk (curated only).
prefixes: [
'src/app/(frontend)/fotos',
'src/components/fotos',
'src/lib/archivePhoto',
'src/utilities/archivePhotos',
'src/utilities/flickr',
'src/globals/PhotoAlbum.ts',
'src/collections/ArchivePhoto.ts',
'src/utilities/privateMedia',
// The tag/allowlist vocabulary the album's cache and runbook tag use.
'src/utilities/documents.ts',
'src/utilities/revalidateRequest.ts',
// The canonical names of the "pessoa pública" facet.
'src/lib/publicFigureCatalog.ts',
],
specs: ['frontendFotos'],
},
{
// S21 — the campaign footer owns the conditional "Jingles" discovery link
// (it renders on the home, the cards page, `/jingles` and the S29
// announcement page).
// S27 — and the conditional "Conteúdos" link.
// C233 — and the conditional "Fotos" link.
prefixes: ['src/components/CampaignFooter.tsx'],
specs: ['frontend', 'frontendJingles', 'frontendConteudos', 'frontendShareLink'],
specs: [
'frontend',
'frontendJingles',
'frontendConteudos',
'frontendShareLink',
'frontendFotos',
],
},
{
// S13/S30 — the personalized-cards studio lives in shared cards components

View file

@ -6,6 +6,7 @@ import { CampaignFooter } from '@/components/CampaignFooter'
import { CARD_PRIVACY_NOTE } from '@/components/cards/cardCopy'
import { CardsStudio } from '@/components/cards/CardsStudio'
import { isCardModelId } from '@/lib/cardModels'
import { hasPublishedArchivePhotos } from '@/utilities/archivePhotos/archivePhotoReads'
import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads'
import { hasPublishedJingles } from '@/utilities/jingleReads'
@ -25,9 +26,10 @@ export default async function CardsPage({ searchParams }: CardsPageProps) {
const params = await searchParams
const rawModel = Array.isArray(params.model) ? params.model[0] : params.model
const initialModelId = isCardModelId(rawModel) ? rawModel : undefined
const [showJingles, showConteudos] = await Promise.all([
const [showJingles, showConteudos, showFotos] = await Promise.all([
hasPublishedJingles(),
hasPublishedContentPieces(),
hasPublishedArchivePhotos(),
])
return (
@ -63,7 +65,11 @@ export default async function CardsPage({ searchParams }: CardsPageProps) {
<CardsStudio initialModelId={initialModelId} fontFamily={brexterBold.style.fontFamily} />
</section>
</main>
<CampaignFooter showJingles={showJingles} showConteudos={showConteudos} />
<CampaignFooter
showJingles={showJingles}
showConteudos={showConteudos}
showFotos={showFotos}
/>
</>
)
}

View file

@ -7,6 +7,7 @@ import { CampaignProblemCard } from '@/components/CampaignProblemCard'
import { CampaignStorySection } from '@/components/CampaignStorySection'
import { ContentPieceHomeSection } from '@/components/conteudos/ContentPieceHomeSection'
import { JingleHomeSection } from '@/components/jingles/JingleHomeSection'
import { hasPublishedArchivePhotos } from '@/utilities/archivePhotos/archivePhotoReads'
import { getCampaignHomeMetaPixelId } from '@/utilities/campaignHomeTracking'
import { getPublishedContentPieceItems } from '@/utilities/content/contentPieceReads'
import { getPublishedJingleItems } from '@/utilities/jingleReads'
@ -116,14 +117,18 @@ const proofItems = [
]
export default async function HomePage() {
// S10 — site-level Meta pixel for the campaign home (cached read, fail-closed).
const pixelId = await getCampaignHomeMetaPixelId()
// S10 — site-level Meta pixel (cached read, fail-closed).
// S21/S22 — one cached listing (`unstable_cache` tag `jingles`) serves the
// home sound section and the footer's discovery flag, keeping the home static.
const jingles = await getPublishedJingleItems()
// S27/S39 — one cached listing (tag `contentPieces`) feeds both the discovery
// flag of the footer and the home sample; zero pieces hides both (fail-closed).
const contentPieces = await getPublishedContentPieceItems()
// C233 — the same contract for the "Fotos" discovery flag (tag `archivePhotos`).
const [pixelId, jingles, contentPieces, showFotos] = await Promise.all([
getCampaignHomeMetaPixelId(),
getPublishedJingleItems(),
getPublishedContentPieceItems(),
hasPublishedArchivePhotos(),
])
const homeJingles = jingles.slice(0, 3)
return (
@ -253,7 +258,11 @@ export default async function HomePage() {
<CampaignNewsletterSection pixelId={pixelId ?? undefined} />
</main>
<CampaignFooter showJingles={jingles.length > 0} showConteudos={contentPieces.length > 0} />
<CampaignFooter
showJingles={jingles.length > 0}
showConteudos={contentPieces.length > 0}
showFotos={showFotos}
/>
</>
)
}

View file

@ -12,6 +12,7 @@ import {
} from '@/lib/shareLink'
import { buildShareLinkAnnouncementView } from '@/lib/shareLinkAnnouncement'
import type { ShareLink } from '@/payload-types'
import { hasPublishedArchivePhotos } from '@/utilities/archivePhotos/archivePhotoReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { hasPublishedJingles } from '@/utilities/jingleReads'
import { POST_TYPE_LABELS, getVisiblePosts, isPostType } from '@/utilities/posts'
@ -140,14 +141,17 @@ export default async function Page({ params }: { params: Promise<RouteParams> })
// deployment-origin URL is only for the OG card in `resolveShareLinkMetadata`.
const image = typeof link.image === 'object' ? link.image : null
const canonicalUrl = await resolveShareLinkCanonicalUrl(link.slug)
const showJingles = await hasPublishedJingles()
const [showJingles, showFotos] = await Promise.all([
hasPublishedJingles(),
hasPublishedArchivePhotos(),
])
const view = buildShareLinkAnnouncementView({
link,
imageUrl: image?.url ?? null,
canonicalUrl: canonicalUrl ?? null,
})
return <ShareLinkAnnouncement view={view} showJingles={showJingles} />
return <ShareLinkAnnouncement view={view} showJingles={showJingles} showFotos={showFotos} />
}
const posts = (await getVisiblePosts()).filter((post) => post.type === type)

View file

@ -20,6 +20,7 @@ import {
type ContentPieceCatalogSearchParams,
type ContentPiecePublicItem,
} from '@/lib/contentPieceCatalog'
import { hasPublishedArchivePhotos } from '@/utilities/archivePhotos/archivePhotoReads'
import { getPublishedContentPieceItems } from '@/utilities/content/contentPieceReads'
import { loadContentPieceCatalogSearch } from '@/utilities/content/contentPieceThemeSearch'
import { getCachedGlobal } from '@/utilities/globalReads'
@ -85,7 +86,11 @@ export default async function ConteudosPage({
}: {
searchParams: Promise<ContentPieceCatalogSearchParams>
}) {
const [rawSearchParams, showJingles] = await Promise.all([searchParams, hasPublishedJingles()])
const [rawSearchParams, showJingles, showFotos] = await Promise.all([
searchParams,
hasPublishedJingles(),
hasPublishedArchivePhotos(),
])
const {
publishedCount,
params,
@ -104,7 +109,7 @@ export default async function ConteudosPage({
<main className="w-full bg-white">
<ContentPieceEmptyState />
</main>
<CampaignFooter showJingles={showJingles} />
<CampaignFooter showJingles={showJingles} showFotos={showFotos} />
</>
)
}
@ -178,7 +183,12 @@ export default async function ConteudosPage({
</section>
</main>
<CampaignFooter showJingles={showJingles} showConteudos current="conteudos" />
<CampaignFooter
showJingles={showJingles}
showConteudos
showFotos={showFotos}
current="conteudos"
/>
</>
)
}

View file

@ -2,6 +2,7 @@ import { CampaignFooter } from '@/components/CampaignFooter'
import { ContentPieceDetail } from '@/components/conteudos/ContentPieceDetail'
import { ContentPiecePageHeader } from '@/components/conteudos/ContentPiecePageHeader'
import { CONTENT_PIECE_CATALOG_PATH } from '@/lib/contentPieceCatalog'
import { hasPublishedArchivePhotos } from '@/utilities/archivePhotos/archivePhotoReads'
import { getPublishedContentPieceBySlug } from '@/utilities/content/contentPieceReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { hasPublishedJingles } from '@/utilities/jingleReads'
@ -71,9 +72,10 @@ export async function generateMetadata({
export default async function ContentPiecePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const [item, showJingles] = await Promise.all([
const [item, showJingles, showFotos] = await Promise.all([
getPublishedContentPieceBySlug(slug),
hasPublishedJingles(),
hasPublishedArchivePhotos(),
])
if (!item) notFound()
@ -85,7 +87,12 @@ export default async function ContentPiecePage({ params }: { params: Promise<{ s
<ContentPieceDetail item={item} />
</div>
</main>
<CampaignFooter showJingles={showJingles} showConteudos current="conteudos" />
<CampaignFooter
showJingles={showJingles}
showConteudos
showFotos={showFotos}
current="conteudos"
/>
</>
)
}

View file

@ -0,0 +1,73 @@
import config from '@payload-config'
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import { ARCHIVE_PHOTO_SLUG } from '@/lib/archivePhoto'
import { ARCHIVE_PHOTO_THUMB_WIDTH } from '@/lib/archivePhotoPublicCatalog'
import { PRIVATE_MEDIA_CACHE_CONTROL } from '@/lib/privateMedia'
import { getApprovedArchivePhotoById } from '@/utilities/archivePhotos/archivePhotoReads'
import {
buildPrivateMediaImageResponse,
resolvePrivateMediaStaticDir,
} from '@/utilities/privateMedia/privateMediaResponse'
export const dynamic = 'force-dynamic'
/**
* C233 — the only public door to an approved photo. The gate is the cached
* public read (`archivePhotoIsPublic`), so a draft, a removed photo, an unknown
* id and a delete all answer the same silent 404 — the route never leaks which
* photos exist. `?tamanho=grade` serves the on-the-fly resized thumbnail,
* `?download=1` forces the legible `jorge-solla-1313-foto-<id>.<ext>` name; the
* object itself stays in the private bucket (the C193/C199 streaming helper is
* the single owner of that I/O). `X-Robots-Tag` keeps the derivative out of the
* index: the canonical surface is the album, never the file.
*/
const notFound = (): NextResponse =>
new NextResponse(null, {
status: 404,
headers: { 'Cache-Control': PRIVATE_MEDIA_CACHE_CONTROL, 'X-Robots-Tag': 'noindex' },
})
export const GET = async (
request: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> => {
const { id } = await params
if (!/^\d+$/.test(id)) return notFound()
const photo = await getApprovedArchivePhotoById(Number(id))
if (!photo) return notFound()
const payload = await getPayload({ config })
// Intentional admin bypass: the public gate is the cached approved read
// above; the media row only supplies the fresh filename/mimeType to stream.
const media = await payload
.findByID({
collection: ARCHIVE_PHOTO_SLUG,
id: photo.id,
depth: 0,
overrideAccess: true,
})
.catch(() => null)
if (!media?.filename) return notFound()
const searchParams = new URL(request.url).searchParams
const thumbnail = searchParams.get('tamanho') === 'grade'
const download = searchParams.get('download') === '1'
const response = await buildPrivateMediaImageResponse({
media,
staticDir: resolvePrivateMediaStaticDir(payload, ARCHIVE_PHOTO_SLUG),
width: thumbnail ? ARCHIVE_PHOTO_THUMB_WIDTH : null,
download,
dispositionFilename: photo.downloadFilename,
// A resized derivative is generated whole; only the stored original honors
// range requests (resumed transfer).
rangeHeader: thumbnail ? null : request.headers.get('range'),
})
response.headers.set('X-Robots-Tag', 'noindex')
return response
}

View file

@ -0,0 +1,18 @@
import React from 'react'
/**
* C233 — the public album shell. The root `(frontend)` layout locks the
* document with `overflow-hidden`, so this segment owns the scroll container;
* `campaign-site` applies the campaign palette, same contract as the Central de
* Conteúdos and the jingles page.
*/
export default function FotosLayout({ children }: { children: React.ReactNode }) {
return (
<div
data-theme="campaign-site"
className="h-dvh w-full overflow-y-auto bg-(--campaign-cream) text-(--campaign-ink)"
>
{children}
</div>
)
}

View file

@ -0,0 +1,173 @@
import { CampaignFooter } from '@/components/CampaignFooter'
import { ArchivePhotoFilters } from '@/components/fotos/ArchivePhotoFilters'
import { ArchivePhotoGrid } from '@/components/fotos/ArchivePhotoGrid'
import { ArchivePhotoHero } from '@/components/fotos/ArchivePhotoHero'
import { ArchivePhotoLightbox } from '@/components/fotos/ArchivePhotoLightbox'
import { ArchivePhotoPageHeader } from '@/components/fotos/ArchivePhotoPageHeader'
import { ArchivePhotoRemovalBand } from '@/components/fotos/ArchivePhotoRemovalBand'
import {
ArchivePhotoEmptyState,
ArchivePhotoNoResults,
} from '@/components/fotos/ArchivePhotoStates'
import { isArchivePhotoRemovalChannelUrl } from '@/lib/archivePhotoCatalog'
import {
ARCHIVE_PHOTO_ALBUM_PATH,
archivePhotoAlbumActiveFilters,
archivePhotoAlbumFacets,
archivePhotoAlbumHeading,
buildArchivePhotoAlbumHref,
filterArchivePhotoAlbumItems,
paginateArchivePhotoAlbumItems,
parseArchivePhotoAlbumParams,
type ArchivePhotoAlbumSearchParams,
} from '@/lib/archivePhotoPublicCatalog'
import {
getApprovedArchivePhotoItems,
hasPublishedArchivePhotos,
} from '@/utilities/archivePhotos/archivePhotoReads'
import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { hasPublishedJingles } from '@/utilities/jingleReads'
import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
const ALBUM_TITLE = 'Fotos do mandato — Jorge Solla 1313'
const ALBUM_DESCRIPTION =
'Procure as fotos do mandato por data, município, atividade ou pessoa pública — só registros aprovados, com o contexto de cada momento.'
const getCachedPhotoAlbum = getCachedGlobal('photoAlbum')
/**
* C233 — the public album: the approved photos of the mandate with the four
* single-value facets plus a term, the overlay detail and the removal channel.
* The `photoAlbum.published` kill switch answers the same `notFound()` for the
* whole route; with zero approved photos the page still renders the honest
* empty state and asks not to be indexed — nothing becomes public by accident.
* The filtered URLs canonicalize to `/fotos`.
*/
export async function generateMetadata(): Promise<Metadata> {
const [hasPhotos, globalMetadata, album] = await Promise.all([
hasPublishedArchivePhotos(),
getCachedGlobal('metadata')(),
getCachedPhotoAlbum(),
])
// The kill switch is checked here as well as in the page. The route has no
// `loading.tsx` on purpose: a Suspense boundary flushes the shell before the
// page resolves, and a `notFound()` thrown behind it would answer `200` with
// the not-found UI instead of a real 404.
if (album.published === false) notFound()
const { siteUrl, siteName, twitterCreator } = resolveSiteMetadata(globalMetadata)
const canonicalUrl = absoluteSitePath(siteUrl, ARCHIVE_PHOTO_ALBUM_PATH)
const title = `${ALBUM_TITLE} | ${siteName}`
return {
title,
description: ALBUM_DESCRIPTION,
...(hasPhotos ? {} : { robots: { index: false, follow: false } }),
...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}),
openGraph: {
type: 'website',
locale: 'pt-BR',
...(canonicalUrl ? { url: canonicalUrl } : {}),
siteName,
title,
description: ALBUM_DESCRIPTION,
images: [],
},
twitter: {
card: 'summary',
title,
description: ALBUM_DESCRIPTION,
creator: twitterCreator,
images: [],
},
}
}
export default async function FotosPage({
searchParams,
}: {
searchParams: Promise<ArchivePhotoAlbumSearchParams>
}) {
const [rawSearchParams, album, showJingles, showConteudos] = await Promise.all([
searchParams,
getCachedPhotoAlbum(),
hasPublishedJingles(),
hasPublishedContentPieces(),
])
if (album.published === false) notFound()
const params = parseArchivePhotoAlbumParams(rawSearchParams)
const approved = await getApprovedArchivePhotoItems()
const removalChannelUrl = isArchivePhotoRemovalChannelUrl(album.removalChannelUrl)
? album.removalChannelUrl.trim()
: null
const selected =
params.foto != null ? (approved.find((item) => item.id === params.foto) ?? null) : null
const closeHref = buildArchivePhotoAlbumHref({ ...params, foto: null })
const isEmptyAlbum = approved.length === 0
const facets = archivePhotoAlbumFacets(approved)
const filtered = isEmptyAlbum ? [] : filterArchivePhotoAlbumItems(approved, params)
const page = paginateArchivePhotoAlbumItems(filtered, params.pagina)
const activeFilters = archivePhotoAlbumActiveFilters(params, facets)
const heading = archivePhotoAlbumHeading(params, facets)
return (
<>
<ArchivePhotoPageHeader showConteudos={showConteudos} />
<ArchivePhotoHero />
<main className="w-full bg-white">
<div className="mx-auto w-full max-w-6xl px-5 py-8 sm:px-8">
{isEmptyAlbum ? (
<ArchivePhotoEmptyState />
) : (
<>
<ArchivePhotoFilters params={params} facets={facets} activeFilters={activeFilters} />
<div className="mt-10 flex items-end justify-between gap-4 border-b border-(--campaign-line) pb-4">
<div>
<p className="text-xs font-black tracking-[0.1em] text-(--pt-red) uppercase">
Álbum público
</p>
<h2 className="mt-1 font-[family-name:var(--font-exo2)] text-2xl font-black text-balance">
{heading}
</h2>
</div>
<span className="shrink-0 text-sm text-(--campaign-muted)">
Mais recentes primeiro
</span>
</div>
<div className="mt-5">
{filtered.length === 0 ? (
<ArchivePhotoNoResults activeFilters={activeFilters} />
) : (
<ArchivePhotoGrid page={page} params={params} />
)}
</div>
</>
)}
</div>
{removalChannelUrl ? <ArchivePhotoRemovalBand href={removalChannelUrl} /> : null}
</main>
<CampaignFooter
showJingles={showJingles}
showConteudos={showConteudos}
showFotos
current="fotos"
/>
{selected ? (
<ArchivePhotoLightbox
item={selected}
closeHref={closeHref}
removalChannelUrl={removalChannelUrl}
/>
) : null}
</>
)
}

View file

@ -3,6 +3,7 @@ import { CampaignPageHeader } from '@/components/CampaignPageHeader'
import { JingleEmptyState } from '@/components/jingles/JingleEmptyState'
import { JingleIntro } from '@/components/jingles/JingleIntro'
import { JinglePlayer } from '@/components/jingles/JinglePlayer'
import { hasPublishedArchivePhotos } from '@/utilities/archivePhotos/archivePhotoReads'
import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads'
import { getCachedGlobal } from '@/utilities/globalReads'
import { getPublishedJingleItems } from '@/utilities/jingleReads'
@ -59,9 +60,10 @@ export async function generateMetadata(): Promise<Metadata> {
}
export default async function JinglesPage() {
const [items, showConteudos] = await Promise.all([
const [items, showConteudos, showFotos] = await Promise.all([
getPublishedJingleItems(),
hasPublishedContentPieces(),
hasPublishedArchivePhotos(),
])
const hasItems = items.length > 0
@ -78,7 +80,12 @@ export default async function JinglesPage() {
<JingleEmptyState />
)}
</main>
<CampaignFooter showJingles={hasItems} showConteudos={showConteudos} current="jingles" />
<CampaignFooter
showJingles={hasItems}
showConteudos={showConteudos}
showFotos={showFotos}
current="jingles"
/>
</>
)
}

View file

@ -1,4 +1,11 @@
import type { CollectionBeforeValidateHook, CollectionConfig, Field } from 'payload'
import type {
CollectionAfterChangeHook,
CollectionAfterDeleteHook,
CollectionBeforeValidateHook,
CollectionConfig,
Field,
} from 'payload'
import { ValidationError } from 'payload'
import { ARCHIVE_PHOTO_SLUG } from '@/lib/archivePhoto'
import {
@ -6,16 +13,20 @@ import {
ARCHIVE_PHOTO_CATALOG_SOURCES,
ARCHIVE_PHOTO_CURATED_FIELDS,
ARCHIVE_PHOTO_DESCRIPTION_MAX_LENGTH,
ARCHIVE_PHOTO_PUBLICATION_STATUSES,
ARCHIVE_PHOTO_SCENES,
ARCHIVE_PHOTO_VISIBLE_TEXT_MAX_LENGTH,
archivePhotoCatalogSourceLabels,
archivePhotoCuratedFieldLabels,
archivePhotoPublicationStatusLabels,
archivePhotoSearchText,
archivePhotoTakenOn,
changedArchivePhotoCuratedFields,
isArchivePhotoRemovalChannelUrl,
} from '@/lib/archivePhotoCatalog'
import { SPEECH_TOPICS } from '@/lib/speechFacets'
import { canReadArchivePhoto, payloadAdminOnly } from '@/utilities/campaignAccess'
import { revalidateArchivePhotosListing } from '@/utilities/documents'
/**
* C231 — private upload collection of the Flickr photo archive: the ORIGINAL
@ -181,7 +192,7 @@ const deriveArchivePhotoCatalogIndex: CollectionBeforeValidateHook = async ({
collection: 'municipality',
id: municipalityId,
depth: 0,
select: { name: true },
select: { name: true, slug: true },
// Intentional admin bypass: the município catalog is read-only geography.
overrideAccess: true,
req,
@ -189,6 +200,12 @@ const deriveArchivePhotoCatalogIndex: CollectionBeforeValidateHook = async ({
.catch(() => null)
: null
// C233 — the public album reads `depth 0` on this row (the `municipality`
// collection is campaign-only), so the label/slug the facet needs are
// snapshotted here, where the geography read already happens.
data.municipalityName = municipality?.name ?? null
data.municipalitySlug = municipality?.slug ?? null
data.searchText = archivePhotoSearchText({
alt: data.alt ?? originalDoc?.alt,
title: data.title ?? originalDoc?.title,
@ -210,6 +227,55 @@ const deriveArchivePhotoCatalogIndex: CollectionBeforeValidateHook = async ({
return data
}
/**
* C233 — fail-closed: a photo only becomes public with a visible removal
* channel ("é você nesta foto? peça a remoção"). The channel is product data
* configured in the album global; this guard never invents one — it refuses
* the approval until a valid channel exists.
*/
const requireRemovalChannelForApproval: CollectionBeforeValidateHook = async ({ data, req }) => {
if (!data) return data
// Only the transition INTO approval is guarded: a later edit of an already
// approved photo (the cataloguing pipeline included) never re-checks the
// channel, and the album global keeps the open album from losing it.
if (data.publicationStatus !== 'approved') return data
// Intentional admin bypass: the channel is public read-only product data and
// the guard must not depend on the role of whoever is approving.
const album = await req.payload
.findGlobal({ slug: 'photoAlbum', depth: 0, overrideAccess: true, req })
.catch(() => null)
if (!isArchivePhotoRemovalChannelUrl(album?.removalChannelUrl)) {
throw new ValidationError({
errors: [
{
path: 'publicationStatus',
message:
'Configure o canal de remoção (Configurações → Álbum de fotos) antes de aprovar uma foto.',
},
],
})
}
return data
}
/**
* C233 — the seam with the public album: every write of a photo (the ficha,
* the cataloguing pipeline, the takedown) busts the listing tag `/fotos`
* caches under. A collection hook and not a per-caller line, so a new write
* path cannot forget it — an `approved→removed` edit pulls the photo AND its
* media (the cached by-id read) down without a deploy.
*/
const revalidateArchivePhotosListingAfterChange: CollectionAfterChangeHook = ({ doc }) => {
revalidateArchivePhotosListing()
return doc
}
const revalidateArchivePhotosListingAfterDelete: CollectionAfterDeleteHook = ({ doc }) => {
revalidateArchivePhotosListing()
return doc
}
export const ArchivePhoto: CollectionConfig = {
slug: ARCHIVE_PHOTO_SLUG,
labels: {
@ -221,7 +287,14 @@ export const ArchivePhoto: CollectionConfig = {
description:
'Originais e metadados do acervo de fotos do Flickr (conta depjorgesolla). Só abrem com login da campanha.',
useAsTitle: 'alt',
defaultColumns: ['alt', 'takenOn', 'catalog.scene', 'catalog.municipality', 'catalog.source'],
defaultColumns: [
'alt',
'publicationStatus',
'takenOn',
'catalog.scene',
'catalog.municipality',
'catalog.source',
],
listSearchableFields: ['searchText'],
},
access: {
@ -231,7 +304,9 @@ export const ArchivePhoto: CollectionConfig = {
delete: payloadAdminOnly,
},
hooks: {
beforeValidate: [deriveArchivePhotoCatalogIndex],
beforeValidate: [deriveArchivePhotoCatalogIndex, requireRemovalChannelForApproval],
afterChange: [revalidateArchivePhotosListingAfterChange],
afterDelete: [revalidateArchivePhotosListingAfterDelete],
},
fields: [
{
@ -256,6 +331,22 @@ export const ArchivePhoto: CollectionConfig = {
},
},
catalogGroup,
{
name: 'publicationStatus',
type: 'select',
label: 'Publicação',
required: true,
index: true,
defaultValue: 'draft',
options: ARCHIVE_PHOTO_PUBLICATION_STATUSES.map((status) => ({
value: status,
label: archivePhotoPublicationStatusLabels[status],
})),
admin: {
description:
'Só "Aprovada" aparece no álbum público (/fotos). "Removida" é o estado de remoção a pedido — a catalogação automática nunca o altera e a foto não volta ao público sem uma nova edição humana.',
},
},
{
name: 'curatedFields',
type: 'select',
@ -381,6 +472,26 @@ export const ArchivePhoto: CollectionConfig = {
type: 'text',
label: 'Licença (Flickr)',
},
{
name: 'municipalityName',
type: 'text',
label: 'Município (nome)',
admin: {
readOnly: true,
description:
'Derivado do município da ficha, para o álbum público ler sem tocar a collection campaign-only.',
},
},
{
name: 'municipalitySlug',
type: 'text',
label: 'Município (slug público)',
index: true,
admin: {
readOnly: true,
description: 'Faceta de município da URL pública (`/fotos?municipio=<slug>`).',
},
},
{
name: 'searchText',
type: 'textarea',

View file

@ -5,7 +5,8 @@ import { cn } from '@/lib/utils'
const APOIAR_URL = 'https://apoiar.me/jorgesolla'
const FOOTER_LINK = cn(
'text-[rgb(255_248_242/75%)] no-underline hover:text-white',
// C233 (design critique) — every footer link is a 44px-tall target on mobile.
'inline-flex min-h-11 items-center text-[rgb(255_248_242/75%)] no-underline hover:text-white',
'focus-visible:rounded-[2px] focus-visible:text-white focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-yellow)',
)
@ -21,20 +22,24 @@ const REDES = [
* the caller resolves the cached flag (`hasPublishedJingles`, or the page's own
* list) and passes it, so this stays a synchronous presentational leaf.
* S27 — the same contract for the "Conteúdos" link (`hasPublishedContentPieces`).
* C233 — the same contract for the "Fotos" link (`hasPublishedArchivePhotos`).
* `current` marks the page as the current one for assistive tech and the
* artefato's highlight.
*/
export const CampaignFooter = ({
showJingles = false,
showConteudos = false,
showFotos = false,
current,
}: {
showJingles?: boolean
showConteudos?: boolean
current?: 'jingles' | 'conteudos'
showFotos?: boolean
current?: 'jingles' | 'conteudos' | 'fotos'
} = {}) => {
const jinglesLinkClass = cn(FOOTER_LINK, current === 'jingles' && 'font-bold text-white')
const conteudosLinkClass = cn(FOOTER_LINK, current === 'conteudos' && 'font-bold text-white')
const fotosLinkClass = cn(FOOTER_LINK, current === 'fotos' && 'font-bold text-white')
return (
<footer className="bg-[#180a09] text-[rgb(255_248_242/75%)]">
@ -59,6 +64,17 @@ export const CampaignFooter = ({
Bandeiras
</a>
</li>
{showFotos ? (
<li>
<Link
href="/fotos"
aria-current={current === 'fotos' ? 'page' : undefined}
className={fotosLinkClass}
>
Fotos
</Link>
</li>
) : null}
{showConteudos ? (
<li>
<Link

View file

@ -0,0 +1,32 @@
import Image from 'next/image'
import Link from 'next/link'
/**
* S36/C233 — the negative campaign lockup as a home link, owner of the crop
* contract shared by the public page-local headers: the official PNG ships with
* a large transparent frame (canvas 1037x595, visible ink 790x285: top 162,
* bottom 148, left 123, right 124), so the header crops the frame with a fixed
* window instead of scaling the whole canvas. The window must have the SAME
* aspect ratio as the ink (the frame does not); a mismatched window clips the
* base of "SOLLA" (production report 2026-09-25). Window = ink box at the
* /jingles reference weight: mobile 128x46 (img 167px -> ink 127.2x45.9, left
* -19.8 / top -26.1) and desktop 144x52 (img 189px -> ink 144x51.9, left -22.4
* / top -29.5). To recompute after a kit PNG change: `sharp(asset).trim()`
* gives the ink box (size + trimOffset); imgWidth = inkHeight x 1037/285 and
* the offsets are the trim offsets x imgWidth/1037. The e2e spec asserts the
* fitted ink.
*/
export const CampaignLogoLink = ({ className }: { className?: string }) => (
<Link href="/" aria-label="Início — Jorge Solla 1313" className={className}>
<span className="relative block h-[46px] w-[128px] shrink-0 overflow-hidden sm:h-[52px] sm:w-[144px]">
<Image
src="/campaign-kit/jorge-solla-negativo.png"
alt="Jorge Solla"
width={1037}
height={595}
priority
className="absolute top-[-26.1px] left-[-19.8px] h-auto w-[167px] max-w-none sm:top-[-29.5px] sm:left-[-22.4px] sm:w-[189px]"
/>
</span>
</Link>
)

View file

@ -1,40 +1,19 @@
import Image from 'next/image'
import Link from 'next/link'
import { CampaignLogoLink } from '@/components/CampaignLogoLink'
import { CONTENT_PIECE_FOCUS } from './contentPieceClasses'
/**
* S27 — the Central de Conteúdos bar (artefato: cenas 01/04/06/07/08): the
* negative logo over the campaign red and, on a piece page, the way back to
* the catalogue. Page-local, like the jingles header — not a site shell.
*
* S36 — the official lockup ships with a large transparent frame (canvas
* 1037x595, visible ink 790x285: top 162, bottom 148, left 123, right 124),
* so the header crops the frame with a fixed window instead of scaling the
* whole canvas. The window must have the SAME aspect ratio as the ink (the
* frame does not); a mismatched window clips the base of "SOLLA" (production
* report 2026-09-25). Window = ink box at the /jingles reference weight:
* mobile 128x46 (img 167px -> ink 127.2x45.9, left -19.8 / top -26.1) and
* desktop 144x52 (img 189px -> ink 144x51.9, left -22.4 / top -29.5).
* To recompute after a kit PNG change: `sharp(asset).trim()` gives the ink box
* (size + trimOffset); imgWidth = inkHeight x 1037/285 and the offsets are the
* trim offsets x imgWidth/1037. The e2e spec asserts the fitted ink.
* negative logo over the campaign red and, on a piece page, the way back to the
* catalogue. Page-local, like the jingles header — not a site shell. The lockup
* crop contract lives in `CampaignLogoLink`, shared with the C233 album header.
*/
export const ContentPiecePageHeader = ({ backHref }: { backHref?: string }) => (
<header className="bg-[#ae1603]">
<div className="mx-auto flex h-14 w-full max-w-6xl items-center justify-between gap-3 px-5 sm:h-16 sm:px-8">
<Link href="/" aria-label="Início — Jorge Solla 1313" className={CONTENT_PIECE_FOCUS}>
<span className="relative block h-[46px] w-[128px] shrink-0 overflow-hidden sm:h-[52px] sm:w-[144px]">
<Image
src="/campaign-kit/jorge-solla-negativo.png"
alt="Jorge Solla"
width={1037}
height={595}
priority
className="absolute top-[-26.1px] left-[-19.8px] h-auto w-[167px] max-w-none sm:top-[-29.5px] sm:left-[-22.4px] sm:w-[189px]"
/>
</span>
</Link>
<CampaignLogoLink className={CONTENT_PIECE_FOCUS} />
{backHref ? (
<Link
href={backHref}

View file

@ -0,0 +1,59 @@
import Link from 'next/link'
import {
ARCHIVE_PHOTO_THUMB_WIDTH,
buildArchivePhotoAlbumHref,
type ArchivePhotoAlbumParams,
type ArchivePhotoPublicItem,
} from '@/lib/archivePhotoPublicCatalog'
import { ARCHIVE_PHOTO_CARD } from './archivePhotoClasses'
/**
* C233 — one photo of the grid (artefato: cenas 01/02/07): the whole card is a
* single link to the same filtered URL plus `?foto=<id>`, one tab stop, yellow
* focus ring; image `alt` is the curated alt (the file description), never the
* filename. The grid serves the resized thumbnail through the same-origin
* private proxy, lazy and async.
*/
export const ArchivePhotoCard = ({
item,
params,
}: {
item: ArchivePhotoPublicItem
params: ArchivePhotoAlbumParams
}) => (
<article>
<Link
href={buildArchivePhotoAlbumHref({ ...params, foto: item.id })}
className={`${ARCHIVE_PHOTO_CARD} group`}
data-photo-id={item.id}
>
{/* eslint-disable-next-line @next/next/no-img-element -- private proxy path with on-demand headers */}
<img
src={item.thumbnailPath}
alt={item.alt}
loading="lazy"
decoding="async"
// The CSS box owns the ratio (square on mobile, 4:3 from `sm`); the
// attributes are the intrinsic hint for the pre-JS layout.
width={ARCHIVE_PHOTO_THUMB_WIDTH}
height={Math.round(ARCHIVE_PHOTO_THUMB_WIDTH * 0.75)}
className="aspect-square w-full rounded-lg object-cover transition-[transform,box-shadow] duration-150 ease-out group-hover:-translate-y-0.5 group-hover:shadow-[0_12px_28px_rgb(32_27_26/16%)] motion-reduce:transition-none sm:aspect-[4/3] sm:rounded-xl"
/>
<div className="pt-3">
<p className="font-[family-name:var(--font-exo2)] text-sm font-black text-balance sm:text-base">
{item.title}
</p>
{item.metaLabel ? (
<p className="mt-1 text-xs leading-4 text-(--campaign-muted) sm:text-sm sm:leading-5">
{item.metaLabel}
</p>
) : null}
{item.peopleLabel ? (
<p className="mt-1 text-xs leading-4 sm:text-sm sm:leading-5">{item.peopleLabel}</p>
) : null}
</div>
</Link>
</article>
)

View file

@ -0,0 +1,192 @@
import { ChevronDown, SearchIcon } from 'lucide-react'
import Link from 'next/link'
import {
ARCHIVE_PHOTO_ALBUM_FACETS,
ARCHIVE_PHOTO_ALBUM_PATH,
archivePhotoAlbumFacetLabels,
buildArchivePhotoAlbumHref,
type ArchivePhotoAlbumActiveFilter,
type ArchivePhotoAlbumFacet,
type ArchivePhotoAlbumFacets,
type ArchivePhotoAlbumParams,
} from '@/lib/archivePhotoPublicCatalog'
import { ArchivePhotoFiltersSheet } from './ArchivePhotoFiltersSheet'
import {
ARCHIVE_PHOTO_ACTIVE_CHIP,
ARCHIVE_PHOTO_CHIP,
ARCHIVE_PHOTO_FOCUS,
ARCHIVE_PHOTO_PRIMARY_BUTTON,
} from './archivePhotoClasses'
const FACET_MENU_HEADINGS: Record<ArchivePhotoAlbumFacet, string> = {
data: 'Dias com fotos publicadas',
municipio: 'Municípios com fotos',
atividade: 'Atividades',
pessoa: 'Pessoas que aparecem',
}
const FIELD_CLASS =
'min-h-12 w-full rounded-[10px] border border-black/20 bg-white py-2.5 pr-4 pl-11 text-[15px] text-black placeholder:text-(--campaign-muted) focus-visible:border-(--pt-red) focus-visible:outline-[3px] focus-visible:outline-offset-[2px] focus-visible:outline-(--pt-red)'
const DROPDOWN_LINK =
'flex min-h-10 items-center rounded-lg px-3 text-sm font-medium text-black hover:bg-(--campaign-band) focus-visible:outline-[3px] focus-visible:outline-offset-[2px] focus-visible:outline-(--pt-red)'
type FacetFilter = ArchivePhotoAlbumActiveFilter & { facet: ArchivePhotoAlbumFacet }
const ActiveChips = ({ filters }: { filters: readonly FacetFilter[] }) => (
<>
{filters.map((filter) => (
<Link
key={filter.facet}
href={filter.removeHref}
aria-label={`Remover filtro ${filter.label}: ${filter.value}`}
className={ARCHIVE_PHOTO_ACTIVE_CHIP}
>
{filter.label} · {filter.value}
<span aria-hidden="true" className="text-base leading-none">
×
</span>
</Link>
))}
</>
)
const ClearFiltersLink = ({ className }: { className?: string }) => (
<Link
href={ARCHIVE_PHOTO_ALBUM_PATH}
className={`inline-flex min-h-11 items-center rounded-sm text-sm font-extrabold text-[#184e92] underline-offset-4 hover:underline ${ARCHIVE_PHOTO_FOCUS} ${className ?? ''}`}
>
Limpar filtros
</Link>
)
/**
* C233 — the album filters (artefato: cenas 01/02): a GET form (works without
* JS, the URL is the state) with the term field, the active chips (each with
* its removal link) and one `<details>` menu per facet on desktop — the
* exclusive accordion shares one `name`. On mobile the active chips + the clear
* link stay visible and the facets move into the bottom sheet (the trigger is
* the only client piece; the menus never render for a facet no approved photo
* carries).
*/
export const ArchivePhotoFilters = ({
params,
facets,
activeFilters,
}: {
params: ArchivePhotoAlbumParams
facets: ArchivePhotoAlbumFacets
activeFilters: readonly ArchivePhotoAlbumActiveFilter[]
}) => {
const facetFilters = activeFilters.filter((filter): filter is FacetFilter => filter.facet !== 'q')
const termFilter = activeFilters.find((filter) => filter.facet === 'q')
return (
<>
<form
action={ARCHIVE_PHOTO_ALBUM_PATH}
method="get"
className="rounded-2xl bg-(--campaign-cream) p-4 sm:p-5"
>
{ARCHIVE_PHOTO_ALBUM_FACETS.map((facet) =>
params[facet] ? (
<input key={facet} type="hidden" name={facet} value={params[facet] ?? ''} />
) : null,
)}
<label htmlFor="album-search" className="text-sm font-extrabold">
O que você procura?
</label>
<div className="mt-2 grid grid-cols-[minmax(0,1fr)_auto] gap-2">
<div className="relative">
<SearchIcon
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-4 size-4 -translate-y-1/2 text-(--campaign-muted)"
/>
<input
id="album-search"
type="search"
name="q"
defaultValue={params.q}
placeholder="Ex.: plenária de saúde em município"
className={FIELD_CLASS}
/>
{termFilter ? (
<Link
href={termFilter.removeHref}
aria-label="Limpar busca"
className={`absolute top-1/2 right-2 grid size-8 -translate-y-1/2 place-items-center rounded-full text-lg leading-none text-(--campaign-muted) hover:text-black ${ARCHIVE_PHOTO_FOCUS}`}
>
×
</Link>
) : null}
</div>
<button type="submit" className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} min-w-28`}>
Buscar fotos
</button>
</div>
<div
className="mt-4 hidden flex-wrap items-start gap-2 sm:flex"
role="group"
aria-label="Filtros"
>
<ActiveChips filters={facetFilters} />
{ARCHIVE_PHOTO_ALBUM_FACETS.map((facet) =>
params[facet] || facets[facet].length === 0 ? null : (
<details key={facet} name="album-facet" className="group relative">
<summary
className={`${ARCHIVE_PHOTO_CHIP} cursor-pointer list-none group-open:border-[#184e92]/35 group-open:text-[#184e92] [&::-webkit-details-marker]:hidden`}
>
{archivePhotoAlbumFacetLabels[facet]}
<ChevronDown
aria-hidden="true"
className="size-3.5 transition-transform group-open:rotate-180 motion-reduce:transition-none"
/>
</summary>
<ul className="absolute z-20 mt-2 max-h-72 min-w-48 list-none overflow-y-auto rounded-xl border border-(--campaign-line) bg-white p-1.5 shadow-[0_14px_34px_rgb(0_0_0/16%)]">
<li className="px-3 py-1.5 text-[10px] font-black tracking-[0.08em] text-(--campaign-muted) uppercase">
{FACET_MENU_HEADINGS[facet]}
</li>
{facets[facet].map((option) => (
<li key={option.value}>
<Link
href={buildArchivePhotoAlbumHref({
...params,
foto: null,
[facet]: option.value,
})}
className={DROPDOWN_LINK}
>
{option.label}
</Link>
</li>
))}
</ul>
</details>
),
)}
{activeFilters.length > 0 ? <ClearFiltersLink className="ml-auto self-center" /> : null}
</div>
</form>
<div
className="mt-4 flex flex-wrap items-center gap-2 sm:hidden"
role="group"
aria-label="Filtros"
>
<ArchivePhotoFiltersSheet
params={params}
facets={facets}
activeCount={facetFilters.length}
/>
<ActiveChips filters={facetFilters} />
{activeFilters.length > 0 ? <ClearFiltersLink /> : null}
</div>
</>
)
}

View file

@ -0,0 +1,196 @@
'use client'
import { SlidersHorizontal, X } from 'lucide-react'
import Link from 'next/link'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import {
ARCHIVE_PHOTO_ALBUM_FACETS,
ARCHIVE_PHOTO_ALBUM_PATH,
archivePhotoAlbumFacetLabels,
type ArchivePhotoAlbumFacets,
type ArchivePhotoAlbumParams,
} from '@/lib/archivePhotoPublicCatalog'
import { cn } from '@/lib/utils'
import {
ARCHIVE_PHOTO_CHIP,
ARCHIVE_PHOTO_FOCUS,
ARCHIVE_PHOTO_PRIMARY_BUTTON,
} from './archivePhotoClasses'
const SELECT_CLASS =
'mt-1.5 h-11 w-full rounded-[10px] border border-black/20 bg-white px-3 font-normal focus-visible:border-(--pt-red) focus-visible:outline-[3px] focus-visible:outline-offset-[2px] focus-visible:outline-(--pt-red)'
const FOCUSABLE = 'a[href]:not([tabindex="-1"]), button:not([disabled]), select:not([disabled])'
const ANY_LABELS = {
data: 'Qualquer data',
municipio: 'Qualquer município',
atividade: 'Todas as atividades',
pessoa: 'Qualquer pessoa',
} as const
/**
* C233 — the mobile facet sheet (artefato: cena 02): the "Filtros" trigger opens
* a bottom sheet with one select per facet and a GET submit ("Ver fotos"), the
* URL staying the state. The panel portals to the body with its own
* `campaign-site` theme — same contract as the share sheet — and the trigger is
* the only piece of this surface that needs JS; the sheet never traps the page
* without it (the album still reads and paginates).
*/
export const ArchivePhotoFiltersSheet = ({
params,
facets,
activeCount,
}: {
params: ArchivePhotoAlbumParams
facets: ArchivePhotoAlbumFacets
activeCount: number
}) => {
const [mounted, setMounted] = useState(false)
const [open, setOpen] = useState(false)
const firstFieldRef = useRef<HTMLSelectElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
useEffect(() => setMounted(true), [])
const close = useCallback(() => {
setOpen(false)
triggerRef.current?.focus()
}, [])
useEffect(() => {
if (!open) return
firstFieldRef.current?.focus()
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
close()
return
}
if (event.key !== 'Tab') return
const focusables = panelRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE)
if (!focusables || focusables.length === 0) return
const first = focusables[0]!
const last = focusables[focusables.length - 1]!
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [close, open])
const fields = ARCHIVE_PHOTO_ALBUM_FACETS.map((facet) => ({
facet,
label: archivePhotoAlbumFacetLabels[facet],
anyLabel: ANY_LABELS[facet],
options: facets[facet],
}))
return (
<>
<button
ref={triggerRef}
type="button"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
className={ARCHIVE_PHOTO_CHIP}
>
<SlidersHorizontal aria-hidden="true" className="size-4" />
Filtros{activeCount > 0 ? ` · ${activeCount}` : ''}
</button>
{mounted && open
? createPortal(
// The portal lands outside the `campaign-site` layout, so the sheet
// carries its own theme (same reason as the share sheet).
<div data-theme="campaign-site" className="fixed inset-0 z-50 text-(--campaign-ink)">
<button
type="button"
tabIndex={-1}
aria-label="Fechar filtros"
onClick={close}
className="absolute inset-0 h-full w-full cursor-default bg-black/12"
/>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label="Filtros do álbum"
className={cn(
'absolute inset-x-0 bottom-0 overscroll-contain rounded-t-2xl border border-black/12 bg-white px-5 pt-4 pb-7 shadow-[0_-16px_44px_rgb(0_0_0/18%)]',
'sm:inset-x-auto sm:top-1/2 sm:bottom-auto sm:left-1/2 sm:w-[430px] sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-2xl sm:p-6 sm:shadow-[0_20px_55px_rgb(0_0_0/22%)]',
)}
>
<div
aria-hidden="true"
className="mx-auto mb-4 h-1 w-10 rounded-full bg-stone-300 sm:hidden"
/>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-black tracking-[0.08em] text-(--pt-red) uppercase">
Filtros
</p>
<h2 className="font-[family-name:var(--font-exo2)] text-xl font-black">
Refine sua busca
</h2>
</div>
<button
type="button"
aria-label="Fechar"
onClick={close}
className={`grid size-11 place-items-center rounded-full bg-(--campaign-band) ${ARCHIVE_PHOTO_FOCUS}`}
>
<X aria-hidden="true" className="size-4" />
</button>
</div>
<form action={ARCHIVE_PHOTO_ALBUM_PATH} method="get" className="mt-5">
<input type="hidden" name="q" value={params.q} />
<div className="grid gap-4">
{fields.map((field) => (
<label key={field.facet} className="text-sm font-bold">
{field.label}
<select
ref={field.facet === 'data' ? firstFieldRef : undefined}
name={field.facet}
defaultValue={params[field.facet] ?? ''}
className={SELECT_CLASS}
>
<option value="">{field.anyLabel}</option>
{field.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
))}
</div>
<button type="submit" className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} mt-6 w-full`}>
Ver fotos
</button>
<Link
href={ARCHIVE_PHOTO_ALBUM_PATH}
className={`mt-3 inline-flex min-h-11 w-full items-center justify-center text-sm font-extrabold text-[#184e92] underline-offset-4 hover:underline ${ARCHIVE_PHOTO_FOCUS}`}
>
Limpar filtros
</Link>
</form>
</div>
</div>,
document.body,
)
: null}
</>
)
}

View file

@ -0,0 +1,73 @@
import { ChevronLeft, ChevronRight } from 'lucide-react'
import Link from 'next/link'
import {
buildArchivePhotoAlbumHref,
type ArchivePhotoAlbumPage,
type ArchivePhotoAlbumParams,
} from '@/lib/archivePhotoPublicCatalog'
import { ArchivePhotoCard } from './ArchivePhotoCard'
import { ARCHIVE_PHOTO_SECONDARY_BUTTON } from './archivePhotoClasses'
/**
* C233 — the grid and its pagination (artefato: cenas 01/02/07): 24 per page,
* 2 columns on mobile and 3 from `sm`; previous/next preserve every facet, the
* first page never serializes `pagina=1` and the overlay id never travels in a
* pagination link.
*/
export const ArchivePhotoGrid = ({
page,
params,
}: {
page: ArchivePhotoAlbumPage
params: ArchivePhotoAlbumParams
}) => {
const pageHref = (target: number) =>
buildArchivePhotoAlbumHref({ ...params, foto: null, pagina: target })
return (
<>
<div className="grid grid-cols-2 gap-x-3 gap-y-5 sm:grid-cols-3 sm:gap-x-4 sm:gap-y-7">
{page.items.map((item) => (
<ArchivePhotoCard key={item.id} item={item} params={params} />
))}
</div>
{page.pageCount > 1 ? (
<nav
aria-label="Paginação das fotos"
className="mt-9 flex items-center justify-between border-t border-(--campaign-line) pt-5"
>
{page.hasPrevious ? (
<Link href={pageHref(page.page - 1)} className={ARCHIVE_PHOTO_SECONDARY_BUTTON}>
<ChevronLeft aria-hidden="true" className="size-4" />
Anterior
</Link>
) : (
<span aria-disabled="true" className={`${ARCHIVE_PHOTO_SECONDARY_BUTTON} opacity-55`}>
<ChevronLeft aria-hidden="true" className="size-4" />
Anterior
</span>
)}
<span className="text-sm font-bold">
Página {page.page} de {page.pageCount}
</span>
{page.hasNext ? (
<Link href={pageHref(page.page + 1)} className={ARCHIVE_PHOTO_SECONDARY_BUTTON}>
Próxima
<ChevronRight aria-hidden="true" className="size-4" />
</Link>
) : (
<span aria-disabled="true" className={`${ARCHIVE_PHOTO_SECONDARY_BUTTON} opacity-55`}>
Próxima
<ChevronRight aria-hidden="true" className="size-4" />
</span>
)}
</nav>
) : null}
</>
)
}

View file

@ -0,0 +1,34 @@
/**
* C233 — the album hero (artefato: cenas 01/02): the campaign gradient band
* with the kit texture, no CTA — the search right below is the primary action
* and duplicating it would not add a destination (the selfie CTA belonged to
* C234, out of this slice). Copy closes on the two levels the album offers:
* the agenda/município/pessoa search and the real context of each record.
*/
export const ArchivePhotoHero = () => (
<section className="relative isolate overflow-hidden border-b-4 border-(--pt-yellow) bg-[radial-gradient(circle_at_78%_0%,rgb(255_230_7/13%),transparent_28%),linear-gradient(112deg,#e4102f_0%,#b8102a_58%,#184e92_100%)] text-white">
<div
aria-hidden="true"
className="absolute inset-y-0 right-0 -z-10 w-[38%] bg-[url('/campaign-kit/pattern-shapes.png')] bg-[length:100%_auto] bg-[position:center_5%] bg-no-repeat opacity-[0.1]"
/>
<div className="relative mx-auto flex min-h-[196px] w-full max-w-6xl items-center px-5 py-8 sm:min-h-[230px] sm:px-8 sm:py-10">
<div className="max-w-2xl">
<p className="m-0 text-xs font-black tracking-[0.12em] text-(--pt-yellow) uppercase">
<span className="sm:hidden">Nossa caminhada</span>
<span className="hidden sm:inline">Memória da nossa caminhada</span>
</p>
<h1 className="mt-3 font-[family-name:var(--font-exo2)] text-[30px] leading-[0.98] font-black tracking-[-0.03em] text-balance sm:text-[42px] sm:leading-[1]">
<span className="sm:hidden">Encontre um momento.</span>
<span className="hidden sm:inline">Fotos que contam onde a gente esteve.</span>
</h1>
<p className="mt-3 max-w-xl text-sm leading-5 text-white/90 sm:mt-4 sm:text-base sm:leading-6">
<span className="sm:hidden">Busque pela agenda, pelo lugar ou por quem apareceu.</span>
<span className="hidden sm:inline">
Procure uma agenda, um município ou uma pessoa pública e abra cada registro com data e
contexto.
</span>
</p>
</div>
</div>
</section>
)

View file

@ -0,0 +1,174 @@
'use client'
import { X } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useEffect, useRef } from 'react'
import type { ArchivePhotoPublicItem } from '@/lib/archivePhotoPublicCatalog'
import { ARCHIVE_PHOTO_FOCUS, ARCHIVE_PHOTO_PRIMARY_BUTTON } from './archivePhotoClasses'
const FOCUSABLE = 'a[href]:not([tabindex="-1"]), button:not([disabled])'
/**
* C233 — one photo in context (artefato: cena 03), always a dialog over the
* grid: `?foto=<id>` is not canonical and `/fotos/<id>` keeps 404ing, so the
* photo never gains an indexable page of its own. The whole overlay is the
* `role="dialog"` (the back/close bar belongs to the modal tree), Tab is
* confined to it, "Voltar ao álbum" and "Fechar" are real links to the same
* filtered URL without `foto` (they work with JS off), focus starts on "Voltar"
* — and returns to the card on close — and Esc runs the same client-side
* navigation. The image is the resized public derivative through the private
* proxy; the removal block only renders when the album has a valid channel.
*/
export const ArchivePhotoLightbox = ({
item,
closeHref,
removalChannelUrl,
}: {
item: ArchivePhotoPublicItem
closeHref: string
removalChannelUrl: string | null
}) => {
const backRef = useRef<HTMLAnchorElement>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const router = useRouter()
useEffect(() => {
backRef.current?.focus()
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
// Esc runs the same client-side navigation as the close links.
router.push(closeHref)
return
}
if (event.key !== 'Tab') return
const focusables = dialogRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE)
if (!focusables || focusables.length === 0) return
const first = focusables[0]!
const last = focusables[focusables.length - 1]!
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
// Closing (link, Esc or backdrop) returns focus to the card behind.
document.querySelector<HTMLElement>(`a[data-photo-id="${item.id}"]`)?.focus()
}
}, [closeHref, item.id, router])
return (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="album-photo-dialog-title"
className="fixed inset-0 z-50 text-white"
>
<Link
href={closeHref}
aria-label="Fechar foto e voltar à grade"
tabIndex={-1}
className="absolute inset-0 cursor-default bg-[#171412]/82 backdrop-blur-[2px]"
/>
<div className="absolute inset-0 overflow-y-auto p-4 sm:p-8">
<div className="mx-auto max-w-6xl">
<div className="mb-5 flex items-center justify-between gap-4">
<Link
ref={backRef}
href={closeHref}
className={`inline-flex min-h-11 items-center gap-2 rounded-lg border border-(--pt-yellow) px-4 text-sm font-bold text-white ${ARCHIVE_PHOTO_FOCUS}`}
>
← Voltar ao álbum
</Link>
<Link
href={closeHref}
aria-label="Fechar foto e voltar à grade"
className={`grid size-11 place-items-center rounded-full bg-white/10 hover:bg-white/20 ${ARCHIVE_PHOTO_FOCUS}`}
>
<X aria-hidden="true" className="size-5" />
</Link>
</div>
<div className="grid overflow-hidden rounded-2xl bg-[#211d1b] shadow-[0_28px_80px_rgb(0_0_0/40%)] sm:grid-cols-[minmax(0,1fr)_340px]">
{/* eslint-disable-next-line @next/next/no-img-element -- private proxy path with on-demand headers */}
<img
src={item.mediaPath}
alt={item.alt}
// The CSS box owns the layout (object-contain + max-h); the
// attributes are the intrinsic hint for the pre-JS layout.
width={1200}
height={900}
className="max-h-[60vh] w-full bg-[#171412] object-contain sm:max-h-none sm:min-h-[650px] sm:object-cover"
/>
<aside className="flex flex-col p-5 sm:p-7">
<p className="text-xs font-black tracking-[0.1em] text-(--pt-yellow) uppercase">
Registro aprovado
</p>
<h2
id="album-photo-dialog-title"
className="mt-3 font-[family-name:var(--font-exo2)] text-xl leading-tight font-black sm:text-2xl"
>
{item.title}
</h2>
<dl className="mt-6 grid gap-5 text-sm">
{item.dateLabel ? (
<div>
<dt className="font-bold text-white/55">Data</dt>
<dd className="mt-1">{item.dateLabel}</dd>
</div>
) : null}
{item.municipalityName ? (
<div>
<dt className="font-bold text-white/55">Local</dt>
<dd className="mt-1">{item.municipalityName} · Bahia</dd>
</div>
) : null}
{item.sceneLabel ? (
<div>
<dt className="font-bold text-white/55">Atividade</dt>
<dd className="mt-1">{item.sceneLabel}</dd>
</div>
) : null}
{item.peopleLabel ? (
<div>
<dt className="font-bold text-white/55">Quem aparece</dt>
<dd className="mt-1">{item.peopleLabel}</dd>
</div>
) : null}
</dl>
<a
href={item.downloadPath}
download={item.downloadFilename}
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} mt-7 w-full`}
>
Baixar foto
</a>
{removalChannelUrl ? (
<div className="mt-auto border-t border-white/15 pt-5">
<p className="m-0 text-sm font-bold">É você nesta foto?</p>
<a
href={removalChannelUrl}
className={`mt-1 inline-flex min-h-11 items-center rounded-sm text-sm text-white/70 underline underline-offset-4 ${ARCHIVE_PHOTO_FOCUS}`}
>
Peça a remoção desta imagem
</a>
</div>
) : null}
</aside>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,40 @@
import Link from 'next/link'
import { CampaignLogoLink } from '@/components/CampaignLogoLink'
import { ARCHIVE_PHOTO_FOCUS } from './archivePhotoClasses'
const NAV_LINK = `rounded-sm text-sm font-bold text-white underline-offset-4 hover:underline ${ARCHIVE_PHOTO_FOCUS}`
/**
* C233 — the album bar (artefato: cena 01): the negative logo over the campaign
* red (crop contract in `CampaignLogoLink`, single owner S36) and the site nav
* on desktop — "Fotos" carries the yellow underline of the current page;
* mobile keeps the section name alone. "Conteúdos" only appears while the
* Central has something published, the same discovery rule as the footer.
*/
export const ArchivePhotoPageHeader = ({ showConteudos }: { showConteudos: boolean }) => (
<header className="bg-[#ae1603]">
<div className="mx-auto flex h-14 w-full max-w-6xl items-center justify-between gap-3 px-5 sm:h-16 sm:px-8">
<CampaignLogoLink className={ARCHIVE_PHOTO_FOCUS} />
<nav aria-label="Navegação principal" className="hidden items-center gap-6 sm:flex">
<Link href="/" className={NAV_LINK}>
Início
</Link>
<Link
href="/fotos"
aria-current="page"
className="border-b-2 border-(--pt-yellow) py-2 text-sm font-bold text-white"
>
Fotos
</Link>
{showConteudos ? (
<Link href="/conteudos" className={NAV_LINK}>
Conteúdos
</Link>
) : null}
</nav>
<span className="text-sm font-bold text-white sm:hidden">Fotos</span>
</div>
</header>
)

View file

@ -0,0 +1,25 @@
import { ARCHIVE_PHOTO_SECONDARY_BUTTON } from './archivePhotoClasses'
/**
* C233 — the removal channel at the foot of the album (artefato: cena 05): a
* person who appears in a photo asks the human team to take it down. The
* address is data configured in the album global, never invented here — the
* band only renders when a valid channel exists (fail-closed).
*/
export const ArchivePhotoRemovalBand = ({ href }: { href: string }) => (
<section className="bg-white">
<div className="mx-auto flex w-full max-w-6xl flex-col items-start justify-between gap-5 px-5 py-7 sm:flex-row sm:items-center sm:px-8">
<div>
<p className="font-[family-name:var(--font-exo2)] text-base font-black">
Aparece em alguma foto?
</p>
<p className="mt-1 text-sm text-(--campaign-muted)">
Se você não quiser que ela fique pública, fale com a equipe responsável pelo acervo.
</p>
</div>
<a href={href} className={`${ARCHIVE_PHOTO_SECONDARY_BUTTON} w-full shrink-0 sm:w-auto`}>
Pedir remoção de uma foto
</a>
</div>
</section>
)

View file

@ -0,0 +1,72 @@
import { ImageIcon, SearchIcon } from 'lucide-react'
import Link from 'next/link'
import {
ARCHIVE_PHOTO_ALBUM_PATH,
type ArchivePhotoAlbumActiveFilter,
} from '@/lib/archivePhotoPublicCatalog'
import { ARCHIVE_PHOTO_PRIMARY_BUTTON } from './archivePhotoClasses'
/**
* C233 — the two honest empty states (artefato: cena 04):
* (A) the album has no approved photo yet — the page exists and explains it,
* never a 404 and never a fake sample; (B) the combination returned nothing —
* the query and the filters in force, with the one way out.
*/
export const ArchivePhotoEmptyState = () => (
<section className="rounded-2xl border border-(--campaign-line) bg-(--campaign-cream) px-6 py-14 text-center">
<div className="mx-auto grid size-12 place-items-center rounded-full border border-(--campaign-line) bg-white text-(--campaign-muted)">
<ImageIcon aria-hidden="true" className="size-6" strokeWidth={2} />
</div>
<h2 className="mt-4 font-[family-name:var(--font-exo2)] text-2xl font-black">
O álbum ainda está sendo preparado
</h2>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-(--campaign-muted)">
Ainda não há fotos aprovadas para publicação. Quando houver registros disponíveis, eles
aparecerão aqui.
</p>
</section>
)
export const ArchivePhotoNoResults = ({
activeFilters,
}: {
activeFilters: readonly ArchivePhotoAlbumActiveFilter[]
}) => (
<section
data-album-no-results
className="rounded-2xl border border-[#184e92]/15 bg-[#f9faff] px-5 py-10 text-center sm:px-8 sm:py-14"
>
<div
aria-hidden="true"
className="mx-auto grid size-11 place-items-center rounded-full border border-[#184e92]/20 bg-white text-[#184e92] sm:size-12"
>
<SearchIcon className="size-5 sm:size-6" strokeWidth={2} />
</div>
<h2 className="mt-4 font-[family-name:var(--font-exo2)] text-xl font-black sm:text-2xl">
Nada encontrado com esses filtros
</h2>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-(--campaign-muted)">
Tente outro termo ou volte a ver todas as fotos aprovadas do álbum.
</p>
{activeFilters.length > 0 ? (
<div className="mt-4 flex flex-wrap justify-center gap-1.5 text-[11px] text-(--campaign-muted) sm:text-xs">
{activeFilters.map((filter) => (
<span
key={filter.facet}
className="rounded-full border border-black/10 bg-white px-2.5 py-1 sm:px-3 sm:py-1.5"
>
{filter.facet === 'q' ? `Busca: “${filter.value}”` : filter.value}
</span>
))}
</div>
) : null}
<Link
href={ARCHIVE_PHOTO_ALBUM_PATH}
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} mt-6 sm:min-w-40`}
>
Limpar filtros
</Link>
</section>
)

View file

@ -0,0 +1,30 @@
import { cn } from '@/lib/utils'
/** Shared class contracts of the public photo album (artefato C233, cenas 01–07). */
export const ARCHIVE_PHOTO_FOCUS =
'focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-yellow)'
export const ARCHIVE_PHOTO_CHIP = cn(
'inline-flex min-h-11 items-center gap-1.5 rounded-full border border-(--campaign-line) bg-white px-3 text-xs font-bold text-black',
ARCHIVE_PHOTO_FOCUS,
)
export const ARCHIVE_PHOTO_ACTIVE_CHIP = cn(
'inline-flex min-h-11 items-center gap-1.5 rounded-full border border-[#184e92]/35 bg-[#eef4fb] px-3 text-xs font-bold text-[#184e92]',
ARCHIVE_PHOTO_FOCUS,
)
export const ARCHIVE_PHOTO_PRIMARY_BUTTON = cn(
'inline-flex min-h-11 items-center justify-center gap-2 rounded-[10px] bg-(--pt-red) px-4 font-[family-name:var(--font-exo2)] text-sm font-extrabold text-white no-underline',
'shadow-[0_4px_0_#7f1616] transition-[transform,box-shadow] duration-150 ease-out hover:-translate-y-0.5 active:translate-y-[2px] active:shadow-[0_2px_0_#7f1616] motion-reduce:transition-none',
ARCHIVE_PHOTO_FOCUS,
)
export const ARCHIVE_PHOTO_SECONDARY_BUTTON = cn(
'inline-flex min-h-11 items-center justify-center gap-2 rounded-[10px] border border-(--campaign-line) bg-white px-4 font-[family-name:var(--font-exo2)] text-sm font-extrabold text-black no-underline',
'transition-colors duration-150 ease-out hover:border-black/30 motion-reduce:transition-none',
ARCHIVE_PHOTO_FOCUS,
)
export const ARCHIVE_PHOTO_CARD = cn('block rounded-xl text-left', ARCHIVE_PHOTO_FOCUS)

View file

@ -38,9 +38,11 @@ const PRIMARY_ACTION_DISABLED = cn(
export const ShareLinkAnnouncement = ({
view,
showJingles = false,
showFotos = false,
}: {
view: ShareLinkAnnouncementView
showJingles?: boolean
showFotos?: boolean
}) => {
const [live, setLive] = useState<ShareLinkLiveTarget | null>(null)
const isLive = live !== null
@ -237,7 +239,7 @@ export const ShareLinkAnnouncement = ({
</article>
</main>
<CampaignFooter showJingles={showJingles} />
<CampaignFooter showJingles={showJingles} showFotos={showFotos} />
</div>
)
}

81
src/globals/PhotoAlbum.ts Normal file
View file

@ -0,0 +1,81 @@
import { isArchivePhotoRemovalChannelUrl } from '@/lib/archivePhotoCatalog'
import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { revalidateGlobal } from '@/utilities/globals'
import type { GlobalBeforeValidateHook, GlobalConfig } from 'payload'
import { ValidationError } from 'payload'
const slug = 'photoAlbum'
/**
* C233 — the two product knobs of the public photo album, kept as data so a
* pull-down never depends on a deploy:
*
* - `published` is the album's kill switch. `true` by explicit decision of the
* gate (2026-09-26): `/fotos` opens with the deploy instead of waiting for
* the election. Flipping it off in the admin closes the route immediately.
* - `removalChannelUrl` is where a person asks for a photo of themselves to be
* taken down. It is never invented in code; a valid channel is a
* precondition for the album to be published and for a photo to be approved
* (see the collection's guard), so a public photo always has the channel
* that the album's notice points to.
*/
const validateRemovalChannel: GlobalBeforeValidateHook = ({ data }) => {
if (!data) return data
const published = data.published ?? false
if (published && !isArchivePhotoRemovalChannelUrl(data.removalChannelUrl)) {
throw new ValidationError({
errors: [
{
path: 'removalChannelUrl',
message:
'Configure um canal de remoção válido (https:// ou mailto:) antes de publicar o álbum.',
},
],
})
}
return data
}
const revalidate = async () => {
revalidateGlobal(slug)
}
export const PhotoAlbum: GlobalConfig = {
slug,
label: 'Álbum de fotos',
admin: {
group: 'Configurações',
description:
'Chaves do álbum público (/fotos). Desmarcar "Publicado" tira a rota do ar imediatamente; o canal de remoção alimenta o aviso "é você nesta foto?".',
},
access: {
read: () => true,
// Public surface config: admin-only like the other site globals — campaign
// JWTs reach /api/*.
update: payloadAdminOnly,
},
hooks: {
beforeValidate: [validateRemovalChannel],
afterChange: [revalidate],
},
fields: [
{
name: 'published',
type: 'checkbox',
label: 'Publicado',
defaultValue: true,
admin: {
description: 'Quando desmarcado, /fotos responde 404 (e some do índice) imediatamente.',
},
},
{
name: 'removalChannelUrl',
type: 'text',
label: 'Canal de remoção',
admin: {
description:
'Link (https://) ou e-mail (mailto:) para pedir a remoção de uma foto. Obrigatório para publicar o álbum ou aprovar uma foto.',
},
},
],
}

View file

@ -51,7 +51,8 @@ export const resolveArchivePhotoScene = (token: unknown): ArchivePhotoScene | nu
return match?.value ?? null
}
const archivePhotoSceneLabel = (value: ArchivePhotoScene): string =>
/** The pt-BR label of a scene value (the facet's own label once it has one). */
export const archivePhotoSceneLabel = (value: ArchivePhotoScene): string =>
ARCHIVE_PHOTO_SCENES.find((scene) => scene.value === value)?.label ?? value
/**
@ -96,6 +97,55 @@ export const archivePhotoCatalogSourceLabels: Record<ArchivePhotoCatalogSource,
none: 'Nada a propor',
}
/**
* C233 — the publication gate between the C232 pre-catalogue and the public
* album. `draft` is the honest initial state (nothing is public by default),
* `approved` is what the assessoria deliberately sent to the album and
* `removed` is the sticky takedown state: a removal request served and
* recorded. The cataloguing pipeline never writes this field, and only a human
* edit leaves `removed` — fail-closed right to erasure.
*/
export const ARCHIVE_PHOTO_PUBLICATION_STATUSES = ['draft', 'approved', 'removed'] as const
export type ArchivePhotoPublicationStatus = (typeof ARCHIVE_PHOTO_PUBLICATION_STATUSES)[number]
export const isArchivePhotoPublicationStatus = (
value: unknown,
): value is ArchivePhotoPublicationStatus =>
ARCHIVE_PHOTO_PUBLICATION_STATUSES.includes(value as ArchivePhotoPublicationStatus)
export const archivePhotoPublicationStatusLabels: Record<ArchivePhotoPublicationStatus, string> = {
draft: 'Rascunho',
approved: 'Aprovada',
removed: 'Removida',
}
/**
* The predicate every public read filters through (mirror of
* `contentPieceIsPublic`): only an approved photo is public, and a missing or
* unknown status fails closed.
*/
export const archivePhotoIsPublic = (photo: { publicationStatus?: unknown }): boolean =>
photo.publicationStatus === 'approved'
/**
* C233 — the removal channel the album advertises ("é você nesta foto? peça a
* remoção"): an absolute http(s) URL or a `mailto:`. Presence of a valid
* channel is a precondition for a photo to be public — the address itself is
* product data configured in the admin, never invented in code.
*/
export const isArchivePhotoRemovalChannelUrl = (value: unknown): value is string => {
if (typeof value !== 'string') return false
const trimmed = value.trim()
if (!trimmed) return false
try {
const { protocol } = new URL(trimmed)
return protocol === 'https:' || protocol === 'http:' || protocol === 'mailto:'
} catch {
return false
}
}
export const ARCHIVE_PHOTO_CAPTION_MAX_LENGTH = 200
export const ARCHIVE_PHOTO_DESCRIPTION_MAX_LENGTH = 600
export const ARCHIVE_PHOTO_VISIBLE_TEXT_MAX_LENGTH = 1000

View file

@ -0,0 +1,482 @@
/**
* C233 — pure contract of the public photo album (`/fotos`): the URL vocabulary
* (path, single-value facets, term, pagination, the non-canonical overlay id),
* the in-memory filtering over the approved list, the derived facets and the
* serializable view model the grid, the dialog and the removal band render. No
* I/O and no `server-only`: the cached public read, the server components and
* the unit tests share this module.
*
* Everything here is fed ONLY by approved photos: the facet vocabularies and
* the headings are derived from the items the caller already gated through
* `archivePhotoIsPublic`, so a filter that cannot match anything never renders
* and the empty states stay honest.
*/
import {
ARCHIVE_PHOTO_SCENES,
archivePhotoIsPublic,
archivePhotoSceneLabel,
archivePhotoTakenOn,
resolveArchivePhotoScene,
type ArchivePhotoScene,
} from '@/lib/archivePhotoCatalog'
import { resolvePublicFigureName } from '@/lib/publicFigureCatalog'
import { SLUG_PATTERN, slugify } from '@/lib/slug'
import { normalizeForSearch } from '@/lib/speechSearch'
export const ARCHIVE_PHOTO_ALBUM_PATH = '/fotos'
/** Grid page size (design scene 07: 24 itens; prev/next preserve the facets). */
export const ARCHIVE_PHOTO_ALBUM_PAGE_SIZE = 24
/** Thumbnail width the media route resizes to (2× of the widest card). */
export const ARCHIVE_PHOTO_THUMB_WIDTH = 720
const archivePhotoMediaPath = (id: number): string => `${ARCHIVE_PHOTO_ALBUM_PATH}/${id}/midia`
const archivePhotoThumbnailPath = (id: number): string =>
`${archivePhotoMediaPath(id)}?tamanho=grade`
const archivePhotoDownloadPath = (id: number): string => `${archivePhotoMediaPath(id)}?download=1`
/**
* The one value per facet, in the canonical URL order (mirrors the staff list
* contracts): `data` is one day, `municipio` a catalog slug, `atividade` a
* scene value and `pessoa` the slug of a canonical display name. `pagina` is
* 1-based and `foto` is the overlay-only id, never canonical.
*/
export type ArchivePhotoAlbumParams = {
data: string | null
municipio: string | null
atividade: ArchivePhotoScene | null
pessoa: string | null
q: string
pagina: number
foto: number | null
}
export type ArchivePhotoAlbumSearchParams = Record<string, string | string[] | undefined>
const firstValue = (value: string | string[] | undefined): string =>
(Array.isArray(value) ? value[0] : value)?.trim() ?? ''
const parsePositiveInt = (value: string): number | null => {
if (!/^\d+$/.test(value)) return null
const parsed = Number(value)
return Number.isSafeInteger(parsed) && parsed >= 1 ? parsed : null
}
/**
* One value per facet; unknown enum values, non-slug labels and impossible
* days are dropped (a hand-typed `?atividade=../etc` never reaches a query or
* a comparison), the page falls back to 1 and an invalid `foto` simply does
* not open the overlay.
*/
export const parseArchivePhotoAlbumParams = (
raw: ArchivePhotoAlbumSearchParams,
): ArchivePhotoAlbumParams => {
const data = firstValue(raw.data)
const day = archivePhotoTakenOn(data)
const atividade = firstValue(raw.atividade)
const municipio = firstValue(raw.municipio)
const pessoa = firstValue(raw.pessoa)
return {
data: day ? day.slice(0, 10) : null,
municipio: municipio && SLUG_PATTERN.test(municipio) ? municipio : null,
atividade: resolveArchivePhotoScene(atividade),
pessoa: pessoa && SLUG_PATTERN.test(pessoa) ? pessoa : null,
q: firstValue(raw.q),
pagina: parsePositiveInt(firstValue(raw.pagina)) ?? 1,
foto: parsePositiveInt(firstValue(raw.foto)),
}
}
/**
* Canonical listing URL: fixed facet order, no empty params, `pagina=1`
* dropped, no params at all when clean. `foto` is included only when asked
* (the card links) — the canonical metadata and every filter-removal href pass
* `foto: null`.
*/
export const buildArchivePhotoAlbumHref = (params: Partial<ArchivePhotoAlbumParams>): string => {
const search = new URLSearchParams()
if (params.data) search.set('data', params.data)
if (params.municipio) search.set('municipio', params.municipio)
if (params.atividade) search.set('atividade', params.atividade)
if (params.pessoa) search.set('pessoa', params.pessoa)
const term = params.q?.trim()
if (term) search.set('q', term)
if (params.pagina && params.pagina > 1) search.set('pagina', String(params.pagina))
if (params.foto) search.set('foto', String(params.foto))
const query = search.toString()
return query ? `${ARCHIVE_PHOTO_ALBUM_PATH}?${query}` : ARCHIVE_PHOTO_ALBUM_PATH
}
const MONTHS_PT = [
'janeiro',
'fevereiro',
'março',
'abril',
'maio',
'junho',
'julho',
'agosto',
'setembro',
'outubro',
'novembro',
'dezembro',
] as const
const SHORT_MONTHS_PT = [
'jan',
'fev',
'mar',
'abr',
'mai',
'jun',
'jul',
'ago',
'set',
'out',
'nov',
'dez',
] as const
const dayPartsOf = (value: string | null | undefined): [string, string, string] | null => {
if (!value) return null
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value.trim())
if (!match) return null
return [match[1], match[2], match[3]]
}
/** "12 de setembro de 2026" (the dialog and the date facet label). */
export const archivePhotoLongDateLabel = (value: string | null | undefined): string | null => {
const parts = dayPartsOf(value)
if (!parts) return null
const [year, month, day] = parts
const monthLabel = MONTHS_PT[Number(month) - 1]
if (!monthLabel) return null
return `${Number(day)} de ${monthLabel} de ${year}`
}
/** "12 set 2026" (the card's metadata line). */
export const archivePhotoShortDateLabel = (value: string | null | undefined): string | null => {
const parts = dayPartsOf(value)
if (!parts) return null
const [year, month, day] = parts
const monthLabel = SHORT_MONTHS_PT[Number(month) - 1]
if (!monthLabel) return null
return `${Number(day)} ${monthLabel} ${year}`
}
/** The day (`YYYY-MM-DD`) a photo belongs to, or null — the `data` facet key. */
const archivePhotoDayOf = (value: string | null | undefined): string | null =>
dayPartsOf(value)?.join('-') ?? null
type ArchivePhotoPublicPerson = { slug: string; name: string }
/** One appearance: canonical catalog spelling (when it resolves), slug-keyed. */
const archivePhotoPublicPeople = (names: readonly string[]): ArchivePhotoPublicPerson[] => {
const people = new Map<string, string>()
for (const raw of names) {
const trimmed = raw.trim()
if (!trimmed) continue
const name = resolvePublicFigureName(trimmed) ?? trimmed
const slug = slugify(name)
if (!slug) continue
people.set(slug, name)
}
return [...people.entries()].map(([slug, name]) => ({ slug, name }))
}
export type ArchivePhotoPublicSource = {
id: number
alt?: string | null
takenOn?: string | null
municipalityName?: string | null
municipalitySlug?: string | null
filename?: string | null
publicationStatus?: string | null
searchText?: string | null
catalog?: {
caption?: string | null
scene?: string | null
people?: string[] | null
} | null
}
export type ArchivePhotoPublicItem = {
id: number
/** Curated alt of the file (the image's accessible text, never the filename). */
alt: string
/** `catalog.caption` when curated, else the alt — the card/dialog title. */
title: string
takenOn: string | null
dateLabel: string | null
shortDateLabel: string | null
municipalityName: string | null
municipalitySlug: string | null
scene: ArchivePhotoScene | null
sceneLabel: string | null
people: string[]
peopleLabel: string | null
/** "12 set 2026 · Município · Plenária" — the card's metadata line. */
metaLabel: string
searchText: string
thumbnailPath: string
mediaPath: string
downloadPath: string
downloadFilename: string
}
/** Portuguese list join ("A", "A e B", "A, B e C"). */
const archivePhotoPeopleLabel = (people: readonly string[]): string | null => {
const names = people.map((name) => name.trim()).filter(Boolean)
if (names.length === 0) return null
if (names.length === 1) return names[0]
return `${names.slice(0, -1).join(', ')} e ${names[names.length - 1]}`
}
const DOWNLOAD_PREFIX = 'jorge-solla-1313-foto'
const FALLBACK_EXTENSION = 'jpg'
/** Legible download name (`jorge-solla-1313-foto-<id>.<ext>`), never the stored key. */
const archivePhotoDownloadFilename = (id: number, filename?: string | null): string => {
const lastDot = filename?.lastIndexOf('.') ?? -1
const extension = lastDot > 0 ? filename?.slice(lastDot + 1).toLowerCase() : null
const safeExtension =
extension && /^[a-z0-9]{1,5}$/.test(extension) ? extension : FALLBACK_EXTENSION
return `${DOWNLOAD_PREFIX}-${id}.${safeExtension}`
}
/**
* Public view of one photo, or null when it may not be shown: the
* `archivePhotoIsPublic` predicate fails (draft, removed, unknown) — the gate
* lives here too, not only in the query, so every caller is fail-closed.
*/
export const toArchivePhotoPublicItem = (
record: ArchivePhotoPublicSource,
): ArchivePhotoPublicItem | null => {
if (!archivePhotoIsPublic(record)) return null
const alt = record.alt?.trim() || `Foto ${record.id}`
const caption = record.catalog?.caption?.trim() || null
const scene = resolveArchivePhotoScene(record.catalog?.scene)
const sceneText = scene ? archivePhotoSceneLabel(scene) : null
const people = archivePhotoPublicPeople(record.catalog?.people ?? [])
const takenOn = record.takenOn ?? null
const shortDateLabel = archivePhotoShortDateLabel(takenOn)
const municipalityName = record.municipalityName?.trim() || null
const municipalitySlug = record.municipalitySlug?.trim() || null
const peopleNames = people.map((person) => person.name)
return {
id: record.id,
alt,
title: caption ?? alt,
takenOn,
dateLabel: archivePhotoLongDateLabel(takenOn),
shortDateLabel,
municipalityName,
municipalitySlug,
scene,
sceneLabel: sceneText,
people: peopleNames,
peopleLabel: archivePhotoPeopleLabel(peopleNames),
metaLabel: [shortDateLabel, municipalityName, sceneText].filter(Boolean).join(' · '),
searchText: record.searchText ?? '',
thumbnailPath: archivePhotoThumbnailPath(record.id),
mediaPath: archivePhotoMediaPath(record.id),
downloadPath: archivePhotoDownloadPath(record.id),
downloadFilename: archivePhotoDownloadFilename(record.id, record.filename),
}
}
type ArchivePhotoAlbumFacetOption = { value: string; label: string }
/**
* The single-value facets, in the canonical URL order. One owner: the active
* chips, the desktop menus and the mobile sheet all derive from this list.
*/
export const ARCHIVE_PHOTO_ALBUM_FACETS = ['data', 'municipio', 'atividade', 'pessoa'] as const
export type ArchivePhotoAlbumFacet = (typeof ARCHIVE_PHOTO_ALBUM_FACETS)[number]
export const archivePhotoAlbumFacetLabels: Record<ArchivePhotoAlbumFacet, string> = {
data: 'Data',
municipio: 'Município',
atividade: 'Atividade',
pessoa: 'Pessoa pública',
}
export type ArchivePhotoAlbumFacets = {
/** Days with approved photos, most recent first. */
data: ArchivePhotoAlbumFacetOption[]
municipio: ArchivePhotoAlbumFacetOption[]
atividade: ArchivePhotoAlbumFacetOption[]
pessoa: ArchivePhotoAlbumFacetOption[]
}
const sortedByLabel = (options: Map<string, string>): ArchivePhotoAlbumFacetOption[] =>
[...options.entries()]
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label, 'pt-BR'))
/**
* Facet options derived from the approved items alone: a filter that cannot
* match anything never renders. Dias come most recent first; cenas keep the
* closed vocabulary order; município/pessoa are alphabetical by their label.
*/
export const archivePhotoAlbumFacets = (
items: readonly ArchivePhotoPublicItem[],
): ArchivePhotoAlbumFacets => {
const days = new Map<string, string>()
const municipalities = new Map<string, string>()
const scenes = new Set<ArchivePhotoScene>()
const people = new Map<string, string>()
for (const item of items) {
const day = archivePhotoDayOf(item.takenOn)
const dayLabel = archivePhotoLongDateLabel(item.takenOn)
if (day && dayLabel) days.set(day, dayLabel)
if (item.municipalitySlug && item.municipalityName) {
municipalities.set(item.municipalitySlug, item.municipalityName)
}
if (item.scene) scenes.add(item.scene)
for (const person of archivePhotoPublicPeople(item.people)) {
people.set(person.slug, person.name)
}
}
return {
data: [...days.entries()]
.map(([value, label]) => ({ value, label }))
.sort((a, b) => b.value.localeCompare(a.value)),
municipio: sortedByLabel(municipalities),
atividade: ARCHIVE_PHOTO_SCENES.filter((scene) => scenes.has(scene.value)).map((scene) => ({
value: scene.value,
label: scene.label,
})),
pessoa: sortedByLabel(people),
}
}
export type ArchivePhotoAlbumActiveFilter = {
facet: ArchivePhotoAlbumFacet | 'q'
label: string
value: string
removeHref: string
}
/** The active filters in canonical order, with the URL that removes each one. */
export const archivePhotoAlbumActiveFilters = (
params: ArchivePhotoAlbumParams,
facets: ArchivePhotoAlbumFacets,
): ArchivePhotoAlbumActiveFilter[] => {
const filters: ArchivePhotoAlbumActiveFilter[] = []
const withoutOverlay = { ...params, foto: null }
for (const facet of ARCHIVE_PHOTO_ALBUM_FACETS) {
const value = params[facet]
if (!value) continue
const option = facets[facet].find((candidate) => candidate.value === value)
filters.push({
facet,
label: archivePhotoAlbumFacetLabels[facet],
value: option?.label ?? value,
removeHref: buildArchivePhotoAlbumHref({ ...withoutOverlay, [facet]: null }),
})
}
if (params.q.trim()) {
filters.push({
facet: 'q',
label: 'Busca',
value: params.q.trim(),
removeHref: buildArchivePhotoAlbumHref({ ...withoutOverlay, q: '' }),
})
}
return filters
}
/**
* All active facets combine (AND); the term matches the denormalized haystack.
* `pessoa` compares the canonical slug of every appearance, so the URL key and
* the facet option always agree.
*/
export const filterArchivePhotoAlbumItems = (
items: readonly ArchivePhotoPublicItem[],
params: ArchivePhotoAlbumParams,
): ArchivePhotoPublicItem[] => {
const term = normalizeForSearch(params.q)
return items.filter((item) => {
if (params.data && archivePhotoDayOf(item.takenOn) !== params.data) return false
if (params.municipio && item.municipalitySlug !== params.municipio) return false
if (params.atividade && item.scene !== params.atividade) return false
if (
params.pessoa &&
!archivePhotoPublicPeople(item.people).some((person) => person.slug === params.pessoa)
) {
return false
}
if (!term) return true
return normalizeForSearch(item.searchText).includes(term)
})
}
/**
* Contextual heading (design scene 06): município → atividade → pessoa → data
* → termo; the other criteria stay visible as removable chips. The heading is
* derived from the items' own labels, never from the raw param.
*/
export const archivePhotoAlbumHeading = (
params: ArchivePhotoAlbumParams,
facets: ArchivePhotoAlbumFacets,
): string => {
const labelOf = (facet: ArchivePhotoAlbumFacet): string | null => {
const value = params[facet]
if (!value) return null
return facets[facet].find((option) => option.value === value)?.label ?? null
}
const municipality = labelOf('municipio')
if (municipality) return `Fotos em ${municipality}`
const scene = labelOf('atividade')
if (scene) return `Fotos de ${scene}`
const person = labelOf('pessoa')
if (person) return `Fotos com ${person}`
const day = labelOf('data')
if (day) return `Fotos de ${day}`
const term = params.q.trim()
if (term) return `Resultados para “${term}”`
return 'Fotos da nossa caminhada'
}
export type ArchivePhotoAlbumPage = {
/** Clamped to `[1, pageCount]`; `pageCount` is 1 even with zero items. */
page: number
pageCount: number
items: ArchivePhotoPublicItem[]
hasPrevious: boolean
hasNext: boolean
}
/** Slices the filtered list for the current page (24/page, design scene 07). */
export const paginateArchivePhotoAlbumItems = (
items: readonly ArchivePhotoPublicItem[],
page: number,
): ArchivePhotoAlbumPage => {
const pageCount = Math.max(1, Math.ceil(items.length / ARCHIVE_PHOTO_ALBUM_PAGE_SIZE))
const current = Math.min(Math.max(1, page), pageCount)
const start = (current - 1) * ARCHIVE_PHOTO_ALBUM_PAGE_SIZE
return {
page: current,
pageCount,
items: items.slice(start, start + ARCHIVE_PHOTO_ALBUM_PAGE_SIZE),
hasPrevious: current > 1,
hasNext: current < pageCount,
}
}

View file

@ -15,6 +15,7 @@ export const SHARE_LINK_RESERVED_SLUGS = [
'abaixo-assinado',
'jingles',
'conteudos',
'fotos',
'api',
'admin',
'noticia',

View file

@ -1,3 +1,6 @@
/** A slug as this module produces it: lowercase alphanumerics, hyphen-separated. */
export const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
export const slugify = (value: string): string =>
value
.normalize('NFD')

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,53 @@
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_archive_photo_publication_status" AS ENUM('draft', 'approved', 'removed');
CREATE TABLE "photo_album" (
"id" serial PRIMARY KEY NOT NULL,
"published" boolean DEFAULT true,
"removal_channel_url" varchar,
"updated_at" timestamp(3) with time zone,
"created_at" timestamp(3) with time zone
);
ALTER TABLE "archive_photo" ADD COLUMN "publication_status" "enum_archive_photo_publication_status" DEFAULT 'draft' NOT NULL;
ALTER TABLE "archive_photo" ADD COLUMN "municipality_name" varchar;
ALTER TABLE "archive_photo" ADD COLUMN "municipality_slug" varchar;
CREATE INDEX "archive_photo_publication_status_idx" ON "archive_photo" USING btree ("publication_status");
CREATE INDEX "archive_photo_municipality_slug_idx" ON "archive_photo" USING btree ("municipality_slug");`)
// C233 — backfill the município snapshots from the relationship already on
// the row (the public album reads `depth 0` and the `municipality`
// collection is campaign-only). Idempotent: only rows whose snapshot differs
// are touched, and the count is logged so a silent no-op is visible at
// deploy time. No photo is approved here — `publication_status` keeps its
// `draft` default, so nothing becomes public.
await db.execute(sql`
DO $$
DECLARE
backfilled integer;
BEGIN
UPDATE "archive_photo" AS photo
SET "municipality_name" = municipality."name",
"municipality_slug" = municipality."slug"
FROM "municipality" AS municipality
WHERE photo."catalog_municipality_id" = municipality."id"
AND (photo."municipality_name" IS DISTINCT FROM municipality."name"
OR photo."municipality_slug" IS DISTINCT FROM municipality."slug");
GET DIAGNOSTICS backfilled = ROW_COUNT;
RAISE NOTICE 'C233 backfill archive_photo município snapshots: % linhas', backfilled;
END $$;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "photo_album" DISABLE ROW LEVEL SECURITY;
DROP TABLE "photo_album" CASCADE;
DROP INDEX "archive_photo_publication_status_idx";
DROP INDEX "archive_photo_municipality_slug_idx";
ALTER TABLE "archive_photo" DROP COLUMN "publication_status";
ALTER TABLE "archive_photo" DROP COLUMN "municipality_name";
ALTER TABLE "archive_photo" DROP COLUMN "municipality_slug";
DROP TYPE "public"."enum_archive_photo_publication_status";`)
}

View file

@ -89,6 +89,7 @@ import * as migration_20260924_123940_add_content_piece_people from './20260924_
import * as migration_20260926_034118_add_speech_embedding from './20260926_034118_add_speech_embedding';
import * as migration_20260926_062039_add_archive_photo from './20260926_062039_add_archive_photo';
import * as migration_20260926_132106_add_archive_photo_catalog from './20260926_132106_add_archive_photo_catalog';
import * as migration_20260926_163248_add_archive_photo_publication from './20260926_163248_add_archive_photo_publication';
export const migrations = [
{
@ -546,4 +547,9 @@ export const migrations = [
down: migration_20260926_132106_add_archive_photo_catalog.down,
name: '20260926_132106_add_archive_photo_catalog',
},
{
up: migration_20260926_163248_add_archive_photo_publication.up,
down: migration_20260926_163248_add_archive_photo_publication.down,
name: '20260926_163248_add_archive_photo_publication'
},
];

View file

@ -191,6 +191,7 @@ export interface Config {
'privacy-policy': PrivacyPolicy;
campaignGoals: CampaignGoal;
'social-feed-settings': SocialFeedSetting;
photoAlbum: PhotoAlbum;
};
globalsSelect: {
'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>;
@ -199,6 +200,7 @@ export interface Config {
'privacy-policy': PrivacyPolicySelect<false> | PrivacyPolicySelect<true>;
campaignGoals: CampaignGoalsSelect<false> | CampaignGoalsSelect<true>;
'social-feed-settings': SocialFeedSettingsSelect<false> | SocialFeedSettingsSelect<true>;
photoAlbum: PhotoAlbumSelect<false> | PhotoAlbumSelect<true>;
};
locale: null;
widgets: {
@ -1483,6 +1485,10 @@ export interface ArchivePhoto {
*/
catalogedAt?: string | null;
};
/**
* Só "Aprovada" aparece no álbum público (/fotos). "Removida" é o estado de remoção a pedido — a catalogação automática nunca o altera e a foto não volta ao público sem uma nova edição humana.
*/
publicationStatus: 'draft' | 'approved' | 'removed';
/**
* O que a assessoria já editou; a catalogação automática nunca sobrescreve estes campos.
*/
@ -1545,6 +1551,14 @@ export interface ArchivePhoto {
sourceUrl?: string | null;
owner?: string | null;
license?: string | null;
/**
* Derivado do município da ficha, para o álbum público ler sem tocar a collection campaign-only.
*/
municipalityName?: string | null;
/**
* Faceta de município da URL pública (`/fotos?municipio=<slug>`).
*/
municipalitySlug?: string | null;
/**
* Tudo o que a busca da lista encontra, normalizado (sem acentos, minúsculas): legenda, texto visível, temas, pessoas, município, álbuns e tags.
*/
@ -3154,6 +3168,7 @@ export interface ArchivePhotoSelect<T extends boolean = true> {
source?: T;
catalogedAt?: T;
};
publicationStatus?: T;
curatedFields?: T;
title?: T;
description?: T;
@ -3183,6 +3198,8 @@ export interface ArchivePhotoSelect<T extends boolean = true> {
sourceUrl?: T;
owner?: T;
license?: T;
municipalityName?: T;
municipalitySlug?: T;
searchText?: T;
updatedAt?: T;
createdAt?: T;
@ -3884,6 +3901,25 @@ export interface SocialFeedSetting {
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* Chaves do álbum público (/fotos). Desmarcar "Publicado" tira a rota do ar imediatamente; o canal de remoção alimenta o aviso "é você nesta foto?".
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "photoAlbum".
*/
export interface PhotoAlbum {
id: number;
/**
* Quando desmarcado, /fotos responde 404 (e some do índice) imediatamente.
*/
published?: boolean | null;
/**
* Link (https://) ou e-mail (mailto:) para pedir a remoção de uma foto. Obrigatório para publicar o álbum ou aprovar uma foto.
*/
removalChannelUrl?: string | null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "site-settings_select".
@ -4003,6 +4039,17 @@ export interface SocialFeedSettingsSelect<T extends boolean = true> {
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "photoAlbum_select".
*/
export interface PhotoAlbumSelect<T extends boolean = true> {
published?: T;
removalChannelUrl?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "collections_widget".

View file

@ -59,6 +59,7 @@ import { VotePledge } from './collections/VotePledge'
import { CampaignGoals } from './globals/CampaignGoals'
import { HomePage } from './globals/HomePage'
import { Metadata } from './globals/Metadata'
import { PhotoAlbum } from './globals/PhotoAlbum'
import { PrivacyPolicy } from './globals/PrivacyPolicy'
import { SiteSettings } from './globals/SiteSettings'
import { SocialFeedSettings } from './globals/SocialFeedSettings'
@ -158,7 +159,15 @@ export default buildConfig({
ShareLink,
Jingle,
],
globals: [SiteSettings, HomePage, Metadata, PrivacyPolicy, CampaignGoals, SocialFeedSettings],
globals: [
SiteSettings,
HomePage,
Metadata,
PrivacyPolicy,
CampaignGoals,
SocialFeedSettings,
PhotoAlbum,
],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '',
typescript: {

View file

@ -0,0 +1,75 @@
import 'server-only'
import {
toArchivePhotoPublicItem,
type ArchivePhotoPublicItem,
type ArchivePhotoPublicSource,
} from '@/lib/archivePhotoPublicCatalog'
import { getCollectionListingTag } from '@/utilities/documents'
import configPromise from '@payload-config'
import { unstable_cache } from 'next/cache'
import { getPayload } from 'payload'
/**
* C233 — the public reads of the photo album. The collection has no public
* access (`canReadArchivePhoto` is campaign-only), so the anonymous read is the
* Local API without `user` — the bypass default — and the gate is the query
* plus the `archivePhotoIsPublic` predicate inside the mapper: only an
* `approved` photo ever leaves here. Same precedent as `contentPieceReads`.
*
* The listing is cached under the collection listing tag (`archivePhotos`,
* busted by the ArchivePhoto afterChange/afterDelete on every edit), and the
* by-id read resolves from the SAME cached list: one tag keeps the whole
* surface honest, so an `approved→removed` edit pulls the photo down with it.
* The municipality snapshot on the row (`municipalityName/Slug`) is why this
* read never touches the campaign-only `municipality` collection.
*/
const archivePhotoPublicSelect = {
alt: true,
takenOn: true,
municipalityName: true,
municipalitySlug: true,
filename: true,
publicationStatus: true,
searchText: true,
catalog: true,
} as const
const findApprovedArchivePhotos = () =>
getPayload({ config: configPromise }).then((payload) =>
payload.find({
collection: 'archivePhoto',
where: { publicationStatus: { equals: 'approved' } },
sort: ['-takenOn', '-id'],
depth: 0,
limit: 0,
pagination: false,
select: archivePhotoPublicSelect,
}),
)
const getCachedApprovedArchivePhotos = () =>
unstable_cache(findApprovedArchivePhotos, ['archive-photos'], {
tags: [getCollectionListingTag('archivePhoto')],
})
/** Approved photos as serializable public view models, newest first. */
export const getApprovedArchivePhotoItems = async (): Promise<ArchivePhotoPublicItem[]> =>
(await getCachedApprovedArchivePhotos()()).docs
.map((record) => toArchivePhotoPublicItem(record as ArchivePhotoPublicSource))
.filter((item): item is ArchivePhotoPublicItem => item !== null)
/**
* One approved photo by id (the overlay and the media gate). A draft, a
* removed photo, an unknown id and a delete all resolve to null — the caller
* turns that into the same silent 404/omitted overlay.
*/
export const getApprovedArchivePhotoById = async (
id: number,
): Promise<ArchivePhotoPublicItem | null> =>
(await getApprovedArchivePhotoItems()).find((item) => item.id === id) ?? null
/** Discovery flag for the footer link: the same resolved list the page renders. */
export const hasPublishedArchivePhotos = async (): Promise<boolean> =>
(await getApprovedArchivePhotoItems()).length > 0

View file

@ -52,3 +52,6 @@ export const revalidateJinglesListing = () => revalidateCollectionListing('jingl
/** C211 — the content pieces listing tag the public Central (S27) will read. */
export const revalidateContentPiecesListing = () => revalidateCollectionListing('contentPiece')
/** C233 — the archive photos listing tag the public album (`/fotos`) reads. */
export const revalidateArchivePhotosListing = () => revalidateCollectionListing('archivePhoto')

View file

@ -104,6 +104,9 @@ export const ingestArchivePhoto = async (
data: {
flickrId: record.flickrId,
alt: archivePhotoAlt(record),
// C233 — ingestion only ever creates drafts; the album publishes
// exclusively through a human curation edit.
publicationStatus: 'draft',
title: record.title,
description: record.description,
tags: record.tags.map((name) => ({ name })),

View file

@ -8,6 +8,8 @@ import { stat } from 'fs/promises'
import path from 'path'
import type { Payload } from 'payload'
import { getRangeRequestInfo } from 'payload/internal'
import sharp from 'sharp'
import { Readable } from 'stream'
import { pipeline } from 'stream/promises'
import {
@ -159,6 +161,26 @@ const openLocalObject = async ({
return { range, body }
}
/**
* Opens one object from whichever backing store is configured (the private S3
* bucket in production, the collection's disk directory in dev/test). Single
* owner of the choice — the streaming and the resizing paths share it.
*/
const openPrivateObject = async ({
filename,
staticDir,
rangeHeader,
}: {
filename: string
staticDir: string
rangeHeader: string | null
}): Promise<OpenedObject> => {
const storage = privateMediaStorage()
return storage
? openS3Object({ storage, filename, rangeHeader })
: openLocalObject({ staticDir, filename, rangeHeader })
}
const notFound = (): Response =>
new Response(null, { status: 404, headers: { 'Cache-Control': PRIVATE_MEDIA_CACHE_CONTROL } })
@ -189,10 +211,7 @@ export const buildPrivateMediaResponse = async ({
if (!filename) return notFound()
try {
const storage = privateMediaStorage()
const opened = storage
? await openS3Object({ storage, filename, rangeHeader })
: await openLocalObject({ staticDir, filename, rangeHeader })
const opened = await openPrivateObject({ filename, staticDir, rangeHeader })
return new Response(opened.body, {
status: opened.range.status,
@ -211,6 +230,82 @@ export const buildPrivateMediaResponse = async ({
}
}
/**
* C233 — one image artifact, optionally resized for a public grid. With no
* `width` it delegates to `buildPrivateMediaResponse` (range, stored MIME,
* disposition); with a `width` it opens the same object and downsizes it on the
* fly through sharp, without storing a variant and without a temp file — the
* same rule the C232 cataloguing already runs over the archive. The private
* headers survive (`no-store`), so a takedown is visible on the next request.
*/
export const buildPrivateMediaImageResponse = async ({
media,
staticDir,
width,
download,
dispositionFilename,
rangeHeader = null,
}: {
media: PrivateMediaFile
staticDir: string
width: number | null
download: boolean
dispositionFilename?: string | null
rangeHeader?: string | null
}): Promise<Response> => {
if (width === null) {
return buildPrivateMediaResponse({
media,
staticDir,
rangeHeader,
download,
dispositionFilename,
})
}
const filename = media.filename
if (!filename) return notFound()
try {
const opened = await openPrivateObject({ filename, staticDir, rangeHeader: null })
if (!(opened.body instanceof Readable)) {
throw new Error(`Objeto privado sem stream: ${filename}`)
}
// sharp's constructor takes no stream; the documented stream path is
// piping into the instance and draining its output.
const transformer = sharp({ failOn: 'error' })
.rotate()
.resize({ width, withoutEnlargement: true })
.jpeg({ quality: 80 })
const source = opened.body
const resized = new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = []
transformer.on('data', (chunk: Buffer) => chunks.push(chunk))
transformer.on('end', () => resolve(Buffer.concat(chunks)))
transformer.on('error', reject)
source.on('error', reject)
})
source.pipe(transformer)
const image = await resized
return new Response(new Uint8Array(image), {
status: 200,
headers: privateMediaHeaders({
range: { status: 200, headers: {} },
mimeType: 'image/jpeg',
filename: dispositionFilename?.trim() || filename,
download,
}),
})
} catch (error) {
if (isMissingObject(error) || (error as NodeJS.ErrnoException)?.code === 'ENOENT') {
return notFound()
}
throw error
}
}
/**
* Copies the stored artifact to `destinationPath` (the transcription job's
* temp input). Streams end to end: a recording of hours never becomes a JS

View file

@ -1,3 +1,4 @@
import { getCollectionListingTag } from '@/utilities/documents'
import { ELECTION_TSE_CACHE_TAG } from '@/utilities/electionCache'
import { getGlobalCacheTag } from '@/utilities/globals'
import { MUNICIPALITY_CATALOG_CACHE_TAG } from '@/utilities/municipality/municipalityCatalogCache'
@ -6,6 +7,13 @@ export const REVALIDATE_POSTS_TAG = 'posts' as const
export const REVALIDATE_PRIVACY_POLICY_CACHE_TAG = getGlobalCacheTag('privacy-policy')
/**
* C233 — the archive photos listing tag the public album (`/fotos`) caches
* under. Derived from the same owner the collection hook busts, so the runbook
* tag and the code can never drift.
*/
const REVALIDATE_ARCHIVE_PHOTOS_TAG = getCollectionListingTag('archivePhoto')
/**
* Cache tag of the campaign home content board's external feeds (`unstable_cache`
* entry of `getYouTubeFeed`). The `SocialFeedSettings` global's `afterChange`
@ -19,6 +27,7 @@ const ALLOWED_REVALIDATE_TAGS = [
ELECTION_TSE_CACHE_TAG,
MUNICIPALITY_CATALOG_CACHE_TAG,
REVALIDATE_SOCIAL_FEED_TAG,
REVALIDATE_ARCHIVE_PHOTOS_TAG,
] as const
type AllowedRevalidateTag = (typeof ALLOWED_REVALIDATE_TAGS)[number]

View file

@ -0,0 +1,346 @@
import { randomUUID } from 'node:crypto'
import type { APIRequestContext, Page } from '@playwright/test'
import sharp from 'sharp'
import { getMunicipalityCatalogEntry } from '../../src/lib/municipalityCatalog.js'
import { adminHeaders } from '../helpers/adminApi'
import { seedTestUser } from '../helpers/seedUser'
import { expect, test } from './fixtures/e2eTest'
/**
* C233 — the public photo album over real HTTP: only approved photos list, the
* facets/term filter the strip, the overlay opens from `?foto=`, the media
* (thumbnail and download) goes through the same-origin private proxy, an
* `approved→removed` edit pulls the photo down immediately and the
* `photoAlbum.published` kill switch closes the route without a deploy.
*
* Photos and the global are seeded through the deployed REST API (admin
* session) so the server process runs the real cache hooks; a Local API call
* from the runner would throw on `revalidateTag` (same reason as the S21/S27
* specs).
*
* The tests are serial: only this spec owns archive photo rows.
*/
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
const REMOVAL_CHANNEL = 'https://example.org/acervo/remocao'
// Read-only catalog lookups (never pinned/written rows — see the allocator
// guard): the spec only mutates its own archive photos. A missing catalog entry
// is a broken fixture and fails loudly instead of deep-linking an empty slug.
const catalogEntry = (slug: string) => {
const entry = getMunicipalityCatalogEntry(slug)
if (!entry) throw new Error(`município fora do catálogo: ${slug}`)
return entry
}
const feira = catalogEntry('feira-de-santana')
const camacari = catalogEntry('camacari')
const feiraSlug = feira.slug
const camacariSlug = camacari.slug
const camacariName = camacari.name
const feiraName = getMunicipalityCatalogEntry('feira-de-santana')?.name ?? ''
type Headers = Record<string, string>
const createdPhotoIds: number[] = []
const uniqueMarker = () => randomUUID().slice(0, 8)
const createTestJpeg = (): Promise<Buffer> =>
sharp({
create: { width: 64, height: 48, channels: 3, background: { r: 174, g: 22, b: 3 } },
})
.jpeg()
.toBuffer()
const setAlbum = async (
request: APIRequestContext,
headers: Headers,
data: { published: boolean; removalChannelUrl: string | null },
): Promise<void> => {
const response = await request.post(`${BASE_URL}/api/globals/photoAlbum`, { headers, data })
expect(response.ok(), await response.text()).toBeTruthy()
}
const setPhotoStatus = async (
request: APIRequestContext,
headers: Headers,
id: number,
publicationStatus: 'draft' | 'approved' | 'removed',
): Promise<void> => {
const response = await request.patch(`${BASE_URL}/api/archivePhoto/${id}`, {
headers,
data: { publicationStatus },
})
expect(response.ok(), await response.text()).toBeTruthy()
}
const createPhoto = async (
request: APIRequestContext,
headers: Headers,
data: {
caption: string
scene: 'plenaria' | 'visita' | 'reuniao'
people: string[]
municipality?: number
takenAt: string
status?: 'draft' | 'approved'
},
): Promise<number> => {
const marker = uniqueMarker()
const response = await request.post(`${BASE_URL}/api/archivePhoto`, {
headers,
multipart: {
_payload: JSON.stringify({
flickrId: `c233-e2e-${marker}`,
alt: `Foto de teste ${marker}: ${data.caption}`,
publicationStatus: 'draft',
takenAt: data.takenAt,
catalog: {
caption: data.caption,
description: `${data.caption} (${marker})`,
scene: data.scene,
people: data.people,
...(data.municipality ? { municipality: data.municipality } : {}),
},
}),
file: { name: `c233-${marker}.jpg`, mimeType: 'image/jpeg', buffer: await createTestJpeg() },
},
})
expect(response.ok(), await response.text()).toBeTruthy()
const id = ((await response.json()) as { doc: { id: number } }).doc.id
createdPhotoIds.push(id)
if (data.status === 'approved') await setPhotoStatus(request, headers, id, 'approved')
return id
}
const municipalityId = async (
request: APIRequestContext,
headers: Headers,
slug: string,
): Promise<number> => {
const response = await request.get(
`${BASE_URL}/api/municipality?limit=1&depth=0&where[slug][equals]=${slug}`,
{ headers },
)
expect(response.ok(), await response.text()).toBeTruthy()
const id = ((await response.json()) as { docs: { id: number }[] }).docs[0]?.id
if (id == null) throw new Error(`município ausente no seed: ${slug}`)
return id
}
/** Dynamic pages stream a transient hidden `S:` copy of the shell; wait it out. */
const waitForSettledPage = async (page: Page) => {
await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0)
}
test.describe.configure({ mode: 'serial' })
test.describe('Frontend Álbum de Fotos (C233)', () => {
let headers: Headers
let feiraId: number
let camacariId: number
let approvedFeira: number
let approvedCamacari: number
let draftPhoto: number
let removedPhoto: number
const marker = uniqueMarker()
test.beforeAll(async ({ request }) => {
await seedTestUser()
headers = await adminHeaders(request, BASE_URL)
await setAlbum(request, headers, { published: true, removalChannelUrl: REMOVAL_CHANNEL })
feiraId = await municipalityId(request, headers, 'feira-de-santana')
camacariId = await municipalityId(request, headers, camacariSlug)
approvedFeira = await createPhoto(request, headers, {
caption: `Plenária da saúde ${marker}`,
scene: 'plenaria',
people: ['Jorge Solla'],
municipality: feiraId,
takenAt: '2026-09-12 10:20:30',
status: 'approved',
})
approvedCamacari = await createPhoto(request, headers, {
caption: `Visita ao posto ${marker}`,
scene: 'visita',
people: ['Rui Costa'],
municipality: camacariId,
takenAt: '2026-09-08 09:00:00',
status: 'approved',
})
draftPhoto = await createPhoto(request, headers, {
caption: `Rascunho interno ${marker}`,
scene: 'reuniao',
people: ['Jorge Solla'],
municipality: feiraId,
takenAt: '2026-09-10 09:00:00',
})
removedPhoto = await createPhoto(request, headers, {
caption: `Removida a pedido ${marker}`,
scene: 'reuniao',
people: ['Jorge Solla'],
municipality: feiraId,
takenAt: '2026-09-11 09:00:00',
status: 'approved',
})
await setPhotoStatus(request, headers, removedPhoto, 'removed')
})
test.afterAll(async ({ request }) => {
const cleanupHeaders = await adminHeaders(request, BASE_URL).catch(() => null)
if (cleanupHeaders) {
for (const id of createdPhotoIds.splice(0)) {
await request
.delete(`${BASE_URL}/api/archivePhoto/${id}`, { headers: cleanupHeaders })
.catch(() => undefined)
}
await setAlbum(request, cleanupHeaders, {
published: true,
removalChannelUrl: REMOVAL_CHANNEL,
}).catch(() => undefined)
}
})
test('lists only approved photos with the curated context', async ({ page, request }) => {
const response = await page.goto('/fotos')
expect(response?.status()).toBe(200)
await waitForSettledPage(page)
const feiraCard = page.locator(`a[data-photo-id="${approvedFeira}"]`)
const camacariCard = page.locator(`a[data-photo-id="${approvedCamacari}"]`)
await expect(feiraCard).toBeVisible()
await expect(camacariCard).toBeVisible()
await expect(feiraCard.getByText(`Plenária da saúde ${marker}`)).toBeVisible()
await expect(feiraCard.getByText(`12 set 2026 · ${feiraName} · Plenária`)).toBeVisible()
await expect(feiraCard.getByText('Jorge Solla')).toBeVisible()
await expect(page.locator(`a[data-photo-id="${draftPhoto}"]`)).toHaveCount(0)
await expect(page.locator(`a[data-photo-id="${removedPhoto}"]`)).toHaveCount(0)
// The image is the same-origin proxy thumbnail, with the curated alt.
const thumbnail = feiraCard.locator('img')
await expect(thumbnail).toHaveAttribute('src', `/fotos/${approvedFeira}/midia?tamanho=grade`)
await expect(thumbnail).toHaveAttribute('alt', new RegExp(`^Foto de teste .*${marker}`))
const thumbnailResponse = await request.get(
`${BASE_URL}/fotos/${approvedFeira}/midia?tamanho=grade`,
)
expect(thumbnailResponse.status()).toBe(200)
expect(thumbnailResponse.headers()['content-type']).toBe('image/jpeg')
expect(thumbnailResponse.headers()['x-robots-tag']).toBe('noindex')
expect(thumbnailResponse.headers()['cache-control']).toContain('no-store')
// The draft and the removed photo answer the same silent 404 on their door.
for (const id of [draftPhoto, removedPhoto]) {
const denied = await request.get(`${BASE_URL}/fotos/${id}/midia?tamanho=grade`)
expect(denied.status()).toBe(404)
}
})
test('filters by município, atividade, pessoa and term, and shows the honest no-results', async ({
page,
}) => {
await page.goto(`/fotos?municipio=${camacariSlug}`)
await waitForSettledPage(page)
await expect(page.getByRole('heading', { name: `Fotos em ${camacariName}` })).toBeVisible()
await expect(page.locator(`a[data-photo-id="${approvedCamacari}"]`)).toBeVisible()
await expect(page.locator(`a[data-photo-id="${approvedFeira}"]`)).toHaveCount(0)
await page.goto('/fotos?atividade=plenaria')
await waitForSettledPage(page)
await expect(page.getByRole('heading', { name: 'Fotos de Plenária' })).toBeVisible()
await expect(page.locator(`a[data-photo-id="${approvedFeira}"]`)).toBeVisible()
await expect(page.locator(`a[data-photo-id="${approvedCamacari}"]`)).toHaveCount(0)
await page.goto('/fotos?pessoa=rui-costa')
await waitForSettledPage(page)
await expect(page.getByRole('heading', { name: 'Fotos com Rui Costa' })).toBeVisible()
await expect(page.locator(`a[data-photo-id="${approvedCamacari}"]`)).toBeVisible()
await page.goto(`/fotos?q=${encodeURIComponent(marker)}`)
await waitForSettledPage(page)
await expect(page.getByRole('heading', { name: `Resultados para “${marker}”` })).toBeVisible()
await expect(page.locator(`a[data-photo-id="${approvedFeira}"]`)).toBeVisible()
await page.goto(`/fotos?municipio=${camacariSlug}&atividade=plenaria`)
await waitForSettledPage(page)
const noResults = page.locator('[data-album-no-results]')
await expect(noResults.getByText('Nada encontrado com esses filtros')).toBeVisible()
await expect(noResults.getByRole('link', { name: 'Limpar filtros' })).toBeVisible()
})
test('opens the photo in the overlay and downloads it with the legible name', async ({
page,
request,
}) => {
await page.goto(`/fotos?municipio=${feiraSlug}&foto=${approvedFeira}`)
await waitForSettledPage(page)
const dialog = page.getByRole('dialog', { name: /Plenária da saúde/ })
await expect(dialog).toBeVisible()
await expect(dialog.getByText('Registro aprovado')).toBeVisible()
await expect(dialog.getByText('12 de setembro de 2026')).toBeVisible()
await expect(dialog.getByText('Feira de Santana · Bahia')).toBeVisible()
await expect(dialog.getByText('Plenária', { exact: true })).toBeVisible()
await expect(dialog.getByText('Jorge Solla', { exact: true })).toBeVisible()
await expect(dialog.getByRole('link', { name: 'Peça a remoção desta imagem' })).toHaveAttribute(
'href',
REMOVAL_CHANNEL,
)
const download = await request.get(`${BASE_URL}/fotos/${approvedFeira}/midia?download=1`)
expect(download.status()).toBe(200)
expect(download.headers()['content-disposition']).toContain('attachment')
expect(download.headers()['content-disposition']).toContain(
`jorge-solla-1313-foto-${approvedFeira}.jpg`,
)
await page.getByRole('link', { name: /Voltar ao álbum/ }).click()
await waitForSettledPage(page)
await expect(page).toHaveURL(new RegExp(`/fotos\\?municipio=${feiraSlug}$`))
await expect(page.getByRole('dialog')).toHaveCount(0)
})
test('pulls a removed photo down immediately, without a deploy', async ({ page, request }) => {
await page.goto('/fotos')
await waitForSettledPage(page)
await expect(page.locator(`a[data-photo-id="${approvedFeira}"]`)).toBeVisible()
await setPhotoStatus(request, headers, approvedFeira, 'removed')
await page.goto('/fotos')
await waitForSettledPage(page)
await expect(page.locator(`a[data-photo-id="${approvedFeira}"]`)).toHaveCount(0)
await expect(page.locator(`a[data-photo-id="${approvedCamacari}"]`)).toBeVisible()
const denied = await request.get(`${BASE_URL}/fotos/${approvedFeira}/midia?tamanho=grade`)
expect(denied.status()).toBe(404)
})
test('shows the removal channel at the foot of the album', async ({ page }) => {
await page.goto('/fotos')
await waitForSettledPage(page)
await expect(page.getByText('Aparece em alguma foto?')).toBeVisible()
await expect(page.getByRole('link', { name: 'Pedir remoção de uma foto' })).toHaveAttribute(
'href',
REMOVAL_CHANNEL,
)
await expect(page.getByRole('contentinfo').getByRole('link', { name: 'Fotos' })).toBeVisible()
})
test('flips the kill switch and closes the whole route without a deploy', async ({ request }) => {
await setAlbum(request, headers, { published: false, removalChannelUrl: REMOVAL_CHANNEL })
const state = await request.get(`${BASE_URL}/api/globals/photoAlbum`, { headers })
expect(((await state.json()) as { published: boolean }).published).toBe(false)
// Through the API context on purpose: the browser would log the document's
// own 404 and trip the console-error guard (same choice as the S27 spec).
expect((await request.get(`${BASE_URL}/fotos`)).status()).toBe(404)
await setAlbum(request, headers, { published: true, removalChannelUrl: REMOVAL_CHANNEL })
expect((await request.get(`${BASE_URL}/fotos`)).status()).toBe(200)
})
})

View file

@ -0,0 +1,277 @@
// @vitest-environment node
import { randomUUID } from 'node:crypto'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
vi.mock('next/cache', () => ({
revalidatePath: () => undefined,
revalidateTag: vi.fn(),
unstable_cache: (fn: unknown) => fn,
}))
import { GET as getArchivePhotoMedia } from '@/app/(frontend)/fotos/[id]/midia/route'
import { ARCHIVE_PHOTO_SLUG, type ArchivePhotoImport } from '@/lib/archivePhoto'
import config from '@/payload.config'
import {
getApprovedArchivePhotoById,
getApprovedArchivePhotoItems,
hasPublishedArchivePhotos,
} from '@/utilities/archivePhotos/archivePhotoReads'
import { ingestArchivePhoto } from '@/utilities/flickr/archivePhotoIngest'
import { ARCHIVE_PHOTO_JPEG_BYTES } from '../helpers/archivePhotoFixture'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
// C233 — the public album boundary over the real Payload `teqo_test`: the
// approve/draft/removed gate, the sticky removal, the removal-channel guard,
// the município snapshot the public read depends on and the public media
// route (approved 200, everything else the same silent 404).
let payload: Payload
const createdFlickrIds = new Set<string>()
const tempDirs: string[] = []
installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const CHANNEL = 'https://jorgessolla.example.org/remocao'
const record = (
flickrId: string,
overrides: Partial<ArchivePhotoImport> = {},
): ArchivePhotoImport => ({
flickrId,
sourceUrl: `https://www.flickr.com/photos/depjorgesolla/${flickrId}/`,
owner: '12345678@N00',
license: '0',
title: `Foto ${flickrId}`,
description: null,
tags: [],
takenAt: '2025-03-14 10:20:30',
postedAt: '2025-03-15T10:00:00.000Z',
albums: [],
geo: null,
exif: [],
originalUrl: `https://live.staticflickr.com/65535/${flickrId}_abcdef_o.jpg`,
originalKind: 'original',
...overrides,
})
const createPhoto = async (): Promise<{ id: number; flickrId: string }> => {
const flickrId = `c233-${randomUUID().slice(0, 8)}`
createdFlickrIds.add(flickrId)
const dir = await mkdtemp(join(tmpdir(), 'archive-photo-public-'))
tempDirs.push(dir)
const filePath = join(dir, `flickr-${flickrId}.jpg`)
await writeFile(filePath, ARCHIVE_PHOTO_JPEG_BYTES)
const created = await ingestArchivePhoto(payload, record(flickrId), { filePath })
if (created.status !== 'created') throw new Error(`fixture not created: ${created.status}`)
return { id: created.id, flickrId }
}
const setChannel = async (removalChannelUrl: string): Promise<void> => {
await payload.updateGlobal({
slug: 'photoAlbum',
data: { published: true, removalChannelUrl },
overrideAccess: true,
})
}
/** Published is false here on purpose: the guard refuses an open album with no channel. */
const closeAlbumAndClearChannel = async (): Promise<void> => {
await payload.updateGlobal({
slug: 'photoAlbum',
data: { published: false, removalChannelUrl: null },
overrideAccess: true,
})
}
const approve = async (id: number): Promise<void> => {
await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id,
data: { publicationStatus: 'approved' },
overrideAccess: true,
})
}
const statusOf = async (id: number): Promise<string> => {
const doc = await payload.findByID({
collection: ARCHIVE_PHOTO_SLUG,
id,
depth: 0,
overrideAccess: true,
})
return doc.publicationStatus
}
describe('public photo album reads (C233)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
await setChannel(CHANNEL)
})
afterAll(async () => {
for (const flickrId of createdFlickrIds) {
await payload.delete({
collection: ARCHIVE_PHOTO_SLUG,
where: { flickrId: { equals: flickrId } },
overrideAccess: true,
})
}
await closeAlbumAndClearChannel()
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
})
it('shows only approved photos and keeps draft/removed invisible', async () => {
const draft = await createPhoto()
const approved = await createPhoto()
const removed = await createPhoto()
await approve(approved.id)
await approve(removed.id)
await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id: removed.id,
data: { publicationStatus: 'removed' },
overrideAccess: true,
})
const items = await getApprovedArchivePhotoItems()
const ids = items.map((item) => item.id)
expect(ids).toContain(approved.id)
expect(ids).not.toContain(draft.id)
expect(ids).not.toContain(removed.id)
expect(await getApprovedArchivePhotoById(approved.id)).not.toBeNull()
expect(await getApprovedArchivePhotoById(draft.id)).toBeNull()
expect(await getApprovedArchivePhotoById(removed.id)).toBeNull()
expect(await getApprovedArchivePhotoById(999_999_999)).toBeNull()
expect(await hasPublishedArchivePhotos()).toBe(true)
})
it('refuses to approve without a valid removal channel and never lets the pipeline flip the status', async () => {
const photo = await createPhoto()
await closeAlbumAndClearChannel()
await expect(approve(photo.id)).rejects.toThrow()
expect(await statusOf(photo.id)).toBe('draft')
// The global guard is the other half: an open album cannot lose its channel.
await expect(
payload.updateGlobal({
slug: 'photoAlbum',
data: { published: true, removalChannelUrl: null },
overrideAccess: true,
}),
).rejects.toThrow()
await setChannel(CHANNEL)
await approve(photo.id)
// The cataloguing write path carries its own context and never touches the
// publication state.
await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id: photo.id,
data: { catalog: { caption: 'Legenda recatalogada', catalogedAt: new Date().toISOString() } },
context: { archivePhotoCatalog: true },
overrideAccess: true,
})
expect(await statusOf(photo.id)).toBe('approved')
// `removed` is sticky: a system write on top keeps it removed.
await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id: photo.id,
data: { publicationStatus: 'removed' },
overrideAccess: true,
})
await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id: photo.id,
data: { catalog: { caption: 'Legenda de novo', catalogedAt: new Date().toISOString() } },
context: { archivePhotoCatalog: true },
overrideAccess: true,
})
expect(await statusOf(photo.id)).toBe('removed')
expect(await getApprovedArchivePhotoById(photo.id)).toBeNull()
})
it('snapshots the município name/slug on the row so the public read never touches the campaign-only collection', async () => {
const photo = await createPhoto()
const municipality = await payload.find({
collection: 'municipality',
where: { slug: { equals: 'feira-de-santana' } },
depth: 0,
limit: 1,
overrideAccess: true,
})
const municipalityId = municipality.docs[0]?.id
expect(municipalityId).toBeDefined()
const updated = await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id: photo.id,
data: { catalog: { municipality: municipalityId } },
overrideAccess: true,
})
expect(updated.municipalityName).toBe('Feira de Santana')
expect(updated.municipalitySlug).toBe('feira-de-santana')
await approve(photo.id)
const item = await getApprovedArchivePhotoById(photo.id)
expect(item?.municipalityName).toBe('Feira de Santana')
expect(item?.municipalitySlug).toBe('feira-de-santana')
})
it('serves the approved photo through the media route and 404s every other state', async () => {
const approved = await createPhoto()
await approve(approved.id)
const draft = await createPhoto()
const thumbnail = await getArchivePhotoMedia(
new Request(`http://localhost/fotos/${approved.id}/midia?tamanho=grade`),
{ params: Promise.resolve({ id: String(approved.id) }) },
)
expect(thumbnail.status).toBe(200)
expect(thumbnail.headers.get('Content-Type')).toBe('image/jpeg')
expect(thumbnail.headers.get('X-Robots-Tag')).toBe('noindex')
const download = await getArchivePhotoMedia(
new Request(`http://localhost/fotos/${approved.id}/midia?download=1`),
{ params: Promise.resolve({ id: String(approved.id) }) },
)
expect(download.status).toBe(200)
expect(download.headers.get('Content-Disposition')).toContain('attachment')
expect(download.headers.get('Content-Disposition')).toContain(
`jorge-solla-1313-foto-${approved.id}`,
)
for (const id of [draft.id, 999_999_999]) {
const response = await getArchivePhotoMedia(
new Request(`http://localhost/fotos/${id}/midia`),
{
params: Promise.resolve({ id: String(id) }),
},
)
expect(response.status).toBe(404)
expect(response.headers.get('X-Robots-Tag')).toBe('noindex')
}
const garbage = await getArchivePhotoMedia(new Request('http://localhost/fotos/x/midia'), {
params: Promise.resolve({ id: 'x' }),
})
expect(garbage.status).toBe(404)
})
})

View file

@ -2,11 +2,16 @@ import { describe, expect, it } from 'vitest'
import {
ARCHIVE_PHOTO_CAPTION_MAX_LENGTH,
ARCHIVE_PHOTO_PUBLICATION_STATUSES,
archivePhotoIsPublic,
archivePhotoPublicationStatusLabels,
archivePhotoSearchText,
archivePhotoTakenOn,
archivePhotoThemesFrom,
buildArchivePhotoCatalogWrite,
changedArchivePhotoCuratedFields,
isArchivePhotoPublicationStatus,
isArchivePhotoRemovalChannelUrl,
matchPublicFigureMentions,
parseArchiveVisionOutput,
resolveArchivePhotoScene,
@ -355,3 +360,35 @@ describe('archivePhotoSearchText (C232)', () => {
expect(archivePhotoSearchText({})).toBe('')
})
})
// C233 — the publication gate appended to the same contract: the closed status
// vocabulary (fail-closed predicate) and the removal-channel validator that
// guards the album's approval path. No Payload here.
describe('archive photo publication (C233)', () => {
it('only approves the exact approved state', () => {
expect(ARCHIVE_PHOTO_PUBLICATION_STATUSES).toEqual(['draft', 'approved', 'removed'])
expect(archivePhotoPublicationStatusLabels).toEqual({
draft: 'Rascunho',
approved: 'Aprovada',
removed: 'Removida',
})
expect(isArchivePhotoPublicationStatus('approved')).toBe(true)
expect(isArchivePhotoPublicationStatus('publicado')).toBe(false)
expect(archivePhotoIsPublic({ publicationStatus: 'approved' })).toBe(true)
expect(archivePhotoIsPublic({ publicationStatus: 'draft' })).toBe(false)
expect(archivePhotoIsPublic({ publicationStatus: 'removed' })).toBe(false)
expect(archivePhotoIsPublic({})).toBe(false)
})
it('accepts only absolute http(s) or mailto removal channels', () => {
expect(isArchivePhotoRemovalChannelUrl('https://jorgesolla1313.com.br/contato')).toBe(true)
expect(isArchivePhotoRemovalChannelUrl('http://localhost:3000/remocao')).toBe(true)
expect(isArchivePhotoRemovalChannelUrl('mailto:acervo@example.org')).toBe(true)
expect(isArchivePhotoRemovalChannelUrl(' mailto:acervo@example.org ')).toBe(true)
expect(isArchivePhotoRemovalChannelUrl('javascript:alert(1)')).toBe(false)
expect(isArchivePhotoRemovalChannelUrl('/contato')).toBe(false)
expect(isArchivePhotoRemovalChannelUrl('')).toBe(false)
expect(isArchivePhotoRemovalChannelUrl(null)).toBe(false)
})
})

View file

@ -0,0 +1,333 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { ARCHIVE_PHOTO_SCENES } from '@/lib/archivePhotoCatalog'
import {
ARCHIVE_PHOTO_ALBUM_PAGE_SIZE,
ARCHIVE_PHOTO_ALBUM_PATH,
archivePhotoAlbumActiveFilters,
archivePhotoAlbumFacets,
archivePhotoAlbumHeading,
archivePhotoLongDateLabel,
archivePhotoShortDateLabel,
buildArchivePhotoAlbumHref,
filterArchivePhotoAlbumItems,
paginateArchivePhotoAlbumItems,
parseArchivePhotoAlbumParams,
toArchivePhotoPublicItem,
type ArchivePhotoPublicItem,
type ArchivePhotoPublicSource,
} from '@/lib/archivePhotoPublicCatalog'
// C233 — the pure contract of the public album: the single-value URL parse
// (fail-closed), the canonical href, the facet derivation over approved items
// only, the AND filtering with the term and the contextual heading.
const source = (
id: number,
overrides: Partial<ArchivePhotoPublicSource> = {},
): ArchivePhotoPublicSource => ({
id,
alt: `Descrição da foto ${id}`,
takenOn: '2026-09-12T12:00:00.000Z',
municipalityName: 'Feira de Santana',
municipalitySlug: 'feira-de-santana',
filename: `flickr-${id}.jpg`,
publicationStatus: 'approved',
searchText: 'plenaria saude feira de santana',
catalog: {
caption: `Legenda ${id}`,
scene: 'plenaria',
people: ['Jorge Solla'],
},
...overrides,
})
const item = (id: number, overrides: Partial<ArchivePhotoPublicSource> = {}) =>
toArchivePhotoPublicItem(source(id, overrides))!
describe('parseArchivePhotoAlbumParams', () => {
it('keeps one valid value per facet and drops hand-typed garbage', () => {
expect(
parseArchivePhotoAlbumParams({
data: '2026-09-12',
municipio: 'feira-de-santana',
atividade: 'plenaria',
pessoa: 'jorge-solla',
q: ' saúde ',
pagina: '3',
foto: '42',
}),
).toEqual({
data: '2026-09-12',
municipio: 'feira-de-santana',
atividade: 'plenaria',
pessoa: 'jorge-solla',
q: 'saúde',
pagina: 3,
foto: 42,
})
})
it('fails each param closed on its own', () => {
const parsed = parseArchivePhotoAlbumParams({
data: '2026-02-30',
municipio: '../etc/passwd',
atividade: 'comicio-inventado',
pessoa: 'Jorge Solla',
pagina: '0',
foto: 'abc',
})
expect(parsed.data).toBeNull()
expect(parsed.municipio).toBeNull()
expect(parsed.atividade).toBeNull()
expect(parsed.pessoa).toBeNull()
expect(parsed.pagina).toBe(1)
expect(parsed.foto).toBeNull()
})
it('accepts every declared scene value and its pt-BR label', () => {
for (const scene of ARCHIVE_PHOTO_SCENES) {
expect(parseArchivePhotoAlbumParams({ atividade: scene.value }).atividade).toBe(scene.value)
expect(parseArchivePhotoAlbumParams({ atividade: scene.label }).atividade).toBe(scene.value)
}
})
})
describe('buildArchivePhotoAlbumHref', () => {
it('serializes the facets in canonical order, drops pagina=1 and clean params', () => {
expect(
buildArchivePhotoAlbumHref({
data: '2026-09-12',
municipio: 'feira-de-santana',
atividade: 'plenaria',
pessoa: 'jorge-solla',
q: 'saúde',
pagina: 1,
}),
).toBe(
'/fotos?data=2026-09-12&municipio=feira-de-santana&atividade=plenaria&pessoa=jorge-solla&q=sa%C3%BAde',
)
expect(buildArchivePhotoAlbumHref({})).toBe(ARCHIVE_PHOTO_ALBUM_PATH)
})
it('includes foto only when asked (the overlay link) and keeps it out of the canonical form', () => {
expect(buildArchivePhotoAlbumHref({ municipio: 'feira-de-santana', foto: 7 })).toBe(
'/fotos?municipio=feira-de-santana&foto=7',
)
expect(buildArchivePhotoAlbumHref({ municipio: 'feira-de-santana', foto: null })).toBe(
'/fotos?municipio=feira-de-santana',
)
})
})
describe('date labels', () => {
it('derives the long and short pt-BR labels from the stored noon-UTC day', () => {
expect(archivePhotoLongDateLabel('2026-09-12T12:00:00.000Z')).toBe('12 de setembro de 2026')
expect(archivePhotoShortDateLabel('2026-09-12T12:00:00.000Z')).toBe('12 set 2026')
expect(archivePhotoLongDateLabel(null)).toBeNull()
expect(archivePhotoShortDateLabel('sem data')).toBeNull()
})
})
describe('toArchivePhotoPublicItem', () => {
it('fails closed for any status that is not approved', () => {
expect(toArchivePhotoPublicItem(source(1, { publicationStatus: 'draft' }))).toBeNull()
expect(toArchivePhotoPublicItem(source(1, { publicationStatus: 'removed' }))).toBeNull()
expect(toArchivePhotoPublicItem(source(1, { publicationStatus: null }))).toBeNull()
expect(toArchivePhotoPublicItem(source(1, { publicationStatus: undefined }))).toBeNull()
})
it('builds the card/dialog view: title, meta line and people label', () => {
const photo = item(1, {
catalog: { caption: null, scene: 'reuniao', people: ['Jorge Solla', 'Rui Costa'] },
})
expect(photo.title).toBe('Descrição da foto 1')
expect(photo.metaLabel).toBe('12 set 2026 · Feira de Santana · Reunião')
expect(photo.peopleLabel).toBe('Jorge Solla e Rui Costa')
expect(photo.dateLabel).toBe('12 de setembro de 2026')
})
it('derives the paths and a legible download name, never the stored key', () => {
const photo = item(9)
expect(photo.thumbnailPath).toBe('/fotos/9/midia?tamanho=grade')
expect(photo.mediaPath).toBe('/fotos/9/midia')
expect(photo.downloadPath).toBe('/fotos/9/midia?download=1')
expect(photo.downloadFilename).toBe('jorge-solla-1313-foto-9.jpg')
})
it('canonicalizes a catalog spelling on the people facet and collapses duplicates', () => {
const photo = item(1, {
catalog: { caption: null, scene: 'plenaria', people: ['rui costa', 'Rui Costa'] },
})
expect(photo.people).toEqual(['Rui Costa'])
})
})
describe('archivePhotoAlbumFacets / filter / heading', () => {
const items = [
item(1),
item(2, {
takenOn: '2026-09-08T12:00:00.000Z',
municipalityName: 'Salvador',
municipalitySlug: 'salvador',
searchText: 'visita posto salvador rui costa',
catalog: { caption: 'Visita', scene: 'visita', people: ['Rui Costa'] },
}),
item(3, {
takenOn: null,
municipalityName: null,
municipalitySlug: null,
searchText: 'retrato gabinete',
catalog: { caption: 'Retrato', scene: 'retrato', people: [] },
}),
]
it('derives facets from the approved items only, in their own orders', () => {
const facets = archivePhotoAlbumFacets(items)
expect(facets.data.map((option) => option.value)).toEqual(['2026-09-12', '2026-09-08'])
expect(facets.municipio).toEqual([
{ value: 'feira-de-santana', label: 'Feira de Santana' },
{ value: 'salvador', label: 'Salvador' },
])
expect(facets.atividade.map((option) => option.value)).toEqual([
'plenaria',
'visita',
'retrato',
])
expect(facets.pessoa).toEqual([
{ value: 'jorge-solla', label: 'Jorge Solla' },
{ value: 'rui-costa', label: 'Rui Costa' },
])
})
it('combines every facet as AND and matches the term over the haystack', () => {
const params = parseArchivePhotoAlbumParams({})
expect(filterArchivePhotoAlbumItems(items, params)).toHaveLength(3)
expect(
filterArchivePhotoAlbumItems(items, { ...params, municipio: 'salvador' }).map((i) => i.id),
).toEqual([2])
expect(
filterArchivePhotoAlbumItems(items, { ...params, atividade: 'plenaria' }).map((i) => i.id),
).toEqual([1])
expect(
filterArchivePhotoAlbumItems(items, { ...params, pessoa: 'rui-costa' }).map((i) => i.id),
).toEqual([2])
expect(
filterArchivePhotoAlbumItems(items, { ...params, data: '2026-09-08' }).map((i) => i.id),
).toEqual([2])
// A day filter never matches a photo without `takenOn`.
expect(filterArchivePhotoAlbumItems(items, { ...params, data: '2026-01-01' })).toHaveLength(0)
// The term uses the normalized haystack (accent/case insensitive) and does
// not surface items whose haystack lacks it.
expect(filterArchivePhotoAlbumItems(items, { ...params, q: 'SAÚDE' }).map((i) => i.id)).toEqual(
[1],
)
expect(
filterArchivePhotoAlbumItems(items, { ...params, q: 'gabinete' }).map((i) => i.id),
).toEqual([3])
// Combining facets narrows.
expect(
filterArchivePhotoAlbumItems(items, {
...params,
municipio: 'salvador',
atividade: 'visita',
pessoa: 'rui-costa',
}).map((i) => i.id),
).toEqual([2])
})
it('lists the active filters in canonical order and removal never keeps the overlay id', () => {
const params = parseArchivePhotoAlbumParams({
data: '2026-09-12',
municipio: 'salvador',
atividade: 'visita',
pessoa: 'rui-costa',
q: 'saúde',
foto: '2',
})
const filters = archivePhotoAlbumActiveFilters(params, archivePhotoAlbumFacets(items))
expect(filters.map((filter) => filter.facet)).toEqual([
'data',
'municipio',
'atividade',
'pessoa',
'q',
])
expect(filters[0]!.removeHref).toBe(
'/fotos?municipio=salvador&atividade=visita&pessoa=rui-costa&q=sa%C3%BAde',
)
expect(filters[4]!.removeHref).toBe(
'/fotos?data=2026-09-12&municipio=salvador&atividade=visita&pessoa=rui-costa',
)
})
it('heads with the first criterion in the fixed precedence', () => {
const facets = archivePhotoAlbumFacets(items)
const params = parseArchivePhotoAlbumParams({})
expect(archivePhotoAlbumHeading(params, facets)).toBe('Fotos da nossa caminhada')
expect(archivePhotoAlbumHeading({ ...params, q: 'saúde' }, facets)).toBe(
'Resultados para “saúde”',
)
expect(archivePhotoAlbumHeading({ ...params, data: '2026-09-12' }, facets)).toBe(
'Fotos de 12 de setembro de 2026',
)
expect(archivePhotoAlbumHeading({ ...params, pessoa: 'rui-costa' }, facets)).toBe(
'Fotos com Rui Costa',
)
expect(archivePhotoAlbumHeading({ ...params, atividade: 'plenaria' }, facets)).toBe(
'Fotos de Plenária',
)
expect(
archivePhotoAlbumHeading(
{ ...params, municipio: 'salvador', atividade: 'plenaria', q: 'saúde' },
facets,
),
).toBe('Fotos em Salvador')
})
})
describe('paginateArchivePhotoAlbumItems', () => {
const many: ArchivePhotoPublicItem[] = Array.from(
{ length: ARCHIVE_PHOTO_ALBUM_PAGE_SIZE + 3 },
(_, index) => item(index + 1),
)
it('slices 24 per page, clamps the page and reports the neighbours', () => {
const first = paginateArchivePhotoAlbumItems(many, 1)
expect(first.items).toHaveLength(ARCHIVE_PHOTO_ALBUM_PAGE_SIZE)
expect(first.pageCount).toBe(2)
expect(first.hasPrevious).toBe(false)
expect(first.hasNext).toBe(true)
const second = paginateArchivePhotoAlbumItems(many, 2)
expect(second.items).toHaveLength(3)
expect(second.hasPrevious).toBe(true)
expect(second.hasNext).toBe(false)
const beyond = paginateArchivePhotoAlbumItems(many, 99)
expect(beyond.page).toBe(2)
expect(beyond.hasNext).toBe(false)
})
it('keeps pageCount at 1 with zero items', () => {
const empty = paginateArchivePhotoAlbumItems([], 4)
expect(empty).toEqual({
page: 1,
pageCount: 1,
items: [],
hasPrevious: false,
hasNext: false,
})
})
})

View file

@ -64,6 +64,13 @@ vi.mock('@/utilities/content/contentPieceReads', () => ({
getPublishedContentPieceItems: async () => contentPieces.items,
}))
// C233 — the footer's "Fotos" discovery flag reads the approved archive photos
// through the Payload DB; the unit env has no database and the real listing is
// e2e-covered (frontendFotos.e2e.spec.ts).
vi.mock('@/utilities/archivePhotos/archivePhotoReads', () => ({
hasPublishedArchivePhotos: async () => false,
}))
// S14 — the card section renders the client studio island (next/font local
// face + matchMedia + canvas); its behavior is e2e-covered, so the unit
// skeleton mocks both the face module and the island.

View file

@ -79,6 +79,10 @@ describe('E2E_AFFECTED_MANIFEST (OPS5)', () => {
// contract and the migration makes every PR of this delivery high-risk
// (curated only).
'campaignSpeechAcervo',
// C233 — deliberate: the public photo album (approval gate, facets,
// overlay, media route) is a new contract and the migration makes every
// PR of this delivery high-risk (curated only).
'frontendFotos',
])
const onDisk = new Set(specNamesOnDisk())
for (const spec of E2E_CURATED_SPECS) {

View file

@ -52,6 +52,7 @@ describe('shareLink slug', () => {
'conteudos',
'corte',
'evento',
'fotos',
'jingles',
'mandato-no-whatsapp',
'noticia',