feat: add CategorySettingsModal with overview and permissions tabs

This commit is contained in:
Jannis Braun
2026-03-21 22:08:47 +01:00
parent edb050fd39
commit cc7d1c191c
@@ -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}
/>
</>
);
}