diff --git a/app/[handle]/layout.tsx b/app/[handle]/layout.tsx new file mode 100644 index 0000000..b71ff47 --- /dev/null +++ b/app/[handle]/layout.tsx @@ -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 ( +
+ +
+ +
{children}
+
+
+ ); +} diff --git a/app/[handle]/videos/page.tsx b/app/[handle]/videos/page.tsx new file mode 100644 index 0000000..700a991 --- /dev/null +++ b/app/[handle]/videos/page.tsx @@ -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 ( + <> +
+ +
+ +
+ +
+
+ + + +
+ +
+ {VIDEOS.map((video) => ( + + ))} +
+
+ + ); +} diff --git a/app/page.tsx b/app/page.tsx index c887311..12d17af 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,69 +1,6 @@ -import Image from "next/image"; +import { redirect } from "next/navigation"; -export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the{" "} - - page.tsx - {" "} - file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - -
-
-
- ); +// Só existe a tela do canal por enquanto. O feed de home não foi desenhado. +export default function RootPage() { + redirect("/@FullCycle/videos"); } diff --git a/components/avatar.tsx b/components/avatar.tsx new file mode 100644 index 0000000..b9457c6 --- /dev/null +++ b/components/avatar.tsx @@ -0,0 +1,13 @@ +import Image from "next/image"; + +// Avatar — 160x160 circular, recortado. +export function Avatar({ src, alt }: { src: string; alt: string }) { + return ( +
+ {alt} +
+ ); +} diff --git a/components/badge/overlay.tsx b/components/badge/overlay.tsx new file mode 100644 index 0000000..fc48da1 --- /dev/null +++ b/components/badge/overlay.tsx @@ -0,0 +1,11 @@ +// Badge / Overlay — duração sobre a thumbnail. $radius-xs é 4px = rounded-sm. +export function BadgeOverlay({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/components/button/icon.tsx b/components/button/icon.tsx new file mode 100644 index 0000000..397fcc5 --- /dev/null +++ b/components/button/icon.tsx @@ -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 ( + + ); +} diff --git a/components/button/outline.tsx b/components/button/outline.tsx new file mode 100644 index 0000000..315dd91 --- /dev/null +++ b/components/button/outline.tsx @@ -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 ( + + ); +} diff --git a/components/button/primary.tsx b/components/button/primary.tsx new file mode 100644 index 0000000..a47c4da --- /dev/null +++ b/components/button/primary.tsx @@ -0,0 +1,12 @@ +// Button / Primary — pill preenchido com a superfície inversa (Subscribe). +export function ButtonPrimary({ children }: { children: React.ReactNode }) { + return ( + + ); +} diff --git a/components/card/video.tsx b/components/card/video.tsx new file mode 100644 index 0000000..d57c00a --- /dev/null +++ b/components/card/video.tsx @@ -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 ( +
+
+ +
+ {video.duration} +
+
+ +
+

{video.title}

+

{video.meta}

+
+
+ ); +} diff --git a/components/channel-header.tsx b/components/channel-header.tsx new file mode 100644 index 0000000..d11cece --- /dev/null +++ b/components/channel-header.tsx @@ -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 ( +
+
+ +
+ +
+ + +
+
+

{CHANNEL.name}

+
+ {CHANNEL.handle} + + {CHANNEL.subscribers} + + {CHANNEL.videoCount} +
+
+ +

+ {CHANNEL.description} + ...more +

+ +
+ + + + {CHANNEL.link} + + + {CHANNEL.moreLinks} +
+ +
+ Subscribe +
+
+
+ + +
+ ); +} diff --git a/components/chip.tsx b/components/chip.tsx new file mode 100644 index 0000000..1868058 --- /dev/null +++ b/components/chip.tsx @@ -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 ( + + ); +} diff --git a/components/logo/youtube.tsx b/components/logo/youtube.tsx new file mode 100644 index 0000000..36fcee1 --- /dev/null +++ b/components/logo/youtube.tsx @@ -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 ( + + + + + + + + + + + + ); +} diff --git a/components/masthead.tsx b/components/masthead.tsx new file mode 100644 index 0000000..a1c41ba --- /dev/null +++ b/components/masthead.tsx @@ -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 ( +
+
+ + + + BR + +
+ +
+ +
+ +
+ + Sign in +
+
+ ); +} diff --git a/components/search-bar.tsx b/components/search-bar.tsx new file mode 100644 index 0000000..b66874c --- /dev/null +++ b/components/search-bar.tsx @@ -0,0 +1,34 @@ +import { Mic, Search } from "lucide-react"; + +// Search Bar — campo (fill) + submit de 64 + gap 16 + voz de 40, total 656. +// O radius por canto do design vira rounded-l-full / rounded-r-full (§6). +export function SearchBar() { + return ( +
+
+
+ +
+ +
+ +
+ ); +} diff --git a/components/sidebar-item.tsx b/components/sidebar-item.tsx new file mode 100644 index 0000000..290757a --- /dev/null +++ b/components/sidebar-item.tsx @@ -0,0 +1,31 @@ +import Link from "next/link"; +import type { LucideIcon } from "lucide-react"; + +// Sidebar Item — 40px, rounded-nav (10px, o único radius fora da escala nativa). +export function SidebarItem({ + icon: Icon, + label, + href, + active = false, +}: { + icon: LucideIcon; + label: string; + href: string; + active?: boolean; +}) { + return ( + + + {label} + + ); +} diff --git a/components/sidebar.tsx b/components/sidebar.tsx new file mode 100644 index 0000000..28c6726 --- /dev/null +++ b/components/sidebar.tsx @@ -0,0 +1,84 @@ +import { + ChevronDown, CircleUserRound, Clapperboard, Film, Flag, History, + House, Music, Radio, SquarePlay, Tv, type LucideIcon, +} from "lucide-react"; +import { ButtonOutline } from "@/components/button/outline"; +import { SidebarItem } from "@/components/sidebar-item"; + +// O design passa o glyph como dado. O mapa explícito mantém só os ícones usados +// no bundle e torna um nome inválido erro de tipo (§11). +type NavItem = { icon: LucideIcon; label: string; href: string }; + +const PRIMARY: NavItem[] = [ + { icon: House, label: "Home", href: "/" }, + { icon: Clapperboard, label: "Shorts", href: "/shorts" }, + { icon: Tv, label: "Subscriptions", href: "/feed/subscriptions" }, + { icon: CircleUserRound, label: "You", href: "/feed/you" }, + { icon: History, label: "History", href: "/feed/history" }, +]; + +const EXPLORE: NavItem[] = [ + { icon: Music, label: "Music", href: "/music" }, + { icon: Film, label: "Movies", href: "/movies" }, + { icon: Radio, label: "Live", href: "/live" }, + { icon: ChevronDown, label: "Show more", href: "/explore" }, +]; + +// DIVERGE DO DESIGN: o .pen pede o glyph `youtube` da lucide para Premium e +// Kids, mas ícones de marca saíram da biblioteca (não existe em lucide-react +// 1.34). SquarePlay é a forma mais próxima. Precisa de decisão de design. +const MORE: NavItem[] = [ + { icon: SquarePlay, label: "YouTube Premium", href: "/premium" }, + { icon: Music, label: "YouTube Music", href: "/music" }, + { icon: SquarePlay, label: "YouTube Kids", href: "/kids" }, +]; + +const REPORT: NavItem[] = [{ icon: Flag, label: "Report history", href: "/report" }]; + +function Group({ title, items }: { title?: string; items: NavItem[] }) { + return ( +
+ {title ? ( +
+

{title}

+
+ ) : null} + {items.map((item) => ( + + ))} +
+ ); +} + +function Divider() { + return
; +} + +export function Sidebar() { + return ( + + ); +} diff --git a/components/tab.tsx b/components/tab.tsx new file mode 100644 index 0000000..038aee5 --- /dev/null +++ b/components/tab.tsx @@ -0,0 +1,22 @@ +import Link from "next/link"; + +// Tab — 48px de altura, indicador de 2px rente à base. O padding [13,0,1,0] do +// design está fora da grade de 4 (§2.5). +export function Tab({ label, href, active = false }: { label: string; href: string; active?: boolean }) { + return ( + + + {label} + + + + ); +} diff --git a/lib/fixtures.ts b/lib/fixtures.ts new file mode 100644 index 0000000..c5b90a3 --- /dev/null +++ b/lib/fixtures.ts @@ -0,0 +1,77 @@ +// Fixtures extraídas de design/pendev/youtube-channel.pen (tela Channel — Videos). +// Conteúdo é DADO, não design: o componente recebe tudo por prop. + +export type Video = { + id: string; + title: string; + meta: string; + duration: string; +}; + +export const VIDEOS: Video[] = [ + { + "id": "mG7ZC63xS-k", + "title": "Coding with AI this way is a waste of time", + "meta": "971 views • 3 days ago", + "duration": "23:42" + }, + { + "id": "w2z4Fai2s9c", + "title": "Stop Creating AI Skills Without Doing This", + "meta": "1.8K views • 5 days ago", + "duration": "12:29" + }, + { + "id": "RO5y-fCIBy8", + "title": "The Developer as You Know It Is Coming to an End!", + "meta": "3.8K views • 7 days ago", + "duration": "17:46" + }, + { + "id": "V3Mtur9JuKY", + "title": "Google ADK in Practice: Build Your First AI Agent", + "meta": "1K views • 10 days ago", + "duration": "30:05" + }, + { + "id": "HGvUc9nDJC0", + "title": "Agent Skills: Everything You Need to Know to Build Skills", + "meta": "705 views • 12 days ago", + "duration": "17:32" + }, + { + "id": "FCwIlzanP4Q", + "title": "Harness Engineering: Agents, Sub-Agents, and Context Window", + "meta": "1.3K views • 2 weeks ago", + "duration": "16:06" + }, + { + "id": "9_atL4yQEa0", + "title": "Developers: Understand What AI Agents Really Are", + "meta": "843 views • 2 weeks ago", + "duration": "14:21" + }, + { + "id": "ZfpYVS7oG6A", + "title": "What Is the Developer's Role When AI Already Writes the Code?", + "meta": "5.5K views • 2 weeks ago", + "duration": "17:07" + }, + { + "id": "aYEa5svlzC0", + "title": "RAG is Not an AI Agent: The Difference Most People Ignore", + "meta": "4.9K views • 3 weeks ago", + "duration": "15:35" + } +]; + +export const CHANNEL = { + name: "Full Cycle", + handle: "@FullCycle", + subscribers: "196K subscribers", + videoCount: "1K videos", + description: + "A Full Cycle ajuda desenvolvedores a desenvolverem aplicações de grande porte!", + link: "fullcycle.com.br", + moreLinks: "and 1 more link", +} as const; diff --git a/public/avatar.jpg b/public/avatar.jpg new file mode 100644 index 0000000..56f2b1a Binary files /dev/null and b/public/avatar.jpg differ diff --git a/public/banner.jpg b/public/banner.jpg new file mode 100644 index 0000000..a8cd9bf Binary files /dev/null and b/public/banner.jpg differ diff --git a/public/thumbs/9_atL4yQEa0.jpg b/public/thumbs/9_atL4yQEa0.jpg new file mode 100644 index 0000000..53cac94 Binary files /dev/null and b/public/thumbs/9_atL4yQEa0.jpg differ diff --git a/public/thumbs/FCwIlzanP4Q.jpg b/public/thumbs/FCwIlzanP4Q.jpg new file mode 100644 index 0000000..e9eca8e Binary files /dev/null and b/public/thumbs/FCwIlzanP4Q.jpg differ diff --git a/public/thumbs/HGvUc9nDJC0.jpg b/public/thumbs/HGvUc9nDJC0.jpg new file mode 100644 index 0000000..29628c0 Binary files /dev/null and b/public/thumbs/HGvUc9nDJC0.jpg differ diff --git a/public/thumbs/RO5y-fCIBy8.jpg b/public/thumbs/RO5y-fCIBy8.jpg new file mode 100644 index 0000000..f40fa03 Binary files /dev/null and b/public/thumbs/RO5y-fCIBy8.jpg differ diff --git a/public/thumbs/V3Mtur9JuKY.jpg b/public/thumbs/V3Mtur9JuKY.jpg new file mode 100644 index 0000000..65f2d5b Binary files /dev/null and b/public/thumbs/V3Mtur9JuKY.jpg differ diff --git a/public/thumbs/ZfpYVS7oG6A.jpg b/public/thumbs/ZfpYVS7oG6A.jpg new file mode 100644 index 0000000..982a793 Binary files /dev/null and b/public/thumbs/ZfpYVS7oG6A.jpg differ diff --git a/public/thumbs/aYEa5svlzC0.jpg b/public/thumbs/aYEa5svlzC0.jpg new file mode 100644 index 0000000..040d25b Binary files /dev/null and b/public/thumbs/aYEa5svlzC0.jpg differ diff --git a/public/thumbs/mG7ZC63xS-k.jpg b/public/thumbs/mG7ZC63xS-k.jpg new file mode 100644 index 0000000..8ff89e0 Binary files /dev/null and b/public/thumbs/mG7ZC63xS-k.jpg differ diff --git a/public/thumbs/w2z4Fai2s9c.jpg b/public/thumbs/w2z4Fai2s9c.jpg new file mode 100644 index 0000000..55466d6 Binary files /dev/null and b/public/thumbs/w2z4Fai2s9c.jpg differ