The hotel moved from .nitro to .hab bundles, so the imager must treat .hab like .nitro and the public avatar endpoints must accept the figure strings the client sends (including extra trailing segments such as `.0`, which the old FIGURE_RE rejected with a 400). - imager-upstream: relax FIGURE_RE, extend parseAvatarQuery test coverage - api/imaging/avatar + /imaging: shared validation, placeholder and ETag/304 - api/imaging/badge: single-flight upstream fetch and conditional responses - imager-http/imager-png: strong ETag helpers and APNG passthrough guard - csp + next/image: unoptimized external images, tightened CSP - deps: override postcss-selector-parser to ^7.1.6 - also fold in the pending drizzle draft snapshot and stray test cleanup
1151 lines
34 KiB
TypeScript
1151 lines
34 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 { LoginForm } from "@/components/auth/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 { SpotlightCard } from "@/components/spotlight-card";
|
|
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, 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 { 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_LIGHTHOUSE,
|
|
ICON_NAV_GOODY,
|
|
ICON_NAV_ME,
|
|
} 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 (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,
|
|
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,
|
|
};
|
|
}
|
|
|
|
export default 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,
|
|
},
|
|
];
|
|
|
|
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"
|
|
unoptimized
|
|
/>
|
|
<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)" }}
|
|
>
|
|
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
|
</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>
|
|
</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: articleCount,
|
|
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%)",
|
|
}}
|
|
/>
|
|
{/* 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]">
|
|
<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="animate-fade-in-up mt-7 inline-flex items-center gap-3 rounded-full border px-3 py-1.5"
|
|
style={{
|
|
background: "rgba(255,255,255,0.08)",
|
|
borderColor: "rgba(255,255,255,0.22)",
|
|
backdropFilter: "blur(12px)",
|
|
WebkitBackdropFilter: "blur(12px)",
|
|
animationDelay: "0.08s",
|
|
}}
|
|
>
|
|
<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="pr-1 text-[10px] font-extrabold uppercase tracking-[0.22em]"
|
|
style={{ color: "rgba(255,255,255,0.75)" }}
|
|
>
|
|
{tp("recentUsers")}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<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 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>
|
|
|
|
{/* Floating stat chips */}
|
|
<div
|
|
className="animate-fade-in-up mt-12 flex flex-wrap items-center justify-center gap-2.5 sm:gap-3"
|
|
style={{ animationDelay: "0.32s" }}
|
|
>
|
|
{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>
|
|
|
|
{/* 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>
|
|
|
|
{/* ── 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, 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:
|
|
i % 2 === 0
|
|
? "var(--color-primary)"
|
|
: "var(--color-accent)",
|
|
boxShadow: `0 0 8px color-mix(in srgb, ${
|
|
i % 2 === 0 ? "var(--color-primary)" : "var(--color-accent)"
|
|
} 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 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 (
|
|
<SpotlightCard
|
|
key={f.title}
|
|
className="spotlight 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: `linear-gradient(135deg, color-mix(in srgb, ${accentVar} 30%, transparent), color-mix(in srgb, ${accentVar} 12%, transparent))`,
|
|
boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${accentVar} 30%, transparent), 0 8px 18px -10px color-mix(in srgb, ${accentVar} 70%, 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>
|
|
</SpotlightCard>
|
|
);
|
|
})}
|
|
</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="card-hairline overflow-hidden rounded-2xl border"
|
|
style={PANEL_STYLE}
|
|
>
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-8 p-6 sm:p-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: articleCount,
|
|
label: tp("statsArticles"),
|
|
icon: ICON_ARTICLE,
|
|
},
|
|
].map((s) => (
|
|
<div
|
|
key={s.label}
|
|
className="stat-cell 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>
|
|
</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"
|
|
>
|
|
<LoginForm
|
|
variant="compact"
|
|
redirectTo="/"
|
|
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, 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:
|
|
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
|
}}
|
|
>
|
|
<Image
|
|
src={p.url}
|
|
alt={`${tp("recentPhotos")} ${i + 1}`}
|
|
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 }}
|
|
/>
|
|
<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>
|
|
);
|
|
}
|