diff --git a/src/api/avatar/AvatarEditorThumbnailsHelper.ts b/src/api/avatar/AvatarEditorThumbnailsHelper.ts index f9ce18f..bffc418 100644 --- a/src/api/avatar/AvatarEditorThumbnailsHelper.ts +++ b/src/api/avatar/AvatarEditorThumbnailsHelper.ts @@ -109,22 +109,6 @@ export class AvatarEditorThumbnailsHelper container.addChild(sprite); } - if(container.children.length > 0) - { - const isPetPart = parts.some(p => p.type === 'pt' || p.type === 'ptl' || p.type === 'ptr'); - - if(isPetPart) - { - const bounds = container.getBounds(); - - for(const child of container.children) - { - (child as NitroSprite).position.x -= bounds.x; - (child as NitroSprite).position.y -= bounds.y; - } - } - } - return container; }; @@ -133,9 +117,9 @@ export class AvatarEditorThumbnailsHelper const resetFigure = async (figure: string) => { const container = buildContainer(part, useColors, partColors, isDisabled); - const imageUrl = await TextureUtils.generateImageUrl(container); + const imageUrl = await TextureUtils.generateImageUrl({ target: container, resolution: 1 }); - AvatarEditorThumbnailsHelper.THUMBNAIL_CACHE.set(thumbnailKey, imageUrl); + if(imageUrl) AvatarEditorThumbnailsHelper.THUMBNAIL_CACHE.set(thumbnailKey, imageUrl); resolve(imageUrl); }; diff --git a/src/api/groups/IGroupData.ts b/src/api/groups/IGroupData.ts index bb65b49..b5d43b3 100644 --- a/src/api/groups/IGroupData.ts +++ b/src/api/groups/IGroupData.ts @@ -8,6 +8,7 @@ export interface IGroupData groupHomeroomId: number; groupState: number; groupCanMembersDecorate: boolean; + groupHasForum: boolean; groupColors: number[]; groupBadgeParts: GroupBadgePart[]; } diff --git a/src/assets/images/toolbar/icons/furnieditor.png b/src/assets/images/toolbar/icons/furnieditor.png new file mode 100644 index 0000000..df621f3 Binary files /dev/null and b/src/assets/images/toolbar/icons/furnieditor.png differ diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index 4a39a59..799654f 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -13,6 +13,7 @@ import { FurniEditorView } from './furni-editor/FurniEditorView'; import { FriendsView } from './friends/FriendsView'; import { GameCenterView } from './game-center/GameCenterView'; import { GroupsView } from './groups/GroupsView'; +import { GroupForumView } from './groups/views/forums/GroupForumView'; import { GuideToolView } from './guide-tool/GuideToolView'; import { HcCenterView } from './hc-center/HcCenterView'; import { HelpView } from './help/HelpView'; @@ -113,6 +114,7 @@ export const MainView: FC<{}> = props => + diff --git a/src/components/avatar-editor/AvatarEditorIcon.tsx b/src/components/avatar-editor/AvatarEditorIcon.tsx index f5623ed..e992373 100644 --- a/src/components/avatar-editor/AvatarEditorIcon.tsx +++ b/src/components/avatar-editor/AvatarEditorIcon.tsx @@ -1,45 +1,81 @@ import { DetailedHTMLProps, HTMLAttributes, PropsWithChildren, forwardRef } from 'react'; import { classNames } from '../../layout'; -type AvatarIconType = 'male' | 'female' | 'clear' | 'sellable'; +import arrowLeftIcon from '../../assets/images/avatareditor/arrow-left-icon.png'; +import arrowRightIcon from '../../assets/images/avatareditor/arrow-right-icon.png'; +import caIcon from '../../assets/images/avatareditor/ca-icon.png'; +import caSelectedIcon from '../../assets/images/avatareditor/ca-selected-icon.png'; +import ccIcon from '../../assets/images/avatareditor/cc-icon.png'; +import ccSelectedIcon from '../../assets/images/avatareditor/cc-selected-icon.png'; +import chIcon from '../../assets/images/avatareditor/ch-icon.png'; +import chSelectedIcon from '../../assets/images/avatareditor/ch-selected-icon.png'; +import clearIcon from '../../assets/images/avatareditor/clear-icon.png'; +import cpIcon from '../../assets/images/avatareditor/cp-icon.png'; +import cpSelectedIcon from '../../assets/images/avatareditor/cp-selected-icon.png'; +import eaIcon from '../../assets/images/avatareditor/ea-icon.png'; +import eaSelectedIcon from '../../assets/images/avatareditor/ea-selected-icon.png'; +import faIcon from '../../assets/images/avatareditor/fa-icon.png'; +import faSelectedIcon from '../../assets/images/avatareditor/fa-selected-icon.png'; +import femaleIcon from '../../assets/images/avatareditor/female-icon.png'; +import femaleSelectedIcon from '../../assets/images/avatareditor/female-selected-icon.png'; +import haIcon from '../../assets/images/avatareditor/ha-icon.png'; +import haSelectedIcon from '../../assets/images/avatareditor/ha-selected-icon.png'; +import heIcon from '../../assets/images/avatareditor/he-icon.png'; +import heSelectedIcon from '../../assets/images/avatareditor/he-selected-icon.png'; +import hrIcon from '../../assets/images/avatareditor/hr-icon.png'; +import hrSelectedIcon from '../../assets/images/avatareditor/hr-selected-icon.png'; +import lgIcon from '../../assets/images/avatareditor/lg-icon.png'; +import lgSelectedIcon from '../../assets/images/avatareditor/lg-selected-icon.png'; +import maleIcon from '../../assets/images/avatareditor/male-icon.png'; +import maleSelectedIcon from '../../assets/images/avatareditor/male-selected-icon.png'; +import sellableIcon from '../../assets/images/avatareditor/sellable-icon.png'; +import shIcon from '../../assets/images/avatareditor/sh-icon.png'; +import shSelectedIcon from '../../assets/images/avatareditor/sh-selected-icon.png'; +import waIcon from '../../assets/images/avatareditor/wa-icon.png'; +import waSelectedIcon from '../../assets/images/avatareditor/wa-selected-icon.png'; + +const ICON_MAP: Record = { + 'arrow-left': { normal: arrowLeftIcon }, + 'arrow-right': { normal: arrowRightIcon }, + 'ca': { normal: caIcon, selected: caSelectedIcon }, + 'cc': { normal: ccIcon, selected: ccSelectedIcon }, + 'ch': { normal: chIcon, selected: chSelectedIcon }, + 'clear': { normal: clearIcon }, + 'cp': { normal: cpIcon, selected: cpSelectedIcon }, + 'ea': { normal: eaIcon, selected: eaSelectedIcon }, + 'fa': { normal: faIcon, selected: faSelectedIcon }, + 'female': { normal: femaleIcon, selected: femaleSelectedIcon }, + 'ha': { normal: haIcon, selected: haSelectedIcon }, + 'he': { normal: heIcon, selected: heSelectedIcon }, + 'hr': { normal: hrIcon, selected: hrSelectedIcon }, + 'lg': { normal: lgIcon, selected: lgSelectedIcon }, + 'male': { normal: maleIcon, selected: maleSelectedIcon }, + 'sellable': { normal: sellableIcon }, + 'sh': { normal: shIcon, selected: shSelectedIcon }, + 'wa': { normal: waIcon, selected: waSelectedIcon }, +}; export const AvatarEditorIcon = forwardRef & DetailedHTMLProps, HTMLDivElement>>((props, ref) => { - const { icon = null, selected = false, className = null, ...rest } = props; + const { icon = null, selected = false, className = null, children, ...rest } = props; - /* - switch (icon) - { - case 'male': + const iconEntry = icon ? ICON_MAP[icon] : null; + if(!iconEntry) return null; - break; + const src = (selected && iconEntry.selected) ? iconEntry.selected : iconEntry.normal; - case 'arrow-left': - - break; - - default: - //statements; - break; - - } -*/ return (
+ className={ classNames('flex items-center justify-center cursor-pointer', className) } + { ...rest }> + { + { children } +
); }); diff --git a/src/components/avatar-editor/figure-set/AvatarEditorFigureSetItemView.tsx b/src/components/avatar-editor/figure-set/AvatarEditorFigureSetItemView.tsx index a08321d..789dc74 100644 --- a/src/components/avatar-editor/figure-set/AvatarEditorFigureSetItemView.tsx +++ b/src/components/avatar-editor/figure-set/AvatarEditorFigureSetItemView.tsx @@ -1,4 +1,3 @@ -import { AvatarEditorFigureCategory, AvatarFigurePartType } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; import { AvatarEditorThumbnailsHelper, GetClubMemberLevel, GetConfigurationValue, IAvatarEditorCategoryPartItem } from '../../../api'; import { LayoutCurrencyIcon, LayoutGridItemProps } from '../../../common'; @@ -15,7 +14,7 @@ export const AvatarEditorFigureSetItemView: FC<{ { const { setType = null, partItem = null, isSelected = false, width = '100%', ...rest } = props; const [ assetUrl, setAssetUrl ] = useState(''); - const { activeModelKey = '', selectedColorParts = null, getFigureStringWithFace = null } = useAvatarEditor(); + const { selectedColorParts = null, getFigureStringWithFace = null } = useAvatarEditor(); const clubLevel = partItem.partSet?.clubLevel ?? 0; const isHC = !GetConfigurationValue('hc.disabled', false) && (clubLevel > 0); @@ -24,7 +23,9 @@ export const AvatarEditorFigureSetItemView: FC<{ useEffect(() => { - if(!setType || !setType.length || !partItem) return; + setAssetUrl(''); + + if(!setType || !setType.length || !partItem || partItem.isClear) return; const loadImage = async () => { @@ -34,7 +35,7 @@ export const AvatarEditorFigureSetItemView: FC<{ let url: string = null; - if(setType === AvatarFigurePartType.HEAD && activeModelKey !== AvatarEditorFigureCategory.NFT) + if(setType === 'hd') { url = await AvatarEditorThumbnailsHelper.buildForFace(getFigureStringWithFace(partItem.id), partIsLocked || isSellableNotOwned); } @@ -53,12 +54,27 @@ export const AvatarEditorFigureSetItemView: FC<{ }; loadImage(); - }, [ setType, partItem, selectedColorParts, getFigureStringWithFace, isSellableNotOwned, activeModelKey ]); + }, [ setType, partItem, selectedColorParts, getFigureStringWithFace, isSellableNotOwned ]); if(!partItem) return null; + const isHead = (setType === 'hd'); + return ( - + + { !partItem.isClear && assetUrl && !isHead && + } { !partItem.isClear && isHC && } { partItem.isClear && } { !partItem.isClear && partItem.partSet.isSellable && !isSellableNotOwned && } diff --git a/src/components/avatar-editor/figure-set/AvatarEditorFigureSetView.tsx b/src/components/avatar-editor/figure-set/AvatarEditorFigureSetView.tsx index 7d7bd68..179f894 100644 --- a/src/components/avatar-editor/figure-set/AvatarEditorFigureSetView.tsx +++ b/src/components/avatar-editor/figure-set/AvatarEditorFigureSetView.tsx @@ -7,9 +7,10 @@ import { AvatarEditorFigureSetItemView } from './AvatarEditorFigureSetItemView'; export const AvatarEditorFigureSetView: FC<{ category: IAvatarEditorCategory; columnCount: number; + estimateSize?: number; }> = props => { - const { category = null, columnCount = 3 } = props; + const { category = null, columnCount = 3, estimateSize = 50 } = props; const { selectedParts = null, selectEditorPart } = useAvatarEditor(); const isPartItemSelected = (partItem: IAvatarEditorCategoryPartItem) => @@ -29,7 +30,7 @@ export const AvatarEditorFigureSetView: FC<{ }; return ( - columnCount={ columnCount } estimateSize={ 50 } itemRender={ (item: IAvatarEditorCategoryPartItem) => + columnCount={ columnCount } estimateSize={ estimateSize } itemRender={ (item: IAvatarEditorCategoryPartItem) => { if(!item) return null; diff --git a/src/components/catalog/CatalogModernView.tsx b/src/components/catalog/CatalogModernView.tsx index ec19b70..95368b0 100644 --- a/src/components/catalog/CatalogModernView.tsx +++ b/src/components/catalog/CatalogModernView.tsx @@ -111,7 +111,7 @@ const CatalogModernViewInner: FC<{}> = () => className={ `flex items-center gap-2 mx-1 px-1.5 py-1.5 rounded cursor-pointer transition-all duration-150 ${ showFavorites ? 'bg-primary text-white' : 'hover:bg-card-grid-item-active' }` } onClick={ () => setShowFavorites(!showFavorites) } > -
+
0 ? 'text-danger' : 'text-muted' }` } /> { totalFavs > 0 && @@ -163,7 +163,7 @@ const CatalogModernViewInner: FC<{}> = () => activateNode(child); } } > -
+
{ isHidden && }
diff --git a/src/components/catalog/views/catalog-icon/CatalogIconView.tsx b/src/components/catalog/views/catalog-icon/CatalogIconView.tsx index 0178662..4e5ed5a 100644 --- a/src/components/catalog/views/catalog-icon/CatalogIconView.tsx +++ b/src/components/catalog/views/catalog-icon/CatalogIconView.tsx @@ -1,20 +1,20 @@ import { FC, useMemo } from 'react'; import { GetConfigurationValue } from '../../../../api'; -import { LayoutImage } from '../../../../common'; export interface CatalogIconViewProps { icon: number; + className?: string; } export const CatalogIconView: FC = props => { - const { icon = 0 } = props; + const { icon = 0, className = '' } = props; - const getIconUrl = useMemo(() => + const iconUrl = useMemo(() => { return ((GetConfigurationValue('catalog.asset.icon.url')).replace('%name%', icon.toString())); }, [ icon ]); - return ; + return ; }; diff --git a/src/components/catalog/views/catalog-rail/CatalogRailItemView.tsx b/src/components/catalog/views/catalog-rail/CatalogRailItemView.tsx index 3b1cd21..4d1e38e 100644 --- a/src/components/catalog/views/catalog-rail/CatalogRailItemView.tsx +++ b/src/components/catalog/views/catalog-rail/CatalogRailItemView.tsx @@ -19,8 +19,8 @@ export const CatalogRailItemView: FC = props => title={ node.localization } onClick={ onClick } > -
- +
+
{ node.localization } diff --git a/src/components/catalog/views/favorites/CatalogFavoritesView.tsx b/src/components/catalog/views/favorites/CatalogFavoritesView.tsx index 676f771..77b02e6 100644 --- a/src/components/catalog/views/favorites/CatalogFavoritesView.tsx +++ b/src/components/catalog/views/favorites/CatalogFavoritesView.tsx @@ -121,7 +121,7 @@ export const CatalogFavoritesView: FC = props => onClick={ () => { openPageByOfferId(fav.offerId); onClose(); } } > { /* Furni icon */ } -
+
{ fav.iconUrl ? : fav.nodeIconId !== null diff --git a/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx b/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx index d31d801..f897cad 100644 --- a/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx +++ b/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx @@ -86,7 +86,7 @@ export const CatalogNavigationItemView: FC = pro > { adminMode && } -
+
{ node.localization } diff --git a/src/components/furni-editor/FurniEditorView.tsx b/src/components/furni-editor/FurniEditorView.tsx index a013554..428035a 100644 --- a/src/components/furni-editor/FurniEditorView.tsx +++ b/src/components/furni-editor/FurniEditorView.tsx @@ -1,5 +1,6 @@ import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; import { FC, useCallback, useEffect, useState } from 'react'; +import { GetSessionDataManager } from '../../api'; import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common'; import { useFurniEditor } from '../../hooks/furni-editor'; import { FurniEditorEditView } from './views/FurniEditorEditView'; @@ -15,13 +16,23 @@ export const FurniEditorView: FC<{}> = () => const { items, total, page, loading, error, clearError, - selectedItem, catalogItems, furniDataEntry, + selectedItem, setSelectedItem, furniDataEntry, interactions, searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions } = useFurniEditor(); + const isMod = GetSessionDataManager()?.isModerator; + + // Auto-switch to edit tab when an item is selected useEffect(() => { + if(selectedItem) setActiveTab(TAB_EDIT); + }, [ selectedItem ]); + + useEffect(() => + { + if(!isMod) return; + const linkTracker: ILinkEventTracker = { linkReceived: (url: string) => { @@ -48,47 +59,62 @@ export const FurniEditorView: FC<{}> = () => AddLinkEventTracker(linkTracker); return () => RemoveLinkEventTracker(linkTracker); - }, []); + }, [ isMod ]); useEffect(() => { if(isVisible) loadInteractions(); }, [ isVisible ]); + // Escape to close useEffect(() => { - const handler = async (e: CustomEvent<{ spriteId: number }>) => + if(!isVisible) return; + + const handler = (e: KeyboardEvent) => + { + if(e.key === 'Escape') setIsVisible(false); + }; + + window.addEventListener('keydown', handler); + + return () => window.removeEventListener('keydown', handler); + }, [ isVisible ]); + + useEffect(() => + { + if(!isMod) return; + + const handler = (e: CustomEvent<{ spriteId: number }>) => { const { spriteId } = e.detail; - const ok = await loadBySpriteId(spriteId); - - if(ok) setActiveTab(TAB_EDIT); + setIsVisible(true); + loadBySpriteId(spriteId); }; window.addEventListener('furni-editor:open', handler as EventListener); return () => window.removeEventListener('furni-editor:open', handler as EventListener); - }, [ loadBySpriteId ]); + }, [ isMod, loadBySpriteId ]); - const handleSelect = useCallback(async (id: number) => + const handleSelect = useCallback((id: number) => { - const ok = await loadDetail(id); - - if(ok) setActiveTab(TAB_EDIT); + loadDetail(id); }, [ loadDetail ]); const handleBack = useCallback(() => { + setSelectedItem(null); setActiveTab(TAB_SEARCH); - }, []); + }, [ setSelectedItem ]); const handleClose = useCallback(() => { setIsVisible(false); }, []); - if(!isVisible) return null; + if(!isVisible || !isMod) return null; return ( @@ -123,14 +149,12 @@ export const FurniEditorView: FC<{}> = () => { activeTab === TAB_EDIT && selectedItem && } diff --git a/src/components/furni-editor/views/FurniEditorEditView.tsx b/src/components/furni-editor/views/FurniEditorEditView.tsx index dc648ef..0d9fe2d 100644 --- a/src/components/furni-editor/views/FurniEditorEditView.tsx +++ b/src/components/furni-editor/views/FurniEditorEditView.tsx @@ -1,23 +1,72 @@ -import { FC, useCallback, useEffect, useState } from 'react'; -import { Button, Column, Flex, Text } from '../../../common'; -import { CatalogRef, FurniDetail } from '../../../hooks/furni-editor'; +import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Button, Column, Flex, LayoutFurniIconImageView, Text } from '../../../common'; +import { FurniDetail } from '../../../hooks/furni-editor'; interface FurniEditorEditViewProps { item: FurniDetail; - catalogItems: CatalogRef[]; furniDataEntry: Record | null; interactions: string[]; loading: boolean; - onUpdate: (id: number, fields: Record) => Promise; - onDelete: (id: number) => Promise; + onUpdate: (id: number, fields: Record) => void; + onDelete: (id: number) => void; onBack: () => void; - onRefresh: (id: number) => void; } +const FIELD_TIPS: Record = { + stackHeight: 'Visual height when items are stacked on top of this furniture', + interactionType: 'Defines behavior when user interacts (e.g. default, gate, teleport, vendingmachine)', + customparams: 'Extra parameters for the interaction type (format depends on interaction)', + interactionModesCount: 'Number of visual states/animations this furniture has', +}; + +const PERM_GROUPS = [ + { label: 'Gameplay', keys: [ 'allowStack', 'allowWalk', 'allowSit', 'allowLay' ] }, + { label: 'Trading', keys: [ 'allowGift', 'allowTrade', 'allowRecycle', 'allowMarketplaceSell' ] }, + { label: 'Inventory', keys: [ 'allowInventoryStack' ] }, +]; + +interface SectionProps { title: string; children: React.ReactNode; defaultOpen?: boolean } + +const Section: FC = ({ title, children, defaultOpen = true }) => +{ + const [ open, setOpen ] = useState(defaultOpen); + + return ( +
+ + { open &&
{ children }
} +
+ ); +}; + +const Tip: FC<{ field: string }> = ({ field }) => +{ + const tip = FIELD_TIPS[field]; + + if(!tip) return null; + + return ( + + ? + + { tip } + + + ); +}; + export const FurniEditorEditView: FC = props => { - const { item, catalogItems, furniDataEntry, interactions, loading, onUpdate, onDelete, onBack, onRefresh } = props; + const { item, furniDataEntry, interactions, loading, onUpdate, onDelete, onBack } = props; + const saveRef = useRef<() => void>(null); const [ form, setForm ] = useState({ itemName: '', @@ -41,7 +90,7 @@ export const FurniEditorEditView: FC = props => customparams: '', }); - const [ confirmDelete, setConfirmDelete ] = useState(false); + const [ showDeleteDialog, setShowDeleteDialog ] = useState(false); useEffect(() => { @@ -69,7 +118,7 @@ export const FurniEditorEditView: FC = props => customparams: item.customparams || '', }); - setConfirmDelete(false); + setShowDeleteDialog(false); }, [ item ]); const setField = useCallback((key: string, value: unknown) => @@ -77,113 +126,183 @@ export const FurniEditorEditView: FC = props => setForm(prev => ({ ...prev, [key]: value })); }, []); - const handleSave = useCallback(async () => + const isDirty = useMemo(() => { - const ok = await onUpdate(item.id, form); + if(!item) return false; - if(ok) onRefresh(item.id); - }, [ item, form, onUpdate, onRefresh ]); + return form.itemName !== (item.itemName || '') || + form.publicName !== (item.publicName || '') || + form.spriteId !== (item.spriteId || 0) || + form.type !== (item.type || 's') || + form.width !== (item.width || 1) || + form.length !== (item.length || 1) || + form.stackHeight !== (item.stackHeight || 0) || + form.allowStack !== !!item.allowStack || + form.allowWalk !== !!item.allowWalk || + form.allowSit !== !!item.allowSit || + form.allowLay !== !!item.allowLay || + form.allowGift !== !!item.allowGift || + form.allowTrade !== !!item.allowTrade || + form.allowRecycle !== !!item.allowRecycle || + form.allowMarketplaceSell !== !!item.allowMarketplaceSell || + form.allowInventoryStack !== !!item.allowInventoryStack || + form.interactionType !== (item.interactionType || '') || + form.interactionModesCount !== (item.interactionModesCount || 0) || + form.customparams !== (item.customparams || ''); + }, [ form, item ]); - const handleDelete = useCallback(async () => + const validation = useMemo(() => { - if(!confirmDelete) return setConfirmDelete(true); + const errors: Record = {}; - const ok = await onDelete(item.id); + if(!form.itemName.trim()) errors.itemName = 'Required'; + if(!form.publicName.trim()) errors.publicName = 'Required'; + if(form.width < 1) errors.width = 'Min 1'; + if(form.length < 1) errors.length = 'Min 1'; + if(form.stackHeight < 0) errors.stackHeight = 'Min 0'; - if(ok) onBack(); - }, [ confirmDelete, item, onDelete, onBack ]); + return errors; + }, [ form ]); - const inputClass = 'form-control form-control-sm'; - const labelClass = 'text-[11px] font-bold text-[#333] mb-0'; + const isValid = useMemo(() => Object.keys(validation).length === 0, [ validation ]); + + const handleSave = useCallback(() => + { + if(!isValid) return; + + onUpdate(item.id, form); + }, [ item, form, isValid, onUpdate ]); + + // Expose save for keyboard shortcut + saveRef.current = handleSave; + + const handleBack = useCallback(() => + { + if(isDirty && !window.confirm('You have unsaved changes. Discard and go back?')) return; + + onBack(); + }, [ isDirty, onBack ]); + + const handleDeleteConfirm = useCallback(() => + { + onDelete(item.id); + setShowDeleteDialog(false); + }, [ item, onDelete ]); + + // Keyboard shortcuts + useEffect(() => + { + const handler = (e: KeyboardEvent) => + { + if(e.ctrlKey && e.key === 's') + { + e.preventDefault(); + saveRef.current?.(); + } + }; + + window.addEventListener('keydown', handler); + + return () => window.removeEventListener('keydown', handler); + }, []); + + const inputClass = (field?: string) => + `w-full px-2 py-1 text-xs leading-normal rounded-sm border border-[#ccc] min-h-[calc(1.5em+0.5rem+2px)] ${ field && validation[field] ? 'border-red-500 bg-red-50' : '' }`; + const labelClass = 'text-[11px] font-bold text-[#333] mb-0 flex items-center gap-0.5'; return ( - - - - - - - { item.id } - | - - - - { item.spriteId } + { /* Header */ } + + +
+ +
+ + + ID: { item.id } + | + Sprite: { item.spriteId } + + ({ item.usageCount } in use) - ({ item.usageCount } in use) + { isDirty && Unsaved changes }
- { /* Basic Info */ } -
- Basic Info +
- setField('itemName', e.target.value) } /> + setField('itemName', e.target.value) } /> + { validation.itemName && { validation.itemName } }
- setField('publicName', e.target.value) } /> + setField('publicName', e.target.value) } /> + { validation.publicName && { validation.publicName } }
- setField('spriteId', Number(e.target.value)) } /> + setField('spriteId', Number(e.target.value)) } />
- setField('type', e.target.value) }>
-
+ - { /* Dimensions */ } -
- Dimensions +
- setField('width', Number(e.target.value)) } /> + setField('width', Number(e.target.value)) } /> + { validation.width && { validation.width } }
- setField('length', Number(e.target.value)) } /> + setField('length', Number(e.target.value)) } /> + { validation.length && { validation.length } }
- - setField('stackHeight', Number(e.target.value)) } /> + + setField('stackHeight', Number(e.target.value)) } /> + { validation.stackHeight && { validation.stackHeight } }
-
+ - { /* Permissions */ } -
- Permissions -
- { [ 'allowStack', 'allowWalk', 'allowSit', 'allowLay', 'allowGift', 'allowTrade', 'allowRecycle', 'allowMarketplaceSell', 'allowInventoryStack' ].map(key => ( - +
+
+ { PERM_GROUPS.map(group => ( +
+ { group.label } +
+ { group.keys.map(key => ( + + )) } +
+
)) }
-
+ - { /* Interaction */ } -
- Interaction +
- - setField('interactionType', e.target.value) }> { interactions.map(i => ( @@ -191,35 +310,18 @@ export const FurniEditorEditView: FC = props =>
- - setField('interactionModesCount', Number(e.target.value)) } /> + + setField('interactionModesCount', Number(e.target.value)) } />
- - setField('customparams', e.target.value) } /> + + setField('customparams', e.target.value) } />
-
+ - { /* Catalog References */ } - { catalogItems.length > 0 && -
- Catalog ({ catalogItems.length }) -
- { catalogItems.map(ci => ( -
- { ci.catalogName } (page: { ci.pageName }) - { ci.costCredits }c + { ci.costPoints }p -
- )) } -
-
- } - - { /* FurniData.json Entry */ } { furniDataEntry && -
- FurniData.json +
{ Object.entries(furniDataEntry).map(([ key, value ]) => (
@@ -228,22 +330,42 @@ export const FurniEditorEditView: FC = props =>
)) }
-
+ } { /* Actions */ } - - + + + + Ctrl+S + + + { /* Delete Confirmation Dialog */ } + { showDeleteDialog && +
setShowDeleteDialog(false) }> +
e.stopPropagation() }> + Delete Item? + + Are you sure you want to delete { item.publicName || item.itemName } (ID: { item.id })? + This action cannot be undone. + + + + + +
+
+ } ); }; diff --git a/src/components/furni-editor/views/FurniEditorSearchView.tsx b/src/components/furni-editor/views/FurniEditorSearchView.tsx index 7b3f8c6..4fb9af5 100644 --- a/src/components/furni-editor/views/FurniEditorSearchView.tsx +++ b/src/components/furni-editor/views/FurniEditorSearchView.tsx @@ -1,5 +1,5 @@ -import { FC, useCallback, useEffect, useState } from 'react'; -import { Button, Column, Flex, Text } from '../../../common'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { Button, Column, Flex, LayoutFurniIconImageView, Text } from '../../../common'; import { FurniItem } from '../../../hooks/furni-editor'; interface FurniEditorSearchViewProps @@ -12,11 +12,23 @@ interface FurniEditorSearchViewProps onSelect: (id: number) => void; } +type SortField = 'id' | 'spriteId' | 'itemName' | 'publicName' | 'type' | 'interactionType'; +type SortDir = 'asc' | 'desc'; + +const SortArrow: FC<{ field: SortField; active: SortField; dir: SortDir }> = ({ field, active, dir }) => +{ + if(field !== active) return ; + + return { dir === 'asc' ? '▲' : '▼' }; +}; + export const FurniEditorSearchView: FC = props => { const { items, total, page, loading, onSearch, onSelect } = props; const [ query, setQuery ] = useState(''); const [ typeFilter, setTypeFilter ] = useState(''); + const [ sortField, setSortField ] = useState('id'); + const [ sortDir, setSortDir ] = useState('asc'); useEffect(() => { @@ -33,6 +45,45 @@ export const FurniEditorSearchView: FC = props => if(e.key === 'Enter') handleSearch(); }, [ handleSearch ]); + const handleSort = useCallback((field: SortField) => + { + setSortDir(prev => (sortField === field ? (prev === 'asc' ? 'desc' : 'asc') : 'asc')); + setSortField(field); + }, [ sortField ]); + + const handleTypeToggle = useCallback((type: string) => + { + setTypeFilter(prev => + { + const next = prev === type ? '' : type; + + onSearch(query, next, 1); + + return next; + }); + }, [ query, onSearch ]); + + const sortedItems = useMemo(() => + { + const sorted = [ ...items ]; + + sorted.sort((a, b) => + { + let va: string | number = a[sortField] ?? ''; + let vb: string | number = b[sortField] ?? ''; + + if(typeof va === 'string') va = va.toLowerCase(); + if(typeof vb === 'string') vb = vb.toLowerCase(); + + if(va < vb) return sortDir === 'asc' ? -1 : 1; + if(va > vb) return sortDir === 'asc' ? 1 : -1; + + return 0; + }); + + return sorted; + }, [ items, sortField, sortDir ]); + const totalPages = Math.ceil(total / 20); return ( @@ -42,55 +93,80 @@ export const FurniEditorSearchView: FC = props => Search setQuery(e.target.value) } onKeyDown={ handleKeyDown } /> - - Type - - + + { [ '', 's', 'i' ].map(t => ( + + )) } +
+ { total > 0 && + + { total } items found + + } + - - - - - - - + + + + + + + + - { items.map(item => ( + { sortedItems.map(item => ( onSelect(item.id) } > + - - + + @@ -99,7 +175,7 @@ export const FurniEditorSearchView: FC = props => )) } { items.length === 0 && !loading && - + } @@ -109,7 +185,7 @@ export const FurniEditorSearchView: FC = props => { totalPages > 1 && - { total } items - Page { page }/{ totalPages } + Page { page }/{ totalPages }
IDSpriteNamePublic NameTypeInteraction
handleSort('id') }> + ID + handleSort('spriteId') }> + Sprite + handleSort('itemName') }> + Name + handleSort('publicName') }> + Public Name + handleSort('type') }> + Type + handleSort('interactionType') }> + Interaction +
+ + { item.id } { item.spriteId }{ item.itemName }{ item.publicName }{ item.itemName }{ item.publicName } - + { item.type === 's' ? 'Floor' : 'Wall' }
No items foundNo items found