Merge remote-tracking branch 'upstream/main' into feature/pr-20260327

# Conflicts:
#	public/UITexts.example
This commit is contained in:
Lorenzune
2026-03-31 09:14:35 +02:00
34 changed files with 1771 additions and 614 deletions
+15 -1
View File
@@ -60,5 +60,19 @@
"wiredfurni.params.action.sign.14": "Cartello 14", "wiredfurni.params.action.sign.14": "Cartello 14",
"wiredfurni.params.action.sign.15": "Cartello 15", "wiredfurni.params.action.sign.15": "Cartello 15",
"wiredfurni.params.action.sign.16": "Cartello 16", "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!"
} }
+2 -18
View File
@@ -109,22 +109,6 @@ export class AvatarEditorThumbnailsHelper
container.addChild(sprite); 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; return container;
}; };
@@ -133,9 +117,9 @@ export class AvatarEditorThumbnailsHelper
const resetFigure = async (figure: string) => const resetFigure = async (figure: string) =>
{ {
const container = buildContainer(part, useColors, partColors, isDisabled); 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); resolve(imageUrl);
}; };
+1
View File
@@ -8,6 +8,7 @@ export interface IGroupData
groupHomeroomId: number; groupHomeroomId: number;
groupState: number; groupState: number;
groupCanMembersDecorate: boolean; groupCanMembersDecorate: boolean;
groupHasForum: boolean;
groupColors: number[]; groupColors: number[];
groupBadgeParts: GroupBadgePart[]; groupBadgeParts: GroupBadgePart[];
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 629 B

+2
View File
@@ -13,6 +13,7 @@ import { FurniEditorView } from './furni-editor/FurniEditorView';
import { FriendsView } from './friends/FriendsView'; import { FriendsView } from './friends/FriendsView';
import { GameCenterView } from './game-center/GameCenterView'; import { GameCenterView } from './game-center/GameCenterView';
import { GroupsView } from './groups/GroupsView'; import { GroupsView } from './groups/GroupsView';
import { GroupForumView } from './groups/views/forums/GroupForumView';
import { GuideToolView } from './guide-tool/GuideToolView'; import { GuideToolView } from './guide-tool/GuideToolView';
import { HcCenterView } from './hc-center/HcCenterView'; import { HcCenterView } from './hc-center/HcCenterView';
import { HelpView } from './help/HelpView'; import { HelpView } from './help/HelpView';
@@ -113,6 +114,7 @@ export const MainView: FC<{}> = props =>
<UserSettingsView /> <UserSettingsView />
<UserProfileView /> <UserProfileView />
<GroupsView /> <GroupsView />
<GroupForumView />
<CameraWidgetView /> <CameraWidgetView />
<HelpView /> <HelpView />
<NitropediaView /> <NitropediaView />
@@ -1,45 +1,81 @@
import { DetailedHTMLProps, HTMLAttributes, PropsWithChildren, forwardRef } from 'react'; import { DetailedHTMLProps, HTMLAttributes, PropsWithChildren, forwardRef } from 'react';
import { classNames } from '../../layout'; 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<{ export const AvatarEditorIcon = forwardRef<HTMLDivElement, PropsWithChildren<{
icon: AvatarIconType; icon: string;
selected?: boolean; selected?: boolean;
}> & DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>>((props, ref) => }> & 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;
/* const iconEntry = icon ? ICON_MAP[icon] : null;
switch (icon)
{
case 'male':
if(!iconEntry) return null;
break; const src = (selected && iconEntry.selected) ? iconEntry.selected : iconEntry.normal;
case 'arrow-left':
break;
default:
//statements;
break;
}
*/
return ( return (
<div <div
ref={ ref } ref={ ref }
className={ classNames('flex items-center justify-center cursor-pointer', className) }
className={ classNames( { ...rest }>
'nitro-avatar-editor-spritesheet', <img src={ src } alt={ icon } className="h-[22px] w-auto object-contain pointer-events-none" draggable={ false } />
'cursor-pointer', { children }
`${ icon }-icon`, </div>
selected && 'selected',
className
) }
{ ...rest } />
); );
}); });
@@ -1,4 +1,3 @@
import { AvatarEditorFigureCategory, AvatarFigurePartType } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react'; import { FC, useEffect, useState } from 'react';
import { AvatarEditorThumbnailsHelper, GetClubMemberLevel, GetConfigurationValue, IAvatarEditorCategoryPartItem } from '../../../api'; import { AvatarEditorThumbnailsHelper, GetClubMemberLevel, GetConfigurationValue, IAvatarEditorCategoryPartItem } from '../../../api';
import { LayoutCurrencyIcon, LayoutGridItemProps } from '../../../common'; 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 { setType = null, partItem = null, isSelected = false, width = '100%', ...rest } = props;
const [ assetUrl, setAssetUrl ] = useState<string>(''); 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 clubLevel = partItem.partSet?.clubLevel ?? 0;
const isHC = !GetConfigurationValue<boolean>('hc.disabled', false) && (clubLevel > 0); const isHC = !GetConfigurationValue<boolean>('hc.disabled', false) && (clubLevel > 0);
@@ -24,7 +23,9 @@ export const AvatarEditorFigureSetItemView: FC<{
useEffect(() => useEffect(() =>
{ {
if(!setType || !setType.length || !partItem) return; setAssetUrl('');
if(!setType || !setType.length || !partItem || partItem.isClear) return;
const loadImage = async () => const loadImage = async () =>
{ {
@@ -34,7 +35,7 @@ export const AvatarEditorFigureSetItemView: FC<{
let url: string = null; let url: string = null;
if(setType === AvatarFigurePartType.HEAD && activeModelKey !== AvatarEditorFigureCategory.NFT) if(setType === 'hd')
{ {
url = await AvatarEditorThumbnailsHelper.buildForFace(getFigureStringWithFace(partItem.id), partIsLocked || isSellableNotOwned); url = await AvatarEditorThumbnailsHelper.buildForFace(getFigureStringWithFace(partItem.id), partIsLocked || isSellableNotOwned);
} }
@@ -53,12 +54,27 @@ export const AvatarEditorFigureSetItemView: FC<{
}; };
loadImage(); loadImage();
}, [ setType, partItem, selectedColorParts, getFigureStringWithFace, isSellableNotOwned, activeModelKey ]); }, [ setType, partItem, selectedColorParts, getFigureStringWithFace, isSellableNotOwned ]);
if(!partItem) return null; if(!partItem) return null;
const isHead = (setType === 'hd');
return ( 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 && isHC && <LayoutCurrencyIcon className="absolute inset-e-1 bottom-1" type="hc" /> }
{ partItem.isClear && <AvatarEditorIcon icon="clear" /> } { partItem.isClear && <AvatarEditorIcon icon="clear" /> }
{ !partItem.isClear && partItem.partSet.isSellable && !isSellableNotOwned && <AvatarEditorIcon className="inset-e-1 bottom-1 absolute" icon="sellable" /> } { !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<{ export const AvatarEditorFigureSetView: FC<{
category: IAvatarEditorCategory; category: IAvatarEditorCategory;
columnCount: number; columnCount: number;
estimateSize?: number;
}> = props => }> = props =>
{ {
const { category = null, columnCount = 3 } = props; const { category = null, columnCount = 3, estimateSize = 50 } = props;
const { selectedParts = null, selectEditorPart } = useAvatarEditor(); const { selectedParts = null, selectEditorPart } = useAvatarEditor();
const isPartItemSelected = (partItem: IAvatarEditorCategoryPartItem) => const isPartItemSelected = (partItem: IAvatarEditorCategoryPartItem) =>
@@ -29,7 +30,7 @@ export const AvatarEditorFigureSetView: FC<{
}; };
return ( return (
<InfiniteGrid<IAvatarEditorCategoryPartItem> columnCount={ columnCount } estimateSize={ 50 } itemRender={ (item: IAvatarEditorCategoryPartItem) => <InfiniteGrid<IAvatarEditorCategoryPartItem> columnCount={ columnCount } estimateSize={ estimateSize } itemRender={ (item: IAvatarEditorCategoryPartItem) =>
{ {
if(!item) return null; if(!item) return null;
+2 -2
View File
@@ -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' }` } 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) } 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' }` } /> <FaHeart className={ `text-xs ${ showFavorites ? 'text-white' : totalFavs > 0 ? 'text-danger' : 'text-muted' }` } />
{ totalFavs > 0 && { 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"> <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); 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 } /> <CatalogIconView icon={ child.iconId } />
{ isHidden && <FaEyeSlash className="absolute -bottom-0.5 -right-0.5 text-[7px] text-danger" /> } { isHidden && <FaEyeSlash className="absolute -bottom-0.5 -right-0.5 text-[7px] text-danger" /> }
</div> </div>
@@ -1,20 +1,20 @@
import { FC, useMemo } from 'react'; import { FC, useMemo } from 'react';
import { GetConfigurationValue } from '../../../../api'; import { GetConfigurationValue } from '../../../../api';
import { LayoutImage } from '../../../../common';
export interface CatalogIconViewProps export interface CatalogIconViewProps
{ {
icon: number; icon: number;
className?: string;
} }
export const CatalogIconView: FC<CatalogIconViewProps> = props => 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())); return ((GetConfigurationValue<string>('catalog.asset.icon.url')).replace('%name%', icon.toString()));
}, [ icon ]); }, [ 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 } title={ node.localization }
onClick={ onClick } onClick={ onClick }
> >
<div className="w-[30px] h-[30px] flex items-center justify-center shrink-0"> <div className="w-8 h-8 flex items-center justify-center shrink-0">
<CatalogIconView icon={ node.iconId } /> <CatalogIconView icon={ node.iconId } className="w-6 h-6" />
</div> </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' }` }> <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 } { node.localization }
@@ -121,7 +121,7 @@ export const CatalogFavoritesView: FC<CatalogFavoritesViewProps> = props =>
onClick={ () => { openPageByOfferId(fav.offerId); onClose(); } } onClick={ () => { openPageByOfferId(fav.offerId); onClose(); } }
> >
{ /* Furni icon */ } { /* 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 { fav.iconUrl
? <img className="max-w-full max-h-full object-contain image-rendering-pixelated" src={ fav.iconUrl } /> ? <img className="max-w-full max-h-full object-contain image-rendering-pixelated" src={ fav.iconUrl } />
: fav.nodeIconId !== null : fav.nodeIconId !== null
@@ -86,7 +86,7 @@ export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = pro
> >
{ adminMode && { adminMode &&
<FaArrowsAlt className="text-[7px] text-muted cursor-grab shrink-0 opacity-0 group-hover/nav:opacity-60" /> } <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 } /> <CatalogIconView icon={ node.iconId } />
</div> </div>
<span className="flex-1 truncate" title={ adminMode ? `Page ID: ${ node.pageId }` : undefined }>{ node.localization }</span> <span className="flex-1 truncate" title={ adminMode ? `Page ID: ${ node.pageId }` : undefined }>{ node.localization }</span>
+39 -15
View File
@@ -1,5 +1,6 @@
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useState } from 'react'; import { FC, useCallback, useEffect, useState } from 'react';
import { GetSessionDataManager } from '../../api';
import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common'; import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
import { useFurniEditor } from '../../hooks/furni-editor'; import { useFurniEditor } from '../../hooks/furni-editor';
import { FurniEditorEditView } from './views/FurniEditorEditView'; import { FurniEditorEditView } from './views/FurniEditorEditView';
@@ -15,13 +16,23 @@ export const FurniEditorView: FC<{}> = () =>
const { const {
items, total, page, loading, error, clearError, items, total, page, loading, error, clearError,
selectedItem, catalogItems, furniDataEntry, selectedItem, setSelectedItem, furniDataEntry,
interactions, interactions,
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions
} = useFurniEditor(); } = useFurniEditor();
const isMod = GetSessionDataManager()?.isModerator;
// Auto-switch to edit tab when an item is selected
useEffect(() => useEffect(() =>
{ {
if(selectedItem) setActiveTab(TAB_EDIT);
}, [ selectedItem ]);
useEffect(() =>
{
if(!isMod) return;
const linkTracker: ILinkEventTracker = { const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) => linkReceived: (url: string) =>
{ {
@@ -48,47 +59,62 @@ export const FurniEditorView: FC<{}> = () =>
AddLinkEventTracker(linkTracker); AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker); return () => RemoveLinkEventTracker(linkTracker);
}, []); }, [ isMod ]);
useEffect(() => useEffect(() =>
{ {
if(isVisible) loadInteractions(); if(isVisible) loadInteractions();
}, [ isVisible ]); }, [ isVisible ]);
// Escape to close
useEffect(() => 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 { spriteId } = e.detail;
const ok = await loadBySpriteId(spriteId); setIsVisible(true);
loadBySpriteId(spriteId);
if(ok) setActiveTab(TAB_EDIT);
}; };
window.addEventListener('furni-editor:open', handler as EventListener); window.addEventListener('furni-editor:open', handler as EventListener);
return () => window.removeEventListener('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); loadDetail(id);
if(ok) setActiveTab(TAB_EDIT);
}, [ loadDetail ]); }, [ loadDetail ]);
const handleBack = useCallback(() => const handleBack = useCallback(() =>
{ {
setSelectedItem(null);
setActiveTab(TAB_SEARCH); setActiveTab(TAB_SEARCH);
}, []); }, [ setSelectedItem ]);
const handleClose = useCallback(() => const handleClose = useCallback(() =>
{ {
setIsVisible(false); setIsVisible(false);
}, []); }, []);
if(!isVisible) return null; if(!isVisible || !isMod) return null;
return ( return (
<NitroCardView uniqueKey="furni-editor" className="w-[620px] h-[520px]"> <NitroCardView uniqueKey="furni-editor" className="w-[620px] h-[520px]">
@@ -123,14 +149,12 @@ export const FurniEditorView: FC<{}> = () =>
{ activeTab === TAB_EDIT && selectedItem && { activeTab === TAB_EDIT && selectedItem &&
<FurniEditorEditView <FurniEditorEditView
item={ selectedItem } item={ selectedItem }
catalogItems={ catalogItems }
furniDataEntry={ furniDataEntry } furniDataEntry={ furniDataEntry }
interactions={ interactions } interactions={ interactions }
loading={ loading } loading={ loading }
onUpdate={ updateItem } onUpdate={ updateItem }
onDelete={ deleteItem } onDelete={ deleteItem }
onBack={ handleBack } onBack={ handleBack }
onRefresh={ loadDetail }
/> />
} }
@@ -1,23 +1,72 @@
import { FC, useCallback, useEffect, useState } from 'react'; import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, Column, Flex, Text } from '../../../common'; import { Button, Column, Flex, LayoutFurniIconImageView, Text } from '../../../common';
import { CatalogRef, FurniDetail } from '../../../hooks/furni-editor'; import { FurniDetail } from '../../../hooks/furni-editor';
interface FurniEditorEditViewProps interface FurniEditorEditViewProps
{ {
item: FurniDetail; item: FurniDetail;
catalogItems: CatalogRef[];
furniDataEntry: Record<string, unknown> | null; furniDataEntry: Record<string, unknown> | null;
interactions: string[]; interactions: string[];
loading: boolean; loading: boolean;
onUpdate: (id: number, fields: Record<string, unknown>) => Promise<boolean>; onUpdate: (id: number, fields: Record<string, unknown>) => void;
onDelete: (id: number) => Promise<boolean>; onDelete: (id: number) => void;
onBack: () => 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 => 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({ const [ form, setForm ] = useState({
itemName: '', itemName: '',
@@ -41,7 +90,7 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
customparams: '', customparams: '',
}); });
const [ confirmDelete, setConfirmDelete ] = useState(false); const [ showDeleteDialog, setShowDeleteDialog ] = useState(false);
useEffect(() => useEffect(() =>
{ {
@@ -69,7 +118,7 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
customparams: item.customparams || '', customparams: item.customparams || '',
}); });
setConfirmDelete(false); setShowDeleteDialog(false);
}, [ item ]); }, [ item ]);
const setField = useCallback((key: string, value: unknown) => const setField = useCallback((key: string, value: unknown) =>
@@ -77,113 +126,183 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
setForm(prev => ({ ...prev, [key]: value })); 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); return form.itemName !== (item.itemName || '') ||
}, [ item, form, onUpdate, onRefresh ]); 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(); return errors;
}, [ confirmDelete, item, onDelete, onBack ]); }, [ form ]);
const inputClass = 'form-control form-control-sm'; const isValid = useMemo(() => Object.keys(validation).length === 0, [ validation ]);
const labelClass = 'text-[11px] font-bold text-[#333] mb-0';
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 ( return (
<Column gap={ 1 } className="h-full overflow-auto"> <Column gap={ 1 } className="h-full overflow-auto">
<Flex gap={ 1 } alignItems="center" className="mb-1"> { /* Header */ }
<Button variant="secondary" onClick={ onBack }>Back</Button> <Flex gap={ 2 } alignItems="center" className="mb-1">
<Flex alignItems="center" gap={ 1 } className="bg-[#e9ecef] px-2 py-0.5 rounded"> <Button variant="secondary" onClick={ handleBack }>Back</Button>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-3 h-3 text-[#1e7295]"> <div className="bg-[#e9ecef] rounded border border-[#ccc] flex items-center justify-center w-[48px] h-[48px]">
<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" /> <LayoutFurniIconImageView productType={ item.type } productClassId={ item.spriteId } className="scale-150" />
</svg> </div>
<Text bold className="text-[12px]">{ item.id }</Text> <Flex column gap={ 0 }>
<span className="text-[#999] mx-0.5">|</span> <Flex alignItems="center" gap={ 1 }>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-3 h-3 text-[#1e7295]"> <Text bold className="text-[12px]">ID: { item.id }</Text>
<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" /> <span className="text-[#999]">|</span>
</svg> <Text bold className="text-[12px]">Sprite: { item.spriteId }</Text>
<Text bold className="text-[12px]">{ item.spriteId }</Text> </Flex>
<Text small variant="gray">({ item.usageCount } in use)</Text>
</Flex> </Flex>
<Text small variant="gray">({ item.usageCount } in use)</Text> { isDirty && <span className="text-[10px] text-orange-500 font-bold ml-auto">Unsaved changes</span> }
</Flex> </Flex>
{ /* Basic Info */ } <Section title="Basic Info">
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Basic Info</Text>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<label className={ labelClass }>Item Name</label> <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>
<div> <div>
<label className={ labelClass }>Public Name</label> <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>
<div> <div>
<label className={ labelClass }>Sprite ID</label> <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>
<div> <div>
<label className={ labelClass }>Type</label> <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="s">Floor (s)</option>
<option value="i">Wall (i)</option> <option value="i">Wall (i)</option>
</select> </select>
</div> </div>
</div> </div>
</div> </Section>
{ /* Dimensions */ } <Section title="Dimensions">
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Dimensions</Text>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
<div> <div>
<label className={ labelClass }>Width</label> <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>
<div> <div>
<label className={ labelClass }>Length</label> <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>
<div> <div>
<label className={ labelClass }>Stack Height</label> <label className={ labelClass }>Stack Height<Tip field="stackHeight" /></label>
<input type="number" step="0.01" className={ inputClass } value={ form.stackHeight } onChange={ e => setField('stackHeight', Number(e.target.value)) } /> <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>
</div> </div>
</div> </Section>
{ /* Permissions */ } <Section title="Permissions">
<div className="bg-white rounded border border-[#ccc] p-2"> <div className="flex flex-col gap-2">
<Text small bold variant="primary" className="mb-1 block">Permissions</Text> { PERM_GROUPS.map(group => (
<div className="grid grid-cols-3 gap-x-3 gap-y-1"> <div key={ group.label }>
{ [ 'allowStack', 'allowWalk', 'allowSit', 'allowLay', 'allowGift', 'allowTrade', 'allowRecycle', 'allowMarketplaceSell', 'allowInventoryStack' ].map(key => ( <Text className="text-[10px] font-bold text-[#666] uppercase tracking-wider mb-0.5 block">{ group.label }</Text>
<label key={ key } className="flex items-center gap-1 text-[11px] cursor-pointer"> <div className="grid grid-cols-4 gap-x-3 gap-y-1">
<input { group.keys.map(key => (
type="checkbox" <label key={ key } className="flex items-center gap-1 text-[11px] cursor-pointer">
className="form-check-input" <input
checked={ (form as any)[key] } type="checkbox"
onChange={ e => setField(key, e.target.checked) } className="mt-1"
/> checked={ (form as any)[key] }
{ key.replace('allow', '') } onChange={ e => setField(key, e.target.checked) }
</label> />
{ key.replace('allow', '') }
</label>
)) }
</div>
</div>
)) } )) }
</div> </div>
</div> </Section>
{ /* Interaction */ } <Section title="Interaction">
<div className="bg-white rounded border border-[#ccc] p-2">
<Text small bold variant="primary" className="mb-1 block">Interaction</Text>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
<div className="col-span-2"> <div className="col-span-2">
<label className={ labelClass }>Type</label> <label className={ labelClass }>Type<Tip field="interactionType" /></label>
<select className="form-select form-select-sm" value={ form.interactionType } onChange={ e => setField('interactionType', e.target.value) }> <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> <option value="">none</option>
{ interactions.map(i => ( { interactions.map(i => (
<option key={ i } value={ i }>{ i }</option> <option key={ i } value={ i }>{ i }</option>
@@ -191,35 +310,18 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
</select> </select>
</div> </div>
<div> <div>
<label className={ labelClass }>Modes</label> <label className={ labelClass }>Modes<Tip field="interactionModesCount" /></label>
<input type="number" className={ inputClass } value={ form.interactionModesCount } onChange={ e => setField('interactionModesCount', Number(e.target.value)) } /> <input type="number" className={ inputClass() } value={ form.interactionModesCount } onChange={ e => setField('interactionModesCount', Number(e.target.value)) } />
</div> </div>
</div> </div>
<div className="mt-1"> <div className="mt-1">
<label className={ labelClass }>Custom Params</label> <label className={ labelClass }>Custom Params<Tip field="customparams" /></label>
<input className={ inputClass } value={ form.customparams } onChange={ e => setField('customparams', e.target.value) } /> <input className={ inputClass() } value={ form.customparams } onChange={ e => setField('customparams', e.target.value) } />
</div> </div>
</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 && { furniDataEntry &&
<div className="bg-white rounded border border-[#ccc] p-2"> <Section title="FurniData.json" defaultOpen={ false }>
<Text small bold variant="primary" className="mb-1 block">FurniData.json</Text>
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-[10px]"> <div className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-[10px]">
{ Object.entries(furniDataEntry).map(([ key, value ]) => ( { Object.entries(furniDataEntry).map(([ key, value ]) => (
<div key={ key } className="flex justify-between bg-[#f5f5f5] px-2 py-0.5 rounded"> <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> </div>
</div> </Section>
} }
{ /* Actions */ } { /* Actions */ }
<Flex gap={ 1 } justifyContent="between" className="mt-1"> <Flex gap={ 1 } justifyContent="between" alignItems="center" className="mt-1">
<Button variant="success" disabled={ loading } onClick={ handleSave }> <Flex gap={ 1 } alignItems="center">
{ loading ? 'Saving...' : 'Save' } <Button variant="success" disabled={ loading || !isValid || !isDirty } onClick={ handleSave }>
</Button> { loading ? 'Saving...' : 'Save' }
</Button>
<span className="text-[9px] text-[#999]">Ctrl+S</span>
</Flex>
<Button <Button
variant={ confirmDelete ? 'danger' : 'warning' } variant="danger"
disabled={ loading || item.usageCount > 0 } disabled={ loading || item.usageCount > 0 }
onClick={ handleDelete } onClick={ () => setShowDeleteDialog(true) }
> >
{ confirmDelete ? 'Confirm Delete' : 'Delete' } Delete
</Button> </Button>
</Flex> </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> </Column>
); );
}; };
@@ -1,5 +1,5 @@
import { FC, useCallback, useEffect, useState } from 'react'; import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Column, Flex, Text } from '../../../common'; import { Button, Column, Flex, LayoutFurniIconImageView, Text } from '../../../common';
import { FurniItem } from '../../../hooks/furni-editor'; import { FurniItem } from '../../../hooks/furni-editor';
interface FurniEditorSearchViewProps interface FurniEditorSearchViewProps
@@ -12,11 +12,23 @@ interface FurniEditorSearchViewProps
onSelect: (id: number) => void; 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 => export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
{ {
const { items, total, page, loading, onSearch, onSelect } = props; const { items, total, page, loading, onSearch, onSelect } = props;
const [ query, setQuery ] = useState(''); const [ query, setQuery ] = useState('');
const [ typeFilter, setTypeFilter ] = useState(''); const [ typeFilter, setTypeFilter ] = useState('');
const [ sortField, setSortField ] = useState<SortField>('id');
const [ sortDir, setSortDir ] = useState<SortDir>('asc');
useEffect(() => useEffect(() =>
{ {
@@ -33,6 +45,45 @@ export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
if(e.key === 'Enter') handleSearch(); if(e.key === 'Enter') handleSearch();
}, [ 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); const totalPages = Math.ceil(total / 20);
return ( return (
@@ -42,55 +93,80 @@ export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
<Text small bold>Search</Text> <Text small bold>Search</Text>
<input <input
type="text" 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..." placeholder="ID, name or sprite ID..."
value={ query } value={ query }
onChange={ e => setQuery(e.target.value) } onChange={ e => setQuery(e.target.value) }
onKeyDown={ handleKeyDown } onKeyDown={ handleKeyDown }
/> />
</Column> </Column>
<Column gap={ 0 } className="w-[80px]"> <Flex gap={ 1 }>
<Text small bold>Type</Text> { [ '', 's', 'i' ].map(t => (
<select <button
className="form-select form-select-sm" key={ t || 'all' }
value={ typeFilter } className={ `px-2 py-1 text-[11px] rounded border cursor-pointer transition-colors ${
onChange={ e => setTypeFilter(e.target.value) } typeFilter === t
> ? 'bg-[#1e7295] text-white border-[#1e7295]'
<option value="">All</option> : 'bg-white text-[#333] border-[#ccc] hover:bg-[#f0f0f0]'
<option value="s">Floor (s)</option> }` }
<option value="i">Wall (i)</option> onClick={ () => handleTypeToggle(t) }
</select> >
</Column> { t === '' ? 'All' : t === 's' ? 'Floor' : 'Wall' }
</button>
)) }
</Flex>
<Button variant="primary" disabled={ loading } onClick={ handleSearch }> <Button variant="primary" disabled={ loading } onClick={ handleSearch }>
{ loading ? '...' : 'Search' } { loading ? '...' : 'Search' }
</Button> </Button>
</Flex> </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"> <Column gap={ 0 } className="flex-1 overflow-auto border border-[#ccc] rounded bg-white">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead> <thead>
<tr className="bg-[#e8e8e8] sticky top-0"> <tr className="bg-[#e8e8e8] sticky top-0 select-none">
<th className="px-2 py-1 text-left">ID</th> <th className="px-1 py-1 text-center w-[50px]"></th>
<th className="px-2 py-1 text-left">Sprite</th> <th className="px-2 py-1 text-left cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('id') }>
<th className="px-2 py-1 text-left">Name</th> ID<SortArrow field="id" active={ sortField } dir={ sortDir } />
<th className="px-2 py-1 text-left">Public Name</th> </th>
<th className="px-2 py-1 text-center">Type</th> <th className="px-2 py-1 text-left cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('spriteId') }>
<th className="px-2 py-1 text-left">Interaction</th> 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> </tr>
</thead> </thead>
<tbody> <tbody>
{ items.map(item => ( { sortedItems.map(item => (
<tr <tr
key={ item.id } key={ item.id }
className="cursor-pointer hover:bg-[#d4edfa] border-b border-[#eee] transition-colors" className="cursor-pointer hover:bg-[#d4edfa] border-b border-[#eee] transition-colors"
onClick={ () => onSelect(item.id) } 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.id }</td>
<td className="px-2 py-1 font-mono">{ item.spriteId }</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]" title={ item.itemName }>{ 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.publicName }>{ item.publicName }</td>
<td className="px-2 py-1 text-center"> <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' } { item.type === 's' ? 'Floor' : 'Wall' }
</span> </span>
</td> </td>
@@ -99,7 +175,7 @@ export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
)) } )) }
{ items.length === 0 && !loading && { items.length === 0 && !loading &&
<tr> <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> </tr>
} }
</tbody> </tbody>
@@ -109,7 +185,7 @@ export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
{ totalPages > 1 && { totalPages > 1 &&
<Flex gap={ 1 } justifyContent="between" alignItems="center"> <Flex gap={ 1 } justifyContent="between" alignItems="center">
<Text small variant="gray"> <Text small variant="gray">
{ total } items - Page { page }/{ totalPages } Page { page }/{ totalPages }
</Text> </Text>
<Flex gap={ 1 }> <Flex gap={ 1 }>
<Button <Button
@@ -94,6 +94,9 @@ export const GroupInformationView: FC<GroupInformationViewProps> = props =>
case 'popular_groups': case 'popular_groups':
CreateLinkEvent('navigator/search/groups'); CreateLinkEvent('navigator/search/groups');
break; 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('homeroom') }>{ LocalizeText('group.linktobase') }</Text>
<Text pointer small underline onClick={ () => handleAction('furniture') }>{ LocalizeText('group.buyfurni') }</Text> <Text pointer small underline onClick={ () => handleAction('furniture') }>{ LocalizeText('group.buyfurni') }</Text>
<Text pointer small underline onClick={ () => handleAction('popular_groups') }>{ LocalizeText('group.showgroups') }</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> </div>
{ (groupInformation.type !== GroupType.PRIVATE || groupInformation.type === GroupType.PRIVATE && groupInformation.membershipType === GroupMembershipType.MEMBER) && { (groupInformation.type !== GroupType.PRIVATE || groupInformation.type === GroupType.PRIVATE && groupInformation.membershipType === GroupMembershipType.MEMBER) &&
<Button disabled={ (groupInformation.membershipType === GroupMembershipType.REQUEST_PENDING) || isRealOwner } onClick={ handleButtonClick }> <Button disabled={ (groupInformation.membershipType === GroupMembershipType.REQUEST_PENDING) || isRealOwner } onClick={ handleButtonClick }>
@@ -77,6 +77,7 @@ export const GroupManagerView: FC<{}> = props =>
groupHomeroomId: parser.roomId, groupHomeroomId: parser.roomId,
groupState: parser.state, groupState: parser.state,
groupCanMembersDecorate: parser.canMembersDecorate, groupCanMembersDecorate: parser.canMembersDecorate,
groupHasForum: parser.hasForum,
groupColors: [ parser.colorA, parser.colorB ], groupColors: [ parser.colorA, parser.colorB ],
groupBadgeParts groupBadgeParts
}); });
@@ -85,7 +86,7 @@ export const GroupManagerView: FC<{}> = props =>
if(!groupData || (groupData.groupId <= 0)) return null; if(!groupData || (groupData.groupId <= 0)) return null;
return ( return (
<NitroCardView className="nitro-group-manager"> <NitroCardView className="nitro-group-manager w-[560px]">
<NitroCardHeaderView headerText={ LocalizeText('group.window.title') } onCloseClick={ onClose } /> <NitroCardHeaderView headerText={ LocalizeText('group.window.title') } onCloseClick={ onClose } />
<NitroCardTabsView> <NitroCardTabsView>
{ TABS.map(tab => { 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 }>
&laquo; { 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 }>
&laquo; { 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 }>
&laquo; { 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 }>
&laquo; { 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 { Dispatch, FC, SetStateAction, useCallback, useEffect, useState } from 'react';
import { IGroupData, LocalizeText, SendMessageComposer } from '../../../../api'; import { IGroupData, LocalizeText, SendMessageComposer } from '../../../../api';
import { Flex, HorizontalRule, Text } from '../../../../common'; import { Flex, HorizontalRule, Text } from '../../../../common';
import { useNotification } from '../../../../hooks';
const STATES: string[] = [ 'regular', 'exclusive', 'private' ]; 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 { groupData = null, setGroupData = null, setCloseAction = null } = props;
const [ groupState, setGroupState ] = useState<number>(groupData.groupState); const [ groupState, setGroupState ] = useState<number>(groupData.groupState);
const [ groupDecorate, setGroupDecorate ] = useState<boolean>(groupData.groupCanMembersDecorate); 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(() => const saveSettings = useCallback(() =>
{ {
if(!groupData) return false; 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) if(groupData.groupId <= 0)
{ {
@@ -32,6 +51,7 @@ export const GroupTabSettingsView: FC<GroupTabSettingsViewProps> = props =>
newValue.groupState = groupState; newValue.groupState = groupState;
newValue.groupCanMembersDecorate = groupDecorate; newValue.groupCanMembersDecorate = groupDecorate;
newValue.groupHasForum = groupForum;
return newValue; return newValue;
}); });
@@ -39,15 +59,16 @@ export const GroupTabSettingsView: FC<GroupTabSettingsViewProps> = props =>
return true; return true;
} }
SendMessageComposer(new GroupSavePreferencesComposer(groupData.groupId, groupState, groupDecorate ? 0 : 1)); SendMessageComposer(new GroupSavePreferencesComposer(groupData.groupId, groupState, groupDecorate ? 0 : 1, groupForum));
return true; return true;
}, [ groupData, groupState, groupDecorate, setGroupData ]); }, [ groupData, groupState, groupDecorate, groupForum, setGroupData ]);
useEffect(() => useEffect(() =>
{ {
setGroupState(groupData.groupState); setGroupState(groupData.groupState);
setGroupDecorate(groupData.groupCanMembersDecorate); setGroupDecorate(groupData.groupCanMembersDecorate);
setGroupForum(groupData.groupHasForum ?? false);
}, [ groupData ]); }, [ groupData ]);
useEffect(() => useEffect(() =>
@@ -84,6 +105,14 @@ export const GroupTabSettingsView: FC<GroupTabSettingsViewProps> = props =>
<Text>{ LocalizeText('group.edit.settings.rights.members.help') }</Text> <Text>{ LocalizeText('group.edit.settings.rights.members.help') }</Text>
</div> </div>
</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> </div>
); );
}; };
+2 -1
View File
@@ -1,6 +1,6 @@
import { CreateLinkEvent, GetRoomEngine, GetSessionDataManager, MouseEventType, RoomObjectCategory } from '@nitrots/nitro-renderer'; import { CreateLinkEvent, GetRoomEngine, GetSessionDataManager, MouseEventType, RoomObjectCategory } from '@nitrots/nitro-renderer';
import { Dispatch, FC, PropsWithChildren, SetStateAction, useEffect, useRef } from 'react'; 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 { Flex, LayoutItemCountView } from '../../common';
import { GuideToolEvent } from '../../events'; 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-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-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-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 } { children }
</Flex> </Flex>
); );
+1 -1
View File
@@ -97,7 +97,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
{ isMod && { isMod &&
<ToolbarItemView icon="modtools" onClick={ event => CreateLinkEvent('mod-tools/toggle') } /> } <ToolbarItemView icon="modtools" onClick={ event => CreateLinkEvent('mod-tools/toggle') } /> }
{ isMod && { isMod &&
<ToolbarItemView icon="catalog" onClick={ event => CreateLinkEvent('furni-editor/toggle') } /> } <ToolbarItemView icon="furnieditor" onClick={ event => CreateLinkEvent('furni-editor/toggle') } /> }
</Flex> </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" 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"> <Flex alignItems="center" gap={ 2 } className="flex-shrink-0">
@@ -43,6 +43,9 @@ export const UserContainerView: FC<{
<div className="flex flex-col gap-1"> <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.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" 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"> <p className="text-sm leading-none">
<b>{ LocalizeText('extendedprofile.achievementscore') }</b> { userProfile.achievementPoints } <b>{ LocalizeText('extendedprofile.achievementscore') }</b> { userProfile.achievementPoints }
</p> </p>
@@ -145,16 +145,16 @@ export const UserProfileView: FC<{}> = props =>
</div> </div>
<NitroCard.Tabs> <NitroCard.Tabs>
<NitroCard.TabItem isActive={ activeTab === 'badge' } count={ userBadges.length } onClick={ () => onTabClick('badge') }> <NitroCard.TabItem isActive={ activeTab === 'badge' } count={ userBadges.length } onClick={ () => onTabClick('badge') }>
Badge { LocalizeText('extendedprofile.tab.badge') }
</NitroCard.TabItem> </NitroCard.TabItem>
<NitroCard.TabItem isActive={ activeTab === 'amici' } count={ userProfile.friendsCount } onClick={ () => onTabClick('amici') }> <NitroCard.TabItem isActive={ activeTab === 'amici' } count={ userProfile.friendsCount } onClick={ () => onTabClick('amici') }>
Amici { LocalizeText('extendedprofile.tab.friends') }
</NitroCard.TabItem> </NitroCard.TabItem>
<NitroCard.TabItem isActive={ activeTab === 'stanze' } onClick={ () => onTabClick('stanze') }> <NitroCard.TabItem isActive={ activeTab === 'stanze' } onClick={ () => onTabClick('stanze') }>
Stanze { LocalizeText('extendedprofile.tab.rooms') }
</NitroCard.TabItem> </NitroCard.TabItem>
<NitroCard.TabItem isActive={ activeTab === 'gruppi' } count={ userProfile.groups?.length } onClick={ () => onTabClick('gruppi') }> <NitroCard.TabItem isActive={ activeTab === 'gruppi' } count={ userProfile.groups?.length } onClick={ () => onTabClick('gruppi') }>
Gruppi { LocalizeText('extendedprofile.tab.groups') }
</NitroCard.TabItem> </NitroCard.TabItem>
</NitroCard.Tabs> </NitroCard.Tabs>
<div className="flex-1 overflow-auto p-2"> <div className="flex-1 overflow-auto p-2">
@@ -166,7 +166,7 @@ export const UserProfileView: FC<{}> = props =>
)) ))
: ( : (
<Flex center fullWidth className="h-full"> <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> </Flex>
) )
} }
@@ -178,7 +178,7 @@ export const UserProfileView: FC<{}> = props =>
<FriendsContainerView friendsCount={ userProfile.friendsCount } relationships={ userRelationships } /> <FriendsContainerView friendsCount={ userProfile.friendsCount } relationships={ userRelationships } />
) : ( ) : (
<Flex center className="h-full"> <Flex center className="h-full">
<Text small variant="muted">Caricamento...</Text> <Text small variant="muted">{ LocalizeText('generic.loading') }</Text>
</Flex> </Flex>
) } ) }
</div> </div>
@@ -187,12 +187,12 @@ export const UserProfileView: FC<{}> = props =>
<div className="flex flex-col gap-1 h-full"> <div className="flex flex-col gap-1 h-full">
{ !userRooms && ( { !userRooms && (
<Flex center className="h-full"> <Flex center className="h-full">
<Text small variant="muted">Caricamento stanze...</Text> <Text small variant="muted">{ LocalizeText('extendedprofile.rooms.loading') }</Text>
</Flex> </Flex>
) } ) }
{ userRooms && userRooms.length === 0 && ( { userRooms && userRooms.length === 0 && (
<Flex center className="h-full"> <Flex center className="h-full">
<Text small variant="muted">Nessuna stanza trovata</Text> <Text small variant="muted">{ LocalizeText('extendedprofile.rooms.empty') }</Text>
</Flex> </Flex>
) } ) }
{ userRooms && userRooms.length > 0 && userRooms.map(room => ( { userRooms && userRooms.length > 0 && userRooms.map(room => (
+6
View File
@@ -70,6 +70,12 @@
height: 34px; height: 34px;
} }
.nitro-icon.icon-furnieditor {
background-image: url("@/assets/images/toolbar/icons/furnieditor.png");
width: 30px;
height: 34px;
}
.nitro-icon.icon-friendall { .nitro-icon.icon-friendall {
background-image: url("@/assets/images/toolbar/icons/friend_all.png"); background-image: url("@/assets/images/toolbar/icons/friend_all.png");
width: 32px; width: 32px;
+29 -247
View File
@@ -30,51 +30,45 @@ body {
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
width: .5rem width: .625rem;
} }
::-webkit-scrollbar:horizontal { ::-webkit-scrollbar:horizontal {
height: .5rem height: .625rem;
} }
::-webkit-scrollbar:not(:horizontal) { ::-webkit-scrollbar:not(:horizontal) {
width: .5rem width: .625rem;
} }
::-webkit-scrollbar-track:horizontal { ::-webkit-scrollbar-track {
border-bottom: .25rem solid rgba(0, 0, 0, .1) background: rgba(0, 0, 0, .08);
border-radius: .5rem;
} }
::-webkit-scrollbar-track:not(:horizontal) { ::-webkit-scrollbar-thumb {
border-right: .25rem solid rgba(0, 0, 0, .1) background: rgba(30, 114, 149, .35);
border-radius: .5rem;
border: 2px solid transparent;
background-clip: padding-box;
} }
::-webkit-scrollbar-thumb:horizontal { ::-webkit-scrollbar-thumb:hover {
border-bottom: .25rem solid rgba(30, 114, 149, .4) background: rgba(30, 114, 149, .6);
border-radius: .5rem;
border: 2px solid transparent;
background-clip: padding-box;
} }
::-webkit-scrollbar-thumb:horizontal:hover { ::-webkit-scrollbar-thumb:active {
border-bottom: .25rem solid rgba(30, 114, 149, .8) background: #185D79;
} border-radius: .5rem;
border: 2px solid transparent;
::-webkit-scrollbar-thumb:horizontal:active { background-clip: padding-box;
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-corner { ::-webkit-scrollbar-corner {
background: rgba(0, 0, 0, .1) background: rgba(0, 0, 0, .08);
} }
@layer components { @layer components {
@@ -447,219 +441,7 @@ body {
} }
} }
.nitro-avatar-editor-spritesheet { /* Avatar editor icons are now rendered as <img> tags via AvatarEditorIcon.tsx */
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;
}
}
}
.nitro-avatar-editor-wardrobe-figure-preview { .nitro-avatar-editor-wardrobe-figure-preview {
background-color: #677181; background-color: #677181;
@@ -710,7 +492,7 @@ body {
.category-item { .category-item {
height: 40px; height: 32px;
} }
.figure-preview-container { .figure-preview-container {
@@ -1093,12 +875,12 @@ body {
.avatar-parts { .avatar-parts {
border: none !important; border: none !important;
height: 42px; position: relative;
width: 42px; aspect-ratio: 1;
background-position: center; width: 100%;
background-repeat: no-repeat; max-width: 42px;
border-radius: 2rem !important; border-radius: 2rem !important;
overflow: visible !important; overflow: hidden !important;
background-color: transparent; background-color: transparent;
&:hover { &:hover {
+162 -144
View File
@@ -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 export interface FurniItem
{ {
@@ -46,18 +49,6 @@ export interface CatalogRef
pageName: string; 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 = () => export const useFurniEditor = () =>
{ {
const [ items, setItems ] = useState<FurniItem[]>([]); const [ items, setItems ] = useState<FurniItem[]>([]);
@@ -69,171 +60,198 @@ export const useFurniEditor = () =>
const [ catalogItems, setCatalogItems ] = useState<CatalogRef[]>([]); const [ catalogItems, setCatalogItems ] = useState<CatalogRef[]>([]);
const [ interactions, setInteractions ] = useState<string[]>([]); const [ interactions, setInteractions ] = useState<string[]>([]);
const [ furniDataEntry, setFurniDataEntry ] = useState<Record<string, unknown> | null>(null); 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 clearError = useCallback(() => setError(null), []);
const searchItems = useCallback(async (query: string, type: string, pg: number) => // Handle search results
useMessageEvent(FurniEditorSearchResultEvent, (event: FurniEditorSearchResultEvent) =>
{
const parser = event.getParser();
setLoading(false);
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);
});
// Handle detail results (for both detail and by-sprite lookups)
useMessageEvent(FurniEditorDetailResultEvent, (event: FurniEditorDetailResultEvent) =>
{
const parser = event.getParser();
const item = parser.item;
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
})));
let furniData: Record<string, unknown> | null = null;
try
{
if(parser.furniDataJson && parser.furniDataJson !== '{}' && parser.furniDataJson !== '')
{
furniData = JSON.parse(parser.furniDataJson);
}
}
catch(e) {}
setFurniDataEntry(furniData);
});
// Handle interaction types list
useMessageEvent(FurniEditorInteractionsResultEvent, (event: FurniEditorInteractionsResultEvent) =>
{
setInteractions(event.getParser().interactions);
});
// Handle operation results (update, create, delete)
useMessageEvent(FurniEditorResultEvent, (event: FurniEditorResultEvent) =>
{
const parser = event.getParser();
const action = pendingActionRef.current;
pendingActionRef.current = null;
setLoading(false);
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); setLoading(true);
setError(null); setError(null);
SendMessageComposer(new FurniEditorSearchComposer(query, type, pg));
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> => const loadDetail = useCallback((id: number) =>
{ {
setLoading(true); setLoading(true);
setError(null); setError(null);
SendMessageComposer(new FurniEditorDetailComposer(id));
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>) => const loadBySpriteId = useCallback((spriteId: number) =>
{ {
setLoading(true); setLoading(true);
setError(null); setError(null);
SendMessageComposer(new FurniEditorBySpriteComposer(spriteId));
try
{
await apiFetch(`${ API_BASE }/update?id=${ id }`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fields)
});
return true;
}
catch(e: any)
{
setError(e.message);
return false;
}
finally
{
setLoading(false);
}
}, []); }, []);
const createItem = useCallback(async (fields: Record<string, unknown>) => const updateItem = useCallback((id: number, fields: Record<string, unknown>) =>
{ {
setLoading(true); setLoading(true);
setError(null); setError(null);
pendingActionRef.current = 'update';
try SendMessageComposer(new FurniEditorUpdateComposer(id, JSON.stringify(fields)));
{
const data = await apiFetch<{ id: number }>(`${ API_BASE }`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fields)
});
return data.id;
}
catch(e: any)
{
setError(e.message);
return null;
}
finally
{
setLoading(false);
}
}, []); }, []);
const deleteItem = useCallback(async (id: number) => const deleteItem = useCallback((id: number) =>
{ {
setLoading(true); setLoading(true);
setError(null); setError(null);
pendingActionRef.current = 'delete';
try SendMessageComposer(new FurniEditorDeleteComposer(id));
{
await apiFetch(`${ API_BASE }/delete?id=${ id }`, { method: 'POST' });
return true;
}
catch(e: any)
{
setError(e.message);
return false;
}
finally
{
setLoading(false);
}
}, []); }, []);
const loadInteractions = useCallback(async () => const loadInteractions = useCallback(() =>
{ {
try SendMessageComposer(new FurniEditorInteractionsComposer());
{
const data = await apiFetch<{ interactions: Array<string | { name: string }> }>(`${ API_BASE }/interactions`);
setInteractions(data.interactions.map(i => typeof i === 'string' ? i : i.name));
}
catch {}
}, []); }, []);
const loadBySpriteId = useCallback(async (spriteId: number): Promise<boolean> =>
{
try
{
const data = await apiFetch<{ id: number }>(`${ API_BASE }/by-sprite?spriteId=${ spriteId }`);
return await loadDetail(data.id);
}
catch(e: any)
{
setError(e.message);
return false;
}
}, [ loadDetail ]);
return { return {
items, total, page, loading, error, clearError, items, total, page, loading, error, clearError,
selectedItem, setSelectedItem, catalogItems, furniDataEntry, selectedItem, setSelectedItem, catalogItems, furniDataEntry,
interactions, interactions,
searchItems, loadDetail, loadBySpriteId, updateItem, createItem, deleteItem, loadInteractions searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions
}; };
}; };
+6 -1
View File
@@ -132,7 +132,12 @@ const InfiniteGridItem = forwardRef<HTMLDivElement, {
useEffect(() => useEffect(() =>
{ {
if(!itemImage || !itemImage.length) return; if(!itemImage || !itemImage.length)
{
setBackgroundImageUrl(null);
return;
}
const image = new Image(); const image = new Image();