mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
🆕 Create Custom Bage & Security update
This commit is contained in:
@@ -55,6 +55,13 @@
|
||||
"login.remember.endpoint": "${api.url}/api/auth/remember",
|
||||
"login.server_key.endpoint": "${api.url}/api/auth/server-key",
|
||||
"login.news.endpoint": "${api.url}/api/auth/news",
|
||||
"login.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": "",
|
||||
"avatar.mandatory.libraries": [
|
||||
|
||||
+37
-1
@@ -1,6 +1,6 @@
|
||||
import { GetAssetManager, GetAvatarRenderManager, GetCommunication, GetConfiguration, GetLocalizationManager, GetRoomEngine, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, GetStage, GetTexturePool, GetTicker, HabboWebTools, LegacyExternalInterface, LoadGameUrlEvent, NitroEventType, NitroLogger, NitroVersion, PrepareRenderer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useCallback, useEffect, useState } from 'react';
|
||||
import { GetUIVersion } from './api';
|
||||
import { clearAccessToken, getAccessToken, getAccessTokenExpiresAt, GetUIVersion, persistAccessTokenFromPayload } from './api';
|
||||
import { Base } from './common';
|
||||
import { LoadingView } from './components/loading/LoadingView';
|
||||
import { LoginView } from './components/login/LoginView';
|
||||
@@ -106,11 +106,13 @@ export const App: FC<{}> = props =>
|
||||
window.localStorage.setItem('nitro.remember.token', payload.rememberToken);
|
||||
}
|
||||
catch {}
|
||||
persistAccessTokenFromPayload(payload);
|
||||
}
|
||||
}
|
||||
else if(response.status === 401)
|
||||
{
|
||||
try { window.localStorage.removeItem('nitro.remember.token'); } catch {}
|
||||
clearAccessToken();
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
@@ -118,6 +120,38 @@ export const App: FC<{}> = props =>
|
||||
}
|
||||
}
|
||||
|
||||
if(ssoTicket)
|
||||
{
|
||||
const expiresAt = getAccessTokenExpiresAt();
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const accessNeedsRefresh = !getAccessToken() || (expiresAt > 0 && expiresAt - nowSec < 60);
|
||||
|
||||
if(accessNeedsRefresh)
|
||||
{
|
||||
const ssoTokenUrlTemplate = GetConfiguration().getValue<string>('login.sso-token.endpoint', '/api/auth/sso-token');
|
||||
const ssoTokenUrl = GetConfiguration().interpolate(ssoTokenUrlTemplate);
|
||||
try
|
||||
{
|
||||
const response = await fetch(ssoTokenUrl, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'NitroSsoExchange'
|
||||
},
|
||||
body: JSON.stringify({ ssoTicket })
|
||||
});
|
||||
if(response.ok)
|
||||
{
|
||||
const payload = await response.json();
|
||||
persistAccessTokenFromPayload(payload);
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if(!ssoTicket || ssoTicket === '')
|
||||
{
|
||||
const rawLoginEnabled = GetConfiguration().getValue<unknown>('login.screen.enabled', false);
|
||||
@@ -219,10 +253,12 @@ export const App: FC<{}> = props =>
|
||||
{
|
||||
try { window.localStorage.setItem('nitro.remember.token', payload.rememberToken); } catch {}
|
||||
}
|
||||
persistAccessTokenFromPayload(payload);
|
||||
}
|
||||
else if(resp.status === 401)
|
||||
{
|
||||
try { window.localStorage.removeItem('nitro.remember.token'); } catch {}
|
||||
clearAccessToken();
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
const STORAGE_KEY = 'nitro.access.token';
|
||||
const EXPIRES_KEY = 'nitro.access.token.exp';
|
||||
|
||||
export const setAccessToken = (token: string | null | undefined, expiresAt?: number | null): void =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if(token && typeof token === 'string')
|
||||
{
|
||||
window.localStorage.setItem(STORAGE_KEY, token);
|
||||
if(typeof expiresAt === 'number' && expiresAt > 0) window.localStorage.setItem(EXPIRES_KEY, String(expiresAt));
|
||||
else window.localStorage.removeItem(EXPIRES_KEY);
|
||||
}
|
||||
else
|
||||
{
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
window.localStorage.removeItem(EXPIRES_KEY);
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
};
|
||||
|
||||
export const getAccessToken = (): string =>
|
||||
{
|
||||
try { return window.localStorage.getItem(STORAGE_KEY) ?? ''; }
|
||||
catch { return ''; }
|
||||
};
|
||||
|
||||
export const getAccessTokenExpiresAt = (): number =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const raw = window.localStorage.getItem(EXPIRES_KEY);
|
||||
if(!raw) return 0;
|
||||
const value = parseInt(raw, 10);
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
catch { return 0; }
|
||||
};
|
||||
|
||||
export const clearAccessToken = (): void =>
|
||||
{
|
||||
setAccessToken(null);
|
||||
};
|
||||
|
||||
export const persistAccessTokenFromPayload = (payload: Record<string, unknown> | null | undefined): void =>
|
||||
{
|
||||
if(!payload) return;
|
||||
const token = typeof payload.accessToken === 'string' ? payload.accessToken : '';
|
||||
const expiresAt = typeof payload.accessTokenExpiresAt === 'number' ? payload.accessTokenExpiresAt : null;
|
||||
if(token) setAccessToken(token, expiresAt);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './accessToken';
|
||||
@@ -224,11 +224,8 @@ export class AvatarEditorThumbnailsHelper
|
||||
|
||||
const texture = avatarImage.processAsTexture(AvatarSetType.HEAD, false);
|
||||
const sprite = new NitroSprite(texture);
|
||||
|
||||
if(isDisabled) sprite.filters = [ AvatarEditorThumbnailsHelper.ALPHA_FILTER ];
|
||||
|
||||
const frame = AvatarEditorThumbnailsHelper.findOpaqueBoundsFrame(sprite, texture.width, texture.height);
|
||||
|
||||
const imageUrl = await TextureUtils.generateImageUrl({
|
||||
target: sprite,
|
||||
frame
|
||||
@@ -257,7 +254,6 @@ export class AvatarEditorThumbnailsHelper
|
||||
const width = data.width;
|
||||
const height = data.height;
|
||||
if(!pixels || width <= 0 || height <= 0) return new NitroRectangle(0, 0, fallbackWidth, fallbackHeight);
|
||||
|
||||
const ALPHA_THRESHOLD = 8;
|
||||
|
||||
let minX = width;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { GetConfiguration, GetLocalizationManager } from '@nitrots/nitro-renderer';
|
||||
import { getAccessToken } from '../auth';
|
||||
|
||||
export interface CustomBadgeRecord
|
||||
{
|
||||
badgeId: string;
|
||||
badgeCode: string;
|
||||
name: string;
|
||||
description: string;
|
||||
dateCreated: number;
|
||||
dateEdit: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface CustomBadgeListResponse
|
||||
{
|
||||
badges: CustomBadgeRecord[];
|
||||
max: number;
|
||||
badgeWidth: number;
|
||||
badgeHeight: number;
|
||||
maxBadgeSizeBytes: number;
|
||||
priceBadge?: number;
|
||||
currencyType?: number;
|
||||
}
|
||||
|
||||
export interface CustomBadgeError
|
||||
{
|
||||
error: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
const interpolate = (value: string): string =>
|
||||
{
|
||||
try { return GetConfiguration().interpolate(value); }
|
||||
catch { return value; }
|
||||
};
|
||||
|
||||
const getConfigUrl = (key: string, fallback: string): string =>
|
||||
interpolate(GetConfiguration().getValue<string>(key, fallback));
|
||||
|
||||
const buildUrl = (key: string, fallback: string, badgeId?: string): string =>
|
||||
{
|
||||
const template = getConfigUrl(key, fallback);
|
||||
if(!badgeId) return template;
|
||||
if(template.includes('%badgeId%')) return template.replace(/%badgeId%/g, encodeURIComponent(badgeId));
|
||||
return template + (template.endsWith('/') ? '' : '/') + encodeURIComponent(badgeId);
|
||||
};
|
||||
|
||||
const authHeaders = (): Record<string, string> =>
|
||||
{
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'NitroCustomBadges'
|
||||
};
|
||||
const token = getAccessToken();
|
||||
if(token) headers['Authorization'] = `Bearer ${ token }`;
|
||||
return headers;
|
||||
};
|
||||
|
||||
const parseJson = async <T>(response: Response): Promise<T> =>
|
||||
{
|
||||
const text = await response.text();
|
||||
if(!text) return {} as T;
|
||||
try { return JSON.parse(text) as T; }
|
||||
catch { throw new Error('Invalid response from server.'); }
|
||||
};
|
||||
|
||||
const throwOnError = async (response: Response): Promise<void> =>
|
||||
{
|
||||
if(response.ok) return;
|
||||
const payload = await parseJson<CustomBadgeError>(response);
|
||||
const message = payload?.error || `Request failed (${ response.status }).`;
|
||||
const err = new Error(message) as Error & { status: number; code?: string };
|
||||
err.status = response.status;
|
||||
if(payload?.code) err.code = payload.code;
|
||||
throw err;
|
||||
};
|
||||
|
||||
export const fetchCustomBadges = async (): Promise<CustomBadgeListResponse> =>
|
||||
{
|
||||
const url = buildUrl('badges.custom.list.endpoint', '/api/badges/custom');
|
||||
const response = await fetch(url, { method: 'GET', credentials: 'include', headers: authHeaders() });
|
||||
await throwOnError(response);
|
||||
return parseJson<CustomBadgeListResponse>(response);
|
||||
};
|
||||
|
||||
export const createCustomBadge = async (body: { name: string; description: string; image: string }): Promise<CustomBadgeRecord> =>
|
||||
{
|
||||
const url = buildUrl('badges.custom.create.endpoint', '/api/badges/custom');
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
await throwOnError(response);
|
||||
return parseJson<CustomBadgeRecord>(response);
|
||||
};
|
||||
|
||||
export const updateCustomBadge = async (badgeId: string, body: { name: string; description: string; image: string }): Promise<CustomBadgeRecord> =>
|
||||
{
|
||||
const url = buildUrl('badges.custom.update.endpoint', '/api/badges/custom/%badgeId%', badgeId);
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
await throwOnError(response);
|
||||
return parseJson<CustomBadgeRecord>(response);
|
||||
};
|
||||
|
||||
export const deleteCustomBadge = async (badgeId: string): Promise<void> =>
|
||||
{
|
||||
const url = buildUrl('badges.custom.delete.endpoint', '/api/badges/custom/%badgeId%', badgeId);
|
||||
const response = await fetch(url, { method: 'DELETE', credentials: 'include', headers: authHeaders() });
|
||||
await throwOnError(response);
|
||||
};
|
||||
|
||||
export const isCustomBadgeCode = (code: string | null | undefined): boolean =>
|
||||
{
|
||||
if(!code) return false;
|
||||
return /^CUST[A-Z0-9]{5}-\d+$/.test(code);
|
||||
};
|
||||
|
||||
let customBadgeTextsLoadPromise: Promise<void> | null = null;
|
||||
|
||||
const injectTextsIntoLocalization = (texts: Record<string, string> | null | undefined): void =>
|
||||
{
|
||||
if(!texts) return;
|
||||
let manager: ReturnType<typeof GetLocalizationManager> | null = null;
|
||||
try { manager = GetLocalizationManager(); }
|
||||
catch { return; }
|
||||
if(!manager || typeof manager.setValue !== 'function') return;
|
||||
for(const key of Object.keys(texts))
|
||||
{
|
||||
const value = texts[key];
|
||||
if(typeof value === 'string') manager.setValue(key, value);
|
||||
}
|
||||
};
|
||||
|
||||
export const ensureCustomBadgeTexts = (): Promise<void> =>
|
||||
{
|
||||
if(customBadgeTextsLoadPromise) return customBadgeTextsLoadPromise;
|
||||
customBadgeTextsLoadPromise = (async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const url = buildUrl('badges.custom.texts.endpoint', '/api/badges/custom/texts');
|
||||
const response = await fetch(url, { method: 'GET', credentials: 'include', headers: { 'Accept': 'application/json' } });
|
||||
if(!response.ok) return;
|
||||
const payload = await parseJson<{ texts: Record<string, string> }>(response);
|
||||
injectTextsIntoLocalization(payload.texts);
|
||||
}
|
||||
catch {}
|
||||
})();
|
||||
return customBadgeTextsLoadPromise;
|
||||
};
|
||||
|
||||
export const refreshCustomBadgeTexts = (): Promise<void> =>
|
||||
{
|
||||
customBadgeTextsLoadPromise = null;
|
||||
return ensureCustomBadgeTexts();
|
||||
};
|
||||
|
||||
export const setCustomBadgeText = (badgeId: string, name: string, description: string): void =>
|
||||
{
|
||||
injectTextsIntoLocalization({
|
||||
[`badge_name_${ badgeId }`]: name || badgeId,
|
||||
[`badge_desc_${ badgeId }`]: description || ''
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './CustomBadgeApi';
|
||||
@@ -1,7 +1,9 @@
|
||||
export * from './GetRendererVersion';
|
||||
export * from './GetUIVersion';
|
||||
export * from './achievements';
|
||||
export * from './auth';
|
||||
export * from './avatar';
|
||||
export * from './badges';
|
||||
export * from './camera';
|
||||
export * from './campaign';
|
||||
export * from './catalog';
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FC, useEffect, useState } from 'react';
|
||||
import { useNitroEvent } from '../hooks';
|
||||
import { AchievementsView } from './achievements/AchievementsView';
|
||||
import { AvatarEditorView } from './avatar-editor';
|
||||
import { BadgeCreatorView } from './badge-creator';
|
||||
import { AvatarEffectsView } from './avatar-effects';
|
||||
import { CameraWidgetView } from './camera/CameraWidgetView';
|
||||
import { CampaignView } from './campaign/CampaignView';
|
||||
@@ -106,6 +107,7 @@ export const MainView: FC<{}> = props =>
|
||||
<ChatHistoryView />
|
||||
<WiredView />
|
||||
<AvatarEditorView />
|
||||
<BadgeCreatorView />
|
||||
<AvatarEffectsView />
|
||||
<AchievementsView />
|
||||
<NavigatorView />
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { FC, MouseEvent as ReactMouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { LocalizeText } from '../../api';
|
||||
import { createCustomBadge, CustomBadgeRecord, deleteCustomBadge, ensureCustomBadgeTexts, fetchCustomBadges, refreshCustomBadgeTexts, setCustomBadgeText, updateCustomBadge } from '../../api/badges';
|
||||
import { Button, Column, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
|
||||
import { useNotification } from '../../hooks';
|
||||
|
||||
const t = (key: string, fallback: string, params?: string[], replacements?: string[]): string =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const value = LocalizeText(key, params ?? null, replacements ?? null);
|
||||
if(value && value !== key) return value;
|
||||
}
|
||||
catch {}
|
||||
|
||||
if(!params || !replacements) return fallback;
|
||||
let out = fallback;
|
||||
for(let i = 0; i < params.length; i++)
|
||||
{
|
||||
if(replacements[i] !== undefined) out = out.replace('%' + params[i] + '%', replacements[i]);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const GRID_WIDTH = 40;
|
||||
const GRID_HEIGHT = 40;
|
||||
const PIXEL_DISPLAY_SIZE = 12;
|
||||
const TRANSPARENT = 0;
|
||||
|
||||
const PALETTE: number[] = [
|
||||
0xFF000000, 0xFF4F4F4F, 0xFF808080, 0xFFB0B0B0, 0xFFD8D8D8, 0xFFFFFFFF, TRANSPARENT, 0xFF7B0000,
|
||||
0xFFBF0000, 0xFFFF0000, 0xFFFF7777, 0xFFFF7700, 0xFFFFAA00, 0xFFFFD700, 0xFFFFEB3B, 0xFF003E1F,
|
||||
0xFF006837, 0xFF00A653, 0xFF2BC93C, 0xFF00C8A0, 0xFF00BCFF, 0xFF2962FF, 0xFF1A237E, 0xFF4A0072,
|
||||
0xFF9C00B5, 0xFFE91E63, 0xFFFF80AB, 0xFF5D2E1A, 0xFF8B5A2B, 0xFFC28E5E, 0xFFF1D7B6, 0xFFE8C3A0
|
||||
];
|
||||
|
||||
const currencyName = (type: number): string =>
|
||||
{
|
||||
if(type === -1) return 'credits';
|
||||
if(type === 0) return 'duckets';
|
||||
if(type === 5) return 'diamonds';
|
||||
return `currency #${ type }`;
|
||||
};
|
||||
|
||||
type Tool = 'paint' | 'erase' | 'picker' | 'fill';
|
||||
|
||||
const floodFill = (grid: Uint32Array, w: number, h: number, startX: number, startY: number, replacement: number): Uint32Array =>
|
||||
{
|
||||
if(startX < 0 || startY < 0 || startX >= w || startY >= h) return grid;
|
||||
const startIdx = startY * w + startX;
|
||||
const target = grid[startIdx];
|
||||
if(target === replacement) return grid;
|
||||
|
||||
const next = new Uint32Array(grid.length);
|
||||
next.set(grid);
|
||||
|
||||
const stack: number[] = [ startIdx ];
|
||||
while(stack.length)
|
||||
{
|
||||
const idx = stack.pop() as number;
|
||||
if(next[idx] !== target) continue;
|
||||
next[idx] = replacement;
|
||||
const x = idx % w;
|
||||
const y = (idx - x) / w;
|
||||
if(x > 0) stack.push(idx - 1);
|
||||
if(x < w - 1) stack.push(idx + 1);
|
||||
if(y > 0) stack.push(idx - w);
|
||||
if(y < h - 1) stack.push(idx + w);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const argbToCss = (argb: number): string =>
|
||||
{
|
||||
if(argb === TRANSPARENT) return 'transparent';
|
||||
const a = ((argb >>> 24) & 0xff) / 255;
|
||||
const r = (argb >>> 16) & 0xff;
|
||||
const g = (argb >>> 8) & 0xff;
|
||||
const b = argb & 0xff;
|
||||
return `rgba(${ r }, ${ g }, ${ b }, ${ a })`;
|
||||
};
|
||||
|
||||
const argbToHex = (argb: number): string =>
|
||||
{
|
||||
if(argb === TRANSPARENT) return '#000000';
|
||||
const r = (argb >>> 16) & 0xff;
|
||||
const g = (argb >>> 8) & 0xff;
|
||||
const b = argb & 0xff;
|
||||
return '#' + [ r, g, b ].map(c => c.toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
|
||||
const hexToArgb = (hex: string): number =>
|
||||
{
|
||||
const match = /^#?([0-9a-f]{6})$/i.exec(hex || '');
|
||||
if(!match) return 0xFF000000;
|
||||
return (0xFF000000 | parseInt(match[1], 16)) >>> 0;
|
||||
};
|
||||
|
||||
const emptyGrid = (): Uint32Array => new Uint32Array(GRID_WIDTH * GRID_HEIGHT);
|
||||
|
||||
const cloneGrid = (src: Uint32Array): Uint32Array =>
|
||||
{
|
||||
const copy = new Uint32Array(src.length);
|
||||
copy.set(src);
|
||||
return copy;
|
||||
};
|
||||
|
||||
const gridToPngBase64 = async (grid: Uint32Array): Promise<{ b64: string; bytes: number }> =>
|
||||
{
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = GRID_WIDTH;
|
||||
canvas.height = GRID_HEIGHT;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if(!ctx) throw new Error('Canvas not supported.');
|
||||
|
||||
const image = ctx.createImageData(GRID_WIDTH, GRID_HEIGHT);
|
||||
for(let i = 0; i < grid.length; i++)
|
||||
{
|
||||
const argb = grid[i];
|
||||
const o = i * 4;
|
||||
image.data[o] = (argb >>> 16) & 0xff;
|
||||
image.data[o + 1] = (argb >>> 8) & 0xff;
|
||||
image.data[o + 2] = argb & 0xff;
|
||||
image.data[o + 3] = (argb >>> 24) & 0xff;
|
||||
}
|
||||
ctx.putImageData(image, 0, 0);
|
||||
|
||||
const blob: Blob = await new Promise((resolve, reject) => canvas.toBlob(b => b ? resolve(b) : reject(new Error('PNG encode failed.')), 'image/png'));
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const bytes = arrayBuffer.byteLength;
|
||||
let binary = '';
|
||||
const u8 = new Uint8Array(arrayBuffer);
|
||||
for(let i = 0; i < u8.length; i++) binary += String.fromCharCode(u8[i]);
|
||||
return { b64: window.btoa(binary), bytes };
|
||||
};
|
||||
|
||||
const loadGridFromUrl = (url: string): Promise<Uint32Array> =>
|
||||
new Promise((resolve, reject) =>
|
||||
{
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = GRID_WIDTH;
|
||||
canvas.height = GRID_HEIGHT;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if(!ctx) return reject(new Error('Canvas not supported.'));
|
||||
ctx.clearRect(0, 0, GRID_WIDTH, GRID_HEIGHT);
|
||||
ctx.drawImage(image, 0, 0, GRID_WIDTH, GRID_HEIGHT);
|
||||
const data = ctx.getImageData(0, 0, GRID_WIDTH, GRID_HEIGHT).data;
|
||||
const grid = emptyGrid();
|
||||
for(let i = 0; i < grid.length; i++)
|
||||
{
|
||||
const o = i * 4;
|
||||
const a = data[o + 3];
|
||||
if(a === 0) { grid[i] = 0; continue; }
|
||||
grid[i] = ((a & 0xff) << 24) | ((data[o] & 0xff) << 16) | ((data[o + 1] & 0xff) << 8) | (data[o + 2] & 0xff);
|
||||
}
|
||||
resolve(grid);
|
||||
}
|
||||
catch(err) { reject(err); }
|
||||
};
|
||||
image.onerror = () => reject(new Error('Could not load badge image (CORS?).'));
|
||||
image.src = url + (url.includes('?') ? '&' : '?') + 't=' + Date.now();
|
||||
});
|
||||
|
||||
export const BadgeCreatorView: FC<{}> = () =>
|
||||
{
|
||||
const [ isVisible, setIsVisible ] = useState(false);
|
||||
const [ grid, setGrid ] = useState<Uint32Array>(() => emptyGrid());
|
||||
const [ selectedColor, setSelectedColor ] = useState<number>(PALETTE[0]);
|
||||
const [ tool, setTool ] = useState<Tool>('paint');
|
||||
const [ showGrid, setShowGrid ] = useState(true);
|
||||
const [ name, setName ] = useState('');
|
||||
const [ description, setDescription ] = useState('');
|
||||
const [ editingBadgeId, setEditingBadgeId ] = useState<string | null>(null);
|
||||
const [ badges, setBadges ] = useState<CustomBadgeRecord[] | null>(null);
|
||||
const [ pendingEditBadgeId, setPendingEditBadgeId ] = useState<string | null>(null);
|
||||
const [ maxBadges, setMaxBadges ] = useState(5);
|
||||
const [ maxBytes, setMaxBytes ] = useState(40960);
|
||||
const [ priceBadge, setPriceBadge ] = useState(0);
|
||||
const [ currencyType, setCurrencyType ] = useState(-1);
|
||||
const [ submitting, setSubmitting ] = useState(false);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
|
||||
const { showConfirm } = useNotification();
|
||||
|
||||
const refresh = useCallback(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const data = await fetchCustomBadges();
|
||||
setBadges(data.badges ?? []);
|
||||
if(typeof data.max === 'number') setMaxBadges(data.max);
|
||||
if(typeof data.maxBadgeSizeBytes === 'number') setMaxBytes(data.maxBadgeSizeBytes);
|
||||
if(typeof data.priceBadge === 'number') setPriceBadge(data.priceBadge);
|
||||
if(typeof data.currencyType === 'number') setCurrencyType(data.currencyType);
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
setBadges([]);
|
||||
setError((err as Error)?.message || 'Could not load badges.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const tracker: ILinkEventTracker = {
|
||||
linkReceived: (url: string) =>
|
||||
{
|
||||
const parts = url.split('/');
|
||||
if(parts.length < 2) return;
|
||||
switch(parts[1])
|
||||
{
|
||||
case 'show': setIsVisible(true); return;
|
||||
case 'hide': setIsVisible(false); return;
|
||||
case 'toggle': setIsVisible(v => !v); return;
|
||||
case 'edit':
|
||||
if(!parts[2]) return;
|
||||
setPendingEditBadgeId(parts[2]);
|
||||
setIsVisible(true);
|
||||
return;
|
||||
}
|
||||
},
|
||||
eventUrlPrefix: 'badge-creator/'
|
||||
};
|
||||
AddLinkEventTracker(tracker);
|
||||
return () => RemoveLinkEventTracker(tracker);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { if(isVisible) { refresh(); ensureCustomBadgeTexts(); } }, [ isVisible, refresh ]);
|
||||
|
||||
const resetEditor = useCallback(() =>
|
||||
{
|
||||
setGrid(emptyGrid());
|
||||
setName('');
|
||||
setDescription('');
|
||||
setEditingBadgeId(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const startEdit = useCallback(async (badge: CustomBadgeRecord) =>
|
||||
{
|
||||
setError(null);
|
||||
setEditingBadgeId(badge.badgeId);
|
||||
setName(badge.name || '');
|
||||
setDescription(badge.description || '');
|
||||
try
|
||||
{
|
||||
const loaded = await loadGridFromUrl(badge.url);
|
||||
setGrid(loaded);
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
setError((err as Error)?.message || 'Could not load that badge.');
|
||||
setGrid(emptyGrid());
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!pendingEditBadgeId || !badges) return;
|
||||
const target = badges.find(b => b.badgeId === pendingEditBadgeId);
|
||||
if(!target) return;
|
||||
setPendingEditBadgeId(null);
|
||||
startEdit(target);
|
||||
}, [ pendingEditBadgeId, badges, startEdit ]);
|
||||
|
||||
const paintAt = useCallback((x: number, y: number, isClick: boolean) =>
|
||||
{
|
||||
if(x < 0 || y < 0 || x >= GRID_WIDTH || y >= GRID_HEIGHT) return;
|
||||
const idx = y * GRID_WIDTH + x;
|
||||
|
||||
if(tool === 'picker')
|
||||
{
|
||||
const cell = grid[idx];
|
||||
if(cell !== TRANSPARENT) setSelectedColor(cell);
|
||||
setTool('paint');
|
||||
return;
|
||||
}
|
||||
|
||||
if(tool === 'fill')
|
||||
{
|
||||
if(!isClick) return;
|
||||
setGrid(floodFill(grid, GRID_WIDTH, GRID_HEIGHT, x, y, selectedColor));
|
||||
return;
|
||||
}
|
||||
|
||||
const value = (tool === 'erase') ? TRANSPARENT : selectedColor;
|
||||
if(grid[idx] === value) return;
|
||||
const next = cloneGrid(grid);
|
||||
next[idx] = value;
|
||||
setGrid(next);
|
||||
}, [ grid, selectedColor, tool ]);
|
||||
|
||||
const isDraggingRef = useRef(false);
|
||||
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||
const mainCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const targets = [ mainCanvasRef.current, previewCanvasRef.current ];
|
||||
for(const canvas of targets)
|
||||
{
|
||||
if(!canvas) continue;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if(!ctx) continue;
|
||||
const image = ctx.createImageData(GRID_WIDTH, GRID_HEIGHT);
|
||||
const buffer = image.data;
|
||||
for(let i = 0; i < grid.length; i++)
|
||||
{
|
||||
const v = grid[i];
|
||||
const o = i * 4;
|
||||
buffer[o] = (v >>> 16) & 0xff;
|
||||
buffer[o + 1] = (v >>> 8) & 0xff;
|
||||
buffer[o + 2] = v & 0xff;
|
||||
buffer[o + 3] = (v >>> 24) & 0xff;
|
||||
}
|
||||
ctx.putImageData(image, 0, 0);
|
||||
}
|
||||
}, [ grid, isVisible ]);
|
||||
|
||||
const openColorPicker = useCallback(() =>
|
||||
{
|
||||
const input = colorInputRef.current;
|
||||
if(!input) return;
|
||||
input.value = argbToHex(selectedColor);
|
||||
input.click();
|
||||
}, [ selectedColor ]);
|
||||
|
||||
const handleColorPicked = useCallback((event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
{
|
||||
setSelectedColor(hexToArgb(event.target.value));
|
||||
setTool('paint');
|
||||
}, []);
|
||||
|
||||
const cellFromEvent = useCallback((event: ReactMouseEvent<HTMLDivElement>): { x: number; y: number } =>
|
||||
{
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const x = Math.floor(((event.clientX - rect.left) / rect.width) * GRID_WIDTH);
|
||||
const y = Math.floor(((event.clientY - rect.top) / rect.height) * GRID_HEIGHT);
|
||||
return { x, y };
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback((event: ReactMouseEvent<HTMLDivElement>) =>
|
||||
{
|
||||
if(event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
isDraggingRef.current = true;
|
||||
const { x, y } = cellFromEvent(event);
|
||||
paintAt(x, y, true);
|
||||
}, [ cellFromEvent, paintAt ]);
|
||||
|
||||
const handleMouseMove = useCallback((event: ReactMouseEvent<HTMLDivElement>) =>
|
||||
{
|
||||
if(!isDraggingRef.current) return;
|
||||
const { x, y } = cellFromEvent(event);
|
||||
paintAt(x, y, false);
|
||||
}, [ cellFromEvent, paintAt ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const stopDrag = () => { isDraggingRef.current = false; };
|
||||
window.addEventListener('mouseup', stopDrag);
|
||||
return () => window.removeEventListener('mouseup', stopDrag);
|
||||
}, []);
|
||||
|
||||
const clearCanvas = useCallback(() => setGrid(emptyGrid()), []);
|
||||
|
||||
const copyColor = useCallback(() => setTool('picker'), []);
|
||||
|
||||
const isEmpty = useMemo(() =>
|
||||
{
|
||||
for(let i = 0; i < grid.length; i++) if(grid[i] !== 0) return false;
|
||||
return true;
|
||||
}, [ grid ]);
|
||||
|
||||
const canCreateMore = (badges?.length ?? 0) < maxBadges;
|
||||
|
||||
const handleSave = useCallback(async () =>
|
||||
{
|
||||
if(submitting) return;
|
||||
if(isEmpty) { setError(t('badgecreator.error.empty', 'Draw something first.')); return; }
|
||||
if(!editingBadgeId && !canCreateMore)
|
||||
{
|
||||
setError(t('badgecreator.error.limit', 'You already have %max% custom badges.', [ 'max' ], [ String(maxBadges) ]));
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try
|
||||
{
|
||||
const { b64, bytes } = await gridToPngBase64(grid);
|
||||
if(bytes > maxBytes)
|
||||
{
|
||||
setError(t('badgecreator.error.too_large', `Image is too large (${ bytes } / %max% bytes).`, [ 'max' ], [ String(maxBytes) ]));
|
||||
return;
|
||||
}
|
||||
const body = { name: name.trim(), description: description.trim(), image: b64 };
|
||||
const saved = editingBadgeId
|
||||
? await updateCustomBadge(editingBadgeId, body)
|
||||
: await createCustomBadge(body);
|
||||
if(saved && saved.badgeId) setCustomBadgeText(saved.badgeId, saved.name, saved.description);
|
||||
await refresh();
|
||||
refreshCustomBadgeTexts();
|
||||
resetEditor();
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
setError((err as Error)?.message || 'Could not save the badge.');
|
||||
}
|
||||
finally
|
||||
{
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [ submitting, isEmpty, editingBadgeId, canCreateMore, maxBadges, grid, maxBytes, name, description, refresh, resetEditor ]);
|
||||
|
||||
const handleDelete = useCallback((badge: CustomBadgeRecord) =>
|
||||
{
|
||||
showConfirm(
|
||||
t('badgecreator.delete.confirm', 'Delete "%name%"?', [ 'name' ], [ badge.name || badge.badgeId ]),
|
||||
async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await deleteCustomBadge(badge.badgeId);
|
||||
if(editingBadgeId === badge.badgeId) resetEditor();
|
||||
await refresh();
|
||||
refreshCustomBadgeTexts();
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
setError((err as Error)?.message || 'Could not delete the badge.');
|
||||
}
|
||||
},
|
||||
null, null, null,
|
||||
t('badgecreator.delete.title', 'Delete badge')
|
||||
);
|
||||
}, [ showConfirm, editingBadgeId, refresh, resetEditor ]);
|
||||
|
||||
if(!isVisible) return null;
|
||||
|
||||
return (
|
||||
<NitroCardView className="nitro-badge-creator w-[760px] h-[680px]" isResizable={ false } theme="primary-slim" uniqueKey="badge-creator">
|
||||
<NitroCardHeaderView headerText={ t('badgecreator.title', 'Badge Creator') } onCloseClick={ () => setIsVisible(false) } />
|
||||
<NitroCardContentView className="text-black">
|
||||
<Flex gap={ 2 } className="badge-creator-main">
|
||||
<Column gap={ 2 }>
|
||||
<div
|
||||
className="badge-creator-canvas"
|
||||
style={ {
|
||||
width: GRID_WIDTH * PIXEL_DISPLAY_SIZE,
|
||||
height: GRID_HEIGHT * PIXEL_DISPLAY_SIZE,
|
||||
backgroundColor: '#ffffff',
|
||||
backgroundImage: showGrid
|
||||
? 'linear-gradient(to right, rgba(0,0,0,0.18) 1px, transparent 1px), linear-gradient(to bottom, rgba(0,0,0,0.18) 1px, transparent 1px)'
|
||||
: 'none',
|
||||
backgroundSize: `${ PIXEL_DISPLAY_SIZE }px ${ PIXEL_DISPLAY_SIZE }px`,
|
||||
backgroundPosition: '0 0',
|
||||
border: '1px solid #888',
|
||||
boxSizing: 'content-box',
|
||||
imageRendering: 'pixelated',
|
||||
cursor: tool === 'picker' ? 'crosshair' : (tool === 'erase' ? 'cell' : 'crosshair'),
|
||||
position: 'relative',
|
||||
userSelect: 'none'
|
||||
} }
|
||||
onMouseDown={ handleMouseDown }
|
||||
onMouseMove={ handleMouseMove }>
|
||||
<canvas
|
||||
ref={ mainCanvasRef }
|
||||
width={ GRID_WIDTH }
|
||||
height={ GRID_HEIGHT }
|
||||
style={ {
|
||||
display: 'block',
|
||||
width: GRID_WIDTH * PIXEL_DISPLAY_SIZE,
|
||||
height: GRID_HEIGHT * PIXEL_DISPLAY_SIZE,
|
||||
imageRendering: 'pixelated',
|
||||
pointerEvents: 'none'
|
||||
} }
|
||||
/>
|
||||
</div>
|
||||
<Flex gap={ 1 } className="badge-creator-tools">
|
||||
<Button onClick={ () => setTool('paint') } variant={ tool === 'paint' ? 'success' : 'primary' }>{ t('badgecreator.tool.paint', 'Paint') }</Button>
|
||||
<Button onClick={ () => setTool('fill') } variant={ tool === 'fill' ? 'success' : 'primary' }>{ t('badgecreator.tool.fill', 'Fill') }</Button>
|
||||
<Button onClick={ () => setTool('erase') } variant={ tool === 'erase' ? 'success' : 'primary' }>{ t('badgecreator.tool.erase', 'Erase') }</Button>
|
||||
<Button onClick={ copyColor } variant={ tool === 'picker' ? 'success' : 'primary' }>{ t('badgecreator.tool.picker', 'Pick') }</Button>
|
||||
<Button onClick={ clearCanvas } variant="danger">{ t('badgecreator.tool.clear', 'Clear') }</Button>
|
||||
<Button onClick={ () => setShowGrid(g => !g) } variant="primary">{ showGrid ? (t('badgecreator.tool.gridoff', 'Grid off')) : (t('badgecreator.tool.gridon', 'Grid on')) }</Button>
|
||||
</Flex>
|
||||
</Column>
|
||||
<Column gap={ 2 } className="badge-creator-side" style={ { minWidth: 220 } }>
|
||||
<div>
|
||||
<Text bold variant="black">{ t('badgecreator.palette', 'Palette') }</Text>
|
||||
<div className="badge-creator-palette" style={ { display: 'grid', gridTemplateColumns: 'repeat(8, 22px)', gap: 4, marginTop: 4 } }>
|
||||
{ PALETTE.map((color, idx) =>
|
||||
{
|
||||
const isTransparent = color === TRANSPARENT;
|
||||
const isSelected = color === selectedColor;
|
||||
return (
|
||||
<button
|
||||
key={ idx }
|
||||
type="button"
|
||||
onClick={ () => { setSelectedColor(color); setTool('paint'); } }
|
||||
title={ isTransparent ? 'Transparent' : argbToCss(color) }
|
||||
style={ {
|
||||
width: 22,
|
||||
height: 22,
|
||||
border: isSelected ? '2px solid #000' : '1px solid #888',
|
||||
background: isTransparent
|
||||
? 'repeating-conic-gradient(#ddd 0% 25%, #fff 0% 50%) 50% / 8px 8px'
|
||||
: argbToCss(color),
|
||||
cursor: 'pointer',
|
||||
padding: 0
|
||||
} }
|
||||
/>
|
||||
);
|
||||
}) }
|
||||
</div>
|
||||
<div style={ { display: 'flex', alignItems: 'center', gap: 6, marginTop: 6 } }>
|
||||
<button
|
||||
type="button"
|
||||
onClick={ openColorPicker }
|
||||
title={ t('badgecreator.color.custom', 'Pick a custom colour') }
|
||||
style={ {
|
||||
width: 28,
|
||||
height: 26,
|
||||
padding: 2,
|
||||
border: '1px solid #888',
|
||||
background: '#f3f3f3',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
} }>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4.5 14.5 12 7l5 5-7.5 7.5a2 2 0 0 1-2.83 0l-2.17-2.17a2 2 0 0 1 0-2.83Z" fill="#d3d3d3" stroke="#222" strokeWidth="1.4" strokeLinejoin="round" />
|
||||
<path d="m12 7-2-2 2-2 2 2" stroke="#222" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M18 14c0 1.5 1.4 2.5 1.4 4a1.4 1.4 0 1 1-2.8 0c0-1.5 1.4-2.5 1.4-4Z" fill="#ffb74d" stroke="#222" strokeWidth="1.2" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
onClick={ openColorPicker }
|
||||
title={ argbToHex(selectedColor) }
|
||||
style={ {
|
||||
width: 26,
|
||||
height: 26,
|
||||
border: '1px solid #888',
|
||||
background: selectedColor === TRANSPARENT
|
||||
? 'repeating-conic-gradient(#ddd 0% 25%, #fff 0% 50%) 50% / 8px 8px'
|
||||
: argbToCss(selectedColor),
|
||||
cursor: 'pointer'
|
||||
} }
|
||||
/>
|
||||
<Text small variant="muted">{ argbToHex(selectedColor).toUpperCase() }</Text>
|
||||
<input
|
||||
ref={ colorInputRef }
|
||||
type="color"
|
||||
onChange={ handleColorPicked }
|
||||
style={ { position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' } }
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text bold variant="black">{ t('badgecreator.preview', 'Preview') }</Text>
|
||||
<div style={ { width: GRID_WIDTH, height: GRID_HEIGHT, marginTop: 4, border: '1px solid #888', imageRendering: 'pixelated', position: 'relative', overflow: 'hidden' } }>
|
||||
<canvas
|
||||
ref={ previewCanvasRef }
|
||||
width={ GRID_WIDTH }
|
||||
height={ GRID_HEIGHT }
|
||||
style={ { display: 'block', width: GRID_WIDTH, height: GRID_HEIGHT, imageRendering: 'pixelated' } }
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text variant="black">{ t('badgecreator.name', 'Name') }</Text>
|
||||
<input className="form-control form-control-sm" maxLength={ 64 } value={ name } onChange={ e => setName(e.target.value) } />
|
||||
</div>
|
||||
<div>
|
||||
<Text variant="black">{ t('badgecreator.description', 'Description') }</Text>
|
||||
<input className="form-control form-control-sm" maxLength={ 255 } value={ description } onChange={ e => setDescription(e.target.value) } />
|
||||
</div>
|
||||
{ error && <Text variant="danger">{ error }</Text> }
|
||||
{ !editingBadgeId && priceBadge > 0 &&
|
||||
<Text small variant="muted">
|
||||
{ t('badgecreator.price', 'Cost: %price% %currency%', [ 'price', 'currency' ], [ String(priceBadge), currencyName(currencyType) ]) }
|
||||
</Text> }
|
||||
<Flex gap={ 1 }>
|
||||
<Button onClick={ handleSave } disabled={ submitting } variant="success">
|
||||
{ submitting
|
||||
? (t('badgecreator.saving', 'Saving…'))
|
||||
: (editingBadgeId
|
||||
? (t('badgecreator.save.edit', 'Save changes'))
|
||||
: (priceBadge > 0
|
||||
? t('badgecreator.save.create.priced', 'Create badge (%price% %currency%)', [ 'price', 'currency' ], [ String(priceBadge), currencyName(currencyType) ])
|
||||
: t('badgecreator.save.create', 'Create badge'))) }
|
||||
</Button>
|
||||
{ editingBadgeId &&
|
||||
<Button onClick={ resetEditor } variant="primary">{ t('generic.cancel', 'Cancel') }</Button> }
|
||||
</Flex>
|
||||
</Column>
|
||||
</Flex>
|
||||
<Column gap={ 1 } className="badge-creator-list" style={ { marginTop: 8 } }>
|
||||
<Text bold variant="black">
|
||||
{ t('badgecreator.list.title', 'Your custom badges (%count%/%max%)', [ 'count', 'max' ], [ String(badges?.length ?? 0), String(maxBadges) ]) }
|
||||
</Text>
|
||||
{ badges === null && <Text variant="black">{ t('badgecreator.list.loading', 'Loading…') }</Text> }
|
||||
{ badges !== null && !badges.length && <Text variant="black">{ t('badgecreator.list.empty', 'You haven\'t made any badges yet.') }</Text> }
|
||||
{ badges !== null && badges.map(badge => (
|
||||
<Flex key={ badge.badgeId } alignItems="center" gap={ 2 } style={ { padding: 4, borderTop: '1px solid #ccc' } }>
|
||||
<img src={ badge.url } alt={ badge.name || badge.badgeId } width={ GRID_WIDTH } height={ GRID_HEIGHT } style={ { imageRendering: 'pixelated', border: '1px solid #888' } } />
|
||||
<Column gap={ 0 } style={ { flex: 1, minWidth: 0 } }>
|
||||
<Text bold variant="black" truncate>{ badge.name || badge.badgeId }</Text>
|
||||
{ badge.description && <Text small variant="muted" truncate>{ badge.description }</Text> }
|
||||
</Column>
|
||||
<Button onClick={ () => startEdit(badge) } variant="primary">{ t('generic.edit', 'Edit') }</Button>
|
||||
<Button onClick={ () => handleDelete(badge) } variant="danger">{ t('generic.delete', 'Delete') }</Button>
|
||||
</Flex>
|
||||
)) }
|
||||
</Column>
|
||||
</NitroCardContentView>
|
||||
</NitroCardView>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './BadgeCreatorView';
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DeleteBadgeMessageComposer } from '@nitrots/nitro-renderer';
|
||||
import { CreateLinkEvent, DeleteBadgeMessageComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FaTrashAlt } from 'react-icons/fa';
|
||||
import { GetConfigurationValue, LocalizeBadgeName, LocalizeText, SendMessageComposer, UnseenItemCategory } from '../../../../api';
|
||||
import { FaPaintBrush, FaPencilAlt, FaTrashAlt } from 'react-icons/fa';
|
||||
import { deleteCustomBadge, ensureCustomBadgeTexts, fetchCustomBadges, GetConfigurationValue, isCustomBadgeCode, LocalizeBadgeName, LocalizeText, refreshCustomBadgeTexts, SendMessageComposer, UnseenItemCategory } from '../../../../api';
|
||||
import { LayoutBadgeImageView } from '../../../../common';
|
||||
import { useInventoryBadges, useInventoryUnseenTracker, useNotification } from '../../../../hooks';
|
||||
import { InfiniteGrid, NitroButton } from '../../../../layout';
|
||||
@@ -90,7 +90,60 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
|
||||
const [ isDraggingFromActive, setIsDraggingFromActive ] = useState(false);
|
||||
|
||||
const maxSlots = useMemo(() => GetConfigurationValue<number>('user.badges.max.slots', 5), []);
|
||||
const displayCodes = (filteredBadgeCodes !== null ? filteredBadgeCodes : badgeCodes);
|
||||
|
||||
const [ ownCustomBadgeIds, setOwnCustomBadgeIds ] = useState<Set<string>>(() => new Set());
|
||||
const [ filter, setFilter ] = useState<'all' | 'custom'>('all');
|
||||
|
||||
const refreshOwnCustomBadges = useCallback(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const data = await fetchCustomBadges();
|
||||
setOwnCustomBadgeIds(new Set((data.badges ?? []).map(b => b.badgeId)));
|
||||
}
|
||||
catch
|
||||
{
|
||||
setOwnCustomBadgeIds(new Set());
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { refreshOwnCustomBadges(); }, [ refreshOwnCustomBadges ]);
|
||||
useEffect(() => { ensureCustomBadgeTexts(); }, []);
|
||||
|
||||
const baseCodes = (filteredBadgeCodes !== null ? filteredBadgeCodes : badgeCodes);
|
||||
const customCount = useMemo(() => baseCodes.filter(c => isCustomBadgeCode(c)).length, [ baseCodes ]);
|
||||
const displayCodes = useMemo(() =>
|
||||
filter === 'custom' ? baseCodes.filter(c => isCustomBadgeCode(c)) : baseCodes,
|
||||
[ baseCodes, filter ]);
|
||||
|
||||
const isOwnCustomBadge = (code: string | null) => !!code && isCustomBadgeCode(code) && ownCustomBadgeIds.has(code);
|
||||
|
||||
const handleEditCustom = useCallback(() =>
|
||||
{
|
||||
if(!selectedBadgeCode) return;
|
||||
CreateLinkEvent(`badge-creator/edit/${ selectedBadgeCode }`);
|
||||
}, [ selectedBadgeCode ]);
|
||||
|
||||
const handleDeleteCustom = useCallback(() =>
|
||||
{
|
||||
if(!selectedBadgeCode) return;
|
||||
const target = selectedBadgeCode;
|
||||
showConfirm(
|
||||
LocalizeText('inventory.delete.confirm_delete.info', [ 'furniname', 'amount' ], [ LocalizeBadgeName(target), '1' ]),
|
||||
async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await deleteCustomBadge(target);
|
||||
await refreshOwnCustomBadges();
|
||||
refreshCustomBadgeTexts();
|
||||
}
|
||||
catch { /* error already surfaced server-side */ }
|
||||
},
|
||||
null, null, null,
|
||||
LocalizeText('inventory.delete.confirm_delete.title')
|
||||
);
|
||||
}, [ selectedBadgeCode, showConfirm, refreshOwnCustomBadges ]);
|
||||
|
||||
const attemptDeleteBadge = () =>
|
||||
{
|
||||
@@ -205,6 +258,28 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
|
||||
<span className="text-red-400/60 text-xs font-medium">{ LocalizeText('inventory.badges.clearbadge') }</span>
|
||||
</div>
|
||||
) }
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className={ `px-2 py-0.5 rounded ${ filter === 'all' ? 'bg-card-grid-item-active text-white' : 'bg-card-grid-item' }` }
|
||||
onClick={ () => setFilter('all') }>
|
||||
{ LocalizeText('inventory.badges.tab.all') !== 'inventory.badges.tab.all' ? LocalizeText('inventory.badges.tab.all') : 'All' } ({ baseCodes.length })
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={ `px-2 py-0.5 rounded ${ filter === 'custom' ? 'bg-card-grid-item-active text-white' : 'bg-card-grid-item' }` }
|
||||
onClick={ () => setFilter('custom') }>
|
||||
{ LocalizeText('inventory.badges.tab.custom') !== 'inventory.badges.tab.custom' ? LocalizeText('inventory.badges.tab.custom') : 'Custom' } ({ customCount })
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto px-2 py-0.5 rounded bg-card-grid-item flex items-center gap-1"
|
||||
onClick={ () => CreateLinkEvent('badge-creator/show') }
|
||||
title={ LocalizeText('inventory.badges.create') !== 'inventory.badges.create' ? LocalizeText('inventory.badges.create') : 'Open badge creator' }>
|
||||
<FaPaintBrush className="fa-icon text-[10px]" />
|
||||
<span>{ LocalizeText('inventory.badges.create') !== 'inventory.badges.create' ? LocalizeText('inventory.badges.create') : 'Create' }</span>
|
||||
</button>
|
||||
</div>
|
||||
<InfiniteGrid<string>
|
||||
columnCount={ 5 }
|
||||
estimateSize={ 50 }
|
||||
@@ -242,8 +317,12 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
|
||||
onClick={ event => toggleBadge(selectedBadgeCode) }>
|
||||
{ LocalizeText(isWearingBadge(selectedBadgeCode) ? 'inventory.badges.clearbadge' : 'inventory.badges.wearbadge') }
|
||||
</NitroButton>
|
||||
{ isOwnCustomBadge(selectedBadgeCode) &&
|
||||
<NitroButton className="p-1" title={ LocalizeText('inventory.badges.edit') !== 'inventory.badges.edit' ? LocalizeText('inventory.badges.edit') : 'Edit' } onClick={ handleEditCustom }>
|
||||
<FaPencilAlt className="fa-icon" />
|
||||
</NitroButton> }
|
||||
{ !isWearingBadge(selectedBadgeCode) &&
|
||||
<NitroButton className="bg-danger! hover:bg-danger/80! p-1" onClick={ attemptDeleteBadge }>
|
||||
<NitroButton className="bg-danger! hover:bg-danger/80! p-1" onClick={ isOwnCustomBadge(selectedBadgeCode) ? handleDeleteCustom : attemptDeleteBadge }>
|
||||
<FaTrashAlt className="fa-icon" />
|
||||
</NitroButton> }
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { GetConfigurationValue } from '../../api';
|
||||
import { GetConfigurationValue, persistAccessTokenFromPayload } from '../../api';
|
||||
import { ForgotDialog } from './components/ForgotDialog';
|
||||
import { NewsWindow } from './components/NewsWindow';
|
||||
import { RegisterDialog } from './components/RegisterDialog';
|
||||
@@ -244,6 +244,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
|
||||
const rememberToken = typeof payload.rememberToken === 'string' ? payload.rememberToken : '';
|
||||
if(rememberMe && rememberToken) window.localStorage.setItem('nitro.remember.token', rememberToken);
|
||||
else window.localStorage.removeItem('nitro.remember.token');
|
||||
persistAccessTokenFromPayload(payload);
|
||||
}
|
||||
catch {}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ export const ToolbarMeView: FC<PropsWithChildren<{
|
||||
<div className="navigation-item relative nitro-icon icon-me-profile cursor-pointer" onClick={ event => GetUserProfile(GetSessionDataManager().userId) } />
|
||||
<div className="navigation-item relative nitro-icon icon-me-rooms cursor-pointer" onClick={ event => CreateLinkEvent('navigator/search/myworld_view') } />
|
||||
<div className="navigation-item relative nitro-icon icon-me-clothing cursor-pointer" onClick={ event => CreateLinkEvent('avatar-editor/toggle') } />
|
||||
<div className="navigation-item relative nitro-icon icon-me-badge-creator cursor-pointer" onClick={ event => CreateLinkEvent('badge-creator/toggle') } title={ LocalizeText('toolbar.icon.label.badge_creator') } />
|
||||
<div className="navigation-item relative nitro-icon icon-me-settings cursor-pointer" onClick={ event => CreateLinkEvent('user-settings/toggle') } />
|
||||
<div className="navigation-item relative nitro-icon icon-me-forums cursor-pointer" onClick={ event => CreateLinkEvent('groupforum/toggle') } title={ LocalizeText('toolbar.icon.label.forums') } />
|
||||
{ children }
|
||||
|
||||
@@ -4,10 +4,6 @@
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
outline: 0;
|
||||
image-rendering: -webkit-optimize-contrast !important;
|
||||
image-rendering: -moz-crisp-edges !important;
|
||||
image-rendering: crisp-edges !important;
|
||||
image-rendering: pixelated !important;
|
||||
}
|
||||
|
||||
.nitro-icon:hover {
|
||||
@@ -147,6 +143,15 @@
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.nitro-icon.icon-me-badge-creator {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><defs><linearGradient id='b' x1='0' x2='0' y1='0' y2='1'><stop offset='0' stop-color='%23ffd966'/><stop offset='1' stop-color='%23e69138'/></linearGradient></defs><path d='M5 7 Q5 5 7 5 L19 5 Q21 5 21 7 L21 18 Q21 22 13 26 Q5 22 5 18 Z' fill='url(%23b)' stroke='%23222' stroke-width='1.6' stroke-linejoin='round'/><polygon points='13,8 14.4,12 18.6,12 15.2,14.6 16.5,18.6 13,16.2 9.5,18.6 10.8,14.6 7.4,12 11.6,12' fill='%23ffffff' stroke='%23222' stroke-width='0.7' stroke-linejoin='round'/><g transform='rotate(40 24 22)'><rect x='17' y='20' width='12' height='4' fill='%23ffd84d' stroke='%23222' stroke-width='1.2' rx='0.5'/><rect x='25' y='20' width='3' height='4' fill='%23ec5e5e' stroke='%23222' stroke-width='1.2'/><polygon points='17,20 17,24 13,22' fill='%23222'/><polygon points='15,21.4 17,21.4 17,22.6 15,22.6' fill='%23ffeb3b'/></g></svg>");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: 30px 30px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.nitro-icon.icon-me-settings {
|
||||
background-image: url("@/assets/images/toolbar/icons/me-menu/cog.png");
|
||||
width: 28px;
|
||||
|
||||
Reference in New Issue
Block a user