feat: navigation restructure + drag-and-drop menu ordering

Navigation:
- Remove Plugins header from sidebar
- Merge static items and plugin items into unified sorted list
- Group related menu items (Dateien, E-Mail, Kalender, Automation)
- German labels for all menu items
- Sort by order field, alphabetical fallback for ties

Drag-and-Drop Menu:
- New backend endpoints: GET/PUT /api/v1/users/me/menu-order
- Store menu order in user preferences JSONB field
- New SettingsMenuOrder page with @dnd-kit drag-and-drop
- New Settings tab: Menu ordering
- Sidebar reads saved menu order and sorts accordingly
- Reset to default option
This commit is contained in:
Agent Zero
2026-07-24 21:46:27 +02:00
parent 4b9cb5a098
commit e017da9d12
17 changed files with 608 additions and 142 deletions
+146 -129
View File
@@ -6,11 +6,13 @@ import { useUIStore } from '@/store/uiStore';
import { ChevronRight, FileText, Home, Settings, Users } from 'lucide-react';
import { usePluginStore } from '@/store/pluginStore';
import * as LucideIcons from 'lucide-react';
import { useMenuOrder } from '@/api/users';
interface NavSingleItem {
to: string;
labelKey: string;
icon: React.ReactNode;
order: number;
}
const chevronIcon = (expanded: boolean) => (
@@ -26,12 +28,12 @@ function getIcon(name: string): React.ReactNode {
// All other menu entries (calendar, dms, mail, ai-assistant, automation, reports, tasks, communication)
// are provided by plugin manifests via usePluginStore(s => s.getAllMenuItems()).
const singleItems: NavSingleItem[] = [
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: <Home className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} /> },
{ to: '/contacts', labelKey: 'nav.contacts', icon: <Users className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} /> },
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: <Home className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 0 },
{ to: '/contacts', labelKey: 'nav.contacts', icon: <Users className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 10 },
];
const bottomItems: NavSingleItem[] = [
{ to: '/settings', labelKey: 'nav.settings', icon: <Settings className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} /> },
{ to: '/settings', labelKey: 'nav.settings', icon: <Settings className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 999 },
];
export function Sidebar() {
@@ -39,27 +41,72 @@ export function Sidebar() {
const { sidebarOpen, setSidebarOpen } = useUIStore();
const location = useLocation();
const manifests = usePluginStore(s => s.manifests);
const pluginMenuItems = useMemo(
() => manifests
const { data: menuOrderData } = useMenuOrder();
const allMenuItems = useMemo(() => {
const staticItems = singleItems.map(item => ({
path: item.to,
labelKey: item.labelKey,
label: item.labelKey,
icon: item.icon,
order: item.order,
group: undefined as string | undefined,
isStatic: true as const,
}));
const pluginItems = manifests
.flatMap((m) => m.menu_items)
.sort((a, b) => a.order - b.order),
[manifests]
);
.map(item => ({
path: item.path,
labelKey: item.label_key,
label: item.label,
icon: item.icon,
order: item.order,
group: item.group,
isStatic: false as const,
}));
const allItems = [...staticItems, ...pluginItems];
// 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) => {
// 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;
const aIdx = orderMap.get(aId);
const bIdx = orderMap.get(bId);
if (aIdx !== undefined && bIdx !== undefined) return aIdx - bIdx;
if (aIdx !== undefined) return -1;
if (bIdx !== undefined) return 1;
// Fall back to order field for unknown items
if (a.order !== b.order) return a.order - b.order;
return a.label.localeCompare(b.label);
});
}
return allItems.sort((a, b) => {
if (a.order !== b.order) return a.order - b.order;
return a.label.localeCompare(b.label);
});
}, [manifests, menuOrderData]);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
useEffect(() => {
setExpandedItems((prev) => {
const next = new Set(prev);
// Auto-expand plugin groups whose child route is active
for (const item of pluginMenuItems) {
// Auto-expand groups whose child route is active
for (const item of allMenuItems) {
if (item.group && location.pathname.startsWith(item.path)) {
next.add(`plugin-group-${item.group}`);
}
}
return next;
});
}, [location.pathname, pluginMenuItems]);
}, [location.pathname, allMenuItems]);
const toggleExpand = (labelKey: string) => {
setExpandedItems((prev) => {
@@ -94,124 +141,94 @@ export function Sidebar() {
</div>
<nav className="flex-1 overflow-y-auto py-4" aria-label="Hauptnavigation">
<ul className="space-y-1 px-2">
{singleItems.map((item) => (
<li key={item.to}>
<NavLink
to={item.to}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium min-h-touch',
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isActive
? 'bg-primary-600 text-white'
: 'hover:bg-secondary-800 hover:text-white'
)
}
aria-label={t(item.labelKey)}
>
{item.icon}
<span className="truncate">{t(item.labelKey)}</span>
</NavLink>
</li>
))}
{pluginMenuItems.length > 0 && (
<>
<li className="px-3 pt-4 pb-1">
<span className="text-xs font-semibold uppercase tracking-wider text-secondary-500">
{t('nav.plugins', 'Plugins')}
</span>
</li>
{(() => {
const groups = new Map<string, typeof pluginMenuItems>();
const singles: typeof pluginMenuItems = [];
for (const item of pluginMenuItems) {
if (item.group) {
if (!groups.has(item.group)) groups.set(item.group, []);
groups.get(item.group)!.push(item);
} else {
singles.push(item);
}
}
const elements: React.ReactNode[] = [];
for (const item of singles) {
elements.push(
<li key={item.path}>
<NavLink
to={item.path}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium min-h-touch',
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isActive
? 'bg-primary-600 text-white'
: 'hover:bg-secondary-800 hover:text-white'
)
}
aria-label={t(item.label_key, item.label)}
>
{getIcon(item.icon)}
<span className="truncate">{t(item.label_key, item.label)}</span>
</NavLink>
</li>
);
}
for (const [group, items] of groups) {
const groupKey = `plugin-group-${group}`;
const expanded = expandedItems.has(groupKey);
elements.push(
<li key={groupKey}>
<div
className={clsx(
'flex items-center gap-2 px-3 py-2.5 rounded-md text-sm font-medium min-h-touch',
'motion-safe:transition-colors cursor-pointer select-none',
'text-secondary-300 hover:bg-secondary-800 hover:text-white'
)}
onClick={() => toggleExpand(groupKey)}
role="button"
tabIndex={0}
aria-expanded={expanded}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpand(groupKey);
}
}}
>
{getIcon(items[0].icon)}
<span className="flex-1 truncate">{group}</span>
{chevronIcon(expanded)}
</div>
{expanded && (
<ul className="mt-1 ml-4 space-y-1 border-l border-secondary-700 pl-2" role="group">
{items.map((child) => (
<li key={child.path}>
<NavLink
to={child.path}
className={({ isActive }) =>
clsx(
'flex items-center gap-2 px-3 py-2 rounded-md text-sm min-h-touch',
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isActive
? 'bg-primary-600 text-white font-medium'
: 'text-secondary-400 hover:bg-secondary-800 hover:text-white'
)
}
aria-label={t(child.label_key, child.label)}
>
<span className="truncate">{t(child.label_key, child.label)}</span>
</NavLink>
</li>
))}
</ul>
)}
</li>
);
}
return elements;
})()}
</>
)}
{(() => {
const groups = new Map<string, typeof allMenuItems>();
const singles: typeof allMenuItems = [];
for (const item of allMenuItems) {
if (item.group) {
if (!groups.has(item.group)) groups.set(item.group, []);
groups.get(item.group)!.push(item);
} else {
singles.push(item);
}
}
const elements: React.ReactNode[] = [];
for (const item of singles) {
elements.push(
<li key={item.path}>
<NavLink
to={item.path}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium min-h-touch',
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isActive
? 'bg-primary-600 text-white'
: 'hover:bg-secondary-800 hover:text-white'
)
}
aria-label={t(item.labelKey)}
>
{item.isStatic ? item.icon : getIcon(item.icon as string)}
<span className="truncate">{t(item.labelKey, item.label)}</span>
</NavLink>
</li>
);
}
for (const [group, items] of groups) {
const groupKey = `plugin-group-${group}`;
const expanded = expandedItems.has(groupKey);
elements.push(
<li key={groupKey}>
<div
className={clsx(
'flex items-center gap-2 px-3 py-2.5 rounded-md text-sm font-medium min-h-touch',
'motion-safe:transition-colors cursor-pointer select-none',
'text-secondary-300 hover:bg-secondary-800 hover:text-white'
)}
onClick={() => toggleExpand(groupKey)}
role="button"
tabIndex={0}
aria-expanded={expanded}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpand(groupKey);
}
}}
>
{getIcon(items[0].icon as string)}
<span className="flex-1 truncate">{group}</span>
{chevronIcon(expanded)}
</div>
{expanded && (
<ul className="mt-1 ml-4 space-y-1 border-l border-secondary-700 pl-2" role="group">
{items.map((child) => (
<li key={child.path}>
<NavLink
to={child.path}
className={({ isActive }) =>
clsx(
'flex items-center gap-2 px-3 py-2 rounded-md text-sm min-h-touch',
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isActive
? 'bg-primary-600 text-white font-medium'
: 'text-secondary-400 hover:bg-secondary-800 hover:text-white'
)
}
aria-label={t(child.labelKey, child.label)}
>
<span className="truncate">{t(child.labelKey, child.label)}</span>
</NavLink>
</li>
))}
</ul>
)}
</li>
);
}
return elements;
})()}
{bottomItems.map((item) => (
<li key={item.to}>
@@ -0,0 +1,65 @@
import React from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, FolderOpen } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import clsx from 'clsx';
interface SortableMenuItemProps {
id: string;
label: string;
icon: string;
isGroup?: boolean;
}
function getIcon(name: string): React.ReactNode {
const Icon = (LucideIcons as any)[name];
return Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.FileText className="h-4 w-4" />;
}
export function SortableMenuItem({ id, label, icon, isGroup }: SortableMenuItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={clsx(
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium',
'bg-white border border-secondary-200',
'motion-safe:transition-shadow',
isDragging && 'shadow-lg ring-2 ring-primary-500 z-50',
!isDragging && 'hover:bg-secondary-50'
)}
>
<button
className="cursor-grab active:cursor-grabbing text-secondary-400 hover:text-secondary-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 rounded"
{...attributes}
{...listeners}
aria-label="Ziehen zum Sortieren"
type="button"
>
<GripVertical className="w-4 h-4" />
</button>
<span className="text-secondary-400">
{isGroup ? <FolderOpen className="h-4 w-4" /> : getIcon(icon)}
</span>
<span className="flex-1 truncate">
{isGroup && <span className="text-xs font-semibold uppercase tracking-wider text-secondary-500 mr-2">{label}</span>}
{!isGroup && label}
</span>
</div>
);
}