From 6124610736acd143d4b5be221bfb7165599c5c5e Mon Sep 17 00:00:00 2001 From: duckietm Date: Fri, 8 May 2026 11:58:32 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=86=99=20Small=20fix=20Avatar=20loading?= =?UTF-8?q?=20&=20moved=20news=20to=20path=20wich=20you=20can=20enter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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", --- .../news}/news.json | 0 public/configuration/renderer-config.example | 2 + scripts/write-asset-loader.mjs | 27 +- src/components/login/LoginView.tsx | 242 ++++++++++++------ src/css/login/LoginView.css | 23 ++ 5 files changed, 214 insertions(+), 80 deletions(-) rename {public/configuration => Content-Gamedata/news}/news.json (100%) diff --git a/public/configuration/news.json b/Content-Gamedata/news/news.json similarity index 100% rename from public/configuration/news.json rename to Content-Gamedata/news/news.json diff --git a/public/configuration/renderer-config.example b/public/configuration/renderer-config.example index a5a0241..f8d94bf 100644 --- a/public/configuration/renderer-config.example +++ b/public/configuration/renderer-config.example @@ -58,6 +58,8 @@ "login.server_key.endpoint": "${api.url}/api/auth/server-key", "login.sso-token.endpoint": "${api.url}/api/auth/sso-token", "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.create.endpoint": "${api.url}/api/badges/custom", "badges.custom.update.endpoint": "${api.url}/api/badges/custom/%badgeId%", diff --git a/scripts/write-asset-loader.mjs b/scripts/write-asset-loader.mjs index d70ec98..4b082fc 100644 --- a/scripts/write-asset-loader.mjs +++ b/scripts/write-asset-loader.mjs @@ -215,6 +215,11 @@ const ASSET_LOADER_JS = `(() => { return new URL(".", source); }; + const getDeployBase = () => { + try { return new URL("..", getBase()); } + catch { return new URL("/", location.href); } + }; + const withCacheBust = (url) => { url.searchParams.set("v", Date.now().toString(36)); return url; @@ -242,9 +247,14 @@ const ASSET_LOADER_JS = `(() => { const resolveAssetCandidates = (path) => { const base = getBase(); + const deploy = getDeployBase(); const normalized = path.replace(/^\\.\\//, ""); const file = normalized.split("/").pop(); + const relative = normalized.replace(/^\\//, ""); 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("./assets/" + file, base), new URL("/src/assets/" + file, base.origin), @@ -376,7 +386,10 @@ const ASSET_LOADER_JS = `(() => { const fetchManifest = async () => { const base = getBase(); + const deploy = getDeployBase(); const candidates = [ + new URL(".vite/manifest.json", deploy), + new URL("manifest.json", deploy), new URL(".vite/manifest.json", base.origin + "/"), new URL("manifest.json", base.origin + "/"), new URL(".vite/manifest.json", base), @@ -392,7 +405,11 @@ const ASSET_LOADER_JS = `(() => { const json = await response.json(); if(json && typeof json === "object") { 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 {} } @@ -418,18 +435,24 @@ const ASSET_LOADER_JS = `(() => { const resolveManifestPath = (manifestBase, file) => { if(/^https?:\\/\\//i.test(file)) 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 fetchEntryFromIndexHtml = async () => { const base = getBase(); + const deploy = getDeployBase(); const candidates = [ + new URL("index.html", deploy), + new URL("./", deploy), new URL("/index.html", base.origin + "/"), new URL("/", base.origin + "/") ]; + const seen = new Set(); for(const candidate of candidates) { + if(seen.has(candidate.href)) continue; + seen.add(candidate.href); try { const response = await fetch(withCacheBust(new URL(candidate.href)), { cache: "no-store" }); if(!response.ok) continue; diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index 32dc933..adb8dbb 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -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 { ClearRememberLogin, GetConfigurationValue, GetOptionalConfigurationValue, GetRememberLogin, StoreRememberLoginFromPayload } from '../../api'; +import { ClearRememberLogin, GetConfigurationValue, GetRememberLogin, StoreRememberLoginFromPayload } from '../../api'; import { configFileUrl } from '../../secure-assets'; import flagBr from '../../assets/images/flag_icon/flag_icon_br.png'; import flagDe from '../../assets/images/flag_icon/flag_icon_de.png'; @@ -195,7 +195,6 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa const [ localeApplying, setLocaleApplying ] = useState(false); const [ localeError, setLocaleError ] = useState(''); const [ loginViewConfig, setLoginViewConfig ] = useState>(() => GetConfigurationValue>('loginview', {})); - const [ , setLocalizationVersion ] = useState(0); const submitTimeRef = useRef(0); const preloadedLoginImagesRef = useRef>(new Set()); @@ -206,22 +205,9 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa const configuredLoginWidgets = useMemo>(() => (loginViewConfig?.['widgets'] as Record) ?? {}, [ loginViewConfig ]); - useEffect(() => - { - const refreshLocalization = () => setLocalizationVersion(value => (value + 1)); - window.addEventListener('nitro-localization-updated', refreshLocalization); - return () => window.removeEventListener('nitro-localization-updated', refreshLocalization); - }, []); - - const loginImages = useMemo>(() => - { - const configured = (loginViewConfig?.['images'] as Record) ?? {}; - return { ...getDefaultLoginImages(), ...configured }; - }, [ loginViewConfig ]); - + const loginWidgetSlots = useMemo(() => { - const configuredLoginWidgets = (loginViewConfig?.['widgets'] as Record) ?? {}; return Object.entries(configuredLoginWidgets) .filter(([ key, value ]) => key.startsWith('slot.') && key.endsWith('.widget') && typeof value === 'string' && value.length > 0) .map(([ key, value ]) => @@ -233,7 +219,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa }) .filter(slot => slot.slotNum > 0) .sort((a, b) => a.slotNum - b.slotNum); - }, [ loginViewConfig ]); + }, [ configuredLoginWidgets ]); const backgroundColor = (loginImages['background.colour'] || GetConfigurationValue('login_background.colour', '#6eadc8')); const background = interpolate(loginImages['background'] || GetConfigurationValue('login_background', '')); @@ -242,11 +228,15 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa const left = interpolate(loginImages['left'] || GetConfigurationValue('login_left', '')); const rightRepeat = interpolate(loginImages['right.repeat'] || GetConfigurationValue('login_right.repeat', '')); const right = interpolate(loginImages['right'] || GetConfigurationValue('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('login.endpoint', '/api/auth/login'); const registerUrl = GetConfigurationValue('login.register.endpoint', '/api/auth/register'); const forgotUrl = GetConfigurationValue('login.forgot.endpoint', '/api/auth/forgot-password'); - const configuredNewsUrl = interpolate(GetOptionalConfigurationValue('login.news.url', '')); - const newsUrl = configuredNewsUrl || configFileUrl('news.json'); + const newsUrl = interpolate(GetConfigurationValue('login.news.url', '')); const turnstileSiteKey = GetConfigurationValue('login.turnstile.sitekey', ''); const rawTurnstileEnabled = GetConfigurationValue('login.turnstile.enabled', false); const turnstileEnabled = (rawTurnstileEnabled === true @@ -413,7 +403,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa }, []); const healthUrl = GetConfigurationValue('login.health.endpoint', ''); - const healthMethodRaw = GetOptionalConfigurationValue('login.health.method', 'GET'); + const healthMethodRaw = GetConfigurationValue('login.health.method', 'GET'); const healthMethod = (healthMethodRaw || 'GET').toUpperCase(); const checkServerReachable = useCallback(async (): Promise => { @@ -527,7 +517,6 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa const checkEmailUrl = GetConfigurationValue('login.check-email.endpoint', '/api/auth/check-email'); const checkUsernameUrl = GetConfigurationValue('login.check-username.endpoint', '/api/auth/check-username'); - const imagingUrl = GetOptionalConfigurationValue('login.register.imaging.url', ''); const interpretAvailability = (ok: boolean, status: number, payload: Record): { available: boolean; error?: string } => { const isTrue = (v: unknown) => v === true || v === 'true' || v === 1 || v === '1'; @@ -675,6 +664,9 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa { left ? : null } { rightRepeat ?
: null } { right ? : null } + { loginWidgetSlots.length > 0 &&
@@ -815,7 +807,6 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa onCheckEmail={ checkEmailAvailable } onCheckUsername={ checkUsernameAvailable } onCheckServer={ checkServerReachable } - imagingUrl={ imagingUrl } submitting={ submitting } error={ error } info={ info } @@ -853,7 +844,6 @@ interface RegisterDialogProps extends DialogSharedProps onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>; onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>; onCheckServer: () => Promise; - imagingUrl: string; } type RegisterStep = 'credentials' | 'avatar'; @@ -914,60 +904,160 @@ const buildFigureString = (selection: FigureSelection): string => 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 buildPartPreviewFigure = (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); + const hd = defaults.hd; + const head = `hd-${ hd.partId }-${ hd.colors.join('-') }`; + const part = `${ setType }-${ partSel.partId }${ tail }`; - let parts: string[]; - if(isHeadOnly) + return setType === 'hd' ? part : `${ head }.${ part }`; +}; + +const AVATAR_PREVIEW_CACHE = new Map(); +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 => +{ + 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(resolve => { - 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 + let avatarImage: IAvatarImage | null = null; + let resolved = false; + let attempts = 0; + let timer: number | null = null; + + const finish = (url: string) => + { + 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(() => + AVATAR_PREVIEW_CACHE.get(`${ gender }|${ setType }|${ figure }`) ?? ''); + + useEffect(() => { - const hd = defaults.hd; - parts = [ - `hd-${ hd.partId }-${ hd.colors.join('-') }`, - `${ setType }-${ partSel.partId }${ tail }` - ]; - } + const cacheKey = `${ gender }|${ setType }|${ figure }`; + const cached = AVATAR_PREVIEW_CACHE.get(cacheKey); + if(cached) + { + setUrl(cached); + return; + } - 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'; + let cancelled = false; + setUrl(''); + renderAvatarPreview(figure, gender, setType).then(result => + { + if(!cancelled) setUrl(result); + }); + return () => { cancelled = true; }; + }, [ figure, gender, setType ]); return url; }; +interface AvatarPartRowProps +{ + setType: string; + selection: FigureSelection; + gender: GenderKey; + onPrev: () => void; + onNext: () => void; +} + +const AvatarPartRow: FC = ({ 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 ( +
+ +
+ { url && { { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> } +
+ +
+ ); +}; + const RegisterDialog: FC = 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('credentials'); const [ email, setEmail ] = useState(''); @@ -1253,7 +1343,7 @@ const RegisterDialog: FC = props => }; const figure = buildFigureString(selection); - const previewSrc = buildImagingUrl(imagingUrl, figure, gender); + const previewSrc = useAvatarPreview(figure, gender, AvatarSetType.FULL); const handleAvatarSubmit = async (event: FormEvent) => { @@ -1391,24 +1481,20 @@ const RegisterDialog: FC = props =>
- { PART_ROWS.map(setType => { - const partPreviewSrc = buildPartPreviewUrl(imagingUrl, setType, selection, gender); - return ( -
- -
- { { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> -
- -
- ); - }) } + { PART_ROWS.map(setType => ( + cyclePart(setType, -1) } + onNext={ () => cyclePart(setType, 1) } + /> + )) }
- Habbo preview { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> + { previewSrc && Habbo preview { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> }
diff --git a/src/css/login/LoginView.css b/src/css/login/LoginView.css index b251f13..d08e028 100644 --- a/src/css/login/LoginView.css +++ b/src/css/login/LoginView.css @@ -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) ─── */ .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) { .nitro-login-view .login-news-stack { display: none;