Files
EpicNext-Cms/src/app/(site)/page.tsx
T
openhands 30583b8e2e
CI / check (push) Successful in 29s
CI / tests-unit (push) Successful in 2m17s
CI / tests-integration (push) Successful in 2m29s
CI / tests-imaging-browsers (push) Successful in 2m32s
CI / tests-quality (push) Failing after 2m18s
CI / tests-ui (push) Successful in 5m14s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m41s
feat(seo): add JSON-LD, OG images and RSS feed for news
2026-10-22 18:22:32 +02:00

1043 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { desc, eq } from "drizzle-orm";
import { ArrowRight, ChevronDown } from "lucide-react";
import type { Metadata } from "next";
import { headers } from "next/headers";
import Image from "next/image";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties, ReactNode } from "react";
import { AnimatedCounter } from "@/components/animated-counter";
import { AuthTopBar } from "@/components/auth/auth-top-bar";
import { LoginForm } from "@/components/auth/login-form";
import Link from "@/components/link";
import { Reveal } from "@/components/motion-reveal";
import { JsonLd } from "@/components/public/json-ld";
import { PublicLoadError } from "@/components/public/load-error";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { SpotlightCard } from "@/components/spotlight-card";
import { TypewriterText } from "@/components/typewriter-text";
import { auth } from "@/lib/auth";
import { cached } from "@/lib/cache";
import { CameraWeb, db, User } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { resolveHotelName } from "@/lib/hotel-name";
import { withPublicPagePerformance } from "@/lib/public-page-performance";
import {
absoluteAssetUrl,
appBaseUrl,
buildWebsiteStructuredData,
} from "@/lib/seo";
import { captchaConfig } from "@/lib/services/captcha";
import { getNewsList } from "@/lib/services/news-list";
import { loadProfilePrivacyMap } from "@/lib/services/profile-privacy";
import {
cachedOnlineCount,
countArticles,
countPhotos,
countRooms,
countUsers,
} from "@/lib/services/public-counters";
import { publicReadFailure } from "@/lib/services/public-read";
import { siteSettings } from "@/lib/services/site-settings";
import {
ICON_ARTICLE,
ICON_CAMERA,
ICON_CATALOG,
ICON_FRIENDS,
ICON_NAV_GOODY,
ICON_NAV_ME,
ICON_STAR,
} from "@/lib/site-icons";
export async function generateMetadata(): Promise<Metadata> {
const tn = await getTranslations("nav");
const tp = await getTranslations("pages.home");
const title = tn("home");
const description = tp("welcomeBody");
const appUrl = appBaseUrl();
return {
title,
description,
alternates: {
canonical: appUrl,
types: { "application/rss+xml": `${appUrl}/news/rss.xml` },
},
openGraph: { title, description, type: "website", url: appUrl },
};
}
/* ── Local visual language ────────────────────────────────────────────
The landing panels used to carry their own translucent washes
(surface 44–60 % + a backdrop blur each). Four different milky smears over
the sky gradient, and a backdrop snapshot per panel on every scroll. The
shared `.panel*` classes in globals.css now own the surface recipe, so the
boxes read as one system and cost nothing to paint. */
// The hero scrim. A photo always needs a dark wash for the copy to stay
// readable, so this one stays a fixed dark gradient in both themes.
const HERO_SCRIM =
"linear-gradient(180deg, rgba(8,11,24,0.80) 0%, rgba(8,11,24,0.58) 38%, rgba(8,11,24,0.42) 62%, rgba(8,11,24,0.60) 85%, color-mix(in srgb, var(--color-background) 92%, transparent) 100%)";
const HERO_COPY = "rgba(255,255,255,0.80)";
const HERO_COPY_STRONG = "rgba(255,255,255,0.96)";
const DARK_BAND =
"linear-gradient(120deg, color-mix(in srgb, var(--color-primary) 30%, #0a0e1e) 0%, color-mix(in srgb, var(--color-accent) 26%, #0a0e1e) 52%, color-mix(in srgb, var(--color-primary) 20%, #0a0e1e) 100%)";
/** Accent used for the nth surface of a repeating set. */
function tintFor(index: number): string {
return index % 2 === 0 ? "var(--color-primary)" : "var(--color-accent)";
}
interface PanelHeaderProps {
title: string;
icon?: string;
actionHref?: string;
actionLabel?: string;
}
/**
* The header row every landing panel shares: tinted icon chip, title and an
* optional "see everything" link.
*/
function PanelHeader({
title,
icon,
actionHref,
actionLabel,
}: PanelHeaderProps) {
return (
<div className="panel-header flex items-center justify-between gap-3 px-5 py-3.5">
<div className="flex min-w-0 items-center gap-2.5">
{icon && (
<div
className="panel-tile flex h-9 w-9 shrink-0 items-center justify-center rounded-xl"
style={{ "--panel-tint": "var(--color-primary)" } as CSSProperties}
>
<Image
src={icon}
alt=""
width={18}
height={18}
unoptimized
className="opacity-90"
/>
</div>
)}
<h2
className="truncate text-sm font-extrabold tracking-wide"
style={{
fontFamily: "var(--font-nunito)",
color: "var(--color-text-readable)",
}}
>
{title}
</h2>
</div>
{actionHref && actionLabel && (
<Link
href={actionHref}
className="shrink-0 text-xs font-bold transition-all duration-300 hover:translate-x-0.5"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
{actionLabel}
</Link>
)}
</div>
);
}
interface PanelProps {
children: ReactNode;
title?: string;
icon?: string;
actionHref?: string;
actionLabel?: string;
bodyClassName?: string;
className?: string;
}
/** A themed surface with an optional header. */
function Panel({
children,
title,
icon,
actionHref,
actionLabel,
bodyClassName,
className,
}: PanelProps) {
const hasHeader = Boolean(title || icon);
return (
<div
className={`panel card-glow card-hairline relative overflow-hidden rounded-[var(--panel-radius)] ${
className ?? ""
}`}
>
{hasHeader && (
<PanelHeader
title={title ?? ""}
icon={icon}
actionHref={actionHref}
actionLabel={actionLabel}
/>
)}
{hasHeader ? (
<div className={bodyClassName ?? "p-5"}>{children}</div>
) : (
children
)}
</div>
);
}
/**
* Cover image for a news article. Articles without an upload used to render a
* broken <Image>, so they fall back to a themed plate instead.
*/
function ArticleCover({
src,
alt,
featured,
}: {
src: string;
alt: string;
featured: boolean;
}) {
if (!src) {
return (
<div
className="flex h-full w-full items-center justify-center"
style={{
background:
"linear-gradient(140deg, color-mix(in srgb, var(--color-primary) 26%, var(--color-surface)), color-mix(in srgb, var(--color-accent) 18%, var(--color-surface)))",
}}
>
<Image
src={ICON_ARTICLE}
alt=""
width={featured ? 56 : 40}
height={featured ? 56 : 40}
unoptimized
className="opacity-70"
/>
</div>
);
}
return (
<Image
src={src}
alt={alt}
fill
sizes={
featured
? "(max-width: 768px) 100vw, 60vw"
: "(max-width: 640px) 100vw, 20vw"
}
className="object-cover transition-transform duration-700 ease-out group-hover:scale-[1.06]"
unoptimized
/>
);
}
async function getHotelData() {
const [
hotelName,
logo,
online,
users,
rooms,
totalPhotos,
articleCount,
articles,
recentUsers,
recentPhotos,
] = await Promise.all([
resolveHotelName(),
siteSettings.get("cms_logo", ""),
cachedOnlineCount().catch(publicReadFailure("home.online")),
cached("total_users", 300_000, countUsers, { staleMs: 300000 }).catch(
publicReadFailure("home.users"),
),
cached("total_rooms", 300_000, countRooms, { staleMs: 300000 }).catch(
publicReadFailure("home.rooms"),
),
cached("total_photos", 300_000, countPhotos, { staleMs: 300000 }).catch(
publicReadFailure("home.photos-count"),
),
cached("total_articles", 300_000, countArticles, {
staleMs: 300000,
}).catch(publicReadFailure("home.articles-count")),
getNewsList(4, { throwOnError: true }).catch(
publicReadFailure("home.news"),
),
cached(
"home_online_users",
15_000,
() =>
db
.select({ id: User.id, username: User.username, look: User.look })
.from(User)
.where(eq(User.online, "1"))
.limit(12),
{ staleMs: 30000 },
).catch(publicReadFailure("home.recent-users")),
cached(
"home_recent_photos",
60_000,
() =>
db
.select({ id: CameraWeb.id, url: CameraWeb.url })
.from(CameraWeb)
.orderBy(desc(CameraWeb.timestamp))
.limit(4),
{ staleMs: 120000 },
).catch(publicReadFailure("home.photos")),
]);
return {
hotelName,
online,
users,
rooms,
totalPhotos,
articleCount,
articles,
recentUsers,
recentPhotos,
logo,
};
}
async function Home() {
const session = await auth();
if (session?.user?.id) redirect("/me");
const [
tp,
te,
th,
tn,
{
hotelName,
online,
users,
rooms,
totalPhotos,
articleCount,
articles,
recentUsers: rawRecentUsers,
recentPhotos,
logo,
},
captcha,
nonceHeaders,
] = await Promise.all([
getTranslations("pages.home"),
getTranslations("publicData"),
getTranslations("header"),
getTranslations("nav"),
getHotelData(),
captchaConfig(),
headers(),
]);
// Users who hide their online state must not surface in the "who is online"
// rails on the homepage either.
const recentUserPrivacy = await loadProfilePrivacyMap(
(rawRecentUsers ?? []).map((u) => u.id),
);
const recentUsers =
rawRecentUsers === null
? null
: rawRecentUsers.filter(
(u) => recentUserPrivacy.get(u.id)?.online !== false,
);
const nonce = nonceHeaders.get("x-nonce") ?? undefined;
const features = [
{
title: tp("featuresAvatar"),
desc: tp("featuresAvatarDesc"),
icon: ICON_FRIENDS,
},
{
title: tp("featuresFurniture"),
desc: tp("featuresFurnitureDesc"),
icon: ICON_CATALOG,
},
{
title: tp("featuresCommunity"),
desc: tp("featuresCommunityDesc"),
icon: ICON_NAV_GOODY,
},
{
title: tp("featuresGames"),
desc: tp("featuresGamesDesc"),
icon: ICON_STAR,
},
];
type NewsArticle = NonNullable<typeof articles>[number];
const renderArticleCard = (a: NewsArticle, featured = false) => {
const published = a.createdAt ? formatDate(a.createdAt, "date", "") : "";
return (
<Link
key={a.slug}
href={`/news/${a.slug}`}
className="group relative block w-full overflow-hidden rounded-[var(--panel-radius)] border transition-all duration-500 hover:-translate-y-1 hover:shadow-2xl"
style={{
height: featured
? "clamp(240px, 28vw, 320px)"
: "clamp(180px, 20vw, 230px)",
backgroundColor: "var(--color-text-muted)",
borderColor: "var(--panel-border)",
}}
>
<div className="relative h-full w-full overflow-hidden rounded-[var(--panel-radius)]">
<ArticleCover src={a.image} alt={a.title} featured={featured} />
<div
className="absolute right-2.5 top-2.5 flex h-8 w-8 translate-x-1 items-center justify-center rounded-full opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100"
style={{ background: "color-mix(in srgb, #000 45%, transparent)" }}
>
<span
className="text-sm font-bold"
style={{ color: HERO_COPY_STRONG }}
>
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</span>
</div>
{published && (
<div
className="absolute left-2.5 top-2.5 rounded-full px-2 py-0.5 text-[10px] font-bold backdrop-blur-sm"
style={{
background: "color-mix(in srgb, #000 45%, transparent)",
color: "#fff",
}}
>
{published}
</div>
)}
<div
className="absolute bottom-0 left-0 w-full p-3 sm:p-4"
style={{
background:
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 50%, transparent 100%)",
}}
>
{featured && (
<p className="mb-1 text-[10px] font-bold uppercase tracking-widest opacity-90">
{tp("latestArticle")}
</p>
)}
<h3
className={`line-clamp-2 font-bold text-shadow-sm ${
featured ? "text-base sm:text-2xl" : "text-sm sm:text-base"
}`}
style={{ color: HERO_COPY_STRONG }}
>
{a.title}
</h3>
</div>
</div>
</Link>
);
};
const exploreLinks = [
{ href: "/community", label: tn("community") },
{ href: "/news", label: tn("articles") },
{ href: "/events", label: tn("events") },
{ href: "/photos", label: tn("photos") },
{ href: "/radio", label: tn("radio") },
{ href: "/shop", label: tn("shop") },
{ href: "/rankings", label: tn("rankings") },
];
// One source of truth for the four headline counters. They used to be
// rendered twice — as four small chips in the hero and again as a full
// panel further down — which made the page feel padded and repeated.
// A failed read stays visible as "unavailable" instead of silently
// shrinking the row, so a broken counter can never be mistaken for zero.
const statItems = [
{ value: users, label: tp("statsCitizens"), icon: ICON_FRIENDS },
{ value: rooms, label: tp("statsRooms"), icon: ICON_CATALOG },
{ value: totalPhotos, label: tp("statsPhotos"), icon: ICON_CAMERA },
{ value: articleCount, label: tp("statsArticles"), icon: ICON_ARTICLE },
];
const structuredData = buildWebsiteStructuredData({
hotelName,
url: appBaseUrl(),
logo: absoluteAssetUrl(logo, appBaseUrl()),
});
return (
<div className="flex w-full flex-col gap-14 pb-16 pt-2 md:gap-20 md:pb-24">
<JsonLd data={structuredData} />
{/* ── Top bar: logo + live online + language/clock/theme ── */}
<div className="mx-auto w-full max-w-7xl">
<AuthTopBar hotelName={hotelName} logo={logo} online={online} />
</div>
{/* ── Hero + the headline counters, which straddle the hero edge ── */}
<div className="relative w-full">
<Reveal className="-mx-3 sm:-mx-4 md:-mx-6">
<div className="relative overflow-hidden">
<Image
src="/assets/images/next_header.jpg"
alt=""
fill
priority
sizes="100vw"
className="ken-burns"
style={{ objectFit: "cover", objectPosition: "center 20%" }}
/>
<div
className="absolute inset-0"
style={{ background: HERO_SCRIM }}
/>
{/* Drifting brand-tinted aurora behind the copy */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 overflow-hidden"
>
<div
className="aurora-blob -left-24 top-1/4 h-[420px] w-[420px]"
style={{
background:
"color-mix(in srgb, var(--color-primary) 35%, transparent)",
opacity: 0.5,
}}
/>
<div
className="aurora-blob -right-24 top-1/3 h-[380px] w-[380px]"
style={{
background:
"color-mix(in srgb, var(--color-accent) 32%, transparent)",
animationDelay: "-7s",
opacity: 0.45,
}}
/>
</div>
<div
aria-hidden="true"
className="brand-halo left-1/2 top-1/2 h-[420px] w-[720px] max-w-full -translate-x-1/2 -translate-y-1/2"
style={{ opacity: 0.5, zIndex: 1 }}
/>
<div className="relative z-10 mx-auto flex min-h-[540px] w-full max-w-6xl flex-col items-center justify-center px-4 py-24 text-center sm:min-h-[580px] lg:min-h-[640px]">
<h1 style={{ fontFamily: "var(--font-nunito)" }}>
<span
className="mb-3 block text-xs font-extrabold uppercase tracking-[0.32em] sm:text-sm"
style={{ color: "rgba(255,255,255,0.72)" }}
>
{tp("heroEyebrow")}
</span>
<span className="gradient-text drop-shadow-[0_2px_24px_rgba(0,0,0,0.55)] block text-5xl font-black leading-[1.02] tracking-tight sm:text-6xl md:text-7xl lg:text-8xl">
<TypewriterText text={hotelName} />
</span>
</h1>
<p
className="mt-6 max-w-xl text-sm font-semibold leading-relaxed sm:text-base md:text-lg"
style={{ color: HERO_COPY }}
>
{tp("welcomeBody")}
</p>
<div
className="animate-fade-in-up mt-9 flex flex-wrap items-center justify-center gap-3"
style={{ animationDelay: "0.2s" }}
>
<Link
href="/register"
className="btn-brand btn-shine px-8 py-4 text-sm font-black uppercase tracking-wider"
>
<Image
src="/assets/images/EnterHubbly.png"
alt=""
width={20}
height={20}
unoptimized
/>
{tp("register")}
</Link>
<Link
href="/login"
className="btn-glass-dark group px-8 py-4 text-sm font-bold"
>
{th("login")}
<span
aria-hidden="true"
className="inline-flex transition-transform duration-300 group-hover:translate-x-1"
>
<ArrowRight className="h-4 w-4" />
</span>
</Link>
</div>
</div>
{/* Scroll cue */}
<div
aria-hidden="true"
className="pointer-events-none absolute bottom-6 left-1/2 hidden -translate-x-1/2 sm:block"
style={{ zIndex: 2 }}
>
<div
className="hero-cue flex h-10 w-10 items-center justify-center rounded-full"
style={{
background: "rgba(255,255,255,0.09)",
border: "1px solid rgba(255,255,255,0.3)",
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
}}
>
<ChevronDown
className="h-5 w-5"
style={{ color: "rgba(255,255,255,0.88)" }}
/>
</div>
</div>
{/* Floating mascot — decorative, hidden on smaller screens. */}
<div
aria-hidden="true"
className="pointer-events-none absolute bottom-8 right-8 hidden lg:block xl:right-14"
style={{ zIndex: 2 }}
>
<div className="brand-halo left-1/2 top-1/2 h-44 w-44 -translate-x-1/2 -translate-y-1/2 xl:h-52 xl:w-52" />
<Image
src="/assets/images/FrankwithBag.gif"
alt=""
width={96}
height={128}
unoptimized
className="relative animate-float object-contain drop-shadow-2xl"
/>
</div>
<div className="hero-ring" aria-hidden="true" />
</div>
</Reveal>
{/* The headline counters. They straddle the hero edge so the
numbers land above the fold instead of in a second block
further down the page. */}
<Reveal className="relative z-20 mx-auto -mt-14 w-full max-w-4xl px-3 sm:-mt-16 sm:px-4 md:px-6">
<div className="panel panel-float relative overflow-hidden rounded-[var(--panel-radius)] px-2 py-5 sm:px-4 sm:py-6">
<div className="grid grid-cols-2 gap-y-6 lg:grid-cols-4 lg:gap-y-0">
{statItems.map((s, i) => (
<div
key={s.label}
className="stat-cell group flex flex-col items-center gap-2 text-center"
>
<div
className="panel-tile flex h-10 w-10 items-center justify-center rounded-xl transition-transform duration-300 group-hover:scale-110"
style={
{
"--panel-tint": tintFor(i),
} as CSSProperties
}
>
<Image
src={s.icon}
alt=""
width={20}
height={20}
unoptimized
/>
</div>
<div
className="gradient-text text-3xl font-black tracking-tight sm:text-4xl"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{s.value === null ? (
<span
className="text-base font-bold"
style={{
color:
"var(--color-text-muted-readable, var(--color-text-muted))",
}}
>
{te("unavailable")}
</span>
) : (
<AnimatedCounter value={s.value} />
)}
</div>
<span
className="rounded-full px-2.5 py-1 text-[11px] font-bold uppercase tracking-widest"
style={{
background:
"color-mix(in srgb, var(--color-text-muted) 9%, transparent)",
color: "var(--color-text-readable)",
}}
>
{s.label}
</span>
</div>
))}
</div>
</div>
</Reveal>
</div>
{/* ── Quick explore ── */}
<Reveal className="-mt-6 mx-auto w-full max-w-7xl">
<nav
aria-label={tp("exploreLabel")}
className="flex flex-wrap items-center justify-center gap-2.5 sm:gap-3"
>
{exploreLinks.map((l, i) => (
<Link key={l.href} href={l.href} className="explore-pill group">
<span
aria-hidden="true"
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{
background: tintFor(i),
boxShadow: `0 0 8px color-mix(in srgb, ${tintFor(i)} 70%, transparent)`,
}}
/>
{l.label}
<ArrowRight
aria-hidden="true"
className="pill-arrow h-3.5 w-3.5"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
/>
</Link>
))}
</nav>
</Reveal>
{/* ── Features ── */}
<Reveal className="mx-auto w-full max-w-7xl">
<div className="flex flex-col gap-6 sm:gap-8">
<div>
<span className="eyebrow">{tp("sectionFeaturesLabel")}</span>
<h2
className="section-title mt-2 text-2xl sm:text-3xl md:text-4xl"
style={{ letterSpacing: "-0.02em" }}
>
{tp("sectionFeaturesTitle")}
</h2>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-5 lg:grid-cols-4">
{features.map((f, i) => (
<SpotlightCard
key={f.title}
className="panel card-glow card-hairline group relative overflow-hidden rounded-[var(--panel-radius)] p-6 transition-all duration-500 hover:-translate-y-1.5 hover:shadow-2xl sm:p-7"
>
<span
aria-hidden="true"
className="pointer-events-none absolute right-5 top-4 select-none text-5xl font-black leading-none opacity-[0.07] transition-opacity duration-500 group-hover:opacity-20"
style={{
background:
"linear-gradient(120deg, var(--color-primary), var(--color-accent))",
WebkitBackgroundClip: "text",
backgroundClip: "text",
color: "transparent",
}}
>
{`0${i + 1}`}
</span>
<div
className="panel-tile relative flex h-12 w-12 items-center justify-center rounded-2xl transition-transform duration-300 group-hover:scale-110 group-hover:-rotate-3"
style={
{
"--panel-tint": tintFor(i),
borderRadius: 14,
} as CSSProperties
}
>
<Image
src={f.icon}
alt=""
width={24}
height={24}
unoptimized
/>
</div>
<h3
className="relative mt-5 text-base font-extrabold sm:text-lg"
style={{
color: "var(--color-text-readable)",
fontFamily: "var(--font-nunito)",
}}
>
{f.title}
</h3>
<p
className="relative mt-1.5 text-sm leading-relaxed"
style={{
color:
"var(--color-text-muted-readable, var(--color-text-muted))",
}}
>
{f.desc}
</p>
</SpotlightCard>
))}
</div>
</div>
</Reveal>
{/* ── Community ── */}
<Reveal className="mx-auto w-full max-w-7xl">
<div className="flex flex-col gap-6 sm:gap-8">
<div>
<span className="eyebrow">{tp("sectionCommunityLabel")}</span>
<h2
className="section-title mt-2 text-2xl sm:text-3xl md:text-4xl"
style={{ letterSpacing: "-0.02em" }}
>
{tp("sectionCommunityTitle")}
</h2>
</div>
<div className="flex flex-col gap-4 md:flex-row">
<div className="flex w-full flex-col gap-4 md:w-1/3 md:flex-shrink-0">
<Panel
title={th("login")}
icon={ICON_NAV_ME}
bodyClassName="p-3.5 sm:p-4"
>
<LoginForm
variant="compact"
redirectTo="/"
captcha={{
provider: captcha.provider,
siteKey: captcha.siteKey || undefined,
field: captcha.field || undefined,
}}
nonce={nonce}
/>
</Panel>
{recentUsers === null ? (
<Panel title={tp("recentUsers")} bodyClassName="p-4">
<PublicLoadError href="/" />
</Panel>
) : (
recentUsers.length > 0 && (
<Panel
title={tp("recentUsers")}
icon={ICON_NAV_GOODY}
actionHref="/community"
actionLabel={tp("allUsers")}
bodyClassName="p-3.5 sm:p-4"
>
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
{recentUsers.map((u) => (
<Link
key={u.username}
href={`/u/${encodeURIComponent(u.username)}`}
title={u.username}
className="group flex flex-col items-center gap-1 rounded-xl border border-transparent p-1.5 transition-all duration-200 hover:border-[color-mix(in_srgb,var(--color-primary)_32%,transparent)] hover:bg-[color-mix(in_srgb,var(--color-primary)_8%,transparent)]"
>
<span className="relative">
<UserAvatarThumbnail
figure={u.look}
alt={u.username}
options={{ direction: 2 }}
className="rounded-md"
/>
<span
aria-hidden="true"
className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full"
style={{
background: "var(--color-accent)",
boxShadow: "0 0 0 2px var(--panel-surface)",
}}
/>
</span>
<span
className="max-w-[64px] truncate text-center text-[9px] sm:text-[10px] font-bold leading-tight"
style={{ color: "var(--color-text-readable)" }}
>
{u.username}
</span>
</Link>
))}
</div>
</Panel>
)
)}
</div>
{/* News */}
<div className="flex w-full flex-col md:w-2/3">
<Panel
title={tp("latestNews")}
icon={ICON_ARTICLE}
actionHref="/news"
actionLabel={tp("allNews")}
>
{articles === null ? (
<PublicLoadError href="/" />
) : articles.length > 0 ? (
<div className="flex flex-col gap-3 sm:gap-4">
{renderArticleCard(articles[0], true)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
{articles.slice(1, 4).map((a) => renderArticleCard(a))}
</div>
</div>
) : (
<p
className="py-8 text-center text-sm"
style={{
color:
"var(--color-text-muted-readable, var(--color-text-muted))",
}}
>
{tp("noArticles")}
</p>
)}
</Panel>
</div>
</div>
</div>
</Reveal>
{/* ── Recent photos ── */}
{recentPhotos === null ? (
<Reveal className="mx-auto w-full max-w-7xl">
<Panel title={tp("recentPhotos")} bodyClassName="p-4">
<PublicLoadError href="/" />
</Panel>
</Reveal>
) : (
recentPhotos.length > 0 && (
<Reveal className="mx-auto w-full max-w-7xl">
<Panel title={tp("recentPhotos")} icon={ICON_CAMERA}>
<div className="mx-auto grid max-w-5xl grid-cols-2 gap-3 sm:grid-cols-4">
{recentPhotos.slice(0, 4).map((p, i) => (
<Link
key={p.id}
href="/photos"
className="block aspect-[4/3] overflow-hidden rounded-2xl border transition-all duration-300 ease-out hover:scale-[1.03] hover:shadow-xl hover:ring-2 hover:ring-[color-mix(in_srgb,var(--color-primary)_45%,transparent)]"
style={{ borderColor: "var(--panel-border)" }}
>
{p.url ? (
<Image
src={p.url}
alt={`${tp("recentPhotos")} ${i + 1}`}
width={280}
height={210}
className="h-full w-full object-cover"
unoptimized
/>
) : (
<span
className="flex h-full w-full items-center justify-center"
style={{
background:
"linear-gradient(140deg, color-mix(in srgb, var(--color-primary) 22%, var(--color-surface)), color-mix(in srgb, var(--color-accent) 16%, var(--color-surface)))",
}}
>
<Image
src={ICON_CAMERA}
alt=""
width={32}
height={32}
unoptimized
className="opacity-70"
/>
</span>
)}
</Link>
))}
</div>
</Panel>
</Reveal>
)
)}
{/* ── Join the fun ── */}
<Reveal className="mx-auto w-full max-w-7xl">
<section
aria-labelledby="join-cta-title"
className="relative overflow-hidden rounded-3xl px-6 py-14 text-center sm:px-10 sm:py-16"
style={{
background: DARK_BAND,
border:
"1px solid color-mix(in srgb, var(--color-primary) 28%, transparent)",
boxShadow:
"0 40px 90px -40px color-mix(in srgb, #000 50%, transparent), 0 0 0 0.5px color-mix(in srgb, #000 20%, transparent)",
}}
>
<div
aria-hidden="true"
className="brand-halo left-1/2 top-0 h-72 w-[620px] max-w-full -translate-x-1/2 -translate-y-1/3"
style={{ opacity: 0.6 }}
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 overflow-hidden"
>
<div
className="aurora-blob -left-20 bottom-0 h-72 w-72"
style={{
background:
"color-mix(in srgb, var(--color-accent) 45%, transparent)",
opacity: 0.5,
}}
/>
<div
className="aurora-blob -right-16 top-4 h-64 w-64"
style={{
background:
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
animationDelay: "-6s",
opacity: 0.5,
}}
/>
</div>
<h2
id="join-cta-title"
className="relative text-3xl font-black tracking-tight text-balance sm:text-4xl"
style={{ color: "#fff" }}
>
{tp("joinCtaTitle")}
</h2>
<p
className="relative mx-auto mt-3 max-w-xl text-sm leading-relaxed sm:text-base"
style={{ color: "rgba(255,255,255,0.72)" }}
>
{tp("joinCtaBody")}
</p>
<div className="relative mt-8 flex flex-wrap items-center justify-center gap-3">
<Link
href="/register"
className="btn-brand btn-shine group px-8 py-4 text-sm font-black uppercase tracking-wide"
>
{tp("ctaJoin")}
<span
aria-hidden="true"
className="inline-flex transition-transform duration-300 group-hover:translate-x-1"
>
<ArrowRight className="h-4 w-4" />
</span>
</Link>
<Link
href="/community"
className="btn-glass-dark px-8 py-4 text-sm font-bold"
>
{tp("browseCommunity")}
</Link>
</div>
<div className="hero-ring" aria-hidden="true" />
</section>
</Reveal>
</div>
);
}
export default withPublicPagePerformance("/", Home);