Complete UI professional makeover: premium design, animations, consistent shadcn components, polished admin dashboard, modern login/auth pages, enhanced navigation
Local Build and Deploy / deploy (push) Successful in 46s

This commit is contained in:
openhands committed 2026-07-16 19:37:22 +02:00
1 parent ac1b412756
commit 3c4aa50fea
12 files changed
+773 -780

No files matched your search

+151 -103
View File
@@ -1,11 +1,13 @@
"use client";
import { KeyRound, Lock, Mail } from "lucide-react";
import Link from "next/link";
import { signIn } from "next-auth/react";
import { useTranslations } from "next-intl";
import { type FormEvent, useState } from "react";
import { precheckLogin } from "@/actions/auth-precheck";
import { ContentCard } from "@/components/public/ui";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export default function LoginPage() {
const t = useTranslations("pages.login");
@@ -51,108 +53,154 @@ export default function LoginPage() {
}
return (
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
<ContentCard
icon={needs2fa ? "🔒" : "🔑"}
title={t("title")}
subtitle={needs2fa ? t("subtitle2fa") : t("subtitle")}
>
<form onSubmit={onSubmit} style={{ display: "grid", gap: "0.7rem" }}>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("usernamePlaceholder")}
autoComplete="username"
disabled={needs2fa}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("passwordPlaceholder")}
autoComplete="current-password"
disabled={needs2fa}
/>
{needs2fa ? (
<input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t("codePlaceholder")}
inputMode="numeric"
autoComplete="one-time-code"
// biome-ignore lint/a11y/noAutofocus: focus the only relevant field in the 2FA step
autoFocus
/>
) : null}
<button type="submit" className="btn btn-primary" disabled={pending}>
{pending ? t("pleaseWait") : needs2fa ? t("verify") : t("signIn")}
</button>
</form>
{!needs2fa ? (
<>
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
margin: "1rem 0",
}}
>
<span
style={{
flex: 1,
height: 1,
background: "var(--border-color)",
}}
/>
<span className="muted">{t("or")}</span>
<span
style={{
flex: 1,
height: 1,
background: "var(--border-color)",
}}
/>
</div>
<div style={{ display: "grid", gap: "0.5rem" }}>
<button
type="button"
className="btn btn-outline"
onClick={() => signIn("discord", { callbackUrl: "/" })}
>
{t("continueWithDiscord")}
</button>
<button
type="button"
className="btn btn-outline"
onClick={() => signIn("google", { callbackUrl: "/" })}
>
{t("continueWithGoogle")}
</button>
</div>
</>
) : null}
{error ? (
<p
style={{
color: "var(--color-danger)",
textAlign: "center",
marginBottom: 0,
}}
>
{error}
<div className="flex min-h-[70vh] items-center justify-center px-4 py-12">
<div className="w-full max-w-md animate-fade-in">
<div className="text-center mb-8">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-[var(--color-primary)]/20 to-[var(--color-primary)]/5 shadow-lg">
<Lock className="h-8 w-8 text-[var(--color-primary)]" />
</div>
<h1 className="text-2xl font-extrabold tracking-tight m-0">
{t("title")}
</h1>
<p className="mt-1.5 text-sm text-[var(--color-text-muted-readable)]">
{needs2fa ? t("subtitle2fa") : t("subtitle")}
</p>
) : null}
<p
className="muted"
style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}
>
{t("noAccount")} <Link href="/register">{t("createOne")}</Link> ·{" "}
<Link href="/forgot">{t("forgotPassword")}</Link>
</p>
</ContentCard>
</main>
</div>
<Card className="border-[var(--border-subtle)] shadow-elevated">
<CardHeader className="px-6 pt-6 pb-0">
<CardTitle className="text-base font-bold">
{needs2fa ? t("subtitle2fa") : t("subtitle")}
</CardTitle>
<CardDescription>
{needs2fa ? "Enter your authentication code" : "Sign in to your account"}
</CardDescription>
</CardHeader>
<CardContent className="p-6">
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-3">
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--color-text-muted)]" />
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("usernamePlaceholder")}
autoComplete="username"
disabled={needs2fa}
className="w-full rounded-xl border-2 border-[var(--input-border-color)] bg-[var(--color-background)] px-10 py-2.5 text-sm transition-all duration-200 focus:border-[var(--color-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20"
/>
</div>
<div className="relative">
<KeyRound className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--color-text-muted)]" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("passwordPlaceholder")}
autoComplete="current-password"
disabled={needs2fa}
className="w-full rounded-xl border-2 border-[var(--input-border-color)] bg-[var(--color-background)] px-10 py-2.5 text-sm transition-all duration-200 focus:border-[var(--color-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20"
/>
</div>
{needs2fa ? (
<input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t("codePlaceholder")}
inputMode="numeric"
autoComplete="one-time-code"
autoFocus
className="w-full rounded-xl border-2 border-[var(--input-border-color)] bg-[var(--color-background)] px-4 py-2.5 text-sm transition-all duration-200 focus:border-[var(--color-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/20"
/>
) : null}
</div>
{error ? (
<div className="rounded-xl bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 px-4 py-3">
<p className="text-sm font-medium text-red-600 dark:text-red-400 text-center m-0">
{error}
</p>
</div>
) : null}
<Button
type="submit"
disabled={pending}
size="lg"
className="w-full font-bold text-base h-12 rounded-xl"
>
{pending ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{t("pleaseWait")}
</span>
) : needs2fa ? t("verify") : t("signIn")}
</Button>
</form>
{!needs2fa ? (
<>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-[var(--border-subtle)]" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-[var(--color-card)] px-3 text-[var(--color-text-muted-readable)] font-bold">
{t("or")}
</span>
</div>
</div>
<div className="grid gap-3">
<Button
type="button"
variant="outline"
size="lg"
className="w-full font-semibold h-11 rounded-xl"
onClick={() => signIn("discord", { callbackUrl: "/" })}
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z" />
</svg>
{t("continueWithDiscord")}
</Button>
<Button
type="button"
variant="outline"
size="lg"
className="w-full font-semibold h-11 rounded-xl"
onClick={() => signIn("google", { callbackUrl: "/" })}
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
</svg>
{t("continueWithGoogle")}
</Button>
</div>
</>
) : null}
<div className="mt-6 text-center">
<p className="text-sm text-[var(--color-text-muted-readable)]">
{t("noAccount")}{" "}
<Link href="/register" className="font-bold text-[var(--color-primary)] hover:underline">
{t("createOne")}
</Link>
<span className="mx-2 text-[var(--border-subtle)]">·</span>
<Link href="/forgot" className="font-bold text-[var(--color-primary)] hover:underline">
{t("forgotPassword")}
</Link>
</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
+52 -28
View File
@@ -1,4 +1,4 @@
import { LogOut } from "lucide-react";
import { LayoutDashboard, LogOut, Shield } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
@@ -35,7 +35,7 @@ export default async function AdminLayout({
<AdminMobileWrapper sidebar={<Sidebar staff={staff} />}>
<div
data-admin
className="flex flex-col min-w-0 p-5 lg:p-6 min-h-screen"
className="flex flex-col min-w-0 p-4 lg:p-6 min-h-screen"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<Topbar staff={staff} />
@@ -60,39 +60,52 @@ async function Sidebar({
data-admin
className="max-lg:static sticky top-0 self-start h-full lg:h-screen overflow-y-auto bg-[var(--admin-sidebar-background)] text-[var(--admin-sidebar-text-readable)] shadow-xl flex flex-col"
>
<div className="flex items-center gap-3 px-4 py-5 border-b border-[var(--admin-border)]">
{/* Sidebar Header */}
<div className="flex items-center gap-3 border-b border-[var(--admin-border)] px-4 py-5">
<span
className="flex-none w-10 h-10 rounded-xl grid place-items-center font-extrabold text-base text-[var(--admin-accent-foreground)] bg-[var(--admin-accent)] shadow-lg shadow-[var(--admin-accent)]/20"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-[var(--admin-accent)] to-[var(--admin-accent)]/80 text-base font-extrabold text-[var(--admin-accent-foreground)] shadow-lg shadow-[var(--admin-accent)]/20"
aria-hidden
>
{initial}
</span>
<div className="min-w-0 flex-1">
<div className="font-semibold text-sm text-[var(--admin-sidebar-text-readable)] truncate">
<div className="truncate text-sm font-bold text-[var(--admin-sidebar-text-readable)]">
{staff.username}
</div>
<span className="inline-block mt-0.5 text-[0.6rem] font-bold uppercase tracking-widest text-[var(--admin-sidebar-muted-readable)]">
<span className="mt-0.5 inline-flex items-center gap-1 text-[0.55rem] font-bold uppercase tracking-widest text-[var(--admin-sidebar-muted-readable)]">
<Shield size={10} />
{t("rankLabel", { rank: staff.rank })}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<div className="flex shrink-0 items-center gap-1">
<LanguageSwitcher variant="admin" />
<ThemeSwitcher variant="admin" />
</div>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto px-3 py-4">
{/* Dashboard link at the top */}
<div className="mb-3">
<AdminNavLink
href="/admin"
icon={<LayoutDashboard size={16} />}
>
Dashboard
</AdminNavLink>
</div>
{ADMIN_NAV_GROUPS.map((group, gi) => {
const GroupIcon = group.icon;
return (
<div
key={group.labelKey}
className={
gi > 0 ? "mt-4 pt-4 border-t border-[var(--admin-border)]" : ""
gi > 0 ? "mt-4 border-t border-[var(--admin-border)] pt-4" : ""
}
>
<div className="flex items-center gap-1.5 px-2 pb-2 text-[0.65rem] font-extrabold uppercase tracking-[0.14em] text-[var(--admin-sidebar-text-readable)] opacity-70">
<GroupIcon size={14} />
<div className="mb-1.5 flex items-center gap-1.5 px-2 pb-1 text-[0.6rem] font-extrabold uppercase tracking-[0.14em] text-[var(--admin-sidebar-text-readable)] opacity-60">
<GroupIcon size={12} />
<span>{t(group.labelKey)}</span>
</div>
<div className="space-y-0.5">
@@ -115,10 +128,11 @@ async function Sidebar({
})}
</nav>
<div className="px-3 py-3 border-t border-[var(--admin-border)]">
{/* Footer */}
<div className="border-t border-[var(--admin-border)] px-3 py-3">
<Link
href="/"
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[var(--admin-sidebar-muted-readable)] text-xs font-medium hover:text-[var(--admin-sidebar-text-readable)] hover:bg-[var(--admin-accent)]/10 transition-all duration-150 no-underline"
className="flex items-center gap-2.5 rounded-xl px-2.5 py-2 text-xs font-bold text-[var(--admin-sidebar-muted-readable)] no-underline transition-all duration-150 hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-sidebar-text-readable)]"
>
<LogOut size={14} />
<span>{t("backToSite")}</span>
@@ -136,25 +150,35 @@ async function Topbar({
const t = await getTranslations("pages.admin.nav");
return (
<header className="flex items-center justify-between gap-4 bg-[var(--admin-surface)] border border-[var(--admin-border)] rounded-2xl px-5 py-3 mb-5 shadow-card">
<header className="mb-5 flex items-center justify-between gap-4 rounded-2xl border border-[var(--admin-border)] bg-[var(--admin-surface)] px-5 py-3 shadow-card">
<div className="flex items-center gap-3">
<h2 className="m-0 text-base font-extrabold text-[var(--admin-text)]">
{t("housekeeping")}
</h2>
<span className="hidden sm:inline-block w-px h-5 bg-[var(--admin-border)]" />
<span className="hidden sm:inline text-xs text-[var(--admin-text-muted)]">
{t("hotelManagementPanel")}
</span>
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 ring-1 ring-[var(--admin-accent)]/10">
<LayoutDashboard size={18} className="text-[var(--admin-accent)]" />
</div>
<div>
<h2 className="m-0 text-sm font-extrabold text-[var(--admin-text)]">
{t("housekeeping")}
</h2>
<span className="hidden text-[0.65rem] font-medium text-[var(--admin-text-muted)] sm:block">
{t("hotelManagementPanel")}
</span>
</div>
</div>
<div className="flex items-center gap-3">
<LanguageSwitcher variant="admin" />
<ThemeSwitcher variant="admin" />
<span className="text-xs text-[var(--admin-text-muted)]">
<strong className="text-[var(--admin-text)]">{staff.username}</strong>
</span>
<span className="inline-flex items-center gap-1.5 text-[0.6rem] font-bold uppercase tracking-wider text-[var(--admin-accent-foreground)] bg-[var(--admin-accent)] rounded-full px-2.5 py-1">
{t("rankLabel", { rank: staff.rank })}
</span>
<div className="hidden items-center gap-2 sm:flex">
<LanguageSwitcher variant="admin" />
<ThemeSwitcher variant="admin" />
</div>
<div className="h-6 w-px bg-[var(--admin-border)]" />
<div className="flex items-center gap-2.5">
<span className="hidden text-xs font-bold text-[var(--admin-text-muted)] sm:block">
<strong className="text-[var(--admin-text)]">{staff.username}</strong>
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-[var(--admin-accent)]/30 bg-[var(--admin-accent)]/15 px-3 py-1 text-[0.55rem] font-bold uppercase tracking-wider text-[var(--admin-accent-foreground)]">
<Shield size={10} />
{t("rankLabel", { rank: staff.rank })}
</span>
</div>
</div>
</header>
);
+68 -46
View File
@@ -4,6 +4,8 @@ import {
Ban,
Database,
Newspaper,
Sparkles,
TrendingUp,
Users,
} from "lucide-react";
import Link from "next/link";
@@ -69,22 +71,36 @@ export default async function AdminDashboard() {
}
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Activity size={20} className="text-[var(--admin-accent)]" />
<main className="animate-fade-in">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 shadow-lg ring-1 ring-[var(--admin-accent)]/10">
<Activity size={22} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold tracking-tight text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 mt-0.5 text-xs text-[var(--admin-text-muted)]">
{t("subtitle")}
</p>
</div>
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
<div className="hidden items-center gap-2 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 sm:flex">
<Sparkles size={14} className="text-[var(--admin-accent)]" />
<span className="text-xs font-bold text-[var(--admin-text-muted)]">
System Online
</span>
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500" />
</span>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{/* Status Cards */}
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatusCard
label={t("playersOnline")}
value={online}
@@ -112,17 +128,24 @@ export default async function AdminDashboard() {
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<section className="admin-card p-5">
<h2 className="text-sm font-bold text-[var(--admin-text)] mb-4 admin-section-title">
{t("usersByRank")}
</h2>
{/* Analytics Grid */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Users by Rank */}
<section className="admin-card overflow-hidden">
<div className="mb-5 flex items-center gap-3 border-b border-[var(--admin-border)] pb-4">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/15 to-[var(--admin-accent)]/5">
<TrendingUp size={16} className="text-[var(--admin-accent)]" />
</div>
<h2 className="m-0 text-sm font-bold text-[var(--admin-text)]">
{t("usersByRank")}
</h2>
</div>
{rankCounts.length === 0 ? (
<p className="text-xs text-[var(--admin-text-muted)]">
{dbOk ? t("noAccountsToBreakDown") : t("databaseDown")}
</p>
) : (
<div className="space-y-2.5">
<div className="space-y-3">
{rankCounts.map((r) => {
const pct =
maxRankCount > 0
@@ -132,21 +155,14 @@ export default async function AdminDashboard() {
<div
key={r.rank}
className="grid items-center gap-3"
style={{ gridTemplateColumns: "5rem 1fr 2.5rem" }}
style={{ gridTemplateColumns: "4.5rem 1fr 2.5rem" }}
>
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
<span className="text-xs font-bold text-[var(--admin-text-muted)]">
{t("rankLabel", { rank: r.rank })}
</span>
<div
className="h-3 rounded-md overflow-hidden bg-[var(--admin-accent)]/10"
role="img"
aria-label={t("rankWithCount", {
rank: r.rank,
count: r.count,
})}
>
<div className="h-2.5 overflow-hidden rounded-full bg-[var(--admin-accent)]/10 ring-1 ring-[var(--admin-accent)]/5">
<div
className="h-full rounded-md bg-gradient-to-r from-[var(--admin-accent)] to-[var(--admin-accent)] transition-all duration-500"
className="h-full rounded-full bg-gradient-to-r from-[var(--admin-accent)] to-[var(--admin-accent)]/70 transition-all duration-700 ease-out"
style={{
width: `${Math.max(pct, r.count > 0 ? 4 : 0)}%`,
}}
@@ -162,48 +178,54 @@ export default async function AdminDashboard() {
)}
</section>
<section className="admin-card p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-bold text-[var(--admin-text)] admin-section-title">
{t("recentStaffActivity")}
</h2>
{/* Recent Staff Activity */}
<section className="admin-card overflow-hidden">
<div className="mb-5 flex items-center justify-between border-b border-[var(--admin-border)] pb-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/15 to-[var(--admin-accent)]/5">
<Activity size={16} className="text-[var(--admin-accent)]" />
</div>
<h2 className="m-0 text-sm font-bold text-[var(--admin-text)]">
{t("recentStaffActivity")}
</h2>
</div>
<Link
href="/admin/logs"
className="text-xs font-medium text-[var(--admin-accent)] hover:text-[var(--admin-accent)] flex items-center gap-1 no-underline"
className="flex items-center gap-1 text-xs font-bold text-[var(--admin-accent)] no-underline transition-all hover:gap-1.5"
>
{t("viewAll")} <ArrowRight size={12} />
</Link>
</div>
{activity.length === 0 ? (
<p className="text-xs text-[var(--admin-text-muted)]">
<p className="py-8 text-center text-xs text-[var(--admin-text-muted)]">
{t("noStaffActivityLogged")}
</p>
) : (
<div className="space-y-0">
<div className="-mx-1">
{activity.map((a, i) => (
<div
key={String(a.id)}
className={`flex items-center gap-3 py-2.5 ${i > 0 ? "border-t border-[var(--admin-border)]" : ""}`}
className={`flex items-center gap-3 rounded-xl px-3 py-2.5 transition-all duration-150 hover:bg-[var(--admin-accent)]/5 ${i > 0 ? "mt-1" : ""}`}
>
<div className="w-7 h-7 rounded-full bg-[var(--admin-accent)]/10 grid place-items-center flex-none">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/15 to-[var(--admin-accent)]/5">
<Activity
size={12}
size={13}
className="text-[var(--admin-accent)]"
/>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-[var(--admin-text)] truncate">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-[var(--admin-text)]">
{nameById.get(Number(a.userId)) ?? `#${a.userId}`}
</div>
<div className="text-xs text-[var(--admin-text-muted)] truncate">
<div className="truncate text-xs text-[var(--admin-text-muted)]">
{a.description}
</div>
</div>
<div className="text-right flex-none">
<span className="inline-block text-[0.6rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)] admin-card inline-flex items-center justify-center min-w-[60px]">
<div className="shrink-0 text-right">
<span className="inline-block rounded-full border border-[var(--admin-border)] bg-[var(--admin-accent)]/8 px-3 py-1 text-[0.6rem] font-bold text-[var(--admin-text)]">
{a.action}
</span>
<div className="text-[0.6rem] text-[var(--admin-text-muted)] mt-0.5">
<div className="mt-0.5 text-[0.55rem] font-medium text-[var(--admin-text-muted)]">
{fromDate(a.createdAt)}
</div>
</div>
+57 -68
View File
@@ -14,10 +14,11 @@
--radius-md: 12px;
--radius-lg: 16px;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-card-hover: 0 8px 24px rgba(0, 0, 0, 0.1), 0 2px 6px rgba(0, 0, 0, 0.06);
--shadow-card-hover: 0 8px 30px rgba(0, 0, 0, 0.1), 0 2px 8px rgba(0, 0, 0, 0.06);
--shadow-elevated: 0 20px 60px rgba(0, 0, 0, 0.12), 0 4px 16px rgba(0, 0, 0, 0.06);
--shadow-glow: 0 0 20px color-mix(in srgb, var(--color-primary) 30%, transparent), 0 0 60px color-mix(in srgb, var(--color-primary) 10%, transparent);
}
/* Default theme variables. ThemeVars may override these at runtime. */
:root {
--color-primary: #eeb425;
--color-primary-hover: #d4a01f;
@@ -86,7 +87,6 @@
--border-subtle: color-mix(in srgb, var(--color-text-muted) 18%, transparent);
}
/* Dark mode colors + readable contrast vars are generated by ThemeVars. */
html.dark input,
html.dark select,
html.dark textarea {
@@ -114,7 +114,6 @@ body {
-moz-osx-font-smoothing: grayscale;
}
/* Mobile-friendly improvements for every screen */
button,
a,
input,
@@ -126,12 +125,10 @@ summary,
touch-action: manipulation;
}
/* Momentum scrolling on iOS */
* {
-webkit-overflow-scrolling: touch;
}
/* Prevent iOS zoom on input focus */
@media (max-width: 48rem) {
input,
select,
@@ -140,7 +137,6 @@ summary,
}
}
/* Semantic colors: safe across every complete light/dark preset. */
.theme-text-muted {
color: var(--color-text-muted-readable);
}
@@ -192,7 +188,6 @@ summary,
background: transparent;
}
/* ── Atom theme components ────────────────────────────────── */
@layer components {
.nav-item {
@apply flex h-auto md:h-[54px] items-center text-[13px] font-bold px-3 md:px-4 relative;
@@ -202,12 +197,14 @@ summary,
white-space: nowrap;
transition:
color 0.15s ease-in-out,
background-color 0.15s ease-in-out;
background-color 0.15s ease-in-out,
transform 0.15s ease;
border-radius: 0.5rem;
}
.nav-item:hover {
color: var(--color-primary-readable, var(--color-primary));
background-color: color-mix(in srgb, var(--color-primary) 8%, transparent);
transform: translateY(-1px);
}
.nav-item[aria-current="page"] {
color: var(--color-primary-readable, var(--color-primary));
@@ -222,11 +219,13 @@ summary,
color: var(--color-dropdown-text-readable, var(--color-text-readable));
transition:
background-color 0.1s ease-in-out,
color 0.1s ease-in-out;
color 0.1s ease-in-out,
transform 0.1s ease;
}
.dropdown-item:hover {
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary-readable, var(--color-primary));
transform: translateX(2px);
}
.dropdown-item:focus-visible {
outline: 2px solid var(--color-primary-readable, var(--color-primary));
@@ -237,10 +236,10 @@ summary,
border: 1px solid
color-mix(in srgb, var(--color-text-muted) 14%, transparent);
box-shadow: 0 8px 32px color-mix(in srgb, rgb(0, 0, 0) 20%, transparent);
border-radius: 16px;
}
}
/* ── Site background ── */
.site-bg {
background:
linear-gradient(
@@ -262,7 +261,6 @@ html.dark .site-bg {
background-blend-mode: overlay;
}
/* Admin must not inherit the public wallpaper — solid HK canvas only. */
body.site-bg:has([data-admin]),
html.dark body.site-bg:has([data-admin]) {
background: var(--admin-canvas);
@@ -270,14 +268,13 @@ html.dark body.site-bg:has([data-admin]) {
background-blend-mode: normal;
}
.article-body {
line-height: 1.7;
}
.article-body img {
max-width: 100%;
height: auto;
border-radius: 8px;
border-radius: 12px;
}
.article-body p {
margin: 0 0 0.85rem;
@@ -307,15 +304,17 @@ html.dark body.site-bg:has([data-admin]) {
transform: translateY(-2px);
box-shadow: var(--shadow-card-hover);
}
/* ── App component classes (used across the converted pages) ── */
h1 {
font-size: var(--size-heading-h1, 30px);
font-weight: 800;
margin: 0.2em 0 0.5em;
letter-spacing: -0.02em;
}
h2 {
font-size: var(--size-heading-h2, 22px);
font-weight: 700;
letter-spacing: -0.01em;
}
h3 {
font-size: var(--size-heading-h3, 18px);
@@ -343,6 +342,7 @@ a:hover {
padding: 2rem 2.25rem;
box-shadow: var(--shadow-card);
margin-bottom: 1.5rem;
backdrop-filter: blur(8px);
}
.card {
background: var(--color-surface);
@@ -507,25 +507,21 @@ tbody tr:hover {
background-size: contain;
outline: 1px solid #6caff4;
}
/* ── Admin panel (Housekeeping) ─────────────────────────────────
Modern, consistent styling for all admin content pages. */
.admin-page {
scroll-margin-top: 1rem;
}
/* Hub chrome already shows the section title — soften page-level heroes. */
.admin-hub-panel > .space-y-6 > div:first-child > h1.text-3xl {
font-size: 1.15rem;
letter-spacing: -0.01em;
}
/* Page heading */
.admin-page main > h1:first-child,
.admin-page > h1:first-child {
margin-top: 0;
}
/* Cards used inside admin pages */
.admin-card {
background: var(--admin-surface);
color: var(--admin-text);
@@ -533,10 +529,12 @@ tbody tr:hover {
border-radius: 16px;
box-shadow: var(--shadow-card);
padding: 1.25rem;
transition: box-shadow 0.2s ease;
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.admin-card:hover {
box-shadow: var(--shadow-card-hover);
}
/* Card with filter/search bar — compact horizontal layout */
.admin-filter-bar {
display: flex;
flex-wrap: wrap;
@@ -550,7 +548,6 @@ tbody tr:hover {
min-width: 200px;
}
/* Compact stat row for list pages (counts, summaries) */
.admin-stat-row {
display: flex;
flex-wrap: wrap;
@@ -569,7 +566,6 @@ tbody tr:hover {
font-weight: 600;
}
/* Tables */
.admin-page table {
width: 100%;
background: var(--admin-surface);
@@ -610,7 +606,6 @@ tbody tr:hover {
border-radius: 0 16px 0 0;
}
/* Section heading inside admin pages */
.admin-section-title {
font-size: 0.9rem;
font-weight: 700;
@@ -627,16 +622,13 @@ tbody tr:hover {
gap: 0.15rem;
}
/* Empty state */
.admin-empty {
text-align: center;
padding: 2.5rem 1rem;
color: var(--admin-text-muted);
font-size: 0.85rem;
}
/* ── Public design system (faithful to the atom theme) ──────────
content-card = surface card with a primary-tinted header (icon +
title + subtitle) and a padded body — the signature atom block. */
.content-card {
display: flex;
flex-direction: column;
@@ -647,10 +639,11 @@ tbody tr:hover {
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
transition: box-shadow 0.2s ease;
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.content-card:hover {
box-shadow: var(--shadow-card-hover);
transform: translateY(-2px);
}
.content-card-head {
display: flex;
@@ -704,7 +697,6 @@ tbody tr:hover {
padding: 1.1rem;
}
/* Responsive card grids (atom: 1 / sm2 / md3 / lg4). */
.card-grid {
display: grid;
gap: 1rem;
@@ -732,7 +724,6 @@ tbody tr:hover {
}
}
/* Stat tiles (value in the primary colour + muted label). */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
@@ -744,6 +735,11 @@ tbody tr:hover {
border-radius: 12px;
padding: 0.85rem 1rem;
text-align: center;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.stat-block:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-card-hover);
}
.stat-block-value {
font-size: 1.6rem;
@@ -757,7 +753,6 @@ tbody tr:hover {
color: var(--color-text-muted-readable, var(--color-text-muted));
}
/* Empty states */
.empty-state {
text-align: center;
padding: 2rem 1rem;
@@ -767,7 +762,6 @@ tbody tr:hover {
margin-bottom: 0.4rem;
}
/* Online / offline pill */
.online-badge {
display: inline-flex;
align-items: center;
@@ -795,7 +789,6 @@ tbody tr:hover {
color-mix(in srgb, var(--color-success) 22%, transparent);
}
/* Leaderboard rank chip */
.rank-badge {
display: inline-grid;
place-items: center;
@@ -821,7 +814,6 @@ tbody tr:hover {
border-color: #f97316;
}
/* ── Radio sidebar ──────────────────────────────────────────── */
.radio-layout {
display: flex;
gap: 1.5rem;
@@ -865,20 +857,37 @@ tbody tr:hover {
}
}
/* ── Skeleton shimmer animation ─────────────────────────────── */
@keyframes shimmer {
0% {
opacity: 0.6;
}
50% {
opacity: 1;
}
100% {
opacity: 0.6;
}
0% { opacity: 0.6; }
50% { opacity: 1; }
100% { opacity: 0.6; }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 8px color-mix(in srgb, var(--color-primary) 20%, transparent); }
50% { box-shadow: 0 0 20px color-mix(in srgb, var(--color-primary) 40%, transparent); }
}
.animate-fade-in {
animation: fadeIn 0.4s ease-out;
}
.animate-slide-up {
animation: slideUp 0.5s ease-out;
}
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
/* Footer CMS info popup */
.cms-info-trigger {
appearance: none;
border: 0;
@@ -918,7 +927,7 @@ tbody tr:hover {
width: min(440px, 100%);
border: 1px solid
color-mix(in srgb, var(--color-primary) 30%, color-mix(in srgb, var(--color-text-muted) 22%, transparent));
border-radius: 8px;
border-radius: 16px;
background:
linear-gradient(
135deg,
@@ -1049,7 +1058,6 @@ tbody tr:hover {
}
}
/* ── Mobile responsive overrides ──────────────────────────── */
@media (max-width: 48rem) {
h1 {
font-size: clamp(1.25rem, 5vw, var(--size-heading-h1, 30px));
@@ -1103,7 +1111,6 @@ tbody tr:hover {
}
}
/* make tables scroll on small screens */
@media (max-width: 640px) {
table {
display: block;
@@ -1112,21 +1119,7 @@ tbody tr:hover {
}
}
/* ── Admin (HK) semantic token remap ──────────────────────────────────────
Shared shadcn UI primitives (Button, Badge, Input, Select, Card, Table,
Tabs, Dialog, Switch, Checkbox, Tooltip, …) are built on the site's
semantic tokens (--color-background, --color-primary, --color-popover,
--color-border, --color-ring, --color-muted-foreground, …). On admin
pages we remap those tokens onto the admin palette so every embedded
component renders with the admin theme and guaranteed contrast, without
touching any component file.
Gated by `body:has([data-admin])` so the public site is completely
unaffected. This also covers Radix portals (Dialog/Select/Dropdown/
Popover/Tooltip) which mount under <body>. */
body:has([data-admin]) {
/* Full public→admin token remap so shadcn + body + leftover
public utilities all inherit the admin palette with contrast. */
--color-background: var(--admin-canvas);
--color-foreground: var(--admin-text);
--color-card: var(--admin-surface);
@@ -1155,7 +1148,6 @@ body:has([data-admin]) {
--color-border: var(--admin-border);
--color-input: var(--admin-border);
--color-ring: var(--admin-accent);
/* Public Atom tokens still referenced by body / .muted / inputs */
--color-text: var(--admin-text);
--color-text-readable: var(--admin-text);
--color-text-muted: var(--admin-text-muted);
@@ -1173,7 +1165,6 @@ body:has([data-admin]) {
font-family: var(--font-family);
}
/* Admin forms / boxes follow admin tokens in both light and dark. */
body:has([data-admin]) input:not([type="checkbox"]):not([type="radio"]):not(
[type="range"]
):not([type="file"]):not([type="color"]),
@@ -1209,7 +1200,6 @@ body:has([data-admin]) .btn-outline:hover {
background: color-mix(in srgb, var(--admin-accent) 12%, transparent);
color: var(--admin-text);
}
/* Override public html.dark input hardcodes inside admin. */
html.dark body:has([data-admin]) input:not([type="checkbox"]):not(
[type="radio"]
):not([type="range"]):not([type="file"]):not([type="color"]),
@@ -1219,7 +1209,6 @@ html.dark body:has([data-admin]) textarea {
color: var(--admin-text);
}
/* ── Details/summary open states for nav ──────────────────── */
details[open] > summary .details-open\:hidden {
display: none;
}
+53 -64
View File
@@ -14,7 +14,6 @@ export interface OnlineUser {
type State = "ok" | "warn" | "danger" | "neutral";
/** A single live-status tile (online users, emulator, DB, …). */
export function StatusCard({
label,
value,
@@ -28,31 +27,40 @@ export function StatusCard({
state?: State;
icon?: ReactNode;
}) {
const stateColors: Record<State, string> = {
ok: "from-green-500/20 to-green-500/5 ring-green-500/20",
warn: "from-yellow-500/20 to-yellow-500/5 ring-yellow-500/20",
danger: "from-red-500/20 to-red-500/5 ring-red-500/20",
neutral: "from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 ring-[var(--admin-accent)]/10",
};
return (
<div className="bg-[var(--admin-surface)] border border-[var(--admin-border)] rounded-2xl p-5 shadow-card hover:shadow-card-hover transition-shadow duration-200">
<div className="flex items-center justify-between gap-3 mb-3">
<span className="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--admin-text-muted)]">
{label}
</span>
{icon ? (
<span className="w-8 h-8 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/15 to-[var(--admin-accent)]/5 grid place-items-center text-[var(--admin-accent)] flex-none">
{icon}
<div className="group relative overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-[var(--admin-surface)] p-5 shadow-card transition-all duration-200 hover:shadow-card-hover hover:-translate-y-0.5">
<div className="absolute inset-0 bg-gradient-to-br opacity-50 transition-opacity group-hover:opacity-100" />
<div className="relative">
<div className="mb-3 flex items-center justify-between gap-3">
<span className="text-[0.6rem] font-bold uppercase tracking-widest text-[var(--admin-text-muted)]">
{label}
</span>
{icon ? (
<span className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br ${stateColors[_state]}`}>
<span className="text-[var(--admin-accent)]">{icon}</span>
</span>
) : null}
</div>
<div className="text-2xl font-extrabold leading-tight text-[var(--admin-text)]">
{value}
</div>
{hint ? (
<div className="mt-1.5 text-[0.65rem] font-medium text-[var(--admin-text-muted)]">
{hint}
</div>
) : null}
</div>
<div className="text-2xl font-extrabold text-[var(--admin-text)] leading-tight">
{value}
</div>
{hint ? (
<div className="text-[0.7rem] text-[var(--admin-text-muted)] mt-1.5 font-medium">
{hint}
</div>
) : null}
</div>
);
}
/** A row in the system-diagnostics list with an ok/warn/error pill. */
export function DiagnosticRow({
label,
detail,
@@ -71,31 +79,23 @@ export function DiagnosticRow({
: "bg-[var(--admin-error-subtle)] shadow-[0_0_0_3px_rgba(239,68,68,0.22)]";
const badgeColor =
state === "ok"
? "bg-[var(--admin-success-subtle)]"
? "bg-green-500/10 text-green-500 border-green-500/20"
: state === "danger"
? "bg-[var(--admin-error-subtle)]"
: "bg-[var(--admin-accent)]/18";
? "bg-red-500/10 text-red-500 border-red-500/20"
: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20";
return (
<div className="flex items-center gap-2.5 py-2 px-0.5 border-t border-[var(--admin-border)] first:border-t-0">
<span
className={`flex-none w-[9px] h-[9px] rounded-full ${dotColor}`}
aria-hidden
/>
<span className="font-bold text-sm">{label}</span>
<span className="flex-1 text-[0.82rem] text-[var(--admin-text-muted)]">
{detail}
</span>
<span
className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${badgeColor} text-[var(--admin-text)]`}
>
<div className="flex items-center gap-3 rounded-xl px-3 py-2.5 transition-colors hover:bg-[var(--admin-accent)]/5">
<span className={`flex-none w-2.5 h-2.5 rounded-full ${dotColor}`} aria-hidden />
<span className="text-sm font-bold text-[var(--admin-text)]">{label}</span>
<span className="flex-1 text-xs text-[var(--admin-text-muted)]">{detail}</span>
<span className={`inline-block rounded-full border px-2.5 py-0.5 text-[0.65rem] font-bold ${badgeColor}`}>
{text}
</span>
</div>
);
}
/** Small labelled key/value used in the server-info card. */
export function InfoItem({
label,
value,
@@ -105,13 +105,12 @@ export function InfoItem({
}) {
return (
<div className="flex flex-col gap-0.5 text-sm">
<span className="text-[var(--admin-text-muted)]">{label}</span>
<strong>{value}</strong>
<span className="text-xs font-medium text-[var(--admin-text-muted)]">{label}</span>
<strong className="text-[var(--admin-text)]">{value}</strong>
</div>
);
}
/** Live list of online users with room and activity tracking */
export function OnlineUsersWidget({
users,
isLoading = false,
@@ -122,7 +121,6 @@ export function OnlineUsersWidget({
const formatLastSeen = (date: Date) => {
const now = new Date();
const diff = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diff < 60) return "Just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
@@ -130,46 +128,37 @@ export function OnlineUsersWidget({
};
return (
<div className="bg-[var(--admin-surface)] rounded-xl border border-[var(--admin-border)] p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="m-0 text-lg font-semibold text-[var(--admin-text)]">
<div className="rounded-2xl border border-[var(--admin-border)] bg-[var(--admin-surface)] p-5 shadow-card">
<div className="mb-4 flex items-center justify-between">
<h3 className="m-0 text-sm font-bold text-[var(--admin-text)]">
Online Now
</h3>
<span className="text-sm text-[var(--admin-text-muted)] bg-[var(--admin-accent)]/10 px-3 py-1 rounded-full">
<span className="rounded-full border border-[var(--admin-border)] bg-[var(--admin-accent)]/8 px-3 py-1 text-[0.65rem] font-bold text-[var(--admin-text-muted)]">
{isLoading ? "..." : users.length} users
</span>
</div>
<div className="max-h-80 overflow-y-auto flex flex-col gap-3">
<div className="flex max-h-80 flex-col gap-2 overflow-y-auto">
{users.length === 0 ? (
<div className="py-8 text-center bg-[var(--admin-text-muted)]/5 rounded-lg">
<span className="text-[var(--admin-text-muted)] text-sm">
No users online
</span>
<div className="rounded-xl bg-[var(--admin-text-muted)]/5 px-6 py-10 text-center">
<p className="text-sm text-[var(--admin-text-muted)]">No users online</p>
</div>
) : (
users.map((user) => (
<div
key={user.id}
className="flex items-center gap-3 p-3 bg-[var(--admin-text-muted)]/5 rounded-lg transition-all duration-150 hover:bg-[var(--admin-text-muted)]/10 hover:translate-x-0.5"
className="flex items-center gap-3 rounded-xl bg-[var(--admin-text-muted)]/5 p-3 transition-all duration-150 hover:bg-[var(--admin-text-muted)]/10 hover:translate-x-1"
>
<div className="relative flex-none w-[50px] h-[62px]">
<div className="relative h-[50px] w-[50px] shrink-0 overflow-hidden rounded-xl border border-[var(--admin-border)] bg-[var(--admin-canvas)]">
<Image
src={`https://www.habbo.com/habbo-imaging/avatarimage?width=50&height=62&direction=2&headonly=1&crop=face&action=wav&gender=m&look=${encodeURIComponent(user.look)}`}
alt={user.username}
fill
className="rounded object-cover border border-[var(--admin-border)]"
className="object-cover"
unoptimized
/>
{user.roomId && user.roomName && (
<div className="absolute -bottom-0.5 -right-0.5">
<span className="block w-[10px] h-[10px] bg-[var(--admin-success-subtle)] rounded-full border-2 border-[var(--admin-surface)]" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-[var(--admin-text)] mb-1 truncate">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-[var(--admin-text)]">
<a
href={`/u/${user.username}`}
className="text-inherit no-underline hover:text-[var(--admin-accent)] hover:underline"
@@ -177,14 +166,14 @@ export function OnlineUsersWidget({
{user.username}
</a>
</div>
<div className="text-[0.75rem] text-[var(--admin-text-muted)]">
<div className="mt-0.5 text-[0.65rem] text-[var(--admin-text-muted)]">
{user.roomId && user.roomName ? (
<span className="text-[var(--admin-success)] font-medium">
🏠 {user.roomName}
<span className="font-medium text-green-500">
{user.roomName}
</span>
) : (
<span className="text-[var(--admin-error)]">
⏱️ {formatLastSeen(user.lastActivity)}
<span className="text-[var(--admin-text-muted)]">
{formatLastSeen(user.lastActivity)}
</span>
)}
</div>
+65 -51
View File
@@ -47,71 +47,85 @@ export function HomeLoginForm() {
}
return (
<form
onSubmit={onSubmit}
className="relative flex flex-col gap-6 rounded-lg bg-gray-100 p-3 dark:bg-gray-800"
>
<fieldset className="flex w-full flex-col gap-1">
<label className="block font-semibold text-gray-700 dark:text-gray-200">
Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder=""
className="focus:ring-0 relative z-10 w-full rounded border-4 border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
style={{ borderColor: "#e5e7eb", borderWidth: "4px" }}
required
disabled={needs2fa}
/>
</fieldset>
<fieldset className="flex w-full flex-col gap-1">
<label className="block font-semibold text-gray-700 dark:text-gray-200">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder=""
className="focus:ring-0 relative z-10 w-full rounded border-4 border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
style={{ borderColor: "#e5e7eb", borderWidth: "4px" }}
required
disabled={needs2fa}
/>
</fieldset>
{needs2fa ? (
<fieldset className="flex w-full flex-col gap-1">
<label className="block font-semibold text-gray-700 dark:text-gray-200">
2FA Code
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<div className="space-y-3">
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-[var(--color-text-muted-readable)] mb-1.5">
Username
</label>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Enter your 2FA code"
inputMode="numeric"
className="focus:ring-0 relative z-10 w-full rounded border-4 border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
style={{ borderColor: "#e5e7eb", borderWidth: "4px" }}
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
className="w-full rounded-lg border-2 px-3 py-2.5 text-sm transition-all duration-200 bg-[var(--color-background)]"
style={{ borderColor: "var(--input-border-color)" }}
required
disabled={needs2fa}
/>
</fieldset>
) : null}
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-[var(--color-text-muted-readable)] mb-1.5">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
className="w-full rounded-lg border-2 px-3 py-2.5 text-sm transition-all duration-200 bg-[var(--color-background)]"
style={{ borderColor: "var(--input-border-color)" }}
required
disabled={needs2fa}
/>
</div>
{needs2fa ? (
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-[var(--color-text-muted-readable)] mb-1.5">
2FA Code
</label>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Enter your 2FA code"
inputMode="numeric"
className="w-full rounded-lg border-2 px-3 py-2.5 text-sm transition-all duration-200 bg-[var(--color-background)]"
style={{ borderColor: "var(--input-border-color)" }}
autoFocus
/>
</div>
) : null}
</div>
{error ? (
<p className="m-0 text-center text-sm text-red-600 dark:text-red-400">
<p className="m-0 text-center text-sm font-medium text-red-500 dark:text-red-400">
{error}
</p>
) : null}
<button
type="submit"
disabled={pending}
className="w-full cursor-pointer rounded-md border-2 p-2 text-base font-semibold text-white transition-colors disabled:opacity-50"
className="w-full cursor-pointer rounded-xl px-4 py-3 text-sm font-bold tracking-wide text-white transition-all duration-200 hover:opacity-90 active:scale-[0.98] disabled:opacity-50"
style={{
backgroundColor: "#27a44d",
borderColor: "#34d399",
background: "linear-gradient(135deg, var(--button-color), color-mix(in srgb, var(--button-color) 80%, #000000))",
boxShadow: "0 4px 16px color-mix(in srgb, var(--button-color) 30%, transparent)",
}}
>
{pending ? "Please wait..." : needs2fa ? "Verify" : "Login"}
{pending ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Please wait...
</span>
) : needs2fa ? (
"Verify"
) : (
"Login"
)}
</button>
</form>
);
+179 -184
View File
@@ -1,14 +1,16 @@
import { ArrowRight, Lock, Newspaper, Sparkles, Users } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { HomeLoginForm } from "@/components/auth/home-login-form";
import { Button } from "@/components/ui/button";
import { avatarImageUrl, excerpt } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
export default async function GuestView() {
const t = await getTranslations("pages.home");
const _hotelName = await siteSettings.get("hotel_name", "Atom");
const hotelName = await siteSettings.get("hotel_name", "Atom");
const imager =
(await siteSettings.get(
"habbo_imaging_url",
@@ -24,7 +26,7 @@ export default async function GuestView() {
try {
articles = await prisma.websiteArticles.findMany({
orderBy: { createdAt: "desc" },
take: 8,
take: 6,
select: { slug: true, title: true, shortStory: true, image: true },
});
} catch {}
@@ -39,203 +41,196 @@ export default async function GuestView() {
} catch {}
return (
<div className="flex flex-col gap-4 md:flex-row">
<div className="flex w-full flex-col gap-4 md:w-1/3 md:flex-shrink-0">
<div
className="overflow-hidden rounded-lg border"
style={{
borderColor:
"color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
}}
>
<div className="relative h-[120px] overflow-hidden md:h-[150px]">
<div
className="absolute inset-0"
style={{
backgroundImage: "url(/assets/images/kasja_mepage_header.png)",
backgroundSize: "cover",
backgroundPosition: "center 30%",
backgroundRepeat: "no-repeat",
}}
/>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, rgba(0,0,0,0.65) 0%, rgba(0,0,0,0.15) 50%, rgba(0,0,0,0.5) 100%)",
}}
/>
<div
className="absolute -bottom-8 right-4 w-32 h-32 rounded-full opacity-[0.12] blur-[60px]"
style={{ background: "var(--color-primary)" }}
/>
<div className="flex flex-col gap-8">
{/* Hero Banner */}
<div className="animate-fade-in relative overflow-hidden rounded-2xl border border-[var(--border-subtle)] bg-gradient-to-br from-[var(--color-primary)]/10 via-[var(--color-surface)] to-[var(--color-accent)]/5 shadow-elevated">
<div className="absolute -top-20 -right-20 h-64 w-64 rounded-full opacity-[0.08] blur-[100px]" style={{ background: "var(--color-primary)" }} />
<div className="absolute -bottom-20 -left-20 h-48 w-48 rounded-full opacity-[0.06] blur-[80px]" style={{ background: "var(--color-accent)" }} />
<div className="relative flex flex-col items-center gap-6 px-8 py-10 text-center md:flex-row md:text-left">
<div className="flex-1">
<div className="mb-2 inline-flex items-center gap-1.5 rounded-full border border-[var(--border-subtle)] bg-[var(--color-surface)] px-3 py-1 text-xs font-bold uppercase tracking-wider text-[var(--color-text-muted-readable)]">
<Sparkles className="h-3 w-3 text-[var(--color-primary)]" />
Welcome to {hotelName}
</div>
<h1 className="m-0 text-3xl font-extrabold tracking-tight md:text-4xl">
Join the <span className="text-[var(--color-primary)]">Community</span>
</h1>
<p className="mt-2 max-w-md text-sm text-[var(--color-text-muted-readable)]">
Connect with friends, explore the hotel, and create unforgettable memories. Your adventure starts here.
</p>
<div className="mt-5 flex flex-wrap gap-3">
<Link href="/register">
<Button size="lg" className="font-bold gap-2 rounded-xl px-8 h-12 shadow-glow">
Get Started <ArrowRight className="h-4 w-4" />
</Button>
</Link>
<Link href="/news">
<Button variant="outline" size="lg" className="font-bold rounded-xl px-8 h-12">
Latest News
</Button>
</Link>
</div>
</div>
<div
className="p-3"
style={{ backgroundColor: "var(--color-surface)" }}
>
<HomeLoginForm />
<div className="shrink-0">
<div className="w-full max-w-xs rounded-2xl border border-[var(--border-subtle)] bg-[var(--color-surface)] shadow-elevated md:w-72">
<div className="rounded-t-2xl bg-gradient-to-r from-[var(--color-primary)]/10 to-[var(--color-primary)]/5 px-5 py-3 border-b border-[var(--border-subtle)]">
<h3 className="m-0 text-sm font-bold flex items-center gap-2">
<Lock className="h-4 w-4 text-[var(--color-primary)]" />
Sign In
</h3>
</div>
<div className="p-4">
<HomeLoginForm />
</div>
</div>
</div>
</div>
<a href="/register" className="relative block">
<div
className="inline-block overflow-hidden rounded-lg border"
style={{
borderColor:
"color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
}}
>
<Image
src="/assets/images/EnterHubbly.png"
alt="Register"
width={300}
height={100}
className="block"
unoptimized
/>
</div>
<span
className="pointer-events-none absolute inset-0 flex items-center text-4xl font-extrabold text-white text-shadow"
style={{ marginLeft: "15px" }}
>
REGISTER NOW
</span>
</a>
{recentUsers.length > 0 && (
<div
className="overflow-hidden rounded-lg border"
style={{
borderColor:
"color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
backgroundColor: "var(--color-surface)",
}}
>
<div
className="flex h-12 w-full items-center justify-center bg-repeat"
style={{
background:
"linear-gradient(140deg, #0A1F0A 0%, #0F2D0F 20%, #153C15 40%, #1B4A1B 60%, #205820 75%, #1A4A1A 90%, #143814 100%)",
}}
>
<h2 className="px-3 text-xl font-bold text-white">
Recent Users
</h2>
</div>
<div className="grid grid-cols-4 sm:grid-cols-6 gap-3 p-3">
{recentUsers.map((u) => (
<div
key={u.username}
className="relative overflow-visible rounded-md p-0 shadow-lg"
style={{
width: "50px",
height: "50px",
justifySelf: "center",
backgroundColor: "var(--color-surface)",
}}
>
<Image
src={avatarImageUrl(imager, u.look, {
headOnly: true,
direction: 3,
})}
alt={u.username}
width={50}
height={62}
style={{
position: "absolute",
top: "-12px",
left: "50%",
transform: "translateX(-50%)",
maxWidth: "none",
maxHeight: "none",
}}
unoptimized
/>
</div>
))}
</div>
</div>
)}
</div>
<div className="flex w-full flex-col gap-4 md:w-2/3">
<div
className="overflow-hidden rounded-lg border"
style={{
borderColor:
"color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
backgroundColor: "var(--color-surface)",
}}
>
<div
className="flex h-12 w-full items-center justify-center"
style={{
background:
"linear-gradient(140deg, #1a3a5c 0%, #1e4a6e 20%, #2a5a80 40%, #3a6a90 60%, #4a7aa0 75%, #3a6a90 90%, #2a5a80 100%)",
}}
>
<h2 className="text-xl font-bold text-white">
{t("latestNews") || "Latest News"}
</h2>
<div className="grid gap-6 lg:grid-cols-3">
{/* Latest News */}
<div className="lg:col-span-2 animate-slide-up">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-[var(--color-primary)]/20 to-[var(--color-primary)]/5">
<Newspaper className="h-4 w-4 text-[var(--color-primary)]" />
</div>
<h2 className="m-0 text-lg font-extrabold">{t("latestNews")}</h2>
</div>
<Link href="/news" className="text-xs font-bold text-[var(--color-primary)] hover:underline flex items-center gap-1">
View all <ArrowRight className="h-3 w-3" />
</Link>
</div>
<div className="p-4">
{articles.length === 0 ? (
<p
className="py-8 text-center"
style={{
color:
"var(--color-text-muted-readable, var(--color-text-muted))",
}}
>
{t("noArticles")}
</p>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{articles.map((a) => (
{articles.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-[var(--border-subtle)] bg-[var(--color-surface)] px-6 py-16 text-center">
<Newspaper className="mb-3 h-10 w-10 text-[var(--color-text-muted)] opacity-40" />
<p className="text-sm text-[var(--color-text-muted-readable)] m-0">{t("noArticles")}</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{articles.slice(0, 2).map((a, i) => (
<Link
key={a.slug}
href={`/news/${a.slug}`}
className="group relative overflow-hidden rounded-2xl border border-[var(--border-subtle)] bg-[var(--color-surface)] transition-all duration-300 hover:shadow-card-hover hover:-translate-y-1"
style={{ animationDelay: `${i * 100}ms` }}
>
<div className="relative aspect-[16/9] overflow-hidden">
<Image
src={a.image}
alt={a.title}
fill
className="object-cover transition-all duration-500 group-hover:scale-110"
unoptimized
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-4">
<h3 className="m-0 text-base font-bold text-white text-shadow-sm truncate">
{a.title}
</h3>
{a.shortStory && (
<p className="mt-1 truncate text-xs text-white/80 text-shadow-sm">
{excerpt(a.shortStory, 80)}
</p>
)}
</div>
</div>
</Link>
))}
{articles.slice(2, 6).map((a, i) => (
<Link
key={a.slug}
href={`/news/${a.slug}`}
className="group flex items-center gap-3 rounded-xl border border-[var(--border-subtle)] bg-[var(--color-surface)] p-3 transition-all duration-200 hover:shadow-card-hover hover:-translate-y-0.5"
>
<div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-lg">
<Image
src={a.image}
alt={a.title}
fill
className="object-cover transition-all duration-300 group-hover:scale-110"
unoptimized
/>
</div>
<div className="min-w-0 flex-1">
<h4 className="m-0 text-sm font-bold truncate">{a.title}</h4>
{a.shortStory && (
<p className="m-0 mt-0.5 truncate text-xs text-[var(--color-text-muted-readable)]">
{excerpt(a.shortStory, 60)}
</p>
)}
</div>
<ArrowRight className="h-4 w-4 shrink-0 text-[var(--color-text-muted)] opacity-0 -translate-x-2 transition-all duration-200 group-hover:opacity-100 group-hover:translate-x-0" />
</Link>
))}
</div>
)}
</div>
{/* Sidebar */}
<div className="animate-slide-up space-y-6" style={{ animationDelay: "150ms" }}>
{/* Recent Users */}
{recentUsers.length > 0 && (
<div className="rounded-2xl border border-[var(--border-subtle)] bg-[var(--color-surface)] overflow-hidden">
<div className="flex items-center gap-2.5 bg-gradient-to-r from-[var(--color-primary)]/10 to-[var(--color-primary)]/5 px-5 py-3 border-b border-[var(--border-subtle)]">
<Users className="h-4 w-4 text-[var(--color-primary)]" />
<h3 className="m-0 text-sm font-bold">Recent Users</h3>
</div>
<div className="grid grid-cols-4 gap-2 p-4">
{recentUsers.map((u) => (
<Link
key={a.slug}
href={`/news/${a.slug}`}
className="group relative w-full overflow-hidden rounded-lg transition-all duration-200"
style={{
height: "215px",
backgroundColor: "var(--color-surface)",
border:
"1px solid color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
}}
key={u.username}
href={`/u/${u.username}`}
className="group relative flex items-center justify-center transition-all duration-200 hover:scale-110"
>
<div className="relative h-full w-full overflow-hidden rounded-lg">
<div className="relative h-14 w-14 overflow-hidden rounded-xl border-2 border-transparent bg-[var(--color-background)] transition-all duration-200 group-hover:border-[var(--color-primary)]/30 group-hover:shadow-lg">
<Image
src={a.image}
alt={a.title}
fill
className="rounded-lg object-cover transition-all duration-300 group-hover:scale-105"
src={avatarImageUrl(imager, u.look, {
headOnly: true,
direction: 3,
})}
alt={u.username}
width={56}
height={70}
style={{
position: "absolute",
top: "-8px",
left: "50%",
transform: "translateX(-50%)",
maxWidth: "none",
}}
unoptimized
/>
<div
className="absolute left-0 w-full p-2"
style={{
bottom: "-2px",
background:
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, transparent 100%)",
}}
>
<h3 className="truncate text-lg font-semibold text-white text-shadow-sm">
{a.title}
</h3>
{a.shortStory && (
<p className="truncate text-xs font-normal text-white text-shadow-sm">
{excerpt(a.shortStory, 80)}
</p>
)}
</div>
</div>
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-full bg-[var(--color-surface)] px-2 py-0.5 text-[0.55rem] font-bold text-[var(--color-text-muted)] opacity-0 shadow-lg transition-all duration-200 group-hover:opacity-100">
{u.username}
</span>
</Link>
))}
</div>
)}
</div>
</div>
)}
{/* Quick Register CTA */}
<Link href="/register" className="group block">
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary)]/80 px-6 py-8 text-center transition-all duration-300 hover:shadow-glow hover:-translate-y-1">
<div className="absolute -top-10 -right-10 h-32 w-32 rounded-full bg-white/10 blur-3xl" />
<div className="absolute -bottom-10 -left-10 h-24 w-24 rounded-full bg-white/10 blur-3xl" />
<div className="relative">
<Sparkles className="mx-auto h-8 w-8 text-white/80" />
<h3 className="mt-3 text-xl font-extrabold text-white">
Join {hotelName} Today
</h3>
<p className="mt-1 text-sm text-white/80">
Create your free account and start your adventure!
</p>
<div className="mt-4 inline-flex items-center gap-2 rounded-xl bg-white/20 px-6 py-2.5 text-sm font-bold text-white backdrop-blur-sm transition-all duration-200 group-hover:bg-white/30">
Register Now <ArrowRight className="h-4 w-4" />
</div>
</div>
</div>
</Link>
</div>
</div>
</div>
+105 -178
View File
@@ -1,3 +1,4 @@
import { ArrowRight, Gamepad2, Newspaper, Sparkles, Users } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
@@ -53,192 +54,109 @@ export default async function UserView({
.catch(() => []);
return (
<div className="flex flex-col gap-6 md:flex-row">
<div className="flex w-full flex-col gap-6 md:w-3/4">
<div className="relative flex h-[340px] items-start overflow-hidden rounded-lg">
<div
className="absolute inset-0"
style={{
backgroundImage: "url(/assets/images/kasja_mepage_header.png)",
backgroundSize: "cover",
backgroundPosition: "center 30%",
backgroundRepeat: "no-repeat",
}}
/>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.15) 40%, rgba(0,0,0,0.25) 65%, rgba(0,0,0,0.6) 100%)",
}}
/>
<div
className="absolute -bottom-10 -right-10 w-64 h-64 rounded-full opacity-[0.15] blur-[80px]"
style={{ background: "var(--color-primary)" }}
/>
<div
className="absolute -top-10 -left-10 w-48 h-48 rounded-full opacity-[0.08] blur-[60px]"
style={{ background: "var(--color-primary)" }}
/>
<div className="relative z-10 mx-auto mt-8 flex w-[95%] max-w-3xl items-center justify-between rounded-2xl px-8 py-6"
style={{
background: "linear-gradient(135deg, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.2) 100%)",
boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
}}
>
<div className="flex items-center gap-6">
<Link
href={`/u/${username}`}
className="transition-transform duration-300 hover:scale-105"
>
<div
className="rounded-full p-[3px]"
style={{
border: "3px solid var(--panel-border-color)",
boxShadow:
"0 0 24px color-mix(in srgb, var(--panel-border-color) 50%, transparent)",
}}
>
<div className="flex flex-col gap-6 lg:flex-row">
{/* Main Content */}
<div className="flex w-full flex-col gap-6 lg:w-3/4">
{/* Hero/Welcome Banner */}
<div className="animate-fade-in relative overflow-hidden rounded-2xl border border-[var(--border-subtle)] bg-gradient-to-br from-[var(--color-primary)]/10 via-[var(--color-surface)] to-[var(--color-accent)]/5 shadow-elevated">
<div className="absolute -top-10 -right-10 h-48 w-48 rounded-full opacity-[0.1] blur-[80px]" style={{ background: "var(--color-primary)" }} />
<div className="absolute -bottom-10 -left-10 h-36 w-36 rounded-full opacity-[0.06] blur-[60px]" style={{ background: "var(--color-accent)" }} />
<div className="relative flex flex-col items-center gap-6 px-6 py-8 md:flex-row md:px-8">
<div className="shrink-0">
<div className="relative">
<div className="rounded-2xl bg-gradient-to-br from-[var(--color-primary)]/20 to-[var(--color-primary)]/5 p-1 shadow-lg">
<Image
src={avatarImageUrl(imager, look, { size: "l", direction: 2 })}
alt={username}
width={110}
height={190}
className="drop-shadow-2xl"
width={120}
height={200}
className="block drop-shadow-xl"
style={{ imageRendering: "pixelated" }}
unoptimized
/>
</div>
</Link>
<div className="flex flex-col gap-1.5 rounded-xl px-4 py-2"
style={{
background: "color-mix(in srgb, var(--color-surface) 88%, transparent)",
backdropFilter: "blur(8px)",
}}
>
<Link
href={`/u/${username}`}
className="text-2xl font-bold drop-shadow-lg hover:underline"
style={{ color: "var(--color-text)" }}
>
{username}
</Link>
<p className="text-sm drop-shadow-md max-w-[240px] leading-tight"
style={{ color: "color-mix(in srgb, var(--color-text) 80%, transparent)" }}
>
{motto || "Welcome back!"}
</p>
<div className="absolute -bottom-2 -right-2 flex h-7 w-7 items-center justify-center rounded-full bg-green-500 shadow-lg ring-2 ring-[var(--color-surface)]">
<span className="h-3 w-3 rounded-full bg-green-200 animate-ping absolute" />
<span className="h-3 w-3 rounded-full bg-white relative" />
</div>
</div>
</div>
<div className="flex items-center">
<Link
href="/client"
target="_blank"
rel="noopener noreferrer"
prefetch={true}
>
<button
type="button"
className="group rounded-full px-7 py-3 text-sm font-bold tracking-wider transition-all duration-300 hover:scale-105 active:scale-95"
style={{
background:
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 92%, white), color-mix(in srgb, var(--color-primary) 92%, black))",
color:
"var(--button-text-color-readable, var(--button-text-color))",
border:
"1px solid color-mix(in srgb, var(--color-primary) 60%, rgba(255,255,255,0.2))",
boxShadow:
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent), inset 0 1px 0 rgba(255,255,255,0.2)",
}}
>
{t("enterHotel", { hotelName })}
<svg
className="inline-block ml-2 w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</Link>
<div className="flex-1 text-center md:text-left">
<h1 className="m-0 text-2xl font-extrabold tracking-tight">
Welcome back, <span className="text-[var(--color-primary)]">{username}</span>
</h1>
<p className="mt-1 text-sm text-[var(--color-text-muted-readable)]">
{motto || "Happy to see you again!"}
</p>
<div className="mt-4 flex flex-wrap justify-center gap-3 md:justify-start">
<Link href="/client" target="_blank" rel="noopener noreferrer">
<div className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary)]/80 px-6 py-2.5 text-sm font-bold text-[var(--button-text-color-readable)] shadow-lg shadow-[var(--color-primary)]/30 transition-all duration-200 hover:scale-105 hover:shadow-glow active:scale-95">
<Gamepad2 size={18} />
{t("enterHotel", { hotelName })}
<ArrowRight size={16} className="transition-transform group-hover:translate-x-0.5" />
</div>
</Link>
<Link href={`/u/${username}`}>
<div className="inline-flex items-center gap-2 rounded-xl border-2 border-[var(--border-subtle)] bg-[var(--color-surface)] px-5 py-2.5 text-sm font-bold text-[var(--color-text-readable)] transition-all duration-200 hover:border-[var(--color-primary)]/30 hover:shadow-md">
View Profile
</div>
</Link>
</div>
</div>
</div>
</div>
<div
className="flex flex-col gap-3 rounded-lg border p-1 lg:flex-row"
style={{
backgroundColor: "var(--color-surface)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
}}
>
<div
className="relative flex items-center justify-center rounded p-2 text-sm font-semibold"
style={{ backgroundColor: "var(--panel-border-color)" }}
>
<span className="relative text-white">{t("onlineFriends")}</span>
</div>
<div className="flex items-center gap-3 px-3">
{onlineFriends.length === 0 ? (
<p
className="mb-3 block w-full text-center text-xs font-medium md:mb-0 md:text-left"
style={{
color:
"var(--color-text-muted-readable, var(--color-text-muted))",
}}
>
{t("noFriendsOnline")}
</p>
) : (
onlineFriends.map((f) => (
<Link
key={f.username}
href={`/u/${f.username}`}
className="transition-all duration-200 hover:scale-110"
>
<Image
src={avatarImageUrl(imager, f.look, { headOnly: true })}
alt={f.username}
width={40}
height={40}
className="h-10 w-10"
title={f.username}
unoptimized
/>
</Link>
))
)}
{/* Online Friends Bar */}
<div className="animate-slide-up rounded-2xl border border-[var(--border-subtle)] bg-[var(--color-surface)] p-1">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
<div className="flex shrink-0 items-center gap-2 rounded-xl bg-gradient-to-r from-[var(--color-primary)]/10 to-[var(--color-primary)]/5 px-4 py-2.5">
<div className="relative flex h-2.5 w-2.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-green-500" />
</div>
<span className="text-xs font-bold text-[var(--color-text-readable)]">{t("onlineFriends")}</span>
</div>
<div className="flex flex-wrap items-center gap-2 px-3 py-1">
{onlineFriends.length === 0 ? (
<p className="m-0 text-xs font-medium text-[var(--color-text-muted-readable)]">
{t("noFriendsOnline")}
</p>
) : (
onlineFriends.map((f) => (
<Link
key={f.username}
href={`/u/${f.username}`}
className="group relative transition-all duration-200 hover:scale-110"
>
<div className="overflow-hidden rounded-xl border-2 border-transparent bg-[var(--color-background)] transition-all group-hover:border-green-500/30">
<Image
src={avatarImageUrl(imager, f.look, { headOnly: true })}
alt={f.username}
width={36}
height={36}
className="block"
title={f.username}
unoptimized
/>
</div>
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-full bg-[var(--color-surface)] px-2 py-0.5 text-[0.5rem] font-bold text-[var(--color-text-muted)] opacity-0 shadow-lg transition-all group-hover:opacity-100">
{f.username}
</span>
</Link>
))
)}
</div>
</div>
</div>
</div>
<div className="flex w-full flex-col gap-6 md:w-1/4">
{/* Sidebar */}
<div className="flex w-full flex-col gap-6 lg:w-1/4">
{latestArticle ? (
<div
className="overflow-hidden rounded-lg border"
style={{
backgroundColor: "var(--color-surface)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
}}
>
<div
className="flex h-12 items-center justify-center"
style={{
background:
"linear-gradient(140deg, #1a3a5c 0%, #1e4a6e 20%, #2a5a80 40%, #3a6a90 60%, #4a7aa0 75%, #3a6a90 90%, #2a5a80 100%)",
}}
>
<h2 className="text-lg font-bold text-white">
{t("latestArticle")}
</h2>
<div className="animate-slide-up overflow-hidden rounded-2xl border border-[var(--border-subtle)] bg-[var(--color-surface)] shadow-card transition-all duration-200 hover:shadow-card-hover">
<div className="flex items-center gap-2 bg-gradient-to-r from-[var(--color-primary)]/10 to-[var(--color-primary)]/5 px-4 py-3 border-b border-[var(--border-subtle)]">
<Newspaper className="h-4 w-4 text-[var(--color-primary)]" />
<h2 className="m-0 text-sm font-bold">{t("latestArticle")}</h2>
</div>
<Link href={`/news/${latestArticle.slug}`} className="group block">
<div className="relative aspect-[16/9] overflow-hidden">
@@ -246,27 +164,36 @@ export default async function UserView({
src={latestArticle.image}
alt={latestArticle.title}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
className="object-cover transition-transform duration-500 group-hover:scale-110"
unoptimized
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
</div>
<div className="p-3">
<h3 className="text-sm font-semibold">{latestArticle.title}</h3>
<div className="p-4">
<h3 className="text-sm font-bold leading-tight">{latestArticle.title}</h3>
{latestArticle.shortStory && (
<p
className="mt-1 text-xs"
style={{
color:
"var(--color-text-muted-readable, var(--color-text-muted))",
}}
>
<p className="mt-1.5 text-xs text-[var(--color-text-muted-readable)] leading-relaxed">
{excerpt(latestArticle.shortStory, 100)}
</p>
)}
<div className="mt-3 flex items-center gap-1 text-xs font-bold text-[var(--color-primary)]">
Read more <ArrowRight size={12} />
</div>
</div>
</Link>
</div>
) : null}
{/* Quick Stats */}
<div className="animate-slide-up rounded-2xl border border-[var(--border-subtle)] bg-gradient-to-br from-[var(--color-primary)]/10 to-[var(--color-accent)]/5 p-5 text-center" style={{ animationDelay: "100ms" }}>
<Sparkles className="mx-auto h-6 w-6 text-[var(--color-primary)]" />
<p className="mt-2 text-sm font-bold text-[var(--color-text-readable)]">
Explore the Hotel
</p>
<p className="mt-1 text-xs text-[var(--color-text-muted-readable)]">
Check out the latest news, visit the marketplace, or chat with friends!
</p>
</div>
</div>
</div>
);
+5 -5
View File
@@ -15,13 +15,13 @@ export async function Navigation() {
return (
<nav
className="sticky top-0 z-40"
className="sticky top-0 z-40 border-b border-[var(--border-subtle)]"
style={{ backgroundColor: "var(--color-navbar)" }}
>
<div className="relative mx-auto flex min-h-[56px] max-w-7xl items-center px-4 md:px-6 gap-x-2">
{/* Primary accent bar */}
<div
className="absolute bottom-0 left-0 right-0 h-[3px]"
style={{ backgroundColor: "var(--color-primary, #eeb425)" }}
className="absolute bottom-0 left-0 right-0 h-[3px] bg-gradient-to-r from-transparent via-[var(--color-primary)] to-transparent opacity-70"
/>
{/* Desktop: home link */}
@@ -161,7 +161,7 @@ export async function Navigation() {
) : null}
</div>
{/* Mobile: hamburger menu (details/summary) */}
{/* Mobile: hamburger menu */}
<MobileNav
menuLabel={t("openMenu") || "Open menu"}
closeLabel={t("closeMenu") || "Close menu"}
@@ -310,7 +310,7 @@ export async function Navigation() {
</div>
</MobileNav>
{/* Mobile: brand (right) */}
{/* Mobile: brand */}
<Link
href="/"
className="ml-auto flex md:hidden items-center gap-2 shrink-0"
+6 -11
View File
@@ -34,7 +34,6 @@ export async function SiteHeader() {
className="site-header relative w-full overflow-hidden"
style={{ minHeight: "clamp(12rem, 30vw, 18rem)" }}
>
{/* Background image */}
<div
className="absolute inset-0"
style={{ background: `url(${bannerUrl}) center/cover no-repeat` }}
@@ -49,31 +48,28 @@ export async function SiteHeader() {
}}
/>
{/* Subtle dot pattern */}
<div
className="absolute inset-0 opacity-[0.04]"
style={{
backgroundImage:
"radial-gradient(rgba(255,255,255,0.5) 1px, transparent 1px)",
"radial-gradient(rgba(255,255,255,0.8) 1px, transparent 1px)",
backgroundSize: "20px 20px",
}}
/>
{/* Decorative glows */}
<div
className="absolute -top-24 -right-24 w-96 h-96 rounded-full opacity-[0.12] blur-[120px]"
className="absolute -top-24 -right-24 w-96 h-96 rounded-full opacity-[0.15] blur-[120px]"
style={{ background: "var(--color-primary)" }}
/>
<div
className="absolute -bottom-24 -left-24 w-72 h-72 rounded-full opacity-[0.08] blur-[100px]"
className="absolute -bottom-24 -left-24 w-72 h-72 rounded-full opacity-[0.1] blur-[100px]"
style={{ background: "var(--color-primary)" }}
/>
{/* Content */}
<div className="relative flex flex-col items-center justify-center px-4 py-8 md:py-12 min-h-[clamp(12rem,30vw,18rem)]">
<Link
href="/"
className="transition-transform duration-300 hover:scale-105 mb-8"
className="transition-all duration-500 hover:scale-105 hover:brightness-110 mb-8"
>
{logo ? (
<Image
@@ -95,14 +91,13 @@ export async function SiteHeader() {
letterSpacing: "-0.02em",
}}
>
{hotelName} - Hotel
{hotelName}
</h1>
)}
</Link>
{/* Online badge */}
<div
className="flex items-center gap-3 px-6 py-3 rounded-full"
className="flex items-center gap-3 px-6 py-3 rounded-full transition-all duration-300 hover:bg-white/10"
style={{
background: "rgba(255,255,255,0.08)",
backdropFilter: "blur(16px)",
+5 -12
View File
@@ -3,11 +3,6 @@
import { Moon, Sun } from "lucide-react";
import { useEffect, useState } from "react";
/**
* Light/dark toggle. The class lives on <html>; theme-init.js applies the
* saved choice before paint. We sync React state after mount so SSR hydration
* does not desync from the real DOM class.
*/
export function ThemeSwitcher({
variant = "nav",
}: {
@@ -28,7 +23,7 @@ export function ThemeSwitcher({
try {
localStorage.setItem("theme", next ? "dark" : "light");
} catch {
/* private mode / storage disabled — runtime toggle still works */
/* private mode / storage disabled */
}
}
@@ -43,10 +38,10 @@ export function ThemeSwitcher({
aria-label={label}
title={title}
suppressHydrationWarning
className="flex-none grid place-items-center w-9 h-9 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-surface-elevated)] text-[var(--admin-text)] hover:bg-[var(--admin-accent)]/15 hover:text-[var(--admin-accent)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]"
className="flex-none grid place-items-center w-9 h-9 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-surface-elevated)] text-[var(--admin-text)] hover:bg-[var(--admin-accent)]/15 hover:text-[var(--admin-accent)] transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)] active:scale-95"
style={{ visibility: ready ? "visible" : "hidden" }}
>
{dark ? <Sun size={16} /> : <Moon size={16} />}
{dark ? <Sun size={15} /> : <Moon size={15} />}
</button>
);
}
@@ -55,15 +50,13 @@ export function ThemeSwitcher({
<button
type="button"
onClick={toggle}
className="nav-item cursor-pointer text-lg leading-none px-2"
aria-label={label}
title={title}
suppressHydrationWarning
className="flex items-center justify-center w-9 h-9 rounded-xl text-base transition-all duration-200 hover:bg-[var(--color-primary)]/10 hover:text-[var(--color-primary)] active:scale-90"
style={{ visibility: ready ? "visible" : "hidden" }}
>
<span aria-hidden className="text-lg leading-none">
{dark ? "☀" : "☾"}
</span>
{dark ? <Sun size={16} /> : <Moon size={16} />}
</button>
);
}
+27 -30
View File
@@ -1,3 +1,4 @@
import { LogOut, Shield, User } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { auth, signOut } from "@/lib/auth";
@@ -16,22 +17,20 @@ function Currency({
}) {
const textColor =
"var(--color-navbar-text-readable, var(--color-navbar-text))";
const valueColor =
"var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))";
return (
<div className="flex gap-x-1 sm:gap-x-2 items-center">
<div className="flex gap-x-1.5 sm:gap-x-2.5 items-center">
<div
className={`h-[20px] w-[20px] sm:h-[25px] sm:w-[25px] rounded-full outline-offset-[3px] ${icon}`}
className={`h-[22px] w-[22px] sm:h-[26px] sm:w-[26px] rounded-full outline-offset-[3px] ${icon}`}
/>
<div style={{ color: textColor }}>
<span
className="font-semibold text-xs sm:text-sm"
style={{ color: valueColor }}
className="font-extrabold text-xs sm:text-sm"
style={{ color: textColor }}
>
{amount.toLocaleString()}
</span>{" "}
<span className="hidden sm:inline text-xs">{label}</span>
<span className="hidden sm:inline text-xs font-bold opacity-70">{label}</span>
</div>
</div>
);
@@ -60,9 +59,7 @@ export async function TopHeader() {
if (c.type === 0) duckets = c.amount;
if (c.type === 5) diamonds = c.amount;
}
} catch {
// render without live balances
}
} catch {}
const imager =
(await siteSettings.get(
@@ -75,9 +72,9 @@ export async function TopHeader() {
"var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))";
return (
<div style={{ backgroundColor: "var(--color-navbar)" }}>
<div className="border-b border-[var(--border-subtle)]" style={{ backgroundColor: "var(--color-navbar)" }}>
<div className="max-w-7xl min-h-[44px] sm:min-h-[60px] px-2 sm:px-4 flex items-center justify-between mx-auto">
<div className="flex flex-wrap gap-x-2 sm:gap-x-6">
<div className="flex flex-wrap gap-x-3 sm:gap-x-8">
<Currency icon="nav-credit-icon" amount={credits} label="Credits" />
<Currency icon="nav-ducket-icon" amount={duckets} label="Duckets" />
<Currency
@@ -87,19 +84,20 @@ export async function TopHeader() {
/>
</div>
<div className="flex items-center gap-x-1 sm:gap-x-3">
<div className="flex items-center gap-x-2 sm:gap-x-4">
{isStaff ? (
<details className="relative">
<summary
className="list-none cursor-pointer font-semibold text-xs sm:text-sm [&::-webkit-details-marker]:hidden"
className="list-none cursor-pointer font-extrabold text-xs sm:text-sm [&::-webkit-details-marker]:hidden flex items-center gap-1.5 rounded-xl px-3 py-1.5 transition-colors hover:bg-[var(--color-primary)]/10"
style={{ color: textColor }}
>
<Shield size={14} className="text-[var(--color-primary)]" />
<span className="hidden sm:inline">Administration</span>
<span className="sm:hidden">Admin</span>
</summary>
<div
role="menu"
className="dropdown-menu absolute right-0 top-full mt-0.5 min-w-[180px] rounded-xl z-50 py-2"
className="dropdown-menu absolute right-0 top-full mt-1 min-w-[200px] rounded-xl z-50 py-2 shadow-elevated"
>
<Link href="/admin" className="dropdown-item" role="menuitem">
Admin panel
@@ -128,7 +126,7 @@ export async function TopHeader() {
<details className="relative">
<summary
aria-label="User menu"
className="list-none cursor-pointer flex items-center [&::-webkit-details-marker]:hidden"
className="list-none cursor-pointer flex items-center [&::-webkit-details-marker]:hidden rounded-xl transition-colors hover:bg-[var(--color-primary)]/10 px-2 py-1"
>
<Image
className="bg-no-repeat bg-center"
@@ -139,7 +137,7 @@ export async function TopHeader() {
unoptimized
/>
<span
className="hidden sm:inline -ml-2 font-semibold"
className="hidden sm:inline -ml-2 font-bold text-sm"
style={{ color: textColor }}
>
{session.user.name}
@@ -147,9 +145,16 @@ export async function TopHeader() {
</summary>
<div
role="menu"
className="dropdown-menu fixed sm:absolute right-2 sm:right-0 top-auto sm:top-full mt-0.5 min-w-[200px] rounded-xl z-50 py-2"
className="dropdown-menu fixed sm:absolute right-2 sm:right-0 top-auto sm:top-full mt-0.5 min-w-[220px] rounded-xl z-50 py-2 shadow-elevated"
>
<div className="px-4 py-2 border-b border-[var(--border-subtle)] mb-1">
<div className="text-sm font-bold">{session.user.name}</div>
<div className="text-[0.6rem] font-bold uppercase tracking-wider text-[var(--color-text-muted-readable)]">
{session.user.email || "Player"}
</div>
</div>
<Link href="/me" className="dropdown-item" role="menuitem">
<User size={14} className="inline mr-2" />
My dashboard
</Link>
<Link
@@ -157,6 +162,7 @@ export async function TopHeader() {
className="dropdown-item"
role="menuitem"
>
<User size={14} className="inline mr-2" />
My profile
</Link>
<Link href="/settings" className="dropdown-item" role="menuitem">
@@ -176,13 +182,7 @@ export async function TopHeader() {
>
Login activity
</Link>
<div
className="h-px my-1 mx-3"
style={{
backgroundColor:
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
}}
/>
<div className="h-px my-1 mx-3 bg-[var(--border-subtle)]" />
<form
action={async () => {
"use server";
@@ -192,16 +192,13 @@ export async function TopHeader() {
<button
type="submit"
role="menuitem"
className="dropdown-item w-full text-center mx-1 rounded-lg"
className="dropdown-item w-full text-center mx-1 rounded-lg flex items-center gap-2 justify-center"
style={{
backgroundColor:
"color-mix(in srgb, var(--button-danger-color) 12%, transparent)",
color: "var(--button-danger-color)",
border: "none",
cursor: "pointer",
width: "calc(100% - 8px)",
}}
>
<LogOut size={14} />
Logout
</button>
</form>