Merge pull request #61 from duckietm/Dev

Dev
This commit is contained in:
DuckieTM
2026-03-29 15:05:33 +02:00
committed by GitHub
16 changed files with 1101 additions and 12 deletions
+15 -1
View File
@@ -38,5 +38,19 @@
"wiredfurni.params.texts.placeholder_type": "Tipo di segnaposto:",
"wiredfurni.params.texts.placeholder_type.1": "Singolo",
"wiredfurni.params.texts.placeholder_type.2": "Multiplo",
"wiredfurni.params.texts.select_delimiter": "Seleziona il delimitatore:"
"wiredfurni.params.texts.select_delimiter": "Seleziona il delimitatore:",
"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!"
}
+1
View File
@@ -8,6 +8,7 @@ export interface IGroupData
groupHomeroomId: number;
groupState: number;
groupCanMembersDecorate: boolean;
groupHasForum: boolean;
groupColors: number[];
groupBadgeParts: GroupBadgePart[];
}
+2
View File
@@ -12,6 +12,7 @@ import { FloorplanEditorView } from './floorplan-editor/FloorplanEditorView';
import { FriendsView } from './friends/FriendsView';
import { GameCenterView } from './game-center/GameCenterView';
import { GroupsView } from './groups/GroupsView';
import { GroupForumView } from './groups/views/forums/GroupForumView';
import { GuideToolView } from './guide-tool/GuideToolView';
import { HcCenterView } from './hc-center/HcCenterView';
import { HelpView } from './help/HelpView';
@@ -112,6 +113,7 @@ export const MainView: FC<{}> = props =>
<UserSettingsView />
<UserProfileView />
<GroupsView />
<GroupForumView />
<CameraWidgetView />
<HelpView />
<NitropediaView />
@@ -94,6 +94,9 @@ export const GroupInformationView: FC<GroupInformationViewProps> = props =>
case 'popular_groups':
CreateLinkEvent('navigator/search/groups');
break;
case 'forum':
CreateLinkEvent('groupforum/' + groupInformation.id);
break;
}
};
@@ -134,6 +137,8 @@ export const GroupInformationView: FC<GroupInformationViewProps> = props =>
<Text pointer small underline onClick={ () => handleAction('homeroom') }>{ LocalizeText('group.linktobase') }</Text>
<Text pointer small underline onClick={ () => handleAction('furniture') }>{ LocalizeText('group.buyfurni') }</Text>
<Text pointer small underline onClick={ () => handleAction('popular_groups') }>{ LocalizeText('group.showgroups') }</Text>
{ groupInformation.hasForum &&
<Text pointer small underline onClick={ () => handleAction('forum') }>{ LocalizeText('group.showforum') }</Text> }
</div>
{ (groupInformation.type !== GroupType.PRIVATE || groupInformation.type === GroupType.PRIVATE && groupInformation.membershipType === GroupMembershipType.MEMBER) &&
<Button disabled={ (groupInformation.membershipType === GroupMembershipType.REQUEST_PENDING) || isRealOwner } onClick={ handleButtonClick }>
@@ -77,6 +77,7 @@ export const GroupManagerView: FC<{}> = props =>
groupHomeroomId: parser.roomId,
groupState: parser.state,
groupCanMembersDecorate: parser.canMembersDecorate,
groupHasForum: parser.hasForum,
groupColors: [ parser.colorA, parser.colorB ],
groupBadgeParts
});
@@ -85,7 +86,7 @@ export const GroupManagerView: FC<{}> = props =>
if(!groupData || (groupData.groupId <= 0)) return null;
return (
<NitroCardView className="nitro-group-manager">
<NitroCardView className="nitro-group-manager w-[555px] h-[415px]">
<NitroCardHeaderView headerText={ LocalizeText('group.window.title') } onCloseClick={ onClose } />
<NitroCardTabsView>
{ TABS.map(tab =>
@@ -0,0 +1,114 @@
import { ForumsListMessageEvent, GetForumsListMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { LocalizeText, SendMessageComposer, GetUserProfile } from '../../../../api';
import { Column, Flex, Text, LayoutBadgeImageView } from '../../../../common';
import { useMessageEvent } from '../../../../hooks';
import { ForumData } from '@nitrots/nitro-renderer';
const FORUMS_PER_PAGE = 20;
interface GroupForumListViewProps
{
onOpenForum: (groupId: number) => void;
}
export const GroupForumListView: FC<GroupForumListViewProps> = props =>
{
const { onOpenForum = null } = props;
const [ forums, setForums ] = useState<ForumData[]>([]);
const [ listMode, setListMode ] = useState<number>(0); // 0 = most active
const [ startIndex, setStartIndex ] = useState<number>(0);
const [ totalForums, setTotalForums ] = useState<number>(0);
useMessageEvent<ForumsListMessageEvent>(ForumsListMessageEvent, event =>
{
const parser = event.getParser();
setTotalForums(parser.totalAmount);
if(parser.startIndex === 0)
{
setForums(parser.forums);
}
else
{
setForums(prev => [ ...prev, ...parser.forums ]);
}
});
useEffect(() =>
{
SendMessageComposer(new GetForumsListMessageComposer(listMode, startIndex, FORUMS_PER_PAGE));
}, [ listMode, startIndex ]);
const formatTimeAgo = (seconds: number): string =>
{
if(seconds < 60) return `${ seconds }s ${ LocalizeText('messageboard.time.ago') }`;
if(seconds < 3600) return `${ Math.floor(seconds / 60) }m ${ LocalizeText('messageboard.time.ago') }`;
if(seconds < 86400) return `${ Math.floor(seconds / 3600) }h ${ LocalizeText('messageboard.time.ago') }`;
return `${ Math.floor(seconds / 86400) }d ${ LocalizeText('messageboard.time.ago') }`;
};
return (
<Column className="h-full" gap={ 0 }>
<Flex className="bg-muted p-2 border-b" gap={ 2 } alignItems="center" justifyContent="between">
<Text bold>{ LocalizeText('messageboard.all.threads.header') }</Text>
<Flex gap={ 1 }>
<select
className="form-select form-select-sm"
value={ listMode }
onChange={ e => { setListMode(parseInt(e.target.value)); setStartIndex(0); } }>
<option value={ 0 }>{ LocalizeText('groupforum.list.tab.most_active') }</option>
<option value={ 2 }>{ LocalizeText('groupforum.list.tab.my_forums') }</option>
</select>
</Flex>
</Flex>
<Column className="overflow-auto flex-1 p-2" gap={ 1 }>
{ forums.map((forum, index) =>
{
return (
<Flex key={ forum.groupId }
className="p-2 rounded bg-white hover:bg-muted cursor-pointer border"
gap={ 2 }
alignItems="center"
onClick={ () => onOpenForum(forum.groupId) }>
<div className="flex-shrink-0">
<LayoutBadgeImageView badgeCode={ forum.icon } isGroup={ true } />
</div>
<Column className="flex-1 overflow-hidden" gap={ 0 }>
<Text bold className="truncate">{ forum.name }</Text>
<Text small variant="muted" className="truncate">{ forum.description }</Text>
</Column>
<Column className="flex-shrink-0 text-end" gap={ 0 }>
<Text small>{ forum.totalThreads } { LocalizeText('groupforum.view.threads') }</Text>
<Text small>{ forum.totalMessages } { LocalizeText('messageboard.messages') }</Text>
{ (forum.unreadMessages > 0) &&
<Text small bold variant="danger">{ forum.unreadMessages } { LocalizeText('messageboard.unread') }</Text> }
</Column>
<Column className="flex-shrink-0 text-end min-w-[100px]" gap={ 0 }>
{ (forum.lastMessageAuthorId > 0) && <>
<Text small variant="muted">{ LocalizeText('messageboard.last.message') }</Text>
<Text small pointer underline onClick={ e => { e.stopPropagation(); GetUserProfile(forum.lastMessageAuthorId); } }>
{ forum.lastMessageAuthorName }
</Text>
<Text small variant="muted">{ formatTimeAgo(forum.lastMessageTimeAsSecondsAgo) }</Text>
</> }
</Column>
</Flex>
);
}) }
{ (forums.length === 0) &&
<Flex className="p-4" justifyContent="center">
<Text variant="muted">{ LocalizeText('groupforum.list.no_forums') }</Text>
</Flex> }
{ (forums.length < totalForums) &&
<Flex justifyContent="center" className="p-2">
<Text pointer underline onClick={ () => setStartIndex(forums.length) }>
{ LocalizeText('groupforum.list.load_more') }
</Text>
</Flex> }
</Column>
</Column>
);
};
@@ -0,0 +1,96 @@
import { PostMessageMessageComposer, PostThreadMessageEvent } from '@nitrots/nitro-renderer';
import { FC, useCallback, useState } from 'react';
import { LocalizeText, SendMessageComposer } from '../../../../api';
import { Button, Column, Flex, Text } from '../../../../common';
import { useMessageEvent, useNotification } from '../../../../hooks';
import { ExtendedForumData } from '@nitrots/nitro-renderer';
interface GroupForumNewThreadViewProps
{
groupId: number;
forumData: ExtendedForumData;
onBack: () => void;
onThreadCreated: (threadId: number) => void;
}
export const GroupForumNewThreadView: FC<GroupForumNewThreadViewProps> = props =>
{
const { groupId = 0, forumData = null, onBack = null, onThreadCreated = null } = props;
const effectiveGroupId = forumData?.groupId || groupId;
const [ subject, setSubject ] = useState<string>('');
const [ message, setMessage ] = useState<string>('');
const [ isSubmitting, setIsSubmitting ] = useState<boolean>(false);
const { simpleAlert = null } = useNotification();
useMessageEvent<PostThreadMessageEvent>(PostThreadMessageEvent, event =>
{
const parser = event.getParser();
if(parser.groupId !== effectiveGroupId) return;
setIsSubmitting(false);
setSubject('');
setMessage('');
if(onThreadCreated) onThreadCreated(parser.thread.threadId);
});
const submitThread = useCallback(() =>
{
if(subject.trim().length < 10)
{
simpleAlert(LocalizeText('groupforum.compose.subject_too_short'));
return;
}
if(message.trim().length < 10)
{
simpleAlert(LocalizeText('groupforum.compose.message_too_short'));
return;
}
setIsSubmitting(true);
// PostMessageMessageComposer with threadId=0 creates a new thread
// params: groupId, threadId (0 for new), subject, message
SendMessageComposer(new PostMessageMessageComposer(effectiveGroupId, 0, subject.trim(), message.trim()));
}, [ effectiveGroupId, subject, message, simpleAlert ]);
return (
<Column className="h-full p-3" gap={ 2 }>
<Flex gap={ 2 } alignItems="center">
<Text pointer underline onClick={ onBack }>
&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 { IGroupData, LocalizeText, SendMessageComposer } from '../../../../api';
import { Flex, HorizontalRule, Text } from '../../../../common';
import { useNotification } from '../../../../hooks';
const STATES: string[] = [ 'regular', 'exclusive', 'private' ];
@@ -17,12 +18,30 @@ export const GroupTabSettingsView: FC<GroupTabSettingsViewProps> = props =>
const { groupData = null, setGroupData = null, setCloseAction = null } = props;
const [ groupState, setGroupState ] = useState<number>(groupData.groupState);
const [ groupDecorate, setGroupDecorate ] = useState<boolean>(groupData.groupCanMembersDecorate);
const [ groupForum, setGroupForum ] = useState<boolean>(groupData.groupHasForum ?? false);
const { showConfirm = null } = useNotification();
const handleForumToggle = useCallback(() =>
{
if(groupForum)
{
// Disabling forum - show confirmation
showConfirm(LocalizeText('group.forum.disable.confirm'), () =>
{
setGroupForum(false);
}, null);
}
else
{
setGroupForum(true);
}
}, [ groupForum, showConfirm ]);
const saveSettings = useCallback(() =>
{
if(!groupData) return false;
if((groupState === groupData.groupState) && (groupDecorate === groupData.groupCanMembersDecorate)) return true;
if((groupState === groupData.groupState) && (groupDecorate === groupData.groupCanMembersDecorate) && (groupForum === (groupData.groupHasForum ?? false))) return true;
if(groupData.groupId <= 0)
{
@@ -32,6 +51,7 @@ export const GroupTabSettingsView: FC<GroupTabSettingsViewProps> = props =>
newValue.groupState = groupState;
newValue.groupCanMembersDecorate = groupDecorate;
newValue.groupHasForum = groupForum;
return newValue;
});
@@ -39,15 +59,16 @@ export const GroupTabSettingsView: FC<GroupTabSettingsViewProps> = props =>
return true;
}
SendMessageComposer(new GroupSavePreferencesComposer(groupData.groupId, groupState, groupDecorate ? 0 : 1));
SendMessageComposer(new GroupSavePreferencesComposer(groupData.groupId, groupState, groupDecorate ? 0 : 1, groupForum));
return true;
}, [ groupData, groupState, groupDecorate, setGroupData ]);
}, [ groupData, groupState, groupDecorate, groupForum, setGroupData ]);
useEffect(() =>
{
setGroupState(groupData.groupState);
setGroupDecorate(groupData.groupCanMembersDecorate);
setGroupForum(groupData.groupHasForum ?? false);
}, [ groupData ]);
useEffect(() =>
@@ -84,6 +105,14 @@ export const GroupTabSettingsView: FC<GroupTabSettingsViewProps> = props =>
<Text>{ LocalizeText('group.edit.settings.rights.members.help') }</Text>
</div>
</div>
<HorizontalRule />
<div className="flex items-center gap-1">
<input checked={ groupForum } className="form-check-input" type="checkbox" onChange={ handleForumToggle } />
<div className="flex flex-col gap-1">
<Text bold>{ LocalizeText('group.forum.enable.caption') }</Text>
<Text>{ LocalizeText('group.forum.enable.help') }</Text>
</div>
</div>
</div>
);
};
+2 -1
View File
@@ -1,6 +1,6 @@
import { CreateLinkEvent, GetRoomEngine, GetSessionDataManager, MouseEventType, RoomObjectCategory } from '@nitrots/nitro-renderer';
import { Dispatch, FC, PropsWithChildren, SetStateAction, useEffect, useRef } from 'react';
import { DispatchUiEvent, GetConfigurationValue, GetRoomSession, GetUserProfile } from '../../api';
import { DispatchUiEvent, GetConfigurationValue, GetRoomSession, GetUserProfile, LocalizeText } from '../../api';
import { Flex, LayoutItemCountView } from '../../common';
import { GuideToolEvent } from '../../events';
@@ -43,6 +43,7 @@ export const ToolbarMeView: FC<PropsWithChildren<{
<div className="navigation-item relative nitro-icon icon-me-rooms cursor-pointer" onClick={ event => CreateLinkEvent('navigator/search/myworld_view') } />
<div className="navigation-item relative nitro-icon icon-me-clothing cursor-pointer" onClick={ event => CreateLinkEvent('avatar-editor/toggle') } />
<div className="navigation-item relative nitro-icon icon-me-settings cursor-pointer" onClick={ event => CreateLinkEvent('user-settings/toggle') } />
<div className="navigation-item relative nitro-icon icon-me-forums cursor-pointer" onClick={ event => CreateLinkEvent('groupforum/toggle') } title={ LocalizeText('toolbar.icon.label.forums') } />
{ children }
</Flex>
);
+5 -5
View File
@@ -875,12 +875,12 @@ body {
.avatar-parts {
border: none !important;
height: 42px;
width: 42px;
background-position: center;
background-repeat: no-repeat;
position: relative;
aspect-ratio: 1;
width: 100%;
max-width: 42px;
border-radius: 2rem !important;
overflow: visible !important;
overflow: hidden !important;
background-color: transparent;
&:hover {
+6 -1
View File
@@ -132,7 +132,12 @@ const InfiniteGridItem = forwardRef<HTMLDivElement, {
useEffect(() =>
{
if(!itemImage || !itemImage.length) return;
if(!itemImage || !itemImage.length)
{
setBackgroundImageUrl(null);
return;
}
const image = new Image();