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
Local Build and Deploy / deploy (push) Successful in 46s
This commit is contained in:
1 parent
ac1b412756
commit
3c4aa50fea
12 files changed
+773
-780
No files matched your search
+151
-103
@@ -1,11 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { KeyRound, Lock, Mail } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { type FormEvent, useState } from "react";
|
import { type FormEvent, useState } from "react";
|
||||||
import { precheckLogin } from "@/actions/auth-precheck";
|
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() {
|
export default function LoginPage() {
|
||||||
const t = useTranslations("pages.login");
|
const t = useTranslations("pages.login");
|
||||||
@@ -51,108 +53,154 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ maxWidth: 420, margin: "2rem auto" }}>
|
<div className="flex min-h-[70vh] items-center justify-center px-4 py-12">
|
||||||
<ContentCard
|
<div className="w-full max-w-md animate-fade-in">
|
||||||
icon={needs2fa ? "🔒" : "🔑"}
|
<div className="text-center mb-8">
|
||||||
title={t("title")}
|
<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">
|
||||||
subtitle={needs2fa ? t("subtitle2fa") : t("subtitle")}
|
<Lock className="h-8 w-8 text-[var(--color-primary)]" />
|
||||||
>
|
</div>
|
||||||
<form onSubmit={onSubmit} style={{ display: "grid", gap: "0.7rem" }}>
|
<h1 className="text-2xl font-extrabold tracking-tight m-0">
|
||||||
<input
|
{t("title")}
|
||||||
value={username}
|
</h1>
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
<p className="mt-1.5 text-sm text-[var(--color-text-muted-readable)]">
|
||||||
placeholder={t("usernamePlaceholder")}
|
{needs2fa ? t("subtitle2fa") : t("subtitle")}
|
||||||
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}
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
</div>
|
||||||
<p
|
|
||||||
className="muted"
|
<Card className="border-[var(--border-subtle)] shadow-elevated">
|
||||||
style={{ textAlign: "center", marginBottom: 0, marginTop: "1rem" }}
|
<CardHeader className="px-6 pt-6 pb-0">
|
||||||
>
|
<CardTitle className="text-base font-bold">
|
||||||
{t("noAccount")} <Link href="/register">{t("createOne")}</Link> ·{" "}
|
{needs2fa ? t("subtitle2fa") : t("subtitle")}
|
||||||
<Link href="/forgot">{t("forgotPassword")}</Link>
|
</CardTitle>
|
||||||
</p>
|
<CardDescription>
|
||||||
</ContentCard>
|
{needs2fa ? "Enter your authentication code" : "Sign in to your account"}
|
||||||
</main>
|
</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
@@ -1,4 +1,4 @@
|
|||||||
import { LogOut } from "lucide-react";
|
import { LayoutDashboard, LogOut, Shield } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
@@ -35,7 +35,7 @@ export default async function AdminLayout({
|
|||||||
<AdminMobileWrapper sidebar={<Sidebar staff={staff} />}>
|
<AdminMobileWrapper sidebar={<Sidebar staff={staff} />}>
|
||||||
<div
|
<div
|
||||||
data-admin
|
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)" }}
|
style={{ backgroundColor: "var(--admin-canvas)" }}
|
||||||
>
|
>
|
||||||
<Topbar staff={staff} />
|
<Topbar staff={staff} />
|
||||||
@@ -60,39 +60,52 @@ async function Sidebar({
|
|||||||
data-admin
|
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"
|
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
|
<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
|
aria-hidden
|
||||||
>
|
>
|
||||||
{initial}
|
{initial}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<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}
|
{staff.username}
|
||||||
</div>
|
</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 })}
|
{t("rankLabel", { rank: staff.rank })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<LanguageSwitcher variant="admin" />
|
<LanguageSwitcher variant="admin" />
|
||||||
<ThemeSwitcher variant="admin" />
|
<ThemeSwitcher variant="admin" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
<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) => {
|
{ADMIN_NAV_GROUPS.map((group, gi) => {
|
||||||
const GroupIcon = group.icon;
|
const GroupIcon = group.icon;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={group.labelKey}
|
key={group.labelKey}
|
||||||
className={
|
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">
|
<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={14} />
|
<GroupIcon size={12} />
|
||||||
<span>{t(group.labelKey)}</span>
|
<span>{t(group.labelKey)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
@@ -115,10 +128,11 @@ async function Sidebar({
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</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
|
<Link
|
||||||
href="/"
|
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} />
|
<LogOut size={14} />
|
||||||
<span>{t("backToSite")}</span>
|
<span>{t("backToSite")}</span>
|
||||||
@@ -136,25 +150,35 @@ async function Topbar({
|
|||||||
const t = await getTranslations("pages.admin.nav");
|
const t = await getTranslations("pages.admin.nav");
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center gap-3">
|
||||||
<h2 className="m-0 text-base font-extrabold text-[var(--admin-text)]">
|
<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">
|
||||||
{t("housekeeping")}
|
<LayoutDashboard size={18} className="text-[var(--admin-accent)]" />
|
||||||
</h2>
|
</div>
|
||||||
<span className="hidden sm:inline-block w-px h-5 bg-[var(--admin-border)]" />
|
<div>
|
||||||
<span className="hidden sm:inline text-xs text-[var(--admin-text-muted)]">
|
<h2 className="m-0 text-sm font-extrabold text-[var(--admin-text)]">
|
||||||
{t("hotelManagementPanel")}
|
{t("housekeeping")}
|
||||||
</span>
|
</h2>
|
||||||
|
<span className="hidden text-[0.65rem] font-medium text-[var(--admin-text-muted)] sm:block">
|
||||||
|
{t("hotelManagementPanel")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<LanguageSwitcher variant="admin" />
|
<div className="hidden items-center gap-2 sm:flex">
|
||||||
<ThemeSwitcher variant="admin" />
|
<LanguageSwitcher variant="admin" />
|
||||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
<ThemeSwitcher variant="admin" />
|
||||||
<strong className="text-[var(--admin-text)]">{staff.username}</strong>
|
</div>
|
||||||
</span>
|
<div className="h-6 w-px bg-[var(--admin-border)]" />
|
||||||
<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">
|
<div className="flex items-center gap-2.5">
|
||||||
{t("rankLabel", { rank: staff.rank })}
|
<span className="hidden text-xs font-bold text-[var(--admin-text-muted)] sm:block">
|
||||||
</span>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
+68
-46
@@ -4,6 +4,8 @@ import {
|
|||||||
Ban,
|
Ban,
|
||||||
Database,
|
Database,
|
||||||
Newspaper,
|
Newspaper,
|
||||||
|
Sparkles,
|
||||||
|
TrendingUp,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -69,22 +71,36 @@ export default async function AdminDashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main className="animate-fade-in">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
{/* Header */}
|
||||||
<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">
|
<div className="mb-8 flex items-center justify-between">
|
||||||
<Activity size={20} className="text-[var(--admin-accent)]" />
|
<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>
|
||||||
<div>
|
<div className="hidden items-center gap-2 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 sm:flex">
|
||||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
<Sparkles size={14} className="text-[var(--admin-accent)]" />
|
||||||
{t("title")}
|
<span className="text-xs font-bold text-[var(--admin-text-muted)]">
|
||||||
</h1>
|
System Online
|
||||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
</span>
|
||||||
{t("subtitle")}
|
<span className="relative flex h-2 w-2">
|
||||||
</p>
|
<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>
|
</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
|
<StatusCard
|
||||||
label={t("playersOnline")}
|
label={t("playersOnline")}
|
||||||
value={online}
|
value={online}
|
||||||
@@ -112,17 +128,24 @@ export default async function AdminDashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
{/* Analytics Grid */}
|
||||||
<section className="admin-card p-5">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
<h2 className="text-sm font-bold text-[var(--admin-text)] mb-4 admin-section-title">
|
{/* Users by Rank */}
|
||||||
{t("usersByRank")}
|
<section className="admin-card overflow-hidden">
|
||||||
</h2>
|
<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 ? (
|
{rankCounts.length === 0 ? (
|
||||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||||
{dbOk ? t("noAccountsToBreakDown") : t("databaseDown")}
|
{dbOk ? t("noAccountsToBreakDown") : t("databaseDown")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-3">
|
||||||
{rankCounts.map((r) => {
|
{rankCounts.map((r) => {
|
||||||
const pct =
|
const pct =
|
||||||
maxRankCount > 0
|
maxRankCount > 0
|
||||||
@@ -132,21 +155,14 @@ export default async function AdminDashboard() {
|
|||||||
<div
|
<div
|
||||||
key={r.rank}
|
key={r.rank}
|
||||||
className="grid items-center gap-3"
|
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 })}
|
{t("rankLabel", { rank: r.rank })}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div className="h-2.5 overflow-hidden rounded-full bg-[var(--admin-accent)]/10 ring-1 ring-[var(--admin-accent)]/5">
|
||||||
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
|
<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={{
|
style={{
|
||||||
width: `${Math.max(pct, r.count > 0 ? 4 : 0)}%`,
|
width: `${Math.max(pct, r.count > 0 ? 4 : 0)}%`,
|
||||||
}}
|
}}
|
||||||
@@ -162,48 +178,54 @@ export default async function AdminDashboard() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-card p-5">
|
{/* Recent Staff Activity */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<section className="admin-card overflow-hidden">
|
||||||
<h2 className="text-sm font-bold text-[var(--admin-text)] admin-section-title">
|
<div className="mb-5 flex items-center justify-between border-b border-[var(--admin-border)] pb-4">
|
||||||
{t("recentStaffActivity")}
|
<div className="flex items-center gap-3">
|
||||||
</h2>
|
<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
|
<Link
|
||||||
href="/admin/logs"
|
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} />
|
{t("viewAll")} <ArrowRight size={12} />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{activity.length === 0 ? (
|
{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")}
|
{t("noStaffActivityLogged")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-0">
|
<div className="-mx-1">
|
||||||
{activity.map((a, i) => (
|
{activity.map((a, i) => (
|
||||||
<div
|
<div
|
||||||
key={String(a.id)}
|
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
|
<Activity
|
||||||
size={12}
|
size={13}
|
||||||
className="text-[var(--admin-accent)]"
|
className="text-[var(--admin-accent)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-sm font-medium text-[var(--admin-text)] truncate">
|
<div className="truncate text-sm font-bold text-[var(--admin-text)]">
|
||||||
{nameById.get(Number(a.userId)) ?? `#${a.userId}`}
|
{nameById.get(Number(a.userId)) ?? `#${a.userId}`}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-[var(--admin-text-muted)] truncate">
|
<div className="truncate text-xs text-[var(--admin-text-muted)]">
|
||||||
{a.description}
|
{a.description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right flex-none">
|
<div className="shrink-0 text-right">
|
||||||
<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]">
|
<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}
|
{a.action}
|
||||||
</span>
|
</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)}
|
{fromDate(a.createdAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+57
-68
@@ -14,10 +14,11 @@
|
|||||||
--radius-md: 12px;
|
--radius-md: 12px;
|
||||||
--radius-lg: 16px;
|
--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: 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 {
|
:root {
|
||||||
--color-primary: #eeb425;
|
--color-primary: #eeb425;
|
||||||
--color-primary-hover: #d4a01f;
|
--color-primary-hover: #d4a01f;
|
||||||
@@ -86,7 +87,6 @@
|
|||||||
--border-subtle: color-mix(in srgb, var(--color-text-muted) 18%, transparent);
|
--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 input,
|
||||||
html.dark select,
|
html.dark select,
|
||||||
html.dark textarea {
|
html.dark textarea {
|
||||||
@@ -114,7 +114,6 @@ body {
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile-friendly improvements for every screen */
|
|
||||||
button,
|
button,
|
||||||
a,
|
a,
|
||||||
input,
|
input,
|
||||||
@@ -126,12 +125,10 @@ summary,
|
|||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Momentum scrolling on iOS */
|
|
||||||
* {
|
* {
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Prevent iOS zoom on input focus */
|
|
||||||
@media (max-width: 48rem) {
|
@media (max-width: 48rem) {
|
||||||
input,
|
input,
|
||||||
select,
|
select,
|
||||||
@@ -140,7 +137,6 @@ summary,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Semantic colors: safe across every complete light/dark preset. */
|
|
||||||
.theme-text-muted {
|
.theme-text-muted {
|
||||||
color: var(--color-text-muted-readable);
|
color: var(--color-text-muted-readable);
|
||||||
}
|
}
|
||||||
@@ -192,7 +188,6 @@ summary,
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Atom theme components ────────────────────────────────── */
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.nav-item {
|
.nav-item {
|
||||||
@apply flex h-auto md:h-[54px] items-center text-[13px] font-bold px-3 md:px-4 relative;
|
@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;
|
white-space: nowrap;
|
||||||
transition:
|
transition:
|
||||||
color 0.15s ease-in-out,
|
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;
|
border-radius: 0.5rem;
|
||||||
}
|
}
|
||||||
.nav-item:hover {
|
.nav-item:hover {
|
||||||
color: var(--color-primary-readable, var(--color-primary));
|
color: var(--color-primary-readable, var(--color-primary));
|
||||||
background-color: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
background-color: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
.nav-item[aria-current="page"] {
|
.nav-item[aria-current="page"] {
|
||||||
color: var(--color-primary-readable, var(--color-primary));
|
color: var(--color-primary-readable, var(--color-primary));
|
||||||
@@ -222,11 +219,13 @@ summary,
|
|||||||
color: var(--color-dropdown-text-readable, var(--color-text-readable));
|
color: var(--color-dropdown-text-readable, var(--color-text-readable));
|
||||||
transition:
|
transition:
|
||||||
background-color 0.1s ease-in-out,
|
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 {
|
.dropdown-item:hover {
|
||||||
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||||
color: var(--color-primary-readable, var(--color-primary));
|
color: var(--color-primary-readable, var(--color-primary));
|
||||||
|
transform: translateX(2px);
|
||||||
}
|
}
|
||||||
.dropdown-item:focus-visible {
|
.dropdown-item:focus-visible {
|
||||||
outline: 2px solid var(--color-primary-readable, var(--color-primary));
|
outline: 2px solid var(--color-primary-readable, var(--color-primary));
|
||||||
@@ -237,10 +236,10 @@ summary,
|
|||||||
border: 1px solid
|
border: 1px solid
|
||||||
color-mix(in srgb, var(--color-text-muted) 14%, transparent);
|
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);
|
box-shadow: 0 8px 32px color-mix(in srgb, rgb(0, 0, 0) 20%, transparent);
|
||||||
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Site background ── */
|
|
||||||
.site-bg {
|
.site-bg {
|
||||||
background:
|
background:
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
@@ -262,7 +261,6 @@ html.dark .site-bg {
|
|||||||
background-blend-mode: overlay;
|
background-blend-mode: overlay;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Admin must not inherit the public wallpaper — solid HK canvas only. */
|
|
||||||
body.site-bg:has([data-admin]),
|
body.site-bg:has([data-admin]),
|
||||||
html.dark body.site-bg:has([data-admin]) {
|
html.dark body.site-bg:has([data-admin]) {
|
||||||
background: var(--admin-canvas);
|
background: var(--admin-canvas);
|
||||||
@@ -270,14 +268,13 @@ html.dark body.site-bg:has([data-admin]) {
|
|||||||
background-blend-mode: normal;
|
background-blend-mode: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.article-body {
|
.article-body {
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
.article-body img {
|
.article-body img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
.article-body p {
|
.article-body p {
|
||||||
margin: 0 0 0.85rem;
|
margin: 0 0 0.85rem;
|
||||||
@@ -307,15 +304,17 @@ html.dark body.site-bg:has([data-admin]) {
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: var(--shadow-card-hover);
|
box-shadow: var(--shadow-card-hover);
|
||||||
}
|
}
|
||||||
/* ── App component classes (used across the converted pages) ── */
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: var(--size-heading-h1, 30px);
|
font-size: var(--size-heading-h1, 30px);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
margin: 0.2em 0 0.5em;
|
margin: 0.2em 0 0.5em;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
h2 {
|
h2 {
|
||||||
font-size: var(--size-heading-h2, 22px);
|
font-size: var(--size-heading-h2, 22px);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
h3 {
|
h3 {
|
||||||
font-size: var(--size-heading-h3, 18px);
|
font-size: var(--size-heading-h3, 18px);
|
||||||
@@ -343,6 +342,7 @@ a:hover {
|
|||||||
padding: 2rem 2.25rem;
|
padding: 2rem 2.25rem;
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
@@ -507,25 +507,21 @@ tbody tr:hover {
|
|||||||
background-size: contain;
|
background-size: contain;
|
||||||
outline: 1px solid #6caff4;
|
outline: 1px solid #6caff4;
|
||||||
}
|
}
|
||||||
/* ── Admin panel (Housekeeping) ─────────────────────────────────
|
|
||||||
Modern, consistent styling for all admin content pages. */
|
|
||||||
.admin-page {
|
.admin-page {
|
||||||
scroll-margin-top: 1rem;
|
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 {
|
.admin-hub-panel > .space-y-6 > div:first-child > h1.text-3xl {
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Page heading */
|
|
||||||
.admin-page main > h1:first-child,
|
.admin-page main > h1:first-child,
|
||||||
.admin-page > h1:first-child {
|
.admin-page > h1:first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Cards used inside admin pages */
|
|
||||||
.admin-card {
|
.admin-card {
|
||||||
background: var(--admin-surface);
|
background: var(--admin-surface);
|
||||||
color: var(--admin-text);
|
color: var(--admin-text);
|
||||||
@@ -533,10 +529,12 @@ tbody tr:hover {
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
padding: 1.25rem;
|
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 {
|
.admin-filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -550,7 +548,6 @@ tbody tr:hover {
|
|||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compact stat row for list pages (counts, summaries) */
|
|
||||||
.admin-stat-row {
|
.admin-stat-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -569,7 +566,6 @@ tbody tr:hover {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tables */
|
|
||||||
.admin-page table {
|
.admin-page table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--admin-surface);
|
background: var(--admin-surface);
|
||||||
@@ -610,7 +606,6 @@ tbody tr:hover {
|
|||||||
border-radius: 0 16px 0 0;
|
border-radius: 0 16px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Section heading inside admin pages */
|
|
||||||
.admin-section-title {
|
.admin-section-title {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -627,16 +622,13 @@ tbody tr:hover {
|
|||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Empty state */
|
|
||||||
.admin-empty {
|
.admin-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2.5rem 1rem;
|
padding: 2.5rem 1rem;
|
||||||
color: var(--admin-text-muted);
|
color: var(--admin-text-muted);
|
||||||
font-size: 0.85rem;
|
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 {
|
.content-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -647,10 +639,11 @@ tbody tr:hover {
|
|||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: box-shadow 0.2s ease;
|
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||||
}
|
}
|
||||||
.content-card:hover {
|
.content-card:hover {
|
||||||
box-shadow: var(--shadow-card-hover);
|
box-shadow: var(--shadow-card-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
.content-card-head {
|
.content-card-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -704,7 +697,6 @@ tbody tr:hover {
|
|||||||
padding: 1.1rem;
|
padding: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive card grids (atom: 1 / sm2 / md3 / lg4). */
|
|
||||||
.card-grid {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -732,7 +724,6 @@ tbody tr:hover {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stat tiles (value in the primary colour + muted label). */
|
|
||||||
.stat-grid {
|
.stat-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||||
@@ -744,6 +735,11 @@ tbody tr:hover {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 0.85rem 1rem;
|
padding: 0.85rem 1rem;
|
||||||
text-align: center;
|
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 {
|
.stat-block-value {
|
||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
@@ -757,7 +753,6 @@ tbody tr:hover {
|
|||||||
color: var(--color-text-muted-readable, var(--color-text-muted));
|
color: var(--color-text-muted-readable, var(--color-text-muted));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Empty states */
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
@@ -767,7 +762,6 @@ tbody tr:hover {
|
|||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Online / offline pill */
|
|
||||||
.online-badge {
|
.online-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -795,7 +789,6 @@ tbody tr:hover {
|
|||||||
color-mix(in srgb, var(--color-success) 22%, transparent);
|
color-mix(in srgb, var(--color-success) 22%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Leaderboard rank chip */
|
|
||||||
.rank-badge {
|
.rank-badge {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -821,7 +814,6 @@ tbody tr:hover {
|
|||||||
border-color: #f97316;
|
border-color: #f97316;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Radio sidebar ──────────────────────────────────────────── */
|
|
||||||
.radio-layout {
|
.radio-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
@@ -865,20 +857,37 @@ tbody tr:hover {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Skeleton shimmer animation ─────────────────────────────── */
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% {
|
0% { opacity: 0.6; }
|
||||||
opacity: 0.6;
|
50% { opacity: 1; }
|
||||||
}
|
100% { opacity: 0.6; }
|
||||||
50% {
|
}
|
||||||
opacity: 1;
|
|
||||||
}
|
@keyframes fadeIn {
|
||||||
100% {
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
opacity: 0.6;
|
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 {
|
.cms-info-trigger {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -918,7 +927,7 @@ tbody tr:hover {
|
|||||||
width: min(440px, 100%);
|
width: min(440px, 100%);
|
||||||
border: 1px solid
|
border: 1px solid
|
||||||
color-mix(in srgb, var(--color-primary) 30%, color-mix(in srgb, var(--color-text-muted) 22%, transparent));
|
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:
|
background:
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
@@ -1049,7 +1058,6 @@ tbody tr:hover {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Mobile responsive overrides ──────────────────────────── */
|
|
||||||
@media (max-width: 48rem) {
|
@media (max-width: 48rem) {
|
||||||
h1 {
|
h1 {
|
||||||
font-size: clamp(1.25rem, 5vw, var(--size-heading-h1, 30px));
|
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) {
|
@media (max-width: 640px) {
|
||||||
table {
|
table {
|
||||||
display: block;
|
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]) {
|
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-background: var(--admin-canvas);
|
||||||
--color-foreground: var(--admin-text);
|
--color-foreground: var(--admin-text);
|
||||||
--color-card: var(--admin-surface);
|
--color-card: var(--admin-surface);
|
||||||
@@ -1155,7 +1148,6 @@ body:has([data-admin]) {
|
|||||||
--color-border: var(--admin-border);
|
--color-border: var(--admin-border);
|
||||||
--color-input: var(--admin-border);
|
--color-input: var(--admin-border);
|
||||||
--color-ring: var(--admin-accent);
|
--color-ring: var(--admin-accent);
|
||||||
/* Public Atom tokens still referenced by body / .muted / inputs */
|
|
||||||
--color-text: var(--admin-text);
|
--color-text: var(--admin-text);
|
||||||
--color-text-readable: var(--admin-text);
|
--color-text-readable: var(--admin-text);
|
||||||
--color-text-muted: var(--admin-text-muted);
|
--color-text-muted: var(--admin-text-muted);
|
||||||
@@ -1173,7 +1165,6 @@ body:has([data-admin]) {
|
|||||||
font-family: var(--font-family);
|
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(
|
body:has([data-admin]) input:not([type="checkbox"]):not([type="radio"]):not(
|
||||||
[type="range"]
|
[type="range"]
|
||||||
):not([type="file"]):not([type="color"]),
|
):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);
|
background: color-mix(in srgb, var(--admin-accent) 12%, transparent);
|
||||||
color: var(--admin-text);
|
color: var(--admin-text);
|
||||||
}
|
}
|
||||||
/* Override public html.dark input hardcodes inside admin. */
|
|
||||||
html.dark body:has([data-admin]) input:not([type="checkbox"]):not(
|
html.dark body:has([data-admin]) input:not([type="checkbox"]):not(
|
||||||
[type="radio"]
|
[type="radio"]
|
||||||
):not([type="range"]):not([type="file"]):not([type="color"]),
|
):not([type="range"]):not([type="file"]):not([type="color"]),
|
||||||
@@ -1219,7 +1209,6 @@ html.dark body:has([data-admin]) textarea {
|
|||||||
color: var(--admin-text);
|
color: var(--admin-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Details/summary open states for nav ──────────────────── */
|
|
||||||
details[open] > summary .details-open\:hidden {
|
details[open] > summary .details-open\:hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export interface OnlineUser {
|
|||||||
|
|
||||||
type State = "ok" | "warn" | "danger" | "neutral";
|
type State = "ok" | "warn" | "danger" | "neutral";
|
||||||
|
|
||||||
/** A single live-status tile (online users, emulator, DB, …). */
|
|
||||||
export function StatusCard({
|
export function StatusCard({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -28,31 +27,40 @@ export function StatusCard({
|
|||||||
state?: State;
|
state?: State;
|
||||||
icon?: ReactNode;
|
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 (
|
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="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="flex items-center justify-between gap-3 mb-3">
|
<div className="absolute inset-0 bg-gradient-to-br opacity-50 transition-opacity group-hover:opacity-100" />
|
||||||
<span className="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--admin-text-muted)]">
|
<div className="relative">
|
||||||
{label}
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
</span>
|
<span className="text-[0.6rem] font-bold uppercase tracking-widest text-[var(--admin-text-muted)]">
|
||||||
{icon ? (
|
{label}
|
||||||
<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}
|
|
||||||
</span>
|
</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}
|
) : null}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A row in the system-diagnostics list with an ok/warn/error pill. */
|
|
||||||
export function DiagnosticRow({
|
export function DiagnosticRow({
|
||||||
label,
|
label,
|
||||||
detail,
|
detail,
|
||||||
@@ -71,31 +79,23 @@ export function DiagnosticRow({
|
|||||||
: "bg-[var(--admin-error-subtle)] shadow-[0_0_0_3px_rgba(239,68,68,0.22)]";
|
: "bg-[var(--admin-error-subtle)] shadow-[0_0_0_3px_rgba(239,68,68,0.22)]";
|
||||||
const badgeColor =
|
const badgeColor =
|
||||||
state === "ok"
|
state === "ok"
|
||||||
? "bg-[var(--admin-success-subtle)]"
|
? "bg-green-500/10 text-green-500 border-green-500/20"
|
||||||
: state === "danger"
|
: state === "danger"
|
||||||
? "bg-[var(--admin-error-subtle)]"
|
? "bg-red-500/10 text-red-500 border-red-500/20"
|
||||||
: "bg-[var(--admin-accent)]/18";
|
: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2.5 py-2 px-0.5 border-t border-[var(--admin-border)] first:border-t-0">
|
<div className="flex items-center gap-3 rounded-xl px-3 py-2.5 transition-colors hover:bg-[var(--admin-accent)]/5">
|
||||||
<span
|
<span className={`flex-none w-2.5 h-2.5 rounded-full ${dotColor}`} aria-hidden />
|
||||||
className={`flex-none w-[9px] h-[9px] rounded-full ${dotColor}`}
|
<span className="text-sm font-bold text-[var(--admin-text)]">{label}</span>
|
||||||
aria-hidden
|
<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}`}>
|
||||||
<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)]`}
|
|
||||||
>
|
|
||||||
{text}
|
{text}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Small labelled key/value used in the server-info card. */
|
|
||||||
export function InfoItem({
|
export function InfoItem({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -105,13 +105,12 @@ export function InfoItem({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5 text-sm">
|
<div className="flex flex-col gap-0.5 text-sm">
|
||||||
<span className="text-[var(--admin-text-muted)]">{label}</span>
|
<span className="text-xs font-medium text-[var(--admin-text-muted)]">{label}</span>
|
||||||
<strong>{value}</strong>
|
<strong className="text-[var(--admin-text)]">{value}</strong>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Live list of online users with room and activity tracking */
|
|
||||||
export function OnlineUsersWidget({
|
export function OnlineUsersWidget({
|
||||||
users,
|
users,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -122,7 +121,6 @@ export function OnlineUsersWidget({
|
|||||||
const formatLastSeen = (date: Date) => {
|
const formatLastSeen = (date: Date) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = Math.floor((now.getTime() - date.getTime()) / 1000);
|
const diff = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||||
|
|
||||||
if (diff < 60) return "Just now";
|
if (diff < 60) return "Just now";
|
||||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
@@ -130,46 +128,37 @@ export function OnlineUsersWidget({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--admin-surface)] rounded-xl border border-[var(--admin-border)] p-6">
|
<div className="rounded-2xl border border-[var(--admin-border)] bg-[var(--admin-surface)] p-5 shadow-card">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h3 className="m-0 text-lg font-semibold text-[var(--admin-text)]">
|
<h3 className="m-0 text-sm font-bold text-[var(--admin-text)]">
|
||||||
Online Now
|
Online Now
|
||||||
</h3>
|
</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
|
{isLoading ? "..." : users.length} users
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex max-h-80 flex-col gap-2 overflow-y-auto">
|
||||||
<div className="max-h-80 overflow-y-auto flex flex-col gap-3">
|
|
||||||
{users.length === 0 ? (
|
{users.length === 0 ? (
|
||||||
<div className="py-8 text-center bg-[var(--admin-text-muted)]/5 rounded-lg">
|
<div className="rounded-xl bg-[var(--admin-text-muted)]/5 px-6 py-10 text-center">
|
||||||
<span className="text-[var(--admin-text-muted)] text-sm">
|
<p className="text-sm text-[var(--admin-text-muted)]">No users online</p>
|
||||||
No users online
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
users.map((user) => (
|
users.map((user) => (
|
||||||
<div
|
<div
|
||||||
key={user.id}
|
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
|
<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)}`}
|
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}
|
alt={user.username}
|
||||||
fill
|
fill
|
||||||
className="rounded object-cover border border-[var(--admin-border)]"
|
className="object-cover"
|
||||||
unoptimized
|
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>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="truncate text-sm font-bold text-[var(--admin-text)]">
|
||||||
<div className="font-medium text-[var(--admin-text)] mb-1 truncate">
|
|
||||||
<a
|
<a
|
||||||
href={`/u/${user.username}`}
|
href={`/u/${user.username}`}
|
||||||
className="text-inherit no-underline hover:text-[var(--admin-accent)] hover:underline"
|
className="text-inherit no-underline hover:text-[var(--admin-accent)] hover:underline"
|
||||||
@@ -177,14 +166,14 @@ export function OnlineUsersWidget({
|
|||||||
{user.username}
|
{user.username}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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 ? (
|
{user.roomId && user.roomName ? (
|
||||||
<span className="text-[var(--admin-success)] font-medium">
|
<span className="font-medium text-green-500">
|
||||||
🏠 {user.roomName}
|
{user.roomName}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[var(--admin-error)]">
|
<span className="text-[var(--admin-text-muted)]">
|
||||||
⏱️ {formatLastSeen(user.lastActivity)}
|
{formatLastSeen(user.lastActivity)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,71 +47,85 @@ export function HomeLoginForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||||
onSubmit={onSubmit}
|
<div className="space-y-3">
|
||||||
className="relative flex flex-col gap-6 rounded-lg bg-gray-100 p-3 dark:bg-gray-800"
|
<div>
|
||||||
>
|
<label className="block text-xs font-bold uppercase tracking-wider text-[var(--color-text-muted-readable)] mb-1.5">
|
||||||
<fieldset className="flex w-full flex-col gap-1">
|
Username
|
||||||
<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
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={code}
|
value={username}
|
||||||
onChange={(e) => setCode(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
placeholder="Enter your 2FA code"
|
placeholder="Enter your username"
|
||||||
inputMode="numeric"
|
className="w-full rounded-lg border-2 px-3 py-2.5 text-sm transition-all duration-200 bg-[var(--color-background)]"
|
||||||
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: "var(--input-border-color)" }}
|
||||||
style={{ borderColor: "#e5e7eb", borderWidth: "4px" }}
|
required
|
||||||
|
disabled={needs2fa}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</div>
|
||||||
) : null}
|
<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 ? (
|
{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}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={pending}
|
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={{
|
style={{
|
||||||
backgroundColor: "#27a44d",
|
background: "linear-gradient(135deg, var(--button-color), color-mix(in srgb, var(--button-color) 80%, #000000))",
|
||||||
borderColor: "#34d399",
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
+179
-184
@@ -1,14 +1,16 @@
|
|||||||
|
import { ArrowRight, Lock, Newspaper, Sparkles, Users } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { HomeLoginForm } from "@/components/auth/home-login-form";
|
import { HomeLoginForm } from "@/components/auth/home-login-form";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { avatarImageUrl, excerpt } from "@/lib/format";
|
import { avatarImageUrl, excerpt } from "@/lib/format";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
export default async function GuestView() {
|
export default async function GuestView() {
|
||||||
const t = await getTranslations("pages.home");
|
const t = await getTranslations("pages.home");
|
||||||
const _hotelName = await siteSettings.get("hotel_name", "Atom");
|
const hotelName = await siteSettings.get("hotel_name", "Atom");
|
||||||
const imager =
|
const imager =
|
||||||
(await siteSettings.get(
|
(await siteSettings.get(
|
||||||
"habbo_imaging_url",
|
"habbo_imaging_url",
|
||||||
@@ -24,7 +26,7 @@ export default async function GuestView() {
|
|||||||
try {
|
try {
|
||||||
articles = await prisma.websiteArticles.findMany({
|
articles = await prisma.websiteArticles.findMany({
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
take: 8,
|
take: 6,
|
||||||
select: { slug: true, title: true, shortStory: true, image: true },
|
select: { slug: true, title: true, shortStory: true, image: true },
|
||||||
});
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -39,203 +41,196 @@ export default async function GuestView() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
<div className="flex flex-col gap-8">
|
||||||
<div className="flex w-full flex-col gap-4 md:w-1/3 md:flex-shrink-0">
|
{/* Hero Banner */}
|
||||||
<div
|
<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">
|
||||||
className="overflow-hidden rounded-lg border"
|
<div className="absolute -top-20 -right-20 h-64 w-64 rounded-full opacity-[0.08] blur-[100px]" style={{ background: "var(--color-primary)" }} />
|
||||||
style={{
|
<div className="absolute -bottom-20 -left-20 h-48 w-48 rounded-full opacity-[0.06] blur-[80px]" style={{ background: "var(--color-accent)" }} />
|
||||||
borderColor:
|
<div className="relative flex flex-col items-center gap-6 px-8 py-10 text-center md:flex-row md:text-left">
|
||||||
"color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
|
<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)]" />
|
||||||
<div className="relative h-[120px] overflow-hidden md:h-[150px]">
|
Welcome to {hotelName}
|
||||||
<div
|
</div>
|
||||||
className="absolute inset-0"
|
<h1 className="m-0 text-3xl font-extrabold tracking-tight md:text-4xl">
|
||||||
style={{
|
Join the <span className="text-[var(--color-primary)]">Community</span>
|
||||||
backgroundImage: "url(/assets/images/kasja_mepage_header.png)",
|
</h1>
|
||||||
backgroundSize: "cover",
|
<p className="mt-2 max-w-md text-sm text-[var(--color-text-muted-readable)]">
|
||||||
backgroundPosition: "center 30%",
|
Connect with friends, explore the hotel, and create unforgettable memories. Your adventure starts here.
|
||||||
backgroundRepeat: "no-repeat",
|
</p>
|
||||||
}}
|
<div className="mt-5 flex flex-wrap gap-3">
|
||||||
/>
|
<Link href="/register">
|
||||||
<div
|
<Button size="lg" className="font-bold gap-2 rounded-xl px-8 h-12 shadow-glow">
|
||||||
className="absolute inset-0"
|
Get Started <ArrowRight className="h-4 w-4" />
|
||||||
style={{
|
</Button>
|
||||||
background:
|
</Link>
|
||||||
"linear-gradient(135deg, rgba(0,0,0,0.65) 0%, rgba(0,0,0,0.15) 50%, rgba(0,0,0,0.5) 100%)",
|
<Link href="/news">
|
||||||
}}
|
<Button variant="outline" size="lg" className="font-bold rounded-xl px-8 h-12">
|
||||||
/>
|
Latest News
|
||||||
<div
|
</Button>
|
||||||
className="absolute -bottom-8 right-4 w-32 h-32 rounded-full opacity-[0.12] blur-[60px]"
|
</Link>
|
||||||
style={{ background: "var(--color-primary)" }}
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="shrink-0">
|
||||||
className="p-3"
|
<div className="w-full max-w-xs rounded-2xl border border-[var(--border-subtle)] bg-[var(--color-surface)] shadow-elevated md:w-72">
|
||||||
style={{ backgroundColor: "var(--color-surface)" }}
|
<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">
|
||||||
<HomeLoginForm />
|
<Lock className="h-4 w-4 text-[var(--color-primary)]" />
|
||||||
|
Sign In
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<HomeLoginForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-4 md:w-2/3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
<div
|
{/* Latest News */}
|
||||||
className="overflow-hidden rounded-lg border"
|
<div className="lg:col-span-2 animate-slide-up">
|
||||||
style={{
|
<div className="mb-4 flex items-center justify-between">
|
||||||
borderColor:
|
<div className="flex items-center gap-2.5">
|
||||||
"color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
|
<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">
|
||||||
backgroundColor: "var(--color-surface)",
|
<Newspaper className="h-4 w-4 text-[var(--color-primary)]" />
|
||||||
}}
|
</div>
|
||||||
>
|
<h2 className="m-0 text-lg font-extrabold">{t("latestNews")}</h2>
|
||||||
<div
|
</div>
|
||||||
className="flex h-12 w-full items-center justify-center"
|
<Link href="/news" className="text-xs font-bold text-[var(--color-primary)] hover:underline flex items-center gap-1">
|
||||||
style={{
|
View all <ArrowRight className="h-3 w-3" />
|
||||||
background:
|
</Link>
|
||||||
"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>
|
</div>
|
||||||
<div className="p-4">
|
|
||||||
{articles.length === 0 ? (
|
{articles.length === 0 ? (
|
||||||
<p
|
<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">
|
||||||
className="py-8 text-center"
|
<Newspaper className="mb-3 h-10 w-10 text-[var(--color-text-muted)] opacity-40" />
|
||||||
style={{
|
<p className="text-sm text-[var(--color-text-muted-readable)] m-0">{t("noArticles")}</p>
|
||||||
color:
|
</div>
|
||||||
"var(--color-text-muted-readable, var(--color-text-muted))",
|
) : (
|
||||||
}}
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
>
|
{articles.slice(0, 2).map((a, i) => (
|
||||||
{t("noArticles")}
|
<Link
|
||||||
</p>
|
key={a.slug}
|
||||||
) : (
|
href={`/news/${a.slug}`}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
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"
|
||||||
{articles.map((a) => (
|
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
|
<Link
|
||||||
key={a.slug}
|
key={u.username}
|
||||||
href={`/news/${a.slug}`}
|
href={`/u/${u.username}`}
|
||||||
className="group relative w-full overflow-hidden rounded-lg transition-all duration-200"
|
className="group relative flex items-center justify-center transition-all duration-200 hover:scale-110"
|
||||||
style={{
|
|
||||||
height: "215px",
|
|
||||||
backgroundColor: "var(--color-surface)",
|
|
||||||
border:
|
|
||||||
"1px solid color-mix(in srgb, var(--color-text-muted) 15%, transparent)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<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
|
<Image
|
||||||
src={a.image}
|
src={avatarImageUrl(imager, u.look, {
|
||||||
alt={a.title}
|
headOnly: true,
|
||||||
fill
|
direction: 3,
|
||||||
className="rounded-lg object-cover transition-all duration-300 group-hover:scale-105"
|
})}
|
||||||
|
alt={u.username}
|
||||||
|
width={56}
|
||||||
|
height={70}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "-8px",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
maxWidth: "none",
|
||||||
|
}}
|
||||||
unoptimized
|
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>
|
</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>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+105
-178
@@ -1,3 +1,4 @@
|
|||||||
|
import { ArrowRight, Gamepad2, Newspaper, Sparkles, Users } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
@@ -53,192 +54,109 @@ export default async function UserView({
|
|||||||
.catch(() => []);
|
.catch(() => []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 md:flex-row">
|
<div className="flex flex-col gap-6 lg:flex-row">
|
||||||
<div className="flex w-full flex-col gap-6 md:w-3/4">
|
{/* Main Content */}
|
||||||
<div className="relative flex h-[340px] items-start overflow-hidden rounded-lg">
|
<div className="flex w-full flex-col gap-6 lg:w-3/4">
|
||||||
<div
|
{/* Hero/Welcome Banner */}
|
||||||
className="absolute inset-0"
|
<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">
|
||||||
style={{
|
<div className="absolute -top-10 -right-10 h-48 w-48 rounded-full opacity-[0.1] blur-[80px]" style={{ background: "var(--color-primary)" }} />
|
||||||
backgroundImage: "url(/assets/images/kasja_mepage_header.png)",
|
<div className="absolute -bottom-10 -left-10 h-36 w-36 rounded-full opacity-[0.06] blur-[60px]" style={{ background: "var(--color-accent)" }} />
|
||||||
backgroundSize: "cover",
|
<div className="relative flex flex-col items-center gap-6 px-6 py-8 md:flex-row md:px-8">
|
||||||
backgroundPosition: "center 30%",
|
<div className="shrink-0">
|
||||||
backgroundRepeat: "no-repeat",
|
<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">
|
||||||
/>
|
|
||||||
<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)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
<Image
|
||||||
src={avatarImageUrl(imager, look, { size: "l", direction: 2 })}
|
src={avatarImageUrl(imager, look, { size: "l", direction: 2 })}
|
||||||
alt={username}
|
alt={username}
|
||||||
width={110}
|
width={120}
|
||||||
height={190}
|
height={200}
|
||||||
className="drop-shadow-2xl"
|
className="block drop-shadow-xl"
|
||||||
style={{ imageRendering: "pixelated" }}
|
style={{ imageRendering: "pixelated" }}
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
<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)]">
|
||||||
<div className="flex flex-col gap-1.5 rounded-xl px-4 py-2"
|
<span className="h-3 w-3 rounded-full bg-green-200 animate-ping absolute" />
|
||||||
style={{
|
<span className="h-3 w-3 rounded-full bg-white relative" />
|
||||||
background: "color-mix(in srgb, var(--color-surface) 88%, transparent)",
|
</div>
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex-1 text-center md:text-left">
|
||||||
<Link
|
<h1 className="m-0 text-2xl font-extrabold tracking-tight">
|
||||||
href="/client"
|
Welcome back, <span className="text-[var(--color-primary)]">{username}</span>
|
||||||
target="_blank"
|
</h1>
|
||||||
rel="noopener noreferrer"
|
<p className="mt-1 text-sm text-[var(--color-text-muted-readable)]">
|
||||||
prefetch={true}
|
{motto || "Happy to see you again!"}
|
||||||
>
|
</p>
|
||||||
<button
|
<div className="mt-4 flex flex-wrap justify-center gap-3 md:justify-start">
|
||||||
type="button"
|
<Link href="/client" target="_blank" rel="noopener noreferrer">
|
||||||
className="group rounded-full px-7 py-3 text-sm font-bold tracking-wider transition-all duration-300 hover:scale-105 active:scale-95"
|
<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">
|
||||||
style={{
|
<Gamepad2 size={18} />
|
||||||
background:
|
{t("enterHotel", { hotelName })}
|
||||||
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 92%, white), color-mix(in srgb, var(--color-primary) 92%, black))",
|
<ArrowRight size={16} className="transition-transform group-hover:translate-x-0.5" />
|
||||||
color:
|
</div>
|
||||||
"var(--button-text-color-readable, var(--button-text-color))",
|
</Link>
|
||||||
border:
|
<Link href={`/u/${username}`}>
|
||||||
"1px solid color-mix(in srgb, var(--color-primary) 60%, rgba(255,255,255,0.2))",
|
<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">
|
||||||
boxShadow:
|
View Profile
|
||||||
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent), inset 0 1px 0 rgba(255,255,255,0.2)",
|
</div>
|
||||||
}}
|
</Link>
|
||||||
>
|
</div>
|
||||||
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{/* Online Friends Bar */}
|
||||||
className="flex flex-col gap-3 rounded-lg border p-1 lg:flex-row"
|
<div className="animate-slide-up rounded-2xl border border-[var(--border-subtle)] bg-[var(--color-surface)] p-1">
|
||||||
style={{
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
||||||
backgroundColor: "var(--color-surface)",
|
<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">
|
||||||
borderColor:
|
<div className="relative flex h-2.5 w-2.5">
|
||||||
"color-mix(in srgb, var(--color-text-muted) 22%, transparent)",
|
<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>
|
||||||
<div
|
<span className="text-xs font-bold text-[var(--color-text-readable)]">{t("onlineFriends")}</span>
|
||||||
className="relative flex items-center justify-center rounded p-2 text-sm font-semibold"
|
</div>
|
||||||
style={{ backgroundColor: "var(--panel-border-color)" }}
|
<div className="flex flex-wrap items-center gap-2 px-3 py-1">
|
||||||
>
|
{onlineFriends.length === 0 ? (
|
||||||
<span className="relative text-white">{t("onlineFriends")}</span>
|
<p className="m-0 text-xs font-medium text-[var(--color-text-muted-readable)]">
|
||||||
</div>
|
{t("noFriendsOnline")}
|
||||||
<div className="flex items-center gap-3 px-3">
|
</p>
|
||||||
{onlineFriends.length === 0 ? (
|
) : (
|
||||||
<p
|
onlineFriends.map((f) => (
|
||||||
className="mb-3 block w-full text-center text-xs font-medium md:mb-0 md:text-left"
|
<Link
|
||||||
style={{
|
key={f.username}
|
||||||
color:
|
href={`/u/${f.username}`}
|
||||||
"var(--color-text-muted-readable, var(--color-text-muted))",
|
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">
|
||||||
{t("noFriendsOnline")}
|
<Image
|
||||||
</p>
|
src={avatarImageUrl(imager, f.look, { headOnly: true })}
|
||||||
) : (
|
alt={f.username}
|
||||||
onlineFriends.map((f) => (
|
width={36}
|
||||||
<Link
|
height={36}
|
||||||
key={f.username}
|
className="block"
|
||||||
href={`/u/${f.username}`}
|
title={f.username}
|
||||||
className="transition-all duration-200 hover:scale-110"
|
unoptimized
|
||||||
>
|
/>
|
||||||
<Image
|
</div>
|
||||||
src={avatarImageUrl(imager, f.look, { headOnly: true })}
|
<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">
|
||||||
alt={f.username}
|
{f.username}
|
||||||
width={40}
|
</span>
|
||||||
height={40}
|
</Link>
|
||||||
className="h-10 w-10"
|
))
|
||||||
title={f.username}
|
)}
|
||||||
unoptimized
|
</div>
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{latestArticle ? (
|
||||||
<div
|
<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">
|
||||||
className="overflow-hidden rounded-lg border"
|
<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)]">
|
||||||
style={{
|
<Newspaper className="h-4 w-4 text-[var(--color-primary)]" />
|
||||||
backgroundColor: "var(--color-surface)",
|
<h2 className="m-0 text-sm font-bold">{t("latestArticle")}</h2>
|
||||||
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>
|
</div>
|
||||||
<Link href={`/news/${latestArticle.slug}`} className="group block">
|
<Link href={`/news/${latestArticle.slug}`} className="group block">
|
||||||
<div className="relative aspect-[16/9] overflow-hidden">
|
<div className="relative aspect-[16/9] overflow-hidden">
|
||||||
@@ -246,27 +164,36 @@ export default async function UserView({
|
|||||||
src={latestArticle.image}
|
src={latestArticle.image}
|
||||||
alt={latestArticle.title}
|
alt={latestArticle.title}
|
||||||
fill
|
fill
|
||||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
className="object-cover transition-transform duration-500 group-hover:scale-110"
|
||||||
unoptimized
|
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>
|
||||||
<div className="p-3">
|
<div className="p-4">
|
||||||
<h3 className="text-sm font-semibold">{latestArticle.title}</h3>
|
<h3 className="text-sm font-bold leading-tight">{latestArticle.title}</h3>
|
||||||
{latestArticle.shortStory && (
|
{latestArticle.shortStory && (
|
||||||
<p
|
<p className="mt-1.5 text-xs text-[var(--color-text-muted-readable)] leading-relaxed">
|
||||||
className="mt-1 text-xs"
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
"var(--color-text-muted-readable, var(--color-text-muted))",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{excerpt(latestArticle.shortStory, 100)}
|
{excerpt(latestArticle.shortStory, 100)}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ export async function Navigation() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className="sticky top-0 z-40"
|
className="sticky top-0 z-40 border-b border-[var(--border-subtle)]"
|
||||||
style={{ backgroundColor: "var(--color-navbar)" }}
|
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">
|
<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
|
<div
|
||||||
className="absolute bottom-0 left-0 right-0 h-[3px]"
|
className="absolute bottom-0 left-0 right-0 h-[3px] bg-gradient-to-r from-transparent via-[var(--color-primary)] to-transparent opacity-70"
|
||||||
style={{ backgroundColor: "var(--color-primary, #eeb425)" }}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Desktop: home link */}
|
{/* Desktop: home link */}
|
||||||
@@ -161,7 +161,7 @@ export async function Navigation() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile: hamburger menu (details/summary) */}
|
{/* Mobile: hamburger menu */}
|
||||||
<MobileNav
|
<MobileNav
|
||||||
menuLabel={t("openMenu") || "Open menu"}
|
menuLabel={t("openMenu") || "Open menu"}
|
||||||
closeLabel={t("closeMenu") || "Close menu"}
|
closeLabel={t("closeMenu") || "Close menu"}
|
||||||
@@ -310,7 +310,7 @@ export async function Navigation() {
|
|||||||
</div>
|
</div>
|
||||||
</MobileNav>
|
</MobileNav>
|
||||||
|
|
||||||
{/* Mobile: brand (right) */}
|
{/* Mobile: brand */}
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="ml-auto flex md:hidden items-center gap-2 shrink-0"
|
className="ml-auto flex md:hidden items-center gap-2 shrink-0"
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export async function SiteHeader() {
|
|||||||
className="site-header relative w-full overflow-hidden"
|
className="site-header relative w-full overflow-hidden"
|
||||||
style={{ minHeight: "clamp(12rem, 30vw, 18rem)" }}
|
style={{ minHeight: "clamp(12rem, 30vw, 18rem)" }}
|
||||||
>
|
>
|
||||||
{/* Background image */}
|
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
style={{ background: `url(${bannerUrl}) center/cover no-repeat` }}
|
style={{ background: `url(${bannerUrl}) center/cover no-repeat` }}
|
||||||
@@ -49,31 +48,28 @@ export async function SiteHeader() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Subtle dot pattern */}
|
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 opacity-[0.04]"
|
className="absolute inset-0 opacity-[0.04]"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage:
|
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",
|
backgroundSize: "20px 20px",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Decorative glows */}
|
|
||||||
<div
|
<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)" }}
|
style={{ background: "var(--color-primary)" }}
|
||||||
/>
|
/>
|
||||||
<div
|
<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)" }}
|
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)]">
|
<div className="relative flex flex-col items-center justify-center px-4 py-8 md:py-12 min-h-[clamp(12rem,30vw,18rem)]">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
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 ? (
|
{logo ? (
|
||||||
<Image
|
<Image
|
||||||
@@ -95,14 +91,13 @@ export async function SiteHeader() {
|
|||||||
letterSpacing: "-0.02em",
|
letterSpacing: "-0.02em",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hotelName} - Hotel
|
{hotelName}
|
||||||
</h1>
|
</h1>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Online badge */}
|
|
||||||
<div
|
<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={{
|
style={{
|
||||||
background: "rgba(255,255,255,0.08)",
|
background: "rgba(255,255,255,0.08)",
|
||||||
backdropFilter: "blur(16px)",
|
backdropFilter: "blur(16px)",
|
||||||
|
|||||||
@@ -3,11 +3,6 @@
|
|||||||
import { Moon, Sun } from "lucide-react";
|
import { Moon, Sun } from "lucide-react";
|
||||||
import { useEffect, useState } from "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({
|
export function ThemeSwitcher({
|
||||||
variant = "nav",
|
variant = "nav",
|
||||||
}: {
|
}: {
|
||||||
@@ -28,7 +23,7 @@ export function ThemeSwitcher({
|
|||||||
try {
|
try {
|
||||||
localStorage.setItem("theme", next ? "dark" : "light");
|
localStorage.setItem("theme", next ? "dark" : "light");
|
||||||
} catch {
|
} catch {
|
||||||
/* private mode / storage disabled — runtime toggle still works */
|
/* private mode / storage disabled */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,10 +38,10 @@ export function ThemeSwitcher({
|
|||||||
aria-label={label}
|
aria-label={label}
|
||||||
title={title}
|
title={title}
|
||||||
suppressHydrationWarning
|
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" }}
|
style={{ visibility: ready ? "visible" : "hidden" }}
|
||||||
>
|
>
|
||||||
{dark ? <Sun size={16} /> : <Moon size={16} />}
|
{dark ? <Sun size={15} /> : <Moon size={15} />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -55,15 +50,13 @@ export function ThemeSwitcher({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
className="nav-item cursor-pointer text-lg leading-none px-2"
|
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
title={title}
|
title={title}
|
||||||
suppressHydrationWarning
|
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" }}
|
style={{ visibility: ready ? "visible" : "hidden" }}
|
||||||
>
|
>
|
||||||
<span aria-hidden className="text-lg leading-none">
|
{dark ? <Sun size={16} /> : <Moon size={16} />}
|
||||||
{dark ? "☀" : "☾"}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { LogOut, Shield, User } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { auth, signOut } from "@/lib/auth";
|
import { auth, signOut } from "@/lib/auth";
|
||||||
@@ -16,22 +17,20 @@ function Currency({
|
|||||||
}) {
|
}) {
|
||||||
const textColor =
|
const textColor =
|
||||||
"var(--color-navbar-text-readable, var(--color-navbar-text))";
|
"var(--color-navbar-text-readable, var(--color-navbar-text))";
|
||||||
const valueColor =
|
|
||||||
"var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))";
|
|
||||||
|
|
||||||
return (
|
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
|
<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 }}>
|
<div style={{ color: textColor }}>
|
||||||
<span
|
<span
|
||||||
className="font-semibold text-xs sm:text-sm"
|
className="font-extrabold text-xs sm:text-sm"
|
||||||
style={{ color: valueColor }}
|
style={{ color: textColor }}
|
||||||
>
|
>
|
||||||
{amount.toLocaleString()}
|
{amount.toLocaleString()}
|
||||||
</span>{" "}
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -60,9 +59,7 @@ export async function TopHeader() {
|
|||||||
if (c.type === 0) duckets = c.amount;
|
if (c.type === 0) duckets = c.amount;
|
||||||
if (c.type === 5) diamonds = c.amount;
|
if (c.type === 5) diamonds = c.amount;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// render without live balances
|
|
||||||
}
|
|
||||||
|
|
||||||
const imager =
|
const imager =
|
||||||
(await siteSettings.get(
|
(await siteSettings.get(
|
||||||
@@ -75,9 +72,9 @@ export async function TopHeader() {
|
|||||||
"var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))";
|
"var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))";
|
||||||
|
|
||||||
return (
|
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="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-credit-icon" amount={credits} label="Credits" />
|
||||||
<Currency icon="nav-ducket-icon" amount={duckets} label="Duckets" />
|
<Currency icon="nav-ducket-icon" amount={duckets} label="Duckets" />
|
||||||
<Currency
|
<Currency
|
||||||
@@ -87,19 +84,20 @@ export async function TopHeader() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 ? (
|
{isStaff ? (
|
||||||
<details className="relative">
|
<details className="relative">
|
||||||
<summary
|
<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 }}
|
style={{ color: textColor }}
|
||||||
>
|
>
|
||||||
|
<Shield size={14} className="text-[var(--color-primary)]" />
|
||||||
<span className="hidden sm:inline">Administration</span>
|
<span className="hidden sm:inline">Administration</span>
|
||||||
<span className="sm:hidden">Admin</span>
|
<span className="sm:hidden">Admin</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div
|
<div
|
||||||
role="menu"
|
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">
|
<Link href="/admin" className="dropdown-item" role="menuitem">
|
||||||
Admin panel
|
Admin panel
|
||||||
@@ -128,7 +126,7 @@ export async function TopHeader() {
|
|||||||
<details className="relative">
|
<details className="relative">
|
||||||
<summary
|
<summary
|
||||||
aria-label="User menu"
|
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
|
<Image
|
||||||
className="bg-no-repeat bg-center"
|
className="bg-no-repeat bg-center"
|
||||||
@@ -139,7 +137,7 @@ export async function TopHeader() {
|
|||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="hidden sm:inline -ml-2 font-semibold"
|
className="hidden sm:inline -ml-2 font-bold text-sm"
|
||||||
style={{ color: textColor }}
|
style={{ color: textColor }}
|
||||||
>
|
>
|
||||||
{session.user.name}
|
{session.user.name}
|
||||||
@@ -147,9 +145,16 @@ export async function TopHeader() {
|
|||||||
</summary>
|
</summary>
|
||||||
<div
|
<div
|
||||||
role="menu"
|
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">
|
<Link href="/me" className="dropdown-item" role="menuitem">
|
||||||
|
<User size={14} className="inline mr-2" />
|
||||||
My dashboard
|
My dashboard
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
@@ -157,6 +162,7 @@ export async function TopHeader() {
|
|||||||
className="dropdown-item"
|
className="dropdown-item"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
|
<User size={14} className="inline mr-2" />
|
||||||
My profile
|
My profile
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/settings" className="dropdown-item" role="menuitem">
|
<Link href="/settings" className="dropdown-item" role="menuitem">
|
||||||
@@ -176,13 +182,7 @@ export async function TopHeader() {
|
|||||||
>
|
>
|
||||||
Login activity
|
Login activity
|
||||||
</Link>
|
</Link>
|
||||||
<div
|
<div className="h-px my-1 mx-3 bg-[var(--border-subtle)]" />
|
||||||
className="h-px my-1 mx-3"
|
|
||||||
style={{
|
|
||||||
backgroundColor:
|
|
||||||
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<form
|
<form
|
||||||
action={async () => {
|
action={async () => {
|
||||||
"use server";
|
"use server";
|
||||||
@@ -192,16 +192,13 @@ export async function TopHeader() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
role="menuitem"
|
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={{
|
style={{
|
||||||
backgroundColor:
|
|
||||||
"color-mix(in srgb, var(--button-danger-color) 12%, transparent)",
|
|
||||||
color: "var(--button-danger-color)",
|
color: "var(--button-danger-color)",
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
width: "calc(100% - 8px)",
|
width: "calc(100% - 8px)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<LogOut size={14} />
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in new issue
Block a user