mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(C176): página pública do corte — revisão de design (Closes #1086)
Corrige o fundo tintado (gradiente do body herdado de :root) com um layout de segmento `data-theme="editorial"` + scroll interno, espelhando `[type]/layout.tsx`, e porta o gate aprovado: vídeo herói, título à esquerda, meta em relógio, prosa medida, kit WhatsApp-first responsivo, secundárias em grade no mobile e estado indisponível redesenhado. Extrai os botões do share kit no próprio dono (acervo/biblioteca inalterados) e adiciona o guard e2e `data-theme="editorial"`.
This commit is contained in:
parent
d199c6b006
commit
01be1de7be
7 changed files with 339 additions and 88 deletions
5
docs/changelog/2026-09-16-c176.md
Normal file
5
docs/changelog/2026-09-16-c176.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
feat(C176): página pública do corte — revisão de design (superfície neutra, vídeo herói, WhatsApp-first no mobile)
|
||||
|
||||
Redesenha `/corte/<id>` preservando o contrato C167 (rota, `noindex`, metadata). O "fundo colorido" era o gradiente do `body` herdado de `:root` — o segmento não tinha `data-theme` nem layout; um novo `src/app/(frontend)/corte/layout.tsx` espelha `[type]/layout.tsx` (`data-theme="editorial"` + `h-dvh overflow-y-auto bg-background`), consertando de uma vez a page e o `not-found` e provendo o scroll interno (sticky do `SiteHeader`). A page porta o gate aprovado (`docs/plans/corte-pagina-publica-design-ui-design.html`): vídeo herói (full-bleed no mobile, arredondado no desktop; `object-cover` faz o poster 4:3 preencher o quadro 16:9), título alinhado à esquerda, meta `tipo · data · duração` (relógio `02:14`, literal do gate), descrição com medida de prosa controlada e contraste AA, kit de compartilhamento responsivo — WhatsApp primário full-width acima da descrição no mobile (alvo 48 px) e kit inline de 3 botões no desktop (44 px) —, ações secundárias em grade de 2 colunas no mobile, link da sessão no YouTube inline na meta (desktop) e avulso (mobile), e rodapé com crédito CC BY + nota de não listagem. Estado indisponível redesenhado (ícone, copy e botão "Voltar ao início") e centralizado. `SpeechCutShareActions.tsx` extraiu `SpeechCutWhatsAppButton`/`SpeechCutDownloadButton` (o dono do URL/rótulo do WhatsApp segue `buildSpeechCutShare`) e ganhou `controlClassName` opcional — acervo/biblioteca continuam idênticos. Guard e2e novo: o HTML servido carrega `data-theme="editorial"` (regressão do fundo tintado). `campaignSpeechCut` (5) e `campaignSpeechAcervo` (11) verdes locais; `pnpm gate:fast` verde. Sem migration/schema/access/Consent.
|
||||
|
||||
Closes #1086
|
||||
119
docs/plans/corte-pagina-publica-design-impl.md
Normal file
119
docs/plans/corte-pagina-publica-design-impl.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# Impl: Página pública do corte: revisão de design
|
||||
|
||||
Status: em execução
|
||||
Atualizado em: 2026-09-16
|
||||
Issue: #1086
|
||||
Intenção: docs/plans/corte-pagina-publica-design.md
|
||||
Appetite restante: herdado (~1–1,5 dia eng, uma página). Sem inflar.
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** a página `/corte/<id>` passa a ler como peça cuidada e compartilhável — vídeo herói, título alinhado à esquerda, meta/descrição em superfície neutra (zero fundo tintado), WhatsApp-first no celular, e o estado indisponível igualmente bonito — sem tocar rota, `noindex` ou metadata (C167).
|
||||
- **O que NÃO negociar:** rota `/corte/<id>` e `robots: { index: false, follow: false }` inalterados; MP4 armazenado como player (nunca YouTube/VOD); crédito `Fonte: Câmara dos Deputados · CC BY 4.0`; mesma tela para id desconhecido e despublicado (não revela existência); comportamento do acervo/biblioteca que reusa `SpeechCutShareActions` idêntico.
|
||||
- **O que reavaliar (hipóteses da intenção):**
|
||||
- _"fundo colorido por theme/`editorial-bg`/gradiente"_ — confirmado: não é `editorial-bg`; é o gradiente global do `body` (`src/app/(frontend)/styles.css:1025-1026`) + `:root --background:#ae1603` (`styles.css:124`) que `/corte` herda por **não ter `data-theme` nem layout**. O conserto é local, não na paleta global.
|
||||
- _"SiteHeader pode precisar mexer"_ — não precisa: `src/components/SiteHeader.tsx` já é `sticky` e pronto para viver dentro de um scroll container (`[type]/layout.tsx` é o precedente). Reusar como está.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
L["corte/layout.tsx (novo)<br/>data-theme=editorial<br/>h-dvh overflow-y-auto bg-background"] --> P["corte/[id]/page.tsx<br/>composição responsiva"]
|
||||
L --> N["corte/not-found.tsx<br/>estado indisponível"]
|
||||
P --> S["SpeechCutShareActions.tsx<br/>exporta WhatsApp + Download button"]
|
||||
S --> C["CopyLinkButton (reuso)"]
|
||||
P --> SC["buildSpeechCutShare / speechCutPublicPath (reuso)"]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) layout de segmento `corte/layout.tsx` + composição responsiva na page reusando botões extraídos do dono; B) editar o gradiente do `body` em `styles.css`; C) wrapper `data-theme` só dentro de `page.tsx`.
|
||||
**Recomendação:** **A** — espelha `src/app/(frontend)/[type]/layout.tsx:12-20` (mesma classe `data-theme="editorial" h-dvh w-full overflow-y-auto bg-background text-foreground`), conserta **de uma vez** a page e `corte/not-found.tsx` (o `not-found` do segmento é envolvido pelo layout do segmento), usa os tokens neutros de `styles.css:143-162` (`--background:#f6f4f3`), e mantém o conserto escopado à rota. O `h-screen`/`overflow-hidden` do root (`(frontend)/layout.tsx:76,80`) já exige que a página dona do viewport forneça o scroll interno — hoje `/corte` não fornece.
|
||||
**Rejeitadas:** **B** porque o gradiente do `body` sustenta as páginas públicas escuras (home/`petition`/`campaign-site`) — mexer nele vaza para fora do escopo; **C** porque um wrapper na page não cobre `corte/not-found.tsx` e duplica a responsabilidade de shell que o App Router já oferece no layout; ambas trocam um conserto de 1 arquivo por risco de regressão global.
|
||||
|
||||
### Decisão: ordem responsiva do share kit (WhatsApp acima da descrição no mobile, kit inline abaixo no desktop)
|
||||
|
||||
**Opções consideradas:** A) extrair/exportar `SpeechCutWhatsAppButton` (+`SpeechCutDownloadButton`) de `src/components/SpeechCutShareActions.tsx` e o próprio componente passar a consumi-los, compondo os marcadores responsivos na page; B) duplicar o kit inteiro em `hidden lg:flex` / `lg:hidden` sem extração; C) reordenar via utilitários `order-*` com um único kit.
|
||||
**Recomendação:** **A** — mantém um único dono do URL/rótulo do WhatsApp (`buildSpeechCutShare`, já em `SpeechCutShareActions.tsx:37`) e do download do MP4, com a page só posicionando blocos: `sm:hidden` WhatsApp `h-12 w-full` logo após a meta → descrição (instância única) → `sm:hidden` grade 2 colunas (`CopyLinkButton` + `SpeechCutDownloadButton`) → `hidden sm:flex` `SpeechCutShareActions primary="whatsapp"` → YouTube inline na meta no desktop (`hidden sm:inline`) e link avulso no mobile (`sm:hidden`). O limite é `sm` (640 px) porque o gate tem exatamente duas cenas (390 e 1280): uma única fronteira realiza as duas sem inventar um estado intermédio não desenhado (o plano dizia `lg`; ajuste de execução). A API pública do componente e o markup do acervo/biblioteca permanecem idênticos (o acervo usa `primary="copy"`; nada muda em `SpeechCutResultCard.tsx:49` e `SpeechCutLibraryShareActions.tsx:25`).
|
||||
**Rejeitadas:** **B** porque duplica fonte de verdade do CTA (label/URL) em dois lugares — DRY de conhecimento; **C** porque o agrupamento difere entre viewports (mobile = WhatsApp primário + grade de secundários; desktop = kit de 3 inline) e não há como "fatiar" um componente ao redor da descrição com `order-*`; **D** (prop nova `variant="mobile"` no componente) porque cria um gêmeo dentro do dono e arrisca o acervo — contra "edit the owner, don't twin". Não criar arquivo novo `src/components/*.tsx`: um componente novo fica fora do manifest e cai no fallback smoke (`scripts/lib/e2e-affected-manifest.mjs:320-325`).
|
||||
|
||||
### Decisão: mapa de tokens do artefato → tokens do app (sob `data-theme="editorial"`)
|
||||
|
||||
**Opções consideradas:** A) portar classes literais do artefato (`text-stone-*`, `bg-white`, `border-stone-300`); B) portar para tokens semânticos do app.
|
||||
**Recomendação:** **B**, token-level (o artefato já usa `text-muted-foreground`/`border-border` na meta/footer, então o port é o que ele mesmo declara):
|
||||
`text-stone-950` → `text-foreground`; `text-stone-700`/`text-stone-800` → `text-foreground/90`; `text-stone-600`/`text-stone-500` → `text-muted-foreground`; `bg-white` em controles → `bg-card`; superfície da página (`<main class="bg-white">`) → `bg-background`; `border-stone-300` → `border-border`; `bg-stone-100 text-stone-600` (chip indisponível) → `bg-muted text-muted-foreground`. O `<video>` continua `bg-black`.
|
||||
**Divergência registrada:** o artefato declara `--background:#ffffff` no `editorial` (linha 30); a produção tem `--background:#f6f4f3` (neutro quente, `styles.css:144`). `bg-background` é o port correto porque (1) o aceite exige "superfície neutra do site público (`background`/`card`)", não branco puro; (2) usar `bg-white` cru divergiria da página de artigo irmã e furaria o contrato de tokens. `bg-card` (#fff) fica para os controles outline, como no artefato.
|
||||
**Rejeitadas:** **A** porque fixa a paleta do mock e ignora o tema do site (contraste/tema divergiriam em `campaign-site`/`petition` caso a página fosse reusada).
|
||||
|
||||
### Decisão: copy/ícone/botão do estado indisponível
|
||||
|
||||
**Opções consideradas:** A) manter a copy atual ("O link pode ter sido despublicado ou o endereço está incorreto." / botão "Ir para jorgesolla1313.com.br"); B) adotar a do gate ("O conteúdo pode ter sido removido ou o endereço não está correto." / chip de ícone / botão "Voltar ao início" → `/`).
|
||||
**Recomendação:** **B** — trata como literal aprovada pelo design; a intenção fixou só o título `Este corte não está disponível` e o gate é o artefato aprovado. `Voltar ao início` é mais limpo que um rótulo com domínio hardcoded.
|
||||
**Assumido — validar com produto:** o parágrafo de apoio e o rótulo do botão são literal do gate, não da intenção.
|
||||
**Rejeitadas:** **A** porque mantém copy mais fraca e um botão com URL de domínio embutida, destoando da peça redesenhada. Semântica: o artefato usa `h3`, mas nessa rota é o heading principal → usar `h1` com as classes de tamanho do artefato (`text-2xl sm:text-3xl font-bold`), mantendo o `text-center` (a base global `styles.css:1029` já centraliza — aqui é o desejado).
|
||||
|
||||
### Decisão: guard de teste da regressão do fundo tintado
|
||||
|
||||
**Opções consideradas:** A) asserção barata no spec existente; B) teste visual/screenshot; C) nenhum guard.
|
||||
**Recomendação:** **A** — no teste "serves the published cut…" (`tests/e2e/campaignSpeechCut.e2e.spec.ts:127-157`) acrescentar `expect(html).toContain('data-theme="editorial"')`: o HTML servido prova que o segmento herdou o tema neutro e, portanto, que o gradiente dark-red não é mais a superfície. É o guard exato do bug.
|
||||
**Rejeitadas:** **B** porque screenshot e2e é caro/instável e o appetite não comporta; **C** porque o bug é justamente uma regressão silenciosa de superfície. **Nenhuma asserção existente quebra:** título, `/api/media/file/`, crédito, `noindex`, thumbnail do YouTube e `Ver sessão no YouTube` permanecem; o título no HTML de not-found continua presente e o rótulo antigo do botão não é asserido.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/app/(frontend)/corte/layout.tsx`** (NOVO): `<div data-theme="editorial" className="h-dvh w-full overflow-y-auto bg-background text-foreground">{children}</div>` — espelho de `src/app/(frontend)/[type]/layout.tsx:12-20`. Cobre `[id]/page.tsx` e `not-found.tsx`; conserta o fundo tintado e fornece o scroll interno (sticky do `SiteHeader`).
|
||||
- **`src/app/(frontend)/corte/[id]/page.tsx`** (EDIT): mantém `generateMetadata`, `loadPublishedCut`, `noindex`, `CREDIT`; muda só o JSX de `SpeechCutPage` (L138-196) — coluna `max-w-4xl px-8 py-12`, `<video>` `aspect-video w-full rounded-xl border bg-black` (mobile full-bleed com `rounded-none border-x-0` / `sm:rounded-xl`), `h1` com `text-left` (a base global centraliza), meta com YouTube `hidden lg:inline`, descrição `max-w-[68ch] text-base leading-7 text-foreground/90`, blocos responsivos descritos acima, footer `border-border`.
|
||||
- **`src/app/(frontend)/corte/not-found.tsx`** (EDIT): bloco centrado (chip `bg-muted text-muted-foreground` com ícone `XCircle` do `lucide-react` já disponível), `h1` título, parágrafo e `Link` "Voltar ao início" `border-border bg-card` para `/`. Sem `SiteHeader` duplicado — o layout não o renderiza, então a page/not-found continuam com o seu.
|
||||
- **`src/components/SpeechCutShareActions.tsx`** (EDIT): exportar `SpeechCutWhatsAppButton` e `SpeechCutDownloadButton`; o `SpeechCutShareActions` passa a consumi-los internamente (mesmo markup/labels/`data-slot`). API pública e comportamento do acervo inalterados.
|
||||
- **`tests/e2e/campaignSpeechCut.e2e.spec.ts`** (EDIT): guard `data-theme="editorial"` (acima).
|
||||
- **Migration:** sem migration (nenhuma mudança de schema/collection/global).
|
||||
- **Access / Consent:** nenhum (leitura pública read-only; sem Consent, sem write path).
|
||||
- **UI:** Impeccable **C** (superfície pública existente redesenhada). Não é novo shape: o port é fiel ao gate já aprovado (`docs/plans/corte-pagina-publica-design-ui-design.html`); shape→craft→critique→polish já ocorreram no gate — aqui é port class-for-class ajustado a tokens. O overlay de player do artefato (botão fake/progresso/`NEEDS ASSET`) é **mock** — NÃO portar; manter `<video controls poster>` nativo, só portando sizing/rounding/bleed.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
N/A — a intenção declara explicitamente "não apresento dados"; nenhum KPI/gráfico/visualização. Nada a decidir em data-presentation.
|
||||
|
||||
### Notas de execução (pós-crítica do designer)
|
||||
|
||||
- **Duração em relógio** (`formatSpeechClock`, ex. `02:14`): literal do gate/intenção (`2:14`) ao lado do player; difere do `formatSpeechSpan` (`2min14s`) dos view models do acervo — decisão registrada em comentário no `page.tsx`. Não mexer no dono compartilhado.
|
||||
- **Breakpoint único `sm`**: o gate tem exatamente duas cenas (390/1280); `sm` realiza as duas sem estado intermédio não desenhado (o rascunho dizia `lg`).
|
||||
- **Poster 16:9**: `object-cover` no `<video>` faz o thumbnail 4:3 (`hqdefault`) preencher o quadro; o MP4 é 16:9, então o enquadramento da reprodução não muda.
|
||||
- **Controles em `min-h-*`**: kit desktop `min-h-11`, primário mobile `min-h-12`, secundárias `min-h-11` — convergência em `min-h` (sem `h-*` fixo) para não cortar texto.
|
||||
- **Débito deferido — shell editorial duplicado**: `corte/layout.tsx` é idêntico a `[type]/layout.tsx` (6 linhas). Extrair um shell compartilhado agora seria abstração para 2 call sites (e `privacidade/layout.tsx` tem classes diferentes). **Gatilho:** um terceiro layout com o mesmo shell verbatim → extrair `EditorialScrollShell`.
|
||||
- **Débito deferido — `controlClassName` no share kit**: sizing livre serve só ao 1º consumidor (`/corte/<id>`); **Gatilho:** um 2º consumidor fora do C176 precisar de sizing por controle → trocar por variante de densidade (`size`) em vez de estender o free-form.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / superfície (≈0,3 dia)** — criar `corte/layout.tsx`; redesenhar `not-found.tsx`. Verificar: `/corte/999999999` responde 404 com superfície neutra (sem vermelho) e `data-theme="editorial"` no HTML; sem scroll duplo; `SiteHeader` sticky funciona.
|
||||
2. **UI da página publicada (≈0,7 dia)** — extrair botões do dono; recompor `page.tsx` (desktop 1280 + mobile 390) conforme o gate; preservar metadata/OG/`noindex`/crédito. Verificar manualmente com um corte semeado no dev local (`pnpm dev`, DB local) com e sem `youtubeUrl`, em 1280 e 390.
|
||||
3. **Gates (≈0,2 dia)** — `pnpm gate:fast`; rodar `tests/e2e/campaignSpeechCut.e2e.spec.ts` (e `campaignSpeechAcervo` localmente como guard do refactor do share kit); push via `pnpm push`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Editar o gradiente do `body`/`:root` de `styles.css`** — vaza para as páginas escuras; corte explícito: conserto só na rota via layout.
|
||||
- **Redesenhar `SiteHeader`, home ou paleta global** — a intenção já corta; o header é reusado como está.
|
||||
- **Player custom/branded, autoplay, controles próprios** — proibido pela intenção; o overlay do artefato é mock. Só `<video controls poster>`.
|
||||
- **Arquivo novo `src/components/*.tsx`** — ficaria sem mapping no manifest e2e (fallback smoke); a extração vai para dentro de `SpeechCutShareActions.tsx`, que já é prefixo mapeado.
|
||||
- **Criar collection/Consent/migration/transação** — não há write path; qualquer proposta nesse sentido é escopo inflado.
|
||||
- **Galeria pública, transcrição, embed de YouTube, slug amigável** — fora de escopo por desenho (unlisted).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Refactor de `SpeechCutShareActions` mexer no acervo/biblioteca** → manter a API e o markup renderizado (mesmos rótulos, `data-slot`, variantes); rodar `campaignSpeechAcervo` + `campaignSpeechCut` localmente; a spec de acervo mapeia `src/components/campaign/speech` e cobre o card que o consome.
|
||||
- **Duplicação de nós no DOM** (WhatsApp/Copiar/Baixar/YouTube existem em bloco mobile e desktop, ocultos por `sm:hidden`/`hidden sm:flex`) → visualmente só um de cada; asserções e2e são por texto, não contagem; aceitável para página estática.
|
||||
- **Scroll/altura duplicados no root (`h-screen overflow-hidden`)** → o layout novo é a única fonte de scroll do segmento; testar que mobile rola até o footer e que o sticky cola no topo do container (não do viewport).
|
||||
- **`h1` continua centralizado** → `text-left` explícito na page publicada (utilities vencem `@layer base`); not-found mantém centralizado de propósito.
|
||||
- **Meta com `·` vazando no mobile** → separador e link do YouTube com `hidden sm:inline`; link avulso só `sm:hidden`.
|
||||
- **Manifest e2e** → `corte/layout.tsx` cai no prefixo `src/app/(frontend)/corte` (`scripts/lib/e2e-affected-manifest.mjs:321`), então o spec roda; nenhum prefixo novo necessário.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto: vídeo herói; superfície neutra (zero fundo tintado); WhatsApp-first acima da dobra no mobile; leitura AA; publicado (com/sem YouTube) e indisponível coerentes.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: rota e `noindex` intocados; C167 congelado; copy pt-BR / identificadores em inglês; sem migration; sem collection/Consent; escopo só em `(frontend)/corte` + o dono do share kit.
|
||||
- [ ] Testes: guard `data-theme="editorial"` no spec C167; `campaignSpeechCut` e `campaignSpeechAcervo` verdes; `pnpm gate:fast` verde.
|
||||
|
||||
## Self-score (decision-quality: 5/5)
|
||||
|
||||
1. **Decisões caras com rejeitadas?** Sim — superfície (rejeitadas B/C), composição responsiva (B/C/D), tokens (A) e copy do indisponível (A) têm opções + rejeitadas nomeadas. (5/5)
|
||||
2. **Cabe no appetite (~1–1,5 dia, uma página)?** Sim — 1 arquivo novo + 3 edits + 1 asserção; sem schema, sem dependência nova. (5/5)
|
||||
3. **Rabbit holes nomeados?** Sim — gradiente global, header/paleta, player custom, arquivo de componente novo, collection/migration. (5/5)
|
||||
4. **Depth check — reusa shells/helpers?** Sim — `[type]/layout.tsx` (shell+theme), `SpeechCutShareActions`/`CopyLinkButton` (dono do share), `buildSpeechCutShare`/`speechCutPublicPath` (URL), tokens `editorial`; nenhum pass-through raso criado. (5/5)
|
||||
5. **Intenção preservada?** Sim — a engenharia não reescreveu o outcome; cada corte remete ao aceite de produto. (5/5)
|
||||
|
|
@ -1,6 +1,11 @@
|
|||
import { CopyLinkButton } from '@/components/CopyLinkButton'
|
||||
import { SiteHeader } from '@/components/SiteHeader'
|
||||
import { SpeechCutShareActions } from '@/components/SpeechCutShareActions'
|
||||
import { formatSpeechDate, formatSpeechSpan } from '@/lib/speechClock'
|
||||
import {
|
||||
SpeechCutDownloadButton,
|
||||
SpeechCutShareActions,
|
||||
SpeechCutWhatsAppButton,
|
||||
} from '@/components/SpeechCutShareActions'
|
||||
import { formatSpeechClock, formatSpeechDate } from '@/lib/speechClock'
|
||||
import { speechCutPublicPath } from '@/lib/speechCut'
|
||||
import { parseYoutubeVideoId } from '@/lib/speechVod'
|
||||
import type { Media, Speech, SpeechCut } from '@/payload-types'
|
||||
|
|
@ -63,6 +68,10 @@ const loadPublishedCut = async (id: string): Promise<SpeechCut | null> => {
|
|||
* share kit. `noindex` on purpose: the link circulates, the page is not listed.
|
||||
* An unpublished or unknown id resolves to the same not-found screen, so the
|
||||
* kill switch does not reveal whether the cut existed.
|
||||
*
|
||||
* C176 — the surface is neutral (`editorial` theme set by the segment layout):
|
||||
* the video leads, the reading column is comfortable, and WhatsApp is the
|
||||
* primary action above the fold on mobile.
|
||||
*/
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
|
|
@ -126,12 +135,15 @@ export default async function SpeechCutPage({ params }: { params: Promise<{ id:
|
|||
const media = mediaOf(cut)
|
||||
const speech = speechOf(cut)
|
||||
const videoUrl = media?.url ?? null
|
||||
const youtubeUrl = speech?.youtubeUrl ?? null
|
||||
const poster = youtubeCoverUrl(cut)
|
||||
const globalMetadata = await getCachedGlobal('metadata')()
|
||||
const { siteUrl } = resolveSiteMetadata(globalMetadata)
|
||||
const publicUrl =
|
||||
absoluteSitePath(siteUrl, speechCutPublicPath(cut.id)) ?? speechCutPublicPath(cut.id)
|
||||
const durationLabel = formatSpeechSpan(
|
||||
// C176 — the gate reads the span as a clock (`2:14`) next to the player, not
|
||||
// the compact `2min14s` the acervo view models use.
|
||||
const durationLabel = formatSpeechClock(
|
||||
cut.durationSeconds ?? Math.max(0, cut.endSeconds - cut.startSeconds),
|
||||
)
|
||||
|
||||
|
|
@ -139,7 +151,7 @@ export default async function SpeechCutPage({ params }: { params: Promise<{ id:
|
|||
<>
|
||||
<SiteHeader />
|
||||
<main className="w-full">
|
||||
<div className="mx-auto w-full max-w-3xl px-4 py-10 sm:px-6 lg:px-8 lg:py-14">
|
||||
<div className="mx-auto w-full max-w-4xl sm:px-6 sm:py-12 lg:px-8">
|
||||
{videoUrl ? (
|
||||
<video
|
||||
controls
|
||||
|
|
@ -147,48 +159,85 @@ export default async function SpeechCutPage({ params }: { params: Promise<{ id:
|
|||
playsInline
|
||||
poster={poster ?? undefined}
|
||||
src={videoUrl}
|
||||
className="aspect-video w-full rounded-xl border bg-black"
|
||||
className="aspect-video w-full bg-black object-cover sm:rounded-xl sm:border"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<h1 className="mt-6 text-2xl font-bold leading-tight tracking-tight text-balance sm:text-3xl">
|
||||
{cut.title}
|
||||
</h1>
|
||||
<div className="mx-auto w-full max-w-3xl px-4 pt-5 pb-8 sm:px-0 sm:pt-7 sm:pb-0">
|
||||
<h1 className="text-left text-2xl leading-[1.2] font-bold tracking-tight text-balance sm:text-3xl">
|
||||
{cut.title}
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{speech?.type ?? 'Fala'}
|
||||
{speech ? <> · {formatSpeechDate(speech.speechAt)}</> : null} · {durationLabel}
|
||||
{speech?.youtubeUrl ? (
|
||||
<>
|
||||
{' · '}
|
||||
<a
|
||||
href={speech.youtubeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
Ver sessão no YouTube ↗
|
||||
</a>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
<p className="mt-5 text-base leading-relaxed text-foreground/90">{cut.description}</p>
|
||||
|
||||
<SpeechCutShareActions
|
||||
className="mt-7"
|
||||
url={publicUrl}
|
||||
title={cut.title}
|
||||
downloadUrl={videoUrl}
|
||||
downloadFilename={media?.filename}
|
||||
primary="whatsapp"
|
||||
/>
|
||||
|
||||
<div className="mt-10 border-t pt-4">
|
||||
<p className="text-xs text-muted-foreground">{CREDIT}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Página não listada — o link circula, mas não é indexada nem aparece em buscas.
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{speech?.type ?? 'Fala'}
|
||||
{speech ? <> · {formatSpeechDate(speech.speechAt)}</> : null} · {durationLabel}
|
||||
{youtubeUrl ? (
|
||||
<span className="hidden sm:inline">
|
||||
{' · '}
|
||||
<a
|
||||
href={youtubeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
Ver sessão no YouTube ↗
|
||||
</a>
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
<SpeechCutWhatsAppButton
|
||||
url={publicUrl}
|
||||
title={cut.title}
|
||||
className="mt-5 min-h-12 w-full font-semibold sm:hidden"
|
||||
/>
|
||||
|
||||
<p className="mt-6 max-w-[36ch] text-base leading-7 text-foreground/90 sm:mt-5 sm:max-w-[68ch]">
|
||||
{cut.description}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="mt-5 grid grid-cols-2 gap-2 sm:hidden"
|
||||
role="group"
|
||||
aria-label="Ações secundárias"
|
||||
>
|
||||
<CopyLinkButton url={publicUrl} className="min-h-11 w-full" />
|
||||
{videoUrl ? (
|
||||
<SpeechCutDownloadButton
|
||||
url={videoUrl}
|
||||
filename={media?.filename}
|
||||
className="min-h-11 w-full"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<SpeechCutShareActions
|
||||
className="mt-7 hidden sm:flex"
|
||||
controlClassName="min-h-11"
|
||||
url={publicUrl}
|
||||
title={cut.title}
|
||||
downloadUrl={videoUrl}
|
||||
downloadFilename={media?.filename}
|
||||
primary="whatsapp"
|
||||
/>
|
||||
|
||||
{youtubeUrl ? (
|
||||
<a
|
||||
href={youtubeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-5 inline-flex min-h-8 items-center text-sm font-medium text-foreground/90 underline decoration-border underline-offset-4 sm:hidden"
|
||||
>
|
||||
Ver sessão no YouTube ↗
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
<footer className="mt-8 border-t border-border pt-4 text-xs leading-5 text-muted-foreground sm:mt-10">
|
||||
<p>{CREDIT}</p>
|
||||
<p className="mt-1">
|
||||
Página não listada — o link circula, mas não é indexada nem aparece em buscas.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
23
src/app/(frontend)/corte/layout.tsx
Normal file
23
src/app/(frontend)/corte/layout.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import React from 'react'
|
||||
|
||||
/**
|
||||
* C176 — shared shell for the unlisted public cut page (`/corte/<id>`) and its
|
||||
* not-found screen.
|
||||
*
|
||||
* The root `(frontend)` layout locks the document with `overflow-hidden`, so
|
||||
* this segment must own its internal scroll container (also what makes the
|
||||
* `SiteHeader` stick). Without a `data-theme`, `/corte` inherited `:root` —
|
||||
* whose background is the dark-red body gradient — and the content sat on a
|
||||
* tinted surface. Mirroring `[type]/layout.tsx` gives the segment the neutral
|
||||
* `editorial` palette and the scroll container in one place.
|
||||
*/
|
||||
export default function SpeechCutLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
data-theme="editorial"
|
||||
className="h-dvh w-full overflow-y-auto bg-background text-foreground"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,28 +1,34 @@
|
|||
import { SiteHeader } from '@/components/SiteHeader'
|
||||
import { XCircleIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
/**
|
||||
* C167 — the same friendly screen for an unpublished cut and an unknown id:
|
||||
* the link is gone, and the page does not reveal whether the cut existed.
|
||||
* C167/C176 — the same friendly screen for an unpublished cut and an unknown
|
||||
* id: the link is gone, and the page does not reveal whether the cut existed.
|
||||
*/
|
||||
export default function SpeechCutNotFound() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-dvh w-full flex-col">
|
||||
<SiteHeader />
|
||||
<main className="flex w-full items-center justify-center px-4 py-24">
|
||||
<div className="max-w-md text-center">
|
||||
<h1 className="text-lg font-semibold">Este corte não está disponível</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
O link pode ter sido despublicado ou o endereço está incorreto.
|
||||
<main className="flex w-full flex-1 items-center justify-center px-4 py-16">
|
||||
<div className="max-w-lg text-center">
|
||||
<span className="mx-auto flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<XCircleIcon className="size-6" aria-hidden="true" />
|
||||
</span>
|
||||
<h1 className="mt-5 text-2xl font-bold leading-tight tracking-tight sm:text-3xl">
|
||||
Este corte não está disponível
|
||||
</h1>
|
||||
<p className="mx-auto mt-3 max-w-[48ch] text-base leading-7 text-muted-foreground">
|
||||
O conteúdo pode ter sido removido ou o endereço não está correto.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-6 inline-flex min-h-10 items-center justify-center rounded-lg border px-4 text-sm"
|
||||
className="mt-6 inline-flex min-h-11 items-center justify-center rounded-lg border border-border bg-card px-4 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
Ir para jorgesolla1313.com.br
|
||||
Voltar ao início
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,58 @@ import { Button } from '@/components/ui/button'
|
|||
import { buildSpeechCutShare } from '@/lib/speechCut'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type SpeechCutWhatsAppButtonProps = {
|
||||
/** Absolute public URL of the cut. */
|
||||
url: string
|
||||
title: string
|
||||
variant?: 'default' | 'outline'
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* C176 — the WhatsApp CTA of the cut surfaces, extracted from the share kit so
|
||||
* the public page can place it on its own (full-width, above the description on
|
||||
* mobile) without duplicating the shared message/URL.
|
||||
*/
|
||||
export const SpeechCutWhatsAppButton = ({
|
||||
url,
|
||||
title,
|
||||
variant = 'default',
|
||||
className,
|
||||
}: SpeechCutWhatsAppButtonProps) => {
|
||||
const share = buildSpeechCutShare({ title, url })
|
||||
|
||||
return (
|
||||
<Button asChild variant={variant} className={cn('min-h-10', className)}>
|
||||
<a href={share.whatsAppUrl} target="_blank" rel="noopener noreferrer">
|
||||
<WhatsAppIcon data-icon="inline-start" />
|
||||
Compartilhar no WhatsApp
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
type SpeechCutDownloadButtonProps = {
|
||||
/** Stored MP4; absent while a cut is still being processed. */
|
||||
url: string
|
||||
filename?: string | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** C176 — the MP4 download control, extracted for the same reason as the WhatsApp one. */
|
||||
export const SpeechCutDownloadButton = ({
|
||||
url,
|
||||
filename = null,
|
||||
className,
|
||||
}: SpeechCutDownloadButtonProps) => (
|
||||
<Button asChild variant="outline" className={cn('min-h-10', className)}>
|
||||
<a href={url} download={filename ?? ''}>
|
||||
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Baixar arquivo (MP4)
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
|
||||
type SpeechCutShareActionsProps = {
|
||||
/** Absolute public URL of the cut. */
|
||||
url: string
|
||||
|
|
@ -18,6 +70,8 @@ type SpeechCutShareActionsProps = {
|
|||
/** Which action leads on this surface (acervo: copy; public page: WhatsApp). */
|
||||
primary?: 'copy' | 'whatsapp'
|
||||
className?: string
|
||||
/** Per-control sizing/extra classes (the public page asks for 44px controls). */
|
||||
controlClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -33,39 +87,31 @@ export const SpeechCutShareActions = ({
|
|||
downloadFilename = null,
|
||||
primary = 'copy',
|
||||
className,
|
||||
}: SpeechCutShareActionsProps) => {
|
||||
const share = buildSpeechCutShare({ title, url })
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-wrap items-center gap-2', className)}
|
||||
data-slot="speech-cut-share"
|
||||
>
|
||||
{primary === 'whatsapp' ? (
|
||||
<Button asChild className="min-h-10">
|
||||
<a href={share.whatsAppUrl} target="_blank" rel="noopener noreferrer">
|
||||
<WhatsAppIcon data-icon="inline-start" />
|
||||
Compartilhar no WhatsApp
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<CopyLinkButton url={url} variant={primary === 'copy' ? 'default' : 'outline'} />
|
||||
{primary === 'copy' ? (
|
||||
<Button asChild variant="outline" className="min-h-10">
|
||||
<a href={share.whatsAppUrl} target="_blank" rel="noopener noreferrer">
|
||||
<WhatsAppIcon data-icon="inline-start" />
|
||||
Compartilhar no WhatsApp
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{downloadUrl ? (
|
||||
<Button asChild variant="outline" className="min-h-10">
|
||||
<a href={downloadUrl} download={downloadFilename ?? ''}>
|
||||
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Baixar arquivo (MP4)
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
controlClassName,
|
||||
}: SpeechCutShareActionsProps) => (
|
||||
<div className={cn('flex flex-wrap items-center gap-2', className)} data-slot="speech-cut-share">
|
||||
{primary === 'whatsapp' ? (
|
||||
<SpeechCutWhatsAppButton url={url} title={title} className={controlClassName} />
|
||||
) : null}
|
||||
<CopyLinkButton
|
||||
url={url}
|
||||
variant={primary === 'copy' ? 'default' : 'outline'}
|
||||
className={controlClassName}
|
||||
/>
|
||||
{primary === 'copy' ? (
|
||||
<SpeechCutWhatsAppButton
|
||||
url={url}
|
||||
title={title}
|
||||
variant="outline"
|
||||
className={controlClassName}
|
||||
/>
|
||||
) : null}
|
||||
{downloadUrl ? (
|
||||
<SpeechCutDownloadButton
|
||||
url={downloadUrl}
|
||||
filename={downloadFilename}
|
||||
className={controlClassName}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -149,6 +149,9 @@ test.describe('Acervo speech cuts (C167)', () => {
|
|||
expect(html).toContain('/api/media/file/')
|
||||
expect(html).toContain('Fonte: Câmara dos Deputados · CC BY 4.0')
|
||||
expect(html).toContain('noindex')
|
||||
// C176 — the neutral `editorial` surface is what removes the dark-red
|
||||
// body gradient this page used to inherit; the attribute is the guard.
|
||||
expect(html).toContain('data-theme="editorial"')
|
||||
expect(html).toContain('https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg')
|
||||
expect(html).toContain('Ver sessão no YouTube')
|
||||
} finally {
|
||||
|
|
|
|||
Loading…
Reference in a new issue