feat: harden atoms-nexst against review findings (37 items)
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Failing after 1m49s
CI / tests-ui (push) Successful in 2m31s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Failing after 1m49s
CI / tests-ui (push) Successful in 2m31s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
Second review pass covering security, performance, admin tooling and the public/room flows. All HIGH and MEDIUM findings from the audit are resolved; nothing in this commit changes the visible feature set. Authentication & session security - CSP is now set on the request headers in the proxy, which is what Next.js uses to derive the render nonce, so the nonce is effective. - 2FA: an already-enabled user cannot re-enroll, the setup endpoint is rate-limited per account, and confirmed codes are persisted so the second secret no longer silently never applies. - Password reset revokes the ticket, authTicket and all personal access tokens, and bumps the token version so existing sessions die. The same revocation is now wired into the staff-side password reset. - /reset and /verify return a stable error code instead of raw text; the mail lookups are ordered by id so duplicates cannot vary between runs. - Resending the verification mail gets a per-address cooldown on top of the per-user limit. - Issue API tokens with the narrower radio/ticket ability set instead of "*". Authorization & input handling - Mid-rank staff can no longer keep dynamically granted non-view admin.* permissions: existing grants are revoked by migration and the grant lookup is restricted to "%.view". Rank guards use the dynamic super-admin check. - Alerting a user is permission-checked and audited like the other tools. - Material mutations (giveCredits/giveDuckets/giveDiamonds, the admin user actions route, bulk user actions) are capped and rank-guarded, and bulk ids are bounded. - updateRoom / updateRoomItem write through a field allowlist, and items may only be edited through their own room. - Classnames reaching the filesystem are validated before use so a crafted value cannot escape the asset directories. - The word filter now also covers offline mails, guild forum threads and replies, and user mottos. - Media uploads are validated by magic bytes, /api/media requires the page edit permission, APP_URL must be configured once mail is enabled, and the diagnostics error route checks the fetch site header. Admin tooling - Secret settings render masked and cannot be overwritten with a blank or an arbitrary raw key; radio credentials are new password inputs. - Commandocentrum balance changes are audited. - Admin list pagination reads the caller's per-page instead of the max, and the log exporter caps offset and search length. Performance - Catalog translations are cached per module, with a cheap revision hash; the public online count uses a stale window instead of hammering the DB. - The cache warmup now primes the payload the home route actually reads. - TopHeader batches its queries into one round trip, and LCP avatars load eagerly. - motion/react and sonner are no longer part of the root layout; the nav dropdown and mobile nav panels are lazy client chunks. Anonymous visitors again get the navigation chrome, and public pages get an edge cacheable response. Accessibility - Nested <main> elements in phase pages became <section>; the page entrance and route progress animations are pure CSS that respect reduced motion.
This commit is contained in:
1 parent
3933214953
commit
6cc45d7413
150 files changed
+2137
-759
No files matched your search
@@ -0,0 +1,23 @@
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
/**
|
||||
* Toast host for the admin surfaces (the only place `toast()` is used). Kept out
|
||||
* of the root layout so public pages don't pay for the Sonner bundle.
|
||||
*/
|
||||
export function AdminToaster() {
|
||||
return (
|
||||
<Toaster
|
||||
position="top-right"
|
||||
richColors
|
||||
closeButton
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text-readable)",
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,11 @@ import { AuthTopBar } from "@/components/auth/auth-top-bar";
|
||||
import { Reveal } from "@/components/motion-reveal";
|
||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { SurfaceCard } from "@/components/surface-card";
|
||||
import { loadProfilePrivacyMap } from "@/lib/services/profile-privacy";
|
||||
import { ICON_FRIENDS, ICON_NAV_GOODY, ICON_NAV_ME } from "@/lib/site-icons";
|
||||
|
||||
export interface PublicUser {
|
||||
id: number;
|
||||
username: string;
|
||||
look: string;
|
||||
}
|
||||
@@ -56,22 +58,28 @@ interface AuthUsersCardsProps {
|
||||
* their left column. Cards with no users render nothing, so a cold database
|
||||
* degrades to just the intro panel instead of an empty grid.
|
||||
*/
|
||||
export function AuthUsersCards({
|
||||
export async function AuthUsersCards({
|
||||
recentUsers,
|
||||
latestUsers,
|
||||
recentTitle,
|
||||
latestTitle,
|
||||
}: AuthUsersCardsProps) {
|
||||
// "Hide my online status" must hold outside the profile too — the roster is
|
||||
// exactly where that setting is worth the most.
|
||||
const privacy = await loadProfilePrivacyMap(recentUsers.map((u) => u.id));
|
||||
const onlineUsers = recentUsers.filter(
|
||||
(u) => privacy.get(u.id)?.online !== false,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{recentUsers.length > 0 && (
|
||||
{onlineUsers.length > 0 && (
|
||||
<SurfaceCard
|
||||
title={recentTitle}
|
||||
icon={ICON_NAV_GOODY}
|
||||
bodyClassName="p-4"
|
||||
className="card-glow"
|
||||
>
|
||||
<AvatarGrid users={recentUsers} online />
|
||||
<AvatarGrid users={onlineUsers} online />
|
||||
</SurfaceCard>
|
||||
)}
|
||||
{latestUsers.length > 0 && (
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useIsPresent } from "motion/react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Global smooth progress indicator for page transitions.
|
||||
* Uses motion/react for high-performance 60fps animations.
|
||||
* Global progress indicator for page transitions.
|
||||
*
|
||||
* Driven by a CSS class instead of motion/react — the progress bar used to be
|
||||
* the only reason the masonry-free public layout shipped the whole animation
|
||||
* runtime to every visitor. Class names come from globals.css (`.route-progress`).
|
||||
*/
|
||||
export function GlobalProgressBar() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const isPresent = useIsPresent();
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Route changes intentionally restart the indicator timer.
|
||||
useEffect(() => {
|
||||
@@ -24,17 +25,13 @@ export function GlobalProgressBar() {
|
||||
return () => clearTimeout(timer);
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
if (!isLoading && isPresent) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 right-0 z-[99999] h-1 bg-transparent pointer-events-none overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-[var(--color-primary)] shadow-[0_0_10px_var(--color-primary)]"
|
||||
initial={{ width: "0%", opacity: 1 }}
|
||||
animate={{ width: "85%", opacity: 1 }}
|
||||
exit={{ width: "100%", opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-loading={isLoading ? "true" : "false"}
|
||||
className="route-progress"
|
||||
>
|
||||
<span className="route-progress__bar" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import type { ReactNode } from "react";
|
||||
import { mobileMenuVariants } from "@/lib/motion";
|
||||
|
||||
/**
|
||||
* Animated mobile menu sheet, split out of `MobileNav` so `motion/react` only
|
||||
* ships once the hamburger menu is opened instead of on every public page.
|
||||
*/
|
||||
export default function MobileNavPanel({
|
||||
open,
|
||||
menuId,
|
||||
menuRef,
|
||||
brandLabel,
|
||||
closeLabel,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
menuId: string;
|
||||
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||
brandLabel: string;
|
||||
closeLabel: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
id={menuId}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={brandLabel || "Navigation menu"}
|
||||
className="absolute left-0 top-full mt-2 w-[min(88vw,360px)] z-50 origin-top-left"
|
||||
style={{ backgroundColor: "transparent" }}
|
||||
variants={mobileMenuVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
>
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="flex flex-col gap-y-1 p-2 rounded-2xl border shadow-xl max-h-[calc(100dvh-5rem)] overflow-y-auto overscroll-contain
|
||||
bg-[var(--color-dropdown,var(--color-surface,#fff))]"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||
boxShadow:
|
||||
"0 12px 40px color-mix(in srgb, rgb(0 0 0) 22%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 mb-1 rounded-xl"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src="/assets/images/icons/navigation/home.png"
|
||||
alt=""
|
||||
width={22}
|
||||
height={22}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span
|
||||
className="text-sm font-extrabold uppercase tracking-wide"
|
||||
style={{
|
||||
color: "var(--color-navbar-text-readable, var(--color-text))",
|
||||
}}
|
||||
>
|
||||
{brandLabel}
|
||||
</span>
|
||||
<span className="ml-auto text-[11px] font-semibold uppercase tracking-wider opacity-75">
|
||||
{closeLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import dynamic from "next/dynamic";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
@@ -10,7 +9,11 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { mobileMenuVariants } from "@/lib/motion";
|
||||
|
||||
/** Lazily loaded so `motion/react` is not in the public pages' initial JS. */
|
||||
const MobileNavPanel = dynamic(() => import("@/components/mobile-nav-panel"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
interface MobileNavProps {
|
||||
children: ReactNode;
|
||||
@@ -129,16 +132,16 @@ export function MobileNav({
|
||||
aria-haspopup="dialog"
|
||||
aria-controls={menuId}
|
||||
>
|
||||
<motion.svg
|
||||
className="w-6 h-6"
|
||||
animate={{ rotate: open ? 90 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
<svg
|
||||
className="w-6 h-6 transition-transform duration-200"
|
||||
style={{ transform: open ? "rotate(90deg)" : undefined }}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{open ? (
|
||||
<>
|
||||
@@ -152,67 +155,18 @@ export function MobileNav({
|
||||
<line x1="4" y1="17" x2="20" y2="17" />
|
||||
</>
|
||||
)}
|
||||
</motion.svg>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
id={menuId}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={brandLabel || "Navigation menu"}
|
||||
className="absolute left-0 top-full mt-2 w-[min(88vw,360px)] z-50 origin-top-left"
|
||||
style={{ backgroundColor: "transparent" }}
|
||||
variants={mobileMenuVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
>
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="flex flex-col gap-y-1 p-2 rounded-2xl border shadow-xl max-h-[calc(100dvh-5rem)] overflow-y-auto overscroll-contain
|
||||
bg-[var(--color-dropdown,var(--color-surface,#fff))]"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||
boxShadow:
|
||||
"0 12px 40px color-mix(in srgb, rgb(0 0 0) 22%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 mb-1 rounded-xl"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src="/assets/images/icons/navigation/home.png"
|
||||
alt=""
|
||||
width={22}
|
||||
height={22}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span
|
||||
className="text-sm font-extrabold uppercase tracking-wide"
|
||||
style={{
|
||||
color:
|
||||
"var(--color-navbar-text-readable, var(--color-text))",
|
||||
}}
|
||||
>
|
||||
{brandLabel}
|
||||
</span>
|
||||
<span className="ml-auto text-[11px] font-semibold uppercase tracking-wider opacity-75">
|
||||
{closeLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<MobileNavPanel
|
||||
open={open}
|
||||
menuId={menuId}
|
||||
menuRef={menuRef}
|
||||
brandLabel={brandLabel}
|
||||
closeLabel={closeLabel}
|
||||
>
|
||||
{children}
|
||||
</MobileNavPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { pageTransition } from "@/lib/motion";
|
||||
|
||||
/**
|
||||
* Route-change entrance animation.
|
||||
*
|
||||
* Pure CSS on purpose: the motion/react build this used to pull in cost ~43 KB
|
||||
* gzip on every public page's initial JS. The animation itself stays identical
|
||||
* (see `.page-enter` in globals.css), and the global `prefers-reduced-motion`
|
||||
* block already neutralises it for visitors who ask for less motion.
|
||||
*/
|
||||
export default function MotionPageWrapper({
|
||||
children,
|
||||
}: {
|
||||
@@ -13,16 +19,8 @@ export default function MotionPageWrapper({
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={pathname}
|
||||
variants={pageTransition}
|
||||
initial="initial"
|
||||
animate="enter"
|
||||
exit="exit"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<div key={pathname} className="page-enter">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { dropdownVariants } from "@/lib/motion";
|
||||
|
||||
/**
|
||||
* Animated body of a nav dropdown. Split out so `motion/react` (~43 KB gzip)
|
||||
* only ships when a dropdown is actually opened — it used to sit in every
|
||||
* public page's initial bundle. Loaded client-side only; the closed state is
|
||||
* rendered by the parent as plain HTML.
|
||||
*/
|
||||
export default function NavDropdownPanel({
|
||||
open,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
variants={dropdownVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="dropdown-menu md:absolute left-0 top-full mt-0.5 min-w-0 md:min-w-[200px] rounded-xl z-50 py-2"
|
||||
role="menu"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Image from "next/image";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { dropdownVariants } from "@/lib/motion";
|
||||
|
||||
/**
|
||||
* The animated body loads lazily (`motion/react` is ~43 KB gzip and only needed
|
||||
* while a dropdown is open), so public pages pay nothing until the first
|
||||
* interaction.
|
||||
*/
|
||||
const NavDropdownPanel = dynamic(
|
||||
() => import("@/components/nav-dropdown-panel"),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
interface NavDropdownProps {
|
||||
label: string;
|
||||
icon?: string;
|
||||
/** Rendered as a link (default) or a menu item (`as="menu"`). */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -55,13 +65,13 @@ export function NavDropdown({ label, icon, children }: NavDropdownProps) {
|
||||
/>
|
||||
) : null}
|
||||
{label}
|
||||
<motion.svg
|
||||
className="ml-auto md:ml-1 w-3 h-3 shrink-0"
|
||||
animate={{ rotate: open ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
<svg
|
||||
className="ml-auto md:ml-1 w-3 h-3 shrink-0 transition-transform duration-200"
|
||||
style={{ transform: open ? "rotate(180deg)" : undefined }}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
@@ -69,22 +79,10 @@ export function NavDropdown({ label, icon, children }: NavDropdownProps) {
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</motion.svg>
|
||||
</svg>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
className="dropdown-menu md:absolute left-0 top-full mt-0.5 min-w-0 md:min-w-[200px] rounded-xl z-50 py-2"
|
||||
role="menu"
|
||||
variants={dropdownVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* Closed state is plain HTML; the animation loads on first open. */}
|
||||
<NavDropdownPanel open={open}>{children}</NavDropdownPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -70,6 +70,8 @@ export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
|
||||
<img
|
||||
src={p.url}
|
||||
alt={p.alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ width: "100%", height: "auto", display: "block" }}
|
||||
/>
|
||||
<div style={{ padding: "0.7rem 0.85rem" }}>
|
||||
|
||||
@@ -8,6 +8,8 @@ export function ProfileImage({
|
||||
height,
|
||||
fallback,
|
||||
className,
|
||||
loading,
|
||||
fetchPriority,
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
@@ -15,6 +17,9 @@ export function ProfileImage({
|
||||
height: number;
|
||||
fallback?: string;
|
||||
className?: string;
|
||||
/** "eager" for the hero image of a page (it *is* the LCP element). */
|
||||
loading?: "lazy" | "eager";
|
||||
fetchPriority?: "high" | "low" | "auto";
|
||||
}) {
|
||||
const [failedSrc, setFailedSrc] = useState<string | null>(null);
|
||||
const failed = failedSrc === src;
|
||||
@@ -43,7 +48,8 @@ export function ProfileImage({
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
loading="lazy"
|
||||
loading={loading ?? "lazy"}
|
||||
fetchPriority={fetchPriority}
|
||||
decoding="async"
|
||||
onError={() => setFailedSrc(src)}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import Image from "next/image";
|
||||
import Link from "@/components/link";
|
||||
import { LiveOnlineCount } from "@/components/live-online-count";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { cachedOnlineCount } from "@/lib/services/public-counters";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export async function SiteHeader() {
|
||||
@@ -16,13 +14,7 @@ export async function SiteHeader() {
|
||||
|
||||
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;
|
||||
});
|
||||
online = await cachedOnlineCount();
|
||||
} catch {
|
||||
online = 0;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import Link from "@/components/link";
|
||||
import { LiveOnlineCount } from "@/components/live-online-count";
|
||||
import { RoomQuickEntry } from "@/components/room-quick-entry";
|
||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { cached } from "@/lib/cache";
|
||||
import {
|
||||
db,
|
||||
MessengerFriendrequests,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { cachedOnlineCount } from "@/lib/services/public-counters";
|
||||
|
||||
function Currency({
|
||||
icon,
|
||||
@@ -61,31 +61,62 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
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) {
|
||||
let showAdmin = false;
|
||||
let showMod = false;
|
||||
let hotelName = "";
|
||||
let online = 0;
|
||||
let friendRequests: { userFromId: number }[] = [];
|
||||
let friendRequestUsers: {
|
||||
id: number;
|
||||
username: string;
|
||||
look: string;
|
||||
}[] = [];
|
||||
|
||||
// One round of queries instead of five sequential ones: the header is on
|
||||
// every authenticated page, so each extra round-trip is paid on every view.
|
||||
const [wallet, ctx, name, onlineCount, requests] = await Promise.all([
|
||||
(async () => {
|
||||
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)),
|
||||
]);
|
||||
return user[0]
|
||||
? { credits: user[0].credits, look: user[0].look, currencies }
|
||||
: null;
|
||||
} catch (error) {
|
||||
logServerError("top-header.wallet_failed", error, { userId: id });
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
getApiAdminContext().catch(() => null),
|
||||
resolveHotelName().catch(() => ""),
|
||||
cachedOnlineCount().catch((error) => {
|
||||
logServerError("top-header.online_count_failed", error);
|
||||
return 0;
|
||||
}),
|
||||
db
|
||||
.select({ userFromId: MessengerFriendrequests.userFromId })
|
||||
.from(MessengerFriendrequests)
|
||||
.where(eq(MessengerFriendrequests.userToId, id))
|
||||
.catch(() => [] as { userFromId: number }[]),
|
||||
]);
|
||||
|
||||
if (wallet) {
|
||||
credits = wallet.credits;
|
||||
look = wallet.look;
|
||||
for (const c of wallet.currencies) {
|
||||
if (c.type === 0) duckets = c.amount;
|
||||
if (c.type === 5) diamonds = c.amount;
|
||||
}
|
||||
} catch (error) {
|
||||
logServerError("top-header.wallet_failed", error, { userId: id });
|
||||
}
|
||||
|
||||
let showAdmin = false;
|
||||
let showMod = false;
|
||||
const ctx = await getApiAdminContext();
|
||||
if (ctx) {
|
||||
showAdmin = canAccess(
|
||||
ctx.permissions,
|
||||
@@ -102,67 +133,38 @@ export async function TopHeader({ session }: { session: Session | null }) {
|
||||
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 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 (error) {
|
||||
logServerError("top-header.online_count_failed", error);
|
||||
online = 0;
|
||||
hotelName = name;
|
||||
online = onlineCount;
|
||||
friendRequests = requests;
|
||||
if (friendRequests.length > 0) {
|
||||
friendRequestUsers = 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 }[]);
|
||||
}
|
||||
|
||||
// The count and the rows are the same question, so derive it from the rows.
|
||||
const pendingFriendRequests = friendRequests.length;
|
||||
const textColor =
|
||||
"var(--color-navbar-text-readable, var(--color-navbar-text, var(--color-text)))";
|
||||
// Unread offline messages is the only count we still need on its own.
|
||||
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;
|
||||
const [row] = await db
|
||||
.select({ total: count() })
|
||||
.from(MessengerOffline)
|
||||
.where(eq(MessengerOffline.userId, id));
|
||||
unreadMessages = row?.total ?? 0;
|
||||
} catch (error) {
|
||||
logServerError("top-header.messenger_counts_failed", error, { userId: id });
|
||||
}
|
||||
|
||||
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 (
|
||||
|
||||
Reference in new issue
Block a user