mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-15 02:23:07 +00:00
feat(C216): terceira fonte do acervo — falas na internet (Closes #1292)
This commit is contained in:
parent
13b5a3c1d1
commit
425361d278
46 changed files with 3203 additions and 226 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -60,6 +60,7 @@ next-env.d.ts
|
|||
/reelMedia
|
||||
/recordingMedia
|
||||
/contentMedia
|
||||
/internetSpeechMedia
|
||||
|
||||
# TSE open-data zip cache (pnpm db:seed:tse)
|
||||
/data/tse/
|
||||
|
|
|
|||
1
docs/changelog/2026-09-24-c216.md
Normal file
1
docs/changelog/2026-09-24-c216.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C216 — Terceira fonte do acervo: "Falas na internet" (2026-09-24):** o alternador do acervo passa a ter três fontes — "Falas da Câmara", "Gravações enviadas" e "Falas na internet" — e a terceira abre sobre o catálogo web do C215 com a paridade de busca e filtros da Câmara: busca textual com destaque, `mode=termo|tema`, ano, tema, alcance, duração, municípios citados, contagem, paginação e ordenação. Contrato sem migration e sem twin: o vocabulário de fonte saiu de `recordingListUrl` para o dono puro `lib/acervoSource` (3 vias, desconhecido→Câmara, fail-closed) e o de ordenação para `lib/acervoListSort` (2º consumidor do vocabulário do C219, com o mapeamento de Payload por domínio); `speechListUrl` ganha `source?: 'internet'` e `sort?` de forma **aditiva** (os bytes canônicos da Câmara ficam intocados, a fase é ignorada na web) e `speechListFilters` deriva `origin: web` do estado, mantendo a fase só na Câmara e o gate de duração (`durationSeconds exists`) nos sorts de duração. Loaders e view models ficam nos donos (`speechPageData` ganha `loadWebSpeechAcervoPageData`/`loadWebSpeechDetailPageData`/`loadWebSpeechTitleForActor` e opções de filtro por origem; `speechViewModels` ganha a família `WebSpeech*`) e o detalhe da Câmara passa a filtrar `origin: camara` — um id web vira 404 honesto, e vice-versa. Rotas novas em `/campanha/comunicacao/acervo/internet/[id]` (+ `/arquivo` e `/capa`), precedente `gravacoes/[id]`: a mídia espelhada privada é servida pelo dono `buildPrivateMediaResponse` com gate `canReadCommunicationCatalog`, range/416/404 silencioso e **nome de download derivado do título** (`webSpeechDownloadFilename` fecha o defer S4 do C215); a capa usa a mesma rota autenticada (nunca `/api/media/file`) e o card sem thumbnail renderiza "SEM CAPA". UI (Impeccable C, porte do design aprovado `docs/plans/acervo-fonte-falas-web-ui-design.html`, estendido pelo `designer` na cena 05 com o player de áudio e os chips de plataforma Instagram/Áudio): `AcervoSourceToggle` vira shell de 3 abas em `shared/` (grid de rótulos curtos no mobile), `AcervoSortSelect` compartilhado (serializador escolhido pelo discriminador de fonte dentro do client component — função nunca cruza a fronteira server→client) e `CampaignTranscriptSegmentButton` movido para `shared/` com tipo estrutural; `SpeechAcervoFilters` ganha props de copy (defaults exatos da Câmara, sem Fase na web), `WebSpeechResultCard/List` portam a cena 01 e `WebSpeechDetailPlayer` a 02/05 (vídeo ou áudio nativo no mesmo palco com o badge "Arquivo espelhado · privado", "Baixar", "Abrir na origem", box de origem com plataforma + canal/autor + data e transcrição clicável que posiciona o player). Verificação: unit (`acervoSource`, `acervoListSort`, URL/filtros/omnibox/VMs web e `webSpeechDownloadFilename`, com os pins da Câmara intocados), int `webSpeechAcervo` (lista só web, facetas, ordenação, tema injetado, rotas privadas no VM, 404 cruzado entre origens, gate de papéis), e2e do `campaignSpeechAcervo` estendido (switcher, card com pill/capa/placeholder, facetas + vazio honesto, ordenação com hint, detalhe com player/transcrição/download/origem, `arquivo`/`capa` com range/download nomeado/negação) e manifest e2e com os vocabulários e shells novos; `pnpm gate:fast`, `pnpm test`, `pnpm build` e os e2e locais (`--workers=1`). Crítica final do `designer` (trigger c) registrada no PR.
|
||||
223
docs/plans/acervo-fonte-falas-web-impl.md
Normal file
223
docs/plans/acervo-fonte-falas-web-impl.md
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
# Impl: C216 — Terceira fonte do acervo: "Falas na internet"
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-09-24
|
||||
Issue: #1292
|
||||
Intenção: docs/plans/acervo-fonte-falas-web.md
|
||||
Appetite restante: herdado (~3–4 dias eng; um outcome verificável — a assessoria alterna para "Falas na internet" no mesmo acervo, busca/filtra com a paridade da Câmara e assiste/baixa a fala encontrada, sabendo de onde ela veio)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** a terceira fonte do acervo existe e é navegável: alternador de três fontes no `/campanha/comunicacao/acervo`; "Falas na internet" com busca textual com destaque, `mode=termo|tema`, ano, tema, alcance, duração e municípios citados (paridade no que a fonte tem), contagem e paginação; o detalhe da fala abre a mídia espelhada privada com player, transcrição clicável por segmento, "Baixar", "Abrir na origem" e atribuição (plataforma + canal/autor + data); chip de plataforma na lista e capa da origem (ou placeholder). Gate fail-closed do acervo; mídia nunca pública; mobile utilizável.
|
||||
- **O que NÃO negociar:**
|
||||
- Gate do acervo fail-closed (`requireCampaignPageActor({ gate: 'communicationCatalog' })` na página + `canReadCommunicationCatalog` na rota de mídia); `advisor`/`leader` negados com `404` silencioso, nunca `403` que confirma existência.
|
||||
- **Contrato da Câmara congelado em bytes**: `q|mode|year|topic|scope|phase|municipality|duration|page` continua parseando/serializando exatamente como hoje; nenhum comportamento da Câmara muda (lista, barra, vazio, detalhe, cortes, Sollinha, cobertura).
|
||||
- Mídia espelhada jamais pública: nunca `/api/media/file`; serving só pela rota autenticada sob `/campanha`.
|
||||
- Sem segundo cadastro de pessoa, sem `Consent` novo, sem escrita (a fonte é read-only), sem item de nav novo.
|
||||
- `mode=tema` mantém a degradação honesta (aviso + busca literal) e a expansão só roda depois do gate.
|
||||
- **O que reavaliar (hipóteses do plano de intenção vs. código):**
|
||||
- `parseAcervoSource`/`AcervoSource` hoje vivem no contrato das **gravações** (`recordingListUrl.ts:44,107`), mas a fonte nova é do domínio `speech`. Importar o vocabulário de fonte de `recordings` dentro de `speech*` criaria **ciclo** (`recordings` já importa `speechListUrl`/`speechListFilters`). O vocabulário compartilhado do acervo sai para `src/lib/acervoSource.ts` (D1).
|
||||
- `RecordingSortSelect`/`RECORDING_SORT_OPTIONS` (C219) são o 3º consumidor antecipado no próprio C219 ("extrair quando C216 chegar — 3º consumidor"): a ordenação vira vocabulário puro compartilhado (D2).
|
||||
- `loadSpeechDetailPageData` não filtra `origin` hoje (`speechPageData.ts:266-275`): um id web em `/acervo/[id]` renderizaria o player da Câmara quebrado — fechar é parte desta entrega (D3).
|
||||
- `clearSpeechOmnibox` descarta tudo (`speechOmnibox.ts:274-276`), inclusive um futuro `source`: sem o ajuste, "limpar" uma busca web cairia na Câmara (D6).
|
||||
- O defer S4 do C215 ("nome do arquivo espelhado genérico (`source.mp4`) — gatilho: C216 define o nome de download") vence agora (D4).
|
||||
- A variante visual de player para **áudio** e as cores dos chips das plataformas **Instagram/Áudio** não existem no artefato aprovado (que cobre vídeo + YouTube/Rádio): trigger (a) do `designer` antes do markup (D6/§Design).
|
||||
- O artefato não mostra `/acervo/internet` como rota de lista: a fonte web é `?source=internet` no mesmo alternador (gate confirmou), e só o detalhe ganha segmento próprio (`internet`) — como o C199 fez com `gravacoes` (D3).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
SW["alternador 3 fontes<br/>?source=internet"] --> URL["speechListUrl (dono)<br/>q|mode|year|topic|scope|municipality|duration + source/sort aditivos"]
|
||||
URL --> WH["buildSpeechListWhere<br/>origin = source internet ? web : camara"]
|
||||
WH --> FIND["payload.find speech<br/>user · overrideAccess:false"]
|
||||
FIND --> VM["WebSpeech*ViewModel<br/>excerpt + plataforma + capa"]
|
||||
VM --> LIST["WebSpeechResultList<br/>mark · chips · contagem · AcervoSortSelect"]
|
||||
|
||||
DETURL["/acervo/internet/[id]"] --> DET["loadWebSpeechDetailPageData<br/>origin web"]
|
||||
DET --> PLAY["WebSpeechDetailPlayer<br/>Arquivo espelhado · privado"]
|
||||
PLAY --> ARQ["internet/[id]/arquivo<br/>buildPrivateMediaResponse · Baixar"]
|
||||
PLAY --> CAPA["internet/[id]/capa<br/>capa privada"]
|
||||
DET --> ORIG["Origem: plataforma · canal/autor · Abrir na origem"]
|
||||
|
||||
LIB["src/lib/acervoSource + src/lib/acervoListSort"] -.-> URL
|
||||
LIB -.-> WH
|
||||
CAM["superfícies Câmara<br/>origin: camara (blindagem C215)"] -.-> FIND
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) a fonte web como **dimensão do contrato da fala** (estender `speechListUrl`/`speechListFilters`/`speechOmnibox`/`speechPageData`/`speechViewModels` com `source`), detalhe em rota própria e mídia privada por fala reusando `buildPrivateMediaResponse` | B) um contrato gêmeo da fonte web (`webSpeechListUrl`, `webSpeechOmnibox`, `webSpeechPageData`, `webSpeechViewModels`, `webSpeechListFilters`) | C) uma rota de lista separada por fonte (e um detalhe despachado dentro de `/acervo/[id]`).
|
||||
**Recomendação:** A — o material é a **mesma unidade de catálogo** (`speech`, as mesmas facetas, os mesmos segmentos, a mesma busca trigram, o mesmo `SpeechAcervoFilters` pode ser reusado porque o estado é o mesmo tipo) e a intenção cortou explicitamente a lista separada; a "fonte" já é dimensão do contrato em `recordingListUrl` (`?source=enviadas`) desde o C199. O que é genuinamente novo (rota do detalhe, mídia privada por fala, card com capa/plataforma, ordenação) entra no dono do concern, sem twin.
|
||||
**Rejeitadas:** B porque duplica parse/serialização do MESMO par `collection`×facetas, e forçaria gêmeos de omnibox, filtros e loaders — a doutrina "edit the owner, don't twin" e o critério real da intenção ("sem renomear/quebrar params existentes") não pedem um segundo contrato, pedem params aditivos; C porque fragmenta o acervo (anti-goal confirmado no gate) e misturar os dois details acoplados em `/acervo/[id]` arriscaria o player/VOD/cortes da Câmara.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
**D1 — A fonte web é dimensão do contrato da fala; o vocabulário de fonte sai para `src/lib/acervoSource.ts`.**
|
||||
`Decisão: A — speechListUrl ganha source?: 'internet' (só serializa quando web) e sort?; os params source|sort entram no paramNameSet de forma aditiva; parse de source fail-closed (desconhecido→Câmara); valor ?source=internet; vocabulário compartilhado em lib pura.`
|
||||
`Por quê: os bytes canônicos da Câmara ficam intocados (pins existentes verdes); a web é um recorte da mesma tabela/facetas, e um único ponto traduz source internet → origin web na where.`
|
||||
`Rejeitadas: B (webSpeechListUrl gêmeo) porque duplica o parse/serialize das MESMAS facetas e força gêmeos de omnibox/loaders; C (rota de lista separada) porque a intenção confirmou alternador único.`
|
||||
|
||||
- **Ajuste ao código (registrado):** `parseAcervoSource`/`AcervoSource`/`buildAcervoSourceHref` **movem** de `src/utilities/recordings/recordingListUrl.ts` para `src/lib/acervoSource.ts` (`AcervoSource = 'camara' | 'enviadas' | 'internet'`, `ACERVO_SOURCE_PARAM`, `parseAcervoSource` com o mesmo fail-closed, `buildAcervoSourceHref`). Motivo: `speechListUrl` precisa do vocabulário e não pode importar de `recordings` (ciclo: `recordings → speechListUrl/speechListFilters`); o lib já é a casa dos contratos puros/client-safe e não importa `@/utilities` (verificado). Os call sites atualizam o import (`AcervoSourceToggle`, `acervo/page.tsx`, `gravacoes/page.tsx`, `gravacoes/[id]/page.tsx`, unit das gravações); sem re-export falso.
|
||||
- `SpeechListState` ganha `source?: 'internet'` (ausente = Câmara; o tipo não vira obrigatório para não quebrar as construções existentes). `parseSpeechListParams` deriva `isWeb = parseAcervoSource(params) === 'internet'`; `source: 'internet'` só entra no estado quando o valor é exatamente esse.
|
||||
- Serialização canônica: `source=internet` **sempre** serializado quando o estado é web (é o que seleciona a fonte); a Câmara segue sem o param. `sort` só serializa quando web e ≠ `recentes`.
|
||||
- **Fase é só da Câmara:** com `source=internet`, `parseSpeechListParams` ignora `phase` → o canônico não o carrega → `?source=internet&phase=X` redireciona para o canônico (fail-closed). Sem web, tudo como hoje.
|
||||
- **Fail-closed de valor desconhecido:** `source=xyz` → Câmara no dispatch e no parse; o canônico omite o param → redirect (mesmo desfecho de hoje, quando o param era "unsupported").
|
||||
- Página: `parseAcervoSource(params)` vira despacho de 3 vias em `acervo/page.tsx:200`; `enviadas` segue o branch C199; `internet` entra no branch novo; o resto é Câmara.
|
||||
|
||||
**D2 — Ordenação compartilhada (extrair o dono, não copiar).**
|
||||
`Decisão: A — vocabulário em src/lib/acervoListSort.ts (ACERVO_SORT_OPTIONS/labels + AcervoSortKey + parseAcervoSort fail-closed + acervoSortIsDuration) e componente shared/AcervoSortSelect parametrizado por toHref + hint.`
|
||||
`Por quê: é o 3º consumidor prometido no defer do C219; o mapeamento para o sort do Payload fica por domínio (recentes → -createdAt nas gravações, → -speechAt na web; duração → ±durationSeconds com gate durationSeconds exists); assim os buckets/ordenação nunca divergem entre fontes.`
|
||||
`Rejeitadas: copiar a vocab para o módulo de speech (gêmea de conhecimento idêntico — mudança de rótulo/valor divergiria as fontes em silêncio); não implementar sort (quebraria classe-a-classe a cena 01 do artefato, que tem o select).`
|
||||
|
||||
- `recordingListUrl.ts` passa a importar de `@/lib/acervoListSort` (as gravações mantêm o contrato em bytes: `RECORDING_SORT_OPTIONS`/`RecordingSortKey` deixam de ser exportados porque não há outro consumidor; `recordingSortOrder` fica no domínio e `recordingSortIsDuration` delega a `acervoSortIsDuration(state.sort)`). `speechListUrl.ts` ganha `webSpeechSortOrder(state)` (`recentes`→`-speechAt`, `duracao_maior`→`-durationSeconds`, `duracao_menor`→`durationSeconds`).
|
||||
- `AcervoSortSelect` (`components/campaign/shared/AcervoSortSelect.tsx`, client) recebe `state` genérico (`{ page: number; sort?: AcervoSortKey }`) + `toHref` + `hint`; mostra o hint quando o sort ativo é de duração. `RecordingSortSelect.tsx` é removido (o ramo das gravações passa o hint atual; a web passa "Falas sem duração aparecem apenas em Mais recentes.").
|
||||
- Gate do sort de duração no `where`: `{ durationSeconds: { exists: true } }` — sem ele o Postgres abriria "Duração (maior)" com as linhas sem duração (NULLS FIRST); na Câmara é inerte (não parseia sort).
|
||||
|
||||
**D3 — Detalhe da fala web em rota própria; detalhe da Câmara blindado.**
|
||||
`Decisão: A — /campanha/comunicacao/acervo/internet/[id] (+ /arquivo e /capa), precedente gravacoes/[id]; loadSpeechDetailPageData ganha origin: camara no where (id web em /acervo/[id] vira 404).`
|
||||
`Por quê: o detalhe da Câmara é acoplado (VOD, SpeechDetailPlayer, cortes C174, taquigrafia) e o da web é outro produto (mídia espelhada, atribuição, download) — separá-los mantém cada um com seus donos e fecha o risco de id web renderizar o player da Câmara.`
|
||||
`Rejeitadas: despachar os dois detalhes no /acervo/[id] existente (mistura dois fluxos acoplados no mesmo arquivo, risco de regressão no player/VOD/cortes da Câmara; o parâmetro oculto de source no detalhe não existe hoje).`
|
||||
|
||||
- Segmento `internet` (decidido): `CAMPAIGN_COMMUNICATION_ACERVO_INTERNET = '/campanha/comunicacao/acervo/internet'` em `campaignPaths.ts`, com `campaignInternetSpeechHref(id)` (a URL que o card/detalhe usam), e o detalhe Câmara segue `/acervo/<id>`. Sem rota de lista `/acervo/internet` (o alternador aponta `?source=internet`); um `/acervo/internet` digitado cai em `[id]` com id não-numérico → `notFound`.
|
||||
- `loadSpeechDetailPageData` (Câmara) passa a consultar `{ id: { equals: speechId }, origin: { equals: 'camara' } }` → `SpeechNotFoundError` para id web (o int pina).
|
||||
- O chrome não precisa de rule nova: o regex genérico `/^\/campanha\/comunicacao\/acervo\/[^/]+$/` (`campaignPageChrome.ts:330`) não casa `/acervo/internet/<id>` (dois segmentos); o detalhe seta o próprio chrome via `SetCampaignPageChrome` (precedente `gravacoes/[id]`).
|
||||
|
||||
**D4 — Mídia privada por fala (arquivo + capa) com nome de download derivado do título.**
|
||||
`Decisão: A — internet/[id]/arquivo (player + ?download=1) e internet/[id]/capa, ambos com o gate do acervo (getCampaignUser + canReadCommunicationCatalog, findByID overrideAccess:false, 404 quando origin !== 'web'), via buildPrivateMediaResponse; dispositionFilename puro derivado do título — fecha o S4 do C215.`
|
||||
`Por quê: o dono do I/O privado já existe e serve range/416/404/download/Cache-Control; a rota por fala conhece o título para nomear o arquivo e é o único lugar que cruza fala × mídia com o gate do catálogo.`
|
||||
`Rejeitadas: rota única por media id (não conhece a fala → não tem nome de download nem o gate do acervo); /api/media/file (contrato público anônimo por definição — vazaria a collection privada).`
|
||||
|
||||
- `arquivo/route.ts`: `findByID` `depth: 1` selecionando `{ origin, title, mirroredMedia }`, `404` se `origin !== 'web'` ou sem mídia; `staticDir = resolvePrivateMediaStaticDir(payload, INTERNET_SPEECH_MEDIA_SLUG)` (helper exportado do dono); `download = searchParams.get('download') === '1'`; `dispositionFilename = webSpeechDownloadFilename({ title, storedFilename: media.filename })`.
|
||||
- `capa/route.ts`: mesma forma, servindo `thumbnail` inline (`download: false`; 404 quando ausente — o placeholder do card decide antes, pela presença do id).
|
||||
- `webSpeechDownloadFilename({ title, storedFilename })` em `src/lib/webSpeech.ts` (puro, unit-testado): preserva a extensão do arquivo armazenado, remove control chars/aspas/CRLF, colapsa separadores, limita o tamanho e cai no próprio `storedFilename` quando o título é vazio.
|
||||
- `privateMedia.ts` já considera áudio/vídeo/imagem inline — nada a mudar; o `Content-Disposition` continua sendo o do dono (`filename` + `filename*`).
|
||||
- Cascata/limpeza: nada novo — o `beforeDelete` do `Speech` (C215) já apaga mídia e capa.
|
||||
|
||||
**D5 — Loaders e view models nos donos (`speechPageData`/`speechViewModels`).**
|
||||
`Decisão: A — generalizar loadSpeechFilterOptions(payload, user, origin) (fase só Câmara) e adicionar loadWebSpeechAcervoPageData/loadWebSpeechDetailPageData no mesmo módulo; adicionar a família WebSpeech*ViewModel + builders puros no mesmo speechViewModels, reusando os helpers privados.`
|
||||
`Por quê: collection, facetas, segmentos, labels e o gate de tema são o mesmo conhecimento; um módulo gêmeo duplicaria (e divergiria) os helpers privados formatSpeechAt/formatSpeechDuration/municipalityViewModels/topicViewModels/pickThemeMatch.`
|
||||
`Rejeitadas: webSpeechPageData.ts/webSpeechViewModels.ts gêmeos (twin de conhecimento com helpers privados duplicados); loader web dentro do componente/página (não testável por camada).`
|
||||
|
||||
- `loadSpeechFilterOptions(payload, user, origin)`: `where { origin: { equals: origin } }`; `phases` só é preenchido para `'camara'`; anos/municípios saem do conjunto da fonte (para a web, `phases: []` — a faceta Fase não aparece e o omnibox não semeia fases).
|
||||
- `loadWebSpeechAcervoPageData(payload, user, searchParams, expandTheme = expandSpeechSearchTheme)`: `resolveSpeechListUrl` (que já lê `source=internet`); gate de tema (`mode=tema && q && canReadCommunicationCatalog` antes de qualquer chamada externa); `payload.find speech` com `where: buildSpeechListWhere(state, themeTerms)`, `sort: webSpeechSortOrder(state)`, `select` web (+`searchText` só quando `q`, para `matchedTextSearch`/evidência de tema) e `user`/`overrideAccess:false`; paginação `speechPageSize`; `loadSegmentsForSpeeches` (dono), `loadMunicipalityLabelsByIds`+`municipalityNameMap` (donos); **sem** cortes/VOD/poster (C217 e admin são outros donos). Retorno `WebSpeechAcervoPageData` espelhando `SpeechAcervoPageData` (rows/state/redirectHref/totalDocs/totalPages/filterOptions/themeUnavailable/themeApplied).
|
||||
- `loadWebSpeechDetailPageData(payload, user, speechId, query?)`: `find` com `{ id, origin: 'web' }` e `depth: 1` (`mirroredMedia`/`thumbnail` com `mimeType`/`filename`); segmentos via `speechSegment` (mesmo select/sort `order`); monta `WebSpeechDetailViewModel`. `loadWebSpeechTitleForActor` para `generateMetadata` (leitura só de título, precedente das gravações).
|
||||
- `speechViewModels.ts`: `WebSpeechListRecord`/`WebSpeechDetailRecord` + `toWebSpeechListItemViewModel`/`toWebSpeechDetailViewModel`, reusando `formatSpeechAt`/`formatSpeechDuration`/`municipalityViewModels`/`topicViewModels` (mesmo módulo), `pickThemeMatch`/`pickMatchingSegment`/`buildHighlightedExcerpt`/`splitHighlightedParts` (`speechHighlight`/`speechSearch` — `keywords` ausente na web é tratado pelo próprio helper) e um `withSeekQuery(href, segment, query)` interno extraído para os dois hrefs de detalhe (o atual `buildWatchHref` da Câmara mantém assinatura e bytes; o web é `buildWebSpeechWatchHref`).
|
||||
- `thumbnailUrl` do VM web = `campaignInternetSpeechCoverHref(id)` quando `thumbnail` existe; senão `null` (o card renderiza o placeholder "SEM CAPA"). `platform` vira `{ value, label }` via `webSpeechPlatformLabel`.
|
||||
|
||||
**D6 — UI: portar o artefato; estender a barra da Câmara por copy condicional (sem twin).**
|
||||
`Decisão: A — SpeechAcervoFilters ganha props de copy com default = Câmara (label/placeholder/aria/label da faceta Município citado/showPhase) e speechOmnibox ganha municipalityLabel opcional; componentes novos só os da fonte web (card/lista/player).`
|
||||
`Por quê: o estado e o omnibox são o MESMO contrato (SpeechListState) — diferente do C219, que rejeitou estender a barra da Câmara para as gravações porque o contrato era outro; aqui estender por copy é reuso do dono, e o default preserva os bytes/comportamento da Câmara.`
|
||||
`Rejeitadas: WebSpeechAcervoFilters próprio (twin de ~150 linhas sobre o mesmo estado/omnibox — a mecânica é a dos shells compartilhados); mover/reescrever a barra da Câmara (anti-goal: a Câmara é a referência intocada).`
|
||||
|
||||
- **Design gate (Passo 3d — trigger (a)):** o artefato cobre vídeo + chips YouTube/Rádio. Antes do markup, despachar o `designer` para estender o artefato com (1) a variante de player para mídia de **áudio** (`<audio controls>` no mesmo box escuro com o badge "Arquivo espelhado · privado") e (2) as cores dos chips de plataforma **Instagram** e **Áudio** (e a regra de fallback para plataforma sem cor). O implementador não inventa esses visuais; se o designer não responder, para no ponto (nunca "segue sem").
|
||||
- `AcervoSourceToggle` (mover de `components/campaign/recording/` para `components/campaign/shared/` — o alternador agora é do acervo, não das gravações): 3 abas com `aria-current`, `grid w-full grid-cols-3` no mobile com rótulos curtos (Câmara | Enviadas | Internet) e `md:inline-flex` com os longos (Falas da Câmara | Gravações enviadas | Falas na internet), conforme cenas 01/03.
|
||||
- `SpeechAcervoFilters` estendido (defaults Câmara): `omniboxLabel`, `omniboxPlaceholder`, `municipalityFacetLabel` ('Município citado' na web), `showPhase` (false na web), `filtersAriaLabel` e o `municipalityLabel` repassado aos builders do omnibox (`buildSpeechOmniboxChips`/`buildSpeechOmniboxSuggestionSeeds`) para o chip/sugestão lerem "Município citado". O restante (modo tema, chips, popovers, pending) é o mesmo.
|
||||
- **Ajuste no dono do omnibox:** `clearSpeechOmnibox` passa a preservar `source: 'internet'` (hoje descarta tudo — "limpar" na web cairia na Câmara); o default da Câmara continua `{ page: 1 }` (pin existente verde).
|
||||
- `WebSpeechResultCard`/`WebSpeechResultList` (`components/campaign/speech/`): grid do artefato (thumb/placeholder, chip de plataforma, data · duração, título, trecho com `SpeechHighlightParts`, chips de tema, "Ver fala"); a capa reusa `SpeechResultThumbnail` (skeleton/lazy) apontando para `/capa`; sem capa, placeholder no mesmo slot com o rótulo "SEM CAPA" (o artefato manda o slot fixo; nada de `<img>` quebrada).
|
||||
- `WebSpeechDetailPlayer` (client, `speech/`): box escuro com o badge "Arquivo espelhado · privado"; `<video controls>` para mídia de vídeo e `<audio controls>` para áudio (mesma busca/estado ativo no `onTimeUpdate`); "Baixar" (`downloadHref`) e "Abrir na origem" (`sourceUrl`, `rel="noreferrer"`, oculto quando nulo); box "Origem" (plataforma · canal/autor · "Publicado em <data>"); barra/lista de transcrição clicável com o segmento ativo.
|
||||
- **Transcrição clicável sem gêmeo:** mover `RecordingTranscriptSegmentButton` → `components/campaign/shared/CampaignTranscriptSegmentButton.tsx` e afinar o tipo do segmento para o contrato estrutural (`{ startSeconds, startLabel, parts }`), atualizando os 2 imports das gravações (`RecordingDetailPlayer`, `RecordingSpeakerTranscript`) — o valor do `variant` e os bytes são os mesmos (precedente C219 movendo `CampaignThemeRetryButton` para `shared/`).
|
||||
- Página/branches: `AcervoHeader` (subtitle com a terceira fonte) + branch web local (`WebSpeechSource`, como `RecordingsSource`) com `CampaignListPendingBoundary`, barra estendida com `key={buildSpeechFiltersKey(state)}`, aviso de tema degradado (`SpeechThemeFallbackNotice` + `CampaignThemeRetryButton`), `AcervoSortSelect` (hint "Falas sem duração aparecem apenas em Mais recentes."), `CampaignListResults`, `WebSpeechResultList`, `CampaignListEmptyState` (vazio honesto com "Limpar busca e filtros" → `buildAcervoSourceHref('internet')`) e `CampaignListFooter` ("falas na internet encontradas"). O ramo da Câmara (e o das gravações) não muda de bytes.
|
||||
- Copy do chrome: `campaignPageChromeCatalog.acervo.subtitle` e o `<p>` do header passam a citar as três fontes ("Busque nas falas da Câmara, nas gravações da equipe ou nas falas encontradas na internet.").
|
||||
- Detalhe web (`internet/[id]/page.tsx`): "← Voltar para Falas na internet" (artefato) → `buildAcervoSourceHref('internet')`; `SetCampaignPageChrome` com título próprio; estados do artefato (o "sem permissão" é o redirect fail-closed do gate, como no C219; o "carregando" é o `loading.tsx` do acervo, que cobre as rotas aninhadas).
|
||||
|
||||
**D7 — Sem migration, sem Consent, sem escrita.**
|
||||
`Decisão: A — read-only: nenhuma collection/campo/action/transação nova; internetSpeechMedia já existe com access próprio (read: canReadSpeech) e os campos web já existem no Speech.`
|
||||
`Por quê: a ingestão (C215) é a dona da escrita; esta entrega só lê e serve o que já está catalogado.`
|
||||
`Rejeitadas: qualquer migration (nada de schema muda); Consent novo (sem PII nova); rota de escrita/upsert no app (o CLI do C215 é o dono).`
|
||||
|
||||
**Decisões baratas (com gatilho de revisitação):**
|
||||
|
||||
- **Capa sem rota de download dedicada:** a rota `capa` serve inline (imagem não vira download); se um dia a assessoria pedir "baixar a capa", é o mesmo `buildPrivateMediaResponse` com `download`.
|
||||
- **Poster da capa no player de vídeo:** não especificado no artefato — não fazer; revisitar se o designer estender.
|
||||
- **Extração do ramo da Câmara em componente local** (`CamaraSource`) quando o arquivo `acervo/page.tsx` passar de ~3 branches — extração local, sem módulo novo (gatilho do S5 do C219).
|
||||
- **`municipalityLabel` no omnibox** por parâmetro opcional, não por cópia do adaptador; se um 3º label aparecer, virar config.
|
||||
- **Sem alias `/acervo/internet`:** o alternador aponta `?source=internet`; um alias só teria custo de redirect e chrome.
|
||||
- **Sem estado de processamento por item** (decisão de produto da intenção): a lista web só mostra o que o C215 considera completo; falhas ficam no relatório da ingestão.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/lib/acervoSource.ts`** (novo, puro/client-safe): `AcervoSource` 3-way, `ACERVO_SOURCE_PARAM`, `parseAcervoSource` (fail-closed), `buildAcervoSourceHref`. Sem import de `@/utilities` (evita ciclo e mantém lib limpa).
|
||||
- **`src/lib/acervoListSort.ts`** (novo, puro): `ACERVO_SORT_OPTIONS` + labels, `AcervoSortKey`, `parseAcervoSort`, `acervoSortIsDuration`.
|
||||
- **`src/lib/webSpeech.ts`** (editar): `webSpeechDownloadFilename({ title, storedFilename })`.
|
||||
- **`src/lib/campaignPaths.ts`** (editar): `CAMPAIGN_COMMUNICATION_ACERVO_INTERNET`, `campaignInternetSpeechHref`, `campaignInternetSpeechFileHref` (com `?download=1`), `campaignInternetSpeechCoverHref`.
|
||||
- **`src/lib/campaignPageChrome.ts`** (editar): subtitle do `acervo` com a terceira fonte (copy).
|
||||
- **`src/utilities/speech/speechListUrl.ts`** (editar): `source?: 'internet'` + `sort?: AcervoSortKey` no estado; params aditivos; parse fail-closed e por fonte (fase só Câmara); serialização canônica; `webSpeechSortOrder`.
|
||||
- **`src/utilities/speech/speechListFilters.ts`** (editar): `buildSpeechFacetWhere` deriva `origin` de `state.source` (web/camara); fase só Câmara; gate `durationSeconds exists` quando sort de duração. `buildSpeechListWhereIncludingCutOrigins` continua Câmara-only.
|
||||
- **`src/utilities/speech/speechPageData.ts`** (editar): `loadSpeechFilterOptions(payload, user, origin)`; `loadWebSpeechAcervoPageData`, `loadWebSpeechDetailPageData`, `loadWebSpeechTitleForActor`; `loadSpeechDetailPageData` ganha `origin: camara`.
|
||||
- **`src/utilities/speech/speechViewModels.ts`** (editar): família `WebSpeech*` (records, list/detail VMs e builders) + `buildWebSpeechWatchHref` (com `withSeekQuery` interno).
|
||||
- **`src/utilities/speech/speechOmnibox.ts`** (editar): `municipalityLabel` opcional nos builders; `clearSpeechOmnibox` preserva `source`.
|
||||
- **`src/utilities/recordings/recordingListUrl.ts`** (editar): importa source/sort dos libs; mantém o contrato em bytes e os toggles; remove os exports sem consumidor.
|
||||
- **`src/components/campaign/shared/`** (novos/movidos): `AcervoSourceToggle.tsx` (movido), `AcervoSortSelect.tsx` (novo), `CampaignTranscriptSegmentButton.tsx` (movido e com tipo estrutural).
|
||||
- **`src/components/campaign/speech/`** (novos): `WebSpeechResultCard.tsx`, `WebSpeechResultList.tsx`, `WebSpeechDetailPlayer.tsx`; (editar) `SpeechAcervoFilters.tsx` com as props de copy.
|
||||
- **Rotas** (novas): `acervo/internet/[id]/page.tsx`, `acervo/internet/[id]/arquivo/route.ts`, `acervo/internet/[id]/capa/route.ts`; (editar) `acervo/page.tsx` (dispatch 3 vias + branch web + header/subtitle).
|
||||
- **Removidos:** `RecordingSortSelect.tsx`, `RecordingTranscriptSegmentButton.tsx` (movido), `AcervoSourceToggle.tsx` (movido).
|
||||
- **Migration:** nenhuma (D7).
|
||||
- **Access / Consent:** nada muda: gate da página `communicationCatalog` e das rotas `canReadCommunicationCatalog`; `findByID`/`find` com `user` + `overrideAccess:false`; sem `Consent`/PII novos; sem rota pública.
|
||||
- **UI:** Impeccable C — port classe-a-classe de `docs/plans/acervo-fonte-falas-web-ui-design.html` (cenas 01–04) com o `designer` nos triggers (a) (áudio + chips Instagram/Áudio) e (c) (crítica final no fechamento). Shells reusados: `CampaignListOmnibox`/`CampaignSearchModeControl`/`CampaignHeaderFilterPopover`/`CampaignListPending*`/`CampaignListEmptyState`/`CampaignListFooter`/`CampaignListPagination`.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
- **N/A por decisão da intenção** (`acervo-fonte-falas-web.md:50`): resultados de busca e facetas são recorte editorial, não métrica; nenhum score de relevância/similaridade é exposto (e a contagem é paginação, não vaidade). Nada a apresentar; nenhuma forma nova.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / contrato + server** (~1–1,5 dia).
|
||||
- `src/lib/acervoSource.ts`, `src/lib/acervoListSort.ts`; `speechListUrl` (source/sort); `speechListFilters` (origin/gates); `recordingListUrl` migrando para os libs; `speechPageData` (opções por origem + loaders web + blindagem do detalhe Câmara); `speechViewModels` (VMs web); `campaignPaths`; `webSpeechDownloadFilename`.
|
||||
- Prova: `tests/unit/acervoSource.unit.spec.ts` (novo: 3-way fail-closed + hrefs), `tests/unit/acervoListSort.unit.spec.ts` (novo), extensões de `speechListUrl.unit.spec.ts` (source/sort web + pins da Câmara intocados + fase ignorada na web), `speechListFilters.unit.spec.ts` (origin por estado, gate de duração), `speechOmnibox.unit.spec.ts` (clear preservando source, label), `speechViewModels.unit.spec.ts` (VMs web), `webSpeech.unit.spec.ts` (nome de download), ajuste de imports de `recordingListUrl.unit.spec.ts`; `tests/int/webSpeechAcervo.int.spec.ts` (novo: lista só web/facetas/tema injetado/paginação/opções; detalhe web; id web 404 no detalhe Câmara; id Câmara 404 no detalhe web; capa ausente; filename do download).
|
||||
2. **UI port** (~1–1,5 dia).
|
||||
- **Primeiro:** dispatch do `designer` (Passo 3d, trigger (a)) para a variante de áudio + cores de chip Instagram/Áudio; só depois do retorno, o markup dessas peças.
|
||||
- `AcervoSourceToggle` 3 fontes; `SpeechAcervoFilters` com copy; `AcervoSortSelect`; `CampaignTranscriptSegmentButton` shared; `WebSpeechResultCard`/`WebSpeechResultList`/`WebSpeechDetailPlayer`; rotas `internet/[id]` (+ `arquivo`/`capa`); página com dispatch e estados.
|
||||
- Prova: `tests/e2e/campaignSpeechAcervo.e2e.spec.ts` estendido (describes novos: switcher de 3 fontes; lista web com chip de plataforma/mark/contagem; faceta estreitando + vazio honesto; detalhe com player/transcrição/Baixar/Abrir na origem/atribuição; `arquivo`/`capa` só para papéis do acervo, com range, download nomeado e 404 para `advisor`; fala web invisível na Câmara e 404 em `/acervo/<id>`); fixtures de mídia real (bytes válidos, padrão de `tests/int/webSpeechIngest.int.spec.ts`); `pnpm dev` manual contra as cenas 01–04.
|
||||
3. **Gates / fechamento** (~0,5 dia).
|
||||
- `scripts/lib/e2e-affected-manifest.mjs` (entry do acervo ganha `src/lib/acervoSource` e `src/lib/acervoListSort` — hoje um diff nesses arquivos cairia no smoke da home); `pnpm gate:fast`; depois a cascata cheia (`pnpm lint`, `pnpm format:check`, `tsc --noEmit`, `pnpm exec knip`, `pnpm check:cycles` — ciclo `recordings ↔ speech` é o risco nomeado, `pnpm test`, `pnpm build`); `pnpm test:e2e:affected`; crítica do `designer` (trigger (c)) com `Design tier` no PR; `pnpm push`; entrada em `docs/changelog/<data>-c216.md`.
|
||||
|
||||
Quota: ~3–4 dias eng, dentro do appetite herdado. Se apertar, os cortes são o `municipalityLabel` (chip da web lê "Município" como a Câmara — divergência de copy) e o placeholder "SEM CAPA" (card sem thumb) — nunca a blindagem do detalhe Câmara, o gate das rotas de mídia nem os pins de bytes da Câmara.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Ingestão/descoberta/download/transcrição/classificação (C215/C218); skill de atualização (C218).
|
||||
- Cortes e links compartilháveis a partir da fala web (C217); `SpeechCut`/origens de corte para web.
|
||||
- Diarização, edição de transcrição, publicação externa; geração de thumbnail (só a da origem); transcode/masters; signed URLs.
|
||||
- Merge multi-fonte/busca global única; item de nav novo; mexer no feed do site ou na Central de Conteúdos (C211).
|
||||
- Abrir a web em `speechCoverage`/`findSpeechExcerpts`/Sollinha (a blindagem C215 mantém `origin: camara` lá; C216 é só a fonte do acervo).
|
||||
- Segundo cadastro de pessoa (canal/autor é texto de origem); tornar `channel` um `Contact`.
|
||||
- Qualquer mudança de contrato/comportamento da Câmara (lista, barra, fase, detalhe, cortes, poster, VOD) — o teste de bytes é o pino.
|
||||
- `loadSpeechFilterOptions` com cache/prefetch; SSR de áudio/vídeo playback (browser é território do browser; o e2e só assere o contrato HTML).
|
||||
- Alias de rota `/acervo/internet` (lista) — não existe por design.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Regressão da Câmara por estender componentes/contratos compartilhados.** Os params são aditivos e a serialização da Câmara não muda (pins de `speechListUrl.unit.spec.ts` intocados); as props novas da barra têm default = Câmara e o call site da Câmara não passa nada; os ints/e2e da Câmara continuam verdes. Qualquer diferença de bytes no `?q=`/`?t=`/detalhe é bug de PR, não débito.
|
||||
- **Ciclo `recordings ↔ speech` ao mover o vocabulário de fonte.** Mitigado por colocar fonte/sort em `src/lib/*` (lib não importa `@/utilities` hoje; verificado); `pnpm check:cycles` no gate e no CI.
|
||||
- **Rota nova engolindo/colidindo com o `[id]` da Câmara ou com o chrome.** Segmento estático `internet` vence o dinâmico em `/acervo/internet/<id>`; o regex do chrome não casa o detalhe (2 segmentos) e o detalhe seta o próprio chrome — como `gravacoes/[id]`.
|
||||
- **Vazamento de mídia privada.** Só rotas sob `/campanha` com o gate do catálogo; `origin !== 'web'` → 404; sem `media` pública; toda negação é 404 silencioso (não confirma existência).
|
||||
- **Nome de download hostil (título com aspas/CRLF/tamanho).** Helper puro sanitiza, preserva a extensão e cai no nome armazenado; `privateMediaContentDisposition` já emite `filename*` (RFC 5987) — teste unitário e e2e do header.
|
||||
- **Sort de duração com linhas sem duração.** Gate `durationSeconds exists` no `where` (o Postgres ordena NULLS FIRST) + hint no controle, como no C219; int cobre.
|
||||
- **Barra web com rótulo errado/`source` perdido ao limpar.** `clearSpeechOmnibox` preserva `source` (unit pina); `municipalityLabel` default mantém a Câmara.
|
||||
- **Mídia de áudio no artefato (variante nova) sem desenho.** Trigger (a) do `designer` antes do markup; sem resposta, o trabalho para — não se inventa player/chip.
|
||||
- **Fixture e2e com mídia real.** Seguir o padrão do int do C215 (MP4/MP3/JPEG mínimos válidos) para o range/download exercitarem bytes de verdade.
|
||||
- **Manifest e2e desatualizado.** `src/lib/acervoSource`/`src/lib/acervoListSort` entram no entry da vertical; `campaignSpeechAcervo` já é o spec mapeado e já está no curado (OPS86), então o PR nunca roda zero e2e.
|
||||
- **`payload-types`/migration:** nada muda (sem schema) — nenhum HARD-STOP de migration nesta entrega.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto: alternador de 3 fontes com `aria-current`; busca/destaque e `mode=termo|tema`; facetas ano/tema/alcance/duração/municípios citados; contagem e paginação; chip de plataforma; capa ou placeholder; detalhe com player privado, transcrição clicável (clique posiciona), "Baixar", "Abrir na origem" e atribuição (plataforma + canal/autor + data); estados vazio/carregando/sem permissão; mobile.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: `user` + `overrideAccess: false` em toda leitura; mídia nunca pública; `advisor`/`leader` fail-closed (404); sem `Consent`/PII/segundo cadastro; sem item de nav; sem escrita/transação nova; copy pt-BR / identificadores em inglês; sem `openai/*` fora do `designer` (OPS123).
|
||||
- [ ] Contrato da Câmara congelado: pins de `speechListUrl`/`speechListFilters`/`speechOmnibox`/`speechViewModels`/`speechAcervo.int`/e2e da Câmara verdes sem adaptação de bytes.
|
||||
- [ ] Blindagem do detalhe Câmara (`origin: camara`) com int provando 404 para id web; fala web invisível na lista/opções/Câmara.
|
||||
- [ ] Testes de domínio previstos: unit (source/sort/URL/filtros/omnibox/VMs/filename), int (`webSpeechAcervo`), e2e (switcher/lista/detalhe/rotas de mídia/gates).
|
||||
- [ ] S4 do C215 fechado: `dispositionFilename` derivado do título no download da fala web.
|
||||
- [ ] Manifest e2e atualizado (`src/lib/acervoSource`, `src/lib/acervoListSort` na vertical).
|
||||
- [ ] Design: artefato estendido pelo `designer` (áudio + chips Instagram/Áudio) antes do markup e crítica final (trigger c) registrada como `Design tier` no PR; `DEGRADED` ⇒ para antes do push (em `--auto`: sem PR, Issue `blocked`).
|
||||
- [ ] Gates: `tsc --noEmit`, `pnpm lint` (0 warnings), `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm build`, `pnpm test:e2e:affected`; `pnpm push`; entrada em `docs/changelog/`.
|
||||
|
||||
## Self-score de decision-quality
|
||||
|
||||
**4,5/5.**
|
||||
|
||||
1. **Decisões caras com rejeitadas:** as 7 decisões (contrato/fonte, ordenação, rota do detalhe + blindagem, mídia privada + nome de download, loaders/VMs, UI/barra, sem migration) têm opções, recomendação e rejeitadas ancoradas em contrato/código (`recordingListUrl.ts:44,107`, ciclo `recordings → speech`, `speechPageData.ts:266-275` sem origin, `speechOmnibox.ts:274-276` descartando source, defer S4 do C215, C219 rejeitando generalização precoce do contrato). As baratas ficam com gatilho explícito.
|
||||
2. **Cabe no appetite:** ~3–4 dias herdados; tracer no contrato+server antes do port de UI; sem infra/provedor/migration novos; reusa classificador de tema, segmentos, labels, shells e o dono de I/O privado. Os dois cortes de contenção estão nomeados.
|
||||
3. **Rabbit holes nomeados:** cortes/C217, descoberta/C218, merge multi-fonte, segundo cadastro, transcode/masters, thumbnails, signed URLs, abrir a web em cobertura/Sollinha, alias de lista.
|
||||
4. **Depth check:** reusa `buildPrivateMediaResponse`/`resolvePrivateMediaStaticDir`, `loadSegmentsForSpeeches`, `loadMunicipalityLabelsByIds`, `SpeechHighlightParts`/`speechHighlight`, `CampaignList*`, `CampaignSearchModeControl`, `useCampaignListFilterNavigation`; extrai os dois vocabulários para lib pura (3º consumidor prometido no C219) e move para `shared/` só o que ganhou 3º consumidor, em vez de criar gêmeos.
|
||||
5. **Intenção preservada:** a engenharia não reescreveu o produto — a terceira fonte entra no mesmo acervo, com a mesma busca/filtros, detalhe com player/download/origem e mídia privada; o que ficou de fora (cortes, descoberta, merge, edição) é exatamente o que a intenção cortou.
|
||||
|
||||
Perde 0,5 porque uma superfície visual (player de áudio + chips Instagram/Áudio) **depende de extensão do artefato pelo `designer`** antes do markup: o plano declara o gate e a parada em vez de inventar o visual — e a execução em `--auto` fica condicionada a esse retorno.
|
||||
|
|
@ -94,6 +94,37 @@
|
|||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.platform-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.platform-youtube {
|
||||
background: #fef2f2;
|
||||
color: #c51414;
|
||||
}
|
||||
.platform-instagram {
|
||||
background: #fdf4ff;
|
||||
color: #86198f;
|
||||
}
|
||||
.platform-radio {
|
||||
background: #eff6ff;
|
||||
color: #1e40af;
|
||||
}
|
||||
.platform-audio {
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
}
|
||||
.platform-unknown {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.seg {
|
||||
display: grid;
|
||||
grid-template-columns: 3rem 1fr;
|
||||
|
|
@ -582,6 +613,300 @@
|
|||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="s5">
|
||||
<div class="mb-4">
|
||||
<p class="text-xs font-semibold text-primary">CENA 05</p>
|
||||
<h2 id="s5" class="mt-1 text-xl font-semibold">Variantes de mídia e plataforma</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
O tipo da mídia muda o controle, não a hierarquia do detalhe. A plataforma mantém a
|
||||
mesma identificação na capa, na linha de data e acima do título.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="frame rounded-xl border border-border bg-white p-6 md:p-8">
|
||||
<div class="flex flex-col gap-8">
|
||||
<section aria-labelledby="s5-chips">
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-primary">
|
||||
Identidade de plataforma
|
||||
</p>
|
||||
<h3 id="s5-chips" class="mt-1 font-semibold">Pills canônicas</h3>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Mesmo par de cores na lista e no detalhe
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-3" aria-label="Variações de plataforma">
|
||||
<span class="platform-pill platform-youtube">YouTube</span>
|
||||
<span class="platform-pill platform-instagram">Instagram</span>
|
||||
<span class="platform-pill platform-radio">Rádio</span>
|
||||
<span class="platform-pill platform-audio">Áudio</span>
|
||||
<span class="platform-pill platform-unknown">Outra plataforma</span>
|
||||
</div>
|
||||
<div
|
||||
class="mt-4 grid gap-2 text-xs leading-5 text-muted-foreground sm:grid-cols-2 lg:grid-cols-5"
|
||||
>
|
||||
<p><strong class="text-foreground">YouTube</strong><br />red-50 · primary</p>
|
||||
<p>
|
||||
<strong class="text-foreground">Instagram</strong><br />fuchsia-50 · fuchsia-800
|
||||
</p>
|
||||
<p><strong class="text-foreground">Rádio</strong><br />blue-50 · blue-800</p>
|
||||
<p><strong class="text-foreground">Áudio</strong><br />amber-50 · amber-800</p>
|
||||
<p>
|
||||
<strong class="text-foreground">Desconhecida</strong><br />muted ·
|
||||
muted-foreground + border
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="border-t border-border pt-7" aria-labelledby="s5-lista">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-primary">Uso na lista</p>
|
||||
<h3 id="s5-lista" class="mt-1 font-semibold">
|
||||
Capa + linha de data, como na cena 01
|
||||
</h3>
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<article
|
||||
class="grid grid-cols-[112px_1fr] gap-3 rounded-xl border border-border p-3"
|
||||
>
|
||||
<div
|
||||
class="relative grid aspect-video place-items-center rounded-lg bg-stone-800 text-white"
|
||||
>
|
||||
<svg class="size-6 opacity-80" aria-hidden="true"><use href="#play" /></svg>
|
||||
<span class="absolute left-2 top-2 rounded bg-black/65 px-2 py-1 text-[10px]"
|
||||
>Instagram</span
|
||||
>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="platform-pill platform-instagram">Instagram</span>
|
||||
<span class="text-xs text-muted-foreground">dd/mm/aaaa · —:—</span>
|
||||
</div>
|
||||
<p class="mt-2 text-sm font-semibold">Título da fala publicada</p>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">Vídeo espelhado</p>
|
||||
</div>
|
||||
</article>
|
||||
<article
|
||||
class="grid grid-cols-[112px_1fr] gap-3 rounded-xl border border-border p-3"
|
||||
>
|
||||
<div
|
||||
class="relative grid aspect-video place-items-center rounded-lg bg-stone-800 text-white"
|
||||
>
|
||||
<span class="text-lg" aria-hidden="true">◖)))</span>
|
||||
<span class="absolute left-2 top-2 rounded bg-black/65 px-2 py-1 text-[10px]"
|
||||
>Áudio</span
|
||||
>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="platform-pill platform-audio">Áudio</span>
|
||||
<span class="text-xs text-muted-foreground">dd/mm/aaaa · —:—</span>
|
||||
</div>
|
||||
<p class="mt-2 text-sm font-semibold">Fala publicada em arquivo de áudio</p>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
Sem capa · placeholder neutro
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
<article
|
||||
class="grid grid-cols-[112px_1fr] gap-3 rounded-xl border border-border p-3"
|
||||
>
|
||||
<div
|
||||
class="relative grid aspect-video place-items-center rounded-lg bg-stone-800 text-white"
|
||||
>
|
||||
<span class="text-lg" aria-hidden="true">◖)))</span>
|
||||
<span class="absolute left-2 top-2 rounded bg-black/65 px-2 py-1 text-[10px]"
|
||||
>Rádio</span
|
||||
>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="platform-pill platform-radio">Rádio</span>
|
||||
<span class="text-xs text-muted-foreground">dd/mm/aaaa · —:—</span>
|
||||
</div>
|
||||
<p class="mt-2 text-sm font-semibold">Entrevista em emissora de rádio</p>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
Arquivo de áudio espelhado
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
<article
|
||||
class="grid grid-cols-[112px_1fr] gap-3 rounded-xl border border-border p-3"
|
||||
>
|
||||
<div
|
||||
class="relative grid aspect-video place-items-center rounded-lg bg-stone-200 text-stone-500"
|
||||
>
|
||||
<svg class="size-6" aria-hidden="true"><use href="#video" /></svg>
|
||||
<span
|
||||
class="absolute left-2 top-2 rounded bg-black/65 px-2 py-1 text-[10px] text-white"
|
||||
>Outra plataforma</span
|
||||
>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="platform-pill platform-unknown">Outra plataforma</span>
|
||||
<span class="text-xs text-muted-foreground">dd/mm/aaaa · —:—</span>
|
||||
</div>
|
||||
<p class="mt-2 text-sm font-semibold">Fala de plataforma não mapeada</p>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
Fallback sem cor semântica
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="border-t border-border pt-7" aria-labelledby="s5-audio-desktop">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-primary">
|
||||
Detalhe · desktop
|
||||
</p>
|
||||
<h3 id="s5-audio-desktop" class="mt-1 font-semibold">Arquivo de áudio espelhado</h3>
|
||||
<div class="mt-4 grid gap-7 lg:grid-cols-[1.2fr_.8fr]">
|
||||
<div>
|
||||
<div
|
||||
class="relative grid aspect-video place-items-center overflow-hidden rounded-xl bg-stone-950 px-6 text-white"
|
||||
>
|
||||
<audio
|
||||
controls
|
||||
preload="metadata"
|
||||
aria-label="Reproduzir arquivo de áudio espelhado"
|
||||
class="w-full max-w-xl accent-primary"
|
||||
>
|
||||
Seu navegador não oferece reprodução de áudio.
|
||||
</audio>
|
||||
<span
|
||||
class="absolute left-4 top-4 rounded-md border border-white/20 bg-black/50 px-2 py-1 text-xs"
|
||||
>
|
||||
Arquivo espelhado · privado
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2" aria-label="Ações do arquivo">
|
||||
<button class="btn btn-p">
|
||||
<svg class="ico" aria-hidden="true"><use href="#download" /></svg>Baixar
|
||||
</button>
|
||||
<button class="btn btn-o">
|
||||
<svg class="ico" aria-hidden="true"><use href="#external" /></svg>Abrir na
|
||||
origem
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4 rounded-lg border border-border bg-muted/40 p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Origem
|
||||
</p>
|
||||
<p class="mt-1 text-sm font-medium">Rádio · Canal/autor da publicação</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Publicado em dd/mm/aaaa</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="platform-pill platform-radio">Rádio</span>
|
||||
<h4 class="mt-3 text-2xl font-semibold tracking-tight">
|
||||
Título da fala publicada na internet
|
||||
</h4>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
—:— · tema e alcance classificados
|
||||
</p>
|
||||
<section class="mt-6" aria-label="Transcrição do áudio">
|
||||
<p class="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Transcrição · clique para posicionar
|
||||
</p>
|
||||
<ol class="mt-2 space-y-1">
|
||||
<li>
|
||||
<button class="seg">
|
||||
<span class="text-xs tabular-nums text-muted-foreground">00:00</span
|
||||
><span class="text-sm leading-6"
|
||||
>Início da fala transcrita em segmentos clicáveis.</span
|
||||
>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="seg active" aria-current="true">
|
||||
<span class="text-xs tabular-nums text-primary">00:12</span
|
||||
><span class="text-sm leading-6"
|
||||
>O trecho ativo acompanha a posição do áudio.</span
|
||||
>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="seg">
|
||||
<span class="text-xs tabular-nums text-muted-foreground">00:28</span
|
||||
><span class="text-sm leading-6"
|
||||
>Clique para ouvir diretamente esta frase.</span
|
||||
>
|
||||
</button>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="border-t border-border pt-7" aria-labelledby="s5-audio-mobile">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-primary">
|
||||
Detalhe · mobile 390
|
||||
</p>
|
||||
<h3 id="s5-audio-mobile" class="mt-1 font-semibold">
|
||||
Palco mais baixo, controles preservados
|
||||
</h3>
|
||||
<div
|
||||
class="frame mt-4 w-[390px] max-w-full overflow-hidden rounded-[28px] border border-border bg-white"
|
||||
>
|
||||
<header class="flex h-14 items-center border-b px-4 text-sm font-semibold">
|
||||
← Detalhe da fala
|
||||
</header>
|
||||
<div class="p-4">
|
||||
<div
|
||||
class="relative grid h-40 place-items-center rounded-lg bg-stone-950 px-3 pt-8 text-white"
|
||||
>
|
||||
<audio
|
||||
controls
|
||||
preload="metadata"
|
||||
aria-label="Reproduzir arquivo de áudio espelhado"
|
||||
class="w-full accent-primary"
|
||||
>
|
||||
Seu navegador não oferece reprodução de áudio.
|
||||
</audio>
|
||||
<span
|
||||
class="absolute left-3 top-3 rounded border border-white/20 bg-black/60 px-2 py-1 text-[11px]"
|
||||
>
|
||||
Arquivo espelhado · privado
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2" aria-label="Ações do arquivo">
|
||||
<button class="btn btn-p">
|
||||
<svg class="ico" aria-hidden="true"><use href="#download" /></svg>Baixar
|
||||
</button>
|
||||
<button class="btn btn-o">
|
||||
<svg class="ico" aria-hidden="true"><use href="#external" /></svg>Abrir na
|
||||
origem
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<span class="platform-pill platform-audio">Áudio</span>
|
||||
<h4 class="mt-2 text-xl font-semibold">Título da fala publicada</h4>
|
||||
<p class="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
Áudio · Canal/autor · dd/mm/aaaa
|
||||
</p>
|
||||
</div>
|
||||
<section class="mt-6" aria-label="Transcrição do áudio">
|
||||
<p class="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Transcrição · toque para posicionar
|
||||
</p>
|
||||
<button class="seg active mt-2" aria-current="true">
|
||||
<span class="text-xs text-primary">00:12</span
|
||||
><span class="text-sm leading-6">Trecho ativo da transcrição clicável.</span>
|
||||
</button>
|
||||
<button class="seg">
|
||||
<span class="text-xs text-muted-foreground">00:28</span
|
||||
><span class="text-sm leading-6">Próximo segmento da fala.</span>
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -464,6 +464,11 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
'src/components/campaign/reels',
|
||||
// C199 — the uploaded recordings source and its private media owner.
|
||||
'src/components/campaign/recording',
|
||||
// C216 — the shared acervo shells the source switcher reuses: the
|
||||
// switcher itself, the ordering control and the transcript segment line.
|
||||
'src/components/campaign/shared/AcervoSourceToggle',
|
||||
'src/components/campaign/shared/AcervoSortSelect',
|
||||
'src/components/campaign/shared/CampaignTranscriptSegmentButton',
|
||||
// C219 — the recording collection owns the facet/provenance hooks the
|
||||
// recordings list filters on (the PR is high-risk anyway; the entry keeps
|
||||
// the mapping honest for the curated/local paths).
|
||||
|
|
@ -484,6 +489,9 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
'src/lib/webSpeech',
|
||||
'src/collections/Speech.ts',
|
||||
'src/collections/InternetSpeechMedia.ts',
|
||||
// C216 — the source and ordering vocabularies shared by the acervo.
|
||||
'src/lib/acervoSource',
|
||||
'src/lib/acervoListSort',
|
||||
'src/lib/reel',
|
||||
'src/lib/recording',
|
||||
'src/lib/contentPiece',
|
||||
|
|
|
|||
|
|
@ -11,11 +11,11 @@ import { RecordingStatusRefresher } from '@/components/campaign/recording/Record
|
|||
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { buildAcervoSourceHref } from '@/lib/acervoSource'
|
||||
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
|
||||
import { parseSeekSeconds } from '@/lib/speechClock'
|
||||
import { firstValue, strictDecimalInteger } from '@/utilities/campaignListUrl'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { buildAcervoSourceHref } from '@/utilities/recordings/recordingListUrl'
|
||||
import {
|
||||
loadRecordingDetailPageData,
|
||||
loadRecordingTitleForActor,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
|
||||
import { buildAcervoSourceHref } from '@/utilities/recordings/recordingListUrl'
|
||||
import { buildAcervoSourceHref } from '@/lib/acervoSource'
|
||||
|
||||
/**
|
||||
* C199 — canonical alias of the recordings source. The list lives on
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import config from '@payload-config'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
|
||||
import { PRIVATE_MEDIA_CACHE_CONTROL } from '@/lib/privateMedia'
|
||||
import { INTERNET_SPEECH_MEDIA_SLUG, webSpeechDownloadFilename } from '@/lib/webSpeech'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import {
|
||||
buildPrivateMediaResponse,
|
||||
resolvePrivateMediaStaticDir,
|
||||
} from '@/utilities/privateMedia/privateMediaResponse'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* C216 — the only door to a web speech's mirrored file. Lives under `/campanha`
|
||||
* because the `campaign-token` cookie is scoped to that path (a `<video>` sends
|
||||
* the cookie, never an Authorization header). The gate is the same
|
||||
* communication catalog predicate as the collection access; only web rows are
|
||||
* served and every denial is a silent `404` so the route never leaks which
|
||||
* speeches exist. The download name is derived from the speech title (C215 S4).
|
||||
*/
|
||||
|
||||
const notFound = (): NextResponse =>
|
||||
new NextResponse(null, {
|
||||
status: 404,
|
||||
headers: { 'Cache-Control': PRIVATE_MEDIA_CACHE_CONTROL },
|
||||
})
|
||||
|
||||
export const GET = async (
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> => {
|
||||
const user = await getCampaignUser()
|
||||
if (!user || !canReadCommunicationCatalog(user.role)) return notFound()
|
||||
|
||||
const { id } = await params
|
||||
const speechId = Number(id)
|
||||
if (!Number.isInteger(speechId) || speechId <= 0) return notFound()
|
||||
|
||||
const payload = await getPayload({ config })
|
||||
const speech = await payload
|
||||
.findByID({
|
||||
collection: 'speech',
|
||||
id: speechId,
|
||||
depth: 1,
|
||||
select: { origin: true, title: true, mirroredMedia: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
.catch(() => null)
|
||||
if (!speech || speech.origin !== 'web') return notFound()
|
||||
|
||||
const media = speech.mirroredMedia
|
||||
if (!media || typeof media !== 'object') return notFound()
|
||||
|
||||
const url = new URL(request.url)
|
||||
return buildPrivateMediaResponse({
|
||||
media,
|
||||
staticDir: resolvePrivateMediaStaticDir(payload, INTERNET_SPEECH_MEDIA_SLUG),
|
||||
rangeHeader: request.headers.get('range'),
|
||||
download: url.searchParams.get('download') === '1',
|
||||
dispositionFilename: webSpeechDownloadFilename({
|
||||
title: speech.title,
|
||||
storedFilename: media.filename,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import config from '@payload-config'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { canReadCommunicationCatalog } from '@/lib/campaignRoles'
|
||||
import { PRIVATE_MEDIA_CACHE_CONTROL } from '@/lib/privateMedia'
|
||||
import { INTERNET_SPEECH_MEDIA_SLUG } from '@/lib/webSpeech'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import {
|
||||
buildPrivateMediaResponse,
|
||||
resolvePrivateMediaStaticDir,
|
||||
} from '@/utilities/privateMedia/privateMediaResponse'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* C216 — the authenticated cover of one web speech. The thumbnail lives in the
|
||||
* private `internetSpeechMedia` collection, so it can never go through the
|
||||
* public `/api/media/file` proxy; a row without a captured thumbnail answers
|
||||
* `404` and the list renders its neutral placeholder.
|
||||
*/
|
||||
|
||||
const notFound = (): NextResponse =>
|
||||
new NextResponse(null, {
|
||||
status: 404,
|
||||
headers: { 'Cache-Control': PRIVATE_MEDIA_CACHE_CONTROL },
|
||||
})
|
||||
|
||||
export const GET = async (
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
): Promise<Response> => {
|
||||
const user = await getCampaignUser()
|
||||
if (!user || !canReadCommunicationCatalog(user.role)) return notFound()
|
||||
|
||||
const { id } = await params
|
||||
const speechId = Number(id)
|
||||
if (!Number.isInteger(speechId) || speechId <= 0) return notFound()
|
||||
|
||||
const payload = await getPayload({ config })
|
||||
const speech = await payload
|
||||
.findByID({
|
||||
collection: 'speech',
|
||||
id: speechId,
|
||||
depth: 1,
|
||||
select: { origin: true, thumbnail: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
.catch(() => null)
|
||||
if (!speech || speech.origin !== 'web') return notFound()
|
||||
|
||||
const thumbnail = speech.thumbnail
|
||||
if (!thumbnail || typeof thumbnail !== 'object') return notFound()
|
||||
|
||||
return buildPrivateMediaResponse({
|
||||
media: thumbnail,
|
||||
staticDir: resolvePrivateMediaStaticDir(payload, INTERNET_SPEECH_MEDIA_SLUG),
|
||||
rangeHeader: request.headers.get('range'),
|
||||
download: false,
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import config from '@payload-config'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { WebSpeechDetailPlayer } from '@/components/campaign/speech/WebSpeechDetailPlayer'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { buildAcervoSourceHref } from '@/lib/acervoSource'
|
||||
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
|
||||
import { parseSeekSeconds } from '@/lib/speechClock'
|
||||
import { firstValue, strictDecimalInteger } from '@/utilities/campaignListUrl'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import {
|
||||
loadWebSpeechDetailPageData,
|
||||
loadWebSpeechTitleForActor,
|
||||
SpeechNotFoundError,
|
||||
} from '@/utilities/speech/speechPageData'
|
||||
|
||||
type WebSpeechDetailPageProps = {
|
||||
params: Promise<{ id: string }>
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: WebSpeechDetailPageProps) {
|
||||
const { id } = await params
|
||||
const speechId = strictDecimalInteger(id)
|
||||
if (!speechId) return campaignPageMetadata({ title: 'Fala na internet' })
|
||||
|
||||
const [user, payload] = await Promise.all([
|
||||
requireCampaignPageActor({ gate: 'communicationCatalog' }),
|
||||
getPayload({ config }),
|
||||
])
|
||||
|
||||
try {
|
||||
const title = await loadWebSpeechTitleForActor(payload, user, speechId)
|
||||
return campaignPageMetadata({ title: title ?? 'Fala na internet' })
|
||||
} catch {
|
||||
return campaignPageMetadata({ title: 'Fala na internet' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* C216 — one web speech ("Falas na internet"): the private player of the
|
||||
* mirrored file, the download, "Abrir na origem" and the clickable transcript.
|
||||
* The Câmara detail is another product (VOD/cuts), which is why the source has
|
||||
* its own route — the Câmara loader answers an honest 404 for a web row and
|
||||
* this one for a Câmara row.
|
||||
*/
|
||||
export default async function WebSpeechDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: WebSpeechDetailPageProps) {
|
||||
const [{ id }, query, user, payload] = await Promise.all([
|
||||
params,
|
||||
searchParams,
|
||||
requireCampaignPageActor({ gate: 'communicationCatalog' }),
|
||||
getPayload({ config }),
|
||||
])
|
||||
const speechId = strictDecimalInteger(id)
|
||||
if (!speechId) notFound()
|
||||
|
||||
const speech = await loadWebSpeechDetailPageData(
|
||||
payload,
|
||||
user,
|
||||
speechId,
|
||||
firstValue(query.q)?.trim() || undefined,
|
||||
).catch((error) => {
|
||||
if (error instanceof SpeechNotFoundError) notFound()
|
||||
throw error
|
||||
})
|
||||
|
||||
const initialSeconds = parseSeekSeconds(firstValue(query.t))
|
||||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<SetCampaignPageChrome chrome={{ title: 'Fala na internet', subtitle: 'Acervo' }} />
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button asChild variant="ghost" className="-ml-2 min-h-11 self-start">
|
||||
<Link href={buildAcervoSourceHref('internet')}>
|
||||
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Voltar para Falas na internet
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<WebSpeechDetailPlayer speech={speech} initialSeconds={initialSeconds} />
|
||||
</div>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,13 +4,13 @@ import Link from 'next/link'
|
|||
import { redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { AcervoSourceToggle } from '@/components/campaign/recording/AcervoSourceToggle'
|
||||
import { RecordingAcervoFilters } from '@/components/campaign/recording/RecordingAcervoFilters'
|
||||
import { RecordingResultList } from '@/components/campaign/recording/RecordingResultList'
|
||||
import { RecordingSortSelect } from '@/components/campaign/recording/RecordingSortSelect'
|
||||
import { RecordingStatusRefresher } from '@/components/campaign/recording/RecordingStatusRefresher'
|
||||
import { RecordingThemeFallbackNotice } from '@/components/campaign/recording/RecordingThemeFallbackNotice'
|
||||
import { RecordingUploadDialog } from '@/components/campaign/recording/RecordingUploadDialog'
|
||||
import { AcervoSortSelect } from '@/components/campaign/shared/AcervoSortSelect'
|
||||
import { AcervoSourceToggle } from '@/components/campaign/shared/AcervoSourceToggle'
|
||||
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
|
||||
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
|
||||
import {
|
||||
|
|
@ -23,20 +23,27 @@ import { SpeechAcervoFilters } from '@/components/campaign/speech/SpeechAcervoFi
|
|||
import { SpeechRefineSearchButton } from '@/components/campaign/speech/SpeechRefineSearchButton'
|
||||
import { SpeechResultList } from '@/components/campaign/speech/SpeechResultList'
|
||||
import { SpeechThemeFallbackNotice } from '@/components/campaign/speech/SpeechThemeFallbackNotice'
|
||||
import { WebSpeechResultList } from '@/components/campaign/speech/WebSpeechResultList'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { buildAcervoSourceHref, parseAcervoSource, type AcervoSource } from '@/lib/acervoSource'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { CAMPAIGN_COMMUNICATION_ACERVO, CAMPAIGN_COMMUNICATION_CORTES } from '@/lib/campaignPaths'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import {
|
||||
buildAcervoSourceHref,
|
||||
buildRecordingFiltersKey,
|
||||
buildRecordingListHref,
|
||||
parseAcervoSource,
|
||||
recordingHasActiveFilters,
|
||||
} from '@/utilities/recordings/recordingListUrl'
|
||||
import { loadRecordingsPageData } from '@/utilities/recordings/recordingPageData'
|
||||
import { buildSpeechFiltersKey, buildSpeechListHref } from '@/utilities/speech/speechListUrl'
|
||||
import { loadSpeechAcervoPageData } from '@/utilities/speech/speechPageData'
|
||||
import {
|
||||
buildSpeechFiltersKey,
|
||||
buildSpeechListHref,
|
||||
speechHasActiveFilters,
|
||||
} from '@/utilities/speech/speechListUrl'
|
||||
import {
|
||||
loadSpeechAcervoPageData,
|
||||
loadWebSpeechAcervoPageData,
|
||||
} from '@/utilities/speech/speechPageData'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('acervo')
|
||||
|
||||
|
|
@ -45,11 +52,12 @@ type SpeechAcervoPageProps = {
|
|||
}
|
||||
|
||||
/**
|
||||
* C199 — the acervo header and its source switcher live above both sources:
|
||||
* "Falas da Câmara" (the C154 catalog with its facets) and "Gravações enviadas"
|
||||
* (the C199 recordings with their own search).
|
||||
* C199/C216 — the acervo header and its source switcher live above the three
|
||||
* sources: "Falas da Câmara" (the C154 catalog with its facets), "Gravações
|
||||
* enviadas" (the C199 recordings with their own search) and "Falas na internet"
|
||||
* (the C216 web speeches).
|
||||
*/
|
||||
const AcervoHeader = ({ source }: { source: 'camara' | 'enviadas' }) => (
|
||||
const AcervoHeader = ({ source }: { source: AcervoSource }) => (
|
||||
<div className="flex flex-col gap-4 pt-4 md:pt-0">
|
||||
{/* Mobile keeps only the switcher row: the shell chrome already carries the
|
||||
title and the action joins the toggle (approved scene 6). */}
|
||||
|
|
@ -57,7 +65,7 @@ const AcervoHeader = ({ source }: { source: 'camara' | 'enviadas' }) => (
|
|||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Acervo de falas</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Busque nas falas da Câmara ou nas gravações da equipe.
|
||||
Busque nas falas da Câmara, nas gravações da equipe ou nas falas encontradas na internet.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
|
@ -127,7 +135,10 @@ const RecordingsSource = ({
|
|||
</div>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
{data.themeUnavailable ? <CampaignThemeRetryButton /> : null}
|
||||
<RecordingSortSelect state={data.state} />
|
||||
<AcervoSortSelect
|
||||
state={data.state}
|
||||
hint="Gravações sem duração aparecem apenas em Mais recentes."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -190,6 +201,123 @@ const RecordingsSource = ({
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* C216 — "Falas na internet": the web speeches list with the Câmara's search
|
||||
* gestures. The source has no Fase facet and no upload action, so the bar takes
|
||||
* the web copy and the ordering control its own hint.
|
||||
*/
|
||||
const WebSpeechSource = ({
|
||||
data,
|
||||
}: {
|
||||
data: Awaited<ReturnType<typeof loadWebSpeechAcervoPageData>>
|
||||
}) => {
|
||||
const hasFilters = speechHasActiveFilters(data.state)
|
||||
const themeMode = data.state.mode === 'tema'
|
||||
const themeActive = themeMode && data.themeApplied && !data.themeUnavailable
|
||||
|
||||
return (
|
||||
<CampaignListPendingBoundary>
|
||||
<SpeechAcervoFilters
|
||||
key={buildSpeechFiltersKey(data.state)}
|
||||
state={data.state}
|
||||
filterOptions={data.filterOptions}
|
||||
themeUnavailable={data.themeUnavailable}
|
||||
omniboxLabel="Buscar nas falas na internet"
|
||||
omniboxPlaceholder="Busque por assunto, tema ou município…"
|
||||
municipalityFacetLabel="Município citado"
|
||||
showPhaseFacet={false}
|
||||
filtersAriaLabel="Filtros das falas na internet"
|
||||
/>
|
||||
|
||||
{data.themeUnavailable ? <SpeechThemeFallbackNotice /> : null}
|
||||
|
||||
<CampaignListResults>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">
|
||||
{data.themeUnavailable
|
||||
? 'Resultados por termo exato'
|
||||
: themeActive
|
||||
? 'Resultados por tema'
|
||||
: 'Resultados encontrados'}
|
||||
</h2>
|
||||
{themeMode ? (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{data.themeUnavailable
|
||||
? 'Comportamento atual do acervo.'
|
||||
: themeActive
|
||||
? 'Confira o indício em cada fala antes de abrir.'
|
||||
: 'Nenhum termo relacionado foi acrescentado; mostramos a busca literal.'}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
{data.themeUnavailable ? <CampaignThemeRetryButton /> : null}
|
||||
<AcervoSortSelect
|
||||
state={data.state}
|
||||
hint="Falas sem duração aparecem apenas em Mais recentes."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.rows.length > 0 ? (
|
||||
<WebSpeechResultList rows={data.rows} />
|
||||
) : (
|
||||
<CampaignListEmptyState
|
||||
className={themeActive ? 'border-solid' : undefined}
|
||||
icon={SearchXIcon}
|
||||
title={
|
||||
themeActive
|
||||
? 'Nenhuma fala encontrada para este tema'
|
||||
: data.state.q
|
||||
? `Nenhuma fala encontrada para "${data.state.q}"`
|
||||
: hasFilters
|
||||
? 'Nenhuma fala encontrada com esses filtros'
|
||||
: 'Nenhuma fala da internet catalogada ainda'
|
||||
}
|
||||
description={
|
||||
themeActive
|
||||
? 'Não encontramos uma fala que corresponda ao sentido desta busca com os filtros atuais. Não vamos preencher a lista com resultados pouco relacionados.'
|
||||
: hasFilters
|
||||
? 'Tente outro termo, remova filtros ou limpe a busca.'
|
||||
: 'As falas encontradas na internet aparecem aqui depois que a ingestão as catalogar.'
|
||||
}
|
||||
>
|
||||
{themeActive ? (
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<SpeechRefineSearchButton />
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={buildSpeechListHref({ ...data.state, mode: undefined }, 1)}>
|
||||
Usar termo exato
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" className="min-h-11">
|
||||
<Link href={buildAcervoSourceHref('internet')}>Limpar filtros</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={buildAcervoSourceHref('internet')}>Limpar busca e filtros</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CampaignListEmptyState>
|
||||
)}
|
||||
|
||||
{data.rows.length > 0 ? (
|
||||
<CampaignListFooter
|
||||
totalDocs={data.totalDocs}
|
||||
singular="fala na internet encontrada"
|
||||
plural="falas na internet encontradas"
|
||||
page={data.state.page}
|
||||
totalPages={data.totalPages}
|
||||
hrefForPage={(page) => buildSpeechListHref(data.state, page)}
|
||||
/>
|
||||
) : null}
|
||||
</CampaignListResults>
|
||||
</CampaignListPendingBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function SpeechAcervoPage({ searchParams }: SpeechAcervoPageProps) {
|
||||
const [user, payload, params] = await Promise.all([
|
||||
requireCampaignPageActor({ gate: 'communicationCatalog' }),
|
||||
|
|
@ -197,7 +325,9 @@ export default async function SpeechAcervoPage({ searchParams }: SpeechAcervoPag
|
|||
searchParams,
|
||||
])
|
||||
|
||||
if (parseAcervoSource(params) === 'enviadas') {
|
||||
const source = parseAcervoSource(params)
|
||||
|
||||
if (source === 'enviadas') {
|
||||
const recordings = await loadRecordingsPageData(payload, user, params)
|
||||
if (recordings.redirectHref) redirect(recordings.redirectHref)
|
||||
|
||||
|
|
@ -209,6 +339,18 @@ export default async function SpeechAcervoPage({ searchParams }: SpeechAcervoPag
|
|||
)
|
||||
}
|
||||
|
||||
if (source === 'internet') {
|
||||
const webSpeeches = await loadWebSpeechAcervoPageData(payload, user, params)
|
||||
if (webSpeeches.redirectHref) redirect(webSpeeches.redirectHref)
|
||||
|
||||
return (
|
||||
<CampaignPageShell aria-label="Acervo de falas na internet">
|
||||
<AcervoHeader source="internet" />
|
||||
<WebSpeechSource data={webSpeeches} />
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
||||
const data = await loadSpeechAcervoPageData(payload, user, params)
|
||||
if (data.redirectHref) redirect(data.redirectHref)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buildAcervoSourceHref, type AcervoSource } from '@/utilities/recordings/recordingListUrl'
|
||||
|
||||
const SOURCE_TABS = [
|
||||
{ source: 'camara', label: 'Falas da Câmara' },
|
||||
{ source: 'enviadas', label: 'Gravações enviadas' },
|
||||
] as const satisfies readonly { source: AcervoSource; label: string }[]
|
||||
|
||||
/**
|
||||
* C199 — the acervo source switcher. Navigation, not a tab widget: two links
|
||||
* with `aria-current="page"` on the active one, keeping the pill appearance of
|
||||
* the approved design.
|
||||
*/
|
||||
export const AcervoSourceToggle = ({ source }: { source: AcervoSource }) => (
|
||||
<nav
|
||||
aria-label="Fonte do acervo"
|
||||
className="inline-flex self-start rounded-lg bg-secondary p-1 text-sm"
|
||||
>
|
||||
{SOURCE_TABS.map((tab) => {
|
||||
const active = tab.source === source
|
||||
return (
|
||||
<Link
|
||||
key={tab.source}
|
||||
href={buildAcervoSourceHref(tab.source)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-2',
|
||||
active
|
||||
? 'bg-card font-medium text-foreground shadow-sm ring-1 ring-border'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
|
|
@ -5,7 +5,7 @@ import { useRef, useState } from 'react'
|
|||
|
||||
import { RecordingRetryButton } from '@/components/campaign/recording/RecordingRetryButton'
|
||||
import { RecordingSpeakerTranscript } from '@/components/campaign/recording/RecordingSpeakerTranscript'
|
||||
import { RecordingTranscriptSegmentButton } from '@/components/campaign/recording/RecordingTranscriptSegmentButton'
|
||||
import { CampaignTranscriptSegmentButton } from '@/components/campaign/shared/CampaignTranscriptSegmentButton'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
canRetryRecording,
|
||||
|
|
@ -232,7 +232,7 @@ export const RecordingDetailPlayer = ({
|
|||
<ol className="mt-2 max-h-[28rem] space-y-0.5 overflow-y-auto">
|
||||
{segments.map((segment) => (
|
||||
<li key={`${segment.startSeconds}-${segment.startLabel}`}>
|
||||
<RecordingTranscriptSegmentButton
|
||||
<CampaignTranscriptSegmentButton
|
||||
segment={segment}
|
||||
active={activeStart === segment.startSeconds}
|
||||
onSeek={seekTo}
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
import {
|
||||
buildRecordingListHref,
|
||||
RECORDING_SORT_OPTIONS,
|
||||
recordingSortIsDuration,
|
||||
type RecordingListState,
|
||||
type RecordingSortKey,
|
||||
} from '@/utilities/recordings/recordingListUrl'
|
||||
|
||||
/**
|
||||
* C219 — the list ordering control (design scene 01): "Mais recentes" (the
|
||||
* default, never serialized) plus the two duration orders. A duration order
|
||||
* only lists rows with a measured duration, so the hint says it out loud.
|
||||
*/
|
||||
export const RecordingSortSelect = ({ state }: { state: RecordingListState }) => {
|
||||
const { navigate, isPending } = useCampaignListFilterNavigation({
|
||||
state,
|
||||
toHref: (next) => buildRecordingListHref(next, 1),
|
||||
})
|
||||
const value: RecordingSortKey = state.sort ?? 'recentes'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
Ordenar por
|
||||
<select
|
||||
value={value}
|
||||
disabled={isPending}
|
||||
className="min-h-10 rounded-md border border-input bg-white px-3 py-2 text-sm text-foreground disabled:opacity-70"
|
||||
onChange={(event) => {
|
||||
const next = event.target.value as RecordingSortKey
|
||||
navigate({ ...state, page: 1, sort: next === 'recentes' ? undefined : next })
|
||||
}}
|
||||
>
|
||||
{RECORDING_SORT_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{recordingSortIsDuration(state) ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Gravações sem duração aparecem apenas em Mais recentes.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
import { InfoIcon, UserIcon } from 'lucide-react'
|
||||
|
||||
import { RecordingSpeakerDialog } from '@/components/campaign/recording/RecordingSpeakerDialog'
|
||||
import { RecordingTranscriptSegmentButton } from '@/components/campaign/recording/RecordingTranscriptSegmentButton'
|
||||
import { CampaignTranscriptSegmentButton } from '@/components/campaign/shared/CampaignTranscriptSegmentButton'
|
||||
import {
|
||||
RECORDING_SPEAKER_BANNER_BODY,
|
||||
RECORDING_SPEAKER_BANNER_DROPPED,
|
||||
|
|
@ -88,7 +88,7 @@ export const RecordingSpeakerTranscript = ({
|
|||
</header>
|
||||
|
||||
{group.segments.map((segment) => (
|
||||
<RecordingTranscriptSegmentButton
|
||||
<CampaignTranscriptSegmentButton
|
||||
key={`${segment.startSeconds}-${segment.startLabel}`}
|
||||
segment={segment}
|
||||
active={activeStart === segment.startSeconds}
|
||||
|
|
|
|||
62
src/components/campaign/shared/AcervoSortSelect.tsx
Normal file
62
src/components/campaign/shared/AcervoSortSelect.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
'use client'
|
||||
|
||||
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
import { ACERVO_SORT_OPTIONS, acervoSortIsDuration, type AcervoSortKey } from '@/lib/acervoListSort'
|
||||
import {
|
||||
buildRecordingListHref,
|
||||
type RecordingListState,
|
||||
} from '@/utilities/recordings/recordingListUrl'
|
||||
import { buildSpeechListHref, type SpeechListState } from '@/utilities/speech/speechListUrl'
|
||||
|
||||
/**
|
||||
* C216 — the list state of one acervo source. The `source` discriminator picks
|
||||
* the canonical serializer inside this client component: a server component
|
||||
* can never hand a function prop across the boundary, so the domain modules
|
||||
* are imported here (they are client-safe pure contracts) and the page only
|
||||
* passes the serializable state and its honest hint.
|
||||
*/
|
||||
export type AcervoSortState = RecordingListState | SpeechListState
|
||||
|
||||
/**
|
||||
* C216 — the shared acervo ordering control (design scene 01): "Mais recentes"
|
||||
* (the default, never serialized) plus the two duration orders. Each source
|
||||
* passes its own state and the honest line shown while a duration order is
|
||||
* active (it only lists rows with a measured duration).
|
||||
*/
|
||||
export const AcervoSortSelect = ({ state, hint }: { state: AcervoSortState; hint: string }) => {
|
||||
const { navigate, isPending } = useCampaignListFilterNavigation({
|
||||
state,
|
||||
toHref: (next) =>
|
||||
next.source === 'enviadas' ? buildRecordingListHref(next, 1) : buildSpeechListHref(next, 1),
|
||||
})
|
||||
|
||||
const change = (value: AcervoSortKey) => {
|
||||
const sort = value === 'recentes' ? undefined : value
|
||||
navigate(
|
||||
state.source === 'enviadas' ? { ...state, page: 1, sort } : { ...state, page: 1, sort },
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
Ordenar por
|
||||
<select
|
||||
value={state.sort ?? 'recentes'}
|
||||
disabled={isPending}
|
||||
className="min-h-10 rounded-md border border-input bg-white px-3 py-2 text-sm text-foreground disabled:opacity-70"
|
||||
onChange={(event) => change(event.target.value as AcervoSortKey)}
|
||||
>
|
||||
{ACERVO_SORT_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{acervoSortIsDuration(state.sort) ? (
|
||||
<p className="text-xs text-muted-foreground">{hint}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
43
src/components/campaign/shared/AcervoSourceToggle.tsx
Normal file
43
src/components/campaign/shared/AcervoSourceToggle.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
import { buildAcervoSourceHref, type AcervoSource } from '@/lib/acervoSource'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const SOURCE_TABS = [
|
||||
{ source: 'camara', label: 'Falas da Câmara', shortLabel: 'Câmara' },
|
||||
{ source: 'enviadas', label: 'Gravações enviadas', shortLabel: 'Enviadas' },
|
||||
{ source: 'internet', label: 'Falas na internet', shortLabel: 'Internet' },
|
||||
] as const satisfies readonly { source: AcervoSource; label: string; shortLabel: string }[]
|
||||
|
||||
/**
|
||||
* C199/C216 — the acervo source switcher. Navigation, not a tab widget: three
|
||||
* links with `aria-current="page"` on the active one, keeping the pill
|
||||
* appearance of the approved design. Mobile takes the three-column short-label
|
||||
* grid (C216 scene 03); desktop keeps the long labels inline.
|
||||
*/
|
||||
export const AcervoSourceToggle = ({ source }: { source: AcervoSource }) => (
|
||||
<nav
|
||||
aria-label="Fonte do acervo"
|
||||
className="grid w-full grid-cols-3 rounded-lg bg-secondary p-1 text-center text-sm md:inline-flex md:w-auto md:text-left"
|
||||
>
|
||||
{SOURCE_TABS.map((tab) => {
|
||||
const active = tab.source === source
|
||||
return (
|
||||
<Link
|
||||
key={tab.source}
|
||||
href={buildAcervoSourceHref(tab.source)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={cn(
|
||||
'rounded-md px-1 py-2 md:px-3',
|
||||
active
|
||||
? 'bg-card font-medium text-foreground shadow-sm ring-1 ring-border'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="md:hidden">{tab.shortLabel}</span>
|
||||
<span className="max-md:hidden">{tab.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
|
|
@ -1,21 +1,33 @@
|
|||
'use client'
|
||||
|
||||
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
|
||||
import type { SpeechHighlightPart } from '@/lib/speechHighlight'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { RecordingDetailSegmentViewModel } from '@/utilities/recordings/recordingViewModels'
|
||||
|
||||
/**
|
||||
* C200 — the clickable transcript line shared by the plain C199 list and the
|
||||
* grouped C200 blocks: seeks the player to the segment and renders the search
|
||||
* highlight. `variant` keeps the two approved paddings/sizes without a twin.
|
||||
* The only contract a transcript line needs: it is structural on purpose, so
|
||||
* the Câmara/detail VMs of every acervo source (C200 recordings, C216 web
|
||||
* speeches) share one clickable segment without a twin.
|
||||
*/
|
||||
export const RecordingTranscriptSegmentButton = ({
|
||||
export type TranscriptSegment = {
|
||||
startSeconds: number
|
||||
startLabel: string
|
||||
parts: readonly SpeechHighlightPart[]
|
||||
}
|
||||
|
||||
/**
|
||||
* C200 — the clickable transcript line shared by the plain C199 list, the
|
||||
* grouped C200 blocks and the C216 web detail: seeks the player to the segment
|
||||
* and renders the search highlight. `variant` keeps the approved
|
||||
* paddings/sizes without a twin.
|
||||
*/
|
||||
export const CampaignTranscriptSegmentButton = ({
|
||||
segment,
|
||||
active,
|
||||
variant = 'plain',
|
||||
onSeek,
|
||||
}: {
|
||||
segment: RecordingDetailSegmentViewModel
|
||||
segment: TranscriptSegment
|
||||
active: boolean
|
||||
variant?: 'plain' | 'grouped'
|
||||
onSeek: (seconds: number) => void
|
||||
|
|
@ -57,11 +57,24 @@ export const SpeechAcervoFilters = ({
|
|||
state,
|
||||
filterOptions,
|
||||
themeUnavailable = false,
|
||||
omniboxLabel = 'Buscar no acervo de falas',
|
||||
omniboxPlaceholder = 'Busque por assunto, tema, município ou palavra-chave…',
|
||||
municipalityFacetLabel = 'Município',
|
||||
showPhaseFacet = true,
|
||||
filtersAriaLabel = 'Filtros do acervo',
|
||||
}: {
|
||||
state: SpeechListState
|
||||
filterOptions: SpeechFilterOptions
|
||||
/** C192 — the theme expansion is down; the selector reflects the fallback. */
|
||||
themeUnavailable?: boolean
|
||||
/** C216 — the source's copy; the defaults are the Câmara's exact strings. */
|
||||
omniboxLabel?: string
|
||||
omniboxPlaceholder?: string
|
||||
/** C216 — the web source labels the facet "Município citado". */
|
||||
municipalityFacetLabel?: string
|
||||
/** C216 — the web source has no Fase facet. */
|
||||
showPhaseFacet?: boolean
|
||||
filtersAriaLabel?: string
|
||||
}) => {
|
||||
const { navigate, isPending } = useCampaignListFilterNavigation({
|
||||
state,
|
||||
|
|
@ -83,8 +96,13 @@ export const SpeechAcervoFilters = ({
|
|||
}, [filterOptions.municipalities])
|
||||
|
||||
const chips = useMemo(
|
||||
() => buildSpeechOmniboxChips({ state: viewState, municipalityLabelsById }),
|
||||
[viewState, municipalityLabelsById],
|
||||
() =>
|
||||
buildSpeechOmniboxChips({
|
||||
state: viewState,
|
||||
municipalityLabelsById,
|
||||
municipalityLabel: municipalityFacetLabel,
|
||||
}),
|
||||
[viewState, municipalityLabelsById, municipalityFacetLabel],
|
||||
)
|
||||
|
||||
const suggestionSeeds = useMemo(
|
||||
|
|
@ -93,8 +111,9 @@ export const SpeechAcervoFilters = ({
|
|||
years: filterOptions.years,
|
||||
phases: filterOptions.phases,
|
||||
municipalityOptions: filterOptions.municipalities,
|
||||
municipalityLabel: municipalityFacetLabel,
|
||||
}),
|
||||
[filterOptions],
|
||||
[filterOptions, municipalityFacetLabel],
|
||||
)
|
||||
|
||||
const suggestions = useMemo(
|
||||
|
|
@ -176,17 +195,21 @@ export const SpeechAcervoFilters = ({
|
|||
selected: scopes,
|
||||
labels: speechScopeLabels,
|
||||
},
|
||||
{
|
||||
id: 'speech-filter-phase',
|
||||
label: 'Fase',
|
||||
prefix: 'phase',
|
||||
stateKey: 'phases',
|
||||
options: filterOptions.phases.map((phase) => ({ value: phase, label: phase })),
|
||||
selected: phases,
|
||||
},
|
||||
...(showPhaseFacet
|
||||
? [
|
||||
{
|
||||
id: 'speech-filter-phase',
|
||||
label: 'Fase',
|
||||
prefix: 'phase',
|
||||
stateKey: 'phases' as const,
|
||||
options: filterOptions.phases.map((phase) => ({ value: phase, label: phase })),
|
||||
selected: phases,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'speech-filter-municipality',
|
||||
label: 'Município',
|
||||
label: municipalityFacetLabel,
|
||||
prefix: 'municipality',
|
||||
stateKey: 'municipalities',
|
||||
options: filterOptions.municipalities,
|
||||
|
|
@ -214,8 +237,8 @@ export const SpeechAcervoFilters = ({
|
|||
>
|
||||
<CampaignListOmnibox
|
||||
id={SPEECH_OMNIBOX_ID}
|
||||
label="Buscar no acervo de falas"
|
||||
placeholder="Busque por assunto, tema, município ou palavra-chave…"
|
||||
label={omniboxLabel}
|
||||
placeholder={omniboxPlaceholder}
|
||||
chips={chips}
|
||||
suggestions={suggestions}
|
||||
query={query}
|
||||
|
|
@ -244,7 +267,7 @@ export const SpeechAcervoFilters = ({
|
|||
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Filtros do acervo"
|
||||
aria-label={filtersAriaLabel}
|
||||
className="mt-2 flex gap-2 overflow-x-auto pb-1 md:flex-wrap md:overflow-visible"
|
||||
>
|
||||
{facets.map((facet) => (
|
||||
|
|
|
|||
|
|
@ -18,11 +18,14 @@ export const SpeechResultThumbnail = ({
|
|||
src,
|
||||
label,
|
||||
fullWidthOnMobile = false,
|
||||
className,
|
||||
}: {
|
||||
href: string
|
||||
src: string
|
||||
label: string
|
||||
fullWidthOnMobile?: boolean
|
||||
/** C216 — the web card's slot (152px desktop, full width on mobile). */
|
||||
className?: string
|
||||
}) => {
|
||||
const [settled, setSettled] = useState(false)
|
||||
const imageRef = useRef<HTMLImageElement>(null)
|
||||
|
|
@ -39,6 +42,7 @@ export const SpeechResultThumbnail = ({
|
|||
className={cn(
|
||||
'relative block shrink-0 overflow-hidden rounded-lg border bg-muted',
|
||||
fullWidthOnMobile ? 'h-28 w-full md:h-20 md:w-32' : 'h-20 w-32',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{settled ? null : <Skeleton className="absolute inset-0 rounded-lg" />}
|
||||
|
|
|
|||
187
src/components/campaign/speech/WebSpeechDetailPlayer.tsx
Normal file
187
src/components/campaign/speech/WebSpeechDetailPlayer.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
'use client'
|
||||
|
||||
import { DownloadIcon, ExternalLinkIcon } from 'lucide-react'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
import { CampaignTranscriptSegmentButton } from '@/components/campaign/shared/CampaignTranscriptSegmentButton'
|
||||
import { WebSpeechPlatformPill } from '@/components/campaign/speech/WebSpeechPlatformPill'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { WebSpeechDetailViewModel } from '@/utilities/speech/speechViewModels'
|
||||
|
||||
type WebSpeechDetailPlayerProps = {
|
||||
speech: WebSpeechDetailViewModel
|
||||
/** `?t=` of the search hit; null when the detail was opened without a term. */
|
||||
initialSeconds: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* C216 — the web speech detail (design scenes 02/05): the mirrored private
|
||||
* media (native video or audio control — the artifact's audio variant), the
|
||||
* download, "Abrir na origem", the origin attribution and the clickable
|
||||
* transcript that seeks the player. Playback itself is browser territory.
|
||||
*/
|
||||
export const WebSpeechDetailPlayer = ({ speech, initialSeconds }: WebSpeechDetailPlayerProps) => {
|
||||
const mediaRef = useRef<HTMLMediaElement | null>(null)
|
||||
const [activeStart, setActiveStart] = useState<number | null>(
|
||||
initialSeconds ?? speech.segments[0]?.startSeconds ?? null,
|
||||
)
|
||||
|
||||
const seekTo = (seconds: number) => {
|
||||
const media = mediaRef.current
|
||||
if (!media) return
|
||||
media.currentTime = seconds
|
||||
setActiveStart(seconds)
|
||||
void media.play().catch(() => undefined)
|
||||
}
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
const media = mediaRef.current
|
||||
if (!media) return
|
||||
const current = media.currentTime
|
||||
const active = speech.segments.find(
|
||||
(segment) => current >= segment.startSeconds && current < segment.endSeconds,
|
||||
)
|
||||
setActiveStart(active?.startSeconds ?? null)
|
||||
}
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
const media = mediaRef.current
|
||||
if (media && initialSeconds !== null && initialSeconds > 0) {
|
||||
media.currentTime = initialSeconds
|
||||
setActiveStart(initialSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
const setMediaRef = (element: HTMLVideoElement | HTMLAudioElement | null) => {
|
||||
mediaRef.current = element
|
||||
}
|
||||
|
||||
const metaLine = [
|
||||
speech.durationLabel,
|
||||
...speech.topics.map((topic) => topic.label),
|
||||
...speech.scopes.map((scope) => scope.label),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
const badge = (
|
||||
<span className="absolute left-3 top-3 rounded border border-white/20 bg-black/60 px-2 py-1 text-[11px] text-white md:left-4 md:top-4 md:text-xs">
|
||||
Arquivo espelhado · privado
|
||||
</span>
|
||||
)
|
||||
|
||||
// The mirrored file is the product: without it the page stays honest instead
|
||||
// of rendering a player that can only fail.
|
||||
const mediaBox = speech.fileHref ? (
|
||||
speech.mediaKind === 'audio' ? (
|
||||
<div className="relative grid h-40 place-items-center overflow-hidden rounded-xl bg-stone-950 px-3 text-white md:aspect-video md:h-auto md:px-6">
|
||||
<audio
|
||||
ref={setMediaRef}
|
||||
controls
|
||||
preload="metadata"
|
||||
aria-label="Reproduzir arquivo de áudio espelhado"
|
||||
src={speech.fileHref}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onLoadedMetadata={onLoadedMetadata}
|
||||
className="w-full max-w-xl accent-primary"
|
||||
>
|
||||
Seu navegador não oferece reprodução de áudio.
|
||||
</audio>
|
||||
{badge}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<video
|
||||
ref={setMediaRef}
|
||||
controls
|
||||
preload="metadata"
|
||||
src={speech.fileHref}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onLoadedMetadata={onLoadedMetadata}
|
||||
className="aspect-video w-full rounded-xl border border-black/30 bg-black"
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
{badge}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
role="status"
|
||||
className="grid aspect-video w-full place-items-center rounded-xl border border-dashed border-input bg-stone-50 px-6 text-center"
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
O arquivo espelhado desta fala não está disponível.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="grid gap-7 lg:grid-cols-[1.2fr_.8fr]">
|
||||
<div className="min-w-0">
|
||||
{mediaBox}
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2 max-md:grid max-md:grid-cols-2">
|
||||
{speech.downloadHref ? (
|
||||
<Button asChild className="min-h-11">
|
||||
<a href={speech.downloadHref}>
|
||||
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Baixar
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{speech.sourceUrl ? (
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<a href={speech.sourceUrl} target="_blank" rel="noreferrer">
|
||||
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Abrir na origem
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-lg border border-border bg-muted/40 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Origem
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium">
|
||||
{[speech.platform.label, speech.channel].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Publicado em {speech.dateLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<WebSpeechPlatformPill platform={speech.platform.value} label={speech.platform.label} />
|
||||
<h1 className="mt-3 text-2xl font-semibold tracking-tight">{speech.title}</h1>
|
||||
{metaLine ? <p className="mt-2 text-sm text-muted-foreground">{metaLine}</p> : null}
|
||||
|
||||
{speech.segments.length > 0 ? (
|
||||
<section aria-label="Transcrição" className="mt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
Transcrição
|
||||
</p>
|
||||
<span className="text-[11px] text-muted-foreground">Clique para posicionar</span>
|
||||
</div>
|
||||
<ol className="mt-2 max-h-[28rem] space-y-0.5 overflow-y-auto">
|
||||
{speech.segments.map((segment) => (
|
||||
<li key={`${segment.startSeconds}-${segment.startLabel}`}>
|
||||
<CampaignTranscriptSegmentButton
|
||||
segment={segment}
|
||||
active={activeStart === segment.startSeconds}
|
||||
onSeek={seekTo}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
) : (
|
||||
<p className="mt-6 text-xs text-muted-foreground">
|
||||
A transcrição desta fala está vazia — o arquivo continua disponível para download.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
src/components/campaign/speech/WebSpeechPlatformPill.tsx
Normal file
34
src/components/campaign/speech/WebSpeechPlatformPill.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
import type { WebSpeechPlatform } from '@/lib/webSpeech'
|
||||
|
||||
/**
|
||||
* C216 — the platform pill of the web speeches (design scene 05): one color
|
||||
* pair per known platform and a neutral, bordered fallback for an unclassified
|
||||
* row. The same pill identifies the row in the list and in the detail.
|
||||
*/
|
||||
const PLATFORM_CLASSES: Record<WebSpeechPlatform, string> = {
|
||||
youtube: 'bg-red-50 text-primary',
|
||||
instagram: 'bg-fuchsia-50 text-fuchsia-800',
|
||||
radio: 'bg-blue-50 text-blue-800',
|
||||
audio: 'bg-amber-50 text-amber-800',
|
||||
}
|
||||
|
||||
export const WebSpeechPlatformPill = ({
|
||||
platform,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
platform: WebSpeechPlatform | null
|
||||
label: string
|
||||
className?: string
|
||||
}) => (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2 py-1 text-xs font-semibold',
|
||||
platform ? PLATFORM_CLASSES[platform] : 'border border-border bg-muted text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
101
src/components/campaign/speech/WebSpeechResultCard.tsx
Normal file
101
src/components/campaign/speech/WebSpeechResultCard.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { VideoIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
|
||||
import { SpeechResultThumbnail } from '@/components/campaign/speech/SpeechResultThumbnail'
|
||||
import { WebSpeechPlatformPill } from '@/components/campaign/speech/WebSpeechPlatformPill'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { WebSpeechListItemViewModel } from '@/utilities/speech/speechViewModels'
|
||||
|
||||
/** Same scan path as the Câmara card: the leading few chips, the rest summed. */
|
||||
const MAX_TOPIC_CHIPS = 3
|
||||
const MAX_SCOPE_CHIPS = 2
|
||||
|
||||
/**
|
||||
* C216 — one web speech in the "Falas na internet" list (design scenes 01/03/05):
|
||||
* the cover (or the honest neutral placeholder), the platform pill on the cover
|
||||
* and beside the date, the matching excerpt with the search highlight and the
|
||||
* single "Ver fala" action.
|
||||
*/
|
||||
export const WebSpeechResultCard = ({ speech }: { speech: WebSpeechListItemViewModel }) => {
|
||||
const { excerpt, thumbnailUrl, watchHref } = speech
|
||||
const metaLine = [speech.dateLabel, speech.durationLabel].filter(Boolean).join(' · ')
|
||||
const visibleTopics = speech.topics.slice(0, MAX_TOPIC_CHIPS)
|
||||
const visibleScopes = speech.scopes.slice(0, MAX_SCOPE_CHIPS)
|
||||
const hiddenChips =
|
||||
speech.topics.length - visibleTopics.length + (speech.scopes.length - visibleScopes.length)
|
||||
const hasChips = Boolean(speech.topics.length || speech.scopes.length)
|
||||
|
||||
return (
|
||||
<article className="grid gap-4 rounded-xl border border-border p-4 md:grid-cols-[152px_minmax(0,1fr)_auto]">
|
||||
<div className="relative">
|
||||
{thumbnailUrl ? (
|
||||
<SpeechResultThumbnail
|
||||
href={watchHref}
|
||||
src={thumbnailUrl}
|
||||
label={`Ver fala: ${speech.title}`}
|
||||
className="aspect-video w-full md:w-[152px]"
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
href={watchHref}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
className="relative grid aspect-video w-full place-items-center overflow-hidden rounded-lg bg-stone-200 text-stone-500 md:w-[152px]"
|
||||
>
|
||||
<VideoIcon className="size-7" aria-hidden="true" />
|
||||
<span className="absolute bottom-2 text-[10px] font-semibold">SEM CAPA</span>
|
||||
</Link>
|
||||
)}
|
||||
<span className="pointer-events-none absolute left-2 top-2 rounded bg-black/65 px-2 py-1 text-[11px] text-white">
|
||||
{speech.platform.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<WebSpeechPlatformPill platform={speech.platform.value} label={speech.platform.label} />
|
||||
<span className="text-xs text-muted-foreground">{metaLine}</span>
|
||||
</div>
|
||||
<h3 className="mt-2 text-sm font-semibold">{speech.title}</h3>
|
||||
|
||||
{excerpt.parts.length > 0 ? (
|
||||
<p className="mt-2 text-sm leading-6 text-foreground/90">
|
||||
{excerpt.truncatedStart ? '… ' : null}
|
||||
<SpeechHighlightParts parts={excerpt.parts} />
|
||||
{excerpt.truncatedEnd ? ' …' : null}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{hasChips ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{visibleTopics.map((topic) => (
|
||||
<Badge key={topic.value} variant="secondary" className="font-normal">
|
||||
{topic.label}
|
||||
</Badge>
|
||||
))}
|
||||
{visibleScopes.map((scope) => (
|
||||
<Badge key={scope.value} variant="secondary" className="font-normal">
|
||||
{scope.label}
|
||||
</Badge>
|
||||
))}
|
||||
{hiddenChips > 0 ? (
|
||||
<Badge variant="outline" className="font-normal text-muted-foreground">
|
||||
+{hiddenChips}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
variant={thumbnailUrl ? 'default' : 'outline'}
|
||||
className="min-h-11 self-end max-md:w-full"
|
||||
>
|
||||
<Link href={watchHref}>Ver fala</Link>
|
||||
</Button>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
11
src/components/campaign/speech/WebSpeechResultList.tsx
Normal file
11
src/components/campaign/speech/WebSpeechResultList.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { WebSpeechResultCard } from '@/components/campaign/speech/WebSpeechResultCard'
|
||||
import type { WebSpeechListItemViewModel } from '@/utilities/speech/speechViewModels'
|
||||
|
||||
/** C216 — the "Falas na internet" results, one card per web speech. */
|
||||
export const WebSpeechResultList = ({ rows }: { rows: readonly WebSpeechListItemViewModel[] }) => (
|
||||
<div className="space-y-3">
|
||||
{rows.map((speech) => (
|
||||
<WebSpeechResultCard key={speech.id} speech={speech} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
33
src/lib/acervoListSort.ts
Normal file
33
src/lib/acervoListSort.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* C216 — the ONE ordering vocabulary of the acervo list: the recordings source
|
||||
* (C219) and the web speeches source (C216) share the options, the labels, the
|
||||
* `recentes` default (never serialized) and the duration gate. Each domain owns
|
||||
* the mapping to its Payload order (recordings default `-createdAt`, web
|
||||
* `-speechAt`); a duration order only lists rows with a measured duration,
|
||||
* because Postgres sorts NULLS FIRST on DESC.
|
||||
*/
|
||||
|
||||
export const ACERVO_SORT_OPTIONS = [
|
||||
{ value: 'recentes', label: 'Mais recentes' },
|
||||
{ value: 'duracao_maior', label: 'Duração (maior)' },
|
||||
{ value: 'duracao_menor', label: 'Duração (menor)' },
|
||||
] as const
|
||||
|
||||
export type AcervoSortKey = (typeof ACERVO_SORT_OPTIONS)[number]['value']
|
||||
|
||||
const ACERVO_SORT_SET = new Set<string>(ACERVO_SORT_OPTIONS.map(({ value }) => value))
|
||||
|
||||
const firstParam = (value: string | string[] | undefined): string | undefined =>
|
||||
Array.isArray(value) ? value[0] : value
|
||||
|
||||
/** Only the non-default orders are meaningful; anything else (and `recentes`) is the default. */
|
||||
export const parseAcervoSort = (raw: string | string[] | undefined): AcervoSortKey | undefined => {
|
||||
const value = firstParam(raw)
|
||||
return value && value !== 'recentes' && ACERVO_SORT_SET.has(value)
|
||||
? (value as AcervoSortKey)
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** Duration orders only make sense over rows with a measured duration. */
|
||||
export const acervoSortIsDuration = (sort: AcervoSortKey | undefined): boolean =>
|
||||
sort === 'duracao_maior' || sort === 'duracao_menor'
|
||||
40
src/lib/acervoSource.ts
Normal file
40
src/lib/acervoSource.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* C216 — source vocabulary of the acervo. `camara` is the default; `enviadas`
|
||||
* is the uploaded-recordings source (C199) and `internet` the web speeches
|
||||
* (C216). Pure/client-safe: the source switcher, the page dispatcher and the
|
||||
* URL contracts share it (importing the vocabulary from `recordings` inside
|
||||
* `speech*` would create a cycle). An unknown value is fail-closed on the
|
||||
* Câmara source — the same rule the URL contracts apply.
|
||||
*/
|
||||
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
|
||||
|
||||
export type RawAcervoSourceParams = Record<string, string | string[] | undefined>
|
||||
|
||||
export const ACERVO_SOURCE_PARAM = 'source'
|
||||
export const ACERVO_SOURCE_ENVIADAS = 'enviadas'
|
||||
export const ACERVO_SOURCE_INTERNET = 'internet'
|
||||
|
||||
/** Which acervo source the page is showing. `camara` is the default. */
|
||||
export type AcervoSource = 'camara' | 'enviadas' | 'internet'
|
||||
|
||||
const firstParam = (value: string | string[] | undefined): string | undefined =>
|
||||
Array.isArray(value) ? value[0] : value
|
||||
|
||||
/** Only the explicit values select a source; anything else is the Câmara default. */
|
||||
export const parseAcervoSource = (params: RawAcervoSourceParams): AcervoSource => {
|
||||
const value = firstParam(params[ACERVO_SOURCE_PARAM])
|
||||
if (value === ACERVO_SOURCE_ENVIADAS) return 'enviadas'
|
||||
if (value === ACERVO_SOURCE_INTERNET) return 'internet'
|
||||
return 'camara'
|
||||
}
|
||||
|
||||
/** Toggle hrefs of the source switcher (the Câmara side is the bare acervo). */
|
||||
export const buildAcervoSourceHref = (source: AcervoSource): string => {
|
||||
if (source === 'enviadas') {
|
||||
return `${CAMPAIGN_COMMUNICATION_ACERVO}?${ACERVO_SOURCE_PARAM}=${ACERVO_SOURCE_ENVIADAS}`
|
||||
}
|
||||
if (source === 'internet') {
|
||||
return `${CAMPAIGN_COMMUNICATION_ACERVO}?${ACERVO_SOURCE_PARAM}=${ACERVO_SOURCE_INTERNET}`
|
||||
}
|
||||
return CAMPAIGN_COMMUNICATION_ACERVO
|
||||
}
|
||||
|
|
@ -122,7 +122,8 @@ export const campaignPageChromeCatalog = {
|
|||
},
|
||||
acervo: {
|
||||
title: 'Acervo de falas',
|
||||
subtitle: 'Busque nas falas da Câmara ou nas gravações da equipe.',
|
||||
subtitle:
|
||||
'Busque nas falas da Câmara, nas gravações da equipe ou nas falas encontradas na internet.',
|
||||
},
|
||||
gravacoes: {
|
||||
title: 'Gravações enviadas',
|
||||
|
|
|
|||
|
|
@ -54,6 +54,19 @@ export const campaignSpeechCutRetryHref = (id: number): string =>
|
|||
export const CAMPAIGN_COMMUNICATION_ACERVO_GRAVACOES =
|
||||
`${CAMPAIGN_COMMUNICATION_ACERVO}/gravacoes` as const
|
||||
|
||||
/** C216 — "Falas na internet" detail (the list stays at `?source=internet`). */
|
||||
const CAMPAIGN_COMMUNICATION_ACERVO_INTERNET = `${CAMPAIGN_COMMUNICATION_ACERVO}/internet` as const
|
||||
|
||||
export const campaignInternetSpeechHref = (id: number): string =>
|
||||
`${CAMPAIGN_COMMUNICATION_ACERVO_INTERNET}/${id}`
|
||||
|
||||
/** C216 — the authenticated private media routes of one web speech. */
|
||||
export const campaignInternetSpeechFileHref = (id: number, download = false): string =>
|
||||
`${campaignInternetSpeechHref(id)}/arquivo${download ? '?download=1' : ''}`
|
||||
|
||||
export const campaignInternetSpeechCoverHref = (id: number): string =>
|
||||
`${campaignInternetSpeechHref(id)}/capa`
|
||||
|
||||
/** C211 — internal Central de Conteúdos (list + `/<id>` ficha). */
|
||||
export const CAMPAIGN_COMMUNICATION_CONTEUDOS = '/campanha/comunicacao/conteudos' as const
|
||||
|
||||
|
|
|
|||
|
|
@ -22,8 +22,13 @@ const WEB_SPEECH_PLATFORM_VALUES = WEB_SPEECH_PLATFORMS.map((platform) => platfo
|
|||
...WebSpeechPlatform[],
|
||||
]
|
||||
|
||||
export const webSpeechPlatformLabel = (value: WebSpeechPlatform): string =>
|
||||
WEB_SPEECH_PLATFORMS.find((platform) => platform.value === value)?.label ?? value
|
||||
/** The honest label of a row without a known platform (C216 list/detail pill). */
|
||||
const WEB_SPEECH_PLATFORM_UNKNOWN_LABEL = 'Outra plataforma'
|
||||
|
||||
export const webSpeechPlatformLabel = (value: WebSpeechPlatform | null | undefined): string => {
|
||||
if (!value) return WEB_SPEECH_PLATFORM_UNKNOWN_LABEL
|
||||
return WEB_SPEECH_PLATFORMS.find((platform) => platform.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
/** Owner of the private upload collection slug (collection, config and S3). */
|
||||
export const INTERNET_SPEECH_MEDIA_SLUG = 'internetSpeechMedia' as const
|
||||
|
|
@ -135,6 +140,40 @@ export const isCompleteWebSpeechState = (
|
|||
state: { segmentCount: number; mirroredMedia: number | null } | null,
|
||||
): boolean => state !== null && state.segmentCount > 0 && state.mirroredMedia !== null
|
||||
|
||||
const DOWNLOAD_FILENAME_MAX_LENGTH = 120
|
||||
|
||||
/**
|
||||
* C216 (closes the C215 S4 defer) — the legible download name of a mirrored
|
||||
* file: the speech title keeps the stored extension, control characters and
|
||||
* path separators become spaces, dots/dashes stop trailing and the length is
|
||||
* capped. Returns null when the stored file has no name or the title leaves
|
||||
* nothing readable, so the caller falls back to the stored filename. The final
|
||||
* `Content-Disposition` sanitization belongs to `privateMediaContentDisposition`.
|
||||
*/
|
||||
export const webSpeechDownloadFilename = ({
|
||||
title,
|
||||
storedFilename,
|
||||
}: {
|
||||
title?: string | null
|
||||
storedFilename?: string | null
|
||||
}): string | null => {
|
||||
const stored = storedFilename?.trim()
|
||||
if (!stored) return null
|
||||
|
||||
const lastDot = stored.lastIndexOf('.')
|
||||
const extension = lastDot > 0 ? stored.slice(lastDot).toLowerCase() : ''
|
||||
const base = (title ?? '')
|
||||
.normalize('NFC')
|
||||
.replace(/[\p{Cc}"\\/:*?<>|]+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (!base) return null
|
||||
|
||||
const maxBaseLength = Math.max(1, DOWNLOAD_FILENAME_MAX_LENGTH - extension.length)
|
||||
const capped = base.slice(0, maxBaseLength).replace(/[.\s]+$/, '')
|
||||
return capped ? `${capped}${extension}` : null
|
||||
}
|
||||
|
||||
const PUBLISHED_AT_PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?$/
|
||||
const TIMEZONE_SUFFIX = /(?:Z|[+-]\d{2}:?\d{2})$/
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
/**
|
||||
* C199/C219 — source switcher + list URL contract of the uploaded recordings.
|
||||
* The Câmara acervo keeps its own frozen contract (`speechListUrl`); the
|
||||
* recordings branch lives on the same `/campanha/comunicacao/acervo` page
|
||||
* behind `?source=enviadas` and owns its parses/serialization over the shared
|
||||
* helpers. C219 brings the parity facets — search mode, year, topic, scope,
|
||||
* cited municipality, duration and sort — reusing the Câmara taxonomy, the
|
||||
* duration buckets and the structural year/municipality parsers. The original
|
||||
* `?source=enviadas&q&person&page` deep links stay byte-identical: the new
|
||||
* params are only serialized when selected and `mode=termo`/the default sort
|
||||
* are never serialized.
|
||||
* C199/C219 — list URL contract of the uploaded recordings. The Câmara acervo
|
||||
* keeps its own frozen contract (`speechListUrl`); the recordings branch lives
|
||||
* on the same `/campanha/comunicacao/acervo` page behind `?source=enviadas` and
|
||||
* owns its parses/serialization over the shared helpers. C219 brings the parity
|
||||
* facets — search mode, year, topic, scope, cited municipality, duration and
|
||||
* sort — reusing the Câmara taxonomy, the duration buckets and the structural
|
||||
* year/municipality parsers. The original `?source=enviadas&q&person&page` deep
|
||||
* links stay byte-identical: the new params are only serialized when selected
|
||||
* and `mode=termo`/the default sort are never serialized. C216 moved the source
|
||||
* vocabulary (`acervoSource`) and the sort vocabulary (`acervoListSort`) to
|
||||
* `src/lib` — this module keeps only the recording's own mapping.
|
||||
*/
|
||||
import { acervoSortIsDuration, parseAcervoSort, type AcervoSortKey } from '@/lib/acervoListSort'
|
||||
import { ACERVO_SOURCE_ENVIADAS, ACERVO_SOURCE_PARAM } from '@/lib/acervoSource'
|
||||
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
|
||||
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
|
||||
import { RECORDING_SPEAKER_LABEL_MAX_LENGTH } from '@/lib/recording'
|
||||
|
|
@ -40,24 +43,12 @@ import {
|
|||
|
||||
export const recordingPageSize = 25
|
||||
|
||||
/** Which acervo source the page is showing. `camara` is the default. */
|
||||
export type AcervoSource = 'camara' | 'enviadas'
|
||||
|
||||
/**
|
||||
* C219 — list ordering. `recentes` is the default and never serialized (the
|
||||
* stored contract stays `-createdAt`); the duration orders only list rows with
|
||||
* a measured duration, which is why the `where` gates `durationSeconds`.
|
||||
* C219/C216 — list ordering. The vocabulary (options, labels, `recentes`
|
||||
* default, duration gate) is shared with the web speeches source in
|
||||
* `@/lib/acervoListSort`; this module owns only the mapping to the recording's
|
||||
* Payload order (`-createdAt` default).
|
||||
*/
|
||||
export const RECORDING_SORT_OPTIONS = [
|
||||
{ value: 'recentes', label: 'Mais recentes' },
|
||||
{ value: 'duracao_maior', label: 'Duração (maior)' },
|
||||
{ value: 'duracao_menor', label: 'Duração (menor)' },
|
||||
] as const
|
||||
|
||||
export type RecordingSortKey = (typeof RECORDING_SORT_OPTIONS)[number]['value']
|
||||
|
||||
const ACERVO_SOURCE_PARAM = 'source'
|
||||
const ACERVO_SOURCE_ENVIADAS = 'enviadas'
|
||||
const ACERVO_PERSON_PARAM = 'person'
|
||||
|
||||
const recordingListParamNames = [
|
||||
|
|
@ -77,7 +68,6 @@ const recordingListParamNameSet = new Set<string>(recordingListParamNames)
|
|||
const recordingTopicSet = new Set<string>(SPEECH_TOPICS.map(({ value }) => value))
|
||||
const recordingScopeSet = new Set<string>(SPEECH_SCOPES.map(({ value }) => value))
|
||||
const recordingDurationSet = new Set<string>(SPEECH_DURATION_BUCKETS.map(({ value }) => value))
|
||||
const recordingSortSet = new Set<string>(RECORDING_SORT_OPTIONS.map(({ value }) => value))
|
||||
|
||||
export type RecordingListState = {
|
||||
source: 'enviadas'
|
||||
|
|
@ -91,7 +81,7 @@ export type RecordingListState = {
|
|||
/** Cited municipalities (`mentionedMunicipalities`), not the uploader's. */
|
||||
municipalities?: number[]
|
||||
durations?: SpeechDurationBucket[]
|
||||
sort?: RecordingSortKey
|
||||
sort?: AcervoSortKey
|
||||
/** Selected "Pessoa" labels; repeated `person` params, literal text. */
|
||||
people?: string[]
|
||||
}
|
||||
|
|
@ -103,10 +93,6 @@ export type RecordingFilterOptions = {
|
|||
municipalities: { value: string; label: string }[]
|
||||
}
|
||||
|
||||
/** `enviadas` only for the explicit value; anything else is the Câmara default. */
|
||||
export const parseAcervoSource = (params: RawSearchParams): AcervoSource =>
|
||||
firstValue(params[ACERVO_SOURCE_PARAM]) === ACERVO_SOURCE_ENVIADAS ? 'enviadas' : 'camara'
|
||||
|
||||
/**
|
||||
* Parses the repeated `person` filter: trims, drops empty and oversized values
|
||||
* (a label never exceeds the field limit, so an oversized token can only be a
|
||||
|
|
@ -130,14 +116,6 @@ const parseRecordingSearchMode = (
|
|||
raw: string | string[] | undefined,
|
||||
): SpeechSearchMode | undefined => (firstValue(raw) === 'tema' ? 'tema' : undefined)
|
||||
|
||||
/** Only the non-default duration orders are meaningful; anything else is `recentes`. */
|
||||
const parseRecordingSort = (raw: string | string[] | undefined): RecordingSortKey | undefined => {
|
||||
const value = firstValue(raw)
|
||||
return value && value !== 'recentes' && recordingSortSet.has(value)
|
||||
? (value as RecordingSortKey)
|
||||
: undefined
|
||||
}
|
||||
|
||||
export const parseRecordingListParams = (params: RawSearchParams): RecordingListState => {
|
||||
const rawPage = strictDecimalInteger(firstValue(params.page))
|
||||
const rawQ = normalizedText(firstValue(params.q))
|
||||
|
|
@ -151,7 +129,7 @@ export const parseRecordingListParams = (params: RawSearchParams): RecordingList
|
|||
params.duration,
|
||||
recordingDurationSet,
|
||||
)
|
||||
const sort = parseRecordingSort(params.sort)
|
||||
const sort = parseAcervoSort(params.sort)
|
||||
const people = parseRecordingPeople(params[ACERVO_PERSON_PARAM])
|
||||
|
||||
return {
|
||||
|
|
@ -267,7 +245,7 @@ export const recordingSortOrder = (state: RecordingListState): string => {
|
|||
|
||||
/** Duration orders only make sense over rows with a measured duration. */
|
||||
export const recordingSortIsDuration = (state: RecordingListState): boolean =>
|
||||
state.sort === 'duracao_maior' || state.sort === 'duracao_menor'
|
||||
acervoSortIsDuration(state.sort)
|
||||
|
||||
/**
|
||||
* C219 — whether the actor narrowed the list (query/mode or any facet). The
|
||||
|
|
@ -286,12 +264,6 @@ export const recordingHasActiveFilters = (state: RecordingListState): boolean =>
|
|||
state.people?.length,
|
||||
)
|
||||
|
||||
/** Toggle hrefs of the source switcher (the Câmara side is the bare acervo). */
|
||||
export const buildAcervoSourceHref = (source: AcervoSource): string =>
|
||||
source === 'enviadas'
|
||||
? `${CAMPAIGN_COMMUNICATION_ACERVO}?${ACERVO_SOURCE_PARAM}=${ACERVO_SOURCE_ENVIADAS}`
|
||||
: CAMPAIGN_COMMUNICATION_ACERVO
|
||||
|
||||
export const resolveRecordingListUrl = (
|
||||
params: RawSearchParams,
|
||||
totalPages?: number,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ import type { Where } from 'payload'
|
|||
|
||||
import { normalizeForSearch } from '@/lib/speechSearch'
|
||||
import { collapseListWhereOrBranches } from '@/utilities/campaignListUrl'
|
||||
import type { SpeechDurationBucket, SpeechListState } from '@/utilities/speech/speechListUrl'
|
||||
import {
|
||||
webSpeechSortIsDuration,
|
||||
type SpeechDurationBucket,
|
||||
type SpeechListState,
|
||||
} from '@/utilities/speech/speechListUrl'
|
||||
|
||||
const DURATION_MEDIA_MIN_SECONDS = 120
|
||||
const DURATION_LONGA_MIN_SECONDS = 300
|
||||
|
|
@ -36,14 +40,16 @@ export const durationBucketWhere = (bucket: SpeechDurationBucket): Where => {
|
|||
}
|
||||
|
||||
const buildSpeechFacetWhere = (state: SpeechListState): Where[] => {
|
||||
// C215 — this contract is the Câmara list: the web speeches share the table
|
||||
// but are served by their own source (C216), never mixed here.
|
||||
const filters: Where[] = [{ origin: { equals: 'camara' } }]
|
||||
// C215/C216 — ONE discriminator decides the source: the web list carries
|
||||
// `source=internet` and reads `origin: web`; everything else is the Câmara
|
||||
// list (the web speeches are never mixed here). The Fase facet is Câmara-only.
|
||||
const isWeb = state.source === 'internet'
|
||||
const filters: Where[] = [{ origin: { equals: isWeb ? 'web' : 'camara' } }]
|
||||
|
||||
if (state.years?.length) filters.push({ year: { in: state.years } })
|
||||
if (state.topics?.length) filters.push({ topics: { in: state.topics } })
|
||||
if (state.scopes?.length) filters.push({ scopes: { in: state.scopes } })
|
||||
if (state.phases?.length) filters.push({ phase: { in: state.phases } })
|
||||
if (!isWeb && state.phases?.length) filters.push({ phase: { in: state.phases } })
|
||||
if (state.municipalities?.length) {
|
||||
filters.push({ mentionedMunicipalities: { in: state.municipalities } })
|
||||
}
|
||||
|
|
@ -51,6 +57,10 @@ const buildSpeechFacetWhere = (state: SpeechListState): Where[] => {
|
|||
const branch = collapseListWhereOrBranches(state.durations.map(durationBucketWhere))
|
||||
if (branch) filters.push(branch)
|
||||
}
|
||||
// C216 — a duration order only lists rows with a measured duration (Postgres
|
||||
// sorts NULLS FIRST on DESC, so without the gate "Duração (maior)" would open
|
||||
// with the rows that have no duration).
|
||||
if (webSpeechSortIsDuration(state)) filters.push({ durationSeconds: { exists: true } })
|
||||
|
||||
return filters
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
* member equals no filter); year/phase/municipality are data-driven and
|
||||
* validated structurally.
|
||||
*/
|
||||
import { acervoSortIsDuration, parseAcervoSort, type AcervoSortKey } from '@/lib/acervoListSort'
|
||||
import { ACERVO_SOURCE_INTERNET, parseAcervoSource } from '@/lib/acervoSource'
|
||||
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
|
||||
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
|
||||
import {
|
||||
|
|
@ -59,15 +61,23 @@ export const speechScopeLabels = Object.fromEntries(
|
|||
) as Record<SpeechScope, string>
|
||||
|
||||
export type SpeechListState = {
|
||||
/**
|
||||
* C216 — the web speeches source. Absent means the Câmara source; only the
|
||||
* exact `source=internet` value is recognized (fail-closed on the default).
|
||||
*/
|
||||
source?: 'internet'
|
||||
page: number
|
||||
q?: string
|
||||
mode?: SpeechSearchMode
|
||||
years?: number[]
|
||||
topics?: SpeechTopic[]
|
||||
scopes?: SpeechScope[]
|
||||
/** Câmara-only facet: `phase` is ignored (and not serialized) for the web. */
|
||||
phases?: string[]
|
||||
municipalities?: number[]
|
||||
durations?: SpeechDurationBucket[]
|
||||
/** C216 — the web list ordering; the Câmara contract has no sort. */
|
||||
sort?: AcervoSortKey
|
||||
}
|
||||
|
||||
/** Server-loaded filter options (years/phases present, cited municipalities). */
|
||||
|
|
@ -80,6 +90,9 @@ export type SpeechFilterOptions = {
|
|||
type RawSpeechListParams = RawSearchParams
|
||||
|
||||
const speechListParamNames = [
|
||||
// C216 — additive params of the web speeches source; the Câmara URL contract
|
||||
// keeps its bytes (they are never serialized without `source=internet`).
|
||||
'source',
|
||||
'q',
|
||||
'mode',
|
||||
'year',
|
||||
|
|
@ -88,6 +101,7 @@ const speechListParamNames = [
|
|||
'phase',
|
||||
'municipality',
|
||||
'duration',
|
||||
'sort',
|
||||
'page',
|
||||
] as const
|
||||
|
||||
|
|
@ -102,6 +116,10 @@ const parseSpeechSearchMode = (raw: string | string[] | undefined): SpeechSearch
|
|||
firstValue(raw) === 'tema' ? 'tema' : undefined
|
||||
|
||||
export const parseSpeechListParams = (params: RawSpeechListParams): SpeechListState => {
|
||||
// C216 — the web source is a dimension of the same contract. Unknown source
|
||||
// values fall back to the Câmara (fail-closed); the web source has no Fase
|
||||
// facet, so `phase` is ignored there instead of leaking into its canonical URL.
|
||||
const isWeb = parseAcervoSource(params) === 'internet'
|
||||
const rawPage = strictDecimalInteger(firstValue(params.page))
|
||||
const rawQ = normalizedText(firstValue(params.q))
|
||||
const q = rawQ && isContactSearchQueryReady(rawQ) ? rawQ : undefined
|
||||
|
|
@ -109,14 +127,18 @@ export const parseSpeechListParams = (params: RawSpeechListParams): SpeechListSt
|
|||
const years = parseYearValues(params.year)
|
||||
const topics = parseExhaustiveEnumParam<SpeechTopic>(params.topic, speechTopicSet)
|
||||
const scopes = parseExhaustiveEnumParam<SpeechScope>(params.scope, speechScopeSet)
|
||||
const phases = allParamValues(params.phase).filter((token) => token.length <= MAX_PHASE_LENGTH)
|
||||
const phases = isWeb
|
||||
? []
|
||||
: allParamValues(params.phase).filter((token) => token.length <= MAX_PHASE_LENGTH)
|
||||
const municipalities = parseMunicipalityValues(params.municipality)
|
||||
const durations = parseExhaustiveEnumParam<SpeechDurationBucket>(
|
||||
params.duration,
|
||||
speechDurationSet,
|
||||
)
|
||||
const sort = isWeb ? parseAcervoSort(params.sort) : undefined
|
||||
|
||||
return {
|
||||
...(isWeb ? { source: ACERVO_SOURCE_INTERNET } : {}),
|
||||
page: rawPage ?? 1,
|
||||
...(q ? { q } : {}),
|
||||
...(mode ? { mode } : {}),
|
||||
|
|
@ -126,6 +148,7 @@ export const parseSpeechListParams = (params: RawSpeechListParams): SpeechListSt
|
|||
...(phases.length ? { phases } : {}),
|
||||
...(municipalities.length ? { municipalities } : {}),
|
||||
...(durations.length ? { durations } : {}),
|
||||
...(sort ? { sort } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -133,6 +156,7 @@ const speechListStateToRawParams = (
|
|||
state: SpeechListState,
|
||||
page = state.page,
|
||||
): RawSpeechListParams => ({
|
||||
source: state.source,
|
||||
page: String(page),
|
||||
q: state.q,
|
||||
mode: state.mode,
|
||||
|
|
@ -142,6 +166,7 @@ const speechListStateToRawParams = (
|
|||
phase: state.phases,
|
||||
municipality: state.municipalities?.map(String),
|
||||
duration: state.durations,
|
||||
sort: state.sort,
|
||||
})
|
||||
|
||||
/** Expects already-canonical state (from parse or a rule-preserving toggle). */
|
||||
|
|
@ -149,6 +174,9 @@ export const serializeCanonicalSpeechListSearchParams = (
|
|||
canonicalState: SpeechListState,
|
||||
): URLSearchParams => {
|
||||
const params = new URLSearchParams()
|
||||
// C216 — `source=internet` always serialized: it is what selects the web
|
||||
// source. The Câmara URL keeps its exact bytes (no source, no sort).
|
||||
if (canonicalState.source === 'internet') params.set('source', 'internet')
|
||||
if (canonicalState.q) params.set('q', canonicalState.q)
|
||||
// `termo` is the default and never serialized; `tema` only makes sense with a
|
||||
// query, so a theme param without `q` canonicalizes away.
|
||||
|
|
@ -161,6 +189,11 @@ export const serializeCanonicalSpeechListSearchParams = (
|
|||
params.append('municipality', String(municipality))
|
||||
}
|
||||
for (const duration of canonicalState.durations ?? []) params.append('duration', duration)
|
||||
// C216 — `recentes` is the default and never serialized; the sort belongs to
|
||||
// the web source only.
|
||||
if (canonicalState.source === 'internet' && canonicalState.sort) {
|
||||
params.set('sort', canonicalState.sort)
|
||||
}
|
||||
if (canonicalState.page > 1) params.set('page', String(canonicalState.page))
|
||||
return params
|
||||
}
|
||||
|
|
@ -176,6 +209,44 @@ export const buildSpeechListHref = (state: SpeechListState, page: number): strin
|
|||
export const buildSpeechFiltersKey = (state: SpeechListState): string =>
|
||||
buildSpeechListSearchParams(state).toString()
|
||||
|
||||
/**
|
||||
* C216 — Payload sort order of the web list: the default is the publication
|
||||
* order (`-speechAt`) and a duration order only lists rows with a measured
|
||||
* duration (the `where` gates `durationSeconds exists`; Postgres sorts NULLS
|
||||
* FIRST on DESC).
|
||||
*/
|
||||
export const webSpeechSortOrder = (state: SpeechListState): string => {
|
||||
switch (state.sort) {
|
||||
case 'duracao_maior':
|
||||
return '-durationSeconds'
|
||||
case 'duracao_menor':
|
||||
return 'durationSeconds'
|
||||
default:
|
||||
return '-speechAt'
|
||||
}
|
||||
}
|
||||
|
||||
/** C216 — the duration gate of the web list, mirroring the recordings source. */
|
||||
export const webSpeechSortIsDuration = (state: SpeechListState): boolean =>
|
||||
state.source === 'internet' && acervoSortIsDuration(state.sort)
|
||||
|
||||
/**
|
||||
* C216 — whether the actor narrowed the list (query/mode or any facet). The
|
||||
* empty state uses it to tell "nothing was ever catalogued" from "the filters
|
||||
* hid everything"; `source` and pagination are not filters.
|
||||
*/
|
||||
export const speechHasActiveFilters = (state: SpeechListState): boolean =>
|
||||
Boolean(
|
||||
state.q ||
|
||||
state.mode ||
|
||||
state.years?.length ||
|
||||
state.topics?.length ||
|
||||
state.scopes?.length ||
|
||||
state.phases?.length ||
|
||||
state.municipalities?.length ||
|
||||
state.durations?.length,
|
||||
)
|
||||
|
||||
export const resolveSpeechListUrl = (
|
||||
params: RawSpeechListParams,
|
||||
totalPages?: number,
|
||||
|
|
|
|||
|
|
@ -104,9 +104,12 @@ const mutateFacet = (
|
|||
export const buildSpeechOmniboxChips = ({
|
||||
state,
|
||||
municipalityLabelsById,
|
||||
municipalityLabel = 'Município',
|
||||
}: {
|
||||
state: SpeechListState
|
||||
municipalityLabelsById: ReadonlyMap<number, string>
|
||||
/** C216 — the web source labels the facet "Município citado". */
|
||||
municipalityLabel?: string
|
||||
}): CampaignListOmniboxChip[] => {
|
||||
const chips: CampaignListOmniboxChip[] = []
|
||||
|
||||
|
|
@ -127,7 +130,7 @@ export const buildSpeechOmniboxChips = ({
|
|||
chips.push({
|
||||
id: `municipality:${municipality}`,
|
||||
label: chipLabel(
|
||||
'Município',
|
||||
municipalityLabel,
|
||||
municipalityLabelsById.get(municipality) ?? `Município #${municipality}`,
|
||||
),
|
||||
})
|
||||
|
|
@ -146,10 +149,13 @@ export const buildSpeechOmniboxSuggestionSeeds = ({
|
|||
years,
|
||||
phases,
|
||||
municipalityOptions,
|
||||
municipalityLabel = 'Município',
|
||||
}: {
|
||||
years: readonly number[]
|
||||
phases: readonly string[]
|
||||
municipalityOptions: readonly SpeechFilterOption[]
|
||||
/** C216 — the web source labels the facet "Município citado". */
|
||||
municipalityLabel?: string
|
||||
}) => {
|
||||
const seeds = []
|
||||
|
||||
|
|
@ -206,7 +212,7 @@ export const buildSpeechOmniboxSuggestionSeeds = ({
|
|||
seeds.push(
|
||||
createOmniboxSuggestionSeed({
|
||||
id: `municipality:${option.value}`,
|
||||
group: 'Município',
|
||||
group: municipalityLabel,
|
||||
label: option.label,
|
||||
keywords: ['municipio', 'cidade'],
|
||||
}),
|
||||
|
|
@ -271,7 +277,14 @@ export const applySpeechSearchMode = ({
|
|||
state: withPageReset({ ...state, mode: mode === 'tema' && state.q ? 'tema' : undefined }),
|
||||
})
|
||||
|
||||
export const clearSpeechOmnibox = (_state: SpeechListState): SpeechOmniboxAction => ({
|
||||
/**
|
||||
* C216 — clearing keeps `source: 'internet'`: the source is what selects the
|
||||
* web list, so dropping it would send "clear" back to the Câmara.
|
||||
*/
|
||||
export const clearSpeechOmnibox = (state: SpeechListState): SpeechOmniboxAction => ({
|
||||
kind: 'clear',
|
||||
state: { page: 1 },
|
||||
state: {
|
||||
page: 1,
|
||||
...(state.source === 'internet' ? { source: 'internet' as const } : {}),
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,10 +16,14 @@ import {
|
|||
loadSpeechCutOriginSpeechIds,
|
||||
loadSpeechCutsForSpeeches,
|
||||
} from '@/utilities/speech/speechCutPageData'
|
||||
import { buildSpeechListWhereIncludingCutOrigins } from '@/utilities/speech/speechListFilters'
|
||||
import {
|
||||
buildSpeechListWhere,
|
||||
buildSpeechListWhereIncludingCutOrigins,
|
||||
} from '@/utilities/speech/speechListFilters'
|
||||
import {
|
||||
resolveSpeechListUrl,
|
||||
speechPageSize,
|
||||
webSpeechSortOrder,
|
||||
type SpeechFilterOptions,
|
||||
type SpeechListState,
|
||||
} from '@/utilities/speech/speechListUrl'
|
||||
|
|
@ -27,10 +31,16 @@ import {
|
|||
municipalityIdsOfSpeech,
|
||||
toSpeechDetailViewModel,
|
||||
toSpeechListItemViewModel,
|
||||
toWebSpeechDetailViewModel,
|
||||
toWebSpeechListItemViewModel,
|
||||
type SpeechDetailRecord,
|
||||
type SpeechDetailViewModel,
|
||||
type SpeechListItemViewModel,
|
||||
type SpeechSegmentRecord,
|
||||
type WebSpeechDetailRecord,
|
||||
type WebSpeechDetailViewModel,
|
||||
type WebSpeechListItemViewModel,
|
||||
type WebSpeechListRecord,
|
||||
} from '@/utilities/speech/speechViewModels'
|
||||
|
||||
type SpeechListSearchParams = Record<string, string | string[] | undefined>
|
||||
|
|
@ -77,23 +87,24 @@ const municipalityNameMap = (
|
|||
): Map<number, string> => new Map([...labels.entries()].map(([id, entry]) => [id, entry.name]))
|
||||
|
||||
/**
|
||||
* Options the URL filters offer: the years and phases actually present in the
|
||||
* catalog and the municipalities cited by at least one speech (labels resolved
|
||||
* through the justified `loadMunicipalityLabelsByIds` bypass — the ids come
|
||||
* from speeches the actor was already authorized to read).
|
||||
* Options the URL filters offer: the years (and, for the Câmara, the phases)
|
||||
* actually present in the source's catalog and the municipalities cited by at
|
||||
* least one speech (labels resolved through the justified
|
||||
* `loadMunicipalityLabelsByIds` bypass — the ids come from speeches the actor
|
||||
* was already authorized to read). C216 — the source discriminator keeps the
|
||||
* options of each list over its own rows; the web list has no phase facet.
|
||||
*/
|
||||
const loadSpeechFilterOptions = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
origin: 'camara' | 'web' = 'camara',
|
||||
): Promise<SpeechFilterOptions> => {
|
||||
const result = await payload.find({
|
||||
collection: 'speech',
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
// C215 — options are the Câmara catalog's; the web source (C216) builds
|
||||
// its own options from its rows.
|
||||
where: { origin: { equals: 'camara' } },
|
||||
where: { origin: { equals: origin } },
|
||||
select: { year: true, phase: true, mentionedMunicipalities: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
|
|
@ -104,7 +115,7 @@ const loadSpeechFilterOptions = async (
|
|||
const municipalityIds = new Set<number>()
|
||||
for (const speech of result.docs) {
|
||||
if (typeof speech.year === 'number') years.add(speech.year)
|
||||
if (speech.phase) phases.add(speech.phase)
|
||||
if (origin === 'camara' && speech.phase) phases.add(speech.phase)
|
||||
for (const id of municipalityIdsOfSpeech(speech)) municipalityIds.add(id)
|
||||
}
|
||||
|
||||
|
|
@ -123,6 +134,30 @@ const loadSpeechFilterOptions = async (
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* C216 — the shared theme gate of both sources: expands only for an actor who
|
||||
* may read the catalog (the `find` is still the final barrier) and only with a
|
||||
* query. The caller injects the resolver so the tests stay offline.
|
||||
*/
|
||||
const resolveSpeechThemeTerms = async ({
|
||||
state,
|
||||
role,
|
||||
expandTheme,
|
||||
}: {
|
||||
state: SpeechListState
|
||||
role: CampaignUser['role']
|
||||
expandTheme: SpeechThemeExpansionResolver
|
||||
}): Promise<{ themeTerms: readonly string[]; themeUnavailable: boolean }> => {
|
||||
const themeRequested = state.mode === 'tema' && Boolean(state.q)
|
||||
if (!themeRequested || !canReadCommunicationCatalog(role)) {
|
||||
return { themeTerms: [], themeUnavailable: false }
|
||||
}
|
||||
const expansion = await expandTheme(state.q ?? '')
|
||||
return expansion
|
||||
? { themeTerms: expansion.terms, themeUnavailable: false }
|
||||
: { themeTerms: [], themeUnavailable: true }
|
||||
}
|
||||
|
||||
export const loadSegmentsForSpeeches = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
|
|
@ -184,14 +219,11 @@ export const loadSpeechAcervoPageData = async (
|
|||
|
||||
// C192 — only expand for an actor who may read the catalog (the `find` below
|
||||
// is still the final, fail-closed barrier) and only with a query.
|
||||
const themeRequested = state.mode === 'tema' && Boolean(state.q)
|
||||
let themeTerms: readonly string[] = []
|
||||
let themeUnavailable = false
|
||||
if (themeRequested && canReadCommunicationCatalog(user.role)) {
|
||||
const expansion = await expandTheme(state.q ?? '')
|
||||
if (expansion) themeTerms = expansion.terms
|
||||
else themeUnavailable = true
|
||||
}
|
||||
const { themeTerms, themeUnavailable } = await resolveSpeechThemeTerms({
|
||||
state,
|
||||
role: user.role,
|
||||
expandTheme,
|
||||
})
|
||||
|
||||
// C174 (option B) — a speech also shows up when one of its cuts matches the
|
||||
// term by title/description, even if the speech text itself does not.
|
||||
|
|
@ -250,6 +282,179 @@ export const loadSpeechAcervoPageData = async (
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C216 — "Falas na internet" loaders. Same collection/segments/facets; the row
|
||||
// carries the web fields (origin, platform, title, channel, source URL and the
|
||||
// mirrored private media) instead of the Câmara VOD coordinates.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const webSpeechListSelect = {
|
||||
speechAt: true,
|
||||
title: true,
|
||||
platform: true,
|
||||
durationSeconds: true,
|
||||
topics: true,
|
||||
scopes: true,
|
||||
// The cover href only needs the relation's presence (depth 0 id).
|
||||
thumbnail: true,
|
||||
} as const
|
||||
|
||||
const webSpeechDetailSelect = {
|
||||
...webSpeechListSelect,
|
||||
externalId: true,
|
||||
sourceUrl: true,
|
||||
channel: true,
|
||||
mirroredMedia: true,
|
||||
} as const
|
||||
|
||||
export type WebSpeechAcervoPageData = {
|
||||
rows: WebSpeechListItemViewModel[]
|
||||
state: SpeechListState
|
||||
redirectHref?: string
|
||||
totalDocs: number
|
||||
totalPages: number
|
||||
filterOptions: SpeechFilterOptions
|
||||
/** C216 — true when `mode=tema` was asked but the expansion was unavailable. */
|
||||
themeUnavailable: boolean
|
||||
/** C216 — true when the expansion actually contributed terms. */
|
||||
themeApplied: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The web speeches list ("Falas na internet"). The source is forced here — the
|
||||
* page dispatches on `source=internet`, and forcing it makes the loader
|
||||
* self-contained (an int test calls it directly) and its canonical redirect
|
||||
* always carry the source. Only web rows are read: `buildSpeechListWhere`
|
||||
* derives `origin: web` from the state.
|
||||
*/
|
||||
export const loadWebSpeechAcervoPageData = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
searchParams: Promise<SpeechListSearchParams> | SpeechListSearchParams,
|
||||
expandTheme: SpeechThemeExpansionResolver = expandSpeechSearchTheme,
|
||||
): Promise<WebSpeechAcervoPageData> => {
|
||||
const rawSearchParams = await searchParams
|
||||
const canonicalUrl = resolveSpeechListUrl({
|
||||
...rawSearchParams,
|
||||
source: 'internet',
|
||||
})
|
||||
const state = canonicalUrl.state
|
||||
|
||||
const { themeTerms, themeUnavailable } = await resolveSpeechThemeTerms({
|
||||
state,
|
||||
role: user.role,
|
||||
expandTheme,
|
||||
})
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'speech',
|
||||
depth: 0,
|
||||
limit: speechPageSize,
|
||||
page: state.page,
|
||||
sort: webSpeechSortOrder(state),
|
||||
where: buildSpeechListWhere(state, themeTerms),
|
||||
// The row only needs its normalized text to tell whether it matched the
|
||||
// term itself; the unsearched list does not pay for it.
|
||||
select: state.q ? { ...webSpeechListSelect, searchText: true } : webSpeechListSelect,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
const resolvedUrl = resolveSpeechListUrl(
|
||||
{ ...rawSearchParams, source: 'internet' },
|
||||
result.totalPages,
|
||||
)
|
||||
const speeches = result.docs as WebSpeechListRecord[]
|
||||
const speechIds = speeches.map((speech) => speech.id)
|
||||
|
||||
const [segmentsBySpeech, filterOptions] = await Promise.all([
|
||||
loadSegmentsForSpeeches(payload, user, speechIds),
|
||||
loadSpeechFilterOptions(payload, user, 'web'),
|
||||
])
|
||||
|
||||
return {
|
||||
rows: speeches.map((speech) =>
|
||||
toWebSpeechListItemViewModel({
|
||||
speech,
|
||||
segments: segmentsBySpeech.get(speech.id) ?? [],
|
||||
query: state.q,
|
||||
themeTerms,
|
||||
}),
|
||||
),
|
||||
state: resolvedUrl.state,
|
||||
redirectHref: resolvedUrl.redirectHref ?? canonicalUrl.redirectHref,
|
||||
totalDocs: result.totalDocs,
|
||||
totalPages: result.totalPages,
|
||||
filterOptions,
|
||||
themeUnavailable,
|
||||
themeApplied: themeTerms.length > 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Title-only read for `generateMetadata`: never loads the transcript of a long
|
||||
* recording just to title the tab (same shape as the recordings detail).
|
||||
*/
|
||||
export const loadWebSpeechTitleForActor = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
speechId: number,
|
||||
): Promise<string | null> => {
|
||||
const result = await payload.find({
|
||||
collection: 'speech',
|
||||
where: { and: [{ id: { equals: speechId } }, { origin: { equals: 'web' } }] },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
select: { title: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
return result.docs[0]?.title ?? null
|
||||
}
|
||||
|
||||
export const loadWebSpeechDetailPageData = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
speechId: number,
|
||||
query?: string,
|
||||
): Promise<WebSpeechDetailViewModel> => {
|
||||
const result = await payload.find({
|
||||
collection: 'speech',
|
||||
where: { and: [{ id: { equals: speechId } }, { origin: { equals: 'web' } }] },
|
||||
depth: 1,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
select: webSpeechDetailSelect,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
const speech = result.docs[0]
|
||||
if (!speech) throw new SpeechNotFoundError()
|
||||
|
||||
const segments = await payload.find({
|
||||
collection: 'speechSegment',
|
||||
where: { speech: { equals: speechId } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
sort: 'order',
|
||||
select: segmentSelect,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
return toWebSpeechDetailViewModel({
|
||||
speech: speech as WebSpeechDetailRecord,
|
||||
segments: segments.docs.map((segment) => ({
|
||||
startSeconds: segment.startSeconds,
|
||||
endSeconds: segment.endSeconds,
|
||||
text: segment.text,
|
||||
})),
|
||||
query,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the broadcast start (ISO) of a session video. The default reads the
|
||||
* cached YouTube anchor (C172); tests inject their own to stay offline.
|
||||
|
|
@ -265,7 +470,10 @@ export const loadSpeechDetailPageData = async (
|
|||
): Promise<SpeechDetailViewModel> => {
|
||||
const result = await payload.find({
|
||||
collection: 'speech',
|
||||
where: { id: { equals: speechId } },
|
||||
// C216 — this detail is the Câmara's: a web row has its own route and
|
||||
// player, so an id of the other origin is an honest 404 here (and the
|
||||
// reverse holds in the web loader).
|
||||
where: { and: [{ id: { equals: speechId } }, { origin: { equals: 'camara' } }] },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@
|
|||
* and the view model decides labels, the matching excerpt and the links the
|
||||
* list/detail render.
|
||||
*/
|
||||
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
|
||||
import {
|
||||
CAMPAIGN_COMMUNICATION_ACERVO,
|
||||
campaignInternetSpeechCoverHref,
|
||||
campaignInternetSpeechFileHref,
|
||||
campaignInternetSpeechHref,
|
||||
} from '@/lib/campaignPaths'
|
||||
import { formatSpeechClock, formatSpeechDate, formatSpeechSpan } from '@/lib/speechClock'
|
||||
import type { SpeechCutSummaryViewModel } from '@/lib/speechCut'
|
||||
import type { SpeechExcerptSegment } from '@/lib/speechExcerpt'
|
||||
|
|
@ -31,6 +36,7 @@ import {
|
|||
speechCoverUrl,
|
||||
speechVodCoordinates,
|
||||
} from '@/lib/speechVod'
|
||||
import { webSpeechPlatformLabel, type WebSpeechPlatform } from '@/lib/webSpeech'
|
||||
import type { Municipality } from '@/payload-types'
|
||||
import { speechScopeLabels, speechTopicLabels } from '@/utilities/speech/speechListUrl'
|
||||
|
||||
|
|
@ -363,3 +369,186 @@ export const toSpeechDetailViewModel = ({
|
|||
vodResolvable,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C216 — web speeches ("Falas na internet"): the same excerpt/segment machine
|
||||
// over the mirrored private media, the origin attribution instead of the
|
||||
// Câmara VOD coordinates. Same module: the collection, the facets and the
|
||||
// formatting helpers are the shared knowledge.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type WebSpeechListRecord = {
|
||||
id: number
|
||||
speechAt: string
|
||||
title?: string | null
|
||||
platform?: WebSpeechPlatform | null
|
||||
durationSeconds?: number | null
|
||||
topics?: SpeechTopic[] | null
|
||||
scopes?: SpeechScope[] | null
|
||||
/** Upload relation at depth 0 (id) or depth 1 (object); presence drives the cover. */
|
||||
thumbnail?: number | { id: number } | null
|
||||
/** Normalized search text; lets the row mirror the Câmara theme evidence. */
|
||||
searchText?: string | null
|
||||
}
|
||||
|
||||
export type WebSpeechDetailRecord = WebSpeechListRecord & {
|
||||
channel?: string | null
|
||||
sourceUrl?: string | null
|
||||
mirroredMedia?: number | { id: number; mimeType?: string | null; filename?: string | null } | null
|
||||
}
|
||||
|
||||
type WebSpeechPlatformViewModel = { value: WebSpeechPlatform | null; label: string }
|
||||
|
||||
export type WebSpeechListItemViewModel = {
|
||||
id: number
|
||||
title: string
|
||||
platform: WebSpeechPlatformViewModel
|
||||
/** Day-only label (`dd/mm/aaaa`) — the web publication date has no session clock. */
|
||||
dateLabel: string
|
||||
durationLabel: string | null
|
||||
excerpt: SpeechHighlightedExcerpt
|
||||
topics: { value: SpeechTopic; label: string }[]
|
||||
scopes: { value: SpeechScope; label: string }[]
|
||||
/** Authenticated cover route; null when the ingestion captured no thumbnail. */
|
||||
thumbnailUrl: string | null
|
||||
/** Detail link that seeks the player to the matching segment (`?t=`) and
|
||||
* keeps the search term for the transcript highlight (`?q=`). */
|
||||
watchHref: string
|
||||
}
|
||||
|
||||
export type WebSpeechDetailViewModel = {
|
||||
id: number
|
||||
title: string
|
||||
platform: WebSpeechPlatformViewModel
|
||||
dateLabel: string
|
||||
durationLabel: string | null
|
||||
/** Who published (channel/radio/profile) — origin text, never a Contact. */
|
||||
channel: string | null
|
||||
/** Original post URL ("Abrir na origem"); null when the row has none. */
|
||||
sourceUrl: string | null
|
||||
/** Which native control the mirrored file needs. */
|
||||
mediaKind: 'video' | 'audio'
|
||||
/** Authenticated private media routes; null when the mirror is missing. */
|
||||
fileHref: string | null
|
||||
downloadHref: string | null
|
||||
segments: SpeechDetailSegmentViewModel[]
|
||||
topics: { value: SpeechTopic; label: string }[]
|
||||
scopes: { value: SpeechScope; label: string }[]
|
||||
}
|
||||
|
||||
const webPlatformViewModel = (platform: WebSpeechPlatform | null | undefined) => ({
|
||||
value: platform ?? null,
|
||||
label: webSpeechPlatformLabel(platform),
|
||||
})
|
||||
|
||||
const mediaIdOf = (value: number | { id: number } | null | undefined): number | null => {
|
||||
if (typeof value === 'number') return value
|
||||
if (value && typeof value === 'object' && typeof value.id === 'number') return value.id
|
||||
return null
|
||||
}
|
||||
|
||||
const webSpeechTitle = (speech: WebSpeechListRecord): string =>
|
||||
speech.title?.trim() || `Fala da internet #${speech.id}`
|
||||
|
||||
const buildWebSpeechWatchHref = (
|
||||
speechId: number,
|
||||
segment: SpeechSegmentRecord | undefined,
|
||||
query: string | undefined,
|
||||
): string => {
|
||||
const params = new URLSearchParams()
|
||||
if (segment) params.set('t', String(Math.max(0, Math.floor(segment.startSeconds))))
|
||||
if (query) params.set('q', query)
|
||||
const queryString = params.toString()
|
||||
return `${campaignInternetSpeechHref(speechId)}${queryString ? `?${queryString}` : ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
* List item of one web speech: platform chip, publication date + duration, the
|
||||
* matching excerpt (theme evidence preferred, like the Câmara) and the private
|
||||
* cover href. The cuts/VOD of the Câmara row have no counterpart here.
|
||||
*/
|
||||
export const toWebSpeechListItemViewModel = ({
|
||||
speech,
|
||||
segments,
|
||||
query,
|
||||
themeTerms = [],
|
||||
}: {
|
||||
speech: WebSpeechListRecord
|
||||
segments: readonly SpeechSegmentRecord[]
|
||||
query?: string
|
||||
/** C216 — expanded theme terms; empty in the literal search. */
|
||||
themeTerms?: readonly string[]
|
||||
}): WebSpeechListItemViewModel => {
|
||||
const matchedSegment = pickMatchingSegment(segments, query)
|
||||
const q = query?.trim()
|
||||
// Same evidence rule as the Câmara VM: a theme passage wins the excerpt so
|
||||
// the card shows why the row appeared.
|
||||
const themeMatch = pickThemeMatch(speech, segments, themeTerms)
|
||||
const excerptSource =
|
||||
themeMatch?.segment?.text ??
|
||||
themeMatch?.keyword ??
|
||||
matchedSegment?.text ??
|
||||
segments[0]?.text ??
|
||||
''
|
||||
|
||||
return {
|
||||
id: speech.id,
|
||||
title: webSpeechTitle(speech),
|
||||
platform: webPlatformViewModel(speech.platform),
|
||||
dateLabel: formatSpeechDate(speech.speechAt),
|
||||
durationLabel: formatSpeechDuration(speech.durationSeconds),
|
||||
excerpt: themeMatch
|
||||
? buildHighlightedExcerpt(excerptSource, themeMatch.term, { phrase: true })
|
||||
: buildHighlightedExcerpt(excerptSource, q ?? ''),
|
||||
topics: topicViewModels(speech),
|
||||
scopes: scopeViewModels(speech),
|
||||
thumbnailUrl:
|
||||
mediaIdOf(speech.thumbnail) === null ? null : campaignInternetSpeechCoverHref(speech.id),
|
||||
watchHref: buildWebSpeechWatchHref(speech.id, matchedSegment ?? themeMatch?.segment, q),
|
||||
}
|
||||
}
|
||||
|
||||
/** Audio artifacts get the native audio control; everything else is video. */
|
||||
const mediaKindOf = (media: WebSpeechDetailRecord['mirroredMedia']): 'video' | 'audio' => {
|
||||
const mimeType = media && typeof media === 'object' ? media.mimeType : null
|
||||
return mimeType?.startsWith('audio/') ? 'audio' : 'video'
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail of one web speech: the private media routes, the origin attribution
|
||||
* (platform + channel + date) and the clickable transcript highlighted by `q`.
|
||||
* `fileHref` is null when the mirror is missing — the page renders the honest
|
||||
* unavailable state instead of a broken player.
|
||||
*/
|
||||
export const toWebSpeechDetailViewModel = ({
|
||||
speech,
|
||||
segments,
|
||||
query,
|
||||
}: {
|
||||
speech: WebSpeechDetailRecord
|
||||
segments: readonly SpeechSegmentRecord[]
|
||||
query?: string
|
||||
}): WebSpeechDetailViewModel => {
|
||||
const hasMedia = mediaIdOf(speech.mirroredMedia) !== null
|
||||
|
||||
return {
|
||||
id: speech.id,
|
||||
title: webSpeechTitle(speech),
|
||||
platform: webPlatformViewModel(speech.platform),
|
||||
dateLabel: formatSpeechDate(speech.speechAt),
|
||||
durationLabel: formatSpeechDuration(speech.durationSeconds),
|
||||
channel: speech.channel ?? null,
|
||||
sourceUrl: speech.sourceUrl ?? null,
|
||||
mediaKind: mediaKindOf(speech.mirroredMedia),
|
||||
fileHref: hasMedia ? campaignInternetSpeechFileHref(speech.id) : null,
|
||||
downloadHref: hasMedia ? campaignInternetSpeechFileHref(speech.id, true) : null,
|
||||
segments: segments.map((segment) => ({
|
||||
startSeconds: segment.startSeconds,
|
||||
endSeconds: segment.endSeconds,
|
||||
startLabel: formatSpeechClock(segment.startSeconds),
|
||||
parts: splitHighlightedParts(segment.text, query ?? ''),
|
||||
})),
|
||||
topics: topicViewModels(speech),
|
||||
scopes: scopeViewModels(speech),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1019,6 +1019,349 @@ test.describe('communication vertical (C154/C162)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
test.describe('web speeches source (C216)', () => {
|
||||
// Real 2x2 JPEG: Payload runs sharp over image uploads.
|
||||
const JPEG_BYTES = Buffer.from(
|
||||
'/9j/2wBDAA0JCgsKCA0LCgsODg0PEyAVExISEyccHhcgLikxMC4pLSwzOko+MzZGNywtQFdBRkxOUlNSMj5aYVpQYEpRUk//2wBDAQ4ODhMREyYVFSZPNS01T09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT0//wAARCAACAAIDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAX/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAABAb/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCGACsH/9k=',
|
||||
'base64',
|
||||
)
|
||||
|
||||
const createWebSpeech = async (
|
||||
campaign: { fixtures: CampaignE2EOwnership },
|
||||
input: {
|
||||
marker: string
|
||||
platform?: 'youtube' | 'instagram' | 'radio' | 'audio'
|
||||
title?: string
|
||||
channel?: string
|
||||
sourceUrl?: string
|
||||
speechAt?: string
|
||||
durationSeconds?: number | null
|
||||
topics?: SpeechTopic[]
|
||||
scopes?: SpeechScope[]
|
||||
text?: string
|
||||
withMedia?: boolean
|
||||
withThumbnail?: boolean
|
||||
},
|
||||
) => {
|
||||
const media = await campaign.fixtures.payload.create({
|
||||
collection: 'internetSpeechMedia',
|
||||
data: { alt: `Mídia ${input.marker}` },
|
||||
file: {
|
||||
data: Buffer.from(`mirror-${input.marker}`),
|
||||
mimetype:
|
||||
input.platform === 'radio' || input.platform === 'audio' ? 'audio/mpeg' : 'video/mp4',
|
||||
name: `${input.marker}.mp4`,
|
||||
size: Buffer.byteLength(`mirror-${input.marker}`),
|
||||
},
|
||||
})
|
||||
const thumbnail = input.withThumbnail
|
||||
? await campaign.fixtures.payload.create({
|
||||
collection: 'internetSpeechMedia',
|
||||
data: { alt: `Capa ${input.marker}` },
|
||||
file: {
|
||||
data: JPEG_BYTES,
|
||||
mimetype: 'image/jpeg',
|
||||
name: `${input.marker}.jpg`,
|
||||
size: JPEG_BYTES.length,
|
||||
},
|
||||
})
|
||||
: null
|
||||
|
||||
const text = input.text ?? `A fala ${input.marker} na internet`
|
||||
const speech = await campaign.fixtures.payload.create({
|
||||
collection: 'speech',
|
||||
data: {
|
||||
sourceKey: `web:test:${input.marker}`,
|
||||
origin: 'web',
|
||||
platform: input.platform ?? 'youtube',
|
||||
externalId: input.marker,
|
||||
sourceUrl:
|
||||
input.sourceUrl ??
|
||||
`https://www.youtube.com/watch?v=${encodeURIComponent(input.marker)}`,
|
||||
title: input.title ?? `Fala ${input.marker}`,
|
||||
channel: input.channel ?? 'Canal do teste',
|
||||
speechAt: input.speechAt ?? '2026-09-20T00:00',
|
||||
durationSeconds: input.durationSeconds === undefined ? 120 : input.durationSeconds,
|
||||
topics: input.topics ?? ['saude'],
|
||||
scopes: input.scopes ?? ['bahia'],
|
||||
classifiedBy: 'gazetteer',
|
||||
searchText: normalizeForSearch(text),
|
||||
...(input.withMedia === false ? {} : { mirroredMedia: media.id }),
|
||||
...(thumbnail ? { thumbnail: thumbnail.id } : {}),
|
||||
},
|
||||
depth: 0,
|
||||
})
|
||||
await campaign.fixtures.payload.create({
|
||||
collection: 'speechSegment',
|
||||
data: {
|
||||
speech: speech.id,
|
||||
order: 1,
|
||||
startSeconds: 12,
|
||||
endSeconds: 20,
|
||||
text,
|
||||
searchText: normalizeForSearch(text),
|
||||
},
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
return { speech, media, thumbnail }
|
||||
}
|
||||
|
||||
test('the third source renders the row, the platform pill and the cover', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
const marker = campaign.fixtures.value('internet')
|
||||
const { speech } = await createWebSpeech(campaign, { marker, withThumbnail: true })
|
||||
const withoutCover = await createWebSpeech(campaign, {
|
||||
marker: `${marker}capa`,
|
||||
platform: 'radio',
|
||||
withThumbnail: false,
|
||||
})
|
||||
|
||||
const user = await campaign.fixtures.createCampaignUser('communicator')
|
||||
const request = await campaignRequest(user, user.password)
|
||||
|
||||
const response = await request.get(`/campanha/comunicacao/acervo?source=internet&q=${marker}`)
|
||||
expect(response.status()).toBe(200)
|
||||
const html = rendered(await response.text())
|
||||
expect(html).toContain('Falas na internet')
|
||||
expect(html).toContain('aria-current="page"')
|
||||
expect(html).toContain(`Fala ${marker}`)
|
||||
expect(html).toContain('<mark')
|
||||
expect(html).toContain(`/campanha/comunicacao/acervo/internet/${speech.id}?t=12`)
|
||||
expect(html).toContain(`/campanha/comunicacao/acervo/internet/${speech.id}/capa`)
|
||||
// The row without a captured thumbnail renders the neutral placeholder.
|
||||
expect(html).toContain('SEM CAPA')
|
||||
|
||||
// The web row never leaks into the Câmara list nor into its detail route,
|
||||
// and the Câmara row never resolves in the web detail route.
|
||||
const camaraList = rendered(
|
||||
await (await request.get(`/campanha/comunicacao/acervo?q=${marker}`)).text(),
|
||||
)
|
||||
expect(camaraList).not.toContain(`/campanha/comunicacao/acervo/internet/${speech.id}`)
|
||||
|
||||
// The page streams behind the acervo `loading.tsx`, so a `notFound()`
|
||||
// can arrive as a 404 or as the 200 shell carrying the 404 body.
|
||||
const camaraDetail = await request.get(`/campanha/comunicacao/acervo/${speech.id}`)
|
||||
expect([200, 404]).toContain(camaraDetail.status())
|
||||
expect(rendered(await camaraDetail.text())).toContain('This page could not be found')
|
||||
|
||||
const camaraSpeech = await campaign.fixtures.payload.create({
|
||||
collection: 'speech',
|
||||
data: {
|
||||
sourceKey: campaign.fixtures.value('speech'),
|
||||
origin: 'camara',
|
||||
speechAt: '2026-03-10T18:00',
|
||||
classifiedBy: 'gazetteer',
|
||||
searchText: 'fala da camara',
|
||||
},
|
||||
depth: 0,
|
||||
})
|
||||
const webDetail = await request.get(
|
||||
`/campanha/comunicacao/acervo/internet/${camaraSpeech.id}`,
|
||||
)
|
||||
expect([200, 404]).toContain(webDetail.status())
|
||||
expect(rendered(await webDetail.text())).toContain('This page could not be found')
|
||||
|
||||
const miss = await request.get('/campanha/comunicacao/acervo?source=internet&q=zzzznada')
|
||||
expect(rendered(await miss.text())).toContain('Nenhuma fala encontrada')
|
||||
expect(withoutCover.speech.id).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('the filters narrow, order and degrade honestly (C219 parity)', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
const marker = campaign.fixtures.value('internetfiltro')
|
||||
const otherMarker = `${marker}b`
|
||||
const { speech: health } = await createWebSpeech(campaign, {
|
||||
marker,
|
||||
speechAt: '2025-04-02T00:00',
|
||||
durationSeconds: 90,
|
||||
topics: ['saude'],
|
||||
text: `A fala sobre a saúde ${marker}`,
|
||||
})
|
||||
const { speech: culture } = await createWebSpeech(campaign, {
|
||||
marker: otherMarker,
|
||||
speechAt: '2023-08-02T00:00',
|
||||
durationSeconds: 400,
|
||||
topics: ['cultura'],
|
||||
text: `A fala sobre a cultura ${otherMarker}`,
|
||||
})
|
||||
const { speech: noDuration } = await createWebSpeech(campaign, {
|
||||
marker: `${marker}c`,
|
||||
durationSeconds: null,
|
||||
text: `A fala sem duração ${marker}`,
|
||||
})
|
||||
|
||||
const user = await campaign.fixtures.createCampaignUser('communicator')
|
||||
const request = await campaignRequest(user, user.password)
|
||||
|
||||
const bar = rendered(
|
||||
await (
|
||||
await request.get(`/campanha/comunicacao/acervo?source=internet&q=${marker}`)
|
||||
).text(),
|
||||
)
|
||||
for (const label of ['Ano', 'Tema', 'Alcance', 'Município citado', 'Duração']) {
|
||||
expect(bar).toContain(label)
|
||||
}
|
||||
// The web source has no Fase facet.
|
||||
expect(bar).not.toContain('Fase')
|
||||
expect(bar).toContain('Termo exato')
|
||||
expect(bar).toContain('Por tema')
|
||||
expect(bar).toContain('Ordenar por')
|
||||
expect(bar).toContain('Mais recentes')
|
||||
|
||||
const narrowed = rendered(
|
||||
await (
|
||||
await request.get(
|
||||
`/campanha/comunicacao/acervo?source=internet&q=${marker}` +
|
||||
`&year=2025&topic=saude&duration=curta`,
|
||||
)
|
||||
).text(),
|
||||
)
|
||||
expect(narrowed).toContain(`/acervo/internet/${health.id}?t=12`)
|
||||
expect(narrowed).not.toContain(`/acervo/internet/${culture.id}?t=12`)
|
||||
expect(narrowed).not.toContain(`/acervo/internet/${noDuration.id}?t=12`)
|
||||
|
||||
const empty = rendered(
|
||||
await (
|
||||
await request.get(
|
||||
`/campanha/comunicacao/acervo?source=internet&q=${marker}&year=2023&topic=saude`,
|
||||
)
|
||||
).text(),
|
||||
)
|
||||
expect(empty).toContain('Nenhuma fala encontrada')
|
||||
expect(empty).toContain('Limpar busca e filtros')
|
||||
|
||||
const longestFirst = rendered(
|
||||
await (
|
||||
await request.get(
|
||||
`/campanha/comunicacao/acervo?source=internet&q=${marker}&sort=duracao_maior`,
|
||||
)
|
||||
).text(),
|
||||
)
|
||||
const cultureAt = longestFirst.indexOf(`/acervo/internet/${culture.id}?t=12`)
|
||||
const healthAt = longestFirst.indexOf(`/acervo/internet/${health.id}?t=12`)
|
||||
expect(cultureAt).toBeGreaterThan(-1)
|
||||
expect(healthAt).toBeGreaterThan(cultureAt)
|
||||
expect(longestFirst).not.toContain(`/acervo/internet/${noDuration.id}?t=12`)
|
||||
expect(longestFirst).toContain('Falas sem duração aparecem apenas em Mais recentes.')
|
||||
})
|
||||
|
||||
test('the detail renders the private player, the transcript, the download and the origin', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
const marker = campaign.fixtures.value('internetdetalhe')
|
||||
const { speech } = await createWebSpeech(campaign, {
|
||||
marker,
|
||||
platform: 'radio',
|
||||
title: `Entrevista ${marker}`,
|
||||
channel: 'Rádio Metrópole',
|
||||
sourceUrl: 'https://radio.example/entrevista',
|
||||
})
|
||||
|
||||
const user = await campaign.fixtures.createCampaignUser('communicator')
|
||||
const request = await campaignRequest(user, user.password)
|
||||
|
||||
const response = await request.get(
|
||||
`/campanha/comunicacao/acervo/internet/${speech.id}?t=12&q=${marker}`,
|
||||
)
|
||||
expect(response.status()).toBe(200)
|
||||
const html = rendered(await response.text())
|
||||
expect(html).toContain(`Entrevista ${marker}`)
|
||||
expect(html).toContain('Rádio')
|
||||
expect(html).toContain('Rádio Metrópole')
|
||||
expect(html).toContain('Publicado em')
|
||||
expect(html).toContain('Abrir na origem')
|
||||
expect(html).toContain('https://radio.example/entrevista')
|
||||
expect(html).toContain('data-start-seconds="12"')
|
||||
expect(html).toContain('<mark')
|
||||
expect(html).toContain('Baixar')
|
||||
expect(html).toContain('Voltar para Falas na internet')
|
||||
expect(html).toContain(`/campanha/comunicacao/acervo/internet/${speech.id}/arquivo`)
|
||||
// The audio artifact gets the native audio control (design scene 05).
|
||||
expect(html).toContain('<audio')
|
||||
})
|
||||
|
||||
test('the private file and cover answer only the acervo roles, with range and download', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
const marker = campaign.fixtures.value('internetarquivo')
|
||||
const { speech, media, thumbnail } = await createWebSpeech(campaign, {
|
||||
marker,
|
||||
title: `Fala privada ${marker}`,
|
||||
withThumbnail: true,
|
||||
})
|
||||
|
||||
const communicator = await campaign.fixtures.createCampaignUser('communicator')
|
||||
const communicatorRequest = await campaignRequest(communicator, communicator.password)
|
||||
|
||||
const full = await communicatorRequest.get(
|
||||
`/campanha/comunicacao/acervo/internet/${speech.id}/arquivo`,
|
||||
)
|
||||
expect(full.status()).toBe(200)
|
||||
expect(full.headers()['content-type']).toBe('video/mp4')
|
||||
expect(full.headers()['cache-control']).toBe('private, no-store')
|
||||
expect(Buffer.from(await full.body())).toEqual(Buffer.from(`mirror-${marker}`))
|
||||
|
||||
const partial = await communicatorRequest.get(
|
||||
`/campanha/comunicacao/acervo/internet/${speech.id}/arquivo`,
|
||||
{ headers: { Range: 'bytes=0-3' } },
|
||||
)
|
||||
expect(partial.status()).toBe(206)
|
||||
|
||||
const download = await communicatorRequest.get(
|
||||
`/campanha/comunicacao/acervo/internet/${speech.id}/arquivo?download=1`,
|
||||
)
|
||||
const disposition = download.headers()['content-disposition'] ?? ''
|
||||
expect(disposition).toContain('attachment')
|
||||
// C215 S4 — the download name comes from the speech title, not `source.mp4`
|
||||
// (RFC 5987 encodes the spaces in `filename*`).
|
||||
expect(disposition).toContain('Fala%20privada')
|
||||
expect(disposition).not.toContain('source.mp4')
|
||||
|
||||
const cover = await communicatorRequest.get(
|
||||
`/campanha/comunicacao/acervo/internet/${speech.id}/capa`,
|
||||
)
|
||||
expect(cover.status()).toBe(200)
|
||||
expect(cover.headers()['content-type']).toBe('image/jpeg')
|
||||
expect(Buffer.from(await cover.body())).toEqual(JPEG_BYTES)
|
||||
|
||||
const advisor = await campaign.fixtures.createCampaignUser('advisor')
|
||||
const advisorRequest = await campaignRequest(advisor, advisor.password)
|
||||
expect(
|
||||
(
|
||||
await advisorRequest.get(`/campanha/comunicacao/acervo/internet/${speech.id}/arquivo`)
|
||||
).status(),
|
||||
).toBe(404)
|
||||
expect(
|
||||
(
|
||||
await advisorRequest.get(`/campanha/comunicacao/acervo/internet/${speech.id}/capa`)
|
||||
).status(),
|
||||
).toBe(404)
|
||||
|
||||
// A row without a captured thumbnail answers 404 on the cover route.
|
||||
const { speech: withoutCover } = await createWebSpeech(campaign, {
|
||||
marker: `${marker}semcapa`,
|
||||
withThumbnail: false,
|
||||
})
|
||||
expect(
|
||||
(
|
||||
await communicatorRequest.get(
|
||||
`/campanha/comunicacao/acervo/internet/${withoutCover.id}/capa`,
|
||||
)
|
||||
).status(),
|
||||
).toBe(404)
|
||||
|
||||
expect(media.id).toBeGreaterThan(0)
|
||||
expect(thumbnail?.id).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('content pieces (C211)', () => {
|
||||
const createPiece = async (
|
||||
campaign: { fixtures: CampaignE2EOwnership },
|
||||
|
|
|
|||
371
tests/int/webSpeechAcervo.int.spec.ts
Normal file
371
tests/int/webSpeechAcervo.int.spec.ts
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
// @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 } from 'vitest'
|
||||
|
||||
import { matchMunicipalityMentions } from '@/lib/speechGazetteer'
|
||||
import { INTERNET_SPEECH_MEDIA_SLUG } from '@/lib/webSpeech'
|
||||
import config from '@/payload.config'
|
||||
import { upsertSpeechBundle, type SpeechImportBundle } from '@/utilities/speech/speechImport'
|
||||
import {
|
||||
loadSpeechAcervoPageData,
|
||||
loadSpeechDetailPageData,
|
||||
loadWebSpeechAcervoPageData,
|
||||
loadWebSpeechDetailPageData,
|
||||
loadWebSpeechTitleForActor,
|
||||
SpeechNotFoundError,
|
||||
} from '@/utilities/speech/speechPageData'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
import { speechBundleFixture } from '../helpers/speechBundleFixture'
|
||||
|
||||
// C216 — the "Falas na internet" loaders over the real Payload boundary: the
|
||||
// list reads only web rows, the facets answer over the web catalog, the detail
|
||||
// carries the private media routes and the Câmara detail never serves a web row
|
||||
// (and vice versa). The private routes themselves are e2e territory; here the
|
||||
// view model contract is the target.
|
||||
|
||||
let payload: Payload
|
||||
const createdSourceKeys = new Set<string>()
|
||||
const createdMediaIds = new Set<number>()
|
||||
const tempDirs: string[] = []
|
||||
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
// Real magic bytes so Payload derives the mime type from the upload itself (the
|
||||
// detail picks the native control from it).
|
||||
const MP4_BYTES = Buffer.from('00000018667479706d703432000000006d70343269736f6d', 'hex')
|
||||
const MP3_BYTES = Buffer.from('fffb900000000000000000000000000000000000', 'hex')
|
||||
|
||||
const createMedia = async (bytes: Buffer, filename: string): Promise<number> => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'web-speech-acervo-'))
|
||||
tempDirs.push(dir)
|
||||
const filePath = join(dir, filename)
|
||||
await writeFile(filePath, bytes)
|
||||
const media = await payload.create({
|
||||
collection: INTERNET_SPEECH_MEDIA_SLUG,
|
||||
data: { alt: `Mídia de teste ${filename}` },
|
||||
filePath,
|
||||
// Intentional bypass: fixtures are a trusted actor with no session.
|
||||
overrideAccess: true,
|
||||
})
|
||||
createdMediaIds.add(media.id)
|
||||
return media.id
|
||||
}
|
||||
|
||||
const webBundle = (
|
||||
overrides: Partial<SpeechImportBundle> = {},
|
||||
sourceKey = `web:test:${randomUUID()}`,
|
||||
): SpeechImportBundle => {
|
||||
createdSourceKeys.add(sourceKey)
|
||||
return {
|
||||
...speechBundleFixture(),
|
||||
sourceKey,
|
||||
origin: 'web',
|
||||
platform: 'youtube',
|
||||
externalId: sourceKey,
|
||||
sourceUrl: `https://www.youtube.com/watch?v=${encodeURIComponent(sourceKey)}`,
|
||||
title: 'Fala publicada na internet',
|
||||
channel: 'Canal do teste',
|
||||
// Câmara-only coordinates never travel on a web row.
|
||||
legislature: null,
|
||||
type: null,
|
||||
phase: null,
|
||||
eventId: null,
|
||||
audioId: null,
|
||||
excerptTMs: null,
|
||||
vodPlaybackUrl: null,
|
||||
vodDownloadUrl: null,
|
||||
officialTextUrl: null,
|
||||
officialTranscript: null,
|
||||
summary: null,
|
||||
presidingOfficer: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const createWebSpeech = async (overrides: Partial<SpeechImportBundle> = {}): Promise<number> => {
|
||||
const bundle = webBundle(overrides)
|
||||
await upsertSpeechBundle(payload, bundle)
|
||||
const found = await payload.find({
|
||||
collection: 'speech',
|
||||
where: { sourceKey: { equals: bundle.sourceKey } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const speech = found.docs[0]
|
||||
if (!speech) throw new Error('web speech fixture was not created')
|
||||
return speech.id
|
||||
}
|
||||
|
||||
const createCamaraSpeech = async (overrides: Partial<SpeechImportBundle> = {}): Promise<number> => {
|
||||
const bundle = speechBundleFixture(overrides)
|
||||
createdSourceKeys.add(bundle.sourceKey)
|
||||
await upsertSpeechBundle(payload, bundle)
|
||||
const found = await payload.find({
|
||||
collection: 'speech',
|
||||
where: { sourceKey: { equals: bundle.sourceKey } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const speech = found.docs[0]
|
||||
if (!speech) throw new Error('Câmara speech fixture was not created')
|
||||
return speech.id
|
||||
}
|
||||
|
||||
const createUsers = async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const [communicator, coordinator, candidate, advisor, leader] = await Promise.all([
|
||||
fixtures.createCampaignUser('communicator'),
|
||||
fixtures.createCampaignUser('coordinator'),
|
||||
fixtures.createCampaignUser('candidate'),
|
||||
fixtures.createCampaignUser('advisor'),
|
||||
fixtures.createCampaignUser('leader'),
|
||||
])
|
||||
return { communicator, coordinator, candidate, advisor, leader }
|
||||
}
|
||||
|
||||
describe('web speech acervo (C216)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
for (const sourceKey of createdSourceKeys) {
|
||||
const found = await payload.find({
|
||||
collection: 'speech',
|
||||
where: { sourceKey: { equals: sourceKey } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const speech = found.docs[0]
|
||||
if (speech) {
|
||||
await payload.delete({
|
||||
collection: 'speech',
|
||||
id: speech.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const mediaId of createdMediaIds) {
|
||||
await payload
|
||||
.delete({
|
||||
collection: INTERNET_SPEECH_MEDIA_SLUG,
|
||||
id: mediaId,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
for (const dir of tempDirs) await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('lists only web rows: the Câmara speech with the same marker stays invisible', async () => {
|
||||
const runId = randomUUID().slice(0, 8)
|
||||
const marker = `internet${runId}`
|
||||
const webId = await createWebSpeech({
|
||||
segments: [{ startSeconds: 0, endSeconds: 3, text: `A fala ${marker} na internet` }],
|
||||
})
|
||||
const camaraId = await createCamaraSpeech({
|
||||
segments: [{ startSeconds: 0, endSeconds: 3, text: `A fala ${marker} na Câmara` }],
|
||||
})
|
||||
|
||||
const { communicator } = await createUsers()
|
||||
const data = await loadWebSpeechAcervoPageData(payload, communicator, { q: marker })
|
||||
|
||||
expect(data.totalDocs).toBe(1)
|
||||
expect(data.rows.map((row) => row.id)).toEqual([webId])
|
||||
expect(data.rows[0]?.excerpt.parts.map((part) => part.text).join('')).toContain(marker)
|
||||
|
||||
// And the Câmara list does not pick the web row up.
|
||||
const camara = await loadSpeechAcervoPageData(payload, communicator, { q: marker })
|
||||
expect(camara.rows.map((row) => row.id)).toEqual([camaraId])
|
||||
})
|
||||
|
||||
it('filters by topic, year, duration and cited municipality over the web catalog', async () => {
|
||||
const id = await createWebSpeech({
|
||||
speechAt: '2021-04-12T00:00',
|
||||
durationSeconds: 90,
|
||||
facets: {
|
||||
topics: ['igualdade-racial'],
|
||||
scopes: ['brasil'],
|
||||
municipalities: matchMunicipalityMentions('Feira de Santana'),
|
||||
people: [],
|
||||
programs: [],
|
||||
projects: [],
|
||||
classifiedBy: 'gazetteer',
|
||||
},
|
||||
})
|
||||
|
||||
const { communicator } = await createUsers()
|
||||
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
|
||||
if (!municipalityId) throw new Error('seeded municipality missing')
|
||||
|
||||
const data = await loadWebSpeechAcervoPageData(payload, communicator, {
|
||||
topic: 'igualdade-racial',
|
||||
year: '2021',
|
||||
duration: 'curta',
|
||||
municipality: String(municipalityId),
|
||||
})
|
||||
|
||||
expect(data.rows.map((row) => row.id)).toContain(id)
|
||||
expect(data.filterOptions.years).toContain(2021)
|
||||
// The web source has no Fase facet.
|
||||
expect(data.filterOptions.phases).toEqual([])
|
||||
})
|
||||
|
||||
it('orders by publication date and gates the duration orders', async () => {
|
||||
const runId = randomUUID().slice(0, 8)
|
||||
const marker = `ordem${runId}`
|
||||
const older = await createWebSpeech({
|
||||
speechAt: '2020-01-10T00:00',
|
||||
durationSeconds: 90,
|
||||
title: `Mais antiga ${marker}`,
|
||||
segments: [{ startSeconds: 0, endSeconds: 3, text: `A fala ${marker} antiga` }],
|
||||
})
|
||||
const newer = await createWebSpeech({
|
||||
speechAt: '2026-01-10T00:00',
|
||||
durationSeconds: null,
|
||||
title: `Mais recente ${marker}`,
|
||||
segments: [{ startSeconds: 0, endSeconds: 3, text: `A fala ${marker} recente` }],
|
||||
})
|
||||
|
||||
const { communicator } = await createUsers()
|
||||
const byRecency = await loadWebSpeechAcervoPageData(payload, communicator, { q: marker })
|
||||
expect(byRecency.rows.map((row) => row.id)).toEqual([newer, older])
|
||||
|
||||
// A duration order only lists rows with a measured duration.
|
||||
const byDuration = await loadWebSpeechAcervoPageData(payload, communicator, {
|
||||
q: marker,
|
||||
sort: 'duracao_maior',
|
||||
})
|
||||
expect(byDuration.state.sort).toBe('duracao_maior')
|
||||
expect(byDuration.rows.map((row) => row.id)).toEqual([older])
|
||||
})
|
||||
|
||||
it('expands a theme only over the web rows and keeps the evidence (C192)', async () => {
|
||||
const runId = randomUUID().slice(0, 8)
|
||||
const marker = `temaweb${runId}`
|
||||
const id = await createWebSpeech({
|
||||
segments: [
|
||||
{
|
||||
startSeconds: 0,
|
||||
endSeconds: 3,
|
||||
text: `Garantir o atendimento universal ${marker} na rede pública`,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { coordinator } = await createUsers()
|
||||
const expansionCalls: string[] = []
|
||||
const data = await loadWebSpeechAcervoPageData(
|
||||
payload,
|
||||
coordinator,
|
||||
{ q: 'defesa do SUS', mode: 'tema' },
|
||||
async (theme) => {
|
||||
expansionCalls.push(theme)
|
||||
return { terms: [`atendimento universal ${marker}`] }
|
||||
},
|
||||
)
|
||||
|
||||
expect(expansionCalls).toEqual(['defesa do SUS'])
|
||||
expect(data.themeUnavailable).toBe(false)
|
||||
expect(data.themeApplied).toBe(true)
|
||||
const row = data.rows.find((item) => item.id === id)
|
||||
expect(row).toBeDefined()
|
||||
expect(row?.excerpt.parts.some((part) => part.highlighted)).toBe(true)
|
||||
})
|
||||
|
||||
it('carries the private routes, the platform and the origin attribution in the detail', async () => {
|
||||
const mediaId = await createMedia(MP3_BYTES, 'source.mp3')
|
||||
const id = await createWebSpeech({
|
||||
platform: 'radio',
|
||||
title: 'Entrevista na rádio',
|
||||
channel: 'Rádio Metrópole',
|
||||
sourceUrl: 'https://radio.example/entrevista',
|
||||
speechAt: '2026-09-21T00:00',
|
||||
mirroredMedia: mediaId,
|
||||
segments: [{ startSeconds: 0, endSeconds: 4, text: 'A saúde pública baiana' }],
|
||||
})
|
||||
|
||||
const { communicator } = await createUsers()
|
||||
const view = await loadWebSpeechDetailPageData(payload, communicator, id, 'saúde')
|
||||
|
||||
expect(view.title).toBe('Entrevista na rádio')
|
||||
expect(view.platform).toEqual({ value: 'radio', label: 'Rádio' })
|
||||
expect(view.channel).toBe('Rádio Metrópole')
|
||||
expect(view.sourceUrl).toBe('https://radio.example/entrevista')
|
||||
expect(view.dateLabel).toBe('21/09/2026')
|
||||
expect(view.mediaKind).toBe('audio')
|
||||
expect(view.fileHref).toBe(`/campanha/comunicacao/acervo/internet/${id}/arquivo`)
|
||||
expect(view.downloadHref).toBe(`/campanha/comunicacao/acervo/internet/${id}/arquivo?download=1`)
|
||||
expect(view.segments[0]?.parts.some((part) => part.highlighted)).toBe(true)
|
||||
})
|
||||
|
||||
it('points the cover at the private route only when a thumbnail was captured', async () => {
|
||||
const thumbnailId = await createMedia(MP4_BYTES, 'thumb.mp4')
|
||||
const withCover = await createWebSpeech({ thumbnail: thumbnailId })
|
||||
const withoutCover = await createWebSpeech({})
|
||||
|
||||
const { communicator } = await createUsers()
|
||||
const data = await loadWebSpeechAcervoPageData(payload, communicator, {})
|
||||
const row = (id: number) => data.rows.find((item) => item.id === id)
|
||||
|
||||
expect(row(withCover)?.thumbnailUrl).toBe(
|
||||
`/campanha/comunicacao/acervo/internet/${withCover}/capa`,
|
||||
)
|
||||
expect(row(withoutCover)?.thumbnailUrl).toBeNull()
|
||||
})
|
||||
|
||||
it('never serves one origin from the other origin detail loader', async () => {
|
||||
const webId = await createWebSpeech({})
|
||||
const camaraId = await createCamaraSpeech({})
|
||||
const { communicator } = await createUsers()
|
||||
|
||||
await expect(
|
||||
loadSpeechDetailPageData(payload, communicator, webId, undefined, async () => null),
|
||||
).rejects.toBeInstanceOf(SpeechNotFoundError)
|
||||
await expect(
|
||||
loadWebSpeechDetailPageData(payload, communicator, camaraId),
|
||||
).rejects.toBeInstanceOf(SpeechNotFoundError)
|
||||
await expect(loadWebSpeechTitleForActor(payload, communicator, camaraId)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('allows communicator/coordinator/candidate and denies advisor/leader', async () => {
|
||||
const id = await createWebSpeech({})
|
||||
const { communicator, coordinator, candidate, advisor, leader } = await createUsers()
|
||||
|
||||
for (const reader of [communicator, coordinator, candidate]) {
|
||||
const data = await loadWebSpeechAcervoPageData(payload, reader, {})
|
||||
expect(data.rows.map((row) => row.id)).toContain(id)
|
||||
await expect(loadWebSpeechDetailPageData(payload, reader, id)).resolves.toBeDefined()
|
||||
}
|
||||
|
||||
for (const denied of [advisor, leader]) {
|
||||
await expect(loadWebSpeechAcervoPageData(payload, denied, {})).rejects.toThrow(/permissão/i)
|
||||
await expect(loadWebSpeechDetailPageData(payload, denied, id)).rejects.toThrow(/permissão/i)
|
||||
}
|
||||
})
|
||||
})
|
||||
39
tests/unit/acervoListSort.unit.spec.ts
Normal file
39
tests/unit/acervoListSort.unit.spec.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ACERVO_SORT_OPTIONS, acervoSortIsDuration, parseAcervoSort } from '@/lib/acervoListSort'
|
||||
|
||||
// C216 — the shared ordering vocabulary of the acervo (recordings C219 + web
|
||||
// speeches): `recentes` is the default and never serialized, and only the two
|
||||
// duration orders count as duration sorts (the `where` gate follows them).
|
||||
|
||||
describe('parseAcervoSort', () => {
|
||||
it('accepts only the non-default known orders', () => {
|
||||
expect(parseAcervoSort('duracao_maior')).toBe('duracao_maior')
|
||||
expect(parseAcervoSort('duracao_menor')).toBe('duracao_menor')
|
||||
expect(parseAcervoSort('recentes')).toBeUndefined()
|
||||
expect(parseAcervoSort('maratona')).toBeUndefined()
|
||||
expect(parseAcervoSort(undefined)).toBeUndefined()
|
||||
expect(parseAcervoSort(['duracao_maior', 'duracao_menor'])).toBe('duracao_maior')
|
||||
})
|
||||
})
|
||||
|
||||
describe('acervoSortIsDuration', () => {
|
||||
it('flags only the duration orders', () => {
|
||||
expect(acervoSortIsDuration('duracao_maior')).toBe(true)
|
||||
expect(acervoSortIsDuration('duracao_menor')).toBe(true)
|
||||
expect(acervoSortIsDuration('recentes')).toBe(false)
|
||||
expect(acervoSortIsDuration(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ACERVO_SORT_OPTIONS', () => {
|
||||
it('keeps the pt-BR labels of the approved control', () => {
|
||||
expect(ACERVO_SORT_OPTIONS).toEqual([
|
||||
{ value: 'recentes', label: 'Mais recentes' },
|
||||
{ value: 'duracao_maior', label: 'Duração (maior)' },
|
||||
{ value: 'duracao_menor', label: 'Duração (menor)' },
|
||||
])
|
||||
})
|
||||
})
|
||||
32
tests/unit/acervoSource.unit.spec.ts
Normal file
32
tests/unit/acervoSource.unit.spec.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildAcervoSourceHref, parseAcervoSource } from '@/lib/acervoSource'
|
||||
|
||||
// C216 — the shared source vocabulary moved out of the recordings contract so
|
||||
// the speech URL contract can use it without a cycle; the Câmara default stays
|
||||
// fail-closed for anything that is not an explicit source value.
|
||||
|
||||
describe('parseAcervoSource', () => {
|
||||
it('detects each explicit source and defaults everything else to the Câmara', () => {
|
||||
expect(parseAcervoSource({ source: 'enviadas' })).toBe('enviadas')
|
||||
expect(parseAcervoSource({ source: 'internet' })).toBe('internet')
|
||||
expect(parseAcervoSource({ source: 'camara' })).toBe('camara')
|
||||
expect(parseAcervoSource({ source: 'gravacoes' })).toBe('camara')
|
||||
expect(parseAcervoSource({ source: 'web' })).toBe('camara')
|
||||
expect(parseAcervoSource({})).toBe('camara')
|
||||
})
|
||||
|
||||
it('takes the first value of a repeated param', () => {
|
||||
expect(parseAcervoSource({ source: ['internet', 'enviadas'] })).toBe('internet')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAcervoSourceHref', () => {
|
||||
it('builds the switcher hrefs (the Câmara side is the bare acervo)', () => {
|
||||
expect(buildAcervoSourceHref('enviadas')).toBe('/campanha/comunicacao/acervo?source=enviadas')
|
||||
expect(buildAcervoSourceHref('internet')).toBe('/campanha/comunicacao/acervo?source=internet')
|
||||
expect(buildAcervoSourceHref('camara')).toBe('/campanha/comunicacao/acervo')
|
||||
})
|
||||
})
|
||||
|
|
@ -64,7 +64,8 @@ describe('campaignPageChrome', () => {
|
|||
})
|
||||
expect(resolveCampaignPageChrome('/campanha/comunicacao/acervo', 'communicator')).toEqual({
|
||||
title: 'Acervo de falas',
|
||||
subtitle: 'Busque nas falas da Câmara ou nas gravações da equipe.',
|
||||
subtitle:
|
||||
'Busque nas falas da Câmara, nas gravações da equipe ou nas falas encontradas na internet.',
|
||||
})
|
||||
expect(resolveCampaignPageChrome('/campanha/comunicacao/acervo/42', 'communicator')).toBeNull()
|
||||
// C199 — the recordings alias has its own entry BEFORE the generic detail
|
||||
|
|
@ -75,6 +76,11 @@ describe('campaignPageChrome', () => {
|
|||
expect(
|
||||
resolveCampaignPageChrome('/campanha/comunicacao/acervo/gravacoes/7', 'communicator'),
|
||||
).toBeNull()
|
||||
// C216 — the web detail has two path segments under the acervo, so the
|
||||
// generic one-segment regex does not swallow it: the page sets its chrome.
|
||||
expect(
|
||||
resolveCampaignPageChrome('/campanha/comunicacao/acervo/internet/7', 'communicator'),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves the reel library list and leaves the detail title to the page (C194)', () => {
|
||||
|
|
|
|||
|
|
@ -4,10 +4,8 @@ import { describe, expect, it } from 'vitest'
|
|||
|
||||
import { SPEECH_TOPICS } from '@/lib/speechFacets'
|
||||
import {
|
||||
buildAcervoSourceHref,
|
||||
buildRecordingFiltersKey,
|
||||
buildRecordingListHref,
|
||||
parseAcervoSource,
|
||||
parseRecordingListParams,
|
||||
parseRecordingPeople,
|
||||
recordingHasActiveFilters,
|
||||
|
|
@ -20,13 +18,6 @@ import {
|
|||
import { SPEECH_DURATION_BUCKETS } from '@/utilities/speech/speechListUrl'
|
||||
|
||||
describe('recording list URL (C199)', () => {
|
||||
it('detects the enviadas source and defaults everything else to the Câmara', () => {
|
||||
expect(parseAcervoSource({ source: 'enviadas' })).toBe('enviadas')
|
||||
expect(parseAcervoSource({ source: 'camara' })).toBe('camara')
|
||||
expect(parseAcervoSource({ source: 'gravacoes' })).toBe('camara')
|
||||
expect(parseAcervoSource({})).toBe('camara')
|
||||
})
|
||||
|
||||
it('parses the canonical state and rejects an unready query', () => {
|
||||
expect(parseRecordingListParams({ source: 'enviadas', q: 'merenda', page: '3' })).toEqual({
|
||||
source: 'enviadas',
|
||||
|
|
@ -73,11 +64,6 @@ describe('recording list URL (C199)', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('builds the switcher hrefs (the Câmara side is the bare acervo)', () => {
|
||||
expect(buildAcervoSourceHref('enviadas')).toBe('/campanha/comunicacao/acervo?source=enviadas')
|
||||
expect(buildAcervoSourceHref('camara')).toBe('/campanha/comunicacao/acervo')
|
||||
})
|
||||
|
||||
it('parses repeated `person` values: trim, oversized drop, case-insensitive dedupe', () => {
|
||||
expect(parseRecordingPeople([' Solla ', 'solla', 'Presidente'])).toEqual([
|
||||
'Solla',
|
||||
|
|
|
|||
|
|
@ -191,3 +191,50 @@ describe('buildSpeechListWhere theme expansion (C192)', () => {
|
|||
expect(buildSpeechListWhere(state, [])).toEqual(buildSpeechListWhere(state))
|
||||
})
|
||||
})
|
||||
|
||||
// C216 — the web source derives `origin: web` from the state and has no Fase
|
||||
// facet; the duration-sort gate only exists there.
|
||||
describe('buildSpeechListWhere web source (C216)', () => {
|
||||
const web = { origin: { equals: 'web' } }
|
||||
|
||||
it('keeps only the web discriminator without filters', () => {
|
||||
expect(buildSpeechListWhere(parseSpeechListParams({ source: 'internet' }))).toEqual({
|
||||
and: [web],
|
||||
})
|
||||
})
|
||||
|
||||
it('assembles the facet filters and ignores a phase that slipped through', () => {
|
||||
const state = parseSpeechListParams({
|
||||
source: 'internet',
|
||||
year: '2026',
|
||||
topic: ['saude'],
|
||||
municipality: '12',
|
||||
phase: 'Ordem do Dia',
|
||||
})
|
||||
|
||||
expect(buildSpeechListWhere(state)).toEqual({
|
||||
and: [
|
||||
web,
|
||||
{ year: { in: [2026] } },
|
||||
{ topics: { in: ['saude'] } },
|
||||
{ mentionedMunicipalities: { in: [12] } },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('searches the normalized text and the raw keyword branch (the row may have neither)', () => {
|
||||
expect(buildSpeechListWhere(parseSpeechListParams({ source: 'internet', q: 'SUS' }))).toEqual({
|
||||
and: [web, { or: [{ searchText: { like: 'sus' } }, { keywords: { contains: 'SUS' } }] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('gates the duration orders on a measured duration', () => {
|
||||
expect(
|
||||
buildSpeechListWhere(parseSpeechListParams({ source: 'internet', sort: 'duracao_maior' })),
|
||||
).toEqual({ and: [web, { durationSeconds: { exists: true } }] })
|
||||
|
||||
expect(buildSpeechListWhere(parseSpeechListParams({ source: 'internet' }))).toEqual({
|
||||
and: [web],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import {
|
|||
parseSpeechListParams,
|
||||
resolveSpeechListUrl,
|
||||
serializeCanonicalSpeechListSearchParams,
|
||||
speechHasActiveFilters,
|
||||
webSpeechSortIsDuration,
|
||||
webSpeechSortOrder,
|
||||
} from '@/utilities/speech/speechListUrl'
|
||||
|
||||
describe('parseSpeechListParams', () => {
|
||||
|
|
@ -151,3 +154,95 @@ describe('buildSpeechFiltersKey', () => {
|
|||
expect(buildSpeechFiltersKey(parseSpeechListParams({ topic: ['saude'] }))).toBe('topic=saude')
|
||||
})
|
||||
})
|
||||
|
||||
// C216 — the web speeches source lives in the same contract, additively: the
|
||||
// Câmara URLs above stay byte-identical, and only `source=internet` turns on
|
||||
// the web state (source always serialized, sort, no Fase).
|
||||
describe('web speeches source (C216)', () => {
|
||||
it('parses the web state and ignores the Câmara-only phase param', () => {
|
||||
const state = parseSpeechListParams({
|
||||
source: 'internet',
|
||||
q: 'saúde pública',
|
||||
topic: ['saude'],
|
||||
phase: ['Breves Comunicações'],
|
||||
page: '2',
|
||||
})
|
||||
|
||||
expect(state).toEqual({
|
||||
source: 'internet',
|
||||
page: 2,
|
||||
q: 'saúde pública',
|
||||
topics: ['saude'],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an unknown source on the Câmara (fail-closed)', () => {
|
||||
expect(parseSpeechListParams({ source: 'web' }).source).toBeUndefined()
|
||||
expect(parseSpeechListParams({ source: 'web', q: 'SUS' }).source).toBeUndefined()
|
||||
})
|
||||
|
||||
it('parses the sort only for the web source and drops the default', () => {
|
||||
expect(parseSpeechListParams({ source: 'internet', sort: 'duracao_maior' }).sort).toBe(
|
||||
'duracao_maior',
|
||||
)
|
||||
expect(parseSpeechListParams({ source: 'internet', sort: 'recentes' }).sort).toBeUndefined()
|
||||
expect(parseSpeechListParams({ source: 'internet', sort: 'maratona' }).sort).toBeUndefined()
|
||||
// A Câmara URL never carries the sort, not even when the param is present.
|
||||
expect(parseSpeechListParams({ sort: 'duracao_maior' }).sort).toBeUndefined()
|
||||
})
|
||||
|
||||
it('serializes `source=internet` always and the sort only when non-default', () => {
|
||||
expect(
|
||||
serializeCanonicalSpeechListSearchParams(
|
||||
parseSpeechListParams({ source: 'internet', q: 'SUS', sort: 'duracao_menor' }),
|
||||
).toString(),
|
||||
).toBe('source=internet&q=SUS&sort=duracao_menor')
|
||||
|
||||
expect(
|
||||
serializeCanonicalSpeechListSearchParams(
|
||||
parseSpeechListParams({ source: 'internet', q: 'SUS', sort: 'recentes', phase: 'X' }),
|
||||
).toString(),
|
||||
).toBe('source=internet&q=SUS')
|
||||
})
|
||||
|
||||
it('builds the canonical web hrefs on the same acervo path', () => {
|
||||
const state = parseSpeechListParams({ source: 'internet', q: 'SUS' })
|
||||
expect(buildSpeechListHref(state, 1)).toBe('/campanha/comunicacao/acervo?source=internet&q=SUS')
|
||||
expect(buildSpeechListHref(state, 2)).toBe(
|
||||
'/campanha/comunicacao/acervo?source=internet&q=SUS&page=2',
|
||||
)
|
||||
expect(buildSpeechFiltersKey(state)).toBe('source=internet&q=SUS')
|
||||
})
|
||||
|
||||
it('redirects a phase on the web source away (canonicalization)', () => {
|
||||
const resolved = resolveSpeechListUrl({ source: 'internet', q: 'SUS', phase: 'Ordem do Dia' })
|
||||
expect(resolved.state.phases).toBeUndefined()
|
||||
expect(resolved.redirectHref).toBe('/campanha/comunicacao/acervo?source=internet&q=SUS')
|
||||
})
|
||||
|
||||
it('maps the sort to the speech order and gates the duration sorts', () => {
|
||||
expect(webSpeechSortOrder(parseSpeechListParams({ source: 'internet' }))).toBe('-speechAt')
|
||||
expect(
|
||||
webSpeechSortOrder(parseSpeechListParams({ source: 'internet', sort: 'duracao_maior' })),
|
||||
).toBe('-durationSeconds')
|
||||
expect(
|
||||
webSpeechSortOrder(parseSpeechListParams({ source: 'internet', sort: 'duracao_menor' })),
|
||||
).toBe('durationSeconds')
|
||||
|
||||
expect(webSpeechSortIsDuration(parseSpeechListParams({ source: 'internet' }))).toBe(false)
|
||||
expect(
|
||||
webSpeechSortIsDuration(parseSpeechListParams({ source: 'internet', sort: 'duracao_maior' })),
|
||||
).toBe(true)
|
||||
// The gate never applies to the Câmara state.
|
||||
expect(webSpeechSortIsDuration(parseSpeechListParams({}))).toBe(false)
|
||||
})
|
||||
|
||||
it('tells an active filter from the source discriminator', () => {
|
||||
expect(speechHasActiveFilters(parseSpeechListParams({ source: 'internet' }))).toBe(false)
|
||||
expect(speechHasActiveFilters(parseSpeechListParams({ source: 'internet', q: 'SUS' }))).toBe(
|
||||
true,
|
||||
)
|
||||
expect(speechHasActiveFilters(parseSpeechListParams({ q: 'SUS' }))).toBe(true)
|
||||
expect(speechHasActiveFilters(parseSpeechListParams({}))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -135,4 +135,26 @@ describe('clearSpeechOmnibox', () => {
|
|||
state: { page: 1 },
|
||||
})
|
||||
})
|
||||
|
||||
// C216 — clearing the web list must not drop the source, or "Limpar" would
|
||||
// land back on the Câmara.
|
||||
it('keeps the web source when clearing', () => {
|
||||
expect(clearSpeechOmnibox(state({ source: 'internet', q: 'SUS' }))).toEqual({
|
||||
kind: 'clear',
|
||||
state: { page: 1, source: 'internet' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// C216 — the facet copy is a parameter so the web source reads "Município
|
||||
// citado" while the Câmara keeps its exact "Município".
|
||||
describe('municipality facet label (C216)', () => {
|
||||
it('labels the chip with the source copy', () => {
|
||||
const chips = buildSpeechOmniboxChips({
|
||||
state: state({ source: 'internet', municipality: '12' }),
|
||||
municipalityLabelsById: new Map([[12, 'Feira de Santana']]),
|
||||
municipalityLabel: 'Município citado',
|
||||
})
|
||||
expect(chips.map((chip) => chip.label)).toEqual(['Município citado: Feira de Santana'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@ import { describe, expect, it } from 'vitest'
|
|||
import { speechPosterHref } from '@/lib/speechPoster'
|
||||
import {
|
||||
toSpeechListItemViewModel,
|
||||
toWebSpeechDetailViewModel,
|
||||
toWebSpeechListItemViewModel,
|
||||
type SpeechListRecord,
|
||||
type WebSpeechDetailRecord,
|
||||
type WebSpeechListRecord,
|
||||
} from '@/utilities/speech/speechViewModels'
|
||||
|
||||
const viewModel = (overrides: Partial<SpeechListRecord> = {}) =>
|
||||
|
|
@ -176,3 +180,93 @@ describe('toSpeechListItemViewModel theme match (C192)', () => {
|
|||
expect(row.excerpt.parts.some((part) => part.highlighted)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// C216 — the web speeches view models: platform label, day-only date, the
|
||||
// private cover href, the media kind of the detail and the origin attribution.
|
||||
describe('web speech view models (C216)', () => {
|
||||
const webListRow = (overrides: Partial<WebSpeechListRecord> = {}) =>
|
||||
toWebSpeechListItemViewModel({
|
||||
speech: {
|
||||
id: 7,
|
||||
speechAt: '2026-09-20T00:00',
|
||||
title: 'Entrevista na rádio',
|
||||
platform: 'radio',
|
||||
durationSeconds: 90,
|
||||
topics: ['saude'],
|
||||
scopes: ['bahia'],
|
||||
...overrides,
|
||||
},
|
||||
segments: [],
|
||||
query: undefined,
|
||||
themeTerms: [],
|
||||
})
|
||||
|
||||
it('labels the platform and the day-only publication date', () => {
|
||||
const row = webListRow()
|
||||
expect(row.platform).toEqual({ value: 'radio', label: 'Rádio' })
|
||||
expect(row.dateLabel).toBe('20/09/2026')
|
||||
expect(row.durationLabel).toBe('1min30s')
|
||||
expect(row.title).toBe('Entrevista na rádio')
|
||||
})
|
||||
|
||||
it('falls back for a row without a platform or title', () => {
|
||||
const row = webListRow({ platform: null, title: ' ' })
|
||||
expect(row.platform).toEqual({ value: null, label: 'Outra plataforma' })
|
||||
expect(row.title).toBe('Fala da internet #7')
|
||||
})
|
||||
|
||||
it('links the private cover only when the ingestion captured a thumbnail', () => {
|
||||
expect(webListRow().thumbnailUrl).toBeNull()
|
||||
expect(webListRow({ thumbnail: 12 }).thumbnailUrl).toBe(
|
||||
'/campanha/comunicacao/acervo/internet/7/capa',
|
||||
)
|
||||
expect(webListRow({ thumbnail: { id: 12 } }).thumbnailUrl).toBe(
|
||||
'/campanha/comunicacao/acervo/internet/7/capa',
|
||||
)
|
||||
})
|
||||
|
||||
it('builds the detail href with the segment seek and keeps the query', () => {
|
||||
expect(webListRow().watchHref).toBe('/campanha/comunicacao/acervo/internet/7')
|
||||
|
||||
const row = toWebSpeechListItemViewModel({
|
||||
speech: { id: 7, speechAt: '2026-09-20T00:00', title: 'Fala' },
|
||||
segments: [{ startSeconds: 12.7, endSeconds: 15, text: 'A saúde pública importa' }],
|
||||
query: 'saúde',
|
||||
})
|
||||
expect(row.watchHref).toBe('/campanha/comunicacao/acervo/internet/7?t=12&q=sa%C3%BAde')
|
||||
expect(row.excerpt.parts.some((part) => part.highlighted)).toBe(true)
|
||||
})
|
||||
|
||||
it('picks the native control from the stored mime type', () => {
|
||||
const detail = (mirroredMedia: WebSpeechDetailRecord['mirroredMedia']) =>
|
||||
toWebSpeechDetailViewModel({
|
||||
speech: {
|
||||
id: 7,
|
||||
speechAt: '2026-09-20T00:00',
|
||||
title: 'Entrevista na rádio',
|
||||
platform: 'radio',
|
||||
channel: 'Rádio Metrópole',
|
||||
sourceUrl: 'https://radio.example/entrevista',
|
||||
mirroredMedia,
|
||||
},
|
||||
segments: [{ startSeconds: 0, endSeconds: 4, text: 'A saúde pública' }],
|
||||
})
|
||||
|
||||
const audio = detail({ id: 12, mimeType: 'audio/mpeg', filename: 'source.mp3' })
|
||||
expect(audio.mediaKind).toBe('audio')
|
||||
expect(audio.fileHref).toBe('/campanha/comunicacao/acervo/internet/7/arquivo')
|
||||
expect(audio.downloadHref).toBe('/campanha/comunicacao/acervo/internet/7/arquivo?download=1')
|
||||
expect(audio.channel).toBe('Rádio Metrópole')
|
||||
expect(audio.sourceUrl).toBe('https://radio.example/entrevista')
|
||||
expect(audio.dateLabel).toBe('20/09/2026')
|
||||
expect(audio.segments[0]?.startLabel).toBe('00:00')
|
||||
|
||||
const video = detail({ id: 12, mimeType: 'video/mp4', filename: 'source.mp4' })
|
||||
expect(video.mediaKind).toBe('video')
|
||||
|
||||
const missing = detail(null)
|
||||
expect(missing.mediaKind).toBe('video')
|
||||
expect(missing.fileHref).toBeNull()
|
||||
expect(missing.downloadHref).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
parseWebSpeechBatch,
|
||||
parseWebSpeechFinding,
|
||||
webSpeechAtFromPublishedAt,
|
||||
webSpeechDownloadFilename,
|
||||
webSpeechPlatformLabel,
|
||||
webSpeechSourceKey,
|
||||
} from '@/lib/webSpeech'
|
||||
|
|
@ -144,4 +145,46 @@ describe('webSpeechPlatformLabel', () => {
|
|||
expect(webSpeechPlatformLabel('youtube')).toBe('YouTube')
|
||||
expect(webSpeechPlatformLabel('radio')).toBe('Rádio')
|
||||
})
|
||||
|
||||
// C216 — the pill of a row without a known platform is honest, not blank.
|
||||
it('falls back for a missing or unknown platform (C216)', () => {
|
||||
expect(webSpeechPlatformLabel(null)).toBe('Outra plataforma')
|
||||
expect(webSpeechPlatformLabel(undefined)).toBe('Outra plataforma')
|
||||
})
|
||||
})
|
||||
|
||||
// C216 (closes the C215 S4 defer) — the download name of the mirrored file.
|
||||
describe('webSpeechDownloadFilename', () => {
|
||||
it('keeps the stored extension and the title in the download name', () => {
|
||||
expect(
|
||||
webSpeechDownloadFilename({
|
||||
title: 'Entrevista sobre a saúde pública',
|
||||
storedFilename: 'source.mp4',
|
||||
}),
|
||||
).toBe('Entrevista sobre a saúde pública.mp4')
|
||||
})
|
||||
|
||||
it('strips control characters and path separators', () => {
|
||||
expect(
|
||||
webSpeechDownloadFilename({
|
||||
title: 'Fala "sobre"\n o SUS/educação',
|
||||
storedFilename: 'source.mp3',
|
||||
}),
|
||||
).toBe('Fala sobre o SUS educação.mp3')
|
||||
})
|
||||
|
||||
it('caps the length and never leaves a trailing dot', () => {
|
||||
const filename = webSpeechDownloadFilename({
|
||||
title: `${'a'.repeat(300)}.`,
|
||||
storedFilename: 'source.mp4',
|
||||
})
|
||||
expect(filename).not.toBeNull()
|
||||
expect(filename?.endsWith('.mp4')).toBe(true)
|
||||
expect(filename?.length).toBeLessThanOrEqual(120)
|
||||
})
|
||||
|
||||
it('falls back to the stored filename when the title leaves nothing readable', () => {
|
||||
expect(webSpeechDownloadFilename({ title: ' ', storedFilename: 'source.mp3' })).toBeNull()
|
||||
expect(webSpeechDownloadFilename({ title: 'Fala', storedFilename: null })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue