From 506a29c9a0fd903d3d79a23af400c5a2f00fb071 Mon Sep 17 00:00:00 2001 From: duckietm Date: Fri, 1 May 2026 16:02:56 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=86=95=20Create=20Custom=20Bage=20&=20Sec?= =?UTF-8?q?urity=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/renderer-config.example | 7 + src/App.tsx | 38 +- src/api/auth/accessToken.ts | 52 ++ src/api/auth/index.ts | 1 + .../avatar/AvatarEditorThumbnailsHelper.ts | 4 - src/api/badges/CustomBadgeApi.ts | 172 +++++ src/api/badges/index.ts | 1 + src/api/index.ts | 2 + src/components/MainView.tsx | 2 + .../badge-creator/BadgeCreatorView.tsx | 629 ++++++++++++++++++ src/components/badge-creator/index.ts | 1 + .../views/badge/InventoryBadgeView.tsx | 89 ++- src/components/login/LoginView.tsx | 3 +- src/components/toolbar/ToolbarMeView.tsx | 1 + src/css/icons/icons.css | 13 +- 15 files changed, 1000 insertions(+), 15 deletions(-) create mode 100644 src/api/auth/accessToken.ts create mode 100644 src/api/auth/index.ts create mode 100644 src/api/badges/CustomBadgeApi.ts create mode 100644 src/api/badges/index.ts create mode 100644 src/components/badge-creator/BadgeCreatorView.tsx create mode 100644 src/components/badge-creator/index.ts diff --git a/public/renderer-config.example b/public/renderer-config.example index b45973e..eccba80 100644 --- a/public/renderer-config.example +++ b/public/renderer-config.example @@ -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": [ diff --git a/src/App.tsx b/src/App.tsx index f08efdc..b59c167 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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('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('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 {} diff --git a/src/api/auth/accessToken.ts b/src/api/auth/accessToken.ts new file mode 100644 index 0000000..1d53575 --- /dev/null +++ b/src/api/auth/accessToken.ts @@ -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 | 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); +}; diff --git a/src/api/auth/index.ts b/src/api/auth/index.ts new file mode 100644 index 0000000..865e7c0 --- /dev/null +++ b/src/api/auth/index.ts @@ -0,0 +1 @@ +export * from './accessToken'; diff --git a/src/api/avatar/AvatarEditorThumbnailsHelper.ts b/src/api/avatar/AvatarEditorThumbnailsHelper.ts index 9cb58b6..5176436 100644 --- a/src/api/avatar/AvatarEditorThumbnailsHelper.ts +++ b/src/api/avatar/AvatarEditorThumbnailsHelper.ts @@ -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; diff --git a/src/api/badges/CustomBadgeApi.ts b/src/api/badges/CustomBadgeApi.ts new file mode 100644 index 0000000..9e6eeca --- /dev/null +++ b/src/api/badges/CustomBadgeApi.ts @@ -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(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 => +{ + const headers: Record = { + 'Accept': 'application/json', + 'X-Requested-With': 'NitroCustomBadges' + }; + const token = getAccessToken(); + if(token) headers['Authorization'] = `Bearer ${ token }`; + return headers; +}; + +const parseJson = async (response: Response): Promise => +{ + 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 => +{ + if(response.ok) return; + const payload = await parseJson(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 => +{ + 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(response); +}; + +export const createCustomBadge = async (body: { name: string; description: string; image: string }): Promise => +{ + 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(response); +}; + +export const updateCustomBadge = async (badgeId: string, body: { name: string; description: string; image: string }): Promise => +{ + 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(response); +}; + +export const deleteCustomBadge = async (badgeId: string): Promise => +{ + 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 | null = null; + +const injectTextsIntoLocalization = (texts: Record | null | undefined): void => +{ + if(!texts) return; + let manager: ReturnType | 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 => +{ + 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 }>(response); + injectTextsIntoLocalization(payload.texts); + } + catch {} + })(); + return customBadgeTextsLoadPromise; +}; + +export const refreshCustomBadgeTexts = (): Promise => +{ + customBadgeTextsLoadPromise = null; + return ensureCustomBadgeTexts(); +}; + +export const setCustomBadgeText = (badgeId: string, name: string, description: string): void => +{ + injectTextsIntoLocalization({ + [`badge_name_${ badgeId }`]: name || badgeId, + [`badge_desc_${ badgeId }`]: description || '' + }); +}; diff --git a/src/api/badges/index.ts b/src/api/badges/index.ts new file mode 100644 index 0000000..75e2cd1 --- /dev/null +++ b/src/api/badges/index.ts @@ -0,0 +1 @@ +export * from './CustomBadgeApi'; diff --git a/src/api/index.ts b/src/api/index.ts index 0f11ac4..6bb1536 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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'; diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index 70f2fd3..41a7322 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -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 => + diff --git a/src/components/badge-creator/BadgeCreatorView.tsx b/src/components/badge-creator/BadgeCreatorView.tsx new file mode 100644 index 0000000..e8d9db1 --- /dev/null +++ b/src/components/badge-creator/BadgeCreatorView.tsx @@ -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 => + 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(() => emptyGrid()); + const [ selectedColor, setSelectedColor ] = useState(PALETTE[0]); + const [ tool, setTool ] = useState('paint'); + const [ showGrid, setShowGrid ] = useState(true); + const [ name, setName ] = useState(''); + const [ description, setDescription ] = useState(''); + const [ editingBadgeId, setEditingBadgeId ] = useState(null); + const [ badges, setBadges ] = useState(null); + const [ pendingEditBadgeId, setPendingEditBadgeId ] = useState(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(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(null); + const mainCanvasRef = useRef(null); + const previewCanvasRef = useRef(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) => + { + setSelectedColor(hexToArgb(event.target.value)); + setTool('paint'); + }, []); + + const cellFromEvent = useCallback((event: ReactMouseEvent): { 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) => + { + 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) => + { + 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 ( + + setIsVisible(false) } /> + + + +
+ +
+ + + + + + + + +
+ +
+ { t('badgecreator.palette', 'Palette') } +
+ { PALETTE.map((color, idx) => + { + const isTransparent = color === TRANSPARENT; + const isSelected = color === selectedColor; + return ( +
+
+ +
+ { argbToHex(selectedColor).toUpperCase() } + +
+
+
+ { t('badgecreator.preview', 'Preview') } +
+ +
+
+
+ { t('badgecreator.name', 'Name') } + setName(e.target.value) } /> +
+
+ { t('badgecreator.description', 'Description') } + setDescription(e.target.value) } /> +
+ { error && { error } } + { !editingBadgeId && priceBadge > 0 && + + { t('badgecreator.price', 'Cost: %price% %currency%', [ 'price', 'currency' ], [ String(priceBadge), currencyName(currencyType) ]) } + } + + + { editingBadgeId && + } + + + + + + { t('badgecreator.list.title', 'Your custom badges (%count%/%max%)', [ 'count', 'max' ], [ String(badges?.length ?? 0), String(maxBadges) ]) } + + { badges === null && { t('badgecreator.list.loading', 'Loading…') } } + { badges !== null && !badges.length && { t('badgecreator.list.empty', 'You haven\'t made any badges yet.') } } + { badges !== null && badges.map(badge => ( + + { + + { badge.name || badge.badgeId } + { badge.description && { badge.description } } + + + + + )) } + + + + ); +}; diff --git a/src/components/badge-creator/index.ts b/src/components/badge-creator/index.ts new file mode 100644 index 0000000..d12515e --- /dev/null +++ b/src/components/badge-creator/index.ts @@ -0,0 +1 @@ +export * from './BadgeCreatorView'; diff --git a/src/components/inventory/views/badge/InventoryBadgeView.tsx b/src/components/inventory/views/badge/InventoryBadgeView.tsx index f1fad00..1d39b6d 100644 --- a/src/components/inventory/views/badge/InventoryBadgeView.tsx +++ b/src/components/inventory/views/badge/InventoryBadgeView.tsx @@ -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('user.badges.max.slots', 5), []); - const displayCodes = (filteredBadgeCodes !== null ? filteredBadgeCodes : badgeCodes); + + const [ ownCustomBadgeIds, setOwnCustomBadgeIds ] = useState>(() => 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 = { LocalizeText('inventory.badges.clearbadge') }
) } +
+ + + +
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') } + { isOwnCustomBadge(selectedBadgeCode) && + + + } { !isWearingBadge(selectedBadgeCode) && - + } diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index e4a8f61..83a8c31 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -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 = ({ 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 {} diff --git a/src/components/toolbar/ToolbarMeView.tsx b/src/components/toolbar/ToolbarMeView.tsx index 642e519..741b66a 100644 --- a/src/components/toolbar/ToolbarMeView.tsx +++ b/src/components/toolbar/ToolbarMeView.tsx @@ -42,6 +42,7 @@ export const ToolbarMeView: FC GetUserProfile(GetSessionDataManager().userId) } />
CreateLinkEvent('navigator/search/myworld_view') } />
CreateLinkEvent('avatar-editor/toggle') } /> +
CreateLinkEvent('badge-creator/toggle') } title={ LocalizeText('toolbar.icon.label.badge_creator') } />
CreateLinkEvent('user-settings/toggle') } />
CreateLinkEvent('groupforum/toggle') } title={ LocalizeText('toolbar.icon.label.forums') } /> { children } diff --git a/src/css/icons/icons.css b/src/css/icons/icons.css index 62120ea..54cced7 100644 --- a/src/css/icons/icons.css +++ b/src/css/icons/icons.css @@ -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,"); + 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;