Add background_effect (aurora/particles), background_overlay tint and opacity to the theme manager, rendered site-wide by ThemeVars on every public page. Polish the home and register pages (hero mascot, live stat pulse, date pills, photo strip, CTA band, theme-aware register intro, i18n for home/register section).
800 lines
25 KiB
TypeScript
800 lines
25 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="hero-ring" aria-hidden="true" style={{ zIndex: 3 }} />
|
|
{/* Floating mascot — decorative, hidden on smaller screens. */}
|
|
<div
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute bottom-3 left-4 hidden lg:block xl:bottom-5 xl:left-8"
|
|
style={{ zIndex: 2 }}
|
|
>
|
|
<div className="brand-halo left-1/2 top-1/2 h-40 w-40 -translate-x-1/2 -translate-y-1/2 xl:h-48 xl:w-48" />
|
|
<Image
|
|
src="/assets/images/FrankwithBag.gif"
|
|
alt=""
|
|
width={104}
|
|
height={138}
|
|
priority
|
|
unoptimized
|
|
className="relative animate-float object-contain drop-shadow-xl"
|
|
/>
|
|
</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"
|
|
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 className="flex flex-wrap items-center justify-center gap-2 sm:gap-2.5">
|
|
{[
|
|
{
|
|
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,
|
|
},
|
|
].map((s) => (
|
|
<div key={s.label} className="glass-chip">
|
|
<Image
|
|
src={s.icon}
|
|
alt=""
|
|
width={12}
|
|
height={12}
|
|
unoptimized
|
|
className="opacity-70"
|
|
/>
|
|
<span
|
|
className="glass-chip-dot"
|
|
style={{
|
|
background: s.live
|
|
? "var(--color-primary)"
|
|
: "color-mix(in srgb, var(--color-text-muted) 55%, transparent)",
|
|
boxShadow: s.live
|
|
? "0 0 10px color-mix(in srgb, var(--color-primary) 70%, transparent)"
|
|
: "none",
|
|
}}
|
|
/>
|
|
<span
|
|
className="glass-chip-value"
|
|
style={{
|
|
color: "var(--color-text-readable)",
|
|
fontSize: "13px",
|
|
}}
|
|
>
|
|
{s.value === null ? (
|
|
<span className="text-xs">{te("unavailable")}</span>
|
|
) : s.live ? (
|
|
<LiveOnlineCounter initial={s.value} />
|
|
) : (
|
|
<AnimatedCounter value={s.value} />
|
|
)}
|
|
</span>
|
|
<span className="glass-chip-label">{s.label}</span>
|
|
</div>
|
|
))}
|
|
</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="group card-glow card-hairline relative p-4 sm:p-5 text-center transition-all duration-300 hover:-translate-y-1.5"
|
|
>
|
|
<div
|
|
className="mx-auto mb-3 flex h-11 w-11 sm:h-12 sm:w-12 items-center justify-center rounded-xl transition-transform duration-300 group-hover:scale-110 group-hover:rotate-3"
|
|
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 card-hairline relative p-3 sm:p-5 text-center transition-all duration-300 ease-out hover:-translate-y-1.5 hover:shadow-xl"
|
|
>
|
|
<div
|
|
className="mb-1 text-2xl sm:text-3xl font-black tracking-tight md:text-4xl"
|
|
style={{
|
|
color: s.live
|
|
? "var(--color-primary)"
|
|
: "var(--color-text-readable)",
|
|
}}
|
|
>
|
|
{s.value === null ? (
|
|
<span className="text-base">{te("unavailable")}</span>
|
|
) : s.live ? (
|
|
<LiveOnlineCounter initial={s.value} />
|
|
) : (
|
|
<AnimatedCounter value={s.value} />
|
|
)}
|
|
</div>
|
|
<div
|
|
className="mt-1.5 inline-flex items-center justify-center gap-1.5 px-2.5 py-0.5 rounded-full text-[10px] sm:text-xs font-bold uppercase tracking-widest"
|
|
style={{
|
|
background:
|
|
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
|
color: s.live
|
|
? "var(--color-primary-readable, var(--color-primary))"
|
|
: "var(--color-text-muted)",
|
|
}}
|
|
>
|
|
{s.live && (
|
|
<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>
|
|
)}
|
|
<Image
|
|
src={s.icon}
|
|
alt=""
|
|
width={13}
|
|
height={13}
|
|
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}
|
|
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)_5%,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>
|
|
</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-500 hover:-translate-y-1 hover:shadow-xl"
|
|
style={{
|
|
height: "clamp(160px, 20vw, 200px)",
|
|
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>
|
|
<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="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-xl grid-cols-4 gap-3">
|
|
{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-xl 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>
|
|
</SurfaceCard>
|
|
</Reveal>
|
|
)
|
|
)}
|
|
|
|
{/* ── Join the fun ── */}
|
|
<Reveal>
|
|
<section
|
|
aria-labelledby="join-cta-title"
|
|
className="relative overflow-hidden rounded-3xl p-6 text-center sm:p-10"
|
|
style={{
|
|
background:
|
|
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 12%, transparent), color-mix(in srgb, var(--color-accent) 12%, transparent))",
|
|
border:
|
|
"1px solid color-mix(in srgb, var(--color-primary) 22%, transparent)",
|
|
}}
|
|
>
|
|
<div
|
|
aria-hidden="true"
|
|
className="brand-halo left-1/2 top-0 h-64 w-[560px] max-w-full -translate-x-1/2 -translate-y-1/3"
|
|
/>
|
|
<h2
|
|
id="join-cta-title"
|
|
className="relative text-2xl font-black tracking-tight text-balance sm:text-3xl"
|
|
style={{ color: "var(--color-text-readable)" }}
|
|
>
|
|
{tp("joinCtaTitle")}
|
|
</h2>
|
|
<p
|
|
className="relative mx-auto mt-2 max-w-xl text-sm sm:text-base"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{tp("joinCtaBody")}
|
|
</p>
|
|
<div className="relative mt-6 flex flex-wrap items-center justify-center gap-3">
|
|
<Link
|
|
href="/register"
|
|
className="btn-shine group inline-flex items-center gap-2 rounded-xl px-6 py-3 text-sm font-bold text-primary-foreground shadow-lg transition-transform duration-300 hover:-translate-y-0.5"
|
|
style={{
|
|
background:
|
|
"linear-gradient(135deg, var(--color-primary), color-mix(in srgb, var(--color-accent) 55%, var(--color-primary)))",
|
|
}}
|
|
>
|
|
{tp("ctaJoin")}
|
|
<span className="transition-transform duration-300 group-hover:translate-x-0.5">
|
|
→
|
|
</span>
|
|
</Link>
|
|
<Link
|
|
href="/community"
|
|
className="inline-flex items-center rounded-xl px-6 py-3 text-sm font-bold transition-all duration-300 hover:-translate-y-0.5"
|
|
style={{
|
|
background:
|
|
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
|
border:
|
|
"1px solid color-mix(in srgb, var(--color-text-muted) 18%, transparent)",
|
|
color: "var(--color-text-readable)",
|
|
}}
|
|
>
|
|
{tp("browseCommunity")}
|
|
</Link>
|
|
</div>
|
|
</section>
|
|
</Reveal>
|
|
</div>
|
|
);
|
|
}
|