Просмотр исходного кода

feat: Centralise sidebar ordering and add page visibility options (#1673)

Ed 2 месяцев назад
Родитель
Сommit
48d3f68ae6

+ 5 - 0
backend/app/schemas/settings.py

@@ -533,6 +533,11 @@ class AppSettingsUpdate(BaseModel):
             raise ValueError("default_sidebar_order must be valid JSON or empty")
         if isinstance(parsed, dict):
             order = parsed.get("order")
+            hidden_system_item_ids = parsed.get("hiddenSystemItemIds", [])
+            if not isinstance(hidden_system_item_ids, list) or not all(
+                isinstance(item, str) for item in hidden_system_item_ids
+            ):
+                raise ValueError("sidebar hidden system item IDs must be an array of strings")
         elif isinstance(parsed, list):
             order = parsed
         else:

+ 17 - 0
backend/tests/unit/test_sidebar_settings.py

@@ -0,0 +1,17 @@
+import pytest
+from pydantic import ValidationError
+
+from backend.app.schemas.settings import AppSettingsUpdate
+
+
+def test_default_sidebar_order_accepts_hidden_system_item_ids():
+    value = '{"order":["printers","ext-1","settings"],"hiddenSystemItemIds":["stats"]}'
+
+    update = AppSettingsUpdate(default_sidebar_order=value)
+
+    assert update.default_sidebar_order == value
+
+
+def test_default_sidebar_order_rejects_invalid_hidden_system_item_ids():
+    with pytest.raises(ValidationError):
+        AppSettingsUpdate(default_sidebar_order='{"order":["printers"],"hiddenSystemItemIds":"stats"}')

+ 56 - 1
frontend/src/__tests__/components/Layout.test.tsx

@@ -2,15 +2,21 @@
  * Tests for the Layout component.
  */
 
-import { describe, it, expect, beforeEach } from 'vitest';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
 import { waitFor } from '@testing-library/react';
 import { render } from '../utils';
 import { Layout } from '../../components/Layout';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
+import { SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, SIDEBAR_ORDER_KEY } from '../../utils/sidebarLayout';
 
 describe('Layout', () => {
   beforeEach(() => {
+    vi.mocked(localStorage.getItem).mockReset();
+    vi.mocked(localStorage.setItem).mockReset();
+    vi.mocked(localStorage.removeItem).mockReset();
+    vi.mocked(localStorage.clear).mockReset();
+    localStorage.clear();
     server.use(
       http.get('/api/v1/printers/', () => {
         return HttpResponse.json([
@@ -102,6 +108,55 @@ describe('Layout', () => {
         expect(settingsLink).toBeInTheDocument();
       });
     });
+
+    it('hides system nav items stored in sidebar layout preferences', async () => {
+      vi.mocked(localStorage.getItem).mockImplementation((key) => {
+        if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['printers']);
+        return null;
+      });
+
+      render(<Layout />);
+
+      await waitFor(() => {
+        const sidebar = document.querySelector('aside');
+        expect(sidebar).toBeInTheDocument();
+        expect(sidebar?.querySelector('a[href="/inventory"]')).toBeInTheDocument();
+      });
+
+      expect(document.querySelector('aside a[href="/"]')).toBeNull();
+    });
+
+    it('applies admin default sidebar hidden state with the default order', async () => {
+      const storage: Record<string, string> = {};
+      vi.mocked(localStorage.getItem).mockImplementation((key) => storage[key] ?? null);
+      vi.mocked(localStorage.setItem).mockImplementation((key, value) => {
+        storage[key] = value;
+      });
+      server.use(
+        http.get('/api/v1/settings/default-sidebar-order', () =>
+          HttpResponse.json({
+            default_sidebar_order: JSON.stringify({
+              order: ['inventory', 'printers', 'settings'],
+              hiddenSystemItemIds: ['printers'],
+            }),
+          }),
+        ),
+      );
+
+      render(<Layout />);
+
+      await waitFor(() => {
+        const sidebar = document.querySelector('aside');
+        expect(sidebar).toBeInTheDocument();
+        expect(sidebar?.querySelector('a[href="/inventory"]')).toBeInTheDocument();
+      });
+
+      await waitFor(() => {
+        expect(document.querySelector('aside a[href="/"]')).toBeNull();
+        expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_ORDER_KEY, JSON.stringify(['inventory', 'printers', 'settings']));
+        expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify(['printers']));
+      });
+    });
   });
 
   describe('version display', () => {

+ 252 - 3
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -2,13 +2,15 @@
  * Tests for the SettingsPage component.
  */
 
-import { describe, it, expect, beforeEach } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { fireEvent, screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { SettingsPage } from '../../pages/SettingsPage';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
+import { SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, SIDEBAR_ORDER_KEY } from '../../utils/sidebarLayout';
+import { setAuthToken } from '../../api/client';
 
 const mockSettings = {
   auto_archive: true,
@@ -41,12 +43,18 @@ describe('SettingsPage', () => {
     // switch in one test (e.g. clicking "Workflow") doesn't carry into
     // sibling tests that expect to land on the default General tab.
     window.history.replaceState({}, '', '/');
+    vi.mocked(localStorage.getItem).mockReset();
+    vi.mocked(localStorage.setItem).mockReset();
+    vi.mocked(localStorage.removeItem).mockReset();
+    vi.mocked(localStorage.clear).mockReset();
+    localStorage.clear();
+    setAuthToken(null);
 
     server.use(
       http.get('/api/v1/settings/', () => {
         return HttpResponse.json(mockSettings);
       }),
-      http.patch('/api/v1/settings/', async ({ request }) => {
+      http.put('/api/v1/settings/', async ({ request }) => {
         const body = await request.json();
         return HttpResponse.json({ ...mockSettings, ...body });
       }),
@@ -70,6 +78,9 @@ describe('SettingsPage', () => {
       }),
       http.get('/api/v1/auth/status', () => {
         return HttpResponse.json({ auth_enabled: false, requires_setup: false });
+      }),
+      http.get('/api/v1/external-links/', () => {
+        return HttpResponse.json([]);
       })
     );
   });
@@ -172,6 +183,244 @@ describe('SettingsPage', () => {
         expect(screen.getByText('Check printer firmware')).toBeInTheDocument();
       });
     });
+
+    it('hides a Bambuddy sidebar page from Sidebar', async () => {
+      const user = userEvent.setup();
+      render(<SettingsPage />);
+
+      await screen.findByRole('heading', { name: 'Sidebar' });
+      await screen.findAllByText('Visible in sidebar');
+
+      vi.mocked(localStorage.setItem).mockClear();
+      await user.click((await screen.findAllByLabelText('Hide page'))[0]);
+
+      expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify(['printers']));
+      expect(screen.getByText('Hidden from sidebar')).toBeInTheDocument();
+    });
+
+    it('shows a previously hidden Bambuddy sidebar page from Sidebar', async () => {
+      vi.mocked(localStorage.getItem).mockImplementation((key) => {
+        if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['printers']);
+        return null;
+      });
+
+      const user = userEvent.setup();
+      render(<SettingsPage />);
+
+      await screen.findByRole('heading', { name: 'Sidebar' });
+      await screen.findByText('Hidden from sidebar');
+
+      vi.mocked(localStorage.setItem).mockClear();
+      await user.click(await screen.findByLabelText('Show page'));
+
+      expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([]));
+      expect(screen.getAllByText('Visible in sidebar').length).toBeGreaterThan(0);
+    });
+
+    it('does not allow Settings to be hidden from Sidebar', async () => {
+      render(<SettingsPage />);
+
+      await screen.findByRole('heading', { name: 'Sidebar' });
+      await screen.findByText('Required in sidebar');
+
+      const settingsVisibilityButton = await screen.findByLabelText('Settings cannot be hidden');
+      expect(settingsVisibilityButton).toBeDisabled();
+      expect(screen.getByText('Required in sidebar')).toBeInTheDocument();
+    });
+
+    it('presents external links and Bambuddy pages in saved sidebar order', async () => {
+      vi.mocked(localStorage.getItem).mockImplementation((key) => {
+        if (key === SIDEBAR_ORDER_KEY) return JSON.stringify(['ext-7', 'printers', 'settings']);
+        return null;
+      });
+      server.use(
+        http.get('/api/v1/external-links/', () =>
+          HttpResponse.json([
+            {
+              id: 7,
+              name: 'Docs',
+              url: 'https://docs.example.test',
+              icon: 'Link',
+              open_in_new_tab: true,
+              custom_icon: null,
+              sort_order: 0,
+              created_at: '2026-01-01T00:00:00Z',
+              updated_at: '2026-01-01T00:00:00Z',
+            },
+          ]),
+        ),
+      );
+
+      render(<SettingsPage />);
+
+      await screen.findByRole('heading', { name: 'Sidebar' });
+      const docs = await screen.findByText('Docs');
+      const printers = screen.getAllByText('Printers').find(element => element.closest('[draggable="true"]'));
+
+      expect(printers).toBeDefined();
+      expect(docs.compareDocumentPosition(printers) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+    });
+
+    it('saves mixed Sidebar order when items are dragged', async () => {
+      server.use(
+        http.get('/api/v1/external-links/', () =>
+          HttpResponse.json([
+            {
+              id: 7,
+              name: 'Docs',
+              url: 'https://docs.example.test',
+              icon: 'Link',
+              open_in_new_tab: true,
+              custom_icon: null,
+              sort_order: 0,
+              created_at: '2026-01-01T00:00:00Z',
+              updated_at: '2026-01-01T00:00:00Z',
+            },
+          ]),
+        ),
+      );
+
+      render(<SettingsPage />);
+
+      await screen.findByRole('heading', { name: 'Sidebar' });
+      const docsRow = (await screen.findByText('Docs')).closest('[draggable="true"]');
+      const printersRow = screen.getAllByText('Printers')
+        .find(element => element.closest('[draggable="true"]'))
+        ?.closest('[draggable="true"]');
+
+      expect(docsRow).not.toBeNull();
+      expect(printersRow).not.toBeNull();
+
+      vi.mocked(localStorage.setItem).mockClear();
+      const dataTransfer = {
+        effectAllowed: '',
+        dropEffect: '',
+        setData: vi.fn(),
+      };
+      fireEvent.dragStart(docsRow!, { dataTransfer });
+      fireEvent.dragOver(printersRow!, { dataTransfer });
+      fireEvent.drop(printersRow!, { dataTransfer });
+
+      expect(localStorage.setItem).toHaveBeenCalledWith(
+        SIDEBAR_ORDER_KEY,
+        JSON.stringify(['ext-7', 'printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'settings']),
+      );
+    });
+
+    it('resets Sidebar to all pages first and configured links at the bottom', async () => {
+      vi.mocked(localStorage.getItem).mockImplementation((key) => {
+        if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['printers', 'stats']);
+        if (key === SIDEBAR_ORDER_KEY) return JSON.stringify(['ext-7', 'settings', 'printers']);
+        return null;
+      });
+      server.use(
+        http.get('/api/v1/external-links/', () =>
+          HttpResponse.json([
+            {
+              id: 7,
+              name: 'Docs',
+              url: 'https://docs.example.test',
+              icon: 'Link',
+              open_in_new_tab: true,
+              custom_icon: null,
+              sort_order: 0,
+              created_at: '2026-01-01T00:00:00Z',
+              updated_at: '2026-01-01T00:00:00Z',
+            },
+          ]),
+        ),
+      );
+
+      const user = userEvent.setup();
+      render(<SettingsPage />);
+
+      const heading = await screen.findByRole('heading', { name: 'Sidebar' });
+      const card = heading.closest('#card-sidebar-links');
+      expect(card).not.toBeNull();
+      await screen.findByText('Docs');
+
+      vi.mocked(localStorage.setItem).mockClear();
+      await user.click(within(card as HTMLElement).getByRole('button', { name: /reset/i }));
+
+      expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([]));
+      expect(localStorage.setItem).toHaveBeenCalledWith(
+        SIDEBAR_ORDER_KEY,
+        JSON.stringify(['printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'settings', 'ext-7']),
+      );
+
+      const settingsRow = screen.getAllByText('Settings')
+        .find(element => element.closest('[draggable="true"]'))
+        ?.closest('[draggable="true"]');
+      const docsRow = screen.getByText('Docs').closest('[draggable="true"]');
+      expect(settingsRow).not.toBeNull();
+      expect(docsRow).not.toBeNull();
+      expect(settingsRow!.compareDocumentPosition(docsRow!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+      expect(screen.queryByText('Hidden from sidebar')).not.toBeInTheDocument();
+    });
+
+    it('sets the current Sidebar order as the backend default for settings admins', async () => {
+      let defaultSidebarOrderPayload: string | null = null;
+      vi.mocked(localStorage.getItem).mockImplementation((key) => {
+        if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['stats']);
+        return null;
+      });
+
+      server.use(
+        http.get('/api/v1/auth/status', () =>
+          HttpResponse.json({ auth_enabled: true, requires_setup: false }),
+        ),
+        http.get('/api/v1/auth/me', () =>
+          HttpResponse.json({
+            id: 1,
+            username: 'admin',
+            role: 'admin',
+            is_active: true,
+            is_admin: false,
+            groups: [{ id: 1, name: 'Administrators' }],
+            permissions: ['settings:update'],
+            created_at: '2026-01-01T00:00:00Z',
+          }),
+        ),
+        http.get('/api/v1/settings/', () =>
+          HttpResponse.json({ ...mockSettings, default_sidebar_order: '' }),
+        ),
+        http.put('/api/v1/settings/', async ({ request }) => {
+          const body = await request.json() as { default_sidebar_order?: string };
+          defaultSidebarOrderPayload = body.default_sidebar_order ?? null;
+          return HttpResponse.json({ ...mockSettings, ...body });
+        }),
+      );
+      setAuthToken('test-token');
+
+      const user = userEvent.setup();
+      render(<SettingsPage />);
+
+      const heading = await screen.findByRole('heading', { name: 'Sidebar' });
+      const card = heading.closest('#card-sidebar-links');
+      expect(card).not.toBeNull();
+
+      await user.click(within(card as HTMLElement).getByRole('switch', { name: 'Set Default' }));
+
+      await waitFor(() => {
+        expect(defaultSidebarOrderPayload).not.toBeNull();
+      });
+      expect(JSON.parse(defaultSidebarOrderPayload!)).toEqual({
+        order: [
+          'printers',
+          'inventory',
+          'archives',
+          'queue',
+          'projects',
+          'files',
+          'makerworld',
+          'profiles',
+          'maintenance',
+          'stats',
+          'settings',
+        ],
+        hiddenSystemItemIds: ['stats'],
+      });
+    });
   });
 
   describe('update CTA per deployment shape', () => {

+ 235 - 47
frontend/src/components/ExternalLinksSettings.tsx

@@ -1,30 +1,47 @@
-import { useState } from 'react';
+import { useMemo, useState } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Link2, Plus, Pencil, Trash2, GripVertical, Loader2, ExternalLink as ExternalLinkIcon } from 'lucide-react';
+import { Eye, EyeOff, Link2, Plus, Pencil, Trash2, GripVertical, Loader2, ExternalLink as ExternalLinkIcon, RotateCcw } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import type { ExternalLink } from '../api/client';
 import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
+import { Toggle } from './Toggle';
 import { AddExternalLinkModal } from './AddExternalLinkModal';
 import { ConfirmModal } from './ConfirmModal';
 import { getIconByName } from './IconPicker';
+import { defaultNavItems } from './Layout';
+import { useAuth } from '../contexts/AuthContext';
+import { useToast } from '../contexts/ToastContext';
+import {
+  getHiddenSidebarSystemItemIds,
+  getSidebarOrder,
+  saveHiddenSidebarSystemItemIds,
+  saveSidebarOrder,
+} from '../utils/sidebarLayout';
+
+type SidebarLayoutItem =
+  | { type: 'system'; id: string; navItem: typeof defaultNavItems[number] }
+  | { type: 'external'; id: string; link: ExternalLink };
 
 export function ExternalLinksSettings() {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
+  const { authEnabled, hasPermission } = useAuth();
+  const { showToast } = useToast();
   const [showAddModal, setShowAddModal] = useState(false);
   const [editingLink, setEditingLink] = useState<ExternalLink | null>(null);
   const [deletingLink, setDeletingLink] = useState<ExternalLink | null>(null);
-  const [draggedId, setDraggedId] = useState<number | null>(null);
+  const [draggedId, setDraggedId] = useState<string | null>(null);
+  const [dragOverId, setDragOverId] = useState<string | null>(null);
+  const [hiddenSystemItemIds, setHiddenSystemItemIds] = useState<string[]>(getHiddenSidebarSystemItemIds);
+  const [sidebarOrder, setSidebarOrder] = useState<string[]>(() => getSidebarOrder(defaultNavItems.map(i => i.id)));
 
-  // Fetch external links
   const { data: links, isLoading } = useQuery({
     queryKey: ['external-links'],
     queryFn: api.getExternalLinks,
   });
 
-  // Delete mutation
   const deleteMutation = useMutation({
     mutationFn: (id: number) => api.deleteExternalLink(id),
     onSuccess: () => {
@@ -32,41 +49,105 @@ export function ExternalLinksSettings() {
     },
   });
 
-  // Reorder mutation
-  const reorderMutation = useMutation({
-    mutationFn: (ids: number[]) => api.reorderExternalLinks(ids),
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['external-links'] });
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+    enabled: authEnabled && hasPermission('settings:update'),
+  });
+
+  const updateDefaultSidebarMutation = useMutation({
+    mutationFn: (defaultSidebarOrder: string) => api.updateSettings({ default_sidebar_order: defaultSidebarOrder }),
+    onSuccess: (_data, defaultSidebarOrder) => {
+      queryClient.invalidateQueries({ queryKey: ['settings'] });
+      queryClient.invalidateQueries({ queryKey: ['default-sidebar-order'] });
+      showToast(
+        defaultSidebarOrder
+          ? t('settings.sidebarDefaultSet')
+          : t('settings.sidebarDefaultCleared'),
+        'success'
+      );
+    },
+    onError: () => {
+      showToast(t('settings.sidebarDefaultFailed'), 'error');
     },
   });
 
-  const handleDragStart = (e: React.DragEvent, id: number) => {
+  const layoutItems = useMemo<SidebarLayoutItem[]>(() => {
+    const navItemsMap = new Map(defaultNavItems.map(item => [item.id, item]));
+    const externalLinksMap = new Map((links || []).map(link => [`ext-${link.id}`, link]));
+    const result: SidebarLayoutItem[] = [];
+    const seen = new Set<string>();
+
+    const addItem = (id: string) => {
+      if (seen.has(id)) return;
+
+      const navItem = navItemsMap.get(id);
+      if (navItem) {
+        result.push({ type: 'system', id, navItem });
+        seen.add(id);
+        return;
+      }
+
+      const link = externalLinksMap.get(id);
+      if (link) {
+        result.push({ type: 'external', id, link });
+        seen.add(id);
+      }
+    };
+
+    sidebarOrder.forEach(addItem);
+    defaultNavItems.forEach(item => addItem(item.id));
+    (links || []).forEach(link => addItem(`ext-${link.id}`));
+
+    return result;
+  }, [links, sidebarOrder]);
+
+  const persistSidebarOrder = (order: string[]) => {
+    setSidebarOrder(order);
+    saveSidebarOrder(order);
+  };
+
+  const handleDragStart = (e: React.DragEvent, id: string) => {
     setDraggedId(id);
     e.dataTransfer.effectAllowed = 'move';
+    e.dataTransfer.setData('text/plain', id);
   };
 
-  const handleDragOver = (e: React.DragEvent) => {
+  const handleDragOver = (e: React.DragEvent, id: string) => {
     e.preventDefault();
     e.dataTransfer.dropEffect = 'move';
+    setDragOverId(id);
   };
 
-  const handleDrop = (e: React.DragEvent, targetId: number) => {
+  const handleDrop = (e: React.DragEvent, targetId: string) => {
     e.preventDefault();
-    if (draggedId === null || draggedId === targetId || !links) return;
+    if (draggedId === null || draggedId === targetId) {
+      setDraggedId(null);
+      setDragOverId(null);
+      return;
+    }
+
+    const currentOrder = layoutItems.map(item => item.id);
+    const draggedIndex = currentOrder.indexOf(draggedId);
+    const targetIndex = currentOrder.indexOf(targetId);
 
-    const currentIds = links.map((l) => l.id);
-    const draggedIndex = currentIds.indexOf(draggedId);
-    const targetIndex = currentIds.indexOf(targetId);
+    if (draggedIndex === -1 || targetIndex === -1) {
+      setDraggedId(null);
+      setDragOverId(null);
+      return;
+    }
 
-    if (draggedIndex === -1 || targetIndex === -1) return;
+    currentOrder.splice(draggedIndex, 1);
+    currentOrder.splice(targetIndex, 0, draggedId);
+    persistSidebarOrder(currentOrder);
 
-    // Reorder
-    const newIds = [...currentIds];
-    newIds.splice(draggedIndex, 1);
-    newIds.splice(targetIndex, 0, draggedId);
+    setDraggedId(null);
+    setDragOverId(null);
+  };
 
-    reorderMutation.mutate(newIds);
+  const handleDragEnd = () => {
     setDraggedId(null);
+    setDragOverId(null);
   };
 
   const handleDelete = (link: ExternalLink) => {
@@ -80,48 +161,163 @@ export function ExternalLinksSettings() {
     }
   };
 
+  const resetSidebarLayout = () => {
+    const resetOrder = [
+      ...defaultNavItems.map(item => item.id),
+      ...(links || []).map(link => `ext-${link.id}`),
+    ];
+
+    setHiddenSystemItemIds([]);
+    setSidebarOrder(resetOrder);
+    saveHiddenSidebarSystemItemIds([]);
+    saveSidebarOrder(resetOrder);
+  };
+
+  const handleToggleDefaultSidebarOrder = (enabled: boolean) => {
+    const currentOrder = layoutItems.map(item => item.id);
+    updateDefaultSidebarMutation.mutate(enabled ? JSON.stringify({
+      order: currentOrder,
+      hiddenSystemItemIds,
+    }) : '');
+  };
+
+  const toggleSystemItemVisibility = (id: string) => {
+    if (id === 'settings') return;
+
+    const isHidden = hiddenSystemItemIds.includes(id);
+    const nextIds = isHidden
+      ? hiddenSystemItemIds.filter(hiddenId => hiddenId !== id)
+      : [...hiddenSystemItemIds, id];
+
+    setHiddenSystemItemIds(nextIds);
+    saveHiddenSidebarSystemItemIds(nextIds);
+  };
+
+  const canSetDefaultSidebarOrder = authEnabled && hasPermission('settings:update');
+  const isDefaultSidebarEnabled = !!settings?.default_sidebar_order;
+
   return (
     <>
       <Card id="card-sidebar-links">
         <CardHeader>
-          <div className="flex items-center justify-between">
+          <div className="flex flex-wrap items-center justify-between gap-y-2 gap-x-3">
             <div className="flex items-center gap-2">
               <Link2 className="w-5 h-5 text-bambu-green" />
-              <h2 className="text-lg font-semibold text-white">Sidebar Links</h2>
+              <h2 className="text-lg font-semibold text-white">{t('externalLinks.sidebarLayout')}</h2>
+            </div>
+            <div className="flex flex-wrap items-center gap-2">
+              {canSetDefaultSidebarOrder && (
+                <label className="flex items-center gap-2 text-sm text-bambu-gray">
+                  <span>{t('settings.setDefault')}</span>
+                  <Toggle
+                    checked={isDefaultSidebarEnabled}
+                    onChange={handleToggleDefaultSidebarOrder}
+                    disabled={updateDefaultSidebarMutation.isPending}
+                  />
+                </label>
+              )}
+              <Button variant="secondary" size="sm" onClick={resetSidebarLayout} className="whitespace-nowrap">
+                <RotateCcw className="w-4 h-4" />
+                {t('settings.reset')}
+              </Button>
+              <Button size="sm" onClick={() => setShowAddModal(true)} className="whitespace-nowrap">
+                <Plus className="w-4 h-4" />
+                Add Link
+              </Button>
             </div>
-            <Button size="sm" onClick={() => setShowAddModal(true)}>
-              <Plus className="w-4 h-4" />
-              Add Link
-            </Button>
           </div>
         </CardHeader>
         <CardContent>
           <p className="text-sm text-bambu-gray mb-4">
-            Add external links to the sidebar navigation. Drag to reorder.
+            {t('externalLinks.sidebarLayoutDescription')}
           </p>
 
           {isLoading ? (
             <div className="flex justify-center py-8">
               <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
             </div>
-          ) : links && links.length > 0 ? (
+          ) : (
             <div className="space-y-2">
-              {links.map((link) => {
+              {layoutItems.map((item) => {
+                const isHidden = item.type === 'system' && hiddenSystemItemIds.includes(item.id);
+                const isSettings = item.id === 'settings';
+
+                if (item.type === 'system') {
+                  const Icon = item.navItem.icon;
+                  return (
+                    <div
+                      key={item.id}
+                      draggable
+                      onDragStart={(e) => handleDragStart(e, item.id)}
+                      onDragOver={(e) => handleDragOver(e, item.id)}
+                      onDragLeave={() => setDragOverId(null)}
+                      onDrop={(e) => handleDrop(e, item.id)}
+                      onDragEnd={handleDragEnd}
+                      className={`relative flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary transition-colors ${
+                        draggedId === item.id ? 'opacity-50' : isHidden ? 'opacity-60' : ''
+                      } ${
+                        dragOverId === item.id && draggedId !== item.id
+                          ? 'before:absolute before:left-3 before:right-3 before:top-0 before:h-0.5 before:bg-bambu-green'
+                          : ''
+                      }`}
+                    >
+                      <GripVertical className="w-6 h-6 md:w-4 md:h-4 text-bambu-gray cursor-grab flex-shrink-0" />
+                      <div className="p-2 rounded-lg bg-bambu-dark-tertiary text-bambu-gray">
+                        <Icon className="w-4 h-4" />
+                      </div>
+                      <div className="flex-1 min-w-0">
+                        <span className="text-white font-medium truncate block">{t(item.navItem.labelKey)}</span>
+                        <span className="text-sm text-bambu-gray truncate block">
+                          {isSettings
+                            ? t('externalLinks.requiredInSidebar')
+                            : isHidden
+                              ? t('externalLinks.hiddenFromSidebar')
+                              : t('externalLinks.visibleInSidebar')}
+                        </span>
+                      </div>
+                      <button
+                        onClick={() => toggleSystemItemVisibility(item.id)}
+                        disabled={isSettings}
+                        className="p-2 rounded-lg hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-bambu-gray"
+                        title={isSettings ? t('externalLinks.settingsCannotBeHidden') : isHidden ? t('externalLinks.showPage') : t('externalLinks.hidePage')}
+                        aria-label={isSettings ? t('externalLinks.settingsCannotBeHidden') : isHidden ? t('externalLinks.showPage') : t('externalLinks.hidePage')}
+                      >
+                        {isHidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
+                      </button>
+                    </div>
+                  );
+                }
+
+                const link = item.link;
                 const Icon = getIconByName(link.icon);
                 return (
                   <div
-                    key={link.id}
+                    key={item.id}
                     draggable
-                    onDragStart={(e) => handleDragStart(e, link.id)}
-                    onDragOver={handleDragOver}
-                    onDrop={(e) => handleDrop(e, link.id)}
-                    className={`flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary transition-colors ${
-                      draggedId === link.id ? 'opacity-50' : ''
+                    onDragStart={(e) => handleDragStart(e, item.id)}
+                    onDragOver={(e) => handleDragOver(e, item.id)}
+                    onDragLeave={() => setDragOverId(null)}
+                    onDrop={(e) => handleDrop(e, item.id)}
+                    onDragEnd={handleDragEnd}
+                    className={`relative flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary transition-colors ${
+                      draggedId === item.id ? 'opacity-50' : ''
+                    } ${
+                      dragOverId === item.id && draggedId !== item.id
+                        ? 'before:absolute before:left-3 before:right-3 before:top-0 before:h-0.5 before:bg-bambu-green'
+                        : ''
                     }`}
                   >
                     <GripVertical className="w-6 h-6 md:w-4 md:h-4 text-bambu-gray cursor-grab flex-shrink-0" />
                     <div className="p-2 rounded-lg bg-bambu-dark-tertiary text-bambu-gray">
-                      <Icon className="w-4 h-4" />
+                      {link.custom_icon ? (
+                        <img
+                          src={api.getExternalLinkIconUrl(link.id)}
+                          alt=""
+                          className="w-4 h-4"
+                        />
+                      ) : (
+                        <Icon className="w-4 h-4" />
+                      )}
                     </div>
                     <div className="flex-1 min-w-0">
                       <div className="flex items-center gap-2">
@@ -151,17 +347,10 @@ export function ExternalLinksSettings() {
                 );
               })}
             </div>
-          ) : (
-            <div className="text-center py-8 text-bambu-gray">
-              <Link2 className="w-8 h-8 mx-auto mb-2 opacity-50" />
-              <p>{t('externalLinks.noLinksConfigured')}</p>
-              <p className="text-sm">Click "Add Link" to add one</p>
-            </div>
           )}
         </CardContent>
       </Card>
 
-      {/* Add/Edit Modal */}
       {(showAddModal || editingLink) && (
         <AddExternalLinkModal
           link={editingLink}
@@ -172,7 +361,6 @@ export function ExternalLinksSettings() {
         />
       )}
 
-      {/* Delete Confirmation Modal */}
       {deletingLink && (
         <ConfirmModal
           title="Delete Link"

+ 44 - 128
frontend/src/components/Layout.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Bell, Globe, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -17,6 +17,14 @@ import { Card, CardHeader, CardContent } from './Card';
 import { parseUTCDate } from '../utils/date';
 import { Button } from './Button';
 import { BugReportBubble } from './BugReportBubble';
+import {
+  getHiddenSidebarSystemItemIds,
+  getSidebarOrder,
+  isExternalSidebarItemId,
+  saveHiddenSidebarSystemItemIds,
+  saveSidebarOrder,
+  SIDEBAR_LAYOUT_CHANGED_EVENT,
+} from '../utils/sidebarLayout';
 
 
 interface NavItem {
@@ -37,34 +45,9 @@ export const defaultNavItems: NavItem[] = [
   { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
   { id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },
   { id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' },
-  // User-account features: kept adjacent to Settings intentionally
-  { id: 'notifications', to: '/notifications', icon: Bell, labelKey: 'nav.notifications' },
   { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
 ];
 
-// Get unified sidebar order from localStorage
-function getSidebarOrder(): string[] {
-  const stored = localStorage.getItem('sidebarOrder');
-  if (stored) {
-    try {
-      return JSON.parse(stored);
-    } catch {
-      return defaultNavItems.map(i => i.id);
-    }
-  }
-  return defaultNavItems.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
 export function getDefaultView(): string {
   return localStorage.getItem('defaultView') || '/';
@@ -103,9 +86,9 @@ export function Layout() {
   const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
   const [showShortcuts, setShowShortcuts] = useState(false);
   const [showSwitchbar, setShowSwitchbar] = useState(false);
-  const [sidebarOrder, setSidebarOrder] = useState<string[]>(getSidebarOrder);
-  const [draggedId, setDraggedId] = useState<string | null>(null);
-  const [dragOverId, setDragOverId] = useState<string | null>(null);
+  const defaultSidebarOrder = useMemo(() => defaultNavItems.map(i => i.id), []);
+  const [sidebarOrder, setSidebarOrder] = useState<string[]>(() => getSidebarOrder(defaultNavItems.map(i => i.id)));
+  const [hiddenSystemItemIds, setHiddenSystemItemIds] = useState<string[]>(getHiddenSidebarSystemItemIds);
   const hasRedirected = useRef(false);
   const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState<string | null>(() =>
     sessionStorage.getItem('dismissedUpdateVersion')
@@ -151,10 +134,16 @@ export function Layout() {
       if (!Array.isArray(orderArr) || orderArr.length === 0) return;
       // Filter to valid sidebar item IDs only
       const validIds = new Set(defaultNavItems.map(i => i.id));
-      const filtered = orderArr.filter((id: string) => typeof id === 'string' && (validIds.has(id) || isExternalLinkId(id)));
+      const filtered = orderArr.filter((id: string) => typeof id === 'string' && (validIds.has(id) || isExternalSidebarItemId(id)));
       if (filtered.length > 0) {
         setSidebarOrder(filtered);
         saveSidebarOrder(filtered);
+        const hiddenIds = Array.isArray(parsed) ? [] : parsed.hiddenSystemItemIds;
+        if (Array.isArray(hiddenIds)) {
+          const filteredHiddenIds = hiddenIds.filter((id: string) => typeof id === 'string' && validIds.has(id) && id !== 'settings');
+          setHiddenSystemItemIds(filteredHiddenIds);
+          saveHiddenSidebarSystemItemIds(filteredHiddenIds);
+        }
         localStorage.setItem(appliedKey, '1');
       }
     } catch (e) {
@@ -162,7 +151,8 @@ export function Layout() {
     }
   }, [defaultSidebarData?.default_sidebar_order, setSidebarOrder, user, authEnabled]);
 
-  // Check advanced auth status for conditional nav items
+  // Check advanced auth status — the notifications nav item is gated on it
+  // (rendered only when authEnabled && advanced_auth_enabled && user_notifications_enabled).
   const { data: advancedAuthStatus } = useQuery({
     queryKey: ['advancedAuthStatus'],
     queryFn: api.getAdvancedAuthStatus,
@@ -296,10 +286,14 @@ export function Layout() {
       files: ['library:read', 'library:read_own', 'library:read_all'],
       makerworld: 'makerworld:view',
       settings: 'settings:read',
-      notifications: 'notifications:user_email',
     };
 
     const isHidden = (id: string) => {
+      // User-toggled hide (#1673) wins first — cheapest check, explicit intent.
+      if (hiddenSystemItemIds.includes(id)) return true;
+      // Permission gate accepts Permission | Permission[] so resources with
+      // granular `*:read_own` / `*:read_all` tiers (default Operators group)
+      // don't get hidden from users who only hold the granular variant (#1755).
       if (authEnabled && id in navPermissions) {
         const required = navPermissions[id];
         const granted = Array.isArray(required)
@@ -342,58 +336,6 @@ export function Layout() {
     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.
   // Suppressed when running as a Home Assistant addon — HA Supervisor surfaces
   // its own update notification in the HA UI, so the in-app banner is duplicate
@@ -425,6 +367,19 @@ export function Layout() {
     localStorage.setItem('sidebarExpanded', String(sidebarExpanded));
   }, [sidebarExpanded]);
 
+  useEffect(() => {
+    const refreshSidebarLayout = () => {
+      setSidebarOrder(getSidebarOrder(defaultSidebarOrder));
+      setHiddenSystemItemIds(getHiddenSidebarSystemItemIds());
+    };
+    window.addEventListener(SIDEBAR_LAYOUT_CHANGED_EVENT, refreshSidebarLayout);
+    window.addEventListener('storage', refreshSidebarLayout);
+    return () => {
+      window.removeEventListener(SIDEBAR_LAYOUT_CHANGED_EVENT, refreshSidebarLayout);
+      window.removeEventListener('storage', refreshSidebarLayout);
+    };
+  }, [defaultSidebarOrder]);
+
   // Close compact drawer on navigation
   useEffect(() => {
     if (isSidebarCompact) {
@@ -466,7 +421,7 @@ export function Layout() {
         const id = orderedSidebarIds[keyNum - 1];
         e.preventDefault();
 
-        if (isExternalLinkId(id)) {
+        if (isExternalSidebarItemId(id)) {
           // External link
           const extLink = extLinksMap.get(id);
           if (extLink?.open_in_new_tab) {
@@ -551,7 +506,7 @@ export function Layout() {
         <nav className="flex-1 p-2 overflow-y-auto">
           <ul className="space-y-2">
             {orderedSidebarIds.map((id) => {
-              const isExternal = isExternalLinkId(id);
+              const isExternal = isExternalSidebarItemId(id);
 
               if (isExternal) {
                 // Render external link
@@ -560,22 +515,7 @@ export function Layout() {
 
                 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'
-                        : ''
-                    }`}
-                  >
+                  <li key={id}>
                     {link.open_in_new_tab ? (
                       <a
                         href={link.url}
@@ -584,9 +524,6 @@ export function Layout() {
                         className={`flex items-center ${isSidebarCompact || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white`}
                         title={!isSidebarCompact && !sidebarExpanded ? link.name : undefined}
                       >
-                        {sidebarExpanded && !isSidebarCompact && (
-                          <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.getExternalLinkIconUrl(link.id)}
@@ -610,9 +547,6 @@ export function Layout() {
                         }
                         title={!isSidebarCompact && !sidebarExpanded ? link.name : undefined}
                       >
-                        {sidebarExpanded && !isSidebarCompact && (
-                          <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.getExternalLinkIconUrl(link.id)}
@@ -640,22 +574,7 @@ export function Layout() {
                 const showClearPlateDot = id === 'printers' && needsClearPlate;
 
                 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'
-                        : ''
-                    }`}
-                  >
+                  <li key={id}>
                     <NavLink
                       to={to}
                       className={({ isActive }) =>
@@ -667,9 +586,6 @@ export function Layout() {
                       }
                       title={!isSidebarCompact && !sidebarExpanded ? t(labelKey) : undefined}
                     >
-                      {sidebarExpanded && !isSidebarCompact && (
-                        <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
-                      )}
                       <div className="relative">
                         <Icon className="w-5 h-5 flex-shrink-0" />
                         {showClearPlateDot && (
@@ -980,7 +896,7 @@ export function Layout() {
         <KeyboardShortcutsModal
           onClose={() => setShowShortcuts(false)}
           sidebarItems={orderedSidebarIds.map(id => {
-            if (isExternalLinkId(id)) {
+            if (isExternalSidebarItemId(id)) {
               const extLink = extLinksMap.get(id);
               return extLink ? { type: 'external' as const, label: extLink.name } : null;
             } else {

+ 12 - 1
frontend/src/i18n/locales/de.ts

@@ -2186,7 +2186,7 @@ export default {
     defaultPrinterDescription: 'Diesen Drucker für Uploads, Nachdrucke und andere Vorgänge vorauswählen.',
     slicerBambuStudio: 'Bambu Studio',
     slicerOrcaSlicer: 'OrcaSlicer',
-    sidebarOrderDescription: 'Elemente in der Seitenleiste per Drag & Drop neu anordnen. Hier auf Standardreihenfolge zurücksetzen.',
+    sidebarOrderDescription: 'Nutze das Seitenleisten-Layout, um Elemente neu anzuordnen, Sichtbarkeit zurückzusetzen und benutzerdefinierte Links zu verwalten.',
     setDefault: 'Standard setzen',
     sidebarOrderSetDefaultHint: 'Standard setzen übernimmt die aktuelle Menüreihenfolge für Benutzer, die ihre noch nicht angepasst haben.',
     sidebarDefaultSet: 'Standard-Menüreihenfolge wurde festgelegt.',
@@ -5140,6 +5140,17 @@ export default {
 
   // External Links
   externalLinks: {
+    title: 'Seitenleisten-Links',
+    sidebarLayout: 'Seitenleiste',
+    sidebarLayoutDescription: 'Integrierte Seiten ein- oder ausblenden, externe Links hinzufügen und Elemente ziehen, um die Seitenleisten-Navigation neu zu ordnen.',
+    systemPages: 'Bambuddy-Seiten',
+    externalLinks: 'Externe Links',
+    visibleInSidebar: 'In Seitenleiste sichtbar',
+    hiddenFromSidebar: 'In Seitenleiste ausgeblendet',
+    requiredInSidebar: 'In Seitenleiste erforderlich',
+    hidePage: 'Seite ausblenden',
+    showPage: 'Seite anzeigen',
+    settingsCannotBeHidden: 'Einstellungen können nicht ausgeblendet werden',
     noLinksConfigured: 'Keine externen Links konfiguriert',
     deleteLink: 'Link löschen',
     removeCustomIcon: 'Benutzerdefiniertes Symbol entfernen',

+ 12 - 1
frontend/src/i18n/locales/en.ts

@@ -2196,7 +2196,7 @@ export default {
     defaultPrinterDescription: 'Pre-select this printer for uploads, reprints, and other operations.',
     slicerBambuStudio: 'Bambu Studio',
     slicerOrcaSlicer: 'OrcaSlicer',
-    sidebarOrderDescription: 'Drag items in the sidebar to reorder. Reset to default order here.',
+    sidebarOrderDescription: 'Use Sidebar to reorder items, reset visibility, and manage custom links.',
     setDefault: 'Set Default',
     sidebarOrderSetDefaultHint: 'Set default applies the current menu order to users who haven\'t customized theirs.',
     sidebarDefaultSet: 'Default menu order has been set.',
@@ -5161,6 +5161,17 @@ export default {
 
   // External Links
   externalLinks: {
+    title: 'Sidebar Links',
+    sidebarLayout: 'Sidebar',
+    sidebarLayoutDescription: 'Show or hide built-in pages, add external links, and drag items to reorder the sidebar navigation.',
+    systemPages: 'Bambuddy pages',
+    externalLinks: 'External links',
+    visibleInSidebar: 'Visible in sidebar',
+    hiddenFromSidebar: 'Hidden from sidebar',
+    requiredInSidebar: 'Required in sidebar',
+    hidePage: 'Hide page',
+    showPage: 'Show page',
+    settingsCannotBeHidden: 'Settings cannot be hidden',
     noLinksConfigured: 'No external links configured',
     deleteLink: 'Delete Link',
     removeCustomIcon: 'Remove custom icon',

+ 12 - 1
frontend/src/i18n/locales/es.ts

@@ -2189,7 +2189,7 @@ export default {
     defaultPrinterDescription: 'Preseleccione esta impresora para subidas, reimpresiones y otras operaciones.',
     slicerBambuStudio: 'Bambu Studio',
     slicerOrcaSlicer: 'OrcaSlicer',
-    sidebarOrderDescription: 'Arrastre los elementos de la barra lateral para reordenarlos. Restablezca aquí el orden predeterminado.',
+    sidebarOrderDescription: 'Use Diseño de la barra lateral para reordenar elementos, restablecer la visibilidad y gestionar enlaces personalizados.',
     setDefault: 'Establecer como predeterminado',
     sidebarOrderSetDefaultHint: 'Establecer como predeterminado aplica el orden de menú actual a los usuarios que no han personalizado el suyo.',
     sidebarDefaultSet: 'Se ha establecido el orden de menú predeterminado.',
@@ -5149,6 +5149,17 @@ export default {
 
   // External Links
   externalLinks: {
+    title: 'Enlaces de la barra lateral',
+    sidebarLayout: 'Barra lateral',
+    sidebarLayoutDescription: 'Muestra u oculta páginas integradas, añade enlaces externos y arrastra elementos para reordenar la navegación lateral.',
+    systemPages: 'Páginas de Bambuddy',
+    externalLinks: 'Enlaces externos',
+    visibleInSidebar: 'Visible en la barra lateral',
+    hiddenFromSidebar: 'Oculto en la barra lateral',
+    requiredInSidebar: 'Obligatorio en la barra lateral',
+    hidePage: 'Ocultar página',
+    showPage: 'Mostrar página',
+    settingsCannotBeHidden: 'Ajustes no se puede ocultar',
     noLinksConfigured: 'No hay enlaces externos configurados',
     deleteLink: 'Eliminar enlace',
     removeCustomIcon: 'Quitar el icono personalizado',

+ 12 - 1
frontend/src/i18n/locales/fr.ts

@@ -2142,7 +2142,7 @@ export default {
     defaultPrinterDescription: 'Présélectionner cette imprimante pour les téléversements, réimpressions et autres opérations.',
     slicerBambuStudio: 'Bambu Studio',
     slicerOrcaSlicer: 'OrcaSlicer',
-    sidebarOrderDescription: 'Glissez les éléments dans la barre latérale pour réorganiser. Réinitialiser l\'ordre par défaut ici.',
+    sidebarOrderDescription: 'Utilisez la disposition de la barre latérale pour réorganiser les éléments, réinitialiser la visibilité et gérer les liens personnalisés.',
     setDefault: 'Définir par défaut',
     sidebarOrderSetDefaultHint: 'Définir par défaut applique l\'ordre actuel du menu aux utilisateurs qui n\'ont pas encore personnalisé le leur.',
     sidebarDefaultSet: 'L\'ordre du menu par défaut a été défini.',
@@ -5130,6 +5130,17 @@ export default {
 
   // External Links
   externalLinks: {
+    title: 'Liens de la barre latérale',
+    sidebarLayout: 'Barre latérale',
+    sidebarLayoutDescription: 'Affichez ou masquez les pages intégrées, ajoutez des liens externes et faites glisser les éléments pour réorganiser la navigation latérale.',
+    systemPages: 'Pages Bambuddy',
+    externalLinks: 'Liens externes',
+    visibleInSidebar: 'Visible dans la barre latérale',
+    hiddenFromSidebar: 'Masqué dans la barre latérale',
+    requiredInSidebar: 'Obligatoire dans la barre latérale',
+    hidePage: 'Masquer la page',
+    showPage: 'Afficher la page',
+    settingsCannotBeHidden: 'Les paramètres ne peuvent pas être masqués',
     noLinksConfigured: 'Aucun lien externe configuré',
     deleteLink: 'Supprimer lien',
     removeCustomIcon: 'Retirer icône personnalisée',

+ 12 - 1
frontend/src/i18n/locales/it.ts

@@ -2141,7 +2141,7 @@ export default {
     defaultPrinterDescription: 'Preseleziona questa stampante per upload, ristampe e altre operazioni.',
     slicerBambuStudio: 'Bambu Studio',
     slicerOrcaSlicer: 'OrcaSlicer',
-    sidebarOrderDescription: 'Trascina gli elementi nella barra laterale per riordinare. Ripristina l\'ordine predefinito qui.',
+    sidebarOrderDescription: 'Usa il layout della barra laterale per riordinare gli elementi, ripristinare la visibilità e gestire i link personalizzati.',
     setDefault: 'Imposta predefinito',
     sidebarOrderSetDefaultHint: 'Imposta predefinito applica l\'ordine attuale del menu agli utenti che non hanno ancora personalizzato il proprio.',
     sidebarDefaultSet: 'L\'ordine predefinito del menu è stato impostato.',
@@ -5129,6 +5129,17 @@ export default {
 
   // External Links
   externalLinks: {
+    title: 'Link della barra laterale',
+    sidebarLayout: 'Barra laterale',
+    sidebarLayoutDescription: 'Mostra o nascondi le pagine integrate, aggiungi link esterni e trascina gli elementi per riordinare la navigazione laterale.',
+    systemPages: 'Pagine Bambuddy',
+    externalLinks: 'Link esterni',
+    visibleInSidebar: 'Visibile nella barra laterale',
+    hiddenFromSidebar: 'Nascosto nella barra laterale',
+    requiredInSidebar: 'Obbligatorio nella barra laterale',
+    hidePage: 'Nascondi pagina',
+    showPage: 'Mostra pagina',
+    settingsCannotBeHidden: 'Le impostazioni non possono essere nascoste',
     noLinksConfigured: 'Nessun link esterno configurato',
     deleteLink: 'Elimina link',
     removeCustomIcon: 'Rimuovi icona personalizzata',

+ 12 - 1
frontend/src/i18n/locales/ja.ts

@@ -2185,7 +2185,7 @@ export default {
     defaultPrinterDescription: 'アップロード、再印刷、その他の操作でこのプリンターを事前選択します。',
     slicerBambuStudio: 'Bambu Studio',
     slicerOrcaSlicer: 'OrcaSlicer',
-    sidebarOrderDescription: 'サイドバーの項目をドラッグして並べ替え。ここでデフォルトの順序にリセット。',
+    sidebarOrderDescription: 'サイドバーレイアウトで項目の並べ替え、表示状態のリセット、カスタムリンクの管理を行います。',
     setDefault: 'デフォルト設定',
     sidebarOrderSetDefaultHint: 'デフォルト設定は、まだカスタマイズしていないユーザーに現在のメニュー順序を適用します。',
     sidebarDefaultSet: 'デフォルトメニュー順序を設定しました。',
@@ -5141,6 +5141,17 @@ export default {
 
   // External Links
   externalLinks: {
+    title: 'サイドバーリンク',
+    sidebarLayout: 'サイドバー',
+    sidebarLayoutDescription: '組み込みページの表示/非表示を切り替え、外部リンクを追加し、項目をドラッグしてサイドバーナビゲーションを並べ替えます。',
+    systemPages: 'Bambuddyページ',
+    externalLinks: '外部リンク',
+    visibleInSidebar: 'サイドバーに表示',
+    hiddenFromSidebar: 'サイドバーで非表示',
+    requiredInSidebar: 'サイドバーで必須',
+    hidePage: 'ページを非表示',
+    showPage: 'ページを表示',
+    settingsCannotBeHidden: '設定は非表示にできません',
     noLinksConfigured: '外部リンクが設定されていません',
     deleteLink: 'リンクを削除',
     removeCustomIcon: 'カスタムアイコンを削除',

+ 12 - 1
frontend/src/i18n/locales/ko.ts

@@ -2058,7 +2058,7 @@ export default {
     defaultPrinterDescription: '업로드, 재인쇄 등 작업에서 이 프린터를 미리 선택합니다.',
     slicerBambuStudio: 'Bambu Studio',
     slicerOrcaSlicer: 'OrcaSlicer',
-    sidebarOrderDescription: '사이드바의 항목을 드래그하여 순서를 변경하세요. 여기서 기본 순서로 초기화하세요.',
+    sidebarOrderDescription: '사이드바 레이아웃에서 항목 순서를 변경하고, 표시 상태를 초기화하고, 사용자 지정 링크를 관리하세요.',
     setDefault: '기본값 설정',
     sidebarOrderSetDefaultHint: '기본값 설정은 현재 메뉴 순서를 사용자 지정하지 않은 사용자에게 적용합니다.',
     sidebarDefaultSet: '기본 메뉴 순서가 설정되었습니다.',
@@ -4848,6 +4848,17 @@ export default {
     removeLink: '링크 제거'
   },
   externalLinks: {
+    title: '사이드바 링크',
+    sidebarLayout: '사이드바',
+    sidebarLayoutDescription: '기본 제공 페이지를 표시하거나 숨기고, 외부 링크를 추가하고, 항목을 드래그하여 사이드바 탐색 순서를 변경하세요.',
+    systemPages: 'Bambuddy 페이지',
+    externalLinks: '외부 링크',
+    visibleInSidebar: '사이드바에 표시',
+    hiddenFromSidebar: '사이드바에서 숨김',
+    requiredInSidebar: '사이드바에 필수',
+    hidePage: '페이지 숨기기',
+    showPage: '페이지 표시',
+    settingsCannotBeHidden: '설정은 숨길 수 없습니다',
     noLinksConfigured: '구성된 외부 링크 없음',
     deleteLink: '링크 삭제',
     removeCustomIcon: '사용자 지정 아이콘 제거',

+ 12 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -2141,7 +2141,7 @@ export default {
     defaultPrinterDescription: 'Pré-selecionar esta impressora para uploads, reimpressões e outras operações.',
     slicerBambuStudio: 'Bambu Studio',
     slicerOrcaSlicer: 'OrcaSlicer',
-    sidebarOrderDescription: 'Arraste itens na barra lateral para reordenar. Restaurar ordem padrão aqui.',
+    sidebarOrderDescription: 'Use o layout da barra lateral para reordenar itens, restaurar a visibilidade e gerenciar links personalizados.',
     setDefault: 'Definir padrão',
     sidebarOrderSetDefaultHint: 'Definir padrão aplica a ordem atual do menu aos usuários que ainda não personalizaram o seu.',
     sidebarDefaultSet: 'Ordem padrão do menu foi definida.',
@@ -5129,6 +5129,17 @@ export default {
 
   // External Links
   externalLinks: {
+    title: 'Links da barra lateral',
+    sidebarLayout: 'Barra lateral',
+    sidebarLayoutDescription: 'Mostre ou oculte páginas integradas, adicione links externos e arraste itens para reordenar a navegação lateral.',
+    systemPages: 'Páginas do Bambuddy',
+    externalLinks: 'Links externos',
+    visibleInSidebar: 'Visível na barra lateral',
+    hiddenFromSidebar: 'Oculto na barra lateral',
+    requiredInSidebar: 'Obrigatório na barra lateral',
+    hidePage: 'Ocultar página',
+    showPage: 'Mostrar página',
+    settingsCannotBeHidden: 'Configurações não pode ser ocultado',
     noLinksConfigured: 'Nenhum link externo configurado',
     deleteLink: 'Excluir link',
     removeCustomIcon: 'Remover ícone personalizado',

+ 12 - 1
frontend/src/i18n/locales/tr.ts

@@ -2189,7 +2189,7 @@ export default {
     defaultPrinterDescription: 'Yüklemeler, tekrar baskılar ve diğer işlemler için bu yazıcıyı önceden seç.',
     slicerBambuStudio: 'Bambu Studio',
     slicerOrcaSlicer: 'OrcaSlicer',
-    sidebarOrderDescription: 'Yeniden sıralamak için kenar çubuğundaki öğeleri sürükleyin. Varsayılan sıraya buradan sıfırlayın.',
+    sidebarOrderDescription: 'Öğeleri yeniden sıralamak, görünürlüğü sıfırlamak ve özel bağlantıları yönetmek için Kenar çubuğu düzenini kullanın.',
     setDefault: 'Varsayılan Yap',
     sidebarOrderSetDefaultHint: 'Varsayılan ayarla, mevcut menü sırasını henüz özelleştirmemiş kullanıcılara uygular.',
     sidebarDefaultSet: 'Varsayılan menü sırası ayarlandı.',
@@ -5084,6 +5084,17 @@ export default {
 
   // Harici Bağlantılar
   externalLinks: {
+    title: 'Kenar çubuğu bağlantıları',
+    sidebarLayout: 'Kenar çubuğu',
+    sidebarLayoutDescription: 'Yerleşik sayfaları gösterin veya gizleyin, harici bağlantılar ekleyin ve kenar çubuğu gezinmesini yeniden sıralamak için öğeleri sürükleyin.',
+    systemPages: 'Bambuddy sayfaları',
+    externalLinks: 'Harici bağlantılar',
+    visibleInSidebar: 'Kenar çubuğunda görünür',
+    hiddenFromSidebar: 'Kenar çubuğunda gizli',
+    requiredInSidebar: 'Kenar çubuğunda gerekli',
+    hidePage: 'Sayfayı gizle',
+    showPage: 'Sayfayı göster',
+    settingsCannotBeHidden: 'Ayarlar gizlenemez',
     noLinksConfigured: 'Yapılandırılmış harici bağlantı yok',
     deleteLink: 'Bağlantıyı Sil',
     removeCustomIcon: 'Özel simgeyi kaldır',

+ 12 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -2186,7 +2186,7 @@ export default {
     defaultPrinterDescription: '为上传、重印和其他操作预选此打印机。',
     slicerBambuStudio: 'Bambu Studio',
     slicerOrcaSlicer: 'OrcaSlicer',
-    sidebarOrderDescription: '拖拽侧边栏项目以重新排序。在此处重置为默认顺序。',
+    sidebarOrderDescription: '使用侧边栏布局重新排序项目、重置可见性并管理自定义链接。',
     setDefault: '设为默认',
     sidebarOrderSetDefaultHint: '设为默认将当前菜单顺序应用于尚未自定义的用户。',
     sidebarDefaultSet: '已设置默认菜单顺序。',
@@ -5128,6 +5128,17 @@ export default {
 
   // External Links
   externalLinks: {
+    title: '侧边栏链接',
+    sidebarLayout: '侧边栏',
+    sidebarLayoutDescription: '显示或隐藏内置页面,添加外部链接,并拖动项目以重新排序侧边栏导航。',
+    systemPages: 'Bambuddy 页面',
+    externalLinks: '外部链接',
+    visibleInSidebar: '在侧边栏中显示',
+    hiddenFromSidebar: '在侧边栏中隐藏',
+    requiredInSidebar: '侧边栏中必需',
+    hidePage: '隐藏页面',
+    showPage: '显示页面',
+    settingsCannotBeHidden: '设置不能隐藏',
     noLinksConfigured: '未配置外部链接',
     deleteLink: '删除链接',
     removeCustomIcon: '移除自定义图标',

+ 12 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -2186,7 +2186,7 @@ export default {
     defaultPrinterDescription: '為上傳、重印和其他操作預選此印表機。',
     slicerBambuStudio: 'Bambu Studio',
     slicerOrcaSlicer: 'OrcaSlicer',
-    sidebarOrderDescription: '拖曳側邊欄項目以重新排序。在此處重設為預設順序。',
+    sidebarOrderDescription: '使用側邊欄版面配置重新排序項目、重設可見性並管理自訂連結。',
     setDefault: '設為預設',
     sidebarOrderSetDefaultHint: '設為預設將目前選單順序套用於尚未自訂的使用者。',
     sidebarDefaultSet: '已設定預設選單順序。',
@@ -5128,6 +5128,17 @@ export default {
 
   // External Links
   externalLinks: {
+    title: '側邊欄連結',
+    sidebarLayout: '側邊欄',
+    sidebarLayoutDescription: '顯示或隱藏內建頁面、加入外部連結,並拖曳項目以重新排序側邊欄導覽。',
+    systemPages: 'Bambuddy 頁面',
+    externalLinks: '外部連結',
+    visibleInSidebar: '顯示於側邊欄',
+    hiddenFromSidebar: '隱藏於側邊欄',
+    requiredInSidebar: '側邊欄中必須顯示',
+    hidePage: '隱藏頁面',
+    showPage: '顯示頁面',
+    settingsCannotBeHidden: '設定無法隱藏',
     noLinksConfigured: '未設定外部連結',
     deleteLink: '刪除連結',
     removeCustomIcon: '移除自訂圖示',

+ 163 - 229
frontend/src/pages/SettingsPage.tsx

@@ -36,7 +36,6 @@ import { TwoFactorSettings } from '../components/TwoFactorSettings';
 import { OIDCProviderSettings } from '../components/OIDCProviderSettings';
 import { SecurityStatusCard } from '../components/SecurityStatusCard';
 import { APIBrowser } from '../components/APIBrowser';
-import { Toggle } from '../components/Toggle';
 import { virtualPrinterApi, spoolbuddyApi } from '../api/client';
 import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
 import { availableLanguages } from '../i18n';
@@ -91,8 +90,8 @@ registerSettingsSearch({ labelKey: 'settings.sessionPolicy.title', labelFallback
 registerSettingsSearch({ labelKey: 'settings.email.smtpSettings', labelFallback: 'SMTP Configuration', tab: 'users', subTab: 'email', keywords: 'smtp email send server port password auth starttls ssl', anchor: 'card-smtp' });
 registerSettingsSearch({ labelKey: 'settings.ldap.title', labelFallback: 'LDAP Authentication', tab: 'users', subTab: 'ldap', keywords: 'ldap active directory ad authentication bind dn search base group mapping', anchor: 'card-ldap' });
 registerSettingsSearch({ labelKey: 'settings.tabs.backup', tab: 'backup', keywords: 'backup github restore download cloud sync profiles archives', anchor: 'card-backup' });
-// Sidebar Links (external links settings is rendered in the General tab)
-registerSettingsSearch({ labelKey: 'externalLinks.title', labelFallback: 'Sidebar Links', tab: 'general', keywords: 'sidebar links external custom navigation url add', anchor: 'card-sidebar-links' });
+// Sidebar (system pages and external links settings is rendered in the General tab)
+registerSettingsSearch({ labelKey: 'externalLinks.sidebarLayout', labelFallback: 'Sidebar', tab: 'general', keywords: 'sidebar layout links pages hide show external custom navigation url add', anchor: 'card-sidebar-links' });
 // Filament tab — integrations
 registerSettingsSearch({ labelKey: 'settings.filamentTracking', tab: 'filament', keywords: 'spoolman filament tracking inventory sync remote integration', anchor: 'card-spoolman' });
 registerSettingsSearch({ labelKey: 'settings.catalog.spoolCatalog', labelFallback: 'Spool Catalog', tab: 'filament', keywords: 'spool catalog entries brand material reset import export', anchor: 'card-spool-catalog' });
@@ -265,42 +264,6 @@ export function SettingsPage() {
     showToast(t('settings.toast.settingsSaved'), 'success');
   };
 
-  const handleResetSidebarOrder = () => {
-    localStorage.removeItem('sidebarOrder');
-    window.location.reload();
-  };
-
-  const isDefaultSidebarEnabled = !!localSettings?.default_sidebar_order;
-
-  const handleToggleDefaultSidebarOrder = async (enabled: boolean) => {
-    try {
-      if (enabled) {
-        let orderArr: string[];
-        const stored = localStorage.getItem('sidebarOrder');
-        try {
-          orderArr = stored ? JSON.parse(stored) : defaultNavItems.map(i => i.id);
-        } catch {
-          orderArr = defaultNavItems.map(i => i.id);
-        }
-        if (!Array.isArray(orderArr) || orderArr.length === 0) {
-          orderArr = defaultNavItems.map(i => i.id);
-        }
-        const payload = JSON.stringify({ order: orderArr });
-        await api.updateSettings({ default_sidebar_order: payload });
-        setLocalSettings(prev => prev ? { ...prev, default_sidebar_order: payload } : prev);
-        showToast(t('settings.sidebarDefaultSet'), 'success');
-      } else {
-        await api.updateSettings({ default_sidebar_order: '' });
-        setLocalSettings(prev => prev ? { ...prev, default_sidebar_order: '' } : prev);
-        showToast(t('settings.sidebarDefaultCleared'), 'success');
-      }
-      queryClient.invalidateQueries({ queryKey: ['settings'] });
-      queryClient.invalidateQueries({ queryKey: ['default-sidebar-order'] });
-    } catch {
-      showToast(t('settings.sidebarDefaultFailed'), 'error');
-    }
-  };
-
   const { data: settings, isLoading } = useQuery({
     queryKey: ['settings'],
     queryFn: api.getSettings,
@@ -1608,35 +1571,6 @@ export function SettingsPage() {
                   {t('settings.defaultPrinterDescription')}
                 </p>
               </div>
-              <div className="flex items-center justify-between">
-                <div>
-                  <p className="text-white">{t('settings.sidebarOrder')}</p>
-                  <p className="text-sm text-bambu-gray">
-                    {t('settings.sidebarOrderDescription')}
-                    {authEnabled && hasPermission('settings:update') && ` ${t('settings.sidebarOrderSetDefaultHint')}`}
-                  </p>
-                </div>
-                <div className="flex items-center gap-2 shrink-0">
-                  <Button
-                    variant="secondary"
-                    size="sm"
-                    onClick={handleResetSidebarOrder}
-                  >
-                    <RotateCcw className="w-4 h-4" />
-                    {t('settings.reset')}
-                  </Button>
-                  {authEnabled && hasPermission('settings:update') && (
-                    <div className="flex items-center gap-2">
-                      <span className="text-sm text-bambu-gray whitespace-nowrap">{t('settings.setDefault')}</span>
-                      <Toggle
-                        checked={isDefaultSidebarEnabled}
-                        onChange={handleToggleDefaultSidebarOrder}
-                        disabled={isLoading}
-                      />
-                    </div>
-                  )}
-                </div>
-              </div>
             </CardContent>
           </Card>
 
@@ -2259,13 +2193,168 @@ export function SettingsPage() {
               )}
             </CardContent>
           </Card>
+
+          {/* Data Management */}
+          <Card id="card-data">
+            <CardHeader>
+              <h2 className="text-lg font-semibold text-white">{t('settings.dataManagement')}</h2>
+            </CardHeader>
+            <CardContent className="space-y-3">
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-white">{t('settings.clearNotificationLogs')}</p>
+                  <p className="text-sm text-bambu-gray">
+                    {t('settings.clearNotificationLogsDescription')}
+                  </p>
+                </div>
+                <Button
+                  variant="secondary"
+                  size="sm"
+                  onClick={() => setShowClearLogsConfirm(true)}
+                >
+                  <Trash2 className="w-4 h-4" />
+                  {t('common.clear')}
+                </Button>
+              </div>
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-white">{t('settings.resetUiPreferences')}</p>
+                  <p className="text-sm text-bambu-gray">
+                    {t('settings.resetUiPreferencesDescription')}
+                  </p>
+                </div>
+                <Button
+                  variant="secondary"
+                  size="sm"
+                  onClick={() => setShowClearStorageConfirm(true)}
+                >
+                  <Trash2 className="w-4 h-4" />
+                  {t('settings.reset')}
+                </Button>
+              </div>
+              <div className="pt-4 border-t border-bambu-dark-tertiary">
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-white">{t('settings.storageUsage', 'Storage Usage')}</p>
+                    <p className="text-sm text-bambu-gray">
+                      {t('settings.storageUsageDescription', 'Breakdown of data usage by category')}
+                    </p>
+                  </div>
+                  <Button
+                    variant="secondary"
+                    size="sm"
+                    onClick={handleStorageUsageRefresh}
+                    disabled={storageUsageFetching || storageUsageRefreshing}
+                  >
+                    <RefreshCw
+                      className={`w-4 h-4 ${storageUsageFetching || storageUsageRefreshing ? 'animate-spin' : ''}`}
+                    />
+                    {t('common.refresh', 'Refresh')}
+                  </Button>
+                </div>
+                <div className="mt-3">
+                  {storageUsageLoading ? (
+                    <div className="flex items-center gap-2 text-sm text-bambu-gray">
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                      {t('common.loading', 'Loading')}
+                    </div>
+                  ) : storageUsage ? (
+                    <>
+                      <div className="w-full h-3 bg-bambu-dark rounded-full overflow-hidden flex">
+                        {storageUsage.categories
+                          .filter((category) => category.bytes > 0)
+                          .map((category, index) => (
+                            <div
+                              key={category.key}
+                              className={`${getStorageColor(category.key, index)} h-full`}
+                              style={{ width: `${category.percent_of_total}%` }}
+                              title={`${category.label}: ${category.formatted}`}
+                            />
+                          ))}
+                      </div>
+                      <div className="mt-3 flex flex-wrap gap-3">
+                        {storageUsage.categories
+                          .filter((category) => category.bytes > 0)
+                          .map((category, index) => (
+                            <div key={category.key} className="flex items-center gap-2 text-xs">
+                              <span
+                                className={`w-3 h-3 rounded-full ${getStorageColor(category.key, index)}`}
+                              />
+                              <span className="text-bambu-gray">{category.label}</span>
+                              <span className="text-white">{category.formatted}</span>
+                              <span className="text-bambu-gray">({category.percent_of_total.toFixed(1)}%)</span>
+                            </div>
+                          ))}
+                      </div>
+                      <div className="mt-2 text-xs text-bambu-gray">
+                        {t('settings.storageUsageTotal', 'Total')}: <span className="text-white">{storageUsage.total_formatted}</span>
+                        {storageUsage.scan_errors > 0 && (
+                          <span className="ml-2 text-amber-400">
+                            {t('settings.storageUsageErrors', 'Scan errors')}: {storageUsage.scan_errors}
+                          </span>
+                        )}
+                      </div>
+                      {storageUsage.other_breakdown?.length > 0 && (
+                        <div className="mt-4">
+                          <p className="text-xs text-bambu-gray mb-2">
+                            {t('settings.storageUsageOtherBreakdown', 'Other breakdown')}
+                          </p>
+                          <div className="space-y-2">
+                            {storageUsage.other_breakdown.map((item) => (
+                              <div key={`${item.bucket}-${item.kind}`} className="flex items-center justify-between text-xs">
+                                <div className="flex items-center gap-2">
+                                  <span className="text-white">{item.label}</span>
+                                  <span
+                                    className={`px-2 py-0.5 rounded-full border ${
+                                      item.kind === 'system'
+                                        ? 'border-slate-500 text-slate-300'
+                                        : 'border-bambu-green text-bambu-green'
+                                    }`}
+                                  >
+                                    {item.kind === 'system'
+                                      ? t('settings.storageUsageSystem', 'System')
+                                      : t('settings.storageUsageData', 'Data')}
+                                  </span>
+                                </div>
+                                <div className="flex items-center gap-2 text-bambu-gray">
+                                  <span className="text-white">{item.formatted}</span>
+                                  <span>({item.percent_of_total.toFixed(1)}%)</span>
+                                </div>
+                              </div>
+                            ))}
+                          </div>
+                        </div>
+                      )}
+                    </>
+                  ) : (
+                    <p className="text-sm text-bambu-gray">
+                      {t('settings.storageUsageUnavailable', 'Storage usage data is unavailable')}
+                    </p>
+                  )}
+                </div>
+              </div>
+              <div className="flex items-center justify-between pt-4 border-t border-bambu-dark-tertiary">
+                <div>
+                  <p className="text-white">{t('settings.backupRestore')}</p>
+                  <p className="text-sm text-bambu-gray">
+                    {t('settings.backupRestoreDescription')}
+                  </p>
+                </div>
+                <Button
+                  variant="secondary"
+                  size="sm"
+                  onClick={() => handleTabChange('backup')}
+                >
+                  <Database className="w-4 h-4" />
+                  {t('settings.goToBackup')}
+                </Button>
+              </div>
+            </CardContent>
+          </Card>
         </div>
 
-        {/* Third Column - Sidebar Links & Updates */}
+        {/* Third Column - Updates & Sidebar Links */}
         <div className="space-y-3 flex-1 lg:max-w-sm">
-          {/* Sidebar Links */}
-          <ExternalLinksSettings />
-
           <Card id="card-updates">
             <CardHeader>
               <h2 className="text-lg font-semibold text-white">{t('settings.updates')}</h2>
@@ -2446,163 +2535,8 @@ export function SettingsPage() {
             </CardContent>
           </Card>
 
-          {/* Data Management */}
-          <Card id="card-data">
-            <CardHeader>
-              <h2 className="text-lg font-semibold text-white">{t('settings.dataManagement')}</h2>
-            </CardHeader>
-            <CardContent className="space-y-3">
-              <div className="flex items-center justify-between">
-                <div>
-                  <p className="text-white">{t('settings.clearNotificationLogs')}</p>
-                  <p className="text-sm text-bambu-gray">
-                    {t('settings.clearNotificationLogsDescription')}
-                  </p>
-                </div>
-                <Button
-                  variant="secondary"
-                  size="sm"
-                  onClick={() => setShowClearLogsConfirm(true)}
-                >
-                  <Trash2 className="w-4 h-4" />
-                  {t('common.clear')}
-                </Button>
-              </div>
-              <div className="flex items-center justify-between">
-                <div>
-                  <p className="text-white">{t('settings.resetUiPreferences')}</p>
-                  <p className="text-sm text-bambu-gray">
-                    {t('settings.resetUiPreferencesDescription')}
-                  </p>
-                </div>
-                <Button
-                  variant="secondary"
-                  size="sm"
-                  onClick={() => setShowClearStorageConfirm(true)}
-                >
-                  <Trash2 className="w-4 h-4" />
-                  {t('settings.reset')}
-                </Button>
-              </div>
-              <div className="pt-4 border-t border-bambu-dark-tertiary">
-                <div className="flex items-center justify-between">
-                  <div>
-                    <p className="text-white">{t('settings.storageUsage', 'Storage Usage')}</p>
-                    <p className="text-sm text-bambu-gray">
-                      {t('settings.storageUsageDescription', 'Breakdown of data usage by category')}
-                    </p>
-                  </div>
-                  <Button
-                    variant="secondary"
-                    size="sm"
-                    onClick={handleStorageUsageRefresh}
-                    disabled={storageUsageFetching || storageUsageRefreshing}
-                  >
-                    <RefreshCw
-                      className={`w-4 h-4 ${storageUsageFetching || storageUsageRefreshing ? 'animate-spin' : ''}`}
-                    />
-                    {t('common.refresh', 'Refresh')}
-                  </Button>
-                </div>
-                <div className="mt-3">
-                  {storageUsageLoading ? (
-                    <div className="flex items-center gap-2 text-sm text-bambu-gray">
-                      <Loader2 className="w-4 h-4 animate-spin" />
-                      {t('common.loading', 'Loading')}
-                    </div>
-                  ) : storageUsage ? (
-                    <>
-                      <div className="w-full h-3 bg-bambu-dark rounded-full overflow-hidden flex">
-                        {storageUsage.categories
-                          .filter((category) => category.bytes > 0)
-                          .map((category, index) => (
-                            <div
-                              key={category.key}
-                              className={`${getStorageColor(category.key, index)} h-full`}
-                              style={{ width: `${category.percent_of_total}%` }}
-                              title={`${category.label}: ${category.formatted}`}
-                            />
-                          ))}
-                      </div>
-                      <div className="mt-3 flex flex-wrap gap-3">
-                        {storageUsage.categories
-                          .filter((category) => category.bytes > 0)
-                          .map((category, index) => (
-                            <div key={category.key} className="flex items-center gap-2 text-xs">
-                              <span
-                                className={`w-3 h-3 rounded-full ${getStorageColor(category.key, index)}`}
-                              />
-                              <span className="text-bambu-gray">{category.label}</span>
-                              <span className="text-white">{category.formatted}</span>
-                              <span className="text-bambu-gray">({category.percent_of_total.toFixed(1)}%)</span>
-                            </div>
-                          ))}
-                      </div>
-                      <div className="mt-2 text-xs text-bambu-gray">
-                        {t('settings.storageUsageTotal', 'Total')}: <span className="text-white">{storageUsage.total_formatted}</span>
-                        {storageUsage.scan_errors > 0 && (
-                          <span className="ml-2 text-amber-400">
-                            {t('settings.storageUsageErrors', 'Scan errors')}: {storageUsage.scan_errors}
-                          </span>
-                        )}
-                      </div>
-                      {storageUsage.other_breakdown?.length > 0 && (
-                        <div className="mt-4">
-                          <p className="text-xs text-bambu-gray mb-2">
-                            {t('settings.storageUsageOtherBreakdown', 'Other breakdown')}
-                          </p>
-                          <div className="space-y-2">
-                            {storageUsage.other_breakdown.map((item) => (
-                              <div key={`${item.bucket}-${item.kind}`} className="flex items-center justify-between text-xs">
-                                <div className="flex items-center gap-2">
-                                  <span className="text-white">{item.label}</span>
-                                  <span
-                                    className={`px-2 py-0.5 rounded-full border ${
-                                      item.kind === 'system'
-                                        ? 'border-slate-500 text-slate-300'
-                                        : 'border-bambu-green text-bambu-green'
-                                    }`}
-                                  >
-                                    {item.kind === 'system'
-                                      ? t('settings.storageUsageSystem', 'System')
-                                      : t('settings.storageUsageData', 'Data')}
-                                  </span>
-                                </div>
-                                <div className="flex items-center gap-2 text-bambu-gray">
-                                  <span className="text-white">{item.formatted}</span>
-                                  <span>({item.percent_of_total.toFixed(1)}%)</span>
-                                </div>
-                              </div>
-                            ))}
-                          </div>
-                        </div>
-                      )}
-                    </>
-                  ) : (
-                    <p className="text-sm text-bambu-gray">
-                      {t('settings.storageUsageUnavailable', 'Storage usage data is unavailable')}
-                    </p>
-                  )}
-                </div>
-              </div>
-              <div className="flex items-center justify-between pt-4 border-t border-bambu-dark-tertiary">
-                <div>
-                  <p className="text-white">{t('settings.backupRestore')}</p>
-                  <p className="text-sm text-bambu-gray">
-                    {t('settings.backupRestoreDescription')}
-                  </p>
-                </div>
-                <Button
-                  variant="secondary"
-                  size="sm"
-                  onClick={() => handleTabChange('backup')}
-                >
-                  <Database className="w-4 h-4" />
-                  {t('settings.goToBackup')}
-                </Button>
-              </div>
-            </CardContent>
-          </Card>
+          {/* Sidebar Links */}
+          <ExternalLinksSettings />
         </div>
       </div>
       )}

+ 41 - 0
frontend/src/utils/sidebarLayout.ts

@@ -0,0 +1,41 @@
+export const SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY = 'sidebarHiddenSystemItems';
+export const SIDEBAR_ORDER_KEY = 'sidebarOrder';
+export const SIDEBAR_LAYOUT_CHANGED_EVENT = 'sidebar-layout-changed';
+
+export function isExternalSidebarItemId(id: string): boolean {
+  return id.startsWith('ext-');
+}
+
+export function getSidebarOrder(defaultOrder: string[]): string[] {
+  const stored = localStorage.getItem(SIDEBAR_ORDER_KEY);
+  if (!stored) return defaultOrder;
+
+  try {
+    const parsed = JSON.parse(stored);
+    return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : defaultOrder;
+  } catch {
+    return defaultOrder;
+  }
+}
+
+export function saveSidebarOrder(order: string[]) {
+  localStorage.setItem(SIDEBAR_ORDER_KEY, JSON.stringify(order));
+  window.dispatchEvent(new CustomEvent(SIDEBAR_LAYOUT_CHANGED_EVENT));
+}
+
+export function getHiddenSidebarSystemItemIds(): string[] {
+  const stored = localStorage.getItem(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY);
+  if (!stored) return [];
+
+  try {
+    const parsed = JSON.parse(stored);
+    return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [];
+  } catch {
+    return [];
+  }
+}
+
+export function saveHiddenSidebarSystemItemIds(ids: string[]) {
+  localStorage.setItem(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify(Array.from(new Set(ids))));
+  window.dispatchEvent(new CustomEvent(SIDEBAR_LAYOUT_CHANGED_EVENT));
+}

+ 1 - 0
frontend/vitest.config.ts

@@ -13,6 +13,7 @@ export default defineConfig({
       },
     },
     setupFiles: ['./src/__tests__/setup.ts'],
+    testTimeout: 10000,
     include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
     exclude: ['node_modules', 'dist'],
     coverage: {

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index--E9-KRw3.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
static/assets/index-CwljnXtI.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/index-Deb9qzJl.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DE1yJIPx.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Deb9qzJl.css">
+    <script type="module" crossorigin src="/assets/index--E9-KRw3.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CwljnXtI.css">
   </head>
   <body>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов