|
|
@@ -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<NavItem[]>(getOrderedNavItems);
|
|
|
- const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
|
|
- const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
|
|
+ const [sidebarOrder, setSidebarOrder] = useState<string[]>(getSidebarOrder);
|
|
|
+ const [draggedId, setDraggedId] = useState<string | null>(null);
|
|
|
+ const [dragOverId, setDragOverId] = useState<string | null>(null);
|
|
|
const hasRedirected = useRef(false);
|
|
|
const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState<string | null>(() =>
|
|
|
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<string>();
|
|
|
+
|
|
|
+ // 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 (
|
|
|
<div className="flex min-h-screen">
|
|
|
- {/* Sidebar */}
|
|
|
+ {/* Mobile Header */}
|
|
|
+ {isMobile && (
|
|
|
+ <header className="fixed top-0 left-0 right-0 z-40 h-14 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary flex items-center px-4">
|
|
|
+ <button
|
|
|
+ onClick={() => setMobileDrawerOpen(true)}
|
|
|
+ className="p-2 -ml-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
|
|
|
+ aria-label="Open menu"
|
|
|
+ >
|
|
|
+ <Menu className="w-6 h-6 text-white" />
|
|
|
+ </button>
|
|
|
+ <img
|
|
|
+ src={theme === 'dark' ? '/img/bambuddy_logo_dark.png' : '/img/bambuddy_logo_light.png'}
|
|
|
+ alt="Bambuddy"
|
|
|
+ className="h-8 ml-3"
|
|
|
+ />
|
|
|
+ </header>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Mobile Drawer Backdrop */}
|
|
|
+ {isMobile && mobileDrawerOpen && (
|
|
|
+ <div
|
|
|
+ className="fixed inset-0 bg-black/60 z-40 transition-opacity"
|
|
|
+ onClick={() => setMobileDrawerOpen(false)}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Sidebar / Mobile Drawer */}
|
|
|
<aside
|
|
|
- className={`${sidebarExpanded ? 'w-64' : 'w-16'} bg-bambu-dark-secondary border-r border-bambu-dark-tertiary flex flex-col fixed inset-y-0 left-0 z-30 transition-all duration-300`}
|
|
|
+ className={`bg-bambu-dark-secondary border-r border-bambu-dark-tertiary flex flex-col transition-all duration-300 ${
|
|
|
+ isMobile
|
|
|
+ ? `fixed inset-y-0 left-0 z-50 w-72 transform ${mobileDrawerOpen ? 'translate-x-0' : '-translate-x-full'}`
|
|
|
+ : `fixed inset-y-0 left-0 z-30 ${sidebarExpanded ? 'w-64' : 'w-16'}`
|
|
|
+ }`}
|
|
|
>
|
|
|
{/* Logo */}
|
|
|
- <div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${sidebarExpanded ? 'p-4' : 'p-2'}`}>
|
|
|
+ <div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${isMobile || sidebarExpanded ? 'p-4' : 'p-2'}`}>
|
|
|
<img
|
|
|
src={theme === 'dark' ? '/img/bambuddy_logo_dark.png' : '/img/bambuddy_logo_light.png'}
|
|
|
alt="Bambuddy"
|
|
|
- className={sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
|
|
|
+ className={isMobile || sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
|
|
|
/>
|
|
|
</div>
|
|
|
|
|
|
{/* Navigation */}
|
|
|
<nav className="flex-1 p-2">
|
|
|
<ul className="space-y-2">
|
|
|
- {navItems.map(({ id, to, icon: Icon, labelKey }, index) => (
|
|
|
- <li
|
|
|
- key={id}
|
|
|
- draggable
|
|
|
- onDragStart={(e) => handleDragStart(e, index)}
|
|
|
- onDragOver={(e) => handleDragOver(e, index)}
|
|
|
- onDragLeave={handleDragLeave}
|
|
|
- onDrop={(e) => handleDrop(e, index)}
|
|
|
- onDragEnd={handleDragEnd}
|
|
|
- className={`relative ${
|
|
|
- draggedIndex === index ? 'opacity-50' : ''
|
|
|
- } ${
|
|
|
- dragOverIndex === index && draggedIndex !== index
|
|
|
- ? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
|
|
|
- : ''
|
|
|
- }`}
|
|
|
- >
|
|
|
- <NavLink
|
|
|
- to={to}
|
|
|
- className={({ isActive }) =>
|
|
|
- `flex items-center ${sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
|
|
|
- isActive
|
|
|
- ? 'bg-bambu-green text-white'
|
|
|
- : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
|
|
|
- }`
|
|
|
- }
|
|
|
- title={!sidebarExpanded ? t(labelKey) : undefined}
|
|
|
- >
|
|
|
- {sidebarExpanded && (
|
|
|
- <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
|
|
|
- )}
|
|
|
- <Icon className="w-5 h-5 flex-shrink-0" />
|
|
|
- {sidebarExpanded && <span>{t(labelKey)}</span>}
|
|
|
- </NavLink>
|
|
|
- </li>
|
|
|
- ))}
|
|
|
+ {orderedSidebarIds.map((id) => {
|
|
|
+ const isExternal = isExternalLinkId(id);
|
|
|
+
|
|
|
+ if (isExternal) {
|
|
|
+ // Render external link
|
|
|
+ const link = extLinksMap.get(id);
|
|
|
+ if (!link) return null;
|
|
|
+
|
|
|
+ const LinkIcon = link.custom_icon ? null : getIconByName(link.icon);
|
|
|
+ return (
|
|
|
+ <li
|
|
|
+ key={id}
|
|
|
+ draggable
|
|
|
+ onDragStart={(e) => handleDragStart(e, id)}
|
|
|
+ onDragOver={(e) => handleDragOver(e, id)}
|
|
|
+ onDragLeave={handleDragLeave}
|
|
|
+ onDrop={(e) => handleDrop(e, id)}
|
|
|
+ onDragEnd={handleDragEnd}
|
|
|
+ className={`relative ${
|
|
|
+ draggedId === id ? 'opacity-50' : ''
|
|
|
+ } ${
|
|
|
+ dragOverId === id && draggedId !== id
|
|
|
+ ? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
|
|
|
+ : ''
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ <NavLink
|
|
|
+ to={`/external/${link.id}`}
|
|
|
+ className={({ isActive }) =>
|
|
|
+ `flex items-center ${isMobile || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
|
|
|
+ isActive
|
|
|
+ ? 'bg-bambu-green text-white'
|
|
|
+ : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
|
|
|
+ }`
|
|
|
+ }
|
|
|
+ title={!isMobile && !sidebarExpanded ? link.name : undefined}
|
|
|
+ >
|
|
|
+ {sidebarExpanded && !isMobile && (
|
|
|
+ <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
|
|
|
+ )}
|
|
|
+ {link.custom_icon ? (
|
|
|
+ <img
|
|
|
+ src={`/api/v1/external-links/${link.id}/icon`}
|
|
|
+ alt=""
|
|
|
+ className={`w-5 h-5 flex-shrink-0 ${theme === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`}
|
|
|
+ />
|
|
|
+ ) : (
|
|
|
+ LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
|
|
|
+ )}
|
|
|
+ {(isMobile || sidebarExpanded) && <span>{link.name}</span>}
|
|
|
+ </NavLink>
|
|
|
+ </li>
|
|
|
+ );
|
|
|
+ } else {
|
|
|
+ // Render internal nav item
|
|
|
+ const navItem = navItemsMap.get(id);
|
|
|
+ if (!navItem) return null;
|
|
|
+
|
|
|
+ const { to, icon: Icon, labelKey } = navItem;
|
|
|
+ return (
|
|
|
+ <li
|
|
|
+ key={id}
|
|
|
+ draggable
|
|
|
+ onDragStart={(e) => handleDragStart(e, id)}
|
|
|
+ onDragOver={(e) => handleDragOver(e, id)}
|
|
|
+ onDragLeave={handleDragLeave}
|
|
|
+ onDrop={(e) => handleDrop(e, id)}
|
|
|
+ onDragEnd={handleDragEnd}
|
|
|
+ className={`relative ${
|
|
|
+ draggedId === id ? 'opacity-50' : ''
|
|
|
+ } ${
|
|
|
+ dragOverId === id && draggedId !== id
|
|
|
+ ? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
|
|
|
+ : ''
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ <NavLink
|
|
|
+ to={to}
|
|
|
+ className={({ isActive }) =>
|
|
|
+ `flex items-center ${isMobile || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
|
|
|
+ isActive
|
|
|
+ ? 'bg-bambu-green text-white'
|
|
|
+ : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
|
|
|
+ }`
|
|
|
+ }
|
|
|
+ title={!isMobile && !sidebarExpanded ? t(labelKey) : undefined}
|
|
|
+ >
|
|
|
+ {sidebarExpanded && !isMobile && (
|
|
|
+ <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
|
|
|
+ )}
|
|
|
+ <Icon className="w-5 h-5 flex-shrink-0" />
|
|
|
+ {(isMobile || sidebarExpanded) && <span>{t(labelKey)}</span>}
|
|
|
+ </NavLink>
|
|
|
+ </li>
|
|
|
+ );
|
|
|
+ }
|
|
|
+ })}
|
|
|
</ul>
|
|
|
</nav>
|
|
|
|
|
|
- {/* Collapse toggle */}
|
|
|
- <button
|
|
|
- onClick={() => setSidebarExpanded(!sidebarExpanded)}
|
|
|
- className="p-2 mx-2 mb-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white flex items-center justify-center"
|
|
|
- title={sidebarExpanded ? t('nav.collapseSidebar') : t('nav.expandSidebar')}
|
|
|
- >
|
|
|
- {sidebarExpanded ? (
|
|
|
- <ChevronLeft className="w-5 h-5" />
|
|
|
- ) : (
|
|
|
- <ChevronRight className="w-5 h-5" />
|
|
|
- )}
|
|
|
- </button>
|
|
|
+ {/* Collapse toggle - hide on mobile */}
|
|
|
+ {!isMobile && (
|
|
|
+ <button
|
|
|
+ onClick={() => setSidebarExpanded(!sidebarExpanded)}
|
|
|
+ className="p-2 mx-2 mb-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white flex items-center justify-center"
|
|
|
+ title={sidebarExpanded ? t('nav.collapseSidebar') : t('nav.expandSidebar')}
|
|
|
+ >
|
|
|
+ {sidebarExpanded ? (
|
|
|
+ <ChevronLeft className="w-5 h-5" />
|
|
|
+ ) : (
|
|
|
+ <ChevronRight className="w-5 h-5" />
|
|
|
+ )}
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
|
|
|
{/* Footer */}
|
|
|
<div className="p-2 border-t border-bambu-dark-tertiary">
|
|
|
- {sidebarExpanded ? (
|
|
|
+ {isMobile || sidebarExpanded ? (
|
|
|
<div className="flex items-center justify-between px-2">
|
|
|
<div className="flex items-center gap-2">
|
|
|
<span className="text-sm text-bambu-gray">v{versionInfo?.version || '...'}</span>
|
|
|
@@ -359,7 +513,9 @@ export function Layout() {
|
|
|
</aside>
|
|
|
|
|
|
{/* Main content */}
|
|
|
- <main className={`flex-1 bg-bambu-dark overflow-auto ${sidebarExpanded ? 'ml-64' : 'ml-16'} transition-all duration-300`}>
|
|
|
+ <main className={`flex-1 bg-bambu-dark overflow-auto transition-all duration-300 ${
|
|
|
+ isMobile ? 'mt-14' : sidebarExpanded ? 'ml-64' : 'ml-16'
|
|
|
+ }`}>
|
|
|
{/* Persistent update banner */}
|
|
|
{showUpdateBanner && (
|
|
|
<div className="bg-bambu-green/20 border-b border-bambu-green/30 px-4 py-2 flex items-center justify-between">
|
|
|
@@ -391,7 +547,15 @@ export function Layout() {
|
|
|
</main>
|
|
|
|
|
|
{/* Keyboard Shortcuts Modal */}
|
|
|
- {showShortcuts && <KeyboardShortcutsModal onClose={() => setShowShortcuts(false)} navItems={navItems} />}
|
|
|
+ {showShortcuts && (
|
|
|
+ <KeyboardShortcutsModal
|
|
|
+ onClose={() => setShowShortcuts(false)}
|
|
|
+ navItems={orderedSidebarIds
|
|
|
+ .filter(id => !isExternalLinkId(id))
|
|
|
+ .map(id => navItemsMap.get(id)!)
|
|
|
+ .filter(Boolean)}
|
|
|
+ />
|
|
|
+ )}
|
|
|
</div>
|
|
|
);
|
|
|
}
|