feat(home): rebuild the landing page on a shared panel system
The homepage mixed four different translucent washes (surface 44-60% plus a backdrop-filter each), rendered the same four counters twice, and showed a dangling "Welcome to " eyebrow because the localized string expected the hotel name. A missing theme wallpaper replaced the whole sky gradient with flat white, and stale CMS image references produced broken images and 404s. Replace the ad-hoc panels with one opaque, theme-derived `.panel` token set (surface, border, shadow, header, radius) and a `.panel-float` variant for the counter strip that straddles the hero edge. The strip is now the single home of the four headline counters; a failed read renders "unavailable" instead of silently dropping a cell. Add the fourth feature card (Games) that already had translated copy, link recent users to their profiles, and fall back to themed placeholders for news covers and camera photos without an upload. Add `pages.home.heroEyebrow` to all 25 locales, layer the CMS wallpaper over `--site-bg-gradient` instead of replacing it, and keep the sky in dark mode. Add `publicAssetExists` so local uploads that no longer exist are skipped (wallpaper, help topics) rather than rendered broken. Fix the `/help` topic icons to use the `/assets/images/help-center/` base the detail page already used. Throttle the spotlight glow to one update per animation frame, drop the dead `.glass-chip` rules, define the missing `.panel-float`, and fix the stat divider and reduced-motion selectors. Stat labels use the readable text token so they clear WCAG AA, and the language button's accessible name now contains its visible label. Verified: tsc, biome, 3590 unit tests, i18n audit unchanged, axe 0 violations at 390/768/1024/1280/1440 in light and dark, quality e2e 16/16.
This commit is contained in:
1 parent
ecd8e5852c
commit
97454f361b
35 files changed
+933
-659
No files matched your search
@@ -26,16 +26,10 @@ export function AuthTopBar({
|
||||
return (
|
||||
<Reveal>
|
||||
<div
|
||||
className="relative flex items-center justify-between gap-3 rounded-full border px-3 py-2.5 sm:px-5"
|
||||
className="panel relative flex items-center justify-between gap-3 rounded-full px-3 py-2.5 sm:px-5"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, color-mix(in srgb, var(--color-surface) 62%, transparent), color-mix(in srgb, var(--color-primary) 7%, transparent))",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
||||
boxShadow:
|
||||
"0 1px 3px rgba(0,0,0,0.04), 0 0 0 0.5px color-mix(in srgb, var(--color-text-muted) 6%, transparent)",
|
||||
"linear-gradient(90deg, var(--panel-surface), color-mix(in srgb, var(--color-primary) 7%, var(--panel-surface)))",
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
|
||||
|
||||
@@ -267,7 +267,7 @@ export function LanguageSwitcher({
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
disabled={pending}
|
||||
aria-label="Change language"
|
||||
aria-label={`Change language — ${current.code.toUpperCase()}`}
|
||||
title="Language"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties, MouseEvent, ReactNode } from "react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
interface SpotlightCardProps {
|
||||
children: ReactNode;
|
||||
@@ -12,22 +18,48 @@ interface SpotlightCardProps {
|
||||
* Wraps a card with a mouse-tracking radial glow. The pointer position is
|
||||
* written to `--spot-x` / `--spot-y` and consumed by the `.spotlight::before`
|
||||
* overlay in globals.css.
|
||||
*
|
||||
* The pointer move handler is throttled to one update per animation frame:
|
||||
* a pointermove fires far more often than the browser paints, and every
|
||||
* extra write re-styles the card (and its ::before gradient) for nothing.
|
||||
*/
|
||||
export function SpotlightCard({
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
}: SpotlightCardProps) {
|
||||
const frame = useRef(0);
|
||||
const pending = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
// Drop the queued frame if the card is removed mid-gesture.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (frame.current) cancelAnimationFrame(frame.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handlePointerMove = (event: MouseEvent<HTMLDivElement>) => {
|
||||
const el = event.currentTarget;
|
||||
const rect = el.getBoundingClientRect();
|
||||
el.style.setProperty("--spot-x", `${event.clientX - rect.left}px`);
|
||||
el.style.setProperty("--spot-y", `${event.clientY - rect.top}px`);
|
||||
pending.current = {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
};
|
||||
if (frame.current) return;
|
||||
|
||||
frame.current = requestAnimationFrame(() => {
|
||||
frame.current = 0;
|
||||
const point = pending.current;
|
||||
if (!point) return;
|
||||
el.style.setProperty("--spot-x", `${point.x}px`);
|
||||
el.style.setProperty("--spot-y", `${point.y}px`);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
className={className ? `spotlight ${className}` : "spotlight"}
|
||||
style={
|
||||
{ "--spot-x": "50%", "--spot-y": "50%", ...style } as CSSProperties
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { publicAssetExists } from "@/lib/public-asset";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import {
|
||||
BACKGROUND_DEFAULTS,
|
||||
@@ -240,9 +241,19 @@ export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
|
||||
? Math.min(parseInt(backgroundOverlayOpacity as string, 10), 90)
|
||||
: 0;
|
||||
|
||||
// The wallpaper is painted as the *first* background layer on top of the
|
||||
// theme gradient (`--site-bg-gradient`). It used to be the only layer,
|
||||
// which meant a missing upload wiped the sky entirely and left the site on
|
||||
// flat white. Layering keeps the gradient as a guaranteed fallback.
|
||||
//
|
||||
// A local wallpaper that no longer exists on disk (deleted upload, stale
|
||||
// theme setting) would also fire a 404 on every page view, so only emit the
|
||||
// layer when the file is actually there. Remote URLs are trusted as-is.
|
||||
const backgroundCss =
|
||||
backgroundImage && isSafeBackgroundUrl(backgroundImage)
|
||||
? `body.site-bg{background-image:url("${backgroundImage}");background-position:${safeBgPosition};background-size:${safeBgSize};background-repeat:${safeBgRepeat}}`
|
||||
backgroundImage &&
|
||||
isSafeBackgroundUrl(backgroundImage) &&
|
||||
publicAssetExists(backgroundImage)
|
||||
? `body.site-bg{background-image:url("${backgroundImage}"),var(--site-bg-gradient);background-position:${safeBgPosition},center top;background-size:${safeBgSize},cover;background-repeat:${safeBgRepeat},no-repeat}`
|
||||
: "";
|
||||
|
||||
const backdropStyle: CSSProperties | undefined = safeOverlay
|
||||
|
||||
Reference in new issue
Block a user