mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
feat(C233): álbum público de fotos com busca por data, local, atividade e pessoa pública
This commit is contained in:
parent
671af1e21f
commit
1dd70b0647
53 changed files with 24502 additions and 195 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
1
docs/changelog/2026-09-26-c233.md
Normal file
1
docs/changelog/2026-09-26-c233.md
Normal 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`.
|
||||
195
docs/plans/album-fotos-publico-busca-impl.md
Normal file
195
docs/plans/album-fotos-publico-busca-impl.md
Normal 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).
|
||||
|
|
@ -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&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&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&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&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&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&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&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&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&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&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&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&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=<id> · 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 <município></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 <atividade></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 <pessoa></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 <data por extenso></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 “<termo>”</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=<id></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>
|
||||
|
|
|
|||
|
|
@ -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/,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
73
src/app/(frontend)/fotos/[id]/midia/route.ts
Normal file
73
src/app/(frontend)/fotos/[id]/midia/route.ts
Normal 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
|
||||
}
|
||||
18
src/app/(frontend)/fotos/layout.tsx
Normal file
18
src/app/(frontend)/fotos/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
173
src/app/(frontend)/fotos/page.tsx
Normal file
173
src/app/(frontend)/fotos/page.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
32
src/components/CampaignLogoLink.tsx
Normal file
32
src/components/CampaignLogoLink.tsx
Normal 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>
|
||||
)
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
59
src/components/fotos/ArchivePhotoCard.tsx
Normal file
59
src/components/fotos/ArchivePhotoCard.tsx
Normal 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>
|
||||
)
|
||||
192
src/components/fotos/ArchivePhotoFilters.tsx
Normal file
192
src/components/fotos/ArchivePhotoFilters.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
196
src/components/fotos/ArchivePhotoFiltersSheet.tsx
Normal file
196
src/components/fotos/ArchivePhotoFiltersSheet.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
73
src/components/fotos/ArchivePhotoGrid.tsx
Normal file
73
src/components/fotos/ArchivePhotoGrid.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
34
src/components/fotos/ArchivePhotoHero.tsx
Normal file
34
src/components/fotos/ArchivePhotoHero.tsx
Normal 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>
|
||||
)
|
||||
174
src/components/fotos/ArchivePhotoLightbox.tsx
Normal file
174
src/components/fotos/ArchivePhotoLightbox.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
src/components/fotos/ArchivePhotoPageHeader.tsx
Normal file
40
src/components/fotos/ArchivePhotoPageHeader.tsx
Normal 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>
|
||||
)
|
||||
25
src/components/fotos/ArchivePhotoRemovalBand.tsx
Normal file
25
src/components/fotos/ArchivePhotoRemovalBand.tsx
Normal 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>
|
||||
)
|
||||
72
src/components/fotos/ArchivePhotoStates.tsx
Normal file
72
src/components/fotos/ArchivePhotoStates.tsx
Normal 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>
|
||||
)
|
||||
30
src/components/fotos/archivePhotoClasses.ts
Normal file
30
src/components/fotos/archivePhotoClasses.ts
Normal 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)
|
||||
|
|
@ -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
81
src/globals/PhotoAlbum.ts
Normal 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.',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
482
src/lib/archivePhotoPublicCatalog.ts
Normal file
482
src/lib/archivePhotoPublicCatalog.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ export const SHARE_LINK_RESERVED_SLUGS = [
|
|||
'abaixo-assinado',
|
||||
'jingles',
|
||||
'conteudos',
|
||||
'fotos',
|
||||
'api',
|
||||
'admin',
|
||||
'noticia',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
20440
src/migrations/20260926_163248_add_archive_photo_publication.json
Normal file
20440
src/migrations/20260926_163248_add_archive_photo_publication.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
75
src/utilities/archivePhotos/archivePhotoReads.ts
Normal file
75
src/utilities/archivePhotos/archivePhotoReads.ts
Normal 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
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
346
tests/e2e/frontendFotos.e2e.spec.ts
Normal file
346
tests/e2e/frontendFotos.e2e.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
277
tests/int/archivePhotoPublicRead.int.spec.ts
Normal file
277
tests/int/archivePhotoPublicRead.int.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
333
tests/unit/archivePhotoPublicCatalog.unit.spec.ts
Normal file
333
tests/unit/archivePhotoPublicCatalog.unit.spec.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ describe('shareLink slug', () => {
|
|||
'conteudos',
|
||||
'corte',
|
||||
'evento',
|
||||
'fotos',
|
||||
'jingles',
|
||||
'mandato-no-whatsapp',
|
||||
'noticia',
|
||||
|
|
|
|||
Loading…
Reference in a new issue