The in-process cache was a FIFO of 500 entries that was never touched on a read, so a key polled on every request could be evicted by an unrelated burst of dynamic keys. That looked exactly like the cache being cleared at random, and it is what made the site fall back to the database unpredictably. - Evict least-recently-used instead, and raise the default budget to 2000 (CACHE_MEMORY_MAX_ENTRIES). Reading a key now marks it as used, so a hot key only leaves when a hotter one takes its place. - Add opt-in stale-while-revalidate (CachedOptions.staleMs). The grace window lives on the entry, so one call site opting in protects every reader of that key. A failed background refresh keeps serving the last good value instead of falling through to the origin, and is reported once rather than per read. - Invalidate across processes. invalidateKey() now clears memory, deletes the Redis key and publishes a signal, so a value written by one process is no longer served stale by the others for the rest of its TTL. A failed Redis delete no longer skips the broadcast. - Guard against a refresh that started before an invalidation writing its outdated result back into the cache. - Read the news revision at most once a second per process instead of on every call, with a pub/sub signal to drop the local copy when it rotates. A Redis outage now degrades to the in-process cache rather than to no cache at all. - Warm the hot public keys on boot, so the first visitors after a deploy do not each pay for a miss. - Count hits, misses, stale serves, errors and evictions per key, exposed at GET /api/admin/devops/cache. Without it a wrong REDIS_URL, a full budget and a dead origin all look identical from the outside. - Enforce the imaging cache budget for real: records are .img/.json pairs, so the old cap counted files and never removed anything while entries were fresh. Sweeps are throttled per directory and prune to a low-water mark. - Cap the JWT version map, and stop per-test scratch roots from littering the runtime imaging cache. Public read-only endpoints get grace windows; admin, account and auth data deliberately stays fresh. Redis TTLs get a little jitter so keys written together no longer expire together. 3209 tests pass. next build could not be verified on this host: the optimized build is OOM-killed before prerender, so this has not run in a real Next runtime yet.
244 lines
7.7 KiB
TypeScript
244 lines
7.7 KiB
TypeScript
import { count, desc, eq } from "drizzle-orm";
|
|
import { headers } from "next/headers";
|
|
import Image from "next/image";
|
|
import { getTranslations } from "next-intl/server";
|
|
import { AuthTopBar } from "@/components/auth/auth-top-bar";
|
|
import { RegisterForm } from "@/components/auth/register-form";
|
|
import { Reveal } from "@/components/motion-reveal";
|
|
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
|
import { SurfaceCard } from "@/components/surface-card";
|
|
import { cached } from "@/lib/cache";
|
|
import { db, User } from "@/lib/db";
|
|
import { resolveHotelName } from "@/lib/hotel-name";
|
|
import { captchaConfig } from "@/lib/services/captcha";
|
|
import { siteSettings } from "@/lib/services/site-settings";
|
|
import { ICON_FRIENDS, ICON_NAV_GOODY, ICON_NAV_ME } from "@/lib/site-icons";
|
|
|
|
export default async function RegisterPage() {
|
|
const tpr = await getTranslations("pages.register");
|
|
const [hotelName, cfg, logo] = await Promise.all([
|
|
resolveHotelName(),
|
|
captchaConfig(),
|
|
siteSettings.get("cms_logo", ""),
|
|
]);
|
|
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
|
|
|
const [online, recentUsers, latestUsers] = await Promise.all([
|
|
cached("online_count", 10_000, () =>
|
|
db
|
|
.select({ total: count() })
|
|
.from(User)
|
|
.where(eq(User.online, "1"))
|
|
.then((rows) => rows[0]?.total ?? 0),
|
|
).catch(() => 0),
|
|
cached(
|
|
"register_online_users",
|
|
10_000,
|
|
() =>
|
|
db
|
|
.select({ username: User.username, look: User.look })
|
|
.from(User)
|
|
.where(eq(User.online, "1"))
|
|
.limit(8),
|
|
{ staleMs: 30000 },
|
|
).catch(() => []),
|
|
cached(
|
|
"register_latest_users",
|
|
30_000,
|
|
() =>
|
|
db
|
|
.select({ username: User.username, look: User.look })
|
|
.from(User)
|
|
.orderBy(desc(User.accountCreated))
|
|
.limit(8),
|
|
{ staleMs: 60000 },
|
|
).catch(() => []),
|
|
]);
|
|
|
|
return (
|
|
<div className="mx-auto w-full max-w-6xl flex flex-col gap-8 pb-16">
|
|
<AuthTopBar hotelName={hotelName} logo={logo} />
|
|
|
|
<Reveal>
|
|
<div className="flex flex-col gap-8 lg:flex-row lg:items-start">
|
|
{/* Left: intro panel */}
|
|
<div className="lg:w-96 shrink-0 space-y-4">
|
|
<SurfaceCard
|
|
className="card-glow relative overflow-hidden p-6"
|
|
style={{
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
|
|
}}
|
|
>
|
|
<div
|
|
className="absolute inset-0"
|
|
style={{
|
|
background:
|
|
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
|
|
}}
|
|
/>
|
|
<div
|
|
aria-hidden="true"
|
|
className="absolute inset-0 overflow-hidden"
|
|
>
|
|
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
|
|
</div>
|
|
<div className="relative">
|
|
<div className="flex items-center gap-4">
|
|
<div className="shrink-0 animate-float">
|
|
<div className="brand-halo left-1/2 top-1/2 h-52 w-52 -translate-x-1/2 -translate-y-1/2" />
|
|
<Image
|
|
src="/assets/images/FrankwithBag.gif"
|
|
alt="Frank"
|
|
width={110}
|
|
height={145}
|
|
className="relative object-contain drop-shadow-xl"
|
|
unoptimized
|
|
priority
|
|
/>
|
|
</div>
|
|
<div className="flex-1">
|
|
<div
|
|
className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider mb-2 border"
|
|
style={{
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-primary) 18%, transparent)",
|
|
background:
|
|
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
|
color:
|
|
"var(--color-primary-readable, var(--color-primary))",
|
|
}}
|
|
>
|
|
<span className="relative flex h-2 w-2">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
|
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary" />
|
|
</span>
|
|
{tpr("usersOnline", { count: online })}
|
|
</div>
|
|
<h1
|
|
className="text-lg font-black leading-tight"
|
|
style={{
|
|
color: "var(--color-text-readable)",
|
|
fontFamily: "var(--font-nunito)",
|
|
}}
|
|
>
|
|
{tpr("title")} —{" "}
|
|
<span className="gradient-text">{hotelName}</span>
|
|
</h1>
|
|
<p
|
|
className="text-xs mt-1.5 leading-relaxed"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{tpr("subtitle")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SurfaceCard>
|
|
|
|
{recentUsers.length > 0 && (
|
|
<SurfaceCard
|
|
title={tpr("whoIsOnline")}
|
|
icon={ICON_NAV_GOODY}
|
|
bodyClassName="p-4"
|
|
className="card-glow"
|
|
>
|
|
<div className="grid grid-cols-4 gap-1.5">
|
|
{recentUsers.map((u) => (
|
|
<div
|
|
key={u.username}
|
|
title={u.username}
|
|
className="flex flex-col items-center gap-1.5 rounded-xl border px-1 py-2 transition-all duration-200 hover:-translate-y-0.5 hover:ring-1 hover:ring-[color-mix(in_srgb,var(--color-primary)_35%,transparent)]"
|
|
style={{
|
|
background:
|
|
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
|
}}
|
|
>
|
|
<UserAvatarThumbnail
|
|
figure={u.look}
|
|
alt=""
|
|
options={{ direction: 2 }}
|
|
className="rounded-lg"
|
|
/>
|
|
<span
|
|
className="w-full truncate text-center text-[9px] font-bold leading-tight"
|
|
style={{ color: "var(--color-text-readable)" }}
|
|
>
|
|
{u.username}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SurfaceCard>
|
|
)}
|
|
|
|
{latestUsers.length > 0 && (
|
|
<SurfaceCard
|
|
title={tpr("newestCitizens")}
|
|
icon={ICON_FRIENDS}
|
|
bodyClassName="p-4"
|
|
className="card-glow"
|
|
>
|
|
<div className="grid grid-cols-4 gap-1.5">
|
|
{latestUsers.map((u) => (
|
|
<div
|
|
key={u.username}
|
|
title={u.username}
|
|
className="flex flex-col items-center gap-1.5 rounded-xl border px-1 py-2 transition-all duration-200 hover:-translate-y-0.5 hover:ring-1 hover:ring-[color-mix(in_srgb,var(--color-primary)_35%,transparent)]"
|
|
style={{
|
|
background:
|
|
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
|
}}
|
|
>
|
|
<UserAvatarThumbnail
|
|
figure={u.look}
|
|
alt=""
|
|
options={{ direction: 2 }}
|
|
className="rounded-lg"
|
|
/>
|
|
<span
|
|
className="w-full truncate text-center text-[9px] font-bold leading-tight"
|
|
style={{ color: "var(--color-text-readable)" }}
|
|
>
|
|
{u.username}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SurfaceCard>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right: form */}
|
|
<div className="flex-1 lg:sticky lg:top-6">
|
|
<SurfaceCard
|
|
title={tpr("title")}
|
|
icon={ICON_NAV_ME}
|
|
bodyClassName="p-6 sm:p-7"
|
|
>
|
|
<p
|
|
className="text-sm mb-6"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{tpr("subtitle")}
|
|
</p>
|
|
<RegisterForm
|
|
hotelName={hotelName}
|
|
captcha={{
|
|
provider: cfg.provider,
|
|
siteKey: cfg.siteKey || undefined,
|
|
}}
|
|
nonce={nonce}
|
|
/>
|
|
</SurfaceCard>
|
|
</div>
|
|
</div>
|
|
</Reveal>
|
|
</div>
|
|
);
|
|
}
|