mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 15:36:18 +00:00
🆙 Small update to the Effect editor
This commit is contained in:
@@ -89,13 +89,13 @@ export const AvatarEditorModelView: FC<{
|
|||||||
<div className="flex-1 min-w-0 overflow-hidden avatar-editor-palette-set-view">
|
<div className="flex-1 min-w-0 overflow-hidden avatar-editor-palette-set-view">
|
||||||
{ advancedColorMode
|
{ advancedColorMode
|
||||||
? <AvatarEditorAdvancedColorView category={ activeCategory } paletteIndex={ 0 } />
|
? <AvatarEditorAdvancedColorView category={ activeCategory } paletteIndex={ 0 } />
|
||||||
: <AvatarEditorPaletteSetView category={ activeCategory } columnCount={ 14 } paletteIndex={ 0 } /> }
|
: <AvatarEditorPaletteSetView category={ activeCategory } columnCount={ maxPaletteCount === 2 ? 5 : 14 } paletteIndex={ 0 } /> }
|
||||||
</div> }
|
</div> }
|
||||||
{ (maxPaletteCount === 2) &&
|
{ (maxPaletteCount === 2) &&
|
||||||
<div className="flex-1 min-w-0 overflow-hidden avatar-editor-palette-set-view">
|
<div className="flex-1 min-w-0 overflow-hidden avatar-editor-palette-set-view">
|
||||||
{ advancedColorMode
|
{ advancedColorMode
|
||||||
? <AvatarEditorAdvancedColorView category={ activeCategory } paletteIndex={ 1 } />
|
? <AvatarEditorAdvancedColorView category={ activeCategory } paletteIndex={ 1 } />
|
||||||
: <AvatarEditorPaletteSetView category={ activeCategory } columnCount={ 14 } paletteIndex={ 1 } /> }
|
: <AvatarEditorPaletteSetView category={ activeCategory } columnCount={ 5 } paletteIndex={ 1 } /> }
|
||||||
</div> }
|
</div> }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export const AvatarEditorView: FC<{}> = props =>
|
|||||||
return (
|
return (
|
||||||
<NitroCardView
|
<NitroCardView
|
||||||
className={ `nitro-avatar-editor ${ isWardrobeOpen ? 'w-[880px]' : 'w-[600px]' } h-[460px]` }
|
className={ `nitro-avatar-editor ${ isWardrobeOpen ? 'w-[880px]' : 'w-[600px]' } h-[460px]` }
|
||||||
|
isResizable={ false }
|
||||||
uniqueKey="avatar-editor">
|
uniqueKey="avatar-editor">
|
||||||
<NitroCardHeaderView headerText={ LocalizeText('avatareditor.title') } onCloseClick={ event => setIsVisible(false) } />
|
<NitroCardHeaderView headerText={ LocalizeText('avatareditor.title') } onCloseClick={ event => setIsVisible(false) } />
|
||||||
<NitroCardTabsView classNames={ ['avatar-editor-tabs'] }>
|
<NitroCardTabsView classNames={ ['avatar-editor-tabs'] }>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AddLinkEventTracker, AvatarDirectionAngle, AvatarEffectActivatedComposer, GetConfiguration, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
import { AddLinkEventTracker, AvatarDirectionAngle, AvatarEffectActivatedComposer, GetConfiguration, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useCallback, useEffect, useState } from 'react';
|
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
|
import { FaChevronLeft, FaChevronRight, FaSearch } from 'react-icons/fa';
|
||||||
import { LocalizeText, SendMessageComposer } from '../../api';
|
import { LocalizeText, SendMessageComposer } from '../../api';
|
||||||
import { Button, Column, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
|
import { Button, Column, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
|
||||||
import { AvatarEffectPreviewView } from './AvatarEffectPreviewView';
|
import { AvatarEffectPreviewView } from './AvatarEffectPreviewView';
|
||||||
@@ -14,6 +14,7 @@ interface EffectMapEntry
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_DIRECTION = 4;
|
const DEFAULT_DIRECTION = 4;
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
export const AvatarEffectsView: FC<{}> = () =>
|
export const AvatarEffectsView: FC<{}> = () =>
|
||||||
{
|
{
|
||||||
@@ -22,6 +23,8 @@ export const AvatarEffectsView: FC<{}> = () =>
|
|||||||
const [ loadError, setLoadError ] = useState<string>(null);
|
const [ loadError, setLoadError ] = useState<string>(null);
|
||||||
const [ selectedId, setSelectedId ] = useState<number>(0);
|
const [ selectedId, setSelectedId ] = useState<number>(0);
|
||||||
const [ direction, setDirection ] = useState<number>(DEFAULT_DIRECTION);
|
const [ direction, setDirection ] = useState<number>(DEFAULT_DIRECTION);
|
||||||
|
const [ query, setQuery ] = useState<string>('');
|
||||||
|
const [ visibleCount, setVisibleCount ] = useState<number>(PAGE_SIZE);
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
@@ -107,48 +110,160 @@ export const AvatarEffectsView: FC<{}> = () =>
|
|||||||
|
|
||||||
const onClose = useCallback(() => setIsVisible(false), []);
|
const onClose = useCallback(() => setIsVisible(false), []);
|
||||||
|
|
||||||
|
const filteredEffects = useMemo(() =>
|
||||||
|
{
|
||||||
|
const trimmed = query.trim().toLowerCase();
|
||||||
|
if(!trimmed) return effects;
|
||||||
|
return effects.filter(e =>
|
||||||
|
e.id.toLowerCase().includes(trimmed) ||
|
||||||
|
e.lib.toLowerCase().includes(trimmed));
|
||||||
|
}, [ effects, query ]);
|
||||||
|
|
||||||
|
const onQueryChange = useCallback((event: ChangeEvent<HTMLInputElement>) =>
|
||||||
|
{
|
||||||
|
setQuery(event.target.value);
|
||||||
|
setVisibleCount(PAGE_SIZE);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const visibleEffects = filteredEffects.slice(0, visibleCount);
|
||||||
|
const hasMore = filteredEffects.length > visibleEffects.length;
|
||||||
|
const selectedEffect = selectedId ? effects.find(e => parseInt(e.id, 10) === selectedId) : null;
|
||||||
|
|
||||||
|
const selectedRowRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
const jumpToSelected = useCallback(() =>
|
||||||
|
{
|
||||||
|
if(!selectedId) return;
|
||||||
|
|
||||||
|
const indexInFiltered = filteredEffects.findIndex(e => parseInt(e.id, 10) === selectedId);
|
||||||
|
const indexInAll = effects.findIndex(e => parseInt(e.id, 10) === selectedId);
|
||||||
|
|
||||||
|
if(indexInFiltered === -1)
|
||||||
|
{
|
||||||
|
setQuery('');
|
||||||
|
if(indexInAll >= 0 && indexInAll >= visibleCount)
|
||||||
|
{
|
||||||
|
setVisibleCount(Math.ceil((indexInAll + 1) / PAGE_SIZE) * PAGE_SIZE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if(indexInFiltered >= visibleCount)
|
||||||
|
{
|
||||||
|
setVisibleCount(Math.ceil((indexInFiltered + 1) / PAGE_SIZE) * PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
{
|
||||||
|
selectedRowRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
});
|
||||||
|
}, [ selectedId, filteredEffects, effects, visibleCount ]);
|
||||||
|
|
||||||
if(!isVisible) return null;
|
if(!isVisible) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NitroCardView className="nitro-avatar-effects w-[620px] h-[460px]" uniqueKey="avatar-effects" theme="primary-slim">
|
<NitroCardView className="nitro-avatar-effects w-[640px] h-[480px]" isResizable={ false } uniqueKey="avatar-effects" theme="primary-slim">
|
||||||
<NitroCardHeaderView headerText={ LocalizeText('product.type.effect') || 'Avatar effect' } onCloseClick={ onClose } />
|
<NitroCardHeaderView headerText={ LocalizeText('product.type.effect') || 'Avatar effect' } onCloseClick={ onClose } />
|
||||||
<NitroCardContentView className="flex flex-row gap-3 text-black">
|
<NitroCardContentView className="flex flex-row gap-3 text-black">
|
||||||
<Column overflow="hidden" className="w-[220px] items-center justify-between">
|
<Column overflow="hidden" className="w-[220px] items-center justify-between">
|
||||||
<div className="figure-preview-container overflow-hidden relative w-full h-[280px] bg-black rounded-md">
|
<div className="figure-preview-container overflow-hidden relative w-full h-[280px] bg-gradient-to-b from-[#1a1a1a] to-black rounded-md shadow-inner">
|
||||||
<AvatarEffectPreviewView figure={ figure } gender={ gender } direction={ direction } effect={ selectedId } height={ 280 } zoom={ 2 } />
|
<AvatarEffectPreviewView figure={ figure } gender={ gender } direction={ direction } effect={ selectedId } height={ 280 } zoom={ 2 } />
|
||||||
<div className="arrow-container absolute bottom-2 left-0 right-0 flex justify-between px-3 z-10">
|
<div className="arrow-container absolute inset-y-0 left-0 right-0 flex items-center justify-between px-1 z-10 pointer-events-none">
|
||||||
<button type="button" className="text-white/80 hover:text-white drop-shadow-[1px_1px_0_rgba(0,0,0,0.8)]" onClick={ () => rotateFigure(1) }><FaChevronLeft /></button>
|
<button
|
||||||
<button type="button" className="text-white/80 hover:text-white drop-shadow-[1px_1px_0_rgba(0,0,0,0.8)]" onClick={ () => rotateFigure(-1) }><FaChevronRight /></button>
|
type="button"
|
||||||
|
className="pointer-events-auto flex items-center justify-center w-7 h-7 rounded-full bg-black/45 hover:bg-black/70 border border-white/15 text-white shadow-md backdrop-blur-sm transition-all hover:scale-110 active:scale-95"
|
||||||
|
onClick={ () => rotateFigure(1) }
|
||||||
|
aria-label="Rotate left"
|
||||||
|
>
|
||||||
|
<FaChevronLeft className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pointer-events-auto flex items-center justify-center w-7 h-7 rounded-full bg-black/45 hover:bg-black/70 border border-white/15 text-white shadow-md backdrop-blur-sm transition-all hover:scale-110 active:scale-95"
|
||||||
|
onClick={ () => rotateFigure(-1) }
|
||||||
|
aria-label="Rotate right"
|
||||||
|
>
|
||||||
|
<FaChevronRight className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{ selectedEffect &&
|
||||||
|
<div className="absolute top-2 left-2 right-2 bg-black/55 backdrop-blur-sm rounded px-2 py-1 text-white text-xs leading-tight">
|
||||||
|
<div className="font-mono opacity-70 text-[10px]">#{ parseInt(selectedEffect.id, 10) }</div>
|
||||||
|
<div className="font-semibold truncate">{ selectedEffect.lib }</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="success" disabled={ !selectedId } onClick={ applySelectedEffect } className="w-full mt-2">
|
<Button variant="success" disabled={ !selectedId } onClick={ applySelectedEffect } className="w-full mt-2">
|
||||||
{ LocalizeText('inventory.effects.activate') || 'Use' }
|
{ LocalizeText('inventory.effects.activate') || 'Use' }
|
||||||
</Button>
|
</Button>
|
||||||
</Column>
|
</Column>
|
||||||
<Column overflow="auto" className="flex-1 min-h-0">
|
<Column overflow="hidden" className="flex-1 min-h-0">
|
||||||
{ loadError && <div className="text-red-600 text-xs px-2 py-1">{ loadError }</div> }
|
<div className="relative">
|
||||||
{ !loadError && !effects.length && <div className="text-xs px-2 py-1 opacity-70">{ LocalizeText('generic.loading') || 'Loading…' }</div> }
|
<FaSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#888] text-sm pointer-events-none" />
|
||||||
{ !!effects.length &&
|
<input
|
||||||
<div className="grid grid-cols-3 gap-2 p-1">
|
type="text"
|
||||||
{ effects.map(effect =>
|
value={ query }
|
||||||
{
|
onChange={ onQueryChange }
|
||||||
const id = parseInt(effect.id, 10);
|
placeholder={ LocalizeText('generic.search') || 'Search by name or #number' }
|
||||||
const isSelected = (id === selectedId);
|
className="w-full pl-8 pr-3 py-1.5 text-sm border border-[#2a2a2a]/20 rounded-md bg-white outline-none transition-colors focus:border-[#3a78c4] focus:shadow-[0_0_0_2px_rgba(58,120,196,0.15)]"
|
||||||
return (
|
/>
|
||||||
<button
|
</div>
|
||||||
key={ effect.id }
|
<div className="flex items-center justify-between px-1 py-1 text-[11px] uppercase tracking-wide text-[#666] border-b border-[#2a2a2a]/10">
|
||||||
type="button"
|
<span>{ filteredEffects.length === effects.length ? `${ effects.length } effects` : `${ filteredEffects.length } of ${ effects.length }` }</span>
|
||||||
onClick={ () => setSelectedId(id) }
|
{ selectedId > 0 &&
|
||||||
className={ `flex flex-col items-center justify-end h-[88px] px-1 py-1 rounded border text-[10px] truncate w-full ${ isSelected ? 'border-[#3a78c4] bg-[#cfe1f5]' : 'border-[#2a2a2a]/15 bg-[#f3f3f3] hover:bg-[#e7eef7]' }` }
|
<button
|
||||||
title={ effect.lib }
|
type="button"
|
||||||
>
|
onClick={ jumpToSelected }
|
||||||
<span className="self-start opacity-60">#{ id }</span>
|
className="text-[#3a78c4] hover:text-[#2a5d9e] hover:underline normal-case font-semibold cursor-pointer"
|
||||||
<span className="truncate w-full text-center font-semibold">{ effect.lib }</span>
|
title="Jump to selected effect"
|
||||||
</button>
|
>
|
||||||
);
|
#{ selectedId } selected
|
||||||
}) }
|
</button> }
|
||||||
</div>
|
</div>
|
||||||
}
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
|
{ loadError && <div className="text-red-600 text-sm px-2 py-3">{ loadError }</div> }
|
||||||
|
{ !loadError && !effects.length && <div className="text-sm px-2 py-3 opacity-70">{ LocalizeText('generic.loading') || 'Loading…' }</div> }
|
||||||
|
{ !!effects.length && !filteredEffects.length &&
|
||||||
|
<div className="text-sm px-2 py-3 opacity-70 italic">{ LocalizeText('generic.search.noresults') || 'No effects match your search.' }</div>
|
||||||
|
}
|
||||||
|
{ !!visibleEffects.length &&
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{ visibleEffects.map((effect, index) =>
|
||||||
|
{
|
||||||
|
const id = parseInt(effect.id, 10);
|
||||||
|
const isSelected = (id === selectedId);
|
||||||
|
return (
|
||||||
|
<li key={ effect.id }>
|
||||||
|
<button
|
||||||
|
ref={ isSelected ? selectedRowRef : undefined }
|
||||||
|
type="button"
|
||||||
|
onClick={ () => setSelectedId(id) }
|
||||||
|
className={ `flex w-full items-center gap-3 px-3 py-1.5 text-sm border-l-[3px] transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'border-[#3a78c4] bg-[#cfe1f5] text-[#1d3a5e]'
|
||||||
|
: `border-transparent hover:bg-[#eef3f9] ${ index % 2 === 0 ? 'bg-white' : 'bg-[#fafafa]' }`
|
||||||
|
}` }
|
||||||
|
title={ effect.lib }
|
||||||
|
>
|
||||||
|
<span className={ `font-mono text-xs w-12 text-right shrink-0 ${ isSelected ? 'opacity-80' : 'opacity-50' }` }>#{ id }</span>
|
||||||
|
<span className="truncate font-semibold">{ effect.lib }</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}) }
|
||||||
|
{ hasMore &&
|
||||||
|
<li className="px-3 py-2 border-t border-[#2a2a2a]/10 mt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={ () => setVisibleCount(prev => prev + PAGE_SIZE) }
|
||||||
|
className="w-full text-sm font-semibold text-[#3a78c4] hover:text-[#2a5d9e] hover:bg-[#eef3f9] cursor-pointer py-1.5 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
{ LocalizeText('navigator.show.more') || 'See More' }
|
||||||
|
<span className="opacity-60 ml-1 font-normal">({ filteredEffects.length - visibleEffects.length } more)</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</Column>
|
</Column>
|
||||||
</NitroCardContentView>
|
</NitroCardContentView>
|
||||||
</NitroCardView>
|
</NitroCardView>
|
||||||
|
|||||||
Reference in New Issue
Block a user