mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
Merge branch 'Dev' into merge-duckie-main-2026-05-06
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"notification.badge.received": "New Badge!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"notification.badge.received": "Nuovo Distintivo!"
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const ASSET_KEY = new TextEncoder().encode("slogga-dist-assets-2026");
|
const ASSET_KEY = new TextEncoder().encode("slogga-dist-assets-2026");
|
||||||
const MODE_DEFAULTS = {
|
const MODE_DEFAULTS = {
|
||||||
distObfuscationEnabled: true,
|
distObfuscationEnabled: false,
|
||||||
secureAssetsEnabled: true,
|
secureAssetsEnabled: false,
|
||||||
secureApiEnabled: true
|
secureApiEnabled: false
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDebug = () => {
|
const isDebug = () => {
|
||||||
@@ -37,6 +37,9 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getBase = () => {
|
const getBase = () => {
|
||||||
|
if(typeof window.__nitroLoaderBase === "string" && window.__nitroLoaderBase) {
|
||||||
|
try { return new URL(window.__nitroLoaderBase); } catch {}
|
||||||
|
}
|
||||||
const source = document.currentScript?.src || location.href;
|
const source = document.currentScript?.src || location.href;
|
||||||
return new URL(".", source);
|
return new URL(".", source);
|
||||||
};
|
};
|
||||||
@@ -81,10 +84,17 @@
|
|||||||
return [...new Map(urls.map(url => [url.href, url])).values()];
|
return [...new Map(urls.map(url => [url.href, url])).values()];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const expandAssetCandidates = (path) => {
|
||||||
|
const base = getBase();
|
||||||
|
if(/^https?:\/\//i.test(path)) return [new URL(path)];
|
||||||
|
if(path.startsWith("/")) return [new URL(path, base.origin + "/")];
|
||||||
|
return resolveAssetCandidates(path);
|
||||||
|
};
|
||||||
|
|
||||||
const fetchBytes = async (path) => {
|
const fetchBytes = async (path) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
debug("loader: fetching " + path);
|
debug("loader: fetching " + path);
|
||||||
for(const candidate of resolveAssetCandidates(path)) {
|
for(const candidate of expandAssetCandidates(path)) {
|
||||||
try {
|
try {
|
||||||
debug("loader: try " + candidate.href);
|
debug("loader: try " + candidate.href);
|
||||||
const response = await fetch(withCacheBust(candidate), { cache: "no-store" });
|
const response = await fetch(withCacheBust(candidate), { cache: "no-store" });
|
||||||
@@ -110,9 +120,39 @@
|
|||||||
debug("loader: css injected from dat");
|
debug("loader: css injected from dat");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const matchesContentType = (contentType, accepted) => {
|
||||||
|
if(!contentType) return true;
|
||||||
|
return accepted.some(token => contentType.indexOf(token) !== -1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const probePlainAsset = async (path, accepted) => {
|
||||||
|
let lastError = null;
|
||||||
|
for(const candidate of expandAssetCandidates(path)) {
|
||||||
|
try {
|
||||||
|
debug("loader: probe " + candidate.href);
|
||||||
|
const response = await fetch(withCacheBust(candidate), { cache: "no-store" });
|
||||||
|
if(!response.ok) {
|
||||||
|
lastError = new Error("asset " + candidate.pathname + " " + response.status);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const contentType = (response.headers.get("content-type") || "").toLowerCase();
|
||||||
|
if(!matchesContentType(contentType, accepted)) {
|
||||||
|
lastError = new Error("asset " + candidate.pathname + " wrong type " + contentType);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
debug("loader: probe ok " + candidate.href);
|
||||||
|
const url = new URL(candidate.href);
|
||||||
|
url.searchParams.set("v", Date.now().toString(36));
|
||||||
|
return url;
|
||||||
|
} catch(caught) {
|
||||||
|
lastError = caught;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError || new Error("asset " + path + " not found");
|
||||||
|
};
|
||||||
|
|
||||||
const loadPlainCss = async (path) => {
|
const loadPlainCss = async (path) => {
|
||||||
const href = resolveAssetCandidates(path)[0];
|
const href = await probePlainAsset(path, ["text/css"]);
|
||||||
href.searchParams.set("v", Date.now().toString(36));
|
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const link = document.createElement("link");
|
const link = document.createElement("link");
|
||||||
link.rel = "stylesheet";
|
link.rel = "stylesheet";
|
||||||
@@ -136,9 +176,8 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const importPlainJs = async (path) => {
|
const importPlainJs = async (path) => {
|
||||||
const href = resolveAssetCandidates(path)[0];
|
const href = await probePlainAsset(path, ["javascript", "ecmascript"]);
|
||||||
href.searchParams.set("v", Date.now().toString(36));
|
debug("loader: importing plain js " + href.href);
|
||||||
debug("loader: importing plain js");
|
|
||||||
await import(href.href);
|
await import(href.href);
|
||||||
debug("loader: plain js imported");
|
debug("loader: plain js imported");
|
||||||
};
|
};
|
||||||
@@ -164,24 +203,135 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchManifest = async () => {
|
||||||
|
const base = getBase();
|
||||||
|
const candidates = [
|
||||||
|
new URL(".vite/manifest.json", base.origin + "/"),
|
||||||
|
new URL("manifest.json", base.origin + "/"),
|
||||||
|
new URL(".vite/manifest.json", base),
|
||||||
|
new URL("manifest.json", base)
|
||||||
|
];
|
||||||
|
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;
|
||||||
|
const json = await response.json();
|
||||||
|
if(json && typeof json === "object") {
|
||||||
|
debug("loader: manifest from " + candidate.href);
|
||||||
|
return { manifest: json, base: new URL(".", candidate.href) };
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findEntryFromManifest = (manifest) => {
|
||||||
|
let bootstrap = null;
|
||||||
|
for(const key of Object.keys(manifest)) {
|
||||||
|
const entry = manifest[key];
|
||||||
|
if(!entry || typeof entry !== "object" || !entry.isEntry) continue;
|
||||||
|
if(/bootstrap\./.test(key) || /bootstrap\./.test(entry.file || "")) {
|
||||||
|
bootstrap = entry;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if(!bootstrap) bootstrap = entry;
|
||||||
|
}
|
||||||
|
if(!bootstrap) return null;
|
||||||
|
const css = Array.isArray(bootstrap.css) ? bootstrap.css.slice() : [];
|
||||||
|
return { js: bootstrap.file, css };
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveManifestPath = (manifestBase, file) => {
|
||||||
|
if(/^https?:\/\//i.test(file)) return file;
|
||||||
|
if(file.startsWith("/")) return file;
|
||||||
|
return new URL(file, manifestBase.origin + "/").pathname;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoaderUrl = (href) => /(?:^|\/)bootstrap\.js(?:$|\?|#)/i.test(href) || /(?:^|\/)asset-loader\.js(?:$|\?|#)/i.test(href);
|
||||||
|
|
||||||
|
const fetchEntryFromIndexHtml = async () => {
|
||||||
|
const base = getBase();
|
||||||
|
const candidates = [
|
||||||
|
new URL("/index.html", base.origin + "/"),
|
||||||
|
new URL("/", base.origin + "/")
|
||||||
|
];
|
||||||
|
for(const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(withCacheBust(new URL(candidate.href)), { cache: "no-store" });
|
||||||
|
if(!response.ok) continue;
|
||||||
|
const contentType = (response.headers.get("content-type") || "").toLowerCase();
|
||||||
|
if(contentType && contentType.indexOf("html") === -1) continue;
|
||||||
|
const html = await response.text();
|
||||||
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
if(!doc) continue;
|
||||||
|
const resolveAttr = (raw) => {
|
||||||
|
if(!raw) return "";
|
||||||
|
if(/^https?:\/\//i.test(raw)) return raw;
|
||||||
|
try { return new URL(raw, candidate.href).pathname; }
|
||||||
|
catch { return raw; }
|
||||||
|
};
|
||||||
|
const scriptNode = Array.from(doc.querySelectorAll('script[type="module"][src]'))
|
||||||
|
.map(node => node.getAttribute("src") || "")
|
||||||
|
.find(src => src && !isLoaderUrl(src));
|
||||||
|
if(!scriptNode) continue;
|
||||||
|
const cssNodes = Array.from(doc.querySelectorAll('link[rel="stylesheet"][href]'))
|
||||||
|
.map(node => node.getAttribute("href") || "")
|
||||||
|
.filter(href => href && !isLoaderUrl(href));
|
||||||
|
const jsAbs = resolveAttr(scriptNode);
|
||||||
|
const cssAbs = cssNodes.map(resolveAttr);
|
||||||
|
debug("loader: entry from index.html " + jsAbs);
|
||||||
|
return { js: jsAbs, css: cssAbs };
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
debug("loader: start");
|
debug("loader: start");
|
||||||
renderShell();
|
renderShell();
|
||||||
const mode = await readClientMode();
|
const mode = await readClientMode();
|
||||||
|
|
||||||
|
let jsPath = null;
|
||||||
|
let cssPaths = [];
|
||||||
|
const manifestResult = await fetchManifest();
|
||||||
|
if(manifestResult) {
|
||||||
|
const entry = findEntryFromManifest(manifestResult.manifest);
|
||||||
|
if(entry) {
|
||||||
|
jsPath = resolveManifestPath(manifestResult.base, entry.js);
|
||||||
|
if(entry.css.length) cssPaths = entry.css.map(file => resolveManifestPath(manifestResult.base, file));
|
||||||
|
debug("loader: entry from manifest " + jsPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(!jsPath) {
|
||||||
|
const indexEntry = await fetchEntryFromIndexHtml();
|
||||||
|
if(indexEntry) {
|
||||||
|
jsPath = indexEntry.js;
|
||||||
|
if(indexEntry.css.length) cssPaths = indexEntry.css;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(!jsPath) {
|
||||||
|
jsPath = "./assets/app.js";
|
||||||
|
cssPaths = ["./assets/app.css"];
|
||||||
|
debug("loader: entry fallback to app.js/app.css");
|
||||||
|
}
|
||||||
|
|
||||||
if(mode.distObfuscationEnabled) {
|
if(mode.distObfuscationEnabled) {
|
||||||
const [cssBytes, jsBytes] = await Promise.all([
|
const [cssBytesList, jsBytes] = await Promise.all([
|
||||||
loadDatAsset("./assets/app.css.dat"),
|
Promise.all(cssPaths.map(path => loadDatAsset(path + ".dat"))),
|
||||||
loadDatAsset("./assets/app.js.dat")
|
loadDatAsset(jsPath + ".dat")
|
||||||
]);
|
]);
|
||||||
injectCssText(cssBytes);
|
cssBytesList.forEach(bytes => injectCssText(bytes));
|
||||||
await importBytes(jsBytes);
|
await importBytes(jsBytes);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await loadPlainCss("./assets/app.css");
|
for(const css of cssPaths) await loadPlainCss(css);
|
||||||
await importPlainJs("./assets/app.js");
|
await importPlainJs(jsPath);
|
||||||
})().catch(error => {
|
})().catch(error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
debug("loader: failed " + (error?.message || error));
|
debug("loader: failed " + (error?.message || error));
|
||||||
document.body.textContent = "Unable to load client.";
|
document.body.textContent = "Unable to load client.";
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
Vendored
+37
-9
@@ -12,12 +12,16 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
ensureMobileViewport();
|
ensureMobileViewport();
|
||||||
|
const FALLBACK_API_BASE = "";
|
||||||
|
|
||||||
const getBase = () => {
|
const getBase = () => {
|
||||||
const source = document.currentScript?.src || location.href;
|
const source = document.currentScript?.src || location.href;
|
||||||
return new URL(".", source);
|
return new URL(".", source);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LOADER_BASE = getBase();
|
||||||
|
window.__nitroLoaderBase = LOADER_BASE.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;
|
||||||
@@ -81,18 +85,34 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchPlainClientMode = async () => {
|
||||||
|
try {
|
||||||
|
const url = withCacheBust(new URL("./client-mode.json", LOADER_BASE));
|
||||||
|
const response = await fetch(url, { cache: "no-store" });
|
||||||
|
if(!response.ok) throw new Error("HTTP " + response.status);
|
||||||
|
const payload = await response.json();
|
||||||
|
if(payload && typeof payload === "object") {
|
||||||
|
window.__nitroClientMode = payload;
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
} catch(error) {
|
||||||
|
console.warn("[Nitro] client-mode fetch failed:", error?.message || error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const loadPlainBootstrap = async () => {
|
const loadPlainBootstrap = async () => {
|
||||||
const url = withCacheBust(new URL("./asset-loader.js", getBase()));
|
const url = withCacheBust(new URL("./asset-loader.js", LOADER_BASE));
|
||||||
await import(url.href);
|
await import(url.href);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSecureBootstrap = async () => {
|
const loadSecureBootstrap = async (apiBase) => {
|
||||||
if(!API_BASE) throw new Error("Missing apiBaseUrl for secure bootstrap.");
|
if(!apiBase) throw new Error("Missing apiBaseUrl for secure bootstrap.");
|
||||||
|
|
||||||
const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
|
const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
|
||||||
const publicKeyBuffer = await crypto.subtle.exportKey("spki", pair.publicKey);
|
const publicKeyBuffer = await crypto.subtle.exportKey("spki", pair.publicKey);
|
||||||
const publicKey = bytesToBase64(publicKeyBuffer);
|
const publicKey = bytesToBase64(publicKeyBuffer);
|
||||||
const base = API_BASE.replace(/\/$/, "");
|
const base = apiBase.replace(/\/$/, "");
|
||||||
const bootstrapResponse = await fetch(base + "/nitro-sec/bootstrap", {
|
const bootstrapResponse = await fetch(base + "/nitro-sec/bootstrap", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -132,12 +152,20 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
const mode = await fetchPlainClientMode();
|
||||||
await loadSecureBootstrap();
|
const wantsSecure = !!(mode && mode.secureAssetsEnabled);
|
||||||
} catch(error) {
|
const apiBase = (mode && typeof mode.apiBaseUrl === "string" && mode.apiBaseUrl) || FALLBACK_API_BASE;
|
||||||
console.warn("[Nitro] Secure bootstrap fallback:", error?.message || error);
|
|
||||||
await loadPlainBootstrap();
|
if(wantsSecure) {
|
||||||
|
try {
|
||||||
|
await loadSecureBootstrap(apiBase);
|
||||||
|
return;
|
||||||
|
} catch(error) {
|
||||||
|
console.warn("[Nitro] Secure bootstrap fallback:", error?.message || error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await loadPlainBootstrap();
|
||||||
})().catch(error => {
|
})().catch(error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
document.body.textContent = "Unable to load client.";
|
document.body.textContent = "Unable to load client.";
|
||||||
|
|||||||
@@ -28,29 +28,44 @@
|
|||||||
"generic.asset.url": "${asset.url}/generic/%libname%.nitro",
|
"generic.asset.url": "${asset.url}/generic/%libname%.nitro",
|
||||||
"badge.asset.url": "${image.library.url}album1584/%badgename%.gif",
|
"badge.asset.url": "${image.library.url}album1584/%badgename%.gif",
|
||||||
"furni.rotation.bounce.steps": 20,
|
"furni.rotation.bounce.steps": 20,
|
||||||
"furni.rotation.bounce.height": 0.0625,
|
"furni.rotation.bounce.height": 0.0625,
|
||||||
"enable.avatar.arrow": false,
|
"enable.avatar.arrow": false,
|
||||||
"system.log.debug": true,
|
"system.log.debug": true,
|
||||||
"system.log.warn": true,
|
"system.log.warn": true,
|
||||||
"system.log.error": true,
|
"system.log.error": true,
|
||||||
"system.log.events": false,
|
"system.log.events": false,
|
||||||
"system.log.packets": true,
|
"system.log.packets": false,
|
||||||
"system.fps.animation": 24,
|
"system.fps.animation": 24,
|
||||||
"system.fps.max": 60,
|
"system.fps.max": 60,
|
||||||
"system.pong.manually": true,
|
"system.pong.manually": true,
|
||||||
"system.pong.interval.ms": 20000,
|
"system.pong.interval.ms": 20000,
|
||||||
"room.color.skip.transition": true,
|
"room.color.skip.transition": true,
|
||||||
"room.landscapes.enabled": true,
|
"room.landscapes.enabled": true,
|
||||||
"room.zoom.enabled": true,
|
"room.zoom.enabled": true,
|
||||||
"login.screen.enabled": true,
|
"timezone.settings": "Europe/Amsterdam",
|
||||||
|
"youtube.publish.disabled": false,
|
||||||
|
"user.badges.group.slot.enabled": true,
|
||||||
|
"login.screen.enabled": true,
|
||||||
"login.endpoint": "${api.url}/api/auth/login",
|
"login.endpoint": "${api.url}/api/auth/login",
|
||||||
"login.register.endpoint": "${api.url}/api/auth/register",
|
"login.register.endpoint": "${api.url}/api/auth/register",
|
||||||
"login.forgot.endpoint": "${api.url}/api/auth/forgot-password",
|
"login.forgot.endpoint": "${api.url}/api/auth/forgot-password",
|
||||||
"login.logout.endpoint": "${api.url}/api/auth/logout",
|
"login.logout.endpoint": "${api.url}/api/auth/logout",
|
||||||
"login.remember.endpoint": "${api.url}/api/auth/remember",
|
"login.health.endpoint": "${api.url}/api/health",
|
||||||
"login.turnstile.enabled": false,
|
"login.check-email.endpoint": "${api.url}/api/auth/check-email",
|
||||||
"login.turnstile.sitekey": "",
|
"login.check-username.endpoint": "${api.url}/api/auth/check-username",
|
||||||
"avatar.mandatory.libraries": [
|
"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.sso-token.endpoint": "${api.url}/api/auth/sso-token",
|
||||||
|
"login.refresh.endpoint": "${api.url}/api/auth/refresh",
|
||||||
|
"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%",
|
||||||
|
"badges.custom.delete.endpoint": "${api.url}/api/badges/custom/%badgeId%",
|
||||||
|
"badges.custom.texts.endpoint": "${api.url}/api/badges/custom/texts",
|
||||||
|
"login.turnstile.enabled": true,
|
||||||
|
"login.turnstile.sitekey": "1x00000000000000000000AA",
|
||||||
|
"avatar.mandatory.libraries": [
|
||||||
"bd:1",
|
"bd:1",
|
||||||
"li:0"
|
"li:0"
|
||||||
],
|
],
|
||||||
@@ -60,32 +75,536 @@
|
|||||||
"dance.3",
|
"dance.3",
|
||||||
"dance.4"
|
"dance.4"
|
||||||
],
|
],
|
||||||
"avatar.default.figuredata": {
|
"avatar.default.figuredata": {
|
||||||
"palettes": [],
|
"palettes": [
|
||||||
"setTypes": []
|
{
|
||||||
},
|
"id": 1,
|
||||||
"avatar.default.actions": {
|
"colors": [
|
||||||
"actions": []
|
{
|
||||||
},
|
"id": 99999,
|
||||||
"pet.types": [],
|
"index": 1001,
|
||||||
"preload.assets.urls": [
|
"club": 0,
|
||||||
"${asset.url}/generic/avatar_additions.nitro",
|
"selectable": false,
|
||||||
"${asset.url}/generic/group_badge.nitro",
|
"hexCode": "DDDDDD"
|
||||||
"${asset.url}/generic/floor_editor.nitro",
|
},
|
||||||
"${images.url}/loading_icon.png",
|
{
|
||||||
"${images.url}/clear_icon.png",
|
"id": 99998,
|
||||||
"${images.url}/big_arrow.png"
|
"index": 1001,
|
||||||
],
|
"club": 0,
|
||||||
"login.health.endpoint": "${api.url}/api/health",
|
"selectable": false,
|
||||||
"login.health.method": "GET",
|
"hexCode": "FAFAFA"
|
||||||
"login.check-email.endpoint": "${api.url}/api/auth/check-email",
|
}
|
||||||
"login.check-username.endpoint": "${api.url}/api/auth/check-username",
|
]
|
||||||
"login.register.imaging.url": "${api.url}/api/avatar/imaging",
|
},
|
||||||
"crypto.ws.enabled": true,
|
{
|
||||||
"login.news.url": "${api.url}/api/auth/news",
|
"id": 3,
|
||||||
"badges.custom.list.endpoint": "${api.url}/api/badges/custom",
|
"colors": [
|
||||||
"badges.custom.create.endpoint": "${api.url}/api/badges/custom",
|
{
|
||||||
"badges.custom.update.endpoint": "${api.url}/api/badges/custom/%badgeId%",
|
"id": 10001,
|
||||||
"badges.custom.delete.endpoint": "${api.url}/api/badges/custom/%badgeId%",
|
"index": 1001,
|
||||||
"badges.custom.texts.endpoint": "${api.url}/api/badges/custom/texts"
|
"club": 0,
|
||||||
}
|
"selectable": false,
|
||||||
|
"hexCode": "EEEEEE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10002,
|
||||||
|
"index": 1002,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "FA3831"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10003,
|
||||||
|
"index": 1003,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "FD92A0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10004,
|
||||||
|
"index": 1004,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "2AC7D2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10005,
|
||||||
|
"index": 1005,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "35332C"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10006,
|
||||||
|
"index": 1006,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "EFFF92"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10007,
|
||||||
|
"index": 1007,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "C6FF98"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10008,
|
||||||
|
"index": 1008,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "FF925A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10009,
|
||||||
|
"index": 1009,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "9D597E"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10010,
|
||||||
|
"index": 1010,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "B6F3FF"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10011,
|
||||||
|
"index": 1011,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "6DFF33"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10012,
|
||||||
|
"index": 1012,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "3378C9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10013,
|
||||||
|
"index": 1013,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "FFB631"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10014,
|
||||||
|
"index": 1014,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "DFA1E9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10015,
|
||||||
|
"index": 1015,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "F9FB32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10016,
|
||||||
|
"index": 1016,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "CAAF8F"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10017,
|
||||||
|
"index": 1017,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "C5C6C5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10018,
|
||||||
|
"index": 1018,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "47623D"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10019,
|
||||||
|
"index": 1019,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "8A8361"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10020,
|
||||||
|
"index": 1020,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "FF8C33"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10021,
|
||||||
|
"index": 1021,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "54C627"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10022,
|
||||||
|
"index": 1022,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "1E6C99"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10023,
|
||||||
|
"index": 1023,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "984F88"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10024,
|
||||||
|
"index": 1024,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "77C8FF"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10025,
|
||||||
|
"index": 1025,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "FFC08E"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10026,
|
||||||
|
"index": 1026,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "3C4B87"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10027,
|
||||||
|
"index": 1027,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "7C2C47"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10028,
|
||||||
|
"index": 1028,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "D7FFE3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10029,
|
||||||
|
"index": 1029,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "8F3F1C"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10030,
|
||||||
|
"index": 1030,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "FF6393"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10031,
|
||||||
|
"index": 1031,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "1F9B79"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10032,
|
||||||
|
"index": 1032,
|
||||||
|
"club": 0,
|
||||||
|
"selectable": false,
|
||||||
|
"hexCode": "FDFF33"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"setTypes": [
|
||||||
|
{
|
||||||
|
"type": "hd",
|
||||||
|
"paletteId": 1,
|
||||||
|
"mandatory_f_0": true,
|
||||||
|
"mandatory_f_1": true,
|
||||||
|
"mandatory_m_0": true,
|
||||||
|
"mandatory_m_1": true,
|
||||||
|
"sets": [
|
||||||
|
{
|
||||||
|
"id": 99999,
|
||||||
|
"gender": "U",
|
||||||
|
"club": 0,
|
||||||
|
"colorable": true,
|
||||||
|
"selectable": false,
|
||||||
|
"preselectable": false,
|
||||||
|
"sellable": false,
|
||||||
|
"parts": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "bd",
|
||||||
|
"colorable": true,
|
||||||
|
"index": 0,
|
||||||
|
"colorindex": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "hd",
|
||||||
|
"colorable": true,
|
||||||
|
"index": 0,
|
||||||
|
"colorindex": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "lh",
|
||||||
|
"colorable": true,
|
||||||
|
"index": 0,
|
||||||
|
"colorindex": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "rh",
|
||||||
|
"colorable": true,
|
||||||
|
"index": 0,
|
||||||
|
"colorindex": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bds",
|
||||||
|
"paletteId": 1,
|
||||||
|
"mandatory_f_0": false,
|
||||||
|
"mandatory_f_1": false,
|
||||||
|
"mandatory_m_0": false,
|
||||||
|
"mandatory_m_1": false,
|
||||||
|
"sets": [
|
||||||
|
{
|
||||||
|
"id": 10001,
|
||||||
|
"gender": "U",
|
||||||
|
"club": 0,
|
||||||
|
"colorable": true,
|
||||||
|
"selectable": false,
|
||||||
|
"preselectable": false,
|
||||||
|
"sellable": false,
|
||||||
|
"parts": [
|
||||||
|
{
|
||||||
|
"id": 10001,
|
||||||
|
"type": "bds",
|
||||||
|
"colorable": true,
|
||||||
|
"index": 0,
|
||||||
|
"colorindex": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10001,
|
||||||
|
"type": "lhs",
|
||||||
|
"colorable": true,
|
||||||
|
"index": 0,
|
||||||
|
"colorindex": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10001,
|
||||||
|
"type": "rhs",
|
||||||
|
"colorable": true,
|
||||||
|
"index": 0,
|
||||||
|
"colorindex": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hiddenLayers": [
|
||||||
|
{
|
||||||
|
"partType": "bd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partType": "rh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partType": "lh"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "ss",
|
||||||
|
"paletteId": 3,
|
||||||
|
"mandatory_f_0": false,
|
||||||
|
"mandatory_f_1": false,
|
||||||
|
"mandatory_m_0": false,
|
||||||
|
"mandatory_m_1": false,
|
||||||
|
"sets": [
|
||||||
|
{
|
||||||
|
"id": 10010,
|
||||||
|
"gender": "F",
|
||||||
|
"club": 0,
|
||||||
|
"colorable": true,
|
||||||
|
"selectable": false,
|
||||||
|
"preselectable": false,
|
||||||
|
"sellable": false,
|
||||||
|
"parts": [
|
||||||
|
{
|
||||||
|
"id": 10001,
|
||||||
|
"type": "ss",
|
||||||
|
"colorable": true,
|
||||||
|
"index": 0,
|
||||||
|
"colorindex": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hiddenLayers": [
|
||||||
|
{
|
||||||
|
"partType": "ch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partType": "lg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partType": "ca"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partType": "wa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partType": "sh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partType": "ls"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partType": "rs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partType": "lc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partType": "rc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partType": "cc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partType": "cp"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10011,
|
||||||
|
"gender": "M",
|
||||||
|
"club": 0,
|
||||||
|
"colorable": true,
|
||||||
|
"selectable": false,
|
||||||
|
"preselectable": false,
|
||||||
|
"sellable": false,
|
||||||
|
"parts": [
|
||||||
|
{
|
||||||
|
"id": 10002,
|
||||||
|
"type": "ss",
|
||||||
|
"colorable": true,
|
||||||
|
"index": 0,
|
||||||
|
"colorindex": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hiddenLayers": [
|
||||||
|
{
|
||||||
|
"partType": "ch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partType": "lg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partType": "ca"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partType": "wa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partType": "sh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partType": "ls"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partType": "rs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partType": "lc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partType": "rc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partType": "cc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"partType": "cp"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"avatar.default.actions": {
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "Default",
|
||||||
|
"state": "std",
|
||||||
|
"precedence": 1000,
|
||||||
|
"main": true,
|
||||||
|
"isDefault": true,
|
||||||
|
"geometryType": "vertical",
|
||||||
|
"activePartSet": "figure",
|
||||||
|
"assetPartDefinition": "std"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"pet.types": [
|
||||||
|
"dog",
|
||||||
|
"cat",
|
||||||
|
"croco",
|
||||||
|
"terrier",
|
||||||
|
"bear",
|
||||||
|
"pig",
|
||||||
|
"lion",
|
||||||
|
"rhino",
|
||||||
|
"spider",
|
||||||
|
"turtle",
|
||||||
|
"chicken",
|
||||||
|
"frog",
|
||||||
|
"dragon",
|
||||||
|
"monster",
|
||||||
|
"monkey",
|
||||||
|
"horse",
|
||||||
|
"monsterplant",
|
||||||
|
"bunnyeaster",
|
||||||
|
"bunnyevil",
|
||||||
|
"bunnydepressed",
|
||||||
|
"bunnylove",
|
||||||
|
"pigeongood",
|
||||||
|
"pigeonevil",
|
||||||
|
"demonmonkey",
|
||||||
|
"bearbaby",
|
||||||
|
"terrierbaby",
|
||||||
|
"gnome",
|
||||||
|
"gnome",
|
||||||
|
"kittenbaby",
|
||||||
|
"puppybaby",
|
||||||
|
"pigletbaby",
|
||||||
|
"haloompa",
|
||||||
|
"fools",
|
||||||
|
"pterosaur",
|
||||||
|
"velociraptor",
|
||||||
|
"cow",
|
||||||
|
"LeetPen",
|
||||||
|
"bbwibb",
|
||||||
|
"elephants"
|
||||||
|
],
|
||||||
|
"preload.assets.urls": [
|
||||||
|
"${images.url}/loading_icon.png",
|
||||||
|
"${images.url}/clear_icon.png",
|
||||||
|
"${images.url}/big_arrow.png"
|
||||||
|
]
|
||||||
|
}
|
||||||
+392
-219
@@ -1,215 +1,22 @@
|
|||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
import { dirname, resolve } from 'path';
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const loader = `(() => {
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const ASSET_KEY = new TextEncoder().encode("slogga-dist-assets-2026");
|
const __dirname = dirname(__filename);
|
||||||
const MODE_DEFAULTS = {
|
const ROOT = resolve(__dirname, '..');
|
||||||
distObfuscationEnabled: true,
|
const OUTPUT_DIR = resolve(ROOT, 'public', 'configuration');
|
||||||
secureAssetsEnabled: true,
|
|
||||||
secureApiEnabled: true
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDebug = () => {
|
const BOOTSTRAP_JS = `(() => {
|
||||||
try {
|
const FALLBACK_API_BASE = "";
|
||||||
const search = new URLSearchParams(location.search);
|
|
||||||
return search.get("loaderDebug") === "1" || localStorage.getItem("nitro.loader.debug") === "1";
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const debug = (message) => {
|
|
||||||
try {
|
|
||||||
window.__nitroLoaderDebug = message;
|
|
||||||
const log = Array.isArray(window.__nitroLoaderDebugLog) ? window.__nitroLoaderDebugLog : [];
|
|
||||||
log.push(message);
|
|
||||||
window.__nitroLoaderDebugLog = log.slice(-30);
|
|
||||||
if(!isDebug()) {
|
|
||||||
document.getElementById("nitro-loader-debug")?.remove();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let node = document.getElementById("nitro-loader-debug");
|
|
||||||
if(!node) {
|
|
||||||
node = document.createElement("div");
|
|
||||||
node.id = "nitro-loader-debug";
|
|
||||||
node.style.cssText = "position:fixed;left:8px;top:8px;z-index:2147483647;padding:6px 8px;max-width:70vw;background:rgba(0,0,0,.85);color:#fff;font:12px monospace;white-space:pre-wrap";
|
|
||||||
document.body.appendChild(node);
|
|
||||||
}
|
|
||||||
node.textContent = window.__nitroLoaderDebugLog.slice(-10).join("\\n");
|
|
||||||
} catch {}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBase = () => {
|
const getBase = () => {
|
||||||
const source = document.currentScript?.src || location.href;
|
const source = document.currentScript?.src || location.href;
|
||||||
return new URL(".", source);
|
return new URL(".", source);
|
||||||
};
|
};
|
||||||
|
|
||||||
const withCacheBust = (url) => {
|
const LOADER_BASE = getBase();
|
||||||
url.searchParams.set("v", Date.now().toString(36));
|
window.__nitroLoaderBase = LOADER_BASE.href;
|
||||||
return url;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderShell = () => {
|
|
||||||
const root = document.getElementById("root");
|
|
||||||
if(!root || root.firstChild) return;
|
|
||||||
root.innerHTML = '<div style="position:fixed;inset:0;background:linear-gradient(180deg,#6eadc8 0%,#78b7cf 45%,#8ec4d7 100%);overflow:hidden;z-index:1"><div style="position:absolute;left:0;top:0;width:220px;height:220px;background:linear-gradient(135deg,rgba(255,255,255,.18),rgba(255,255,255,0));clip-path:polygon(0 0,100% 0,0 100%)"></div><div style="position:absolute;right:0;bottom:0;width:32vw;max-width:420px;height:100%;background:linear-gradient(270deg,rgba(255,255,255,.16),rgba(255,255,255,0))"></div><div style="position:absolute;top:50%;right:8vw;transform:translateY(-50%);display:flex;flex-direction:column;gap:18px;width:260px"><div style="height:86px;background:#a2bfd1;border:2px solid #3f6a85;border-radius:8px;box-shadow:inset 0 2px rgba(255,255,255,.35),0 4px 6px rgba(0,0,0,.25)"></div><div style="height:190px;background:#a2bfd1;border:2px solid #3f6a85;border-radius:8px;box-shadow:inset 0 2px rgba(255,255,255,.35),0 4px 6px rgba(0,0,0,.25)"></div></div></div>';
|
|
||||||
};
|
|
||||||
|
|
||||||
const decodeAsset = (bytes) => {
|
|
||||||
const output = new Uint8Array(bytes.length);
|
|
||||||
for(let index = 0; index < bytes.length; index++) {
|
|
||||||
output[index] = bytes[index] ^ ASSET_KEY[index % ASSET_KEY.length] ^ ((index * 31) & 255);
|
|
||||||
}
|
|
||||||
return output;
|
|
||||||
};
|
|
||||||
|
|
||||||
const gunzip = async (bytes) => {
|
|
||||||
if(!("DecompressionStream" in self)) throw new Error("gzip decompression unsupported");
|
|
||||||
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
|
|
||||||
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveAssetCandidates = (path) => {
|
|
||||||
const base = getBase();
|
|
||||||
const normalized = path.replace(/^\\.\\//, "");
|
|
||||||
const file = normalized.split("/").pop();
|
|
||||||
const urls = [
|
|
||||||
new URL("./src/assets/" + file, base),
|
|
||||||
new URL("./assets/" + file, base),
|
|
||||||
new URL("/src/assets/" + file, base.origin),
|
|
||||||
new URL("/assets/" + file, base.origin),
|
|
||||||
new URL("/client/src/assets/" + file, base.origin),
|
|
||||||
new URL("/client/assets/" + file, base.origin)
|
|
||||||
];
|
|
||||||
return [...new Map(urls.map(url => [url.href, url])).values()];
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchBytes = async (path) => {
|
|
||||||
let error = null;
|
|
||||||
debug("loader: fetching " + path);
|
|
||||||
for(const candidate of resolveAssetCandidates(path)) {
|
|
||||||
try {
|
|
||||||
debug("loader: try " + candidate.href);
|
|
||||||
const response = await fetch(withCacheBust(candidate), { cache: "no-store" });
|
|
||||||
if(!response.ok) {
|
|
||||||
error = new Error("asset " + candidate.pathname + " " + response.status);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
debug("loader: ok " + candidate.href);
|
|
||||||
return new Uint8Array(await response.arrayBuffer());
|
|
||||||
} catch(caught) {
|
|
||||||
error = caught;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw error || new Error("asset " + path + " not found");
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadDatAsset = async (path) => gunzip(decodeAsset(await fetchBytes(path)));
|
|
||||||
|
|
||||||
const injectCssText = (bytes) => {
|
|
||||||
const node = document.createElement("style");
|
|
||||||
node.textContent = new TextDecoder().decode(bytes);
|
|
||||||
document.head.appendChild(node);
|
|
||||||
debug("loader: css injected from dat");
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadPlainCss = async (path) => {
|
|
||||||
const href = resolveAssetCandidates(path)[0];
|
|
||||||
href.searchParams.set("v", Date.now().toString(36));
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
const link = document.createElement("link");
|
|
||||||
link.rel = "stylesheet";
|
|
||||||
link.href = href.href;
|
|
||||||
link.onload = () => resolve();
|
|
||||||
link.onerror = () => reject(new Error("plain css failed"));
|
|
||||||
document.head.appendChild(link);
|
|
||||||
});
|
|
||||||
debug("loader: css linked");
|
|
||||||
};
|
|
||||||
|
|
||||||
const importBytes = async (bytes) => {
|
|
||||||
const blobUrl = URL.createObjectURL(new Blob([bytes], { type: "text/javascript" }));
|
|
||||||
try {
|
|
||||||
debug("loader: importing app blob");
|
|
||||||
await import(blobUrl);
|
|
||||||
debug("loader: app blob imported");
|
|
||||||
} finally {
|
|
||||||
URL.revokeObjectURL(blobUrl);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const importPlainJs = async (path) => {
|
|
||||||
const href = resolveAssetCandidates(path)[0];
|
|
||||||
href.searchParams.set("v", Date.now().toString(36));
|
|
||||||
debug("loader: importing plain js");
|
|
||||||
await import(href.href);
|
|
||||||
debug("loader: plain js imported");
|
|
||||||
};
|
|
||||||
|
|
||||||
const readClientMode = async () => {
|
|
||||||
try {
|
|
||||||
if(window.__nitroClientMode && typeof window.__nitroClientMode === "object") {
|
|
||||||
debug("loader: client-mode preset");
|
|
||||||
return window.__nitroClientMode;
|
|
||||||
}
|
|
||||||
const url = withCacheBust(new URL("./client-mode.json", getBase()));
|
|
||||||
const response = await fetch(url, { cache: "no-store" });
|
|
||||||
if(!response.ok) throw new Error("client-mode " + response.status);
|
|
||||||
const payload = await response.json();
|
|
||||||
const mode = { ...MODE_DEFAULTS, ...(payload && typeof payload === "object" ? payload : {}) };
|
|
||||||
window.__nitroClientMode = mode;
|
|
||||||
debug("loader: client-mode loaded");
|
|
||||||
return mode;
|
|
||||||
} catch(error) {
|
|
||||||
window.__nitroClientMode = { ...MODE_DEFAULTS };
|
|
||||||
debug("loader: client-mode fallback " + (error?.message || error));
|
|
||||||
return window.__nitroClientMode;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
debug("loader: start");
|
|
||||||
renderShell();
|
|
||||||
const mode = await readClientMode();
|
|
||||||
if(mode.distObfuscationEnabled) {
|
|
||||||
const [cssBytes, jsBytes] = await Promise.all([
|
|
||||||
loadDatAsset("./assets/app.css.dat"),
|
|
||||||
loadDatAsset("./assets/app.js.dat")
|
|
||||||
]);
|
|
||||||
injectCssText(cssBytes);
|
|
||||||
await importBytes(jsBytes);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await loadPlainCss("./assets/app.css");
|
|
||||||
await importPlainJs("./assets/app.js");
|
|
||||||
})().catch(error => {
|
|
||||||
console.error(error);
|
|
||||||
debug("loader: failed " + (error?.message || error));
|
|
||||||
document.body.textContent = "Unable to load client.";
|
|
||||||
});
|
|
||||||
})();`;
|
|
||||||
|
|
||||||
const clientModePath = resolve('public', 'configuration', 'client-mode.json');
|
|
||||||
let bootstrapApiBase = '';
|
|
||||||
|
|
||||||
if(existsSync(clientModePath))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const clientMode = JSON.parse(readFileSync(clientModePath, 'utf8'));
|
|
||||||
|
|
||||||
if(typeof clientMode.apiBaseUrl === 'string') bootstrapApiBase = clientMode.apiBaseUrl;
|
|
||||||
}
|
|
||||||
catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bootstrap = `(() => {
|
|
||||||
const API_BASE = ${ JSON.stringify(bootstrapApiBase) };
|
|
||||||
|
|
||||||
const getBase = () => {
|
|
||||||
const source = document.currentScript?.src || location.href;
|
|
||||||
return new URL(".", source);
|
|
||||||
};
|
|
||||||
|
|
||||||
const withCacheBust = (url) => {
|
const withCacheBust = (url) => {
|
||||||
url.searchParams.set("v", Date.now().toString(36));
|
url.searchParams.set("v", Date.now().toString(36));
|
||||||
@@ -274,18 +81,34 @@ const bootstrap = `(() => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchPlainClientMode = async () => {
|
||||||
|
try {
|
||||||
|
const url = withCacheBust(new URL("./client-mode.json", LOADER_BASE));
|
||||||
|
const response = await fetch(url, { cache: "no-store" });
|
||||||
|
if(!response.ok) throw new Error("HTTP " + response.status);
|
||||||
|
const payload = await response.json();
|
||||||
|
if(payload && typeof payload === "object") {
|
||||||
|
window.__nitroClientMode = payload;
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
} catch(error) {
|
||||||
|
console.warn("[Nitro] client-mode fetch failed:", error?.message || error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const loadPlainBootstrap = async () => {
|
const loadPlainBootstrap = async () => {
|
||||||
const url = withCacheBust(new URL("./asset-loader.js", getBase()));
|
const url = withCacheBust(new URL("./asset-loader.js", LOADER_BASE));
|
||||||
await import(url.href);
|
await import(url.href);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSecureBootstrap = async () => {
|
const loadSecureBootstrap = async (apiBase) => {
|
||||||
if(!API_BASE) throw new Error("Missing apiBaseUrl for secure bootstrap.");
|
if(!apiBase) throw new Error("Missing apiBaseUrl for secure bootstrap.");
|
||||||
|
|
||||||
const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
|
const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
|
||||||
const publicKeyBuffer = await crypto.subtle.exportKey("spki", pair.publicKey);
|
const publicKeyBuffer = await crypto.subtle.exportKey("spki", pair.publicKey);
|
||||||
const publicKey = bytesToBase64(publicKeyBuffer);
|
const publicKey = bytesToBase64(publicKeyBuffer);
|
||||||
const base = API_BASE.replace(/\\/$/, "");
|
const base = apiBase.replace(/\\/$/, "");
|
||||||
const bootstrapResponse = await fetch(base + "/nitro-sec/bootstrap", {
|
const bootstrapResponse = await fetch(base + "/nitro-sec/bootstrap", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -325,21 +148,371 @@ const bootstrap = `(() => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
const mode = await fetchPlainClientMode();
|
||||||
await loadSecureBootstrap();
|
const wantsSecure = !!(mode && mode.secureAssetsEnabled);
|
||||||
} catch(error) {
|
const apiBase = (mode && typeof mode.apiBaseUrl === "string" && mode.apiBaseUrl) || FALLBACK_API_BASE;
|
||||||
console.warn("[Nitro] Secure bootstrap fallback:", error?.message || error);
|
|
||||||
await loadPlainBootstrap();
|
if(wantsSecure) {
|
||||||
|
try {
|
||||||
|
await loadSecureBootstrap(apiBase);
|
||||||
|
return;
|
||||||
|
} catch(error) {
|
||||||
|
console.warn("[Nitro] Secure bootstrap fallback:", error?.message || error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await loadPlainBootstrap();
|
||||||
})().catch(error => {
|
})().catch(error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
document.body.textContent = "Unable to load client.";
|
document.body.textContent = "Unable to load client.";
|
||||||
});
|
});
|
||||||
})();`;
|
})();
|
||||||
|
`;
|
||||||
|
|
||||||
const target = resolve('public', 'configuration', 'asset-loader.js');
|
const ASSET_LOADER_JS = `(() => {
|
||||||
const bootstrapTarget = resolve('public', 'configuration', 'bootstrap.js');
|
const ASSET_KEY = new TextEncoder().encode("slogga-dist-assets-2026");
|
||||||
|
const MODE_DEFAULTS = {
|
||||||
|
distObfuscationEnabled: false,
|
||||||
|
secureAssetsEnabled: false,
|
||||||
|
secureApiEnabled: false
|
||||||
|
};
|
||||||
|
|
||||||
mkdirSync(dirname(target), { recursive: true });
|
const isDebug = () => {
|
||||||
writeFileSync(target, loader);
|
try {
|
||||||
writeFileSync(bootstrapTarget, bootstrap);
|
const search = new URLSearchParams(location.search);
|
||||||
|
return search.get("loaderDebug") === "1" || localStorage.getItem("nitro.loader.debug") === "1";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const debug = (message) => {
|
||||||
|
try {
|
||||||
|
window.__nitroLoaderDebug = message;
|
||||||
|
const log = Array.isArray(window.__nitroLoaderDebugLog) ? window.__nitroLoaderDebugLog : [];
|
||||||
|
log.push(message);
|
||||||
|
window.__nitroLoaderDebugLog = log.slice(-30);
|
||||||
|
if(!isDebug()) {
|
||||||
|
document.getElementById("nitro-loader-debug")?.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let node = document.getElementById("nitro-loader-debug");
|
||||||
|
if(!node) {
|
||||||
|
node = document.createElement("div");
|
||||||
|
node.id = "nitro-loader-debug";
|
||||||
|
node.style.cssText = "position:fixed;left:8px;top:8px;z-index:2147483647;padding:6px 8px;max-width:70vw;background:rgba(0,0,0,.85);color:#fff;font:12px monospace;white-space:pre-wrap";
|
||||||
|
document.body.appendChild(node);
|
||||||
|
}
|
||||||
|
node.textContent = window.__nitroLoaderDebugLog.slice(-10).join("\\n");
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBase = () => {
|
||||||
|
if(typeof window.__nitroLoaderBase === "string" && window.__nitroLoaderBase) {
|
||||||
|
try { return new URL(window.__nitroLoaderBase); } catch {}
|
||||||
|
}
|
||||||
|
const source = document.currentScript?.src || location.href;
|
||||||
|
return new URL(".", source);
|
||||||
|
};
|
||||||
|
|
||||||
|
const withCacheBust = (url) => {
|
||||||
|
url.searchParams.set("v", Date.now().toString(36));
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderShell = () => {
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
if(!root || root.firstChild) return;
|
||||||
|
root.innerHTML = '<div style="position:fixed;inset:0;background:linear-gradient(180deg,#6eadc8 0%,#78b7cf 45%,#8ec4d7 100%);overflow:hidden;z-index:1"><div style="position:absolute;left:0;top:0;width:220px;height:220px;background:linear-gradient(135deg,rgba(255,255,255,.18),rgba(255,255,255,0));clip-path:polygon(0 0,100% 0,0 100%)"></div><div style="position:absolute;right:0;bottom:0;width:32vw;max-width:420px;height:100%;background:linear-gradient(270deg,rgba(255,255,255,.16),rgba(255,255,255,0))"></div><div style="position:absolute;top:50%;right:8vw;transform:translateY(-50%);display:flex;flex-direction:column;gap:18px;width:260px"><div style="height:86px;background:#a2bfd1;border:2px solid #3f6a85;border-radius:8px;box-shadow:inset 0 2px rgba(255,255,255,.35),0 4px 6px rgba(0,0,0,.25)"></div><div style="height:190px;background:#a2bfd1;border:2px solid #3f6a85;border-radius:8px;box-shadow:inset 0 2px rgba(255,255,255,.35),0 4px 6px rgba(0,0,0,.25)"></div></div></div>';
|
||||||
|
};
|
||||||
|
|
||||||
|
const decodeAsset = (bytes) => {
|
||||||
|
const output = new Uint8Array(bytes.length);
|
||||||
|
for(let index = 0; index < bytes.length; index++) {
|
||||||
|
output[index] = bytes[index] ^ ASSET_KEY[index % ASSET_KEY.length] ^ ((index * 31) & 255);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
|
||||||
|
const gunzip = async (bytes) => {
|
||||||
|
if(!("DecompressionStream" in self)) throw new Error("gzip decompression unsupported");
|
||||||
|
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
|
||||||
|
return new Uint8Array(await new Response(stream).arrayBuffer());
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveAssetCandidates = (path) => {
|
||||||
|
const base = getBase();
|
||||||
|
const normalized = path.replace(/^\\.\\//, "");
|
||||||
|
const file = normalized.split("/").pop();
|
||||||
|
const urls = [
|
||||||
|
new URL("./src/assets/" + file, base),
|
||||||
|
new URL("./assets/" + file, base),
|
||||||
|
new URL("/src/assets/" + file, base.origin),
|
||||||
|
new URL("/assets/" + file, base.origin),
|
||||||
|
new URL("/client/src/assets/" + file, base.origin),
|
||||||
|
new URL("/client/assets/" + file, base.origin)
|
||||||
|
];
|
||||||
|
return [...new Map(urls.map(url => [url.href, url])).values()];
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandAssetCandidates = (path) => {
|
||||||
|
const base = getBase();
|
||||||
|
if(/^https?:\\/\\//i.test(path)) return [new URL(path)];
|
||||||
|
if(path.startsWith("/")) return [new URL(path, base.origin + "/")];
|
||||||
|
return resolveAssetCandidates(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchBytes = async (path) => {
|
||||||
|
let error = null;
|
||||||
|
debug("loader: fetching " + path);
|
||||||
|
for(const candidate of expandAssetCandidates(path)) {
|
||||||
|
try {
|
||||||
|
debug("loader: try " + candidate.href);
|
||||||
|
const response = await fetch(withCacheBust(candidate), { cache: "no-store" });
|
||||||
|
if(!response.ok) {
|
||||||
|
error = new Error("asset " + candidate.pathname + " " + response.status);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
debug("loader: ok " + candidate.href);
|
||||||
|
return new Uint8Array(await response.arrayBuffer());
|
||||||
|
} catch(caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error || new Error("asset " + path + " not found");
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDatAsset = async (path) => gunzip(decodeAsset(await fetchBytes(path)));
|
||||||
|
|
||||||
|
const injectCssText = (bytes) => {
|
||||||
|
const node = document.createElement("style");
|
||||||
|
node.textContent = new TextDecoder().decode(bytes);
|
||||||
|
document.head.appendChild(node);
|
||||||
|
debug("loader: css injected from dat");
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchesContentType = (contentType, accepted) => {
|
||||||
|
if(!contentType) return true;
|
||||||
|
return accepted.some(token => contentType.indexOf(token) !== -1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const probePlainAsset = async (path, accepted) => {
|
||||||
|
let lastError = null;
|
||||||
|
for(const candidate of expandAssetCandidates(path)) {
|
||||||
|
try {
|
||||||
|
debug("loader: probe " + candidate.href);
|
||||||
|
const response = await fetch(withCacheBust(candidate), { cache: "no-store" });
|
||||||
|
if(!response.ok) {
|
||||||
|
lastError = new Error("asset " + candidate.pathname + " " + response.status);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const contentType = (response.headers.get("content-type") || "").toLowerCase();
|
||||||
|
if(!matchesContentType(contentType, accepted)) {
|
||||||
|
lastError = new Error("asset " + candidate.pathname + " wrong type " + contentType);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
debug("loader: probe ok " + candidate.href);
|
||||||
|
const url = new URL(candidate.href);
|
||||||
|
url.searchParams.set("v", Date.now().toString(36));
|
||||||
|
return url;
|
||||||
|
} catch(caught) {
|
||||||
|
lastError = caught;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError || new Error("asset " + path + " not found");
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPlainCss = async (path) => {
|
||||||
|
const href = await probePlainAsset(path, ["text/css"]);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
link.href = href.href;
|
||||||
|
link.onload = () => resolve();
|
||||||
|
link.onerror = () => reject(new Error("plain css failed"));
|
||||||
|
document.head.appendChild(link);
|
||||||
|
});
|
||||||
|
debug("loader: css linked");
|
||||||
|
};
|
||||||
|
|
||||||
|
const importBytes = async (bytes) => {
|
||||||
|
const blobUrl = URL.createObjectURL(new Blob([bytes], { type: "text/javascript" }));
|
||||||
|
try {
|
||||||
|
debug("loader: importing app blob");
|
||||||
|
await import(blobUrl);
|
||||||
|
debug("loader: app blob imported");
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const importPlainJs = async (path) => {
|
||||||
|
const href = await probePlainAsset(path, ["javascript", "ecmascript"]);
|
||||||
|
debug("loader: importing plain js " + href.href);
|
||||||
|
await import(href.href);
|
||||||
|
debug("loader: plain js imported");
|
||||||
|
};
|
||||||
|
|
||||||
|
const readClientMode = async () => {
|
||||||
|
try {
|
||||||
|
if(window.__nitroClientMode && typeof window.__nitroClientMode === "object") {
|
||||||
|
debug("loader: client-mode preset");
|
||||||
|
return window.__nitroClientMode;
|
||||||
|
}
|
||||||
|
const url = withCacheBust(new URL("./client-mode.json", getBase()));
|
||||||
|
const response = await fetch(url, { cache: "no-store" });
|
||||||
|
if(!response.ok) throw new Error("client-mode " + response.status);
|
||||||
|
const payload = await response.json();
|
||||||
|
const mode = { ...MODE_DEFAULTS, ...(payload && typeof payload === "object" ? payload : {}) };
|
||||||
|
window.__nitroClientMode = mode;
|
||||||
|
debug("loader: client-mode loaded");
|
||||||
|
return mode;
|
||||||
|
} catch(error) {
|
||||||
|
window.__nitroClientMode = { ...MODE_DEFAULTS };
|
||||||
|
debug("loader: client-mode fallback " + (error?.message || error));
|
||||||
|
return window.__nitroClientMode;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchManifest = async () => {
|
||||||
|
const base = getBase();
|
||||||
|
const candidates = [
|
||||||
|
new URL(".vite/manifest.json", base.origin + "/"),
|
||||||
|
new URL("manifest.json", base.origin + "/"),
|
||||||
|
new URL(".vite/manifest.json", base),
|
||||||
|
new URL("manifest.json", base)
|
||||||
|
];
|
||||||
|
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;
|
||||||
|
const json = await response.json();
|
||||||
|
if(json && typeof json === "object") {
|
||||||
|
debug("loader: manifest from " + candidate.href);
|
||||||
|
return { manifest: json, base: new URL(".", candidate.href) };
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findEntryFromManifest = (manifest) => {
|
||||||
|
let bootstrap = null;
|
||||||
|
for(const key of Object.keys(manifest)) {
|
||||||
|
const entry = manifest[key];
|
||||||
|
if(!entry || typeof entry !== "object" || !entry.isEntry) continue;
|
||||||
|
if(/bootstrap\\./.test(key) || /bootstrap\\./.test(entry.file || "")) {
|
||||||
|
bootstrap = entry;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if(!bootstrap) bootstrap = entry;
|
||||||
|
}
|
||||||
|
if(!bootstrap) return null;
|
||||||
|
const css = Array.isArray(bootstrap.css) ? bootstrap.css.slice() : [];
|
||||||
|
return { js: bootstrap.file, css };
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveManifestPath = (manifestBase, file) => {
|
||||||
|
if(/^https?:\\/\\//i.test(file)) return file;
|
||||||
|
if(file.startsWith("/")) return file;
|
||||||
|
return new URL(file, manifestBase.origin + "/").pathname;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoaderUrl = (href) => /(?:^|\\/)bootstrap\\.js(?:$|\\?|#)/i.test(href) || /(?:^|\\/)asset-loader\\.js(?:$|\\?|#)/i.test(href);
|
||||||
|
|
||||||
|
const fetchEntryFromIndexHtml = async () => {
|
||||||
|
const base = getBase();
|
||||||
|
const candidates = [
|
||||||
|
new URL("/index.html", base.origin + "/"),
|
||||||
|
new URL("/", base.origin + "/")
|
||||||
|
];
|
||||||
|
for(const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(withCacheBust(new URL(candidate.href)), { cache: "no-store" });
|
||||||
|
if(!response.ok) continue;
|
||||||
|
const contentType = (response.headers.get("content-type") || "").toLowerCase();
|
||||||
|
if(contentType && contentType.indexOf("html") === -1) continue;
|
||||||
|
const html = await response.text();
|
||||||
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
if(!doc) continue;
|
||||||
|
const resolveAttr = (raw) => {
|
||||||
|
if(!raw) return "";
|
||||||
|
if(/^https?:\\/\\//i.test(raw)) return raw;
|
||||||
|
try { return new URL(raw, candidate.href).pathname; }
|
||||||
|
catch { return raw; }
|
||||||
|
};
|
||||||
|
const scriptNode = Array.from(doc.querySelectorAll('script[type="module"][src]'))
|
||||||
|
.map(node => node.getAttribute("src") || "")
|
||||||
|
.find(src => src && !isLoaderUrl(src));
|
||||||
|
if(!scriptNode) continue;
|
||||||
|
const cssNodes = Array.from(doc.querySelectorAll('link[rel="stylesheet"][href]'))
|
||||||
|
.map(node => node.getAttribute("href") || "")
|
||||||
|
.filter(href => href && !isLoaderUrl(href));
|
||||||
|
const jsAbs = resolveAttr(scriptNode);
|
||||||
|
const cssAbs = cssNodes.map(resolveAttr);
|
||||||
|
debug("loader: entry from index.html " + jsAbs);
|
||||||
|
return { js: jsAbs, css: cssAbs };
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
debug("loader: start");
|
||||||
|
renderShell();
|
||||||
|
const mode = await readClientMode();
|
||||||
|
|
||||||
|
let jsPath = null;
|
||||||
|
let cssPaths = [];
|
||||||
|
const manifestResult = await fetchManifest();
|
||||||
|
if(manifestResult) {
|
||||||
|
const entry = findEntryFromManifest(manifestResult.manifest);
|
||||||
|
if(entry) {
|
||||||
|
jsPath = resolveManifestPath(manifestResult.base, entry.js);
|
||||||
|
if(entry.css.length) cssPaths = entry.css.map(file => resolveManifestPath(manifestResult.base, file));
|
||||||
|
debug("loader: entry from manifest " + jsPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(!jsPath) {
|
||||||
|
const indexEntry = await fetchEntryFromIndexHtml();
|
||||||
|
if(indexEntry) {
|
||||||
|
jsPath = indexEntry.js;
|
||||||
|
if(indexEntry.css.length) cssPaths = indexEntry.css;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(!jsPath) {
|
||||||
|
jsPath = "./assets/app.js";
|
||||||
|
cssPaths = ["./assets/app.css"];
|
||||||
|
debug("loader: entry fallback to app.js/app.css");
|
||||||
|
}
|
||||||
|
|
||||||
|
if(mode.distObfuscationEnabled) {
|
||||||
|
const [cssBytesList, jsBytes] = await Promise.all([
|
||||||
|
Promise.all(cssPaths.map(path => loadDatAsset(path + ".dat"))),
|
||||||
|
loadDatAsset(jsPath + ".dat")
|
||||||
|
]);
|
||||||
|
cssBytesList.forEach(bytes => injectCssText(bytes));
|
||||||
|
await importBytes(jsBytes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for(const css of cssPaths) await loadPlainCss(css);
|
||||||
|
await importPlainJs(jsPath);
|
||||||
|
})().catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
debug("loader: failed " + (error?.message || error));
|
||||||
|
document.body.textContent = "Unable to load client.";
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
|
||||||
|
const writeFileEnsuring = async (path, contents) => {
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await writeFile(path, contents, 'utf8');
|
||||||
|
console.log(`[write-asset-loader] wrote ${path}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeFileEnsuring(resolve(OUTPUT_DIR, 'bootstrap.js'), BOOTSTRAP_JS);
|
||||||
|
await writeFileEnsuring(resolve(OUTPUT_DIR, 'asset-loader.js'), ASSET_LOADER_JS);
|
||||||
|
|||||||
@@ -4,3 +4,8 @@ export function GetConfigurationValue<T = string>(key: string, value: T = null):
|
|||||||
{
|
{
|
||||||
return GetConfiguration().getValue(key, value);
|
return GetConfiguration().getValue(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetOptionalConfigurationValue<T = string>(key: string, value: T = null): T
|
||||||
|
{
|
||||||
|
return GetConfiguration().definitions.has(key) ? GetConfiguration().getValue(key, value) : value;
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,11 +80,97 @@ const applyWiredTextMarkup = (content: string) =>
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FONT_NAMED_COLORS = new Set([
|
||||||
|
'red', 'green', 'blue', 'yellow', 'white', 'black',
|
||||||
|
'orange', 'cyan', 'brown', 'purple', 'pink', 'magenta',
|
||||||
|
'violet', 'gray', 'grey', 'lime', 'teal', 'gold',
|
||||||
|
'silver', 'navy', 'maroon', 'olive', 'indigo'
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const sanitizeFontColor = (raw: string | null | undefined): string | null =>
|
||||||
|
{
|
||||||
|
if(!raw) return null;
|
||||||
|
if(raw.length > 20) return null;
|
||||||
|
|
||||||
|
const value = raw.trim().toLowerCase();
|
||||||
|
|
||||||
|
if(/^#([0-9a-f]{3}|[0-9a-f]{6})$/.test(value)) return value;
|
||||||
|
if(FONT_NAMED_COLORS.has(value)) return value;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FontSegment = { color: string | null; text: string };
|
||||||
|
|
||||||
|
const FONT_COLOR_ATTR = /color\s*=\s*(?:"([^"]{1,32})"|'([^']{1,32})'|([^\s"'>]{1,32}))/i;
|
||||||
|
|
||||||
|
export const parseFontSegments = (input: string): FontSegment[] =>
|
||||||
|
{
|
||||||
|
if(!input) return [];
|
||||||
|
|
||||||
|
const pattern = /<font\b([^>]{0,200}?)>([\s\S]{0,200}?)<\/font>/gi;
|
||||||
|
const segments: FontSegment[] = [];
|
||||||
|
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while((match = pattern.exec(input)) !== null)
|
||||||
|
{
|
||||||
|
if(match.index > lastIndex)
|
||||||
|
{
|
||||||
|
segments.push({ color: null, text: input.slice(lastIndex, match.index) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorMatch = FONT_COLOR_ATTR.exec(match[1] || '');
|
||||||
|
const rawColor = colorMatch ? (colorMatch[1] || colorMatch[2] || colorMatch[3]) : null;
|
||||||
|
const color = sanitizeFontColor(rawColor);
|
||||||
|
|
||||||
|
segments.push({ color, text: match[2] });
|
||||||
|
lastIndex = pattern.lastIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(lastIndex < input.length)
|
||||||
|
{
|
||||||
|
segments.push({ color: null, text: input.slice(lastIndex) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyFontMarkup = (content: string) =>
|
||||||
|
{
|
||||||
|
const fontPattern = /<font\b([^&]{0,200}?)>([\s\S]{0,4000}?)<\/font>/gi;
|
||||||
|
const colorAttr = /color\s*=\s*(?:"([^"]{1,32})"|'([^']{1,32})'|([^\s"'>]{1,32}))/i;
|
||||||
|
|
||||||
|
let previous = '';
|
||||||
|
let next = content;
|
||||||
|
let guard = 0;
|
||||||
|
|
||||||
|
while((previous !== next) && (guard < 20))
|
||||||
|
{
|
||||||
|
previous = next;
|
||||||
|
next = next.replace(fontPattern, (_match, attrs: string, inner: string) =>
|
||||||
|
{
|
||||||
|
const colorMatch = colorAttr.exec(attrs || '');
|
||||||
|
const rawColor = colorMatch ? (colorMatch[1] || colorMatch[2] || colorMatch[3]) : null;
|
||||||
|
const color = sanitizeFontColor(rawColor);
|
||||||
|
|
||||||
|
if(!color) return inner;
|
||||||
|
|
||||||
|
return `<span style="color:${ color }">${ inner }</span>`;
|
||||||
|
});
|
||||||
|
guard++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
export const RoomChatFormatter = (content: string) =>
|
export const RoomChatFormatter = (content: string) =>
|
||||||
{
|
{
|
||||||
let result = '';
|
let result = '';
|
||||||
|
|
||||||
content = encodeHTML(content);
|
content = encodeHTML(content);
|
||||||
|
content = applyFontMarkup(content);
|
||||||
content = applyWiredTextMarkup(content);
|
content = applyWiredTextMarkup(content);
|
||||||
//content = (joypixels.shortnameToUnicode(content) as string)
|
//content = (joypixels.shortnameToUnicode(content) as string)
|
||||||
|
|
||||||
|
|||||||
+34
-12
@@ -1,4 +1,4 @@
|
|||||||
import { getClientMode, installSecureFetch, secureUrl } from './secure-assets';
|
import { configFileUrl, getClientMode, installSecureFetch } from './secure-assets';
|
||||||
|
|
||||||
const ensureMobileViewport = () =>
|
const ensureMobileViewport = () =>
|
||||||
{
|
{
|
||||||
@@ -31,13 +31,43 @@ const setBootDebug = (message: string) =>
|
|||||||
|
|
||||||
setBootDebug('boot: secure fetch installed');
|
setBootDebug('boot: secure fetch installed');
|
||||||
|
|
||||||
|
const deployBaseUrl = (): string =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const loaderBase = (window as any).__nitroLoaderBase;
|
||||||
|
if(typeof loaderBase === 'string' && loaderBase.length) return new URL('..', loaderBase).toString();
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const moduleUrl = (import.meta as any).url;
|
||||||
|
if(typeof moduleUrl === 'string' && moduleUrl.length) return new URL('..', new URL('.', moduleUrl)).toString();
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const base = (import.meta as any).env?.BASE_URL;
|
||||||
|
if(typeof base === 'string' && base.length)
|
||||||
|
{
|
||||||
|
const trimmed = base.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
|
return trimmed ? `${ window.location.origin }/${ trimmed }/` : `${ window.location.origin }/`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
|
||||||
|
return `${ window.location.origin }/`;
|
||||||
|
};
|
||||||
|
|
||||||
const loadClientMode = async () =>
|
const loadClientMode = async () =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if((window as any).__nitroClientMode) return;
|
if((window as any).__nitroClientMode) return;
|
||||||
|
|
||||||
const url = new URL('configuration/client-mode.json', `${ window.location.origin }/`);
|
const url = new URL('configuration/client-mode.json', deployBaseUrl());
|
||||||
url.searchParams.set('v', Date.now().toString(36));
|
url.searchParams.set('v', Date.now().toString(36));
|
||||||
|
|
||||||
const response = await fetch(url.toString());
|
const response = await fetch(url.toString());
|
||||||
@@ -57,21 +87,13 @@ await loadClientMode();
|
|||||||
|
|
||||||
const search = new URLSearchParams(window.location.search);
|
const search = new URLSearchParams(window.location.search);
|
||||||
const clientMode = getClientMode();
|
const clientMode = getClientMode();
|
||||||
const cacheBustUrl = (path: string): string =>
|
|
||||||
{
|
|
||||||
const url = new URL(path.replace(/^\/+/, ''), `${ window.location.origin }/`);
|
|
||||||
|
|
||||||
url.searchParams.set('v', Date.now().toString(36));
|
|
||||||
|
|
||||||
return url.toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || window.location.origin;
|
(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || window.location.origin;
|
||||||
(window as any).NitroClientMode = clientMode;
|
(window as any).NitroClientMode = clientMode;
|
||||||
(window as any).NitroConfig = {
|
(window as any).NitroConfig = {
|
||||||
'config.urls': [
|
'config.urls': [
|
||||||
clientMode.secureAssetsEnabled ? secureUrl('config', 'renderer-config.json', true) : cacheBustUrl('configuration/renderer-config.json'),
|
configFileUrl('renderer-config.json', true),
|
||||||
clientMode.secureAssetsEnabled ? secureUrl('config', 'ui-config.json', true) : cacheBustUrl('configuration/ui-config.json')
|
configFileUrl('ui-config.json', true)
|
||||||
],
|
],
|
||||||
'sso.ticket': search.get('sso') || null,
|
'sso.ticket': search.get('sso') || null,
|
||||||
'forward.type': search.get('room') ? 2 : -1,
|
'forward.type': search.get('room') ? 2 : -1,
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
import { FC, useMemo } from 'react';
|
import { FC, Fragment, ReactNode, useMemo } from 'react';
|
||||||
import { GetNickIconUrl } from '../assets/images/user_custom/nick_icons';
|
import { GetNickIconUrl } from '../assets/images/user_custom/nick_icons';
|
||||||
import { PREFIX_EFFECT_KEYFRAMES, getPrefixEffectStyle, getPrefixFontStyle, parsePrefixColors } from '../api';
|
import { PREFIX_EFFECT_KEYFRAMES, getPrefixEffectStyle, getPrefixFontStyle, parseFontSegments, parsePrefixColors } from '../api';
|
||||||
|
|
||||||
|
const renderInlineFontMarkup = (text: string): ReactNode =>
|
||||||
|
{
|
||||||
|
if(!text) return text;
|
||||||
|
if(text.indexOf('<font') === -1) return text;
|
||||||
|
|
||||||
|
const segments = parseFontSegments(text);
|
||||||
|
|
||||||
|
if(!segments.length) return text;
|
||||||
|
|
||||||
|
return segments.map((segment, index) =>
|
||||||
|
{
|
||||||
|
if(segment.color) return <span key={ index } style={ { color: segment.color } }>{ segment.text }</span>;
|
||||||
|
|
||||||
|
return <Fragment key={ index }>{ segment.text }</Fragment>;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
interface UserIdentityViewProps
|
interface UserIdentityViewProps
|
||||||
{
|
{
|
||||||
@@ -87,7 +104,7 @@ export const UserIdentityView: FC<UserIdentityViewProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case 'name':
|
case 'name':
|
||||||
return <span key="identity-name" className={ `${ nameClassName } whitespace-nowrap` }>{ username }{ showColon ? ':' : '' }{ showColon ? ' ' : '' }</span>;
|
return <span key="identity-name" className={ `${ nameClassName } whitespace-nowrap` }>{ renderInlineFontMarkup(username) }{ showColon ? ':' : '' }{ showColon ? ' ' : '' }</span>;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Dispatch, FC, SetStateAction, useCallback, useMemo, useState } from 'react';
|
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Base, Grid, Flex, NitroCardView, NitroCardHeaderView, NitroCardTabsView, NitroCardTabsItemView, NitroCardContentView, Text } from '../../common';
|
import { Base, Grid, Flex, NitroCardView, NitroCardHeaderView, NitroCardTabsView, NitroCardTabsItemView, NitroCardContentView, Text } from '../../common';
|
||||||
import { useRoom } from '../../hooks';
|
import { useRoom } from '../../hooks';
|
||||||
import { GetConfigurationValue } from '../../api';
|
import { GetOptionalConfigurationValue } from '../../api';
|
||||||
|
import { configFileUrl } from '../../secure-assets';
|
||||||
|
|
||||||
interface ItemData {
|
interface ItemData {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -22,6 +23,8 @@ interface BackgroundsViewProps {
|
|||||||
const TABS = ['backgrounds', 'stands', 'overlays', 'cards'] as const;
|
const TABS = ['backgrounds', 'stands', 'overlays', 'cards'] as const;
|
||||||
type TabType = typeof TABS[number];
|
type TabType = typeof TABS[number];
|
||||||
|
|
||||||
|
type RemoteData = Partial<Record<'backgrounds.data' | 'stands.data' | 'overlays.data' | 'cards.data', any[]>>;
|
||||||
|
|
||||||
export const BackgroundsView: FC<BackgroundsViewProps> = ({
|
export const BackgroundsView: FC<BackgroundsViewProps> = ({
|
||||||
setIsVisible,
|
setIsVisible,
|
||||||
selectedBackground,
|
selectedBackground,
|
||||||
@@ -34,20 +37,36 @@ export const BackgroundsView: FC<BackgroundsViewProps> = ({
|
|||||||
setSelectedCardBackground
|
setSelectedCardBackground
|
||||||
}) => {
|
}) => {
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('backgrounds');
|
const [activeTab, setActiveTab] = useState<TabType>('backgrounds');
|
||||||
|
const [remoteData, setRemoteData] = useState<RemoteData | null>(null);
|
||||||
const { roomSession } = useRoom();
|
const { roomSession } = useRoom();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetch(configFileUrl('infostand_backgrounds.json'), { credentials: 'omit' })
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(json => { if(!cancelled && json && typeof json === 'object') setRemoteData(json as RemoteData); })
|
||||||
|
.catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const processData = useCallback((configData: any[], idField: string): ItemData[] => {
|
const processData = useCallback((configData: any[], idField: string): ItemData[] => {
|
||||||
if (!configData?.length) return [];
|
if (!configData?.length) return [];
|
||||||
|
|
||||||
return configData.map(item => ({ id: item[idField] }));
|
return configData.map(item => ({ id: typeof item === 'number' ? item : item[idField] }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const readData = useCallback((key: 'backgrounds.data' | 'stands.data' | 'overlays.data' | 'cards.data'): any[] => {
|
||||||
|
const fromRemote = remoteData?.[key];
|
||||||
|
if(Array.isArray(fromRemote)) return fromRemote;
|
||||||
|
return GetOptionalConfigurationValue<any[]>(key, []) || [];
|
||||||
|
}, [remoteData]);
|
||||||
|
|
||||||
const allData = useMemo(() => ({
|
const allData = useMemo(() => ({
|
||||||
backgrounds: processData(GetConfigurationValue('backgrounds.data'), 'backgroundId'),
|
backgrounds: processData(readData('backgrounds.data'), 'backgroundId'),
|
||||||
stands: processData(GetConfigurationValue('stands.data'), 'standId'),
|
stands: processData(readData('stands.data'), 'standId'),
|
||||||
overlays: processData(GetConfigurationValue('overlays.data'), 'overlayId'),
|
overlays: processData(readData('overlays.data'), 'overlayId'),
|
||||||
cards: processData(GetConfigurationValue('cards.data') || GetConfigurationValue('backgrounds.data'), 'backgroundId')
|
cards: processData(readData('cards.data').length ? readData('cards.data') : readData('backgrounds.data'), 'backgroundId')
|
||||||
}), [processData]);
|
}), [processData, readData]);
|
||||||
|
|
||||||
const handleSelection = useCallback((id: number) => {
|
const handleSelection = useCallback((id: number) => {
|
||||||
if (!roomSession) return;
|
if (!roomSession) return;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { GetConfiguration } from '@nitrots/nitro-renderer';
|
import { GetConfiguration } 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, GetRememberLogin, StoreRememberLoginFromPayload } from '../../api';
|
import { ClearRememberLogin, GetConfigurationValue, GetOptionalConfigurationValue, 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';
|
||||||
@@ -15,6 +15,7 @@ import flagTr from '../../assets/images/flag_icon/flag_icon_tr.png';
|
|||||||
import { applyTextTranslationLocale } from '../../hooks/translation/useTranslation';
|
import { applyTextTranslationLocale } from '../../hooks/translation/useTranslation';
|
||||||
import { NewsWindow } from './components/NewsWindow';
|
import { NewsWindow } from './components/NewsWindow';
|
||||||
import { TurnstileWidget } from './TurnstileWidget';
|
import { TurnstileWidget } from './TurnstileWidget';
|
||||||
|
import { t } from './utils/i18n';
|
||||||
|
|
||||||
type DialogMode = 'login' | 'register' | 'forgot';
|
type DialogMode = 'login' | 'register' | 'forgot';
|
||||||
type LoginLocale = { code: string; file: string; label: string; flag: string };
|
type LoginLocale = { code: string; file: string; label: string; flag: string };
|
||||||
@@ -194,6 +195,7 @@ 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());
|
||||||
|
|
||||||
@@ -204,8 +206,22 @@ 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 ]) =>
|
||||||
@@ -217,7 +233,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);
|
||||||
}, [ configuredLoginWidgets ]);
|
}, [ loginViewConfig ]);
|
||||||
|
|
||||||
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', ''));
|
||||||
@@ -226,15 +242,11 @@ 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 newsUrl = interpolate(GetConfigurationValue<string>('login.news.url', ''));
|
const configuredNewsUrl = interpolate(GetOptionalConfigurationValue<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
|
||||||
@@ -401,7 +413,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 = GetConfigurationValue<string>('login.health.method', 'GET');
|
const healthMethodRaw = GetOptionalConfigurationValue<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> =>
|
||||||
{
|
{
|
||||||
@@ -457,19 +469,19 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
if(state.lockedUntil > nowTs)
|
if(state.lockedUntil > nowTs)
|
||||||
{
|
{
|
||||||
const remaining = Math.ceil((state.lockedUntil - nowTs) / 1000);
|
const remaining = Math.ceil((state.lockedUntil - nowTs) / 1000);
|
||||||
setError(`Too many attempts. Try again in ${ remaining }s.`);
|
setError(t('nitro.login.error.too_many_attempts', 'Too many attempts. Try again in %seconds%s.', [ 'seconds' ], [ String(remaining) ]));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(!username.trim() || !password)
|
if(!username.trim() || !password)
|
||||||
{
|
{
|
||||||
setError('Please enter both your Habbo name and password.');
|
setError(t('nitro.login.error.missing_credentials', 'Please enter both your Habbo name and password.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(turnstileEnabled && !loginTurnstileToken)
|
if(turnstileEnabled && !loginTurnstileToken)
|
||||||
{
|
{
|
||||||
setError('Please complete the security check.');
|
setError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,14 +509,14 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
}
|
}
|
||||||
|
|
||||||
recordFailure();
|
recordFailure();
|
||||||
const message = typeof payload.error === 'string' ? payload.error : 'Invalid Habbo name or password.';
|
const message = typeof payload.error === 'string' ? payload.error : t('nitro.login.error.invalid_credentials', 'Invalid Habbo name or password.');
|
||||||
setError(message);
|
setError(message);
|
||||||
resetLoginTurnstile();
|
resetLoginTurnstile();
|
||||||
}
|
}
|
||||||
catch(err)
|
catch(err)
|
||||||
{
|
{
|
||||||
recordFailure();
|
recordFailure();
|
||||||
setError('Unable to reach the login service. Please try again.');
|
setError(t('nitro.login.error.login_unreachable', 'Unable to reach the login service. Please try again.'));
|
||||||
resetLoginTurnstile();
|
resetLoginTurnstile();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -515,7 +527,7 @@ 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 = GetConfigurationValue<string>('login.register.imaging.url', '');
|
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';
|
||||||
@@ -541,7 +553,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
const { ok, status, payload } = await postJson(checkEmailUrl, { email });
|
const { ok, status, payload } = await postJson(checkEmailUrl, { email });
|
||||||
const result = interpretAvailability(ok, status, payload);
|
const result = interpretAvailability(ok, status, payload);
|
||||||
if(result.available) return { available: true };
|
if(result.available) return { available: true };
|
||||||
return { available: false, error: result.error || 'This email is already in use.' };
|
return { available: false, error: result.error || t('nitro.login.error.email_taken', 'This email is already in use.') };
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -556,7 +568,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
const { ok, status, payload } = await postJson(checkUsernameUrl, { username });
|
const { ok, status, payload } = await postJson(checkUsernameUrl, { username });
|
||||||
const result = interpretAvailability(ok, status, payload);
|
const result = interpretAvailability(ok, status, payload);
|
||||||
if(result.available) return { available: true };
|
if(result.available) return { available: true };
|
||||||
return { available: false, error: result.error || 'This Habbo name is already taken.' };
|
return { available: false, error: result.error || t('nitro.login.error.username_taken', 'This Habbo name is already taken.') };
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -568,7 +580,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
{
|
{
|
||||||
if(turnstileEnabled && !body.turnstileToken)
|
if(turnstileEnabled && !body.turnstileToken)
|
||||||
{
|
{
|
||||||
setError('Please complete the security check.');
|
setError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,7 +601,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
|
|
||||||
if(ok)
|
if(ok)
|
||||||
{
|
{
|
||||||
const friendly = `Welcome aboard, ${ body.username }! Your account is ready — log in below with the password you just chose.`;
|
const friendly = t('nitro.login.register.success', 'Welcome aboard, %username%! Your account is ready — log in below with the password you just chose.', [ 'username' ], [ body.username ]);
|
||||||
setInfo(typeof payload.message === 'string' ? payload.message : friendly);
|
setInfo(typeof payload.message === 'string' ? payload.message : friendly);
|
||||||
setMode('login');
|
setMode('login');
|
||||||
setUsername(body.username);
|
setUsername(body.username);
|
||||||
@@ -597,12 +609,12 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(typeof payload.error === 'string' ? payload.error : 'Unable to create your account.');
|
setError(typeof payload.error === 'string' ? payload.error : t('nitro.login.error.register_failed', 'Unable to create your account.'));
|
||||||
onDialogReset();
|
onDialogReset();
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
setError('Unable to reach the registration service.');
|
setError(t('nitro.login.error.register_unreachable', 'Unable to reach the registration service.'));
|
||||||
onDialogReset();
|
onDialogReset();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -615,7 +627,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
{
|
{
|
||||||
if(turnstileEnabled && !body.turnstileToken)
|
if(turnstileEnabled && !body.turnstileToken)
|
||||||
{
|
{
|
||||||
setError('Please complete the security check.');
|
setError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,18 +644,18 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
|
|
||||||
if(ok)
|
if(ok)
|
||||||
{
|
{
|
||||||
const friendly = 'Email sent! If an account matches that address you\'ll find a reset link in your inbox shortly (check spam if it doesn\'t show up within a minute).';
|
const friendly = t('nitro.login.forgot.success', 'Email sent! If an account matches that address you\'ll find a reset link in your inbox shortly (check spam if it doesn\'t show up within a minute).');
|
||||||
setInfo(typeof payload.message === 'string' ? payload.message : friendly);
|
setInfo(typeof payload.message === 'string' ? payload.message : friendly);
|
||||||
setMode('login');
|
setMode('login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(typeof payload.error === 'string' ? payload.error : 'Unable to send a reset email right now.');
|
setError(typeof payload.error === 'string' ? payload.error : t('nitro.login.error.forgot_failed', 'Unable to send a reset email right now.'));
|
||||||
onDialogReset();
|
onDialogReset();
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
setError('Unable to reach the password reset service.');
|
setError(t('nitro.login.error.forgot_unreachable', 'Unable to reach the password reset service.'));
|
||||||
onDialogReset();
|
onDialogReset();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -663,9 +675,6 @@ 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">
|
||||||
@@ -702,8 +711,8 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
|
|
||||||
<div className="login-stack">
|
<div className="login-stack">
|
||||||
<div className="nitro-login-card login-language-card">
|
<div className="nitro-login-card login-language-card">
|
||||||
<div className="card-title">Choose your language</div>
|
<div className="card-title">{ t('nitro.login.language.title', 'Choose your language') }</div>
|
||||||
<div className="login-language-grid" role="list" aria-label="Language selection">
|
<div className="login-language-grid" role="list" aria-label={ t('nitro.login.language.aria', 'Language selection') }>
|
||||||
{ LOGIN_LOCALES.map(locale =>
|
{ LOGIN_LOCALES.map(locale =>
|
||||||
<button
|
<button
|
||||||
key={ locale.code }
|
key={ locale.code }
|
||||||
@@ -720,23 +729,23 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
</div>
|
</div>
|
||||||
{ localeError.length > 0 && <div className="language-error">{ localeError }</div> }
|
{ localeError.length > 0 && <div className="language-error">{ localeError }</div> }
|
||||||
<button type="button" className="ok-button login-language-confirm" disabled={ localeApplying } onClick={ confirmLocaleSelection }>
|
<button type="button" className="ok-button login-language-confirm" disabled={ localeApplying } onClick={ confirmLocaleSelection }>
|
||||||
{ localeApplying ? 'Loading...' : 'OK' }
|
{ localeApplying ? t('nitro.login.language.loading', 'Loading...') : t('nitro.login.language.ok', 'OK') }
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="nitro-login-card">
|
<div className="nitro-login-card">
|
||||||
<div className="card-title">First time here?</div>
|
<div className="card-title">{ t('nitro.login.firsttime.title', 'First time here?') }</div>
|
||||||
<div className="card-body register-card-body">
|
<div className="card-body register-card-body">
|
||||||
<span>Don't have a Habbo yet?</span>
|
<span>{ t('nitro.login.firsttime.text', 'Don\'t have a Habbo yet?') }</span>
|
||||||
<a onClick={ () => setMode('register') }>You can create one here</a>
|
<a onClick={ () => setMode('register') }>{ t('nitro.login.firsttime.link', 'You can create one here') }</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="nitro-login-card">
|
<div className="nitro-login-card">
|
||||||
<div className="card-title">What's your Habbo called?</div>
|
<div className="card-title">{ t('nitro.login.card.title', 'What\'s your Habbo called?') }</div>
|
||||||
<form className="card-body" onSubmit={ handleLoginSubmit } autoComplete="on">
|
<form className="card-body" onSubmit={ handleLoginSubmit } autoComplete="on">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="login-username">Name of your Habbo</label>
|
<label htmlFor="login-username">{ t('login.username', 'Name of your Habbo') }</label>
|
||||||
<input
|
<input
|
||||||
id="login-username"
|
id="login-username"
|
||||||
name="username"
|
name="username"
|
||||||
@@ -748,7 +757,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="login-password">Password</label>
|
<label htmlFor="login-password">{ t('generic.password', 'Password') }</label>
|
||||||
<input
|
<input
|
||||||
id="login-password"
|
id="login-password"
|
||||||
name="password"
|
name="password"
|
||||||
@@ -765,7 +774,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
checked={ rememberMe }
|
checked={ rememberMe }
|
||||||
onChange={ e => setRememberMe(e.target.checked) }
|
onChange={ e => setRememberMe(e.target.checked) }
|
||||||
/>
|
/>
|
||||||
<span>Ricordami</span>
|
<span>{ t('login.remember_me', 'Remember me') }</span>
|
||||||
</label>
|
</label>
|
||||||
{ turnstileEnabled && mode === 'login' &&
|
{ turnstileEnabled && mode === 'login' &&
|
||||||
<TurnstileWidget
|
<TurnstileWidget
|
||||||
@@ -778,9 +787,9 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
/> }
|
/> }
|
||||||
{ loginServerReachable === false &&
|
{ loginServerReachable === false &&
|
||||||
<div className="error-line server-offline">
|
<div className="error-line server-offline">
|
||||||
The gameserver isn't running right now. Please try again in a moment.
|
{ t('nitro.login.server.offline.short', 'The gameserver isn\'t running right now. Please try again in a moment.') }
|
||||||
<button type="button" className="retry-link" onClick={ pingLoginServer } disabled={ loginPingingServer }>
|
<button type="button" className="retry-link" onClick={ pingLoginServer } disabled={ loginPingingServer }>
|
||||||
{ loginPingingServer ? 'Checking…' : 'Retry' }
|
{ loginPingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry') }
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -791,9 +800,9 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
type="submit"
|
type="submit"
|
||||||
className="ok-button"
|
className="ok-button"
|
||||||
disabled={ submitting || isEntering || isLocked }
|
disabled={ submitting || isEntering || isLocked }
|
||||||
>{ isEntering ? 'Entrando…' : loginPingingServer ? 'Checking…' : 'OK' }</button>
|
>{ isEntering ? t('nitro.login.entering', 'Entering…') : loginPingingServer ? t('nitro.login.server.checking', 'Checking…') : t('login.title', 'Log in') }</button>
|
||||||
</div>
|
</div>
|
||||||
<a className="forgot" onClick={ () => setMode('forgot') }>Forgotten your password?</a>
|
<a className="forgot" onClick={ () => setMode('forgot') }>{ t('login.forgot_password', 'Forgotten your password?') }</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1144,22 +1153,22 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
|
|||||||
|
|
||||||
if(!email.trim() || !password || !confirm)
|
if(!email.trim() || !password || !confirm)
|
||||||
{
|
{
|
||||||
setLocalError('Please fill in every field.');
|
setLocalError(t('nitro.login.register.error.missing_fields', 'Please fill in every field.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(!EMAIL_REGEX.test(email.trim()))
|
if(!EMAIL_REGEX.test(email.trim()))
|
||||||
{
|
{
|
||||||
setLocalError('Please enter a valid email address.');
|
setLocalError(t('nitro.login.register.error.invalid_email', 'Please enter a valid email address.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(password.length < 8)
|
if(password.length < 8)
|
||||||
{
|
{
|
||||||
setLocalError('Your password must be at least 8 characters.');
|
setLocalError(t('nitro.login.register.error.password_too_short', 'Your password must be at least 8 characters.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(password !== confirm)
|
if(password !== confirm)
|
||||||
{
|
{
|
||||||
setLocalError('Passwords do not match.');
|
setLocalError(t('nitro.login.register.error.password_mismatch', 'Passwords do not match.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1169,13 +1178,13 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
|
|||||||
const serverOk = await pingServer();
|
const serverOk = await pingServer();
|
||||||
if(!serverOk)
|
if(!serverOk)
|
||||||
{
|
{
|
||||||
setLocalError('The gameserver is not running. Please try again later.');
|
setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await onCheckEmail(email.trim());
|
const result = await onCheckEmail(email.trim());
|
||||||
if(!result.available)
|
if(!result.available)
|
||||||
{
|
{
|
||||||
setLocalError(result.error || 'This email is already in use.');
|
setLocalError(result.error || t('nitro.login.error.email_taken', 'This email is already in use.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStep('avatar');
|
setStep('avatar');
|
||||||
@@ -1254,18 +1263,18 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
|
|||||||
const trimmed = username.trim();
|
const trimmed = username.trim();
|
||||||
if(!trimmed)
|
if(!trimmed)
|
||||||
{
|
{
|
||||||
setLocalError('Please choose a Habbo name.');
|
setLocalError(t('nitro.login.register.error.username_required', 'Please choose a Habbo name.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(trimmed.length < 3 || trimmed.length > 16)
|
if(trimmed.length < 3 || trimmed.length > 16)
|
||||||
{
|
{
|
||||||
setLocalError('Habbo name must be 3–16 characters.');
|
setLocalError(t('nitro.login.register.error.username_length', 'Habbo name must be 3–16 characters.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(turnstileEnabled && !turnstileToken)
|
if(turnstileEnabled && !turnstileToken)
|
||||||
{
|
{
|
||||||
setLocalError('Please complete the security check.');
|
setLocalError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1275,13 +1284,13 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
|
|||||||
const serverOk = await pingServer();
|
const serverOk = await pingServer();
|
||||||
if(!serverOk)
|
if(!serverOk)
|
||||||
{
|
{
|
||||||
setLocalError('The gameserver is not running. Please try again later.');
|
setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await onCheckUsername(trimmed);
|
const result = await onCheckUsername(trimmed);
|
||||||
if(!result.available)
|
if(!result.available)
|
||||||
{
|
{
|
||||||
setLocalError(result.error || 'This Habbo name is already taken.');
|
setLocalError(result.error || t('nitro.login.error.username_taken', 'This Habbo name is already taken.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1308,35 +1317,35 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
|
|||||||
<div className={ `dialog ${ step === 'avatar' ? 'dialog-avatar' : '' }` }>
|
<div className={ `dialog ${ step === 'avatar' ? 'dialog-avatar' : '' }` }>
|
||||||
<div className="nitro-login-card">
|
<div className="nitro-login-card">
|
||||||
<div className="card-title">
|
<div className="card-title">
|
||||||
<span>Habbo Details</span>
|
<span>{ t('nitro.login.register.title', 'Habbo Details') }</span>
|
||||||
<span className="nitro-card-close-button" role="button" aria-label="Close" onClick={ onCancel } />
|
<span className="nitro-card-close-button" role="button" aria-label={ t('generic.close', 'Close') } onClick={ onCancel } />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ step === 'credentials' &&
|
{ step === 'credentials' &&
|
||||||
<form className="card-body" onSubmit={ handleCredentialsNext } autoComplete="on">
|
<form className="card-body" onSubmit={ handleCredentialsNext } autoComplete="on">
|
||||||
<div className="register-intro">
|
<div className="register-intro">
|
||||||
Let's create your account. Enter your email and pick a password — we'll check that email isn't already in use.
|
{ 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>
|
</div>
|
||||||
{ serverOffline &&
|
{ serverOffline &&
|
||||||
<div className="error-line server-offline">
|
<div className="error-line server-offline">
|
||||||
The gameserver isn't running right now, so new accounts can't be created. Please try again in a moment.
|
{ t('nitro.login.register.server.offline', '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 }>
|
<button type="button" className="retry-link" onClick={ pingServer } disabled={ pingingServer }>
|
||||||
{ pingingServer ? 'Checking…' : 'Retry' }
|
{ pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry') }
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="register-email">Email</label>
|
<label htmlFor="register-email">{ t('nitro.login.register.email', 'Email') }</label>
|
||||||
<input id="register-email" type="email" maxLength={ 120 } autoComplete="email"
|
<input id="register-email" type="email" maxLength={ 120 } autoComplete="email"
|
||||||
value={ email } onChange={ e => setEmail(e.target.value) } />
|
value={ email } onChange={ e => setEmail(e.target.value) } />
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="register-password">Password</label>
|
<label htmlFor="register-password">{ t('generic.password', 'Password') }</label>
|
||||||
<input id="register-password" type="password" maxLength={ 128 } autoComplete="new-password"
|
<input id="register-password" type="password" maxLength={ 128 } autoComplete="new-password"
|
||||||
value={ password } onChange={ e => setPassword(e.target.value) } />
|
value={ password } onChange={ e => setPassword(e.target.value) } />
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="register-confirm">Confirm password</label>
|
<label htmlFor="register-confirm">{ t('nitro.login.register.confirm_password', 'Confirm password') }</label>
|
||||||
<input id="register-confirm" type="password" maxLength={ 128 } autoComplete="new-password"
|
<input id="register-confirm" type="password" maxLength={ 128 } autoComplete="new-password"
|
||||||
value={ confirm } onChange={ e => setConfirm(e.target.value) } />
|
value={ confirm } onChange={ e => setConfirm(e.target.value) } />
|
||||||
</div>
|
</div>
|
||||||
@@ -1345,7 +1354,7 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
|
|||||||
<div className="step-footer">
|
<div className="step-footer">
|
||||||
<span className="step-indicator">1/2</span>
|
<span className="step-indicator">1/2</span>
|
||||||
<button type="submit" className="ok-button" disabled={ !credentialsValid || busy || serverOffline }>
|
<button type="submit" className="ok-button" disabled={ !credentialsValid || busy || serverOffline }>
|
||||||
{ checking || pingingServer ? 'Checking…' : 'Next' }
|
{ checking || pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -1354,29 +1363,29 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
|
|||||||
{ step === 'avatar' &&
|
{ step === 'avatar' &&
|
||||||
<form className="card-body" onSubmit={ handleAvatarSubmit } autoComplete="on">
|
<form className="card-body" onSubmit={ handleAvatarSubmit } autoComplete="on">
|
||||||
<div className="register-intro">
|
<div className="register-intro">
|
||||||
Now it's time to make your own Habbo character! To make your own Habbo, please start by choosing your Habbo Name.
|
{ 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>
|
</div>
|
||||||
{ serverOffline &&
|
{ serverOffline &&
|
||||||
<div className="error-line server-offline">
|
<div className="error-line server-offline">
|
||||||
The gameserver isn't running right now, so new accounts can't be created. Please try again in a moment.
|
{ t('nitro.login.register.server.offline', '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 }>
|
<button type="button" className="retry-link" onClick={ pingServer } disabled={ pingingServer }>
|
||||||
{ pingingServer ? 'Checking…' : 'Retry' }
|
{ pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry') }
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<input id="register-username" type="text" maxLength={ 16 } autoComplete="username" placeholder="HabboName"
|
<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) } />
|
value={ username } onChange={ e => setUsername(e.target.value) } />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="gender-row">
|
<div className="gender-row">
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" name="register-gender" checked={ gender === 'F' } onChange={ () => applyGender('F') } />
|
<input type="radio" name="register-gender" checked={ gender === 'F' } onChange={ () => applyGender('F') } />
|
||||||
<span>Girl</span>
|
<span>{ t('nitro.login.register.gender.girl', 'Girl') }</span>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" name="register-gender" checked={ gender === 'M' } onChange={ () => applyGender('M') } />
|
<input type="radio" name="register-gender" checked={ gender === 'M' } onChange={ () => applyGender('M') } />
|
||||||
<span>Boy</span>
|
<span>{ t('nitro.login.register.gender.boy', 'Boy') }</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1424,8 +1433,10 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
|
|||||||
<button type="button" className="ok-button hot-looks-button"
|
<button type="button" className="ok-button hot-looks-button"
|
||||||
onClick={ cycleHotLook }
|
onClick={ cycleHotLook }
|
||||||
disabled={ !hotLooks.length || busy }
|
disabled={ !hotLooks.length || busy }
|
||||||
title={ hotLooks.length ? `${ hotLooks.length } looks available` : 'No hot looks loaded' }>
|
title={ hotLooks.length
|
||||||
Hot Looks{ hotLookIndex >= 0 && hotLooks.length ? ` (${ hotLookIndex + 1 }/${ hotLooks.length })` : '' }
|
? t('nitro.login.register.hotlooks.available', '%count% looks available', [ 'count' ], [ String(hotLooks.length) ])
|
||||||
|
: t('nitro.login.register.hotlooks.none', 'No hot looks loaded') }>
|
||||||
|
{ t('nitro.login.register.hotlooks', 'Hot Looks') }{ hotLookIndex >= 0 && hotLooks.length ? ` (${ hotLookIndex + 1 }/${ hotLooks.length })` : '' }
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1442,10 +1453,10 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
|
|||||||
{ info && <div className="info-line">{ info }</div> }
|
{ info && <div className="info-line">{ info }</div> }
|
||||||
|
|
||||||
<div className="step-footer step-footer-split">
|
<div className="step-footer step-footer-split">
|
||||||
<button type="button" className="ok-button back-button" onClick={ () => setStep('credentials') } disabled={ busy }>Back</button>
|
<button type="button" className="ok-button back-button" onClick={ () => setStep('credentials') } disabled={ busy }>{ t('nitro.login.register.back', 'Back') }</button>
|
||||||
<span className="step-indicator">2/2</span>
|
<span className="step-indicator">2/2</span>
|
||||||
<button type="submit" className="ok-button" disabled={ !username.trim() || busy || serverOffline }>
|
<button type="submit" className="ok-button" disabled={ !username.trim() || busy || serverOffline }>
|
||||||
{ submitting ? 'Creating…' : (checking || pingingServer) ? 'Checking…' : 'Next' }
|
{ submitting ? t('nitro.login.register.creating', 'Creating…') : (checking || pingingServer) ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -1483,7 +1494,7 @@ const ForgotDialog: FC<ForgotDialogProps> = props =>
|
|||||||
|
|
||||||
if(!email.trim())
|
if(!email.trim())
|
||||||
{
|
{
|
||||||
setLocalError('Please enter your email address.');
|
setLocalError(t('nitro.login.forgot.error.email_required', 'Please enter your email address.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1495,12 +1506,12 @@ const ForgotDialog: FC<ForgotDialogProps> = props =>
|
|||||||
<div className="dialog">
|
<div className="dialog">
|
||||||
<div className="nitro-login-card">
|
<div className="nitro-login-card">
|
||||||
<div className="card-title">
|
<div className="card-title">
|
||||||
<span>Reset password</span>
|
<span>{ t('nitro.login.forgot.title', 'Reset password') }</span>
|
||||||
<span className="nitro-card-close-button" role="button" aria-label="Close" onClick={ onCancel } />
|
<span className="nitro-card-close-button" role="button" aria-label={ t('generic.close', 'Close') } onClick={ onCancel } />
|
||||||
</div>
|
</div>
|
||||||
<form className="card-body" onSubmit={ handle } autoComplete="on">
|
<form className="card-body" onSubmit={ handle } autoComplete="on">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="forgot-email">Email address</label>
|
<label htmlFor="forgot-email">{ t('nitro.login.forgot.email_label', 'Email address') }</label>
|
||||||
<input id="forgot-email" type="email" maxLength={ 120 } autoComplete="email"
|
<input id="forgot-email" type="email" maxLength={ 120 } autoComplete="email"
|
||||||
value={ email } onChange={ e => setEmail(e.target.value) } />
|
value={ email } onChange={ e => setEmail(e.target.value) } />
|
||||||
</div>
|
</div>
|
||||||
@@ -1516,7 +1527,7 @@ const ForgotDialog: FC<ForgotDialogProps> = props =>
|
|||||||
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
|
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
|
||||||
{ info && <div className="info-line">{ info }</div> }
|
{ info && <div className="info-line">{ info }</div> }
|
||||||
<div className="submit-row">
|
<div className="submit-row">
|
||||||
<button type="submit" className="ok-button" disabled={ submitting }>Send email</button>
|
<button type="submit" className="ok-button" disabled={ submitting }>{ t('nitro.login.forgot.send_email', 'Send email') }</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FC, useEffect, useState } from 'react';
|
import { FC, useEffect, useState } from 'react';
|
||||||
import { t } from '../utils/i18n';
|
import { interpolate, t } from '../utils/i18n';
|
||||||
import { resolveNewsImage, resolveNewsLink } from '../utils/news';
|
import { resolveNewsImage, resolveNewsLink } from '../utils/news';
|
||||||
|
|
||||||
interface NewsItem
|
interface NewsItem
|
||||||
@@ -12,6 +12,26 @@ interface NewsItem
|
|||||||
linkUrl: string;
|
linkUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RawNewsItem
|
||||||
|
{
|
||||||
|
id?: number;
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
image?: string | null;
|
||||||
|
link?: string;
|
||||||
|
linkUrl?: string;
|
||||||
|
linkText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeNewsItem = (raw: RawNewsItem, fallbackId: number): NewsItem => ({
|
||||||
|
id: typeof raw.id === 'number' ? raw.id : fallbackId,
|
||||||
|
title: typeof raw.title === 'string' ? raw.title : '',
|
||||||
|
body: typeof raw.body === 'string' ? raw.body : '',
|
||||||
|
image: typeof raw.image === 'string' && raw.image.length ? interpolate(raw.image) : null,
|
||||||
|
linkText: typeof raw.linkText === 'string' ? raw.linkText : '',
|
||||||
|
linkUrl: interpolate((typeof raw.linkUrl === 'string' && raw.linkUrl) || (typeof raw.link === 'string' ? raw.link : ''))
|
||||||
|
});
|
||||||
|
|
||||||
interface NewsWindowProps { newsUrl: string; }
|
interface NewsWindowProps { newsUrl: string; }
|
||||||
|
|
||||||
const NEWS_AUTO_ADVANCE_MS = 10000;
|
const NEWS_AUTO_ADVANCE_MS = 10000;
|
||||||
@@ -38,10 +58,10 @@ export const NewsWindow: FC<NewsWindowProps> = ({ newsUrl }) =>
|
|||||||
.then((json: unknown) =>
|
.then((json: unknown) =>
|
||||||
{
|
{
|
||||||
if(cancelled) return;
|
if(cancelled) return;
|
||||||
const list = Array.isArray((json as { news?: unknown })?.news)
|
const rawList = Array.isArray((json as { news?: unknown })?.news)
|
||||||
? (json as { news: NewsItem[] }).news
|
? (json as { news: RawNewsItem[] }).news
|
||||||
: [];
|
: Array.isArray(json) ? (json as RawNewsItem[]) : [];
|
||||||
setItems(list);
|
setItems(rawList.map((raw, idx) => normalizeNewsItem(raw, idx + 1)));
|
||||||
})
|
})
|
||||||
.catch(() => { if(!cancelled) setFailed(true); });
|
.catch(() => { if(!cancelled) setFailed(true); });
|
||||||
return () =>
|
return () =>
|
||||||
|
|||||||
@@ -153,16 +153,16 @@
|
|||||||
&.active {
|
&.active {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: rgba(#fff, 0.5);
|
background: rgba(255, 255, 255, 0.5);
|
||||||
border-bottom: 1px solid rgba(#000, 0.2);
|
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nitro-card-accordion-set-header {
|
.nitro-card-accordion-set-header {
|
||||||
border-bottom: 1px solid rgba(#000, 0.2);
|
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@include media-breakpoint-down(lg) {
|
@media (max-width: 991.98px) {
|
||||||
.nitro-card {
|
.nitro-card {
|
||||||
resize: none !important;
|
resize: none !important;
|
||||||
max-width: calc(100vw - 16px) !important;
|
max-width: calc(100vw - 16px) !important;
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 3px 0px;
|
padding: 3px 0px;
|
||||||
color: #FFF;
|
color: #FFF;
|
||||||
border-color: rgba(#000, 0.3);
|
border-color: rgba(0, 0, 0, 0.3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -137,9 +137,9 @@
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
font-size: large;
|
font-size: large;
|
||||||
background: rgba(#000, 0.95);
|
background: rgba(0, 0, 0, 0.95);
|
||||||
box-shadow: inset 0px 5px lighten(rgba(#000, .6), 2.5), inset 0 -4px darken(rgba(#000, .6), 4);
|
box-shadow: inset 0px 5px rgba(10, 10, 10, 0.6), inset 0 -4px rgba(0, 0, 0, 0.6);
|
||||||
border-radius: $border-radius;
|
border-radius: 0.375rem;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
z-index: 21;
|
z-index: 21;
|
||||||
|
|
||||||
|
|||||||
+33
-3
@@ -19,6 +19,36 @@ const CLIENT_MODE_DEFAULTS: NitroClientMode = {
|
|||||||
secureApiEnabled: true
|
secureApiEnabled: true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDeployBaseUrl = (): string =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const loaderBase = (window as any).__nitroLoaderBase;
|
||||||
|
if(typeof loaderBase === 'string' && loaderBase.length) return new URL('..', loaderBase).toString();
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const moduleUrl = (import.meta as any).url;
|
||||||
|
if(typeof moduleUrl === 'string' && moduleUrl.length) return new URL('..', new URL('.', moduleUrl)).toString();
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const base = (import.meta as any).env?.BASE_URL;
|
||||||
|
if(typeof base === 'string' && base.length)
|
||||||
|
{
|
||||||
|
const trimmed = base.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
|
return trimmed ? `${ window.location.origin }/${ trimmed }/` : `${ window.location.origin }/`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
|
||||||
|
return `${ window.location.origin }/`;
|
||||||
|
};
|
||||||
|
|
||||||
const isDebugEnabled = (): boolean =>
|
const isDebugEnabled = (): boolean =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -205,7 +235,7 @@ const getPlainAssetBase = (kind: 'config' | 'gamedata'): string =>
|
|||||||
|
|
||||||
if(typeof configured === 'string' && configured.length) return configured.endsWith('/') ? configured : `${ configured }/`;
|
if(typeof configured === 'string' && configured.length) return configured.endsWith('/') ? configured : `${ configured }/`;
|
||||||
|
|
||||||
if(kind === 'config') return `${ window.location.origin }/configuration/`;
|
if(kind === 'config') return new URL('configuration/', getDeployBaseUrl()).toString();
|
||||||
|
|
||||||
return `${ window.location.origin }/nitro/gamedata/`;
|
return `${ window.location.origin }/nitro/gamedata/`;
|
||||||
};
|
};
|
||||||
@@ -227,7 +257,7 @@ export const secureUrl = (kind: 'config' | 'gamedata', file: string, cacheBust =
|
|||||||
{
|
{
|
||||||
if(!getClientMode().secureAssetsEnabled)
|
if(!getClientMode().secureAssetsEnabled)
|
||||||
{
|
{
|
||||||
const plainUrl = new URL(file.replace(/^\/+/, ''), `${ window.location.origin }/`);
|
const plainUrl = new URL(file.replace(/^\/+/, ''), getPlainAssetBase(kind));
|
||||||
|
|
||||||
if(cacheBust) plainUrl.searchParams.set('v', Date.now().toString(36));
|
if(cacheBust) plainUrl.searchParams.set('v', Date.now().toString(36));
|
||||||
|
|
||||||
@@ -244,7 +274,7 @@ export const configFileUrl = (file: string, cacheBust = false): string =>
|
|||||||
{
|
{
|
||||||
if(getClientMode().secureAssetsEnabled) return secureUrl('config', file, cacheBust);
|
if(getClientMode().secureAssetsEnabled) return secureUrl('config', file, cacheBust);
|
||||||
|
|
||||||
const plainUrl = new URL(`configuration/${ file.replace(/^\/+/, '') }`, `${ window.location.origin }/`);
|
const plainUrl = new URL(file.replace(/^\/+/, ''), getPlainAssetBase('config'));
|
||||||
|
|
||||||
if(cacheBust) plainUrl.searchParams.set('v', Date.now().toString(36));
|
if(cacheBust) plainUrl.searchParams.set('v', Date.now().toString(36));
|
||||||
|
|
||||||
|
|||||||
+18
-10
@@ -16,13 +16,17 @@ export default defineConfig({
|
|||||||
rendererRoot,
|
rendererRoot,
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: process.env.AUTH_PROXY_TARGET || 'http://192.168.0.181:2096',
|
||||||
|
changeOrigin: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
tsconfigPaths: true,
|
tsconfigPaths: true,
|
||||||
alias: {
|
alias: {
|
||||||
'@': resolve(__dirname, 'src'),
|
'@': resolve(__dirname, 'src'),
|
||||||
'@layout': resolve(__dirname, 'src/layout'),
|
|
||||||
'~': resolve(__dirname, 'node_modules'),
|
'~': resolve(__dirname, 'node_modules'),
|
||||||
'@nitrots/api': resolve(rendererRoot, 'packages/api/src/index.ts'),
|
'@nitrots/api': resolve(rendererRoot, 'packages/api/src/index.ts'),
|
||||||
'@nitrots/assets': resolve(rendererRoot, 'packages/assets/src/index.ts'),
|
'@nitrots/assets': resolve(rendererRoot, 'packages/assets/src/index.ts'),
|
||||||
@@ -43,17 +47,21 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
assetsInlineLimit: 4096,
|
assetsInlineLimit: 102400,
|
||||||
chunkSizeWarningLimit: 200000,
|
chunkSizeWarningLimit: 200000,
|
||||||
|
manifest: true,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: resolve(__dirname, 'index.html'),
|
|
||||||
output: {
|
output: {
|
||||||
inlineDynamicImports: true,
|
assetFileNames: 'src/assets/[name]-[hash].[ext]',
|
||||||
entryFileNames: 'assets/app.js',
|
manualChunks: id =>
|
||||||
chunkFileNames: 'assets/app.js',
|
{
|
||||||
assetFileNames: assetInfo => assetInfo.name && assetInfo.name.endsWith('.css')
|
if(id.includes('node_modules'))
|
||||||
? 'assets/app.css'
|
{
|
||||||
: 'src/assets/[name]-[hash].[ext]'
|
if(id.includes('@nitrots/nitro-renderer') || id.includes('renderer3') || id.includes('Nitro_Render_V3')) return 'nitro-renderer';
|
||||||
|
|
||||||
|
return 'vendor';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user