teqo-backup/src/components/conteudos/ContentPieceCard.tsx
Francisco Solla dda55adc27 feat(S27): Central de Conteúdos pública — Peça voto pra Solla 1313
/conteudos + /conteudos/<slug>: catálogo filtrável (Tipo · Cidade · Região ·
Tema · Instituição + termo na URL canônica), play sob demanda (Vídeo/Áudio só
carregam no toque; Foto com preview leve), Baixar e Compartilhar do próprio
card com a mensagem de voto por tipo (wa.me sem destinatário, editável; "A
mídia" usa a folha nativa e cai em download onde o aparelho não suportar) e o
card personalizado como peça sintética do catálogo → /cards.

- leitura pública nova (contentPieceReads) cacheada na tag contentPieces com o
  gate where status=publicado + contentPieceIsPublic; a rota GET
  /conteudos/<slug>/midia reusa buildPrivateMediaResponse (range, disposition,
  private no-store, 404 uniforme) com download jorge-solla-1313-<slug>.<ext> e
  a allowlist inline do dono estendida (webp/gif/avif + áudios das peças)
- contrato público puro do catálogo (filtros/VM/download) + mensagens de voto
  (contentPieceShare); OG com fallback do global; conteudos entra nos slugs
  reservados; kill switch por peça vale na hora (lista, página, rodapé/selo);
  descoberta condicional no CampaignFooter e no header local
- UI portada classe-a-classe do design aprovado (cenas 01–08, com as cenas
  06–08 estendidas no trigger a); sem migration, sem Consent e sem access novo;
  contentMedia segue privada (sem segunda cópia de mídia)
- testes: 2 unit specs novos, 18 casos int (leitura pública + rota de mídia) e
  e2e frontendConteudos (5 casos); manifest e curado atualizados
- follow-up de engenharia: #1276 (docs/plans/escala-dry-pos-s27.md)

Closes #1255
2026-09-23 11:13:57 -03:00

110 lines
3.7 KiB
TypeScript

import Link from 'next/link'
import { contentPieceMediaKind, type ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import { cn } from '@/lib/utils'
import { ContentPieceDownloadLink, ContentPieceShareButton } from './ContentPieceActions'
import { ContentPieceMedia } from './ContentPieceMedia'
import { CONTENT_PIECE_CARD, CONTENT_PIECE_TAG } from './contentPieceClasses'
type ContentPieceCardProps = {
item: ContentPiecePublicItem
playing: boolean
onToggle: () => void
onEnded: () => void
onShare: (item: ContentPiecePublicItem) => void
}
const CardTag = ({ item }: { item: ContentPiecePublicItem }) => (
<span className={CONTENT_PIECE_TAG}>{item.isLink ? item.originLabel : item.typeLabel}</span>
)
const CardTitle = ({ item, className }: { item: ContentPiecePublicItem; className?: string }) => (
<h3 className={cn('mt-2 font-[family-name:var(--font-exo2)] font-extrabold', className)}>
<Link
href={item.publicPath}
className="rounded-sm text-black underline-offset-4 hover:underline focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-red)"
>
{item.title}
</Link>
</h3>
)
const CardMeta = ({ item }: { item: ContentPiecePublicItem }) =>
item.metaLabel ? <p className="mt-1 text-xs text-(--campaign-muted)">{item.metaLabel}</p> : null
/**
* S27 — one catalogue card (artefato: cenas 01/02/07): video/audio/card and
* link pieces take the full card with the asset on top; a photo/text is the
* compact horizontal row. Actions work from the card itself — download never
* needs the piece page.
*/
export const ContentPieceCard = ({
item,
playing,
onToggle,
onEnded,
onShare,
}: ContentPieceCardProps) => {
const kind = contentPieceMediaKind(item)
const isCompact = kind === 'image' || kind === 'text' || kind === 'other'
if (isCompact) {
return (
<article
data-content-piece={item.slug}
data-state={playing ? 'playing' : 'stopped'}
className={cn(CONTENT_PIECE_CARD, 'flex items-center gap-4 p-3')}
>
<ContentPieceMedia
item={item}
playing={playing}
onToggle={onToggle}
onEnded={onEnded}
variant="thumb"
className="h-24 w-32 shrink-0 rounded-lg"
/>
<div className="min-w-0 flex-1">
<CardTag item={item} />
<CardTitle item={item} className="text-sm" />
<CardMeta item={item} />
<div className="mt-3 flex flex-wrap gap-2">
<ContentPieceDownloadLink item={item} className="min-h-9 px-3" />
<ContentPieceShareButton item={item} onShare={onShare} className="min-h-9 px-3" />
</div>
</div>
</article>
)
}
return (
<article
data-content-piece={item.slug}
data-state={playing ? 'playing' : 'stopped'}
className={CONTENT_PIECE_CARD}
>
<ContentPieceMedia
item={item}
playing={playing}
onToggle={onToggle}
onEnded={onEnded}
className="aspect-video"
/>
<div className="p-4">
<CardTag item={item} />
<CardTitle item={item} className="text-base" />
<CardMeta item={item} />
<div className={cn('mt-4 grid gap-2', item.file ? 'grid-cols-2' : 'grid-cols-1')}>
<ContentPieceDownloadLink item={item} />
<ContentPieceShareButton item={item} onShare={onShare} />
</div>
<Link
href={item.publicPath}
className="mt-3 block rounded-sm text-center text-xs font-bold text-(--pt-red) underline-offset-4 hover:underline focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-red) sm:hidden"
>
Ver detalhes da peça →
</Link>
</div>
</article>
)
}