feat(home): rebuild the landing page on a shared panel system
CI / check (push) Successful in 32s
CI / tests-unit (push) Successful in 2m12s
CI / tests-integration (push) Successful in 2m43s
CI / tests-ui (push) Successful in 4m22s
CI / tests-imaging-browsers (push) Successful in 2m10s
CI / tests-quality (push) Successful in 1m32s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m1s
CI / check (push) Successful in 32s
CI / tests-unit (push) Successful in 2m12s
CI / tests-integration (push) Successful in 2m43s
CI / tests-ui (push) Successful in 4m22s
CI / tests-imaging-browsers (push) Successful in 2m10s
CI / tests-quality (push) Successful in 1m32s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m1s
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
+632
-358
No files matched your search
@@ -6,6 +6,7 @@ import { ContentCard } from "@/components/public/ui";
|
|||||||
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
import { SanitizedHtml } from "@/components/shared/sanitized-html";
|
||||||
import { db, WebsiteHelpCenterCategories } from "@/lib/db";
|
import { db, WebsiteHelpCenterCategories } from "@/lib/db";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
|
import { publicAssetExists } from "@/lib/public-asset";
|
||||||
import { sanitize } from "@/lib/sanitize";
|
import { sanitize } from "@/lib/sanitize";
|
||||||
|
|
||||||
// Asset path matches AtomCMS's Blade view: asset('/assets/images/help-center/<image_url>').
|
// Asset path matches AtomCMS's Blade view: asset('/assets/images/help-center/<image_url>').
|
||||||
@@ -99,7 +100,10 @@ export default async function HelpCategoryPage({
|
|||||||
const title = withHotel(cat.name, hotelName);
|
const title = withHotel(cat.name, hotelName);
|
||||||
const content = withHotel(cat.content, hotelName);
|
const content = withHotel(cat.content, hotelName);
|
||||||
const buttonText = withHotel(cat.buttonText, 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() !== "");
|
const hasButton = Boolean(cat.buttonText && cat.buttonText.trim() !== "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ import {
|
|||||||
WebsiteRuleCategories,
|
WebsiteRuleCategories,
|
||||||
WebsiteRules,
|
WebsiteRules,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
|
import { publicAssetExists } from "@/lib/public-asset";
|
||||||
|
|
||||||
|
// Asset path matches AtomCMS's Blade view: asset('/assets/images/help-center/<image_url>').
|
||||||
|
const HELP_IMAGE_BASE = "/assets/images/help-center";
|
||||||
|
|
||||||
type HelpCategory = {
|
type HelpCategory = {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
@@ -149,7 +153,11 @@ export default async function HelpCenterPage() {
|
|||||||
<EmptyState icon="🧭">{t("topicsEmpty")}</EmptyState>
|
<EmptyState icon="🧭">{t("topicsEmpty")}</EmptyState>
|
||||||
) : (
|
) : (
|
||||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
||||||
{categories.map((c) => (
|
{categories.map((c) => {
|
||||||
|
const imageSrc = c.imageUrl
|
||||||
|
? `${HELP_IMAGE_BASE}/${c.imageUrl}`
|
||||||
|
: "";
|
||||||
|
return (
|
||||||
<article key={String(c.id)} className="card hover">
|
<article key={String(c.id)} className="card hover">
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -158,9 +166,9 @@ export default async function HelpCenterPage() {
|
|||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{c.imageUrl ? (
|
{imageSrc && publicAssetExists(imageSrc) ? (
|
||||||
<img
|
<img
|
||||||
src={c.imageUrl}
|
src={imageSrc}
|
||||||
alt=""
|
alt=""
|
||||||
width={48}
|
width={48}
|
||||||
height={48}
|
height={48}
|
||||||
@@ -208,7 +216,8 @@ export default async function HelpCenterPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ContentCard>
|
</ContentCard>
|
||||||
|
|||||||
+283
-257
@@ -40,6 +40,7 @@ import {
|
|||||||
ICON_FRIENDS,
|
ICON_FRIENDS,
|
||||||
ICON_NAV_GOODY,
|
ICON_NAV_GOODY,
|
||||||
ICON_NAV_ME,
|
ICON_NAV_ME,
|
||||||
|
ICON_STAR,
|
||||||
} from "@/lib/site-icons";
|
} from "@/lib/site-icons";
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
@@ -54,73 +55,53 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Local visual language (theme-aware, no big white blocks) ──────────
|
/* ── Local visual language ────────────────────────────────────────────
|
||||||
const PANEL_BG =
|
The landing panels used to carry their own translucent washes
|
||||||
"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%)";
|
(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 = {
|
// The hero scrim. A photo always needs a dark wash for the copy to stay
|
||||||
background: PANEL_BG,
|
// readable, so this one stays a fixed dark gradient in both themes.
|
||||||
backdropFilter: "blur(18px)",
|
const HERO_SCRIM =
|
||||||
WebkitBackdropFilter: "blur(18px)",
|
"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%)";
|
||||||
borderColor: "color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
|
||||||
boxShadow:
|
const HERO_COPY = "rgba(255,255,255,0.80)";
|
||||||
"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)",
|
const HERO_COPY_STRONG = "rgba(255,255,255,0.96)";
|
||||||
};
|
|
||||||
|
|
||||||
const DARK_BAND =
|
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%)";
|
"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 =
|
/** Accent used for the nth surface of a repeating set. */
|
||||||
"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%)";
|
function tintFor(index: number): string {
|
||||||
|
return index % 2 === 0 ? "var(--color-primary)" : "var(--color-accent)";
|
||||||
|
}
|
||||||
|
|
||||||
interface GlassPanelProps {
|
interface PanelHeaderProps {
|
||||||
children: ReactNode;
|
title: string;
|
||||||
title?: string;
|
|
||||||
icon?: string;
|
icon?: string;
|
||||||
actionHref?: string;
|
actionHref?: string;
|
||||||
actionLabel?: string;
|
actionLabel?: string;
|
||||||
bodyClassName?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function GlassPanel({
|
/**
|
||||||
children,
|
* The header row every landing panel shares: tinted icon chip, title and an
|
||||||
|
* optional "see everything" link.
|
||||||
|
*/
|
||||||
|
function PanelHeader({
|
||||||
title,
|
title,
|
||||||
icon,
|
icon,
|
||||||
actionHref,
|
actionHref,
|
||||||
actionLabel,
|
actionLabel,
|
||||||
bodyClassName,
|
}: PanelHeaderProps) {
|
||||||
className,
|
|
||||||
}: GlassPanelProps) {
|
|
||||||
const hasHeader = Boolean(title || icon);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="panel-header flex items-center justify-between gap-3 px-5 py-3.5">
|
||||||
className={`card-glow card-hairline relative overflow-hidden rounded-2xl border ${
|
|
||||||
className ?? ""
|
|
||||||
}`}
|
|
||||||
style={PANEL_STYLE}
|
|
||||||
>
|
|
||||||
{hasHeader && (
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between gap-3 border-b px-5 py-4"
|
|
||||||
style={{
|
|
||||||
borderColor:
|
|
||||||
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
|
||||||
background:
|
|
||||||
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 8%, transparent), transparent 70%)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
{icon && (
|
{icon && (
|
||||||
<div
|
<div
|
||||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl"
|
className="panel-tile flex h-9 w-9 shrink-0 items-center justify-center rounded-xl"
|
||||||
style={{
|
style={{ "--panel-tint": "var(--color-primary)" } as CSSProperties}
|
||||||
background:
|
|
||||||
"color-mix(in srgb, var(--color-primary) 14%, transparent)",
|
|
||||||
boxShadow:
|
|
||||||
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={icon}
|
src={icon}
|
||||||
@@ -132,7 +113,6 @@ function GlassPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{title && (
|
|
||||||
<h2
|
<h2
|
||||||
className="truncate text-sm font-extrabold tracking-wide"
|
className="truncate text-sm font-extrabold tracking-wide"
|
||||||
style={{
|
style={{
|
||||||
@@ -142,7 +122,6 @@ function GlassPanel({
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{actionHref && actionLabel && (
|
{actionHref && actionLabel && (
|
||||||
<Link
|
<Link
|
||||||
@@ -156,6 +135,44 @@ function GlassPanel({
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PanelProps {
|
||||||
|
children: ReactNode;
|
||||||
|
title?: string;
|
||||||
|
icon?: string;
|
||||||
|
actionHref?: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
bodyClassName?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A themed surface with an optional header. */
|
||||||
|
function Panel({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
actionHref,
|
||||||
|
actionLabel,
|
||||||
|
bodyClassName,
|
||||||
|
className,
|
||||||
|
}: PanelProps) {
|
||||||
|
const hasHeader = Boolean(title || icon);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`panel card-glow card-hairline relative overflow-hidden rounded-[var(--panel-radius)] ${
|
||||||
|
className ?? ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{hasHeader && (
|
||||||
|
<PanelHeader
|
||||||
|
title={title ?? ""}
|
||||||
|
icon={icon}
|
||||||
|
actionHref={actionHref}
|
||||||
|
actionLabel={actionLabel}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{hasHeader ? (
|
{hasHeader ? (
|
||||||
<div className={bodyClassName ?? "p-5"}>{children}</div>
|
<div className={bodyClassName ?? "p-5"}>{children}</div>
|
||||||
@@ -166,6 +183,56 @@ function GlassPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cover image for a news article. Articles without an upload used to render a
|
||||||
|
* broken <Image>, so they fall back to a themed plate instead.
|
||||||
|
*/
|
||||||
|
function ArticleCover({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
featured,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
featured: boolean;
|
||||||
|
}) {
|
||||||
|
if (!src) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-full w-full items-center justify-center"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(140deg, color-mix(in srgb, var(--color-primary) 26%, var(--color-surface)), color-mix(in srgb, var(--color-accent) 18%, var(--color-surface)))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={ICON_ARTICLE}
|
||||||
|
alt=""
|
||||||
|
width={featured ? 56 : 40}
|
||||||
|
height={featured ? 56 : 40}
|
||||||
|
unoptimized
|
||||||
|
className="opacity-70"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
fill
|
||||||
|
sizes={
|
||||||
|
featured
|
||||||
|
? "(max-width: 768px) 100vw, 60vw"
|
||||||
|
: "(max-width: 640px) 100vw, 20vw"
|
||||||
|
}
|
||||||
|
className="object-cover transition-transform duration-700 ease-out group-hover:scale-[1.06]"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function getHotelData() {
|
async function getHotelData() {
|
||||||
const [
|
const [
|
||||||
hotelName,
|
hotelName,
|
||||||
@@ -297,46 +364,45 @@ async function Home() {
|
|||||||
desc: tp("featuresCommunityDesc"),
|
desc: tp("featuresCommunityDesc"),
|
||||||
icon: ICON_NAV_GOODY,
|
icon: ICON_NAV_GOODY,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: tp("featuresGames"),
|
||||||
|
desc: tp("featuresGamesDesc"),
|
||||||
|
icon: ICON_STAR,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
type NewsArticle = NonNullable<typeof articles>[number];
|
type NewsArticle = NonNullable<typeof articles>[number];
|
||||||
|
|
||||||
const renderArticleCard = (a: NewsArticle, featured = false) => (
|
const renderArticleCard = (a: NewsArticle, featured = false) => {
|
||||||
|
const published = a.createdAt ? formatDate(a.createdAt, "date", "") : "";
|
||||||
|
|
||||||
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={a.slug}
|
key={a.slug}
|
||||||
href={`/news/${a.slug}`}
|
href={`/news/${a.slug}`}
|
||||||
className="group relative w-full overflow-hidden rounded-2xl transition-all duration-500 hover:-translate-y-1 hover:shadow-2xl"
|
className="group relative block w-full overflow-hidden rounded-[var(--panel-radius)] border transition-all duration-500 hover:-translate-y-1 hover:shadow-2xl"
|
||||||
style={{
|
style={{
|
||||||
height: featured
|
height: featured
|
||||||
? "clamp(240px, 28vw, 320px)"
|
? "clamp(240px, 28vw, 320px)"
|
||||||
: "clamp(180px, 20vw, 230px)",
|
: "clamp(180px, 20vw, 230px)",
|
||||||
backgroundColor: "var(--color-text-muted)",
|
backgroundColor: "var(--color-text-muted)",
|
||||||
border:
|
borderColor: "var(--panel-border)",
|
||||||
"1px solid color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative h-full w-full overflow-hidden rounded-2xl">
|
<div className="relative h-full w-full overflow-hidden rounded-[var(--panel-radius)]">
|
||||||
<Image
|
<ArticleCover src={a.image} alt={a.title} featured={featured} />
|
||||||
src={a.image}
|
|
||||||
alt={a.title}
|
|
||||||
fill
|
|
||||||
sizes={featured ? "100vw" : "(max-width: 640px) 100vw, 33vw"}
|
|
||||||
className="rounded-2xl object-cover transition-transform duration-700 ease-out group-hover:scale-108"
|
|
||||||
unoptimized
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
className="absolute right-2.5 top-2.5 flex h-8 w-8 translate-x-1 items-center justify-center rounded-full opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100"
|
className="absolute right-2.5 top-2.5 flex h-8 w-8 translate-x-1 items-center justify-center rounded-full opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100"
|
||||||
style={{
|
style={{ background: "color-mix(in srgb, #000 45%, transparent)" }}
|
||||||
background: "color-mix(in srgb, #000 45%, transparent)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="text-sm font-bold"
|
className="text-sm font-bold"
|
||||||
style={{ color: "rgba(255,255,255,0.95)" }}
|
style={{ color: HERO_COPY_STRONG }}
|
||||||
>
|
>
|
||||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{published && (
|
||||||
<div
|
<div
|
||||||
className="absolute left-2.5 top-2.5 rounded-full px-2 py-0.5 text-[10px] font-bold backdrop-blur-sm"
|
className="absolute left-2.5 top-2.5 rounded-full px-2 py-0.5 text-[10px] font-bold backdrop-blur-sm"
|
||||||
style={{
|
style={{
|
||||||
@@ -344,8 +410,9 @@ async function Home() {
|
|||||||
color: "#fff",
|
color: "#fff",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formatDate(a.createdAt, "date", "")}
|
{published}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-0 left-0 w-full p-3 sm:p-4"
|
className="absolute bottom-0 left-0 w-full p-3 sm:p-4"
|
||||||
style={{
|
style={{
|
||||||
@@ -362,7 +429,7 @@ async function Home() {
|
|||||||
className={`line-clamp-2 font-bold text-shadow-sm ${
|
className={`line-clamp-2 font-bold text-shadow-sm ${
|
||||||
featured ? "text-base sm:text-2xl" : "text-sm sm:text-base"
|
featured ? "text-base sm:text-2xl" : "text-sm sm:text-base"
|
||||||
}`}
|
}`}
|
||||||
style={{ color: "rgba(255,255,255,0.95)" }}
|
style={{ color: HERO_COPY_STRONG }}
|
||||||
>
|
>
|
||||||
{a.title}
|
{a.title}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -370,6 +437,7 @@ async function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const exploreLinks = [
|
const exploreLinks = [
|
||||||
{ href: "/community", label: tn("community") },
|
{ href: "/community", label: tn("community") },
|
||||||
@@ -380,24 +448,17 @@ async function Home() {
|
|||||||
{ href: "/rankings", label: tn("rankings") },
|
{ href: "/rankings", label: tn("rankings") },
|
||||||
];
|
];
|
||||||
|
|
||||||
const statChips = [
|
// One source of truth for the four headline counters. They used to be
|
||||||
{ value: users, label: tp("statsCitizens"), color: "var(--color-primary)" },
|
// rendered twice — as four small chips in the hero and again as a full
|
||||||
{ value: rooms, label: tp("statsRooms"), color: "var(--color-accent)" },
|
// panel further down — which made the page feel padded and repeated.
|
||||||
{
|
// A failed read stays visible as "unavailable" instead of silently
|
||||||
value: totalPhotos,
|
// shrinking the row, so a broken counter can never be mistaken for zero.
|
||||||
label: tp("statsPhotos"),
|
const statItems = [
|
||||||
color: "var(--color-primary)",
|
{ 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,
|
{ value: articleCount, label: tp("statsArticles"), icon: ICON_ARTICLE },
|
||||||
label: tp("statsArticles"),
|
];
|
||||||
color: "var(--color-accent)",
|
|
||||||
},
|
|
||||||
].filter((s) => s.value !== null) as Array<{
|
|
||||||
value: number;
|
|
||||||
label: string;
|
|
||||||
color: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-14 pb-16 pt-2 md:gap-20 md:pb-24">
|
<div className="flex w-full flex-col gap-14 pb-16 pt-2 md:gap-20 md:pb-24">
|
||||||
@@ -405,7 +466,9 @@ async function Home() {
|
|||||||
<div className="mx-auto w-full max-w-7xl">
|
<div className="mx-auto w-full max-w-7xl">
|
||||||
<AuthTopBar hotelName={hotelName} logo={logo} online={online} />
|
<AuthTopBar hotelName={hotelName} logo={logo} online={online} />
|
||||||
</div>
|
</div>
|
||||||
{/* ── Cinematic hero ── */}
|
|
||||||
|
{/* ── Hero + the headline counters, which straddle the hero edge ── */}
|
||||||
|
<div className="relative w-full">
|
||||||
<Reveal className="-mx-3 sm:-mx-4 md:-mx-6">
|
<Reveal className="-mx-3 sm:-mx-4 md:-mx-6">
|
||||||
<div className="relative overflow-hidden">
|
<div className="relative overflow-hidden">
|
||||||
<Image
|
<Image
|
||||||
@@ -419,10 +482,7 @@ async function Home() {
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
style={{
|
style={{ background: HERO_SCRIM }}
|
||||||
background:
|
|
||||||
"linear-gradient(180deg, rgba(8,11,24,0.74) 0%, rgba(8,11,24,0.55) 38%, rgba(8,11,24,0.38) 62%, rgba(8,11,24,0.55) 85%, color-mix(in srgb, var(--color-background) 94%, transparent) 100%)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{/* Drifting brand-tinted aurora behind the copy */}
|
{/* Drifting brand-tinted aurora behind the copy */}
|
||||||
<div
|
<div
|
||||||
@@ -458,7 +518,7 @@ async function Home() {
|
|||||||
className="mb-3 block text-xs font-extrabold uppercase tracking-[0.32em] sm:text-sm"
|
className="mb-3 block text-xs font-extrabold uppercase tracking-[0.32em] sm:text-sm"
|
||||||
style={{ color: "rgba(255,255,255,0.72)" }}
|
style={{ color: "rgba(255,255,255,0.72)" }}
|
||||||
>
|
>
|
||||||
{tp("welcome", { hotelName: "" })}
|
{tp("heroEyebrow")}
|
||||||
</span>
|
</span>
|
||||||
<span className="gradient-text drop-shadow-[0_2px_24px_rgba(0,0,0,0.55)] block text-5xl font-black leading-[1.02] tracking-tight sm:text-6xl md:text-7xl lg:text-8xl">
|
<span className="gradient-text drop-shadow-[0_2px_24px_rgba(0,0,0,0.55)] block text-5xl font-black leading-[1.02] tracking-tight sm:text-6xl md:text-7xl lg:text-8xl">
|
||||||
<TypewriterText text={hotelName} />
|
<TypewriterText text={hotelName} />
|
||||||
@@ -467,7 +527,7 @@ async function Home() {
|
|||||||
|
|
||||||
<p
|
<p
|
||||||
className="mt-6 max-w-xl text-sm font-semibold leading-relaxed sm:text-base md:text-lg"
|
className="mt-6 max-w-xl text-sm font-semibold leading-relaxed sm:text-base md:text-lg"
|
||||||
style={{ color: "rgba(255,255,255,0.78)" }}
|
style={{ color: HERO_COPY }}
|
||||||
>
|
>
|
||||||
{tp("welcomeBody")}
|
{tp("welcomeBody")}
|
||||||
</p>
|
</p>
|
||||||
@@ -491,7 +551,7 @@ async function Home() {
|
|||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="btn-glass-dark px-8 py-4 text-sm font-bold"
|
className="btn-glass-dark group px-8 py-4 text-sm font-bold"
|
||||||
>
|
>
|
||||||
{th("login")}
|
{th("login")}
|
||||||
<span
|
<span
|
||||||
@@ -502,31 +562,6 @@ async function Home() {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating stat chips */}
|
|
||||||
<div
|
|
||||||
className="animate-fade-in-up mt-12 flex flex-wrap items-center justify-center gap-2.5 sm:gap-3"
|
|
||||||
style={{ animationDelay: "0.32s" }}
|
|
||||||
>
|
|
||||||
{statChips.map((s) => (
|
|
||||||
<span key={s.label} className="glass-chip">
|
|
||||||
<span
|
|
||||||
className="glass-chip-dot"
|
|
||||||
style={{ background: s.color }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="glass-chip-value"
|
|
||||||
style={{
|
|
||||||
color: "var(--color-text-readable)",
|
|
||||||
fontSize: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AnimatedCounter value={s.value} />
|
|
||||||
</span>
|
|
||||||
<span className="glass-chip-label">{s.label}</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scroll cue */}
|
{/* Scroll cue */}
|
||||||
@@ -572,8 +607,70 @@ async function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
|
{/* The headline counters. They straddle the hero edge so the
|
||||||
|
numbers land above the fold instead of in a second block
|
||||||
|
further down the page. */}
|
||||||
|
<Reveal className="relative z-20 mx-auto -mt-14 w-full max-w-4xl px-3 sm:-mt-16 sm:px-4 md:px-6">
|
||||||
|
<div className="panel panel-float relative overflow-hidden rounded-[var(--panel-radius)] px-2 py-5 sm:px-4 sm:py-6">
|
||||||
|
<div className="grid grid-cols-2 gap-y-6 lg:grid-cols-4 lg:gap-y-0">
|
||||||
|
{statItems.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={s.label}
|
||||||
|
className="stat-cell group flex flex-col items-center gap-2 text-center"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="panel-tile flex h-10 w-10 items-center justify-center rounded-xl transition-transform duration-300 group-hover:scale-110"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--panel-tint": tintFor(i),
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={s.icon}
|
||||||
|
alt=""
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="gradient-text text-3xl font-black tracking-tight sm:text-4xl"
|
||||||
|
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||||
|
>
|
||||||
|
{s.value === null ? (
|
||||||
|
<span
|
||||||
|
className="text-base font-bold"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
"var(--color-text-muted-readable, var(--color-text-muted))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{te("unavailable")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<AnimatedCounter value={s.value} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="rounded-full px-2.5 py-1 text-[11px] font-bold uppercase tracking-widest"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 9%, transparent)",
|
||||||
|
color: "var(--color-text-readable)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Quick explore ── */}
|
{/* ── Quick explore ── */}
|
||||||
<Reveal className="-mt-3 mx-auto w-full max-w-7xl">
|
<Reveal className="-mt-6 mx-auto w-full max-w-7xl">
|
||||||
<nav
|
<nav
|
||||||
aria-label={tp("exploreLabel")}
|
aria-label={tp("exploreLabel")}
|
||||||
className="flex flex-wrap items-center justify-center gap-2.5 sm:gap-3"
|
className="flex flex-wrap items-center justify-center gap-2.5 sm:gap-3"
|
||||||
@@ -584,13 +681,8 @@ async function Home() {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-1.5 w-1.5 shrink-0 rounded-full"
|
className="h-1.5 w-1.5 shrink-0 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
background:
|
background: tintFor(i),
|
||||||
i % 2 === 0
|
boxShadow: `0 0 8px color-mix(in srgb, ${tintFor(i)} 70%, transparent)`,
|
||||||
? "var(--color-primary)"
|
|
||||||
: "var(--color-accent)",
|
|
||||||
boxShadow: `0 0 8px color-mix(in srgb, ${
|
|
||||||
i % 2 === 0 ? "var(--color-primary)" : "var(--color-accent)"
|
|
||||||
} 70%, transparent)`,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{l.label}
|
{l.label}
|
||||||
@@ -609,7 +701,6 @@ async function Home() {
|
|||||||
{/* ── Features ── */}
|
{/* ── Features ── */}
|
||||||
<Reveal className="mx-auto w-full max-w-7xl">
|
<Reveal className="mx-auto w-full max-w-7xl">
|
||||||
<div className="flex flex-col gap-6 sm:gap-8">
|
<div className="flex flex-col gap-6 sm:gap-8">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
|
||||||
<div>
|
<div>
|
||||||
<span className="eyebrow">{tp("sectionFeaturesLabel")}</span>
|
<span className="eyebrow">{tp("sectionFeaturesLabel")}</span>
|
||||||
<h2
|
<h2
|
||||||
@@ -619,22 +710,11 @@ async function Home() {
|
|||||||
{tp("sectionFeaturesTitle")}
|
{tp("sectionFeaturesTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-5 lg:grid-cols-4">
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-5">
|
{features.map((f, i) => (
|
||||||
{features.map((f, i) => {
|
|
||||||
const accentVar =
|
|
||||||
i % 2 === 0 ? "var(--color-primary)" : "var(--color-accent)";
|
|
||||||
return (
|
|
||||||
<SpotlightCard
|
<SpotlightCard
|
||||||
key={f.title}
|
key={f.title}
|
||||||
className="spotlight card-glow card-hairline group relative overflow-hidden rounded-2xl border p-6 transition-all duration-500 hover:-translate-y-1.5 hover:shadow-2xl sm:p-7"
|
className="panel card-glow card-hairline group relative overflow-hidden rounded-[var(--panel-radius)] p-6 transition-all duration-500 hover:-translate-y-1.5 hover:shadow-2xl sm:p-7"
|
||||||
style={{
|
|
||||||
background: GLASS_TILE_BG,
|
|
||||||
backdropFilter: "blur(16px)",
|
|
||||||
WebkitBackdropFilter: "blur(16px)",
|
|
||||||
borderColor:
|
|
||||||
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -650,12 +730,13 @@ async function Home() {
|
|||||||
{`0${i + 1}`}
|
{`0${i + 1}`}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
className="relative flex h-12 w-12 items-center justify-center rounded-2xl transition-transform duration-300 group-hover:scale-110 group-hover:-rotate-3"
|
className="panel-tile relative flex h-12 w-12 items-center justify-center rounded-2xl transition-transform duration-300 group-hover:scale-110 group-hover:-rotate-3"
|
||||||
style={{
|
style={
|
||||||
background: `linear-gradient(135deg, color-mix(in srgb, ${accentVar} 30%, transparent), color-mix(in srgb, ${accentVar} 12%, transparent))`,
|
{
|
||||||
boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${accentVar} 30%, transparent), 0 8px 18px -10px color-mix(in srgb, ${accentVar} 70%, transparent)`,
|
"--panel-tint": tintFor(i),
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
}}
|
} as CSSProperties
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={f.icon}
|
src={f.icon}
|
||||||
@@ -676,106 +757,22 @@ async function Home() {
|
|||||||
</h3>
|
</h3>
|
||||||
<p
|
<p
|
||||||
className="relative mt-1.5 text-sm leading-relaxed"
|
className="relative mt-1.5 text-sm leading-relaxed"
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
style={{
|
||||||
|
color:
|
||||||
|
"var(--color-text-muted-readable, var(--color-text-muted))",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{f.desc}
|
{f.desc}
|
||||||
</p>
|
</p>
|
||||||
</SpotlightCard>
|
</SpotlightCard>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
|
|
||||||
{/* ── Stats ── */}
|
|
||||||
<Reveal className="mx-auto w-full max-w-7xl">
|
|
||||||
<div className="flex flex-col gap-6 sm:gap-8">
|
|
||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<span className="eyebrow">{tp("sectionStatsLabel")}</span>
|
|
||||||
<h2
|
|
||||||
className="section-title mt-2 text-2xl sm:text-3xl md:text-4xl"
|
|
||||||
style={{ letterSpacing: "-0.02em" }}
|
|
||||||
>
|
|
||||||
{tp("sectionStatsTitle")}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="card-hairline overflow-hidden rounded-2xl border"
|
|
||||||
style={PANEL_STYLE}
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-8 p-6 sm:p-8 lg:grid-cols-4">
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
].map((s) => (
|
|
||||||
<div
|
|
||||||
key={s.label}
|
|
||||||
className="stat-cell group relative flex flex-col items-center gap-2.5 text-center"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex h-10 w-10 items-center justify-center rounded-xl transition-transform duration-300 group-hover:scale-110"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"color-mix(in srgb, var(--color-primary) 14%, transparent)",
|
|
||||||
boxShadow:
|
|
||||||
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={s.icon}
|
|
||||||
alt=""
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
unoptimized
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="gradient-text text-4xl font-black tracking-tight sm:text-5xl"
|
|
||||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
|
||||||
>
|
|
||||||
{s.value === null ? (
|
|
||||||
<span className="text-base">{te("unavailable")}</span>
|
|
||||||
) : (
|
|
||||||
<AnimatedCounter value={s.value} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className="rounded-full px-3 py-1 text-[10px] font-bold uppercase tracking-widest"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{s.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
{/* ── Community ── */}
|
{/* ── Community ── */}
|
||||||
<Reveal className="mx-auto w-full max-w-7xl">
|
<Reveal className="mx-auto w-full max-w-7xl">
|
||||||
<div className="flex flex-col gap-6 sm:gap-8">
|
<div className="flex flex-col gap-6 sm:gap-8">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
|
||||||
<div>
|
<div>
|
||||||
<span className="eyebrow">{tp("sectionCommunityLabel")}</span>
|
<span className="eyebrow">{tp("sectionCommunityLabel")}</span>
|
||||||
<h2
|
<h2
|
||||||
@@ -785,10 +782,9 @@ async function Home() {
|
|||||||
{tp("sectionCommunityTitle")}
|
{tp("sectionCommunityTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
<div className="flex w-full flex-col gap-4 md:w-1/3 md:flex-shrink-0">
|
<div className="flex w-full flex-col gap-4 md:w-1/3 md:flex-shrink-0">
|
||||||
<GlassPanel
|
<Panel
|
||||||
title={th("login")}
|
title={th("login")}
|
||||||
icon={ICON_NAV_ME}
|
icon={ICON_NAV_ME}
|
||||||
bodyClassName="p-3.5 sm:p-4"
|
bodyClassName="p-3.5 sm:p-4"
|
||||||
@@ -803,15 +799,15 @@ async function Home() {
|
|||||||
}}
|
}}
|
||||||
nonce={nonce}
|
nonce={nonce}
|
||||||
/>
|
/>
|
||||||
</GlassPanel>
|
</Panel>
|
||||||
|
|
||||||
{recentUsers === null ? (
|
{recentUsers === null ? (
|
||||||
<GlassPanel title={tp("recentUsers")} bodyClassName="p-4">
|
<Panel title={tp("recentUsers")} bodyClassName="p-4">
|
||||||
<PublicLoadError href="/" />
|
<PublicLoadError href="/" />
|
||||||
</GlassPanel>
|
</Panel>
|
||||||
) : (
|
) : (
|
||||||
recentUsers.length > 0 && (
|
recentUsers.length > 0 && (
|
||||||
<GlassPanel
|
<Panel
|
||||||
title={tp("recentUsers")}
|
title={tp("recentUsers")}
|
||||||
icon={ICON_NAV_GOODY}
|
icon={ICON_NAV_GOODY}
|
||||||
actionHref="/community"
|
actionHref="/community"
|
||||||
@@ -820,34 +816,45 @@ async function Home() {
|
|||||||
>
|
>
|
||||||
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
|
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
|
||||||
{recentUsers.map((u) => (
|
{recentUsers.map((u) => (
|
||||||
<div
|
<Link
|
||||||
key={u.username}
|
key={u.username}
|
||||||
|
href={`/u/${encodeURIComponent(u.username)}`}
|
||||||
title={u.username}
|
title={u.username}
|
||||||
className="flex flex-col items-center gap-0.5 rounded-lg px-1 py-1.5 transition-all duration-200 hover:bg-[color-mix(in_srgb,var(--color-primary)_6%,transparent)] hover:ring-1 hover:ring-[color-mix(in_srgb,var(--color-primary)_35%,transparent)]"
|
className="group flex flex-col items-center gap-1 rounded-xl border border-transparent p-1.5 transition-all duration-200 hover:border-[color-mix(in_srgb,var(--color-primary)_32%,transparent)] hover:bg-[color-mix(in_srgb,var(--color-primary)_8%,transparent)]"
|
||||||
>
|
>
|
||||||
|
<span className="relative">
|
||||||
<UserAvatarThumbnail
|
<UserAvatarThumbnail
|
||||||
figure={u.look}
|
figure={u.look}
|
||||||
alt={u.username}
|
alt={u.username}
|
||||||
options={{ direction: 2 }}
|
options={{ direction: 2 }}
|
||||||
className="rounded-md"
|
className="rounded-md"
|
||||||
/>
|
/>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-accent)",
|
||||||
|
boxShadow: "0 0 0 2px var(--panel-surface)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
className="max-w-[64px] truncate text-center text-[9px] sm:text-[10px] font-bold leading-tight"
|
className="max-w-[64px] truncate text-center text-[9px] sm:text-[10px] font-bold leading-tight"
|
||||||
style={{ color: "var(--color-text-readable)" }}
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
>
|
>
|
||||||
{u.username}
|
{u.username}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</GlassPanel>
|
</Panel>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* News */}
|
{/* News */}
|
||||||
<div className="flex w-full flex-col md:w-2/3">
|
<div className="flex w-full flex-col md:w-2/3">
|
||||||
<GlassPanel
|
<Panel
|
||||||
title={tp("latestNews")}
|
title={tp("latestNews")}
|
||||||
icon={ICON_ARTICLE}
|
icon={ICON_ARTICLE}
|
||||||
actionHref="/news"
|
actionHref="/news"
|
||||||
@@ -865,12 +872,15 @@ async function Home() {
|
|||||||
) : (
|
) : (
|
||||||
<p
|
<p
|
||||||
className="py-8 text-center text-sm"
|
className="py-8 text-center text-sm"
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
style={{
|
||||||
|
color:
|
||||||
|
"var(--color-text-muted-readable, var(--color-text-muted))",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{tp("noArticles")}
|
{tp("noArticles")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</GlassPanel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -879,25 +889,23 @@ async function Home() {
|
|||||||
{/* ── Recent photos ── */}
|
{/* ── Recent photos ── */}
|
||||||
{recentPhotos === null ? (
|
{recentPhotos === null ? (
|
||||||
<Reveal className="mx-auto w-full max-w-7xl">
|
<Reveal className="mx-auto w-full max-w-7xl">
|
||||||
<GlassPanel title={tp("recentPhotos")} bodyClassName="p-4">
|
<Panel title={tp("recentPhotos")} bodyClassName="p-4">
|
||||||
<PublicLoadError href="/" />
|
<PublicLoadError href="/" />
|
||||||
</GlassPanel>
|
</Panel>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
) : (
|
) : (
|
||||||
recentPhotos.length > 0 && (
|
recentPhotos.length > 0 && (
|
||||||
<Reveal className="mx-auto w-full max-w-7xl">
|
<Reveal className="mx-auto w-full max-w-7xl">
|
||||||
<GlassPanel title={tp("recentPhotos")} icon={ICON_CAMERA}>
|
<Panel title={tp("recentPhotos")} icon={ICON_CAMERA}>
|
||||||
<div className="mx-auto grid max-w-5xl grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="mx-auto grid max-w-5xl grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
{recentPhotos.slice(0, 4).map((p, i) => (
|
{recentPhotos.slice(0, 4).map((p, i) => (
|
||||||
<Link
|
<Link
|
||||||
key={p.id}
|
key={p.id}
|
||||||
href="/photos"
|
href="/photos"
|
||||||
className="block aspect-[4/3] overflow-hidden rounded-2xl border transition-all duration-300 ease-out hover:scale-[1.03] hover:shadow-xl hover:ring-2 hover:ring-[color-mix(in_srgb,var(--color-primary)_45%,transparent)]"
|
className="block aspect-[4/3] overflow-hidden rounded-2xl border transition-all duration-300 ease-out hover:scale-[1.03] hover:shadow-xl hover:ring-2 hover:ring-[color-mix(in_srgb,var(--color-primary)_45%,transparent)]"
|
||||||
style={{
|
style={{ borderColor: "var(--panel-border)" }}
|
||||||
borderColor:
|
|
||||||
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
{p.url ? (
|
||||||
<Image
|
<Image
|
||||||
src={p.url}
|
src={p.url}
|
||||||
alt={`${tp("recentPhotos")} ${i + 1}`}
|
alt={`${tp("recentPhotos")} ${i + 1}`}
|
||||||
@@ -906,10 +914,28 @@ async function Home() {
|
|||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="flex h-full w-full items-center justify-center"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(140deg, color-mix(in srgb, var(--color-primary) 22%, var(--color-surface)), color-mix(in srgb, var(--color-accent) 16%, var(--color-surface)))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={ICON_CAMERA}
|
||||||
|
alt=""
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
unoptimized
|
||||||
|
className="opacity-70"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</GlassPanel>
|
</Panel>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
+177
-81
@@ -500,9 +500,14 @@ summary,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Site background: Habbo sky ── */
|
/* ── Site background: Habbo sky ──
|
||||||
|
The sky lives in a custom property so the Theme Manager can layer a CMS
|
||||||
|
wallpaper *on top of* it (`background-image: url(...), var(--site-bg-gradient)`)
|
||||||
|
instead of replacing it. Painting the wallpaper as the only layer meant a
|
||||||
|
missing upload silently erased the whole gradient and left the site on flat
|
||||||
|
white. */
|
||||||
.site-bg {
|
.site-bg {
|
||||||
background: linear-gradient(
|
--site-bg-gradient: linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
color-mix(in srgb, var(--color-primary) 4%, #b3d9f7) 0%,
|
color-mix(in srgb, var(--color-primary) 4%, #b3d9f7) 0%,
|
||||||
color-mix(in srgb, var(--color-primary) 6%, #d4e8fa) 25%,
|
color-mix(in srgb, var(--color-primary) 6%, #d4e8fa) 25%,
|
||||||
@@ -510,7 +515,10 @@ summary,
|
|||||||
color-mix(in srgb, var(--color-primary) 10%, #f0e8d8) 75%,
|
color-mix(in srgb, var(--color-primary) 10%, #f0e8d8) 75%,
|
||||||
color-mix(in srgb, var(--color-primary) 14%, var(--color-background)) 100%
|
color-mix(in srgb, var(--color-primary) 14%, var(--color-background)) 100%
|
||||||
);
|
);
|
||||||
|
background-image: var(--site-bg-gradient);
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
|
background-size: cover;
|
||||||
|
background-repeat: no-repeat;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
/* Habbo-style clouds */
|
/* Habbo-style clouds */
|
||||||
@@ -711,13 +719,16 @@ html.dark .cloud {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
html.dark .site-bg {
|
html.dark .site-bg {
|
||||||
background: linear-gradient(
|
--site-bg-gradient: linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
color-mix(in srgb, var(--color-primary) 6%, #0a0e1a) 0%,
|
color-mix(in srgb, var(--color-primary) 6%, #0a0e1a) 0%,
|
||||||
color-mix(in srgb, var(--color-primary) 8%, #10152a) 30%,
|
color-mix(in srgb, var(--color-primary) 8%, #10152a) 30%,
|
||||||
color-mix(in srgb, var(--color-primary) 10%, #161c30) 60%,
|
color-mix(in srgb, var(--color-primary) 10%, #161c30) 60%,
|
||||||
color-mix(in srgb, var(--color-primary) 12%, #0f141f) 100%
|
color-mix(in srgb, var(--color-primary) 12%, #0f141f) 100%
|
||||||
);
|
);
|
||||||
|
/* Re-declaring `background-image` here would out-specify the wallpaper
|
||||||
|
layer injected by ThemeVars (body.site-bg) and silently drop it in dark
|
||||||
|
mode. The gradient is picked up through `--site-bg-gradient` instead. */
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
}
|
}
|
||||||
html.dark .site-bg::before {
|
html.dark .site-bg::before {
|
||||||
@@ -2559,17 +2570,6 @@ details[open] > summary .details-open\:rotate-180 {
|
|||||||
/* ── Classic-landing polish (theme-aware) ────────────────────────────
|
/* ── Classic-landing polish (theme-aware) ────────────────────────────
|
||||||
Keeps the Habbo sky and theme colors; adds subtle depth + glow to the
|
Keeps the Habbo sky and theme colors; adds subtle depth + glow to the
|
||||||
restored landing pages. Everything is driven by theme CSS variables. */
|
restored landing pages. Everything is driven by theme CSS variables. */
|
||||||
.brand-halo {
|
|
||||||
position: absolute;
|
|
||||||
pointer-events: none;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: radial-gradient(
|
|
||||||
circle,
|
|
||||||
color-mix(in srgb, var(--color-primary) 30%, transparent) 0%,
|
|
||||||
transparent 70%
|
|
||||||
);
|
|
||||||
filter: blur(20px);
|
|
||||||
}
|
|
||||||
.card-glow {
|
.card-glow {
|
||||||
transition:
|
transition:
|
||||||
box-shadow 0.3s ease,
|
box-shadow 0.3s ease,
|
||||||
@@ -2647,6 +2647,123 @@ details[open] > summary .details-open\:rotate-180 {
|
|||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Landing panel system ────────────────────────────────────────────
|
||||||
|
One recipe for every card on the public pages. The old panels each mixed
|
||||||
|
their own translucent washes (surface 44–60% + backdrop blur), which read as
|
||||||
|
a milky smear over the sky gradient, gave the same four numbers two
|
||||||
|
different-looking boxes, and cost a backdrop snapshot per panel on every
|
||||||
|
scroll. These tokens are opaque, theme-derived and shared. */
|
||||||
|
:root {
|
||||||
|
--panel-surface: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-surface) 93%,
|
||||||
|
var(--color-primary)
|
||||||
|
);
|
||||||
|
--panel-surface-alt: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-surface) 88%,
|
||||||
|
var(--color-accent)
|
||||||
|
);
|
||||||
|
--panel-surface-sunken: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-surface) 96%,
|
||||||
|
var(--color-background)
|
||||||
|
);
|
||||||
|
--panel-border: color-mix(in srgb, var(--color-text-muted) 16%, transparent);
|
||||||
|
--panel-border-strong: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-primary) 32%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
--panel-shadow:
|
||||||
|
0 1px 2px color-mix(in srgb, #0b1220 7%, transparent),
|
||||||
|
0 18px 40px -26px color-mix(in srgb, #0b1220 55%, transparent);
|
||||||
|
--panel-header-bg: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-primary) 8%,
|
||||||
|
var(--color-surface)
|
||||||
|
);
|
||||||
|
--panel-header-border: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-text-muted) 12%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
--panel-radius: 18px;
|
||||||
|
}
|
||||||
|
html.dark {
|
||||||
|
--panel-surface: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-surface) 88%,
|
||||||
|
var(--color-background)
|
||||||
|
);
|
||||||
|
--panel-surface-alt: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-surface) 82%,
|
||||||
|
var(--color-background)
|
||||||
|
);
|
||||||
|
--panel-surface-sunken: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-surface) 92%,
|
||||||
|
var(--color-background)
|
||||||
|
);
|
||||||
|
--panel-border: color-mix(in srgb, var(--color-text-muted) 24%, transparent);
|
||||||
|
--panel-border-strong: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-primary) 46%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
--panel-shadow:
|
||||||
|
0 1px 2px rgb(0 0 0 / 0.4), 0 24px 48px -28px rgb(0 0 0 / 0.85);
|
||||||
|
--panel-header-bg: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-primary) 13%,
|
||||||
|
var(--color-surface)
|
||||||
|
);
|
||||||
|
--panel-header-border: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-text-muted) 20%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The standard card. `rounded-[--panel-radius]` is spelled out in the
|
||||||
|
component, this class only owns colour, border and depth. */
|
||||||
|
.panel {
|
||||||
|
background: var(--panel-surface);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
box-shadow: var(--panel-shadow);
|
||||||
|
}
|
||||||
|
/* A panel that floats over the hero, so it needs a deeper shadow. */
|
||||||
|
.panel-float {
|
||||||
|
box-shadow:
|
||||||
|
0 2px 6px color-mix(in srgb, #0b1220 10%, transparent),
|
||||||
|
0 34px 64px -30px color-mix(in srgb, #0b1220 62%, transparent);
|
||||||
|
}
|
||||||
|
html.dark .panel-float {
|
||||||
|
box-shadow:
|
||||||
|
0 2px 6px rgb(0 0 0 / 0.5),
|
||||||
|
0 36px 70px -30px rgb(0 0 0 / 0.9);
|
||||||
|
}
|
||||||
|
/* A tile that sits *inside* a panel — icon chips, stat wells, list rows. */
|
||||||
|
.panel-sunken {
|
||||||
|
background: var(--panel-surface-sunken);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-text-muted) 10%, transparent);
|
||||||
|
}
|
||||||
|
.panel-header {
|
||||||
|
background: var(--panel-header-bg);
|
||||||
|
border-bottom: 1px solid var(--panel-header-border);
|
||||||
|
}
|
||||||
|
/* Icon chip inside a panel. `--panel-tint` is set per instance. */
|
||||||
|
.panel-tile {
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--panel-tint, var(--color-primary)) 15%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
box-shadow: inset 0 0 0 1px
|
||||||
|
color-mix(in srgb, var(--panel-tint, var(--color-primary)) 26%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Smooth motion (landing) ───────────────────────────────────────── */
|
/* ── Smooth motion (landing) ───────────────────────────────────────── */
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
@@ -2668,6 +2785,15 @@ html {
|
|||||||
|
|
||||||
/* Gentle breathing pulse for the brand glows behind Frank / the hero. */
|
/* Gentle breathing pulse for the brand glows behind Frank / the hero. */
|
||||||
.brand-halo {
|
.brand-halo {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: radial-gradient(
|
||||||
|
circle,
|
||||||
|
color-mix(in srgb, var(--color-primary) 30%, transparent) 0%,
|
||||||
|
transparent 70%
|
||||||
|
);
|
||||||
|
filter: blur(20px);
|
||||||
animation: hero-halo-pulse 6s ease-in-out infinite;
|
animation: hero-halo-pulse 6s ease-in-out infinite;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
@@ -2691,8 +2817,7 @@ html {
|
|||||||
.brand-halo {
|
.brand-halo {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
.hero-ring,
|
.hero-ring {
|
||||||
.glass-chip {
|
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2734,48 +2859,6 @@ html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Frosted floating stat chips that bob gently on the hero.
|
|
||||||
No backdrop-filter while animating: sampling the backdrop every frame is
|
|
||||||
expensive, so the chips use a higher-opacity fill instead. */
|
|
||||||
.glass-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.45rem;
|
|
||||||
padding: 0.45rem 0.85rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: color-mix(in srgb, var(--color-surface) 72%, transparent);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-text-muted) 14%, transparent);
|
|
||||||
box-shadow:
|
|
||||||
0 10px 26px -10px color-mix(in srgb, #000 30%, transparent),
|
|
||||||
inset 0 1px 0 0 color-mix(in srgb, #fff 12%, transparent);
|
|
||||||
animation: float 5s ease-in-out infinite;
|
|
||||||
will-change: transform;
|
|
||||||
}
|
|
||||||
.glass-chip:nth-child(2) {
|
|
||||||
animation-delay: -1.7s;
|
|
||||||
}
|
|
||||||
.glass-chip:nth-child(3) {
|
|
||||||
animation-delay: -3.4s;
|
|
||||||
}
|
|
||||||
.glass-chip-dot {
|
|
||||||
width: 0.45rem;
|
|
||||||
height: 0.45rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.glass-chip-value {
|
|
||||||
font-weight: 900;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.glass-chip-label {
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Public page polish: buttons ──────────────────────────────────── */
|
/* ── Public page polish: buttons ──────────────────────────────────── */
|
||||||
.btn-brand {
|
.btn-brand {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -2957,10 +3040,9 @@ html {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-text-readable);
|
color: var(--color-text-readable);
|
||||||
background: color-mix(in srgb, var(--color-surface) 55%, transparent);
|
background: var(--panel-surface);
|
||||||
border: 1px solid color-mix(in srgb, var(--color-text-muted) 16%, transparent);
|
border: 1px solid var(--panel-border);
|
||||||
-webkit-backdrop-filter: blur(10px);
|
box-shadow: 0 1px 2px color-mix(in srgb, #0b1220 6%, transparent);
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
transition:
|
transition:
|
||||||
background 0.25s ease,
|
background 0.25s ease,
|
||||||
border-color 0.25s ease,
|
border-color 0.25s ease,
|
||||||
@@ -2970,13 +3052,13 @@ html {
|
|||||||
.explore-pill:hover {
|
.explore-pill:hover {
|
||||||
background: color-mix(
|
background: color-mix(
|
||||||
in srgb,
|
in srgb,
|
||||||
var(--color-primary) 16%,
|
var(--color-primary) 14%,
|
||||||
var(--color-surface)
|
var(--panel-surface)
|
||||||
);
|
);
|
||||||
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
|
border-color: var(--panel-border-strong);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 12px 26px -14px
|
box-shadow: 0 14px 28px -16px
|
||||||
color-mix(in srgb, var(--color-primary) 80%, transparent);
|
color-mix(in srgb, var(--color-primary) 85%, transparent);
|
||||||
}
|
}
|
||||||
.explore-pill .pill-arrow {
|
.explore-pill .pill-arrow {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -3079,30 +3161,44 @@ html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dividers between the four counters in the stats panel. */
|
/* Hairlines between the headline counters.
|
||||||
|
The strip is two-up on narrow screens and four-up from `lg`. The old rules
|
||||||
|
only knew the four-up layout: on mobile they drew a single vertical line
|
||||||
|
between the two rows instead of between the two columns, and none between
|
||||||
|
the columns at all. Both rules are drawn from the same token so the grid
|
||||||
|
always reads as four separate figures. */
|
||||||
.stat-cell {
|
.stat-cell {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@media (min-width: 1024px) {
|
.stat-cell::before,
|
||||||
.stat-cell:not(:first-child)::before {
|
.stat-cell::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -0.5rem;
|
|
||||||
top: 18%;
|
|
||||||
height: 64%;
|
|
||||||
width: 1px;
|
|
||||||
background: color-mix(in srgb, var(--color-text-muted) 20%, transparent);
|
background: color-mix(in srgb, var(--color-text-muted) 20%, transparent);
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
/* Four across: a vertical rule in front of every cell but the first. */
|
||||||
|
.stat-cell:not(:first-child)::before {
|
||||||
|
left: -0.75rem;
|
||||||
|
top: 8%;
|
||||||
|
height: 84%;
|
||||||
|
width: 1px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
.stat-cell:nth-child(3)::before {
|
/* Two across: a vertical rule between the two columns of each row… */
|
||||||
content: "";
|
.stat-cell:nth-child(even)::before {
|
||||||
position: absolute;
|
left: -0.75rem;
|
||||||
left: -0.5rem;
|
top: 8%;
|
||||||
top: 10%;
|
height: 84%;
|
||||||
height: 80%;
|
|
||||||
width: 1px;
|
width: 1px;
|
||||||
background: color-mix(in srgb, var(--color-text-muted) 20%, transparent);
|
}
|
||||||
|
/* …and a horizontal rule between the two rows. */
|
||||||
|
.stat-cell:nth-child(n + 3)::after {
|
||||||
|
left: 6%;
|
||||||
|
right: 6%;
|
||||||
|
top: -0.75rem;
|
||||||
|
height: 1px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,16 +26,10 @@ export function AuthTopBar({
|
|||||||
return (
|
return (
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div
|
<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={{
|
style={{
|
||||||
background:
|
background:
|
||||||
"linear-gradient(90deg, color-mix(in srgb, var(--color-surface) 62%, transparent), color-mix(in srgb, var(--color-primary) 7%, transparent))",
|
"linear-gradient(90deg, var(--panel-surface), color-mix(in srgb, var(--color-primary) 7%, var(--panel-surface)))",
|
||||||
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)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
|
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ export function LanguageSwitcher({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
aria-label="Change language"
|
aria-label={`Change language — ${current.code.toUpperCase()}`}
|
||||||
title="Language"
|
title="Language"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CSSProperties, MouseEvent, ReactNode } from "react";
|
import {
|
||||||
|
type CSSProperties,
|
||||||
|
type MouseEvent,
|
||||||
|
type ReactNode,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
interface SpotlightCardProps {
|
interface SpotlightCardProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -12,22 +18,48 @@ interface SpotlightCardProps {
|
|||||||
* Wraps a card with a mouse-tracking radial glow. The pointer position is
|
* 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`
|
* written to `--spot-x` / `--spot-y` and consumed by the `.spotlight::before`
|
||||||
* overlay in globals.css.
|
* 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({
|
export function SpotlightCard({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
}: SpotlightCardProps) {
|
}: 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 handlePointerMove = (event: MouseEvent<HTMLDivElement>) => {
|
||||||
const el = event.currentTarget;
|
const el = event.currentTarget;
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
el.style.setProperty("--spot-x", `${event.clientX - rect.left}px`);
|
pending.current = {
|
||||||
el.style.setProperty("--spot-y", `${event.clientY - rect.top}px`);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={className}
|
className={className ? `spotlight ${className}` : "spotlight"}
|
||||||
style={
|
style={
|
||||||
{ "--spot-x": "50%", "--spot-y": "50%", ...style } as CSSProperties
|
{ "--spot-x": "50%", "--spot-y": "50%", ...style } as CSSProperties
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
import { publicAssetExists } from "@/lib/public-asset";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import {
|
import {
|
||||||
BACKGROUND_DEFAULTS,
|
BACKGROUND_DEFAULTS,
|
||||||
@@ -240,9 +241,19 @@ export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
|
|||||||
? Math.min(parseInt(backgroundOverlayOpacity as string, 10), 90)
|
? Math.min(parseInt(backgroundOverlayOpacity as string, 10), 90)
|
||||||
: 0;
|
: 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 =
|
const backgroundCss =
|
||||||
backgroundImage && isSafeBackgroundUrl(backgroundImage)
|
backgroundImage &&
|
||||||
? `body.site-bg{background-image:url("${backgroundImage}");background-position:${safeBgPosition};background-size:${safeBgSize};background-repeat:${safeBgRepeat}}`
|
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
|
const backdropStyle: CSSProperties | undefined = safeOverlay
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { publicAssetExists } from "./public-asset";
|
||||||
|
|
||||||
|
describe("publicAssetExists", () => {
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
let root: string;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
root = mkdtempSync(join(tmpdir(), "public-asset-"));
|
||||||
|
mkdirSync(join(root, "public", "assets"), { recursive: true });
|
||||||
|
writeFileSync(join(root, "public", "assets", "present.png"), "");
|
||||||
|
process.chdir(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a local file that exists", () => {
|
||||||
|
expect(publicAssetExists("/assets/present.png")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores query strings and hashes", () => {
|
||||||
|
expect(publicAssetExists("/assets/present.png?v=2#x")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a local file that is missing", () => {
|
||||||
|
expect(publicAssetExists("/assets/gone.png")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty input", () => {
|
||||||
|
expect(publicAssetExists(null)).toBe(false);
|
||||||
|
expect(publicAssetExists(undefined)).toBe(false);
|
||||||
|
expect(publicAssetExists("")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trusts remote and protocol-relative URLs", () => {
|
||||||
|
expect(publicAssetExists("https://cdn.example.com/a.png")).toBe(true);
|
||||||
|
expect(publicAssetExists("http://cdn.example.com/a.png")).toBe(true);
|
||||||
|
expect(publicAssetExists("//cdn.example.com/a.png")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-http schemes", () => {
|
||||||
|
expect(publicAssetExists("javascript:alert(1)")).toBe(false);
|
||||||
|
expect(publicAssetExists("data:image/png;base64,AAAA")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a site-relative URL points at a file that really exists under
|
||||||
|
* `public/`. CMS records can keep pointing at an upload that has since been
|
||||||
|
* deleted, and a stale reference renders a broken image (or a 404 background
|
||||||
|
* layer) on every page view. Server-only: it stats the filesystem.
|
||||||
|
*
|
||||||
|
* Remote URLs (`http:`, `https:`, protocol-relative) are trusted as-is because
|
||||||
|
* we cannot stat them, and data/blob URIs are rejected outright.
|
||||||
|
*/
|
||||||
|
export function publicAssetExists(url: string | null | undefined): boolean {
|
||||||
|
if (!url) return false;
|
||||||
|
if (url.startsWith("//")) return true;
|
||||||
|
if (/^[a-z][a-z0-9+.-]*:/i.test(url)) {
|
||||||
|
return /^https?:/i.test(url);
|
||||||
|
}
|
||||||
|
if (!url.startsWith("/")) return true;
|
||||||
|
try {
|
||||||
|
const file = decodeURIComponent(url.split(/[?#]/)[0] ?? "");
|
||||||
|
return existsSync(join(process.cwd(), "public", file));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5353,6 +5353,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Welcome to {hotelName}",
|
"welcome": "Welcome to {hotelName}",
|
||||||
|
"heroEyebrow": "أهلاً بك",
|
||||||
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
|
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
|
||||||
"welcomeBody": "Hang out, collect furni, and meet friends.",
|
"welcomeBody": "Hang out, collect furni, and meet friends.",
|
||||||
"enterHotel": "Enter {hotelName}",
|
"enterHotel": "Enter {hotelName}",
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Добре дошли в {hotelName}",
|
"welcome": "Добре дошли в {hotelName}",
|
||||||
|
"heroEyebrow": "Добре дошли",
|
||||||
"welcomeSubtitle": "Вашият ретро хотел, сега захранван от Next.js",
|
"welcomeSubtitle": "Вашият ретро хотел, сега захранван от Next.js",
|
||||||
"welcomeBody": "Излизайте, събирайте мебели и се срещайте с приятели.",
|
"welcomeBody": "Излизайте, събирайте мебели и се срещайте с приятели.",
|
||||||
"enterHotel": "Въведете {hotelName}",
|
"enterHotel": "Въведете {hotelName}",
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Vítejte v {hotelName}",
|
"welcome": "Vítejte v {hotelName}",
|
||||||
|
"heroEyebrow": "Vítejte",
|
||||||
"welcomeSubtitle": "Váš retro hotel, nyní poháněný Next.js",
|
"welcomeSubtitle": "Váš retro hotel, nyní poháněný Next.js",
|
||||||
"welcomeBody": "Setkejte se, sbírejte nábytek a setkejte se s přáteli.",
|
"welcomeBody": "Setkejte se, sbírejte nábytek a setkejte se s přáteli.",
|
||||||
"enterHotel": "Zadejte {hotelName}",
|
"enterHotel": "Zadejte {hotelName}",
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Velkommen til {hotelName}",
|
"welcome": "Velkommen til {hotelName}",
|
||||||
|
"heroEyebrow": "Velkommen",
|
||||||
"welcomeSubtitle": "Dit retrohotel, nu drevet af Next.js",
|
"welcomeSubtitle": "Dit retrohotel, nu drevet af Next.js",
|
||||||
"welcomeBody": "Hæng ud, saml møbler og mød venner.",
|
"welcomeBody": "Hæng ud, saml møbler og mød venner.",
|
||||||
"enterHotel": "Indtast {hotelName}",
|
"enterHotel": "Indtast {hotelName}",
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Willkommen bei {hotelName}",
|
"welcome": "Willkommen bei {hotelName}",
|
||||||
|
"heroEyebrow": "Willkommen",
|
||||||
"welcomeSubtitle": "Dein Retro-Hotel, jetzt mit Next.js",
|
"welcomeSubtitle": "Dein Retro-Hotel, jetzt mit Next.js",
|
||||||
"welcomeBody": "Abhängen, Möbel sammeln und Freunde treffen.",
|
"welcomeBody": "Abhängen, Möbel sammeln und Freunde treffen.",
|
||||||
"enterHotel": "{hotelName} betreten",
|
"enterHotel": "{hotelName} betreten",
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Καλώς ήρθατε στο {hotelName}",
|
"welcome": "Καλώς ήρθατε στο {hotelName}",
|
||||||
|
"heroEyebrow": "Καλώς ήρθατε",
|
||||||
"welcomeSubtitle": "Το ρετρό ξενοδοχείο σας, τώρα με την υποστήριξη του Next.js",
|
"welcomeSubtitle": "Το ρετρό ξενοδοχείο σας, τώρα με την υποστήριξη του Next.js",
|
||||||
"welcomeBody": "Κάντε παρέα, μαζέψτε έπιπλα και συναντήστε φίλους.",
|
"welcomeBody": "Κάντε παρέα, μαζέψτε έπιπλα και συναντήστε φίλους.",
|
||||||
"enterHotel": "Εισαγάγετε {hotelName}",
|
"enterHotel": "Εισαγάγετε {hotelName}",
|
||||||
|
|||||||
@@ -278,6 +278,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Welcome to {hotelName}",
|
"welcome": "Welcome to {hotelName}",
|
||||||
|
"heroEyebrow": "Welcome",
|
||||||
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
|
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
|
||||||
"welcomeBody": "Hang out, collect furni, and meet friends.",
|
"welcomeBody": "Hang out, collect furni, and meet friends.",
|
||||||
"enterHotel": "Enter {hotelName}",
|
"enterHotel": "Enter {hotelName}",
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Bienvenido a {hotelName}",
|
"welcome": "Bienvenido a {hotelName}",
|
||||||
|
"heroEyebrow": "Bienvenido",
|
||||||
"welcomeSubtitle": "Tu hotel retro, ahora impulsado por Next.js",
|
"welcomeSubtitle": "Tu hotel retro, ahora impulsado por Next.js",
|
||||||
"welcomeBody": "Pasa el rato, colecciona furni y conoce amigos.",
|
"welcomeBody": "Pasa el rato, colecciona furni y conoce amigos.",
|
||||||
"enterHotel": "Entrar a {hotelName}",
|
"enterHotel": "Entrar a {hotelName}",
|
||||||
|
|||||||
@@ -5353,6 +5353,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Welcome to {hotelName}",
|
"welcome": "Welcome to {hotelName}",
|
||||||
|
"heroEyebrow": "Tervetuloa",
|
||||||
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
|
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
|
||||||
"welcomeBody": "Hang out, collect furni, and meet friends.",
|
"welcomeBody": "Hang out, collect furni, and meet friends.",
|
||||||
"enterHotel": "Enter {hotelName}",
|
"enterHotel": "Enter {hotelName}",
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Bienvenue sur {hotelName}",
|
"welcome": "Bienvenue sur {hotelName}",
|
||||||
|
"heroEyebrow": "Bienvenue",
|
||||||
"welcomeSubtitle": "Votre hôtel rétro, désormais propulsé par Next.js",
|
"welcomeSubtitle": "Votre hôtel rétro, désormais propulsé par Next.js",
|
||||||
"welcomeBody": "Traînez, collectionnez des meubles et rencontrez des amis.",
|
"welcomeBody": "Traînez, collectionnez des meubles et rencontrez des amis.",
|
||||||
"enterHotel": "Entrer dans {hotelName}",
|
"enterHotel": "Entrer dans {hotelName}",
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Dobro došli u {hotelName}",
|
"welcome": "Dobro došli u {hotelName}",
|
||||||
|
"heroEyebrow": "Dobrodošli",
|
||||||
"welcomeSubtitle": "Vaš retro hotel, sada pokreće Next.js",
|
"welcomeSubtitle": "Vaš retro hotel, sada pokreće Next.js",
|
||||||
"welcomeBody": "Družite se, skupljajte namještaj i upoznajte prijatelje.",
|
"welcomeBody": "Družite se, skupljajte namještaj i upoznajte prijatelje.",
|
||||||
"enterHotel": "Unesite {hotelName}",
|
"enterHotel": "Unesite {hotelName}",
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Üdvözöljük itt: {hotelName}",
|
"welcome": "Üdvözöljük itt: {hotelName}",
|
||||||
|
"heroEyebrow": "Üdvözöljük",
|
||||||
"welcomeSubtitle": "Az Ön retro szállodája, amelyet most a Next.js üzemeltet",
|
"welcomeSubtitle": "Az Ön retro szállodája, amelyet most a Next.js üzemeltet",
|
||||||
"welcomeBody": "Tárcsázz, gyűjts bútorokat és találkozz barátokkal.",
|
"welcomeBody": "Tárcsázz, gyűjts bútorokat és találkozz barátokkal.",
|
||||||
"enterHotel": "Írja be a következőt: {hotelName}",
|
"enterHotel": "Írja be a következőt: {hotelName}",
|
||||||
|
|||||||
@@ -274,6 +274,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Benvenuto su {hotelName}",
|
"welcome": "Benvenuto su {hotelName}",
|
||||||
|
"heroEyebrow": "Benvenuto",
|
||||||
"welcomeSubtitle": "Il tuo hotel retrò, ora con tecnologia Next.js",
|
"welcomeSubtitle": "Il tuo hotel retrò, ora con tecnologia Next.js",
|
||||||
"welcomeBody": "Rilassati, colleziona furni e fai nuove amicizie.",
|
"welcomeBody": "Rilassati, colleziona furni e fai nuove amicizie.",
|
||||||
"enterHotel": "Entra in {hotelName}",
|
"enterHotel": "Entra in {hotelName}",
|
||||||
|
|||||||
@@ -5353,6 +5353,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Welcome to {hotelName}",
|
"welcome": "Welcome to {hotelName}",
|
||||||
|
"heroEyebrow": "ようこそ",
|
||||||
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
|
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
|
||||||
"welcomeBody": "Hang out, collect furni, and meet friends.",
|
"welcomeBody": "Hang out, collect furni, and meet friends.",
|
||||||
"enterHotel": "Enter {hotelName}",
|
"enterHotel": "Enter {hotelName}",
|
||||||
|
|||||||
@@ -274,6 +274,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Welkom bij {hotelName}",
|
"welcome": "Welkom bij {hotelName}",
|
||||||
|
"heroEyebrow": "Welkom",
|
||||||
"welcomeSubtitle": "Je retro hotel, nu mogelijk gemaakt door Next.js",
|
"welcomeSubtitle": "Je retro hotel, nu mogelijk gemaakt door Next.js",
|
||||||
"welcomeBody": "Rondhangen, meubels verzamelen en vrienden ontmoeten.",
|
"welcomeBody": "Rondhangen, meubels verzamelen en vrienden ontmoeten.",
|
||||||
"enterHotel": "{hotelName} betreden",
|
"enterHotel": "{hotelName} betreden",
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Velkommen til {hotelName}",
|
"welcome": "Velkommen til {hotelName}",
|
||||||
|
"heroEyebrow": "Velkommen",
|
||||||
"welcomeSubtitle": "Ditt retrohotell, nå drevet av Next.js",
|
"welcomeSubtitle": "Ditt retrohotell, nå drevet av Next.js",
|
||||||
"welcomeBody": "Heng med, samle møbler og møt venner.",
|
"welcomeBody": "Heng med, samle møbler og møt venner.",
|
||||||
"enterHotel": "Skriv inn {hotelName}",
|
"enterHotel": "Skriv inn {hotelName}",
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Witamy w {hotelName}",
|
"welcome": "Witamy w {hotelName}",
|
||||||
|
"heroEyebrow": "Witamy",
|
||||||
"welcomeSubtitle": "Twój hotel w stylu retro, teraz obsługiwany przez Next.js",
|
"welcomeSubtitle": "Twój hotel w stylu retro, teraz obsługiwany przez Next.js",
|
||||||
"welcomeBody": "Spędzaj czas, zbieraj meble i spotykaj się z przyjaciółmi.",
|
"welcomeBody": "Spędzaj czas, zbieraj meble i spotykaj się z przyjaciółmi.",
|
||||||
"enterHotel": "Wpisz {hotelName}",
|
"enterHotel": "Wpisz {hotelName}",
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Bem-vindo ao {hotelName}",
|
"welcome": "Bem-vindo ao {hotelName}",
|
||||||
|
"heroEyebrow": "Bem-vindo",
|
||||||
"welcomeSubtitle": "Seu hotel retrô, agora com tecnologia Next.js",
|
"welcomeSubtitle": "Seu hotel retrô, agora com tecnologia Next.js",
|
||||||
"welcomeBody": "Saia, colete mobis e encontre amigos.",
|
"welcomeBody": "Saia, colete mobis e encontre amigos.",
|
||||||
"enterHotel": "Digite {hotelName}",
|
"enterHotel": "Digite {hotelName}",
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Bun venit la {hotelName}",
|
"welcome": "Bun venit la {hotelName}",
|
||||||
|
"heroEyebrow": "Bine ai venit",
|
||||||
"welcomeSubtitle": "Hotelul tău retro, acum dezvoltat de Next.js",
|
"welcomeSubtitle": "Hotelul tău retro, acum dezvoltat de Next.js",
|
||||||
"welcomeBody": "Ieșiți, strângeți mobilier și întâlniți-vă cu prietenii.",
|
"welcomeBody": "Ieșiți, strângeți mobilier și întâlniți-vă cu prietenii.",
|
||||||
"enterHotel": "Introdu {hotelName}",
|
"enterHotel": "Introdu {hotelName}",
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Добро пожаловать в {hotelName}",
|
"welcome": "Добро пожаловать в {hotelName}",
|
||||||
|
"heroEyebrow": "Добро пожаловать",
|
||||||
"welcomeSubtitle": "Ваш ретро-отель на базе Next.js",
|
"welcomeSubtitle": "Ваш ретро-отель на базе Next.js",
|
||||||
"welcomeBody": "Тусуйтесь, собирайте мебель и встречайтесь с друзьями.",
|
"welcomeBody": "Тусуйтесь, собирайте мебель и встречайтесь с друзьями.",
|
||||||
"enterHotel": "Введите {hotelName}",
|
"enterHotel": "Введите {hotelName}",
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Vitajte v {hotelName}",
|
"welcome": "Vitajte v {hotelName}",
|
||||||
|
"heroEyebrow": "Vitajte",
|
||||||
"welcomeSubtitle": "Váš retro hotel, ktorý teraz používa Next.js",
|
"welcomeSubtitle": "Váš retro hotel, ktorý teraz používa Next.js",
|
||||||
"welcomeBody": "Stretnite sa, zbierajte nábytok a stretávajte sa s priateľmi.",
|
"welcomeBody": "Stretnite sa, zbierajte nábytok a stretávajte sa s priateľmi.",
|
||||||
"enterHotel": "Zadajte {hotelName}",
|
"enterHotel": "Zadajte {hotelName}",
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Добро дошли у {hotelName}",
|
"welcome": "Добро дошли у {hotelName}",
|
||||||
|
"heroEyebrow": "Добродошли",
|
||||||
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
|
"welcomeSubtitle": "Your retro hotel, now powered by Next.js",
|
||||||
"welcomeBody": "Hang out, collect furni, and meet friends.",
|
"welcomeBody": "Hang out, collect furni, and meet friends.",
|
||||||
"enterHotel": "Унесите {hotelName}",
|
"enterHotel": "Унесите {hotelName}",
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Välkommen till {hotelName}",
|
"welcome": "Välkommen till {hotelName}",
|
||||||
|
"heroEyebrow": "Välkommen",
|
||||||
"welcomeSubtitle": "Ditt retrohotell, nu drivs av Next.js",
|
"welcomeSubtitle": "Ditt retrohotell, nu drivs av Next.js",
|
||||||
"welcomeBody": "Umgås, samla möbler och träffa vänner.",
|
"welcomeBody": "Umgås, samla möbler och träffa vänner.",
|
||||||
"enterHotel": "Ange {hotelName}",
|
"enterHotel": "Ange {hotelName}",
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "{hotelName}'e hoş geldiniz",
|
"welcome": "{hotelName}'e hoş geldiniz",
|
||||||
|
"heroEyebrow": "Hoş geldiniz",
|
||||||
"welcomeSubtitle": "Retro oteliniz artık Next.js tarafından destekleniyor",
|
"welcomeSubtitle": "Retro oteliniz artık Next.js tarafından destekleniyor",
|
||||||
"welcomeBody": "Takılın, mobilya toplayın ve arkadaşlarla tanışın.",
|
"welcomeBody": "Takılın, mobilya toplayın ve arkadaşlarla tanışın.",
|
||||||
"enterHotel": "{hotelName} girin",
|
"enterHotel": "{hotelName} girin",
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Ласкаво просимо до {hotelName}",
|
"welcome": "Ласкаво просимо до {hotelName}",
|
||||||
|
"heroEyebrow": "Ласкаво просимо",
|
||||||
"welcomeSubtitle": "Ваш ретро-готель, тепер на базі Next.js",
|
"welcomeSubtitle": "Ваш ретро-готель, тепер на базі Next.js",
|
||||||
"welcomeBody": "Гуляйте, збирайте меблі та зустрічайтеся з друзями.",
|
"welcomeBody": "Гуляйте, збирайте меблі та зустрічайтеся з друзями.",
|
||||||
"enterHotel": "Введіть {hotelName}",
|
"enterHotel": "Введіть {hotelName}",
|
||||||
|
|||||||
Reference in new issue
Block a user