diff --git a/public/renderer-config.example b/public/renderer-config.example index 5fb8068..b45973e 100644 --- a/public/renderer-config.example +++ b/public/renderer-config.example @@ -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": [ diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index 79a10f2..e4a8f61 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -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): BanInfo | null => -{ - const raw = payload?.ban; - if(!raw || typeof raw !== 'object') return null; - const ban = raw as Record; - 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 = ({ 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 = ({ onAuthenticated }) => } }, [ submitting, username, password, rememberMe, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]); + const newsUrl = GetConfigurationValue('login.news.endpoint', '/api/auth/news'); + const checkEmailUrl = GetConfigurationValue('login.check-email.endpoint', '/api/auth/check-email'); const checkUsernameUrl = GetConfigurationValue('login.check-username.endpoint', '/api/auth/check-username'); const imagingUrl = GetConfigurationValue('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): { available: boolean; error?: string } => { const isTrue = (v: unknown) => v === true || v === 'true' || v === 1 || v === '1'; @@ -512,6 +432,8 @@ export const LoginView: FC = ({ onAuthenticated }) => { rightRepeat ?
: null } { right ?
: null } + +
{ t('nitro.login.firsttime.title', 'First time here?') }
@@ -628,804 +550,3 @@ export const LoginView: FC = ({ onAuthenticated }) =>
); }; - -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; - 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> = { - 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 = { - 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; - -const buildFigureString = (selection: FigureSelection): string => -{ - const seen = new Set(); - 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(); - 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 = props => -{ - const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, imagingUrl, roomTemplatesUrl, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; - - const [ step, setStep ] = useState('credentials'); - const [ email, setEmail ] = useState(''); - const [ password, setPassword ] = useState(''); - const [ confirm, setConfirm ] = useState(''); - const [ username, setUsername ] = useState(''); - const [ gender, setGender ] = useState('F'); - const [ selection, setSelection ] = useState(() => ({ ...FALLBACK_DEFAULTS.F })); - const [ localError, setLocalError ] = useState(null); - const [ checking, setChecking ] = useState(false); - const [ turnstileToken, setTurnstileToken ] = useState(''); - const [ resetSignal, setResetSignal ] = useState(0); - const [ serverReachable, setServerReachable ] = useState(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(null); - const [ roomTemplatesError, setRoomTemplatesError ] = useState(null); - const [ selectedTemplateId, setSelectedTemplateId ] = useState(null); - - const [ figureData, setFigureData ] = useState(null); - const figureDataUrlRaw = GetConfigurationValue('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> = {}; - 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 = {}; - 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[]) - { - 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) => - { - 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) => - { - 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 3–16 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) => - { - event.preventDefault(); - setLocalError(null); - submitRegistration(selectedTemplateId); - }; - - const busy = submitting || checking || pingingServer; - const serverOffline = serverReachable === false; - - return ( -
-
-
-
- { t('nitro.login.register.title', 'Habbo Details') } - -
- - { step === 'credentials' && -
-
- { 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.') } -
- { serverOffline && -
- { 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.') } - -
- } -
- - setEmail(e.target.value) } /> -
-
- - setPassword(e.target.value) } /> -
-
- - setConfirm(e.target.value) } /> -
- { (localError || error) &&
{ localError || error }
} - { info &&
{ info }
} -
- 1/3 - -
-
- } - - { step === 'avatar' && -
-
- { 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.') } -
- { serverOffline && -
- { 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.') } - -
- } -
- setUsername(e.target.value) } /> -
- -
- - -
- -
-
- { PART_ROWS.map(setType => { - const partPreviewSrc = buildPartPreviewUrl(imagingUrl, setType, selection, gender); - return ( -
- -
- { { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> -
- -
- ); - }) } -
- -
- Habbo preview { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> -
- -
- { 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 ( -
- -
- -
- ); - }) } -
-
- -
- -
- - { turnstileEnabled && - setTurnstileToken('') } - onError={ () => setTurnstileToken('') } - resetSignal={ resetSignal } - /> } - { (localError || error) &&
{ localError || error }
} - { info &&
{ info }
} - -
- - 2/3 - -
- - } - - { step === 'room' && -
-
- { t('nitro.login.register.intro.room', 'Last step — pick a starter room, or skip and create your own later.') } -
- { serverOffline && -
- { 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.') } - -
- } - -
- - - { roomTemplates === null &&
{ t('nitro.login.register.room.loading', 'Loading rooms…') }
} - - { roomTemplates !== null && roomTemplates.map(template => ( - - )) } -
- - { roomTemplatesError &&
{ roomTemplatesError }
} - { (localError || error) &&
{ localError || error }
} - { info &&
{ info }
} - -
- - 3/3 - -
-
- } -
-
-
- ); -}; - - -interface ForgotDialogProps extends DialogSharedProps -{ - onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => void; -} - -const ForgotDialog: FC = props => -{ - const { onCancel, onSubmit, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; - const [ email, setEmail ] = useState(''); - const [ localError, setLocalError ] = useState(null); - const [ turnstileToken, setTurnstileToken ] = useState(''); - const [ resetSignal, setResetSignal ] = useState(0); - - const resetWidget = useCallback(() => - { - setTurnstileToken(''); - setResetSignal(prev => prev + 1); - }, []); - - const handle = (event: FormEvent) => - { - 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 ( -
-
-
-
- { t('nitro.login.forgot.title', 'Reset password') } - -
-
-
- - setEmail(e.target.value) } /> -
- { turnstileEnabled && - setTurnstileToken('') } - onError={ () => setTurnstileToken('') } - resetSignal={ resetSignal } - /> } - { (localError || error) &&
{ localError || error }
} - { info &&
{ info }
} -
- -
- -
-
-
- ); -}; diff --git a/src/components/login/components/ForgotDialog.tsx b/src/components/login/components/ForgotDialog.tsx new file mode 100644 index 0000000..65cffa8 --- /dev/null +++ b/src/components/login/components/ForgotDialog.tsx @@ -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 = props => +{ + const { onCancel, onSubmit, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; + const [ email, setEmail ] = useState(''); + const [ localError, setLocalError ] = useState(null); + const [ turnstileToken, setTurnstileToken ] = useState(''); + const [ resetSignal, setResetSignal ] = useState(0); + + const resetWidget = useCallback(() => + { + setTurnstileToken(''); + setResetSignal(prev => prev + 1); + }, []); + + const handle = (event: FormEvent) => + { + 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 ( +
+
+
+
+ { t('nitro.login.forgot.title', 'Reset password') } + +
+
+
+ + setEmail(e.target.value) } /> +
+ { turnstileEnabled && + setTurnstileToken('') } + onError={ () => setTurnstileToken('') } + resetSignal={ resetSignal } + /> } + { (localError || error) &&
{ localError || error }
} + { info &&
{ info }
} +
+ +
+ +
+
+
+ ); +}; diff --git a/src/components/login/components/NewsWindow.tsx b/src/components/login/components/NewsWindow.tsx new file mode 100644 index 0000000..a7c05d3 --- /dev/null +++ b/src/components/login/components/NewsWindow.tsx @@ -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 = ({ newsUrl }) => +{ + const [ items, setItems ] = useState(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 ( +
+
+ + + + + + +
+
+ { t('nitro.login.news.title', 'Hotel News') } +
+
+ { safeImageSrc && +
+ { { (e.currentTarget as HTMLImageElement).style.display = 'none'; } } + /> +
+ } +
{ current.title }
+ { current.body && +
{ current.body }
} + +
+ { current.linkText && safeLinkUrl + ? + : } + + { hasMany && +
+ + { index + 1 }/{ items.length } + +
+ } +
+
+
+
+
+ ); +}; diff --git a/src/components/login/components/RegisterDialog.tsx b/src/components/login/components/RegisterDialog.tsx new file mode 100644 index 0000000..b952c56 --- /dev/null +++ b/src/components/login/components/RegisterDialog.tsx @@ -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; + imagingUrl: string; + roomTemplatesUrl: string; +} + +type RegisterStep = 'credentials' | 'avatar' | 'room'; + +interface RoomTemplate { templateId: number; title: string; description: string; thumbnail: string; } + +export const RegisterDialog: FC = props => +{ + const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, imagingUrl, roomTemplatesUrl, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; + + const [ step, setStep ] = useState('credentials'); + const [ email, setEmail ] = useState(''); + const [ password, setPassword ] = useState(''); + const [ confirm, setConfirm ] = useState(''); + const [ username, setUsername ] = useState(''); + const [ gender, setGender ] = useState('F'); + const [ selection, setSelection ] = useState(() => ({ ...FALLBACK_DEFAULTS.F })); + const [ localError, setLocalError ] = useState(null); + const [ checking, setChecking ] = useState(false); + const [ turnstileToken, setTurnstileToken ] = useState(''); + const [ resetSignal, setResetSignal ] = useState(0); + const [ serverReachable, setServerReachable ] = useState(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(null); + const [ roomTemplatesError, setRoomTemplatesError ] = useState(null); + const [ selectedTemplateId, setSelectedTemplateId ] = useState(null); + + const [ figureData, setFigureData ] = useState(null); + const figureDataUrlRaw = GetConfigurationValue('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> = {}; + 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 = {}; + 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[]) + { + 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) => + { + 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) => + { + 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 3–16 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) => + { + event.preventDefault(); + setLocalError(null); + submitRegistration(selectedTemplateId); + }; + + const busy = submitting || checking || pingingServer; + const serverOffline = serverReachable === false; + + return ( +
+
+
+
+ { t('nitro.login.register.title', 'Habbo Details') } + +
+ + { step === 'credentials' && +
+
+ { 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.') } +
+ { serverOffline && +
+ { 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.') } + +
+ } +
+ + setEmail(e.target.value) } /> +
+
+ + setPassword(e.target.value) } /> +
+
+ + setConfirm(e.target.value) } /> +
+ { (localError || error) &&
{ localError || error }
} + { info &&
{ info }
} +
+ 1/3 + +
+
+ } + + { step === 'avatar' && +
+
+ { 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.') } +
+ { serverOffline && +
+ { 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.') } + +
+ } +
+ setUsername(e.target.value) } /> +
+ +
+ + +
+ +
+
+ { PART_ROWS.map(setType => { + const partPreviewSrc = buildPartPreviewUrl(imagingUrl, setType, selection, gender); + return ( +
+ +
+ { { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> +
+ +
+ ); + }) } +
+ +
+ Habbo preview { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> +
+ +
+ { 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 ( +
+ +
+ +
+ ); + }) } +
+
+ +
+ +
+ + { turnstileEnabled && + setTurnstileToken('') } + onError={ () => setTurnstileToken('') } + resetSignal={ resetSignal } + /> } + { (localError || error) &&
{ localError || error }
} + { info &&
{ info }
} + +
+ + 2/3 + +
+ + } + + { step === 'room' && +
+
+ { t('nitro.login.register.intro.room', 'Last step — pick a starter room, or skip and create your own later.') } +
+ { serverOffline && +
+ { 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.') } + +
+ } + +
+ + + { roomTemplates === null &&
{ t('nitro.login.register.room.loading', 'Loading rooms…') }
} + + { roomTemplates !== null && roomTemplates.map(template => ( + + )) } +
+ + { roomTemplatesError &&
{ roomTemplatesError }
} + { (localError || error) &&
{ localError || error }
} + { info &&
{ info }
} + +
+ + 3/3 + +
+
+ } +
+
+
+ ); +}; diff --git a/src/components/login/components/shared.ts b/src/components/login/components/shared.ts new file mode 100644 index 0000000..53e30ed --- /dev/null +++ b/src/components/login/components/shared.ts @@ -0,0 +1,9 @@ +export interface DialogSharedProps +{ + onCancel: () => void; + submitting: boolean; + error: string | null; + info: string | null; + turnstileEnabled: boolean; + turnstileSiteKey: string; +} diff --git a/src/components/login/utils/ban.ts b/src/components/login/utils/ban.ts new file mode 100644 index 0000000..7c9b904 --- /dev/null +++ b/src/components/login/utils/ban.ts @@ -0,0 +1,32 @@ +export interface BanInfo +{ + type: 'account' | 'ip' | 'machine' | 'super' | string; + reason: string; + permanent: boolean; + expiresAt?: number; +} + +export const parseBan = (payload: Record): BanInfo | null => +{ + const raw = payload?.ban; + if(!raw || typeof raw !== 'object') return null; + const ban = raw as Record; + 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`; +}; diff --git a/src/components/login/utils/figure.ts b/src/components/login/utils/figure.ts new file mode 100644 index 0000000..2743662 --- /dev/null +++ b/src/components/login/utils/figure.ts @@ -0,0 +1,106 @@ +export type GenderKey = 'M' | 'F'; + +export const PART_ROWS: string[] = [ 'hr', 'hd', 'ch', 'lg', 'sh' ]; + +export const FALLBACK_DEFAULTS: Record> = { + 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 = { + 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; + +export const buildFigureString = (selection: FigureSelection): string => +{ + const seen = new Set(); + 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(); + 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@]+$/; diff --git a/src/components/login/utils/i18n.ts b/src/components/login/utils/i18n.ts new file mode 100644 index 0000000..58f88f8 --- /dev/null +++ b/src/components/login/utils/i18n.ts @@ -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; } +}; diff --git a/src/components/login/utils/lockState.ts b/src/components/login/utils/lockState.ts new file mode 100644 index 0000000..ef5a946 --- /dev/null +++ b/src/components/login/utils/lockState.ts @@ -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 { } +}; diff --git a/src/components/login/utils/news.ts b/src/components/login/utils/news.ts new file mode 100644 index 0000000..510655c --- /dev/null +++ b/src/components/login/utils/news.ts @@ -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 ''; } +}; diff --git a/src/css/icons/icons.css b/src/css/icons/icons.css index 7b85ad0..62120ea 100644 --- a/src/css/icons/icons.css +++ b/src/css/icons/icons.css @@ -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 { diff --git a/src/css/login/LoginView.css b/src/css/login/LoginView.css index e91cbad..df21654 100644 --- a/src/css/login/LoginView.css +++ b/src/css/login/LoginView.css @@ -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; + } +}