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
+453 -252

No files matched your search

+446 -251
View File
@@ -34,7 +34,6 @@ import { HomeLoginForm } from "@/components/auth/home-login-form";
import { Clock } from "@/components/clock"; import { Clock } from "@/components/clock";
import { LanguageSwitcher } from "@/components/language-switcher"; import { LanguageSwitcher } from "@/components/language-switcher";
import Link from "@/components/link"; import Link from "@/components/link";
import { LiveOnlineCount } from "@/components/live-online-count";
import { LiveOnlineCounter } from "@/components/live-online-counter"; import { LiveOnlineCounter } from "@/components/live-online-counter";
import { Reveal } from "@/components/motion-reveal"; import { Reveal } from "@/components/motion-reveal";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
@@ -56,51 +55,65 @@ async function getHotelData() {
siteSettings.get("cms_logo", ""), siteSettings.get("cms_logo", ""),
]); ]);
const [online, users, rooms, articles, recentUsers, recentPhotos] = const [
await Promise.all([ online,
cached("online_count", 10_000, () => users,
db rooms,
.select({ total: count() }) totalPhotos,
.from(User) articles,
.where(eq(User.online, "1")) recentUsers,
.then((rows) => rows[0]?.total ?? 0), recentPhotos,
).catch(publicReadFailure("home.online")), ] = await Promise.all([
cached("total_users", 300_000, () => cached("online_count", 10_000, () =>
db db
.select({ total: count() }) .select({ total: count() })
.from(User) .from(User)
.then((rows) => rows[0]?.total ?? 0), .where(eq(User.online, "1"))
).catch(publicReadFailure("home.users")), .then((rows) => rows[0]?.total ?? 0),
cached("total_rooms", 300_000, () => ).catch(publicReadFailure("home.online")),
db cached("total_users", 300_000, () =>
.select({ total: count() }) db
.from(Rooms) .select({ total: count() })
.then((rows) => rows[0]?.total ?? 0), .from(User)
).catch(publicReadFailure("home.rooms")), .then((rows) => rows[0]?.total ?? 0),
getNewsList(4, { throwOnError: true }).catch( ).catch(publicReadFailure("home.users")),
publicReadFailure("home.news"), cached("total_rooms", 300_000, () =>
), db
cached("home_online_users", 15_000, () => .select({ total: count() })
db .from(Rooms)
.select({ username: User.username, look: User.look }) .then((rows) => rows[0]?.total ?? 0),
.from(User) ).catch(publicReadFailure("home.rooms")),
.where(eq(User.online, "1")) cached("total_photos", 300_000, () =>
.limit(12), db
).catch(publicReadFailure("home.recent-users")), .select({ total: count() })
cached("home_recent_photos", 60_000, () => .from(CameraWeb)
db .then((rows) => rows[0]?.total ?? 0),
.select({ id: CameraWeb.id, url: CameraWeb.url }) ).catch(publicReadFailure("home.photos-count")),
.from(CameraWeb) getNewsList(4, { throwOnError: true }).catch(
.orderBy(desc(CameraWeb.timestamp)) publicReadFailure("home.news"),
.limit(4), ),
).catch(publicReadFailure("home.photos")), 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 { return {
hotelName, hotelName,
online, online,
users, users,
rooms, rooms,
totalPhotos,
articles, articles,
recentUsers, recentUsers,
recentPhotos, recentPhotos,
@@ -115,11 +128,13 @@ export default async function Home() {
const tp = await getTranslations("pages.home"); const tp = await getTranslations("pages.home");
const te = await getTranslations("publicData"); const te = await getTranslations("publicData");
const th = await getTranslations("header"); const th = await getTranslations("header");
const tn = await getTranslations("nav");
const { const {
hotelName, hotelName,
online, online,
users, users,
rooms, rooms,
totalPhotos,
articles, articles,
recentUsers, recentUsers,
recentPhotos, 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 ( 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"> <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 ── */} {/* ── 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%)", "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 /> <AmbientOrbs />
<div className="hero-ring" aria-hidden="true" style={{ zIndex: 3 }} /> <div className="hero-ring" aria-hidden="true" style={{ zIndex: 3 }} />
{/* Floating mascot — decorative, hidden on smaller screens. */} {/* Floating mascot — decorative, hidden on smaller screens. */}
@@ -267,87 +355,198 @@ export default async function Home() {
/> />
</div> </div>
<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 }} style={{ zIndex: 2 }}
> >
<div <div className="relative flex flex-col items-center gap-5 sm:gap-7 lg:items-start lg:text-left">
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" <div
style={{ zIndex: -1 }} 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" <h1
style={{ className="text-2xl font-black leading-[1.05] tracking-tight sm:text-3xl md:text-5xl lg:text-6xl"
background: style={{
"color-mix(in srgb, var(--color-primary) 12%, transparent)", fontFamily: "var(--font-nunito)",
borderColor: textShadow:
"color-mix(in srgb, var(--color-primary) 20%, transparent)", "0 1px 0 color-mix(in srgb, #ffffff 35%, transparent), 0 2px 14px color-mix(in srgb, #000 28%, transparent)",
color: "var(--color-primary-readable, var(--color-primary))", }}
}} >
> <span style={{ color: "var(--color-text-readable)" }}>
<span className="relative flex h-2 w-2"> {tp("welcome", { hotelName: "" })}
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" /> </span>{" "}
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary" /> <span className="gradient-text">
</span> <TypewriterText text={hotelName} />
{online === null ? ( </span>
<span>{te("unavailable")}</span> </h1>
) : ( <p
<LiveOnlineCount initial={online} hotelName={hotelName} /> 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 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"
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>
<h1
className="text-2xl font-black leading-[1.05] tracking-tight mb-2 sm:text-3xl md:text-5xl lg:text-6xl" {/* ── Live status panel ── */}
style={{ <div className="relative mx-auto w-full max-w-md lg:mx-0 lg:justify-self-end">
fontFamily: "var(--font-nunito)", <div
textShadow: className="card-glow card-hairline relative overflow-hidden rounded-2xl border p-5 text-left sm:p-6"
"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={{ 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: background:
"color-mix(in srgb, var(--color-surface) 50%, transparent)", "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)",
}} }}
> >
{th("login")} <div
</Link> 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> </div>
</div> </div>
@@ -469,10 +668,10 @@ export default async function Home() {
live: false, live: false,
}, },
{ {
value: online, value: totalPhotos,
label: tp("statsOnline"), label: tp("statsPhotos"),
icon: ICON_NAV_ME, icon: ICON_CAMERA,
live: true, live: false,
}, },
{ {
value: articles?.length ?? null, value: articles?.length ?? null,
@@ -548,149 +747,145 @@ export default async function Home() {
</div> </div>
</Reveal> </Reveal>
{/* ── Login + Online users ── */} {/* ── Quick explore ── */}
<div> <Reveal>
<span className="eyebrow">{tp("sectionCommunityLabel")}</span> <nav
<h2 className="section-title mt-2 text-xl sm:text-2xl"> aria-label={tp("exploreLabel")}
{tp("sectionCommunityTitle")} className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-6"
</h2> >
</div> {[
<div className="flex flex-col gap-4 md:flex-row"> { href: "/community", label: tn("community") },
<Reveal className="flex w-full flex-col gap-4 md:w-1/3 md:flex-shrink-0"> { href: "/news", label: tn("articles") },
<SurfaceCard { href: "/photos", label: tn("photos") },
title={th("login")} { href: "/radio", label: tn("radio") },
icon={ICON_NAV_ME} { href: "/shop", label: tn("shop") },
bodyClassName="p-3.5 sm:p-4" { href: "/rankings", label: tn("rankings") },
> ].map((l) => (
<HomeLoginForm <Link
captcha={{ key={l.href}
provider: captcha.provider, href={l.href}
siteKey: captcha.siteKey || undefined, 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"
field: captcha.field || undefined, 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)",
}} }}
nonce={nonce} >
/> {l.label}
</SurfaceCard> <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>
{recentUsers === null ? ( {/* ── Login + Online users ── */}
<SurfaceCard title={tp("recentUsers")} bodyClassName="p-4"> <Reveal>
<PublicLoadError href="/" /> <div className="flex flex-col gap-4">
</SurfaceCard> <div>
) : ( <span className="eyebrow">{tp("sectionCommunityLabel")}</span>
recentUsers.length > 0 && ( <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 <SurfaceCard
title={tp("recentUsers")} title={th("login")}
icon={ICON_NAV_GOODY} icon={ICON_NAV_ME}
actionHref="/community"
actionLabel={tp("allUsers")}
bodyClassName="p-3.5 sm:p-4" bodyClassName="p-3.5 sm:p-4"
> >
<div className="grid grid-cols-4 gap-1.5 sm:gap-2"> <HomeLoginForm
{recentUsers.map((u) => ( captcha={{
<div provider: captcha.provider,
key={u.username} siteKey: captcha.siteKey || undefined,
title={u.username} field: captcha.field || undefined,
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)_5%,transparent)] hover:ring-1 hover:ring-[color-mix(in_srgb,var(--color-primary)_35%,transparent)]" }}
> nonce={nonce}
<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> </SurfaceCard>
)
)}
</Reveal>
{/* News */} {recentUsers === null ? (
<Reveal delay={0.15} className="flex w-full flex-col gap-4 md:w-2/3"> <SurfaceCard title={tp("recentUsers")} bodyClassName="p-4">
<SurfaceCard <PublicLoadError href="/" />
title={tp("latestNews")} </SurfaceCard>
icon={ICON_ARTICLE} ) : (
actionHref="/news" recentUsers.length > 0 && (
actionLabel={tp("allNews")} <SurfaceCard
> title={tp("recentUsers")}
{articles === null ? ( icon={ICON_NAV_GOODY}
<PublicLoadError href="/" /> actionHref="/community"
) : articles.length > 0 ? ( actionLabel={tp("allUsers")}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4"> bodyClassName="p-3.5 sm:p-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"> <div className="grid grid-cols-4 gap-1.5 sm:gap-2">
<Image {recentUsers.map((u) => (
src={a.image} <div
alt={a.title} key={u.username}
fill title={u.username}
sizes="(max-width: 640px) 100vw, 400px" 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)_5%,transparent)] hover:ring-1 hover:ring-[color-mix(in_srgb,var(--color-primary)_35%,transparent)]"
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-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", "")} <UserAvatarThumbnail
</p> figure={u.look}
</div> 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> </div>
</Link> </SurfaceCard>
))} )
</div> )}
) : ( </Reveal>
<p
className="py-8 text-center text-sm" {/* News */}
style={{ color: "var(--color-text-muted)" }} <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")}
> >
{tp("noArticles")} {articles === null ? (
</p> <PublicLoadError href="/" />
)} ) : articles.length > 0 ? (
</SurfaceCard> <div className="flex flex-col gap-3 sm:gap-4">
</Reveal> {renderArticleCard(articles[0], true)}
</div> <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>
)}
</SurfaceCard>
</Reveal>
</div>
</div>
</Reveal>
{/* ── Recent photos ── */} {/* ── Recent photos ── */}
{recentPhotos === null ? ( {recentPhotos === null ? (
@@ -701,7 +896,7 @@ export default async function Home() {
recentPhotos.length > 0 && ( recentPhotos.length > 0 && (
<Reveal> <Reveal>
<SurfaceCard title={tp("recentPhotos")} icon={ICON_CAMERA}> <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) => ( {recentPhotos.slice(0, 4).map((p) => (
<Link <Link
key={p.id} key={p.id}
@@ -10,7 +10,7 @@ const compactCallSites = new Map<string, number>([
["src/app/(site)/login/page.tsx", 2], ["src/app/(site)/login/page.tsx", 2],
["src/app/(site)/me/page.tsx", 1], ["src/app/(site)/me/page.tsx", 1],
["src/app/(site)/messages/page.tsx", 2], ["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/leaderboard/page.tsx", 1],
["src/app/(site)/radio/shouts/page.tsx", 1], ["src/app/(site)/radio/shouts/page.tsx", 1],
["src/app/(site)/rankings/page.tsx", 1], ["src/app/(site)/rankings/page.tsx", 1],
+3
View File
@@ -274,7 +274,10 @@
"statsOnline": "Online", "statsOnline": "Online",
"statsCitizens": "Citizens", "statsCitizens": "Citizens",
"statsRooms": "Rooms", "statsRooms": "Rooms",
"statsPhotos": "Photos",
"statsArticles": "Articles", "statsArticles": "Articles",
"onlineNow": "Online now",
"exploreLabel": "Explore the hotel",
"featuresAvatar": "Avatar", "featuresAvatar": "Avatar",
"featuresAvatarDesc": "Create your unique look", "featuresAvatarDesc": "Create your unique look",
"featuresFurniture": "Furniture", "featuresFurniture": "Furniture",
+3
View File
@@ -274,7 +274,10 @@
"statsOnline": "Online", "statsOnline": "Online",
"statsCitizens": "Cittadini", "statsCitizens": "Cittadini",
"statsRooms": "Stanze", "statsRooms": "Stanze",
"statsPhotos": "Foto",
"statsArticles": "Articoli", "statsArticles": "Articoli",
"onlineNow": "Online adesso",
"exploreLabel": "Esplora l'hotel",
"featuresAvatar": "Avatar", "featuresAvatar": "Avatar",
"featuresAvatarDesc": "Crea il tuo look unico", "featuresAvatarDesc": "Crea il tuo look unico",
"featuresFurniture": "Arredi", "featuresFurniture": "Arredi",