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
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:
1 parent
179484642f
commit
759ae91745
16 files changed
+794
-262
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user