Merge pull request #109 from duckietm/Dev

Dev
This commit is contained in:
DuckieTM
2026-05-01 07:47:31 +02:00
committed by GitHub
13 changed files with 1553 additions and 892 deletions
+1
View File
@@ -54,6 +54,7 @@
"login.room_templates.endpoint": "${api.url}/api/auth/room-templates",
"login.remember.endpoint": "${api.url}/api/auth/remember",
"login.server_key.endpoint": "${api.url}/api/auth/server-key",
"login.news.endpoint": "${api.url}/api/auth/news",
"login.turnstile.enabled": true,
"login.turnstile.sitekey": "",
"avatar.mandatory.libraries": [
+13 -892
View File
@@ -1,98 +1,15 @@
import { GetConfiguration } from '@nitrots/nitro-renderer';
import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { GetConfigurationValue, LocalizeText } from '../../api';
import { GetConfigurationValue } from '../../api';
import { ForgotDialog } from './components/ForgotDialog';
import { NewsWindow } from './components/NewsWindow';
import { RegisterDialog } from './components/RegisterDialog';
import { TurnstileWidget } from './TurnstileWidget';
/**
* Looks up a localized string. Falls back to `fallback` when the key is
* missing (LocalizeText returns the key itself) or when the localization
* manager isn't ready yet (login runs very early). Parameters are
* %name%-substituted into the fallback so the UI stays correct pre-init.
*/
const t = (key: string, fallback: string, params?: string[], replacements?: string[]): string =>
{
try
{
const value = LocalizeText(key, params ?? null, replacements ?? null);
if(value && value !== key) return value;
}
catch { /* localization manager not initialised yet */ }
if(!params || !replacements) return fallback;
let out = fallback;
for(let i = 0; i < params.length; i++)
{
if(replacements[i] !== undefined) out = out.replace('%' + params[i] + '%', replacements[i]);
}
return out;
};
import { BanInfo, formatRemaining, parseBan } from './utils/ban';
import { interpolate, t } from './utils/i18n';
import { LOCK_DURATION_MS, LOCK_WINDOW_MS, MAX_ATTEMPTS, readLock, writeLock } from './utils/lockState';
type DialogMode = 'login' | 'register' | 'forgot';
interface BanInfo
{
type: 'account' | 'ip' | 'machine' | 'super' | string;
reason: string;
permanent: boolean;
expiresAt?: number;
}
const parseBan = (payload: Record<string, unknown>): BanInfo | null =>
{
const raw = payload?.ban;
if(!raw || typeof raw !== 'object') return null;
const ban = raw as Record<string, unknown>;
const type = typeof ban.type === 'string' ? ban.type : 'account';
const reason = typeof ban.reason === 'string' ? ban.reason : '';
const permanent = ban.permanent === true || ban.permanent === 'true';
const expiresAt = typeof ban.expiresAt === 'number' ? ban.expiresAt : undefined;
return { type, reason, permanent, expiresAt };
};
const formatRemaining = (epochSeconds: number): string =>
{
const totalSeconds = Math.max(0, epochSeconds - Math.floor(Date.now() / 1000));
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if(days > 0) return `${ days }d ${ hours }h ${ minutes }m`;
if(hours > 0) return `${ hours }h ${ minutes }m`;
if(minutes > 0) return `${ minutes }m ${ seconds }s`;
return `${ seconds }s`;
};
const interpolate = (value: string | null | undefined): string =>
{
if(!value) return '';
try { return GetConfiguration().interpolate(value); }
catch { return value; }
};
const LOCK_KEY = 'nitro.login.lock';
const MAX_ATTEMPTS = 5;
const LOCK_WINDOW_MS = 60_000;
const LOCK_DURATION_MS = 2 * 60_000;
type AttemptState = { attempts: number; firstAt: number; lockedUntil: number };
const readLock = (): AttemptState =>
{
try
{
const raw = sessionStorage.getItem(LOCK_KEY);
if(!raw) return { attempts: 0, firstAt: 0, lockedUntil: 0 };
return JSON.parse(raw);
}
catch { return { attempts: 0, firstAt: 0, lockedUntil: 0 }; }
};
const writeLock = (state: AttemptState) =>
{
try { sessionStorage.setItem(LOCK_KEY, JSON.stringify(state)); }
catch { }
};
export interface LoginViewProps
{
onAuthenticated: (ssoTicket: string) => void;
@@ -328,7 +245,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
if(rememberMe && rememberToken) window.localStorage.setItem('nitro.remember.token', rememberToken);
else window.localStorage.removeItem('nitro.remember.token');
}
catch { /* localStorage may be disabled in private mode */ }
catch {}
clearLock();
onAuthenticated(ssoTicket);
@@ -360,9 +277,12 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
}
}, [ submitting, username, password, rememberMe, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]);
const newsUrl = GetConfigurationValue<string>('login.news.endpoint', '/api/auth/news');
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', 'https://www.habbo.com/habbo-imaging/avatarimage?figure={figure}&gender={gender}&direction=2&head_direction=2&size=l');
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';
@@ -512,6 +432,8 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
{ rightRepeat ? <div className="login-right-repeat login-layer" style={ { backgroundImage: `url(${ rightRepeat })` } } /> : null }
{ right ? <div className="login-right login-layer" style={ { backgroundImage: `url(${ right })` } } /> : null }
<NewsWindow newsUrl={ newsUrl } />
<div className="login-stack">
<div className="nitro-login-card">
<div className="card-title">{ t('nitro.login.firsttime.title', 'First time here?') }</div>
@@ -628,804 +550,3 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
</div>
);
};
interface DialogSharedProps
{
onCancel: () => void;
submitting: boolean;
error: string | null;
info: string | null;
turnstileEnabled: boolean;
turnstileSiteKey: string;
}
interface RegisterDialogProps extends DialogSharedProps
{
onSubmit: (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; templateId: number | null; }, onDialogReset: () => void) => void;
onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>;
onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>;
onCheckServer: () => Promise<boolean>;
imagingUrl: string;
roomTemplatesUrl: string;
}
type RegisterStep = 'credentials' | 'avatar' | 'room';
interface RoomTemplate { templateId: number; title: string; description: string; thumbnail: string; }
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
type GenderKey = 'M' | 'F';
const PART_ROWS: string[] = [ 'hr', 'hd', 'ch', 'lg', 'sh' ];
const FALLBACK_DEFAULTS: Record<GenderKey, Record<string, { partId: number; colors: number[] }>> = {
M: {
hr: { partId: 180, colors: [ 45 ] },
hd: { partId: 180, colors: [ 1 ] },
ch: { partId: 215, colors: [ 66 ] },
lg: { partId: 270, colors: [ 82 ] },
sh: { partId: 290, colors: [ 80 ] }
},
F: {
hr: { partId: 515, colors: [ 45 ] },
hd: { partId: 600, colors: [ 1 ] },
ch: { partId: 660, colors: [ 100 ] },
lg: { partId: 716, colors: [ 82 ] },
sh: { partId: 725, colors: [ 61 ] }
}
};
const FALLBACK_HEX: Record<number, string> = {
1: '#ffcb98', 8: '#f4ac54', 14: '#f5da88', 19: '#b87560', 20: '#9c543f',
45: '#e8c498', 61: '#f1ece3', 66: '#96743d', 80: '#4f4d4d', 82: '#7f4f30',
92: '#ececec', 100: '#c7ddff', 106: '#c6e6bd', 110: '#91a7c8', 143: '#ffffff'
};
interface FigureColor { id: number; hexCode: string; club: number; selectable: boolean; }
interface FigurePalette { id: number; colors: FigureColor[]; }
interface FigureSet { id: number; gender: 'M' | 'F' | 'U'; club: number; selectable: boolean; }
interface FigureSetType { type: string; paletteId: number; sets: FigureSet[]; }
interface FigureData { palettes: FigurePalette[]; setTypes: FigureSetType[]; }
interface PartSelection { partId: number; colors: number[]; }
type FigureSelection = Record<string, PartSelection>;
const buildFigureString = (selection: FigureSelection): string =>
{
const seen = new Set<string>();
const parts: string[] = [];
const push = (setType: string) =>
{
if(seen.has(setType)) return;
seen.add(setType);
const sel = selection[setType];
if(!sel || sel.partId < 0) return;
const tail = (sel.colors && sel.colors.length) ? `-${ sel.colors.join('-') }` : '';
parts.push(`${ setType }-${ sel.partId }${ tail }`);
};
for(const setType of PART_ROWS) push(setType);
for(const setType of Object.keys(selection)) push(setType);
return parts.join('.');
};
const buildImagingUrl = (template: string, figure: string, gender: GenderKey): string =>
template
.replace(/\{figure\}/g, encodeURIComponent(figure))
.replace(/\{gender\}/g, gender)
.replace(/\{direction\}/g, '2');
const HEAD_ONLY_PARTS = new Set([ 'hr', 'hd' ]);
const buildPartPreviewUrl = (
template: string,
setType: string,
selection: FigureSelection,
gender: GenderKey
): string =>
{
const defaults = FALLBACK_DEFAULTS[gender];
const partSel = selection[setType] ?? defaults[setType];
const tail = (partSel.colors && partSel.colors.length) ? `-${ partSel.colors.join('-') }` : '';
const isHeadOnly = HEAD_ONLY_PARTS.has(setType);
let parts: string[];
if(isHeadOnly)
{
const hd = defaults.hd;
const pieces = new Map<string, string>();
pieces.set('hd', `hd-${ hd.partId }-${ hd.colors.join('-') }`);
pieces.set(setType, `${ setType }-${ partSel.partId }${ tail }`);
parts = Array.from(pieces.values());
}
else
{
const hd = defaults.hd;
parts = [
`hd-${ hd.partId }-${ hd.colors.join('-') }`,
`${ setType }-${ partSel.partId }${ tail }`
];
}
const figure = parts.join('.');
let url = template
.replace(/\{figure\}/g, encodeURIComponent(figure))
.replace(/\{gender\}/g, gender)
.replace(/\{direction\}/g, '2');
url = url.replace(/size=l/, 'size=s').replace(/size=m/, 'size=s');
if(!/size=/.test(url)) url += (url.includes('?') ? '&' : '?') + 'size=s';
if(isHeadOnly && !/headonly=/.test(url)) url += '&headonly=1';
return url;
};
const RegisterDialog: FC<RegisterDialogProps> = props =>
{
const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, imagingUrl, roomTemplatesUrl, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props;
const [ step, setStep ] = useState<RegisterStep>('credentials');
const [ email, setEmail ] = useState('');
const [ password, setPassword ] = useState('');
const [ confirm, setConfirm ] = useState('');
const [ username, setUsername ] = useState('');
const [ gender, setGender ] = useState<GenderKey>('F');
const [ selection, setSelection ] = useState<FigureSelection>(() => ({ ...FALLBACK_DEFAULTS.F }));
const [ localError, setLocalError ] = useState<string | null>(null);
const [ checking, setChecking ] = useState(false);
const [ turnstileToken, setTurnstileToken ] = useState('');
const [ resetSignal, setResetSignal ] = useState(0);
const [ serverReachable, setServerReachable ] = useState<boolean | null>(null);
const [ pingingServer, setPingingServer ] = useState(false);
const pingServer = useCallback(async () =>
{
setPingingServer(true);
try
{
const ok = await onCheckServer();
setServerReachable(ok);
return ok;
}
finally
{
setPingingServer(false);
}
}, [ onCheckServer ]);
useEffect(() =>
{
let cancelled = false;
(async () =>
{
const ok = await onCheckServer();
if(!cancelled) setServerReachable(ok);
})();
return () => { cancelled = true; };
}, [ onCheckServer ]);
const resetWidget = useCallback(() =>
{
setTurnstileToken('');
setResetSignal(prev => prev + 1);
}, []);
useEffect(() => { setLocalError(null); }, [ step ]);
const [ roomTemplates, setRoomTemplates ] = useState<RoomTemplate[] | null>(null);
const [ roomTemplatesError, setRoomTemplatesError ] = useState<string | null>(null);
const [ selectedTemplateId, setSelectedTemplateId ] = useState<number | null>(null);
const [ figureData, setFigureData ] = useState<FigureData | null>(null);
const figureDataUrlRaw = GetConfigurationValue<string>('avatar.figuredata.url', '');
const figureDataUrl = useMemo(() =>
{
if(!figureDataUrlRaw) return '';
try { return GetConfiguration().interpolate(figureDataUrlRaw); }
catch { return figureDataUrlRaw; }
}, [ figureDataUrlRaw ]);
useEffect(() =>
{
if(step !== 'avatar' || figureData || !figureDataUrl) return;
let cancelled = false;
fetch(figureDataUrl, { credentials: 'omit' })
.then(r => r.ok ? r.json() : null)
.then(json => { if(!cancelled && json) setFigureData(json as FigureData); })
.catch(() => { });
return () => { cancelled = true; };
}, [ step, figureData, figureDataUrl ]);
useEffect(() =>
{
if(step !== 'room' || roomTemplates !== null || !roomTemplatesUrl) return;
let cancelled = false;
setRoomTemplatesError(null);
fetch(roomTemplatesUrl, { credentials: 'include' })
.then(async r => {
if(!r.ok) throw new Error(`status ${ r.status }`);
return r.json();
})
.then(json => {
if(cancelled) return;
const list = Array.isArray((json as { templates?: unknown })?.templates)
? (json as { templates: RoomTemplate[] }).templates
: [];
setRoomTemplates(list);
})
.catch(() => {
if(cancelled) return;
setRoomTemplates([]);
setRoomTemplatesError(t('nitro.login.register.room.error', 'Could not load room options. You can still skip this step.'));
});
return () => { cancelled = true; };
}, [ step, roomTemplates, roomTemplatesUrl ]);
const partOptions = useMemo(() =>
{
const result: Record<string, Record<GenderKey, number[]>> = {};
if(!figureData) return result;
for(const st of figureData.setTypes)
{
if(!PART_ROWS.includes(st.type)) continue;
const forGender = (g: GenderKey) => st.sets
.filter(s => s.selectable && s.club === 0 && (s.gender === g || s.gender === 'U'))
.map(s => s.id);
result[st.type] = { M: forGender('M'), F: forGender('F') };
}
return result;
}, [ figureData ]);
const paletteOptions = useMemo(() =>
{
const result: Record<string, { id: number; hex: string }[]> = {};
if(!figureData) return result;
for(const st of figureData.setTypes)
{
if(!PART_ROWS.includes(st.type)) continue;
const palette = figureData.palettes.find(p => p.id === st.paletteId);
if(!palette) { result[st.type] = []; continue; }
result[st.type] = palette.colors
.filter(c => c.selectable && c.club === 0)
.map(c => ({ id: c.id, hex: '#' + c.hexCode.toUpperCase() }));
}
return result;
}, [ figureData ]);
const hexFor = useCallback((setType: string, colorId: number): string =>
{
const list = paletteOptions[setType];
if(list)
{
const found = list.find(c => c.id === colorId);
if(found) return found.hex;
}
return FALLBACK_HEX[colorId] || '#c9c9c9';
}, [ paletteOptions ]);
const [ hotLooks, setHotLooks ] = useState<{ gender: GenderKey; figure: string }[]>([]);
const [ hotLookIndex, setHotLookIndex ] = useState(-1);
useEffect(() =>
{
if(step !== 'avatar' || hotLooks.length) return;
let cancelled = false;
fetch('hotlooks.json', { credentials: 'omit' })
.then(r => r.ok ? r.json() : null)
.then((json: unknown) =>
{
if(cancelled || !Array.isArray(json)) return;
const parsed: { gender: GenderKey; figure: string }[] = [];
for(const entry of json as Record<string, unknown>[])
{
const rawGender = typeof entry._gender === 'string' ? entry._gender.toUpperCase() : '';
const figure = typeof entry._figure === 'string' ? entry._figure : '';
if((rawGender !== 'M' && rawGender !== 'F') || !figure) continue;
parsed.push({ gender: rawGender as GenderKey, figure });
}
if(parsed.length) setHotLooks(parsed);
})
.catch(() => { });
return () => { cancelled = true; };
}, [ step, hotLooks.length ]);
const applyLook = useCallback((figure: string, lookGender: GenderKey) =>
{
const next: FigureSelection = {};
for(const setPart of figure.split('.'))
{
const bits = setPart.split('-');
if(bits.length < 2) continue;
const setType = bits[0];
const partId = parseInt(bits[1], 10);
if(!setType || Number.isNaN(partId)) continue;
const colors: number[] = [];
for(let i = 2; i < bits.length; i++)
{
const c = parseInt(bits[i], 10);
if(!Number.isNaN(c)) colors.push(c);
}
next[setType] = { partId, colors };
}
for(const setType of PART_ROWS)
{
if(!next[setType]) next[setType] = { ...FALLBACK_DEFAULTS[lookGender][setType] };
}
setGender(lookGender);
setSelection(next);
}, []);
const cycleHotLook = useCallback(() =>
{
if(!hotLooks.length) return;
const nextIdx = (hotLookIndex + 1) % hotLooks.length;
setHotLookIndex(nextIdx);
const look = hotLooks[nextIdx];
applyLook(look.figure, look.gender);
}, [ hotLooks, hotLookIndex, applyLook ]);
const credentialsValid =
EMAIL_REGEX.test(email.trim()) &&
password.length >= 8 &&
password === confirm;
const handleCredentialsNext = async (event: FormEvent<HTMLFormElement>) =>
{
event.preventDefault();
setLocalError(null);
if(!email.trim() || !password || !confirm)
{
setLocalError(t('nitro.login.error.missing_fields', 'Please fill in every field.'));
return;
}
if(!EMAIL_REGEX.test(email.trim()))
{
setLocalError(t('nitro.login.error.invalid_email', 'Please enter a valid email address.'));
return;
}
if(password.length < 8)
{
setLocalError(t('nitro.login.error.password_too_short', 'Your password must be at least 8 characters.'));
return;
}
if(password !== confirm)
{
setLocalError(t('nitro.login.error.password_mismatch', 'Passwords do not match.'));
return;
}
setChecking(true);
try
{
const serverOk = await pingServer();
if(!serverOk)
{
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 || t('nitro.login.error.email_taken', 'This email is already in use.'));
return;
}
setStep('avatar');
}
finally
{
setChecking(false);
}
};
const applyGender = (newGender: GenderKey) =>
{
setGender(newGender);
setSelection({ ...FALLBACK_DEFAULTS[newGender] });
setHotLookIndex(-1);
};
const getPartList = useCallback((setType: string): number[] =>
{
const loaded = partOptions[setType]?.[gender];
if(loaded && loaded.length) return loaded;
const fallback = FALLBACK_DEFAULTS[gender][setType]?.partId;
return fallback !== undefined ? [ fallback ] : [];
}, [ partOptions, gender ]);
const getColorList = useCallback((setType: string): number[] =>
{
const loaded = paletteOptions[setType];
if(loaded && loaded.length) return loaded.map(c => c.id);
const fallback = FALLBACK_DEFAULTS[gender][setType]?.colors?.[0];
return fallback !== undefined ? [ fallback ] : [];
}, [ paletteOptions, gender ]);
const cyclePart = (setType: string, direction: 1 | -1) =>
{
const options = getPartList(setType);
if(!options.length) return;
const current = selection[setType]?.partId ?? options[0];
const idx = options.indexOf(current);
const nextIdx = ((idx === -1 ? 0 : idx) + direction + options.length) % options.length;
const colors = getColorList(setType);
setSelection(prev => ({
...prev,
[setType]: {
partId: options[nextIdx],
colors: prev[setType]?.colors ?? [ colors[0] ?? 0 ]
}
}));
};
const cycleColor = (setType: string, direction: 1 | -1) =>
{
const colors = getColorList(setType);
if(!colors.length) return;
const currentColor = selection[setType]?.colors?.[0] ?? colors[0];
const idx = colors.indexOf(currentColor);
const nextIdx = ((idx === -1 ? 0 : idx) + direction + colors.length) % colors.length;
const parts = getPartList(setType);
setSelection(prev => ({
...prev,
[setType]: {
partId: prev[setType]?.partId ?? parts[0],
colors: [ colors[nextIdx] ]
}
}));
};
const figure = buildFigureString(selection);
const previewSrc = buildImagingUrl(imagingUrl, figure, gender);
const handleAvatarSubmit = async (event: FormEvent<HTMLFormElement>) =>
{
event.preventDefault();
setLocalError(null);
const trimmed = username.trim();
if(!trimmed)
{
setLocalError(t('nitro.login.error.missing_username', 'Please choose a Habbo name.'));
return;
}
if(trimmed.length < 3 || trimmed.length > 16)
{
setLocalError(t('nitro.login.error.username_length', 'Habbo name must be 316 characters.'));
return;
}
if(turnstileEnabled && !turnstileToken)
{
setLocalError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
return;
}
setChecking(true);
try
{
const serverOk = await pingServer();
if(!serverOk)
{
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 || t('nitro.login.error.username_taken', 'This Habbo name is already taken.'));
return;
}
}
finally
{
setChecking(false);
}
setStep('room');
};
const submitRegistration = (templateId: number | null) =>
{
onSubmit({
username: username.trim(),
email: email.trim(),
password,
figure,
gender,
turnstileToken,
templateId
}, resetWidget);
};
const handleRoomSubmit = (event: FormEvent<HTMLFormElement>) =>
{
event.preventDefault();
setLocalError(null);
submitRegistration(selectedTemplateId);
};
const busy = submitting || checking || pingingServer;
const serverOffline = serverReachable === false;
return (
<div className="nitro-login-modal">
<div className={ `dialog ${ step === 'avatar' ? 'dialog-avatar' : '' } ${ step === 'room' ? 'dialog-room' : '' }` }>
<div className="nitro-login-card">
<div className="card-title">
<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">
{ 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">
{ t('nitro.login.server.offline.long', '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 ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry') }
</button>
</div>
}
<div className="field">
<label htmlFor="register-email">{ t('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">{ 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">{ t('nitro.login.register.confirm.label', 'Confirm password') }</label>
<input id="register-confirm" type="password" maxLength={ 128 } autoComplete="new-password"
value={ confirm } onChange={ e => setConfirm(e.target.value) } />
</div>
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
{ info && <div className="info-line">{ info }</div> }
<div className="step-footer">
<span className="step-indicator">1/3</span>
<button type="submit" className="ok-button" disabled={ !credentialsValid || busy || serverOffline }>
{ checking || pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
</button>
</div>
</form>
}
{ step === 'avatar' &&
<form className="card-body" onSubmit={ handleAvatarSubmit } autoComplete="on">
<div className="register-intro">
{ 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">
{ t('nitro.login.server.offline.long', '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 ? 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={ 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>{ t('avatareditor.generic.girl', 'Girl') }</span>
</label>
<label>
<input type="radio" name="register-gender" checked={ gender === 'M' } onChange={ () => applyGender('M') } />
<span>{ t('avatareditor.generic.boy', 'Boy') }</span>
</label>
</div>
<div className="avatar-builder">
<div className="avatar-part-col">
{ PART_ROWS.map(setType => {
const partPreviewSrc = buildPartPreviewUrl(imagingUrl, setType, selection, gender);
return (
<div className="avatar-part-row" key={ `part-${ setType }` }>
<button type="button" className="arrow-btn" aria-label={ `Previous ${ setType }` }
onClick={ () => cyclePart(setType, -1) }>&lsaquo;</button>
<div className={ `part-preview part-preview-${ setType }` }>
<img src={ partPreviewSrc } alt={ `${ setType } preview` } onError={ e => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } />
</div>
<button type="button" className="arrow-btn" aria-label={ `Next ${ setType }` }
onClick={ () => cyclePart(setType, 1) }>&rsaquo;</button>
</div>
);
}) }
</div>
<div className="avatar-preview">
<img src={ previewSrc } alt="Habbo preview" onError={ e => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } />
</div>
<div className="avatar-color-col">
{ PART_ROWS.map(setType => {
const fallbackColor = FALLBACK_DEFAULTS[gender][setType]?.colors?.[0] ?? 0;
const currentColor = selection[setType]?.colors?.[0] ?? fallbackColor;
const swatchHex = hexFor(setType, currentColor);
return (
<div className="avatar-color-row" key={ `color-${ setType }` }>
<button type="button" className="arrow-btn" aria-label={ `Previous color ${ setType }` }
onClick={ () => cycleColor(setType, -1) }>&lsaquo;</button>
<div className="color-swatch" style={ { background: swatchHex } } />
<button type="button" className="arrow-btn" aria-label={ `Next color ${ setType }` }
onClick={ () => cycleColor(setType, 1) }>&rsaquo;</button>
</div>
);
}) }
</div>
</div>
<div className="hot-looks-row">
<button type="button" className="ok-button hot-looks-button"
onClick={ cycleHotLook }
disabled={ !hotLooks.length || busy }
title={ hotLooks.length
? t('nitro.login.register.hotlooks.count', '%count% looks available', [ 'count' ], [ String(hotLooks.length) ])
: t('nitro.login.register.hotlooks.none', 'No hot looks loaded') }>
{ t('avatareditor.category.hotlooks', 'Hot Looks') }{ hotLookIndex >= 0 && hotLooks.length ? ` (${ hotLookIndex + 1 }/${ hotLooks.length })` : '' }
</button>
</div>
{ turnstileEnabled &&
<TurnstileWidget
siteKey={ turnstileSiteKey }
size="compact"
onToken={ setTurnstileToken }
onExpire={ () => setTurnstileToken('') }
onError={ () => setTurnstileToken('') }
resetSignal={ resetSignal }
/> }
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
{ 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 }>{ t('generic.back', 'Back') }</button>
<span className="step-indicator">2/3</span>
<button type="submit" className="ok-button" disabled={ !username.trim() || busy || serverOffline }>
{ (checking || pingingServer) ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
</button>
</div>
</form>
}
{ step === 'room' &&
<form className="card-body" onSubmit={ handleRoomSubmit } autoComplete="off">
<div className="register-intro">
{ t('nitro.login.register.intro.room', 'Last step — pick a starter room, or skip and create your own later.') }
</div>
{ serverOffline &&
<div className="error-line server-offline">
{ t('nitro.login.server.offline.long', '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 ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry') }
</button>
</div>
}
<div className="room-templates-list">
<label className={ `room-template-option room-template-skip ${ selectedTemplateId === null ? 'selected' : '' }` }>
<input type="radio" name="register-room-template" checked={ selectedTemplateId === null }
onChange={ () => setSelectedTemplateId(null) } />
<div className="room-template-body">
<div className="room-template-title">{ t('nitro.login.register.room.skip.title', 'I\'m okay — I\'ll create my own rooms') }</div>
<div className="room-template-description">{ t('nitro.login.register.room.skip.description', 'Skip for now and start with an empty hotel inventory.') }</div>
</div>
</label>
{ roomTemplates === null && <div className="info-line">{ t('nitro.login.register.room.loading', 'Loading rooms…') }</div> }
{ roomTemplates !== null && roomTemplates.map(template => (
<label key={ template.templateId }
className={ `room-template-option ${ selectedTemplateId === template.templateId ? 'selected' : '' }` }>
<input type="radio" name="register-room-template" checked={ selectedTemplateId === template.templateId }
onChange={ () => setSelectedTemplateId(template.templateId) } />
{ template.thumbnail &&
<img className="room-template-thumb" src={ template.thumbnail } alt={ template.title }
onError={ e => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> }
<div className="room-template-body">
<div className="room-template-title">{ template.title }</div>
{ template.description &&
<div className="room-template-description">{ template.description }</div> }
</div>
</label>
)) }
</div>
{ roomTemplatesError && <div className="error-line">{ roomTemplatesError }</div> }
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
{ info && <div className="info-line">{ info }</div> }
<div className="step-footer step-footer-split">
<button type="button" className="ok-button back-button" onClick={ () => setStep('avatar') } disabled={ busy }>{ t('generic.back', 'Back') }</button>
<span className="step-indicator">3/3</span>
<button type="submit" className="ok-button" disabled={ busy || serverOffline }>
{ submitting ? t('nitro.login.register.creating', 'Creating…') : t('nitro.login.register.finish', 'Finish') }
</button>
</div>
</form>
}
</div>
</div>
</div>
);
};
interface ForgotDialogProps extends DialogSharedProps
{
onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => void;
}
const ForgotDialog: FC<ForgotDialogProps> = props =>
{
const { onCancel, onSubmit, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props;
const [ email, setEmail ] = useState('');
const [ localError, setLocalError ] = useState<string | null>(null);
const [ turnstileToken, setTurnstileToken ] = useState('');
const [ resetSignal, setResetSignal ] = useState(0);
const resetWidget = useCallback(() =>
{
setTurnstileToken('');
setResetSignal(prev => prev + 1);
}, []);
const handle = (event: FormEvent<HTMLFormElement>) =>
{
event.preventDefault();
setLocalError(null);
if(!email.trim())
{
setLocalError(t('nitro.login.error.missing_email', 'Please enter your email address.'));
return;
}
onSubmit({ email: email.trim(), turnstileToken }, resetWidget);
};
return (
<div className="nitro-login-modal">
<div className="dialog">
<div className="nitro-login-card">
<div className="card-title">
<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">{ 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>
{ turnstileEnabled &&
<TurnstileWidget
siteKey={ turnstileSiteKey }
size="compact"
onToken={ setTurnstileToken }
onExpire={ () => setTurnstileToken('') }
onError={ () => setTurnstileToken('') }
resetSignal={ resetSignal }
/> }
{ (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 }>{ t('nitro.login.forgot.send', 'Send email') }</button>
</div>
</form>
</div>
</div>
</div>
);
};
@@ -0,0 +1,72 @@
import { FC, FormEvent, useCallback, useState } from 'react';
import { TurnstileWidget } from '../TurnstileWidget';
import { t } from '../utils/i18n';
import { DialogSharedProps } from './shared';
export interface ForgotDialogProps extends DialogSharedProps
{
onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => void;
}
export const ForgotDialog: FC<ForgotDialogProps> = props =>
{
const { onCancel, onSubmit, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props;
const [ email, setEmail ] = useState('');
const [ localError, setLocalError ] = useState<string | null>(null);
const [ turnstileToken, setTurnstileToken ] = useState('');
const [ resetSignal, setResetSignal ] = useState(0);
const resetWidget = useCallback(() =>
{
setTurnstileToken('');
setResetSignal(prev => prev + 1);
}, []);
const handle = (event: FormEvent<HTMLFormElement>) =>
{
event.preventDefault();
setLocalError(null);
if(!email.trim())
{
setLocalError(t('nitro.login.error.missing_email', 'Please enter your email address.'));
return;
}
onSubmit({ email: email.trim(), turnstileToken }, resetWidget);
};
return (
<div className="nitro-login-modal">
<div className="dialog">
<div className="nitro-login-card">
<div className="card-title">
<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">{ 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>
{ turnstileEnabled &&
<TurnstileWidget
siteKey={ turnstileSiteKey }
size="compact"
onToken={ setTurnstileToken }
onExpire={ () => setTurnstileToken('') }
onError={ () => setTurnstileToken('') }
resetSignal={ resetSignal }
/> }
{ (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 }>{ t('nitro.login.forgot.send', 'Send email') }</button>
</div>
</form>
</div>
</div>
</div>
);
};
@@ -0,0 +1,122 @@
import { FC, useEffect, useState } from 'react';
import { t } from '../utils/i18n';
import { resolveNewsImage, resolveNewsLink } from '../utils/news';
interface NewsItem
{
id: number;
title: string;
body: string;
image: string | null;
linkText: string;
linkUrl: string;
}
interface NewsWindowProps { newsUrl: string; }
const NEWS_AUTO_ADVANCE_MS = 10000;
export const NewsWindow: FC<NewsWindowProps> = ({ newsUrl }) =>
{
const [ items, setItems ] = useState<NewsItem[] | null>(null);
const [ failed, setFailed ] = useState(false);
const [ index, setIndex ] = useState(0);
const [ autoTick, setAutoTick ] = useState(0);
useEffect(() =>
{
if(!newsUrl) { setFailed(true); return; }
let cancelled = false;
fetch(newsUrl, { credentials: 'omit' })
.then(async r =>
{
if(!r.ok) throw new Error('status ' + r.status);
return r.json();
})
.then((json: unknown) =>
{
if(cancelled) return;
const list = Array.isArray((json as { news?: unknown })?.news)
? (json as { news: NewsItem[] }).news
: [];
setItems(list);
})
.catch(() => { if(!cancelled) setFailed(true); });
return () => { cancelled = true; };
}, [ newsUrl ]);
useEffect(() =>
{
if(!items || items.length < 2) return;
const id = window.setTimeout(() =>
{
setIndex(i => (i + 1) % items.length);
}, NEWS_AUTO_ADVANCE_MS);
return () => window.clearTimeout(id);
}, [ items, index, autoTick ]);
if(failed) return null;
if(!items || !items.length) return null;
const current = items[Math.min(index, items.length - 1)];
const hasMany = items.length > 1;
const bumpAuto = () => setAutoTick(t => t + 1);
const prev = () => { setIndex(i => (i - 1 + items.length) % items.length); bumpAuto(); };
const next = () => { setIndex(i => (i + 1) % items.length); bumpAuto(); };
const safeLinkUrl = resolveNewsLink(current.linkUrl);
const safeImageSrc = resolveNewsImage(current.image);
const openLink = () =>
{
if(!safeLinkUrl) return;
window.open(safeLinkUrl, '_blank', 'noopener,noreferrer');
};
return (
<div className="login-news-stack">
<div className="news-card-wrapper" key={ current.id }>
<span className="news-sparkle news-sparkle-1" aria-hidden="true"></span>
<span className="news-sparkle news-sparkle-2" aria-hidden="true"></span>
<span className="news-sparkle news-sparkle-3" aria-hidden="true"></span>
<div className="news-new-badge" aria-hidden="true">
<span>{ t('nitro.login.news.new', 'NEW!') }</span>
</div>
<div className="nitro-login-card nitro-news-card">
<div className="card-title news-ribbon">
<span className="news-ribbon-text">{ t('nitro.login.news.title', 'Hotel News') }</span>
</div>
<div className="card-body news-body">
{ safeImageSrc &&
<div className="news-image">
<img
src={ safeImageSrc }
alt={ current.title || 'news' }
onError={ e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; } }
/>
</div>
}
<div className="news-headline">{ current.title }</div>
{ current.body &&
<div className="news-text">{ current.body }</div> }
<div className="news-footer">
{ current.linkText && safeLinkUrl
? <button type="button" className="ok-button news-link-button" onClick={ openLink }>{ current.linkText }</button>
: <span /> }
{ hasMany &&
<div className="news-pager">
<button type="button" className="arrow-btn" aria-label="Previous news" onClick={ prev }>&lsaquo;</button>
<span className="news-counter">{ index + 1 }/{ items.length }</span>
<button type="button" className="arrow-btn" aria-label="Next news" onClick={ next }>&rsaquo;</button>
</div>
}
</div>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,633 @@
import { FC, FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { GetConfiguration } from '@nitrots/nitro-renderer';
import { GetConfigurationValue } from '../../../api';
import { TurnstileWidget } from '../TurnstileWidget';
import { t } from '../utils/i18n';
import {
buildFigureString,
buildImagingUrl,
buildPartPreviewUrl,
EMAIL_REGEX,
FALLBACK_DEFAULTS,
FALLBACK_HEX,
FigureData,
FigureSelection,
GenderKey,
PART_ROWS
} from '../utils/figure';
import { DialogSharedProps } from './shared';
export interface RegisterDialogProps extends DialogSharedProps
{
onSubmit: (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; templateId: number | null; }, onDialogReset: () => void) => void;
onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>;
onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>;
onCheckServer: () => Promise<boolean>;
imagingUrl: string;
roomTemplatesUrl: string;
}
type RegisterStep = 'credentials' | 'avatar' | 'room';
interface RoomTemplate { templateId: number; title: string; description: string; thumbnail: string; }
export const RegisterDialog: FC<RegisterDialogProps> = props =>
{
const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, imagingUrl, roomTemplatesUrl, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props;
const [ step, setStep ] = useState<RegisterStep>('credentials');
const [ email, setEmail ] = useState('');
const [ password, setPassword ] = useState('');
const [ confirm, setConfirm ] = useState('');
const [ username, setUsername ] = useState('');
const [ gender, setGender ] = useState<GenderKey>('F');
const [ selection, setSelection ] = useState<FigureSelection>(() => ({ ...FALLBACK_DEFAULTS.F }));
const [ localError, setLocalError ] = useState<string | null>(null);
const [ checking, setChecking ] = useState(false);
const [ turnstileToken, setTurnstileToken ] = useState('');
const [ resetSignal, setResetSignal ] = useState(0);
const [ serverReachable, setServerReachable ] = useState<boolean | null>(null);
const [ pingingServer, setPingingServer ] = useState(false);
const pingServer = useCallback(async () =>
{
setPingingServer(true);
try
{
const ok = await onCheckServer();
setServerReachable(ok);
return ok;
}
finally
{
setPingingServer(false);
}
}, [ onCheckServer ]);
useEffect(() =>
{
let cancelled = false;
(async () =>
{
const ok = await onCheckServer();
if(!cancelled) setServerReachable(ok);
})();
return () => { cancelled = true; };
}, [ onCheckServer ]);
const resetWidget = useCallback(() =>
{
setTurnstileToken('');
setResetSignal(prev => prev + 1);
}, []);
useEffect(() => { setLocalError(null); }, [ step ]);
const [ roomTemplates, setRoomTemplates ] = useState<RoomTemplate[] | null>(null);
const [ roomTemplatesError, setRoomTemplatesError ] = useState<string | null>(null);
const [ selectedTemplateId, setSelectedTemplateId ] = useState<number | null>(null);
const [ figureData, setFigureData ] = useState<FigureData | null>(null);
const figureDataUrlRaw = GetConfigurationValue<string>('avatar.figuredata.url', '');
const figureDataUrl = useMemo(() =>
{
if(!figureDataUrlRaw) return '';
try { return GetConfiguration().interpolate(figureDataUrlRaw); }
catch { return figureDataUrlRaw; }
}, [ figureDataUrlRaw ]);
useEffect(() =>
{
if(step !== 'avatar' || figureData || !figureDataUrl) return;
let cancelled = false;
fetch(figureDataUrl, { credentials: 'omit' })
.then(r => r.ok ? r.json() : null)
.then(json => { if(!cancelled && json) setFigureData(json as FigureData); })
.catch(() => { });
return () => { cancelled = true; };
}, [ step, figureData, figureDataUrl ]);
useEffect(() =>
{
if(step !== 'room' || roomTemplates !== null || !roomTemplatesUrl) return;
let cancelled = false;
setRoomTemplatesError(null);
fetch(roomTemplatesUrl, { credentials: 'include' })
.then(async r => {
if(!r.ok) throw new Error(`status ${ r.status }`);
return r.json();
})
.then(json => {
if(cancelled) return;
const list = Array.isArray((json as { templates?: unknown })?.templates)
? (json as { templates: RoomTemplate[] }).templates
: [];
setRoomTemplates(list);
})
.catch(() => {
if(cancelled) return;
setRoomTemplates([]);
setRoomTemplatesError(t('nitro.login.register.room.error', 'Could not load room options. You can still skip this step.'));
});
return () => { cancelled = true; };
}, [ step, roomTemplates, roomTemplatesUrl ]);
const partOptions = useMemo(() =>
{
const result: Record<string, Record<GenderKey, number[]>> = {};
if(!figureData) return result;
for(const st of figureData.setTypes)
{
if(!PART_ROWS.includes(st.type)) continue;
const forGender = (g: GenderKey) => st.sets
.filter(s => s.selectable && s.club === 0 && (s.gender === g || s.gender === 'U'))
.map(s => s.id);
result[st.type] = { M: forGender('M'), F: forGender('F') };
}
return result;
}, [ figureData ]);
const paletteOptions = useMemo(() =>
{
const result: Record<string, { id: number; hex: string }[]> = {};
if(!figureData) return result;
for(const st of figureData.setTypes)
{
if(!PART_ROWS.includes(st.type)) continue;
const palette = figureData.palettes.find(p => p.id === st.paletteId);
if(!palette) { result[st.type] = []; continue; }
result[st.type] = palette.colors
.filter(c => c.selectable && c.club === 0)
.map(c => ({ id: c.id, hex: '#' + c.hexCode.toUpperCase() }));
}
return result;
}, [ figureData ]);
const hexFor = useCallback((setType: string, colorId: number): string =>
{
const list = paletteOptions[setType];
if(list)
{
const found = list.find(c => c.id === colorId);
if(found) return found.hex;
}
return FALLBACK_HEX[colorId] || '#c9c9c9';
}, [ paletteOptions ]);
const [ hotLooks, setHotLooks ] = useState<{ gender: GenderKey; figure: string }[]>([]);
const [ hotLookIndex, setHotLookIndex ] = useState(-1);
useEffect(() =>
{
if(step !== 'avatar' || hotLooks.length) return;
let cancelled = false;
fetch('hotlooks.json', { credentials: 'omit' })
.then(r => r.ok ? r.json() : null)
.then((json: unknown) =>
{
if(cancelled || !Array.isArray(json)) return;
const parsed: { gender: GenderKey; figure: string }[] = [];
for(const entry of json as Record<string, unknown>[])
{
const rawGender = typeof entry._gender === 'string' ? entry._gender.toUpperCase() : '';
const figure = typeof entry._figure === 'string' ? entry._figure : '';
if((rawGender !== 'M' && rawGender !== 'F') || !figure) continue;
parsed.push({ gender: rawGender as GenderKey, figure });
}
if(parsed.length) setHotLooks(parsed);
})
.catch(() => { });
return () => { cancelled = true; };
}, [ step, hotLooks.length ]);
const applyLook = useCallback((figure: string, lookGender: GenderKey) =>
{
const next: FigureSelection = {};
for(const setPart of figure.split('.'))
{
const bits = setPart.split('-');
if(bits.length < 2) continue;
const setType = bits[0];
const partId = parseInt(bits[1], 10);
if(!setType || Number.isNaN(partId)) continue;
const colors: number[] = [];
for(let i = 2; i < bits.length; i++)
{
const c = parseInt(bits[i], 10);
if(!Number.isNaN(c)) colors.push(c);
}
next[setType] = { partId, colors };
}
for(const setType of PART_ROWS)
{
if(!next[setType]) next[setType] = { ...FALLBACK_DEFAULTS[lookGender][setType] };
}
setGender(lookGender);
setSelection(next);
}, []);
const cycleHotLook = useCallback(() =>
{
if(!hotLooks.length) return;
const nextIdx = (hotLookIndex + 1) % hotLooks.length;
setHotLookIndex(nextIdx);
const look = hotLooks[nextIdx];
applyLook(look.figure, look.gender);
}, [ hotLooks, hotLookIndex, applyLook ]);
const credentialsValid =
EMAIL_REGEX.test(email.trim()) &&
password.length >= 8 &&
password === confirm;
const handleCredentialsNext = async (event: FormEvent<HTMLFormElement>) =>
{
event.preventDefault();
setLocalError(null);
if(!email.trim() || !password || !confirm)
{
setLocalError(t('nitro.login.error.missing_fields', 'Please fill in every field.'));
return;
}
if(!EMAIL_REGEX.test(email.trim()))
{
setLocalError(t('nitro.login.error.invalid_email', 'Please enter a valid email address.'));
return;
}
if(password.length < 8)
{
setLocalError(t('nitro.login.error.password_too_short', 'Your password must be at least 8 characters.'));
return;
}
if(password !== confirm)
{
setLocalError(t('nitro.login.error.password_mismatch', 'Passwords do not match.'));
return;
}
setChecking(true);
try
{
const serverOk = await pingServer();
if(!serverOk)
{
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 || t('nitro.login.error.email_taken', 'This email is already in use.'));
return;
}
setStep('avatar');
}
finally
{
setChecking(false);
}
};
const applyGender = (newGender: GenderKey) =>
{
setGender(newGender);
setSelection({ ...FALLBACK_DEFAULTS[newGender] });
setHotLookIndex(-1);
};
const getPartList = useCallback((setType: string): number[] =>
{
const loaded = partOptions[setType]?.[gender];
if(loaded && loaded.length) return loaded;
const fallback = FALLBACK_DEFAULTS[gender][setType]?.partId;
return fallback !== undefined ? [ fallback ] : [];
}, [ partOptions, gender ]);
const getColorList = useCallback((setType: string): number[] =>
{
const loaded = paletteOptions[setType];
if(loaded && loaded.length) return loaded.map(c => c.id);
const fallback = FALLBACK_DEFAULTS[gender][setType]?.colors?.[0];
return fallback !== undefined ? [ fallback ] : [];
}, [ paletteOptions, gender ]);
const cyclePart = (setType: string, direction: 1 | -1) =>
{
const options = getPartList(setType);
if(!options.length) return;
const current = selection[setType]?.partId ?? options[0];
const idx = options.indexOf(current);
const nextIdx = ((idx === -1 ? 0 : idx) + direction + options.length) % options.length;
const colors = getColorList(setType);
setSelection(prev => ({
...prev,
[setType]: {
partId: options[nextIdx],
colors: prev[setType]?.colors ?? [ colors[0] ?? 0 ]
}
}));
};
const cycleColor = (setType: string, direction: 1 | -1) =>
{
const colors = getColorList(setType);
if(!colors.length) return;
const currentColor = selection[setType]?.colors?.[0] ?? colors[0];
const idx = colors.indexOf(currentColor);
const nextIdx = ((idx === -1 ? 0 : idx) + direction + colors.length) % colors.length;
const parts = getPartList(setType);
setSelection(prev => ({
...prev,
[setType]: {
partId: prev[setType]?.partId ?? parts[0],
colors: [ colors[nextIdx] ]
}
}));
};
const figure = buildFigureString(selection);
const previewSrc = buildImagingUrl(imagingUrl, figure, gender);
const handleAvatarSubmit = async (event: FormEvent<HTMLFormElement>) =>
{
event.preventDefault();
setLocalError(null);
const trimmed = username.trim();
if(!trimmed)
{
setLocalError(t('nitro.login.error.missing_username', 'Please choose a Habbo name.'));
return;
}
if(trimmed.length < 3 || trimmed.length > 16)
{
setLocalError(t('nitro.login.error.username_length', 'Habbo name must be 316 characters.'));
return;
}
if(turnstileEnabled && !turnstileToken)
{
setLocalError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
return;
}
setChecking(true);
try
{
const serverOk = await pingServer();
if(!serverOk)
{
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 || t('nitro.login.error.username_taken', 'This Habbo name is already taken.'));
return;
}
}
finally
{
setChecking(false);
}
setStep('room');
};
const submitRegistration = (templateId: number | null) =>
{
onSubmit({
username: username.trim(),
email: email.trim(),
password,
figure,
gender,
turnstileToken,
templateId
}, resetWidget);
};
const handleRoomSubmit = (event: FormEvent<HTMLFormElement>) =>
{
event.preventDefault();
setLocalError(null);
submitRegistration(selectedTemplateId);
};
const busy = submitting || checking || pingingServer;
const serverOffline = serverReachable === false;
return (
<div className="nitro-login-modal">
<div className={ `dialog ${ step === 'avatar' ? 'dialog-avatar' : '' } ${ step === 'room' ? 'dialog-room' : '' }` }>
<div className="nitro-login-card">
<div className="card-title">
<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">
{ 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">
{ t('nitro.login.server.offline.long', '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 ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry') }
</button>
</div>
}
<div className="field">
<label htmlFor="register-email">{ t('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">{ 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">{ t('nitro.login.register.confirm.label', 'Confirm password') }</label>
<input id="register-confirm" type="password" maxLength={ 128 } autoComplete="new-password"
value={ confirm } onChange={ e => setConfirm(e.target.value) } />
</div>
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
{ info && <div className="info-line">{ info }</div> }
<div className="step-footer">
<span className="step-indicator">1/3</span>
<button type="submit" className="ok-button" disabled={ !credentialsValid || busy || serverOffline }>
{ checking || pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
</button>
</div>
</form>
}
{ step === 'avatar' &&
<form className="card-body" onSubmit={ handleAvatarSubmit } autoComplete="on">
<div className="register-intro">
{ 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">
{ t('nitro.login.server.offline.long', '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 ? 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={ 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>{ t('avatareditor.generic.girl', 'Girl') }</span>
</label>
<label>
<input type="radio" name="register-gender" checked={ gender === 'M' } onChange={ () => applyGender('M') } />
<span>{ t('avatareditor.generic.boy', 'Boy') }</span>
</label>
</div>
<div className="avatar-builder">
<div className="avatar-part-col">
{ PART_ROWS.map(setType => {
const partPreviewSrc = buildPartPreviewUrl(imagingUrl, setType, selection, gender);
return (
<div className="avatar-part-row" key={ `part-${ setType }` }>
<button type="button" className="arrow-btn" aria-label={ `Previous ${ setType }` }
onClick={ () => cyclePart(setType, -1) }>&lsaquo;</button>
<div className={ `part-preview part-preview-${ setType }` }>
<img src={ partPreviewSrc } alt={ `${ setType } preview` } onError={ e => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } />
</div>
<button type="button" className="arrow-btn" aria-label={ `Next ${ setType }` }
onClick={ () => cyclePart(setType, 1) }>&rsaquo;</button>
</div>
);
}) }
</div>
<div className="avatar-preview">
<img src={ previewSrc } alt="Habbo preview" onError={ e => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } />
</div>
<div className="avatar-color-col">
{ PART_ROWS.map(setType => {
const fallbackColor = FALLBACK_DEFAULTS[gender][setType]?.colors?.[0] ?? 0;
const currentColor = selection[setType]?.colors?.[0] ?? fallbackColor;
const swatchHex = hexFor(setType, currentColor);
return (
<div className="avatar-color-row" key={ `color-${ setType }` }>
<button type="button" className="arrow-btn" aria-label={ `Previous color ${ setType }` }
onClick={ () => cycleColor(setType, -1) }>&lsaquo;</button>
<div className="color-swatch" style={ { background: swatchHex } } />
<button type="button" className="arrow-btn" aria-label={ `Next color ${ setType }` }
onClick={ () => cycleColor(setType, 1) }>&rsaquo;</button>
</div>
);
}) }
</div>
</div>
<div className="hot-looks-row">
<button type="button" className="ok-button hot-looks-button"
onClick={ cycleHotLook }
disabled={ !hotLooks.length || busy }
title={ hotLooks.length
? t('nitro.login.register.hotlooks.count', '%count% looks available', [ 'count' ], [ String(hotLooks.length) ])
: t('nitro.login.register.hotlooks.none', 'No hot looks loaded') }>
{ t('avatareditor.category.hotlooks', 'Hot Looks') }{ hotLookIndex >= 0 && hotLooks.length ? ` (${ hotLookIndex + 1 }/${ hotLooks.length })` : '' }
</button>
</div>
{ turnstileEnabled &&
<TurnstileWidget
siteKey={ turnstileSiteKey }
size="compact"
onToken={ setTurnstileToken }
onExpire={ () => setTurnstileToken('') }
onError={ () => setTurnstileToken('') }
resetSignal={ resetSignal }
/> }
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
{ 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 }>{ t('generic.back', 'Back') }</button>
<span className="step-indicator">2/3</span>
<button type="submit" className="ok-button" disabled={ !username.trim() || busy || serverOffline }>
{ (checking || pingingServer) ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
</button>
</div>
</form>
}
{ step === 'room' &&
<form className="card-body" onSubmit={ handleRoomSubmit } autoComplete="off">
<div className="register-intro">
{ t('nitro.login.register.intro.room', 'Last step — pick a starter room, or skip and create your own later.') }
</div>
{ serverOffline &&
<div className="error-line server-offline">
{ t('nitro.login.server.offline.long', '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 ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry') }
</button>
</div>
}
<div className="room-templates-list">
<label className={ `room-template-option room-template-skip ${ selectedTemplateId === null ? 'selected' : '' }` }>
<input type="radio" name="register-room-template" checked={ selectedTemplateId === null }
onChange={ () => setSelectedTemplateId(null) } />
<div className="room-template-body">
<div className="room-template-title">{ t('nitro.login.register.room.skip.title', 'I\'m okay — I\'ll create my own rooms') }</div>
<div className="room-template-description">{ t('nitro.login.register.room.skip.description', 'Skip for now and start with an empty hotel inventory.') }</div>
</div>
</label>
{ roomTemplates === null && <div className="info-line">{ t('nitro.login.register.room.loading', 'Loading rooms…') }</div> }
{ roomTemplates !== null && roomTemplates.map(template => (
<label key={ template.templateId }
className={ `room-template-option ${ selectedTemplateId === template.templateId ? 'selected' : '' }` }>
<input type="radio" name="register-room-template" checked={ selectedTemplateId === template.templateId }
onChange={ () => setSelectedTemplateId(template.templateId) } />
{ template.thumbnail &&
<img className="room-template-thumb" src={ template.thumbnail } alt={ template.title }
onError={ e => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> }
<div className="room-template-body">
<div className="room-template-title">{ template.title }</div>
{ template.description &&
<div className="room-template-description">{ template.description }</div> }
</div>
</label>
)) }
</div>
{ roomTemplatesError && <div className="error-line">{ roomTemplatesError }</div> }
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
{ info && <div className="info-line">{ info }</div> }
<div className="step-footer step-footer-split">
<button type="button" className="ok-button back-button" onClick={ () => setStep('avatar') } disabled={ busy }>{ t('generic.back', 'Back') }</button>
<span className="step-indicator">3/3</span>
<button type="submit" className="ok-button" disabled={ busy || serverOffline }>
{ submitting ? t('nitro.login.register.creating', 'Creating…') : t('nitro.login.register.finish', 'Finish') }
</button>
</div>
</form>
}
</div>
</div>
</div>
);
};
@@ -0,0 +1,9 @@
export interface DialogSharedProps
{
onCancel: () => void;
submitting: boolean;
error: string | null;
info: string | null;
turnstileEnabled: boolean;
turnstileSiteKey: string;
}
+32
View File
@@ -0,0 +1,32 @@
export interface BanInfo
{
type: 'account' | 'ip' | 'machine' | 'super' | string;
reason: string;
permanent: boolean;
expiresAt?: number;
}
export const parseBan = (payload: Record<string, unknown>): BanInfo | null =>
{
const raw = payload?.ban;
if(!raw || typeof raw !== 'object') return null;
const ban = raw as Record<string, unknown>;
const type = typeof ban.type === 'string' ? ban.type : 'account';
const reason = typeof ban.reason === 'string' ? ban.reason : '';
const permanent = ban.permanent === true || ban.permanent === 'true';
const expiresAt = typeof ban.expiresAt === 'number' ? ban.expiresAt : undefined;
return { type, reason, permanent, expiresAt };
};
export const formatRemaining = (epochSeconds: number): string =>
{
const totalSeconds = Math.max(0, epochSeconds - Math.floor(Date.now() / 1000));
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if(days > 0) return `${ days }d ${ hours }h ${ minutes }m`;
if(hours > 0) return `${ hours }h ${ minutes }m`;
if(minutes > 0) return `${ minutes }m ${ seconds }s`;
return `${ seconds }s`;
};
+106
View File
@@ -0,0 +1,106 @@
export type GenderKey = 'M' | 'F';
export const PART_ROWS: string[] = [ 'hr', 'hd', 'ch', 'lg', 'sh' ];
export const FALLBACK_DEFAULTS: Record<GenderKey, Record<string, { partId: number; colors: number[] }>> = {
M: {
hr: { partId: 180, colors: [ 45 ] },
hd: { partId: 180, colors: [ 1 ] },
ch: { partId: 215, colors: [ 66 ] },
lg: { partId: 270, colors: [ 82 ] },
sh: { partId: 290, colors: [ 80 ] }
},
F: {
hr: { partId: 515, colors: [ 45 ] },
hd: { partId: 600, colors: [ 1 ] },
ch: { partId: 660, colors: [ 100 ] },
lg: { partId: 716, colors: [ 82 ] },
sh: { partId: 725, colors: [ 61 ] }
}
};
export const FALLBACK_HEX: Record<number, string> = {
1: '#ffcb98', 8: '#f4ac54', 14: '#f5da88', 19: '#b87560', 20: '#9c543f',
45: '#e8c498', 61: '#f1ece3', 66: '#96743d', 80: '#4f4d4d', 82: '#7f4f30',
92: '#ececec', 100: '#c7ddff', 106: '#c6e6bd', 110: '#91a7c8', 143: '#ffffff'
};
export interface FigureColor { id: number; hexCode: string; club: number; selectable: boolean; }
export interface FigurePalette { id: number; colors: FigureColor[]; }
export interface FigureSet { id: number; gender: 'M' | 'F' | 'U'; club: number; selectable: boolean; }
export interface FigureSetType { type: string; paletteId: number; sets: FigureSet[]; }
export interface FigureData { palettes: FigurePalette[]; setTypes: FigureSetType[]; }
export interface PartSelection { partId: number; colors: number[]; }
export type FigureSelection = Record<string, PartSelection>;
export const buildFigureString = (selection: FigureSelection): string =>
{
const seen = new Set<string>();
const parts: string[] = [];
const push = (setType: string) =>
{
if(seen.has(setType)) return;
seen.add(setType);
const sel = selection[setType];
if(!sel || sel.partId < 0) return;
const tail = (sel.colors && sel.colors.length) ? `-${ sel.colors.join('-') }` : '';
parts.push(`${ setType }-${ sel.partId }${ tail }`);
};
for(const setType of PART_ROWS) push(setType);
for(const setType of Object.keys(selection)) push(setType);
return parts.join('.');
};
export const buildImagingUrl = (template: string, figure: string, gender: GenderKey): string =>
template
.replace(/\{figure\}/g, encodeURIComponent(figure))
.replace(/\{gender\}/g, gender)
.replace(/\{direction\}/g, '2');
const HEAD_ONLY_PARTS = new Set([ 'hr', 'hd' ]);
export const buildPartPreviewUrl = (
template: string,
setType: string,
selection: FigureSelection,
gender: GenderKey
): string =>
{
const defaults = FALLBACK_DEFAULTS[gender];
const partSel = selection[setType] ?? defaults[setType];
const tail = (partSel.colors && partSel.colors.length) ? `-${ partSel.colors.join('-') }` : '';
const isHeadOnly = HEAD_ONLY_PARTS.has(setType);
let parts: string[];
if(isHeadOnly)
{
const hd = defaults.hd;
const pieces = new Map<string, string>();
pieces.set('hd', `hd-${ hd.partId }-${ hd.colors.join('-') }`);
pieces.set(setType, `${ setType }-${ partSel.partId }${ tail }`);
parts = Array.from(pieces.values());
}
else
{
const hd = defaults.hd;
parts = [
`hd-${ hd.partId }-${ hd.colors.join('-') }`,
`${ setType }-${ partSel.partId }${ tail }`
];
}
const figure = parts.join('.');
let url = template
.replace(/\{figure\}/g, encodeURIComponent(figure))
.replace(/\{gender\}/g, gender)
.replace(/\{direction\}/g, '2');
url = url.replace(/size=l/, 'size=s').replace(/size=m/, 'size=s');
if(!/size=/.test(url)) url += (url.includes('?') ? '&' : '?') + 'size=s';
if(isHeadOnly && !/headonly=/.test(url)) url += '&headonly=1';
return url;
};
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+27
View File
@@ -0,0 +1,27 @@
import { GetConfiguration } from '@nitrots/nitro-renderer';
import { LocalizeText } from '../../../api';
export const t = (key: string, fallback: string, params?: string[], replacements?: string[]): string =>
{
try
{
const value = LocalizeText(key, params ?? null, replacements ?? null);
if(value && value !== key) return value;
}
catch {}
if(!params || !replacements) return fallback;
let out = fallback;
for(let i = 0; i < params.length; i++)
{
if(replacements[i] !== undefined) out = out.replace('%' + params[i] + '%', replacements[i]);
}
return out;
};
export const interpolate = (value: string | null | undefined): string =>
{
if(!value) return '';
try { return GetConfiguration().interpolate(value); }
catch { return value; }
};
+23
View File
@@ -0,0 +1,23 @@
export const LOCK_KEY = 'nitro.login.lock';
export const MAX_ATTEMPTS = 5;
export const LOCK_WINDOW_MS = 60_000;
export const LOCK_DURATION_MS = 2 * 60_000;
export type AttemptState = { attempts: number; firstAt: number; lockedUntil: number };
export const readLock = (): AttemptState =>
{
try
{
const raw = sessionStorage.getItem(LOCK_KEY);
if(!raw) return { attempts: 0, firstAt: 0, lockedUntil: 0 };
return JSON.parse(raw);
}
catch { return { attempts: 0, firstAt: 0, lockedUntil: 0 }; }
};
export const writeLock = (state: AttemptState) =>
{
try { sessionStorage.setItem(LOCK_KEY, JSON.stringify(state)); }
catch { }
};
+46
View File
@@ -0,0 +1,46 @@
/**
* Accepts a URL (http/https, protocol-relative, or site-relative),
* a data URL with an image mime type, or a raw base64 image payload.
* Anything else (including data:text/html, javascript:, etc.) is rejected
* to keep an admin-set DB value from becoming an XSS / phishing vector.
*/
export const resolveNewsImage = (raw: string | null | undefined): string =>
{
const value = (raw ?? '').trim();
if(!value) return '';
if(/^https?:\/\//i.test(value)) return value;
if(value.startsWith('//')) return value;
if(value.startsWith('/') && !value.startsWith('//')) return value;
if(value.startsWith('data:'))
{
return /^data:image\/[a-z0-9.+-]+[,;]/i.test(value) ? value : '';
}
const stripped = value.replace(/\s+/g, '');
if(!/^[A-Za-z0-9+/=]+$/.test(stripped)) return '';
let mime = 'image/png';
if(stripped.startsWith('/9j/')) mime = 'image/jpeg';
else if(stripped.startsWith('R0lGOD')) mime = 'image/gif';
else if(stripped.startsWith('UklGR')) mime = 'image/webp';
else if(stripped.startsWith('PHN2Zy') || stripped.startsWith('PD94bWw')) mime = 'image/svg+xml';
else if(stripped.startsWith('iVBORw0KGgo')) mime = 'image/png';
return `data:${ mime };base64,${ stripped }`;
};
/**
* Rejects anything that isn't an http(s) URL or a same-origin path so a
* malicious DB value can't be a `javascript:` / `data:` / `file:` link.
*/
export const resolveNewsLink = (raw: string | null | undefined): string =>
{
const value = (raw ?? '').trim();
if(!value) return '';
try
{
const url = new URL(value, window.location.href);
const proto = url.protocol.toLowerCase();
if(proto !== 'http:' && proto !== 'https:') return '';
return url.href;
}
catch { return ''; }
};
+4
View File
@@ -4,6 +4,10 @@
background-position: center;
background-repeat: no-repeat;
outline: 0;
image-rendering: -webkit-optimize-contrast !important;
image-rendering: -moz-crisp-edges !important;
image-rendering: crisp-edges !important;
image-rendering: pixelated !important;
}
.nitro-icon:hover {
+465
View File
@@ -1,3 +1,25 @@
@font-face {
font-family: Volter;
font-weight: normal;
font-style: normal;
src: url("@/assets/webfonts/Volter.ttf") format("truetype");
}
@font-face {
font-family: Volter;
font-weight: bold;
font-style: normal;
src: url("@/assets/webfonts/Volter-b.ttf") format("truetype");
}
.nitro-login-view,
.nitro-login-view * {
font-family: Volter, "Volter (Goldfish)", monospace;
-webkit-font-smoothing: none;
-moz-osx-font-smoothing: grayscale;
font-smooth: never;
}
.nitro-login-view {
position: fixed;
inset: 0;
@@ -564,3 +586,446 @@
line-height: 1.3;
}
/* ─── Login News Window (Habbo flavour) ─── */
.nitro-login-view .login-news-stack {
position: absolute;
top: 25%;
left: 8vw;
transform: translateY(-50%);
display: flex;
flex-direction: column;
width: 388px;
z-index: 50;
pointer-events: auto;
}
.nitro-login-view .news-card-wrapper {
position: relative;
animation: news-pop-in 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
.nitro-login-view .news-card-wrapper > .nitro-login-card.nitro-news-card {
position: relative;
overflow: visible;
border-width: 3px;
padding-top: 22px;
background: linear-gradient(180deg, #b9d4e3 0%, #a2bfd1 60%, #93b3c8 100%);
box-shadow:
inset 0 2px rgba(255, 255, 255, 0.5),
inset 0 -2px rgba(0, 0, 0, 0.12),
0 6px 14px rgba(0, 0, 0, 0.35),
0 0 0 4px rgba(63, 106, 133, 0.0);
animation: news-glow 3.2s ease-in-out infinite;
}
/* Yellow Habbo-style ribbon title */
.nitro-login-card.nitro-news-card .card-title.news-ribbon {
position: absolute;
top: -14px;
left: -10px;
right: -10px;
margin: 0;
padding: 6px 12px;
background: linear-gradient(180deg, #ffe27a 0%, #ffc742 50%, #f0a812 100%);
color: #5a3a00;
text-shadow: 0 1px rgba(255, 255, 255, 0.55);
border: 2px solid #8a5a00;
border-radius: 6px;
box-shadow:
inset 0 1px rgba(255, 255, 255, 0.7),
inset 0 -2px rgba(0, 0, 0, 0.15),
0 3px 0 rgba(0, 0, 0, 0.2);
font-size: 13px;
font-weight: 800;
letter-spacing: 0.6px;
text-transform: uppercase;
text-align: center;
z-index: 2;
}
/* Pennant tails on the ribbon */
.nitro-login-card.nitro-news-card .card-title.news-ribbon::before,
.nitro-login-card.nitro-news-card .card-title.news-ribbon::after {
content: "";
position: absolute;
bottom: -6px;
width: 12px;
height: 12px;
background: #c47800;
border: 2px solid #8a5a00;
z-index: -1;
}
.nitro-login-card.nitro-news-card .card-title.news-ribbon::before {
left: -2px;
clip-path: polygon(0 0, 100% 0, 100% 100%);
transform: rotate(0deg);
}
.nitro-login-card.nitro-news-card .card-title.news-ribbon::after {
right: -2px;
clip-path: polygon(0 0, 100% 0, 0 100%);
}
.nitro-login-card.nitro-news-card .news-ribbon-text {
display: inline-block;
animation: news-ribbon-wobble 4s ease-in-out infinite;
}
/* "NEW!" star badge */
.nitro-login-view .news-new-badge {
position: absolute;
top: -28px;
right: -24px;
width: 78px;
height: 78px;
background:
radial-gradient(circle at 35% 30%, #fff7c2 0%, #ffd23a 45%, #d97c00 100%);
color: #5a1900;
font-weight: 900;
font-size: 11px;
letter-spacing: 0;
text-transform: uppercase;
text-shadow: 0 1px rgba(255, 255, 255, 0.6);
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #8a3a00;
box-shadow:
inset 0 2px rgba(255, 255, 255, 0.55),
inset 0 -2px rgba(0, 0, 0, 0.2),
0 3px 6px rgba(0, 0, 0, 0.35);
clip-path: polygon(
50% 0%, 61% 35%, 98% 35%, 68% 57%,
79% 91%, 50% 70%, 21% 91%, 32% 57%,
2% 35%, 39% 35%
);
z-index: 4;
animation: news-badge-spin 2.8s ease-in-out infinite;
pointer-events: none;
}
.nitro-login-view .news-new-badge span {
transform: rotate(-10deg);
display: inline-block;
line-height: 1;
white-space: nowrap;
}
/* Sparkles around the card */
.nitro-login-view .news-sparkle {
position: absolute;
color: #fff5b0;
text-shadow:
0 0 6px rgba(255, 220, 120, 0.9),
0 0 12px rgba(255, 200, 60, 0.6);
pointer-events: none;
z-index: 3;
user-select: none;
font-weight: 700;
}
.nitro-login-view .news-sparkle-1 {
top: -8px;
left: 18px;
font-size: 14px;
animation: news-sparkle 2.1s ease-in-out infinite;
animation-delay: 0s;
}
.nitro-login-view .news-sparkle-2 {
top: 38%;
left: -12px;
font-size: 12px;
animation: news-sparkle 2.4s ease-in-out infinite;
animation-delay: 0.6s;
}
.nitro-login-view .news-sparkle-3 {
bottom: -6px;
right: 36px;
font-size: 16px;
animation: news-sparkle 2.7s ease-in-out infinite;
animation-delay: 1.1s;
}
/* Body */
.nitro-login-card.nitro-news-card .card-body.news-body {
gap: 8px;
font-size: 12px;
color: #0a2e45;
}
.nitro-login-card.nitro-news-card .news-image {
position: relative;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #3f6a85;
border-radius: 4px;
background:
repeating-linear-gradient(
45deg,
rgba(255, 255, 255, 0.15) 0 6px,
rgba(255, 255, 255, 0) 6px 12px
),
linear-gradient(180deg, #cfe1ee 0%, #a8c5d6 100%);
overflow: hidden;
box-shadow:
inset 0 2px rgba(255, 255, 255, 0.6),
inset 0 -2px rgba(0, 0, 0, 0.15);
max-height: 150px;
transition: transform 0.25s ease;
}
.nitro-login-card.nitro-news-card .news-image:hover {
transform: translateY(-1px) scale(1.01);
}
.nitro-login-card.nitro-news-card .news-image::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0) 35%);
}
.nitro-login-card.nitro-news-card .news-image img {
max-width: 100%;
max-height: 146px;
width: auto;
height: auto;
display: block;
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
position: relative;
z-index: 1;
}
.nitro-login-card.nitro-news-card .news-headline {
font-weight: 800;
font-size: 13px;
line-height: 1.25;
color: #0a2e45;
text-shadow: 0 1px rgba(255, 255, 255, 0.5);
letter-spacing: 0.2px;
border-bottom: 1px dashed rgba(63, 106, 133, 0.4);
padding-bottom: 4px;
}
.nitro-login-card.nitro-news-card .news-text {
font-size: 11px;
line-height: 1.45;
color: #103e5d;
white-space: pre-line;
word-break: break-word;
max-height: 120px;
overflow-y: auto;
padding-right: 2px;
}
.nitro-login-card.nitro-news-card .news-text::-webkit-scrollbar {
width: 6px;
}
.nitro-login-card.nitro-news-card .news-text::-webkit-scrollbar-thumb {
background: rgba(63, 106, 133, 0.6);
border-radius: 3px;
}
.nitro-login-card.nitro-news-card .news-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 4px;
}
.nitro-login-card.nitro-news-card .news-link-button {
padding: 4px 14px;
font-size: 11px;
font-weight: 800;
background: linear-gradient(180deg, #ffe27a 0%, #ffc742 60%, #f0a812 100%);
color: #5a3a00;
border: 1px solid #8a5a00;
text-shadow: 0 1px rgba(255, 255, 255, 0.45);
box-shadow:
inset 0 1px rgba(255, 255, 255, 0.7),
inset 0 -1px rgba(0, 0, 0, 0.15),
0 2px 0 rgba(0, 0, 0, 0.2);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.nitro-login-card.nitro-news-card .news-link-button:hover {
background: linear-gradient(180deg, #fff0a8 0%, #ffd45c 60%, #f7b822 100%);
transform: translateY(-1px);
box-shadow:
inset 0 1px rgba(255, 255, 255, 0.8),
inset 0 -1px rgba(0, 0, 0, 0.15),
0 3px 0 rgba(0, 0, 0, 0.25);
}
.nitro-login-card.nitro-news-card .news-link-button:active {
transform: translateY(1px);
box-shadow:
inset 0 1px rgba(0, 0, 0, 0.15),
0 0 0 rgba(0, 0, 0, 0);
}
.nitro-login-card.nitro-news-card .news-pager {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
.nitro-login-card.nitro-news-card .news-pager .arrow-btn {
transition: transform 0.12s ease;
}
.nitro-login-card.nitro-news-card .news-pager .arrow-btn:hover {
transform: scale(1.15);
}
.nitro-login-card.nitro-news-card .news-counter {
font-size: 11px;
color: #134b6e;
font-weight: 700;
font-variant-numeric: tabular-nums;
min-width: 28px;
text-align: center;
text-shadow: 0 1px rgba(255, 255, 255, 0.4);
}
@keyframes news-pop-in {
0% { opacity: 0; transform: scale(0.85) translateY(8px); }
60% { opacity: 1; transform: scale(1.04) translateY(0); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes news-glow {
0%, 100% { box-shadow:
inset 0 2px rgba(255, 255, 255, 0.5),
inset 0 -2px rgba(0, 0, 0, 0.12),
0 6px 14px rgba(0, 0, 0, 0.35),
0 0 0 0 rgba(255, 210, 60, 0.0); }
50% { box-shadow:
inset 0 2px rgba(255, 255, 255, 0.5),
inset 0 -2px rgba(0, 0, 0, 0.12),
0 6px 14px rgba(0, 0, 0, 0.35),
0 0 18px 4px rgba(255, 210, 60, 0.45); }
}
@keyframes news-ribbon-wobble {
0%, 100% { transform: rotate(0deg) translateY(0); }
25% { transform: rotate(-1.2deg) translateY(-1px); }
75% { transform: rotate(1.2deg) translateY(1px); }
}
@keyframes news-badge-spin {
0%, 100% { transform: rotate(-8deg) scale(1); }
50% { transform: rotate(8deg) scale(1.08); }
}
@keyframes news-sparkle {
0%, 100% { opacity: 0.2; transform: scale(0.7) rotate(0deg); }
50% { opacity: 1; transform: scale(1.2) rotate(20deg); }
}
@media (prefers-reduced-motion: reduce) {
.nitro-login-view .news-card-wrapper,
.nitro-login-view .news-card-wrapper > .nitro-login-card.nitro-news-card,
.nitro-login-view .news-new-badge,
.nitro-login-view .news-sparkle,
.nitro-login-card.nitro-news-card .news-ribbon-text {
animation: none !important;
}
}
@media (max-width: 900px) {
.nitro-login-view .login-news-stack {
display: none;
}
}
/* ─── Cloud intro (plays once per session) ─── */
.login-intro-clouds {
position: fixed;
inset: 0;
z-index: 1000;
pointer-events: none;
overflow: hidden;
animation: cloud-overlay-fade 2.8s linear forwards;
}
.intro-cloud-bank {
position: absolute;
left: -10%;
width: 120%;
height: 70%;
display: flex;
align-items: center;
justify-content: space-around;
will-change: transform;
}
.intro-cloud-bank-top {
top: -70%;
animation: cloud-bank-top 2.8s cubic-bezier(0.65, 0, 0.35, 1) forwards;
}
.intro-cloud-bank-bottom {
bottom: -70%;
animation: cloud-bank-bottom 2.8s cubic-bezier(0.65, 0, 0.35, 1) forwards;
}
.intro-cloud-puff {
flex-shrink: 0;
background:
radial-gradient(ellipse at 45% 38%, #ffffff 0%, #fbfdff 35%, rgba(247, 251, 255, 0.85) 60%, rgba(255, 255, 255, 0) 78%);
filter: drop-shadow(0 8px 14px rgba(140, 175, 205, 0.35));
border-radius: 50%;
}
.intro-cloud-bank-top .intro-cloud-puff {
align-self: flex-end;
}
.intro-cloud-bank-bottom .intro-cloud-puff {
align-self: flex-start;
}
.intro-cloud-puff-1 { width: 360px; height: 320px; transform: translateY(-10px); }
.intro-cloud-puff-2 { width: 260px; height: 240px; transform: translateY(20px); }
.intro-cloud-puff-3 { width: 420px; height: 380px; transform: translateY(-30px); }
.intro-cloud-puff-4 { width: 300px; height: 280px; transform: translateY(15px); }
.intro-cloud-puff-5 { width: 340px; height: 300px; transform: translateY(-5px); }
@keyframes cloud-bank-top {
0% { transform: translateY(0); }
35% { transform: translateY(105%); }
55% { transform: translateY(105%); }
100% { transform: translateY(-10%); }
}
@keyframes cloud-bank-bottom {
0% { transform: translateY(0); }
35% { transform: translateY(-105%); }
55% { transform: translateY(-105%); }
100% { transform: translateY(10%); }
}
@keyframes cloud-overlay-fade {
0%, 88% { opacity: 1; }
100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.login-intro-clouds,
.intro-cloud-bank-top,
.intro-cloud-bank-bottom {
animation-duration: 0.4s !important;
}
}