feat(housekeeping): add per-hotel theme manager with import/export and background
Theme Manager under /admin-next/hotel/theme-manager lets the owner save, apply, rename, delete, import, and export custom themes, plus set a custom site background by URL or upload. Themes are stored in WebsiteSetting/custom_themes JSON so they survive CMS updates.
This commit is contained in:
1 parent
55c13b533c
commit
5b4b275b2a
13 files changed
+1376
-1
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,47 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getThemeManagerData } from "@/actions/theme-manager";
|
||||||
|
import { ThemeManagerPanel } from "@/components/admin/theme/theme-manager-panel";
|
||||||
|
import { HousekeepingPageShell } from "@/features/housekeeping/foundation/page/housekeeping-page-shell";
|
||||||
|
|
||||||
|
const MESSAGE_PREFIX = "pages.housekeeping.domains.hotel.routes.themeManager";
|
||||||
|
|
||||||
|
const NOTICES = new Set([
|
||||||
|
"saved",
|
||||||
|
"applied",
|
||||||
|
"renamed",
|
||||||
|
"deleted",
|
||||||
|
"imported",
|
||||||
|
"preset",
|
||||||
|
"background",
|
||||||
|
"importError",
|
||||||
|
"backgroundError",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default async function HousekeepingHotelThemeManagerPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
|
}) {
|
||||||
|
const t = await getTranslations(MESSAGE_PREFIX);
|
||||||
|
const params = await searchParams;
|
||||||
|
const first = Array.isArray(params.notice) ? params.notice[0] : params.notice;
|
||||||
|
const notice = first && NOTICES.has(first) ? first : undefined;
|
||||||
|
|
||||||
|
const { themes, current, background } = await getThemeManagerData();
|
||||||
|
const activeName =
|
||||||
|
typeof current.theme_preset === "string" && current.theme_preset
|
||||||
|
? current.theme_preset
|
||||||
|
: "Atom (golden)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HousekeepingPageShell title={t("title")} description={t("description")}>
|
||||||
|
<ThemeManagerPanel
|
||||||
|
themes={themes}
|
||||||
|
current={current}
|
||||||
|
background={background}
|
||||||
|
activeName={activeName}
|
||||||
|
notice={notice}
|
||||||
|
/>
|
||||||
|
</HousekeepingPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,11 @@
|
|||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
import {
|
||||||
|
BACKGROUND_DEFAULTS,
|
||||||
|
BACKGROUND_POSITIONS,
|
||||||
|
BACKGROUND_REPEATS,
|
||||||
|
BACKGROUND_SIZES,
|
||||||
|
isSafeBackgroundUrl,
|
||||||
|
} from "@/lib/theme-bg";
|
||||||
import { themePaletteCss } from "@/lib/theme-css";
|
import { themePaletteCss } from "@/lib/theme-css";
|
||||||
import {
|
import {
|
||||||
FONTS,
|
FONTS,
|
||||||
@@ -51,6 +58,10 @@ export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
|
|||||||
adminTextMuted,
|
adminTextMuted,
|
||||||
adminBorder,
|
adminBorder,
|
||||||
adminSidebarBg,
|
adminSidebarBg,
|
||||||
|
backgroundImage,
|
||||||
|
backgroundPosition,
|
||||||
|
backgroundSize,
|
||||||
|
backgroundRepeat,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
g("color_primary", "#f59e0b"),
|
g("color_primary", "#f59e0b"),
|
||||||
g("color_background", "#f8fafc"),
|
g("color_background", "#f8fafc"),
|
||||||
@@ -91,6 +102,10 @@ export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
|
|||||||
g("admin_text_muted", ""),
|
g("admin_text_muted", ""),
|
||||||
g("admin_border", ""),
|
g("admin_border", ""),
|
||||||
g("admin_sidebar_bg", ""),
|
g("admin_sidebar_bg", ""),
|
||||||
|
g("background_image", BACKGROUND_DEFAULTS.background_image),
|
||||||
|
g("background_position", BACKGROUND_DEFAULTS.background_position),
|
||||||
|
g("background_size", BACKGROUND_DEFAULTS.background_size),
|
||||||
|
g("background_repeat", BACKGROUND_DEFAULTS.background_repeat),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Only hex/keyword color values are admin-controlled — sanitize defensively.
|
// Only hex/keyword color values are admin-controlled — sanitize defensively.
|
||||||
@@ -172,10 +187,37 @@ export async function ThemeVars({ nonce }: { nonce?: string } = {}) {
|
|||||||
? `https://fonts.googleapis.com/css2?family=${font.google}&display=swap`
|
? `https://fonts.googleapis.com/css2?family=${font.google}&display=swap`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const safeBgPosition = (BACKGROUND_POSITIONS as readonly string[]).includes(
|
||||||
|
backgroundPosition ?? "",
|
||||||
|
)
|
||||||
|
? backgroundPosition
|
||||||
|
: BACKGROUND_DEFAULTS.background_position;
|
||||||
|
const safeBgSize = (BACKGROUND_SIZES as readonly string[]).includes(
|
||||||
|
backgroundSize ?? "",
|
||||||
|
)
|
||||||
|
? backgroundSize
|
||||||
|
: BACKGROUND_DEFAULTS.background_size;
|
||||||
|
const safeBgRepeat = (BACKGROUND_REPEATS as readonly string[]).includes(
|
||||||
|
backgroundRepeat ?? "",
|
||||||
|
)
|
||||||
|
? backgroundRepeat
|
||||||
|
: BACKGROUND_DEFAULTS.background_repeat;
|
||||||
|
|
||||||
|
const backgroundCss =
|
||||||
|
backgroundImage && isSafeBackgroundUrl(backgroundImage)
|
||||||
|
? `body.site-bg{background-image:url("${backgroundImage}");background-position:${safeBgPosition};background-size:${safeBgSize};background-repeat:${safeBgRepeat}}`
|
||||||
|
: "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{googleHref ? <link rel="stylesheet" href={googleHref} /> : null}
|
{googleHref ? <link rel="stylesheet" href={googleHref} /> : null}
|
||||||
<style nonce={nonce} dangerouslySetInnerHTML={{ __html: css }} />
|
<style nonce={nonce} dangerouslySetInnerHTML={{ __html: css }} />
|
||||||
|
{backgroundCss ? (
|
||||||
|
<style
|
||||||
|
nonce={nonce}
|
||||||
|
dangerouslySetInnerHTML={{ __html: backgroundCss }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{customCss?.trim() ? (
|
{customCss?.trim() ? (
|
||||||
// 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 }} />
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ export const hotelManifest = {
|
|||||||
PERMS.CATALOG_EDIT,
|
PERMS.CATALOG_EDIT,
|
||||||
),
|
),
|
||||||
routes: [
|
routes: [
|
||||||
|
{
|
||||||
|
id: "theme-manager",
|
||||||
|
labelKey: "pages.housekeeping.domains.hotel.routes.themeManager",
|
||||||
|
href: "/admin-next/hotel/theme-manager",
|
||||||
|
capability: anyCapability(PERMS.SETTINGS_VIEW, PERMS.SETTINGS_EDIT),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "nitro-cleanup",
|
id: "nitro-cleanup",
|
||||||
labelKey: "pages.housekeeping.domains.hotel.routes.nitroCleanup",
|
labelKey: "pages.housekeeping.domains.hotel.routes.nitroCleanup",
|
||||||
|
|||||||
@@ -580,6 +580,11 @@ describe("housekeeping foundation completion contracts", () => {
|
|||||||
expect(
|
expect(
|
||||||
registry.domains.find((domain) => domain.id === "hotel")?.routes,
|
registry.domains.find((domain) => domain.id === "hotel")?.routes,
|
||||||
).toEqual([
|
).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "theme-manager",
|
||||||
|
href: "/admin-next/hotel/theme-manager",
|
||||||
|
labelKey: "pages.housekeeping.domains.hotel.routes.themeManager",
|
||||||
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: "nitro-cleanup",
|
id: "nitro-cleanup",
|
||||||
href: "/admin-next/hotel/nitro-cleanup",
|
href: "/admin-next/hotel/nitro-cleanup",
|
||||||
|
|||||||
@@ -166,6 +166,15 @@ const expectedManifests = [
|
|||||||
PERMS.CATALOG_EDIT,
|
PERMS.CATALOG_EDIT,
|
||||||
],
|
],
|
||||||
routes: [
|
routes: [
|
||||||
|
{
|
||||||
|
id: "theme-manager",
|
||||||
|
labelKey: "pages.housekeeping.domains.hotel.routes.themeManager",
|
||||||
|
href: "/admin-next/hotel/theme-manager",
|
||||||
|
capability: {
|
||||||
|
mode: "any",
|
||||||
|
slugs: [PERMS.SETTINGS_VIEW, PERMS.SETTINGS_EDIT],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "nitro-cleanup",
|
id: "nitro-cleanup",
|
||||||
labelKey: "pages.housekeeping.domains.hotel.routes.nitroCleanup",
|
labelKey: "pages.housekeeping.domains.hotel.routes.nitroCleanup",
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Site background settings shared by the Theme Manager, its server actions,
|
||||||
|
// and ThemeVars. Kept in a plain module because it is imported by client
|
||||||
|
// components and "use server" files.
|
||||||
|
|
||||||
|
export const BACKGROUND_KEYS = [
|
||||||
|
"background_image",
|
||||||
|
"background_position",
|
||||||
|
"background_size",
|
||||||
|
"background_repeat",
|
||||||
|
] as const;
|
||||||
|
export type BackgroundKey = (typeof BACKGROUND_KEYS)[number];
|
||||||
|
|
||||||
|
export const BACKGROUND_POSITIONS = [
|
||||||
|
"center center",
|
||||||
|
"top center",
|
||||||
|
"top left",
|
||||||
|
"top right",
|
||||||
|
"center left",
|
||||||
|
"center right",
|
||||||
|
"bottom center",
|
||||||
|
"bottom left",
|
||||||
|
"bottom right",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const BACKGROUND_SIZES = ["cover", "contain", "auto"] as const;
|
||||||
|
|
||||||
|
export const BACKGROUND_REPEATS = [
|
||||||
|
"no-repeat",
|
||||||
|
"repeat",
|
||||||
|
"repeat-x",
|
||||||
|
"repeat-y",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const BACKGROUND_DEFAULTS: Record<BackgroundKey, string> = {
|
||||||
|
background_image: "",
|
||||||
|
background_position: "center center",
|
||||||
|
background_size: "cover",
|
||||||
|
background_repeat: "no-repeat",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A URL accepted as a custom site background. Blocks `javascript:`, data
|
||||||
|
* URIs and any character that could break out of CSS `url("...")` (quotes,
|
||||||
|
* backslashes, backticks, whitespace).
|
||||||
|
*/
|
||||||
|
export function isSafeBackgroundUrl(value: string): boolean {
|
||||||
|
const URL_SAFE = /^[a-zA-Z0-9/:_\-.?&=%~+#]+$/;
|
||||||
|
return (
|
||||||
|
(value.startsWith("/") || /^https?:\/\/[^\s"'`\\]+$/i.test(value)) &&
|
||||||
|
URL_SAFE.test(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { db, WebsiteSetting } from "@/lib/db";
|
import { db, WebsiteSetting } from "@/lib/db";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
import { BACKGROUND_DEFAULTS, BACKGROUND_KEYS } from "@/lib/theme-bg";
|
||||||
import {
|
import {
|
||||||
PRESETS,
|
PRESETS,
|
||||||
THEME_COLOR_KEYS,
|
THEME_COLOR_KEYS,
|
||||||
@@ -29,6 +30,7 @@ const EXTRA_KEYS = [
|
|||||||
"size_heading_h3",
|
"size_heading_h3",
|
||||||
"custom_css",
|
"custom_css",
|
||||||
"theme_preset",
|
"theme_preset",
|
||||||
|
...BACKGROUND_KEYS,
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALL_KEYS: string[] = [
|
const ALL_KEYS: string[] = [
|
||||||
@@ -59,8 +61,9 @@ function fallbackFor(key: string): string {
|
|||||||
size_heading_h3: "18",
|
size_heading_h3: "18",
|
||||||
custom_css: "",
|
custom_css: "",
|
||||||
theme_preset: "Atom (golden)",
|
theme_preset: "Atom (golden)",
|
||||||
|
...BACKGROUND_DEFAULTS,
|
||||||
};
|
};
|
||||||
return extras[key] ?? ""; // eslint-disable-line security/detect-object-injection -- key derived from internal THEME_COLOR_KEYS
|
return extras[key] ?? ""; // eslint-disable-line security/detect-object-injection -- key derived from internal EXTRA_KEYS
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listCustomThemes(): Promise<CustomTheme[]> {
|
export async function listCustomThemes(): Promise<CustomTheme[]> {
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
normalizeImportedTheme,
|
||||||
|
serializeThemeExport,
|
||||||
|
swatchColors,
|
||||||
|
THEME_ALLOWED_SETTING_KEYS,
|
||||||
|
THEME_IMPORT_FORMAT,
|
||||||
|
} from "@/lib/theme-manager-format";
|
||||||
|
|
||||||
|
describe("theme import/export format", () => {
|
||||||
|
it("serialises a theme in the native atom-theme-v1 format", () => {
|
||||||
|
const json = serializeThemeExport("Summer Gold", {
|
||||||
|
color_primary: "#f59e0b",
|
||||||
|
});
|
||||||
|
const parsed = JSON.parse(json) as Record<string, unknown>;
|
||||||
|
expect(parsed.format).toBe(THEME_IMPORT_FORMAT);
|
||||||
|
expect(parsed.name).toBe("Summer Gold");
|
||||||
|
expect(parsed.settings).toEqual({ color_primary: "#f59e0b" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises the native export shape", () => {
|
||||||
|
const result = normalizeImportedTheme(
|
||||||
|
{
|
||||||
|
format: THEME_IMPORT_FORMAT,
|
||||||
|
name: "Pink",
|
||||||
|
settings: {
|
||||||
|
color_primary: "#e11d48",
|
||||||
|
color_primary_dark: "#fb7185",
|
||||||
|
custom_css: "body{}",
|
||||||
|
unknown_key: "ignored",
|
||||||
|
background_image: "/api/media/bg.png",
|
||||||
|
bad_color: "url(javascript:alert(1))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Fallback",
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
name: "Pink",
|
||||||
|
settings: {
|
||||||
|
color_primary: "#e11d48",
|
||||||
|
color_primary_dark: "#fb7185",
|
||||||
|
custom_css: "body{}",
|
||||||
|
background_image: "/api/media/bg.png",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises the /api/themes/export palette shape", () => {
|
||||||
|
const result = normalizeImportedTheme({
|
||||||
|
palette: {
|
||||||
|
light: { color_primary: "#f59e0b", color_text: "#0f172a" },
|
||||||
|
dark: { color_primary: "#fbbf24", color_text: "#f8fafc" },
|
||||||
|
},
|
||||||
|
cssVariables: {},
|
||||||
|
scopes: [],
|
||||||
|
meta: { format: THEME_IMPORT_FORMAT },
|
||||||
|
});
|
||||||
|
expect(result?.settings).toEqual({
|
||||||
|
color_primary: "#f59e0b",
|
||||||
|
color_primary_dark: "#fbbf24",
|
||||||
|
color_text: "#0f172a",
|
||||||
|
color_text_dark: "#f8fafc",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-printable and oversized values", () => {
|
||||||
|
const result = normalizeImportedTheme({
|
||||||
|
format: THEME_IMPORT_FORMAT,
|
||||||
|
settings: {
|
||||||
|
color_primary: "#fff\nbackground:red",
|
||||||
|
custom_css: "x".repeat(20001),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result?.settings).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unrecognised payloads", () => {
|
||||||
|
expect(normalizeImportedTheme(null)).toBeNull();
|
||||||
|
expect(normalizeImportedTheme("nope")).toBeNull();
|
||||||
|
expect(normalizeImportedTheme({})).toBeNull();
|
||||||
|
expect(normalizeImportedTheme({ format: "other" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restricts imported settings to the known theme keys", () => {
|
||||||
|
expect(THEME_ALLOWED_SETTING_KEYS.has("color_primary")).toBe(true);
|
||||||
|
expect(THEME_ALLOWED_SETTING_KEYS.has("background_image")).toBe(true);
|
||||||
|
expect(THEME_ALLOWED_SETTING_KEYS.has("custom_themes")).toBe(false);
|
||||||
|
expect(THEME_ALLOWED_SETTING_KEYS.has("link_color")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds swatch colours from a saved snapshot", () => {
|
||||||
|
expect(
|
||||||
|
swatchColors({ color_primary: "#f59e0b", color_accent: "#10b981" }),
|
||||||
|
).toEqual(["#f59e0b", "#10b981", "#64748b", "#64748b"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -4773,6 +4773,55 @@
|
|||||||
"title": "Hotel",
|
"title": "Hotel",
|
||||||
"description": "Rooms, radio, and asset tools",
|
"description": "Rooms, radio, and asset tools",
|
||||||
"routes": {
|
"routes": {
|
||||||
|
"themeManager": {
|
||||||
|
"title": "Theme manager",
|
||||||
|
"description": "Save, switch, import, export, and publish custom hotel themes (survive updates)",
|
||||||
|
"currentThemeTitle": "Current theme",
|
||||||
|
"currentThemeHint": "The palette and background being served to your visitors right now.",
|
||||||
|
"saveLabel": "Save theme",
|
||||||
|
"savePlaceholder": "Theme name (e.g. Summer Gold)",
|
||||||
|
"exportCurrent": "Export",
|
||||||
|
"savedThemesTitle": "Saved themes",
|
||||||
|
"savedThemesHint": "Apply any saved theme anytime. Saved themes are stored in the database, so they keep working after CMS updates.",
|
||||||
|
"noThemes": "No saved themes yet — save one above.",
|
||||||
|
"active": "Active",
|
||||||
|
"apply": "Apply",
|
||||||
|
"export": "Export",
|
||||||
|
"rename": "Rename",
|
||||||
|
"renameSave": "OK",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteConfirm": "Delete theme \"{name}\"?",
|
||||||
|
"importTitle": "Import a theme",
|
||||||
|
"importHint": "Upload a `.atom-theme.json` file exported here, or a theme exported from the Theme Builder.",
|
||||||
|
"importNameLabel": "Name",
|
||||||
|
"importNamePlaceholder": "Optional override",
|
||||||
|
"importFileLabel": "Theme file",
|
||||||
|
"importApplyLabel": "Apply after import",
|
||||||
|
"importButton": "Import",
|
||||||
|
"invalidFile": "That file isn't a valid JSON theme.",
|
||||||
|
"presetsTitle": "Quick start presets",
|
||||||
|
"backgroundTitle": "Background",
|
||||||
|
"backgroundHint": "Set a custom background image for the public site using any image URL, or upload one.",
|
||||||
|
"backgroundPreviewLabel": "Preview",
|
||||||
|
"backgroundUrlLabel": "Image URL",
|
||||||
|
"backgroundPositionLabel": "Position",
|
||||||
|
"backgroundSizeLabel": "Size",
|
||||||
|
"backgroundRepeatLabel": "Repeat",
|
||||||
|
"applyBackground": "Apply background",
|
||||||
|
"uploadButton": "Upload image…",
|
||||||
|
"clearBackground": "Remove background",
|
||||||
|
"notice": {
|
||||||
|
"saved": "Theme saved.",
|
||||||
|
"applied": "Theme applied.",
|
||||||
|
"renamed": "Theme renamed.",
|
||||||
|
"deleted": "Theme deleted.",
|
||||||
|
"imported": "Theme imported.",
|
||||||
|
"preset": "Preset applied.",
|
||||||
|
"background": "Background updated.",
|
||||||
|
"importError": "That file couldn't be imported. It may be too large or not a theme export.",
|
||||||
|
"backgroundError": "The image couldn't be uploaded. Use a PNG, JPEG, GIF, or WebP up to 10MB."
|
||||||
|
}
|
||||||
|
},
|
||||||
"nitroCleanup": {
|
"nitroCleanup": {
|
||||||
"title": "Nitro cleanup",
|
"title": "Nitro cleanup",
|
||||||
"description": "Scan and remove fake, broken, and orphaned furni asset files"
|
"description": "Scan and remove fake, broken, and orphaned furni asset files"
|
||||||
|
|||||||
@@ -4772,6 +4772,55 @@
|
|||||||
"title": "Hotel",
|
"title": "Hotel",
|
||||||
"description": "Stanze, radio e strumenti per gli asset",
|
"description": "Stanze, radio e strumenti per gli asset",
|
||||||
"routes": {
|
"routes": {
|
||||||
|
"themeManager": {
|
||||||
|
"title": "Gestione temi",
|
||||||
|
"description": "Salva, attiva, importa, esporta e pubblica temi personalizzati per l'hotel (sopravvivono agli aggiornamenti)",
|
||||||
|
"currentThemeTitle": "Tema corrente",
|
||||||
|
"currentThemeHint": "La palette e lo sfondo attualmente mostrati ai tuoi visitatori.",
|
||||||
|
"saveLabel": "Salva tema",
|
||||||
|
"savePlaceholder": "Nome del tema (es. Oro estivo)",
|
||||||
|
"exportCurrent": "Esporta",
|
||||||
|
"savedThemesTitle": "Temi salvati",
|
||||||
|
"savedThemesHint": "Attiva un tema salvato in qualsiasi momento. I temi sono salvati nel database e restano attivi dopo gli aggiornamenti CMS.",
|
||||||
|
"noThemes": "Nessun tema salvato — salvane uno qui sopra.",
|
||||||
|
"active": "Attivo",
|
||||||
|
"apply": "Applica",
|
||||||
|
"export": "Esporta",
|
||||||
|
"rename": "Rinomina",
|
||||||
|
"renameSave": "OK",
|
||||||
|
"delete": "Elimina",
|
||||||
|
"deleteConfirm": "Eliminare il tema \"{name}\"?",
|
||||||
|
"importTitle": "Importa un tema",
|
||||||
|
"importHint": "Carica un file `.atom-theme.json` esportato qui, oppure un tema esportato dal Theme Builder.",
|
||||||
|
"importNameLabel": "Nome",
|
||||||
|
"importNamePlaceholder": "Sostituzione opzionale",
|
||||||
|
"importFileLabel": "File del tema",
|
||||||
|
"importApplyLabel": "Applica dopo l'importazione",
|
||||||
|
"importButton": "Importa",
|
||||||
|
"invalidFile": "Il file non è un tema JSON valido.",
|
||||||
|
"presetsTitle": "Preset di partenza",
|
||||||
|
"backgroundTitle": "Sfondo",
|
||||||
|
"backgroundHint": "Imposta un'immagine di sfondo personalizzata per il sito pubblico usando un URL o caricando un file.",
|
||||||
|
"backgroundPreviewLabel": "Anteprima",
|
||||||
|
"backgroundUrlLabel": "URL immagine",
|
||||||
|
"backgroundPositionLabel": "Posizione",
|
||||||
|
"backgroundSizeLabel": "Dimensione",
|
||||||
|
"backgroundRepeatLabel": "Ripetizione",
|
||||||
|
"applyBackground": "Applica sfondo",
|
||||||
|
"uploadButton": "Carica immagine…",
|
||||||
|
"clearBackground": "Rimuovi sfondo",
|
||||||
|
"notice": {
|
||||||
|
"saved": "Tema salvato.",
|
||||||
|
"applied": "Tema applicato.",
|
||||||
|
"renamed": "Tema rinominato.",
|
||||||
|
"deleted": "Tema eliminato.",
|
||||||
|
"imported": "Tema importato.",
|
||||||
|
"preset": "Preset applicato.",
|
||||||
|
"background": "Sfondo aggiornato.",
|
||||||
|
"importError": "Impossibile importare il file. Potrebbe essere troppo grande o non essere un'esportazione di un tema.",
|
||||||
|
"backgroundError": "Immagine non caricata. Usa PNG, JPEG, GIF o WebP fino a 10MB."
|
||||||
|
}
|
||||||
|
},
|
||||||
"nitroCleanup": {
|
"nitroCleanup": {
|
||||||
"title": "Pulizia nitro",
|
"title": "Pulizia nitro",
|
||||||
"description": "Scansiona e rimuovi i file asset furni falsi, danneggiati o orfani"
|
"description": "Scansiona e rimuovi i file asset furni falsi, danneggiati o orfani"
|
||||||
|
|||||||
Reference in new issue
Block a user