mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 06:56:20 +00:00
🆙 Small fix Avatar loading & moved news to path wich you can enter
The example data has been provided in /Content-Gamedata so you could place it in /gamadata or anything you like.
Do not forget the render-config.json to update :
"login.health.method": "GET",
"login.news.url": "${asset.url}/news/news.json",
This commit is contained in:
@@ -58,6 +58,8 @@
|
|||||||
"login.server_key.endpoint": "${api.url}/api/auth/server-key",
|
"login.server_key.endpoint": "${api.url}/api/auth/server-key",
|
||||||
"login.sso-token.endpoint": "${api.url}/api/auth/sso-token",
|
"login.sso-token.endpoint": "${api.url}/api/auth/sso-token",
|
||||||
"login.refresh.endpoint": "${api.url}/api/auth/refresh",
|
"login.refresh.endpoint": "${api.url}/api/auth/refresh",
|
||||||
|
"login.health.method": "GET",
|
||||||
|
"login.news.url": "${asset.url}/news/news.json",
|
||||||
"badges.custom.list.endpoint": "${api.url}/api/badges/custom",
|
"badges.custom.list.endpoint": "${api.url}/api/badges/custom",
|
||||||
"badges.custom.create.endpoint": "${api.url}/api/badges/custom",
|
"badges.custom.create.endpoint": "${api.url}/api/badges/custom",
|
||||||
"badges.custom.update.endpoint": "${api.url}/api/badges/custom/%badgeId%",
|
"badges.custom.update.endpoint": "${api.url}/api/badges/custom/%badgeId%",
|
||||||
|
|||||||
@@ -215,6 +215,11 @@ const ASSET_LOADER_JS = `(() => {
|
|||||||
return new URL(".", source);
|
return new URL(".", source);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDeployBase = () => {
|
||||||
|
try { return new URL("..", getBase()); }
|
||||||
|
catch { return new URL("/", location.href); }
|
||||||
|
};
|
||||||
|
|
||||||
const withCacheBust = (url) => {
|
const withCacheBust = (url) => {
|
||||||
url.searchParams.set("v", Date.now().toString(36));
|
url.searchParams.set("v", Date.now().toString(36));
|
||||||
return url;
|
return url;
|
||||||
@@ -242,9 +247,14 @@ const ASSET_LOADER_JS = `(() => {
|
|||||||
|
|
||||||
const resolveAssetCandidates = (path) => {
|
const resolveAssetCandidates = (path) => {
|
||||||
const base = getBase();
|
const base = getBase();
|
||||||
|
const deploy = getDeployBase();
|
||||||
const normalized = path.replace(/^\\.\\//, "");
|
const normalized = path.replace(/^\\.\\//, "");
|
||||||
const file = normalized.split("/").pop();
|
const file = normalized.split("/").pop();
|
||||||
|
const relative = normalized.replace(/^\\//, "");
|
||||||
const urls = [
|
const urls = [
|
||||||
|
new URL("src/assets/" + file, deploy),
|
||||||
|
new URL("assets/" + file, deploy),
|
||||||
|
new URL(relative, deploy),
|
||||||
new URL("./src/assets/" + file, base),
|
new URL("./src/assets/" + file, base),
|
||||||
new URL("./assets/" + file, base),
|
new URL("./assets/" + file, base),
|
||||||
new URL("/src/assets/" + file, base.origin),
|
new URL("/src/assets/" + file, base.origin),
|
||||||
@@ -376,7 +386,10 @@ const ASSET_LOADER_JS = `(() => {
|
|||||||
|
|
||||||
const fetchManifest = async () => {
|
const fetchManifest = async () => {
|
||||||
const base = getBase();
|
const base = getBase();
|
||||||
|
const deploy = getDeployBase();
|
||||||
const candidates = [
|
const candidates = [
|
||||||
|
new URL(".vite/manifest.json", deploy),
|
||||||
|
new URL("manifest.json", deploy),
|
||||||
new URL(".vite/manifest.json", base.origin + "/"),
|
new URL(".vite/manifest.json", base.origin + "/"),
|
||||||
new URL("manifest.json", base.origin + "/"),
|
new URL("manifest.json", base.origin + "/"),
|
||||||
new URL(".vite/manifest.json", base),
|
new URL(".vite/manifest.json", base),
|
||||||
@@ -392,7 +405,11 @@ const ASSET_LOADER_JS = `(() => {
|
|||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
if(json && typeof json === "object") {
|
if(json && typeof json === "object") {
|
||||||
debug("loader: manifest from " + candidate.href);
|
debug("loader: manifest from " + candidate.href);
|
||||||
return { manifest: json, base: new URL(".", candidate.href) };
|
let manifestBase = new URL(".", candidate.href);
|
||||||
|
if(/\\/\\.vite\\/manifest\\.json$/.test(candidate.pathname)) {
|
||||||
|
manifestBase = new URL("..", manifestBase);
|
||||||
|
}
|
||||||
|
return { manifest: json, base: manifestBase };
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -418,18 +435,24 @@ const ASSET_LOADER_JS = `(() => {
|
|||||||
const resolveManifestPath = (manifestBase, file) => {
|
const resolveManifestPath = (manifestBase, file) => {
|
||||||
if(/^https?:\\/\\//i.test(file)) return file;
|
if(/^https?:\\/\\//i.test(file)) return file;
|
||||||
if(file.startsWith("/")) return file;
|
if(file.startsWith("/")) return file;
|
||||||
return new URL(file, manifestBase.origin + "/").pathname;
|
return new URL(file, manifestBase).pathname;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isLoaderUrl = (href) => /(?:^|\\/)bootstrap\\.js(?:$|\\?|#)/i.test(href) || /(?:^|\\/)asset-loader\\.js(?:$|\\?|#)/i.test(href);
|
const isLoaderUrl = (href) => /(?:^|\\/)bootstrap\\.js(?:$|\\?|#)/i.test(href) || /(?:^|\\/)asset-loader\\.js(?:$|\\?|#)/i.test(href);
|
||||||
|
|
||||||
const fetchEntryFromIndexHtml = async () => {
|
const fetchEntryFromIndexHtml = async () => {
|
||||||
const base = getBase();
|
const base = getBase();
|
||||||
|
const deploy = getDeployBase();
|
||||||
const candidates = [
|
const candidates = [
|
||||||
|
new URL("index.html", deploy),
|
||||||
|
new URL("./", deploy),
|
||||||
new URL("/index.html", base.origin + "/"),
|
new URL("/index.html", base.origin + "/"),
|
||||||
new URL("/", base.origin + "/")
|
new URL("/", base.origin + "/")
|
||||||
];
|
];
|
||||||
|
const seen = new Set();
|
||||||
for(const candidate of candidates) {
|
for(const candidate of candidates) {
|
||||||
|
if(seen.has(candidate.href)) continue;
|
||||||
|
seen.add(candidate.href);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(withCacheBust(new URL(candidate.href)), { cache: "no-store" });
|
const response = await fetch(withCacheBust(new URL(candidate.href)), { cache: "no-store" });
|
||||||
if(!response.ok) continue;
|
if(!response.ok) continue;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { GetConfiguration } from '@nitrots/nitro-renderer';
|
import { AvatarScaleType, AvatarSetType, GetAvatarRenderManager, GetConfiguration, IAvatarImage } from '@nitrots/nitro-renderer';
|
||||||
import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { ClearRememberLogin, GetConfigurationValue, GetOptionalConfigurationValue, GetRememberLogin, StoreRememberLoginFromPayload } from '../../api';
|
import { ClearRememberLogin, GetConfigurationValue, GetRememberLogin, StoreRememberLoginFromPayload } from '../../api';
|
||||||
import { configFileUrl } from '../../secure-assets';
|
import { configFileUrl } from '../../secure-assets';
|
||||||
import flagBr from '../../assets/images/flag_icon/flag_icon_br.png';
|
import flagBr from '../../assets/images/flag_icon/flag_icon_br.png';
|
||||||
import flagDe from '../../assets/images/flag_icon/flag_icon_de.png';
|
import flagDe from '../../assets/images/flag_icon/flag_icon_de.png';
|
||||||
@@ -195,7 +195,6 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
const [ localeApplying, setLocaleApplying ] = useState(false);
|
const [ localeApplying, setLocaleApplying ] = useState(false);
|
||||||
const [ localeError, setLocaleError ] = useState('');
|
const [ localeError, setLocaleError ] = useState('');
|
||||||
const [ loginViewConfig, setLoginViewConfig ] = useState<Record<string, unknown>>(() => GetConfigurationValue<Record<string, unknown>>('loginview', {}));
|
const [ loginViewConfig, setLoginViewConfig ] = useState<Record<string, unknown>>(() => GetConfigurationValue<Record<string, unknown>>('loginview', {}));
|
||||||
const [ , setLocalizationVersion ] = useState(0);
|
|
||||||
const submitTimeRef = useRef(0);
|
const submitTimeRef = useRef(0);
|
||||||
const preloadedLoginImagesRef = useRef<Set<string>>(new Set());
|
const preloadedLoginImagesRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
@@ -206,22 +205,9 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
|
|
||||||
const configuredLoginWidgets = useMemo<Record<string, unknown>>(() =>
|
const configuredLoginWidgets = useMemo<Record<string, unknown>>(() =>
|
||||||
(loginViewConfig?.['widgets'] as Record<string, unknown>) ?? {}, [ loginViewConfig ]);
|
(loginViewConfig?.['widgets'] as Record<string, unknown>) ?? {}, [ loginViewConfig ]);
|
||||||
useEffect(() =>
|
|
||||||
{
|
|
||||||
const refreshLocalization = () => setLocalizationVersion(value => (value + 1));
|
|
||||||
window.addEventListener('nitro-localization-updated', refreshLocalization);
|
|
||||||
return () => window.removeEventListener('nitro-localization-updated', refreshLocalization);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loginImages = useMemo<Record<string, string>>(() =>
|
|
||||||
{
|
|
||||||
const configured = (loginViewConfig?.['images'] as Record<string, string>) ?? {};
|
|
||||||
return { ...getDefaultLoginImages(), ...configured };
|
|
||||||
}, [ loginViewConfig ]);
|
|
||||||
|
|
||||||
const loginWidgetSlots = useMemo(() =>
|
const loginWidgetSlots = useMemo(() =>
|
||||||
{
|
{
|
||||||
const configuredLoginWidgets = (loginViewConfig?.['widgets'] as Record<string, unknown>) ?? {};
|
|
||||||
return Object.entries(configuredLoginWidgets)
|
return Object.entries(configuredLoginWidgets)
|
||||||
.filter(([ key, value ]) => key.startsWith('slot.') && key.endsWith('.widget') && typeof value === 'string' && value.length > 0)
|
.filter(([ key, value ]) => key.startsWith('slot.') && key.endsWith('.widget') && typeof value === 'string' && value.length > 0)
|
||||||
.map(([ key, value ]) =>
|
.map(([ key, value ]) =>
|
||||||
@@ -233,7 +219,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
})
|
})
|
||||||
.filter(slot => slot.slotNum > 0)
|
.filter(slot => slot.slotNum > 0)
|
||||||
.sort((a, b) => a.slotNum - b.slotNum);
|
.sort((a, b) => a.slotNum - b.slotNum);
|
||||||
}, [ loginViewConfig ]);
|
}, [ configuredLoginWidgets ]);
|
||||||
|
|
||||||
const backgroundColor = (loginImages['background.colour'] || GetConfigurationValue<string>('login_background.colour', '#6eadc8'));
|
const backgroundColor = (loginImages['background.colour'] || GetConfigurationValue<string>('login_background.colour', '#6eadc8'));
|
||||||
const background = interpolate(loginImages['background'] || GetConfigurationValue<string>('login_background', ''));
|
const background = interpolate(loginImages['background'] || GetConfigurationValue<string>('login_background', ''));
|
||||||
@@ -242,11 +228,15 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
const left = interpolate(loginImages['left'] || GetConfigurationValue<string>('login_left', ''));
|
const left = interpolate(loginImages['left'] || GetConfigurationValue<string>('login_left', ''));
|
||||||
const rightRepeat = interpolate(loginImages['right.repeat'] || GetConfigurationValue<string>('login_right.repeat', ''));
|
const rightRepeat = interpolate(loginImages['right.repeat'] || GetConfigurationValue<string>('login_right.repeat', ''));
|
||||||
const right = interpolate(loginImages['right'] || GetConfigurationValue<string>('login_right', ''));
|
const right = interpolate(loginImages['right'] || GetConfigurationValue<string>('login_right', ''));
|
||||||
|
const widgetImageUrls = useMemo(() => loginWidgetSlots
|
||||||
|
.map(slot => typeof slot.conf.image === 'string' ? interpolate(slot.conf.image) : '')
|
||||||
|
.filter(Boolean), [ loginWidgetSlots ]);
|
||||||
|
const loginImageUrls = useMemo(() => [ background, sun, drape, left, rightRepeat, right, ...widgetImageUrls ].filter(Boolean), [ background, sun, drape, left, rightRepeat, right, widgetImageUrls ]);
|
||||||
|
const [ loginImagesVersion, setLoginImagesVersion ] = useState(0);
|
||||||
const loginUrl = GetConfigurationValue<string>('login.endpoint', '/api/auth/login');
|
const loginUrl = GetConfigurationValue<string>('login.endpoint', '/api/auth/login');
|
||||||
const registerUrl = GetConfigurationValue<string>('login.register.endpoint', '/api/auth/register');
|
const registerUrl = GetConfigurationValue<string>('login.register.endpoint', '/api/auth/register');
|
||||||
const forgotUrl = GetConfigurationValue<string>('login.forgot.endpoint', '/api/auth/forgot-password');
|
const forgotUrl = GetConfigurationValue<string>('login.forgot.endpoint', '/api/auth/forgot-password');
|
||||||
const configuredNewsUrl = interpolate(GetOptionalConfigurationValue<string>('login.news.url', ''));
|
const newsUrl = interpolate(GetConfigurationValue<string>('login.news.url', ''));
|
||||||
const newsUrl = configuredNewsUrl || configFileUrl('news.json');
|
|
||||||
const turnstileSiteKey = GetConfigurationValue<string>('login.turnstile.sitekey', '');
|
const turnstileSiteKey = GetConfigurationValue<string>('login.turnstile.sitekey', '');
|
||||||
const rawTurnstileEnabled = GetConfigurationValue<unknown>('login.turnstile.enabled', false);
|
const rawTurnstileEnabled = GetConfigurationValue<unknown>('login.turnstile.enabled', false);
|
||||||
const turnstileEnabled = (rawTurnstileEnabled === true
|
const turnstileEnabled = (rawTurnstileEnabled === true
|
||||||
@@ -413,7 +403,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const healthUrl = GetConfigurationValue<string>('login.health.endpoint', '');
|
const healthUrl = GetConfigurationValue<string>('login.health.endpoint', '');
|
||||||
const healthMethodRaw = GetOptionalConfigurationValue<string>('login.health.method', 'GET');
|
const healthMethodRaw = GetConfigurationValue<string>('login.health.method', 'GET');
|
||||||
const healthMethod = (healthMethodRaw || 'GET').toUpperCase();
|
const healthMethod = (healthMethodRaw || 'GET').toUpperCase();
|
||||||
const checkServerReachable = useCallback(async (): Promise<boolean> =>
|
const checkServerReachable = useCallback(async (): Promise<boolean> =>
|
||||||
{
|
{
|
||||||
@@ -527,7 +517,6 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
|
|
||||||
const checkEmailUrl = GetConfigurationValue<string>('login.check-email.endpoint', '/api/auth/check-email');
|
const checkEmailUrl = GetConfigurationValue<string>('login.check-email.endpoint', '/api/auth/check-email');
|
||||||
const checkUsernameUrl = GetConfigurationValue<string>('login.check-username.endpoint', '/api/auth/check-username');
|
const checkUsernameUrl = GetConfigurationValue<string>('login.check-username.endpoint', '/api/auth/check-username');
|
||||||
const imagingUrl = GetOptionalConfigurationValue<string>('login.register.imaging.url', '');
|
|
||||||
const interpretAvailability = (ok: boolean, status: number, payload: Record<string, unknown>): { available: boolean; error?: string } =>
|
const interpretAvailability = (ok: boolean, status: number, payload: Record<string, unknown>): { available: boolean; error?: string } =>
|
||||||
{
|
{
|
||||||
const isTrue = (v: unknown) => v === true || v === 'true' || v === 1 || v === '1';
|
const isTrue = (v: unknown) => v === true || v === 'true' || v === 1 || v === '1';
|
||||||
@@ -675,6 +664,9 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
{ left ? <img className="login-left login-layer login-layer-img" src={ left } alt="" draggable={ false } /> : null }
|
{ left ? <img className="login-left login-layer login-layer-img" src={ left } alt="" draggable={ false } /> : null }
|
||||||
{ rightRepeat ? <div className="login-right-repeat login-layer" style={ { backgroundImage: `url(${ rightRepeat })` } } /> : null }
|
{ rightRepeat ? <div className="login-right-repeat login-layer" style={ { backgroundImage: `url(${ rightRepeat })` } } /> : null }
|
||||||
{ right ? <img className="login-right login-layer login-layer-img" src={ right } alt="" draggable={ false } /> : null }
|
{ right ? <img className="login-right login-layer login-layer-img" src={ right } alt="" draggable={ false } /> : null }
|
||||||
|
<div className="login-image-preloader" aria-hidden="true" data-version={ loginImagesVersion }>
|
||||||
|
{ loginImageUrls.map(url => <img key={ url } src={ url } decoding="async" loading="eager" alt="" />) }
|
||||||
|
</div>
|
||||||
|
|
||||||
{ loginWidgetSlots.length > 0 &&
|
{ loginWidgetSlots.length > 0 &&
|
||||||
<div className="login-widgets">
|
<div className="login-widgets">
|
||||||
@@ -815,7 +807,6 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
onCheckEmail={ checkEmailAvailable }
|
onCheckEmail={ checkEmailAvailable }
|
||||||
onCheckUsername={ checkUsernameAvailable }
|
onCheckUsername={ checkUsernameAvailable }
|
||||||
onCheckServer={ checkServerReachable }
|
onCheckServer={ checkServerReachable }
|
||||||
imagingUrl={ imagingUrl }
|
|
||||||
submitting={ submitting }
|
submitting={ submitting }
|
||||||
error={ error }
|
error={ error }
|
||||||
info={ info }
|
info={ info }
|
||||||
@@ -853,7 +844,6 @@ interface RegisterDialogProps extends DialogSharedProps
|
|||||||
onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>;
|
onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>;
|
||||||
onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>;
|
onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>;
|
||||||
onCheckServer: () => Promise<boolean>;
|
onCheckServer: () => Promise<boolean>;
|
||||||
imagingUrl: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type RegisterStep = 'credentials' | 'avatar';
|
type RegisterStep = 'credentials' | 'avatar';
|
||||||
@@ -914,60 +904,160 @@ const buildFigureString = (selection: FigureSelection): string =>
|
|||||||
return parts.join('.');
|
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 HEAD_ONLY_PARTS = new Set([ 'hr', 'hd' ]);
|
||||||
|
|
||||||
const buildPartPreviewUrl = (
|
const buildPartPreviewFigure = (setType: string, selection: FigureSelection, gender: GenderKey): string =>
|
||||||
template: string,
|
|
||||||
setType: string,
|
|
||||||
selection: FigureSelection,
|
|
||||||
gender: GenderKey
|
|
||||||
): string =>
|
|
||||||
{
|
{
|
||||||
const defaults = FALLBACK_DEFAULTS[gender];
|
const defaults = FALLBACK_DEFAULTS[gender];
|
||||||
const partSel = selection[setType] ?? defaults[setType];
|
const partSel = selection[setType] ?? defaults[setType];
|
||||||
const tail = (partSel.colors && partSel.colors.length) ? `-${ partSel.colors.join('-') }` : '';
|
const tail = (partSel.colors && partSel.colors.length) ? `-${ partSel.colors.join('-') }` : '';
|
||||||
const isHeadOnly = HEAD_ONLY_PARTS.has(setType);
|
const hd = defaults.hd;
|
||||||
|
const head = `hd-${ hd.partId }-${ hd.colors.join('-') }`;
|
||||||
|
const part = `${ setType }-${ partSel.partId }${ tail }`;
|
||||||
|
|
||||||
let parts: string[];
|
return setType === 'hd' ? part : `${ head }.${ part }`;
|
||||||
if(isHeadOnly)
|
};
|
||||||
|
|
||||||
|
const AVATAR_PREVIEW_CACHE = new Map<string, string>();
|
||||||
|
const AVATAR_PREVIEW_CACHE_MAX = 200;
|
||||||
|
|
||||||
|
const AVATAR_PREVIEW_MAX_ATTEMPTS = 4;
|
||||||
|
const AVATAR_PREVIEW_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
|
const renderAvatarPreview = (figure: string, gender: GenderKey, setType: string): Promise<string> =>
|
||||||
|
{
|
||||||
|
if(!figure) return Promise.resolve('');
|
||||||
|
|
||||||
|
const cacheKey = `${ gender }|${ setType }|${ figure }`;
|
||||||
|
const cached = AVATAR_PREVIEW_CACHE.get(cacheKey);
|
||||||
|
if(cached) return Promise.resolve(cached);
|
||||||
|
|
||||||
|
return new Promise<string>(resolve =>
|
||||||
{
|
{
|
||||||
const hd = defaults.hd;
|
let avatarImage: IAvatarImage | null = null;
|
||||||
const pieces = new Map<string, string>();
|
let resolved = false;
|
||||||
pieces.set('hd', `hd-${ hd.partId }-${ hd.colors.join('-') }`);
|
let attempts = 0;
|
||||||
pieces.set(setType, `${ setType }-${ partSel.partId }${ tail }`);
|
let timer: number | null = null;
|
||||||
parts = Array.from(pieces.values());
|
|
||||||
}
|
const finish = (url: string) =>
|
||||||
else
|
{
|
||||||
|
if(resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
if(timer !== null) window.clearTimeout(timer);
|
||||||
|
try { avatarImage?.dispose(); } catch {}
|
||||||
|
avatarImage = null;
|
||||||
|
if(url)
|
||||||
|
{
|
||||||
|
AVATAR_PREVIEW_CACHE.set(cacheKey, url);
|
||||||
|
if(AVATAR_PREVIEW_CACHE.size > AVATAR_PREVIEW_CACHE_MAX)
|
||||||
|
{
|
||||||
|
const firstKey = AVATAR_PREVIEW_CACHE.keys().next().value;
|
||||||
|
if(firstKey) AVATAR_PREVIEW_CACHE.delete(firstKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
timer = window.setTimeout(() => finish(''), AVATAR_PREVIEW_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const attempt = () =>
|
||||||
|
{
|
||||||
|
if(resolved) return;
|
||||||
|
if(attempts >= AVATAR_PREVIEW_MAX_ATTEMPTS) { finish(''); return; }
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
try { avatarImage?.dispose(); } catch {}
|
||||||
|
avatarImage = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
avatarImage = GetAvatarRenderManager().createAvatarImage(figure, AvatarScaleType.LARGE, gender, {
|
||||||
|
resetFigure: () => attempt(),
|
||||||
|
dispose: () => {},
|
||||||
|
disposed: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
finish('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!avatarImage) { finish(''); return; }
|
||||||
|
|
||||||
|
if(avatarImage.isPlaceholder()) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const url = avatarImage.processAsImageUrl(setType);
|
||||||
|
if(url) finish(url);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
finish('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
attempt();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const useAvatarPreview = (figure: string, gender: GenderKey, setType: string): string =>
|
||||||
|
{
|
||||||
|
const [ url, setUrl ] = useState<string>(() =>
|
||||||
|
AVATAR_PREVIEW_CACHE.get(`${ gender }|${ setType }|${ figure }`) ?? '');
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
const hd = defaults.hd;
|
const cacheKey = `${ gender }|${ setType }|${ figure }`;
|
||||||
parts = [
|
const cached = AVATAR_PREVIEW_CACHE.get(cacheKey);
|
||||||
`hd-${ hd.partId }-${ hd.colors.join('-') }`,
|
if(cached)
|
||||||
`${ setType }-${ partSel.partId }${ tail }`
|
{
|
||||||
];
|
setUrl(cached);
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const figure = parts.join('.');
|
let cancelled = false;
|
||||||
let url = template
|
setUrl('');
|
||||||
.replace(/\{figure\}/g, encodeURIComponent(figure))
|
renderAvatarPreview(figure, gender, setType).then(result =>
|
||||||
.replace(/\{gender\}/g, gender)
|
{
|
||||||
.replace(/\{direction\}/g, '2');
|
if(!cancelled) setUrl(result);
|
||||||
|
});
|
||||||
url = url.replace(/size=l/, 'size=s').replace(/size=m/, 'size=s');
|
return () => { cancelled = true; };
|
||||||
if(!/size=/.test(url)) url += (url.includes('?') ? '&' : '?') + 'size=s';
|
}, [ figure, gender, setType ]);
|
||||||
if(isHeadOnly && !/headonly=/.test(url)) url += '&headonly=1';
|
|
||||||
|
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface AvatarPartRowProps
|
||||||
|
{
|
||||||
|
setType: string;
|
||||||
|
selection: FigureSelection;
|
||||||
|
gender: GenderKey;
|
||||||
|
onPrev: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AvatarPartRow: FC<AvatarPartRowProps> = ({ setType, selection, gender, onPrev, onNext }) =>
|
||||||
|
{
|
||||||
|
const figure = useMemo(() => buildPartPreviewFigure(setType, selection, gender), [ setType, selection, gender ]);
|
||||||
|
const previewSetType = HEAD_ONLY_PARTS.has(setType) ? AvatarSetType.HEAD : AvatarSetType.FULL;
|
||||||
|
const url = useAvatarPreview(figure, gender, previewSetType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="avatar-part-row">
|
||||||
|
<button type="button" className="arrow-btn" aria-label={ `Previous ${ setType }` } onClick={ onPrev }>‹</button>
|
||||||
|
<div className={ `part-preview part-preview-${ setType }` }>
|
||||||
|
{ url && <img src={ url } alt={ `${ setType } preview` } onError={ e => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> }
|
||||||
|
</div>
|
||||||
|
<button type="button" className="arrow-btn" aria-label={ `Next ${ setType }` } onClick={ onNext }>›</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const RegisterDialog: FC<RegisterDialogProps> = props =>
|
const RegisterDialog: FC<RegisterDialogProps> = props =>
|
||||||
{
|
{
|
||||||
const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, imagingUrl, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props;
|
const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props;
|
||||||
|
|
||||||
const [ step, setStep ] = useState<RegisterStep>('credentials');
|
const [ step, setStep ] = useState<RegisterStep>('credentials');
|
||||||
const [ email, setEmail ] = useState('');
|
const [ email, setEmail ] = useState('');
|
||||||
@@ -1253,7 +1343,7 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
const figure = buildFigureString(selection);
|
const figure = buildFigureString(selection);
|
||||||
const previewSrc = buildImagingUrl(imagingUrl, figure, gender);
|
const previewSrc = useAvatarPreview(figure, gender, AvatarSetType.FULL);
|
||||||
|
|
||||||
const handleAvatarSubmit = async (event: FormEvent<HTMLFormElement>) =>
|
const handleAvatarSubmit = async (event: FormEvent<HTMLFormElement>) =>
|
||||||
{
|
{
|
||||||
@@ -1391,24 +1481,20 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
|
|||||||
|
|
||||||
<div className="avatar-builder">
|
<div className="avatar-builder">
|
||||||
<div className="avatar-part-col">
|
<div className="avatar-part-col">
|
||||||
{ PART_ROWS.map(setType => {
|
{ PART_ROWS.map(setType => (
|
||||||
const partPreviewSrc = buildPartPreviewUrl(imagingUrl, setType, selection, gender);
|
<AvatarPartRow
|
||||||
return (
|
key={ `part-${ setType }` }
|
||||||
<div className="avatar-part-row" key={ `part-${ setType }` }>
|
setType={ setType }
|
||||||
<button type="button" className="arrow-btn" aria-label={ `Previous ${ setType }` }
|
selection={ selection }
|
||||||
onClick={ () => cyclePart(setType, -1) }>‹</button>
|
gender={ gender }
|
||||||
<div className={ `part-preview part-preview-${ setType }` }>
|
onPrev={ () => cyclePart(setType, -1) }
|
||||||
<img src={ partPreviewSrc } alt={ `${ setType } preview` } onError={ e => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } />
|
onNext={ () => cyclePart(setType, 1) }
|
||||||
</div>
|
/>
|
||||||
<button type="button" className="arrow-btn" aria-label={ `Next ${ setType }` }
|
)) }
|
||||||
onClick={ () => cyclePart(setType, 1) }>›</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}) }
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="avatar-preview">
|
<div className="avatar-preview">
|
||||||
<img src={ previewSrc } alt="Habbo preview" onError={ e => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } />
|
{ previewSrc && <img src={ previewSrc } alt="Habbo preview" onError={ e => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> }
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="avatar-color-col">
|
<div className="avatar-color-col">
|
||||||
|
|||||||
@@ -708,6 +708,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 600px) and (max-width: 1100px) {
|
||||||
|
.nitro-login-view .login-stack {
|
||||||
|
right: 16px;
|
||||||
|
width: auto;
|
||||||
|
max-width: min(540px, calc(100vw - 32px));
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
column-gap: 12px;
|
||||||
|
row-gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nitro-login-view .login-stack > .nitro-login-card:nth-child(3) {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Login News Window (Habbo flavour) ─── */
|
/* ─── Login News Window (Habbo flavour) ─── */
|
||||||
|
|
||||||
.nitro-login-view .login-news-stack {
|
.nitro-login-view .login-news-stack {
|
||||||
@@ -1065,6 +1081,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.nitro-login-view .login-news-stack {
|
||||||
|
left: 24px;
|
||||||
|
top: 45%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.nitro-login-view .login-news-stack {
|
.nitro-login-view .login-news-stack {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user