The homepage mixed four different translucent washes (surface 44-60% plus a backdrop-filter each), rendered the same four counters twice, and showed a dangling "Welcome to " eyebrow because the localized string expected the hotel name. A missing theme wallpaper replaced the whole sky gradient with flat white, and stale CMS image references produced broken images and 404s. Replace the ad-hoc panels with one opaque, theme-derived `.panel` token set (surface, border, shadow, header, radius) and a `.panel-float` variant for the counter strip that straddles the hero edge. The strip is now the single home of the four headline counters; a failed read renders "unavailable" instead of silently dropping a cell. Add the fourth feature card (Games) that already had translated copy, link recent users to their profiles, and fall back to themed placeholders for news covers and camera photos without an upload. Add `pages.home.heroEyebrow` to all 25 locales, layer the CMS wallpaper over `--site-bg-gradient` instead of replacing it, and keep the sky in dark mode. Add `publicAssetExists` so local uploads that no longer exist are skipped (wallpaper, help topics) rather than rendered broken. Fix the `/help` topic icons to use the `/assets/images/help-center/` base the detail page already used. Throttle the spotlight glow to one update per animation frame, drop the dead `.glass-chip` rules, define the missing `.panel-float`, and fix the stat divider and reduced-motion selectors. Stat labels use the readable text token so they clear WCAG AA, and the language button's accessible name now contains its visible label. Verified: tsc, biome, 3590 unit tests, i18n audit unchanged, axe 0 violations at 390/768/1024/1280/1440 in light and dark, quality e2e 16/16.
1024 lines
30 KiB
TypeScript
1024 lines
30 KiB
TypeScript
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 { 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 { 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");
|
||
return {
|
||
title,
|
||
description,
|
||
openGraph: { title, description, type: "website" },
|
||
};
|
||
}
|
||
|
||
/* ── 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: "/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 },
|
||
];
|
||
|
||
return (
|
||
<div className="flex w-full flex-col gap-14 pb-16 pt-2 md:gap-20 md:pb-24">
|
||
{/* ── 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);
|