From f5f412a5290b3d4bddc5a6186bb777c1ad3ea5b5 Mon Sep 17 00:00:00 2001
From: Francisco Solla
Date: Fri, 18 Sep 2026 18:50:43 -0300
Subject: [PATCH] =?UTF-8?q?feat(C194):=20biblioteca=20de=20reels=20na=20ve?=
=?UTF-8?q?rtical=20Comunica=C3=A7=C3=A3o=20(#1153)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Lista publicados-only com capas 9:16, detalhe com player 9:16 e painel de
downloads (vídeo sem áudio primário; com áudio, mp3 e .srt quando existem;
transcrição em texto) e kill switch reversível: despublicar tira da lista e
bloqueia a mídia (C193 decision B), republicar reativa e re-carimba publishedAt.
Fiação: gate communicationCatalog (rename do speechCatalog), 3º sub-item da
vertical no nav, rule da lista no chrome, paths e loader próprios; sem
migration, sem Consent, sem tocar Media.read ou a rota de mídia do C193.
Design tier: DEGRADED — crítica final do designer indisponível no tier
primário (limite de uso); requer sign-off humano antes de push/PR.
---
docs/changelog/2026-09-18-c194.md | 1 +
docs/plans/reels-biblioteca-impl.md | 102 ++++++
docs/plans/reels-biblioteca-ui-design.html | 113 +++++--
scripts/lib/e2e-affected-manifest.mjs | 21 +-
.../(app)/comunicacao/acervo/[id]/page.tsx | 4 +-
.../comunicacao/acervo/cortes/[id]/page.tsx | 4 +-
.../(app)/comunicacao/acervo/cortes/page.tsx | 2 +-
.../(app)/comunicacao/acervo/page.tsx | 2 +-
.../campanha/(app)/comunicacao/page.tsx | 2 +-
.../comunicacao/reels/[id]/not-found.tsx | 29 ++
.../(app)/comunicacao/reels/[id]/page.tsx | 138 ++++++++
.../reels/[id]/publicacao/route.ts | 27 ++
.../(app)/comunicacao/reels/[id]/types.ts | 12 +
.../(app)/comunicacao/reels/loading.tsx | 19 ++
.../campanha/(app)/comunicacao/reels/page.tsx | 77 +++++
src/app/(campaign)/campanha/actions/reels.ts | 58 ++++
src/app/(campaign)/campanha/actions/speech.ts | 2 +-
.../campaign/reels/ReelDownloadPanel.tsx | 109 +++++++
.../campaign/reels/ReelLibraryCard.tsx | 43 +++
.../campaign/reels/ReelLibraryList.tsx | 11 +
src/components/campaign/reels/ReelPlayer.tsx | 29 ++
.../campaign/reels/ReelPublicationPanel.tsx | 131 ++++++++
.../campaign/reels/ReelStatusBadge.tsx | 14 +
.../campaign/reels/ReelTranscriptCard.tsx | 54 ++++
.../campaign/shared/usePublicationToggle.ts | 54 ++++
src/components/campaign/shell/nav.ts | 7 +-
.../speech/SpeechCutPublicationPanel.tsx | 38 +--
src/lib/campaignPageChrome.ts | 11 +
src/lib/campaignPaths.ts | 11 +
src/lib/copyFeedback.ts | 46 ++-
src/lib/reel.ts | 190 +++++++++++
src/lib/schemas/reel.ts | 21 ++
src/utilities/campaignPageActor.ts | 19 +-
src/utilities/reels/reelListUrl.ts | 60 ++++
src/utilities/reels/reelPageData.ts | 120 +++++++
src/utilities/speech/speechPageData.ts | 4 +-
tests/e2e/campaignReel.e2e.spec.ts | 205 ++++++++++++
tests/e2e/fixtures/campaignHttpTest.ts | 2 +-
tests/int/reelLibrary.int.spec.ts | 303 ++++++++++++++++++
tests/unit/campaignNav.unit.spec.ts | 12 +-
tests/unit/campaignPageActorGate.unit.spec.ts | 30 +-
tests/unit/campaignPageChrome.unit.spec.ts | 8 +
tests/unit/reel.unit.spec.ts | 106 ++++++
43 files changed, 2156 insertions(+), 95 deletions(-)
create mode 100644 docs/changelog/2026-09-18-c194.md
create mode 100644 docs/plans/reels-biblioteca-impl.md
create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/not-found.tsx
create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/page.tsx
create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/publicacao/route.ts
create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/types.ts
create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/reels/loading.tsx
create mode 100644 src/app/(campaign)/campanha/(app)/comunicacao/reels/page.tsx
create mode 100644 src/app/(campaign)/campanha/actions/reels.ts
create mode 100644 src/components/campaign/reels/ReelDownloadPanel.tsx
create mode 100644 src/components/campaign/reels/ReelLibraryCard.tsx
create mode 100644 src/components/campaign/reels/ReelLibraryList.tsx
create mode 100644 src/components/campaign/reels/ReelPlayer.tsx
create mode 100644 src/components/campaign/reels/ReelPublicationPanel.tsx
create mode 100644 src/components/campaign/reels/ReelStatusBadge.tsx
create mode 100644 src/components/campaign/reels/ReelTranscriptCard.tsx
create mode 100644 src/components/campaign/shared/usePublicationToggle.ts
create mode 100644 src/lib/schemas/reel.ts
create mode 100644 src/utilities/reels/reelListUrl.ts
create mode 100644 src/utilities/reels/reelPageData.ts
create mode 100644 tests/e2e/campaignReel.e2e.spec.ts
create mode 100644 tests/int/reelLibrary.int.spec.ts
diff --git a/docs/changelog/2026-09-18-c194.md b/docs/changelog/2026-09-18-c194.md
new file mode 100644
index 00000000..2deb0817
--- /dev/null
+++ b/docs/changelog/2026-09-18-c194.md
@@ -0,0 +1 @@
+**C194 — Biblioteca de reels na vertical Comunicação (2026-09-18):** a assessoria produzia os Reels de tutorial fora do Teqo e não tinha onde se servir do material pronto; o C193 registrou o modelo e os arquivos privados, e o C194 entregou a biblioteca. (1) **Lista** em `/campanha/comunicacao/reels` — grid de capas 9:16 (o card inteiro é o link), só `published`, ordenada por `-publishedAt`, com paginação pelo shell do sistema de listas e vazio honesto ("a produção acontece fora desta tela"); loader próprio `src/utilities/reels/reelPageData.ts` (Local API `overrideAccess:false` + `user`, `select` enxuto) e URL `src/utilities/reels/reelListUrl.ts` (só `page`). (2) **Detalhe** em `/campanha/comunicacao/reels/[id]` — player 9:16 (`video-audio ?? video`, time nativo), painel de downloads (CTA primário "Baixar vídeo sem áudio" com `?download=1`; vídeo com áudio, mp3 e `.srt` como linhas quando existem, com motivo honesto quando não) e transcrição/roteiro em texto com copiar (owner `copyFeedback` parametrizado por assunto) — sem duração (o modelo não tem campo; a decisão visual foi adaptada pelo `designer`, tier primário). (3) **Kill switch** no detalhe: `POST .../reels/[id]/publicacao` via `campaignJsonMutationRoute` + `setReelPublishedForActor` (gate fresco do papel, update único, sem transação, hook do C193 re-carimba `publishedAt`); despublicar tira da lista e bloqueia player/downloads na tela com "Republicar" no lugar (a rota de mídia do C193 responde 404 a `draft`/`unpublished` — decisão B herdada, sem link morto), e a transcrição (texto, não mídia) permanece legível. (4) **Acesso**: só `communicator`/`coordinator`/`candidate` (fail-closed; `advisor`/`leader` redirecionados, rota de mutação com mensagem de domínio própria). (5) **Fiação**: 3º sub-item "Reels" da vertical no `nav.ts` (teste do pin atualizado), rule da lista no `campaignPageChrome.ts` (o detalhe resolve chrome próprio), paths em `campaignPaths.ts`, e o rename mecânico do gate `speechCatalog` → `communicationCatalog` (dono `campaignPageActor.ts`; inclui a referência que o C193 deixou passar em `speechPageData.ts`, que quebrava o typecheck do `main`). (6) **Sem migration, sem Consent, sem `Media.read`/rota do C193 tocados, site público e cards idênticos.** (7) **Testes**: unit de view models (arquivos bloqueados fora de `published`, capa/alt, publicação), nav/chrome/gate; int do loader (lista só publicados e ordenada, matriz de papéis, detalhe em qualquer status, `ReelNotFoundError`) e do kill switch (despublicar não apaga, republicar re-carimba `publishedAt`, negado/inexistente); e2e novo `campaignReel` (HTTP: lista/detalhe/download, anônimo 404, unpublish sem lista e sem mídia, republicar volta, advisor/leader negados) mapeado no manifest. `pnpm gate:fast` verde + e2e local do spec novo e das mesmas superfícies (cortes/acervo) verdes.
diff --git a/docs/plans/reels-biblioteca-impl.md b/docs/plans/reels-biblioteca-impl.md
new file mode 100644
index 00000000..5d199d04
--- /dev/null
+++ b/docs/plans/reels-biblioteca-impl.md
@@ -0,0 +1,102 @@
+# Impl: Biblioteca de reels na vertical Comunicação
+
+Status: aprovado
+Atualizado em: 2026-09-18
+Issue: #1153
+Intenção: docs/plans/reels-biblioteca.md
+Appetite restante: herdado (~1–2 dias eng; lista + detalhe + kill switch verificáveis com fixtures)
+
+## Leitura da intenção
+
+- **Outcome:** `/campanha/comunicacao/reels` lista os reels **publicados** (capa 9:16 + título), o detalhe abre o reel em 9:16 e oferece os arquivos disponíveis (vídeo sem áudio primário, vídeo com áudio, mp3, `.srt`, transcrição) para a assessoria trabalhar fora do site; despublicar tira da lista sem apagar e republicar reativa; acesso só a `communicator`/`coordinator`/`candidate`, fail-closed.
+- **O que NÃO negociar:** arquivos privados (rota C193 é a única porta; `Media.read` intocado); nada de publicação/edição de vídeo na UI; nenhum reel some por acidente (kill switch reversível); sem busca/filtros no MVP (`-publishedAt`); sem `Consent` novo; sem migração (C193 fechou o modelo).
+- **O que reavaliar:** a recomendação A da intenção para o link direto do despublicado **conflita com o C193 (decisão B, mergeada)** — vale B: `draft`/`unpublished` não servem mídia; o detalhe interno continua abrindo, mas sem player/downloads. Verifiquei que o regex `/acervo/[^/]+` do chrome (`campaignPageChrome.ts:297`) **não** engole `/reels` (só falta a rule da lista). O conflito C184 (#1106) **não foi mergeado** (commit docs-only) — prosseguir. Os defers do C193 (`reelMediaPath`/`isReelStatus`) têm gatilho explícito neste item.
+
+## Abordagem recomendada
+
+```mermaid
+flowchart TD
+ L["Lista /campanha/comunicacao/reels"] -->|"loadReelLibraryPageData · status=published · sort -publishedAt"| DB[("reel + reelMedia (depth 1)")]
+ D["Detalhe /campanha/comunicacao/reels/:id"] -->|"loadReelDetailPageData · qualquer status"| DB
+ D --> P["Player 9:16 + painel de downloads"]
+ P -->|"video-audio ?? video · ?download=1"| M["GET .../media/kind — rota C193: só published"]
+ D --> K["ReelPublicationPanel"]
+ K -->|"POST .../publicacao"| A["setReelPublishedForActor: gate fresco + update único"]
+ A --> DB
+```
+
+**D1 — Dados da página. Opções: A | B | C — Recomendação: A.** Loader em `src/utilities/reels/reelPageData.ts` (`server-only`, `overrideAccess: false, user`, select enxuto), view models puros no dono do vocabulário `src/lib/reel.ts` (como `lib/speechCut.ts` faz) e URL curta em `src/utilities/reels/reelListUrl.ts` (só `page`, reusando `campaignListUrl`). Rejeitadas: **B** query inline nos `page.tsx` (espalha o where do kill switch e mata o int sem HTTP); **C** top-level `src/utilities/reelPageData.ts` (top-level é pinado por `codebaseConventions.unit.spec.ts`; a subpasta `utilities/reels/` já é o dono criado no C193).
+
+**D2 — Render da lista. Opções: A | B | C — Recomendação: A.** Grid RSC de capas 9:16, todo o card é um `` (sem ilha cliente), com os shells de lista (`CampaignPageShell`, `CampaignListPendingBoundary/Results`, `CampaignListEmptyState`, `CampaignListFooter`). Rejeitadas: **B** `CampaignTable` (o sistema de listas é de tabela; o design é capa-led e o job é varrer por capa/título); **C** busca/filtro/sort toggle no MVP (produto recomenda A; ordem fixa `-publishedAt`).
+
+**D3 — Transporte do kill switch. Opções: A | B | C — Recomendação: A.** `POST .../reels/[id]/publicacao` com `campaignJsonMutationRoute` + `setReelPublishedForActor` em `src/app/(campaign)/campanha/actions/reels.ts` (gate fresco `canReadCommunicationCatalog`, find ator-escopado, **um** `payload.update`); painel cliente com `useState` pending + `postCampaignJson` + `router.refresh()` (precedente `SpeechCutPublicationPanel.tsx:42-65`, regra `campanha-action-feedback`). Rejeitadas: **B** server action + `
- Em aberto no gate: quem opera o kill switch e se o link direto do
- despublicado permanece acessível. Esta proposta representa a recomendação A/A do plano.
+ Contrato C193 incorporado: communicator, coordinator e candidate podem
+ operar o kill switch. Despublicado preserva o registro, mas bloqueia player e downloads;
+ os arquivos voltam ao republicar. O tempo total mostrado pelo player vem do próprio vídeo,
+ não de um campo do catálogo.
diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs
index 2534accc..ca48dab6 100644
--- a/scripts/lib/e2e-affected-manifest.mjs
+++ b/scripts/lib/e2e-affected-manifest.mjs
@@ -262,9 +262,14 @@ export const E2E_AFFECTED_MANIFEST = [
// Zod input schemas surface in the browser through form flows; the
// public-site and newsletter specs exercise those flows end to end. C167
// adds the cut schemas, whose HTTP contract is the cut spec; C168's library
- // mutations live in the same `speech` actions file.
- prefixes: ['src/lib/schemas', 'src/app/(campaign)/campanha/actions/speech.ts'],
- specs: ['frontend', 'campaignNewsletter', 'campaignSpeechCut'],
+ // mutations live in the same `speech` actions file; C194 adds the reel
+ // schemas and its kill-switch action.
+ prefixes: [
+ 'src/lib/schemas',
+ 'src/app/(campaign)/campanha/actions/speech.ts',
+ 'src/app/(campaign)/campanha/actions/reels.ts',
+ ],
+ specs: ['frontend', 'campaignNewsletter', 'campaignSpeechCut', 'campaignReel'],
},
{
// Web Push client — the opt-in toast mounts on the campaign shell, so a
@@ -299,18 +304,22 @@ export const E2E_AFFECTED_MANIFEST = [
},
{
// C154 — the communication vertical: the acervo search/filters/detail and
- // the `speechCatalog` role gate. `src/lib/speech*` carries the pure
+ // the `communicationCatalog` role gate. `src/lib/speech*` carries the pure
// search/highlight modules the RSC list renders with. C167 adds the cut
- // routes/player/dialog and the public cut page below.
+ // routes/player/dialog and the public cut page below. C194 adds the reel
+ // library (list/detail/downloads/kill switch) to the same vertical.
prefixes: [
`${CAMPAIGN_APP}/comunicacao`,
'src/components/campaign/speech',
+ 'src/components/campaign/reels',
'src/utilities/speech',
+ 'src/utilities/reels',
'src/lib/speech',
+ 'src/lib/reel',
// The role predicates drive the vertical gate and the assistant surfaces.
'src/lib/campaignRoles',
],
- specs: ['campaignSpeechAcervo', 'campaignSpeechCut'],
+ specs: ['campaignSpeechAcervo', 'campaignSpeechCut', 'campaignReel'],
},
{
// C167 — the unlisted public page of a cut: 200 with the stored file and
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx
index f7eac65e..398a0786 100644
--- a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx
@@ -33,7 +33,7 @@ export async function generateMetadata({ params }: SpeechDetailPageProps) {
if (!speechId) return campaignPageMetadata({ title: 'Fala' })
const [user, payload] = await Promise.all([
- requireCampaignPageActor({ gate: 'speechCatalog' }),
+ requireCampaignPageActor({ gate: 'communicationCatalog' }),
getPayload({ config }),
])
@@ -49,7 +49,7 @@ export default async function SpeechDetailPage({ params, searchParams }: SpeechD
const [{ id }, query, user, payload] = await Promise.all([
params,
searchParams,
- requireCampaignPageActor({ gate: 'speechCatalog' }),
+ requireCampaignPageActor({ gate: 'communicationCatalog' }),
getPayload({ config }),
])
const speechId = strictDecimalInteger(id)
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/page.tsx
index 92a986ee..d1ea8861 100644
--- a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/[id]/page.tsx
@@ -34,7 +34,7 @@ export async function generateMetadata({ params }: SpeechCutDetailPageProps) {
if (!cutId) return campaignPageMetadata({ title: 'Corte' })
const [user, payload] = await Promise.all([
- requireCampaignPageActor({ gate: 'speechCatalog' }),
+ requireCampaignPageActor({ gate: 'communicationCatalog' }),
getPayload({ config }),
])
@@ -54,7 +54,7 @@ export async function generateMetadata({ params }: SpeechCutDetailPageProps) {
export default async function SpeechCutDetailPage({ params }: SpeechCutDetailPageProps) {
const [{ id }, user, payload] = await Promise.all([
params,
- requireCampaignPageActor({ gate: 'speechCatalog' }),
+ requireCampaignPageActor({ gate: 'communicationCatalog' }),
getPayload({ config }),
])
const cutId = strictDecimalInteger(id)
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/page.tsx
index 19a37237..d00dceba 100644
--- a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/cortes/page.tsx
@@ -32,7 +32,7 @@ type SpeechCutLibraryPageProps = {
/** C168 — the cut library: what was already cut, newest first, searchable. */
export default async function SpeechCutLibraryPage({ searchParams }: SpeechCutLibraryPageProps) {
const [user, payload] = await Promise.all([
- requireCampaignPageActor({ gate: 'speechCatalog' }),
+ requireCampaignPageActor({ gate: 'communicationCatalog' }),
getPayload({ config }),
])
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx
index 1ff3ff26..780f39c4 100644
--- a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx
@@ -31,7 +31,7 @@ type SpeechAcervoPageProps = {
export default async function SpeechAcervoPage({ searchParams }: SpeechAcervoPageProps) {
const [user, payload] = await Promise.all([
- requireCampaignPageActor({ gate: 'speechCatalog' }),
+ requireCampaignPageActor({ gate: 'communicationCatalog' }),
getPayload({ config }),
])
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/page.tsx
index b03abc81..b9340cdd 100644
--- a/src/app/(campaign)/campanha/(app)/comunicacao/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/comunicacao/page.tsx
@@ -9,6 +9,6 @@ import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
* stable destination and future vertical items have a home.
*/
export default async function CommunicationHomePage() {
- await requireCampaignPageActor({ gate: 'speechCatalog' })
+ await requireCampaignPageActor({ gate: 'communicationCatalog' })
redirect(CAMPAIGN_COMMUNICATION_ACERVO)
}
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/not-found.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/not-found.tsx
new file mode 100644
index 00000000..38c1a6f1
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/not-found.tsx
@@ -0,0 +1,29 @@
+import Link from 'next/link'
+
+import { Button } from '@/components/ui/button'
+import {
+ Empty,
+ EmptyContent,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyTitle,
+} from '@/components/ui/Empty'
+import { CAMPAIGN_COMMUNICATION_REELS } from '@/lib/campaignPaths'
+
+export default function ReelNotFoundPage() {
+ return (
+
+
+ Reel não encontrado
+
+ O reel pode ter sido removido ou você não tem acesso a ele.
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/page.tsx
new file mode 100644
index 00000000..ab1219bc
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/page.tsx
@@ -0,0 +1,138 @@
+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 { ReelDownloadPanel } from '@/components/campaign/reels/ReelDownloadPanel'
+import { ReelPlayer } from '@/components/campaign/reels/ReelPlayer'
+import { ReelPublicationPanel } from '@/components/campaign/reels/ReelPublicationPanel'
+import { ReelStatusBadge } from '@/components/campaign/reels/ReelStatusBadge'
+import { ReelTranscriptCard } from '@/components/campaign/reels/ReelTranscriptCard'
+import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
+import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
+import { Button } from '@/components/ui/button'
+import { campaignPageMetadata } from '@/lib/campaignPageChrome'
+import { CAMPAIGN_COMMUNICATION_REELS } from '@/lib/campaignPaths'
+import { strictDecimalInteger } from '@/utilities/campaignListUrl'
+import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
+import { loadReelDetailPageData, ReelNotFoundError } from '@/utilities/reels/reelPageData'
+
+type ReelDetailPageProps = {
+ params: Promise<{ id: string }>
+}
+
+export async function generateMetadata({ params }: ReelDetailPageProps) {
+ const { id } = await params
+ const reelId = strictDecimalInteger(id)
+ if (!reelId) return campaignPageMetadata({ title: 'Reel' })
+
+ const [user, payload] = await Promise.all([
+ requireCampaignPageActor({ gate: 'communicationCatalog' }),
+ getPayload({ config }),
+ ])
+
+ try {
+ const reel = await loadReelDetailPageData(payload, user, reelId)
+ return campaignPageMetadata({ title: reel.title })
+ } catch {
+ return campaignPageMetadata({ title: 'Reel' })
+ }
+}
+
+/**
+ * C194 — one reel: the 9:16 player, every artifact the production delivered
+ * and the kill switch. Unpublished reels stay reachable here (so the switch
+ * can be reversed) but the player and the downloads are withheld — the C193
+ * media route serves only `published`.
+ */
+export default async function ReelDetailPage({ params }: ReelDetailPageProps) {
+ const [{ id }, user, payload] = await Promise.all([
+ params,
+ requireCampaignPageActor({ gate: 'communicationCatalog' }),
+ getPayload({ config }),
+ ])
+ const reelId = strictDecimalInteger(id)
+ if (!reelId) notFound()
+
+ const reel = await loadReelDetailPageData(payload, user, reelId).catch((error) => {
+ if (error instanceof ReelNotFoundError) notFound()
+ throw error
+ })
+
+ return (
+
+
+
+
+ Este reel não aparece na biblioteca. O registro e os arquivos continuam guardados,
+ mas nenhuma mídia é servida enquanto ele não estiver publicado.
+
+
+ )}
+
+
+
+ {reel.transcript ? (
+
+
+
+ ) : null}
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/publicacao/route.ts b/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/publicacao/route.ts
new file mode 100644
index 00000000..f6028e54
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/publicacao/route.ts
@@ -0,0 +1,27 @@
+import { NextResponse } from 'next/server'
+
+import { setReelPublishedForActor } from '@/app/(campaign)/campanha/actions/reels'
+import {
+ REEL_FORBIDDEN_MESSAGE,
+ REEL_GENERIC_ERROR_MESSAGE,
+ REEL_NOT_FOUND_MESSAGE,
+ reelPublicationRequestSchema,
+} from '@/lib/schemas/reel'
+import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
+
+import type { ReelPublicationResponse } from '../types'
+
+export const dynamic = 'force-dynamic'
+
+export const POST = campaignJsonMutationRoute(
+ {
+ bodySchema: reelPublicationRequestSchema,
+ safeMessages: [REEL_FORBIDDEN_MESSAGE, REEL_NOT_FOUND_MESSAGE],
+ genericMessage: REEL_GENERIC_ERROR_MESSAGE,
+ },
+ async (body) => {
+ const reel = await setReelPublishedForActor(body)
+
+ return NextResponse.json({ status: 'success', reel })
+ },
+)
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/types.ts b/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/types.ts
new file mode 100644
index 00000000..c25f40a3
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/comunicacao/reels/[id]/types.ts
@@ -0,0 +1,12 @@
+import type { ReelStatus } from '@/lib/reel'
+
+/** C194 — the JSON envelope of the kill-switch route. */
+export type ReelPublicationResponse =
+ | {
+ status: 'success'
+ reel: {
+ id: number
+ status: ReelStatus
+ }
+ }
+ | { status: 'error'; message: string }
diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/reels/loading.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/reels/loading.tsx
new file mode 100644
index 00000000..6707a0f9
--- /dev/null
+++ b/src/app/(campaign)/campanha/(app)/comunicacao/reels/loading.tsx
@@ -0,0 +1,19 @@
+import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
+import { Skeleton } from '@/components/ui/skeleton'
+
+/** C194 — grid-shaped skeleton for the reel library and its detail route. */
+export default function ReelLibraryLoading() {
+ return (
+
+
+ Baixar
+
+ )
+}
diff --git a/src/components/campaign/reels/ReelLibraryCard.tsx b/src/components/campaign/reels/ReelLibraryCard.tsx
new file mode 100644
index 00000000..a2f0011d
--- /dev/null
+++ b/src/components/campaign/reels/ReelLibraryCard.tsx
@@ -0,0 +1,43 @@
+import Link from 'next/link'
+
+import type { ReelLibraryItemViewModel } from '@/lib/reel'
+
+/**
+ * C194 — one card of the reel library: the 9:16 cover leads, the title and the
+ * feature follow, and the whole card is the link to the detail (there is no
+ * second action on the card, so the "Abrir reel" affordance is visual).
+ */
+export const ReelLibraryCard = ({ reel }: { reel: ReelLibraryItemViewModel }) => (
+
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element -- the cover is served by the authenticated campaign route; the Next optimizer has no session cookie. */}
+
+
+ {reel.statusLabel}
+
+
+
{reel.title}
+
+
+
+
{reel.title}
+
{reel.featureLabel}
+ {reel.publishedAtLabel ? (
+
Publicado em {reel.publishedAtLabel}
+ ) : null}
+
+ Abrir reel
+
+
+
+
+)
diff --git a/src/components/campaign/reels/ReelLibraryList.tsx b/src/components/campaign/reels/ReelLibraryList.tsx
new file mode 100644
index 00000000..7ce51a64
--- /dev/null
+++ b/src/components/campaign/reels/ReelLibraryList.tsx
@@ -0,0 +1,11 @@
+import { ReelLibraryCard } from '@/components/campaign/reels/ReelLibraryCard'
+import type { ReelLibraryItemViewModel } from '@/lib/reel'
+
+/** C194 — the cover grid: two columns on the phone, four on the desktop. */
+export const ReelLibraryList = ({ rows }: { rows: readonly ReelLibraryItemViewModel[] }) => (
+
+ {rows.map((reel) => (
+
+ ))}
+
+)
diff --git a/src/components/campaign/reels/ReelPlayer.tsx b/src/components/campaign/reels/ReelPlayer.tsx
new file mode 100644
index 00000000..dfd60afb
--- /dev/null
+++ b/src/components/campaign/reels/ReelPlayer.tsx
@@ -0,0 +1,29 @@
+import type { ReelDetailViewModel } from '@/lib/reel'
+
+/**
+ * C194 — the 9:16 player. The source prefers the rough cut with narration
+ * (`video-audio`) and falls back to the silent primary video; while the reel is
+ * unpublished there is no source at all and the player states why (C193 kill
+ * switch withholds every file).
+ */
+export const ReelPlayer = ({ reel }: { reel: ReelDetailViewModel }) => {
+ if (!reel.videoSourceUrl) {
+ return (
+
+ Player indisponível
+
{reel.mediaBlockedLabel}
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/campaign/reels/ReelPublicationPanel.tsx b/src/components/campaign/reels/ReelPublicationPanel.tsx
new file mode 100644
index 00000000..b69001a3
--- /dev/null
+++ b/src/components/campaign/reels/ReelPublicationPanel.tsx
@@ -0,0 +1,131 @@
+'use client'
+
+import { ReelStatusBadge } from '@/components/campaign/reels/ReelStatusBadge'
+import { usePublicationToggle } from '@/components/campaign/shared/usePublicationToggle'
+import { Alert, AlertDescription } from '@/components/ui/Alert'
+import {
+ AlertDialog,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/AlertDialog'
+import { Button } from '@/components/ui/button'
+import { Spinner } from '@/components/ui/Spinner'
+import { campaignReelPublicationHref } from '@/lib/campaignPaths'
+import type { ReelStatus } from '@/lib/reel'
+
+type ReelPublicationPanelProps = {
+ reelId: number
+ status: ReelStatus
+}
+
+const UNPUBLISH_WARNING =
+ 'Despublicar tira este reel da lista e bloqueia o player e os downloads. Os arquivos voltam a ficar disponíveis quando o reel for republicado.'
+
+const PUBLISH_WARNING =
+ 'Republicar devolve o reel à lista e libera novamente o player e os downloads.'
+
+/**
+ * C194 — the kill switch on the reel detail. Publishing puts the same row back
+ * on the list and reopens the files; unpublishing takes it off the list and
+ * withholds every file (C193 serves only `published`). Confirmation only on
+ * the destructive direction.
+ */
+export const ReelPublicationPanel = ({ reelId, status }: ReelPublicationPanelProps) => {
+ const { submitting, error, setPublished } = usePublicationToggle({
+ href: campaignReelPublicationHref(reelId),
+ buildBody: (published) => ({ reelId, published }),
+ })
+
+ return (
+
+