Files
EpicNext-Cms/src/app/(site)/page.tsx
T
openhands e5e17d75ae
CI / check (push) Successful in 2m27s
CI / deploy (push) Successful in 1m34s
CI / publish-container (push) Successful in 50s
perf(site): inline small classic icons and favicon as base64
- New src/lib/site-icons.ts: base64 data URIs for the 13 tiny classic
  icons actually referenced in markup (100–2000 bytes), replacing extra
  requests with inline payloads. home.png, dynamic flags and currency
  sets stay on the filesystem.
- Default favicon is now served server-side as a base64 SVG data URI
  (memoized), while a DB-configured custom favicon still takes priority.
- Icons render through <Image unoptimized>, so data URIs pass through
  untouched on all affected pages (home, login, register, settings,
  navigation, auth top bar, client loading).
2026-09-12 17:28:01 +02:00

630 lines
19 KiB
TypeScript

import { count, desc, eq } from "drizzle-orm";
import type { Metadata } from "next";
import { headers } from "next/headers";
import Image from "next/image";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { PublicLoadError } from "@/components/public/load-error";
import { publicReadFailure } from "@/lib/services/public-read";
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",
},
};
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 { LiveOnlineCount } from "@/components/live-online-count";
import { LiveOnlineCounter } from "@/components/live-online-counter";
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] =
await Promise.all([
cached("online_count", 10_000, () =>
db
.select({ total: count() })
.from(User)
.where(eq(User.online, "1"))
.then((rows) => rows[0]?.total ?? 0),
).catch(publicReadFailure("home.online")),
cached("total_users", 300_000, () =>
db
.select({ total: count() })
.from(User)
.then((rows) => rows[0]?.total ?? 0),
).catch(publicReadFailure("home.users")),
cached("total_rooms", 300_000, () =>
db
.select({ total: count() })
.from(Rooms)
.then((rows) => rows[0]?.total ?? 0),
).catch(publicReadFailure("home.rooms")),
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),
).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),
).catch(publicReadFailure("home.photos")),
]);
return {
hotelName,
online,
users,
rooms,
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 {
hotelName,
online,
users,
rooms,
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,
},
];
return (
<div className="mx-auto w-full max-w-6xl flex flex-col gap-8 px-1 sm:px-0 md:gap-10 pb-12 md:pb-16">
{/* ── Top Bar ── */}
<Reveal>
<div
className="relative flex items-center justify-between rounded-xl border px-3 py-2.5 sm:px-5 sm:py-3 card-hairline"
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)",
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: 28, width: "auto", height: "auto" }}
/>
) : (
<Image
src={ICON_LIGHTHOUSE}
alt={hotelName}
width={28}
height={28}
className="shrink-0"
unoptimized
/>
)}
<span
className="hidden sm:inline text-sm font-extrabold uppercase tracking-wide"
style={{ color: "var(--color-text-readable)" }}
>
{hotelName}
</span>
</div>
<div
className="flex items-center gap-1 sm:gap-1.5 rounded-lg px-1.5 py-1 sm:px-2"
style={{
background:
"color-mix(in srgb, var(--color-text-muted) 4%, transparent)",
}}
>
<LanguageSwitcher />
<div
className="hidden sm:block 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-xl border"
style={{
borderColor:
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
boxShadow:
"0 4px 24px rgba(0,0,0,0.08), 0 0 0 0.5px color-mix(in srgb, var(--color-text-muted) 6%, transparent)",
}}
>
<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(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-5 px-4 py-10 text-center sm:gap-8 sm:px-6 sm:py-12 md:px-12 md:py-20"
style={{ zIndex: 2 }}
>
<div
className="brand-halo left-1/2 top-4 h-48 w-48 -translate-x-1/2 sm:top-6 sm:h-64 sm:w-64"
style={{ zIndex: -1 }}
/>
<div
className="inline-flex items-center gap-2 px-3 py-1 sm:px-4 sm:py-1.5 rounded-full text-[10px] sm:text-xs font-bold uppercase tracking-widest mb-1 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-primary opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary" />
</span>
{online === null ? (
<span>{te("unavailable")}</span>
) : (
<LiveOnlineCount initial={online} hotelName={hotelName} />
)}
</div>
<h1
className="text-2xl font-black leading-[1.05] tracking-tight mb-2 sm:text-3xl md:text-5xl lg:text-6xl"
style={{
fontFamily: "var(--font-nunito)",
textShadow:
"0 1px 0 color-mix(in srgb, #ffffff 35%, transparent), 0 2px 14px color-mix(in srgb, #000 28%, transparent)",
}}
>
<span style={{ color: "var(--color-text-readable)" }}>
{tp("welcome", { hotelName: "" })}
</span>{" "}
<span className="gradient-text">
<TypewriterText text={hotelName} />
</span>
</h1>
<p
className="text-sm leading-relaxed max-w-2xl mb-5 sm:text-base md:text-lg md:mb-8"
style={{ color: "var(--color-text-muted)" }}
>
{tp("welcomeBody")}
</p>
<div className="flex flex-wrap gap-2.5 sm:gap-3 justify-center">
<Link
href="/register"
className="btn-shine inline-flex items-center gap-2 sm:gap-2.5 font-extrabold text-xs sm:text-sm px-5 sm:px-8 py-3 sm:py-4 rounded-xl transition-all duration-300 ease-out 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-xs sm:text-sm px-5 sm:px-8 py-3 sm:py-4 rounded-xl border-2 transition-all duration-300 ease-out 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>
</Reveal>
{/* ── Features ── */}
<Reveal>
<div className="flex flex-col gap-4">
<div>
<span className="eyebrow">{tp("sectionFeaturesLabel")}</span>
<h2 className="section-title mt-2 text-xl sm:text-2xl">
{tp("sectionFeaturesTitle")}
</h2>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
{features.map((f) => (
<SurfaceCard
key={f.title}
className="card-glow relative p-4 sm:p-5 text-center hover:-translate-y-1"
>
<div
className="mx-auto mb-3 flex h-11 w-11 sm:h-12 sm:w-12 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) 10%, transparent)",
}}
>
<Image
src={f.icon}
alt=""
width={24}
height={24}
unoptimized
/>
</div>
<h3
className="text-sm sm:text-base font-extrabold mb-1"
style={{
color: "var(--color-text-readable)",
fontFamily: "var(--font-nunito)",
}}
>
{f.title}
</h3>
<p
className="text-xs sm:text-sm leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
{f.desc}
</p>
</SurfaceCard>
))}
</div>
</div>
</Reveal>
{/* ── Stats + Login ── */}
<Reveal>
<div className="flex flex-col gap-4">
<div>
<span className="eyebrow">{tp("sectionStatsLabel")}</span>
<h2 className="section-title mt-2 text-xl sm:text-2xl">
{tp("sectionStatsTitle")}
</h2>
</div>
<div className="grid grid-cols-2 gap-3 sm:gap-4 md:grid-cols-4">
{[
{
value: users,
label: tp("statsCitizens"),
icon: ICON_FRIENDS,
live: false,
},
{
value: rooms,
label: tp("statsRooms"),
icon: ICON_CATALOG,
live: false,
},
{
value: online,
label: tp("statsOnline"),
icon: ICON_NAV_ME,
live: true,
},
{
value: articles?.length ?? null,
label: tp("statsArticles"),
icon: ICON_ARTICLE,
live: false,
},
].map((s) => (
<SurfaceCard
key={s.label}
className="card-glow relative p-3 sm:p-5 text-center hover:-translate-y-1"
>
<div className="mb-0.5 text-2xl sm:text-3xl font-black tracking-tight md:text-4xl">
{s.value === null ? (
<span className="text-base">{te("unavailable")}</span>
) : s.live ? (
<LiveOnlineCounter initial={s.value} />
) : (
<AnimatedCounter value={s.value} />
)}
</div>
<div
className="text-[10px] sm:text-xs font-bold uppercase tracking-widest flex items-center justify-center gap-1 sm:gap-1.5"
style={{ color: "var(--color-text-muted)" }}
>
<Image
src={s.icon}
alt=""
width={14}
height={14}
unoptimized
/>
{s.label}
</div>
</SurfaceCard>
))}
</div>
</div>
</Reveal>
{/* ── Login + Online users ── */}
<div>
<span className="eyebrow">{tp("sectionCommunityLabel")}</span>
<h2 className="section-title mt-2 text-xl sm:text-2xl">
{tp("sectionCommunityTitle")}
</h2>
</div>
<div className="flex flex-col gap-4 md:flex-row">
<Reveal className="flex w-full flex-col gap-4 md:w-1/3 md:flex-shrink-0">
<SurfaceCard
title={th("login")}
icon={ICON_NAV_ME}
bodyClassName="p-3.5 sm:p-4"
>
<HomeLoginForm
captcha={{
provider: captcha.provider,
siteKey: captcha.siteKey || undefined,
field: captcha.field || undefined,
}}
nonce={nonce}
/>
</SurfaceCard>
{recentUsers === null ? (
<SurfaceCard title={tp("recentUsers")} bodyClassName="p-4">
<PublicLoadError href="/" />
</SurfaceCard>
) : (
recentUsers.length > 0 && (
<SurfaceCard
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}
className="flex flex-col items-center gap-0.5 rounded-lg px-1 py-1.5 transition-colors hover:bg-[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-[64px] truncate text-center text-[9px] sm:text-[10px] font-bold leading-tight"
style={{ color: "var(--color-text-readable)" }}
>
{u.username}
</span>
</div>
))}
</div>
</SurfaceCard>
)
)}
</Reveal>
{/* News */}
<Reveal delay={0.15} className="flex w-full flex-col gap-4 md:w-2/3">
<SurfaceCard
title={tp("latestNews")}
icon={ICON_ARTICLE}
actionHref="/news"
actionLabel={tp("allNews")}
>
{articles === null ? (
<PublicLoadError href="/" />
) : articles.length > 0 ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
{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: "clamp(160px, 20vw, 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: 640px) 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-foreground/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-primary-foreground">
→
</span>
</div>
<div
className="absolute bottom-0 left-0 w-full p-2.5 sm:p-3"
style={{
background:
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 50%, transparent 100%)",
}}
>
<h3 className="truncate text-sm sm:text-base font-bold text-primary-foreground text-shadow-sm">
{a.title}
</h3>
<p
className="mt-0.5 text-[10px] sm: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>
{/* ── Recent photos ── */}
{recentPhotos === null ? (
<SurfaceCard title={tp("recentPhotos")} bodyClassName="p-4">
<PublicLoadError href="/" />
</SurfaceCard>
) : (
recentPhotos.length > 0 && (
<Reveal>
<SurfaceCard title={tp("recentPhotos")} icon={ICON_CAMERA}>
<div className="mx-auto grid max-w-md 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 hover:scale-[1.02]"
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"
unoptimized
/>
</Link>
))}
</div>
</SurfaceCard>
</Reveal>
)
)}
</div>
);
}