mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 06:56:20 +00:00
feat(loading): redesigned loader with progress bar, task labels, configurable assets
Loading screen overhaul:
- LoadingView: Nitro V3 logo flush top-left, loading.gif at viewport
centre, large progress bar (max 900px / 90vw, h-8, gradient + glow)
anchored bottom-centre with the percentage rendered inside the bar in
Poppins, plus a friendly stage label underneath. Logo + background +
progress bar colour overridable via renderer-config keys
(loading.logo.url, loading.background, loading.progress.color).
- App.tsx: wired a real loadingProgress (0->100) + loadingTask driven by
the boot pipeline: config init (10), renderer (20), per-warmup-task
bumps for AssetManager/Localization/AvatarRender/SoundManager (25->70),
session managers (78/85/92), Communication (98), ready (100). Each bump
carries a task label looked up via a new taskLabel(key, fallback)
helper so the Italian baseline ("Sto caricando il guardaroba",
"Connessione al server", ...) can be translated by editing
renderer-config; fallback keeps current strings if the key is missing.
- AvatarEffectsView: replace raw fetch(url).json() with
loadGamedata(url) so the effectmap root manifest (JSON5 with
// comments) parses correctly and supports the core/custom/seasonal
tier merge.
- fallbackToLogin: respect login.screen.enabled=false. When login is
disabled (SSO-only deployments), init failures now route to
showSessionExpired() (home + diagnostic) instead of rendering an empty
LoginView placeholder.
- scripts/write-asset-loader.mjs: the pre-React shell rendered into
#root before the JS bundle takes over was a light-blue login skeleton
(linear gradient + two grey rectangles) producing a visible flash
before the real loader appeared. Replaced with the same
radial-gradient the LoadingView paints — the handoff is now invisible.
- renderer-config.example: document the 13 loader keys so operators can
copy & translate.
This commit is contained in:
@@ -57,7 +57,10 @@
|
|||||||
const renderShell = () => {
|
const renderShell = () => {
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
if(!root || root.firstChild) return;
|
if(!root || root.firstChild) return;
|
||||||
root.innerHTML = '<div style="position:fixed;inset:0;background:linear-gradient(180deg,#6eadc8 0%,#78b7cf 45%,#8ec4d7 100%);overflow:hidden;z-index:1"><div style="position:absolute;left:0;top:0;width:220px;height:220px;background:linear-gradient(135deg,rgba(255,255,255,.18),rgba(255,255,255,0));clip-path:polygon(0 0,100% 0,0 100%)"></div><div style="position:absolute;right:0;bottom:0;width:32vw;max-width:420px;height:100%;background:linear-gradient(270deg,rgba(255,255,255,.16),rgba(255,255,255,0))"></div><div style="position:absolute;top:50%;right:8vw;transform:translateY(-50%);display:flex;flex-direction:column;gap:18px;width:260px"><div style="height:86px;background:#a2bfd1;border:2px solid #3f6a85;border-radius:8px;box-shadow:inset 0 2px rgba(255,255,255,.35),0 4px 6px rgba(0,0,0,.25)"></div><div style="height:190px;background:#a2bfd1;border:2px solid #3f6a85;border-radius:8px;box-shadow:inset 0 2px rgba(255,255,255,.35),0 4px 6px rgba(0,0,0,.25)"></div></div></div>';
|
// Match the React LoadingView background so the pre-React shell paints
|
||||||
|
// the same gradient — no light-blue login-skeleton flash before the
|
||||||
|
// loader takes over.
|
||||||
|
root.innerHTML = '<div style="position:fixed;inset:0;background:radial-gradient(#1d1a24,#003a6b);overflow:hidden;z-index:1"></div>';
|
||||||
};
|
};
|
||||||
|
|
||||||
const decodeAsset = (bytes) => {
|
const decodeAsset = (bytes) => {
|
||||||
|
|||||||
@@ -48,6 +48,26 @@
|
|||||||
"timezone.settings": "Europe/Amsterdam",
|
"timezone.settings": "Europe/Amsterdam",
|
||||||
"youtube.publish.disabled": false,
|
"youtube.publish.disabled": false,
|
||||||
"user.badges.group.slot.enabled": true,
|
"user.badges.group.slot.enabled": true,
|
||||||
|
|
||||||
|
"_comment_loading_screen": "Schermata di caricamento — sostituibili per traduzione/branding. Logo, sfondo e colore della barra usano lo standard CSS (URL, gradient, colore esadecimale). Le label compaiono sotto la barra di progresso man mano che ogni fase del boot completa.",
|
||||||
|
"loading.logo.url": "",
|
||||||
|
"loading.background": "",
|
||||||
|
"loading.progress.color": "linear-gradient(90deg,#4f8cff,#2563eb)",
|
||||||
|
"loading.task.boot": "Avvio in corso...",
|
||||||
|
"loading.task.session": "Verifica sessione",
|
||||||
|
"loading.task.renderer": "Inizializzazione renderer",
|
||||||
|
"loading.task.warmup": "Caricamento contenuti...",
|
||||||
|
"loading.task.assets": "Sto caricando gli asset di gioco",
|
||||||
|
"loading.task.localization": "Sto caricando le traduzioni",
|
||||||
|
"loading.task.avatar": "Sto caricando il guardaroba",
|
||||||
|
"loading.task.sounds": "Sto caricando i suoni",
|
||||||
|
"loading.task.startsession": "Avvio sessione",
|
||||||
|
"loading.task.userdata": "Caricamento dati utente",
|
||||||
|
"loading.task.rooms": "Caricamento stanze",
|
||||||
|
"loading.task.engine": "Caricamento engine grafico",
|
||||||
|
"loading.task.connect": "Connessione al server",
|
||||||
|
"loading.task.ready": "Pronto!",
|
||||||
|
|
||||||
"login.screen.enabled": true,
|
"login.screen.enabled": true,
|
||||||
"login.endpoint": "${api.url}/api/auth/login",
|
"login.endpoint": "${api.url}/api/auth/login",
|
||||||
"login.register.endpoint": "${api.url}/api/auth/register",
|
"login.register.endpoint": "${api.url}/api/auth/register",
|
||||||
|
|||||||
@@ -228,7 +228,10 @@ const ASSET_LOADER_JS = `(() => {
|
|||||||
const renderShell = () => {
|
const renderShell = () => {
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
if(!root || root.firstChild) return;
|
if(!root || root.firstChild) return;
|
||||||
root.innerHTML = '<div style="position:fixed;inset:0;background:linear-gradient(180deg,#6eadc8 0%,#78b7cf 45%,#8ec4d7 100%);overflow:hidden;z-index:1"><div style="position:absolute;left:0;top:0;width:220px;height:220px;background:linear-gradient(135deg,rgba(255,255,255,.18),rgba(255,255,255,0));clip-path:polygon(0 0,100% 0,0 100%)"></div><div style="position:absolute;right:0;bottom:0;width:32vw;max-width:420px;height:100%;background:linear-gradient(270deg,rgba(255,255,255,.16),rgba(255,255,255,0))"></div><div style="position:absolute;top:50%;right:8vw;transform:translateY(-50%);display:flex;flex-direction:column;gap:18px;width:260px"><div style="height:86px;background:#a2bfd1;border:2px solid #3f6a85;border-radius:8px;box-shadow:inset 0 2px rgba(255,255,255,.35),0 4px 6px rgba(0,0,0,.25)"></div><div style="height:190px;background:#a2bfd1;border:2px solid #3f6a85;border-radius:8px;box-shadow:inset 0 2px rgba(255,255,255,.35),0 4px 6px rgba(0,0,0,.25)"></div></div></div>';
|
// Match the React LoadingView background so the pre-React shell paints
|
||||||
|
// the same gradient — no light-blue login-skeleton flash before the
|
||||||
|
// loader takes over.
|
||||||
|
root.innerHTML = '<div style="position:fixed;inset:0;background:radial-gradient(#1d1a24,#003a6b);overflow:hidden;z-index:1"></div>';
|
||||||
};
|
};
|
||||||
|
|
||||||
const decodeAsset = (bytes) => {
|
const decodeAsset = (bytes) => {
|
||||||
|
|||||||
+88
-25
@@ -72,6 +72,29 @@ export const App: FC<{}> = props =>
|
|||||||
const [ showLogin, setShowLogin ] = useState(false);
|
const [ showLogin, setShowLogin ] = useState(false);
|
||||||
const [ isEnteringHotel, setIsEnteringHotel ] = useState(() => !!window.NitroConfig?.['sso.ticket'] || hasRememberLogin());
|
const [ isEnteringHotel, setIsEnteringHotel ] = useState(() => !!window.NitroConfig?.['sso.ticket'] || hasRememberLogin());
|
||||||
const [ prepareTrigger, setPrepareTrigger ] = useState(0);
|
const [ prepareTrigger, setPrepareTrigger ] = useState(0);
|
||||||
|
const [ loadingProgress, setLoadingProgress ] = useState(0);
|
||||||
|
const [ loadingTask, setLoadingTask ] = useState('');
|
||||||
|
// Look up a loader-stage label from renderer-config so the strings the user
|
||||||
|
// sees during the boot ("Sto caricando il guardaroba", "Connessione…") can
|
||||||
|
// be translated by editing the JSON/JSON5 config — fallback keeps the
|
||||||
|
// Italian baseline shipped with the client.
|
||||||
|
const taskLabel = useCallback((key: string, fallback: string): string =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const raw = GetConfiguration().getValue<string>(key, '');
|
||||||
|
return (typeof raw === 'string' && raw.length) ? raw : fallback;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const bumpProgress = useCallback((value: number, task?: string) =>
|
||||||
|
{
|
||||||
|
setLoadingProgress(prev => (value > prev ? value : prev));
|
||||||
|
if(task !== undefined) setLoadingTask(task);
|
||||||
|
}, []);
|
||||||
const warmupPromiseRef = useRef<Promise<void>>(null);
|
const warmupPromiseRef = useRef<Promise<void>>(null);
|
||||||
const rendererPromiseRef = useRef<Promise<any>>(null);
|
const rendererPromiseRef = useRef<Promise<any>>(null);
|
||||||
const gameInitPromiseRef = useRef<Promise<void> | null>(null);
|
const gameInitPromiseRef = useRef<Promise<void> | null>(null);
|
||||||
@@ -104,8 +127,36 @@ export const App: FC<{}> = props =>
|
|||||||
catch {}
|
catch {}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const showSessionExpired = useCallback(() =>
|
||||||
|
{
|
||||||
|
console.warn('[App] showSessionExpired — diagnostic shown (mid-game close)');
|
||||||
|
clearStoredCredentials();
|
||||||
|
|
||||||
|
const baseUrl = window.location.origin + '/';
|
||||||
|
setHomeUrl(baseUrl);
|
||||||
|
setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.');
|
||||||
|
setIsReady(false);
|
||||||
|
setShowLogin(false);
|
||||||
|
setIsEnteringHotel(false);
|
||||||
|
}, [ clearStoredCredentials ]);
|
||||||
|
|
||||||
const fallbackToLogin = useCallback(() =>
|
const fallbackToLogin = useCallback(() =>
|
||||||
{
|
{
|
||||||
|
// When login.screen.enabled is false this hotel uses SSO-only auth
|
||||||
|
// (CMS issues the ticket and redirects here). Surfacing a login form
|
||||||
|
// on init failure would just dump an empty/broken placeholder, since
|
||||||
|
// the form's backgrounds and Turnstile aren't even configured. Send
|
||||||
|
// the user back to the hotel home page instead.
|
||||||
|
const rawLoginEnabled = GetConfiguration().getValue<unknown>('login.screen.enabled', false);
|
||||||
|
const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1;
|
||||||
|
|
||||||
|
if(!loginScreenEnabled)
|
||||||
|
{
|
||||||
|
console.warn('[App] fallbackToLogin — login.screen.enabled=false, redirecting to home instead');
|
||||||
|
showSessionExpired();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Using console.warn (not NitroLogger.log) on purpose: NitroLogger
|
// Using console.warn (not NitroLogger.log) on purpose: NitroLogger
|
||||||
// is gated on LOG_DEBUG, which only flips to true once startWarmup's
|
// is gated on LOG_DEBUG, which only flips to true once startWarmup's
|
||||||
// GetConfiguration().init() completes. Auth-failure paths fire before
|
// GetConfiguration().init() completes. Auth-failure paths fire before
|
||||||
@@ -119,20 +170,7 @@ export const App: FC<{}> = props =>
|
|||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
setShowLogin(true);
|
setShowLogin(true);
|
||||||
setIsEnteringHotel(false);
|
setIsEnteringHotel(false);
|
||||||
}, [ clearStoredCredentials ]);
|
}, [ clearStoredCredentials, showSessionExpired ]);
|
||||||
|
|
||||||
const showSessionExpired = useCallback(() =>
|
|
||||||
{
|
|
||||||
console.warn('[App] showSessionExpired — diagnostic shown (mid-game close)');
|
|
||||||
clearStoredCredentials();
|
|
||||||
|
|
||||||
const baseUrl = window.location.origin + '/';
|
|
||||||
setHomeUrl(baseUrl);
|
|
||||||
setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.');
|
|
||||||
setIsReady(false);
|
|
||||||
setShowLogin(false);
|
|
||||||
setIsEnteringHotel(false);
|
|
||||||
}, [ clearStoredCredentials ]);
|
|
||||||
|
|
||||||
const applySsoTicket = useCallback((ssoTicket: string) =>
|
const applySsoTicket = useCallback((ssoTicket: string) =>
|
||||||
{
|
{
|
||||||
@@ -352,6 +390,7 @@ export const App: FC<{}> = props =>
|
|||||||
warmupPromiseRef.current = (async () =>
|
warmupPromiseRef.current = (async () =>
|
||||||
{
|
{
|
||||||
await GetConfiguration().init();
|
await GetConfiguration().init();
|
||||||
|
bumpProgress(25, taskLabel('loading.task.warmup', 'Caricamento contenuti...'));
|
||||||
|
|
||||||
GetTicker().maxFPS = GetConfiguration().getValue<number>('system.fps.max', 24);
|
GetTicker().maxFPS = GetConfiguration().getValue<number>('system.fps.max', 24);
|
||||||
NitroLogger.LOG_DEBUG = GetConfiguration().getValue<boolean>('system.log.debug', true);
|
NitroLogger.LOG_DEBUG = GetConfiguration().getValue<boolean>('system.log.debug', true);
|
||||||
@@ -388,18 +427,29 @@ export const App: FC<{}> = props =>
|
|||||||
loginImageUrls.forEach(preloadImage);
|
loginImageUrls.forEach(preloadImage);
|
||||||
gamedataUrls.forEach(url => preloadUrl(url));
|
gamedataUrls.forEach(url => preloadUrl(url));
|
||||||
|
|
||||||
await Promise.all(
|
// Wire each warmup task to a progress bump so the bar reflects
|
||||||
[
|
// real subsystem-init completion, not a fake timer. Range 25→70.
|
||||||
GetAssetManager().downloadAssets(assetUrls),
|
// Each task carries a friendly label so the user sees what is
|
||||||
GetLocalizationManager().init(),
|
// currently being prepared instead of raw file names.
|
||||||
GetAvatarRenderManager().init(),
|
const warmupTasks: { promise: Promise<any>; label: string }[] = [
|
||||||
GetSoundManager().init()
|
{ promise: GetAssetManager().downloadAssets(assetUrls), label: taskLabel('loading.task.assets', 'Sto caricando gli asset di gioco') },
|
||||||
]
|
{ promise: GetLocalizationManager().init(), label: taskLabel('loading.task.localization', 'Sto caricando le traduzioni') },
|
||||||
);
|
{ promise: GetAvatarRenderManager().init(), label: taskLabel('loading.task.avatar', 'Sto caricando il guardaroba') },
|
||||||
|
{ promise: GetSoundManager().init(), label: taskLabel('loading.task.sounds', 'Sto caricando i suoni') }
|
||||||
|
];
|
||||||
|
let warmupDone = 0;
|
||||||
|
const warmupStart = 25;
|
||||||
|
const warmupSpan = 45;
|
||||||
|
await Promise.all(warmupTasks.map(t => t.promise.then(value =>
|
||||||
|
{
|
||||||
|
warmupDone++;
|
||||||
|
bumpProgress(warmupStart + Math.round((warmupSpan * warmupDone) / warmupTasks.length), t.label);
|
||||||
|
return value;
|
||||||
|
})));
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return warmupPromiseRef.current;
|
return warmupPromiseRef.current;
|
||||||
}, [ startRenderer ]);
|
}, [ startRenderer, bumpProgress, taskLabel ]);
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
@@ -434,12 +484,18 @@ export const App: FC<{}> = props =>
|
|||||||
urlSso: new URLSearchParams(window.location.search).get('sso')
|
urlSso: new URLSearchParams(window.location.search).get('sso')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const bootLabel = taskLabel('loading.task.boot', 'Avvio in corso...');
|
||||||
|
setLoadingProgress(0);
|
||||||
|
setLoadingTask(bootLabel);
|
||||||
|
bumpProgress(5, bootLabel);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if(!window.NitroConfig) throw new Error('NitroConfig is not defined!');
|
if(!window.NitroConfig) throw new Error('NitroConfig is not defined!');
|
||||||
|
|
||||||
let ssoTicket = window.NitroConfig['sso.ticket'];
|
let ssoTicket = window.NitroConfig['sso.ticket'];
|
||||||
if(ssoTicket) GetConfiguration().setValue('sso.ticket', ssoTicket);
|
if(ssoTicket) GetConfiguration().setValue('sso.ticket', ssoTicket);
|
||||||
|
bumpProgress(10, taskLabel('loading.task.session', 'Verifica sessione'));
|
||||||
|
|
||||||
if(!ssoTicket || ssoTicket === '')
|
if(!ssoTicket || ssoTicket === '')
|
||||||
{
|
{
|
||||||
@@ -506,17 +562,23 @@ export const App: FC<{}> = props =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderer = await startRenderer(width, height);
|
const renderer = await startRenderer(width, height);
|
||||||
|
bumpProgress(20, taskLabel('loading.task.renderer', 'Inizializzazione renderer'));
|
||||||
|
|
||||||
await startWarmup(width, height);
|
await startWarmup(width, height);
|
||||||
|
bumpProgress(70, taskLabel('loading.task.startsession', 'Avvio sessione'));
|
||||||
|
|
||||||
if(!gameInitPromiseRef.current)
|
if(!gameInitPromiseRef.current)
|
||||||
{
|
{
|
||||||
gameInitPromiseRef.current = (async () =>
|
gameInitPromiseRef.current = (async () =>
|
||||||
{
|
{
|
||||||
await GetSessionDataManager().init();
|
await GetSessionDataManager().init();
|
||||||
|
bumpProgress(78, taskLabel('loading.task.userdata', 'Caricamento dati utente'));
|
||||||
await GetRoomSessionManager().init();
|
await GetRoomSessionManager().init();
|
||||||
|
bumpProgress(85, taskLabel('loading.task.rooms', 'Caricamento stanze'));
|
||||||
await GetRoomEngine().init();
|
await GetRoomEngine().init();
|
||||||
|
bumpProgress(92, taskLabel('loading.task.engine', 'Caricamento engine grafico'));
|
||||||
await GetCommunication().init();
|
await GetCommunication().init();
|
||||||
|
bumpProgress(98, taskLabel('loading.task.connect', 'Connessione al server'));
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,6 +607,7 @@ export const App: FC<{}> = props =>
|
|||||||
GetTicker().add(ticker => GetTexturePool().run());
|
GetTicker().add(ticker => GetTexturePool().run());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bumpProgress(100, taskLabel('loading.task.ready', 'Pronto!'));
|
||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
setShowLogin(false);
|
setShowLogin(false);
|
||||||
setIsEnteringHotel(false);
|
setIsEnteringHotel(false);
|
||||||
@@ -581,12 +644,12 @@ export const App: FC<{}> = props =>
|
|||||||
if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current);
|
if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current);
|
||||||
if(rememberRotateIntervalRef.current !== null) window.clearInterval(rememberRotateIntervalRef.current);
|
if(rememberRotateIntervalRef.current !== null) window.clearInterval(rememberRotateIntervalRef.current);
|
||||||
};
|
};
|
||||||
}, [ prepareTrigger, startWarmup, startRenderer, tryRememberLogin, applySsoTicket, rotateRememberLogin ]);
|
}, [ prepareTrigger, startWarmup, startRenderer, tryRememberLogin, applySsoTicket, rotateRememberLogin, bumpProgress, taskLabel ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Base fit overflow="hidden" className={ `nitro-app-root ${ !(window.devicePixelRatio % 1) ? 'image-rendering-pixelated' : '' }` }>
|
<Base fit overflow="hidden" className={ `nitro-app-root ${ !(window.devicePixelRatio % 1) ? 'image-rendering-pixelated' : '' }` }>
|
||||||
{ !isReady && !showLogin &&
|
{ !isReady && !showLogin &&
|
||||||
<LoadingView isError={ errorMessage.length > 0 } message={ errorMessage } homeUrl={ homeUrl } /> }
|
<LoadingView isError={ errorMessage.length > 0 } message={ errorMessage } homeUrl={ homeUrl } progress={ loadingProgress } currentTask={ loadingTask } /> }
|
||||||
{ !isReady && showLogin && <LoginView onAuthenticated={ handleAuthenticated } isEntering={ isEnteringHotel } /> }
|
{ !isReady && showLogin && <LoginView onAuthenticated={ handleAuthenticated } isEntering={ isEnteringHotel } /> }
|
||||||
{ isReady && <MainView /> }
|
{ isReady && <MainView /> }
|
||||||
{ /* Reconnect overlay must NOT render before we've actually entered
|
{ /* Reconnect overlay must NOT render before we've actually entered
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AddLinkEventTracker, AvatarDirectionAngle, AvatarEffectActivatedComposer, GetConfiguration, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
import { AddLinkEventTracker, AvatarDirectionAngle, AvatarEffectActivatedComposer, GetConfiguration, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||||
|
import { loadGamedata } from '@nitrots/utils';
|
||||||
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { FaChevronLeft, FaChevronRight, FaSearch } from 'react-icons/fa';
|
import { FaChevronLeft, FaChevronRight, FaSearch } from 'react-icons/fa';
|
||||||
import { LocalizeText, SendMessageComposer } from '../../api';
|
import { LocalizeText, SendMessageComposer } from '../../api';
|
||||||
@@ -65,9 +66,11 @@ export const AvatarEffectsView: FC<{}> = () =>
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const response = await fetch(url);
|
// The effectmap is served either as a single JSON file or as a
|
||||||
if(!response.ok) throw new Error(`HTTP ${ response.status }`);
|
// tiered directory with core/custom/seasonal manifests using
|
||||||
const json = await response.json();
|
// JSON5 syntax (// comments allowed). loadGamedata picks the
|
||||||
|
// right mode for us and merges tiers.
|
||||||
|
const json = await loadGamedata<{ effects?: EffectMapEntry[] }>(url);
|
||||||
if(cancelled) return;
|
if(cancelled) return;
|
||||||
|
|
||||||
const list: EffectMapEntry[] = Array.isArray(json?.effects)
|
const list: EffectMapEntry[] = Array.isArray(json?.effects)
|
||||||
|
|||||||
@@ -1,22 +1,77 @@
|
|||||||
import { FC } from 'react';
|
import { GetConfiguration } from '@nitrots/nitro-renderer';
|
||||||
|
import { FC, useMemo } from 'react';
|
||||||
import loadingGif from '@/assets/images/loading/loading.gif';
|
import loadingGif from '@/assets/images/loading/loading.gif';
|
||||||
|
import nitroV3Logo from '@/assets/images/notifications/nitro_v3.png';
|
||||||
import { Base, Column, Text } from '../../common';
|
import { Base, Column, Text } from '../../common';
|
||||||
|
|
||||||
interface LoadingViewProps {
|
interface LoadingViewProps {
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
homeUrl?: string;
|
homeUrl?: string;
|
||||||
|
progress?: number;
|
||||||
|
currentTask?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveConfigUrl = (key: string): string =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const raw = GetConfiguration().getValue<string>(key, '');
|
||||||
|
if(!raw) return '';
|
||||||
|
|
||||||
|
const interpolated = GetConfiguration().interpolate(raw) || raw;
|
||||||
|
return interpolated;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveConfigString = (key: string, fallback = ''): string =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const raw = GetConfiguration().getValue<string>(key, '');
|
||||||
|
if(!raw) return fallback;
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const LoadingView: FC<LoadingViewProps> = props =>
|
export const LoadingView: FC<LoadingViewProps> = props =>
|
||||||
{
|
{
|
||||||
const { isError = false, message = '', homeUrl = '' } = props;
|
const { isError = false, message = '', homeUrl = '', progress, currentTask = '' } = props;
|
||||||
|
|
||||||
|
const customLogoUrl = useMemo(() => resolveConfigUrl('loading.logo.url'), []);
|
||||||
|
const customBackground = useMemo(() => resolveConfigString('loading.background', ''), []);
|
||||||
|
const progressBarColor = useMemo(() => resolveConfigString('loading.progress.color', 'linear-gradient(90deg,#4f8cff,#2563eb)'), []);
|
||||||
|
|
||||||
|
const clampedProgress = typeof progress === 'number' && Number.isFinite(progress)
|
||||||
|
? Math.max(0, Math.min(100, Math.round(progress)))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const backgroundStyle = customBackground
|
||||||
|
? { background: customBackground }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const backgroundClassName = customBackground
|
||||||
|
? 'fixed inset-0 z-[2147483000]'
|
||||||
|
: 'fixed inset-0 z-[2147483000] bg-[radial-gradient(#1d1a24,#003a6b)]';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column fullHeight position="fixed" className="fixed inset-0 z-[2147483000] bg-[radial-gradient(#1d1a24,#003a6b)]">
|
<Column fullHeight position="fixed" className={ backgroundClassName } style={ backgroundStyle }>
|
||||||
|
<img
|
||||||
|
src={ nitroV3Logo }
|
||||||
|
alt="Nitro V3"
|
||||||
|
draggable={ false }
|
||||||
|
className="absolute top-5 left-0 z-2 w-37.5 h-auto select-none pointer-events-none"
|
||||||
|
/>
|
||||||
<Base fullHeight className="container h-100">
|
<Base fullHeight className="container h-100">
|
||||||
<Column fullHeight alignItems="center" justifyContent="center">
|
<Column fullHeight alignItems="center" justifyContent="center">
|
||||||
<Base className="absolute top-[20px] left-[20px] z-[2] w-[150px] h-[100px] bg-[url('@/assets/images/notifications/nitro_v3.png')] bg-no-repeat bg-left-top" />
|
|
||||||
{ isError && (message && message.length) ?
|
{ isError && (message && message.length) ?
|
||||||
<Column alignItems="center" className="absolute bottom-[20px] left-1/2 z-[3] -translate-x-1/2 max-w-[80%]" gap={ 2 }>
|
<Column alignItems="center" className="absolute bottom-[20px] left-1/2 z-[3] -translate-x-1/2 max-w-[80%]" gap={ 2 }>
|
||||||
<Text fontSizeCustom={ 20 } variant="white" className="text-center [text-shadow:0px_4px_4px_rgba(0,0,0,0.25)]">
|
<Text fontSizeCustom={ 20 } variant="white" className="text-center [text-shadow:0px_4px_4px_rgba(0,0,0,0.25)]">
|
||||||
@@ -32,15 +87,59 @@ export const LoadingView: FC<LoadingViewProps> = props =>
|
|||||||
}
|
}
|
||||||
</Column>
|
</Column>
|
||||||
:
|
:
|
||||||
<Column alignItems="center" justifyContent="center" gap={ 3 } className="z-[3]">
|
<>
|
||||||
<img src={ loadingGif } alt="" draggable={ false } className="block w-auto h-auto select-none pointer-events-none" />
|
<Column alignItems="center" justifyContent="center" className="z-[3] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||||
{ message && message.length ?
|
<img
|
||||||
<Text fontSizeCustom={ 20 } variant="white" className="text-center [text-shadow:0px_4px_4px_rgba(0,0,0,0.25)]">
|
src={ customLogoUrl || loadingGif }
|
||||||
{ message }
|
alt=""
|
||||||
</Text>
|
draggable={ false }
|
||||||
: null
|
className="block w-auto h-auto max-w-[80vw] max-h-[40vh] select-none pointer-events-none"
|
||||||
|
/>
|
||||||
|
{ message && message.length ?
|
||||||
|
<Text fontSizeCustom={ 22 } variant="white" className="text-center mt-4 [text-shadow:0px_4px_4px_rgba(0,0,0,0.4)] tracking-wide">
|
||||||
|
{ message }
|
||||||
|
</Text>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</Column>
|
||||||
|
{ clampedProgress !== null &&
|
||||||
|
<Column
|
||||||
|
alignItems="center"
|
||||||
|
gap={ 2 }
|
||||||
|
className="absolute bottom-[8vh] left-1/2 -translate-x-1/2 z-[4] w-[min(900px,90vw)]"
|
||||||
|
>
|
||||||
|
<Base
|
||||||
|
className="relative w-full h-8 rounded-full overflow-hidden border border-white/30 shadow-[0_8px_24px_rgba(0,0,0,0.45)]"
|
||||||
|
style={ { background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(4px)' } }
|
||||||
|
>
|
||||||
|
<Base
|
||||||
|
className="h-full rounded-full transition-[width] duration-300 ease-out"
|
||||||
|
style={ { width: `${ clampedProgress }%`, background: progressBarColor, boxShadow: '0 0 18px rgba(79,140,255,0.55)' } }
|
||||||
|
/>
|
||||||
|
<Base
|
||||||
|
className="absolute inset-0 flex items-center justify-center pointer-events-none"
|
||||||
|
style={ { fontFamily: '"Poppins","Segoe UI",system-ui,sans-serif', fontWeight: 700, fontSize: '16px', color: '#fff', letterSpacing: '0.08em', textShadow: '0 2px 4px rgba(0,0,0,0.6)' } }
|
||||||
|
>
|
||||||
|
{ clampedProgress }%
|
||||||
|
</Base>
|
||||||
|
</Base>
|
||||||
|
<Base
|
||||||
|
className="text-center"
|
||||||
|
style={ {
|
||||||
|
fontFamily: '"Poppins","Segoe UI",system-ui,sans-serif',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '15px',
|
||||||
|
color: 'rgba(255,255,255,0.85)',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
textShadow: '0 2px 4px rgba(0,0,0,0.5)',
|
||||||
|
minHeight: '22px'
|
||||||
|
} }
|
||||||
|
>
|
||||||
|
{ currentTask }
|
||||||
|
</Base>
|
||||||
|
</Column>
|
||||||
}
|
}
|
||||||
</Column>
|
</>
|
||||||
}
|
}
|
||||||
</Column>
|
</Column>
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
Reference in New Issue
Block a user