mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(S45): cards verticais no catálogo, na home e na página da peça
This commit is contained in:
parent
6394c97a9e
commit
ddcc05d09a
14 changed files with 326 additions and 78 deletions
|
|
@ -1,23 +1,28 @@
|
|||
/**
|
||||
* S27 — the loading state of the Central (artefato: cena 05): the skeleton
|
||||
* shape of the board, so a slow connection sees the page it is about to get.
|
||||
* S45 — the vertical board shape (4 columns from `lg`; the dominant 9:16 video
|
||||
* slot, an approximation of the mixed board) with the media edge-to-edge like
|
||||
* the real card.
|
||||
*/
|
||||
export default function ConteudosLoading() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl px-5 py-10 sm:px-8">
|
||||
<div className="h-44 animate-pulse rounded-xl bg-(--campaign-band)" />
|
||||
<div className="mt-9 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[0, 1, 2].map((index) => (
|
||||
<div className="h-44 animate-pulse rounded-xl bg-(--campaign-band) motion-reduce:animate-none" />
|
||||
<div className="mt-9 grid items-start gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[0, 1, 2, 3].map((index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="overflow-hidden rounded-[14px] border border-(--campaign-line) bg-white p-4"
|
||||
className="overflow-hidden rounded-[14px] border border-(--campaign-line) bg-white"
|
||||
>
|
||||
<div className="h-44 animate-pulse rounded-xl bg-(--campaign-band)" />
|
||||
<div className="mt-4 h-5 w-3/4 animate-pulse rounded bg-(--campaign-band)" />
|
||||
<div className="mt-3 h-4 w-full animate-pulse rounded bg-(--campaign-band)" />
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<div className="h-11 animate-pulse rounded bg-(--campaign-band)" />
|
||||
<div className="h-11 animate-pulse rounded bg-(--campaign-band)" />
|
||||
<div className="aspect-[9/16] animate-pulse bg-(--campaign-band) motion-reduce:animate-none" />
|
||||
<div className="p-4">
|
||||
<div className="h-5 w-3/4 animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
|
||||
<div className="mt-3 h-4 w-full animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<div className="h-11 animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
|
||||
<div className="h-11 animate-pulse rounded bg-(--campaign-band) motion-reduce:animate-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import Link from 'next/link'
|
|||
|
||||
import { sendCardOpeningEvent } from '@/lib/contentEvents'
|
||||
import { contentPieceTypeLabels } from '@/lib/contentPiece'
|
||||
import type { CardCatalogItem } from '@/lib/contentPieceCatalog'
|
||||
import { CONTENT_PIECE_ASPECT_CLASS, type CardCatalogItem } from '@/lib/contentPieceCatalog'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
|
|
@ -15,7 +15,7 @@ import {
|
|||
|
||||
/**
|
||||
* S38 — the art of a model item (artefato: cenas 01–03): the model's real file
|
||||
* cropped from the top (the banners and the name line live there), or the
|
||||
* whole in the 3:4 vertical slot (S45 — the art is never cropped), or the
|
||||
* neutral dashed placeholder of the photo models, whose master is only the
|
||||
* frame with a transparent photo window. The `NOVO` badge of the newest model
|
||||
* sits on the art.
|
||||
|
|
@ -23,7 +23,8 @@ import {
|
|||
const CardItemArt = ({ item }: { item: CardCatalogItem }) => (
|
||||
<span
|
||||
className={cn(
|
||||
'relative grid min-h-40 place-items-center overflow-hidden sm:aspect-video sm:min-h-0',
|
||||
'relative grid place-items-center overflow-hidden',
|
||||
CONTENT_PIECE_ASPECT_CLASS.model,
|
||||
item.art.kind === 'placeholder'
|
||||
? 'bg-[linear-gradient(145deg,#eee9e5,#fff)]'
|
||||
: 'bg-[linear-gradient(145deg,#184e92,#0876ac)]',
|
||||
|
|
@ -44,8 +45,8 @@ const CardItemArt = ({ item }: { item: CardCatalogItem }) => (
|
|||
src={item.art.src}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(min-width: 1024px) 360px, (min-width: 640px) 45vw, 128px"
|
||||
className="object-cover object-top"
|
||||
sizes="(min-width: 1024px) 260px, (min-width: 640px) 45vw, 112px"
|
||||
className="object-contain"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -72,7 +73,7 @@ export const ContentCatalogCardItem = ({ item }: { item: CardCatalogItem }) => (
|
|||
onClick={() => sendCardOpeningEvent(item.modelId)}
|
||||
className={cn(
|
||||
CONTENT_PIECE_CARD,
|
||||
'grid grid-cols-[128px_1fr] text-black no-underline sm:block',
|
||||
'grid grid-cols-[112px_minmax(0,1fr)] text-black no-underline sm:block',
|
||||
item.badge ? 'border-2 border-[#e4102f]' : null,
|
||||
CONTENT_PIECE_FOCUS,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
import { contentPieceMediaKind, type ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
|
||||
import {
|
||||
CONTENT_PIECE_ASPECT_CLASS,
|
||||
contentPieceBoardBucket,
|
||||
contentPieceMediaKind,
|
||||
type ContentPiecePublicItem,
|
||||
} from '@/lib/contentPieceCatalog'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { ContentPieceDownloadLink, ContentPieceShareButton } from './ContentPieceActions'
|
||||
|
|
@ -84,8 +89,9 @@ const CardThemeEvidence = ({ item }: { item: ContentPiecePublicItem }) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* S27/S28 — one catalogue card (artefato: cenas 01/02/05–07): video/audio/card
|
||||
* and link pieces take the full card with the asset on top; a photo/text is the
|
||||
* S27/S28 — one catalogue card (artefato: cenas 01/02/05–07): video takes the
|
||||
* full card with the 9:16 frame on top, audio/link take the wide row (the
|
||||
* media column at the left, no framing to preserve), and a photo/text is the
|
||||
* compact horizontal row. Actions work from the card itself — download never
|
||||
* needs the piece page.
|
||||
*/
|
||||
|
|
@ -97,23 +103,25 @@ export const ContentPieceCard = ({
|
|||
onShare,
|
||||
themeMode,
|
||||
}: ContentPieceCardProps) => {
|
||||
const kind = contentPieceMediaKind(item)
|
||||
const isCompact = kind === 'image' || kind === 'text' || kind === 'other'
|
||||
const bucket = contentPieceBoardBucket(contentPieceMediaKind(item))
|
||||
|
||||
if (isCompact) {
|
||||
if (bucket === 'compact') {
|
||||
return (
|
||||
<article
|
||||
data-content-piece={item.slug}
|
||||
data-state={playing ? 'playing' : 'stopped'}
|
||||
className={cn(CONTENT_PIECE_CARD, 'flex items-center gap-4 p-3')}
|
||||
>
|
||||
{/* S45 — a fixed 96×120 (4:5) box: the compact row is a flex child, and
|
||||
`aspect-ratio` on a flex item is the WebKit #265243 trap; the fixed
|
||||
box keeps the whole photo, never a crop. */}
|
||||
<ContentPieceMedia
|
||||
item={item}
|
||||
playing={playing}
|
||||
onToggle={onToggle}
|
||||
onEnded={onEnded}
|
||||
variant="thumb"
|
||||
className="h-24 w-32 shrink-0 rounded-lg"
|
||||
className="h-[120px] w-24 shrink-0 rounded-lg"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTag item={item} themeMode={themeMode} />
|
||||
|
|
@ -121,14 +129,49 @@ export const ContentPieceCard = ({
|
|||
<ContentPieceCardMeta item={item} />
|
||||
<CardThemeEvidence 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" />
|
||||
<ContentPieceDownloadLink item={item} className="min-h-11 px-3" />
|
||||
<ContentPieceShareButton item={item} onShare={onShare} className="min-h-11 px-3" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
const body = (
|
||||
<div className="min-w-0 p-4">
|
||||
<CardTag item={item} themeMode={themeMode} />
|
||||
<ContentPieceCardTitle item={item} className="text-base" />
|
||||
<ContentPieceCardMeta item={item} />
|
||||
<CardThemeEvidence 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-yellow) focus-visible:shadow-[0_0_0_6px_rgb(24_78_146/72%)] sm:hidden"
|
||||
>
|
||||
Ver detalhes da peça →
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (bucket === 'wide') {
|
||||
return (
|
||||
<article
|
||||
data-content-piece={item.slug}
|
||||
data-state={playing ? 'playing' : 'stopped'}
|
||||
className={cn(
|
||||
CONTENT_PIECE_CARD,
|
||||
'grid min-h-[156px] grid-cols-[112px_minmax(0,1fr)] sm:min-h-[170px] sm:grid-cols-[190px_minmax(0,1fr)]',
|
||||
)}
|
||||
>
|
||||
<ContentPieceMedia item={item} playing={playing} onToggle={onToggle} onEnded={onEnded} />
|
||||
{body}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
data-content-piece={item.slug}
|
||||
|
|
@ -140,24 +183,9 @@ export const ContentPieceCard = ({
|
|||
playing={playing}
|
||||
onToggle={onToggle}
|
||||
onEnded={onEnded}
|
||||
className="aspect-video"
|
||||
className={CONTENT_PIECE_ASPECT_CLASS.video}
|
||||
/>
|
||||
<div className="p-4">
|
||||
<CardTag item={item} themeMode={themeMode} />
|
||||
<ContentPieceCardTitle item={item} className="text-base" />
|
||||
<ContentPieceCardMeta item={item} />
|
||||
<CardThemeEvidence 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>
|
||||
{body}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useState } from 'react'
|
||||
|
||||
import {
|
||||
contentPieceBoardBucket,
|
||||
contentPieceMediaKind,
|
||||
interleaveCatalogItems,
|
||||
isCardCatalogItem,
|
||||
|
|
@ -16,14 +17,15 @@ import { ContentPieceCard } from './ContentPieceCard'
|
|||
import { ContentPieceShareSheet } from './ContentPieceShareSheet'
|
||||
|
||||
/**
|
||||
* S27 — the catalogue board (artefato: cena 01): full cards for video, audio
|
||||
* and link pieces, compact rows for photo/text, one piece plays at a time
|
||||
* (switching unmounts the previous element) and the share sheet is owned here,
|
||||
* so every card opens the same vote message.
|
||||
* S27 — the catalogue board (artefato: cena 01): the vertical board (video and
|
||||
* the S38 models) runs four columns from `lg`, audio/link keep a wide two-column
|
||||
* grid (no framing to preserve) and photo/text keep the compact scan row. One
|
||||
* piece plays at a time (switching unmounts the previous element) and the share
|
||||
* sheet is owned here, so every card opens the same vote message.
|
||||
*
|
||||
* S38 — the six card models are items of the same board: they take the full-card
|
||||
* bucket and the whole item is one link to the studio (`/cards?model=<id>`); the
|
||||
* old single invite tile is gone and no section groups them.
|
||||
* S38 — the six card models are items of the same board: they take the vertical
|
||||
* board bucket and the whole item is one link to the studio (`/cards?model=<id>`);
|
||||
* the old single invite tile is gone and no section groups them.
|
||||
*/
|
||||
export const ContentPieceCatalog = ({
|
||||
items,
|
||||
|
|
@ -36,7 +38,8 @@ export const ContentPieceCatalog = ({
|
|||
const [playingId, setPlayingId] = useState<number | null>(null)
|
||||
const [sharingItem, setSharingItem] = useState<ContentPiecePublicItem | null>(null)
|
||||
|
||||
const largePieces: ContentPiecePublicItem[] = []
|
||||
const videoPieces: ContentPiecePublicItem[] = []
|
||||
const widePieces: ContentPiecePublicItem[] = []
|
||||
const compactItems: ContentPiecePublicItem[] = []
|
||||
const cards: CardCatalogItem[] = []
|
||||
for (const item of items) {
|
||||
|
|
@ -44,13 +47,14 @@ export const ContentPieceCatalog = ({
|
|||
cards.push(item)
|
||||
continue
|
||||
}
|
||||
const kind = contentPieceMediaKind(item)
|
||||
if (kind === null || kind === 'video' || kind === 'audio') largePieces.push(item)
|
||||
const bucket = contentPieceBoardBucket(contentPieceMediaKind(item))
|
||||
if (bucket === 'vertical') videoPieces.push(item)
|
||||
else if (bucket === 'wide') widePieces.push(item)
|
||||
else compactItems.push(item)
|
||||
}
|
||||
// S38 — the models alternate with the full-card pieces (never a block of
|
||||
// their own); the compact rows keep the S27 board below.
|
||||
const largeItems = interleaveCatalogItems(largePieces, cards)
|
||||
// S38 — the models alternate with the video pieces (never a block of their
|
||||
// own); the wide and compact rows keep the S27 board below.
|
||||
const verticalItems = interleaveCatalogItems(videoPieces, cards)
|
||||
|
||||
const cardProps = (item: ContentPiecePublicItem) => ({
|
||||
item,
|
||||
|
|
@ -63,9 +67,9 @@ export const ContentPieceCatalog = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
{largeItems.length > 0 ? (
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{largeItems.map((item) =>
|
||||
{verticalItems.length > 0 ? (
|
||||
<div className="grid items-start gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{verticalItems.map((item) =>
|
||||
isCardCatalogItem(item) ? (
|
||||
<ContentCatalogCardItem key={`card:${item.modelId}`} item={item} />
|
||||
) : (
|
||||
|
|
@ -75,8 +79,16 @@ export const ContentPieceCatalog = ({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{widePieces.length > 0 ? (
|
||||
<div className="mt-6 grid items-start gap-5 lg:grid-cols-2">
|
||||
{widePieces.map((item) => (
|
||||
<ContentPieceCard key={item.id} {...cardProps(item)} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{compactItems.length > 0 ? (
|
||||
<div className="mt-6 grid gap-6 sm:grid-cols-2">
|
||||
<div className="mt-6 grid items-start gap-5 sm:grid-cols-2">
|
||||
{compactItems.map((item) => (
|
||||
<ContentPieceCard key={item.id} {...cardProps(item)} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { sendContentPieceEvent } from '@/lib/contentEvents'
|
||||
import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
|
||||
import {
|
||||
contentPieceAspectClass,
|
||||
contentPieceMediaKind,
|
||||
type ContentPiecePublicItem,
|
||||
} from '@/lib/contentPieceCatalog'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { ContentPieceDownloadLink, ContentPieceShareButton } from './ContentPieceActions'
|
||||
|
|
@ -50,14 +54,17 @@ export const ContentPieceDetail = ({ item }: { item: ContentPiecePublicItem }) =
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-8 lg:grid-cols-[1.2fr_.8fr] lg:gap-10">
|
||||
{/* S45 — the piece page keeps the card's promise: the 360px media column
|
||||
(artefato cena 05) and the frame of the type — Reel 9:16, photo 4:5,
|
||||
wide kinds 16:9 — with the media whole, never cropped. */}
|
||||
<div className="grid items-start gap-8 lg:grid-cols-[360px_minmax(0,1fr)] lg:gap-10">
|
||||
<ContentPieceMedia
|
||||
item={item}
|
||||
playing={playing}
|
||||
onToggle={() => setPlaying((current) => !current)}
|
||||
onEnded={() => setPlaying(false)}
|
||||
variant="detail"
|
||||
className="aspect-video"
|
||||
className={contentPieceAspectClass(contentPieceMediaKind(item)) ?? 'aspect-video'}
|
||||
/>
|
||||
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -241,7 +241,9 @@ export const ContentPieceHomeBoard = ({
|
|||
<div
|
||||
className={cn(
|
||||
'mt-6',
|
||||
isSingle ? 'lg:mt-0' : 'grid gap-4 sm:grid-cols-2 sm:gap-6 lg:mt-9 lg:grid-cols-3',
|
||||
isSingle
|
||||
? 'lg:mt-0'
|
||||
: 'grid items-start gap-4 sm:grid-cols-2 sm:gap-6 lg:mt-9 lg:grid-cols-3',
|
||||
)}
|
||||
>
|
||||
{selection.map(({ item, match }) => (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
import { buildContentPieceCatalogHref } from '@/lib/contentPieceCatalog'
|
||||
import {
|
||||
buildContentPieceCatalogHref,
|
||||
contentPieceAspectClass,
|
||||
contentPieceMediaKind,
|
||||
} from '@/lib/contentPieceCatalog'
|
||||
import type { ContentPieceHomeItem, ContentPieceHomeMatch } from '@/lib/contentPieceHomeSelection'
|
||||
import { slugify } from '@/lib/slug'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -67,6 +71,10 @@ export const ContentPieceHomeCard = ({
|
|||
}) => {
|
||||
const typeHref = item.isLink ? null : buildContentPieceCatalogHref({ tipo: item.type })
|
||||
const matchFacetHref = matchHref(item, match)
|
||||
// S45 — the thumb keeps the form of the type (photo 4:5, Reel 9:16); the
|
||||
// wide kinds (audio/link/text) keep a short fixed column on the phone and the
|
||||
// wide block from `sm` up, where the card becomes a full block.
|
||||
const aspectClass = contentPieceAspectClass(contentPieceMediaKind(item))
|
||||
|
||||
return (
|
||||
<article
|
||||
|
|
@ -83,7 +91,7 @@ export const ContentPieceHomeCard = ({
|
|||
// the card becomes a full block and the media slot goes back to the
|
||||
// catalogue treatment.
|
||||
variant="home-thumb"
|
||||
className="aspect-video self-start"
|
||||
className={cn('self-start', aspectClass ?? 'max-sm:h-[156px] sm:aspect-video')}
|
||||
/>
|
||||
<div className="min-w-0 p-4">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
|
|
|
|||
|
|
@ -50,7 +50,12 @@ const PlayButton = ({
|
|||
aria-label={label}
|
||||
className={cn(
|
||||
'absolute grid place-items-center rounded-full bg-(--pt-yellow) text-(--pt-yellow-ink) shadow-[0_6px_0_#cfb900,0_10px_22px_rgb(0_0_0/15%)] transition-[transform,box-shadow] duration-150 ease-out hover:-translate-y-0.5 active:translate-y-[3px] active:shadow-[0_3px_0_#cfb900] motion-reduce:transition-none',
|
||||
compact ? 'size-11 sm:size-16' : 'size-14 sm:size-16',
|
||||
// C243 — public light-surface focus: the yellow signal plus the dark blue
|
||||
// outer separator, so the ring separates from the yellow button itself.
|
||||
'focus-visible:outline-[3px] focus-visible:outline-offset-[3px] focus-visible:outline-(--pt-yellow) focus-visible:shadow-[0_0_0_6px_rgb(24_78_146/72%)]',
|
||||
// S45 — the approved play (artefato cena 07): 56px on the card, 44px on
|
||||
// the compact thumb (44 → 56 from `sm`, where that card becomes a block).
|
||||
compact ? 'size-11 sm:size-14' : 'size-14',
|
||||
)}
|
||||
>
|
||||
<PlayIcon />
|
||||
|
|
@ -141,6 +146,7 @@ export const ContentPieceMedia = ({
|
|||
if (kind === null) {
|
||||
return (
|
||||
<div
|
||||
data-content-piece-media=""
|
||||
className={cn(
|
||||
base,
|
||||
'grid place-items-center bg-[linear-gradient(145deg,#fff4f5,#eef4fb)]',
|
||||
|
|
@ -163,7 +169,9 @@ export const ContentPieceMedia = ({
|
|||
className={cn(
|
||||
'mt-2 font-[family-name:var(--font-exo2)] font-black',
|
||||
item.origin === 'instagram' ? 'text-[#e4102f]' : 'text-[#184e92]',
|
||||
variant === 'detail' ? 'text-4xl' : 'text-2xl',
|
||||
// S45 — the wide row of the catalogue and the home thumb carry a
|
||||
// narrow media column on the phone; from `sm` the full scale returns.
|
||||
variant === 'detail' ? 'text-4xl' : 'text-base sm:text-2xl',
|
||||
)}
|
||||
>
|
||||
{item.originLabel}
|
||||
|
|
@ -180,14 +188,17 @@ export const ContentPieceMedia = ({
|
|||
|
||||
if (kind === 'image') {
|
||||
return (
|
||||
<div className={cn(base, variant === 'detail' && 'rounded-2xl border border-black/10')}>
|
||||
<div
|
||||
data-content-piece-media=""
|
||||
className={cn(base, variant === 'detail' && 'rounded-2xl border border-black/10')}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- private proxy path with on-demand headers */}
|
||||
<img
|
||||
src={item.file?.path}
|
||||
alt={item.title}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -196,6 +207,7 @@ export const ContentPieceMedia = ({
|
|||
if (kind === 'text') {
|
||||
return (
|
||||
<div
|
||||
data-content-piece-media=""
|
||||
className={cn(
|
||||
base,
|
||||
'grid place-items-center bg-(--campaign-cream) px-3 text-center',
|
||||
|
|
@ -218,6 +230,7 @@ export const ContentPieceMedia = ({
|
|||
if (playing) {
|
||||
return (
|
||||
<div
|
||||
data-content-piece-media=""
|
||||
className={cn(
|
||||
base,
|
||||
'bg-[#180a09]',
|
||||
|
|
@ -252,6 +265,7 @@ export const ContentPieceMedia = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
data-content-piece-media=""
|
||||
className={cn(
|
||||
base,
|
||||
'grid place-items-center bg-[linear-gradient(135deg,#e5e2df,#f7f5f3)]',
|
||||
|
|
@ -274,7 +288,7 @@ export const ContentPieceMedia = ({
|
|||
// the browser's broken-image glyph over the slot for a moment, and
|
||||
// the design's honest state is the neutral surface, never a glyph.
|
||||
className={cn(
|
||||
'absolute inset-0 h-full w-full object-cover transition-opacity duration-200',
|
||||
'absolute inset-0 h-full w-full object-contain transition-opacity duration-200 motion-reduce:transition-none',
|
||||
frameState !== 'ready' && 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
|
|
@ -304,6 +318,7 @@ export const ContentPieceMedia = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
data-content-piece-media=""
|
||||
className={cn(
|
||||
base,
|
||||
'grid place-items-center bg-[linear-gradient(135deg,#e5e2df,#f7f5f3)] px-3 text-center',
|
||||
|
|
|
|||
|
|
@ -72,6 +72,6 @@ export const CONTENT_PIECE_LOCAL_TAG_LINK = cn(
|
|||
|
||||
/** S42 — the card's `Ver esta peça →` under the share button (artefato `.piece-link`). */
|
||||
export const CONTENT_PIECE_PIECE_LINK = cn(
|
||||
'mt-2 flex min-h-9 w-full items-center justify-center rounded-sm text-xs font-extrabold text-(--pt-red) underline-offset-4 hover:underline',
|
||||
'mt-2 flex min-h-11 w-full items-center justify-center rounded-sm text-xs font-extrabold text-(--pt-red) underline-offset-4 hover:underline sm:min-h-9',
|
||||
CONTENT_PIECE_FOCUS,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -560,6 +560,44 @@ export const contentPieceMediaKind = (
|
|||
return 'other'
|
||||
}
|
||||
|
||||
/**
|
||||
* S45 — the frame each form breathes in (artefato cena 07): a Reel keeps 9:16,
|
||||
* a feed photo keeps 4:5 and the S38 model art keeps 3:4. The media itself
|
||||
* never crops (`object-contain`), so a genuinely horizontal piece lands whole
|
||||
* in the neutral letterbox of its slot; audio, link, text and other have no
|
||||
* framing to preserve and stay wide (no class).
|
||||
*/
|
||||
export const CONTENT_PIECE_ASPECT_CLASS = {
|
||||
video: 'aspect-[9/16]',
|
||||
image: 'aspect-[4/5]',
|
||||
model: 'aspect-[3/4]',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* The slot class of a piece by its media kind; `undefined` means the host keeps
|
||||
* its wide form (audio, link, text, other, unknown).
|
||||
*/
|
||||
export const contentPieceAspectClass = (kind: ContentPieceMediaKind | null): string | undefined => {
|
||||
if (kind === 'video' || kind === 'image') return CONTENT_PIECE_ASPECT_CLASS[kind]
|
||||
return undefined
|
||||
}
|
||||
|
||||
export type ContentPieceBoardBucket = 'vertical' | 'wide' | 'compact'
|
||||
|
||||
/**
|
||||
* S45 — the bucket of a piece on the board (artefato cena 01): video and the
|
||||
* S38 models share the vertical board, audio/link keep the wide row and
|
||||
* image/text/other keep the compact scan row. One rule for the card and the
|
||||
* catalogue grids.
|
||||
*/
|
||||
export const contentPieceBoardBucket = (
|
||||
kind: ContentPieceMediaKind | null,
|
||||
): ContentPieceBoardBucket => {
|
||||
if (kind === 'video') return 'vertical'
|
||||
if (kind === 'audio' || kind === null) return 'wide'
|
||||
return 'compact'
|
||||
}
|
||||
|
||||
const DOWNLOAD_PREFIX = 'jorge-solla-1313'
|
||||
const DOWNLOAD_FALLBACK_EXTENSION = 'bin'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import type { APIRequestContext, Page } from '@playwright/test'
|
||||
|
|
@ -46,9 +47,15 @@ const createdLeadershipIds: number[] = []
|
|||
|
||||
const uniqueMarker = () => randomUUID().slice(0, 8)
|
||||
|
||||
const mediaFor = (type: 'video' | 'foto' | 'texto') => {
|
||||
const mediaFor = (type: 'video' | 'foto' | 'audio' | 'texto') => {
|
||||
if (type === 'video') return { mimetype: 'video/mp4', buffer: TEST_MP4, extension: 'mp4' }
|
||||
if (type === 'foto') return { mimetype: 'image/png', buffer: TEST_PNG, extension: 'png' }
|
||||
if (type === 'audio')
|
||||
return {
|
||||
mimetype: 'audio/mpeg',
|
||||
buffer: readFileSync(join(process.cwd(), 'tests/fixtures/jingle-tone.mp3')),
|
||||
extension: 'mp3',
|
||||
}
|
||||
return {
|
||||
mimetype: 'text/plain',
|
||||
buffer: Buffer.from('Peça voto pra Solla 1313.'),
|
||||
|
|
@ -92,7 +99,8 @@ const createPiece = async (
|
|||
publicFigures?: string[]
|
||||
},
|
||||
): Promise<{ id: number; slug: string }> => {
|
||||
const mediaKind = data.type === 'video' || data.type === 'foto' ? data.type : 'texto'
|
||||
const mediaKind =
|
||||
data.type === 'video' || data.type === 'foto' || data.type === 'audio' ? data.type : 'texto'
|
||||
const mediaSpec = mediaFor(mediaKind)
|
||||
const media =
|
||||
data.withMedia === false
|
||||
|
|
@ -279,8 +287,10 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
|
|||
const headers = await adminHeaders(request, BASE_URL)
|
||||
const videoTitle = `Fim da escala ${uniqueMarker()}`
|
||||
const photoTitle = `Solla no SUS ${uniqueMarker()}`
|
||||
const audioTitle = `Áudio da Central ${uniqueMarker()}`
|
||||
const video = await createPiece(request, headers, { title: videoTitle, type: 'video' })
|
||||
const photo = await createPiece(request, headers, { title: photoTitle, type: 'foto' })
|
||||
const audio = await createPiece(request, headers, { title: audioTitle, type: 'audio' })
|
||||
|
||||
const media = mediaRequests(page)
|
||||
const frames = frameRequests(page)
|
||||
|
|
@ -293,12 +303,13 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
|
|||
page.getByRole('heading', { name: 'Uma mensagem sua pode conquistar mais um voto.' }),
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('Central de Conteúdos').first()).toBeVisible()
|
||||
await expect(page.locator('article[data-content-piece]')).toHaveCount(2)
|
||||
await expect(page.locator('article[data-content-piece]')).toHaveCount(3)
|
||||
// S38 — the six models are items of the same board: the card item and the
|
||||
// pieces share one grid (no section of their own, no invite tile).
|
||||
await expect(page.locator('[data-card-model]')).toHaveCount(6)
|
||||
// The video piece shares the full-card grid with the models (the photo is a
|
||||
// compact row below): the models are items of the board, not a section.
|
||||
// S45 — the video shares the vertical board with the models (the photo is a
|
||||
// compact row below and the audio keeps the wide row): the models are items
|
||||
// of the board, not a section.
|
||||
const board = page.locator('[data-card-model]').first().locator('xpath=..')
|
||||
await expect(board.locator('article[data-content-piece]')).toHaveCount(1)
|
||||
await expect(board.locator('[data-card-model]')).toHaveCount(6)
|
||||
|
|
@ -331,8 +342,29 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
|
|||
.toBeGreaterThan(0)
|
||||
await expect(page.getByRole('button', { name: `Reproduzir ${videoTitle}` })).toBeVisible()
|
||||
|
||||
// S45 — every piece appears in the form of its type: the Reel keeps the
|
||||
// 9:16 slot (the still whole, `contain`), the photo keeps the 96×120 (4:5)
|
||||
// compact thumb, the audio keeps the wide row and the model art keeps 3:4.
|
||||
const mediaSlot = (slug: string) =>
|
||||
page.locator(`article[data-content-piece="${slug}"] [data-content-piece-media]`)
|
||||
const videoSlot = await mediaSlot(video.slug).boundingBox()
|
||||
expect(videoSlot!.height / videoSlot!.width).toBeCloseTo(16 / 9, 2)
|
||||
await expect(still).toHaveCSS('object-fit', 'contain')
|
||||
const photoSlot = await mediaSlot(photo.slug).boundingBox()
|
||||
expect(photoSlot!.width).toBeCloseTo(96, 0)
|
||||
expect(photoSlot!.height).toBeCloseTo(120, 0)
|
||||
await expect(mediaSlot(photo.slug).locator('img')).toHaveCSS('object-fit', 'contain')
|
||||
const audioSlot = await mediaSlot(audio.slug).boundingBox()
|
||||
expect(audioSlot!.width).toBeCloseTo(190, 0)
|
||||
expect(audioSlot!.height).toBeGreaterThanOrEqual(156)
|
||||
const modelSlot = await page
|
||||
.locator('[data-card-model="time-de-voce"] > span')
|
||||
.first()
|
||||
.boundingBox()
|
||||
expect(modelSlot!.height / modelSlot!.width).toBeCloseTo(4 / 3, 2)
|
||||
|
||||
// Play mounts only the tapped piece and fetches only its media. The still
|
||||
// leaves the slot: the player takes the same 16:9 box (cena 03).
|
||||
// leaves the slot: the player takes the same 9:16 box (cena 06).
|
||||
await page.getByRole('button', { name: `Reproduzir ${videoTitle}` }).click()
|
||||
await expect(page.locator('video')).toHaveCount(1)
|
||||
await expect.poll(() => media.filter((url) => url.includes(video.slug)).length).toBe(1)
|
||||
|
|
@ -368,6 +400,17 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
|
|||
|
||||
await sheet.getByRole('button', { name: 'Fechar', exact: true }).click()
|
||||
await expect(sheet).toHaveCount(0)
|
||||
|
||||
// S45 — the vertical board never overflows the phone (cena 02).
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto('/conteudos')
|
||||
await waitForSettledPage(page)
|
||||
await expect(page.locator('article[data-content-piece]')).toHaveCount(3)
|
||||
const overflow = await page.evaluate(() => {
|
||||
const container = document.querySelector<HTMLElement>('[data-theme="campaign-site"]')
|
||||
return container ? container.scrollWidth - container.clientWidth : 0
|
||||
})
|
||||
expect(overflow).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('filters by facet and term and shows the honest no-results state', async ({
|
||||
|
|
@ -378,8 +421,9 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
|
|||
await unpublishEveryPiece(request, headers)
|
||||
const marker = uniqueMarker()
|
||||
const videoTitle = `Debate na rádio ${marker}`
|
||||
const photoTitle = `Card do giro ${marker}`
|
||||
const video = await createPiece(request, headers, { title: videoTitle, type: 'video' })
|
||||
await createPiece(request, headers, { title: `Card do giro ${marker}`, type: 'foto' })
|
||||
const photo = await createPiece(request, headers, { title: photoTitle, type: 'foto' })
|
||||
|
||||
await page.goto(`/conteudos?tipo=video`)
|
||||
await waitForSettledPage(page)
|
||||
|
|
@ -410,6 +454,19 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
|
|||
await expect(page).toHaveURL(new RegExp(`/conteudos/${video.slug}$`))
|
||||
await expect(page.getByRole('heading', { name: videoTitle })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Voltar à Central' })).toBeVisible()
|
||||
|
||||
// S45 — the piece page keeps the card's promise: the 9:16 stage in the
|
||||
// 360px media column (cena 05), never wider than the text.
|
||||
const stage = await page.locator('[data-content-piece-media]').boundingBox()
|
||||
expect(stage!.width).toBeCloseTo(360, 0)
|
||||
expect(stage!.height / stage!.width).toBeCloseTo(16 / 9, 2)
|
||||
|
||||
// The photo keeps the 4:5 stage on its own page.
|
||||
await page.goto(`/conteudos/${photo.slug}`)
|
||||
await expect(page.getByRole('heading', { name: photoTitle })).toBeVisible()
|
||||
const photoStage = await page.locator('[data-content-piece-media]').boundingBox()
|
||||
expect(photoStage!.width).toBeCloseTo(360, 0)
|
||||
expect(photoStage!.height / photoStage!.width).toBeCloseTo(5 / 4, 2)
|
||||
})
|
||||
|
||||
test('lists the card models by nickname, filters by Tipo Card and routes to the studio (S38)', async ({
|
||||
|
|
@ -756,6 +813,13 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
|
|||
await expect(section.getByText('Mais recente', { exact: true })).toHaveCount(2)
|
||||
await expect(section.getByRole('link', { name: 'Mais recente' })).toHaveCount(0)
|
||||
|
||||
// S45 — the 124px phone thumb keeps the form of the type: photo 4:5, Reel 9:16.
|
||||
const photoBox = await photoCard.locator('[data-content-piece-media]').boundingBox()
|
||||
expect(photoBox!.width).toBeCloseTo(124, 0)
|
||||
expect(photoBox!.height / photoBox!.width).toBeCloseTo(5 / 4, 2)
|
||||
const videoBox = await videoCard.locator('[data-content-piece-media]').boundingBox()
|
||||
expect(videoBox!.height / videoBox!.width).toBeCloseTo(16 / 9, 2)
|
||||
|
||||
// S42 — the card shares through the same S27 sheet, without leaving the home.
|
||||
await section.getByRole('button', { name: `Compartilhar ${photoTitle}` }).click()
|
||||
const homeSheet = page.getByRole('dialog', { name: 'Compartilhar peça' })
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import { describe, expect, it } from 'vitest'
|
|||
import {
|
||||
buildContentPieceCatalogHref,
|
||||
cardCatalogItems,
|
||||
CONTENT_PIECE_ASPECT_CLASS,
|
||||
contentCatalogItems,
|
||||
contentPieceAspectClass,
|
||||
contentPieceBoardBucket,
|
||||
contentPieceCatalogActiveFilters,
|
||||
contentPieceCatalogFacets,
|
||||
contentPieceDownloadFilename,
|
||||
|
|
@ -525,6 +528,26 @@ describe('content piece public view model', () => {
|
|||
expect(contentPieceMediaKind(item({ media: null, sourceUrl: 'https://youtu.be/X' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('maps the frame of the type, leaving the wide kinds without a class (S45)', () => {
|
||||
expect(contentPieceAspectClass('video')).toBe('aspect-[9/16]')
|
||||
expect(contentPieceAspectClass('image')).toBe('aspect-[4/5]')
|
||||
// The S38 model art is not a media kind: its host reads the table directly.
|
||||
expect(CONTENT_PIECE_ASPECT_CLASS.model).toBe('aspect-[3/4]')
|
||||
// Audio, link, text and other have no framing to preserve.
|
||||
for (const kind of ['audio', 'text', 'other', null] as const) {
|
||||
expect(contentPieceAspectClass(kind)).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('buckets the board by type: vertical, wide or compact (S45)', () => {
|
||||
expect(contentPieceBoardBucket('video')).toBe('vertical')
|
||||
expect(contentPieceBoardBucket('audio')).toBe('wide')
|
||||
expect(contentPieceBoardBucket(null)).toBe('wide')
|
||||
for (const kind of ['image', 'text', 'other'] as const) {
|
||||
expect(contentPieceBoardBucket(kind)).toBe('compact')
|
||||
}
|
||||
})
|
||||
|
||||
it('leads the metadata line with who appears in the piece (S37)', () => {
|
||||
expect(item({ leaderNames: ['Maria Silva'] }).metaLabel).toBe(
|
||||
'Maria Silva · Economia e Trabalho · Salvador',
|
||||
|
|
|
|||
|
|
@ -256,6 +256,47 @@ describe('ContentPieceHomeBoard', () => {
|
|||
await waitFor(() => expect(document.querySelector('video')).toBeNull())
|
||||
})
|
||||
|
||||
it('keeps the frame of the type on the sample card (S45)', async () => {
|
||||
readPermission.mockResolvedValue('denied')
|
||||
const photo = item(1)
|
||||
const video = item(2, {
|
||||
title: 'Vídeo da home',
|
||||
type: 'video',
|
||||
typeLabel: 'Vídeo',
|
||||
file: {
|
||||
id: 102,
|
||||
path: '/conteudos/peca-2/midia',
|
||||
mimeType: 'video/mp4',
|
||||
downloadFilename: 'peca-2.mp4',
|
||||
},
|
||||
})
|
||||
const audio = item(3, {
|
||||
title: 'Áudio da home',
|
||||
type: 'audio',
|
||||
typeLabel: 'Áudio',
|
||||
file: {
|
||||
id: 103,
|
||||
path: '/conteudos/peca-3/midia',
|
||||
mimeType: 'audio/mpeg',
|
||||
downloadFilename: 'peca-3.mp3',
|
||||
},
|
||||
})
|
||||
|
||||
renderBoard([photo, video, audio])
|
||||
await waitFor(() => expect(readPermission).toHaveBeenCalled())
|
||||
|
||||
const slot = (slug: string) =>
|
||||
document.querySelector<HTMLElement>(
|
||||
`article[data-content-piece="${slug}"] [data-content-piece-media]`,
|
||||
)
|
||||
|
||||
// Photo keeps 4:5 and the Reel keeps 9:16; the wide kinds stay short on the
|
||||
// phone and take the wide block from `sm` up.
|
||||
expect(slot('peca-1')?.className).toContain('aspect-[4/5]')
|
||||
expect(slot('peca-2')?.className).toContain('aspect-[9/16]')
|
||||
expect(slot('peca-3')?.className).toContain('max-sm:h-[156px]')
|
||||
})
|
||||
|
||||
it('keeps the recent selection when the position fails, without an error or a retry', async () => {
|
||||
readPermission.mockResolvedValue('granted')
|
||||
readPosition.mockResolvedValue({ ok: false, reason: 'timeout' })
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ describe('ContentPieceMedia — the video still before the play (C226)', () => {
|
|||
renderSlot({})
|
||||
|
||||
expect(frameImage()).not.toBeNull()
|
||||
// S45 — the still shows whole in the slot, never cropped.
|
||||
expect(frameImage()?.className).toContain('object-contain')
|
||||
expect(document.querySelector('video')).toBeNull()
|
||||
expect(document.querySelector('audio')).toBeNull()
|
||||
// S27: the element that would fetch the file only exists after the tap.
|
||||
|
|
@ -246,5 +248,7 @@ describe('ContentPieceMedia — the video still before the play (C226)', () => {
|
|||
|
||||
expect(document.querySelectorAll('img')).toHaveLength(1)
|
||||
expect(document.querySelector('img')?.getAttribute('src')).toBe('/conteudos/peca-7/midia')
|
||||
// S45 — the photo shows whole in its 4:5 slot, never cropped.
|
||||
expect(document.querySelector('img')?.className).toContain('object-contain')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue