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 <link> + 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)
This commit is contained in:
medievalshell
2026-05-31 14:39:59 +02:00
parent cbd63220bd
commit 8097344561
18 changed files with 400 additions and 1 deletions
+3
View File
@@ -44,3 +44,6 @@ Thumbs.db
# the dev server takes minutes to start with 100k+ files under public/. # the dev server takes minutes to start with 100k+ files under public/.
/public/nitro-assets /public/nitro-assets
/public/swf /public/swf
# Temi custom locali di test (i temi veri stanno sul server, mai su git)
public/custom-themes/
+40
View File
@@ -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?" } ] }
<id>/
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 `<id>/`.
2. Modifica `theme.json` (nome + elenco pezzi).
3. Scrivi i CSS dei pezzi (override con `!important`, caricati dopo il base).
4. Aggiungi `{ "id": "<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.
+5
View File
@@ -0,0 +1,5 @@
{
"themes": [
{ "id": "neon-viola", "name": "Neon Viola", "author": "infinityhotel" }
]
}
+24
View File
@@ -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;
}
+10
View File
@@ -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;
}
+12
View File
@@ -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;
}
+10
View File
@@ -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" }
]
}
+9
View File
@@ -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;
}
+1
View File
@@ -28,6 +28,7 @@ export * from './room';
export * from './room/events'; export * from './room/events';
export * from './room/widgets'; export * from './room/widgets';
export * from './soundboard'; export * from './soundboard';
export * from './theme';
export * from './ui-settings'; export * from './ui-settings';
export * from './user'; export * from './user';
export * from './utils'; export * from './utils';
+122
View File
@@ -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:
// <base>/index.json -> { "themes": [ { id, name, author? } ] }
// <base>/<id>/theme.json -> { name, pieces: [ { id, name, file } ] }
// <base>/<id>/<file>.css -> one CSS "piece" (cards, chat, catalog, ...)
//
// Each enabled piece is injected as a <link> in <head>. 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<string>('theme.base.url', 'custom-themes').replace(/\/+$/, '');
export const FetchThemeIndex = async (): Promise<ThemeInfo[]> =>
{
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<ThemeManifest> =>
{
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);
}
};
+1
View File
@@ -0,0 +1 @@
export * from './ThemeManager';
+2
View File
@@ -5,4 +5,6 @@ export class LocalStorageKeys
public static CHAT_WINDOW_ENABLED: string = 'chatWindowEnabled'; public static CHAT_WINDOW_ENABLED: string = 'chatWindowEnabled';
public static CHAT_TRANSLATION_SETTINGS: string = 'chatTranslationSettings'; public static CHAT_TRANSLATION_SETTINGS: string = 'chatTranslationSettings';
public static CATALOG_CLASSIC_STYLE: string = 'catalogClassicStyle'; public static CATALOG_CLASSIC_STYLE: string = 'catalogClassicStyle';
public static THEME_ACTIVE: string = 'nitroThemeActive';
public static THEME_PIECES: string = 'nitroThemePieces';
} }
+2
View File
@@ -28,6 +28,7 @@ import { HousekeepingView } from './housekeeping/HousekeepingView';
import { RareValuesView } from './rare-values/RareValuesView'; import { RareValuesView } from './rare-values/RareValuesView';
import { FortuneWheelView } from './fortune-wheel/FortuneWheelView'; import { FortuneWheelView } from './fortune-wheel/FortuneWheelView';
import { SoundboardView } from './soundboard/SoundboardView'; import { SoundboardView } from './soundboard/SoundboardView';
import { ThemeApplier } from './theme/ThemeApplier';
import { RadioView } from './radio/RadioView'; import { RadioView } from './radio/RadioView';
import { InventoryView } from './inventory/InventoryView'; import { InventoryView } from './inventory/InventoryView';
import { ModToolsView } from './mod-tools/ModToolsView'; import { ModToolsView } from './mod-tools/ModToolsView';
@@ -134,6 +135,7 @@ export const MainView: FC<{}> = props =>
return ( return (
<> <>
<ThemeApplier />
<div className="hidden" data-localization-version={ localizationVersion } /> <div className="hidden" data-localization-version={ localizationVersion } />
<AnimatePresence> <AnimatePresence>
{ landingViewVisible && { landingViewVisible &&
+12
View File
@@ -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;
};
@@ -3,13 +3,15 @@ import { FC, useEffect, useState } from 'react';
import { FaUserCog, FaVolumeDown, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'; import { FaUserCog, FaVolumeDown, FaVolumeMute, FaVolumeUp } from 'react-icons/fa';
import { DispatchMainEvent, DispatchUiEvent, LocalizeText, SendMessageComposer } from '../../api'; import { DispatchMainEvent, DispatchUiEvent, LocalizeText, SendMessageComposer } from '../../api';
import { NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common'; 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'; import { classNames } from '../../layout';
export const UserSettingsView: FC<{}> = props => export const UserSettingsView: FC<{}> = props =>
{ {
const [ isVisible, setIsVisible ] = useState(false); const [ isVisible, setIsVisible ] = useState(false);
const [ activeTab, setActiveTab ] = useState<'general' | 'themes'>('general');
const [ userSettings, setUserSettings ] = useState<NitroSettingsEvent>(null); const [ userSettings, setUserSettings ] = useState<NitroSettingsEvent>(null);
const { themes, activeThemeId, manifest, activeEnabled, selectTheme, togglePiece } = useThemes();
const [ catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects ] = useCatalogPlaceMultipleItems(); const [ catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects ] = useCatalogPlaceMultipleItems();
const [ catalogSkipPurchaseConfirmation, setCatalogSkipPurchaseConfirmation ] = useCatalogSkipPurchaseConfirmation(); const [ catalogSkipPurchaseConfirmation, setCatalogSkipPurchaseConfirmation ] = useCatalogSkipPurchaseConfirmation();
const [ chatWindowEnabled, setChatWindowEnabled ] = useChatWindow(); const [ chatWindowEnabled, setChatWindowEnabled ] = useChatWindow();
@@ -132,6 +134,11 @@ export const UserSettingsView: FC<{}> = props =>
<NitroCardView className="user-settings-window" theme="primary-slim" uniqueKey="user-settings"> <NitroCardView className="user-settings-window" theme="primary-slim" uniqueKey="user-settings">
<NitroCardHeaderView headerText={ LocalizeText('widget.memenu.settings.title') } onCloseClick={ event => processAction('close_view') } /> <NitroCardHeaderView headerText={ LocalizeText('widget.memenu.settings.title') } onCloseClick={ event => processAction('close_view') } />
<NitroCardContentView className="text-black"> <NitroCardContentView className="text-black">
<div className="flex items-center gap-1 mb-2 border-b border-black/10 pb-1">
<button type="button" onClick={ () => setActiveTab('general') } className={ classNames('px-3 py-1 rounded text-xs font-bold cursor-pointer transition-colors', activeTab === 'general' ? 'bg-[#1e7295] text-white' : 'bg-black/5 hover:bg-black/10') }>Generale</button>
<button type="button" onClick={ () => setActiveTab('themes') } className={ classNames('px-3 py-1 rounded text-xs font-bold cursor-pointer transition-colors', activeTab === 'themes' ? 'bg-[#1e7295] text-white' : 'bg-black/5 hover:bg-black/10') }>Temi</button>
</div>
{ activeTab === 'general' && <>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<input checked={ userSettings.oldChat } className="form-check-input" type="checkbox" onChange={ event => processAction('oldchat', event.target.checked) } /> <input checked={ userSettings.oldChat } className="form-check-input" type="checkbox" onChange={ event => processAction('oldchat', event.target.checked) } />
@@ -207,6 +214,35 @@ export const UserSettingsView: FC<{}> = props =>
<span className="text-black/30 group-hover:text-[#1e7295] text-[10px]"></span> <span className="text-black/30 group-hover:text-[#1e7295] text-[10px]"></span>
</button> </button>
</div> </div>
</> }
{ activeTab === 'themes' && <div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<Text bold>Tema custom</Text>
<select
value={ activeThemeId }
onChange={ event => selectTheme(event.target.value) }
className="form-select rounded border border-black/15 px-2 py-1 text-sm">
<option value="">Default (nessun tema)</option>
{ themes.map(theme => (
<option key={ theme.id } value={ theme.id }>{ theme.name }{ theme.author ? `${ theme.author }` : '' }</option>
)) }
</select>
</div>
{ activeThemeId && manifest && manifest.pieces.length > 0 &&
<div className="flex flex-col gap-1 pt-1 border-t border-black/10">
<Text bold>Pezzi attivi</Text>
{ manifest.pieces.map(piece => (
<div key={ piece.id } className="flex items-center gap-1">
<input className="form-check-input" type="checkbox" checked={ activeEnabled.includes(piece.id) } onChange={ () => togglePiece(piece.id) } />
<Text>{ piece.name }</Text>
</div>
)) }
</div> }
{ activeThemeId && !manifest &&
<Text small className="text-black/60">Tema non valido o non raggiungibile uso il default.</Text> }
{ !themes.length &&
<Text small className="text-black/60">Nessun tema disponibile. Aggiungi una cartella in custom-themes/ sul server.</Text> }
</div> }
</NitroCardContentView> </NitroCardContentView>
</NitroCardView> </NitroCardView>
); );
+1
View File
@@ -24,6 +24,7 @@ export * from './rooms/widgets';
export * from './rooms/widgets/furniture'; export * from './rooms/widgets/furniture';
export * from './session'; export * from './session';
export * from './soundboard/useSoundboard'; export * from './soundboard/useSoundboard';
export * from './theme';
export * from './translation'; export * from './translation';
export * from './useLocalStorage'; export * from './useLocalStorage';
export * from './useSharedVisibility'; export * from './useSharedVisibility';
+1
View File
@@ -0,0 +1 @@
export * from './useThemes';
+108
View File
@@ -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<string>(LocalStorageKeys.THEME_ACTIVE, GetConfigurationValue<string>('theme.default', ''));
const [ enabledPieces, setEnabledPieces ] = useLocalStorage<Record<string, string[]>>(LocalStorageKeys.THEME_PIECES, {});
const [ themes, setThemes ] = useState<ThemeInfo[]>([]);
const [ manifest, setManifest ] = useState<ThemeManifest>(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<string[]>('theme.default.pieces', null);
// Default: config list (if this is the default theme) else every piece on.
if(fromConfig && activeThemeId === GetConfigurationValue<string>('theme.default', '')) return fromConfig;
return manifest.pieces.map(p => p.id);
}, [ manifest, enabledPieces, activeThemeId ]);
// Apply (inject/remove <link>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);