Wrap next/link in a shared Link component that ships prefetch=false by default, so no route is ever prefetched (viewport or hover) anymore, and drop the DNS prefetch hint. Removes hidden background requests that were the source of intermittent issues.
624 lines
18 KiB
TypeScript
624 lines
18 KiB
TypeScript
import { count, desc, eq } from "drizzle-orm";
|
|
import { headers } from "next/headers";
|
|
import Image from "next/image";
|
|
import { redirect } from "next/navigation";
|
|
import { getTranslations } from "next-intl/server";
|
|
import { AmbientOrbs } from "@/components/ambient-orbs";
|
|
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 { Reveal } from "@/components/motion-reveal";
|
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
|
import { SurfaceCard } from "@/components/surface-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, 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 { siteSettings } from "@/lib/services/site-settings";
|
|
|
|
async function getHotelData() {
|
|
const [hotelName, logo] = await Promise.all([
|
|
resolveHotelName(),
|
|
siteSettings.get("cms_logo", ""),
|
|
]);
|
|
|
|
const [
|
|
online,
|
|
users,
|
|
rooms,
|
|
articles,
|
|
recentUsers,
|
|
recentPhotos,
|
|
latestUsers,
|
|
] = 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(() => 0),
|
|
cached("total_users", 300_000, () =>
|
|
db
|
|
.select({ total: count() })
|
|
.from(User)
|
|
.then((rows) => rows[0]?.total ?? 0),
|
|
).catch(() => 0),
|
|
cached("total_rooms", 300_000, () =>
|
|
db
|
|
.select({ total: count() })
|
|
.from(Rooms)
|
|
.then((rows) => rows[0]?.total ?? 0),
|
|
).catch(() => 0),
|
|
getNewsList(4),
|
|
cached("home_online_users", 15_000, () =>
|
|
db
|
|
.select({ username: User.username, look: User.look })
|
|
.from(User)
|
|
.where(eq(User.online, "1"))
|
|
.limit(30),
|
|
).catch(() => []),
|
|
cached("home_recent_photos", 60_000, () =>
|
|
db
|
|
.select({ id: CameraWeb.id, url: CameraWeb.url })
|
|
.from(CameraWeb)
|
|
.orderBy(desc(CameraWeb.timestamp))
|
|
.limit(4),
|
|
).catch(() => []),
|
|
cached("home_latest_users", 60_000, () =>
|
|
db
|
|
.select({ username: User.username, look: User.look })
|
|
.from(User)
|
|
.orderBy(desc(User.accountCreated))
|
|
.limit(8),
|
|
).catch(() => []),
|
|
]);
|
|
|
|
return {
|
|
hotelName,
|
|
online,
|
|
users,
|
|
rooms,
|
|
articles,
|
|
recentUsers,
|
|
recentPhotos,
|
|
latestUsers,
|
|
logo,
|
|
};
|
|
}
|
|
|
|
export default async function Home() {
|
|
const session = await auth();
|
|
if (session?.user?.id) redirect("/me");
|
|
|
|
const tp = await getTranslations("pages.home");
|
|
const th = await getTranslations("header");
|
|
const {
|
|
hotelName,
|
|
online,
|
|
users,
|
|
rooms,
|
|
articles,
|
|
recentUsers,
|
|
recentPhotos,
|
|
latestUsers,
|
|
logo,
|
|
} = await getHotelData();
|
|
|
|
const captcha = await captchaConfig();
|
|
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
|
|
|
return (
|
|
<div className="mx-auto w-full max-w-6xl flex flex-col gap-8 pb-16">
|
|
{/* ── Top Bar ── */}
|
|
<Reveal>
|
|
<div
|
|
className="relative flex items-center justify-between rounded-xl border px-5 py-3"
|
|
style={{
|
|
background:
|
|
"color-mix(in srgb, var(--color-surface) 60%, transparent)",
|
|
backdropFilter: "blur(14px)",
|
|
WebkitBackdropFilter: "blur(14px)",
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
{logo ? (
|
|
<Image
|
|
src={logo}
|
|
alt={hotelName}
|
|
width={100}
|
|
height={28}
|
|
className="shrink-0 object-contain"
|
|
unoptimized
|
|
style={{ maxHeight: 28, width: "auto", height: "auto" }}
|
|
/>
|
|
) : (
|
|
<Image
|
|
src="/assets/images/icons/lighthouse.png"
|
|
alt={hotelName}
|
|
width={28}
|
|
height={28}
|
|
className="shrink-0"
|
|
unoptimized
|
|
/>
|
|
)}
|
|
<span
|
|
className="text-sm font-extrabold uppercase tracking-wide"
|
|
style={{ color: "var(--color-text-readable)" }}
|
|
>
|
|
{hotelName}
|
|
</span>
|
|
</div>
|
|
<div
|
|
className="flex items-center gap-1.5 rounded-lg px-2 py-1"
|
|
style={{
|
|
background:
|
|
"color-mix(in srgb, var(--color-text-muted) 4%, transparent)",
|
|
}}
|
|
>
|
|
<LanguageSwitcher />
|
|
<div
|
|
className="w-px h-5"
|
|
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>
|
|
|
|
{/* ── Hero ── */}
|
|
<Reveal>
|
|
<div
|
|
className="relative overflow-hidden rounded-3xl border"
|
|
style={{
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
|
}}
|
|
>
|
|
<Image
|
|
src="/assets/images/next_header.jpg"
|
|
alt=""
|
|
fill
|
|
priority
|
|
sizes="100vw"
|
|
style={{ objectFit: "cover", objectPosition: "center 20%" }}
|
|
/>
|
|
<div
|
|
className="absolute inset-0"
|
|
style={{
|
|
background:
|
|
"linear-gradient(135deg, color-mix(in srgb, var(--color-surface) 92%, transparent) 0%, color-mix(in srgb, var(--color-surface) 45%, transparent) 50%, color-mix(in srgb, var(--color-surface) 12%, transparent) 100%)",
|
|
}}
|
|
/>
|
|
<AmbientOrbs />
|
|
<div
|
|
className="relative flex flex-col items-center gap-8 px-6 py-12 md:flex-row md:px-12 md:py-16"
|
|
style={{ zIndex: 2 }}
|
|
>
|
|
<div className="flex-1 text-center md:text-left">
|
|
<div
|
|
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-widest mb-5 border backdrop-blur-sm"
|
|
style={{
|
|
background:
|
|
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
|
color: "var(--color-primary-readable, var(--color-primary))",
|
|
}}
|
|
>
|
|
<span className="relative flex h-2 w-2">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500" />
|
|
</span>
|
|
{th("online", { count: online, hotel: hotelName })}
|
|
</div>
|
|
<h1
|
|
className="text-4xl font-black leading-[1.05] tracking-tight mb-3 md:text-5xl lg:text-6xl"
|
|
style={{
|
|
color: "var(--color-text-readable)",
|
|
fontFamily: "var(--font-nunito)",
|
|
}}
|
|
>
|
|
<TypewriterText text={tp("welcome", { hotelName })} />
|
|
</h1>
|
|
<p
|
|
className="text-base leading-relaxed max-w-xl mb-7 md:text-lg"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{th("tagline")}
|
|
</p>
|
|
<div className="flex flex-wrap gap-3 justify-center md:justify-start">
|
|
<Link
|
|
href="/register"
|
|
className="inline-flex items-center gap-2.5 font-extrabold text-sm px-7 py-3.5 rounded-xl transition-all duration-200 hover:scale-[1.03] active:scale-95 shadow-lg"
|
|
style={{
|
|
background: "var(--color-primary)",
|
|
color: "var(--color-primary-foreground)",
|
|
boxShadow:
|
|
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, 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 font-bold text-sm px-7 py-3.5 rounded-xl border-2 transition-all duration-200 hover:scale-[1.03] active:scale-95 backdrop-blur-sm"
|
|
style={{
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-text-muted) 20%, transparent)",
|
|
color: "var(--color-text-readable)",
|
|
background:
|
|
"color-mix(in srgb, var(--color-surface) 50%, transparent)",
|
|
}}
|
|
>
|
|
{th("login")}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
<div className="shrink-0 relative animate-float">
|
|
<div
|
|
className="absolute -bottom-4 -left-4 w-48 h-48 rounded-full opacity-[0.15] blur-[60px]"
|
|
style={{ background: "var(--color-primary)" }}
|
|
/>
|
|
<Image
|
|
src="/assets/images/FrankwithBag.gif"
|
|
alt="Frank"
|
|
width={170}
|
|
height={230}
|
|
className="object-contain drop-shadow-2xl relative"
|
|
unoptimized
|
|
priority
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Reveal>
|
|
|
|
{/* ── Stats ── */}
|
|
<Reveal>
|
|
<div className="grid grid-cols-3 gap-4 stagger-children">
|
|
{[
|
|
{
|
|
value: online,
|
|
label: tp("statsOnline"),
|
|
icon: "/assets/images/icons/online-friends.png",
|
|
},
|
|
{
|
|
value: users,
|
|
label: tp("statsCitizens"),
|
|
icon: "/assets/images/icons/friends.png",
|
|
},
|
|
{
|
|
value: rooms,
|
|
label: tp("statsRooms"),
|
|
icon: "/assets/images/icons/catalog.png",
|
|
},
|
|
].map((s) => (
|
|
<SurfaceCard
|
|
key={s.label}
|
|
className="relative p-5 text-center transition-all duration-300 hover:-translate-y-1 hover:shadow-xl"
|
|
>
|
|
<div
|
|
className="absolute -top-6 -right-6 w-20 h-20 rounded-full opacity-[0.06]"
|
|
style={{ background: "var(--color-primary)" }}
|
|
/>
|
|
<div className="relative">
|
|
<div
|
|
className="mx-auto mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl"
|
|
style={{
|
|
background:
|
|
"color-mix(in srgb, var(--color-primary) 14%, transparent)",
|
|
}}
|
|
>
|
|
<Image
|
|
src={s.icon}
|
|
alt=""
|
|
width={24}
|
|
height={24}
|
|
unoptimized
|
|
/>
|
|
</div>
|
|
<div
|
|
className="mb-0.5 text-3xl font-black tracking-tight md:text-4xl"
|
|
style={{ color: "var(--color-primary)" }}
|
|
>
|
|
<AnimatedCounter value={s.value} />
|
|
</div>
|
|
<div
|
|
className="text-xs font-bold uppercase tracking-widest"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{s.label}
|
|
</div>
|
|
</div>
|
|
</SurfaceCard>
|
|
))}
|
|
</div>
|
|
</Reveal>
|
|
|
|
{/* ── Login + News ── */}
|
|
<div className="flex flex-col gap-4 md:flex-row">
|
|
{/* Left: Login & Register */}
|
|
<Reveal className="flex w-full flex-col gap-4 md:w-1/3 md:flex-shrink-0">
|
|
<SurfaceCard
|
|
title={th("login")}
|
|
icon="/assets/images/icons/navigation/me.png"
|
|
bodyClassName="p-4"
|
|
>
|
|
<HomeLoginForm
|
|
captcha={{
|
|
provider: captcha.provider,
|
|
siteKey: captcha.siteKey || undefined,
|
|
field: captcha.field || undefined,
|
|
}}
|
|
nonce={nonce}
|
|
/>
|
|
</SurfaceCard>
|
|
|
|
<Link href="/register" className="relative block group">
|
|
<SurfaceCard className="transition-all duration-200 group-hover:shadow-lg">
|
|
<Image
|
|
src="/assets/images/EnterHubbly.png"
|
|
alt="Register"
|
|
width={300}
|
|
height={100}
|
|
className="block w-full"
|
|
unoptimized
|
|
/>
|
|
</SurfaceCard>
|
|
<span
|
|
className="pointer-events-none absolute inset-0 flex items-center text-2xl font-extrabold text-white md:text-3xl"
|
|
style={{
|
|
marginLeft: "18px",
|
|
fontFamily: "var(--font-nunito)",
|
|
textShadow: "0 2px 8px rgba(0,0,0,0.6)",
|
|
}}
|
|
>
|
|
{tp("register")}
|
|
</span>
|
|
</Link>
|
|
|
|
{latestUsers.length > 0 && (
|
|
<SurfaceCard
|
|
title={tp("latestUsers", { count: users.toLocaleString() })}
|
|
icon="/assets/images/icons/navigation/goody.png"
|
|
bodyClassName="p-4"
|
|
>
|
|
<div className="grid grid-cols-4 gap-2">
|
|
{latestUsers.map((u) => (
|
|
<div
|
|
key={u.username}
|
|
className="flex flex-col items-center gap-1"
|
|
>
|
|
<UserAvatarThumbnail
|
|
figure={u.look}
|
|
alt={u.username}
|
|
options={{ direction: 3 }}
|
|
/>
|
|
<span
|
|
className="max-w-[64px] truncate text-center text-[11px] font-bold leading-tight"
|
|
style={{ color: "var(--color-text-readable)" }}
|
|
>
|
|
{u.username}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SurfaceCard>
|
|
)}
|
|
</Reveal>
|
|
|
|
{/* Right: News */}
|
|
<Reveal delay={0.15} className="flex w-full flex-col gap-4 md:w-2/3">
|
|
<SurfaceCard
|
|
title={tp("latestNews")}
|
|
icon="/assets/images/icons/article.gif"
|
|
actionHref="/news"
|
|
actionLabel={tp("allNews")}
|
|
>
|
|
{articles.length > 0 ? (
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
{articles.slice(0, 4).map((a) => (
|
|
<Link
|
|
key={a.slug}
|
|
href={`/news/${a.slug}`}
|
|
className="group relative w-full overflow-hidden rounded-xl transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg"
|
|
style={{
|
|
height: "200px",
|
|
backgroundColor: "var(--color-background)",
|
|
border:
|
|
"1px solid color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
|
}}
|
|
>
|
|
<div className="relative h-full w-full overflow-hidden rounded-xl">
|
|
<Image
|
|
src={a.image}
|
|
alt={a.title}
|
|
fill
|
|
sizes="(max-width: 768px) 100vw, 400px"
|
|
className="rounded-xl object-cover transition-all duration-300 group-hover:scale-105"
|
|
/>
|
|
<div className="absolute right-2.5 top-2.5 flex h-8 w-8 translate-x-1 items-center justify-center rounded-full bg-black/50 opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100">
|
|
<span className="text-sm font-bold text-white">→</span>
|
|
</div>
|
|
<div
|
|
className="absolute bottom-0 left-0 w-full p-2.5"
|
|
style={{
|
|
background:
|
|
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, transparent 100%)",
|
|
}}
|
|
>
|
|
<h3 className="truncate text-base font-bold text-white text-shadow-sm">
|
|
{a.title}
|
|
</h3>
|
|
<p
|
|
className="mt-0.5 text-[11px] font-semibold opacity-80"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{formatDate(a.createdAt, "date", "")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p
|
|
className="py-8 text-center text-sm"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{tp("noArticles")}
|
|
</p>
|
|
)}
|
|
</SurfaceCard>
|
|
</Reveal>
|
|
</div>
|
|
|
|
{/* ── Online users ── */}
|
|
{recentUsers.length > 0 && (
|
|
<Reveal>
|
|
<SurfaceCard
|
|
title={tp("recentUsers")}
|
|
icon="/assets/images/icons/navigation/goody.png"
|
|
actionHref="/community"
|
|
actionLabel={tp("allUsers")}
|
|
>
|
|
<div className="flex flex-wrap justify-center gap-2">
|
|
{recentUsers.slice(0, 12).map((u) => (
|
|
<div
|
|
key={u.username}
|
|
className="flex flex-col items-center gap-0.5 rounded-lg px-2 py-1.5 transition-all duration-200 hover:scale-110 hover:shadow-md"
|
|
style={{
|
|
background:
|
|
"color-mix(in srgb, var(--color-primary) 5%, transparent)",
|
|
}}
|
|
>
|
|
<UserAvatarThumbnail
|
|
figure={u.look}
|
|
alt={u.username}
|
|
options={{ direction: 2 }}
|
|
className="rounded-md"
|
|
/>
|
|
<span
|
|
className="max-w-[50px] truncate text-center text-[9px] font-bold leading-tight"
|
|
style={{ color: "var(--color-text-readable)" }}
|
|
>
|
|
{u.username}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SurfaceCard>
|
|
</Reveal>
|
|
)}
|
|
|
|
{/* ── Bottom CTA ── */}
|
|
<Reveal>
|
|
<div
|
|
className="relative overflow-hidden rounded-3xl border px-6 py-12 text-center"
|
|
style={{
|
|
background: `linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 8%, var(--color-surface)), color-mix(in srgb, var(--color-surface) 70%, transparent))`,
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-primary) 15%, transparent)",
|
|
}}
|
|
>
|
|
<h2
|
|
className="mb-2 text-2xl font-black tracking-tight md:text-3xl"
|
|
style={{
|
|
color: "var(--color-text-readable)",
|
|
fontFamily: "var(--font-nunito)",
|
|
}}
|
|
>
|
|
{tp("joinNow", { hotelName })}
|
|
</h2>
|
|
<p
|
|
className="mb-6 max-w-sm mx-auto text-sm"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{tp("welcomeBody")}
|
|
</p>
|
|
<Link
|
|
href="/register"
|
|
className="inline-flex items-center gap-2.5 font-extrabold text-sm px-8 py-3.5 rounded-xl transition-all duration-200 hover:scale-[1.03] active:scale-95 shadow-lg"
|
|
style={{
|
|
background: "var(--color-primary)",
|
|
color: "var(--color-primary-foreground)",
|
|
boxShadow:
|
|
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
|
}}
|
|
>
|
|
<Image
|
|
src="/assets/images/EnterHubbly.png"
|
|
alt=""
|
|
width={20}
|
|
height={20}
|
|
unoptimized
|
|
/>
|
|
{tp("register")}
|
|
</Link>
|
|
</div>
|
|
</Reveal>
|
|
|
|
{/* ── Recent photos ── */}
|
|
{recentPhotos.length > 0 && (
|
|
<Reveal>
|
|
<SurfaceCard
|
|
title={tp("recentPhotos")}
|
|
icon="/assets/images/icons/camera.png"
|
|
>
|
|
<div className="mx-auto grid max-w-sm grid-cols-4 gap-2">
|
|
{recentPhotos.slice(0, 4).map((p) => (
|
|
<Link
|
|
key={p.id}
|
|
href="/photos"
|
|
className="block aspect-square overflow-hidden rounded-xl border transition-all duration-200 hover:shadow-md"
|
|
style={{
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
|
}}
|
|
>
|
|
<Image
|
|
src={p.url}
|
|
alt=""
|
|
width={200}
|
|
height={200}
|
|
className="h-full w-full object-cover transition-transform duration-300 hover:scale-105"
|
|
unoptimized
|
|
/>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</SurfaceCard>
|
|
</Reveal>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|