From 809734456153b0a55414655f8ae7fc47774a58e0 Mon Sep 17 00:00:00 2001 From: medievalshell Date: Sun, 31 May 2026 14:39:59 +0200 Subject: [PATCH] feat(theme): runtime custom theme ecosystem (graphics-only) Runtime-loaded visual re-skin system (no client rebuild, real themes never hit git). A theme = a folder on the server (theme.base.url) with a manifest + CSS "pieces"; each piece is toggled from Settings > Themes (checkboxes). A broken/404 piece auto-falls back to the default (per piece). Hotel-wide default via ui-config theme.default (+ theme.default.pieces), per-user override in localStorage (same pattern as the catalog style toggle). - api/theme/ThemeManager: fetch index/manifest + inject/remove + fallback - hooks/theme/useThemes: state + persist + default-from-config + live apply - components/theme/ThemeApplier: applies on boot (mounted in MainView) - UserSettings: General/Themes tabs with theme selector + per-piece checkboxes - custom-themes/: reference template (demo theme "Neon Viola" + README) - .gitignore: public/custom-themes/ (real themes are never committed) --- .gitignore | 3 + custom-themes/README.md | 40 ++++++ custom-themes/index.example.json | 5 + custom-themes/neon-viola/cards.css | 24 ++++ custom-themes/neon-viola/catalog.css | 10 ++ custom-themes/neon-viola/chat.css | 12 ++ custom-themes/neon-viola/theme.json | 10 ++ custom-themes/neon-viola/toolbar.css | 9 ++ src/api/index.ts | 1 + src/api/theme/ThemeManager.ts | 122 ++++++++++++++++++ src/api/theme/index.ts | 1 + src/api/utils/LocalStorageKeys.ts | 2 + src/components/MainView.tsx | 2 + src/components/theme/ThemeApplier.tsx | 12 ++ .../user-settings/UserSettingsView.tsx | 38 +++++- src/hooks/index.ts | 1 + src/hooks/theme/index.ts | 1 + src/hooks/theme/useThemes.ts | 108 ++++++++++++++++ 18 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 custom-themes/README.md create mode 100644 custom-themes/index.example.json create mode 100644 custom-themes/neon-viola/cards.css create mode 100644 custom-themes/neon-viola/catalog.css create mode 100644 custom-themes/neon-viola/chat.css create mode 100644 custom-themes/neon-viola/theme.json create mode 100644 custom-themes/neon-viola/toolbar.css create mode 100644 src/api/theme/ThemeManager.ts create mode 100644 src/api/theme/index.ts create mode 100644 src/components/theme/ThemeApplier.tsx create mode 100644 src/hooks/theme/index.ts create mode 100644 src/hooks/theme/useThemes.ts diff --git a/.gitignore b/.gitignore index e7bee96..d80f3e8 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ Thumbs.db # the dev server takes minutes to start with 100k+ files under public/. /public/nitro-assets /public/swf + +# Temi custom locali di test (i temi veri stanno sul server, mai su git) +public/custom-themes/ diff --git a/custom-themes/README.md b/custom-themes/README.md new file mode 100644 index 0000000..e9f7436 --- /dev/null +++ b/custom-themes/README.md @@ -0,0 +1,40 @@ +# Custom themes (graphics-only) + +Ecosistema temi caricati a **runtime** (niente rebuild del client). Un tema = +una cartella con un manifest + "pezzi" CSS. Ogni pezzo è attivabile/disattivabile +dall'utente da **Impostazioni → Temi** (checkbox). Se un pezzo è rotto/404 → +fallback automatico al default (solo quel pezzo). + +## Dove vivono +- **Questa cartella (`custom-themes/`) è solo il TEMPLATE di riferimento**, versionata su git. +- I temi **veri** stanno sul server in `public/nitro/custom-themes/` (serviti via + l'url configurato in ui-config `theme.base.url`, es. `/client/nitro/custom-themes`). + NON vanno su git → vedi `.gitignore` (`public/custom-themes/`). + +## Struttura +``` +custom-themes/ + index.json # { "themes": [ { "id", "name", "author?" } ] } + / + theme.json # { "name", "pieces": [ { "id", "name", "file" } ] } + cards.css chat.css ... # un file per "pezzo" + assets/... # immagini referenziate dai CSS (url assoluti) +``` + +## Creare un tema +1. Copia `neon-viola/` in una nuova cartella `/`. +2. Modifica `theme.json` (nome + elenco pezzi). +3. Scrivi i CSS dei pezzi (override con `!important`, caricati dopo il base). +4. Aggiungi `{ "id": "", "name": "..." }` a `index.json`. +5. Carica la cartella in `public/nitro/custom-themes/` sul server. **Nessun rebuild.** + +## Default hotel-wide (admin) +In `ui-config.json`: +- `theme.base.url` → dove sono serviti i temi +- `theme.default` → id del tema attivo di default (vuoto = nessuno) +- `theme.default.pieces` → array di id pezzi attivi di default + +Ogni utente può comunque sovrascrivere da Impostazioni → Temi (salvato in localStorage). + +> Nota: i temi ri-skinnano solo la **grafica** (CSS). Non cambiano la struttura +> dei componenti né il comportamento. diff --git a/custom-themes/index.example.json b/custom-themes/index.example.json new file mode 100644 index 0000000..46fd0ea --- /dev/null +++ b/custom-themes/index.example.json @@ -0,0 +1,5 @@ +{ + "themes": [ + { "id": "neon-viola", "name": "Neon Viola", "author": "infinityhotel" } + ] +} diff --git a/custom-themes/neon-viola/cards.css b/custom-themes/neon-viola/cards.css new file mode 100644 index 0000000..5608fb7 --- /dev/null +++ b/custom-themes/neon-viola/cards.css @@ -0,0 +1,24 @@ +/* Tema Neon Viola — pezzo "cards" (finestre / NitroCard). + Ricolora header + cornice delle finestre. Caricato DOPO il CSS base, quindi + usa !important per vincere. Tocca solo la cornice/header (non lo sfondo del + contenuto) per non rovinare la leggibilita' del testo. */ + +.nitro-card-shell:not(.nitro-wired) { + border-color: #7c3aed !important; + box-shadow: 0 0 14px rgba(124, 58, 237, .55), 0 8px 22px rgba(0, 0, 0, .4) !important; +} + +.nitro-card-shell:not(.nitro-wired) .nitro-card-header-shell { + background: linear-gradient(180deg, #9333ea 0%, #6d28d9 100%) !important; + border-color: #a855f7 !important; + border-bottom-color: #2a0a4a !important; +} + +.nitro-card-shell:not(.nitro-wired) .nitro-card-title { + color: #fff !important; + text-shadow: 0 0 6px #c084fc, 0 1px 0 #3b0764 !important; +} + +.nitro-card-shell:not(.nitro-wired) .nitro-card-tabs-shell .nitro-card-tab-item-active { + box-shadow: inset 0 -2px 0 #a855f7 !important; +} diff --git a/custom-themes/neon-viola/catalog.css b/custom-themes/neon-viola/catalog.css new file mode 100644 index 0000000..1f8973d --- /dev/null +++ b/custom-themes/neon-viola/catalog.css @@ -0,0 +1,10 @@ +/* Tema Neon Viola — pezzo "catalog" (catalogo Hippiehotel, .nitro-catalog). */ + +.nitro-catalog .nitro-card-header-shell { + background: linear-gradient(180deg, #9333ea 0%, #6d28d9 100%) !important; +} + +.nitro-catalog .group\/rail { + background: #1a1030 !important; + border-right-color: #7c3aed !important; +} diff --git a/custom-themes/neon-viola/chat.css b/custom-themes/neon-viola/chat.css new file mode 100644 index 0000000..c0f767e --- /dev/null +++ b/custom-themes/neon-viola/chat.css @@ -0,0 +1,12 @@ +/* Tema Neon Viola — pezzo "chat". + Accento viola sulla bubble di default (bubble-0) e sull'input chat. + (Le bubble custom hanno la loro grafica; qui tocchiamo solo l'accento base.) */ + +.chat-bubble.bubble-0 { + filter: drop-shadow(0 0 5px rgba(168, 85, 247, .8)); +} + +.nitro-chat-input-container, +.chat-input-container { + box-shadow: inset 0 0 0 1px #7c3aed !important; +} diff --git a/custom-themes/neon-viola/theme.json b/custom-themes/neon-viola/theme.json new file mode 100644 index 0000000..199fd09 --- /dev/null +++ b/custom-themes/neon-viola/theme.json @@ -0,0 +1,10 @@ +{ + "name": "Neon Viola", + "author": "infinityhotel", + "pieces": [ + { "id": "cards", "name": "Finestre / Card", "file": "cards.css" }, + { "id": "chat", "name": "Chat", "file": "chat.css" }, + { "id": "toolbar", "name": "Toolbar", "file": "toolbar.css" }, + { "id": "catalog", "name": "Catalogo", "file": "catalog.css" } + ] +} diff --git a/custom-themes/neon-viola/toolbar.css b/custom-themes/neon-viola/toolbar.css new file mode 100644 index 0000000..ed74f7f --- /dev/null +++ b/custom-themes/neon-viola/toolbar.css @@ -0,0 +1,9 @@ +/* Tema Neon Viola — pezzo "toolbar". + Best-effort: ricolora la barra strumenti in basso. Se i selettori non + matchano nella tua build, il pezzo non ha effetto (fallback sicuro). */ + +.nitro-toolbar, +[class*="toolbar-container"] { + background: linear-gradient(180deg, #2a0a4a 0%, #1a0730 100%) !important; + box-shadow: 0 -2px 10px rgba(124, 58, 237, .4) !important; +} diff --git a/src/api/index.ts b/src/api/index.ts index 424bb4f..ae86f5d 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -28,6 +28,7 @@ export * from './room'; export * from './room/events'; export * from './room/widgets'; export * from './soundboard'; +export * from './theme'; export * from './ui-settings'; export * from './user'; export * from './utils'; diff --git a/src/api/theme/ThemeManager.ts b/src/api/theme/ThemeManager.ts new file mode 100644 index 0000000..cbd9e80 --- /dev/null +++ b/src/api/theme/ThemeManager.ts @@ -0,0 +1,122 @@ +import { NitroLogger } from '@nitrots/nitro-renderer'; +import { GetConfigurationValue } from '../nitro'; + +// --------------------------------------------------------------------------- +// Custom theme ecosystem (graphics-only, runtime-loaded). +// +// A "theme" is a folder on the server (NOT bundled in the build) made of: +// /index.json -> { "themes": [ { id, name, author? } ] } +// //theme.json -> { name, pieces: [ { id, name, file } ] } +// //.css -> one CSS "piece" (cards, chat, catalog, ...) +// +// Each enabled piece is injected as a in . If a piece fails to +// load (404 / network) the link removes itself, so the UI falls back to the +// default look for that piece (per-piece fallback, never breaks the client). +// +// The base url is configurable via ui-config ("theme.base.url") so themes can +// live anywhere (and never need a client rebuild to add/change them). +// --------------------------------------------------------------------------- + +export interface ThemeInfo +{ + id: string; + name: string; + author?: string; +} + +export interface ThemePiece +{ + id: string; + name: string; + file: string; +} + +export interface ThemeManifest +{ + name: string; + pieces: ThemePiece[]; +} + +const LINK_ATTR = 'data-nitro-theme'; + +export const GetThemeBaseUrl = (): string => + GetConfigurationValue('theme.base.url', 'custom-themes').replace(/\/+$/, ''); + +export const FetchThemeIndex = async (): Promise => +{ + try + { + const response = await fetch(`${ GetThemeBaseUrl() }/index.json`, { cache: 'no-cache' }); + + if(!response.ok) return []; + + const data = await response.json(); + + return Array.isArray(data?.themes) ? data.themes.filter((t: any) => t && t.id) : []; + } + catch(error) + { + NitroLogger.warn('[ThemeManager] index.json non caricabile, nessun tema custom', error); + + return []; + } +}; + +export const FetchThemeManifest = async (themeId: string): Promise => +{ + if(!themeId) return null; + + try + { + const response = await fetch(`${ GetThemeBaseUrl() }/${ themeId }/theme.json`, { cache: 'no-cache' }); + + if(!response.ok) return null; + + const data = await response.json(); + + if(!data || !Array.isArray(data.pieces)) return null; + + return { + name: data.name ?? themeId, + pieces: data.pieces.filter((p: any) => p && p.id && p.file) + }; + } + catch(error) + { + NitroLogger.warn(`[ThemeManager] manifest non valido per tema "${ themeId }" -> fallback default`, error); + + return null; + } +}; + +export const ClearTheme = (): void => +{ + document.head.querySelectorAll(`link[${ LINK_ATTR }]`).forEach(node => node.remove()); +}; + +export const ApplyThemePieces = (themeId: string, pieces: ThemePiece[]): void => +{ + ClearTheme(); + + if(!themeId || !pieces || !pieces.length) return; + + const base = GetThemeBaseUrl(); + + for(const piece of pieces) + { + const link = document.createElement('link'); + + link.rel = 'stylesheet'; + link.setAttribute(LINK_ATTR, piece.id); + link.href = `${ base }/${ themeId }/${ piece.file }`; + + // Per-piece fallback: a broken piece removes itself, leaving the default. + link.onerror = () => + { + NitroLogger.warn(`[ThemeManager] pezzo tema rotto "${ themeId }/${ piece.file }" -> fallback default`); + link.remove(); + }; + + document.head.appendChild(link); + } +}; diff --git a/src/api/theme/index.ts b/src/api/theme/index.ts new file mode 100644 index 0000000..ee14d93 --- /dev/null +++ b/src/api/theme/index.ts @@ -0,0 +1 @@ +export * from './ThemeManager'; diff --git a/src/api/utils/LocalStorageKeys.ts b/src/api/utils/LocalStorageKeys.ts index 75ecfe8..c1ee611 100644 --- a/src/api/utils/LocalStorageKeys.ts +++ b/src/api/utils/LocalStorageKeys.ts @@ -5,4 +5,6 @@ export class LocalStorageKeys public static CHAT_WINDOW_ENABLED: string = 'chatWindowEnabled'; public static CHAT_TRANSLATION_SETTINGS: string = 'chatTranslationSettings'; public static CATALOG_CLASSIC_STYLE: string = 'catalogClassicStyle'; + public static THEME_ACTIVE: string = 'nitroThemeActive'; + public static THEME_PIECES: string = 'nitroThemePieces'; } diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index e155f05..d94bbc5 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -28,6 +28,7 @@ import { HousekeepingView } from './housekeeping/HousekeepingView'; import { RareValuesView } from './rare-values/RareValuesView'; import { FortuneWheelView } from './fortune-wheel/FortuneWheelView'; import { SoundboardView } from './soundboard/SoundboardView'; +import { ThemeApplier } from './theme/ThemeApplier'; import { RadioView } from './radio/RadioView'; import { InventoryView } from './inventory/InventoryView'; import { ModToolsView } from './mod-tools/ModToolsView'; @@ -134,6 +135,7 @@ export const MainView: FC<{}> = props => return ( <> +
{ landingViewVisible && diff --git a/src/components/theme/ThemeApplier.tsx b/src/components/theme/ThemeApplier.tsx new file mode 100644 index 0000000..1f02ed1 --- /dev/null +++ b/src/components/theme/ThemeApplier.tsx @@ -0,0 +1,12 @@ +import { FC } from 'react'; +import { useThemes } from '../../hooks'; + +// Mounted once at app level: subscribing to the shared theme store triggers the +// load + apply effects, so the saved/default custom theme is applied on boot +// and kept in sync when the user changes it from Settings. Renders nothing. +export const ThemeApplier: FC<{}> = () => +{ + useThemes(); + + return null; +}; diff --git a/src/components/user-settings/UserSettingsView.tsx b/src/components/user-settings/UserSettingsView.tsx index 999ac3b..77adc46 100644 --- a/src/components/user-settings/UserSettingsView.tsx +++ b/src/components/user-settings/UserSettingsView.tsx @@ -3,13 +3,15 @@ import { FC, useEffect, useState } from 'react'; import { FaUserCog, FaVolumeDown, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'; import { DispatchMainEvent, DispatchUiEvent, LocalizeText, SendMessageComposer } from '../../api'; import { NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common'; -import { useCatalogClassicStyle, useCatalogPlaceMultipleItems, useCatalogSkipPurchaseConfirmation, useChatWindow, useMessageEvent } from '../../hooks'; +import { useCatalogClassicStyle, useCatalogPlaceMultipleItems, useCatalogSkipPurchaseConfirmation, useChatWindow, useMessageEvent, useThemes } from '../../hooks'; import { classNames } from '../../layout'; export const UserSettingsView: FC<{}> = props => { const [ isVisible, setIsVisible ] = useState(false); + const [ activeTab, setActiveTab ] = useState<'general' | 'themes'>('general'); const [ userSettings, setUserSettings ] = useState(null); + const { themes, activeThemeId, manifest, activeEnabled, selectTheme, togglePiece } = useThemes(); const [ catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects ] = useCatalogPlaceMultipleItems(); const [ catalogSkipPurchaseConfirmation, setCatalogSkipPurchaseConfirmation ] = useCatalogSkipPurchaseConfirmation(); const [ chatWindowEnabled, setChatWindowEnabled ] = useChatWindow(); @@ -132,6 +134,11 @@ export const UserSettingsView: FC<{}> = props => processAction('close_view') } /> +
+ + +
+ { activeTab === 'general' && <>
processAction('oldchat', event.target.checked) } /> @@ -207,6 +214,35 @@ export const UserSettingsView: FC<{}> = props =>
+ } + { activeTab === 'themes' &&
+
+ Tema custom + +
+ { activeThemeId && manifest && manifest.pieces.length > 0 && +
+ Pezzi attivi + { manifest.pieces.map(piece => ( +
+ togglePiece(piece.id) } /> + { piece.name } +
+ )) } +
} + { activeThemeId && !manifest && + Tema non valido o non raggiungibile — uso il default. } + { !themes.length && + Nessun tema disponibile. Aggiungi una cartella in custom-themes/ sul server. } +
} ); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 8a85f2a..46a0287 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -24,6 +24,7 @@ export * from './rooms/widgets'; export * from './rooms/widgets/furniture'; export * from './session'; export * from './soundboard/useSoundboard'; +export * from './theme'; export * from './translation'; export * from './useLocalStorage'; export * from './useSharedVisibility'; diff --git a/src/hooks/theme/index.ts b/src/hooks/theme/index.ts new file mode 100644 index 0000000..effac86 --- /dev/null +++ b/src/hooks/theme/index.ts @@ -0,0 +1 @@ +export * from './useThemes'; diff --git a/src/hooks/theme/useThemes.ts b/src/hooks/theme/useThemes.ts new file mode 100644 index 0000000..c2ef444 --- /dev/null +++ b/src/hooks/theme/useThemes.ts @@ -0,0 +1,108 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useBetween } from 'use-between'; +import { ApplyThemePieces, ClearTheme, FetchThemeIndex, FetchThemeManifest, GetConfigurationValue, LocalStorageKeys, ThemeInfo, ThemeManifest } from '../../api'; +import { useLocalStorage } from '../useLocalStorage'; + +// Per-user custom theme selection. +// - activeThemeId: '' = default (no custom theme). Default for new users comes +// from ui-config `theme.default` so the admin can set a hotel-wide default +// (like catalog.classic.style), while each user can override from Settings. +// - enabledPieces[themeId]: which graphic pieces of that theme are active +// (checkboxes). If absent, defaults to ui-config `theme.default.pieces` +// (when on the default theme) or ALL pieces. +const useThemesState = () => +{ + const [ activeThemeId, setActiveThemeId ] = useLocalStorage(LocalStorageKeys.THEME_ACTIVE, GetConfigurationValue('theme.default', '')); + const [ enabledPieces, setEnabledPieces ] = useLocalStorage>(LocalStorageKeys.THEME_PIECES, {}); + const [ themes, setThemes ] = useState([]); + const [ manifest, setManifest ] = useState(null); + const [ loaded, setLoaded ] = useState(false); + + // Load the theme index once. + useEffect(() => + { + let alive = true; + + FetchThemeIndex().then(list => + { + if(alive) setThemes(list); + }).finally(() => + { + if(alive) setLoaded(true); + }); + + return () => { alive = false; }; + }, []); + + // Load the manifest whenever the active theme changes. + useEffect(() => + { + let alive = true; + + if(!activeThemeId) + { + setManifest(null); + ClearTheme(); + return; + } + + FetchThemeManifest(activeThemeId).then(m => + { + if(!alive) return; + + setManifest(m); + + if(!m) ClearTheme(); // broken/missing manifest -> full fallback to default + }); + + return () => { alive = false; }; + }, [ activeThemeId ]); + + // Which pieces are enabled for the current theme. + const activeEnabled = useMemo(() => + { + if(!manifest) return [] as string[]; + + const stored = enabledPieces[activeThemeId]; + + if(stored) return stored; + + const fromConfig = GetConfigurationValue('theme.default.pieces', null); + + // Default: config list (if this is the default theme) else every piece on. + if(fromConfig && activeThemeId === GetConfigurationValue('theme.default', '')) return fromConfig; + + return manifest.pieces.map(p => p.id); + }, [ manifest, enabledPieces, activeThemeId ]); + + // Apply (inject/remove s) whenever theme or enabled pieces change. + useEffect(() => + { + if(!activeThemeId || !manifest) + { + ClearTheme(); + return; + } + + ApplyThemePieces(activeThemeId, manifest.pieces.filter(p => activeEnabled.includes(p.id))); + }, [ activeThemeId, manifest, activeEnabled ]); + + const selectTheme = (id: string) => setActiveThemeId(id || ''); + + const togglePiece = (pieceId: string) => + { + if(!activeThemeId || !manifest) return; + + setEnabledPieces(prev => + { + const current = prev[activeThemeId] ?? manifest.pieces.map(p => p.id); + const next = current.includes(pieceId) ? current.filter(x => x !== pieceId) : [ ...current, pieceId ]; + + return { ...prev, [activeThemeId]: next }; + }); + }; + + return { themes, activeThemeId, manifest, activeEnabled, loaded, selectTheme, togglePiece }; +}; + +export const useThemes = () => useBetween(useThemesState);