Files
Epicnabbo-Catalogus-Updated…/src/components/top-header.tsx
T
openhands 58c35a2920 feat: enforce no hardcoded colors across entire CMS
Added scripts/check-admin-colors.mjs — scans all src/ files for:
- text-white, text-black (use theme text vars)
- bg-white, bg-black (use theme background/overlay vars)
- bg/text/border/ring with gray/slate/zinc/stone palette
- bg/text/border/ring with red/green/blue/etc palette

Fixed 21 violations across 11 files:
- Overlays: bg-black/* → bg-foreground/*
- Text: text-white → text-primary-foreground
- Backgrounds: bg-white/10 → bg-background/10
- Green accents: bg-green-* → bg-primary
- Red accents: bg-red-* → bg-destructive

Integrated into:
- lint-staged: runs on every *.ts/*.tsx commit
- vitest: src/lib/no-hardcoded-colors.test.ts replaces old audit test
- Allowlist: shadcn/ui primitives (button, badge, dialog) + 4 graphical files
2026-09-01 19:33:50 +02:00

432 lines
12 KiB
TypeScript

import { count, eq, inArray } from "drizzle-orm";
import Image from "next/image";
import type { Session } from "next-auth";
import { getTranslations } from "next-intl/server";
import { signOutAndRevokeTicket } from "@/actions/sessions";
import { HeaderUserIdentity } from "@/components/header-user-identity";
import Link from "@/components/link";
import { RoomQuickEntry } from "@/components/room-quick-entry";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { cached } from "@/lib/cache";
import {
db,
MessengerFriendrequests,
MessengerOffline,
User,
UsersCurrency,
} from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name";
import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions";
function Currency({
icon,
amount,
label,
}: {
icon: string;
amount: number;
label: string;
}) {
const textColor =
"var(--color-navbar-text-readable, var(--color-navbar-text))";
const valueColor =
"var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))";
return (
<div className="flex gap-x-1 sm:gap-x-2 items-center">
<div
className={`h-[20px] w-[20px] sm:h-[25px] sm:w-[25px] rounded-full outline-offset-[3px] ${icon}`}
/>
<div style={{ color: textColor }}>
<span
className="font-semibold text-xs sm:text-sm"
style={{ color: valueColor }}
>
{amount.toLocaleString()}
</span>{" "}
<span className="hidden sm:inline text-xs">{label}</span>
</div>
</div>
);
}
export async function TopHeader({ session }: { session: Session | null }) {
if (!session?.user?.id) return null;
const id = Number(session.user.id);
let credits = 0;
let duckets = 0;
let diamonds = 0;
let look = "";
try {
const [[user], currencies] = await Promise.all([
db
.select({ credits: User.credits, look: User.look })
.from(User)
.where(eq(User.id, id))
.limit(1),
db
.select({ type: UsersCurrency.type, amount: UsersCurrency.amount })
.from(UsersCurrency)
.where(eq(UsersCurrency.userId, id)),
]);
credits = user?.credits ?? 0;
look = user?.look ?? "";
for (const c of currencies) {
if (c.type === 0) duckets = c.amount;
if (c.type === 5) diamonds = c.amount;
}
} catch {}
let showAdmin = false;
let showMod = false;
const ctx = await getApiAdminContext();
if (ctx) {
showAdmin = canAccess(
ctx.permissions,
PERMS.ADMIN_DASHBOARD,
ctx.session.user.rank,
);
showMod =
canAccess(ctx.permissions, PERMS.MOD_DASHBOARD, ctx.session.user.rank) ||
canAccess(
ctx.permissions,
PERMS.MODERATION_VIEW,
ctx.session.user.rank,
) ||
canAccess(ctx.permissions, PERMS.MOD_CFH_VIEW, ctx.session.user.rank) ||
canAccess(ctx.permissions, PERMS.MOD_ACTIONS, ctx.session.user.rank);
}
const textColor =
"var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))";
const t = await getTranslations("header");
const hotelName = await resolveHotelName();
let online: number;
try {
online = await cached("online_count", 10_000, async () => {
const [row] = await db
.select({ total: count() })
.from(User)
.where(eq(User.online, "1"));
return row?.total ?? 0;
});
} catch {
online = 0;
}
let unreadMessages = 0;
let pendingFriendRequests = 0;
try {
const [unreadRows, pendingRows] = await Promise.all([
db
.select({ total: count() })
.from(MessengerOffline)
.where(eq(MessengerOffline.userId, id)),
db
.select({ total: count() })
.from(MessengerFriendrequests)
.where(eq(MessengerFriendrequests.userToId, id)),
]);
unreadMessages = unreadRows[0]?.total ?? 0;
pendingFriendRequests = pendingRows[0]?.total ?? 0;
} catch {}
const friendRequests = await db
.select({ userFromId: MessengerFriendrequests.userFromId })
.from(MessengerFriendrequests)
.where(eq(MessengerFriendrequests.userToId, id))
.catch(() => [] as { userFromId: number }[]);
const friendRequestUsers = friendRequests.length
? await db
.select({
id: User.id,
username: User.username,
look: User.look,
})
.from(User)
.where(
inArray(
User.id,
friendRequests.map((r) => r.userFromId),
),
)
.catch(() => [] as { id: number; username: string; look: string }[])
: [];
const totalNotifications = unreadMessages + pendingFriendRequests;
return (
<div style={{ backgroundColor: "var(--color-navbar)" }}>
<div className="max-w-7xl min-h-[44px] sm:min-h-[60px] px-2 sm:px-4 flex items-center justify-between mx-auto">
<div className="flex flex-wrap items-center gap-x-1.5 sm:gap-x-4 md:gap-x-6">
<div
className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1 rounded-full"
style={{
background: "rgba(255,255,255,0.08)",
border: "1px solid rgba(255,255,255,0.10)",
}}
>
<span
className="text-[10px] sm:text-sm font-bold tracking-wide"
style={{ color: "rgba(255,255,255,0.9)" }}
>
{t("online", { count: online, hotel: hotelName })}
</span>
</div>
<Link
href="/client"
className="hidden sm:flex items-center gap-1.5 rounded-xl border py-1.5 px-3 font-extrabold text-xs transition-all duration-200 hover:scale-[1.02] active:scale-95 shadow-lg"
style={{
background: "var(--color-primary)",
color: "var(--color-primary-foreground)",
borderColor:
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
boxShadow:
"0 4px 24px color-mix(in srgb, var(--color-primary) 35%, transparent)",
}}
>
<Image
src="/assets/images/EnterHubbly.png"
alt=""
width={14}
height={14}
unoptimized
/>
Enter
</Link>
<RoomQuickEntry />
<Currency icon="nav-credit-icon" amount={credits} label="Credits" />
<Currency icon="nav-ducket-icon" amount={duckets} label="Duckets" />
<Currency
icon="nav-diamond-icon"
amount={diamonds}
label="Diamonds"
/>
</div>
<div className="flex items-center gap-x-0.5 sm:gap-x-3">
<form
action="/search"
method="GET"
className="hidden sm:flex items-center gap-1"
>
<input
type="text"
name="q"
placeholder="Search users..."
className="w-24 rounded-lg border px-2 py-1 text-xs font-medium transition-all focus:w-32"
style={{
background: "var(--color-background)",
color: "var(--color-text-readable)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
/>
</form>
<Link
href="/messages"
className="relative flex items-center gap-1 rounded-lg px-1.5 sm:px-2 py-1.5 transition-colors hover:bg-background/10"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: textColor }}
role="img"
aria-label="Notifications"
>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
{totalNotifications > 0 && (
<span
className="absolute -top-1 -right-1 flex min-w-[16px] h-4 items-center justify-center rounded-full px-1 text-[10px] font-bold leading-none"
style={{
background: "var(--color-danger)",
color: "white",
}}
>
{totalNotifications > 99 ? "99+" : totalNotifications}
</span>
)}
</Link>
{showAdmin || showMod ? (
<details className="relative">
<summary
className="list-none cursor-pointer font-semibold text-xs sm:text-sm [&::-webkit-details-marker]:hidden"
style={{ color: textColor }}
>
<span className="hidden sm:inline">Staff</span>
<span className="sm:hidden">Staff</span>
</summary>
<div
role="menu"
className="dropdown-menu absolute right-0 top-full mt-0.5 min-w-[180px] rounded-xl z-50 py-2"
>
{showMod ? (
<Link href="/mod" className="dropdown-item" role="menuitem">
Mod panel
</Link>
) : null}
{showAdmin ? (
<>
<Link
href="/admin"
className="dropdown-item"
role="menuitem"
>
Admin panel
</Link>
<Link
href="/admin/commandocentrum"
className="dropdown-item"
role="menuitem"
>
Commandocentrum
</Link>
<Link
href="/admin/logs"
className="dropdown-item"
role="menuitem"
>
Logs
</Link>
</>
) : null}
<Link href="/logo" className="dropdown-item" role="menuitem">
Logo generator
</Link>
</div>
</details>
) : null}
<details className="relative">
<summary
aria-label="User menu"
className="list-none cursor-pointer flex items-center [&::-webkit-details-marker]:hidden"
>
<HeaderUserIdentity
figure={look}
username={session.user.name ?? ""}
textColor={textColor}
/>
</summary>
<div
role="menu"
className="dropdown-menu fixed sm:absolute right-2 sm:right-0 top-auto sm:top-full mt-0.5 min-w-[200px] rounded-xl z-50 py-2"
>
<Link href="/me" className="dropdown-item" role="menuitem">
My dashboard
</Link>
<Link
href={`/u/${session.user.name}`}
className="dropdown-item"
role="menuitem"
>
My profile
</Link>
<Link href="/settings" className="dropdown-item" role="menuitem">
User settings
</Link>
<Link
href="/settings/2fa"
className="dropdown-item"
role="menuitem"
>
Two-factor
</Link>
<Link
href="/settings/sessions"
className="dropdown-item"
role="menuitem"
>
Login activity
</Link>
{pendingFriendRequests > 0 && (
<>
<div
className="h-px my-1 mx-3"
style={{
backgroundColor:
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
}}
/>
<div className="px-3 py-1.5">
<p
className="text-xs font-bold mb-1"
style={{ color: "var(--color-text-muted)" }}
>
Friend requests ({pendingFriendRequests})
</p>
{friendRequestUsers.slice(0, 3).map((u) => (
<Link
key={u.id}
href="/messages"
className="flex items-center gap-2 rounded-lg px-2 py-1 text-xs transition-colors hover:bg-background/10"
>
<UserAvatarThumbnail
figure={u.look}
alt=""
options={{ direction: 2 }}
/>
<span style={{ color: textColor }}>{u.username}</span>
</Link>
))}
{friendRequestUsers.length > 3 && (
<p
className="text-xs mt-1 opacity-60"
style={{ color: textColor }}
>
+{friendRequestUsers.length - 3} more
</p>
)}
</div>
</>
)}
<div
className="h-px my-1 mx-3"
style={{
backgroundColor:
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
}}
/>
<form action={signOutAndRevokeTicket}>
<button
type="submit"
role="menuitem"
className="dropdown-item w-full text-center mx-1 rounded-lg"
style={{
backgroundColor:
"color-mix(in srgb, var(--button-danger-color) 12%, transparent)",
color: "var(--button-danger-color)",
border: "none",
cursor: "pointer",
width: "calc(100% - 8px)",
}}
>
Logout
</button>
</form>
</div>
</details>
</div>
</div>
</div>
);
}