Added scripts/check-admin-colors.mjs — scans all src/ files for: - text-white, text-black (use theme text vars) - bg-white, bg-black (use theme background/overlay vars) - bg/text/border/ring with gray/slate/zinc/stone palette - bg/text/border/ring with red/green/blue/etc palette Fixed 21 violations across 11 files: - Overlays: bg-black/* → bg-foreground/* - Text: text-white → text-primary-foreground - Backgrounds: bg-white/10 → bg-background/10 - Green accents: bg-green-* → bg-primary - Red accents: bg-red-* → bg-destructive Integrated into: - lint-staged: runs on every *.ts/*.tsx commit - vitest: src/lib/no-hardcoded-colors.test.ts replaces old audit test - Allowlist: shadcn/ui primitives (button, badge, dialog) + 4 graphical files
530 lines
16 KiB
TypeScript
530 lines
16 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] =
|
|
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(12),
|
|
).catch(() => []),
|
|
cached("home_recent_photos", 60_000, () =>
|
|
db
|
|
.select({ id: CameraWeb.id, url: CameraWeb.url })
|
|
.from(CameraWeb)
|
|
.orderBy(desc(CameraWeb.timestamp))
|
|
.limit(4),
|
|
).catch(() => []),
|
|
]);
|
|
|
|
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 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: "/assets/images/icons/friends.png",
|
|
},
|
|
{
|
|
title: tp("featuresFurniture"),
|
|
desc: tp("featuresFurnitureDesc"),
|
|
icon: "/assets/images/icons/catalog.png",
|
|
},
|
|
{
|
|
title: tp("featuresCommunity"),
|
|
desc: tp("featuresCommunityDesc"),
|
|
icon: "/assets/images/icons/navigation/goody.png",
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="mx-auto w-full max-w-6xl flex flex-col gap-6 px-1 sm:px-0 md:gap-8 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"
|
|
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="/assets/images/icons/lighthouse.png"
|
|
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-2xl sm:rounded-3xl 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"
|
|
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="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>
|
|
{th("online", { count: online, hotel: 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={{
|
|
color: "var(--color-text-readable)",
|
|
fontFamily: "var(--font-nunito)",
|
|
}}
|
|
>
|
|
<TypewriterText text={tp("welcome", { hotelName })} />
|
|
</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="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-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-xs sm:text-sm px-5 sm:px-8 py-3 sm:py-4 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>
|
|
</Reveal>
|
|
|
|
{/* ── Features ── */}
|
|
<Reveal>
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
|
|
{features.map((f) => (
|
|
<SurfaceCard
|
|
key={f.title}
|
|
className="relative p-4 sm:p-5 text-center transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
|
|
>
|
|
<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>
|
|
</Reveal>
|
|
|
|
{/* ── Stats + Login ── */}
|
|
<Reveal>
|
|
<div className="grid grid-cols-2 gap-3 sm:gap-4 md:grid-cols-4">
|
|
{[
|
|
{
|
|
value: users,
|
|
label: tp("statsCitizens"),
|
|
icon: "/assets/images/icons/friends.png",
|
|
},
|
|
{
|
|
value: rooms,
|
|
label: tp("statsRooms"),
|
|
icon: "/assets/images/icons/catalog.png",
|
|
},
|
|
{
|
|
value: online,
|
|
label: tp("statsOnline"),
|
|
icon: "/assets/images/icons/navigation/me.png",
|
|
},
|
|
{
|
|
value: articles.length,
|
|
label: tp("statsArticles"),
|
|
icon: "/assets/images/icons/article.gif",
|
|
},
|
|
].map((s) => (
|
|
<SurfaceCard
|
|
key={s.label}
|
|
className="relative p-3 sm:p-5 text-center transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
|
|
>
|
|
<div className="mb-0.5 text-2xl sm:text-3xl font-black tracking-tight md:text-4xl">
|
|
<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>
|
|
</Reveal>
|
|
|
|
{/* ── Login + Online users ── */}
|
|
<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="/assets/images/icons/navigation/me.png"
|
|
bodyClassName="p-3.5 sm:p-4"
|
|
>
|
|
<HomeLoginForm
|
|
captcha={{
|
|
provider: captcha.provider,
|
|
siteKey: captcha.siteKey || undefined,
|
|
field: captcha.field || undefined,
|
|
}}
|
|
nonce={nonce}
|
|
/>
|
|
</SurfaceCard>
|
|
|
|
{recentUsers.length > 0 && (
|
|
<SurfaceCard
|
|
title={tp("recentUsers")}
|
|
icon="/assets/images/icons/navigation/goody.png"
|
|
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="/assets/images/icons/article.gif"
|
|
actionHref="/news"
|
|
actionLabel={tp("allNews")}
|
|
>
|
|
{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.length > 0 && (
|
|
<Reveal>
|
|
<SurfaceCard
|
|
title={tp("recentPhotos")}
|
|
icon="/assets/images/icons/camera.png"
|
|
>
|
|
<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>
|
|
);
|
|
}
|