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} + +
+
+ +
+ +
+ +
+
+ ); +}