mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 06:56:20 +00:00
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:
@@ -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/
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"themes": [
|
||||||
|
{ "id": "neon-viola", "name": "Neon Viola", "author": "infinityhotel" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './ThemeManager';
|
||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './useThemes';
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user