Files
Epicnabbo-Catalogus-Updated…/src/app/(site)/page.tsx
T
openhands 203399aab7 fix(cache): true LRU, stale-while-revalidate and cross-process invalidation
The in-process cache was a FIFO of 500 entries that was never touched on a
read, so a key polled on every request could be evicted by an unrelated burst
of dynamic keys. That looked exactly like the cache being cleared at random,
and it is what made the site fall back to the database unpredictably.

- Evict least-recently-used instead, and raise the default budget to 2000
  (CACHE_MEMORY_MAX_ENTRIES). Reading a key now marks it as used, so a hot key
  only leaves when a hotter one takes its place.
- Add opt-in stale-while-revalidate (CachedOptions.staleMs). The grace window
  lives on the entry, so one call site opting in protects every reader of that
  key. A failed background refresh keeps serving the last good value instead of
  falling through to the origin, and is reported once rather than per read.
- Invalidate across processes. invalidateKey() now clears memory, deletes the
  Redis key and publishes a signal, so a value written by one process is no
  longer served stale by the others for the rest of its TTL. A failed Redis
  delete no longer skips the broadcast.
- Guard against a refresh that started before an invalidation writing its
  outdated result back into the cache.
- Read the news revision at most once a second per process instead of on every
  call, with a pub/sub signal to drop the local copy when it rotates. A Redis
  outage now degrades to the in-process cache rather than to no cache at all.
- Warm the hot public keys on boot, so the first visitors after a deploy do not
  each pay for a miss.
- Count hits, misses, stale serves, errors and evictions per key, exposed at
  GET /api/admin/devops/cache. Without it a wrong REDIS_URL, a full budget and
  a dead origin all look identical from the outside.
- Enforce the imaging cache budget for real: records are .img/.json pairs, so
  the old cap counted files and never removed anything while entries were
  fresh. Sweeps are throttled per directory and prune to a low-water mark.
- Cap the JWT version map, and stop per-test scratch roots from littering the
  runtime imaging cache.

Public read-only endpoints get grace windows; admin, account and auth data
deliberately stays fresh. Redis TTLs get a little jitter so keys written
together no longer expire together.

3209 tests pass. next build could not be verified on this host: the optimized
build is OOM-killed before prerender, so this has not run in a real Next
runtime yet.
2026-09-25 18:26:45 +02:00

1077 lines
33 KiB
TypeScript

import { count, desc, eq } from "drizzle-orm";
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 { HomeLoginForm } from "@/components/auth/home-login-form";
import { Clock } from "@/components/clock";
import { LanguageSwitcher } from "@/components/language-switcher";
import Link from "@/components/link";
import { LiveOnlineCounter } from "@/components/live-online-counter";
import { Reveal } from "@/components/motion-reveal";
import { PublicLoadError } from "@/components/public/load-error";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { TypewriterText } from "@/components/typewriter-text";
import { auth } from "@/lib/auth";
import { cached } from "@/lib/cache";
import { CameraWeb, db, Rooms, User } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { resolveHotelName } from "@/lib/hotel-name";
import { captchaConfig } from "@/lib/services/captcha";
import { getNewsList } from "@/lib/services/news-list";
import { publicReadFailure } from "@/lib/services/public-read";
import { siteSettings } from "@/lib/services/site-settings";
import {
ICON_ARTICLE,
ICON_CAMERA,
ICON_CATALOG,
ICON_FRIENDS,
ICON_LIGHTHOUSE,
ICON_NAV_GOODY,
ICON_NAV_ME,
} from "@/lib/site-icons";
export const metadata: Metadata = {
title: "Home",
description:
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
openGraph: {
title: "Home",
description:
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
type: "website",
},
};
// ── Local visual language (theme-aware, no big white blocks) ──────────
const PANEL_BG =
"linear-gradient(160deg, color-mix(in srgb, var(--color-primary) 12%, transparent) 0%, color-mix(in srgb, var(--color-surface) 58%, transparent) 52%, color-mix(in srgb, var(--color-accent) 9%, transparent) 100%)";
const PANEL_STYLE: CSSProperties = {
background: PANEL_BG,
backdropFilter: "blur(18px)",
WebkitBackdropFilter: "blur(18px)",
borderColor: "color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
boxShadow:
"0 0 0 0.5px color-mix(in srgb, var(--color-text-muted) 6%, transparent), 0 24px 60px -30px color-mix(in srgb, #000 28%, transparent)",
};
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%)";
const GLASS_TILE_BG =
"linear-gradient(155deg, color-mix(in srgb, var(--color-surface) 60%, transparent) 0%, color-mix(in srgb, var(--color-surface) 44%, transparent) 60%, color-mix(in srgb, var(--color-accent) 6%, transparent) 100%)";
interface GlassPanelProps {
children: ReactNode;
title?: string;
icon?: string;
actionHref?: string;
actionLabel?: string;
bodyClassName?: string;
className?: string;
}
function GlassPanel({
children,
title,
icon,
actionHref,
actionLabel,
bodyClassName,
className,
}: GlassPanelProps) {
const hasHeader = Boolean(title || icon);
return (
<div
className={`card-glow card-hairline relative overflow-hidden rounded-2xl border ${
className ?? ""
}`}
style={PANEL_STYLE}
>
{hasHeader && (
<div
className="flex items-center justify-between gap-3 border-b px-5 py-4"
style={{
borderColor:
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
background:
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 8%, transparent), transparent 70%)",
}}
>
<div className="flex min-w-0 items-center gap-2.5">
{icon && (
<div
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl"
style={{
background:
"color-mix(in srgb, var(--color-primary) 14%, transparent)",
boxShadow:
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent)",
}}
>
<Image
src={icon}
alt=""
width={18}
height={18}
unoptimized
className="opacity-90"
/>
</div>
)}
{title && (
<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>
)}
{hasHeader ? (
<div className={bodyClassName ?? "p-5"}>{children}</div>
) : (
children
)}
</div>
);
}
async function getHotelData() {
const [hotelName, logo] = await Promise.all([
resolveHotelName(),
siteSettings.get("cms_logo", ""),
]);
const [
online,
users,
rooms,
totalPhotos,
articles,
recentUsers,
recentPhotos,
] = await Promise.all([
cached("online_count", 10_000, () =>
db
.select({ total: count() })
.from(User)
.where(eq(User.online, "1"))
.then((rows) => rows[0]?.total ?? 0),
).catch(publicReadFailure("home.online")),
cached(
"total_users",
300_000,
() =>
db
.select({ total: count() })
.from(User)
.then((rows) => rows[0]?.total ?? 0),
{ staleMs: 300000 },
).catch(publicReadFailure("home.users")),
cached(
"total_rooms",
300_000,
() =>
db
.select({ total: count() })
.from(Rooms)
.then((rows) => rows[0]?.total ?? 0),
{ staleMs: 300000 },
).catch(publicReadFailure("home.rooms")),
cached(
"total_photos",
300_000,
() =>
db
.select({ total: count() })
.from(CameraWeb)
.then((rows) => rows[0]?.total ?? 0),
{ staleMs: 300000 },
).catch(publicReadFailure("home.photos-count")),
getNewsList(4, { throwOnError: true }).catch(
publicReadFailure("home.news"),
),
cached(
"home_online_users",
15_000,
() =>
db
.select({ 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,
articles,
recentUsers,
recentPhotos,
logo,
};
}
export default async function Home() {
const session = await auth();
if (session?.user?.id) redirect("/me");
const tp = await getTranslations("pages.home");
const te = await getTranslations("publicData");
const th = await getTranslations("header");
const tn = await getTranslations("nav");
const {
hotelName,
online,
users,
rooms,
totalPhotos,
articles,
recentUsers,
recentPhotos,
logo,
} = await getHotelData();
const captcha = await captchaConfig();
const nonce = (await headers()).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,
},
];
type NewsArticle = NonNullable<typeof articles>[number];
const renderArticleCard = (a: NewsArticle, featured = false) => (
<Link
key={a.slug}
href={`/news/${a.slug}`}
className="group relative w-full overflow-hidden rounded-2xl 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)",
border:
"1px solid color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
}}
>
<div className="relative h-full w-full overflow-hidden rounded-2xl">
<Image
src={a.image}
alt={a.title}
fill
sizes={featured ? "100vw" : "(max-width: 640px) 100vw, 33vw"}
className="rounded-2xl object-cover transition-transform duration-700 ease-out group-hover:scale-108"
/>
<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: "rgba(255,255,255,0.95)" }}
>
→
</span>
</div>
<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",
}}
>
{formatDate(a.createdAt, "date", "")}
</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: "rgba(255,255,255,0.95)" }}
>
{a.title}
</h3>
<p
className="mt-0.5 text-[10px] sm:text-[11px] font-semibold opacity-80"
style={{ color: "#cbd5e1" }}
>
{formatDate(a.createdAt, "date", "")}
</p>
</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") },
];
const statChips = [
{ value: users, label: tp("statsCitizens"), color: "var(--color-primary)" },
{ value: rooms, label: tp("statsRooms"), color: "var(--color-accent)" },
{
value: totalPhotos,
label: tp("statsPhotos"),
color: "var(--color-primary)",
},
{
value: articles?.length ?? null,
label: tp("statsArticles"),
color: "var(--color-accent)",
},
].filter((s) => s.value !== null) as Array<{
value: number;
label: string;
color: string;
}>;
return (
<div className="flex w-full flex-col gap-14 pb-16 pt-2 md:gap-20 md:pb-24">
{/* ── Top bar ── */}
<Reveal className="mx-auto w-full max-w-7xl">
<div
className="flex items-center justify-between rounded-full border px-3 py-2.5 sm:px-5"
style={{
background:
"linear-gradient(90deg, color-mix(in srgb, var(--color-surface) 62%, transparent), color-mix(in srgb, var(--color-primary) 7%, transparent))",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
boxShadow:
"0 1px 3px rgba(0,0,0,0.04), 0 0 0 0.5px color-mix(in srgb, var(--color-text-muted) 6%, transparent)",
}}
>
<div className="flex items-center gap-2 sm:gap-3">
{logo ? (
<Image
src={logo}
alt={hotelName}
width={100}
height={28}
className="shrink-0 object-contain"
unoptimized
style={{ maxHeight: 26, width: "auto", height: "auto" }}
/>
) : (
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
style={{
background:
"linear-gradient(135deg, var(--color-primary), color-mix(in srgb, var(--color-accent) 60%, var(--color-primary)))",
boxShadow:
"0 4px 16px -4px color-mix(in srgb, var(--color-primary) 60%, transparent)",
}}
>
<Image
src={ICON_LIGHTHOUSE}
alt=""
width={18}
height={18}
className="shrink-0"
unoptimized
style={{ filter: "brightness(0) invert(1)" }}
/>
</div>
)}
<span
className="hidden text-sm font-extrabold uppercase tracking-wide sm:inline"
style={{ color: "var(--color-text-readable)" }}
>
{hotelName}
</span>
</div>
<div
className="flex items-center gap-1 sm:gap-1.5 rounded-full px-1.5 py-1"
style={{
background:
"color-mix(in srgb, var(--color-text-muted) 5%, transparent)",
}}
>
<LanguageSwitcher />
<div
className="hidden w-px h-5 sm:block"
style={{
background:
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
}}
/>
<Clock />
<div
className="w-px h-5"
style={{
background:
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
}}
/>
<ThemeSwitcher />
</div>
</div>
</Reveal>
{/* ── Cinematic hero ── */}
<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:
"linear-gradient(180deg, rgba(8,11,24,0.74) 0%, rgba(8,11,24,0.55) 38%, rgba(8,11,24,0.38) 62%, rgba(8,11,24,0.55) 85%, color-mix(in srgb, var(--color-background) 94%, transparent) 100%)",
}}
/>
<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]">
<span
className="glass-chip gap-2.5 px-4 py-2"
style={{
fontWeight: 800,
letterSpacing: "0.14em",
textTransform: "uppercase",
}}
>
<span className="relative flex h-2 w-2">
<span
className="absolute inline-flex h-full w-full animate-ping rounded-full opacity-70"
style={{ background: "var(--color-primary)" }}
/>
<span
className="relative inline-flex h-2 w-2 rounded-full"
style={{ background: "var(--color-primary)" }}
/>
</span>
<span
className="text-[10px] font-black sm:text-xs"
style={{ color: "var(--color-text-readable)" }}
>
{tp("onlineNow")}
</span>
{online === null ? (
<span className="text-xs">{te("unavailable")}</span>
) : (
<span
className="text-sm sm:text-base"
style={{
color:
"var(--color-primary-readable, var(--color-primary))",
fontWeight: 900,
}}
>
<LiveOnlineCounter initial={online} />
</span>
)}
</span>
<h1
className="mt-7 sm:mt-8"
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("welcome", { hotelName: "" })}
</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: "rgba(255,255,255,0.78)" }}
>
{tp("welcomeBody")}
</p>
{recentUsers !== null && recentUsers.length > 0 && (
<div className="mt-7 flex items-center justify-center gap-3">
<div className="flex -space-x-2.5">
{recentUsers.slice(0, 4).map((u) => (
<UserAvatarThumbnail
key={u.username}
figure={u.look}
alt={u.username}
options={{ direction: 2 }}
className="h-9 w-9 rounded-full ring-2 ring-white/70 shadow-lg"
/>
))}
</div>
<span
className="text-[10px] font-extrabold uppercase tracking-[0.22em]"
style={{ color: "rgba(255,255,255,0.72)" }}
>
{tp("recentUsers")}
</span>
</div>
)}
<div className="mt-9 flex flex-wrap items-center justify-center gap-3">
<Link
href="/register"
className="btn-shine inline-flex items-center gap-2.5 rounded-2xl px-8 py-4 text-sm font-black uppercase tracking-wider transition-all duration-300 ease-out hover:-translate-y-1 hover:scale-[1.02] active:scale-95"
style={{
background:
"linear-gradient(120deg, var(--color-primary), color-mix(in srgb, var(--color-accent) 55%, var(--color-primary)))",
color: "var(--color-primary-foreground)",
boxShadow:
"0 12px 40px -8px color-mix(in srgb, var(--color-primary) 55%, transparent), 0 0 0 1px color-mix(in srgb, var(--color-primary) 55%, transparent)",
}}
>
<Image
src="/assets/images/EnterHubbly.png"
alt=""
width={20}
height={20}
unoptimized
/>
{tp("register")}
</Link>
<Link
href="/login"
className="inline-flex items-center gap-2 rounded-2xl px-8 py-4 text-sm font-bold transition-all duration-300 ease-out hover:-translate-y-1 hover:brightness-110 active:scale-95"
style={{
color: "#fff",
background: "rgba(255,255,255,0.07)",
border: "1px solid rgba(255,255,255,0.24)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
}}
>
{th("login")} →
</Link>
</div>
{/* Floating stat chips */}
<div className="mt-12 flex flex-wrap items-center justify-center gap-2.5 sm:gap-3">
{statChips.map((s) => (
<span key={s.label} className="glass-chip">
<span
className="glass-chip-dot"
style={{ background: s.color }}
/>
<span
className="glass-chip-value"
style={{
color: "var(--color-text-readable)",
fontSize: "1rem",
}}
>
<AnimatedCounter value={s.value} />
</span>
<span className="glass-chip-label">{s.label}</span>
</span>
))}
</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}
priority
unoptimized
className="relative animate-float object-contain drop-shadow-2xl"
/>
</div>
<div className="hero-ring" aria-hidden="true" />
</div>
</Reveal>
{/* ── Quick explore ── */}
<Reveal className="-mt-3 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) => (
<Link
key={l.href}
href={l.href}
className="group inline-flex items-center gap-1.5 rounded-full px-5 py-2.5 text-xs font-bold transition-all duration-300 hover:-translate-y-0.5 sm:text-sm"
style={{
background:
"color-mix(in srgb, var(--color-surface) 55%, transparent)",
border:
"1px solid color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
color: "var(--color-text-readable)",
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
boxShadow:
"0 2px 8px -2px color-mix(in srgb, #000 8%, transparent)",
}}
>
{l.label}
<span
className="-translate-x-1 text-[10px] opacity-0 transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
→
</span>
</Link>
))}
</nav>
</Reveal>
{/* ── Features ── */}
<Reveal className="mx-auto w-full max-w-7xl">
<div className="flex flex-col gap-6 sm:gap-8">
<div className="flex flex-wrap items-end justify-between gap-3">
<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>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-5">
{features.map((f, i) => {
const accentVar =
i % 2 === 0 ? "var(--color-primary)" : "var(--color-accent)";
return (
<div
key={f.title}
className="card-glow card-hairline group relative overflow-hidden rounded-2xl border p-6 transition-all duration-500 hover:-translate-y-1.5 hover:shadow-2xl sm:p-7"
style={{
background: GLASS_TILE_BG,
backdropFilter: "blur(16px)",
WebkitBackdropFilter: "blur(16px)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
}}
>
<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="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={{
background: `color-mix(in srgb, ${accentVar} 16%, transparent)`,
boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${accentVar} 22%, transparent)`,
borderRadius: 14,
}}
>
<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)" }}
>
{f.desc}
</p>
</div>
);
})}
</div>
</div>
</Reveal>
{/* ── Stats ── */}
<Reveal className="mx-auto w-full max-w-7xl">
<div className="flex flex-col gap-6 sm:gap-8">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<span className="eyebrow">{tp("sectionStatsLabel")}</span>
<h2
className="section-title mt-2 text-2xl sm:text-3xl md:text-4xl"
style={{ letterSpacing: "-0.02em" }}
>
{tp("sectionStatsTitle")}
</h2>
</div>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-8 lg:grid-cols-4">
{[
{ 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: articles?.length ?? null,
label: tp("statsArticles"),
icon: ICON_ARTICLE,
},
].map((s) => (
<div
key={s.label}
className="group relative flex flex-col items-center gap-2.5 text-center"
>
<div
className="flex h-10 w-10 items-center justify-center rounded-xl transition-transform duration-300 group-hover:scale-110"
style={{
background:
"color-mix(in srgb, var(--color-primary) 14%, transparent)",
boxShadow:
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent)",
}}
>
<Image
src={s.icon}
alt=""
width={20}
height={20}
unoptimized
/>
</div>
<div
className="gradient-text text-4xl font-black tracking-tight sm:text-5xl"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{s.value === null ? (
<span className="text-base">{te("unavailable")}</span>
) : (
<AnimatedCounter value={s.value} />
)}
</div>
<span
className="rounded-full px-3 py-1 text-[10px] font-bold uppercase tracking-widest"
style={{
background:
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
color: "var(--color-text-muted)",
}}
>
{s.label}
</span>
</div>
))}
</div>
</div>
</Reveal>
{/* ── Community ── */}
<Reveal className="mx-auto w-full max-w-7xl">
<div className="flex flex-col gap-6 sm:gap-8">
<div className="flex flex-wrap items-end justify-between gap-3">
<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>
<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">
<GlassPanel
title={th("login")}
icon={ICON_NAV_ME}
bodyClassName="p-3.5 sm:p-4"
>
<HomeLoginForm
captcha={{
provider: captcha.provider,
siteKey: captcha.siteKey || undefined,
field: captcha.field || undefined,
}}
nonce={nonce}
/>
</GlassPanel>
{recentUsers === null ? (
<GlassPanel title={tp("recentUsers")} bodyClassName="p-4">
<PublicLoadError href="/" />
</GlassPanel>
) : (
recentUsers.length > 0 && (
<GlassPanel
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) => (
<div
key={u.username}
title={u.username}
className="flex flex-col items-center gap-0.5 rounded-lg px-1 py-1.5 transition-all duration-200 hover:bg-[color-mix(in_srgb,var(--color-primary)_6%,transparent)] hover:ring-1 hover:ring-[color-mix(in_srgb,var(--color-primary)_35%,transparent)]"
>
<UserAvatarThumbnail
figure={u.look}
alt={u.username}
options={{ direction: 2 }}
className="rounded-md"
/>
<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>
</div>
))}
</div>
</GlassPanel>
)
)}
</div>
{/* News */}
<div className="flex w-full flex-col md:w-2/3">
<GlassPanel
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)" }}
>
{tp("noArticles")}
</p>
)}
</GlassPanel>
</div>
</div>
</div>
</Reveal>
{/* ── Recent photos ── */}
{recentPhotos === null ? (
<Reveal className="mx-auto w-full max-w-7xl">
<GlassPanel title={tp("recentPhotos")} bodyClassName="p-4">
<PublicLoadError href="/" />
</GlassPanel>
</Reveal>
) : (
recentPhotos.length > 0 && (
<Reveal className="mx-auto w-full max-w-7xl">
<GlassPanel 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) => (
<Link
key={p.id}
href="/photos"
title=""
aria-label={tp("recentPhotos")}
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:
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
}}
>
<Image
src={p.url}
alt={tp("recentPhotos")}
width={280}
height={210}
className="h-full w-full object-cover"
unoptimized
/>
</Link>
))}
</div>
</GlassPanel>
</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 }}
/>
<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-shine group inline-flex items-center gap-2 rounded-2xl px-8 py-4 text-sm font-black uppercase tracking-wide transition-all duration-300 hover:-translate-y-1 hover:scale-[1.02] active:scale-95"
style={{
background:
"linear-gradient(120deg, var(--color-primary), color-mix(in srgb, var(--color-accent) 55%, var(--color-primary)))",
color: "var(--color-primary-foreground)",
boxShadow:
"0 12px 40px -8px color-mix(in srgb, var(--color-primary) 50%, transparent)",
}}
>
{tp("ctaJoin")}
<span className="transition-transform duration-300 group-hover:translate-x-0.5">
→
</span>
</Link>
<Link
href="/community"
className="inline-flex items-center rounded-2xl px-8 py-4 text-sm font-bold transition-all duration-300 hover:-translate-y-1 hover:brightness-110 active:scale-95"
style={{
color: "#fff",
background: "rgba(255,255,255,0.07)",
border: "1px solid rgba(255,255,255,0.22)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
}}
>
{tp("browseCommunity")}
</Link>
</div>
</section>
</Reveal>
</div>
);
}