From 741f01e8978eba363d09f0aa9879303be136de64 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 12 Sep 2026 15:40:54 +0200 Subject: [PATCH] feat(site): redesign home, login and register with premium dark gaming theme Replaced the classic Habbo landing style on the home, login and register pages with a modern premium dark-gaming look: always-dark hero canvas with brand glows, grid overlay and ambient orbs, glass panels, gradient text and floating art. Adds shared LandingTopBar, AuthShell and BrandFrank components plus reusable premium CSS utilities. Build, typecheck and lint pass. --- src/app/(site)/login/page.tsx | 282 ++++++---------- src/app/(site)/page.tsx | 458 ++++++++++++-------------- src/app/(site)/register/page.tsx | 296 ++++++----------- src/app/globals.css | 105 ++++++ src/components/auth/auth-shell.tsx | 47 +++ src/components/auth/brand-frank.tsx | 32 ++ src/components/auth/register-form.tsx | 78 ++--- src/components/landing-top-bar.tsx | 53 +++ 8 files changed, 694 insertions(+), 657 deletions(-) create mode 100644 src/components/auth/auth-shell.tsx create mode 100644 src/components/auth/brand-frank.tsx create mode 100644 src/components/landing-top-bar.tsx diff --git a/src/app/(site)/login/page.tsx b/src/app/(site)/login/page.tsx index 0cc720b1..4b44e571 100644 --- a/src/app/(site)/login/page.tsx +++ b/src/app/(site)/login/page.tsx @@ -1,22 +1,27 @@ -import { count, desc, eq } from "drizzle-orm"; +import { count, eq } from "drizzle-orm"; import { headers } from "next/headers"; import Image from "next/image"; import { getTranslations } from "next-intl/server"; -import { AuthTopBar } from "@/components/auth/auth-top-bar"; +import { AuthShell } from "@/components/auth/auth-shell"; +import { BrandFrank } from "@/components/auth/brand-frank"; import { LoginForm } from "@/components/auth/login-form"; 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 { cached } from "@/lib/cache"; import { db, User } from "@/lib/db"; import { resolveHotelName } from "@/lib/hotel-name"; import { captchaConfig } from "@/lib/services/captcha"; import { siteSettings } from "@/lib/services/site-settings"; +export const metadata = { + title: "Sign in", +}; + export default async function LoginPage() { const t = await getTranslations("pages.login"); const th = await getTranslations("header"); + const thp = await getTranslations("pages.home"); const [hotelName, cfg, logo] = await Promise.all([ resolveHotelName(), captchaConfig(), @@ -24,7 +29,7 @@ export default async function LoginPage() { ]); const nonce = (await headers()).get("x-nonce") ?? undefined; - const [online, recentUsers, latestUsers] = await Promise.all([ + const [online, totalUsers, recentUsers] = await Promise.all([ cached("online_count", 10_000, () => db .select({ total: count() }) @@ -32,197 +37,120 @@ export default async function LoginPage() { .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), db .select({ username: User.username, look: User.look }) .from(User) .where(eq(User.online, "1")) - .limit(8) - .catch(() => []), - db - .select({ username: User.username, look: User.look }) - .from(User) - .orderBy(desc(User.accountCreated)) - .limit(8) + .limit(6) .catch(() => []), ]); + const onlineLabel = th("online", { count: online, hotel: "" }).trim(); + return ( -
- - +
-
- {/* Left panel */} -
- -
-
- Frank -
- - - - - {th("online", { count: online, hotel: "" }).trim()} -
-

- {t("welcomeBack")} -

-

- {t("welcomeBackSub", { hotelName })} -

-
- - - {recentUsers.length > 0 && ( - -
- {recentUsers.map((u) => ( -
- - - {u.username} - -
- ))} -
-
- )} - - {latestUsers.length > 0 && ( - -
- {latestUsers.map((u) => ( -
- - - {u.username} - -
- ))} -
-
- )} - -

+ {t("noAccount") ?? "No account?"}{" "} {t("createOne") ?? "Create one"}

-
+ } + side={ +
+
+ + + + + + {onlineLabel} + +

+ {t("welcomeBack")} +
+ {hotelName} +

+

+ {t("welcomeBackSub", { hotelName })} +

+
- {/* Right: form */} -
- -

- {t("subtitle")} -

- -
+
+ +
+ +
+
+
{online}
+

+ {onlineLabel} +

+ {recentUsers.length > 0 && ( +
+ {recentUsers.slice(0, 5).map((u) => ( + + ))} +
+ )} +
+
+
+ + {totalUsers} + + +
+

+ {thp("statsCitizens")} +

+
+
+
+ } + > +
+

+ {t("title")} +

+

{t("subtitle")}

-
+ +
); diff --git a/src/app/(site)/page.tsx b/src/app/(site)/page.tsx index 77e7c6f8..37fc5c45 100644 --- a/src/app/(site)/page.tsx +++ b/src/app/(site)/page.tsx @@ -4,8 +4,27 @@ 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 { LandingTopBar } from "@/components/landing-top-bar"; +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 { PublicLoadError } from "@/components/public/load-error"; +import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; +import { SurfaceCard } from "@/components/surface-card"; +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 { publicReadFailure } from "@/lib/services/public-read"; +import { siteSettings } from "@/lib/services/site-settings"; export const metadata: Metadata = { title: "Home", @@ -19,28 +38,6 @@ export const metadata: Metadata = { }, }; -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(), @@ -138,181 +135,211 @@ export default async function Home() { }, ]; + const stats = [ + { + value: users, + label: tp("statsCitizens"), + icon: "/assets/images/icons/friends.png", + live: false, + }, + { + value: rooms, + label: tp("statsRooms"), + icon: "/assets/images/icons/catalog.png", + live: false, + }, + { + value: online, + label: tp("statsOnline"), + icon: "/assets/images/icons/navigation/me.png", + live: true, + }, + { + value: articles?.length ?? null, + label: tp("statsArticles"), + icon: "/assets/images/icons/article.gif", + live: false, + }, + ]; + return (
- {/* ── Top Bar ── */} - -
-
- {logo ? ( - {hotelName} - ) : ( - {hotelName} - )} - - {hotelName} - -
-
- -
- -
- -
-
- - {/* ── Hero ── */} -
- -
+
-
-
- - - + + +
+ {/* Left: copy */} +
+ + + + + + {online === null ? ( + {te("unavailable")} + ) : ( + + )} - {online === null ? ( - {te("unavailable")} - ) : ( - - )} + +

+ {tp("welcome", { hotelName: "" }).trim()} + +

+ +

+ {tp("welcomeBody")} +

+ +
+ + + {tp("register")} + + + {th("login")} + +
-

- -

-

- {tp("welcomeBody")} -

-
- + +
+
- {tp("register")} - - + {tp("welcomeSubtitle")} +

+
+
+
+
+
+
+
+
+
+ +
+

+ +

+

+ {tp("statsOnline")} +

+
+ +
- {th("login")} - +

+ +

+

+ {tp("statsCitizens")} +

+
-
+ + {/* Stats strip */} +
+
+ {stats.map((s) => ( +
+
+ + {s.value === null ? ( + + {te("unavailable")} + + ) : s.live ? ( + + ) : ( + + )} + + +
+

+ {s.label} +

+
+ ))} +
+
+
{/* ── Features ── */} @@ -321,21 +348,28 @@ export default async function Home() { {features.map((f) => (
+

{f.desc} @@ -354,60 +388,6 @@ export default async function Home() {

- {/* ── Stats + Login ── */} - -
- {[ - { - value: users, - label: tp("statsCitizens"), - icon: "/assets/images/icons/friends.png", - live: false, - }, - { - value: rooms, - label: tp("statsRooms"), - icon: "/assets/images/icons/catalog.png", - live: false, - }, - { - value: online, - label: tp("statsOnline"), - icon: "/assets/images/icons/navigation/me.png", - live: true, - }, - { - value: articles?.length ?? null, - label: tp("statsArticles"), - icon: "/assets/images/icons/article.gif", - live: false, - }, - ].map((s) => ( - -
- {s.value === null ? ( - {te("unavailable")} - ) : s.live ? ( - - ) : ( - - )} -
-
- - {s.label} -
-
- ))} -
-
- {/* ── Login + Online users ── */}
diff --git a/src/app/(site)/register/page.tsx b/src/app/(site)/register/page.tsx index ed784407..e035526d 100644 --- a/src/app/(site)/register/page.tsx +++ b/src/app/(site)/register/page.tsx @@ -1,21 +1,24 @@ import { count, desc, eq } from "drizzle-orm"; import { headers } from "next/headers"; -import Image from "next/image"; import { getTranslations } from "next-intl/server"; -import { AuthTopBar } from "@/components/auth/auth-top-bar"; +import { AuthShell } from "@/components/auth/auth-shell"; +import { BrandFrank } from "@/components/auth/brand-frank"; import { RegisterForm } from "@/components/auth/register-form"; -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 { cached } from "@/lib/cache"; import { db, User } from "@/lib/db"; import { resolveHotelName } from "@/lib/hotel-name"; import { captchaConfig } from "@/lib/services/captcha"; import { siteSettings } from "@/lib/services/site-settings"; +export const metadata = { + title: "Create account", +}; + export default async function RegisterPage() { const tpr = await getTranslations("pages.register"); + const th = await getTranslations("pages.home"); const [hotelName, cfg, logo] = await Promise.all([ resolveHotelName(), captchaConfig(), @@ -23,7 +26,7 @@ export default async function RegisterPage() { ]); const nonce = (await headers()).get("x-nonce") ?? undefined; - const [online, recentUsers, latestUsers] = await Promise.all([ + const [online, latestUsers] = await Promise.all([ cached("online_count", 10_000, () => db .select({ total: count() }) @@ -31,207 +34,124 @@ export default async function RegisterPage() { .where(eq(User.online, "1")) .then((rows) => rows[0]?.total ?? 0), ).catch(() => 0), - cached("register_online_users", 10_000, () => - db - .select({ username: User.username, look: User.look }) - .from(User) - .where(eq(User.online, "1")) - .limit(8), - ).catch(() => []), cached("register_latest_users", 30_000, () => db .select({ username: User.username, look: User.look }) .from(User) .orderBy(desc(User.accountCreated)) - .limit(8), + .limit(6), ).catch(() => []), ]); - return ( -
- + const benefits = [ + { title: th("featuresAvatar"), desc: th("featuresAvatarDesc") }, + { title: th("featuresFurniture"), desc: th("featuresFurnitureDesc") }, + { title: th("featuresCommunity"), desc: th("featuresCommunityDesc") }, + ]; + return ( +
-
- {/* Left: intro panel */} -
- -
-
-
-
-
- Frank -
-
-
+
+ + + + + + {tpr("usersOnline", { count: online })} + +

+ {tpr("title")} +
+ {hotelName} +

+

+ {tpr("subtitle")} +

+
+ +
+ {benefits.map((b) => ( +
+ - - {tpr("usersOnline", { count: online })} + + {b.title} + + + +
+

+ {b.title} +

+

+ {b.desc} +

-

- {tpr("title")} — {hotelName} -

-

- {tpr("subtitle")} -

+
+ ))} +
+ +
+
+ +
+ {latestUsers.length > 0 && ( + <> +

+ Latest citizens +

+
+ {latestUsers.slice(0, 5).map((u) => ( + + ))} +
+ + )}
- - - {recentUsers.length > 0 && ( - -
- {recentUsers.map((u) => ( -
- - - {u.username} - -
- ))} -
-
- )} - - {latestUsers.length > 0 && ( - -
- {latestUsers.map((u) => ( -
- - - {u.username} - -
- ))} -
-
- )} - -

- {tpr("alreadyHaveAccountPre")}{" "} - - {tpr("alreadyHaveAccountLink")} - -

-
- - {/* Right: form */} -
- -

- {tpr("subtitle")} -

- -
-
-
+
+ } + > + +
); diff --git a/src/app/globals.css b/src/app/globals.css index fd00b5cb..146ac131 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1992,3 +1992,108 @@ details[open] > summary .details-open\:rotate-180 { .stagger-children > *:nth-child(8) { animation-delay: 0.4s; } + +/* ── Premium landing kit (dark gaming style) ─────────────────── + Shared primitives for the redesigned home / login / register + pages. Everything stays theme-aware: brand colors are driven by + --color-primary / --color-accent, and the "always-dark" hero + canvas is mixed from the surface token so it blends with both + light and dark hotel themes. */ +.landing-hero { + position: relative; + isolation: isolate; + overflow: hidden; + background: + radial-gradient( + 60em 40em at 12% -10%, + color-mix(in srgb, var(--color-primary) 22%, transparent) 0%, + transparent 60% + ), + radial-gradient( + 50em 36em at 108% 12%, + color-mix(in srgb, var(--color-accent) 18%, transparent) 0%, + transparent 62% + ), + linear-gradient( + 175deg, + color-mix(in srgb, var(--color-surface) 6%, #05070d) 0%, + color-mix(in srgb, var(--color-surface) 8%, #0a0f1d) 55%, + color-mix(in srgb, var(--color-background) 45%, #0a0f1d) 100% + ); + color-scheme: dark; +} +.landing-hero::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background-image: + linear-gradient( + to right, + color-mix(in srgb, #ffffff 5%, transparent) 1px, + transparent 1px + ), + linear-gradient( + to bottom, + color-mix(in srgb, #ffffff 5%, transparent) 1px, + transparent 1px + ); + background-size: 44px 44px; + -webkit-mask-image: linear-gradient(to bottom, #000 30%, transparent 92%); + mask-image: linear-gradient(to bottom, #000 30%, transparent 92%); + z-index: 0; +} + +/* Text with a brand gradient (primary → accent). */ +.text-gradient { + background: linear-gradient( + 115deg, + var(--color-primary) 10%, + color-mix(in srgb, var(--color-primary) 55%, var(--color-accent)) 48%, + var(--color-accent) 92% + ); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +/* Primary-tinted glass card used for auth forms / panels. */ +.glass-panel { + background: color-mix(in srgb, #0b1020 62%, transparent); + backdrop-filter: blur(22px); + -webkit-backdrop-filter: blur(22px); + border: 1px solid color-mix(in srgb, #ffffff 10%, transparent); + box-shadow: + 0 30px 80px -20px rgba(0, 0, 0, 0.55), + inset 0 1px 0 color-mix(in srgb, #ffffff 9%, transparent); +} + +/* Soft animated halo behind floating hero art. */ +.hero-halo { + position: absolute; + border-radius: 999px; + background: radial-gradient( + circle, + color-mix(in srgb, var(--color-primary) 55%, transparent) 0%, + transparent 68% + ); + filter: blur(6px); + animation: hero-halo-pulse 5s ease-in-out infinite; +} +@keyframes hero-halo-pulse { + 0%, + 100% { + opacity: 0.55; + transform: scale(1); + } + 50% { + opacity: 0.9; + transform: scale(1.07); + } +} + +@media (prefers-reduced-motion: reduce) { + .hero-halo { + animation: none; + } +} diff --git a/src/components/auth/auth-shell.tsx b/src/components/auth/auth-shell.tsx new file mode 100644 index 00000000..86bd0e5a --- /dev/null +++ b/src/components/auth/auth-shell.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from "react"; +import { AmbientOrbs } from "@/components/ambient-orbs"; +import { LandingTopBar } from "@/components/landing-top-bar"; + +/** + * Premium dark-gaming shell shared by the login and register pages. + * Renders an always-dark hero canvas (brand glows + grid overlay) with a + * hotel header on top, a brand/side column on the left and the auth form + * (rendered by the page) on the right in a frosted glass panel. + */ +export function AuthShell({ + hotelName, + logo, + side, + footer, + children, +}: { + hotelName: string; + logo?: string | null; + side: ReactNode; + footer?: ReactNode; + children: ReactNode; +}) { + return ( +
+ {/* Ambient glows */} + + + {/* Header */} + + + {/* Body */} +
+
{side}
+
+ {children} +
+
+ + {footer ? ( +
+ {footer} +
+ ) : null} +
+ ); +} diff --git a/src/components/auth/brand-frank.tsx b/src/components/auth/brand-frank.tsx new file mode 100644 index 00000000..46b0b6e1 --- /dev/null +++ b/src/components/auth/brand-frank.tsx @@ -0,0 +1,32 @@ +import Image from "next/image"; + +/** + * Floating brand art for the premium auth pages: the welcome character + * hovers over a pulsing primary halo with a faint slow-orbiting ring. + */ +export function BrandFrank({ size = 150 }: { size?: number }) { + return ( +
+ + + +
+ +
+
+ ); +} diff --git a/src/components/auth/register-form.tsx b/src/components/auth/register-form.tsx index e92c9207..070abeb7 100644 --- a/src/components/auth/register-form.tsx +++ b/src/components/auth/register-form.tsx @@ -113,52 +113,30 @@ export function RegisterForm({ /> ) : null} {/* Header Banner */} -
-
-
-
+
+ +
+
+

-

-
-
- -
+ {t("accountDetails")} +

+

+ {t("title")} +

+

+ {t("subtitle")} +

@@ -171,12 +149,7 @@ export function RegisterForm({ }; formAction(fd); }} - className="p-6 flex flex-col gap-6" - style={{ - backgroundColor: - "color-mix(in srgb, var(--color-background) 50%, var(--color-surface))", - borderRadius: "0 0 12px 12px", - }} + className="flex flex-col gap-6" > {(error || state.error) && (
+
+ {logo ? ( + {hotelName} + ) : ( + {hotelName} + )} + + {hotelName} + +
+
+ +
+ +
+ +
+
+ ); +}