Merge pull request #179 from medievalshell/Dev
feat(chat): 39 new chat bubbles (253-291) + soundboard paginator + feat(custom theme): created infrastucture for custom themes css and icons
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 695 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 806 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 551 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 880 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 381 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 891 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 798 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 645 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 632 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 656 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 534 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 625 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 661 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 325 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 326 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 455 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 533 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 326 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 408 B |
|
After Width: | Height: | Size: 112 B |
|
After Width: | Height: | Size: 825 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 831 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 408 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 906 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 573 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 879 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 548 B |
|
After Width: | Height: | Size: 113 B |
@@ -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 &&
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ export const SoundboardView: FC<{}> = () =>
|
|||||||
const [ isVisible, setIsVisible ] = useState(false);
|
const [ isVisible, setIsVisible ] = useState(false);
|
||||||
const { enabled, sounds, lastPlayed, play } = useSoundboard();
|
const { enabled, sounds, lastPlayed, play } = useSoundboard();
|
||||||
|
|
||||||
|
const PAGE_SIZE = 9;
|
||||||
|
const [ page, setPage ] = useState(0);
|
||||||
|
const totalPages = Math.max(1, Math.ceil(sounds.length / PAGE_SIZE));
|
||||||
|
|
||||||
|
// Clamp the page if the sound list shrinks (or on first load).
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
if(page > (totalPages - 1)) setPage(0);
|
||||||
|
}, [ totalPages, page ]);
|
||||||
|
|
||||||
|
const pageSounds = sounds.slice(page * PAGE_SIZE, (page * PAGE_SIZE) + PAGE_SIZE);
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
const linkTracker: ILinkEventTracker = {
|
const linkTracker: ILinkEventTracker = {
|
||||||
@@ -49,18 +61,32 @@ export const SoundboardView: FC<{}> = () =>
|
|||||||
{ !sounds.length &&
|
{ !sounds.length &&
|
||||||
<Text small className="text-black/50">{ LocalizeText('soundboard.empty') }</Text> }
|
<Text small className="text-black/50">{ LocalizeText('soundboard.empty') }</Text> }
|
||||||
{ !!sounds.length &&
|
{ !!sounds.length &&
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<>
|
||||||
{ sounds.map(sound => (
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<button
|
{ pageSounds.map(sound => (
|
||||||
key={ sound.id }
|
<button
|
||||||
onClick={ () => play(sound) }
|
key={ sound.id }
|
||||||
title={ sound.name }
|
onClick={ () => play(sound) }
|
||||||
className="flex h-20 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg bg-[#3a7bb5] px-2 text-white shadow transition-transform hover:bg-[#336ea3] active:scale-95">
|
title={ sound.name }
|
||||||
<span className="text-2xl leading-none">🔊</span>
|
className="flex h-20 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg bg-[#3a7bb5] px-2 text-white shadow transition-transform hover:bg-[#336ea3] active:scale-95">
|
||||||
<span className="line-clamp-2 text-center text-[11px] font-bold leading-tight">{ sound.name }</span>
|
<span className="text-2xl leading-none">🔊</span>
|
||||||
</button>
|
<span className="line-clamp-2 text-center text-[11px] font-bold leading-tight">{ sound.name }</span>
|
||||||
)) }
|
</button>
|
||||||
</div> }
|
)) }
|
||||||
|
</div>
|
||||||
|
{ totalPages > 1 &&
|
||||||
|
<Flex alignItems="center" justifyContent="center" gap={ 2 } className="select-none pt-1">
|
||||||
|
<button
|
||||||
|
disabled={ page === 0 }
|
||||||
|
onClick={ () => setPage(p => Math.max(0, p - 1)) }
|
||||||
|
className="cursor-pointer rounded bg-[#3a7bb5] px-3 py-1 text-sm font-bold text-white hover:bg-[#336ea3] disabled:cursor-default disabled:opacity-40">◀</button>
|
||||||
|
<Text small bold className="min-w-[44px] text-center text-[#2f6f95]">{ page + 1 } / { totalPages }</Text>
|
||||||
|
<button
|
||||||
|
disabled={ page >= (totalPages - 1) }
|
||||||
|
onClick={ () => setPage(p => Math.min(totalPages - 1, p + 1)) }
|
||||||
|
className="cursor-pointer rounded bg-[#3a7bb5] px-3 py-1 text-sm font-bold text-white hover:bg-[#336ea3] disabled:cursor-default disabled:opacity-40">▶</button>
|
||||||
|
</Flex> }
|
||||||
|
</> }
|
||||||
{ lastPlayed &&
|
{ lastPlayed &&
|
||||||
<Flex alignItems="center" justifyContent="center" className="pt-1">
|
<Flex alignItems="center" justifyContent="center" className="pt-1">
|
||||||
<Text small className="text-[#2f6f95]">
|
<Text small className="text-[#2f6f95]">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -807,6 +807,435 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.bubble-253 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_253.png');
|
||||||
|
border-image-slice: 16 22 15 27 fill;
|
||||||
|
border-image-width: 16px 22px 15px 27px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_253_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-254 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_254.png');
|
||||||
|
border-image-slice: 7 28 15 25 fill;
|
||||||
|
border-image-width: 7px 28px 15px 25px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_254_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-255 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_255.png');
|
||||||
|
border-image-slice: 12 19 22 30 fill;
|
||||||
|
border-image-width: 12px 19px 22px 30px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_255_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-256 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_256.png');
|
||||||
|
border-image-slice: 24 18 10 31 fill;
|
||||||
|
border-image-width: 24px 18px 10px 31px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_256_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-257 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_257.png');
|
||||||
|
border-image-slice: 6 17 19 36 fill;
|
||||||
|
border-image-width: 6px 17px 19px 36px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_257_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-258 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_258.png');
|
||||||
|
border-image-slice: 22 27 10 27 fill;
|
||||||
|
border-image-width: 22px 27px 10px 27px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_258_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-259 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_259.png');
|
||||||
|
border-image-slice: 21 27 18 37 fill;
|
||||||
|
border-image-width: 21px 27px 18px 37px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_259_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-260 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_260.png');
|
||||||
|
border-image-slice: 6 22 16 27 fill;
|
||||||
|
border-image-width: 6px 22px 16px 27px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_260_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-261 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_261.png');
|
||||||
|
border-image-slice: 18 27 5 22 fill;
|
||||||
|
border-image-width: 18px 27px 5px 22px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_261_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-262 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_262.png');
|
||||||
|
border-image-slice: 33 31 11 34 fill;
|
||||||
|
border-image-width: 33px 31px 11px 34px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_262_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-263 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_263.png');
|
||||||
|
border-image-slice: 15 19 10 32 fill;
|
||||||
|
border-image-width: 15px 19px 10px 32px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_263_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-264 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_264.png');
|
||||||
|
border-image-slice: 18 24 16 25 fill;
|
||||||
|
border-image-width: 18px 24px 16px 25px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_264_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-265 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_265.png');
|
||||||
|
border-image-slice: 41 40 17 18 fill;
|
||||||
|
border-image-width: 41px 40px 17px 18px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_265_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-266 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_266.png');
|
||||||
|
border-image-slice: 13 34 22 27 fill;
|
||||||
|
border-image-width: 13px 34px 22px 27px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_266_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-267 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_267.png');
|
||||||
|
border-image-slice: 17 30 22 25 fill;
|
||||||
|
border-image-width: 17px 30px 22px 25px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_267_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-268 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_268.png');
|
||||||
|
border-image-slice: 7 30 21 24 fill;
|
||||||
|
border-image-width: 7px 30px 21px 24px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_268_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-269 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_269.png');
|
||||||
|
border-image-slice: 10 23 25 35 fill;
|
||||||
|
border-image-width: 10px 23px 25px 35px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_269_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-270 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_270.png');
|
||||||
|
border-image-slice: 13 30 14 26 fill;
|
||||||
|
border-image-width: 13px 30px 14px 26px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_270_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-271 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_271.png');
|
||||||
|
border-image-slice: 23 23 9 35 fill;
|
||||||
|
border-image-width: 23px 23px 9px 35px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_271_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-272 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_272.png');
|
||||||
|
border-image-slice: 9 31 24 25 fill;
|
||||||
|
border-image-width: 9px 31px 24px 25px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_272_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-273 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_273.png');
|
||||||
|
border-image-slice: 11 16 25 37 fill;
|
||||||
|
border-image-width: 11px 16px 25px 37px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_273_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-274 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_274.png');
|
||||||
|
border-image-slice: 7 22 19 27 fill;
|
||||||
|
border-image-width: 7px 22px 19px 27px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_274_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-275 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_275.png');
|
||||||
|
border-image-slice: 8 23 14 26 fill;
|
||||||
|
border-image-width: 8px 23px 14px 26px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_275_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-276 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_276.png');
|
||||||
|
border-image-slice: 12 40 17 17 fill;
|
||||||
|
border-image-width: 12px 40px 17px 17px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_276_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-277 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_277.png');
|
||||||
|
border-image-slice: 6 39 18 17 fill;
|
||||||
|
border-image-width: 6px 39px 18px 17px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_277_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-278 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_278.png');
|
||||||
|
border-image-slice: 16 38 6 19 fill;
|
||||||
|
border-image-width: 16px 38px 6px 19px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_278_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-279 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_279.png');
|
||||||
|
border-image-slice: 6 26 16 23 fill;
|
||||||
|
border-image-width: 6px 26px 16px 23px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_279_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-280 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_280.png');
|
||||||
|
border-image-slice: 23 29 6 15 fill;
|
||||||
|
border-image-width: 23px 29px 6px 15px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_280_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-281 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_281.png');
|
||||||
|
border-image-slice: 18 42 9 18 fill;
|
||||||
|
border-image-width: 18px 42px 9px 18px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_281_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-282 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_282.png');
|
||||||
|
border-image-slice: 18 42 9 18 fill;
|
||||||
|
border-image-width: 18px 42px 9px 18px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_282_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-283 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_283.png');
|
||||||
|
border-image-slice: 17 26 13 31 fill;
|
||||||
|
border-image-width: 17px 26px 13px 31px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_283_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-284 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_284.png');
|
||||||
|
border-image-slice: 9 26 23 26 fill;
|
||||||
|
border-image-width: 9px 26px 23px 26px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_284_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-285 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_285.png');
|
||||||
|
border-image-slice: 16 35 15 15 fill;
|
||||||
|
border-image-width: 16px 35px 15px 15px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_285_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-286 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_286.png');
|
||||||
|
border-image-slice: 18 22 4 23 fill;
|
||||||
|
border-image-width: 18px 22px 4px 23px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_286_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-287 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_287.png');
|
||||||
|
border-image-slice: 6 22 18 26 fill;
|
||||||
|
border-image-width: 6px 22px 18px 26px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_287_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-288 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_288.png');
|
||||||
|
border-image-slice: 18 31 11 24 fill;
|
||||||
|
border-image-width: 18px 31px 11px 24px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_288_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-289 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_289.png');
|
||||||
|
border-image-slice: 7 54 17 24 fill;
|
||||||
|
border-image-width: 7px 54px 17px 24px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_289_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-290 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_290.png');
|
||||||
|
border-image-slice: 18 24 14 29 fill;
|
||||||
|
border-image-width: 18px 24px 14px 29px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_290_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-291 {
|
||||||
|
border-image-source: url('@/assets/images/chat/chatbubbles/bubble_291.png');
|
||||||
|
border-image-slice: 9 26 11 35 fill;
|
||||||
|
border-image-width: 9px 26px 11px 35px;
|
||||||
|
border-image-repeat: stretch stretch;
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
background: url('@/assets/images/chat/chatbubbles/bubble_291_pointer.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.bubble-200,
|
&.bubble-200,
|
||||||
&.bubble-201,
|
&.bubble-201,
|
||||||
&.bubble-202,
|
&.bubble-202,
|
||||||
@@ -1810,4 +2239,160 @@
|
|||||||
background: center / contain no-repeat url('@/assets/images/chat/chatbubbles/bubble_252_extra.png');
|
background: center / contain no-repeat url('@/assets/images/chat/chatbubbles/bubble_252_extra.png');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
&.bubble-253 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_253.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-254 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_254.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-255 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_255.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-256 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_256.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-257 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_257.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-258 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_258.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-259 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_259.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-260 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_260.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-261 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_261.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-262 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_262.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-263 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_263.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-264 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_264.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-265 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_265.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-266 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_266.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-267 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_267.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-268 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_268.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-269 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_269.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-270 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_270.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-271 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_271.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-272 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_272.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-273 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_273.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-274 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_274.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-275 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_275.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-276 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_276.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-277 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_277.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-278 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_278.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-279 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_279.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-280 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_280.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-281 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_281.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-282 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_282.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-283 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_283.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-284 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_284.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-285 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_285.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-286 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_286.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-287 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_287.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-288 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_288.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-289 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_289.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-290 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_290.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bubble-291 {
|
||||||
|
background-image: url('@/assets/images/chat/chatbubbles/bubble_291.png');
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||