+
+
+ {isOpen && (
+ <>
+ {/* Backdrop */}
+
setIsOpen(false)}
+ />
+
+ {/* Dropdown */}
+
+
+ {AVAILABLE_ICONS.map(({ name, icon: Icon }) => (
+
+ ))}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
index 81865c7c4..ac6113548 100644
--- a/frontend/src/components/Layout.tsx
+++ b/frontend/src/components/Layout.tsx
@@ -1,11 +1,13 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, X, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, X, Menu, type LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
import { useQuery } from '@tanstack/react-query';
import { api } from '../api/client';
+import { getIconByName } from './IconPicker';
+import { useIsMobile } from '../hooks/useIsMobile';
interface NavItem {
id: string;
@@ -24,36 +26,27 @@ export const defaultNavItems: NavItem[] = [
{ id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
];
-// Get ordered nav items from localStorage
-function getOrderedNavItems(): NavItem[] {
+// Get unified sidebar order from localStorage
+function getSidebarOrder(): string[] {
const stored = localStorage.getItem('sidebarOrder');
if (stored) {
try {
- const order: string[] = JSON.parse(stored);
- const itemMap = new Map(defaultNavItems.map(item => [item.id, item]));
- const ordered: NavItem[] = [];
- for (const id of order) {
- const item = itemMap.get(id);
- if (item) {
- ordered.push(item);
- itemMap.delete(id);
- }
- }
- // Add any new items that weren't in the stored order
- for (const item of itemMap.values()) {
- ordered.push(item);
- }
- return ordered;
+ return JSON.parse(stored);
} catch {
- return defaultNavItems;
+ return defaultNavItems.map(i => i.id);
}
}
- return defaultNavItems;
+ return defaultNavItems.map(i => i.id);
}
-// Save nav item order to localStorage
-function saveNavOrder(items: NavItem[]) {
- localStorage.setItem('sidebarOrder', JSON.stringify(items.map(i => i.id)));
+// Save unified sidebar order to localStorage
+function saveSidebarOrder(order: string[]) {
+ localStorage.setItem('sidebarOrder', JSON.stringify(order));
+}
+
+// Check if an ID is an external link
+function isExternalLinkId(id: string): boolean {
+ return id.startsWith('ext-');
}
// Get default view from localStorage
@@ -71,14 +64,16 @@ export function Layout() {
const location = useLocation();
const { theme, toggleTheme } = useTheme();
const { t } = useTranslation();
+ const isMobile = useIsMobile();
const [sidebarExpanded, setSidebarExpanded] = useState(() => {
const stored = localStorage.getItem('sidebarExpanded');
return stored !== 'false';
});
+ const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [showShortcuts, setShowShortcuts] = useState(false);
- const [navItems, setNavItems] = useState
(getOrderedNavItems);
- const [draggedIndex, setDraggedIndex] = useState(null);
- const [dragOverIndex, setDragOverIndex] = useState(null);
+ const [sidebarOrder, setSidebarOrder] = useState(getSidebarOrder);
+ const [draggedId, setDraggedId] = useState(null);
+ const [dragOverId, setDragOverId] = useState(null);
const hasRedirected = useRef(false);
const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState(() =>
sessionStorage.getItem('dismissedUpdateVersion')
@@ -105,6 +100,101 @@ export function Layout() {
refetchInterval: 60 * 60 * 1000, // Check every hour
});
+ // Fetch external links for sidebar
+ const { data: externalLinks } = useQuery({
+ queryKey: ['external-links'],
+ queryFn: api.getExternalLinks,
+ });
+
+ // Build the unified sidebar items list
+ const navItemsMap = new Map(defaultNavItems.map(item => [item.id, item]));
+ const extLinksMap = new Map((externalLinks || []).map(link => [`ext-${link.id}`, link]));
+
+ // Compute the ordered sidebar: include stored order + any new items
+ const orderedSidebarIds = (() => {
+ const result: string[] = [];
+ const seen = new Set();
+
+ // Add items in stored order
+ for (const id of sidebarOrder) {
+ if (navItemsMap.has(id) || extLinksMap.has(id)) {
+ result.push(id);
+ seen.add(id);
+ }
+ }
+
+ // Add any new internal nav items not in stored order
+ for (const item of defaultNavItems) {
+ if (!seen.has(item.id)) {
+ result.push(item.id);
+ seen.add(item.id);
+ }
+ }
+
+ // Add any new external links not in stored order
+ for (const link of externalLinks || []) {
+ const extId = `ext-${link.id}`;
+ if (!seen.has(extId)) {
+ result.push(extId);
+ seen.add(extId);
+ }
+ }
+
+ return result;
+ })();
+
+ // Unified drag handlers
+ const handleDragStart = (e: React.DragEvent, id: string) => {
+ setDraggedId(id);
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', id);
+ };
+
+ const handleDragOver = (e: React.DragEvent, id: string) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ setDragOverId(id);
+ };
+
+ const handleDragLeave = () => {
+ setDragOverId(null);
+ };
+
+ const handleDrop = (e: React.DragEvent, targetId: string) => {
+ e.preventDefault();
+ if (draggedId === null || draggedId === targetId) {
+ setDraggedId(null);
+ setDragOverId(null);
+ return;
+ }
+
+ const currentOrder = [...orderedSidebarIds];
+ const draggedIndex = currentOrder.indexOf(draggedId);
+ const targetIndex = currentOrder.indexOf(targetId);
+
+ if (draggedIndex === -1 || targetIndex === -1) {
+ setDraggedId(null);
+ setDragOverId(null);
+ return;
+ }
+
+ // Reorder
+ currentOrder.splice(draggedIndex, 1);
+ currentOrder.splice(targetIndex, 0, draggedId);
+
+ // Save to localStorage and update state
+ setSidebarOrder(currentOrder);
+ saveSidebarOrder(currentOrder);
+
+ setDraggedId(null);
+ setDragOverId(null);
+ };
+
+ const handleDragEnd = () => {
+ setDraggedId(null);
+ setDragOverId(null);
+ };
+
// Show update banner if update available and not dismissed for this version
const showUpdateBanner = updateCheck?.update_available &&
updateCheck.latest_version &&
@@ -132,45 +222,12 @@ export function Layout() {
localStorage.setItem('sidebarExpanded', String(sidebarExpanded));
}, [sidebarExpanded]);
- // Drag and drop handlers
- const handleDragStart = (e: React.DragEvent, index: number) => {
- setDraggedIndex(index);
- e.dataTransfer.effectAllowed = 'move';
- e.dataTransfer.setData('text/plain', String(index));
- };
-
- const handleDragOver = (e: React.DragEvent, index: number) => {
- e.preventDefault();
- e.dataTransfer.dropEffect = 'move';
- setDragOverIndex(index);
- };
-
- const handleDragLeave = () => {
- setDragOverIndex(null);
- };
-
- const handleDrop = (e: React.DragEvent, dropIndex: number) => {
- e.preventDefault();
- if (draggedIndex === null || draggedIndex === dropIndex) {
- setDraggedIndex(null);
- setDragOverIndex(null);
- return;
+ // Close mobile drawer on navigation
+ useEffect(() => {
+ if (isMobile) {
+ setMobileDrawerOpen(false);
}
-
- const newItems = [...navItems];
- const [draggedItem] = newItems.splice(draggedIndex, 1);
- newItems.splice(dropIndex, 0, draggedItem);
-
- setNavItems(newItems);
- saveNavOrder(newItems);
- setDraggedIndex(null);
- setDragOverIndex(null);
- };
-
- const handleDragEnd = () => {
- setDraggedIndex(null);
- setDragOverIndex(null);
- };
+ }, [location.pathname, isMobile]);
// Global keyboard shortcuts for navigation
const handleKeyDown = useCallback((e: KeyboardEvent) => {
@@ -180,13 +237,17 @@ export function Layout() {
return;
}
- // Number keys for navigation (1-6) - follows sidebar order
+ // Number keys for navigation (1-9) - follows sidebar order for internal nav items only
if (!e.metaKey && !e.ctrlKey && !e.altKey) {
const keyNum = parseInt(e.key);
- if (keyNum >= 1 && keyNum <= navItems.length) {
- e.preventDefault();
- navigate(navItems[keyNum - 1].to);
- return;
+ const internalItems = orderedSidebarIds.filter(id => !isExternalLinkId(id));
+ if (keyNum >= 1 && keyNum <= internalItems.length) {
+ const navItem = navItemsMap.get(internalItems[keyNum - 1]);
+ if (navItem) {
+ e.preventDefault();
+ navigate(navItem.to);
+ return;
+ }
}
switch (e.key) {
@@ -199,7 +260,7 @@ export function Layout() {
break;
}
}
- }, [navigate, navItems]);
+ }, [navigate, orderedSidebarIds, navItemsMap]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
@@ -208,77 +269,170 @@ export function Layout() {
return (