feat(home): two-column hero with live status panel, explore nav, featured news
CI / check (push) Successful in 4m8s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m0s

This commit is contained in:
openhands committed 2026-09-18 13:27:08 +02:00
1 parent d0db352f36
commit 469f69ddd7
4 files changed
+291 -90

No files matched your search

+284 -89
View File
@@ -34,7 +34,6 @@ 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";
@@ -56,8 +55,15 @@ async function getHotelData() {
siteSettings.get("cms_logo", ""),
]);
const [online, users, rooms, articles, recentUsers, recentPhotos] =
await Promise.all([
const [
online,
users,
rooms,
totalPhotos,
articles,
recentUsers,
recentPhotos,
] = await Promise.all([
cached("online_count", 10_000, () =>
db
.select({ total: count() })
@@ -77,6 +83,12 @@ async function getHotelData() {
.from(Rooms)
.then((rows) => rows[0]?.total ?? 0),
).catch(publicReadFailure("home.rooms")),
cached("total_photos", 300_000, () =>
db
.select({ total: count() })
.from(CameraWeb)
.then((rows) => rows[0]?.total ?? 0),
).catch(publicReadFailure("home.photos-count")),
getNewsList(4, { throwOnError: true }).catch(
publicReadFailure("home.news"),
),
@@ -101,6 +113,7 @@ async function getHotelData() {
online,
users,
rooms,
totalPhotos,
articles,
recentUsers,
recentPhotos,
@@ -115,11 +128,13 @@ export default async function Home() {
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,
@@ -147,6 +162,72 @@ export default async function Home() {
},
];
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-xl transition-all duration-500 hover:-translate-y-1 hover:shadow-xl"
style={{
height: featured
? "clamp(220px, 26vw, 280px)"
: "clamp(170px, 18vw, 210px)",
backgroundColor: "var(--color-background)",
border:
"1px solid color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
}}
>
<div className="relative h-full w-full overflow-hidden rounded-xl">
<Image
src={a.image}
alt={a.title}
fill
sizes={featured ? "100vw" : "(max-width: 640px) 100vw, 33vw"}
className="rounded-xl 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 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 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-primary-foreground text-shadow-sm ${
featured ? "text-base sm:text-2xl" : "text-sm sm:text-base"
}`}
>
{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>
);
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 ── */}
@@ -247,6 +328,13 @@ export default async function Home() {
"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%)",
}}
/>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(to top, color-mix(in srgb, var(--color-surface) 40%, transparent) 0%, transparent 55%)",
}}
/>
<AmbientOrbs />
<div className="hero-ring" aria-hidden="true" style={{ zIndex: 3 }} />
{/* Floating mascot — decorative, hidden on smaller screens. */}
@@ -267,35 +355,16 @@ export default async function Home() {
/>
</div>
<div
className="relative flex flex-col items-center gap-5 px-4 py-12 text-center sm:gap-8 sm:px-6 sm:py-14 md:px-12 md:py-24"
className="relative mx-auto grid w-full max-w-6xl grid-cols-1 items-center gap-8 px-4 py-12 text-center sm:px-6 sm:py-14 md:px-12 md:py-20 lg:grid-cols-[1.15fr_0.85fr] lg:gap-10 lg:text-left"
style={{ zIndex: 2 }}
>
<div className="relative flex flex-col items-center gap-5 sm:gap-7 lg:items-start lg:text-left">
<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"
className="brand-halo left-1/2 top-6 h-48 w-48 -translate-x-1/2 sm:h-64 sm:w-64 lg:left-0 lg:translate-x-0"
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"
className="text-2xl font-black leading-[1.05] tracking-tight sm:text-3xl md:text-5xl lg:text-6xl"
style={{
fontFamily: "var(--font-nunito)",
textShadow:
@@ -310,12 +379,12 @@ export default async function Home() {
</span>
</h1>
<p
className="text-sm leading-relaxed max-w-2xl mb-5 sm:text-base md:text-lg md:mb-8"
className="text-sm leading-relaxed max-w-2xl sm:text-base md:text-lg"
style={{ color: "var(--color-text-muted)" }}
>
{tp("welcomeBody")}
</p>
<div className="flex flex-wrap gap-2.5 sm:gap-3 justify-center">
<div className="flex flex-wrap justify-center gap-2.5 sm:gap-3 lg:justify-start">
<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"
@@ -350,6 +419,136 @@ export default async function Home() {
</Link>
</div>
</div>
{/* ── Live status panel ── */}
<div className="relative mx-auto w-full max-w-md lg:mx-0 lg:justify-self-end">
<div
className="card-glow card-hairline relative overflow-hidden rounded-2xl border p-5 text-left sm:p-6"
style={{
background:
"color-mix(in srgb, var(--color-surface) 78%, transparent)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
borderColor:
"color-mix(in srgb, var(--color-primary) 18%, transparent)",
}}
>
<div
aria-hidden="true"
className="brand-halo left-1/2 top-0 h-40 w-72 max-w-full -translate-x-1/2 -translate-y-1/2"
/>
<div className="relative flex flex-wrap items-center justify-between gap-2">
<span
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest border"
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-1.5 w-1.5">
<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-1.5 w-1.5 bg-primary" />
</span>
{tp("onlineNow")}
</span>
<span
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest"
style={{
background:
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
color: "var(--color-text-muted)",
}}
>
{rooms === null ? (
<span>{te("unavailable")}</span>
) : (
<AnimatedCounter value={rooms} />
)}
{tp("statsRooms")}
</span>
</div>
<div className="relative mt-4">
<div className="gradient-text text-5xl font-black tracking-tight leading-none sm:text-6xl">
{online === null ? (
<span
className="text-xl font-bold underline decoration-transparent"
style={{
color: "var(--color-text-muted)",
}}
>
{te("unavailable")}
</span>
) : (
<LiveOnlineCounter initial={online} />
)}
</div>
<p
className="mt-2 text-[11px] font-bold uppercase tracking-widest"
style={{ color: "var(--color-text-muted)" }}
>
{hotelName}
</p>
</div>
<div className="relative mt-5">
{recentUsers === null ? (
<span
className="text-xs"
style={{ color: "var(--color-text-muted)" }}
>
{te("unavailable")}
</span>
) : recentUsers.length > 0 ? (
<div className="flex -space-x-2">
{recentUsers.slice(0, 6).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-[color-mix(in_srgb,var(--color-surface)_90%,transparent)]"
/>
))}
</div>
) : (
<span
className="text-xs"
style={{ color: "var(--color-text-muted)" }}
>
{tp("noUsers")}
</span>
)}
</div>
<div
className="relative mt-5 flex items-center justify-between gap-3 border-t pt-4"
style={{
borderTopColor:
"color-mix(in srgb, var(--color-primary) 14%, transparent)",
}}
>
<span
className="text-[10px] font-bold uppercase tracking-widest"
style={{ color: "var(--color-text-muted)" }}
>
{tp("liveUpdates")}
</span>
<Link
href="/community"
className="inline-flex items-center gap-1 text-xs font-extrabold transition-transform duration-300 hover:translate-x-0.5"
style={{
color:
"var(--color-primary-readable, var(--color-primary))",
}}
>
{tp("allUsers")} →
</Link>
</div>
</div>
</div>
</div>
</div>
</Reveal>
@@ -469,10 +668,10 @@ export default async function Home() {
live: false,
},
{
value: online,
label: tp("statsOnline"),
icon: ICON_NAV_ME,
live: true,
value: totalPhotos,
label: tp("statsPhotos"),
icon: ICON_CAMERA,
live: false,
},
{
value: articles?.length ?? null,
@@ -548,7 +747,49 @@ export default async function Home() {
</div>
</Reveal>
{/* ── Quick explore ── */}
<Reveal>
<nav
aria-label={tp("exploreLabel")}
className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-6"
>
{[
{ 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") },
].map((l) => (
<Link
key={l.href}
href={l.href}
className="card-hairline group inline-flex items-center justify-center gap-1.5 rounded-xl border px-3 py-2.5 text-xs font-bold transition-all duration-300 hover:-translate-y-0.5 sm:text-sm"
style={{
background:
"color-mix(in srgb, var(--color-surface) 55%, transparent)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
color: "var(--color-text-readable)",
}}
>
{l.label}
<span
className="text-[10px] transition-transform duration-300 group-hover:translate-x-0.5"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
→
</span>
</Link>
))}
</nav>
</Reveal>
{/* ── Login + Online users ── */}
<Reveal>
<div className="flex flex-col gap-4">
<div>
<span className="eyebrow">{tp("sectionCommunityLabel")}</span>
<h2 className="section-title mt-2 text-xl sm:text-2xl">
@@ -613,7 +854,10 @@ export default async function Home() {
</Reveal>
{/* News */}
<Reveal delay={0.15} className="flex w-full flex-col gap-4 md:w-2/3">
<Reveal
delay={0.15}
className="flex w-full flex-col gap-4 md:w-2/3"
>
<SurfaceCard
title={tp("latestNews")}
icon={ICON_ARTICLE}
@@ -623,62 +867,11 @@ export default async function Home() {
{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-500 hover:-translate-y-1 hover:shadow-xl"
style={{
height: "clamp(180px, 22vw, 220px)",
backgroundColor: "var(--color-background)",
border:
"1px solid color-mix(in srgb, var(--color-text-muted) 12%, 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-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 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 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
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-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="line-clamp-2 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
@@ -691,6 +884,8 @@ export default async function Home() {
</SurfaceCard>
</Reveal>
</div>
</div>
</Reveal>
{/* ── Recent photos ── */}
{recentPhotos === null ? (
@@ -701,7 +896,7 @@ export default async function Home() {
recentPhotos.length > 0 && (
<Reveal>
<SurfaceCard title={tp("recentPhotos")} icon={ICON_CAMERA}>
<div className="mx-auto grid max-w-xl grid-cols-4 gap-3">
<div className="mx-auto grid max-w-xl grid-cols-2 gap-3 sm:grid-cols-4">
{recentPhotos.slice(0, 4).map((p) => (
<Link
key={p.id}
@@ -10,7 +10,7 @@ const compactCallSites = new Map<string, number>([
["src/app/(site)/login/page.tsx", 2],
["src/app/(site)/me/page.tsx", 1],
["src/app/(site)/messages/page.tsx", 2],
["src/app/(site)/page.tsx", 1],
["src/app/(site)/page.tsx", 2],
["src/app/(site)/radio/leaderboard/page.tsx", 1],
["src/app/(site)/radio/shouts/page.tsx", 1],
["src/app/(site)/rankings/page.tsx", 1],
+3
View File
@@ -274,7 +274,10 @@
"statsOnline": "Online",
"statsCitizens": "Citizens",
"statsRooms": "Rooms",
"statsPhotos": "Photos",
"statsArticles": "Articles",
"onlineNow": "Online now",
"exploreLabel": "Explore the hotel",
"featuresAvatar": "Avatar",
"featuresAvatarDesc": "Create your unique look",
"featuresFurniture": "Furniture",
+3
View File
@@ -274,7 +274,10 @@
"statsOnline": "Online",
"statsCitizens": "Cittadini",
"statsRooms": "Stanze",
"statsPhotos": "Foto",
"statsArticles": "Articoli",
"onlineNow": "Online adesso",
"exploreLabel": "Esplora l'hotel",
"featuresAvatar": "Avatar",
"featuresAvatarDesc": "Crea il tuo look unico",
"featuresFurniture": "Arredi",