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 { 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 (
{icon && (
)}

{title}

{actionHref && actionLabel && ( {actionLabel} )}
); } 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 (
{hasHeader && ( )} {hasHeader ? (
{children}
) : ( children )}
); } /** * Cover image for a news article. Articles without an upload used to render a * broken , so they fall back to a themed plate instead. */ function ArticleCover({ src, alt, featured, }: { src: string; alt: string; featured: boolean; }) { if (!src) { return (
); } return ( {alt} ); } 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[number]; const renderArticleCard = (a: NewsArticle, featured = false) => { const published = a.createdAt ? formatDate(a.createdAt, "date", "") : ""; return (
{published && (
{published}
)}
{featured && (

{tp("latestArticle")}

)}

{a.title}

); }; 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 (
{/* ── Top bar: logo + live online + language/clock/theme ── */}
{/* ── Hero + the headline counters, which straddle the hero edge ── */}
{/* Drifting brand-tinted aurora behind the copy */}