mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
Merge remote-tracking branch 'upstream/main' into feature/pr-20260327
# Conflicts: # public/UITexts.example
This commit is contained in:
+15
-1
@@ -60,5 +60,19 @@
|
||||
"wiredfurni.params.action.sign.14": "Cartello 14",
|
||||
"wiredfurni.params.action.sign.15": "Cartello 15",
|
||||
"wiredfurni.params.action.sign.16": "Cartello 16",
|
||||
"wiredfurni.params.action.sign.17": "Cartello 17"
|
||||
"wiredfurni.params.action.sign.17": "Cartello 17",
|
||||
"groupforum.list.tab.most_active": "Meest active threads",
|
||||
"groupforum.list.tab.my_forums": "Mijn group forums",
|
||||
"groupforum.list.no_forums": "Er zijn geen forums",
|
||||
"groupforum.view.threads": "Aantal threads",
|
||||
"groupforum.thread.pin": "Pin hem vast",
|
||||
"groupforum.thread.unpin": "Unpin bericht",
|
||||
"groupforum.thread.lock": "Lock de thread",
|
||||
"groupforum.thread.unlock": "Unlock de thread",
|
||||
"groupforum.thread.hide": "Verberg thread",
|
||||
"groupforum.thread.restore": "Maak thread weer zichtbaar",
|
||||
"groupforum.thread.delete": "Verwijder thread + posts",
|
||||
"groupforum.message.hide": "Verberg bericht",
|
||||
"group.forum.enable.caption": "Enable / Disable Group forum",
|
||||
"group.forum.enable.help": "Als je de group forum disabled dan verwijderen ook alle posts!"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface IGroupData
|
||||
groupHomeroomId: number;
|
||||
groupState: number;
|
||||
groupCanMembersDecorate: boolean;
|
||||
groupHasForum: boolean;
|
||||
groupColors: number[];
|
||||
groupBadgeParts: GroupBadgePart[];
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 629 B |
@@ -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 =>
|
||||
<UserSettingsView />
|
||||
<UserProfileView />
|
||||
<GroupsView />
|
||||
<GroupForumView />
|
||||
<CameraWidgetView />
|
||||
<HelpView />
|
||||
<NitropediaView />
|
||||
|
||||
@@ -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<string, { normal: string; selected?: string }> = {
|
||||
'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<HTMLDivElement, PropsWithChildren<{
|
||||
icon: AvatarIconType;
|
||||
icon: string;
|
||||
selected?: boolean;
|
||||
}> & DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, 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 (
|
||||
<div
|
||||
ref={ ref }
|
||||
|
||||
className={ classNames(
|
||||
'nitro-avatar-editor-spritesheet',
|
||||
'cursor-pointer',
|
||||
`${ icon }-icon`,
|
||||
selected && 'selected',
|
||||
className
|
||||
) }
|
||||
{ ...rest } />
|
||||
className={ classNames('flex items-center justify-center cursor-pointer', className) }
|
||||
{ ...rest }>
|
||||
<img src={ src } alt={ icon } className="h-[22px] w-auto object-contain pointer-events-none" draggable={ false } />
|
||||
{ children }
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string>('');
|
||||
const { activeModelKey = '', selectedColorParts = null, getFigureStringWithFace = null } = useAvatarEditor();
|
||||
const { selectedColorParts = null, getFigureStringWithFace = null } = useAvatarEditor();
|
||||
|
||||
const clubLevel = partItem.partSet?.clubLevel ?? 0;
|
||||
const isHC = !GetConfigurationValue<boolean>('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 (
|
||||
<InfiniteGrid.Item itemActive={ isSelected } itemImage={ (partItem.isClear ? undefined : assetUrl) } className={ `avatar-parts mx-auto${ isSelected ? ' part-selected' : '' }${ !partItem.isClear && isSellableNotOwned ? ' pet-sellable-locked' : '' }` } style={ { backgroundPosition: (setType === AvatarFigurePartType.HEAD && activeModelKey !== AvatarEditorFigureCategory.NFT) ? 'center -35px' : 'center' } } { ...rest }>
|
||||
<InfiniteGrid.Item
|
||||
itemActive={ isSelected }
|
||||
itemImage={ (!partItem.isClear && isHead) ? assetUrl : undefined }
|
||||
className={ `avatar-parts mx-auto${ isSelected ? ' part-selected' : '' }${ !partItem.isClear && isSellableNotOwned ? ' pet-sellable-locked' : '' }` }
|
||||
style={ isHead ? { backgroundSize: '200%', backgroundPosition: 'center -32px' } : undefined }
|
||||
{ ...rest }
|
||||
>
|
||||
{ !partItem.isClear && assetUrl && !isHead &&
|
||||
<img
|
||||
src={ assetUrl }
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-contain pointer-events-none image-rendering-pixelated"
|
||||
draggable={ false }
|
||||
/> }
|
||||
{ !partItem.isClear && isHC && <LayoutCurrencyIcon className="absolute inset-e-1 bottom-1" type="hc" /> }
|
||||
{ partItem.isClear && <AvatarEditorIcon icon="clear" /> }
|
||||
{ !partItem.isClear && partItem.partSet.isSellable && !isSellableNotOwned && <AvatarEditorIcon className="inset-e-1 bottom-1 absolute" icon="sellable" /> }
|
||||
|
||||
@@ -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 (
|
||||
<InfiniteGrid<IAvatarEditorCategoryPartItem> columnCount={ columnCount } estimateSize={ 50 } itemRender={ (item: IAvatarEditorCategoryPartItem) =>
|
||||
<InfiniteGrid<IAvatarEditorCategoryPartItem> columnCount={ columnCount } estimateSize={ estimateSize } itemRender={ (item: IAvatarEditorCategoryPartItem) =>
|
||||
{
|
||||
if(!item) return null;
|
||||
|
||||
|
||||
@@ -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) }
|
||||
>
|
||||
<div className="w-[28px] h-[24px] flex items-center justify-center shrink-0 relative">
|
||||
<div className="w-7 h-6 flex items-center justify-center shrink-0 relative">
|
||||
<FaHeart className={ `text-xs ${ showFavorites ? 'text-white' : totalFavs > 0 ? 'text-danger' : 'text-muted' }` } />
|
||||
{ totalFavs > 0 &&
|
||||
<span className="absolute -top-1 -right-1 min-w-[14px] h-[14px] bg-danger text-white text-[8px] font-bold rounded-full flex items-center justify-center px-0.5 leading-none">
|
||||
@@ -163,7 +163,7 @@ const CatalogModernViewInner: FC<{}> = () =>
|
||||
activateNode(child);
|
||||
} }
|
||||
>
|
||||
<div className="w-[28px] h-[24px] flex items-center justify-center shrink-0 relative">
|
||||
<div className="w-7 h-6 flex items-center justify-center shrink-0 relative">
|
||||
<CatalogIconView icon={ child.iconId } />
|
||||
{ isHidden && <FaEyeSlash className="absolute -bottom-0.5 -right-0.5 text-[7px] text-danger" /> }
|
||||
</div>
|
||||
|
||||
@@ -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<CatalogIconViewProps> = props =>
|
||||
{
|
||||
const { icon = 0 } = props;
|
||||
const { icon = 0, className = '' } = props;
|
||||
|
||||
const getIconUrl = useMemo(() =>
|
||||
const iconUrl = useMemo(() =>
|
||||
{
|
||||
return ((GetConfigurationValue<string>('catalog.asset.icon.url')).replace('%name%', icon.toString()));
|
||||
}, [ icon ]);
|
||||
|
||||
return <LayoutImage imageUrl={ getIconUrl } style={ { width: 20, height: 20 } } />;
|
||||
return <img src={ iconUrl } alt="" className={ `w-5 h-5 object-contain image-rendering-pixelated ${ className }` } draggable={ false } />;
|
||||
};
|
||||
|
||||
@@ -19,8 +19,8 @@ export const CatalogRailItemView: FC<CatalogRailItemViewProps> = props =>
|
||||
title={ node.localization }
|
||||
onClick={ onClick }
|
||||
>
|
||||
<div className="w-[30px] h-[30px] flex items-center justify-center shrink-0">
|
||||
<CatalogIconView icon={ node.iconId } />
|
||||
<div className="w-8 h-8 flex items-center justify-center shrink-0">
|
||||
<CatalogIconView icon={ node.iconId } className="w-6 h-6" />
|
||||
</div>
|
||||
<span className={ `text-[11px] font-medium whitespace-nowrap overflow-hidden opacity-0 group-hover:opacity-100 transition-opacity duration-200 truncate ${ isActive ? 'text-catalog-accent' : 'text-catalog-text' }` }>
|
||||
{ node.localization }
|
||||
|
||||
@@ -121,7 +121,7 @@ export const CatalogFavoritesView: FC<CatalogFavoritesViewProps> = props =>
|
||||
onClick={ () => { openPageByOfferId(fav.offerId); onClose(); } }
|
||||
>
|
||||
{ /* Furni icon */ }
|
||||
<div className="w-[28px] h-[28px] flex items-center justify-center shrink-0 bg-white rounded border border-card-grid-item-border overflow-hidden">
|
||||
<div className="w-7 h-7 flex items-center justify-center shrink-0 bg-white rounded border border-card-grid-item-border overflow-hidden">
|
||||
{ fav.iconUrl
|
||||
? <img className="max-w-full max-h-full object-contain image-rendering-pixelated" src={ fav.iconUrl } />
|
||||
: fav.nodeIconId !== null
|
||||
|
||||
@@ -86,7 +86,7 @@ export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = pro
|
||||
>
|
||||
{ adminMode &&
|
||||
<FaArrowsAlt className="text-[7px] text-muted cursor-grab shrink-0 opacity-0 group-hover/nav:opacity-60" /> }
|
||||
<div className="w-[20px] h-[20px] flex items-center justify-center shrink-0">
|
||||
<div className="w-5 h-5 flex items-center justify-center shrink-0">
|
||||
<CatalogIconView icon={ node.iconId } />
|
||||
</div>
|
||||
<span className="flex-1 truncate" title={ adminMode ? `Page ID: ${ node.pageId }` : undefined }>{ node.localization }</span>
|
||||
|
||||
@@ -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 (
|
||||
<NitroCardView uniqueKey="furni-editor" className="w-[620px] h-[520px]">
|
||||
@@ -123,14 +149,12 @@ export const FurniEditorView: FC<{}> = () =>
|
||||
{ activeTab === TAB_EDIT && selectedItem &&
|
||||
<FurniEditorEditView
|
||||
item={ selectedItem }
|
||||
catalogItems={ catalogItems }
|
||||
furniDataEntry={ furniDataEntry }
|
||||
interactions={ interactions }
|
||||
loading={ loading }
|
||||
onUpdate={ updateItem }
|
||||
onDelete={ deleteItem }
|
||||
onBack={ handleBack }
|
||||
onRefresh={ loadDetail }
|
||||
/>
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown> | null;
|
||||
interactions: string[];
|
||||
loading: boolean;
|
||||
onUpdate: (id: number, fields: Record<string, unknown>) => Promise<boolean>;
|
||||
onDelete: (id: number) => Promise<boolean>;
|
||||
onUpdate: (id: number, fields: Record<string, unknown>) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onBack: () => void;
|
||||
onRefresh: (id: number) => void;
|
||||
}
|
||||
|
||||
const FIELD_TIPS: Record<string, string> = {
|
||||
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<SectionProps> = ({ title, children, defaultOpen = true }) =>
|
||||
{
|
||||
const [ open, setOpen ] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded border border-[#ccc]">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-between px-2 py-1.5 cursor-pointer hover:bg-[#f5f5f5] transition-colors"
|
||||
onClick={ () => setOpen(p => !p) }
|
||||
>
|
||||
<Text small bold variant="primary">{ title }</Text>
|
||||
<span className="text-[10px] text-[#999]">{ open ? '▼' : '▶' }</span>
|
||||
</button>
|
||||
{ open && <div className="px-2 pb-2">{ children }</div> }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Tip: FC<{ field: string }> = ({ field }) =>
|
||||
{
|
||||
const tip = FIELD_TIPS[field];
|
||||
|
||||
if(!tip) return null;
|
||||
|
||||
return (
|
||||
<span className="relative group ml-0.5 inline-flex">
|
||||
<span className="w-3 h-3 rounded-full bg-[#1e7295] text-white text-[8px] flex items-center justify-center cursor-help font-bold">?</span>
|
||||
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 bg-[#333] text-white text-[10px] rounded whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity z-10">
|
||||
{ tip }
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const FurniEditorEditView: FC<FurniEditorEditViewProps> = 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<FurniEditorEditViewProps> = props =>
|
||||
customparams: '',
|
||||
});
|
||||
|
||||
const [ confirmDelete, setConfirmDelete ] = useState(false);
|
||||
const [ showDeleteDialog, setShowDeleteDialog ] = useState(false);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
@@ -69,7 +118,7 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
||||
customparams: item.customparams || '',
|
||||
});
|
||||
|
||||
setConfirmDelete(false);
|
||||
setShowDeleteDialog(false);
|
||||
}, [ item ]);
|
||||
|
||||
const setField = useCallback((key: string, value: unknown) =>
|
||||
@@ -77,97 +126,166 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = 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<string, string> = {};
|
||||
|
||||
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 (
|
||||
<Column gap={ 1 } className="h-full overflow-auto">
|
||||
<Flex gap={ 1 } alignItems="center" className="mb-1">
|
||||
<Button variant="secondary" onClick={ onBack }>Back</Button>
|
||||
<Flex alignItems="center" gap={ 1 } className="bg-[#e9ecef] px-2 py-0.5 rounded">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-3 h-3 text-[#1e7295]">
|
||||
<path fillRule="evenodd" d="M4.93 1.31a41.401 41.401 0 0 1 10.14 0C16.194 1.45 17 2.414 17 3.517V18.25a.75.75 0 0 1-1.075.676l-2.8-1.344-2.8 1.344a.75.75 0 0 1-.65 0l-2.8-1.344-2.8 1.344A.75.75 0 0 1 3 18.25V3.517c0-1.103.806-2.068 1.93-2.207Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<Text bold className="text-[12px]">{ item.id }</Text>
|
||||
<span className="text-[#999] mx-0.5">|</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-3 h-3 text-[#1e7295]">
|
||||
<path d="M12.586 2.586a2 2 0 1 1 2.828 2.828l-3 3a2 2 0 0 1-2.828 0 1 1 0 0 0-1.414 1.414 4 4 0 0 0 5.656 0l3-3a4 4 0 0 0-5.656-5.656l-1.5 1.5a1 1 0 1 0 1.414 1.414l1.5-1.5ZM7.414 17.414a2 2 0 1 1-2.828-2.828l3-3a2 2 0 0 1 2.828 0 1 1 0 0 0 1.414-1.414 4 4 0 0 0-5.656 0l-3 3a4 4 0 0 0 5.656 5.656l1.5-1.5a1 1 0 1 0-1.414-1.414l-1.5 1.5Z" />
|
||||
</svg>
|
||||
<Text bold className="text-[12px]">{ item.spriteId }</Text>
|
||||
{ /* Header */ }
|
||||
<Flex gap={ 2 } alignItems="center" className="mb-1">
|
||||
<Button variant="secondary" onClick={ handleBack }>Back</Button>
|
||||
<div className="bg-[#e9ecef] rounded border border-[#ccc] flex items-center justify-center w-[48px] h-[48px]">
|
||||
<LayoutFurniIconImageView productType={ item.type } productClassId={ item.spriteId } className="scale-150" />
|
||||
</div>
|
||||
<Flex column gap={ 0 }>
|
||||
<Flex alignItems="center" gap={ 1 }>
|
||||
<Text bold className="text-[12px]">ID: { item.id }</Text>
|
||||
<span className="text-[#999]">|</span>
|
||||
<Text bold className="text-[12px]">Sprite: { item.spriteId }</Text>
|
||||
</Flex>
|
||||
<Text small variant="gray">({ item.usageCount } in use)</Text>
|
||||
</Flex>
|
||||
{ isDirty && <span className="text-[10px] text-orange-500 font-bold ml-auto">Unsaved changes</span> }
|
||||
</Flex>
|
||||
|
||||
{ /* Basic Info */ }
|
||||
<div className="bg-white rounded border border-[#ccc] p-2">
|
||||
<Text small bold variant="primary" className="mb-1 block">Basic Info</Text>
|
||||
<Section title="Basic Info">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className={ labelClass }>Item Name</label>
|
||||
<input className={ inputClass } value={ form.itemName } onChange={ e => setField('itemName', e.target.value) } />
|
||||
<input className={ inputClass('itemName') } value={ form.itemName } onChange={ e => setField('itemName', e.target.value) } />
|
||||
{ validation.itemName && <span className="text-[9px] text-red-500">{ validation.itemName }</span> }
|
||||
</div>
|
||||
<div>
|
||||
<label className={ labelClass }>Public Name</label>
|
||||
<input className={ inputClass } value={ form.publicName } onChange={ e => setField('publicName', e.target.value) } />
|
||||
<input className={ inputClass('publicName') } value={ form.publicName } onChange={ e => setField('publicName', e.target.value) } />
|
||||
{ validation.publicName && <span className="text-[9px] text-red-500">{ validation.publicName }</span> }
|
||||
</div>
|
||||
<div>
|
||||
<label className={ labelClass }>Sprite ID</label>
|
||||
<input type="number" className={ inputClass } value={ form.spriteId } onChange={ e => setField('spriteId', Number(e.target.value)) } />
|
||||
<input type="number" className={ inputClass() } value={ form.spriteId } onChange={ e => setField('spriteId', Number(e.target.value)) } />
|
||||
</div>
|
||||
<div>
|
||||
<label className={ labelClass }>Type</label>
|
||||
<select className="form-select form-select-sm" value={ form.type } onChange={ e => setField('type', e.target.value) }>
|
||||
<select className="w-full px-2 py-1 text-xs leading-normal rounded-sm border border-[#ccc] pr-8" value={ form.type } onChange={ e => setField('type', e.target.value) }>
|
||||
<option value="s">Floor (s)</option>
|
||||
<option value="i">Wall (i)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{ /* Dimensions */ }
|
||||
<div className="bg-white rounded border border-[#ccc] p-2">
|
||||
<Text small bold variant="primary" className="mb-1 block">Dimensions</Text>
|
||||
<Section title="Dimensions">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label className={ labelClass }>Width</label>
|
||||
<input type="number" className={ inputClass } value={ form.width } onChange={ e => setField('width', Number(e.target.value)) } />
|
||||
<input type="number" className={ inputClass('width') } value={ form.width } onChange={ e => setField('width', Number(e.target.value)) } />
|
||||
{ validation.width && <span className="text-[9px] text-red-500">{ validation.width }</span> }
|
||||
</div>
|
||||
<div>
|
||||
<label className={ labelClass }>Length</label>
|
||||
<input type="number" className={ inputClass } value={ form.length } onChange={ e => setField('length', Number(e.target.value)) } />
|
||||
<input type="number" className={ inputClass('length') } value={ form.length } onChange={ e => setField('length', Number(e.target.value)) } />
|
||||
{ validation.length && <span className="text-[9px] text-red-500">{ validation.length }</span> }
|
||||
</div>
|
||||
<div>
|
||||
<label className={ labelClass }>Stack Height</label>
|
||||
<input type="number" step="0.01" className={ inputClass } value={ form.stackHeight } onChange={ e => setField('stackHeight', Number(e.target.value)) } />
|
||||
</div>
|
||||
<label className={ labelClass }>Stack Height<Tip field="stackHeight" /></label>
|
||||
<input type="number" step="0.01" className={ inputClass('stackHeight') } value={ form.stackHeight } onChange={ e => setField('stackHeight', Number(e.target.value)) } />
|
||||
{ validation.stackHeight && <span className="text-[9px] text-red-500">{ validation.stackHeight }</span> }
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{ /* Permissions */ }
|
||||
<div className="bg-white rounded border border-[#ccc] p-2">
|
||||
<Text small bold variant="primary" className="mb-1 block">Permissions</Text>
|
||||
<div className="grid grid-cols-3 gap-x-3 gap-y-1">
|
||||
{ [ 'allowStack', 'allowWalk', 'allowSit', 'allowLay', 'allowGift', 'allowTrade', 'allowRecycle', 'allowMarketplaceSell', 'allowInventoryStack' ].map(key => (
|
||||
<Section title="Permissions">
|
||||
<div className="flex flex-col gap-2">
|
||||
{ PERM_GROUPS.map(group => (
|
||||
<div key={ group.label }>
|
||||
<Text className="text-[10px] font-bold text-[#666] uppercase tracking-wider mb-0.5 block">{ group.label }</Text>
|
||||
<div className="grid grid-cols-4 gap-x-3 gap-y-1">
|
||||
{ group.keys.map(key => (
|
||||
<label key={ key } className="flex items-center gap-1 text-[11px] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
className="mt-1"
|
||||
checked={ (form as any)[key] }
|
||||
onChange={ e => setField(key, e.target.checked) }
|
||||
/>
|
||||
@@ -176,14 +294,15 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
||||
)) }
|
||||
</div>
|
||||
</div>
|
||||
)) }
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{ /* Interaction */ }
|
||||
<div className="bg-white rounded border border-[#ccc] p-2">
|
||||
<Text small bold variant="primary" className="mb-1 block">Interaction</Text>
|
||||
<Section title="Interaction">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="col-span-2">
|
||||
<label className={ labelClass }>Type</label>
|
||||
<select className="form-select form-select-sm" value={ form.interactionType } onChange={ e => setField('interactionType', e.target.value) }>
|
||||
<label className={ labelClass }>Type<Tip field="interactionType" /></label>
|
||||
<select className="w-full px-2 py-1 text-xs leading-normal rounded-sm border border-[#ccc] pr-8" value={ form.interactionType } onChange={ e => setField('interactionType', e.target.value) }>
|
||||
<option value="">none</option>
|
||||
{ interactions.map(i => (
|
||||
<option key={ i } value={ i }>{ i }</option>
|
||||
@@ -191,35 +310,18 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={ labelClass }>Modes</label>
|
||||
<input type="number" className={ inputClass } value={ form.interactionModesCount } onChange={ e => setField('interactionModesCount', Number(e.target.value)) } />
|
||||
<label className={ labelClass }>Modes<Tip field="interactionModesCount" /></label>
|
||||
<input type="number" className={ inputClass() } value={ form.interactionModesCount } onChange={ e => setField('interactionModesCount', Number(e.target.value)) } />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<label className={ labelClass }>Custom Params</label>
|
||||
<input className={ inputClass } value={ form.customparams } onChange={ e => setField('customparams', e.target.value) } />
|
||||
</div>
|
||||
<label className={ labelClass }>Custom Params<Tip field="customparams" /></label>
|
||||
<input className={ inputClass() } value={ form.customparams } onChange={ e => setField('customparams', e.target.value) } />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{ /* Catalog References */ }
|
||||
{ catalogItems.length > 0 &&
|
||||
<div className="bg-white rounded border border-[#ccc] p-2">
|
||||
<Text small bold variant="primary" className="mb-1 block">Catalog ({ catalogItems.length })</Text>
|
||||
<div className="text-[10px] space-y-0.5">
|
||||
{ catalogItems.map(ci => (
|
||||
<div key={ ci.id } className="flex justify-between bg-[#f5f5f5] px-2 py-0.5 rounded">
|
||||
<span>{ ci.catalogName } (page: { ci.pageName })</span>
|
||||
<span>{ ci.costCredits }c + { ci.costPoints }p</span>
|
||||
</div>
|
||||
)) }
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
{ /* FurniData.json Entry */ }
|
||||
{ furniDataEntry &&
|
||||
<div className="bg-white rounded border border-[#ccc] p-2">
|
||||
<Text small bold variant="primary" className="mb-1 block">FurniData.json</Text>
|
||||
<Section title="FurniData.json" defaultOpen={ false }>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-[10px]">
|
||||
{ Object.entries(furniDataEntry).map(([ key, value ]) => (
|
||||
<div key={ key } className="flex justify-between bg-[#f5f5f5] px-2 py-0.5 rounded">
|
||||
@@ -228,22 +330,42 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
||||
</div>
|
||||
)) }
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
}
|
||||
|
||||
{ /* Actions */ }
|
||||
<Flex gap={ 1 } justifyContent="between" className="mt-1">
|
||||
<Button variant="success" disabled={ loading } onClick={ handleSave }>
|
||||
<Flex gap={ 1 } justifyContent="between" alignItems="center" className="mt-1">
|
||||
<Flex gap={ 1 } alignItems="center">
|
||||
<Button variant="success" disabled={ loading || !isValid || !isDirty } onClick={ handleSave }>
|
||||
{ loading ? 'Saving...' : 'Save' }
|
||||
</Button>
|
||||
<span className="text-[9px] text-[#999]">Ctrl+S</span>
|
||||
</Flex>
|
||||
<Button
|
||||
variant={ confirmDelete ? 'danger' : 'warning' }
|
||||
variant="danger"
|
||||
disabled={ loading || item.usageCount > 0 }
|
||||
onClick={ handleDelete }
|
||||
onClick={ () => setShowDeleteDialog(true) }
|
||||
>
|
||||
{ confirmDelete ? 'Confirm Delete' : 'Delete' }
|
||||
Delete
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{ /* Delete Confirmation Dialog */ }
|
||||
{ showDeleteDialog &&
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={ () => setShowDeleteDialog(false) }>
|
||||
<div className="bg-white rounded-lg shadow-xl p-4 w-[320px]" onClick={ e => e.stopPropagation() }>
|
||||
<Text bold className="text-[14px] mb-2 block">Delete Item?</Text>
|
||||
<Text small className="mb-3 block text-[#666]">
|
||||
Are you sure you want to delete <strong>{ item.publicName || item.itemName }</strong> (ID: { item.id })?
|
||||
This action cannot be undone.
|
||||
</Text>
|
||||
<Flex gap={ 1 } justifyContent="end">
|
||||
<Button variant="secondary" onClick={ () => setShowDeleteDialog(false) }>Cancel</Button>
|
||||
<Button variant="danger" onClick={ handleDeleteConfirm }>Delete</Button>
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 <span className="ml-0.5 opacity-30">↕</span>;
|
||||
|
||||
return <span className="ml-0.5">{ dir === 'asc' ? '▲' : '▼' }</span>;
|
||||
};
|
||||
|
||||
export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
|
||||
{
|
||||
const { items, total, page, loading, onSearch, onSelect } = props;
|
||||
const [ query, setQuery ] = useState('');
|
||||
const [ typeFilter, setTypeFilter ] = useState('');
|
||||
const [ sortField, setSortField ] = useState<SortField>('id');
|
||||
const [ sortDir, setSortDir ] = useState<SortDir>('asc');
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
@@ -33,6 +45,45 @@ export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = 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<FurniEditorSearchViewProps> = props =>
|
||||
<Text small bold>Search</Text>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
className="w-full px-2 py-1 text-xs leading-normal rounded-sm border border-[#ccc] min-h-[calc(1.5em+0.5rem+2px)]"
|
||||
placeholder="ID, name or sprite ID..."
|
||||
value={ query }
|
||||
onChange={ e => setQuery(e.target.value) }
|
||||
onKeyDown={ handleKeyDown }
|
||||
/>
|
||||
</Column>
|
||||
<Column gap={ 0 } className="w-[80px]">
|
||||
<Text small bold>Type</Text>
|
||||
<select
|
||||
className="form-select form-select-sm"
|
||||
value={ typeFilter }
|
||||
onChange={ e => setTypeFilter(e.target.value) }
|
||||
<Flex gap={ 1 }>
|
||||
{ [ '', 's', 'i' ].map(t => (
|
||||
<button
|
||||
key={ t || 'all' }
|
||||
className={ `px-2 py-1 text-[11px] rounded border cursor-pointer transition-colors ${
|
||||
typeFilter === t
|
||||
? 'bg-[#1e7295] text-white border-[#1e7295]'
|
||||
: 'bg-white text-[#333] border-[#ccc] hover:bg-[#f0f0f0]'
|
||||
}` }
|
||||
onClick={ () => handleTypeToggle(t) }
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="s">Floor (s)</option>
|
||||
<option value="i">Wall (i)</option>
|
||||
</select>
|
||||
</Column>
|
||||
{ t === '' ? 'All' : t === 's' ? 'Floor' : 'Wall' }
|
||||
</button>
|
||||
)) }
|
||||
</Flex>
|
||||
<Button variant="primary" disabled={ loading } onClick={ handleSearch }>
|
||||
{ loading ? '...' : 'Search' }
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{ total > 0 &&
|
||||
<Text small variant="gray" className="text-[10px]">
|
||||
{ total } items found
|
||||
</Text>
|
||||
}
|
||||
|
||||
<Column gap={ 0 } className="flex-1 overflow-auto border border-[#ccc] rounded bg-white">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-[#e8e8e8] sticky top-0">
|
||||
<th className="px-2 py-1 text-left">ID</th>
|
||||
<th className="px-2 py-1 text-left">Sprite</th>
|
||||
<th className="px-2 py-1 text-left">Name</th>
|
||||
<th className="px-2 py-1 text-left">Public Name</th>
|
||||
<th className="px-2 py-1 text-center">Type</th>
|
||||
<th className="px-2 py-1 text-left">Interaction</th>
|
||||
<tr className="bg-[#e8e8e8] sticky top-0 select-none">
|
||||
<th className="px-1 py-1 text-center w-[50px]"></th>
|
||||
<th className="px-2 py-1 text-left cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('id') }>
|
||||
ID<SortArrow field="id" active={ sortField } dir={ sortDir } />
|
||||
</th>
|
||||
<th className="px-2 py-1 text-left cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('spriteId') }>
|
||||
Sprite<SortArrow field="spriteId" active={ sortField } dir={ sortDir } />
|
||||
</th>
|
||||
<th className="px-2 py-1 text-left cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('itemName') }>
|
||||
Name<SortArrow field="itemName" active={ sortField } dir={ sortDir } />
|
||||
</th>
|
||||
<th className="px-2 py-1 text-left cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('publicName') }>
|
||||
Public Name<SortArrow field="publicName" active={ sortField } dir={ sortDir } />
|
||||
</th>
|
||||
<th className="px-2 py-1 text-center cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('type') }>
|
||||
Type<SortArrow field="type" active={ sortField } dir={ sortDir } />
|
||||
</th>
|
||||
<th className="px-2 py-1 text-left cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('interactionType') }>
|
||||
Interaction<SortArrow field="interactionType" active={ sortField } dir={ sortDir } />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ items.map(item => (
|
||||
{ sortedItems.map(item => (
|
||||
<tr
|
||||
key={ item.id }
|
||||
className="cursor-pointer hover:bg-[#d4edfa] border-b border-[#eee] transition-colors"
|
||||
onClick={ () => onSelect(item.id) }
|
||||
>
|
||||
<td className="px-1 py-1 text-center">
|
||||
<LayoutFurniIconImageView productType={ item.type } productClassId={ item.spriteId } className="inline-block scale-125" />
|
||||
</td>
|
||||
<td className="px-2 py-1 font-mono">{ item.id }</td>
|
||||
<td className="px-2 py-1 font-mono">{ item.spriteId }</td>
|
||||
<td className="px-2 py-1 truncate max-w-[120px]">{ item.itemName }</td>
|
||||
<td className="px-2 py-1 truncate max-w-[120px]">{ item.publicName }</td>
|
||||
<td className="px-2 py-1 truncate max-w-[120px]" title={ item.itemName }>{ item.itemName }</td>
|
||||
<td className="px-2 py-1 truncate max-w-[120px]" title={ item.publicName }>{ item.publicName }</td>
|
||||
<td className="px-2 py-1 text-center">
|
||||
<span className={ `px-1 rounded text-white text-[10px] ${ item.type === 's' ? 'bg-[#1e7295]' : 'bg-[#6b7280]' }` }>
|
||||
<span className={ `px-1.5 py-0.5 rounded text-white text-[10px] font-medium ${ item.type === 's' ? 'bg-[#1e7295]' : 'bg-[#6b7280]' }` }>
|
||||
{ item.type === 's' ? 'Floor' : 'Wall' }
|
||||
</span>
|
||||
</td>
|
||||
@@ -99,7 +175,7 @@ export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
|
||||
)) }
|
||||
{ items.length === 0 && !loading &&
|
||||
<tr>
|
||||
<td colSpan={ 6 } className="px-2 py-4 text-center text-[#999]">No items found</td>
|
||||
<td colSpan={ 7 } className="px-2 py-4 text-center text-[#999]">No items found</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -109,7 +185,7 @@ export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
|
||||
{ totalPages > 1 &&
|
||||
<Flex gap={ 1 } justifyContent="between" alignItems="center">
|
||||
<Text small variant="gray">
|
||||
{ total } items - Page { page }/{ totalPages }
|
||||
Page { page }/{ totalPages }
|
||||
</Text>
|
||||
<Flex gap={ 1 }>
|
||||
<Button
|
||||
|
||||
@@ -94,6 +94,9 @@ export const GroupInformationView: FC<GroupInformationViewProps> = props =>
|
||||
case 'popular_groups':
|
||||
CreateLinkEvent('navigator/search/groups');
|
||||
break;
|
||||
case 'forum':
|
||||
CreateLinkEvent('groupforum/' + groupInformation.id);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -134,6 +137,8 @@ export const GroupInformationView: FC<GroupInformationViewProps> = props =>
|
||||
<Text pointer small underline onClick={ () => handleAction('homeroom') }>{ LocalizeText('group.linktobase') }</Text>
|
||||
<Text pointer small underline onClick={ () => handleAction('furniture') }>{ LocalizeText('group.buyfurni') }</Text>
|
||||
<Text pointer small underline onClick={ () => handleAction('popular_groups') }>{ LocalizeText('group.showgroups') }</Text>
|
||||
{ groupInformation.hasForum &&
|
||||
<Text pointer small underline onClick={ () => handleAction('forum') }>{ LocalizeText('group.showforum') }</Text> }
|
||||
</div>
|
||||
{ (groupInformation.type !== GroupType.PRIVATE || groupInformation.type === GroupType.PRIVATE && groupInformation.membershipType === GroupMembershipType.MEMBER) &&
|
||||
<Button disabled={ (groupInformation.membershipType === GroupMembershipType.REQUEST_PENDING) || isRealOwner } onClick={ handleButtonClick }>
|
||||
|
||||
@@ -77,6 +77,7 @@ export const GroupManagerView: FC<{}> = props =>
|
||||
groupHomeroomId: parser.roomId,
|
||||
groupState: parser.state,
|
||||
groupCanMembersDecorate: parser.canMembersDecorate,
|
||||
groupHasForum: parser.hasForum,
|
||||
groupColors: [ parser.colorA, parser.colorB ],
|
||||
groupBadgeParts
|
||||
});
|
||||
@@ -85,7 +86,7 @@ export const GroupManagerView: FC<{}> = props =>
|
||||
if(!groupData || (groupData.groupId <= 0)) return null;
|
||||
|
||||
return (
|
||||
<NitroCardView className="nitro-group-manager">
|
||||
<NitroCardView className="nitro-group-manager w-[560px]">
|
||||
<NitroCardHeaderView headerText={ LocalizeText('group.window.title') } onCloseClick={ onClose } />
|
||||
<NitroCardTabsView>
|
||||
{ TABS.map(tab =>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ForumsListMessageEvent, GetForumsListMessageComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { LocalizeText, SendMessageComposer, GetUserProfile } from '../../../../api';
|
||||
import { Column, Flex, Text, LayoutBadgeImageView } from '../../../../common';
|
||||
import { useMessageEvent } from '../../../../hooks';
|
||||
import { ForumData } from '@nitrots/nitro-renderer';
|
||||
|
||||
const FORUMS_PER_PAGE = 20;
|
||||
|
||||
interface GroupForumListViewProps
|
||||
{
|
||||
onOpenForum: (groupId: number) => void;
|
||||
}
|
||||
|
||||
export const GroupForumListView: FC<GroupForumListViewProps> = props =>
|
||||
{
|
||||
const { onOpenForum = null } = props;
|
||||
const [ forums, setForums ] = useState<ForumData[]>([]);
|
||||
const [ listMode, setListMode ] = useState<number>(0); // 0 = most active
|
||||
const [ startIndex, setStartIndex ] = useState<number>(0);
|
||||
const [ totalForums, setTotalForums ] = useState<number>(0);
|
||||
|
||||
useMessageEvent<ForumsListMessageEvent>(ForumsListMessageEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
setTotalForums(parser.totalAmount);
|
||||
|
||||
if(parser.startIndex === 0)
|
||||
{
|
||||
setForums(parser.forums);
|
||||
}
|
||||
else
|
||||
{
|
||||
setForums(prev => [ ...prev, ...parser.forums ]);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
SendMessageComposer(new GetForumsListMessageComposer(listMode, startIndex, FORUMS_PER_PAGE));
|
||||
}, [ listMode, startIndex ]);
|
||||
|
||||
const formatTimeAgo = (seconds: number): string =>
|
||||
{
|
||||
if(seconds < 60) return `${ seconds }s ${ LocalizeText('messageboard.time.ago') }`;
|
||||
if(seconds < 3600) return `${ Math.floor(seconds / 60) }m ${ LocalizeText('messageboard.time.ago') }`;
|
||||
if(seconds < 86400) return `${ Math.floor(seconds / 3600) }h ${ LocalizeText('messageboard.time.ago') }`;
|
||||
|
||||
return `${ Math.floor(seconds / 86400) }d ${ LocalizeText('messageboard.time.ago') }`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Column className="h-full" gap={ 0 }>
|
||||
<Flex className="bg-muted p-2 border-b" gap={ 2 } alignItems="center" justifyContent="between">
|
||||
<Text bold>{ LocalizeText('messageboard.all.threads.header') }</Text>
|
||||
<Flex gap={ 1 }>
|
||||
<select
|
||||
className="form-select form-select-sm"
|
||||
value={ listMode }
|
||||
onChange={ e => { setListMode(parseInt(e.target.value)); setStartIndex(0); } }>
|
||||
<option value={ 0 }>{ LocalizeText('groupforum.list.tab.most_active') }</option>
|
||||
<option value={ 2 }>{ LocalizeText('groupforum.list.tab.my_forums') }</option>
|
||||
</select>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Column className="overflow-auto flex-1 p-2" gap={ 1 }>
|
||||
{ forums.map((forum, index) =>
|
||||
{
|
||||
return (
|
||||
<Flex key={ forum.groupId }
|
||||
className="p-2 rounded bg-white hover:bg-muted cursor-pointer border"
|
||||
gap={ 2 }
|
||||
alignItems="center"
|
||||
onClick={ () => onOpenForum(forum.groupId) }>
|
||||
<div className="flex-shrink-0">
|
||||
<LayoutBadgeImageView badgeCode={ forum.icon } isGroup={ true } />
|
||||
</div>
|
||||
<Column className="flex-1 overflow-hidden" gap={ 0 }>
|
||||
<Text bold className="truncate">{ forum.name }</Text>
|
||||
<Text small variant="muted" className="truncate">{ forum.description }</Text>
|
||||
</Column>
|
||||
<Column className="flex-shrink-0 text-end" gap={ 0 }>
|
||||
<Text small>{ forum.totalThreads } { LocalizeText('groupforum.view.threads') }</Text>
|
||||
<Text small>{ forum.totalMessages } { LocalizeText('messageboard.messages') }</Text>
|
||||
{ (forum.unreadMessages > 0) &&
|
||||
<Text small bold variant="danger">{ forum.unreadMessages } { LocalizeText('messageboard.unread') }</Text> }
|
||||
</Column>
|
||||
<Column className="flex-shrink-0 text-end min-w-[100px]" gap={ 0 }>
|
||||
{ (forum.lastMessageAuthorId > 0) && <>
|
||||
<Text small variant="muted">{ LocalizeText('messageboard.last.message') }</Text>
|
||||
<Text small pointer underline onClick={ e => { e.stopPropagation(); GetUserProfile(forum.lastMessageAuthorId); } }>
|
||||
{ forum.lastMessageAuthorName }
|
||||
</Text>
|
||||
<Text small variant="muted">{ formatTimeAgo(forum.lastMessageTimeAsSecondsAgo) }</Text>
|
||||
</> }
|
||||
</Column>
|
||||
</Flex>
|
||||
);
|
||||
}) }
|
||||
{ (forums.length === 0) &&
|
||||
<Flex className="p-4" justifyContent="center">
|
||||
<Text variant="muted">{ LocalizeText('groupforum.list.no_forums') }</Text>
|
||||
</Flex> }
|
||||
{ (forums.length < totalForums) &&
|
||||
<Flex justifyContent="center" className="p-2">
|
||||
<Text pointer underline onClick={ () => setStartIndex(forums.length) }>
|
||||
{ LocalizeText('groupforum.list.load_more') }
|
||||
</Text>
|
||||
</Flex> }
|
||||
</Column>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import { PostMessageMessageComposer, PostThreadMessageEvent } from '@nitrots/nitro-renderer';
|
||||
import { FC, useCallback, useState } from 'react';
|
||||
import { LocalizeText, SendMessageComposer } from '../../../../api';
|
||||
import { Button, Column, Flex, Text } from '../../../../common';
|
||||
import { useMessageEvent, useNotification } from '../../../../hooks';
|
||||
import { ExtendedForumData } from '@nitrots/nitro-renderer';
|
||||
|
||||
interface GroupForumNewThreadViewProps
|
||||
{
|
||||
groupId: number;
|
||||
forumData: ExtendedForumData;
|
||||
onBack: () => void;
|
||||
onThreadCreated: (threadId: number) => void;
|
||||
}
|
||||
|
||||
export const GroupForumNewThreadView: FC<GroupForumNewThreadViewProps> = props =>
|
||||
{
|
||||
const { groupId = 0, forumData = null, onBack = null, onThreadCreated = null } = props;
|
||||
const effectiveGroupId = forumData?.groupId || groupId;
|
||||
const [ subject, setSubject ] = useState<string>('');
|
||||
const [ message, setMessage ] = useState<string>('');
|
||||
const [ isSubmitting, setIsSubmitting ] = useState<boolean>(false);
|
||||
const { simpleAlert = null } = useNotification();
|
||||
|
||||
useMessageEvent<PostThreadMessageEvent>(PostThreadMessageEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(parser.groupId !== effectiveGroupId) return;
|
||||
|
||||
setIsSubmitting(false);
|
||||
setSubject('');
|
||||
setMessage('');
|
||||
|
||||
if(onThreadCreated) onThreadCreated(parser.thread.threadId);
|
||||
});
|
||||
|
||||
const submitThread = useCallback(() =>
|
||||
{
|
||||
if(subject.trim().length < 10)
|
||||
{
|
||||
simpleAlert(LocalizeText('groupforum.compose.subject_too_short'));
|
||||
return;
|
||||
}
|
||||
|
||||
if(message.trim().length < 10)
|
||||
{
|
||||
simpleAlert(LocalizeText('groupforum.compose.message_too_short'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
// PostMessageMessageComposer with threadId=0 creates a new thread
|
||||
// params: groupId, threadId (0 for new), subject, message
|
||||
SendMessageComposer(new PostMessageMessageComposer(effectiveGroupId, 0, subject.trim(), message.trim()));
|
||||
}, [ effectiveGroupId, subject, message, simpleAlert ]);
|
||||
|
||||
return (
|
||||
<Column className="h-full p-3" gap={ 2 }>
|
||||
<Flex gap={ 2 } alignItems="center">
|
||||
<Text pointer underline onClick={ onBack }>
|
||||
« { LocalizeText('groupforum.view.back') }
|
||||
</Text>
|
||||
</Flex>
|
||||
<Column gap={ 1 }>
|
||||
<Text bold>{ LocalizeText('messageboard.message.thread.subject') }</Text>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
placeholder={ LocalizeText('messageboard.message.thread.subject') }
|
||||
maxLength={ 120 }
|
||||
value={ subject }
|
||||
onChange={ e => setSubject(e.target.value) }
|
||||
/>
|
||||
</Column>
|
||||
<Column className="flex-1" gap={ 1 }>
|
||||
<Text bold>{ LocalizeText('messageboard.forum.compose.message.header') }</Text>
|
||||
<textarea
|
||||
className="form-control form-control-sm flex-1"
|
||||
placeholder={ LocalizeText('messageboard.forum.compose.message.header') }
|
||||
maxLength={ 4000 }
|
||||
value={ message }
|
||||
onChange={ e => setMessage(e.target.value) }
|
||||
/>
|
||||
</Column>
|
||||
<Flex gap={ 2 } justifyContent="end">
|
||||
<Button variant="secondary" className="btn-sm" onClick={ onBack }>
|
||||
{ LocalizeText('generic.cancel') }
|
||||
</Button>
|
||||
<Button variant="primary" className="btn-sm" onClick={ submitThread } disabled={ isSubmitting || subject.trim().length < 10 || message.trim().length < 10 }>
|
||||
{ isSubmitting ? '...' : LocalizeText('messageboard.new.thread.button') }
|
||||
</Button>
|
||||
</Flex>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { UpdateForumSettingsMessageComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useCallback, useState } from 'react';
|
||||
import { LocalizeText, SendMessageComposer } from '../../../../api';
|
||||
import { Button, Column, Flex, Text } from '../../../../common';
|
||||
import { ExtendedForumData } from '@nitrots/nitro-renderer';
|
||||
|
||||
// Permission levels: 0 = EVERYONE, 1 = MEMBERS, 2 = ADMINS, 3 = OWNER
|
||||
const PERMISSION_EVERYONE = 0;
|
||||
const PERMISSION_MEMBERS = 1;
|
||||
const PERMISSION_ADMINS = 2;
|
||||
const PERMISSION_OWNER = 3;
|
||||
|
||||
interface GroupForumSettingsViewProps
|
||||
{
|
||||
groupId: number;
|
||||
forumData: ExtendedForumData;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const GroupForumSettingsView: FC<GroupForumSettingsViewProps> = props =>
|
||||
{
|
||||
const { groupId = 0, forumData = null, onBack = null } = props;
|
||||
const effectiveGroupId = forumData?.groupId || groupId;
|
||||
const [ readPermission, setReadPermission ] = useState<number>(forumData?.readPermissions ?? PERMISSION_EVERYONE);
|
||||
const [ postMessagePermission, setPostMessagePermission ] = useState<number>(forumData?.postMessagePermissions ?? PERMISSION_MEMBERS);
|
||||
const [ postThreadPermission, setPostThreadPermission ] = useState<number>(forumData?.postThreadPermissions ?? PERMISSION_MEMBERS);
|
||||
const [ moderatePermission, setModeratePermission ] = useState<number>(forumData?.moderatePermissions ?? PERMISSION_ADMINS);
|
||||
|
||||
const saveSettings = useCallback(() =>
|
||||
{
|
||||
SendMessageComposer(new UpdateForumSettingsMessageComposer(
|
||||
effectiveGroupId,
|
||||
readPermission,
|
||||
postMessagePermission,
|
||||
postThreadPermission,
|
||||
moderatePermission
|
||||
));
|
||||
|
||||
onBack();
|
||||
}, [ effectiveGroupId, readPermission, postMessagePermission, postThreadPermission, moderatePermission, onBack ]);
|
||||
|
||||
const getPermissionOptions = (includeOwner: boolean = false) =>
|
||||
{
|
||||
const options = [
|
||||
{ value: PERMISSION_EVERYONE, label: LocalizeText('groupforum.permissions.option_all') },
|
||||
{ value: PERMISSION_MEMBERS, label: LocalizeText('groupforum.permissions.option_group_members') },
|
||||
{ value: PERMISSION_ADMINS, label: LocalizeText('groupforum.permissions.option_group_admins') }
|
||||
];
|
||||
|
||||
if(includeOwner)
|
||||
{
|
||||
options.push({ value: PERMISSION_OWNER, label: LocalizeText('groupforum.permissions.option_owner') });
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
return (
|
||||
<Column className="h-full p-3" gap={ 3 }>
|
||||
<Flex gap={ 2 } alignItems="center">
|
||||
<Text pointer underline onClick={ onBack }>
|
||||
« { LocalizeText('groupforum.view.back') }
|
||||
</Text>
|
||||
</Flex>
|
||||
<Text bold>{ LocalizeText('groupforum.settings.window_title') }</Text>
|
||||
<Column gap={ 2 }>
|
||||
<Column gap={ 1 }>
|
||||
<Text bold small>{ LocalizeText('groupforum.permissions.read_label') }</Text>
|
||||
<select className="form-select form-select-sm" value={ readPermission } onChange={ e => setReadPermission(parseInt(e.target.value)) }>
|
||||
{ getPermissionOptions().map(opt => (
|
||||
<option key={ opt.value } value={ opt.value }>{ opt.label }</option>
|
||||
)) }
|
||||
</select>
|
||||
</Column>
|
||||
<Column gap={ 1 }>
|
||||
<Text bold small>{ LocalizeText('groupforum.permissions.post_message_label') }</Text>
|
||||
<select className="form-select form-select-sm" value={ postMessagePermission } onChange={ e => setPostMessagePermission(parseInt(e.target.value)) }>
|
||||
{ getPermissionOptions(true).map(opt => (
|
||||
<option key={ opt.value } value={ opt.value }>{ opt.label }</option>
|
||||
)) }
|
||||
</select>
|
||||
</Column>
|
||||
<Column gap={ 1 }>
|
||||
<Text bold small>{ LocalizeText('groupforum.permissions.post_thread_label') }</Text>
|
||||
<select className="form-select form-select-sm" value={ postThreadPermission } onChange={ e => setPostThreadPermission(parseInt(e.target.value)) }>
|
||||
{ getPermissionOptions(true).map(opt => (
|
||||
<option key={ opt.value } value={ opt.value }>{ opt.label }</option>
|
||||
)) }
|
||||
</select>
|
||||
</Column>
|
||||
<Column gap={ 1 }>
|
||||
<Text bold small>{ LocalizeText('groupforum.permissions.moderate_label') }</Text>
|
||||
<select className="form-select form-select-sm" value={ moderatePermission } onChange={ e => setModeratePermission(parseInt(e.target.value)) }>
|
||||
{ [
|
||||
{ value: PERMISSION_ADMINS, label: LocalizeText('groupforum.permissions.option_group_admins') },
|
||||
{ value: PERMISSION_OWNER, label: LocalizeText('groupforum.permissions.option_owner') }
|
||||
].map(opt => (
|
||||
<option key={ opt.value } value={ opt.value }>{ opt.label }</option>
|
||||
)) }
|
||||
</select>
|
||||
</Column>
|
||||
</Column>
|
||||
<Flex className="mt-auto" gap={ 2 } justifyContent="end">
|
||||
<Button variant="secondary" className="btn-sm" onClick={ onBack }>
|
||||
{ LocalizeText('generic.cancel') }
|
||||
</Button>
|
||||
<Button variant="primary" className="btn-sm" onClick={ saveSettings }>
|
||||
{ LocalizeText('groupforum.settings.ok') }
|
||||
</Button>
|
||||
</Flex>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,211 @@
|
||||
import { ExtendedForumData, GetThreadsMessageComposer, GuildForumThread, GuildForumThreadsEvent, ModerateThreadMessageComposer, PostThreadMessageEvent } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { LocalizeText, SendMessageComposer, GetUserProfile } from '../../../../api';
|
||||
import { Button, Column, Flex, LayoutBadgeImageView, Text } from '../../../../common';
|
||||
import { useMessageEvent } from '../../../../hooks';
|
||||
|
||||
const THREADS_PER_PAGE = 20;
|
||||
|
||||
interface GroupForumThreadListViewProps
|
||||
{
|
||||
groupId: number;
|
||||
forumData: ExtendedForumData;
|
||||
onOpenThread: (groupId: number, threadId: number, thread?: GuildForumThread) => void;
|
||||
onNewThread: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const GroupForumThreadListView: FC<GroupForumThreadListViewProps> = props =>
|
||||
{
|
||||
const { groupId = 0, forumData = null, onOpenThread = null, onNewThread = null, onOpenSettings = null, onBack = null } = props;
|
||||
const effectiveGroupId = forumData?.groupId || groupId;
|
||||
const [ threads, setThreads ] = useState<GuildForumThread[]>([]);
|
||||
const [ startIndex, setStartIndex ] = useState<number>(0);
|
||||
const [ totalThreads, setTotalThreads ] = useState<number>(0);
|
||||
|
||||
useMessageEvent<GuildForumThreadsEvent>(GuildForumThreadsEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(parser.groupId !== effectiveGroupId) return;
|
||||
|
||||
setTotalThreads(parser.amount);
|
||||
|
||||
if(parser.startIndex === 0)
|
||||
{
|
||||
setThreads(parser.threads);
|
||||
}
|
||||
else
|
||||
{
|
||||
setThreads(prev => [ ...prev, ...parser.threads ]);
|
||||
}
|
||||
});
|
||||
|
||||
useMessageEvent<PostThreadMessageEvent>(PostThreadMessageEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(parser.groupId !== effectiveGroupId) return;
|
||||
|
||||
setThreads(prev => [ parser.thread, ...prev ]);
|
||||
});
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!effectiveGroupId) return;
|
||||
|
||||
setThreads([]);
|
||||
setStartIndex(0);
|
||||
SendMessageComposer(new GetThreadsMessageComposer(effectiveGroupId, 0, THREADS_PER_PAGE));
|
||||
}, [ effectiveGroupId ]);
|
||||
|
||||
const formatTimeAgo = (seconds: number): string =>
|
||||
{
|
||||
if(seconds < 60) return `${ seconds }s ${ LocalizeText('messageboard.time.ago') }`;
|
||||
if(seconds < 3600) return `${ Math.floor(seconds / 60) }m ${ LocalizeText('messageboard.time.ago') }`;
|
||||
if(seconds < 86400) return `${ Math.floor(seconds / 3600) }h ${ LocalizeText('messageboard.time.ago') }`;
|
||||
|
||||
return `${ Math.floor(seconds / 86400) }d ${ LocalizeText('messageboard.time.ago') }`;
|
||||
};
|
||||
|
||||
const getThreadStateText = (thread: GuildForumThread): string =>
|
||||
{
|
||||
if(thread.state === 10) return LocalizeText('messageboard.thread.hidden.by.admin');
|
||||
if(thread.state === 20) return LocalizeText('messageboard.thread.permanently.deleted.by.moderator');
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const canModerate = forumData && forumData.hasModeratePermissionError;
|
||||
|
||||
const pinnedThreads = threads.filter(t => t.isPinned);
|
||||
const normalThreads = threads.filter(t => !t.isPinned);
|
||||
const sortedThreads = [ ...pinnedThreads, ...normalThreads ];
|
||||
|
||||
const restoreThread = (thread: GuildForumThread) =>
|
||||
{
|
||||
SendMessageComposer(new ModerateThreadMessageComposer(effectiveGroupId, thread.threadId, 1));
|
||||
};
|
||||
|
||||
return (
|
||||
<Column className="h-full" gap={ 0 }>
|
||||
<Flex className="bg-muted p-2 border-b" gap={ 2 } alignItems="center" justifyContent="between">
|
||||
<Flex gap={ 2 } alignItems="center">
|
||||
<Text pointer underline onClick={ onBack }>
|
||||
« { LocalizeText('groupforum.view.back') }
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex gap={ 1 } alignItems="center">
|
||||
{ forumData && forumData.canChangeSettings &&
|
||||
<Button variant="link" className="btn-sm" onClick={ onOpenSettings }>
|
||||
{ LocalizeText('groupforum.view.settings.header') }
|
||||
</Button> }
|
||||
{ forumData && forumData.hasReadPermissionError && forumData.hasPostThreadPermissionError &&
|
||||
<Button variant="primary" className="btn-sm" onClick={ onNewThread }>
|
||||
{ LocalizeText('messageboard.new.thread.button') }
|
||||
</Button> }
|
||||
</Flex>
|
||||
</Flex>
|
||||
{ forumData &&
|
||||
<Flex className="bg-light p-2 border-b" gap={ 2 } alignItems="center">
|
||||
<LayoutBadgeImageView badgeCode={ forumData.icon } isGroup={ true } />
|
||||
<Column className="flex-1" gap={ 0 }>
|
||||
<Text bold>{ forumData.name }</Text>
|
||||
<Text small variant="muted">{ forumData.description }</Text>
|
||||
</Column>
|
||||
<Column className="text-end" gap={ 0 }>
|
||||
<Text small>{ forumData.totalThreads } { LocalizeText('groupforum.view.threads') }</Text>
|
||||
<Text small>{ forumData.totalMessages } { LocalizeText('messageboard.messages') }</Text>
|
||||
</Column>
|
||||
</Flex> }
|
||||
{ forumData && !forumData.hasReadPermissionError &&
|
||||
<Flex className="flex-1 p-4" justifyContent="center" alignItems="center">
|
||||
<Column alignItems="center" gap={ 2 }>
|
||||
<Text bold>{ LocalizeText('groupforum.view.error.operation_read') }</Text>
|
||||
<Text small variant="muted">
|
||||
{ LocalizeText('groupforum.view.error.' + forumData.readPermissionError) }
|
||||
</Text>
|
||||
</Column>
|
||||
</Flex> }
|
||||
{ (!forumData || forumData.hasReadPermissionError) &&
|
||||
<Column className="overflow-auto flex-1" gap={ 0 }>
|
||||
{ sortedThreads.map((thread, index) =>
|
||||
{
|
||||
const stateText = getThreadStateText(thread);
|
||||
|
||||
if(stateText)
|
||||
{
|
||||
return (
|
||||
<Flex key={ thread.threadId } className="p-2 border-b bg-danger bg-opacity-10" alignItems="center" justifyContent="between">
|
||||
<Column gap={ 0 }>
|
||||
<Text small variant="muted">{ stateText }</Text>
|
||||
{ canModerate &&
|
||||
<Text small variant="muted">{ thread.header }</Text> }
|
||||
</Column>
|
||||
{ canModerate &&
|
||||
<Button variant="outline-success" className="btn-sm" onClick={ () => restoreThread(thread) }>
|
||||
{ LocalizeText('groupforum.thread.restore') }
|
||||
</Button> }
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex key={ thread.threadId }
|
||||
className={ `p-2 border-b hover:bg-muted cursor-pointer ${ thread.isPinned ? 'bg-warning bg-opacity-10' : '' } ${ thread.unreadMessagesCount > 0 ? 'fw-bold' : '' }` }
|
||||
gap={ 2 }
|
||||
alignItems="center"
|
||||
onClick={ () => onOpenThread(effectiveGroupId, thread.threadId, thread) }>
|
||||
<Column className="flex-1 overflow-hidden" gap={ 0 }>
|
||||
<Flex gap={ 1 } alignItems="center">
|
||||
{ thread.isPinned && <i className="fas fa-thumbtack text-warning" /> }
|
||||
{ thread.isLocked && <i className="fas fa-lock text-muted" /> }
|
||||
<Text bold={ thread.unreadMessagesCount > 0 } className="truncate">{ thread.header }</Text>
|
||||
</Flex>
|
||||
<Flex gap={ 1 }>
|
||||
<Text small variant="muted">{ LocalizeText('messageboard.started.by') }</Text>
|
||||
<Text small pointer underline onClick={ e => { e.stopPropagation(); GetUserProfile(thread.authorId); } }>
|
||||
{ thread.authorName }
|
||||
</Text>
|
||||
<Text small variant="muted">- { formatTimeAgo(thread.creationTimeAsSecondsAgo) }</Text>
|
||||
</Flex>
|
||||
</Column>
|
||||
<Column className="flex-shrink-0 text-center min-w-[60px]" gap={ 0 }>
|
||||
<Text small>{ thread.totalMessages }</Text>
|
||||
<Text small variant="muted">{ LocalizeText('messageboard.messages') }</Text>
|
||||
</Column>
|
||||
{ (thread.unreadMessagesCount > 0) &&
|
||||
<Column className="flex-shrink-0 text-center min-w-[60px]" gap={ 0 }>
|
||||
<Text small bold variant="danger">{ thread.unreadMessagesCount }</Text>
|
||||
<Text small variant="danger">{ LocalizeText('messageboard.unread') }</Text>
|
||||
</Column> }
|
||||
<Column className="flex-shrink-0 text-end min-w-[100px]" gap={ 0 }>
|
||||
<Text small variant="muted">{ LocalizeText('messageboard.last.message') }</Text>
|
||||
<Text small pointer underline onClick={ e => { e.stopPropagation(); GetUserProfile(thread.lastUserId); } }>
|
||||
{ thread.lastUserName }
|
||||
</Text>
|
||||
<Text small variant="muted">{ formatTimeAgo(thread.lastCommentTime) }</Text>
|
||||
</Column>
|
||||
</Flex>
|
||||
);
|
||||
}) }
|
||||
{ (sortedThreads.length === 0) &&
|
||||
<Flex className="p-4" justifyContent="center">
|
||||
<Text variant="muted">{ LocalizeText('groupforum.view.no_threads') }</Text>
|
||||
</Flex> }
|
||||
{ (threads.length < totalThreads) &&
|
||||
<Flex justifyContent="center" className="p-2">
|
||||
<Text pointer underline onClick={ () =>
|
||||
{
|
||||
const nextIndex = threads.length;
|
||||
setStartIndex(nextIndex);
|
||||
SendMessageComposer(new GetThreadsMessageComposer(effectiveGroupId, nextIndex, THREADS_PER_PAGE));
|
||||
} }>
|
||||
{ LocalizeText('groupforum.list.load_more') }
|
||||
</Text>
|
||||
</Flex> }
|
||||
</Column> }
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,321 @@
|
||||
import { GetMessagesMessageComposer, ModerateMessageMessageComposer, ModerateThreadMessageComposer, PostMessageMessageComposer, PostMessageMessageEvent, PostThreadMessageEvent, ThreadMessagesMessageEvent, UpdateForumReadMarkerMessageComposer, UpdateForumReadMarkerEntry, UpdateMessageMessageEvent, UpdateThreadMessageComposer, UpdateThreadMessageEvent } from '@nitrots/nitro-renderer';
|
||||
import { FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { LocalizeText, SendMessageComposer, GetUserProfile } from '../../../../api';
|
||||
import { Button, Column, Flex, LayoutAvatarImageView, Text } from '../../../../common';
|
||||
import { useMessageEvent } from '../../../../hooks';
|
||||
import { ExtendedForumData, GuildForumThread, MessageData } from '@nitrots/nitro-renderer';
|
||||
|
||||
const MESSAGES_PER_PAGE = 20;
|
||||
|
||||
// Message states
|
||||
const STATE_NORMAL = 0;
|
||||
const STATE_VISIBLE = 1;
|
||||
const STATE_HIDDEN_BY_ADMIN = 10;
|
||||
const STATE_DELETED_BY_MODERATOR = 20;
|
||||
|
||||
interface GroupForumThreadViewProps
|
||||
{
|
||||
groupId: number;
|
||||
threadId: number;
|
||||
initialThread?: GuildForumThread;
|
||||
forumData: ExtendedForumData;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const GroupForumThreadView: FC<GroupForumThreadViewProps> = props =>
|
||||
{
|
||||
const { groupId = 0, threadId = 0, initialThread = null, forumData = null, onBack = null } = props;
|
||||
const effectiveGroupId = forumData?.groupId || groupId;
|
||||
const [ messages, setMessages ] = useState<MessageData[]>([]);
|
||||
const [ totalMessages, setTotalMessages ] = useState<number>(0);
|
||||
const [ replyText, setReplyText ] = useState<string>('');
|
||||
const [ threadInfo, setThreadInfo ] = useState<GuildForumThread>(initialThread);
|
||||
const [ isSubmitting, setIsSubmitting ] = useState<boolean>(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useMessageEvent<ThreadMessagesMessageEvent>(ThreadMessagesMessageEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(parser.groupId !== effectiveGroupId || parser.threadId !== threadId) return;
|
||||
|
||||
setTotalMessages(parser.amount);
|
||||
|
||||
if(parser.startIndex === 0)
|
||||
{
|
||||
setMessages(parser.messages);
|
||||
}
|
||||
else
|
||||
{
|
||||
setMessages(prev => [ ...prev, ...parser.messages ]);
|
||||
}
|
||||
|
||||
// Mark messages as read
|
||||
if(parser.messages.length > 0)
|
||||
{
|
||||
const lastMessage = parser.messages[parser.messages.length - 1];
|
||||
SendMessageComposer(new UpdateForumReadMarkerMessageComposer(
|
||||
new UpdateForumReadMarkerEntry(effectiveGroupId, lastMessage.messageId, true)
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
useMessageEvent<PostMessageMessageEvent>(PostMessageMessageEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(parser.groupId !== effectiveGroupId || parser.threadId !== threadId) return;
|
||||
|
||||
setMessages(prev => [ ...prev, parser.message ]);
|
||||
});
|
||||
|
||||
useMessageEvent<PostThreadMessageEvent>(PostThreadMessageEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(parser.groupId !== effectiveGroupId) return;
|
||||
|
||||
// Update thread info if this is our thread
|
||||
if(parser.thread.threadId === threadId)
|
||||
{
|
||||
setThreadInfo(parser.thread);
|
||||
}
|
||||
});
|
||||
|
||||
useMessageEvent<UpdateMessageMessageEvent>(UpdateMessageMessageEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(parser.groupId !== effectiveGroupId || parser.threadId !== threadId) return;
|
||||
|
||||
setMessages(prev => prev.map(msg =>
|
||||
{
|
||||
if(msg.messageId === parser.message.messageId)
|
||||
{
|
||||
return parser.message;
|
||||
}
|
||||
|
||||
return msg;
|
||||
}));
|
||||
});
|
||||
|
||||
useMessageEvent<UpdateThreadMessageEvent>(UpdateThreadMessageEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(parser.groupId !== effectiveGroupId) return;
|
||||
|
||||
if(parser.thread.threadId === threadId)
|
||||
{
|
||||
setThreadInfo(parser.thread);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!effectiveGroupId || !threadId) return;
|
||||
|
||||
setMessages([]);
|
||||
SendMessageComposer(new GetMessagesMessageComposer(effectiveGroupId, threadId, 0, MESSAGES_PER_PAGE));
|
||||
}, [ effectiveGroupId, threadId ]);
|
||||
|
||||
const sendReply = useCallback(() =>
|
||||
{
|
||||
if(replyText.trim().length < 10 || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
SendMessageComposer(new PostMessageMessageComposer(effectiveGroupId, threadId, '', replyText.trim()));
|
||||
setReplyText('');
|
||||
|
||||
setTimeout(() => setIsSubmitting(false), 1000);
|
||||
}, [ effectiveGroupId, threadId, replyText, isSubmitting ]);
|
||||
|
||||
const togglePinThread = useCallback(() =>
|
||||
{
|
||||
if(!threadInfo) return;
|
||||
|
||||
// UpdateThreadMessageComposer swaps 3rd/4th params internally: (groupId, threadId, isLocked, isPinned)
|
||||
SendMessageComposer(new UpdateThreadMessageComposer(effectiveGroupId, threadId, threadInfo.isLocked, !threadInfo.isPinned));
|
||||
}, [ effectiveGroupId, threadId, threadInfo ]);
|
||||
|
||||
const toggleLockThread = useCallback(() =>
|
||||
{
|
||||
if(!threadInfo) return;
|
||||
|
||||
// UpdateThreadMessageComposer swaps 3rd/4th params internally: (groupId, threadId, isLocked, isPinned)
|
||||
SendMessageComposer(new UpdateThreadMessageComposer(effectiveGroupId, threadId, !threadInfo.isLocked, threadInfo.isPinned));
|
||||
}, [ effectiveGroupId, threadId, threadInfo ]);
|
||||
|
||||
const hideMessage = useCallback((messageId: number) =>
|
||||
{
|
||||
SendMessageComposer(new ModerateMessageMessageComposer(effectiveGroupId, threadId, messageId, STATE_HIDDEN_BY_ADMIN));
|
||||
}, [ effectiveGroupId, threadId ]);
|
||||
|
||||
const restoreMessage = useCallback((messageId: number) =>
|
||||
{
|
||||
SendMessageComposer(new ModerateMessageMessageComposer(effectiveGroupId, threadId, messageId, STATE_VISIBLE));
|
||||
}, [ effectiveGroupId, threadId ]);
|
||||
|
||||
const hideThread = useCallback(() =>
|
||||
{
|
||||
SendMessageComposer(new ModerateThreadMessageComposer(effectiveGroupId, threadId, STATE_HIDDEN_BY_ADMIN));
|
||||
onBack();
|
||||
}, [ effectiveGroupId, threadId, onBack ]);
|
||||
|
||||
const deleteThread = useCallback(() =>
|
||||
{
|
||||
SendMessageComposer(new ModerateThreadMessageComposer(effectiveGroupId, threadId, STATE_DELETED_BY_MODERATOR));
|
||||
onBack();
|
||||
}, [ effectiveGroupId, threadId, onBack ]);
|
||||
|
||||
const formatTimeAgo = (seconds: number): string =>
|
||||
{
|
||||
if(seconds < 60) return `${ seconds }s ${ LocalizeText('messageboard.time.ago') }`;
|
||||
if(seconds < 3600) return `${ Math.floor(seconds / 60) }m ${ LocalizeText('messageboard.time.ago') }`;
|
||||
if(seconds < 86400) return `${ Math.floor(seconds / 3600) }h ${ LocalizeText('messageboard.time.ago') }`;
|
||||
|
||||
return `${ Math.floor(seconds / 86400) }d ${ LocalizeText('messageboard.time.ago') }`;
|
||||
};
|
||||
|
||||
const getMessageStateText = (message: MessageData): string =>
|
||||
{
|
||||
if(message.state === STATE_HIDDEN_BY_ADMIN)
|
||||
{
|
||||
return LocalizeText('messageboard.message.hidden.by.admin');
|
||||
}
|
||||
|
||||
if(message.state === STATE_DELETED_BY_MODERATOR)
|
||||
{
|
||||
return LocalizeText('messageboard.message.permanently.deleted.by.moderator');
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const canModerate = forumData && forumData.hasModeratePermissionError;
|
||||
const canPost = forumData && forumData.hasPostMessagePermissionError;
|
||||
const isLocked = threadInfo ? threadInfo.isLocked : false;
|
||||
|
||||
// Derive thread info from first message if we don't have explicit thread info
|
||||
const threadHeader = (messages.length > 0 && messages[0]) ? messages[0].messageText : '';
|
||||
|
||||
return (
|
||||
<Column className="h-full" gap={ 0 }>
|
||||
<Flex className="bg-muted p-2 border-b" gap={ 2 } alignItems="center" justifyContent="between">
|
||||
<Flex gap={ 2 } alignItems="center">
|
||||
<Text pointer underline onClick={ onBack }>
|
||||
« { LocalizeText('groupforum.view.back') }
|
||||
</Text>
|
||||
</Flex>
|
||||
{ canModerate &&
|
||||
<Flex gap={ 1 }>
|
||||
<Button variant={ threadInfo?.isPinned ? 'warning' : 'outline-secondary' } className="btn-sm" onClick={ togglePinThread }>
|
||||
{ threadInfo?.isPinned ? LocalizeText('groupforum.thread.unpin') : LocalizeText('groupforum.thread.pin') }
|
||||
</Button>
|
||||
<Button variant={ isLocked ? 'danger' : 'outline-secondary' } className="btn-sm" onClick={ toggleLockThread }>
|
||||
{ isLocked ? LocalizeText('groupforum.thread.unlock') : LocalizeText('groupforum.thread.lock') }
|
||||
</Button>
|
||||
<Button variant="outline-danger" className="btn-sm" onClick={ hideThread }>
|
||||
{ LocalizeText('groupforum.thread.hide') }
|
||||
</Button>
|
||||
<Button variant="danger" className="btn-sm" onClick={ deleteThread }>
|
||||
{ LocalizeText('groupforum.thread.delete') }
|
||||
</Button>
|
||||
</Flex> }
|
||||
</Flex>
|
||||
<Column className="overflow-auto flex-1" gap={ 0 }>
|
||||
{ messages.map((message, index) =>
|
||||
{
|
||||
const stateText = getMessageStateText(message);
|
||||
|
||||
if(stateText && !canModerate)
|
||||
{
|
||||
return (
|
||||
<Flex key={ message.messageId } className="p-2 border-b bg-danger bg-opacity-10" alignItems="center">
|
||||
<Text small variant="muted">{ stateText }</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex key={ message.messageId }
|
||||
className={ `p-3 border-b ${ (message.state !== STATE_NORMAL) ? 'bg-danger bg-opacity-10' : '' }` }
|
||||
gap={ 3 }>
|
||||
<Column className="flex-shrink-0 items-center w-[50px]" gap={ 1 }>
|
||||
<div className="w-[40px] h-[40px] rounded-full mx-auto overflow-hidden bg-[rgba(255,255,255,0.1)] flex justify-center">
|
||||
<div className="mt-[-25px]">
|
||||
<LayoutAvatarImageView figure={ message.authorFigure } headOnly={ true } direction={ 2 } />
|
||||
</div>
|
||||
</div>
|
||||
<Text small bold pointer underline onClick={ () => GetUserProfile(message.authorId) }>
|
||||
{ message.authorName }
|
||||
</Text>
|
||||
<Text small variant="muted">{ message.authorPostCount } { LocalizeText('messageboard.messages') }</Text>
|
||||
</Column>
|
||||
<Column className="flex-1" gap={ 1 }>
|
||||
<Flex justifyContent="between" alignItems="center">
|
||||
<Text small variant="muted">{ formatTimeAgo(message.creationTime) }</Text>
|
||||
{ canModerate && (message.state !== STATE_NORMAL) &&
|
||||
<Flex gap={ 1 }>
|
||||
<Text small variant="muted">{ stateText }</Text>
|
||||
<Text small pointer underline variant="primary" onClick={ () => restoreMessage(message.messageId) }>
|
||||
{ LocalizeText('groupforum.message.restore') }
|
||||
</Text>
|
||||
</Flex> }
|
||||
{ canModerate && (message.state === STATE_NORMAL) &&
|
||||
<Text small pointer underline variant="danger" onClick={ () => hideMessage(message.messageId) }>
|
||||
{ LocalizeText('groupforum.message.hide') }
|
||||
</Text> }
|
||||
</Flex>
|
||||
{ (message.state === STATE_NORMAL || canModerate) &&
|
||||
<Text className="whitespace-pre-wrap break-words">{ message.messageText }</Text> }
|
||||
</Column>
|
||||
</Flex>
|
||||
);
|
||||
}) }
|
||||
{ (messages.length < totalMessages) &&
|
||||
<Flex justifyContent="center" className="p-2">
|
||||
<Text pointer underline onClick={ () =>
|
||||
{
|
||||
SendMessageComposer(new GetMessagesMessageComposer(effectiveGroupId, threadId, messages.length, MESSAGES_PER_PAGE));
|
||||
} }>
|
||||
{ LocalizeText('groupforum.thread.load_more') }
|
||||
</Text>
|
||||
</Flex> }
|
||||
<div ref={ messagesEndRef } />
|
||||
</Column>
|
||||
{ canPost && !isLocked &&
|
||||
<Flex className="p-2 border-t bg-light" gap={ 2 }>
|
||||
<textarea
|
||||
className="form-control form-control-sm flex-1"
|
||||
placeholder={ LocalizeText('messageboard.message.replying.to') }
|
||||
rows={ 2 }
|
||||
maxLength={ 4000 }
|
||||
value={ replyText }
|
||||
onChange={ e => setReplyText(e.target.value) }
|
||||
onKeyDown={ e =>
|
||||
{
|
||||
if(e.key === 'Enter' && !e.shiftKey)
|
||||
{
|
||||
e.preventDefault();
|
||||
sendReply();
|
||||
}
|
||||
} }
|
||||
/>
|
||||
<Button variant="primary" className="btn-sm align-self-end" onClick={ sendReply } disabled={ replyText.trim().length < 10 || isSubmitting }>
|
||||
{ LocalizeText('messageboard.reply.button') }
|
||||
</Button>
|
||||
</Flex> }
|
||||
{ isLocked &&
|
||||
<Flex className="p-2 border-t bg-warning bg-opacity-10" justifyContent="center">
|
||||
<Text small variant="muted">{ LocalizeText('groupforum.thread.locked') }</Text>
|
||||
</Flex> }
|
||||
{ !canPost && !isLocked && forumData &&
|
||||
<Flex className="p-2 border-t bg-muted" justifyContent="center">
|
||||
<Text small variant="muted">
|
||||
{ LocalizeText('groupforum.view.error.' + forumData.postMessagePermissionError) }
|
||||
</Text>
|
||||
</Flex> }
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
import { AddLinkEventTracker, ForumDataMessageEvent, GetForumStatsMessageComposer, GuildForumThread, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { FC, useCallback, useEffect, useState } from 'react';
|
||||
import { LocalizeText, SendMessageComposer } from '../../../../api';
|
||||
import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
|
||||
import { useMessageEvent } from '../../../../hooks';
|
||||
import { GroupForumThreadListView } from './GroupForumThreadListView';
|
||||
import { GroupForumThreadView } from './GroupForumThreadView';
|
||||
import { GroupForumNewThreadView } from './GroupForumNewThreadView';
|
||||
import { GroupForumSettingsView } from './GroupForumSettingsView';
|
||||
import { GroupForumListView } from './GroupForumListView';
|
||||
import { ExtendedForumData } from '@nitrots/nitro-renderer';
|
||||
|
||||
const VIEW_FORUM_LIST = 0;
|
||||
const VIEW_THREAD_LIST = 1;
|
||||
const VIEW_THREAD = 2;
|
||||
const VIEW_NEW_THREAD = 3;
|
||||
const VIEW_SETTINGS = 4;
|
||||
|
||||
export const GroupForumView: FC<{}> = props =>
|
||||
{
|
||||
const [ isVisible, setIsVisible ] = useState<boolean>(false);
|
||||
const [ currentView, setCurrentView ] = useState<number>(VIEW_FORUM_LIST);
|
||||
const [ groupId, setGroupId ] = useState<number>(0);
|
||||
const [ threadId, setThreadId ] = useState<number>(0);
|
||||
const [ currentThread, setCurrentThread ] = useState<GuildForumThread>(null);
|
||||
const [ forumData, setForumData ] = useState<ExtendedForumData>(null);
|
||||
|
||||
useMessageEvent<ForumDataMessageEvent>(ForumDataMessageEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
setForumData(parser.extendedForumData);
|
||||
});
|
||||
|
||||
const openForum = useCallback((id: number) =>
|
||||
{
|
||||
setGroupId(id);
|
||||
setCurrentView(VIEW_THREAD_LIST);
|
||||
setIsVisible(true);
|
||||
SendMessageComposer(new GetForumStatsMessageComposer(id));
|
||||
}, []);
|
||||
|
||||
const openThread = useCallback((gId: number, tId: number, thread: GuildForumThread = null) =>
|
||||
{
|
||||
setGroupId(gId);
|
||||
setThreadId(tId);
|
||||
setCurrentThread(thread);
|
||||
setCurrentView(VIEW_THREAD);
|
||||
}, []);
|
||||
|
||||
const openNewThread = useCallback(() =>
|
||||
{
|
||||
setCurrentView(VIEW_NEW_THREAD);
|
||||
}, []);
|
||||
|
||||
const openSettings = useCallback(() =>
|
||||
{
|
||||
setCurrentView(VIEW_SETTINGS);
|
||||
}, []);
|
||||
|
||||
const backToThreadList = useCallback(() =>
|
||||
{
|
||||
setCurrentView(VIEW_THREAD_LIST);
|
||||
setThreadId(0);
|
||||
}, []);
|
||||
|
||||
const backToForumList = useCallback(() =>
|
||||
{
|
||||
setCurrentView(VIEW_FORUM_LIST);
|
||||
setGroupId(0);
|
||||
setForumData(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const linkTracker: ILinkEventTracker = {
|
||||
linkReceived: (url: string) =>
|
||||
{
|
||||
const parts = url.split('/');
|
||||
|
||||
if(parts.length < 2) return;
|
||||
|
||||
switch(parts[1])
|
||||
{
|
||||
case 'toggle':
|
||||
setIsVisible(prev => !prev);
|
||||
if(!isVisible) setCurrentView(VIEW_FORUM_LIST);
|
||||
return;
|
||||
case 'show':
|
||||
setIsVisible(true);
|
||||
return;
|
||||
case 'hide':
|
||||
setIsVisible(false);
|
||||
return;
|
||||
default: {
|
||||
const id = parseInt(parts[1]);
|
||||
|
||||
if(!isNaN(id) && id > 0)
|
||||
{
|
||||
openForum(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
eventUrlPrefix: 'groupforum/'
|
||||
};
|
||||
|
||||
AddLinkEventTracker(linkTracker);
|
||||
|
||||
return () => RemoveLinkEventTracker(linkTracker);
|
||||
}, [ isVisible, openForum ]);
|
||||
|
||||
const getHeaderText = () =>
|
||||
{
|
||||
switch(currentView)
|
||||
{
|
||||
case VIEW_FORUM_LIST:
|
||||
return LocalizeText('groupforum.view.window_title');
|
||||
case VIEW_THREAD_LIST:
|
||||
return forumData ? forumData.name : LocalizeText('messageboard.forum.header');
|
||||
case VIEW_THREAD:
|
||||
return forumData ? forumData.name : LocalizeText('messageboard.forum.header');
|
||||
case VIEW_NEW_THREAD:
|
||||
return LocalizeText('messageboard.new.thread.button');
|
||||
case VIEW_SETTINGS:
|
||||
return LocalizeText('groupforum.settings.window_title');
|
||||
default:
|
||||
return LocalizeText('messageboard.forum.header');
|
||||
}
|
||||
};
|
||||
|
||||
if(!isVisible) return null;
|
||||
|
||||
return (
|
||||
<NitroCardView className="nitro-group-forum w-[600px] h-[500px]" theme="primary" uniqueKey="group-forum">
|
||||
<NitroCardHeaderView headerText={ getHeaderText() } onCloseClick={ () => setIsVisible(false) } />
|
||||
<NitroCardContentView overflow="hidden" className="p-0">
|
||||
{ (currentView === VIEW_FORUM_LIST) &&
|
||||
<GroupForumListView onOpenForum={ openForum } /> }
|
||||
{ (currentView === VIEW_THREAD_LIST) &&
|
||||
<GroupForumThreadListView
|
||||
groupId={ groupId }
|
||||
forumData={ forumData }
|
||||
onOpenThread={ openThread }
|
||||
onNewThread={ openNewThread }
|
||||
onOpenSettings={ openSettings }
|
||||
onBack={ backToForumList } /> }
|
||||
{ (currentView === VIEW_THREAD) &&
|
||||
<GroupForumThreadView
|
||||
groupId={ groupId }
|
||||
threadId={ threadId }
|
||||
initialThread={ currentThread }
|
||||
forumData={ forumData }
|
||||
onBack={ backToThreadList } /> }
|
||||
{ (currentView === VIEW_NEW_THREAD) &&
|
||||
<GroupForumNewThreadView
|
||||
groupId={ groupId }
|
||||
forumData={ forumData }
|
||||
onBack={ backToThreadList }
|
||||
onThreadCreated={ (tId: number) => openThread(groupId, tId) } /> }
|
||||
{ (currentView === VIEW_SETTINGS) &&
|
||||
<GroupForumSettingsView
|
||||
groupId={ groupId }
|
||||
forumData={ forumData }
|
||||
onBack={ backToThreadList } /> }
|
||||
</NitroCardContentView>
|
||||
</NitroCardView>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './GroupForumView';
|
||||
export * from './GroupForumListView';
|
||||
export * from './GroupForumNewThreadView';
|
||||
export * from './GroupForumSettingsView';
|
||||
export * from './GroupForumThreadListView';
|
||||
export * from './GroupForumThreadView';
|
||||
@@ -2,6 +2,7 @@ import { GroupSavePreferencesComposer } from '@nitrots/nitro-renderer';
|
||||
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useState } from 'react';
|
||||
import { IGroupData, LocalizeText, SendMessageComposer } from '../../../../api';
|
||||
import { Flex, HorizontalRule, Text } from '../../../../common';
|
||||
import { useNotification } from '../../../../hooks';
|
||||
|
||||
const STATES: string[] = [ 'regular', 'exclusive', 'private' ];
|
||||
|
||||
@@ -17,12 +18,30 @@ export const GroupTabSettingsView: FC<GroupTabSettingsViewProps> = props =>
|
||||
const { groupData = null, setGroupData = null, setCloseAction = null } = props;
|
||||
const [ groupState, setGroupState ] = useState<number>(groupData.groupState);
|
||||
const [ groupDecorate, setGroupDecorate ] = useState<boolean>(groupData.groupCanMembersDecorate);
|
||||
const [ groupForum, setGroupForum ] = useState<boolean>(groupData.groupHasForum ?? false);
|
||||
const { showConfirm = null } = useNotification();
|
||||
|
||||
const handleForumToggle = useCallback(() =>
|
||||
{
|
||||
if(groupForum)
|
||||
{
|
||||
// Disabling forum - show confirmation
|
||||
showConfirm(LocalizeText('group.forum.disable.confirm'), () =>
|
||||
{
|
||||
setGroupForum(false);
|
||||
}, null);
|
||||
}
|
||||
else
|
||||
{
|
||||
setGroupForum(true);
|
||||
}
|
||||
}, [ groupForum, showConfirm ]);
|
||||
|
||||
const saveSettings = useCallback(() =>
|
||||
{
|
||||
if(!groupData) return false;
|
||||
|
||||
if((groupState === groupData.groupState) && (groupDecorate === groupData.groupCanMembersDecorate)) return true;
|
||||
if((groupState === groupData.groupState) && (groupDecorate === groupData.groupCanMembersDecorate) && (groupForum === (groupData.groupHasForum ?? false))) return true;
|
||||
|
||||
if(groupData.groupId <= 0)
|
||||
{
|
||||
@@ -32,6 +51,7 @@ export const GroupTabSettingsView: FC<GroupTabSettingsViewProps> = props =>
|
||||
|
||||
newValue.groupState = groupState;
|
||||
newValue.groupCanMembersDecorate = groupDecorate;
|
||||
newValue.groupHasForum = groupForum;
|
||||
|
||||
return newValue;
|
||||
});
|
||||
@@ -39,15 +59,16 @@ export const GroupTabSettingsView: FC<GroupTabSettingsViewProps> = props =>
|
||||
return true;
|
||||
}
|
||||
|
||||
SendMessageComposer(new GroupSavePreferencesComposer(groupData.groupId, groupState, groupDecorate ? 0 : 1));
|
||||
SendMessageComposer(new GroupSavePreferencesComposer(groupData.groupId, groupState, groupDecorate ? 0 : 1, groupForum));
|
||||
|
||||
return true;
|
||||
}, [ groupData, groupState, groupDecorate, setGroupData ]);
|
||||
}, [ groupData, groupState, groupDecorate, groupForum, setGroupData ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
setGroupState(groupData.groupState);
|
||||
setGroupDecorate(groupData.groupCanMembersDecorate);
|
||||
setGroupForum(groupData.groupHasForum ?? false);
|
||||
}, [ groupData ]);
|
||||
|
||||
useEffect(() =>
|
||||
@@ -84,6 +105,14 @@ export const GroupTabSettingsView: FC<GroupTabSettingsViewProps> = props =>
|
||||
<Text>{ LocalizeText('group.edit.settings.rights.members.help') }</Text>
|
||||
</div>
|
||||
</div>
|
||||
<HorizontalRule />
|
||||
<div className="flex items-center gap-1">
|
||||
<input checked={ groupForum } className="form-check-input" type="checkbox" onChange={ handleForumToggle } />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Text bold>{ LocalizeText('group.forum.enable.caption') }</Text>
|
||||
<Text>{ LocalizeText('group.forum.enable.help') }</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CreateLinkEvent, GetRoomEngine, GetSessionDataManager, MouseEventType, RoomObjectCategory } from '@nitrots/nitro-renderer';
|
||||
import { Dispatch, FC, PropsWithChildren, SetStateAction, useEffect, useRef } from 'react';
|
||||
import { DispatchUiEvent, GetConfigurationValue, GetRoomSession, GetUserProfile } from '../../api';
|
||||
import { DispatchUiEvent, GetConfigurationValue, GetRoomSession, GetUserProfile, LocalizeText } from '../../api';
|
||||
import { Flex, LayoutItemCountView } from '../../common';
|
||||
import { GuideToolEvent } from '../../events';
|
||||
|
||||
@@ -43,6 +43,7 @@ export const ToolbarMeView: FC<PropsWithChildren<{
|
||||
<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-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 }
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -97,7 +97,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
{ isMod &&
|
||||
<ToolbarItemView icon="modtools" onClick={ event => CreateLinkEvent('mod-tools/toggle') } /> }
|
||||
{ isMod &&
|
||||
<ToolbarItemView icon="catalog" onClick={ event => CreateLinkEvent('furni-editor/toggle') } /> }
|
||||
<ToolbarItemView icon="furnieditor" onClick={ event => CreateLinkEvent('furni-editor/toggle') } /> }
|
||||
</Flex>
|
||||
<Flex alignItems="center" justifyContent="center" className="flex-1 min-w-0 max-w-[600px] mx-auto" id="toolbar-chat-input-container" />
|
||||
<Flex alignItems="center" gap={ 2 } className="flex-shrink-0">
|
||||
|
||||
@@ -43,6 +43,9 @@ export const UserContainerView: FC<{
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm leading-none" dangerouslySetInnerHTML={{ __html: LocalizeText('extendedprofile.created', ['created'], [userProfile.registration]) }} />
|
||||
<p className="text-sm leading-none" dangerouslySetInnerHTML={{ __html: LocalizeText('extendedprofile.last.login', ['lastlogin'], [FriendlyTime.format(userProfile.secondsSinceLastVisit, '.ago', 2)]) }} />
|
||||
<p className="text-sm leading-none">
|
||||
<b>{ LocalizeText('extendedprofile.friends.count') }</b> { userProfile.friendsCount }
|
||||
</p>
|
||||
<p className="text-sm leading-none">
|
||||
<b>{ LocalizeText('extendedprofile.achievementscore') }</b> { userProfile.achievementPoints }
|
||||
</p>
|
||||
|
||||
@@ -145,16 +145,16 @@ export const UserProfileView: FC<{}> = props =>
|
||||
</div>
|
||||
<NitroCard.Tabs>
|
||||
<NitroCard.TabItem isActive={ activeTab === 'badge' } count={ userBadges.length } onClick={ () => onTabClick('badge') }>
|
||||
Badge
|
||||
{ LocalizeText('extendedprofile.tab.badge') }
|
||||
</NitroCard.TabItem>
|
||||
<NitroCard.TabItem isActive={ activeTab === 'amici' } count={ userProfile.friendsCount } onClick={ () => onTabClick('amici') }>
|
||||
Amici
|
||||
{ LocalizeText('extendedprofile.tab.friends') }
|
||||
</NitroCard.TabItem>
|
||||
<NitroCard.TabItem isActive={ activeTab === 'stanze' } onClick={ () => onTabClick('stanze') }>
|
||||
Stanze
|
||||
{ LocalizeText('extendedprofile.tab.rooms') }
|
||||
</NitroCard.TabItem>
|
||||
<NitroCard.TabItem isActive={ activeTab === 'gruppi' } count={ userProfile.groups?.length } onClick={ () => onTabClick('gruppi') }>
|
||||
Gruppi
|
||||
{ LocalizeText('extendedprofile.tab.groups') }
|
||||
</NitroCard.TabItem>
|
||||
</NitroCard.Tabs>
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
@@ -166,7 +166,7 @@ export const UserProfileView: FC<{}> = props =>
|
||||
))
|
||||
: (
|
||||
<Flex center fullWidth className="h-full">
|
||||
<Text small variant="muted">Nessun badge da mostrare</Text>
|
||||
<Text small variant="muted">{ LocalizeText('extendedprofile.badge.empty') }</Text>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -178,7 +178,7 @@ export const UserProfileView: FC<{}> = props =>
|
||||
<FriendsContainerView friendsCount={ userProfile.friendsCount } relationships={ userRelationships } />
|
||||
) : (
|
||||
<Flex center className="h-full">
|
||||
<Text small variant="muted">Caricamento...</Text>
|
||||
<Text small variant="muted">{ LocalizeText('generic.loading') }</Text>
|
||||
</Flex>
|
||||
) }
|
||||
</div>
|
||||
@@ -187,12 +187,12 @@ export const UserProfileView: FC<{}> = props =>
|
||||
<div className="flex flex-col gap-1 h-full">
|
||||
{ !userRooms && (
|
||||
<Flex center className="h-full">
|
||||
<Text small variant="muted">Caricamento stanze...</Text>
|
||||
<Text small variant="muted">{ LocalizeText('extendedprofile.rooms.loading') }</Text>
|
||||
</Flex>
|
||||
) }
|
||||
{ userRooms && userRooms.length === 0 && (
|
||||
<Flex center className="h-full">
|
||||
<Text small variant="muted">Nessuna stanza trovata</Text>
|
||||
<Text small variant="muted">{ LocalizeText('extendedprofile.rooms.empty') }</Text>
|
||||
</Flex>
|
||||
) }
|
||||
{ userRooms && userRooms.length > 0 && userRooms.map(room => (
|
||||
|
||||
@@ -70,6 +70,12 @@
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.nitro-icon.icon-furnieditor {
|
||||
background-image: url("@/assets/images/toolbar/icons/furnieditor.png");
|
||||
width: 30px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.nitro-icon.icon-friendall {
|
||||
background-image: url("@/assets/images/toolbar/icons/friend_all.png");
|
||||
width: 32px;
|
||||
|
||||
+29
-247
@@ -30,51 +30,45 @@ body {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: .5rem
|
||||
width: .625rem;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar:horizontal {
|
||||
height: .5rem
|
||||
height: .625rem;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar:not(:horizontal) {
|
||||
width: .5rem
|
||||
width: .625rem;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track:horizontal {
|
||||
border-bottom: .25rem solid rgba(0, 0, 0, .1)
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, .08);
|
||||
border-radius: .5rem;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track:not(:horizontal) {
|
||||
border-right: .25rem solid rgba(0, 0, 0, .1)
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(30, 114, 149, .35);
|
||||
border-radius: .5rem;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:horizontal {
|
||||
border-bottom: .25rem solid rgba(30, 114, 149, .4)
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(30, 114, 149, .6);
|
||||
border-radius: .5rem;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:horizontal:hover {
|
||||
border-bottom: .25rem solid rgba(30, 114, 149, .8)
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:horizontal:active {
|
||||
border-bottom: .25rem solid #185D79
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:not(:horizontal) {
|
||||
border-right: .25rem solid rgba(30, 114, 149, .4)
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:not(:horizontal):hover {
|
||||
border-right: .25rem solid rgba(30, 114, 149, .8)
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:not(:horizontal):active {
|
||||
border-right: .25rem solid #185D79
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background: #185D79;
|
||||
border-radius: .5rem;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: rgba(0, 0, 0, .1)
|
||||
background: rgba(0, 0, 0, .08);
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@@ -447,219 +441,7 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.nitro-avatar-editor-spritesheet {
|
||||
background: url('@/assets/images/avatareditor/avatar-editor-spritesheet.png') transparent no-repeat;
|
||||
|
||||
&.arrow-left-icon {
|
||||
width: 28px;
|
||||
height: 21px;
|
||||
background-position: -226px -131px;
|
||||
}
|
||||
|
||||
&.arrow-right-icon {
|
||||
width: 28px;
|
||||
height: 21px;
|
||||
background-position: -226px -162px;
|
||||
}
|
||||
|
||||
&.ca-icon {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
background-position: -226px -61px;
|
||||
|
||||
&.selected {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
background-position: -226px -96px;
|
||||
}
|
||||
}
|
||||
|
||||
&.cc-icon {
|
||||
width: 31px;
|
||||
height: 29px;
|
||||
background-position: -145px -5px;
|
||||
|
||||
&.selected {
|
||||
width: 31px;
|
||||
height: 29px;
|
||||
background-position: -145px -44px;
|
||||
}
|
||||
}
|
||||
|
||||
&.ch-icon {
|
||||
width: 29px;
|
||||
height: 24px;
|
||||
background-position: -186px -39px;
|
||||
|
||||
&.selected {
|
||||
width: 29px;
|
||||
height: 24px;
|
||||
background-position: -186px -73px;
|
||||
}
|
||||
}
|
||||
|
||||
&.clear-icon {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
background-position: -145px -157px;
|
||||
}
|
||||
|
||||
&.cp-icon {
|
||||
width: 30px;
|
||||
height: 24px;
|
||||
background-position: -145px -264px;
|
||||
|
||||
&.selected {
|
||||
width: 30px;
|
||||
height: 24px;
|
||||
background-position: -186px -5px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
&.ea-icon {
|
||||
width: 35px;
|
||||
height: 16px;
|
||||
background-position: -226px -193px;
|
||||
|
||||
&.selected {
|
||||
width: 35px;
|
||||
height: 16px;
|
||||
background-position: -226px -219px;
|
||||
}
|
||||
}
|
||||
|
||||
&.fa-icon {
|
||||
width: 27px;
|
||||
height: 20px;
|
||||
background-position: -186px -137px;
|
||||
|
||||
&.selected {
|
||||
width: 27px;
|
||||
height: 20px;
|
||||
background-position: -186px -107px;
|
||||
}
|
||||
}
|
||||
|
||||
&.female-icon {
|
||||
width: 18px;
|
||||
height: 27px;
|
||||
background-position: -186px -202px;
|
||||
|
||||
&.selected {
|
||||
width: 18px;
|
||||
height: 27px;
|
||||
background-position: -186px -239px;
|
||||
}
|
||||
}
|
||||
|
||||
&.ha-icon {
|
||||
width: 25px;
|
||||
height: 22px;
|
||||
background-position: -226px -245px;
|
||||
|
||||
&.selected {
|
||||
width: 25px;
|
||||
height: 22px;
|
||||
background-position: -226px -277px;
|
||||
}
|
||||
}
|
||||
|
||||
&.he-icon {
|
||||
width: 31px;
|
||||
height: 27px;
|
||||
background-position: -145px -83px;
|
||||
|
||||
&.selected {
|
||||
width: 31px;
|
||||
height: 27px;
|
||||
background-position: -145px -120px;
|
||||
}
|
||||
}
|
||||
|
||||
&.hr-icon {
|
||||
width: 29px;
|
||||
height: 25px;
|
||||
background-position: -145px -194px;
|
||||
|
||||
&.selected {
|
||||
width: 29px;
|
||||
height: 25px;
|
||||
background-position: -145px -229px;
|
||||
}
|
||||
}
|
||||
|
||||
&.lg-icon {
|
||||
width: 19px;
|
||||
height: 20px;
|
||||
background-position: -303px -45px;
|
||||
|
||||
&.selected {
|
||||
width: 19px;
|
||||
height: 20px;
|
||||
background-position: -303px -75px;
|
||||
}
|
||||
}
|
||||
|
||||
&.loading-icon {
|
||||
width: 21px;
|
||||
height: 25px;
|
||||
background-position: -186px -167px;
|
||||
}
|
||||
|
||||
|
||||
&.male-icon {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
background-position: -186px -276px;
|
||||
|
||||
&.selected {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
background-position: -272px -5px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
&.sellable-icon {
|
||||
width: 17px;
|
||||
height: 15px;
|
||||
background-position: -303px -105px;
|
||||
}
|
||||
|
||||
|
||||
&.sh-icon {
|
||||
width: 37px;
|
||||
height: 10px;
|
||||
background-position: -303px -5px;
|
||||
|
||||
&.selected {
|
||||
width: 37px;
|
||||
height: 10px;
|
||||
background-position: -303px -25px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
&.spotlight-icon {
|
||||
width: 130px;
|
||||
height: 305px;
|
||||
background-position: -5px -5px;
|
||||
}
|
||||
|
||||
|
||||
&.wa-icon {
|
||||
width: 36px;
|
||||
height: 18px;
|
||||
background-position: -226px -5px;
|
||||
|
||||
&.selected {
|
||||
width: 36px;
|
||||
height: 18px;
|
||||
background-position: -226px -33px;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* Avatar editor icons are now rendered as <img> tags via AvatarEditorIcon.tsx */
|
||||
|
||||
.nitro-avatar-editor-wardrobe-figure-preview {
|
||||
background-color: #677181;
|
||||
@@ -710,7 +492,7 @@ body {
|
||||
|
||||
|
||||
.category-item {
|
||||
height: 40px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.figure-preview-container {
|
||||
@@ -1093,12 +875,12 @@ body {
|
||||
|
||||
.avatar-parts {
|
||||
border: none !important;
|
||||
height: 42px;
|
||||
width: 42px;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
width: 100%;
|
||||
max-width: 42px;
|
||||
border-radius: 2rem !important;
|
||||
overflow: visible !important;
|
||||
overflow: hidden !important;
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { FurniEditorBySpriteComposer, FurniEditorDeleteComposer, FurniEditorDetailComposer, FurniEditorDetailResultEvent, FurniEditorInteractionsComposer, FurniEditorInteractionsResultEvent, FurniEditorResultEvent, FurniEditorSearchComposer, FurniEditorSearchResultEvent, FurniEditorUpdateComposer } from '@nitrots/nitro-renderer';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { NotificationAlertType, SendMessageComposer } from '../../api';
|
||||
import { useMessageEvent, useNotification } from '../../hooks';
|
||||
|
||||
export interface FurniItem
|
||||
{
|
||||
@@ -46,18 +49,6 @@ export interface CatalogRef
|
||||
pageName: string;
|
||||
}
|
||||
|
||||
const API_BASE = '/api/admin/furni-editor';
|
||||
|
||||
async function apiFetch<T>(url: string, options?: RequestInit): Promise<T>
|
||||
{
|
||||
const res = await fetch(url, { credentials: 'include', ...options });
|
||||
const data = await res.json();
|
||||
|
||||
if(!res.ok || data.error) throw new Error(data.error || 'API error');
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export const useFurniEditor = () =>
|
||||
{
|
||||
const [ items, setItems ] = useState<FurniItem[]>([]);
|
||||
@@ -69,171 +60,198 @@ export const useFurniEditor = () =>
|
||||
const [ catalogItems, setCatalogItems ] = useState<CatalogRef[]>([]);
|
||||
const [ interactions, setInteractions ] = useState<string[]>([]);
|
||||
const [ furniDataEntry, setFurniDataEntry ] = useState<Record<string, unknown> | null>(null);
|
||||
const pendingActionRef = useRef<string | null>(null);
|
||||
const { simpleAlert = null } = useNotification();
|
||||
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
const searchItems = useCallback(async (query: string, type: string, pg: number) =>
|
||||
// Handle search results
|
||||
useMessageEvent(FurniEditorSearchResultEvent, (event: FurniEditorSearchResultEvent) =>
|
||||
{
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const parser = event.getParser();
|
||||
|
||||
try
|
||||
{
|
||||
const params = new URLSearchParams({ q: query, limit: '20', page: String(pg) });
|
||||
|
||||
if(type) params.set('type', type);
|
||||
|
||||
const data = await apiFetch<{ items: FurniItem[]; total: number; page: number }>(`${ API_BASE }?${ params }`);
|
||||
|
||||
setItems(data.items);
|
||||
setTotal(data.total);
|
||||
setPage(data.page);
|
||||
}
|
||||
catch(e: any)
|
||||
{
|
||||
setError(e.message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadDetail = useCallback(async (id: number): Promise<boolean> =>
|
||||
{
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try
|
||||
{
|
||||
const data = await apiFetch<{ item: FurniDetail; catalogItems: CatalogRef[]; furniDataEntry: Record<string, unknown> | null }>(`${ API_BASE }/detail?id=${ id }`);
|
||||
|
||||
setSelectedItem(data.item);
|
||||
setCatalogItems(data.catalogItems);
|
||||
setFurniDataEntry(data.furniDataEntry);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch(e: any)
|
||||
{
|
||||
setError(e.message);
|
||||
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateItem = useCallback(async (id: number, fields: Record<string, unknown>) =>
|
||||
{
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try
|
||||
{
|
||||
await apiFetch(`${ API_BASE }/update?id=${ id }`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fields)
|
||||
setItems(parser.items.map(item => ({
|
||||
id: item.id,
|
||||
spriteId: item.spriteId,
|
||||
itemName: item.itemName,
|
||||
publicName: item.publicName,
|
||||
type: item.type,
|
||||
width: item.width,
|
||||
length: item.length,
|
||||
stackHeight: item.stackHeight,
|
||||
allowStack: item.allowStack,
|
||||
allowWalk: item.allowWalk,
|
||||
allowSit: item.allowSit,
|
||||
allowLay: item.allowLay,
|
||||
interactionType: item.interactionType,
|
||||
interactionModesCount: item.interactionModesCount
|
||||
})));
|
||||
setTotal(parser.total);
|
||||
setPage(parser.page);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
catch(e: any)
|
||||
// Handle detail results (for both detail and by-sprite lookups)
|
||||
useMessageEvent(FurniEditorDetailResultEvent, (event: FurniEditorDetailResultEvent) =>
|
||||
{
|
||||
setError(e.message);
|
||||
const parser = event.getParser();
|
||||
const item = parser.item;
|
||||
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
setSelectedItem({
|
||||
id: item.id,
|
||||
spriteId: item.spriteId,
|
||||
itemName: item.itemName,
|
||||
publicName: item.publicName,
|
||||
type: item.type,
|
||||
width: item.width,
|
||||
length: item.length,
|
||||
stackHeight: item.stackHeight,
|
||||
allowStack: item.allowStack,
|
||||
allowWalk: item.allowWalk,
|
||||
allowSit: item.allowSit,
|
||||
allowLay: item.allowLay,
|
||||
interactionType: item.interactionType,
|
||||
interactionModesCount: item.interactionModesCount,
|
||||
allowGift: item.allowGift,
|
||||
allowTrade: item.allowTrade,
|
||||
allowRecycle: item.allowRecycle,
|
||||
allowMarketplaceSell: item.allowMarketplaceSell,
|
||||
allowInventoryStack: item.allowInventoryStack,
|
||||
vendingIds: item.vendingIds,
|
||||
customparams: item.customparams,
|
||||
effectIdMale: item.effectIdMale,
|
||||
effectIdFemale: item.effectIdFemale,
|
||||
clothingOnWalk: item.clothingOnWalk,
|
||||
multiheight: item.multiheight,
|
||||
description: item.description,
|
||||
usageCount: item.usageCount
|
||||
});
|
||||
setCatalogItems(parser.catalogItems.map(ref => ({
|
||||
id: ref.id,
|
||||
catalogName: ref.catalogName,
|
||||
costCredits: ref.costCredits,
|
||||
costPoints: ref.costPoints,
|
||||
pointsType: ref.pointsType,
|
||||
pageId: ref.pageId,
|
||||
pageName: ref.pageName
|
||||
})));
|
||||
|
||||
const createItem = useCallback(async (fields: Record<string, unknown>) =>
|
||||
{
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
let furniData: Record<string, unknown> | null = null;
|
||||
|
||||
try
|
||||
{
|
||||
const data = await apiFetch<{ id: number }>(`${ API_BASE }`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fields)
|
||||
if(parser.furniDataJson && parser.furniDataJson !== '{}' && parser.furniDataJson !== '')
|
||||
{
|
||||
furniData = JSON.parse(parser.furniDataJson);
|
||||
}
|
||||
}
|
||||
catch(e) {}
|
||||
|
||||
setFurniDataEntry(furniData);
|
||||
});
|
||||
|
||||
return data.id;
|
||||
}
|
||||
catch(e: any)
|
||||
// Handle interaction types list
|
||||
useMessageEvent(FurniEditorInteractionsResultEvent, (event: FurniEditorInteractionsResultEvent) =>
|
||||
{
|
||||
setError(e.message);
|
||||
setInteractions(event.getParser().interactions);
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
// Handle operation results (update, create, delete)
|
||||
useMessageEvent(FurniEditorResultEvent, (event: FurniEditorResultEvent) =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const action = pendingActionRef.current;
|
||||
|
||||
pendingActionRef.current = null;
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteItem = useCallback(async (id: number) =>
|
||||
if(!parser.success)
|
||||
{
|
||||
setError(parser.message || 'Operation failed');
|
||||
|
||||
if(simpleAlert)
|
||||
{
|
||||
simpleAlert(parser.message || 'Operation failed', NotificationAlertType.ALERT, null, null, 'Furni Editor Error');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
if(action === 'update')
|
||||
{
|
||||
// Auto-reload detail after update
|
||||
if(selectedItem)
|
||||
{
|
||||
SendMessageComposer(new FurniEditorDetailComposer(selectedItem.id));
|
||||
}
|
||||
|
||||
if(simpleAlert)
|
||||
{
|
||||
simpleAlert('Item updated successfully', NotificationAlertType.DEFAULT, null, null, 'Furni Editor');
|
||||
}
|
||||
}
|
||||
else if(action === 'delete')
|
||||
{
|
||||
setSelectedItem(null);
|
||||
setCatalogItems([]);
|
||||
setFurniDataEntry(null);
|
||||
|
||||
if(simpleAlert)
|
||||
{
|
||||
simpleAlert('Item deleted successfully', NotificationAlertType.DEFAULT, null, null, 'Furni Editor');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const searchItems = useCallback((query: string, type: string, pg: number) =>
|
||||
{
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try
|
||||
{
|
||||
await apiFetch(`${ API_BASE }/delete?id=${ id }`, { method: 'POST' });
|
||||
|
||||
return true;
|
||||
}
|
||||
catch(e: any)
|
||||
{
|
||||
setError(e.message);
|
||||
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoading(false);
|
||||
}
|
||||
SendMessageComposer(new FurniEditorSearchComposer(query, type, pg));
|
||||
}, []);
|
||||
|
||||
const loadInteractions = useCallback(async () =>
|
||||
const loadDetail = useCallback((id: number) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const data = await apiFetch<{ interactions: Array<string | { name: string }> }>(`${ API_BASE }/interactions`);
|
||||
|
||||
setInteractions(data.interactions.map(i => typeof i === 'string' ? i : i.name));
|
||||
}
|
||||
catch {}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
SendMessageComposer(new FurniEditorDetailComposer(id));
|
||||
}, []);
|
||||
|
||||
const loadBySpriteId = useCallback(async (spriteId: number): Promise<boolean> =>
|
||||
const loadBySpriteId = useCallback((spriteId: number) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const data = await apiFetch<{ id: number }>(`${ API_BASE }/by-sprite?spriteId=${ spriteId }`);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
SendMessageComposer(new FurniEditorBySpriteComposer(spriteId));
|
||||
}, []);
|
||||
|
||||
return await loadDetail(data.id);
|
||||
}
|
||||
catch(e: any)
|
||||
const updateItem = useCallback((id: number, fields: Record<string, unknown>) =>
|
||||
{
|
||||
setError(e.message);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
pendingActionRef.current = 'update';
|
||||
SendMessageComposer(new FurniEditorUpdateComposer(id, JSON.stringify(fields)));
|
||||
}, []);
|
||||
|
||||
return false;
|
||||
}
|
||||
}, [ loadDetail ]);
|
||||
const deleteItem = useCallback((id: number) =>
|
||||
{
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
pendingActionRef.current = 'delete';
|
||||
SendMessageComposer(new FurniEditorDeleteComposer(id));
|
||||
}, []);
|
||||
|
||||
const loadInteractions = useCallback(() =>
|
||||
{
|
||||
SendMessageComposer(new FurniEditorInteractionsComposer());
|
||||
}, []);
|
||||
|
||||
return {
|
||||
items, total, page, loading, error, clearError,
|
||||
selectedItem, setSelectedItem, catalogItems, furniDataEntry,
|
||||
interactions,
|
||||
searchItems, loadDetail, loadBySpriteId, updateItem, createItem, deleteItem, loadInteractions
|
||||
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions
|
||||
};
|
||||
};
|
||||
|
||||
@@ -132,7 +132,12 @@ const InfiniteGridItem = forwardRef<HTMLDivElement, {
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!itemImage || !itemImage.length) return;
|
||||
if(!itemImage || !itemImage.length)
|
||||
{
|
||||
setBackgroundImageUrl(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const image = new Image();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user