Merge branch 'Dev' into merge-duckie-main-2026-05-06

This commit is contained in:
DuckieTM
2026-05-08 07:45:17 +02:00
committed by GitHub
18 changed files with 1512 additions and 418 deletions
+5
View File
@@ -4,3 +4,8 @@ export function GetConfigurationValue<T = string>(key: string, value: T = null):
{
return GetConfiguration().getValue(key, value);
}
export function GetOptionalConfigurationValue<T = string>(key: string, value: T = null): T
{
return GetConfiguration().definitions.has(key) ? GetConfiguration().getValue(key, value) : value;
}
+86
View File
@@ -80,11 +80,97 @@ const applyWiredTextMarkup = (content: string) =>
return result;
};
const FONT_NAMED_COLORS = new Set([
'red', 'green', 'blue', 'yellow', 'white', 'black',
'orange', 'cyan', 'brown', 'purple', 'pink', 'magenta',
'violet', 'gray', 'grey', 'lime', 'teal', 'gold',
'silver', 'navy', 'maroon', 'olive', 'indigo'
]);
export const sanitizeFontColor = (raw: string | null | undefined): string | null =>
{
if(!raw) return null;
if(raw.length > 20) return null;
const value = raw.trim().toLowerCase();
if(/^#([0-9a-f]{3}|[0-9a-f]{6})$/.test(value)) return value;
if(FONT_NAMED_COLORS.has(value)) return value;
return null;
};
export type FontSegment = { color: string | null; text: string };
const FONT_COLOR_ATTR = /color\s*=\s*(?:"([^"]{1,32})"|'([^']{1,32})'|([^\s"'>]{1,32}))/i;
export const parseFontSegments = (input: string): FontSegment[] =>
{
if(!input) return [];
const pattern = /<font\b([^>]{0,200}?)>([\s\S]{0,200}?)<\/font>/gi;
const segments: FontSegment[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while((match = pattern.exec(input)) !== null)
{
if(match.index > lastIndex)
{
segments.push({ color: null, text: input.slice(lastIndex, match.index) });
}
const colorMatch = FONT_COLOR_ATTR.exec(match[1] || '');
const rawColor = colorMatch ? (colorMatch[1] || colorMatch[2] || colorMatch[3]) : null;
const color = sanitizeFontColor(rawColor);
segments.push({ color, text: match[2] });
lastIndex = pattern.lastIndex;
}
if(lastIndex < input.length)
{
segments.push({ color: null, text: input.slice(lastIndex) });
}
return segments;
};
const applyFontMarkup = (content: string) =>
{
const fontPattern = /&#60;font\b([^&]{0,200}?)&#62;([\s\S]{0,4000}?)&#60;\/font&#62;/gi;
const colorAttr = /color\s*=\s*(?:"([^"]{1,32})"|'([^']{1,32})'|([^\s"'>]{1,32}))/i;
let previous = '';
let next = content;
let guard = 0;
while((previous !== next) && (guard < 20))
{
previous = next;
next = next.replace(fontPattern, (_match, attrs: string, inner: string) =>
{
const colorMatch = colorAttr.exec(attrs || '');
const rawColor = colorMatch ? (colorMatch[1] || colorMatch[2] || colorMatch[3]) : null;
const color = sanitizeFontColor(rawColor);
if(!color) return inner;
return `<span style="color:${ color }">${ inner }</span>`;
});
guard++;
}
return next;
};
export const RoomChatFormatter = (content: string) =>
{
let result = '';
content = encodeHTML(content);
content = applyFontMarkup(content);
content = applyWiredTextMarkup(content);
//content = (joypixels.shortnameToUnicode(content) as string)
+34 -12
View File
@@ -1,4 +1,4 @@
import { getClientMode, installSecureFetch, secureUrl } from './secure-assets';
import { configFileUrl, getClientMode, installSecureFetch } from './secure-assets';
const ensureMobileViewport = () =>
{
@@ -31,13 +31,43 @@ const setBootDebug = (message: string) =>
setBootDebug('boot: secure fetch installed');
const deployBaseUrl = (): string =>
{
try
{
const loaderBase = (window as any).__nitroLoaderBase;
if(typeof loaderBase === 'string' && loaderBase.length) return new URL('..', loaderBase).toString();
}
catch {}
try
{
const moduleUrl = (import.meta as any).url;
if(typeof moduleUrl === 'string' && moduleUrl.length) return new URL('..', new URL('.', moduleUrl)).toString();
}
catch {}
try
{
const base = (import.meta as any).env?.BASE_URL;
if(typeof base === 'string' && base.length)
{
const trimmed = base.replace(/^\/+/, '').replace(/\/+$/, '');
return trimmed ? `${ window.location.origin }/${ trimmed }/` : `${ window.location.origin }/`;
}
}
catch {}
return `${ window.location.origin }/`;
};
const loadClientMode = async () =>
{
try
{
if((window as any).__nitroClientMode) return;
const url = new URL('configuration/client-mode.json', `${ window.location.origin }/`);
const url = new URL('configuration/client-mode.json', deployBaseUrl());
url.searchParams.set('v', Date.now().toString(36));
const response = await fetch(url.toString());
@@ -57,21 +87,13 @@ await loadClientMode();
const search = new URLSearchParams(window.location.search);
const clientMode = getClientMode();
const cacheBustUrl = (path: string): string =>
{
const url = new URL(path.replace(/^\/+/, ''), `${ window.location.origin }/`);
url.searchParams.set('v', Date.now().toString(36));
return url.toString();
};
(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || window.location.origin;
(window as any).NitroClientMode = clientMode;
(window as any).NitroConfig = {
'config.urls': [
clientMode.secureAssetsEnabled ? secureUrl('config', 'renderer-config.json', true) : cacheBustUrl('configuration/renderer-config.json'),
clientMode.secureAssetsEnabled ? secureUrl('config', 'ui-config.json', true) : cacheBustUrl('configuration/ui-config.json')
configFileUrl('renderer-config.json', true),
configFileUrl('ui-config.json', true)
],
'sso.ticket': search.get('sso') || null,
'forward.type': search.get('room') ? 2 : -1,
+20 -3
View File
@@ -1,6 +1,23 @@
import { FC, useMemo } from 'react';
import { FC, Fragment, ReactNode, useMemo } from 'react';
import { GetNickIconUrl } from '../assets/images/user_custom/nick_icons';
import { PREFIX_EFFECT_KEYFRAMES, getPrefixEffectStyle, getPrefixFontStyle, parsePrefixColors } from '../api';
import { PREFIX_EFFECT_KEYFRAMES, getPrefixEffectStyle, getPrefixFontStyle, parseFontSegments, parsePrefixColors } from '../api';
const renderInlineFontMarkup = (text: string): ReactNode =>
{
if(!text) return text;
if(text.indexOf('<font') === -1) return text;
const segments = parseFontSegments(text);
if(!segments.length) return text;
return segments.map((segment, index) =>
{
if(segment.color) return <span key={ index } style={ { color: segment.color } }>{ segment.text }</span>;
return <Fragment key={ index }>{ segment.text }</Fragment>;
});
};
interface UserIdentityViewProps
{
@@ -87,7 +104,7 @@ export const UserIdentityView: FC<UserIdentityViewProps> = ({
</span>
);
case 'name':
return <span key="identity-name" className={ `${ nameClassName } whitespace-nowrap` }>{ username }{ showColon ? ':' : '' }{ showColon ? ' ' : '' }</span>;
return <span key="identity-name" className={ `${ nameClassName } whitespace-nowrap` }>{ renderInlineFontMarkup(username) }{ showColon ? ':' : '' }{ showColon ? ' ' : '' }</span>;
default:
return null;
}
+27 -8
View File
@@ -1,7 +1,8 @@
import { Dispatch, FC, SetStateAction, useCallback, useMemo, useState } from 'react';
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react';
import { Base, Grid, Flex, NitroCardView, NitroCardHeaderView, NitroCardTabsView, NitroCardTabsItemView, NitroCardContentView, Text } from '../../common';
import { useRoom } from '../../hooks';
import { GetConfigurationValue } from '../../api';
import { GetOptionalConfigurationValue } from '../../api';
import { configFileUrl } from '../../secure-assets';
interface ItemData {
id: number;
@@ -22,6 +23,8 @@ interface BackgroundsViewProps {
const TABS = ['backgrounds', 'stands', 'overlays', 'cards'] as const;
type TabType = typeof TABS[number];
type RemoteData = Partial<Record<'backgrounds.data' | 'stands.data' | 'overlays.data' | 'cards.data', any[]>>;
export const BackgroundsView: FC<BackgroundsViewProps> = ({
setIsVisible,
selectedBackground,
@@ -34,20 +37,36 @@ export const BackgroundsView: FC<BackgroundsViewProps> = ({
setSelectedCardBackground
}) => {
const [activeTab, setActiveTab] = useState<TabType>('backgrounds');
const [remoteData, setRemoteData] = useState<RemoteData | null>(null);
const { roomSession } = useRoom();
useEffect(() => {
let cancelled = false;
fetch(configFileUrl('infostand_backgrounds.json'), { credentials: 'omit' })
.then(r => r.ok ? r.json() : null)
.then(json => { if(!cancelled && json && typeof json === 'object') setRemoteData(json as RemoteData); })
.catch(() => {});
return () => { cancelled = true; };
}, []);
const processData = useCallback((configData: any[], idField: string): ItemData[] => {
if (!configData?.length) return [];
return configData.map(item => ({ id: item[idField] }));
return configData.map(item => ({ id: typeof item === 'number' ? item : item[idField] }));
}, []);
const readData = useCallback((key: 'backgrounds.data' | 'stands.data' | 'overlays.data' | 'cards.data'): any[] => {
const fromRemote = remoteData?.[key];
if(Array.isArray(fromRemote)) return fromRemote;
return GetOptionalConfigurationValue<any[]>(key, []) || [];
}, [remoteData]);
const allData = useMemo(() => ({
backgrounds: processData(GetConfigurationValue('backgrounds.data'), 'backgroundId'),
stands: processData(GetConfigurationValue('stands.data'), 'standId'),
overlays: processData(GetConfigurationValue('overlays.data'), 'overlayId'),
cards: processData(GetConfigurationValue('cards.data') || GetConfigurationValue('backgrounds.data'), 'backgroundId')
}), [processData]);
backgrounds: processData(readData('backgrounds.data'), 'backgroundId'),
stands: processData(readData('stands.data'), 'standId'),
overlays: processData(readData('overlays.data'), 'overlayId'),
cards: processData(readData('cards.data').length ? readData('cards.data') : readData('backgrounds.data'), 'backgroundId')
}), [processData, readData]);
const handleSelection = useCallback((id: number) => {
if (!roomSession) return;
+88 -77
View File
@@ -1,6 +1,6 @@
import { GetConfiguration } from '@nitrots/nitro-renderer';
import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ClearRememberLogin, GetConfigurationValue, GetRememberLogin, StoreRememberLoginFromPayload } from '../../api';
import { ClearRememberLogin, GetConfigurationValue, GetOptionalConfigurationValue, GetRememberLogin, StoreRememberLoginFromPayload } from '../../api';
import { configFileUrl } from '../../secure-assets';
import flagBr from '../../assets/images/flag_icon/flag_icon_br.png';
import flagDe from '../../assets/images/flag_icon/flag_icon_de.png';
@@ -15,6 +15,7 @@ import flagTr from '../../assets/images/flag_icon/flag_icon_tr.png';
import { applyTextTranslationLocale } from '../../hooks/translation/useTranslation';
import { NewsWindow } from './components/NewsWindow';
import { TurnstileWidget } from './TurnstileWidget';
import { t } from './utils/i18n';
type DialogMode = 'login' | 'register' | 'forgot';
type LoginLocale = { code: string; file: string; label: string; flag: string };
@@ -194,6 +195,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
const [ localeApplying, setLocaleApplying ] = useState(false);
const [ localeError, setLocaleError ] = useState('');
const [ loginViewConfig, setLoginViewConfig ] = useState<Record<string, unknown>>(() => GetConfigurationValue<Record<string, unknown>>('loginview', {}));
const [ , setLocalizationVersion ] = useState(0);
const submitTimeRef = useRef(0);
const preloadedLoginImagesRef = useRef<Set<string>>(new Set());
@@ -204,8 +206,22 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
const configuredLoginWidgets = useMemo<Record<string, unknown>>(() =>
(loginViewConfig?.['widgets'] as Record<string, unknown>) ?? {}, [ loginViewConfig ]);
useEffect(() =>
{
const refreshLocalization = () => setLocalizationVersion(value => (value + 1));
window.addEventListener('nitro-localization-updated', refreshLocalization);
return () => window.removeEventListener('nitro-localization-updated', refreshLocalization);
}, []);
const loginImages = useMemo<Record<string, string>>(() =>
{
const configured = (loginViewConfig?.['images'] as Record<string, string>) ?? {};
return { ...getDefaultLoginImages(), ...configured };
}, [ loginViewConfig ]);
const loginWidgetSlots = useMemo(() =>
{
const configuredLoginWidgets = (loginViewConfig?.['widgets'] as Record<string, unknown>) ?? {};
return Object.entries(configuredLoginWidgets)
.filter(([ key, value ]) => key.startsWith('slot.') && key.endsWith('.widget') && typeof value === 'string' && value.length > 0)
.map(([ key, value ]) =>
@@ -217,7 +233,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
})
.filter(slot => slot.slotNum > 0)
.sort((a, b) => a.slotNum - b.slotNum);
}, [ configuredLoginWidgets ]);
}, [ loginViewConfig ]);
const backgroundColor = (loginImages['background.colour'] || GetConfigurationValue<string>('login_background.colour', '#6eadc8'));
const background = interpolate(loginImages['background'] || GetConfigurationValue<string>('login_background', ''));
@@ -226,15 +242,11 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
const left = interpolate(loginImages['left'] || GetConfigurationValue<string>('login_left', ''));
const rightRepeat = interpolate(loginImages['right.repeat'] || GetConfigurationValue<string>('login_right.repeat', ''));
const right = interpolate(loginImages['right'] || GetConfigurationValue<string>('login_right', ''));
const widgetImageUrls = useMemo(() => loginWidgetSlots
.map(slot => typeof slot.conf.image === 'string' ? interpolate(slot.conf.image) : '')
.filter(Boolean), [ loginWidgetSlots ]);
const loginImageUrls = useMemo(() => [ background, sun, drape, left, rightRepeat, right, ...widgetImageUrls ].filter(Boolean), [ background, sun, drape, left, rightRepeat, right, widgetImageUrls ]);
const [ loginImagesVersion, setLoginImagesVersion ] = useState(0);
const loginUrl = GetConfigurationValue<string>('login.endpoint', '/api/auth/login');
const registerUrl = GetConfigurationValue<string>('login.register.endpoint', '/api/auth/register');
const forgotUrl = GetConfigurationValue<string>('login.forgot.endpoint', '/api/auth/forgot-password');
const newsUrl = interpolate(GetConfigurationValue<string>('login.news.url', ''));
const configuredNewsUrl = interpolate(GetOptionalConfigurationValue<string>('login.news.url', ''));
const newsUrl = configuredNewsUrl || configFileUrl('news.json');
const turnstileSiteKey = GetConfigurationValue<string>('login.turnstile.sitekey', '');
const rawTurnstileEnabled = GetConfigurationValue<unknown>('login.turnstile.enabled', false);
const turnstileEnabled = (rawTurnstileEnabled === true
@@ -401,7 +413,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
}, []);
const healthUrl = GetConfigurationValue<string>('login.health.endpoint', '');
const healthMethodRaw = GetConfigurationValue<string>('login.health.method', 'GET');
const healthMethodRaw = GetOptionalConfigurationValue<string>('login.health.method', 'GET');
const healthMethod = (healthMethodRaw || 'GET').toUpperCase();
const checkServerReachable = useCallback(async (): Promise<boolean> =>
{
@@ -457,19 +469,19 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
if(state.lockedUntil > nowTs)
{
const remaining = Math.ceil((state.lockedUntil - nowTs) / 1000);
setError(`Too many attempts. Try again in ${ remaining }s.`);
setError(t('nitro.login.error.too_many_attempts', 'Too many attempts. Try again in %seconds%s.', [ 'seconds' ], [ String(remaining) ]));
return;
}
if(!username.trim() || !password)
{
setError('Please enter both your Habbo name and password.');
setError(t('nitro.login.error.missing_credentials', 'Please enter both your Habbo name and password.'));
return;
}
if(turnstileEnabled && !loginTurnstileToken)
{
setError('Please complete the security check.');
setError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
return;
}
@@ -497,14 +509,14 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
}
recordFailure();
const message = typeof payload.error === 'string' ? payload.error : 'Invalid Habbo name or password.';
const message = typeof payload.error === 'string' ? payload.error : t('nitro.login.error.invalid_credentials', 'Invalid Habbo name or password.');
setError(message);
resetLoginTurnstile();
}
catch(err)
{
recordFailure();
setError('Unable to reach the login service. Please try again.');
setError(t('nitro.login.error.login_unreachable', 'Unable to reach the login service. Please try again.'));
resetLoginTurnstile();
}
finally
@@ -515,7 +527,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
const checkEmailUrl = GetConfigurationValue<string>('login.check-email.endpoint', '/api/auth/check-email');
const checkUsernameUrl = GetConfigurationValue<string>('login.check-username.endpoint', '/api/auth/check-username');
const imagingUrl = GetConfigurationValue<string>('login.register.imaging.url', '');
const imagingUrl = GetOptionalConfigurationValue<string>('login.register.imaging.url', '');
const interpretAvailability = (ok: boolean, status: number, payload: Record<string, unknown>): { available: boolean; error?: string } =>
{
const isTrue = (v: unknown) => v === true || v === 'true' || v === 1 || v === '1';
@@ -541,7 +553,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
const { ok, status, payload } = await postJson(checkEmailUrl, { email });
const result = interpretAvailability(ok, status, payload);
if(result.available) return { available: true };
return { available: false, error: result.error || 'This email is already in use.' };
return { available: false, error: result.error || t('nitro.login.error.email_taken', 'This email is already in use.') };
}
catch
{
@@ -556,7 +568,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
const { ok, status, payload } = await postJson(checkUsernameUrl, { username });
const result = interpretAvailability(ok, status, payload);
if(result.available) return { available: true };
return { available: false, error: result.error || 'This Habbo name is already taken.' };
return { available: false, error: result.error || t('nitro.login.error.username_taken', 'This Habbo name is already taken.') };
}
catch
{
@@ -568,7 +580,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
{
if(turnstileEnabled && !body.turnstileToken)
{
setError('Please complete the security check.');
setError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
return;
}
@@ -589,7 +601,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
if(ok)
{
const friendly = `Welcome aboard, ${ body.username }! Your account is ready — log in below with the password you just chose.`;
const friendly = t('nitro.login.register.success', 'Welcome aboard, %username%! Your account is ready — log in below with the password you just chose.', [ 'username' ], [ body.username ]);
setInfo(typeof payload.message === 'string' ? payload.message : friendly);
setMode('login');
setUsername(body.username);
@@ -597,12 +609,12 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
return;
}
setError(typeof payload.error === 'string' ? payload.error : 'Unable to create your account.');
setError(typeof payload.error === 'string' ? payload.error : t('nitro.login.error.register_failed', 'Unable to create your account.'));
onDialogReset();
}
catch
{
setError('Unable to reach the registration service.');
setError(t('nitro.login.error.register_unreachable', 'Unable to reach the registration service.'));
onDialogReset();
}
finally
@@ -615,7 +627,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
{
if(turnstileEnabled && !body.turnstileToken)
{
setError('Please complete the security check.');
setError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
return;
}
@@ -632,18 +644,18 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
if(ok)
{
const friendly = 'Email sent! If an account matches that address you\'ll find a reset link in your inbox shortly (check spam if it doesn\'t show up within a minute).';
const friendly = t('nitro.login.forgot.success', 'Email sent! If an account matches that address you\'ll find a reset link in your inbox shortly (check spam if it doesn\'t show up within a minute).');
setInfo(typeof payload.message === 'string' ? payload.message : friendly);
setMode('login');
return;
}
setError(typeof payload.error === 'string' ? payload.error : 'Unable to send a reset email right now.');
setError(typeof payload.error === 'string' ? payload.error : t('nitro.login.error.forgot_failed', 'Unable to send a reset email right now.'));
onDialogReset();
}
catch
{
setError('Unable to reach the password reset service.');
setError(t('nitro.login.error.forgot_unreachable', 'Unable to reach the password reset service.'));
onDialogReset();
}
finally
@@ -663,9 +675,6 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
{ left ? <img className="login-left login-layer login-layer-img" src={ left } alt="" draggable={ false } /> : null }
{ rightRepeat ? <div className="login-right-repeat login-layer" style={ { backgroundImage: `url(${ rightRepeat })` } } /> : null }
{ right ? <img className="login-right login-layer login-layer-img" src={ right } alt="" draggable={ false } /> : null }
<div className="login-image-preloader" aria-hidden="true" data-version={ loginImagesVersion }>
{ loginImageUrls.map(url => <img key={ url } src={ url } decoding="async" loading="eager" alt="" />) }
</div>
{ loginWidgetSlots.length > 0 &&
<div className="login-widgets">
@@ -702,8 +711,8 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
<div className="login-stack">
<div className="nitro-login-card login-language-card">
<div className="card-title">Choose your language</div>
<div className="login-language-grid" role="list" aria-label="Language selection">
<div className="card-title">{ t('nitro.login.language.title', 'Choose your language') }</div>
<div className="login-language-grid" role="list" aria-label={ t('nitro.login.language.aria', 'Language selection') }>
{ LOGIN_LOCALES.map(locale =>
<button
key={ locale.code }
@@ -720,23 +729,23 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
</div>
{ localeError.length > 0 && <div className="language-error">{ localeError }</div> }
<button type="button" className="ok-button login-language-confirm" disabled={ localeApplying } onClick={ confirmLocaleSelection }>
{ localeApplying ? 'Loading...' : 'OK' }
{ localeApplying ? t('nitro.login.language.loading', 'Loading...') : t('nitro.login.language.ok', 'OK') }
</button>
</div>
<div className="nitro-login-card">
<div className="card-title">First time here?</div>
<div className="card-title">{ t('nitro.login.firsttime.title', 'First time here?') }</div>
<div className="card-body register-card-body">
<span>Don't have a Habbo yet?</span>
<a onClick={ () => setMode('register') }>You can create one here</a>
<span>{ t('nitro.login.firsttime.text', 'Don\'t have a Habbo yet?') }</span>
<a onClick={ () => setMode('register') }>{ t('nitro.login.firsttime.link', 'You can create one here') }</a>
</div>
</div>
<div className="nitro-login-card">
<div className="card-title">What's your Habbo called?</div>
<div className="card-title">{ t('nitro.login.card.title', 'What\'s your Habbo called?') }</div>
<form className="card-body" onSubmit={ handleLoginSubmit } autoComplete="on">
<div className="field">
<label htmlFor="login-username">Name of your Habbo</label>
<label htmlFor="login-username">{ t('login.username', 'Name of your Habbo') }</label>
<input
id="login-username"
name="username"
@@ -748,7 +757,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
/>
</div>
<div className="field">
<label htmlFor="login-password">Password</label>
<label htmlFor="login-password">{ t('generic.password', 'Password') }</label>
<input
id="login-password"
name="password"
@@ -765,7 +774,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
checked={ rememberMe }
onChange={ e => setRememberMe(e.target.checked) }
/>
<span>Ricordami</span>
<span>{ t('login.remember_me', 'Remember me') }</span>
</label>
{ turnstileEnabled && mode === 'login' &&
<TurnstileWidget
@@ -778,9 +787,9 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
/> }
{ loginServerReachable === false &&
<div className="error-line server-offline">
The gameserver isn't running right now. Please try again in a moment.
{ t('nitro.login.server.offline.short', 'The gameserver isn\'t running right now. Please try again in a moment.') }
<button type="button" className="retry-link" onClick={ pingLoginServer } disabled={ loginPingingServer }>
{ loginPingingServer ? 'Checking' : 'Retry' }
{ loginPingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry') }
</button>
</div>
}
@@ -791,9 +800,9 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
type="submit"
className="ok-button"
disabled={ submitting || isEntering || isLocked }
>{ isEntering ? 'Entrando' : loginPingingServer ? 'Checking' : 'OK' }</button>
>{ isEntering ? t('nitro.login.entering', 'Entering…') : loginPingingServer ? t('nitro.login.server.checking', 'Checking…') : t('login.title', 'Log in') }</button>
</div>
<a className="forgot" onClick={ () => setMode('forgot') }>Forgotten your password?</a>
<a className="forgot" onClick={ () => setMode('forgot') }>{ t('login.forgot_password', 'Forgotten your password?') }</a>
</form>
</div>
@@ -1144,22 +1153,22 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
if(!email.trim() || !password || !confirm)
{
setLocalError('Please fill in every field.');
setLocalError(t('nitro.login.register.error.missing_fields', 'Please fill in every field.'));
return;
}
if(!EMAIL_REGEX.test(email.trim()))
{
setLocalError('Please enter a valid email address.');
setLocalError(t('nitro.login.register.error.invalid_email', 'Please enter a valid email address.'));
return;
}
if(password.length < 8)
{
setLocalError('Your password must be at least 8 characters.');
setLocalError(t('nitro.login.register.error.password_too_short', 'Your password must be at least 8 characters.'));
return;
}
if(password !== confirm)
{
setLocalError('Passwords do not match.');
setLocalError(t('nitro.login.register.error.password_mismatch', 'Passwords do not match.'));
return;
}
@@ -1169,13 +1178,13 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
const serverOk = await pingServer();
if(!serverOk)
{
setLocalError('The gameserver is not running. Please try again later.');
setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.'));
return;
}
const result = await onCheckEmail(email.trim());
if(!result.available)
{
setLocalError(result.error || 'This email is already in use.');
setLocalError(result.error || t('nitro.login.error.email_taken', 'This email is already in use.'));
return;
}
setStep('avatar');
@@ -1254,18 +1263,18 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
const trimmed = username.trim();
if(!trimmed)
{
setLocalError('Please choose a Habbo name.');
setLocalError(t('nitro.login.register.error.username_required', 'Please choose a Habbo name.'));
return;
}
if(trimmed.length < 3 || trimmed.length > 16)
{
setLocalError('Habbo name must be 316 characters.');
setLocalError(t('nitro.login.register.error.username_length', 'Habbo name must be 316 characters.'));
return;
}
if(turnstileEnabled && !turnstileToken)
{
setLocalError('Please complete the security check.');
setLocalError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
return;
}
@@ -1275,13 +1284,13 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
const serverOk = await pingServer();
if(!serverOk)
{
setLocalError('The gameserver is not running. Please try again later.');
setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.'));
return;
}
const result = await onCheckUsername(trimmed);
if(!result.available)
{
setLocalError(result.error || 'This Habbo name is already taken.');
setLocalError(result.error || t('nitro.login.error.username_taken', 'This Habbo name is already taken.'));
return;
}
}
@@ -1308,35 +1317,35 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
<div className={ `dialog ${ step === 'avatar' ? 'dialog-avatar' : '' }` }>
<div className="nitro-login-card">
<div className="card-title">
<span>Habbo Details</span>
<span className="nitro-card-close-button" role="button" aria-label="Close" onClick={ onCancel } />
<span>{ t('nitro.login.register.title', 'Habbo Details') }</span>
<span className="nitro-card-close-button" role="button" aria-label={ t('generic.close', 'Close') } onClick={ onCancel } />
</div>
{ step === 'credentials' &&
<form className="card-body" onSubmit={ handleCredentialsNext } autoComplete="on">
<div className="register-intro">
Let's create your account. Enter your email and pick a password we'll check that email isn't already in use.
{ t('nitro.login.register.intro.credentials', 'Let\'s create your account. Enter your email and pick a password — we\'ll check that email isn\'t already in use.') }
</div>
{ serverOffline &&
<div className="error-line server-offline">
The gameserver isn't running right now, so new accounts can't be created. Please try again in a moment.
{ t('nitro.login.register.server.offline', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') }
<button type="button" className="retry-link" onClick={ pingServer } disabled={ pingingServer }>
{ pingingServer ? 'Checking…' : 'Retry' }
{ pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry') }
</button>
</div>
}
<div className="field">
<label htmlFor="register-email">Email</label>
<label htmlFor="register-email">{ t('nitro.login.register.email', 'Email') }</label>
<input id="register-email" type="email" maxLength={ 120 } autoComplete="email"
value={ email } onChange={ e => setEmail(e.target.value) } />
</div>
<div className="field">
<label htmlFor="register-password">Password</label>
<label htmlFor="register-password">{ t('generic.password', 'Password') }</label>
<input id="register-password" type="password" maxLength={ 128 } autoComplete="new-password"
value={ password } onChange={ e => setPassword(e.target.value) } />
</div>
<div className="field">
<label htmlFor="register-confirm">Confirm password</label>
<label htmlFor="register-confirm">{ t('nitro.login.register.confirm_password', 'Confirm password') }</label>
<input id="register-confirm" type="password" maxLength={ 128 } autoComplete="new-password"
value={ confirm } onChange={ e => setConfirm(e.target.value) } />
</div>
@@ -1345,7 +1354,7 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
<div className="step-footer">
<span className="step-indicator">1/2</span>
<button type="submit" className="ok-button" disabled={ !credentialsValid || busy || serverOffline }>
{ checking || pingingServer ? 'Checking…' : 'Next' }
{ checking || pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
</button>
</div>
</form>
@@ -1354,29 +1363,29 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
{ step === 'avatar' &&
<form className="card-body" onSubmit={ handleAvatarSubmit } autoComplete="on">
<div className="register-intro">
Now it's time to make your own Habbo character! To make your own Habbo, please start by choosing your Habbo Name.
{ t('nitro.login.register.intro.avatar', 'Now it\'s time to make your own Habbo character! To make your own Habbo, please start by choosing your Habbo Name.') }
</div>
{ serverOffline &&
<div className="error-line server-offline">
The gameserver isn't running right now, so new accounts can't be created. Please try again in a moment.
{ t('nitro.login.register.server.offline', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') }
<button type="button" className="retry-link" onClick={ pingServer } disabled={ pingingServer }>
{ pingingServer ? 'Checking' : 'Retry' }
{ pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry') }
</button>
</div>
}
<div className="field">
<input id="register-username" type="text" maxLength={ 16 } autoComplete="username" placeholder="HabboName"
<input id="register-username" type="text" maxLength={ 16 } autoComplete="username" placeholder={ t('nitro.login.register.username_placeholder', 'HabboName') }
value={ username } onChange={ e => setUsername(e.target.value) } />
</div>
<div className="gender-row">
<label>
<input type="radio" name="register-gender" checked={ gender === 'F' } onChange={ () => applyGender('F') } />
<span>Girl</span>
<span>{ t('nitro.login.register.gender.girl', 'Girl') }</span>
</label>
<label>
<input type="radio" name="register-gender" checked={ gender === 'M' } onChange={ () => applyGender('M') } />
<span>Boy</span>
<span>{ t('nitro.login.register.gender.boy', 'Boy') }</span>
</label>
</div>
@@ -1424,8 +1433,10 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
<button type="button" className="ok-button hot-looks-button"
onClick={ cycleHotLook }
disabled={ !hotLooks.length || busy }
title={ hotLooks.length ? `${ hotLooks.length } looks available` : 'No hot looks loaded' }>
Hot Looks{ hotLookIndex >= 0 && hotLooks.length ? ` (${ hotLookIndex + 1 }/${ hotLooks.length })` : '' }
title={ hotLooks.length
? t('nitro.login.register.hotlooks.available', '%count% looks available', [ 'count' ], [ String(hotLooks.length) ])
: t('nitro.login.register.hotlooks.none', 'No hot looks loaded') }>
{ t('nitro.login.register.hotlooks', 'Hot Looks') }{ hotLookIndex >= 0 && hotLooks.length ? ` (${ hotLookIndex + 1 }/${ hotLooks.length })` : '' }
</button>
</div>
@@ -1442,10 +1453,10 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
{ info && <div className="info-line">{ info }</div> }
<div className="step-footer step-footer-split">
<button type="button" className="ok-button back-button" onClick={ () => setStep('credentials') } disabled={ busy }>Back</button>
<button type="button" className="ok-button back-button" onClick={ () => setStep('credentials') } disabled={ busy }>{ t('nitro.login.register.back', 'Back') }</button>
<span className="step-indicator">2/2</span>
<button type="submit" className="ok-button" disabled={ !username.trim() || busy || serverOffline }>
{ submitting ? 'Creating' : (checking || pingingServer) ? 'Checking' : 'Next' }
{ submitting ? t('nitro.login.register.creating', 'Creating…') : (checking || pingingServer) ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
</button>
</div>
</form>
@@ -1483,7 +1494,7 @@ const ForgotDialog: FC<ForgotDialogProps> = props =>
if(!email.trim())
{
setLocalError('Please enter your email address.');
setLocalError(t('nitro.login.forgot.error.email_required', 'Please enter your email address.'));
return;
}
@@ -1495,12 +1506,12 @@ const ForgotDialog: FC<ForgotDialogProps> = props =>
<div className="dialog">
<div className="nitro-login-card">
<div className="card-title">
<span>Reset password</span>
<span className="nitro-card-close-button" role="button" aria-label="Close" onClick={ onCancel } />
<span>{ t('nitro.login.forgot.title', 'Reset password') }</span>
<span className="nitro-card-close-button" role="button" aria-label={ t('generic.close', 'Close') } onClick={ onCancel } />
</div>
<form className="card-body" onSubmit={ handle } autoComplete="on">
<div className="field">
<label htmlFor="forgot-email">Email address</label>
<label htmlFor="forgot-email">{ t('nitro.login.forgot.email_label', 'Email address') }</label>
<input id="forgot-email" type="email" maxLength={ 120 } autoComplete="email"
value={ email } onChange={ e => setEmail(e.target.value) } />
</div>
@@ -1516,7 +1527,7 @@ const ForgotDialog: FC<ForgotDialogProps> = props =>
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
{ info && <div className="info-line">{ info }</div> }
<div className="submit-row">
<button type="submit" className="ok-button" disabled={ submitting }>Send email</button>
<button type="submit" className="ok-button" disabled={ submitting }>{ t('nitro.login.forgot.send_email', 'Send email') }</button>
</div>
</form>
</div>
+25 -5
View File
@@ -1,5 +1,5 @@
import { FC, useEffect, useState } from 'react';
import { t } from '../utils/i18n';
import { interpolate, t } from '../utils/i18n';
import { resolveNewsImage, resolveNewsLink } from '../utils/news';
interface NewsItem
@@ -12,6 +12,26 @@ interface NewsItem
linkUrl: string;
}
interface RawNewsItem
{
id?: number;
title?: string;
body?: string;
image?: string | null;
link?: string;
linkUrl?: string;
linkText?: string;
}
const normalizeNewsItem = (raw: RawNewsItem, fallbackId: number): NewsItem => ({
id: typeof raw.id === 'number' ? raw.id : fallbackId,
title: typeof raw.title === 'string' ? raw.title : '',
body: typeof raw.body === 'string' ? raw.body : '',
image: typeof raw.image === 'string' && raw.image.length ? interpolate(raw.image) : null,
linkText: typeof raw.linkText === 'string' ? raw.linkText : '',
linkUrl: interpolate((typeof raw.linkUrl === 'string' && raw.linkUrl) || (typeof raw.link === 'string' ? raw.link : ''))
});
interface NewsWindowProps { newsUrl: string; }
const NEWS_AUTO_ADVANCE_MS = 10000;
@@ -38,10 +58,10 @@ export const NewsWindow: FC<NewsWindowProps> = ({ newsUrl }) =>
.then((json: unknown) =>
{
if(cancelled) return;
const list = Array.isArray((json as { news?: unknown })?.news)
? (json as { news: NewsItem[] }).news
: [];
setItems(list);
const rawList = Array.isArray((json as { news?: unknown })?.news)
? (json as { news: RawNewsItem[] }).news
: Array.isArray(json) ? (json as RawNewsItem[]) : [];
setItems(rawList.map((raw, idx) => normalizeNewsItem(raw, idx + 1)));
})
.catch(() => { if(!cancelled) setFailed(true); });
return () =>
+4 -4
View File
@@ -153,16 +153,16 @@
&.active {
height: 100%;
overflow: hidden;
background: rgba(#fff, 0.5);
border-bottom: 1px solid rgba(#000, 0.2);
background: rgba(255, 255, 255, 0.5);
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
}
.nitro-card-accordion-set-header {
border-bottom: 1px solid rgba(#000, 0.2);
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
}
}
@include media-breakpoint-down(lg) {
@media (max-width: 991.98px) {
.nitro-card {
resize: none !important;
max-width: calc(100vw - 16px) !important;
+4 -4
View File
@@ -65,7 +65,7 @@
background: transparent;
padding: 3px 0px;
color: #FFF;
border-color: rgba(#000, 0.3);
border-color: rgba(0, 0, 0, 0.3);
cursor: pointer;
&:hover {
@@ -137,9 +137,9 @@
left: 50%;
transform: translateX(-50%);
font-size: large;
background: rgba(#000, 0.95);
box-shadow: inset 0px 5px lighten(rgba(#000, .6), 2.5), inset 0 -4px darken(rgba(#000, .6), 4);
border-radius: $border-radius;
background: rgba(0, 0, 0, 0.95);
box-shadow: inset 0px 5px rgba(10, 10, 10, 0.6), inset 0 -4px rgba(0, 0, 0, 0.6);
border-radius: 0.375rem;
transition: all 0.2s ease;
z-index: 21;
+33 -3
View File
@@ -19,6 +19,36 @@ const CLIENT_MODE_DEFAULTS: NitroClientMode = {
secureApiEnabled: true
};
const getDeployBaseUrl = (): string =>
{
try
{
const loaderBase = (window as any).__nitroLoaderBase;
if(typeof loaderBase === 'string' && loaderBase.length) return new URL('..', loaderBase).toString();
}
catch {}
try
{
const moduleUrl = (import.meta as any).url;
if(typeof moduleUrl === 'string' && moduleUrl.length) return new URL('..', new URL('.', moduleUrl)).toString();
}
catch {}
try
{
const base = (import.meta as any).env?.BASE_URL;
if(typeof base === 'string' && base.length)
{
const trimmed = base.replace(/^\/+/, '').replace(/\/+$/, '');
return trimmed ? `${ window.location.origin }/${ trimmed }/` : `${ window.location.origin }/`;
}
}
catch {}
return `${ window.location.origin }/`;
};
const isDebugEnabled = (): boolean =>
{
try
@@ -205,7 +235,7 @@ const getPlainAssetBase = (kind: 'config' | 'gamedata'): string =>
if(typeof configured === 'string' && configured.length) return configured.endsWith('/') ? configured : `${ configured }/`;
if(kind === 'config') return `${ window.location.origin }/configuration/`;
if(kind === 'config') return new URL('configuration/', getDeployBaseUrl()).toString();
return `${ window.location.origin }/nitro/gamedata/`;
};
@@ -227,7 +257,7 @@ export const secureUrl = (kind: 'config' | 'gamedata', file: string, cacheBust =
{
if(!getClientMode().secureAssetsEnabled)
{
const plainUrl = new URL(file.replace(/^\/+/, ''), `${ window.location.origin }/`);
const plainUrl = new URL(file.replace(/^\/+/, ''), getPlainAssetBase(kind));
if(cacheBust) plainUrl.searchParams.set('v', Date.now().toString(36));
@@ -244,7 +274,7 @@ export const configFileUrl = (file: string, cacheBust = false): string =>
{
if(getClientMode().secureAssetsEnabled) return secureUrl('config', file, cacheBust);
const plainUrl = new URL(`configuration/${ file.replace(/^\/+/, '') }`, `${ window.location.origin }/`);
const plainUrl = new URL(file.replace(/^\/+/, ''), getPlainAssetBase('config'));
if(cacheBust) plainUrl.searchParams.set('v', Date.now().toString(36));