Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Failing after 26s
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / preflight (push) Skipped
CI / deploy (push) Skipped
- add theme-aware helpers: btn-brand, btn-glass-dark, auth-input, auth-label, auth-alert, explore-pill, avatar-tile, aurora blobs and a hero scroll cue - reorder backdrop-filter declarations so the glass blur survives the production CSS optimizer in modern Chromium - hero: aurora glow, frosted recent-users chip, premium CTA buttons and cue - explore nav: icon pills with hover arrows; features: gradient icon tiles - stats: single glass panel with column dividers; join CTA: aurora + ring - auth forms: visible labels, icon inputs, eye/password toggle, gradient submit and pill footer links - auth pages: gradient card frame, aurora accents on the intro panel and hover-lift avatar tiles; unified pill-shaped top bar - drop the hard-coded register banner image in favor of the framed card
1126 lines
34 KiB
TypeScript
1126 lines
34 KiB
TypeScript
import { count, desc, eq } from "drizzle-orm";
|
|
import { 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 { 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 {
|
|
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 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, 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"),
|
|
),
|
|
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%)",
|
|
}}
|
|
/>
|
|
{/* 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="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)",
|
|
}}
|
|
>
|
|
<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="mt-9 flex flex-wrap items-center justify-center gap-3">
|
|
<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="transition-transform duration-300 group-hover:translate-x-1"
|
|
>
|
|
→
|
|
</span>
|
|
</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>
|
|
|
|
{/* 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}
|
|
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, 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}
|
|
<span
|
|
aria-hidden="true"
|
|
className="pill-arrow text-[11px]"
|
|
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: `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>
|
|
</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="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: articles?.length ?? null,
|
|
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) => (
|
|
<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 }}
|
|
/>
|
|
<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 className="transition-transform duration-300 group-hover:translate-x-1">
|
|
→
|
|
</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>
|
|
);
|
|
}
|