From 97454f361b9f3a395ecf532b5fac3c50904c9129 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 22 Oct 2026 17:39:30 +0200 Subject: [PATCH] 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. --- src/app/(site)/help/[category]/page.tsx | 6 +- src/app/(site)/help/page.tsx | 125 +-- src/app/(site)/page.tsx | 1034 ++++++++++++----------- src/app/globals.css | 258 ++++-- src/components/auth/auth-top-bar.tsx | 10 +- src/components/language-switcher.tsx | 2 +- src/components/spotlight-card.tsx | 40 +- src/components/theme-vars.tsx | 15 +- src/lib/public-asset.test.ts | 51 ++ src/lib/public-asset.ts | 26 + src/messages/ar.json | 1 + src/messages/bg.json | 1 + src/messages/cs.json | 1 + src/messages/da.json | 1 + src/messages/de.json | 1 + src/messages/el.json | 1 + src/messages/en.json | 1 + src/messages/es.json | 1 + src/messages/fi.json | 1 + src/messages/fr.json | 1 + src/messages/hr.json | 1 + src/messages/hu.json | 1 + src/messages/it.json | 1 + src/messages/ja.json | 1 + src/messages/nl.json | 1 + src/messages/no.json | 1 + src/messages/pl.json | 1 + src/messages/pt.json | 1 + src/messages/ro.json | 1 + src/messages/ru.json | 1 + src/messages/sk.json | 1 + src/messages/sr.json | 1 + src/messages/sv.json | 1 + src/messages/tr.json | 1 + src/messages/uk.json | 1 + 35 files changed, 933 insertions(+), 659 deletions(-) create mode 100644 src/lib/public-asset.test.ts create mode 100644 src/lib/public-asset.ts diff --git a/src/app/(site)/help/[category]/page.tsx b/src/app/(site)/help/[category]/page.tsx index 9044c79f..4f3826a7 100644 --- a/src/app/(site)/help/[category]/page.tsx +++ b/src/app/(site)/help/[category]/page.tsx @@ -6,6 +6,7 @@ import { ContentCard } from "@/components/public/ui"; import { SanitizedHtml } from "@/components/shared/sanitized-html"; import { db, WebsiteHelpCenterCategories } from "@/lib/db"; import { resolveHotelName } from "@/lib/hotel-name"; +import { publicAssetExists } from "@/lib/public-asset"; import { sanitize } from "@/lib/sanitize"; // Asset path matches AtomCMS's Blade view: asset('/assets/images/help-center/'). @@ -99,7 +100,10 @@ export default async function HelpCategoryPage({ const title = withHotel(cat.name, hotelName); const content = withHotel(cat.content, hotelName); const buttonText = withHotel(cat.buttonText, hotelName); - const imageSrc = cat.imageUrl ? `${HELP_IMAGE_BASE}/${cat.imageUrl}` : ""; + const imageSrc = + cat.imageUrl && publicAssetExists(`${HELP_IMAGE_BASE}/${cat.imageUrl}`) + ? `${HELP_IMAGE_BASE}/${cat.imageUrl}` + : ""; const hasButton = Boolean(cat.buttonText && cat.buttonText.trim() !== ""); return ( diff --git a/src/app/(site)/help/page.tsx b/src/app/(site)/help/page.tsx index 22576be7..ff0ecdb6 100644 --- a/src/app/(site)/help/page.tsx +++ b/src/app/(site)/help/page.tsx @@ -23,6 +23,10 @@ import { WebsiteRuleCategories, WebsiteRules, } from "@/lib/db"; +import { publicAssetExists } from "@/lib/public-asset"; + +// Asset path matches AtomCMS's Blade view: asset('/assets/images/help-center/'). +const HELP_IMAGE_BASE = "/assets/images/help-center"; type HelpCategory = { id: bigint; @@ -149,66 +153,71 @@ export default async function HelpCenterPage() { {t("topicsEmpty")} ) : (
- {categories.map((c) => ( -
-
- {c.imageUrl ? ( - - ) : null} -
-

- {c.name} -

-

- {c.content} -

- {c.buttonUrl && c.buttonText ? ( -
- {isExternal(c.buttonUrl) ? ( - - {c.buttonText} - - ) : ( - - {c.buttonText} - - )} -
+ {categories.map((c) => { + const imageSrc = c.imageUrl + ? `${HELP_IMAGE_BASE}/${c.imageUrl}` + : ""; + return ( +
+
+ {imageSrc && publicAssetExists(imageSrc) ? ( + ) : null} +
+

+ {c.name} +

+

+ {c.content} +

+ {c.buttonUrl && c.buttonText ? ( +
+ {isExternal(c.buttonUrl) ? ( + + {c.buttonText} + + ) : ( + + {c.buttonText} + + )} +
+ ) : null} +
-
-
- ))} + + ); + })}
)} diff --git a/src/app/(site)/page.tsx b/src/app/(site)/page.tsx index 004dd5d2..c1fdfd11 100644 --- a/src/app/(site)/page.tsx +++ b/src/app/(site)/page.tsx @@ -40,6 +40,7 @@ import { ICON_FRIENDS, ICON_NAV_GOODY, ICON_NAV_ME, + ICON_STAR, } from "@/lib/site-icons"; export async function generateMetadata(): Promise { @@ -54,26 +55,90 @@ export async function generateMetadata(): Promise { }; } -// ── Local visual language (theme-aware, no big white blocks) ────────── -const PANEL_BG = - "linear-gradient(160deg, color-mix(in srgb, var(--color-primary) 12%, transparent) 0%, color-mix(in srgb, var(--color-surface) 58%, transparent) 52%, color-mix(in srgb, var(--color-accent) 9%, transparent) 100%)"; +/* ── Local visual language ──────────────────────────────────────────── + The landing panels used to carry their own translucent washes + (surface 44–60 % + a backdrop blur each). Four different milky smears over + the sky gradient, and a backdrop snapshot per panel on every scroll. The + shared `.panel*` classes in globals.css now own the surface recipe, so the + boxes read as one system and cost nothing to paint. */ -const PANEL_STYLE: CSSProperties = { - background: PANEL_BG, - backdropFilter: "blur(18px)", - WebkitBackdropFilter: "blur(18px)", - borderColor: "color-mix(in srgb, var(--color-text-muted) 12%, transparent)", - boxShadow: - "0 0 0 0.5px color-mix(in srgb, var(--color-text-muted) 6%, transparent), 0 24px 60px -30px color-mix(in srgb, #000 28%, transparent)", -}; +// The hero scrim. A photo always needs a dark wash for the copy to stay +// readable, so this one stays a fixed dark gradient in both themes. +const HERO_SCRIM = + "linear-gradient(180deg, rgba(8,11,24,0.80) 0%, rgba(8,11,24,0.58) 38%, rgba(8,11,24,0.42) 62%, rgba(8,11,24,0.60) 85%, color-mix(in srgb, var(--color-background) 92%, transparent) 100%)"; + +const HERO_COPY = "rgba(255,255,255,0.80)"; +const HERO_COPY_STRONG = "rgba(255,255,255,0.96)"; const DARK_BAND = "linear-gradient(120deg, color-mix(in srgb, var(--color-primary) 30%, #0a0e1e) 0%, color-mix(in srgb, var(--color-accent) 26%, #0a0e1e) 52%, color-mix(in srgb, var(--color-primary) 20%, #0a0e1e) 100%)"; -const GLASS_TILE_BG = - "linear-gradient(155deg, color-mix(in srgb, var(--color-surface) 60%, transparent) 0%, color-mix(in srgb, var(--color-surface) 44%, transparent) 60%, color-mix(in srgb, var(--color-accent) 6%, transparent) 100%)"; +/** Accent used for the nth surface of a repeating set. */ +function tintFor(index: number): string { + return index % 2 === 0 ? "var(--color-primary)" : "var(--color-accent)"; +} -interface GlassPanelProps { +interface PanelHeaderProps { + title: string; + icon?: string; + actionHref?: string; + actionLabel?: string; +} + +/** + * The header row every landing panel shares: tinted icon chip, title and an + * optional "see everything" link. + */ +function PanelHeader({ + title, + icon, + actionHref, + actionLabel, +}: PanelHeaderProps) { + return ( +
+
+ {icon && ( +
+ +
+ )} +

+ {title} +

+
+ {actionHref && actionLabel && ( + + {actionLabel} + + )} +
+ ); +} + +interface PanelProps { children: ReactNode; title?: string; icon?: string; @@ -83,7 +148,8 @@ interface GlassPanelProps { className?: string; } -function GlassPanel({ +/** A themed surface with an optional header. */ +function Panel({ children, title, icon, @@ -91,71 +157,22 @@ function GlassPanel({ actionLabel, bodyClassName, className, -}: GlassPanelProps) { +}: PanelProps) { const hasHeader = Boolean(title || icon); return (
{hasHeader && ( -
-
- {icon && ( -
- -
- )} - {title && ( -

- {title} -

- )} -
- {actionHref && actionLabel && ( - - {actionLabel} - - )} -
+ )} {hasHeader ? (
{children}
@@ -166,6 +183,56 @@ function GlassPanel({ ); } +/** + * Cover image for a news article. Articles without an upload used to render a + * broken , so they fall back to a themed plate instead. + */ +function ArticleCover({ + src, + alt, + featured, +}: { + src: string; + alt: string; + featured: boolean; +}) { + if (!src) { + return ( +
+ +
+ ); + } + + return ( + {alt} + ); +} + async function getHotelData() { const [ hotelName, @@ -297,79 +364,80 @@ async function Home() { desc: tp("featuresCommunityDesc"), icon: ICON_NAV_GOODY, }, + { + title: tp("featuresGames"), + desc: tp("featuresGamesDesc"), + icon: ICON_STAR, + }, ]; type NewsArticle = NonNullable[number]; - const renderArticleCard = (a: NewsArticle, featured = false) => ( - -
- {a.title} -
- { + const published = a.createdAt ? formatDate(a.createdAt, "date", "") : ""; + + return ( + +
+ +
-
-
- {formatDate(a.createdAt, "date", "")} -
-
- {featured && ( -

- {tp("latestArticle")} -

+ + +
+ {published && ( +
+ {published} +
)} -

- {a.title} -

+ {featured && ( +

+ {tp("latestArticle")} +

+ )} +

+ {a.title} +

+
-
- - ); + + ); + }; const exploreLinks = [ { href: "/community", label: tn("community") }, @@ -380,24 +448,17 @@ async function Home() { { href: "/rankings", label: tn("rankings") }, ]; - const statChips = [ - { value: users, label: tp("statsCitizens"), color: "var(--color-primary)" }, - { value: rooms, label: tp("statsRooms"), color: "var(--color-accent)" }, - { - value: totalPhotos, - label: tp("statsPhotos"), - color: "var(--color-primary)", - }, - { - value: articleCount, - label: tp("statsArticles"), - color: "var(--color-accent)", - }, - ].filter((s) => s.value !== null) as Array<{ - value: number; - label: string; - color: string; - }>; + // One source of truth for the four headline counters. They used to be + // rendered twice — as four small chips in the hero and again as a full + // panel further down — which made the page feel padded and repeated. + // A failed read stays visible as "unavailable" instead of silently + // shrinking the row, so a broken counter can never be mistaken for zero. + const statItems = [ + { value: users, label: tp("statsCitizens"), icon: ICON_FRIENDS }, + { value: rooms, label: tp("statsRooms"), icon: ICON_CATALOG }, + { value: totalPhotos, label: tp("statsPhotos"), icon: ICON_CAMERA }, + { value: articleCount, label: tp("statsArticles"), icon: ICON_ARTICLE }, + ]; return (
@@ -405,175 +466,211 @@ async function Home() {
- {/* ── Cinematic hero ── */} - -
- -
- {/* Drifting brand-tinted aurora behind the copy */} -