feat: add CategorySettingsModal with overview and permissions tabs
This commit is contained in:
@@ -0,0 +1,387 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Modal } from '../ui/Modal';
|
||||||
|
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
||||||
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
import { useSpaceStore, getApiForOrigin } from '../../stores/spaceStore';
|
||||||
|
import { PermissionBits, permissionsToString, stringToPermissions, hasPermissionBit } from '../../utils/permissions';
|
||||||
|
import { Toggle } from '../ui/Toggle';
|
||||||
|
import { PermissionsEditor } from '../ui/PermissionsEditor';
|
||||||
|
import type { PermissionDef } from '../ui/OverrideEntry';
|
||||||
|
|
||||||
|
// ─── Permission Definitions for Category Overrides ──────────────────────────────
|
||||||
|
|
||||||
|
const CATEGORY_PERMISSIONS: PermissionDef[] = [
|
||||||
|
{ key: 'VIEW_CHANNEL', label: 'View Channel', bit: PermissionBits.VIEW_CHANNEL },
|
||||||
|
{ key: 'SEND_MESSAGES', label: 'Send Messages', bit: PermissionBits.SEND_MESSAGES },
|
||||||
|
{ key: 'MANAGE_MESSAGES', label: 'Manage Messages', bit: PermissionBits.MANAGE_MESSAGES },
|
||||||
|
{ key: 'ATTACH_FILES', label: 'Attach Files', bit: PermissionBits.ATTACH_FILES },
|
||||||
|
{ key: 'READ_MESSAGE_HISTORY', label: 'Read Message History', bit: PermissionBits.READ_MESSAGE_HISTORY },
|
||||||
|
{ key: 'ADD_REACTIONS', label: 'Add Reactions', bit: PermissionBits.ADD_REACTIONS },
|
||||||
|
{ key: 'CONNECT', label: 'Connect', bit: PermissionBits.CONNECT },
|
||||||
|
{ key: 'SPEAK', label: 'Speak', bit: PermissionBits.SPEAK },
|
||||||
|
{ key: 'STREAM', label: 'Stream', bit: PermissionBits.STREAM },
|
||||||
|
{ key: 'MUTE_MEMBERS', label: 'Mute Members', bit: PermissionBits.MUTE_MEMBERS },
|
||||||
|
{ key: 'DEAFEN_MEMBERS', label: 'Deafen Members', bit: PermissionBits.DEAFEN_MEMBERS },
|
||||||
|
{ key: 'MOVE_MEMBERS', label: 'Move Members', bit: PermissionBits.MOVE_MEMBERS },
|
||||||
|
{ key: 'DISCONNECT_MEMBERS', label: 'Disconnect Members', bit: PermissionBits.DISCONNECT_MEMBERS },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Overview Tab ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function OverviewTab({
|
||||||
|
categoryId,
|
||||||
|
categoryName,
|
||||||
|
isPrivate,
|
||||||
|
isFetching,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
canManageChannels,
|
||||||
|
canManageRoles,
|
||||||
|
onTogglePrivate,
|
||||||
|
onDeleteCategory,
|
||||||
|
onRename,
|
||||||
|
}: {
|
||||||
|
categoryId: string;
|
||||||
|
categoryName: string;
|
||||||
|
isPrivate: boolean;
|
||||||
|
isFetching: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string;
|
||||||
|
canManageChannels: boolean;
|
||||||
|
canManageRoles: boolean;
|
||||||
|
onTogglePrivate: () => void;
|
||||||
|
onDeleteCategory: () => void;
|
||||||
|
onRename: (name: string) => void;
|
||||||
|
}) {
|
||||||
|
const [editName, setEditName] = useState(categoryName);
|
||||||
|
const [isSavingName, setIsSavingName] = useState(false);
|
||||||
|
|
||||||
|
// Sync edit name when category name changes externally
|
||||||
|
useEffect(() => {
|
||||||
|
setEditName(categoryName);
|
||||||
|
}, [categoryName]);
|
||||||
|
|
||||||
|
const handleNameBlur = () => {
|
||||||
|
const trimmed = editName.trim();
|
||||||
|
if (trimmed && trimmed !== categoryName) {
|
||||||
|
setIsSavingName(true);
|
||||||
|
onRename(trimmed);
|
||||||
|
// The parent will update the store; we just reset the saving state after a short delay
|
||||||
|
setTimeout(() => setIsSavingName(false), 500);
|
||||||
|
} else {
|
||||||
|
setEditName(categoryName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNameKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
(e.target as HTMLInputElement).blur();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setEditName(categoryName);
|
||||||
|
(e.target as HTMLInputElement).blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
{canManageChannels ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="opacity-60 flex-shrink-0 text-txt-primary">
|
||||||
|
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
onBlur={handleNameBlur}
|
||||||
|
onKeyDown={handleNameKeyDown}
|
||||||
|
disabled={isSavingName}
|
||||||
|
className="input-standard flex-1 py-1.5 px-2 text-sm"
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-txt-primary">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="opacity-60 flex-shrink-0">
|
||||||
|
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium">{categoryName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-border-soft">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-txt-primary">Private Category</div>
|
||||||
|
<div className="text-xs text-txt-tertiary mt-0.5">
|
||||||
|
Only selected members and roles will be able to view channels in this category.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`flex-shrink-0 ml-4 ${(isLoading || isFetching || !canManageRoles) ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||||
|
<Toggle enabled={isPrivate} onChange={onTogglePrivate} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPrivate && !isFetching && (
|
||||||
|
<div className="flex items-start gap-2 p-2 bg-surface-input/50 rounded text-xs text-txt-tertiary">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 mt-0.5 text-txt-secondary">
|
||||||
|
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" />
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
This category is hidden from members without explicit access. Channels inside inherit this restriction unless they explicitly override it.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canManageChannels && (
|
||||||
|
<div className="pt-4 border-t border-border-soft">
|
||||||
|
<label className="block text-xs font-bold text-accent-rose uppercase mb-2">Danger Zone</label>
|
||||||
|
<button
|
||||||
|
onClick={onDeleteCategory}
|
||||||
|
className="w-full px-3 py-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-accent-rose text-sm font-medium hover:bg-accent-rose/20 transition-colors"
|
||||||
|
>
|
||||||
|
Delete Category
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Modal ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function CategorySettingsModal() {
|
||||||
|
const activeModal = useUIStore((s) => s.activeModal);
|
||||||
|
const modalData = useUIStore((s) => s.modalData);
|
||||||
|
const closeModal = useUIStore((s) => s.closeModal);
|
||||||
|
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
|
const categories = useSpaceStore((s) => s.categories);
|
||||||
|
const spaces = useSpaceStore((s) => s.spaces);
|
||||||
|
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<'overview' | 'permissions'>('overview');
|
||||||
|
const [isPrivate, setIsPrivate] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isFetching, setIsFetching] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
const isOpen = activeModal === 'categorySettings';
|
||||||
|
const categoryId = modalData?.categoryId as string | undefined;
|
||||||
|
const category = categories.find(c => c.id === categoryId);
|
||||||
|
|
||||||
|
const myPerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined;
|
||||||
|
const canManageChannels = myPerms !== undefined && hasPermissionBit(myPerms, PermissionBits.MANAGE_CHANNELS);
|
||||||
|
const canManageRoles = myPerms !== undefined && hasPermissionBit(myPerms, PermissionBits.MANAGE_ROLES);
|
||||||
|
|
||||||
|
// Reset state when modal closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
setIsDeleting(false);
|
||||||
|
setTab('overview');
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Fetch overrides for the private toggle (overview tab)
|
||||||
|
const fetchPrivateState = useCallback(() => {
|
||||||
|
if (!categoryId || !currentSpaceId) return;
|
||||||
|
|
||||||
|
setIsFetching(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
const space = spaces.find(s => s.id === currentSpaceId);
|
||||||
|
const catApi = getApiForOrigin(space?._instanceOrigin ?? '');
|
||||||
|
|
||||||
|
catApi.categories.getOverrides(categoryId)
|
||||||
|
.then((data: { targetType: string; targetId: string; allow: string; deny: string }[]) => {
|
||||||
|
// Check if @everyone role (id === spaceId) has VIEW_CHANNEL denied
|
||||||
|
const everyoneOverride = data.find(
|
||||||
|
o => o.targetType === 'role' && o.targetId === currentSpaceId
|
||||||
|
);
|
||||||
|
if (everyoneOverride) {
|
||||||
|
const denyBits = stringToPermissions(everyoneOverride.deny);
|
||||||
|
setIsPrivate((denyBits & PermissionBits.VIEW_CHANNEL) !== 0n);
|
||||||
|
} else {
|
||||||
|
setIsPrivate(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
setError(err.message || 'Failed to load category overrides');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsFetching(false);
|
||||||
|
});
|
||||||
|
}, [categoryId, currentSpaceId, spaces]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && categoryId && currentSpaceId) {
|
||||||
|
fetchPrivateState();
|
||||||
|
} else {
|
||||||
|
setIsFetching(false);
|
||||||
|
}
|
||||||
|
}, [isOpen, categoryId, currentSpaceId, fetchPrivateState]);
|
||||||
|
|
||||||
|
if (!isOpen || !category || !categoryId || !currentSpaceId) return null;
|
||||||
|
|
||||||
|
const space = spaces.find(s => s.id === currentSpaceId);
|
||||||
|
|
||||||
|
const handleToggle = async () => {
|
||||||
|
setError('');
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const catApi = getApiForOrigin(space?._instanceOrigin ?? '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!isPrivate) {
|
||||||
|
// Make private: deny VIEW_CHANNEL for @everyone role
|
||||||
|
await catApi.categories.putOverride(categoryId, {
|
||||||
|
targetType: 'role',
|
||||||
|
targetId: currentSpaceId,
|
||||||
|
allow: '0',
|
||||||
|
deny: permissionsToString(PermissionBits.VIEW_CHANNEL),
|
||||||
|
});
|
||||||
|
setIsPrivate(true);
|
||||||
|
} else {
|
||||||
|
// Make public: remove the @everyone VIEW_CHANNEL deny override
|
||||||
|
await catApi.categories.deleteOverride(categoryId, 'role', currentSpaceId);
|
||||||
|
setIsPrivate(false);
|
||||||
|
}
|
||||||
|
// Re-fetch to keep in sync
|
||||||
|
fetchPrivateState();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to update category privacy');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRename = async (name: string) => {
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await useSpaceStore.getState().updateCategory(categoryId, { name });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to rename category');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCategory = async () => {
|
||||||
|
if (!categoryId) return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await useSpaceStore.getState().deleteCategory(categoryId);
|
||||||
|
closeModal();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to delete category');
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showTabs = canManageRoles;
|
||||||
|
|
||||||
|
const tabClass = (t: typeof tab) =>
|
||||||
|
`w-full text-left px-2.5 py-1.5 rounded text-sm transition-colors ${
|
||||||
|
tab === t ? 'bg-interactive-selected text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal isOpen={isOpen} onClose={closeModal} title="Category Settings" mobileStyle="fullscreen" maxWidth={showTabs ? 'max-w-2xl' : 'max-w-md'}>
|
||||||
|
{showTabs ? (
|
||||||
|
<div className="flex gap-4 h-[min(520px,70vh)]">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="w-32 flex-shrink-0 self-start z-10">
|
||||||
|
<div className="glass-bubble rounded-lg p-1.5 space-y-0.5">
|
||||||
|
<button onClick={() => setTab('overview')} className={tabClass('overview')}>
|
||||||
|
Overview
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setTab('permissions')} className={tabClass('permissions')}>
|
||||||
|
Permissions
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0 overflow-y-auto scrollbar-thin">
|
||||||
|
{tab === 'overview' && (
|
||||||
|
<OverviewTab
|
||||||
|
categoryId={categoryId}
|
||||||
|
categoryName={category.name}
|
||||||
|
isPrivate={isPrivate}
|
||||||
|
isFetching={isFetching}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={error}
|
||||||
|
canManageChannels={canManageChannels}
|
||||||
|
canManageRoles={canManageRoles}
|
||||||
|
onTogglePrivate={handleToggle}
|
||||||
|
onDeleteCategory={() => setShowDeleteConfirm(true)}
|
||||||
|
onRename={handleRename}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === 'permissions' && (
|
||||||
|
<PermissionsEditor
|
||||||
|
entityId={categoryId}
|
||||||
|
spaceId={currentSpaceId}
|
||||||
|
instanceOrigin={space?._instanceOrigin}
|
||||||
|
permDefs={CATEGORY_PERMISSIONS}
|
||||||
|
getOverrides={() => {
|
||||||
|
const catApi = getApiForOrigin(space?._instanceOrigin ?? '');
|
||||||
|
return catApi.categories.getOverrides(categoryId);
|
||||||
|
}}
|
||||||
|
putOverride={(data) => {
|
||||||
|
const catApi = getApiForOrigin(space?._instanceOrigin ?? '');
|
||||||
|
return catApi.categories.putOverride(categoryId, data);
|
||||||
|
}}
|
||||||
|
deleteOverride={(targetType, targetId) => {
|
||||||
|
const catApi = getApiForOrigin(space?._instanceOrigin ?? '');
|
||||||
|
return catApi.categories.deleteOverride(categoryId, targetType, targetId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<OverviewTab
|
||||||
|
categoryId={categoryId}
|
||||||
|
categoryName={category.name}
|
||||||
|
isPrivate={isPrivate}
|
||||||
|
isFetching={isFetching}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={error}
|
||||||
|
canManageChannels={canManageChannels}
|
||||||
|
canManageRoles={canManageRoles}
|
||||||
|
onTogglePrivate={handleToggle}
|
||||||
|
onDeleteCategory={() => setShowDeleteConfirm(true)}
|
||||||
|
onRename={handleRename}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={showDeleteConfirm}
|
||||||
|
onClose={() => setShowDeleteConfirm(false)}
|
||||||
|
onConfirm={handleDeleteCategory}
|
||||||
|
title={`Delete "${category.name}"?`}
|
||||||
|
description={<>
|
||||||
|
This will permanently delete the <strong>{category.name}</strong> category. Channels inside it will be moved to the top level. This action cannot be undone.
|
||||||
|
</>}
|
||||||
|
confirmLabel="Delete Category"
|
||||||
|
variant="danger"
|
||||||
|
loading={isDeleting}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user