phase5: workspace frontend — API hooks, useWorkspace hook, WorkspaceSwitcher, Sidebar workspace filter
This commit is contained in:
@@ -8,6 +8,7 @@ import { usePluginStore } from '@/store/pluginStore';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { useMenuOrder } from '@/api/users';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useWorkspace } from '@/hooks/useWorkspace';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
interface NavSingleItem {
|
||||
@@ -44,6 +45,7 @@ export function Sidebar() {
|
||||
const { data: menuOrderData } = useMenuOrder();
|
||||
const { hasPermission } = usePermission();
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const { isModuleVisible } = useWorkspace();
|
||||
|
||||
// Use hasPermission directly — permissions are loaded via useUserPermissions hook
|
||||
const canAccess = (perm?: string): boolean => {
|
||||
@@ -78,12 +80,19 @@ export function Sidebar() {
|
||||
}));
|
||||
|
||||
const allItems = [...staticItems, ...pluginItems];
|
||||
|
||||
// Filter by workspace visibility (in addition to permissions)
|
||||
// If no workspace is active, isModuleVisible returns true (backward compatible)
|
||||
const workspaceFiltered = allItems.filter(item => {
|
||||
const moduleKey = item.path.replace(/^\//, '').split('/')[0];
|
||||
return isModuleVisible(moduleKey);
|
||||
});
|
||||
|
||||
// Apply saved menu order if available
|
||||
const savedOrder = menuOrderData?.menu_order;
|
||||
if (savedOrder && savedOrder.length > 0) {
|
||||
const orderMap = new Map(savedOrder.map((id, idx) => [id, idx]));
|
||||
return allItems.sort((a, b) => {
|
||||
return workspaceFiltered.sort((a, b) => {
|
||||
// Map path to menu ID: static items use their path without leading /
|
||||
const aId = a.path.startsWith('/') ? a.path.slice(1) : a.path;
|
||||
const bId = b.path.startsWith('/') ? b.path.slice(1) : b.path;
|
||||
@@ -98,7 +107,7 @@ export function Sidebar() {
|
||||
});
|
||||
}
|
||||
|
||||
return allItems.sort((a, b) => {
|
||||
return workspaceFiltered.sort((a, b) => {
|
||||
if (a.order !== b.order) return a.order - b.order;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SearchDropdown } from '@/components/shared/SearchDropdown';
|
||||
import { SuggestionBadge } from '@/components/ai/SuggestionBadge';
|
||||
import { Building, ChevronDown, Menu, Zap, Bot, Layers, Code } from 'lucide-react';
|
||||
import { NotificationBell } from '@/components/layout/NotificationBell';
|
||||
import { WorkspaceSwitcher } from '@/components/layout/WorkspaceSwitcher';
|
||||
import { useWindowStore } from '@/store/windowStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
@@ -87,6 +88,7 @@ export function TopBar() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<WorkspaceSwitcher />
|
||||
<NotificationBell />
|
||||
{/* Minimized windows */}
|
||||
{minimizedWindows.length > 0 && (
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useWorkspace } from '@/hooks/useWorkspace';
|
||||
import { LayoutGrid, ChevronDown, Check } from 'lucide-react';
|
||||
|
||||
export function WorkspaceSwitcher() {
|
||||
const { t } = useTranslation();
|
||||
const { myWorkspaces, activeWorkspaceId, switchWorkspace, hasWorkspaces } = useWorkspace();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
if (!hasWorkspaces) return null;
|
||||
|
||||
const activeWs = myWorkspaces.find(w => w.id === activeWorkspaceId);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-sm font-medium text-gray-700 dark:text-gray-300 transition-colors"
|
||||
title={activeWs?.description || activeWs?.name || t('workspaces.select')}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
<span className="max-w-[120px] truncate">{activeWs?.name || t('workspaces.select')}</span>
|
||||
<ChevronDown className="w-3.5 h-3.5 opacity-60" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 w-64 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50 py-1">
|
||||
<div className="px-3 py-1.5 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
{t('workspaces.myWorkspaces')}
|
||||
</div>
|
||||
{myWorkspaces.map(ws => (
|
||||
<button
|
||||
key={ws.id}
|
||||
onClick={() => {
|
||||
switchWorkspace(ws.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800 text-sm text-gray-700 dark:text-gray-300 transition-colors"
|
||||
>
|
||||
<span className="flex-1 text-left">
|
||||
<span className="block font-medium">{ws.name}</span>
|
||||
{ws.description && (
|
||||
<span className="block text-xs text-gray-500 dark:text-gray-400 truncate">{ws.description}</span>
|
||||
)}
|
||||
</span>
|
||||
{ws.id === activeWorkspaceId && <Check className="w-4 h-4 text-blue-500" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user