mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
feat(guild): duplicate role, plus delete and hover in roles sidebar (#2105)
This commit is contained in:
1 parent
19efbd3d61
commit
8f20b29b16
6 files changed
+198
-16
No files matched your search
@@ -254,10 +254,11 @@ export async function updateVanityURL(guildId: string, code: string | null): Pro
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRole(guildId: string, name: string): Promise<void> {
|
||||
export async function createRole(guildId: string, name: string): Promise<GuildRole> {
|
||||
try {
|
||||
await http.post(Endpoints.GUILD_ROLES(guildId), {body: {name}});
|
||||
const response = await http.post<GuildRole>(Endpoints.GUILD_ROLES(guildId), {body: {name}});
|
||||
logger.debug(`Created role "${name}" in guild ${guildId}`);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create role in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
|
||||
@@ -262,7 +262,8 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overwriteItem:not(.overwriteItemSelected):hover {
|
||||
.overwriteItem:not(.overwriteItemSelected):hover,
|
||||
.overwriteItem:not(.overwriteItemSelected).overwriteItemHovered {
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {RoleUpdateFailedModal} from '@app/features/moderation/components/alerts/
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import PermissionLayout from '@app/features/permissions/state/PermissionLayout';
|
||||
import * as PermissionUtils from '@app/features/permissions/utils/PermissionUtils';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
||||
@@ -43,10 +44,16 @@ import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
const logger = new Logger('GuildRolesTab');
|
||||
const NEW_ROLE_DESCRIPTOR = msg({
|
||||
message: 'New role',
|
||||
comment: 'Default name for a newly created role in the community roles settings tab. Short label.',
|
||||
});
|
||||
const ROLE_COPY_NAME_DESCRIPTOR = msg({
|
||||
message: '{name} copy',
|
||||
comment:
|
||||
'Default name for a role created by duplicating another role in the community roles settings tab. {name} is the source role name; translate the whole label so "copy" can move or inflect as the language needs.',
|
||||
});
|
||||
const DELETE_ROLE_DESCRIPTOR = msg({
|
||||
message: 'Delete role',
|
||||
comment:
|
||||
@@ -341,6 +348,55 @@ const GuildRolesTab: React.FC<{guildId: string}> = observer(({guildId}) => {
|
||||
);
|
||||
}
|
||||
}, [guild]);
|
||||
const handleDuplicateRole = useCallback(
|
||||
async (sourceRoleId: string) => {
|
||||
if (!guild || !canManageRoles) return;
|
||||
const sourceRole = guild.roles[sourceRoleId];
|
||||
if (!sourceRole) return;
|
||||
const copyName = i18n._(ROLE_COPY_NAME_DESCRIPTOR, {name: sourceRole.name}).slice(0, 100);
|
||||
const grantablePermissions = sourceRole.permissions & currentUserPermissions;
|
||||
pendingRoleCreationRef.current = true;
|
||||
try {
|
||||
const createdRole = await GuildCommands.createRole(guild.id, copyName);
|
||||
await GuildCommands.updateRole(guild.id, createdRole.id, {
|
||||
color: sourceRole.color,
|
||||
hoist: sourceRole.hoist,
|
||||
mentionable: sourceRole.mentionable,
|
||||
permissions: grantablePermissions.toString(),
|
||||
});
|
||||
try {
|
||||
const orderedRoleIds = roles.map((role: GuildRole) => role.id);
|
||||
const sourceIndex = orderedRoleIds.indexOf(sourceRoleId);
|
||||
const insertIndex = sourceIndex === -1 ? orderedRoleIds.length : sourceIndex + 1;
|
||||
orderedRoleIds.splice(insertIndex, 0, createdRole.id);
|
||||
const submittableRoleOrder = createSubmittableRoleOrderIds({
|
||||
guildId: guild.id,
|
||||
orderedRoleIds,
|
||||
isRoleLocked: (roleId) => {
|
||||
if (roleId === createdRole.id) return false;
|
||||
const role = guild.roles[roleId];
|
||||
if (!role) return true;
|
||||
return isRoleLocked(role);
|
||||
},
|
||||
});
|
||||
if (submittableRoleOrder.length > 0) {
|
||||
await GuildCommands.setRoleOrder(guild.id, submittableRoleOrder);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to position duplicated role in guild ${guild.id}:`, error);
|
||||
}
|
||||
ToastCommands.createToast({type: 'success', children: <Trans>Role created successfully</Trans>});
|
||||
} catch (_error) {
|
||||
pendingRoleCreationRef.current = false;
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<RoleCreateFailedModal data-flx="guild.guild-tabs.guild-roles-tab.handle-duplicate-role.role-create-failed-modal" />
|
||||
)),
|
||||
);
|
||||
}
|
||||
},
|
||||
[guild, canManageRoles, roles, isRoleLocked, currentUserPermissions, i18n],
|
||||
);
|
||||
const handleDeleteRole = useCallback(() => {
|
||||
if (!selectedRole || selectedRole.isEveryone || !guild) return;
|
||||
const currentIndex = roles.findIndex((r: GuildRole) => r.id === selectedRole.id);
|
||||
@@ -386,6 +442,30 @@ const GuildRolesTab: React.FC<{guildId: string}> = observer(({guildId}) => {
|
||||
)),
|
||||
);
|
||||
}, [selectedRole, guild, roles]);
|
||||
const handleContextMenuDeleteRole = useCallback(
|
||||
async (roleId: string) => {
|
||||
if (!guild) return;
|
||||
const role = guild.roles[roleId];
|
||||
if (!role || role.isEveryone || isRoleLocked(role)) return;
|
||||
const currentIndex = roles.findIndex((r: GuildRole) => r.id === roleId);
|
||||
const nextRole = roles[currentIndex + 1] ?? roles[0];
|
||||
try {
|
||||
await GuildCommands.deleteRole(guild.id, roleId);
|
||||
ToastCommands.createToast({type: 'success', children: <Trans>Role deleted successfully</Trans>});
|
||||
setSelectedRoleId((current) => (current === roleId ? (nextRole?.id ?? null) : current));
|
||||
} catch (_error) {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<RoleDeleteFailedModal
|
||||
roleName={role.name}
|
||||
data-flx="guild.guild-tabs.guild-roles-tab.handle-context-menu-delete-role.role-delete-failed-modal"
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}
|
||||
},
|
||||
[guild, roles, isRoleLocked],
|
||||
);
|
||||
const evaluateRoleMove = useCallback(
|
||||
(draggedRoleId: string, targetRoleId: string | null, position: 'before' | 'after') => {
|
||||
if (!guild) return null;
|
||||
@@ -500,6 +580,8 @@ const GuildRolesTab: React.FC<{guildId: string}> = observer(({guildId}) => {
|
||||
isRoleLocked={isRoleLocked}
|
||||
onSelectRole={setSelectedRoleId}
|
||||
onCreateRole={handleCreateRole}
|
||||
onDuplicateRole={handleDuplicateRole}
|
||||
onDeleteRole={handleContextMenuDeleteRole}
|
||||
onEnterHoistOrderMode={handleEnterHoistOrderMode}
|
||||
onExitHoistOrderMode={handleExitHoistOrderMode}
|
||||
onResetHoistOrder={handleResetHoistOrder}
|
||||
@@ -519,6 +601,8 @@ const GuildRolesTab: React.FC<{guildId: string}> = observer(({guildId}) => {
|
||||
isGuildOwner,
|
||||
canManageRoles,
|
||||
handleCreateRole,
|
||||
handleDuplicateRole,
|
||||
handleContextMenuDeleteRole,
|
||||
evaluateRoleMove,
|
||||
handleRoleDrop,
|
||||
evaluateHoistMove,
|
||||
|
||||
+27
-3
@@ -2,6 +2,7 @@
|
||||
|
||||
import {DEFAULT_ROLE_COLOR_HEX, getRoleColor} from '@app/features/app/components/dialogs/shared/PermissionComponents';
|
||||
import {DropIndicator} from '@app/features/app/components/layout/DropIndicator';
|
||||
import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHoverState';
|
||||
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
|
||||
import styles from '@app/features/guild/components/modals/guild_tabs/GuildRolesTab.module.css';
|
||||
import {
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from '@app/features/guild/components/modals/guild_tabs/RoleReorderStateMachine';
|
||||
import type {GuildRole} from '@app/features/guild/models/GuildRole';
|
||||
import {openRoleContextMenu} from '@app/features/ui/action_menu/RoleContextMenu';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
@@ -43,6 +45,8 @@ interface RoleItemProps {
|
||||
isTerminal: boolean;
|
||||
canManageRoles: boolean;
|
||||
onClick: () => void;
|
||||
onDuplicate: (roleId: string) => void;
|
||||
onDelete: (roleId: string) => void;
|
||||
onEvaluateMove: (
|
||||
draggedRoleId: string,
|
||||
targetRoleId: string | null,
|
||||
@@ -52,15 +56,34 @@ interface RoleItemProps {
|
||||
}
|
||||
|
||||
export const RoleItem: React.FC<RoleItemProps> = observer(
|
||||
({role, isSelected, isLocked, isGuildOwner, isTerminal, canManageRoles, onClick, onEvaluateMove, onCommitMove}) => {
|
||||
({
|
||||
role,
|
||||
isSelected,
|
||||
isLocked,
|
||||
isGuildOwner,
|
||||
isTerminal,
|
||||
canManageRoles,
|
||||
onClick,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onEvaluateMove,
|
||||
onCommitMove,
|
||||
}) => {
|
||||
const {i18n} = useLingui();
|
||||
const elementRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [dropIndicator, setDropIndicator] = useState<{position: 'top' | 'bottom'; isValid: boolean} | null>(null);
|
||||
const contextMenuOpen = useContextMenuHoverState(elementRef, !MobileLayout.enabled);
|
||||
const canDelete = canManageRoles && !role.isEveryone && !isLocked;
|
||||
const handleContextMenu = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
openRoleContextMenu(event, role.id);
|
||||
openRoleContextMenu(event, role.id, {
|
||||
canDuplicate: canManageRoles,
|
||||
onDuplicate: () => onDuplicate(role.id),
|
||||
canDelete,
|
||||
onDelete: () => onDelete(role.id),
|
||||
});
|
||||
},
|
||||
[role.id],
|
||||
[role.id, canManageRoles, canDelete, onDuplicate, onDelete],
|
||||
);
|
||||
const dragItem = useMemo<RoleDragItem>(
|
||||
() => ({type: ROLE_DND_TYPE, id: role.id, isEveryone: role.isEveryone, isLocked}),
|
||||
@@ -204,6 +227,7 @@ export const RoleItem: React.FC<RoleItemProps> = observer(
|
||||
styles.overwriteItem,
|
||||
styles.roleButton,
|
||||
{[styles.overwriteItemSelected]: isSelected},
|
||||
contextMenuOpen && styles.overwriteItemHovered,
|
||||
isDragging && styles.dragging,
|
||||
!canDrag && styles.noDrag,
|
||||
)}
|
||||
|
||||
+8
@@ -44,6 +44,8 @@ interface RoleSidebarProps {
|
||||
isRoleLocked: (role: GuildRole) => boolean;
|
||||
onSelectRole: (roleId: string) => void;
|
||||
onCreateRole: () => void;
|
||||
onDuplicateRole: (roleId: string) => void;
|
||||
onDeleteRole: (roleId: string) => void;
|
||||
onEnterHoistOrderMode: () => void;
|
||||
onExitHoistOrderMode: () => void;
|
||||
onResetHoistOrder: () => void;
|
||||
@@ -74,6 +76,8 @@ export const RoleSidebar: React.FC<RoleSidebarProps> = observer(
|
||||
isRoleLocked,
|
||||
onSelectRole,
|
||||
onCreateRole,
|
||||
onDuplicateRole,
|
||||
onDeleteRole,
|
||||
onEnterHoistOrderMode,
|
||||
onExitHoistOrderMode,
|
||||
onResetHoistOrder,
|
||||
@@ -155,6 +159,8 @@ export const RoleSidebar: React.FC<RoleSidebarProps> = observer(
|
||||
isTerminal={index === hoistedRoles.length - 1}
|
||||
canManageRoles={canManageRoles}
|
||||
onClick={() => onSelectRole(role.id)}
|
||||
onDuplicate={onDuplicateRole}
|
||||
onDelete={onDeleteRole}
|
||||
onEvaluateMove={onEvaluateHoistMove}
|
||||
onCommitMove={onHoistDrop}
|
||||
data-flx="guild.guild-tabs.guild-roles-tab.sidebar-content.role-item.set-selected-role-id"
|
||||
@@ -251,6 +257,8 @@ export const RoleSidebar: React.FC<RoleSidebarProps> = observer(
|
||||
isTerminal={index === roles.length - 1}
|
||||
canManageRoles={canManageRoles}
|
||||
onClick={() => onSelectRole(role.id)}
|
||||
onDuplicate={onDuplicateRole}
|
||||
onDelete={onDeleteRole}
|
||||
onEvaluateMove={onEvaluateRoleMove}
|
||||
onCommitMove={onRoleDrop}
|
||||
data-flx="guild.guild-tabs.guild-roles-tab.sidebar-content.role-item.set-selected-role-id--2"
|
||||
|
||||
@@ -1,32 +1,96 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {CopyIcon, DeleteIcon} from '@app/features/ui/action_menu/ContextMenuIcons';
|
||||
import {CopyRoleIdMenuItem} from '@app/features/ui/action_menu/items/CopyMenuItems';
|
||||
import {MenuGroup} from '@app/features/ui/action_menu/MenuGroup';
|
||||
import {MenuItem} from '@app/features/ui/action_menu/MenuItem';
|
||||
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import type React from 'react';
|
||||
|
||||
interface RoleContextMenuProps {
|
||||
const DUPLICATE_ROLE_DESCRIPTOR = msg({
|
||||
message: 'Duplicate role',
|
||||
comment:
|
||||
'Context menu action in the community roles settings tab that creates a new role copying the settings of the one right-clicked. Keep it short.',
|
||||
});
|
||||
const DELETE_ROLE_DESCRIPTOR = msg({
|
||||
message: 'Delete role',
|
||||
comment:
|
||||
'Destructive context menu action in the community roles settings tab that immediately removes the right-clicked role from every member. Keep it short.',
|
||||
});
|
||||
|
||||
export interface RoleContextMenuActions {
|
||||
canDuplicate?: boolean;
|
||||
onDuplicate?: () => void;
|
||||
canDelete?: boolean;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
interface RoleContextMenuProps extends RoleContextMenuActions {
|
||||
roleId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const RoleContextMenu: React.FC<RoleContextMenuProps> = ({roleId, onClose}) => {
|
||||
export const RoleContextMenu: React.FC<RoleContextMenuProps> = ({
|
||||
roleId,
|
||||
onClose,
|
||||
canDuplicate,
|
||||
onDuplicate,
|
||||
canDelete,
|
||||
onDelete,
|
||||
}) => {
|
||||
const {i18n} = useLingui();
|
||||
return (
|
||||
<MenuGroup data-flx="ui.action-menu.role-context-menu.menu-group">
|
||||
<CopyRoleIdMenuItem
|
||||
roleId={roleId}
|
||||
onClose={onClose}
|
||||
data-flx="ui.action-menu.role-context-menu.copy-role-id-menu-item"
|
||||
/>
|
||||
</MenuGroup>
|
||||
<>
|
||||
<MenuGroup data-flx="ui.action-menu.role-context-menu.menu-group">
|
||||
<CopyRoleIdMenuItem
|
||||
roleId={roleId}
|
||||
onClose={onClose}
|
||||
data-flx="ui.action-menu.role-context-menu.copy-role-id-menu-item"
|
||||
/>
|
||||
{canDuplicate && onDuplicate && (
|
||||
<MenuItem
|
||||
icon={<CopyIcon data-flx="ui.action-menu.role-context-menu.duplicate-role-menu-item.copy-icon" />}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onDuplicate();
|
||||
}}
|
||||
data-flx="ui.action-menu.role-context-menu.duplicate-role-menu-item"
|
||||
>
|
||||
{i18n._(DUPLICATE_ROLE_DESCRIPTOR)}
|
||||
</MenuItem>
|
||||
)}
|
||||
</MenuGroup>
|
||||
{canDelete && onDelete && (
|
||||
<MenuGroup data-flx="ui.action-menu.role-context-menu.menu-group--danger">
|
||||
<MenuItem
|
||||
danger
|
||||
icon={<DeleteIcon data-flx="ui.action-menu.role-context-menu.delete-role-menu-item.delete-icon" />}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onDelete();
|
||||
}}
|
||||
data-flx="ui.action-menu.role-context-menu.delete-role-menu-item"
|
||||
>
|
||||
{i18n._(DELETE_ROLE_DESCRIPTOR)}
|
||||
</MenuItem>
|
||||
</MenuGroup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export function openRoleContextMenu(event: React.MouseEvent | MouseEvent, roleId: string): void {
|
||||
export function openRoleContextMenu(
|
||||
event: React.MouseEvent | MouseEvent,
|
||||
roleId: string,
|
||||
actions?: RoleContextMenuActions,
|
||||
): void {
|
||||
ContextMenuCommands.openFromEvent(event, ({onClose}) => (
|
||||
<RoleContextMenu
|
||||
roleId={roleId}
|
||||
onClose={onClose}
|
||||
{...actions}
|
||||
data-flx="ui.action-menu.role-context-menu.open-role-context-menu.role-context-menu"
|
||||
/>
|
||||
));
|
||||
|
||||
Reference in new issue
Block a user