feat(layout): add animated promotional side rails

This commit is contained in:
poyrazavsever
2026-08-31 19:39:22 +03:00
parent 1fd03bf614
commit eca56fab5c
7 changed files with 737 additions and 31 deletions
+37 -26
View File
@@ -1,7 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { Icon } from "@iconify/react";
import { usePathname } from "@/i18n/routing";
import { AnnouncementBar } from "poyraz-ui/organisms";
import { SiteNavbar } from "@/components/site-navbar";
import { NekoFollower } from "@/components/neko-follower";
@@ -10,6 +9,11 @@ import { useLocale } from "next-intl";
import { getLocalizedValue } from "@/lib/locale";
import dynamic from "next/dynamic";
import type { AnimationSourceSearchItem } from "@/lib/command-palette-links";
import {
LayoutLeftPromoRail,
LayoutRightPromoRail,
type LayoutContentPromo,
} from "@/components/layout-promo-rails";
const AtaturkWidgetModal = dynamic(
() => import("@/components/ataturk-widget-modal").then((mod) => mod.AtaturkWidgetModal),
@@ -19,6 +23,8 @@ const AtaturkWidgetModal = dynamic(
type AppShellProps = {
children: React.ReactNode;
animationSources: AnimationSourceSearchItem[];
latestAgenda: LayoutContentPromo | null;
latestPost: LayoutContentPromo | null;
};
export type ThemeMode = "light" | "dark";
@@ -32,15 +38,15 @@ function getInitialTheme(): ThemeMode {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function AppShell({ children, animationSources }: AppShellProps) {
const pathname = usePathname();
export function AppShell({
children,
animationSources,
latestAgenda,
latestPost,
}: AppShellProps) {
const locale = useLocale();
const announcement = ANNOUNCEMENT_ITEMS[0];
const [theme, setTheme] = useState<ThemeMode>(getInitialTheme);
const isStandaloneLinksPage =
pathname === "/links" || pathname.startsWith("/links/");
const isStandaloneMediaKitPage =
pathname === "/media-kit" || pathname.startsWith("/media-kit/");
useEffect(() => {
document.documentElement.dataset.poyrazTheme = theme;
@@ -48,32 +54,37 @@ export function AppShell({ children, animationSources }: AppShellProps) {
localStorage.setItem("poyraz-theme", theme);
}, [theme]);
if (isStandaloneLinksPage || isStandaloneMediaKitPage) {
return children;
}
const localizedText = announcement ? getLocalizedValue(announcement.text, locale) : "";
return (
<>
<AtaturkWidgetModal theme={theme} />
{ENABLE_NEKO_FOLLOWER ? <NekoFollower /> : null}
<div className="mx-auto flex w-full max-w-4xl flex-col px-4 py-4 ">
<SiteNavbar
theme={theme}
onThemeChange={setTheme}
animationSources={animationSources}
<div className="mx-auto grid w-full max-w-[1800px] grid-cols-1 gap-4 px-4 min-[1420px]:grid-cols-[220px_minmax(0,896px)_220px] min-[1420px]:justify-between">
<LayoutLeftPromoRail
latestAgenda={latestAgenda}
latestPost={latestPost}
/>
{announcement ? (
<AnnouncementBar
variant="branded"
dismissible={false}
icon={<Icon icon="mdi:sparkles" width={16} height={16} />}
>
{localizedText}
</AnnouncementBar>
) : null}
<main className="flex-1 py-4">{children}</main>
<div className="min-w-0 w-full max-w-4xl justify-self-center min-[1420px]:max-w-none">
<div className="pt-4">
<SiteNavbar
theme={theme}
onThemeChange={setTheme}
animationSources={animationSources}
/>
{announcement ? (
<AnnouncementBar
variant="branded"
dismissible={false}
icon={<Icon icon="mdi:sparkles" width={16} height={16} />}
>
{localizedText}
</AnnouncementBar>
) : null}
</div>
<main className="min-w-0 py-4">{children}</main>
</div>
<LayoutRightPromoRail />
</div>
</>
);
+342
View File
@@ -0,0 +1,342 @@
"use client";
import { useState, type KeyboardEvent } from "react";
import Image from "next/image";
import { Icon } from "@iconify/react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useLocale, useTranslations } from "next-intl";
import {
Badge,
Button,
ButtonIcon,
ButtonLabel,
Card,
Typography,
} from "poyraz-ui/atoms";
import { Link } from "@/i18n/routing";
import {
LEFT_LAYOUT_PROMO_SLIDES,
RIGHT_LAYOUT_PROMO_SLIDES,
type LayoutPromoCardDefinition,
type LayoutPromoSlide,
} from "@/data/layout-promos";
import { SPONSORS } from "@/data/sponsors";
import { getLocalizedValue } from "@/lib/locale";
export type LayoutContentPromo = {
title: string;
href: string;
};
type PromoRailSide = "left" | "right";
function RailButton({
href,
label,
icon = "mdi:arrow-right",
variant = "outline",
external = false,
}: {
href: string;
label: string;
icon?: string;
variant?: "default" | "outline" | "secondary";
external?: boolean;
}) {
const content = (
<>
<ButtonLabel>{label}</ButtonLabel>
<ButtonIcon>
<Icon icon={icon} width={14} height={14} />
</ButtonIcon>
</>
);
return (
<Button
asChild
variant={variant}
size="xs"
radius="sm"
effect="swap"
swapTarget="both"
className="w-full justify-between"
>
{external ? (
<a href={href} target="_blank" rel="noreferrer">
{content}
</a>
) : (
<Link href={href}>{content}</Link>
)}
</Button>
);
}
function SponsorLogos() {
return (
<div className="mt-3 grid grid-cols-2 gap-1.5">
{SPONSORS.map((sponsor, index) => (
<a
key={sponsor.id}
href={sponsor.websiteUrl}
target="_blank"
rel="noreferrer"
aria-label={sponsor.name}
className={`relative flex h-10 items-center justify-center rounded-sm border border-border bg-white p-1.5 transition-colors hover:border-primary/40 ${
index === SPONSORS.length - 1 && SPONSORS.length % 2 === 1
? "col-span-2"
: ""
}`}
>
<Image
src={sponsor.logo}
alt=""
fill
sizes="100px"
className="object-contain p-1.5 grayscale transition duration-300 hover:grayscale-0"
/>
</a>
))}
</div>
);
}
function PromoIcon({
card,
className,
}: {
card: LayoutPromoCardDefinition;
className?: string;
}) {
return (
<Icon
icon={card.icon}
width={17}
height={17}
className={className}
/>
);
}
function PromoCard({
card,
latestAgenda,
latestPost,
}: {
card: LayoutPromoCardDefinition;
latestAgenda: LayoutContentPromo | null;
latestPost: LayoutContentPromo | null;
}) {
const t = useTranslations("LayoutPromos");
const locale = useLocale();
const liveContent =
card.contentSource === "latestAgenda"
? latestAgenda
: card.contentSource === "latestPost"
? latestPost
: null;
const href = liveContent?.href ?? getLocalizedValue(card.href, locale);
const description = liveContent?.title ?? t(card.descriptionKey);
const external = card.external ?? false;
const iconSurface = card.iconSurface ?? "accent";
const cardClassName =
card.surface === "primary"
? "rounded-sm border-primary/25 bg-primary/5 p-3"
: "rounded-sm border-border p-3";
const iconClassName =
iconSurface === "primary"
? "bg-primary text-primary-foreground"
: iconSurface === "foreground"
? "bg-foreground text-background"
: "bg-accent text-foreground";
return (
<Card className={cardClassName}>
{card.eyebrowKey ? (
<div className="flex items-center justify-between gap-2">
<Badge variant="secondary" radius="sm">
{t(card.eyebrowKey)}
</Badge>
<PromoIcon card={card} className="text-primary" />
</div>
) : (
<div
className={`flex size-8 items-center justify-center rounded-sm ${iconClassName}`}
>
<PromoIcon card={card} />
</div>
)}
<Typography
variant="large"
component="h2"
className="mt-3 text-base leading-5 tracking-[-0.025em]"
>
{t(card.titleKey)}
</Typography>
<Typography
variant="small"
className={`mt-2 text-xs leading-5 text-muted-foreground ${
liveContent ? "line-clamp-3" : ""
}`}
>
{description}
</Typography>
{card.kind === "sponsors" ? <SponsorLogos /> : null}
<div className="mt-3">
<RailButton
href={href}
label={t(card.ctaKey)}
icon={external ? "mdi:arrow-top-right" : "mdi:arrow-right"}
variant={card.buttonVariant}
external={external}
/>
</div>
</Card>
);
}
function PromoRail({
side,
slides,
latestAgenda = null,
latestPost = null,
}: {
side: PromoRailSide;
slides: readonly LayoutPromoSlide[];
latestAgenda?: LayoutContentPromo | null;
latestPost?: LayoutContentPromo | null;
}) {
const t = useTranslations("LayoutPromos");
const reduceMotion = useReducedMotion();
const [activeSlide, setActiveSlide] = useState(0);
const direction = side === "left" ? -1 : 1;
const activeCards = slides[activeSlide] ?? slides[0];
const railId = `${side}-promo-rail`;
const selectAdjacentSlide = (step: number) => {
setActiveSlide((current) => (current + step + slides.length) % slides.length);
};
const handleNavigationKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
selectAdjacentSlide(event.key === "ArrowRight" ? 1 : -1);
};
return (
<aside
aria-label={t(side === "left" ? "leftRailLabel" : "rightRailLabel")}
className="relative z-50 hidden min-[1420px]:block"
>
<div className="sticky top-4 py-4">
<div
role="tablist"
aria-label={t("slideNavigationLabel")}
onKeyDown={handleNavigationKeyDown}
className="mb-3 flex h-5 items-center justify-center gap-2"
>
{slides.map((_, index) => {
const selected = index === activeSlide;
return (
<button
key={`${railId}-dot-${index}`}
type="button"
role="tab"
aria-selected={selected}
aria-controls={`${railId}-panel-${index}`}
aria-label={t("slideCta", { slide: index + 1 })}
onClick={() => setActiveSlide(index)}
className="group flex size-5 items-center justify-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<span
className={`block rounded-full transition-[width,background-color,transform] duration-300 group-hover:scale-110 ${
selected
? "h-2 w-5 bg-primary"
: "size-2 bg-border group-hover:bg-muted-foreground/60"
}`}
/>
</button>
);
})}
</div>
<div className="overflow-hidden">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`${railId}-slide-${activeSlide}`}
id={`${railId}-panel-${activeSlide}`}
role="tabpanel"
aria-label={t("slideStatus", {
current: activeSlide + 1,
total: slides.length,
})}
initial={
reduceMotion
? { opacity: 0 }
: { opacity: 0, x: -direction * 26 }
}
animate={{ opacity: 1, x: 0 }}
exit={
reduceMotion
? { opacity: 0 }
: { opacity: 0, x: direction * 26 }
}
transition={{
duration: reduceMotion ? 0.12 : 0.38,
ease: [0.22, 1, 0.36, 1],
}}
className="space-y-3"
>
{activeCards.map((card, index) => (
<motion.div
key={card.id}
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reduceMotion ? 0 : 0.28,
delay: reduceMotion ? 0 : index * 0.045,
ease: "easeOut",
}}
whileHover={reduceMotion ? undefined : { y: -3 }}
>
<PromoCard
card={card}
latestAgenda={latestAgenda}
latestPost={latestPost}
/>
</motion.div>
))}
</motion.div>
</AnimatePresence>
</div>
</div>
</aside>
);
}
export function LayoutLeftPromoRail({
latestAgenda,
latestPost,
}: {
latestAgenda: LayoutContentPromo | null;
latestPost: LayoutContentPromo | null;
}) {
return (
<PromoRail
side="left"
slides={LEFT_LAYOUT_PROMO_SLIDES}
latestAgenda={latestAgenda}
latestPost={latestPost}
/>
);
}
export function LayoutRightPromoRail() {
return <PromoRail side="right" slides={RIGHT_LAYOUT_PROMO_SLIDES} />;
}