perf: cache search and news archive, drop motion/react from public pages
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 26s
CI / tests-unit (push) Failing after 1m37s
CI / tests-integration (push) Successful in 1m38s
CI / tests-ui (push) Failing after 2m24s
CI / preflight (push) Skipped
CI / deploy (push) Skipped

Closes the four remaining LOW items.

Search and news archive caching
- A leading-wildcard LIKE cannot use an index, so every /search section cost a
  COUNT(*) scan plus an ordered page fetch, and /news did the same for its
  archive. Both now cache: search per section for 30s, the archive for 60s
  under the existing news revision so publishing an article drops it at once.
- Sections are cached independently, so one slow query cannot hold up the rest
  and a failure is not cached as a result.
- The cached value is passed through cacheSafe() so the Redis path and the
  in-process path return the same types; without it a cache hit would hand the
  events grid a string where a miss hands it a Date, and it calls toISOString()
  on that field. Dates are revived on the way out so the public signatures of
  loadNewsArchive and loadPublicSearch are unchanged.
- Archive entries are keyed on the REQUESTED page rather than the clamped one,
  so two requests that clamp onto the same page cannot alias each other.

motion/react out of the public bundle
- Converted the six public-facing users: the radio player, the typewriter text
  (a motion.span with no animation props at all), the photo lightbox, the
  animated counter, the footer CMS-info popup and the scroll reveal. That was
  the actual entry points — the counter and the popup reach the public home page
  and footer through static imports, so removing only the three originally named
  would have left the library in the bundle anyway.
- Each animation moved to a CSS class, and the two that animate on exit now hold
  the element for the length of the fade, which is what AnimatePresence used to
  do.
- motion/react now only ships with /admin and the two already-lazy nav panels.
- Two safety fixes came out of this: the scroll reveal starts at opacity 0, so
  it is forced visible under prefers-reduced-motion and via a <noscript> rule in
  the root layout; and it now emits the .motion-reveal class, which the theme
  panel's "Scroll Reveal" toggle selects and which previously matched nothing.
- The CMS-info backdrop became a real button in a pointer-transparent layer
  instead of a handler on a static element, so click-outside-to-dismiss is
  reachable by keyboard.

Fewer duplicate router refreshes
- Next.js re-renders the current route as part of a server action's own response
  when that action revalidates, and applies it with a seeded navigation; the
  router only skips its own update when the action did NOT revalidate. So the
  refresh after such an action fetched the same tree twice.
- useServerAction takes an opt-in `revalidated` flag that skips it. It is opt-in
  per call rather than derived from an action name, since a rename would
  silently change behaviour. Applied to the two user-facing call sites whose
  actions were verified to revalidate their own route.

Touch targets
- .btn was the one shared control at 40px; it and the lightbox and CMS-info
  close buttons are now 44px, as is the password toggle (the auth input already
  reserved 44px for it). The remaining 32px icon buttons pass WCAG 2.2 AA, which
  only asks for 24px; enlarging those inside inputs and overlays was left alone
  because it risks visual breakage that cannot be checked from here.
This commit is contained in:
openhands committed 2026-10-09 17:35:38 +02:00
1 parent 179484642f
commit 759ae91745
16 files changed
+794 -262

No files matched your search

@@ -33,6 +33,8 @@ export function EventRegisterButton({
run(() => registerForEvent({ eventId }), {
successMessage: t("registerSuccess"),
errorMessage: t("registerError"),
// registerForEvent revalidates /events and /events/<id>.
revalidated: true,
})
}
>
@@ -69,6 +69,8 @@ export function PollVoteForm({
run(() => voteOnPoll({ pollId, votes }), {
successMessage: t("voteSuccess"),
errorMessage: t("voteError"),
// voteOnPoll revalidates /polls and /polls/<id>.
revalidated: true,
});
}
+161 -4
View File
@@ -182,6 +182,88 @@ html {
animation: page-enter-rise 240ms ease-out both;
}
/* ── Radio player entrance (was a motion/react initial/animate pair) ────── */
@keyframes radio-player-enter {
from {
opacity: 0;
transform: translate3d(0, 20px, 0) scale(0.95);
}
to {
opacity: 1;
transform: none;
}
}
.radio-player-enter {
animation: radio-player-enter 400ms cubic-bezier(0.25, 0.1, 0.25, 1) both;
}
/* ── Animated counter entrance (was motion/react on a <span>) ───────────── */
@keyframes counter-rise {
from {
opacity: 0;
transform: translate3d(0, 12px, 0);
}
to {
opacity: 1;
transform: none;
}
}
.counter-rise {
animation: counter-rise 500ms ease-out both;
}
/* ── Scroll reveal (was motion/react whileInView) ───────────────────────── */
.motion-reveal {
opacity: 0;
}
@keyframes motion-reveal-rise {
from {
opacity: 0;
transform: translate3d(0, 24px, 0);
}
to {
opacity: 1;
transform: none;
}
}
.motion-reveal-in {
animation: motion-reveal-rise 550ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* The reveal hides its content until the animation runs, so a visitor who asks
for less motion must get it back immediately. The global reduced-motion block
below already flattens the animation duration; this restores the content. */
@media (prefers-reduced-motion: reduce) {
.motion-reveal {
opacity: 1;
}
}
/* ── Photo lightbox (was an AnimatePresence opacity pair) ───────────────── */
@keyframes lightbox-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes lightbox-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.lightbox-enter {
animation: lightbox-fade-in 200ms ease-out both;
}
/* Paired with the 180ms unmount delay in PhotoLightbox: 200ms in, 180ms out,
matching the 200ms the previous motion transition used. */
.lightbox-exit {
animation: lightbox-fade-out 180ms ease-in both;
}
/* ── Route progress indicator ─────────────────────────────────────────── */
.route-progress {
position: fixed;
@@ -941,7 +1023,10 @@ a:hover {
font-weight: 700;
font-size: 14px;
cursor: pointer;
min-height: 40px;
/* 44px: the comfortable touch-target size (WCAG 2.1 AAA 2.5.5 / Apple HIG).
WCAG 2.2 AA only asks for 24px, so this is a comfort floor rather than a
compliance fix, but every button in the system shares this class. */
min-height: 44px;
transition: all 0.18s ease;
background: color-mix(
in srgb,
@@ -1534,15 +1619,85 @@ a.content-card {
outline: 3px solid color-mix(in srgb, var(--color-primary) 42%, transparent);
outline-offset: 3px;
}
.cms-info-backdrop {
/* Centering layer. Transparent and click-through itself, so only the backdrop
button and the dialog catch pointer events. */
.cms-info-layer {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
padding: 1rem;
pointer-events: none;
}
.cms-info-backdrop {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 0;
border: 0;
cursor: default;
background: rgba(8, 10, 14, 0.68);
backdrop-filter: blur(6px);
pointer-events: auto;
}
/* Enter/exit were motion/react opacity pairs; the dialog itself also used a
scale/translate pair. Paired with the 180ms unmount delay in CmsInfoPopup. */
@keyframes cms-info-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes cms-info-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes cms-info-dialog-in {
from {
opacity: 0;
transform: scale(0.95) translate3d(0, 10px, 0);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes cms-info-dialog-out {
from {
opacity: 1;
transform: none;
}
to {
opacity: 0;
transform: scale(0.95) translate3d(0, 10px, 0);
}
}
.cms-info-backdrop-enter {
animation: cms-info-backdrop-in 200ms ease-out both;
}
.cms-info-backdrop-exit {
animation: cms-info-backdrop-out 180ms ease-in both;
}
.cms-info-dialog-enter {
animation: cms-info-dialog-in 200ms ease-out both;
}
.cms-info-dialog-exit {
animation: cms-info-dialog-out 180ms ease-in both;
}
/* Sits above the backdrop button inside the pointer-transparent layer. */
.cms-info-dialog-layer {
pointer-events: auto;
position: relative;
z-index: 1;
}
.cms-info-dialog {
position: relative;
@@ -1573,8 +1728,10 @@ a.content-card {
top: 0.75rem;
right: 0.75rem;
display: grid;
width: 2rem;
height: 2rem;
/* 44px touch target (was 2rem/32px) while keeping the circular button visually
the same weight via its border and glyph. */
width: 2.75rem;
height: 2.75rem;
place-items: center;
border: 1px solid
color-mix(
+6
View File
@@ -82,6 +82,12 @@ export default async function RootLayout({
>
<head>
<meta name="theme-default-dark" content={String(defaultDark)} />
{/* Scroll-revealed content starts transparent and is only shown by a
client-side animation. Without JavaScript that animation never
runs, so force it visible instead of leaving the page blank. */}
<noscript>
<style>{".motion-reveal{opacity:1 !important}"}</style>
</noscript>
<Script
src="/scripts/theme-init.js"
strategy="beforeInteractive"
+59 -9
View File
@@ -1,6 +1,5 @@
"use client";
import { motion, useInView, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
export function AnimatedCounter({
@@ -11,7 +10,7 @@ export function AnimatedCounter({
suffix?: string;
}) {
const ref = useRef<HTMLSpanElement>(null);
const isInView = useInView(ref, { once: true, margin: "-50px" });
const isInView = useInView(ref);
const prefersReducedMotion = useReducedMotion();
const started = useRef(false);
const [display, setDisplay] = useState(0);
@@ -49,15 +48,66 @@ export function AnimatedCounter({
return () => cancelAnimationFrame(raf);
}, [isInView, value, prefersReducedMotion]);
// Plain span, no motion/react: this counter renders on the public home page
// and the rise/fade was the only thing the library was used for here. The
// count-up itself was already driven by requestAnimationFrame. The
// `counter-rise` class keeps the entrance and is neutralised for visitors who
// ask for reduced motion by the global block in globals.css.
return (
<motion.span
ref={ref}
initial={{ opacity: 0, y: 12 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, ease: "easeOut" }}
>
<span ref={ref} className="counter-rise">
{display.toLocaleString()}
{suffix}
</motion.span>
</span>
);
}
/**
* Replaces motion/react's `useInView`. It only had to answer "is the element on
* screen", which an IntersectionObserver does without pulling the library in.
*/
function useInView(ref: React.RefObject<HTMLElement | null>) {
const [inView, setInView] = useState(false);
useEffect(() => {
const node = ref.current;
if (!node || inView) return;
if (typeof IntersectionObserver === "undefined") {
setInView(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
// Latch: the counter only animates once, so disconnect as
// soon as it has been seen.
setInView(true);
observer.disconnect();
return;
}
}
},
{ rootMargin: "-50px" },
);
observer.observe(node);
return () => observer.disconnect();
}, [ref, inView]);
return inView;
}
/** Media-query read for `prefers-reduced-motion`. */
function useReducedMotion() {
const [reduced, setReduced] = useState(false);
useEffect(() => {
if (typeof window.matchMedia !== "function") return;
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduced(query.matches);
const onChange = (event: MediaQueryListEvent) => setReduced(event.matches);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
return reduced;
}
+1 -1
View File
@@ -24,7 +24,7 @@ export function PasswordToggle({
aria-label={show ? labelHide : labelShow}
aria-pressed={show}
tabIndex={-1}
className="absolute right-2.5 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full transition-colors duration-200 hover:bg-[color-mix(in_srgb,var(--color-primary)_14%,transparent)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)]"
className="absolute right-2.5 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full transition-colors duration-200 hover:bg-[color-mix(in_srgb,var(--color-primary)_14%,transparent)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)]"
style={{ color: "var(--color-text-muted)" }}
>
{show ? (
+85 -62
View File
@@ -1,7 +1,6 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
const cmsDetails = [
["Edition", "EpicNext"],
@@ -14,8 +13,35 @@ const credits = [
["Life", "Next.js architecture and CMS refinements"],
];
const EXIT_MS = 180;
export function CmsInfoPopup() {
const [open, setOpen] = useState(false);
// The dialog used to unmount through AnimatePresence so its fade-out could
// finish. With the animation in CSS the component holds it for the length of
// the fade instead; this popup lives in the public footer, so pulling in
// motion/react for it was not worth it.
const [closing, setClosing] = useState(false);
const exitTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Stable identity so the keydown effect below does not re-subscribe on every
// render.
const close = useCallback(() => {
if (exitTimer.current) return;
setClosing(true);
exitTimer.current = setTimeout(() => {
exitTimer.current = null;
setOpen(false);
setClosing(false);
}, EXIT_MS);
}, []);
useEffect(
() => () => {
if (exitTimer.current) clearTimeout(exitTimer.current);
},
[],
);
useEffect(() => {
if (window.location.hash === "#cms-info") setOpen(true);
@@ -24,11 +50,11 @@ export function CmsInfoPopup() {
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
if (event.key === "Escape") close();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open]);
}, [open, close]);
return (
<>
@@ -39,66 +65,63 @@ export function CmsInfoPopup() {
>
EpicNext Info
</button>
<AnimatePresence>
{open ? (
<motion.div
key="cms-info-backdrop"
className="cms-info-backdrop"
role="presentation"
onMouseDown={() => setOpen(false)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
{open ? (
<div className="cms-info-layer">
{/* A real button, so click-outside-to-dismiss is reachable by
keyboard and assistive tech instead of being a pointer-only
handler on a static element. The layer itself is
pointer-transparent so only these two children catch clicks. */}
<button
type="button"
aria-label="Close CMS info"
className={`cms-info-backdrop ${
closing ? "cms-info-backdrop-exit" : "cms-info-backdrop-enter"
}`}
onClick={close}
/>
<section
aria-labelledby="cms-info-title"
aria-modal="true"
className={`cms-info-dialog cms-info-dialog-layer ${
closing ? "cms-info-dialog-exit" : "cms-info-dialog-enter"
}`}
role="dialog"
>
<motion.section
key="cms-info-dialog"
aria-labelledby="cms-info-title"
aria-modal="true"
className="cms-info-dialog"
role="dialog"
onMouseDown={(event) => event.stopPropagation()}
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.2 }}
<button
type="button"
aria-label="Close CMS info"
className="cms-info-close"
onClick={close}
>
<button
type="button"
aria-label="Close CMS info"
className="cms-info-close"
onClick={() => setOpen(false)}
>
x
</button>
<div className="cms-info-kicker">CMS information</div>
<h2 id="cms-info-title">EpicNext</h2>
<div className="cms-info-badge">Modern Next.js CMS</div>
<p className="cms-info-copy">
A modern web layer for the hotel community, focused on fast
navigation, clean administration, and a coherent EpicNext
experience.
</p>
<dl className="cms-info-grid">
{cmsDetails.map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
<section className="cms-info-credit-list" aria-label="Credits">
{credits.map(([name, role]) => (
<div className="cms-info-credit" key={name}>
<span>{name}</span>
<small>{role}</small>
</div>
))}
</section>
</motion.section>
</motion.div>
) : null}
</AnimatePresence>
x
</button>
<div className="cms-info-kicker">CMS information</div>
<h2 id="cms-info-title">EpicNext</h2>
<div className="cms-info-badge">Modern Next.js CMS</div>
<p className="cms-info-copy">
A modern web layer for the hotel community, focused on fast
navigation, clean administration, and a coherent EpicNext
experience.
</p>
<dl className="cms-info-grid">
{cmsDetails.map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
<section className="cms-info-credit-list" aria-label="Credits">
{credits.map(([name, role]) => (
<div className="cms-info-credit" key={name}>
<span>{name}</span>
<small>{role}</small>
</div>
))}
</section>
</section>
</div>
) : null}
</>
);
}
+52 -10
View File
@@ -1,26 +1,68 @@
"use client";
import { motion, useReducedMotion } from "motion/react";
import type { ReactNode } from "react";
import { type ReactNode, useEffect, useRef, useState } from "react";
interface RevealProps {
children: ReactNode;
className?: string;
/** Seconds to wait before the reveal starts. */
delay?: number;
}
/**
* Fade-and-rise-on-scroll, without motion/react.
*
* This component sits on the public home page, the auth pages and the settings
* page, so the animation library used to be pulled into their initial bundle
* for this one effect. The behaviour is a plain IntersectionObserver plus two
* CSS classes.
*
* Two safety rules, because "invisible until a scroll event" is a bad failure
* mode:
* - `.motion-reveal` starts at `opacity: 0` and is only restored by the
* animation, so globals.css forces it visible under
* `prefers-reduced-motion`, and the root layout ships a `<noscript>` rule
* for the no-JavaScript case. Either way the content is readable.
* - The observer latches and disconnects, matching the previous `once: true`.
*/
export function Reveal({ children, className, delay = 0 }: RevealProps) {
const prefersReducedMotion = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const [shown, setShown] = useState(false);
useEffect(() => {
const node = ref.current;
if (!node) return;
if (typeof IntersectionObserver === "undefined") {
setShown(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setShown(true);
observer.disconnect();
return;
}
}
},
{ rootMargin: "-40px" },
);
observer.observe(node);
return () => observer.disconnect();
}, []);
return (
<motion.div
className={className}
initial={{ opacity: 0, y: prefersReducedMotion ? 0 : 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-40px" }}
transition={{ duration: 0.55, delay, ease: [0.22, 1, 0.36, 1] }}
<div
ref={ref}
// The theme panel's "Scroll Reveal" toggle selects `.motion-reveal`;
// it previously matched nothing because the class was never emitted.
className={`motion-reveal${shown ? " motion-reveal-in" : ""}${
className ? ` ${className}` : ""
}`}
style={delay ? { animationDelay: `${delay}s` } : undefined}
>
{children}
</motion.div>
</div>
);
}
+119 -87
View File
@@ -1,7 +1,6 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
/**
* Client-side photo gallery for the public Photos page. Renders the same
@@ -20,13 +19,45 @@ export type LightboxPhoto = {
user: string;
};
const EXIT_MS = 180;
export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
// Index of the photo shown in the lightbox, or null when closed.
const [openIndex, setOpenIndex] = useState<number | null>(null);
// Kept mounted for the length of the fade-out, then removed. Previously
// AnimatePresence owned that delay; with the animation in CSS the component
// has to hold the overlay itself.
const [closing, setClosing] = useState(false);
const exitTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// eslint-disable-next-line security/detect-object-injection -- openIndex is numeric array index, guarded by null check
const active = openIndex !== null ? photos[openIndex] : null;
const close = useCallback(() => setOpenIndex(null), []);
const close = useCallback(() => {
if (exitTimer.current) return;
setClosing(true);
exitTimer.current = setTimeout(() => {
exitTimer.current = null;
setOpenIndex(null);
setClosing(false);
}, EXIT_MS);
}, []);
const open = useCallback((index: number) => {
if (exitTimer.current) {
clearTimeout(exitTimer.current);
exitTimer.current = null;
}
setClosing(false);
setOpenIndex(index);
}, []);
// Never leave a pending timer behind on unmount.
useEffect(
() => () => {
if (exitTimer.current) clearTimeout(exitTimer.current);
},
[],
);
// Esc-to-close + lock background scroll while the overlay is open.
useEffect(() => {
@@ -50,7 +81,7 @@ export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
<button
key={p.id}
type="button"
onClick={() => setOpenIndex(i)}
onClick={() => open(i)}
className="card hover"
style={{
padding: 0,
@@ -86,95 +117,96 @@ export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
))}
</div>
<AnimatePresence>
{active ? (
<motion.div
key="lightbox"
onClick={(e) => {
if (e.target === e.currentTarget) close();
}}
role="dialog"
aria-modal="true"
aria-label={active.alt}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
{active ? (
<div
// .lightbox-enter / .lightbox-exit replace the AnimatePresence
// pair: motion/react is a ~43 KB chunk and the Photos page is
// public. The fade-out is driven by `closing`.
className={closing ? "lightbox-exit" : "lightbox-enter"}
// onMouseDown, not onClick: the backdrop is a pointer-only
// convenience dismiss. Keyboard users already have Escape and the
// ✕ button, and a <div> cannot take focus to host a key handler.
onMouseDown={(e) => {
if (e.target === e.currentTarget) close();
}}
role="dialog"
aria-modal="true"
aria-label={active.alt}
style={{
position: "fixed",
inset: 0,
zIndex: 1000,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1.5rem",
background: "rgba(0, 0, 0, 0.82)",
backdropFilter: "blur(8px)",
cursor: "zoom-out",
}}
>
<button
type="button"
onClick={close}
aria-label="Close"
style={{
position: "fixed",
inset: 0,
zIndex: 1000,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1.5rem",
background: "rgba(0, 0, 0, 0.82)",
backdropFilter: "blur(8px)",
cursor: "zoom-out",
position: "absolute",
top: "1rem",
right: "1rem",
// 44px touch target (was 2.5rem/40px).
width: "2.75rem",
height: "2.75rem",
borderRadius: "999px",
border: 0,
background: "rgba(255, 255, 255, 0.16)",
color: "#fff",
fontSize: "1.4rem",
lineHeight: 1,
cursor: "pointer",
}}
>
<button
type="button"
onClick={close}
aria-label="Close"
✕
</button>
{/* Clicking the dialog backdrop (not the figure) closes the lightbox. */}
<figure
style={{
margin: 0,
maxWidth: "min(92vw, 1100px)",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.75rem",
cursor: "default",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={active.url}
alt={active.alt}
style={{
position: "absolute",
top: "1rem",
right: "1rem",
width: "2.5rem",
height: "2.5rem",
borderRadius: "999px",
border: 0,
background: "rgba(255, 255, 255, 0.16)",
color: "#fff",
fontSize: "1.4rem",
lineHeight: 1,
cursor: "pointer",
maxWidth: "100%",
maxHeight: "78vh",
width: "auto",
height: "auto",
display: "block",
borderRadius: "var(--radius-md)",
boxShadow: "var(--shadow-card-hover)",
scale: active ? "1" : "0.95",
}}
/>
<figcaption
style={{
color: "rgba(255, 255, 255, 0.85)",
fontSize: "0.9rem",
textAlign: "center",
}}
>
✕
</button>
{/* Clicking the dialog backdrop (not the figure) closes the lightbox. */}
<figure
style={{
margin: 0,
maxWidth: "min(92vw, 1100px)",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.75rem",
cursor: "default",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={active.url}
alt={active.alt}
style={{
maxWidth: "100%",
maxHeight: "78vh",
width: "auto",
height: "auto",
display: "block",
borderRadius: "var(--radius-md)",
boxShadow: "var(--shadow-card-hover)",
scale: active ? "1" : "0.95",
}}
/>
<figcaption
style={{
color: "rgba(255, 255, 255, 0.85)",
fontSize: "0.9rem",
textAlign: "center",
}}
>
{active.date} · {active.user}
</figcaption>
</figure>
</motion.div>
) : null}
</AnimatePresence>
{active.date} · {active.user}
</figcaption>
</figure>
</div>
) : null}
</>
);
}
+5 -6
View File
@@ -1,6 +1,5 @@
"use client";
import { motion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEventSource } from "@/lib/use-event-source";
@@ -214,10 +213,10 @@ export default function RadioPlayer() {
const border = "color-mix(in srgb, var(--color-text-muted) 18%, transparent)";
return (
<motion.section
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.4, ease: [0.25, 0.1, 0.25, 1] }}
<section
// Entrance is CSS (.radio-player-enter) rather than motion/react: the
// player sits on every public page, and that library is a ~43 KB chunk.
className="radio-player-enter"
aria-label={`${config.name} player`}
style={{
position: "fixed",
@@ -378,6 +377,6 @@ export default function RadioPlayer() {
<audio ref={audioRef} preload="none">
<track kind="captions" />
</audio>
</motion.section>
</section>
);
}
+4 -3
View File
@@ -1,6 +1,5 @@
"use client";
import { motion } from "motion/react";
import { useEffect, useState } from "react";
export function TypewriterText({
@@ -41,12 +40,14 @@ export function TypewriterText({
return (
<>
<span className="sr-only">{text}</span>
<motion.span
{/* A plain span: this used to be a motion.span with no animation
props at all, so it only cost the ~43 KB motion/react chunk. */}
<span
className={`${className ?? ""} ${!done ? "typing-cursor" : ""}`}
aria-hidden
>
{displayed}
</motion.span>
</span>
</>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
/**
* Contract test rather than a render test: the project runs Vitest in the node
* environment without a DOM or React testing library, so the refresh decision is
* asserted on the source. The behaviour it protects is a one-line branch, and
* the reasoning behind it (Next.js re-renders revalidating actions as part of
* the action response, so an explicit refresh is a duplicate round-trip) is
* documented at the option itself.
*/
const source = readFileSync(
resolve(process.cwd(), "src/hooks/use-server-action.ts"),
"utf8",
);
describe("useServerAction refresh handling", () => {
it("refreshes unless the caller marked the action as revalidating", () => {
expect(source).toMatch(/revalidated = false/);
expect(source).toMatch(/else if \(!revalidated\) router\.refresh\(\);/);
});
it("keeps refreshing on the default path", () => {
// The refresh must stay the fallback: an action that does not revalidate
// leaves the screen stale without it.
expect(source).not.toMatch(/router\.refresh\(\);\s*\}\s*else/);
});
it("documents why the refresh can be skipped", () => {
expect(source).toContain("revalidatePath");
expect(source).toContain("duplicate round-trip");
});
it("callers that opt in point at an action that revalidates", () => {
const files = [
"src/app/(site)/polls/[id]/poll-vote-form.tsx",
"src/app/(site)/events/[slugOrId]/event-register-button.tsx",
];
for (const file of files) {
const caller = readFileSync(resolve(process.cwd(), file), "utf8");
expect(caller).toContain("revalidated: true");
}
// …and the actions they name must actually revalidate their own route.
const polls = readFileSync(
resolve(process.cwd(), "src/actions/polls.ts"),
"utf8",
);
expect(polls).toContain('revalidatePath("/polls")');
const events = readFileSync(
resolve(process.cwd(), "src/actions/events.ts"),
"utf8",
);
expect(events).toContain('revalidatePath("/events")');
});
});
+20 -1
View File
@@ -20,6 +20,21 @@ interface RunOptions {
onSuccess?: (data: Record<string, unknown>) => void;
/** Called when the action fails or throws */
onError?: () => void;
/**
* Set only when the action calls `revalidatePath()` / `revalidateTag()` for
* the route being displayed. Next.js then re-renders that page as part of the
* action's own response and the router applies it with a seeded navigation,
* so the extra `router.refresh()` is a duplicate round-trip.
*
* The framework only skips its own update when the action did NOT revalidate
* and sent no flight data — see the bail-out in
* `next/dist/client/components/router-reducer/reducers/server-action-reducer.js`.
*
* Leaving this off is always safe. Setting it for an action that does not
* revalidate leaves the screen stale, so it is opt-in per call rather than
* derived from the action name.
*/
revalidated?: boolean;
}
/**
@@ -52,6 +67,7 @@ export function useServerAction(options: UseServerActionOptions = {}) {
redirectTo,
onSuccess,
onError,
revalidated = false,
} = opts;
startTransition(async () => {
@@ -65,7 +81,10 @@ export function useServerAction(options: UseServerActionOptions = {}) {
} else {
const target = redirectTo || options.redirectOnSuccess;
if (target) router.push(target);
else router.refresh();
// The action already revalidated the current route, so its
// own response carried the new tree; refreshing again would
// just re-fetch it.
else if (!revalidated) router.refresh();
}
} else {
setFieldErrors(result.fieldErrors ?? {});
+45 -2
View File
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
total: 55,
fail: false,
rows: [] as unknown[],
queries: [] as { sql: string; params: unknown[] }[],
}));
vi.mock("@/lib/db", async () => {
@@ -14,7 +15,8 @@ vi.mock("@/lib/db", async () => {
db: drizzle(async (sql, params) => {
if (state.fail) throw new Error("unavailable");
state.queries.push({ sql, params });
return { rows: sql.includes("count(*)") ? [[state.total]] : [] };
if (sql.includes("count(*)")) return { rows: [[state.total]] };
return { rows: state.rows };
}),
};
});
@@ -25,6 +27,7 @@ describe("public news archive", () => {
beforeEach(() => {
state.total = 55;
state.fail = false;
state.rows = [];
state.queries = [];
});
it("filters unpublished and future articles before counting and paginating", async () => {
@@ -61,6 +64,46 @@ describe("public news archive", () => {
});
it("propagates failures instead of presenting them as an empty archive", async () => {
state.fail = true;
await expect(loadNewsArchive()).rejects.toThrow();
// Its own search term, so the entry is a cache miss and the failure
// actually reaches the database.
await expect(loadNewsArchive({ search: "boom" })).rejects.toThrow();
});
it("serves a repeated query from cache without querying again", async () => {
const first = await loadNewsArchive({ search: "repeat", page: 1 });
expect(state.queries.length).toBeGreaterThan(0);
const after = state.queries.length;
const second = await loadNewsArchive({ search: "repeat", page: 1 });
expect(second).toEqual(first);
expect(state.queries.length).toBe(after);
});
it("keeps cache entries separate per search, page and order", async () => {
await loadNewsArchive({ search: "distinct", page: 1 });
const after = state.queries.length;
await loadNewsArchive({ search: "distinct", page: 2 });
await loadNewsArchive({ search: "distinct", page: 1, order: "oldest" });
await loadNewsArchive({ search: "different", page: 1 });
expect(state.queries.length).toBeGreaterThan(after);
});
it("returns createdAt as a Date on a cache hit", async () => {
const rows = [
{
slug: "a",
title: "A",
shortStory: "s",
image: null,
createdAt: new Date("2024-01-02T03:04:05.000Z"),
},
];
state.rows = rows;
const first = await loadNewsArchive({ search: "dates" });
expect(first.rows[0]?.createdAt).toBeInstanceOf(Date);
const second = await loadNewsArchive({ search: "dates" });
expect(second.rows[0]?.createdAt).toBeInstanceOf(Date);
expect(second.rows[0]?.createdAt.getTime()).toBe(
first.rows[0]?.createdAt.getTime(),
);
});
});
+56 -2
View File
@@ -1,8 +1,20 @@
import "server-only";
import { and, asc, count, desc, eq, like, or, sql } from "drizzle-orm";
import { db, WebsiteArticles } from "@/lib/db";
import { cacheSafe } from "@/lib/redis-cache";
import { cacheNews } from "@/lib/services/news-cache";
export async function loadNewsArchive(
/**
* Fresh for a minute, then servable stale for another minute. Both the landing
* page and /search hit this, and the COUNT(*) over a leading-wildcard LIKE is a
* full scan of website_articles — the reason the archive felt slow once the
* table grew. Publishing an article rotates the news revision and drops these
* entries immediately, so the window only applies when pub/sub cannot be
* delivered.
*/
const ARCHIVE_TTL_MS = 60_000;
async function queryNewsArchive(
options: { search?: string; page?: number; order?: string } = {},
) {
const search = (typeof options.search === "string" ? options.search : "")
@@ -53,5 +65,47 @@ export async function loadNewsArchive(
.orderBy(sort(publishedAt), sort(WebsiteArticles.id))
.limit(perPage)
.offset((page - 1) * perPage);
return { rows, total, page, perPage, lastPage, search, order };
// cacheSafe() flattens the Date into the same ISO string on both cache
// paths. Without it a Redis hit would hand callers a string where a miss
// hands them a Date, and the archive grid formats this field directly.
return cacheSafe({ rows, total, page, perPage, lastPage, search, order });
}
/**
* Keyed on the REQUESTED page rather than the clamped one, so two requests that
* clamp onto the same page cannot alias each other's entry.
*/
function archiveKey(options: {
search?: string;
page?: number;
order?: string;
}): string {
const search = (typeof options.search === "string" ? options.search : "")
.trim()
.slice(0, 191);
const order = options.order === "oldest" ? "oldest" : "newest";
const page = Number.isFinite(options.page)
? Math.trunc(options.page ?? 1)
: 1;
return `news:archive:${order}:${page}:${search}`;
}
export async function loadNewsArchive(
options: { search?: string; page?: number; order?: string } = {},
) {
const key = archiveKey(options);
// cacheNews() namespaces by the news revision and owns the stale window.
const result = await cacheNews(key, ARCHIVE_TTL_MS, () =>
queryNewsArchive(options),
);
// Revive the one field callers treat as a Date. Kept outside the cache so
// the cached value stays plain JSON.
return {
...result,
rows: result.rows.map((row) => ({
...row,
createdAt: new Date(row.createdAt),
})),
};
}
+119 -75
View File
@@ -1,15 +1,37 @@
import { cached } from "@/lib/cache";
import { publicReadFailure } from "@/lib/services/public-read";
import "server-only";
import { and, asc, count, eq, inArray, like, or } from "drizzle-orm";
import { db, Rooms, User, WebsiteEvent } from "@/lib/db";
import { cacheSafe } from "@/lib/redis-cache";
import { loadNewsArchive } from "@/lib/services/news-archive";
/**
* A leading-wildcard LIKE cannot use an index, so every search section costs a
* COUNT(*) scan plus an ordered page fetch. Repeated queries are common enough
* (nav links, back-navigation, the same popular terms from many visitors) to be
* worth a short cache. The window is deliberately short: this is a live
* directory, and a user who registers or a room that opens should show up
* without waiting.
*/
const SEARCH_TTL_MS = 30_000;
function pageNumber(value: number | undefined, total: number) {
return Math.min(
Math.max(1, Math.ceil(total / 12)),
Number.isFinite(value) ? Math.max(1, Math.trunc(value ?? 1)) : 1,
);
}
/**
* Sections are cached independently so one slow query cannot hold up the rest,
* and so a failure stays a failure instead of being cached as a result.
*/
function searchKey(section: string, query: string, page: number | undefined) {
const requested = Number.isFinite(page) ? Math.trunc(page ?? 1) : 1;
return `search:${section}:${requested}:${query}`;
}
export async function loadPublicSearch(options: {
query: string;
usersPage?: number;
@@ -34,82 +56,104 @@ export async function loadPublicSearch(options: {
),
);
const [users, rooms, news, events] = await Promise.allSettled([
(async () => {
const [{ value: total }] = await db
.select({ value: count() })
.from(User)
.where(userWhere);
const page = pageNumber(options.usersPage, Number(total));
const rows = await db
.select({
id: User.id,
username: User.username,
look: User.look,
online: User.online,
})
.from(User)
.where(userWhere)
.orderBy(asc(User.username), asc(User.id))
.limit(12)
.offset((page - 1) * 12);
return {
rows,
total: Number(total),
page,
lastPage: Math.max(1, Math.ceil(Number(total) / 12)),
};
})(),
(async () => {
const [{ value: total }] = await db
.select({ value: count() })
.from(Rooms)
.where(roomWhere);
const page = pageNumber(options.roomsPage, Number(total));
const rows = await db
.select({
id: Rooms.id,
name: Rooms.name,
ownerName: Rooms.ownerName,
users: Rooms.users,
usersMax: Rooms.usersMax,
})
.from(Rooms)
.where(roomWhere)
.orderBy(asc(Rooms.name), asc(Rooms.id))
.limit(12)
.offset((page - 1) * 12);
return {
rows,
total: Number(total),
page,
lastPage: Math.max(1, Math.ceil(Number(total) / 12)),
};
})(),
cached(
searchKey("users", query, options.usersPage),
SEARCH_TTL_MS,
async () => {
const [{ value: total }] = await db
.select({ value: count() })
.from(User)
.where(userWhere);
const page = pageNumber(options.usersPage, Number(total));
const rows = await db
.select({
id: User.id,
username: User.username,
look: User.look,
online: User.online,
})
.from(User)
.where(userWhere)
.orderBy(asc(User.username), asc(User.id))
.limit(12)
.offset((page - 1) * 12);
return cacheSafe({
rows,
total: Number(total),
page,
lastPage: Math.max(1, Math.ceil(Number(total) / 12)),
});
},
),
cached(
searchKey("rooms", query, options.roomsPage),
SEARCH_TTL_MS,
async () => {
const [{ value: total }] = await db
.select({ value: count() })
.from(Rooms)
.where(roomWhere);
const page = pageNumber(options.roomsPage, Number(total));
const rows = await db
.select({
id: Rooms.id,
name: Rooms.name,
ownerName: Rooms.ownerName,
users: Rooms.users,
usersMax: Rooms.usersMax,
})
.from(Rooms)
.where(roomWhere)
.orderBy(asc(Rooms.name), asc(Rooms.id))
.limit(12)
.offset((page - 1) * 12);
return cacheSafe({
rows,
total: Number(total),
page,
lastPage: Math.max(1, Math.ceil(Number(total) / 12)),
});
},
),
loadNewsArchive({ search: query, page: options.newsPage }),
(async () => {
const [{ value: total }] = await db
.select({ value: count() })
.from(WebsiteEvent)
.where(eventWhere);
const page = pageNumber(options.eventsPage, Number(total));
const rows = await db
.select({
id: WebsiteEvent.id,
title: WebsiteEvent.title,
startsAt: WebsiteEvent.startsAt,
})
.from(WebsiteEvent)
.where(eventWhere)
.orderBy(asc(WebsiteEvent.startsAt), asc(WebsiteEvent.id))
.limit(12)
.offset((page - 1) * 12);
return {
rows,
total: Number(total),
page,
lastPage: Math.max(1, Math.ceil(Number(total) / 12)),
};
})(),
cached(
searchKey("events", query, options.eventsPage),
SEARCH_TTL_MS,
async () => {
const [{ value: total }] = await db
.select({ value: count() })
.from(WebsiteEvent)
.where(eventWhere);
const page = pageNumber(options.eventsPage, Number(total));
const rows = await db
.select({
id: WebsiteEvent.id,
title: WebsiteEvent.title,
startsAt: WebsiteEvent.startsAt,
})
.from(WebsiteEvent)
.where(eventWhere)
.orderBy(asc(WebsiteEvent.startsAt), asc(WebsiteEvent.id))
.limit(12)
.offset((page - 1) * 12);
// cacheSafe() turns startsAt into an ISO string for both cache
// paths; the page formats it with toISOString(), so revive it
// here to keep that call valid on a cache hit too.
const cachedResult = cacheSafe({
rows,
total: Number(total),
page,
lastPage: Math.max(1, Math.ceil(Number(total) / 12)),
});
return {
...cachedResult,
rows: cachedResult.rows.map((row) => ({
...row,
startsAt: new Date(row.startsAt),
})),
};
},
),
]);
for (const [section, result] of Object.entries({
users,