Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions app/[handle]/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Masthead } from "@/components/masthead";
import { Sidebar } from "@/components/sidebar";

// A moldura do canal: Masthead + Body(Sidebar, Main), igual nas duas telas.
export default function ChannelLayout({ children }: LayoutProps<"/[handle]">) {
return (
<div className="flex min-h-full flex-col bg-surface-page">
<Masthead />
<div className="flex w-full flex-1">
<Sidebar />
<main className="flex w-full min-w-0 flex-1 flex-col">{children}</main>
</div>
</div>
);
}
38 changes: 38 additions & 0 deletions app/[handle]/videos/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { CardVideo } from "@/components/card/video";
import { ChannelHeader } from "@/components/channel-header";
import { Chip } from "@/components/chip";
import { VIDEOS } from "@/lib/fixtures";

// Channel — Videos.
//
// O Header Region usa $space-14, que é 58px — NÃO os 56px de p-14 (§4).
// O grid do design tem 3 linhas de 3 cards; em código isso vira auto-fill sobre
// a largura medida do card (357px), com gap-x-4 dentro da linha e gap-y-8 entre
// linhas, que são os dois gaps do .pen (§8).
export default async function ChannelVideosPage({ params }: PageProps<"/[handle]/videos">) {
const { handle } = await params;

return (
<>
<div className="w-full px-[58px]">
<ChannelHeader handle={handle} activeTab="Videos" />
</div>

<hr className="w-full border-0 border-t border-border-divider" />

<div className="flex w-full flex-col px-12 pb-12">
<div className="flex w-full gap-2 pt-4">
<Chip label="Latest" active />
<Chip label="Popular" />
<Chip label="Oldest" />
</div>

<div className="grid w-full grid-cols-[repeat(auto-fill,minmax(357px,1fr))] gap-x-4 gap-y-8 pt-6">
{VIDEOS.map((video) => (
<CardVideo key={video.id} video={video} />
))}
</div>
</div>
</>
);
}
71 changes: 4 additions & 67 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,69 +1,6 @@
import Image from "next/image";
import { redirect } from "next/navigation";

export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert h-5 w-[100px]"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the{" "}
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
page.tsx
</code>{" "}
file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert h-[14px] w-4"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={14}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
// Só existe a tela do canal por enquanto. O feed de home não foi desenhado.
export default function RootPage() {
redirect("/@FullCycle/videos");
}
13 changes: 13 additions & 0 deletions components/avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Image from "next/image";

// Avatar — 160x160 circular, recortado.
export function Avatar({ src, alt }: { src: string; alt: string }) {
return (
<div
data-component="Avatar"
className="relative size-40 shrink-0 overflow-hidden rounded-full"
>
<Image src={src} alt={alt} fill sizes="160px" className="object-cover" priority />
</div>
);
}
11 changes: 11 additions & 0 deletions components/badge/overlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Badge / Overlay — duração sobre a thumbnail. $radius-xs é 4px = rounded-sm.
export function BadgeOverlay({ children }: { children: React.ReactNode }) {
return (
<span
data-component="Badge / Overlay"
className="flex items-center justify-center rounded-sm bg-surface-overlay px-1 py-px text-caption font-medium text-text-on-overlay"
>
{children}
</span>
);
}
16 changes: 16 additions & 0 deletions components/button/icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { LucideIcon } from "lucide-react";

// Button / Icon — 40x40, circular. §9: hover e foco são acréscimo de
// engenharia; o design não os define.
export function ButtonIcon({ icon: Icon, label }: { icon: LucideIcon; label: string }) {
return (
<button
type="button"
aria-label={label}
data-component="Button / Icon"
className="flex size-10 items-center justify-center rounded-full text-text-primary hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-focus focus-visible:outline-none"
>
<Icon className="size-6" aria-hidden />
</button>
);
}
22 changes: 22 additions & 0 deletions components/button/outline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { LucideIcon } from "lucide-react";

// Button / Outline — pill com borda (Sign in). O padding horizontal é 15px no
// design: fora da grade de 4, então valor arbitrário (§2.5).
export function ButtonOutline({
icon: Icon,
children,
}: {
icon: LucideIcon;
children: React.ReactNode;
}) {
return (
<button
type="button"
data-component="Button / Outline"
className="flex h-10 items-center gap-2 rounded-full border border-border-control px-[15px] text-body font-medium text-text-link hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-focus focus-visible:outline-none"
>
<Icon className="size-6" aria-hidden />
{children}
</button>
);
}
12 changes: 12 additions & 0 deletions components/button/primary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Button / Primary — pill preenchido com a superfície inversa (Subscribe).
export function ButtonPrimary({ children }: { children: React.ReactNode }) {
return (
<button
type="button"
data-component="Button / Primary"
className="flex h-10 items-center justify-center rounded-full bg-surface-inverse px-4 text-body font-medium text-text-inverse focus-visible:ring-2 focus-visible:ring-focus focus-visible:outline-none"
>
{children}
</button>
);
}
35 changes: 35 additions & 0 deletions components/card/video.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import Image from "next/image";
import { BadgeOverlay } from "@/components/badge/overlay";
import type { Video } from "@/lib/fixtures";

// Card / Video — variante do grid.
//
// A altura de 201px da thumbnail no .pen é 16:9 da largura do card congelada
// (357.33 x 9/16). O formato não tem primitiva de proporção; o código tem, e o
// layout é fluido — então aspect-video, nunca h-[201px] (§8).
//
// Os insets de 26px e 24px no bloco de texto fazem o título quebrar antes da
// metadata. São valores do design fora da grade de 4 (§2.5).
export function CardVideo({ video }: { video: Video }) {
return (
<article data-component="Card / Video" className="flex w-full flex-col gap-3">
<div className="relative aspect-video w-full overflow-hidden rounded-xl">
<Image
src={`/thumbs/${video.id}.jpg`}
alt=""
fill
sizes="(max-width: 1100px) 50vw, 33vw"
className="object-cover"
/>
<div className="absolute right-0 bottom-0 p-2">
<BadgeOverlay>{video.duration}</BadgeOverlay>
</div>
</div>

<div className="flex flex-col gap-0.5 pr-[26px]">
<h3 className="pr-6 text-title font-medium text-text-primary">{video.title}</h3>
<p className="text-body text-text-secondary">{video.meta}</p>
</div>
</article>
);
}
74 changes: 74 additions & 0 deletions components/channel-header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import Image from "next/image";
import { Link2, Search } from "lucide-react";
import { Avatar } from "@/components/avatar";
import { ButtonPrimary } from "@/components/button/primary";
import { Tab } from "@/components/tab";
import { CHANNEL } from "@/lib/fixtures";

const TABS = ["Home", "Videos", "Shorts", "Live", "Playlists"] as const;
export type ChannelTab = (typeof TABS)[number];

function tabHref(handle: string, tab: ChannelTab) {
return tab === "Home" ? `/${handle}` : `/${handle}/${tab.toLowerCase()}`;
}

// Channel Header — banner, identidade e a barra de abas.
// O banner tem 172px no design (43 x 4), logo h-43 na escala nativa.
export function ChannelHeader({ handle, activeTab }: { handle: string; activeTab: ChannelTab }) {
return (
<div data-component="Channel Header" className="flex w-full flex-col">
<div className="relative h-43 w-full overflow-hidden rounded-2xl">
<Image src="/banner.jpg" alt="" fill sizes="100vw" className="object-cover" priority />
</div>

<div className="flex w-full items-center gap-4 pt-4 pb-1">
<Avatar src="/avatar.jpg" alt={CHANNEL.name} />

<div className="flex w-full flex-col gap-3">
<div className="flex flex-col gap-1.5">
<h1 className="text-display font-bold text-text-primary">{CHANNEL.name}</h1>
<div className="flex items-center gap-1 text-body">
<span className="font-medium text-text-primary">{CHANNEL.handle}</span>
<span className="text-text-secondary">•</span>
<span className="text-text-secondary">{CHANNEL.subscribers}</span>
<span className="text-text-secondary">•</span>
<span className="text-text-secondary">{CHANNEL.videoCount}</span>
</div>
</div>

<p className="flex items-end gap-1 text-body">
<span className="text-text-secondary">{CHANNEL.description}</span>
<span className="font-medium text-text-primary">...more</span>
</p>

<div className="flex items-center gap-1 text-body">
<span className="flex items-center gap-2">
<Link2 className="size-4 text-text-secondary" aria-hidden />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regra 10 (drift de valor) — glyph do ícone diverge do design (§11)

O .pen especifica link para o Link Icon do Channel Links:

{ "fill": "$text-secondary", "height": 16, "icon": "link",
  "library": "lucide", "name": "Link Icon", "width": 16 }

O código usa Link2, que é outro glyph da lucide (corrente inclinada em vez da corrente partida). §11 é normativo e a tradução é mecânica — o kebab do .pen vira PascalCase: "link"Link. Diferente do caso youtubeSquarePlay em components/sidebar.tsx:27-33, aqui não há motivo de biblioteca: Link existe em lucide-react.

O resto do nó bate (size-4 = 16 ✓, text-text-secondary = $text-secondary ✓).

Correção — importar o glyph certo (na linha 2, import { Link2, Search }import { Link as LinkIcon, Search }; não há colisão com next/link neste arquivo):

Suggested change
<Link2 className="size-4 text-text-secondary" aria-hidden />
<LinkIcon className="size-4 text-text-secondary" aria-hidden />

<a href={`https://${CHANNEL.link}`} className="text-text-link">
{CHANNEL.link}
</a>
</span>
<span className="font-medium text-text-primary">{CHANNEL.moreLinks}</span>
</div>

<div className="flex gap-3 pt-0.5">
<ButtonPrimary>Subscribe</ButtonPrimary>
</div>
</div>
</div>

<nav aria-label="Channel sections" className="flex h-12 w-full gap-6">
{TABS.map((tab) => (
<Tab key={tab} label={tab} href={tabHref(handle, tab)} active={tab === activeTab} />
))}
<button
type="button"
aria-label="Search this channel"
className="flex h-12 items-start px-2 pt-[13px] text-text-secondary focus-visible:ring-2 focus-visible:ring-focus focus-visible:outline-none"
>
<Search className="size-6" aria-hidden />
</button>
</nav>
</div>
);
}
19 changes: 19 additions & 0 deletions components/chip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Chip — $radius-sm é 8px, logo rounded-lg e NÃO rounded-sm (§6).
export function Chip({ label, active = false }: { label: string; active?: boolean }) {
return (
<button
type="button"
data-component="Chip"
aria-pressed={active}
className={[
"flex h-8 items-center justify-center rounded-lg px-3 text-body font-medium",
"focus-visible:ring-2 focus-visible:ring-focus focus-visible:outline-none",
active
? "bg-surface-inverse text-text-inverse"
: "bg-surface-chip text-text-primary hover:bg-surface-hover",
Comment on lines +11 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regra 7 — variante de componente que não existe no .pen (§2.4, §9, §10)

O Chip do .pen tem um estado só:

{ "cornerRadius": "$radius-sm", "fill": "$surface-chip", "height": 32,
  "children": [{ "name": "Label", "fill": "$text-primary", ... }] }

E em screens.json os três Chip do Filter Chips são instâncias sem nenhum override — ou seja, na tela Channel — Videos desenhada as três pílulas são cinzas e iguais. O ramo active inverte superfície e texto, então <Chip label="Latest" active /> (app/[handle]/videos/page.tsx:25) renderiza uma pílula preta onde o design mostra uma cinza.

Os tokens usados existem, mas §2.4 fala de componente, não de token: "Nenhum componente que não exista no .pen. Precisou de um? Pare e peça design." E §9 autoriza sem design apenas hover:, focus-visible:, active:, disabled: e motion-reduce: — "filtro selecionado" é estado de produto, não de interação. Compare com o Tab, onde o estado selecionado é desenhado (o Indicator de 2px vem no componente com enabled: false), e por isso tab.tsx está correto.

O ramo inativo já bate com o design (bg-surface-chip / text-text-primary).

Correção: ou o .pen ganha a variante selecionada do Chip (§13: o design muda primeiro), ou o active fica só semântico — o aria-pressed da linha 7 já carrega o estado — até o design existir:

Suggested change
active
? "bg-surface-inverse text-text-inverse"
: "bg-surface-chip text-text-primary hover:bg-surface-hover",
active
? "bg-surface-chip text-text-primary"
: "bg-surface-chip text-text-primary hover:bg-surface-hover",

].join(" ")}
>
{label}
</button>
);
}
19 changes: 19 additions & 0 deletions components/logo/youtube.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Wordmark do YouTube, transcrito de design/pendev/logo-youtube.json.
// Não é ícone lucide: são paths próprios, com os três grupos tokenizados.
// O triângulo é branco fixo nos dois temas — assim está no design.

export function YouTubeWordmark() {
return (
<svg viewBox="0 0 93 20" className="h-5 w-[93px]" aria-label="YouTube" role="img">
<path className="fill-brand-primary" d="M14.4848 20c0 0 9.0847 0 11.3381-0.6 1.2688-0.34 2.223-1.32 2.5579-2.53 0.6192-2.22 0.6192-6.89 0.6192-6.89 0 0 0-4.64-0.6192-6.84-0.3349-1.24-1.2891-2.2-2.5579-2.53-2.2534-0.61-11.3381-0.61-11.3381-0.61 0 0-9.06443 0-11.30769 0.61-1.24851 0.33-2.22296 1.29-2.57823 2.53-0.59888 2.2-0.59888 6.84-0.59888 6.84 0 0 0 4.67 0.59888 6.89 0.35527 1.21 1.32972 2.19 2.57823 2.53 2.24326 0.6 11.30769 0.6 11.30769 0.6z" />
<path className="fill-white" d="M19 10l-7.5-4.25v8.5l7.5-4.25z" />
<path className="fill-text-primary" d="M37.1384 18.8999v-5.46l3.47-11.33996h-2.59l-1.32 5.15c-0.3 1.18-0.57 2.41-0.77 3.54996h-0.16c-0.11-0.99996-0.43-2.30996-0.75-3.56996l-1.28-5.13h-2.59l3.42 11.33996v5.46h2.57z" />
<path className="fill-text-primary" d="M44.1003 6.29994c-3.03 0-4.07 1.75-4.07 5.51996v1.79c0 3.38 0.65 5.5 4.01 5.5 3.31 0 4.02-2.02 4.02-5.5v-1.79c0-3.36996-0.68-5.51996-3.96-5.51996z m1.29 8.41996c0 1.64-0.29 2.67-1.34 2.67-1.03 0-1.32-1.04-1.32-2.67v-4.04c0-1.39996 0.2-2.64996 1.32-2.64996 1.18 0 1.34 1.32 1.34 2.64996v4.04z" />
<path className="fill-text-primary" d="M52.2713 19.0899c1.46 0 2.37-0.61 3.12-1.71h0.11l0.11 1.52h1.9899v-12.35996h-2.6399v9.92996c-0.2801 0.49-0.9301 0.85-1.5401 0.85-0.77 0-1.0099-0.61-1.0099-1.63v-9.14996h-2.6301v9.26996c0 2.01 0.5801 3.28 2.4901 3.28z" />
<path className="fill-text-primary" d="M62.8261 18.8999v-14.74996h3.04v-2.05h-8.69v2.05h3.04v14.74996h2.61z" />
<path className="fill-text-primary" d="M67.8728 19.0899c1.46 0 2.37-0.61 3.12-1.71h0.11l0.11 1.52h1.99v-12.35996h-2.64v9.92996c-0.28 0.49-0.93 0.85-1.54 0.85-0.77 0-1.01-0.61-1.01-1.63v-9.14996h-2.63v9.26996c0 2.01 0.58 3.28 2.49 3.28z" />
<path className="fill-text-primary" d="M80.6744 6.26994c-1.28 0-2.2 0.56-2.81 1.47h-0.13c0.08-1.2 0.14-2.22 0.14-3.03v-3.27h-2.55l-0.01 10.73996 0.01 6.72h2.22l0.19-1.2h0.07c0.59 0.81 1.5 1.32 2.71 1.32 2.01 0 2.87-1.73 2.87-5.41v-1.91c0-3.43996-0.39-5.42996-2.71-5.42996z m0.09 7.33996c0 2.3-0.34 3.67-1.41 3.67-0.5 0-1.19-0.24-1.5-0.69v-7.34996c0.27-0.7 0.87-1.21 1.54-1.21 1.08 0 1.37 1.31 1.37 3.69996v1.88z" />
<path className="fill-text-primary" d="M92.6517 11.4999c0-2.97996-0.3-5.18996-3.73-5.18996-3.23 0-3.95 2.15-3.95 5.30996v2.17c0 3.08 0.66 5.32 3.87 5.32 2.54 0 3.85-1.27 3.7-3.73l-2.25-0.12c-0.03 1.52-0.38 2.14-1.39 2.14-1.27 0-1.33-1.21-1.33-3.01v-0.84h5.08v-2.05z m-3.79-3.52996c1.22 0 1.31 1.15 1.31 3.09996v1.01h-2.6v-1.01c0-1.92996 0.08-3.09996 1.29-3.09996z" />
</svg>
);
}
34 changes: 34 additions & 0 deletions components/masthead.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Link from "next/link";
import { CircleUserRound, EllipsisVertical, Menu } from "lucide-react";
import { ButtonIcon } from "@/components/button/icon";
import { ButtonOutline } from "@/components/button/outline";
import { SearchBar } from "@/components/search-bar";
import { YouTubeWordmark } from "@/components/logo/youtube";

// Masthead — 56px de altura, três regiões distribuídas.
// O "BR" é 10px literal: o design não o tokeniza, e não existe papel para ele.
export function Masthead() {
return (
<header
data-component="Masthead"
className="flex h-14 w-full items-center justify-between bg-surface-page px-4"
>
<div className="flex items-center gap-4">
<ButtonIcon icon={Menu} label="Guide" />
<Link href="/" className="flex items-start gap-[3px]">
<YouTubeWordmark />
<span className="text-[10px]/[1.15] text-text-secondary">BR</span>
</Link>
</div>

<div className="flex w-[656px] items-center">
<SearchBar />
</div>

<div className="flex items-center gap-2">
<ButtonIcon icon={EllipsisVertical} label="Settings" />
<ButtonOutline icon={CircleUserRound}>Sign in</ButtonOutline>
</div>
</header>
);
}
Loading
Loading