feat(theme): animate public background with aurora and particles, polish landing pages
Add background_effect (aurora/particles), background_overlay tint and opacity to the theme manager, rendered site-wide by ThemeVars on every public page. Polish the home and register pages (hero mascot, live stat pulse, date pills, photo strip, CTA band, theme-aware register intro, i18n for home/register section).
This commit is contained in:
1 parent
5923b736fa
commit
d131124515
9 files changed
+573
-25
No files matched your search
@@ -12,10 +12,12 @@ import { siteSettings } from "@/lib/services/site-settings";
|
|||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
import {
|
import {
|
||||||
BACKGROUND_DEFAULTS,
|
BACKGROUND_DEFAULTS,
|
||||||
|
BACKGROUND_EFFECTS,
|
||||||
BACKGROUND_POSITIONS,
|
BACKGROUND_POSITIONS,
|
||||||
BACKGROUND_REPEATS,
|
BACKGROUND_REPEATS,
|
||||||
BACKGROUND_SIZES,
|
BACKGROUND_SIZES,
|
||||||
isSafeBackgroundUrl,
|
isSafeBackgroundUrl,
|
||||||
|
OVERLAY_OPACITY_MAX,
|
||||||
} from "@/lib/theme-bg";
|
} from "@/lib/theme-bg";
|
||||||
import {
|
import {
|
||||||
type CustomTheme,
|
type CustomTheme,
|
||||||
@@ -187,6 +189,17 @@ export async function saveBackground(formData: FormData): Promise<void> {
|
|||||||
const repeat = String(formData.get("background_repeat") ?? "")
|
const repeat = String(formData.get("background_repeat") ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim();
|
.trim();
|
||||||
|
const effect = String(formData.get("background_effect") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
const overlay = String(formData.get("background_overlay") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
const overlayOpacity = String(
|
||||||
|
formData.get("background_overlay_opacity") ?? "",
|
||||||
|
)
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
|
||||||
if (image && !isSafeBackgroundUrl(image)) redirect(MANAGER_PATH);
|
if (image && !isSafeBackgroundUrl(image)) redirect(MANAGER_PATH);
|
||||||
|
|
||||||
@@ -210,6 +223,22 @@ export async function saveBackground(formData: FormData): Promise<void> {
|
|||||||
? repeat
|
? repeat
|
||||||
: BACKGROUND_DEFAULTS.background_repeat,
|
: BACKGROUND_DEFAULTS.background_repeat,
|
||||||
);
|
);
|
||||||
|
await writeSetting(
|
||||||
|
"background_effect",
|
||||||
|
(BACKGROUND_EFFECTS as readonly string[]).includes(effect)
|
||||||
|
? effect
|
||||||
|
: BACKGROUND_DEFAULTS.background_effect,
|
||||||
|
);
|
||||||
|
await writeSetting(
|
||||||
|
"background_overlay",
|
||||||
|
/^#[0-9a-fA-F]{3,8}$/.test(overlay) ? overlay : "",
|
||||||
|
);
|
||||||
|
await writeSetting(
|
||||||
|
"background_overlay_opacity",
|
||||||
|
/^\d{1,2}$/.test(overlayOpacity)
|
||||||
|
? String(Math.min(parseInt(overlayOpacity, 10), OVERLAY_OPACITY_MAX))
|
||||||
|
: "0",
|
||||||
|
);
|
||||||
siteSettings.reload();
|
siteSettings.reload();
|
||||||
revalidatePath("/", "layout");
|
revalidatePath("/", "layout");
|
||||||
revalidatePath(MANAGER_PATH);
|
revalidatePath(MANAGER_PATH);
|
||||||
@@ -344,6 +373,29 @@ export async function getThemeManagerData() {
|
|||||||
)
|
)
|
||||||
? (backgroundValues.background_repeat as string)
|
? (backgroundValues.background_repeat as string)
|
||||||
: BACKGROUND_DEFAULTS.background_repeat,
|
: BACKGROUND_DEFAULTS.background_repeat,
|
||||||
|
effect: (BACKGROUND_EFFECTS as readonly string[]).includes(
|
||||||
|
backgroundValues.background_effect ?? "",
|
||||||
|
)
|
||||||
|
? (backgroundValues.background_effect as string)
|
||||||
|
: BACKGROUND_DEFAULTS.background_effect,
|
||||||
|
overlay: /^#[0-9a-fA-F]{3,8}$/.test(
|
||||||
|
backgroundValues.background_overlay ?? "",
|
||||||
|
)
|
||||||
|
? (backgroundValues.background_overlay as string)
|
||||||
|
: "",
|
||||||
|
overlayOpacity: /^\d{1,2}$/.test(
|
||||||
|
backgroundValues.background_overlay_opacity ?? "",
|
||||||
|
)
|
||||||
|
? String(
|
||||||
|
Math.min(
|
||||||
|
parseInt(
|
||||||
|
backgroundValues.background_overlay_opacity as string,
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
OVERLAY_OPACITY_MAX,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: "0",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+107
-9
@@ -249,6 +249,23 @@ export default async function Home() {
|
|||||||
/>
|
/>
|
||||||
<AmbientOrbs />
|
<AmbientOrbs />
|
||||||
<div className="hero-ring" aria-hidden="true" style={{ zIndex: 3 }} />
|
<div className="hero-ring" aria-hidden="true" style={{ zIndex: 3 }} />
|
||||||
|
{/* Floating mascot — decorative, hidden on smaller screens. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute bottom-3 left-4 hidden lg:block xl:bottom-5 xl:left-8"
|
||||||
|
style={{ zIndex: 2 }}
|
||||||
|
>
|
||||||
|
<div className="brand-halo left-1/2 top-1/2 h-40 w-40 -translate-x-1/2 -translate-y-1/2 xl:h-48 xl:w-48" />
|
||||||
|
<Image
|
||||||
|
src="/assets/images/FrankwithBag.gif"
|
||||||
|
alt=""
|
||||||
|
width={104}
|
||||||
|
height={138}
|
||||||
|
priority
|
||||||
|
unoptimized
|
||||||
|
className="relative animate-float object-contain drop-shadow-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className="relative flex flex-col items-center gap-5 px-4 py-12 text-center sm:gap-8 sm:px-6 sm:py-14 md:px-12 md:py-24"
|
className="relative flex flex-col items-center gap-5 px-4 py-12 text-center sm:gap-8 sm:px-6 sm:py-14 md:px-12 md:py-24"
|
||||||
style={{ zIndex: 2 }}
|
style={{ zIndex: 2 }}
|
||||||
@@ -409,7 +426,7 @@ export default async function Home() {
|
|||||||
{features.map((f) => (
|
{features.map((f) => (
|
||||||
<SurfaceCard
|
<SurfaceCard
|
||||||
key={f.title}
|
key={f.title}
|
||||||
className="group card-glow relative p-4 sm:p-5 text-center transition-all duration-300 hover:-translate-y-1.5"
|
className="group card-glow card-hairline relative p-4 sm:p-5 text-center transition-all duration-300 hover:-translate-y-1.5"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="mx-auto mb-3 flex h-11 w-11 sm:h-12 sm:w-12 items-center justify-center rounded-xl transition-transform duration-300 group-hover:scale-110 group-hover:rotate-3"
|
className="mx-auto mb-3 flex h-11 w-11 sm:h-12 sm:w-12 items-center justify-center rounded-xl transition-transform duration-300 group-hover:scale-110 group-hover:rotate-3"
|
||||||
@@ -487,7 +504,7 @@ export default async function Home() {
|
|||||||
].map((s) => (
|
].map((s) => (
|
||||||
<SurfaceCard
|
<SurfaceCard
|
||||||
key={s.label}
|
key={s.label}
|
||||||
className="card-glow relative p-3 sm:p-5 text-center transition-all duration-300 ease-out hover:-translate-y-1.5 hover:shadow-xl"
|
className="card-glow card-hairline relative p-3 sm:p-5 text-center transition-all duration-300 ease-out hover:-translate-y-1.5 hover:shadow-xl"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="mb-1 text-2xl sm:text-3xl font-black tracking-tight md:text-4xl"
|
className="mb-1 text-2xl sm:text-3xl font-black tracking-tight md:text-4xl"
|
||||||
@@ -510,9 +527,17 @@ export default async function Home() {
|
|||||||
style={{
|
style={{
|
||||||
background:
|
background:
|
||||||
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||||
color: "var(--color-text-muted)",
|
color: s.live
|
||||||
|
? "var(--color-primary-readable, var(--color-primary))"
|
||||||
|
: "var(--color-text-muted)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{s.live && (
|
||||||
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
|
||||||
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-primary" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Image
|
<Image
|
||||||
src={s.icon}
|
src={s.icon}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -569,7 +594,8 @@ export default async function Home() {
|
|||||||
{recentUsers.map((u) => (
|
{recentUsers.map((u) => (
|
||||||
<div
|
<div
|
||||||
key={u.username}
|
key={u.username}
|
||||||
className="flex flex-col items-center gap-0.5 rounded-lg px-1 py-1.5 transition-colors hover:bg-[color-mix(in_srgb,var(--color-primary)_5%,transparent)]"
|
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)_5%,transparent)] hover:ring-1 hover:ring-[color-mix(in_srgb,var(--color-primary)_35%,transparent)]"
|
||||||
>
|
>
|
||||||
<UserAvatarThumbnail
|
<UserAvatarThumbnail
|
||||||
figure={u.look}
|
figure={u.look}
|
||||||
@@ -628,6 +654,16 @@ export default async function Home() {
|
|||||||
→
|
→
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
className="absolute left-2.5 top-2.5 rounded-full px-2 py-0.5 text-[10px] font-bold backdrop-blur-sm"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, #000 45%, transparent)",
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatDate(a.createdAt, "date", "")}
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-0 left-0 w-full p-2.5 sm:p-3"
|
className="absolute bottom-0 left-0 w-full p-2.5 sm:p-3"
|
||||||
style={{
|
style={{
|
||||||
@@ -670,12 +706,14 @@ export default async function Home() {
|
|||||||
recentPhotos.length > 0 && (
|
recentPhotos.length > 0 && (
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<SurfaceCard title={tp("recentPhotos")} icon={ICON_CAMERA}>
|
<SurfaceCard title={tp("recentPhotos")} icon={ICON_CAMERA}>
|
||||||
<div className="mx-auto grid max-w-md grid-cols-4 gap-2">
|
<div className="mx-auto grid max-w-xl grid-cols-4 gap-3">
|
||||||
{recentPhotos.slice(0, 4).map((p) => (
|
{recentPhotos.slice(0, 4).map((p) => (
|
||||||
<Link
|
<Link
|
||||||
key={p.id}
|
key={p.id}
|
||||||
href="/photos"
|
href="/photos"
|
||||||
className="block aspect-square overflow-hidden rounded-xl border transition-all duration-200 hover:shadow-md hover:scale-[1.02]"
|
title=""
|
||||||
|
aria-label={tp("recentPhotos")}
|
||||||
|
className="block aspect-[4/3] overflow-hidden rounded-xl 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:
|
borderColor:
|
||||||
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||||
@@ -683,9 +721,9 @@ export default async function Home() {
|
|||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={p.url}
|
src={p.url}
|
||||||
alt=""
|
alt={tp("recentPhotos")}
|
||||||
width={200}
|
width={280}
|
||||||
height={200}
|
height={210}
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
@@ -696,6 +734,66 @@ export default async function Home() {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Join the fun ── */}
|
||||||
|
<Reveal>
|
||||||
|
<section
|
||||||
|
aria-labelledby="join-cta-title"
|
||||||
|
className="relative overflow-hidden rounded-3xl p-6 text-center sm:p-10"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 12%, transparent), color-mix(in srgb, var(--color-accent) 12%, transparent))",
|
||||||
|
border:
|
||||||
|
"1px solid color-mix(in srgb, var(--color-primary) 22%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="brand-halo left-1/2 top-0 h-64 w-[560px] max-w-full -translate-x-1/2 -translate-y-1/3"
|
||||||
|
/>
|
||||||
|
<h2
|
||||||
|
id="join-cta-title"
|
||||||
|
className="relative text-2xl font-black tracking-tight text-balance sm:text-3xl"
|
||||||
|
style={{ color: "var(--color-text-readable)" }}
|
||||||
|
>
|
||||||
|
{tp("joinCtaTitle")}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="relative mx-auto mt-2 max-w-xl text-sm sm:text-base"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{tp("joinCtaBody")}
|
||||||
|
</p>
|
||||||
|
<div className="relative mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="btn-shine group inline-flex items-center gap-2 rounded-xl px-6 py-3 text-sm font-bold text-primary-foreground shadow-lg transition-transform duration-300 hover:-translate-y-0.5"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, var(--color-primary), color-mix(in srgb, var(--color-accent) 55%, var(--color-primary)))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tp("ctaJoin")}
|
||||||
|
<span className="transition-transform duration-300 group-hover:translate-x-0.5">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/community"
|
||||||
|
className="inline-flex items-center rounded-xl px-6 py-3 text-sm font-bold transition-all duration-300 hover:-translate-y-0.5"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||||
|
border:
|
||||||
|
"1px solid color-mix(in srgb, var(--color-text-muted) 18%, transparent)",
|
||||||
|
color: "var(--color-text-readable)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tp("browseCommunity")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,16 +65,16 @@ export default async function RegisterPage() {
|
|||||||
<div
|
<div
|
||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
style={{
|
style={{
|
||||||
background: `url(/assets/images/background.png) center/cover no-repeat`,
|
background:
|
||||||
|
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0"
|
aria-hidden="true"
|
||||||
style={{
|
className="absolute inset-0 overflow-hidden"
|
||||||
background:
|
>
|
||||||
"linear-gradient(135deg, color-mix(in srgb, var(--color-surface) 88%, transparent) 0%, color-mix(in srgb, var(--color-surface) 35%, transparent) 100%)",
|
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
|
||||||
}}
|
</div>
|
||||||
/>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="shrink-0 animate-float">
|
<div className="shrink-0 animate-float">
|
||||||
@@ -135,7 +135,8 @@ export default async function RegisterPage() {
|
|||||||
{recentUsers.map((u) => (
|
{recentUsers.map((u) => (
|
||||||
<div
|
<div
|
||||||
key={u.username}
|
key={u.username}
|
||||||
className="flex flex-col items-center gap-1.5 rounded-xl border px-1 py-2 transition-all duration-200 hover:-translate-y-0.5"
|
title={u.username}
|
||||||
|
className="flex flex-col items-center gap-1.5 rounded-xl border px-1 py-2 transition-all duration-200 hover:-translate-y-0.5 hover:ring-1 hover:ring-[color-mix(in_srgb,var(--color-primary)_35%,transparent)]"
|
||||||
style={{
|
style={{
|
||||||
background:
|
background:
|
||||||
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||||
@@ -163,7 +164,7 @@ export default async function RegisterPage() {
|
|||||||
|
|
||||||
{latestUsers.length > 0 && (
|
{latestUsers.length > 0 && (
|
||||||
<SurfaceCard
|
<SurfaceCard
|
||||||
title="Newest citizens"
|
title={tpr("newestCitizens")}
|
||||||
icon={ICON_FRIENDS}
|
icon={ICON_FRIENDS}
|
||||||
bodyClassName="p-4"
|
bodyClassName="p-4"
|
||||||
className="card-glow"
|
className="card-glow"
|
||||||
@@ -172,7 +173,8 @@ export default async function RegisterPage() {
|
|||||||
{latestUsers.map((u) => (
|
{latestUsers.map((u) => (
|
||||||
<div
|
<div
|
||||||
key={u.username}
|
key={u.username}
|
||||||
className="flex flex-col items-center gap-1.5 rounded-xl border px-1 py-2 transition-all duration-200 hover:-translate-y-0.5"
|
title={u.username}
|
||||||
|
className="flex flex-col items-center gap-1.5 rounded-xl border px-1 py-2 transition-all duration-200 hover:-translate-y-0.5 hover:ring-1 hover:ring-[color-mix(in_srgb,var(--color-primary)_35%,transparent)]"
|
||||||
style={{
|
style={{
|
||||||
background:
|
background:
|
||||||
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
"color-mix(in srgb, var(--color-primary) 4%, transparent)",
|
||||||
|
|||||||
@@ -590,6 +590,148 @@ html.dark body.site-bg:has([data-admin]) {
|
|||||||
background-blend-mode: normal;
|
background-blend-mode: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Animated site backdrop (Theme Manager effects) ────────────────
|
||||||
|
A fixed, decorative layer between the body wallpaper and the content.
|
||||||
|
Theme-driven: aurora + particles are pure CSS on transform/opacity, so
|
||||||
|
they stay cheap and pause for prefers-reduced-motion automatically. */
|
||||||
|
.site-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
contain: layout paint style;
|
||||||
|
}
|
||||||
|
.site-backdrop > span {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
body:has([data-admin]) .site-backdrop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aurora blobs (large, soft radial gradients — no filters). */
|
||||||
|
.site-backdrop > .site-backdrop-aurora {
|
||||||
|
inset: -12% -10%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.site-backdrop-blob {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 999px;
|
||||||
|
opacity: 0.42;
|
||||||
|
will-change: transform;
|
||||||
|
animation: aurora-drift 20s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
.site-backdrop-blob-1 {
|
||||||
|
width: clamp(320px, 44vw, 620px);
|
||||||
|
height: clamp(320px, 44vw, 620px);
|
||||||
|
top: -14%;
|
||||||
|
left: -10%;
|
||||||
|
background: radial-gradient(
|
||||||
|
circle at 32% 32%,
|
||||||
|
color-mix(in srgb, var(--color-primary) 60%, transparent) 0%,
|
||||||
|
transparent 68%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.site-backdrop-blob-2 {
|
||||||
|
width: clamp(280px, 38vw, 540px);
|
||||||
|
height: clamp(280px, 38vw, 540px);
|
||||||
|
top: -10%;
|
||||||
|
right: -8%;
|
||||||
|
background: radial-gradient(
|
||||||
|
circle at 62% 40%,
|
||||||
|
color-mix(in srgb, var(--color-accent) 48%, transparent) 0%,
|
||||||
|
transparent 68%
|
||||||
|
);
|
||||||
|
animation-delay: -7s;
|
||||||
|
animation-duration: 24s;
|
||||||
|
}
|
||||||
|
.site-backdrop-blob-3 {
|
||||||
|
width: clamp(360px, 50vw, 700px);
|
||||||
|
height: clamp(360px, 50vw, 700px);
|
||||||
|
bottom: -22%;
|
||||||
|
left: 12%;
|
||||||
|
background: radial-gradient(
|
||||||
|
circle at 50% 50%,
|
||||||
|
color-mix(in srgb, var(--color-primary) 34%, transparent) 0%,
|
||||||
|
color-mix(in srgb, var(--color-accent) 18%, transparent) 42%,
|
||||||
|
transparent 72%
|
||||||
|
);
|
||||||
|
animation-delay: -13s;
|
||||||
|
animation-duration: 28s;
|
||||||
|
}
|
||||||
|
@keyframes aurora-drift {
|
||||||
|
0% {
|
||||||
|
transform: translate3d(0, 0, 0) scale(1) rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate3d(6vw, 5vh, 0) scale(1.16) rotate(6deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html.dark .site-backdrop-blob {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating particles drifting upward. */
|
||||||
|
.site-backdrop-particles {
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
.site-backdrop-dot {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -4vh;
|
||||||
|
width: calc(var(--dot-size, 3) * 1px + 1px);
|
||||||
|
height: calc(var(--dot-size, 3) * 1px + 1px);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 70%, transparent);
|
||||||
|
box-shadow: 0 0 8px color-mix(in srgb, var(--color-primary) 55%, transparent);
|
||||||
|
opacity: 0;
|
||||||
|
animation: particle-rise var(--dot-duration, 12s) linear infinite;
|
||||||
|
animation-delay: var(--dot-delay, 0s);
|
||||||
|
}
|
||||||
|
@keyframes particle-rise {
|
||||||
|
0% {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
10% {
|
||||||
|
opacity: var(--dot-alpha, 0.45);
|
||||||
|
}
|
||||||
|
88% {
|
||||||
|
opacity: var(--dot-alpha, 0.45);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate3d(var(--dot-drift, 2vw), -108vh, 0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html.dark .site-backdrop-dot {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 82%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overlay tint above image/effects keeps custom backgrounds readable. */
|
||||||
|
.site-backdrop-overlay {
|
||||||
|
inset: 0;
|
||||||
|
background: var(--background-overlay, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show only the selected effect(s). */
|
||||||
|
.site-backdrop[data-effect="none"] .site-backdrop-particles,
|
||||||
|
.site-backdrop[data-effect="none"] .site-backdrop-aurora,
|
||||||
|
.site-backdrop[data-effect="aurora"] .site-backdrop-particles,
|
||||||
|
.site-backdrop[data-effect="particles"] .site-backdrop-aurora {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.site-backdrop-blob {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.site-backdrop-dot {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.article-body {
|
.article-body {
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ import {
|
|||||||
} from "@/actions/theme-manager";
|
} from "@/actions/theme-manager";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
BACKGROUND_EFFECTS,
|
||||||
BACKGROUND_POSITIONS,
|
BACKGROUND_POSITIONS,
|
||||||
BACKGROUND_REPEATS,
|
BACKGROUND_REPEATS,
|
||||||
BACKGROUND_SIZES,
|
BACKGROUND_SIZES,
|
||||||
|
OVERLAY_OPACITY_MAX,
|
||||||
|
OVERLAY_PRESETS,
|
||||||
} from "@/lib/theme-bg";
|
} from "@/lib/theme-bg";
|
||||||
import type { CustomTheme } from "@/lib/theme-custom-store";
|
import type { CustomTheme } from "@/lib/theme-custom-store";
|
||||||
import { serializeThemeExport, swatchColors } from "@/lib/theme-manager-format";
|
import { serializeThemeExport, swatchColors } from "@/lib/theme-manager-format";
|
||||||
@@ -28,6 +31,9 @@ export type ThemeManagerBackground = {
|
|||||||
position: string;
|
position: string;
|
||||||
size: string;
|
size: string;
|
||||||
repeat: string;
|
repeat: string;
|
||||||
|
effect: string;
|
||||||
|
overlay: string;
|
||||||
|
overlayOpacity: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ThemeManagerPanelProps {
|
interface ThemeManagerPanelProps {
|
||||||
@@ -133,6 +139,45 @@ function RenameInline({ theme }: { theme: CustomTheme }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OverlayOpacityField({
|
||||||
|
defaultValue = "0",
|
||||||
|
}: {
|
||||||
|
defaultValue?: string;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations(
|
||||||
|
"pages.housekeeping.domains.hotel.routes.themeManager",
|
||||||
|
);
|
||||||
|
const parsed = /^\d{1,2}$/.test(defaultValue)
|
||||||
|
? Math.min(Number(defaultValue), OVERLAY_OPACITY_MAX)
|
||||||
|
: 0;
|
||||||
|
const [value, setValue] = useState(parsed);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 text-xs text-[var(--admin-text-muted)] sm:col-span-2 lg:col-span-2">
|
||||||
|
<label
|
||||||
|
htmlFor="background_overlay_opacity"
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span>{t("backgroundOverlayOpacityLabel")}</span>
|
||||||
|
<span className="font-semibold text-[var(--admin-text)] tabular-nums">
|
||||||
|
{value}%
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="background_overlay_opacity"
|
||||||
|
type="range"
|
||||||
|
name="background_overlay_opacity"
|
||||||
|
min={0}
|
||||||
|
max={OVERLAY_OPACITY_MAX}
|
||||||
|
step={5}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(Number(e.target.value))}
|
||||||
|
className="accent-[var(--admin-accent)] py-2"
|
||||||
|
style={{ border: "none", background: "transparent" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ThemeManagerPanel({
|
export function ThemeManagerPanel({
|
||||||
themes,
|
themes,
|
||||||
current,
|
current,
|
||||||
@@ -397,8 +442,20 @@ export function ThemeManagerPanel({
|
|||||||
backgroundRepeat: background.repeat,
|
backgroundRepeat: background.repeat,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(background.overlay
|
||||||
|
? {
|
||||||
|
backgroundColor: `color-mix(in srgb, ${background.overlay} ${Number(background.overlayOpacity) || 0}%, transparent)`,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{background.effect !== "none" ? (
|
||||||
|
<p className="mt-1.5 text-xs text-[var(--admin-accent)]">
|
||||||
|
{t(`backgroundEffectPreview`, {
|
||||||
|
effect: t(`backgroundEffect_${background.effect}`),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
@@ -461,6 +518,68 @@ export function ThemeManagerPanel({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-[var(--admin-text-muted)] lg:col-span-2">
|
||||||
|
<span>{t("backgroundEffectLabel")}</span>
|
||||||
|
<select
|
||||||
|
name="background_effect"
|
||||||
|
defaultValue={background.effect}
|
||||||
|
className="px-3 py-2 rounded-lg border bg-[var(--admin-canvas)] text-sm text-[var(--admin-text)]"
|
||||||
|
style={{ borderColor: "var(--admin-border)" }}
|
||||||
|
>
|
||||||
|
{BACKGROUND_EFFECTS.map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{t(`backgroundEffect_${value}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 text-xs text-[var(--admin-text-muted)] lg:col-span-2">
|
||||||
|
<label
|
||||||
|
htmlFor="background_overlay"
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span>{t("backgroundOverlayLabel")}</span>
|
||||||
|
<input
|
||||||
|
id="background_overlay"
|
||||||
|
name="background_overlay"
|
||||||
|
type="color"
|
||||||
|
value={
|
||||||
|
/^#[0-9a-fA-F]{6}$/.test(background.overlay)
|
||||||
|
? background.overlay
|
||||||
|
: "#000000"
|
||||||
|
}
|
||||||
|
className="h-8 w-12 cursor-pointer rounded-md border bg-[var(--admin-canvas)] px-0.5"
|
||||||
|
style={{ borderColor: "var(--admin-border)" }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{OVERLAY_PRESETS.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.label}
|
||||||
|
type="button"
|
||||||
|
title={preset.label}
|
||||||
|
onClick={() => {
|
||||||
|
const input = document.getElementById(
|
||||||
|
"background_overlay",
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
if (input) input.value = preset.value || "#000000";
|
||||||
|
}}
|
||||||
|
className="h-7 w-7 rounded-md border transition-transform hover:scale-110"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--admin-border)",
|
||||||
|
background:
|
||||||
|
preset.value ||
|
||||||
|
"linear-gradient(135deg, #64748b, #94a3b8)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OverlayOpacityField defaultValue={background.overlayOpacity} />
|
||||||
|
|
||||||
<div className="sm:col-span-2 lg:col-span-4 flex flex-wrap gap-2">
|
<div className="sm:col-span-2 lg:col-span-4 flex flex-wrap gap-2">
|
||||||
<Button type="submit" size="sm">
|
<Button type="submit" size="sm">
|
||||||
{t("applyBackground")}
|
{t("applyBackground")}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import {
|
import {
|
||||||
BACKGROUND_DEFAULTS,
|
BACKGROUND_DEFAULTS,
|
||||||
|
BACKGROUND_EFFECTS,
|
||||||
BACKGROUND_POSITIONS,
|
BACKGROUND_POSITIONS,
|
||||||
BACKGROUND_REPEATS,
|
BACKGROUND_REPEATS,
|
||||||
BACKGROUND_SIZES,
|
BACKGROUND_SIZES,
|
||||||
|
type BackgroundEffect,
|
||||||
isSafeBackgroundUrl,
|
isSafeBackgroundUrl,
|
||||||
} from "@/lib/theme-bg";
|
} from "@/lib/theme-bg";
|
||||||
import { themePaletteCss } from "@/lib/theme-css";
|
import { themePaletteCss } from "@/lib/theme-css";
|
||||||
@@ -16,6 +19,18 @@ import {
|
|||||||
|
|
||||||
// Injects the DB-driven CSS custom properties into :root / html.dark.
|
// Injects the DB-driven CSS custom properties into :root / html.dark.
|
||||||
// Readable (WCAG) text colors are derived centrally in themePaletteCss.
|
// Readable (WCAG) text colors are derived centrally in themePaletteCss.
|
||||||
|
|
||||||
|
// Deterministic particle layout (stable keys, no random state per render).
|
||||||
|
const PARTICLE_DOTS = Array.from({ length: 14 }, (_, i) => ({
|
||||||
|
key: `site-backdrop-dot-${i}`,
|
||||||
|
left: Math.round(((i * 7.13 + 3) % 100) * 100) / 100,
|
||||||
|
duration: 10 + (i % 5) * 1.7,
|
||||||
|
delay: (i % 8) * -1.9,
|
||||||
|
drift: ((i % 3) - 1) * 2.2,
|
||||||
|
size: 3 + (i % 3),
|
||||||
|
alpha: Math.round((0.35 + (i % 4) * 0.12) * 100) / 100,
|
||||||
|
}));
|
||||||
|
|
||||||
export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
|
export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
|
||||||
const g = (k: string, d: string) => siteSettings.get(k, d);
|
const g = (k: string, d: string) => siteSettings.get(k, d);
|
||||||
const [
|
const [
|
||||||
@@ -62,6 +77,9 @@ export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
|
|||||||
backgroundPosition,
|
backgroundPosition,
|
||||||
backgroundSize,
|
backgroundSize,
|
||||||
backgroundRepeat,
|
backgroundRepeat,
|
||||||
|
backgroundEffect,
|
||||||
|
backgroundOverlay,
|
||||||
|
backgroundOverlayOpacity,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
g("color_primary", "#f59e0b"),
|
g("color_primary", "#f59e0b"),
|
||||||
g("color_background", "#f8fafc"),
|
g("color_background", "#f8fafc"),
|
||||||
@@ -106,6 +124,12 @@ export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
|
|||||||
g("background_position", BACKGROUND_DEFAULTS.background_position),
|
g("background_position", BACKGROUND_DEFAULTS.background_position),
|
||||||
g("background_size", BACKGROUND_DEFAULTS.background_size),
|
g("background_size", BACKGROUND_DEFAULTS.background_size),
|
||||||
g("background_repeat", BACKGROUND_DEFAULTS.background_repeat),
|
g("background_repeat", BACKGROUND_DEFAULTS.background_repeat),
|
||||||
|
g("background_effect", BACKGROUND_DEFAULTS.background_effect),
|
||||||
|
g("background_overlay", BACKGROUND_DEFAULTS.background_overlay),
|
||||||
|
g(
|
||||||
|
"background_overlay_opacity",
|
||||||
|
BACKGROUND_DEFAULTS.background_overlay_opacity,
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Only hex/keyword color values are admin-controlled — sanitize defensively.
|
// Only hex/keyword color values are admin-controlled — sanitize defensively.
|
||||||
@@ -203,11 +227,30 @@ export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
|
|||||||
? backgroundRepeat
|
? backgroundRepeat
|
||||||
: BACKGROUND_DEFAULTS.background_repeat;
|
: BACKGROUND_DEFAULTS.background_repeat;
|
||||||
|
|
||||||
|
const safeEffect = (BACKGROUND_EFFECTS as readonly string[]).includes(
|
||||||
|
backgroundEffect ?? "",
|
||||||
|
)
|
||||||
|
? (backgroundEffect as BackgroundEffect)
|
||||||
|
: BACKGROUND_EFFECTS[0];
|
||||||
|
|
||||||
|
const safeOverlay = /^#[0-9a-fA-F]{3,8}$/.test(backgroundOverlay ?? "")
|
||||||
|
? (backgroundOverlay as string)
|
||||||
|
: "";
|
||||||
|
const overlayPct = /^\d{1,2}$/.test(backgroundOverlayOpacity ?? "")
|
||||||
|
? Math.min(parseInt(backgroundOverlayOpacity as string, 10), 90)
|
||||||
|
: 0;
|
||||||
|
|
||||||
const backgroundCss =
|
const backgroundCss =
|
||||||
backgroundImage && isSafeBackgroundUrl(backgroundImage)
|
backgroundImage && isSafeBackgroundUrl(backgroundImage)
|
||||||
? `body.site-bg{background-image:url("${backgroundImage}");background-position:${safeBgPosition};background-size:${safeBgSize};background-repeat:${safeBgRepeat}}`
|
? `body.site-bg{background-image:url("${backgroundImage}");background-position:${safeBgPosition};background-size:${safeBgSize};background-repeat:${safeBgRepeat}}`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
const backdropStyle: CSSProperties | undefined = safeOverlay
|
||||||
|
? ({
|
||||||
|
"--background-overlay": `color-mix(in srgb, ${safeOverlay} ${overlayPct}%, transparent)`,
|
||||||
|
} as CSSProperties)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{googleHref ? <link rel="stylesheet" href={googleHref} /> : null}
|
{googleHref ? <link rel="stylesheet" href={googleHref} /> : null}
|
||||||
@@ -222,6 +265,39 @@ export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
|
|||||||
// Staff-authored custom CSS (housekeeping → Theme). Trusted input.
|
// Staff-authored custom CSS (housekeeping → Theme). Trusted input.
|
||||||
<style nonce={nonce} dangerouslySetInnerHTML={{ __html: customCss }} />
|
<style nonce={nonce} dangerouslySetInnerHTML={{ __html: customCss }} />
|
||||||
) : null}
|
) : null}
|
||||||
|
{/* Animated site backdrop: aurora + particles + optional tint. Pure
|
||||||
|
decoration, behind the content, hidden in the admin panel. */}
|
||||||
|
<div
|
||||||
|
className="site-backdrop"
|
||||||
|
data-effect={safeEffect}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={backdropStyle}
|
||||||
|
>
|
||||||
|
<span className="site-backdrop-aurora">
|
||||||
|
<i className="site-backdrop-blob site-backdrop-blob-1" />
|
||||||
|
<i className="site-backdrop-blob site-backdrop-blob-2" />
|
||||||
|
<i className="site-backdrop-blob site-backdrop-blob-3" />
|
||||||
|
</span>
|
||||||
|
<span className="site-backdrop-particles">
|
||||||
|
{PARTICLE_DOTS.map((dot) => (
|
||||||
|
<i
|
||||||
|
key={dot.key}
|
||||||
|
className="site-backdrop-dot"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
left: `${dot.left}%`,
|
||||||
|
"--dot-duration": `${dot.duration}s`,
|
||||||
|
"--dot-delay": `${dot.delay}s`,
|
||||||
|
"--dot-drift": `${dot.drift}vw`,
|
||||||
|
"--dot-size": dot.size,
|
||||||
|
"--dot-alpha": dot.alpha,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="site-backdrop-overlay" />
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,9 +7,37 @@ export const BACKGROUND_KEYS = [
|
|||||||
"background_position",
|
"background_position",
|
||||||
"background_size",
|
"background_size",
|
||||||
"background_repeat",
|
"background_repeat",
|
||||||
|
"background_effect",
|
||||||
|
"background_overlay",
|
||||||
|
"background_overlay_opacity",
|
||||||
] as const;
|
] as const;
|
||||||
export type BackgroundKey = (typeof BACKGROUND_KEYS)[number];
|
export type BackgroundKey = (typeof BACKGROUND_KEYS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animated background effects rendered behind the public site. Purely
|
||||||
|
* decorative and CSS-driven (transform/opacity only) so they stay cheap on
|
||||||
|
* every device and respect `prefers-reduced-motion`.
|
||||||
|
*/
|
||||||
|
export const BACKGROUND_EFFECTS = [
|
||||||
|
"none",
|
||||||
|
"aurora",
|
||||||
|
"particles",
|
||||||
|
"aurora-particles",
|
||||||
|
] as const;
|
||||||
|
export type BackgroundEffect = (typeof BACKGROUND_EFFECTS)[number];
|
||||||
|
|
||||||
|
export const BACKGROUND_EFFECT_DEFAULT: BackgroundEffect = "none";
|
||||||
|
|
||||||
|
export const OVERLAY_PRESETS = [
|
||||||
|
{ label: "None", value: "" },
|
||||||
|
{ label: "Black", value: "#000000" },
|
||||||
|
{ label: "White", value: "#ffffff" },
|
||||||
|
{ label: "Navy", value: "#0b1220" },
|
||||||
|
{ label: "Slate", value: "#1e293b" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const OVERLAY_OPACITY_MAX = 90;
|
||||||
|
|
||||||
export const BACKGROUND_POSITIONS = [
|
export const BACKGROUND_POSITIONS = [
|
||||||
"center center",
|
"center center",
|
||||||
"top center",
|
"top center",
|
||||||
@@ -36,6 +64,9 @@ export const BACKGROUND_DEFAULTS: Record<BackgroundKey, string> = {
|
|||||||
background_position: "center center",
|
background_position: "center center",
|
||||||
background_size: "cover",
|
background_size: "cover",
|
||||||
background_repeat: "no-repeat",
|
background_repeat: "no-repeat",
|
||||||
|
background_effect: BACKGROUND_EFFECT_DEFAULT,
|
||||||
|
background_overlay: "",
|
||||||
|
background_overlay_opacity: "0",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+15
-1
@@ -288,7 +288,11 @@
|
|||||||
"sectionStatsLabel": "Live stats",
|
"sectionStatsLabel": "Live stats",
|
||||||
"sectionStatsTitle": "A community that keeps growing",
|
"sectionStatsTitle": "A community that keeps growing",
|
||||||
"sectionCommunityLabel": "Community",
|
"sectionCommunityLabel": "Community",
|
||||||
"sectionCommunityTitle": "Make yourself at home"
|
"sectionCommunityTitle": "Make yourself at home",
|
||||||
|
"joinCtaTitle": "Ready to make yourself at home?",
|
||||||
|
"joinCtaBody": "Create your avatar, grab some furni and meet new friends — your hotel adventure starts here.",
|
||||||
|
"ctaJoin": "Join the hotel",
|
||||||
|
"browseCommunity": "Browse the community"
|
||||||
},
|
},
|
||||||
"news": {
|
"news": {
|
||||||
"title": "News",
|
"title": "News",
|
||||||
@@ -997,6 +1001,7 @@
|
|||||||
"alreadyHaveAccountLink": "Sign in",
|
"alreadyHaveAccountLink": "Sign in",
|
||||||
"register": "Register",
|
"register": "Register",
|
||||||
"whoIsOnline": "Who's online",
|
"whoIsOnline": "Who's online",
|
||||||
|
"newestCitizens": "Newest citizens",
|
||||||
"usersOnline": "{count} online",
|
"usersOnline": "{count} online",
|
||||||
"termsError": "You must accept the terms & rules to register.",
|
"termsError": "You must accept the terms & rules to register.",
|
||||||
"usernameError": "Username is required.",
|
"usernameError": "Username is required.",
|
||||||
@@ -4810,6 +4815,15 @@
|
|||||||
"applyBackground": "Apply background",
|
"applyBackground": "Apply background",
|
||||||
"uploadButton": "Upload image…",
|
"uploadButton": "Upload image…",
|
||||||
"clearBackground": "Remove background",
|
"clearBackground": "Remove background",
|
||||||
|
"backgroundEffectLabel": "Effect",
|
||||||
|
"backgroundEffectHint": "Animate the public background with a gentle, theme-matched layer (aurora uses your primary/accent colors).",
|
||||||
|
"backgroundEffect_none": "None (plain background)",
|
||||||
|
"backgroundEffect_aurora": "Aurora glow",
|
||||||
|
"backgroundEffect_particles": "Floating particles",
|
||||||
|
"backgroundEffect_aurora-particles": "Aurora + particles",
|
||||||
|
"backgroundEffectPreview": "Effect: {effect} — shown behind the public site.",
|
||||||
|
"backgroundOverlayLabel": "Overlay tint",
|
||||||
|
"backgroundOverlayOpacityLabel": "Overlay strength",
|
||||||
"notice": {
|
"notice": {
|
||||||
"saved": "Theme saved.",
|
"saved": "Theme saved.",
|
||||||
"applied": "Theme applied.",
|
"applied": "Theme applied.",
|
||||||
|
|||||||
+19
-5
@@ -283,12 +283,16 @@
|
|||||||
"featuresCommunityDesc": "Fai amicizia e chatta",
|
"featuresCommunityDesc": "Fai amicizia e chatta",
|
||||||
"featuresGames": "Giochi",
|
"featuresGames": "Giochi",
|
||||||
"featuresGamesDesc": "Competi e divertiti",
|
"featuresGamesDesc": "Competi e divertiti",
|
||||||
"sectionFeaturesLabel": "Features",
|
"sectionFeaturesLabel": "Funzionalità",
|
||||||
"sectionFeaturesTitle": "Everything a great hotel needs",
|
"sectionFeaturesTitle": "Tutto ciò che un grande hotel merita",
|
||||||
"sectionStatsLabel": "Live stats",
|
"sectionStatsLabel": "Statistiche live",
|
||||||
"sectionStatsTitle": "A community that keeps growing",
|
"sectionStatsTitle": "Una community che continua a crescere",
|
||||||
"sectionCommunityLabel": "Community",
|
"sectionCommunityLabel": "Community",
|
||||||
"sectionCommunityTitle": "Make yourself at home"
|
"sectionCommunityTitle": "Fatti sentire a casa",
|
||||||
|
"joinCtaTitle": "Pronto a sentirti a casa?",
|
||||||
|
"joinCtaBody": "Crea il tuo avatar, prendi dei mobili e incontra nuovi amici: la tua avventura nell'hotel inizia qui.",
|
||||||
|
"ctaJoin": "Entra nell'hotel",
|
||||||
|
"browseCommunity": "Scopri la community"
|
||||||
},
|
},
|
||||||
"news": {
|
"news": {
|
||||||
"title": "Notizie",
|
"title": "Notizie",
|
||||||
@@ -976,6 +980,7 @@
|
|||||||
"alreadyHaveAccountPre": "Hai già un account?",
|
"alreadyHaveAccountPre": "Hai già un account?",
|
||||||
"alreadyHaveAccountLink": "Accedi",
|
"alreadyHaveAccountLink": "Accedi",
|
||||||
"whoIsOnline": "Chi è online",
|
"whoIsOnline": "Chi è online",
|
||||||
|
"newestCitizens": "Nuovi cittadini",
|
||||||
"usersOnline": "{count} online",
|
"usersOnline": "{count} online",
|
||||||
"accepted": "Accettato e confermato",
|
"accepted": "Accettato e confermato",
|
||||||
"showPassword": "Mostra",
|
"showPassword": "Mostra",
|
||||||
@@ -4809,6 +4814,15 @@
|
|||||||
"applyBackground": "Applica sfondo",
|
"applyBackground": "Applica sfondo",
|
||||||
"uploadButton": "Carica immagine…",
|
"uploadButton": "Carica immagine…",
|
||||||
"clearBackground": "Rimuovi sfondo",
|
"clearBackground": "Rimuovi sfondo",
|
||||||
|
"backgroundEffectLabel": "Effetto",
|
||||||
|
"backgroundEffectHint": "Anima lo sfondo pubblico con un livello delicato e abbinato al tema (l'aurora usa i colori primario/accento).",
|
||||||
|
"backgroundEffect_none": "Nessuno (sfondo semplice)",
|
||||||
|
"backgroundEffect_aurora": "Bagliore aurora",
|
||||||
|
"backgroundEffect_particles": "Particelle fluttuanti",
|
||||||
|
"backgroundEffect_aurora-particles": "Aurora + particelle",
|
||||||
|
"backgroundEffectPreview": "Effetto: {effect} — mostrato dietro il sito pubblico.",
|
||||||
|
"backgroundOverlayLabel": "Tinta di sovrapposizione",
|
||||||
|
"backgroundOverlayOpacityLabel": "Intensità tinta",
|
||||||
"notice": {
|
"notice": {
|
||||||
"saved": "Tema salvato.",
|
"saved": "Tema salvato.",
|
||||||
"applied": "Tema applicato.",
|
"applied": "Tema applicato.",
|
||||||
|
|||||||
Reference in new issue
Block a user