Merge pull request #114 from duckietm/Dev

Dev
This commit is contained in:
DuckieTM
2026-05-04 10:32:02 +02:00
committed by GitHub
36 changed files with 2059 additions and 382 deletions
+3 -6
View File
@@ -13,19 +13,17 @@
"@babel/runtime": "^7.29.2",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-slider": "^1.2.4",
"@tanstack/react-virtual": "3.13.24",
"@types/react-transition-group": "^4.4.12",
"dompurify": "^3.4.1",
"emoji-mart": "^5.6.0",
"emoji-toolkit": "10.0.0",
"framer-motion": "^12.38.0",
"react": "^19.2.5",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.2.5",
"react-icons": "^5.5.0",
"react-slider": "^2.0.6",
"react-tiny-popover": "^8.1.6",
"react-youtube": "^10.1.0",
"react-player": "^2.16.0",
"use-between": "^1.4.0"
},
"devDependencies": {
@@ -34,7 +32,6 @@
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-slider": "^1.3.6",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1",
"@vitejs/plugin-react": "^6.0.1",
+7
View File
@@ -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": [
+17
View File
@@ -1346,6 +1346,23 @@
"isAmbassadorOnly": false
}
],
"cards.data": [
{ "backgroundId": 1, "minRank": 0, "isHcOnly": false, "isAmbassadorOnly": false },
{ "backgroundId": 2, "minRank": 0, "isHcOnly": false, "isAmbassadorOnly": false },
{ "backgroundId": 3, "minRank": 0, "isHcOnly": false, "isAmbassadorOnly": false },
{ "backgroundId": 4, "minRank": 0, "isHcOnly": false, "isAmbassadorOnly": false },
{ "backgroundId": 5, "minRank": 0, "isHcOnly": false, "isAmbassadorOnly": false },
{ "backgroundId": 6, "minRank": 0, "isHcOnly": false, "isAmbassadorOnly": false },
{ "backgroundId": 7, "minRank": 0, "isHcOnly": false, "isAmbassadorOnly": false },
{ "backgroundId": 8, "minRank": 0, "isHcOnly": false, "isAmbassadorOnly": false },
{ "backgroundId": 9, "minRank": 0, "isHcOnly": false, "isAmbassadorOnly": false },
{ "backgroundId": 10, "minRank": 0, "isHcOnly": false, "isAmbassadorOnly": false },
{ "backgroundId": 11, "minRank": 0, "isHcOnly": false, "isAmbassadorOnly": false },
{ "backgroundId": 12, "minRank": 0, "isHcOnly": false, "isAmbassadorOnly": false },
{ "backgroundId": 13, "minRank": 0, "isHcOnly": false, "isAmbassadorOnly": false },
{ "backgroundId": 14, "minRank": 0, "isHcOnly": false, "isAmbassadorOnly": false },
{ "backgroundId": 15, "minRank": 0, "isHcOnly": false, "isAmbassadorOnly": false }
],
"stands.data": [
{
"standId": 0,
+37 -1
View File
@@ -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 {}
+52
View File
@@ -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);
};
+1
View File
@@ -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;
+172
View File
@@ -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 || ''
});
};
+1
View File
@@ -0,0 +1 @@
export * from './CustomBadgeApi';
+2
View File
@@ -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';
+1
View File
@@ -16,6 +16,7 @@ export class AvatarInfoUser implements IAvatarInfo
public backgroundId: number = 0;
public standId: number = 0;
public overlayId: number = 0;
public cardBackgroundId: number = 0;
public webID: number = 0;
public xp: number = 0;
public userType: number = -1;
@@ -186,6 +186,7 @@ export class AvatarInfoUtilities
userInfo.backgroundId = userData.background;
userInfo.standId = userData.stand;
userInfo.overlayId = userData.overlay;
userInfo.cardBackgroundId = userData.cardBackground ?? 0;
userInfo.achievementScore = userData.activityPoints;
userInfo.webID = userData.webID;
userInfo.roomIndex = userData.roomIndex;
+127 -14
View File
@@ -1,35 +1,148 @@
import { FC } from 'react';
import ReactSlider, { ReactSliderProps } from 'react-slider';
import * as RadixSlider from '@radix-ui/react-slider';
import { CSSProperties, FC, HTMLProps, ReactElement } from 'react';
import { FaAngleLeft, FaAngleRight } from 'react-icons/fa';
import { Button } from './Button';
import { Flex } from './Flex';
import { FaAngleLeft, FaAngleRight } from 'react-icons/fa';
export interface SliderProps extends ReactSliderProps
export interface SliderThumbState
{
disabledButton?: boolean;
index: number;
value: number | number[];
valueNow: number;
}
export interface SliderProps
{
min?: number;
max?: number;
step?: number;
value?: number | number[];
defaultValue?: number | number[];
onChange?: (value: any, thumbIndex: number) => void;
disabled?: boolean;
disabledButton?: boolean;
invert?: boolean;
className?: string;
style?: CSSProperties;
trackClassName?: string;
thumbClassName?: string;
renderThumb?: (props: HTMLProps<HTMLDivElement>, state: SliderThumbState) => ReactElement;
}
const toArray = (value: number | number[] | undefined): number[] =>
{
if(Array.isArray(value)) return value;
if(typeof value === 'number') return [ value ];
return [ 0 ];
};
const cn = (...parts: (string | undefined | false)[]) => parts.filter(Boolean).join(' ');
export const Slider: FC<SliderProps> = props =>
{
const { disabledButton, max, min, step, value, onChange, ...rest } = props;
const currentValue = Array.isArray(value) ? value[0] : ((typeof value === 'number') ? value : 0);
const {
disabledButton,
disabled,
max = 100,
min = 0,
step = 1,
value,
defaultValue,
onChange,
invert,
className,
style,
trackClassName,
thumbClassName,
renderThumb
} = props;
const valueArr = toArray(value);
const currentValue = valueArr[0] ?? 0;
const minimum = (typeof min === 'number') ? min : 0;
const maximum = (typeof max === 'number') ? max : 0;
const buttonStep = ((typeof step === 'number') && (step > 0)) ? step : 1;
const isRange = valueArr.length > 1;
const roundToStep = (nextValue: number) =>
{
if(typeof buttonStep !== 'number') return nextValue;
const decimalStep = buttonStep.toString();
const precision = decimalStep.includes('.') ? (decimalStep.length - decimalStep.indexOf('.') - 1) : 0;
return parseFloat(nextValue.toFixed(precision));
};
return <Flex fullWidth gap={ 1 } classNames={ [ 'nitro-slider-wrapper' ] }>
{ !disabledButton && <Button classNames={ [ 'nitro-slider-button', 'nitro-slider-button-left' ] } disabled={ minimum >= currentValue } onClick={ () => onChange(roundToStep(minimum < currentValue ? currentValue - buttonStep : minimum), 0) }><FaAngleLeft /></Button> }
<ReactSlider className={ 'nitro-slider' } max={ max } min={ min } step={ step } value={ value } onChange={ onChange } { ...rest } />
{ !disabledButton && <Button classNames={ [ 'nitro-slider-button', 'nitro-slider-button-right' ] } disabled={ maximum <= currentValue } onClick={ () => onChange(roundToStep(maximum > currentValue ? currentValue + buttonStep : maximum), 0) }><FaAngleRight /></Button> }
</Flex>;
const emit = (next: number[]) =>
{
if(!onChange) return;
if(isRange) onChange(next, 0);
else onChange(next[0], 0);
};
const stepDown = () =>
{
const next = roundToStep(minimum < currentValue ? currentValue - buttonStep : minimum);
emit([ next, ...valueArr.slice(1) ]);
};
const stepUp = () =>
{
const next = roundToStep(maximum > currentValue ? currentValue + buttonStep : maximum);
emit([ next, ...valueArr.slice(1) ]);
};
const renderThumbElement = (i: number) =>
{
const baseProps: HTMLProps<HTMLDivElement> = {
key: i,
className: cn('thumb', `thumb-${ i }`, thumbClassName)
};
const state: SliderThumbState = {
index: i,
value: isRange ? valueArr : currentValue,
valueNow: valueArr[i] ?? 0
};
return (
<RadixSlider.Thumb key={ i } asChild>
{ renderThumb ? renderThumb(baseProps, state) : <div { ...baseProps } /> }
</RadixSlider.Thumb>
);
};
return (
<Flex fullWidth gap={ 1 } classNames={ [ 'nitro-slider-wrapper' ] }>
{ !disabledButton && (
<Button classNames={ [ 'nitro-slider-button', 'nitro-slider-button-left' ] } disabled={ disabled || (minimum >= currentValue) } onClick={ stepDown }>
<FaAngleLeft />
</Button>
) }
<RadixSlider.Root
inverted={ invert }
disabled={ disabled }
className={ cn('nitro-slider', 'relative', 'min-w-0', 'grow', className) }
style={ style }
max={ max }
min={ min }
step={ step }
value={ value !== undefined ? valueArr : undefined }
defaultValue={ defaultValue !== undefined ? toArray(defaultValue) : undefined }
onValueChange={ emit }>
<RadixSlider.Track className={ cn('track', 'track-1', 'grow', trackClassName) }>
<RadixSlider.Range className={ cn('track', 'track-0', trackClassName) } />
</RadixSlider.Track>
{ valueArr.map((_, i) => renderThumbElement(i)) }
</RadixSlider.Root>
{ !disabledButton && (
<Button classNames={ [ 'nitro-slider-button', 'nitro-slider-button-right' ] } disabled={ disabled || (maximum <= currentValue) } onClick={ stepUp }>
<FaAngleRight />
</Button>
) }
</Flex>
);
}
+36 -5
View File
@@ -1,5 +1,6 @@
import { BadgeImageReadyEvent, GetEventDispatcher, GetSessionDataManager, NitroSprite, TextureUtils } from '@nitrots/nitro-renderer';
import { CSSProperties, FC, useEffect, useMemo, useState } from 'react';
import { CSSProperties, FC, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { GetConfigurationValue, LocalizeBadgeDescription, LocalizeBadgeName, LocalizeText } from '../../api';
import { Base, BaseProps } from '../Base';
@@ -17,6 +18,26 @@ export const LayoutBadgeImageView: FC<LayoutBadgeImageViewProps> = props =>
{
const { badgeCode = null, isGroup = false, showInfo = false, customTitle = null, isGrayscale = false, scale = 1, classNames = [], style = {}, children = null, ...rest } = props;
const [ imageElement, setImageElement ] = useState<HTMLImageElement>(null);
const [ tooltipPosition, setTooltipPosition ] = useState<{ top: number; left: number } | null>(null);
const badgeRef = useRef<HTMLDivElement>(null);
const tooltipsEnabled = showInfo && GetConfigurationValue<boolean>('badge.descriptions.enabled', true);
const showTooltip = () =>
{
if(!tooltipsEnabled || !badgeRef.current) return;
const rect = badgeRef.current.getBoundingClientRect();
const tooltipWidth = 210;
const gap = 10;
let left = rect.left - tooltipWidth - gap;
if(left < gap) left = rect.right + gap;
setTooltipPosition({ top: rect.top, left });
};
const hideTooltip = () => setTooltipPosition(null);
const getClassNames = useMemo(() =>
{
@@ -116,12 +137,22 @@ export const LayoutBadgeImageView: FC<LayoutBadgeImageViewProps> = props =>
}, [ badgeCode, isGroup ]);
return (
<Base className="group" classNames={ getClassNames } style={ getStyle } { ...rest }>
{ (showInfo && GetConfigurationValue<boolean>('badge.descriptions.enabled', true)) &&
<Base className="hidden group-hover:block before:absolute before:content-['_'] before:w-0 before:h-0 before:border-l-10! before:border-b-10! before:border-t-10! before:top-[10px] before:-right-[10px] before:border-l-[white] before:border-t-transparent before:border-b-transparent z-50 absolute pointer-events-none select-none w-[210px] rounded-[.25rem] bg-[#fff] -left-[220px] text-black py-1 px-2 small">
<Base
innerRef={ badgeRef }
classNames={ getClassNames }
style={ getStyle }
onMouseEnter={ tooltipsEnabled ? showTooltip : undefined }
onMouseLeave={ tooltipsEnabled ? hideTooltip : undefined }
{ ...rest }>
{ tooltipsEnabled && tooltipPosition && createPortal(
<div
className="fixed z-[9999] pointer-events-none select-none w-[210px] rounded-[.25rem] bg-[#fff] text-black py-1 px-2 small"
style={ { top: tooltipPosition.top, left: tooltipPosition.left } }>
<div className="font-bold mb-1">{ isGroup ? customTitle : LocalizeBadgeName(badgeCode) }</div>
<div>{ isGroup ? LocalizeText('group.badgepopup.body') : LocalizeBadgeDescription(badgeCode) }</div>
</Base> }
</div>,
document.body
) }
{ children }
</Base>
);
+17 -33
View File
@@ -1,6 +1,6 @@
import { FC, ReactNode, useEffect, useState } from 'react';
import { Transition } from 'react-transition-group';
import { getTransitionAnimationStyle } from './TransitionAnimationStyles';
import { AnimatePresence, motion, Variants } from 'framer-motion';
import { FC, ReactNode } from 'react';
import { getTransitionVariants } from './TransitionAnimationStyles';
interface TransitionAnimationProps
{
@@ -15,38 +15,22 @@ export const TransitionAnimation: FC<TransitionAnimationProps> = props =>
{
const { type = null, inProp = false, timeout = 300, className = null, children = null } = props;
const [ isChildrenVisible, setChildrenVisible ] = useState(false);
useEffect(() =>
{
let timeoutData: ReturnType<typeof setTimeout> = null;
if(inProp)
{
setChildrenVisible(true);
}
else
{
timeoutData = setTimeout(() =>
{
setChildrenVisible(false);
clearTimeout(timeout);
}, timeout);
}
return () =>
{
if(timeoutData) clearTimeout(timeoutData);
};
}, [ inProp, timeout ]);
const variants: Variants = getTransitionVariants(type);
const duration = timeout / 1000;
return (
<Transition in={ inProp } timeout={ timeout }>
{ state => (
<div className={ (className ?? '') + ' animate__animated' } style={ { ...getTransitionAnimationStyle(type, state, timeout) } }>
{ isChildrenVisible && children }
</div>
<AnimatePresence initial={ false }>
{ inProp && (
<motion.div
className={ className ?? '' }
variants={ variants }
initial="hidden"
animate="visible"
exit="exit"
transition={ { duration } }>
{ children }
</motion.div>
) }
</Transition>
</AnimatePresence>
);
};
@@ -1,136 +1,66 @@
import { CSSProperties } from 'react';
import { TransitionStatus } from 'react-transition-group';
import { ENTERING, EXITING } from 'react-transition-group/Transition';
import { Variants } from 'framer-motion';
import { TransitionAnimationTypes } from './TransitionAnimationTypes';
export function getTransitionAnimationStyle(type: string, transition: TransitionStatus, timeout: number = 300): Partial<CSSProperties>
export function getTransitionVariants(type: string): Variants
{
switch(type)
{
case TransitionAnimationTypes.BOUNCE:
switch(transition)
{
default:
return {};
case ENTERING:
return {
animationName: 'bounceIn',
animationDuration: `${ timeout }ms`
};
case EXITING:
return {
animationName: 'bounceOut',
animationDuration: `${ timeout }ms`
};
}
return {
hidden: { opacity: 0, scale: 0.3 },
visible: { opacity: 1, scale: 1, transition: { type: 'spring', stiffness: 260, damping: 12 } },
exit: { opacity: 0, scale: 0.3 }
};
case TransitionAnimationTypes.SLIDE_LEFT:
switch(transition)
{
default:
return {};
case ENTERING:
return {
animationName: 'slideInLeft',
animationDuration: `${ timeout }ms`
};
case EXITING:
return {
animationName: 'slideOutLeft',
animationDuration: `${ timeout }ms`
};
}
return {
hidden: { opacity: 0, x: '-100%' },
visible: { opacity: 1, x: 0 },
exit: { opacity: 0, x: '-100%' }
};
case TransitionAnimationTypes.SLIDE_RIGHT:
switch(transition)
{
default:
return {};
case ENTERING:
return {
animationName: 'slideInRight',
animationDuration: `${ timeout }ms`
};
case EXITING:
return {
animationName: 'slideOutRight',
animationDuration: `${ timeout }ms`
};
}
return {
hidden: { opacity: 0, x: '100%' },
visible: { opacity: 1, x: 0 },
exit: { opacity: 0, x: '100%' }
};
case TransitionAnimationTypes.FLIP_X:
switch(transition)
{
default:
return {};
case ENTERING:
return {
animationName: 'flipInX',
animationDuration: `${ timeout }ms`
};
case EXITING:
return {
animationName: 'flipOutX',
animationDuration: `${ timeout }ms`
};
}
return {
hidden: { opacity: 0, rotateX: 90 },
visible: { opacity: 1, rotateX: 0 },
exit: { opacity: 0, rotateX: 90 }
};
case TransitionAnimationTypes.FADE_UP:
switch(transition)
{
default:
return {};
case ENTERING:
return {
animationName: 'fadeInUp',
animationDuration: `${ timeout }ms`
};
case EXITING:
return {
animationName: 'fadeOutDown',
animationDuration: `${ timeout }ms`
};
}
return {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 20 }
};
case TransitionAnimationTypes.FADE_IN:
switch(transition)
{
default:
return {};
case ENTERING:
return {
animationName: 'fadeIn',
animationDuration: `${ timeout }ms`
};
case EXITING:
return {
animationName: 'fadeOut',
animationDuration: `${ timeout }ms`
};
}
return {
hidden: { opacity: 0 },
visible: { opacity: 1 },
exit: { opacity: 0 }
};
case TransitionAnimationTypes.FADE_DOWN:
switch(transition)
{
default:
return {};
case ENTERING:
return {
animationName: 'fadeInDown',
animationDuration: `${ timeout }ms`
};
case EXITING:
return {
animationName: 'fadeOutUp',
animationDuration: `${ timeout }ms`
};
}
return {
hidden: { opacity: 0, y: -20 },
visible: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -20 }
};
case TransitionAnimationTypes.HEAD_SHAKE:
switch(transition)
{
default:
return {};
case ENTERING:
return {
animationName: 'headShake',
animationDuration: `${ timeout }ms`
};
}
return {
hidden: { x: 0 },
visible: {
x: [ 0, -6, 5, -3, 2, 0 ],
transition: { duration: 0.5 }
},
exit: { x: 0 }
};
}
return null;
return {
hidden: {},
visible: {},
exit: {}
};
}
+2
View File
@@ -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 />
+18 -10
View File
@@ -20,9 +20,11 @@ interface BackgroundsViewProps {
setSelectedStand: Dispatch<SetStateAction<number>>;
selectedOverlay: number;
setSelectedOverlay: Dispatch<SetStateAction<number>>;
selectedCardBackground: number;
setSelectedCardBackground: Dispatch<SetStateAction<number>>;
}
const TABS = ['backgrounds', 'stands', 'overlays'] as const;
const TABS = ['backgrounds', 'stands', 'overlays', 'cards'] as const;
type TabType = typeof TABS[number];
export const BackgroundsView: FC<BackgroundsViewProps> = ({
@@ -32,7 +34,9 @@ export const BackgroundsView: FC<BackgroundsViewProps> = ({
selectedStand,
setSelectedStand,
selectedOverlay,
setSelectedOverlay
setSelectedOverlay,
selectedCardBackground,
setSelectedCardBackground
}) => {
const [activeTab, setActiveTab] = useState<TabType>('backgrounds');
const { roomSession } = useRoom();
@@ -58,20 +62,21 @@ export const BackgroundsView: FC<BackgroundsViewProps> = ({
const allData = useMemo(() => ({
backgrounds: processData(GetConfigurationValue('backgrounds.data'), 'background'),
stands: processData(GetConfigurationValue('stands.data'), 'stand'),
overlays: processData(GetConfigurationValue('overlays.data'), 'overlay')
overlays: processData(GetConfigurationValue('overlays.data'), 'overlay'),
cards: processData(GetConfigurationValue('cards.data') || GetConfigurationValue('backgrounds.data'), 'background')
}), [processData]);
const handleSelection = useCallback((id: number) => {
if (!roomSession) return;
const setters = { backgrounds: setSelectedBackground, stands: setSelectedStand, overlays: setSelectedOverlay };
const currentValues = { backgrounds: selectedBackground, stands: selectedStand, overlays: selectedOverlay };
const setters = { backgrounds: setSelectedBackground, stands: setSelectedStand, overlays: setSelectedOverlay, cards: setSelectedCardBackground };
const currentValues = { backgrounds: selectedBackground, stands: selectedStand, overlays: selectedOverlay, cards: selectedCardBackground };
setters[activeTab](id);
const newValues = { ...currentValues, [activeTab]: id };
roomSession.sendBackgroundMessage( newValues.backgrounds, newValues.stands, newValues.overlays );
}, [activeTab, roomSession, selectedBackground, selectedStand, selectedOverlay, setSelectedBackground, setSelectedStand, setSelectedOverlay]);
roomSession.sendBackgroundMessage( newValues.backgrounds, newValues.stands, newValues.overlays, newValues.cards );
}, [activeTab, roomSession, selectedBackground, selectedStand, selectedOverlay, selectedCardBackground, setSelectedBackground, setSelectedStand, setSelectedOverlay, setSelectedCardBackground]);
const renderItem = useCallback((item: ItemData, type: string) => (
<Flex
@@ -81,7 +86,10 @@ export const BackgroundsView: FC<BackgroundsViewProps> = ({
onClick={() => item.selectable && handleSelection(item.id)}
className={item.selectable ? '' : 'non-selectable'}
>
<Base className={`profile-${type} ${type}-${item.id}`} />
<Base
className={`profile-${type} ${type}-${item.id}`}
style={type === 'card-background' ? { width: 60, height: 80, borderRadius: 4 } : undefined}
/>
{item.isHcOnly && <LayoutCurrencyIcon position="absolute" className="top-1 inset-e-1" type="hc" />}
</Flex>
), [handleSelection]);
@@ -103,7 +111,7 @@ export const BackgroundsView: FC<BackgroundsViewProps> = ({
<NitroCardContentView gap={1}>
<Text bold center>Select an Option</Text>
<Grid gap={1} columnCount={7} overflow="auto">
{allData[activeTab].map(item => renderItem(item, activeTab.slice(0, -1)))}
{allData[activeTab].map(item => renderItem(item, activeTab === 'cards' ? 'card-background' : activeTab.slice(0, -1)))}
</Grid>
</NitroCardContentView>
</NitroCardView>
@@ -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>
);
};
+1
View File
@@ -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>
+2 -1
View File
@@ -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 {}
+6 -2
View File
@@ -9,8 +9,12 @@ export const resolveNewsImage = (raw: string | null | undefined): string =>
const value = (raw ?? '').trim();
if(!value) return '';
if(/^https?:\/\//i.test(value)) return value;
if(value.startsWith('//')) return value;
if(value.startsWith('/') && !value.startsWith('//')) return value;
if(value.startsWith('//')) return window.location.protocol + value;
if(value.startsWith('/'))
{
try { return new URL(value, window.location.origin).href; }
catch { return window.location.origin + value; }
}
if(value.startsWith('data:'))
{
return /^data:image\/[a-z0-9.+-]+[,;]/i.test(value) ? value : '';
@@ -1,7 +1,7 @@
import { RoomDataParser, RoomSettingsComposer, UpdateHomeRoomMessageComposer } from '@nitrots/nitro-renderer';
import * as Popover from '@radix-ui/react-popover';
import React, { FC, useRef, useState } from 'react';
import { FaUser } from 'react-icons/fa';
import { ArrowContainer, Popover } from 'react-tiny-popover';
import { GetGroupInformation, GetSessionDataManager, GetUserProfile, LocalizeText, ReportType, SendMessageComposer, ToggleFavoriteRoom } from '../../../../api';
import { Column, Flex, LayoutBadgeImageView, LayoutRoomThumbnailView, NitroCardContentView, Text, UserProfileIconView } from '../../../../common';
import { useHelp, useNavigator } from '../../../../hooks';
@@ -26,6 +26,12 @@ export const NavigatorSearchResultItemInfoView: FC<NavigatorSearchResultItemInfo
const isControlled = isVisible !== undefined;
const popoverOpen = isControlled ? isVisible : internalVisible;
const handleOpenChange = (open: boolean) =>
{
if(!isControlled) setInternalVisible(open);
if(!open && setIsPopoverActive) setIsPopoverActive(false);
};
const getUserCounterColor = () =>
{
const num: number = (100 * (roomData.userCount / roomData.maxUserCount));
@@ -88,17 +94,22 @@ export const NavigatorSearchResultItemInfoView: FC<NavigatorSearchResultItemInfo
};
return (
<Popover
containerClassName="max-w-[276px] not-italic font-normal leading-normal text-left no-underline text-shadow-none normal-case tracking-[normal] [word-break:normal] [word-spacing:normal] whitespace-normal text-[.7875rem] [word-wrap:break-word] bg-[#f2f2eb] border border-[#000] rounded-[8px] shadow-none z-[1070]"
content={ ({ position, childRect, popoverRect }) => (
<ArrowContainer
arrowColor="black"
arrowSize={ 7 }
arrowStyle={ { left: 'calc(-.5rem - 0px)' } }
childRect={ childRect }
popoverRect={ popoverRect }
position={ position }
>
<Popover.Root open={ popoverOpen } onOpenChange={ handleOpenChange }>
<Popover.Trigger asChild>
<div
ref={ elementRef }
className="cursor-pointer nitro-icon icon-navigator-info"
onClick={ handleIconClick }
onMouseOver={ () => { if(!isControlled) setInternalVisible(true); } }
onMouseLeave={ () => { if(!isControlled) setInternalVisible(false); } }
/>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side="right"
sideOffset={ 10 }
collisionPadding={ 8 }
className="max-w-[276px] not-italic font-normal leading-normal text-left no-underline normal-case tracking-normal whitespace-normal text-[.7875rem] [word-wrap:break-word] bg-[#f2f2eb] border border-black rounded-[8px] shadow-none z-[1070]">
<NitroCardContentView className="bg-transparent room-info image-rendering-pixelated !p-0" overflow="hidden" onClick={ e => e.stopPropagation() }>
<Flex gap={ 1 } overflow="hidden" className="p-2">
<LayoutRoomThumbnailView className="flex flex-col items-center justify-end mb-1" customUrl={ roomData.officialRoomPicRef } roomId={ roomData.roomId }>
@@ -173,24 +184,9 @@ export const NavigatorSearchResultItemInfoView: FC<NavigatorSearchResultItemInfo
</Flex> }
</Column>
</NitroCardContentView>
</ArrowContainer>
) }
isOpen={ popoverOpen }
onClickOutside={ () =>
{
if(!isControlled) setInternalVisible(false);
if(setIsPopoverActive) setIsPopoverActive(false);
} }
padding={ 10 }
positions={ [ 'right', 'left', 'top', 'bottom' ] }
>
<div
ref={ elementRef }
className="cursor-pointer nitro-icon icon-navigator-info"
onClick={ handleIconClick }
onMouseOver={ () => { if(!isControlled) setInternalVisible(true); } }
onMouseLeave={ () => { if(!isControlled) setInternalVisible(false); } }
/>
</Popover>
<Popover.Arrow className="fill-black" width={ 14 } height={ 7 } />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
};
+9 -11
View File
@@ -1,5 +1,4 @@
import { FC, useMemo } from 'react';
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
import { LocalizeFormattedNumber, LocalizeShortNumber } from '../../../api';
import { Flex, LayoutCurrencyIcon, Text } from '../../../common';
@@ -17,23 +16,22 @@ export const CurrencyView: FC<CurrencyViewProps> = props =>
const element = useMemo(() =>
{
return (
<Flex justifyContent="end" pointer gap={ 1 } className={`nitro-purse-button rounded allcurrencypurse nitro-purse-button currency-${type}`}>
<Flex justifyContent="end" pointer gap={ 1 } className={ `nitro-purse-button rounded allcurrencypurse nitro-purse-button currency-${ type }` }>
<Text truncate textEnd variant="white" grow>{ short ? LocalizeShortNumber(amount) : LocalizeFormattedNumber(amount) }</Text>
<LayoutCurrencyIcon type={ type } />
</Flex>);
}, [ amount, short, type ]);
if(!short) return element;
return (
<OverlayTrigger
placement="left"
overlay={
<Tooltip id={ `tooltip-${ type }` }>
{ LocalizeFormattedNumber(amount) }
</Tooltip>
}>
<div className="group relative">
{ element }
</OverlayTrigger>
<div
role="tooltip"
className="pointer-events-none absolute right-full top-1/2 z-50 mr-2 -translate-y-1/2 whitespace-nowrap rounded bg-black/80 px-2 py-1 text-xs text-white opacity-0 shadow transition-opacity duration-150 group-hover:opacity-100">
{ LocalizeFormattedNumber(amount) }
</div>
</div>
);
}
@@ -1,4 +1,4 @@
import { CrackableDataType, CreateLinkEvent, FurnitureFloorUpdateEvent, GetRoomEngine, GetSoundManager, GroupInformationComposer, GroupInformationEvent, NowPlayingEvent, RoomControllerLevel, RoomObjectCategory, RoomObjectOperationType, RoomObjectVariable, RoomWidgetEnumItemExtradataParameter, RoomWidgetFurniInfoUsagePolicyEnum, SetObjectDataMessageComposer, SongInfoReceivedEvent, StringDataType, UpdateFurniturePositionComposer } from '@nitrots/nitro-renderer';
import { CrackableDataType, CreateLinkEvent, FurnitureFloorUpdateEvent, GetRoomEngine, GetSessionDataManager, GetSoundManager, GroupInformationComposer, GroupInformationEvent, NowPlayingEvent, RoomControllerLevel, RoomObjectCategory, RoomObjectOperationType, RoomObjectVariable, RoomWidgetEnumItemExtradataParameter, RoomWidgetFurniInfoUsagePolicyEnum, SetObjectDataMessageComposer, SongInfoReceivedEvent, StringDataType, UpdateFurniturePositionComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FaCrosshairs, FaRulerVertical, FaTimes } from 'react-icons/fa';
import { GrFormNextLink, GrRotateLeft, GrRotateRight } from 'react-icons/gr';
@@ -585,19 +585,20 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
onClick={ () => setDropdownOpen(!dropdownOpen) }>
{ dropdownOpen ? `${LocalizeText('widget.furni.present.close')} Buildtools` : `${LocalizeText('navigator.roomsettings.doormode.open')} Buildtools` }
</button>
<button
className="w-full text-white text-xs bg-[#1e7295] hover:bg-[#1a617f] border border-[#ffffff33] rounded px-2 py-1 cursor-pointer transition-colors"
onClick={ () =>
{
const roomObject = GetRoomEngine().getRoomObject(roomSession.roomId, avatarInfo.id, avatarInfo.isWallItem ? RoomObjectCategory.WALL : RoomObjectCategory.FLOOR);
const typeId = roomObject?.model?.getValue(RoomObjectVariable.FURNITURE_TYPE_ID);
{ GetSessionDataManager().isModerator &&
<button
className="w-full text-white text-xs bg-[#1e7295] hover:bg-[#1a617f] border border-[#ffffff33] rounded px-2 py-1 cursor-pointer transition-colors"
onClick={ () =>
{
const roomObject = GetRoomEngine().getRoomObject(roomSession.roomId, avatarInfo.id, avatarInfo.isWallItem ? RoomObjectCategory.WALL : RoomObjectCategory.FLOOR);
const typeId = roomObject?.model?.getValue(RoomObjectVariable.FURNITURE_TYPE_ID);
CreateLinkEvent('furni-editor/show');
CreateLinkEvent('furni-editor/show');
if(typeId) window.dispatchEvent(new CustomEvent('furni-editor:open', { detail: { spriteId: typeId } }));
} }>
Edit Furni
</button>
if(typeId) window.dispatchEvent(new CustomEvent('furni-editor:open', { detail: { spriteId: typeId } }));
} }>
Edit Furni
</button> }
{ dropdownOpen &&
<div className="flex gap-[4px] w-full">
{ /* Left panel: position + rotation */ }
@@ -24,12 +24,14 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
const [backgroundId, setBackgroundId] = useState<number>(null);
const [standId, setStandId] = useState<number>(null);
const [overlayId, setOverlayId] = useState<number>(null);
const [cardBackgroundId, setCardBackgroundId] = useState<number>(null);
const [isVisible, setIsVisible] = useState(false);
const { roomSession = null } = useRoom();
const infostandBackgroundClass = `background-${backgroundId ?? 'default'}`;
const infostandStandClass = `stand-${standId ?? 'default'}`;
const infostandOverlayClass = `overlay-${overlayId ?? 'default'}`;
const infostandCardBackgroundClass = cardBackgroundId ? `card-background-${cardBackgroundId}` : '';
const handleProfileClick = useCallback(() => { GetUserProfile(avatarInfo.webID); }, [avatarInfo.webID]);
@@ -91,6 +93,7 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
newValue.backgroundId = event.backgroundId;
newValue.standId = event.standId;
newValue.overlayId = event.overlayId;
newValue.cardBackgroundId = event.cardBackgroundId ?? 0;
return newValue;
});
});
@@ -125,16 +128,12 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
setBackgroundId(avatarInfo.backgroundId);
setStandId(avatarInfo.standId);
setOverlayId(avatarInfo.overlayId);
setCardBackgroundId(avatarInfo.cardBackgroundId ?? 0);
SendMessageComposer(new UserRelationshipsComposer(avatarInfo.webID));
return () => {
setIsEditingMotto(false);
setMotto(null);
setRelationships(null);
setBackgroundId(null);
setStandId(null);
setOverlayId(null);
};
}, [avatarInfo]);
@@ -142,7 +141,7 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
return (
<>
<Column className="relative min-w-[190px] max-w-[190px] z-30 pointer-events-auto bg-[rgba(28,28,32,0.95)] [box-shadow:inset_0_5px_#22222799,inset_0_-4px_#12121599] rounded">
<Column className={`relative min-w-[190px] max-w-[190px] z-30 pointer-events-auto ${cardBackgroundId ? '' : 'bg-[rgba(28,28,32,0.95)]'} [box-shadow:inset_0_5px_#22222799,inset_0_-4px_#12121599] rounded overflow-hidden profile-card-background ${infostandCardBackgroundClass}`}>
<Column className="h-full p-[8px] overflow-auto" gap={1} overflow="visible">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
@@ -277,6 +276,8 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
setSelectedStand={setStandId}
selectedOverlay={overlayId}
setSelectedOverlay={setOverlayId}
selectedCardBackground={cardBackgroundId}
setSelectedCardBackground={setCardBackgroundId}
/>
</div>
)}
@@ -1,7 +1,7 @@
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
import * as Popover from '@radix-ui/react-popover';
import { FC, useState } from 'react';
import { Popover } from 'react-tiny-popover';
interface ChatInputEmojiSelectorViewProps
{
@@ -19,19 +19,16 @@ export const ChatInputEmojiSelectorView: FC<ChatInputEmojiSelectorViewProps> = p
setSelectorVisible(false);
};
const toggleSelector = () => setSelectorVisible(prev => !prev);
return (
<div>
<Popover
containerClassName="z-[1070]"
content={ <Picker data={ data } onEmojiSelect={ handleEmojiSelect } /> }
isOpen={ selectorVisible }
positions={ [ 'top' ] }
onClickOutside={ () => setSelectorVisible(false) }
>
<div className="cursor-pointer text-lg select-none px-1" onClick={ toggleSelector }>🙂</div>
</Popover>
</div>
<Popover.Root open={ selectorVisible } onOpenChange={ setSelectorVisible }>
<Popover.Trigger asChild>
<div className="cursor-pointer text-lg select-none px-1">🙂</div>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content className="z-[1070]" side="top" sideOffset={ 8 }>
<Picker data={ data } onEmojiSelect={ handleEmojiSelect } />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
};
@@ -1,5 +1,5 @@
import * as Popover from '@radix-ui/react-popover';
import { FC, useState } from 'react';
import { ArrowContainer, Popover } from 'react-tiny-popover';
import { Flex, Grid, NitroCardContentView } from '../../../../common';
interface ChatInputStyleSelectorViewProps
@@ -21,20 +21,17 @@ export const ChatInputStyleSelectorView: FC<ChatInputStyleSelectorViewProps> = p
};
return (
<Popover
padding={12}
isOpen={selectorVisible}
positions={['top']}
reposition={false}
containerClassName="max-w-[276px] not-italic font-normal leading-normal text-left no-underline text-shadow-none normal-case tracking-[normal] [word-break:normal] [word-spacing:normal] whitespace-normal text-[.7875rem] [word-wrap:break-word] bg-[#dfdfdf] bg-clip-padding border border-[solid] border-[#283F5D] rounded-[.25rem] [box-shadow:0_2px_#00000073] z-1070"
content={({ position, childRect, popoverRect }) => (
<ArrowContainer
arrowColor={'black'}
arrowSize={7}
arrowStyle={{ bottom: 'calc(-.5rem - 1px)' }}
childRect={childRect}
popoverRect={popoverRect}
position={position}
<Popover.Root open={selectorVisible} onOpenChange={setSelectorVisible}>
<Popover.Trigger asChild>
<div className="chatstyles-anchor">
<div className="nitro-icon chatstyles-icon" />
</div>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side="top"
sideOffset={12}
className="max-w-[276px] not-italic font-normal leading-normal text-left no-underline normal-case tracking-normal whitespace-normal text-[.7875rem] [word-wrap:break-word] bg-[#dfdfdf] bg-clip-padding border border-solid border-[#283F5D] rounded-[.25rem] [box-shadow:0_2px_#00000073] z-[1070]"
>
<NitroCardContentView className="bg-transparent max-h-[210px]!" overflow="hidden">
<Grid columnCount={3} overflow="auto">
@@ -47,15 +44,9 @@ export const ChatInputStyleSelectorView: FC<ChatInputStyleSelectorViewProps> = p
))}
</Grid>
</NitroCardContentView>
</ArrowContainer>
)}
>
<div
className="chatstyles-anchor"
onClick={() => setSelectorVisible(v => !v)}
>
<div className="nitro-icon chatstyles-icon" />
</div>
</Popover>
<Popover.Arrow className="fill-black" width={14} height={7} />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
};
};
@@ -1,6 +1,5 @@
import { FC, useEffect, useState } from 'react';
import YouTube, { Options } from 'react-youtube';
import { YouTubePlayer } from 'youtube-player/dist/types';
import { FC, useRef } from 'react';
import ReactPlayer from 'react-player/youtube';
import { LocalizeText, YoutubeVideoPlaybackStateEnum } from '../../../../api';
import { AutoGrid, AutoGridProps, LayoutGridItem, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useFurnitureYoutubeWidget } from '../../../../hooks';
@@ -12,71 +11,24 @@ interface FurnitureYoutubeDisplayViewProps extends AutoGridProps
export const FurnitureYoutubeDisplayView: FC<{}> = FurnitureYoutubeDisplayViewProps =>
{
const [ player, setPlayer ] = useState<any>(null);
const { objectId = -1, videoId = null, videoStart = 0, videoEnd = 0, currentVideoState = null, selectedVideo = null, playlists = [], onClose = null, previous = null, next = null, pause = null, play = null, selectVideo = null } = useFurnitureYoutubeWidget();
const playerRef = useRef<ReactPlayer>(null);
const onStateChange = (event: { target: YouTubePlayer; data: number }) =>
const handlePlay = () =>
{
try
{
setPlayer(event.target);
if(objectId === -1) return;
switch(event.target.getPlayerState())
{
case -1:
case 1:
if(currentVideoState !== 1) play();
return;
case 2:
if(currentVideoState !== 2) pause();
}
}
catch(err) {}
if(objectId === -1) return;
if(currentVideoState !== YoutubeVideoPlaybackStateEnum.PLAYING) play();
};
useEffect(() =>
const handlePause = () =>
{
if((currentVideoState === null) || !player) return;
try
{
if((currentVideoState === YoutubeVideoPlaybackStateEnum.PLAYING) && (player.getPlayerState() !== YoutubeVideoPlaybackStateEnum.PLAYING))
{
player.playVideo();
return;
}
if((currentVideoState === YoutubeVideoPlaybackStateEnum.PAUSED) && (player.getPlayerState() !== YoutubeVideoPlaybackStateEnum.PAUSED))
{
player.pauseVideo();
return;
}
}
catch(err)
{
setPlayer(null);
}
}, [ currentVideoState, player ]);
if(objectId === -1) return;
if(currentVideoState !== YoutubeVideoPlaybackStateEnum.PAUSED) pause();
};
if(objectId === -1) return null;
const youtubeOptions: Options = {
height: '375',
width: '500',
playerVars: {
autoplay: 1,
disablekb: 1,
controls: 0,
origin: window.origin,
modestbranding: 1,
start: videoStart,
end: videoEnd
}
};
const playing = (currentVideoState === null) ? true : (currentVideoState === YoutubeVideoPlaybackStateEnum.PLAYING);
return (
<NitroCardView className="youtube-tv-widget">
@@ -85,7 +37,26 @@ export const FurnitureYoutubeDisplayView: FC<{}> = FurnitureYoutubeDisplayViewPr
<div className="row size-full">
<div className="youtube-video-container col-span-9 overflow-hidden">
{ (videoId && videoId.length > 0) &&
<YouTube containerClassName={ 'youtubeContainer' } opts={ youtubeOptions } videoId={ videoId } onReady={ event => setPlayer(event.target) } onStateChange={ onStateChange } />
<ReactPlayer
ref={ playerRef }
url={ `https://www.youtube.com/watch?v=${ videoId }` }
width={ 500 }
height={ 375 }
playing={ playing }
controls={ false }
onPlay={ handlePlay }
onPause={ handlePause }
config={ {
playerVars: {
autoplay: 1,
disablekb: 1,
controls: 0,
origin: window.origin,
modestbranding: 1,
start: videoStart,
end: videoEnd
}
} } />
}
{ (!videoId || videoId.length === 0) &&
<div className="empty-video size-full justify-center items-center flex">{ LocalizeText('widget.furni.video_viewer.no_videos') }</div>
+2 -1
View File
@@ -32,7 +32,7 @@ export const ToolbarMeView: FC<PropsWithChildren<{
}, [ setMeExpanded ]);
return (
<Flex alignItems="center" className="absolute bottom-[60px] left-[33px] bg-[rgba(20,20,20,.95)] border border-[solid] border-[#101010] [box-shadow:inset_2px_2px_rgba(255,255,255,.1),inset_-2px_-2px_rgba(255,255,255,.1)] rounded-[$border-radius] p-2" gap={ 2 } innerRef={ elementRef }>
<Flex alignItems="center" className="bg-[rgba(20,20,20,.95)] border border-[solid] border-[#101010] [box-shadow:inset_2px_2px_rgba(255,255,255,.1),inset_-2px_-2px_rgba(255,255,255,.1)] rounded-[$border-radius] p-2" gap={ 2 } innerRef={ elementRef }>
{ (GetConfigurationValue('guides.enabled') && useGuideTool) &&
<div className="navigation-item relative nitro-icon icon-me-helper-tool cursor-pointer" onClick={ event => DispatchUiEvent(new GuideToolEvent(GuideToolEvent.TOGGLE_GUIDE_TOOL)) } /> }
<div className="navigation-item relative nitro-icon icon-me-achievements cursor-pointer" onClick={ event => CreateLinkEvent('achievements/toggle') }>
@@ -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 }
+2 -2
View File
@@ -184,7 +184,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
</motion.div> }
</AnimatePresence>
<motion.div whileHover={ { scale: 1.15 } } whileTap={ { scale: 1 } } className="cursor-pointer" onClick={ event => { setMeExpanded(value => !value); event.stopPropagation(); } }>
<LayoutAvatarImageView headOnly={ true } direction={ 2 } figure={ userFigure } className="tb-icon !h-[63px] !w-[32px] !bg-center !bg-no-repeat" style={ { marginTop: '2px' } } />
<LayoutAvatarImageView headOnly={ true } direction={ 2 } figure={ userFigure } className="tb-icon !h-[63px] !w-[32px] !bg-center !bg-no-repeat" style={ { marginTop: '12px' } } />
</motion.div>
{ (getTotalUnseen > 0) &&
<LayoutItemCountView count={ getTotalUnseen } className="pointer-events-none absolute -right-1 -top-1 z-10" /> }
@@ -279,7 +279,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
</motion.div> }
</AnimatePresence>
<motion.div whileHover={ { scale: 1.08 } } whileTap={ { scale: 0.95 } } className="cursor-pointer" onClick={ event => { setMeExpanded(value => !value); event.stopPropagation(); } }>
<LayoutAvatarImageView headOnly={ true } direction={ 2 } figure={ userFigure } className="tb-icon !h-[44px] !w-[32px] !bg-center !bg-no-repeat" style={ { marginTop: '4px' } } />
<LayoutAvatarImageView headOnly={ true } direction={ 2 } figure={ userFigure } className="tb-icon !h-[44px] !w-[32px] !bg-center !bg-no-repeat" style={ { marginTop: '9px' } } />
</motion.div>
{ (getTotalUnseen > 0) &&
<LayoutItemCountView count={ getTotalUnseen } className="pointer-events-none absolute -right-1 -top-1 z-10" /> }
+13 -13
View File
@@ -1,6 +1,6 @@
import { ControlYoutubeDisplayPlaybackMessageComposer, YouTubeRoomBroadcastEvent, YouTubeRoomPlayComposer, YouTubeRoomSettingsEvent, YouTubeRoomWatchersEvent, YouTubeRoomWatchingComposer } from "@nitrots/nitro-renderer";
import { FC, useEffect, useRef, useState } from "react";
import YouTube from "react-youtube";
import ReactPlayer from "react-player/youtube";
import { GetRoomSession, getYoutubeRoomEnabled, GetSessionDataManager, LocalizeText, SendMessageComposer, YoutubeVideoPlaybackStateEnum } from "../../api";
import { NitroCardContentView, NitroCardHeaderView, NitroCardView, LayoutAvatarImageView } from "../../common";
import { useFurnitureYoutubeWidget, useMessageEvent } from "../../hooks";
@@ -35,7 +35,7 @@ export const YouTubePlayerView: FC<{}> = () => {
const [playlist, setPlaylist] = useState<string[]>([]);
const [history, setHistory] = useState<string[]>([]);
const [showVolumeSlider, setShowVolumeSlider] = useState(true);
const playerRef = useRef<any>(null);
const playerRef = useRef<ReactPlayer | null>(null);
const { objectId: youtubeObjectId, videoId: roomVideoId, currentVideoState, hasControl } = useFurnitureYoutubeWidget();
const [spectators, setSpectators] = useState< { id: number; name: string; look: string }[] >([]);
const [broadcastVideo, setBroadcastVideo] = useState("");
@@ -310,22 +310,22 @@ export const YouTubePlayerView: FC<{}> = () => {
)}
{videoId ? (
<YouTube
videoId={videoId}
opts={{
width: "100%",
height: isFullscreen ? "100%" : "280",
<ReactPlayer
ref={ref => { playerRef.current = ref; }}
url={`https://www.youtube.com/watch?v=${videoId}`}
width="100%"
height={isFullscreen ? "100%" : 280}
playing
muted={isMuted}
loop={isLooping}
volume={Math.max(0, Math.min(1, volume / 100))}
config={{
playerVars: {
autoplay: 1,
volume: volume,
muted: isMuted ? 1 : 0,
loop: isLooping ? 1 : 0,
},
}}
onReady={(e) => {
playerRef.current = e.target;
addToHistory(videoId);
}}
onReady={() => addToHistory(videoId)}
/>
) : (
<div className="h-[280px] flex items-center justify-center bg-gray-800 text-gray-500">
@@ -18,6 +18,7 @@ export const UserContainerView: FC<{
const infostandBackgroundClass = `background-${userProfile.backgroundId ?? 'default'}`;
const infostandStandClass = `stand-${userProfile.standId ?? 'default'}`;
const infostandOverlayClass = `overlay-${userProfile.overlayId ?? 'default'}`;
const profileCardBgClass = userProfile.cardBackgroundId ? `card-background-${userProfile.cardBackgroundId}` : '';
const addFriend = () =>
{
@@ -32,7 +33,7 @@ export const UserContainerView: FC<{
}, [ userProfile ]);
return (
<div className="flex gap-2">
<div className={`flex gap-2 p-2 rounded profile-card-background ${profileCardBgClass}`}>
<div className={`flex flex-col justify-center items-center w-[75px] h-[120px] rounded-sm relative overflow-hidden profile-background ${infostandBackgroundClass}`}>
<div className={`absolute inset-0 profile-stand ${infostandStandClass}`} />
<LayoutAvatarImageView direction={ 2 } figure={ userProfile.figure } />
+650
View File
@@ -78,6 +78,656 @@
background: none;
}
.profile-card-background {
background-repeat: repeat;
background-position: top left;
background-size: auto;
&.card-background-0 {
background-image: url('@/assets/images/backgrounds/background/bg_0.png');
}
&.card-background-1 {
background-image: url('@/assets/images/backgrounds/background/bg_1.png');
}
&.card-background-2 {
background-image: url('@/assets/images/backgrounds/background/bg_2.png');
}
&.card-background-3 {
background-image: url('@/assets/images/backgrounds/background/bg_3.png');
}
&.card-background-4 {
background-image: url('@/assets/images/backgrounds/background/bg_4.png');
}
&.card-background-5 {
background-image: url('@/assets/images/backgrounds/background/bg_5.png');
}
&.card-background-6 {
background-image: url('@/assets/images/backgrounds/background/bg_6.png');
}
&.card-background-7 {
background-image: url('@/assets/images/backgrounds/background/bg_7.png');
}
&.card-background-8 {
background-image: url('@/assets/images/backgrounds/background/bg_8.png');
}
&.card-background-9 {
background-image: url('@/assets/images/backgrounds/background/bg_9.png');
}
&.card-background-10 {
background-image: url('@/assets/images/backgrounds/background/bg_10.png');
}
&.card-background-11 {
background-image: url('@/assets/images/backgrounds/background/bg_11.png');
}
&.card-background-12 {
background-image: url('@/assets/images/backgrounds/background/bg_12.png');
}
&.card-background-13 {
background-image: url('@/assets/images/backgrounds/background/bg_13.png');
}
&.card-background-14 {
background-image: url('@/assets/images/backgrounds/background/bg_14.png');
}
&.card-background-15 {
background-image: url('@/assets/images/backgrounds/background/bg_15.png');
}
&.card-background-16 {
background-image: url('@/assets/images/backgrounds/background/bg_16.png');
}
&.card-background-17 {
background-image: url('@/assets/images/backgrounds/background/bg_17.png');
}
&.card-background-18 {
background-image: url('@/assets/images/backgrounds/background/bg_18.png');
}
&.card-background-19 {
background-image: url('@/assets/images/backgrounds/background/bg_19.png');
}
&.card-background-20 {
background-image: url('@/assets/images/backgrounds/background/bg_20.png');
}
&.card-background-21 {
background-image: url('@/assets/images/backgrounds/background/bg_21.png');
}
&.card-background-22 {
background-image: url('@/assets/images/backgrounds/background/bg_22.png');
}
&.card-background-23 {
background-image: url('@/assets/images/backgrounds/background/bg_23.png');
}
&.card-background-24 {
background-image: url('@/assets/images/backgrounds/background/bg_24.png');
}
&.card-background-25 {
background-image: url('@/assets/images/backgrounds/background/bg_25.png');
}
&.card-background-26 {
background-image: url('@/assets/images/backgrounds/background/bg_26.png');
}
&.card-background-27 {
background-image: url('@/assets/images/backgrounds/background/bg_27.png');
}
&.card-background-28 {
background-image: url('@/assets/images/backgrounds/background/bg_28.png');
}
&.card-background-29 {
background-image: url('@/assets/images/backgrounds/background/bg_29.png');
}
&.card-background-30 {
background-image: url('@/assets/images/backgrounds/background/bg_30.png');
}
&.card-background-31 {
background-image: url('@/assets/images/backgrounds/background/bg_31.png');
}
&.card-background-32 {
background-image: url('@/assets/images/backgrounds/background/bg_32.png');
}
&.card-background-33 {
background-image: url('@/assets/images/backgrounds/background/bg_33.png');
}
&.card-background-34 {
background-image: url('@/assets/images/backgrounds/background/bg_34.png');
}
&.card-background-35 {
background-image: url('@/assets/images/backgrounds/background/bg_35.png');
}
&.card-background-36 {
background-image: url('@/assets/images/backgrounds/background/bg_36.gif');
}
&.card-background-37 {
background-image: url('@/assets/images/backgrounds/background/bg_37.png');
}
&.card-background-38 {
background-image: url('@/assets/images/backgrounds/background/bg_38.png');
}
&.card-background-39 {
background-image: url('@/assets/images/backgrounds/background/bg_39.png');
}
&.card-background-40 {
background-image: url('@/assets/images/backgrounds/background/bg_40.png');
}
&.card-background-41 {
background-image: url('@/assets/images/backgrounds/background/bg_41.png');
}
&.card-background-42 {
background-image: url('@/assets/images/backgrounds/background/bg_42.png');
}
&.card-background-43 {
background-image: url('@/assets/images/backgrounds/background/bg_43.png');
}
&.card-background-44 {
background-image: url('@/assets/images/backgrounds/background/bg_44.png');
}
&.card-background-45 {
background-image: url('@/assets/images/backgrounds/background/bg_45.png');
}
&.card-background-46 {
background-image: url('@/assets/images/backgrounds/background/bg_46.png');
}
&.card-background-47 {
background-image: url('@/assets/images/backgrounds/background/bg_47.png');
}
&.card-background-48 {
background-image: url('@/assets/images/backgrounds/background/bg_48.png');
}
&.card-background-49 {
background-image: url('@/assets/images/backgrounds/background/bg_49.png');
}
&.card-background-50 {
background-image: url('@/assets/images/backgrounds/background/bg_50.png');
}
&.card-background-51 {
background-image: url('@/assets/images/backgrounds/background/bg_51.gif');
}
&.card-background-52 {
background-image: url('@/assets/images/backgrounds/background/bg_52.gif');
}
&.card-background-53 {
background-image: url('@/assets/images/backgrounds/background/bg_53.gif');
}
&.card-background-54 {
background-image: url('@/assets/images/backgrounds/background/bg_54.gif');
}
&.card-background-55 {
background-image: url('@/assets/images/backgrounds/background/bg_55.gif');
}
&.card-background-56 {
background-image: url('@/assets/images/backgrounds/background/bg_56.gif');
}
&.card-background-57 {
background-image: url('@/assets/images/backgrounds/background/bg_57.gif');
}
&.card-background-58 {
background-image: url('@/assets/images/backgrounds/background/bg_58.gif');
}
&.card-background-59 {
background-image: url('@/assets/images/backgrounds/background/bg_59.gif');
}
&.card-background-60 {
background-image: url('@/assets/images/backgrounds/background/bg_60.gif');
}
&.card-background-61 {
background-image: url('@/assets/images/backgrounds/background/bg_61.gif');
}
&.card-background-62 {
background-image: url('@/assets/images/backgrounds/background/bg_62.gif');
}
&.card-background-63 {
background-image: url('@/assets/images/backgrounds/background/bg_63.gif');
}
&.card-background-64 {
background-image: url('@/assets/images/backgrounds/background/bg_64.gif');
}
&.card-background-65 {
background-image: url('@/assets/images/backgrounds/background/bg_65.gif');
}
&.card-background-66 {
background-image: url('@/assets/images/backgrounds/background/bg_66.gif');
}
&.card-background-67 {
background-image: url('@/assets/images/backgrounds/background/bg_67.gif');
}
&.card-background-68 {
background-image: url('@/assets/images/backgrounds/background/bg_68.gif');
}
&.card-background-69 {
background-image: url('@/assets/images/backgrounds/background/bg_69.gif');
}
&.card-background-70 {
background-image: url('@/assets/images/backgrounds/background/bg_70.gif');
}
&.card-background-71 {
background-image: url('@/assets/images/backgrounds/background/bg_71.gif');
}
&.card-background-72 {
background-image: url('@/assets/images/backgrounds/background/bg_72.gif');
}
&.card-background-73 {
background-image: url('@/assets/images/backgrounds/background/bg_73.gif');
}
&.card-background-74 {
background-image: url('@/assets/images/backgrounds/background/bg_74.gif');
}
&.card-background-75 {
background-image: url('@/assets/images/backgrounds/background/bg_75.gif');
}
&.card-background-76 {
background-image: url('@/assets/images/backgrounds/background/bg_76.gif');
}
&.card-background-77 {
background-image: url('@/assets/images/backgrounds/background/bg_77.gif');
}
&.card-background-78 {
background-image: url('@/assets/images/backgrounds/background/bg_78.gif');
}
&.card-background-79 {
background-image: url('@/assets/images/backgrounds/background/bg_79.gif');
}
&.card-background-80 {
background-image: url('@/assets/images/backgrounds/background/bg_80.gif');
}
&.card-background-81 {
background-image: url('@/assets/images/backgrounds/background/bg_81.gif');
}
&.card-background-82 {
background-image: url('@/assets/images/backgrounds/background/bg_82.gif');
}
&.card-background-83 {
background-image: url('@/assets/images/backgrounds/background/bg_83.gif');
}
&.card-background-84 {
background-image: url('@/assets/images/backgrounds/background/bg_84.gif');
}
&.card-background-85 {
background-image: url('@/assets/images/backgrounds/background/bg_85.gif');
}
&.card-background-86 {
background-image: url('@/assets/images/backgrounds/background/bg_86.png');
}
&.card-background-87 {
background-image: url('@/assets/images/backgrounds/background/bg_87.gif');
}
&.card-background-88 {
background-image: url('@/assets/images/backgrounds/background/bg_88.gif');
}
&.card-background-89 {
background-image: url('@/assets/images/backgrounds/background/bg_89.gif');
}
&.card-background-90 {
background-image: url('@/assets/images/backgrounds/background/bg_90.gif');
}
&.card-background-91 {
background-image: url('@/assets/images/backgrounds/background/bg_91.gif');
}
&.card-background-92 {
background-image: url('@/assets/images/backgrounds/background/bg_92.gif');
}
&.card-background-93 {
background-image: url('@/assets/images/backgrounds/background/bg_93.gif');
}
&.card-background-94 {
background-image: url('@/assets/images/backgrounds/background/bg_94.gif');
}
&.card-background-95 {
background-image: url('@/assets/images/backgrounds/background/bg_95.gif');
}
&.card-background-96 {
background-image: url('@/assets/images/backgrounds/background/bg_96.gif');
}
&.card-background-97 {
background-image: url('@/assets/images/backgrounds/background/bg_97.gif');
}
&.card-background-98 {
background-image: url('@/assets/images/backgrounds/background/bg_98.gif');
}
&.card-background-99 {
background-image: url('@/assets/images/backgrounds/background/bg_99.gif');
}
&.card-background-100 {
background-image: url('@/assets/images/backgrounds/background/bg_100.gif');
}
&.card-background-101 {
background-image: url('@/assets/images/backgrounds/background/bg_101.png');
}
&.card-background-102 {
background-image: url('@/assets/images/backgrounds/background/bg_102.gif');
}
&.card-background-103 {
background-image: url('@/assets/images/backgrounds/background/bg_103.gif');
}
&.card-background-104 {
background-image: url('@/assets/images/backgrounds/background/bg_104.gif');
}
&.card-background-105 {
background-image: url('@/assets/images/backgrounds/background/bg_105.gif');
}
&.card-background-106 {
background-image: url('@/assets/images/backgrounds/background/bg_106.gif');
}
&.card-background-107 {
background-image: url('@/assets/images/backgrounds/background/bg_107.gif');
}
&.card-background-108 {
background-image: url('@/assets/images/backgrounds/background/bg_108.gif');
}
&.card-background-109 {
background-image: url('@/assets/images/backgrounds/background/bg_109.gif');
}
&.card-background-110 {
background-image: url('@/assets/images/backgrounds/background/bg_110.gif');
}
&.card-background-111 {
background-image: url('@/assets/images/backgrounds/background/bg_111.gif');
}
&.card-background-112 {
background-image: url('@/assets/images/backgrounds/background/bg_112.gif');
}
&.card-background-113 {
background-image: url('@/assets/images/backgrounds/background/bg_113.gif');
}
&.card-background-114 {
background-image: url('@/assets/images/backgrounds/background/bg_114.gif');
}
&.card-background-115 {
background-image: url('@/assets/images/backgrounds/background/bg_115.gif');
}
&.card-background-116 {
background-image: url('@/assets/images/backgrounds/background/bg_116.gif');
}
&.card-background-117 {
background-image: url('@/assets/images/backgrounds/background/bg_117.gif');
}
&.card-background-118 {
background-image: url('@/assets/images/backgrounds/background/bg_118.gif');
}
&.card-background-119 {
background-image: url('@/assets/images/backgrounds/background/bg_119.gif');
}
&.card-background-120 {
background-image: url('@/assets/images/backgrounds/background/bg_120.gif');
}
&.card-background-121 {
background-image: url('@/assets/images/backgrounds/background/bg_121.gif');
}
&.card-background-122 {
background-image: url('@/assets/images/backgrounds/background/bg_122.gif');
}
&.card-background-123 {
background-image: url('@/assets/images/backgrounds/background/bg_123.gif');
}
&.card-background-124 {
background-image: url('@/assets/images/backgrounds/background/bg_124.gif');
}
&.card-background-125 {
background-image: url('@/assets/images/backgrounds/background/bg_125.gif');
}
&.card-background-126 {
background-image: url('@/assets/images/backgrounds/background/bg_126.gif');
}
&.card-background-127 {
background-image: url('@/assets/images/backgrounds/background/bg_127.gif');
}
&.card-background-128 {
background-image: url('@/assets/images/backgrounds/background/bg_128.gif');
}
&.card-background-129 {
background-image: url('@/assets/images/backgrounds/background/bg_129.gif');
}
&.card-background-130 {
background-image: url('@/assets/images/backgrounds/background/bg_130.gif');
}
&.card-background-131 {
background-image: url('@/assets/images/backgrounds/background/bg_131.gif');
}
&.card-background-132 {
background-image: url('@/assets/images/backgrounds/background/bg_132.gif');
}
&.card-background-133 {
background-image: url('@/assets/images/backgrounds/background/bg_133.gif');
}
&.card-background-134 {
background-image: url('@/assets/images/backgrounds/background/bg_134.gif');
}
&.card-background-135 {
background-image: url('@/assets/images/backgrounds/background/bg_135.gif');
}
&.card-background-136 {
background-image: url('@/assets/images/backgrounds/background/bg_136.gif');
}
&.card-background-137 {
background-image: url('@/assets/images/backgrounds/background/bg_137.gif');
}
&.card-background-138 {
background-image: url('@/assets/images/backgrounds/background/bg_138.gif');
}
&.card-background-139 {
background-image: url('@/assets/images/backgrounds/background/bg_139.gif');
}
&.card-background-140 {
background-image: url('@/assets/images/backgrounds/background/bg_140.gif');
}
&.card-background-141 {
background-image: url('@/assets/images/backgrounds/background/bg_141.gif');
}
&.card-background-142 {
background-image: url('@/assets/images/backgrounds/background/bg_142.gif');
}
&.card-background-143 {
background-image: url('@/assets/images/backgrounds/background/bg_143.gif');
}
&.card-background-144 {
background-image: url('@/assets/images/backgrounds/background/bg_144.gif');
}
&.card-background-145 {
background-image: url('@/assets/images/backgrounds/background/bg_145.gif');
}
&.card-background-146 {
background-image: url('@/assets/images/backgrounds/background/bg_146.gif');
}
&.card-background-147 {
background-image: url('@/assets/images/backgrounds/background/bg_147.gif');
}
&.card-background-148 {
background-image: url('@/assets/images/backgrounds/background/bg_148.gif');
}
&.card-background-149 {
background-image: url('@/assets/images/backgrounds/background/bg_149.gif');
}
&.card-background-150 {
background-image: url('@/assets/images/backgrounds/background/bg_150.gif');
}
&.card-background-151 {
background-image: url('@/assets/images/backgrounds/background/bg_151.gif');
}
&.card-background-152 {
background-image: url('@/assets/images/backgrounds/background/bg_152.gif');
}
&.card-background-153 {
background-image: url('@/assets/images/backgrounds/background/bg_153.gif');
}
&.card-background-154 {
background-image: url('@/assets/images/backgrounds/background/bg_154.gif');
}
&.card-background-155 {
background-image: url('@/assets/images/backgrounds/background/bg_155.gif');
}
&.card-background-156 {
background-image: url('@/assets/images/backgrounds/background/bg_156.gif');
}
&.card-background-157 {
background-image: url('@/assets/images/backgrounds/background/bg_157.gif');
}
&.card-background-158 {
background-image: url('@/assets/images/backgrounds/background/bg_158.gif');
}
&.card-background-159 {
background-image: url('@/assets/images/backgrounds/background/bg_159.gif');
}
&.card-background-160 {
background-image: url('@/assets/images/backgrounds/background/bg_160.gif');
}
&.card-background-161 {
background-image: url('@/assets/images/backgrounds/background/bg_161.gif');
}
&.card-background-162 {
background-image: url('@/assets/images/backgrounds/background/bg_162.gif');
}
&.card-background-163 {
background-image: url('@/assets/images/backgrounds/background/bg_163.gif');
}
&.card-background-164 {
background-image: url('@/assets/images/backgrounds/background/bg_164.gif');
}
&.card-background-165 {
background-image: url('@/assets/images/backgrounds/background/bg_165.gif');
}
&.card-background-166 {
background-image: url('@/assets/images/backgrounds/background/bg_166.gif');
}
&.card-background-167 {
background-image: url('@/assets/images/backgrounds/background/bg_167.gif');
}
&.card-background-168 {
background-image: url('@/assets/images/backgrounds/background/bg_168.gif');
}
&.card-background-169 {
background-image: url('@/assets/images/backgrounds/background/bg_169.gif');
}
&.card-background-170 {
background-image: url('@/assets/images/backgrounds/background/bg_170.png');
}
&.card-background-171 {
background-image: url('@/assets/images/backgrounds/background/bg_171.png');
}
&.card-background-172 {
background-image: url('@/assets/images/backgrounds/background/bg_172.png');
}
&.card-background-173 {
background-image: url('@/assets/images/backgrounds/background/bg_173.png');
}
&.card-background-174 {
background-image: url('@/assets/images/backgrounds/background/bg_174.png');
}
&.card-background-175 {
background-image: url('@/assets/images/backgrounds/background/bg_175.png');
}
&.card-background-176 {
background-image: url('@/assets/images/backgrounds/background/bg_176.png');
}
&.card-background-177 {
background-image: url('@/assets/images/backgrounds/background/bg_177.gif');
}
&.card-background-178 {
background-image: url('@/assets/images/backgrounds/background/bg_178.png');
}
&.card-background-179 {
background-image: url('@/assets/images/backgrounds/background/bg_179.png');
}
&.card-background-180 {
background-image: url('@/assets/images/backgrounds/background/bg_180.png');
}
&.card-background-181 {
background-image: url('@/assets/images/backgrounds/background/bg_181.png');
}
&.card-background-182 {
background-image: url('@/assets/images/backgrounds/background/bg_182.png');
}
&.card-background-183 {
background-image: url('@/assets/images/backgrounds/background/bg_183.png');
}
&.card-background-184 {
background-image: url('@/assets/images/backgrounds/background/bg_184.png');
}
&.card-background-185 {
background-image: url('@/assets/images/backgrounds/background/bg_185.png');
}
&.card-background-186 {
background-image: url('@/assets/images/backgrounds/background/bg_186.png');
}
&.card-background-187 {
background-image: url('@/assets/images/backgrounds/background/bg_187.gif');
}
}
.profile-card-background.card-background-1 {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
background-repeat: no-repeat;
background-size: cover;
}
.profile-card-background.card-background-2 {
background: linear-gradient(135deg, #4ecdc4 0%, #44a8a3 100%);
background-repeat: no-repeat;
background-size: cover;
}
.profile-card-background.card-background-3 {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
background-repeat: no-repeat;
background-size: cover;
}
.profile-card-background.card-background-4 {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
background-repeat: no-repeat;
background-size: cover;
}
.profile-card-background.card-background-5 {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
background-repeat: no-repeat;
background-size: cover;
}
.profile-card-background.card-background-6 {
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
background-repeat: no-repeat;
background-size: cover;
}
.profile-card-background.card-background-7 {
background: linear-gradient(135deg, #5ee7df 0%, #b490ca 100%);
background-repeat: no-repeat;
background-size: cover;
}
.profile-card-background.card-background-8 {
background: linear-gradient(135deg, #243949 0%, #517fa4 100%);
background-repeat: no-repeat;
background-size: cover;
}
.profile-card-background.card-background-9 {
background-image: repeating-linear-gradient(45deg, #ff6b9d 0 10px, #c06c84 10px 20px);
background-color: #c06c84;
background-size: auto;
}
.profile-card-background.card-background-10 {
background-image: repeating-linear-gradient(90deg, #2b5876 0 8px, #4e4376 8px 16px);
background-color: #2b5876;
background-size: auto;
}
.profile-card-background.card-background-11 {
background-image: radial-gradient(circle, #ffd54f 1.5px, transparent 2px);
background-color: #2c3e50;
background-size: 12px 12px;
background-repeat: repeat;
}
.profile-card-background.card-background-12 {
background-image: linear-gradient(45deg, #1a1a2e 25%, transparent 25%), linear-gradient(-45deg, #1a1a2e 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #1a1a2e 75%), linear-gradient(-45deg, transparent 75%, #1a1a2e 75%);
background-color: #16213e;
background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
background-repeat: repeat;
}
.profile-card-background.card-background-13 {
background: linear-gradient(135deg, #232526 0%, #414345 100%);
background-repeat: no-repeat;
background-size: cover;
}
.profile-card-background.card-background-14 {
background: linear-gradient(135deg, #56ab2f 0%, #a8e063 100%);
background-repeat: no-repeat;
background-size: cover;
}
.profile-card-background.card-background-15 {
background-image: linear-gradient(0deg, transparent 49%, rgba(255,255,255,0.08) 49% 51%, transparent 51%), linear-gradient(90deg, transparent 49%, rgba(255,255,255,0.08) 49% 51%, transparent 51%);
background-color: #1a1a2e;
background-size: 24px 24px;
background-repeat: repeat;
}
.profile-background {
background-repeat: no-repeat;
background-position: center;
+9 -4
View File
@@ -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;