mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
🆙 Code cleanup News UI Login
This commit is contained in:
@@ -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": [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 }>‹</button>
|
||||
<span className="news-counter">{ index + 1 }/{ items.length }</span>
|
||||
<button type="button" className="arrow-btn" aria-label="Next news" onClick={ next }>›</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 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<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) }>‹</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) }>›</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) }>‹</button>
|
||||
<div className="color-swatch" style={ { background: swatchHex } } />
|
||||
<button type="button" className="arrow-btn" aria-label={ `Next color ${ setType }` }
|
||||
onClick={ () => cycleColor(setType, 1) }>›</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;
|
||||
}
|
||||
@@ -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`;
|
||||
};
|
||||
@@ -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@]+$/;
|
||||
@@ -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; }
|
||||
};
|
||||
@@ -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 { }
|
||||
};
|
||||
@@ -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 ''; }
|
||||
};
|
||||
Reference in New Issue
Block a user