diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 319363b0b..72fff4818 100644 --- a/backend/app/schemas/settings.py +++ b/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: diff --git a/backend/tests/unit/test_sidebar_settings.py b/backend/tests/unit/test_sidebar_settings.py new file mode 100644 index 000000000..b5cba3032 --- /dev/null +++ b/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"}') diff --git a/frontend/src/__tests__/components/Layout.test.tsx b/frontend/src/__tests__/components/Layout.test.tsx index f3c1905ec..c9b2d38d4 100644 --- a/frontend/src/__tests__/components/Layout.test.tsx +++ b/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(); + + 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 = {}; + 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(); + + 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', () => { diff --git a/frontend/src/__tests__/pages/SettingsPage.test.tsx b/frontend/src/__tests__/pages/SettingsPage.test.tsx index 305326c3a..58c5095bc 100644 --- a/frontend/src/__tests__/pages/SettingsPage.test.tsx +++ b/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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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', () => { diff --git a/frontend/src/components/ExternalLinksSettings.tsx b/frontend/src/components/ExternalLinksSettings.tsx index c004b9543..2991f7676 100644 --- a/frontend/src/components/ExternalLinksSettings.tsx +++ b/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(null); const [deletingLink, setDeletingLink] = useState(null); - const [draggedId, setDraggedId] = useState(null); + const [draggedId, setDraggedId] = useState(null); + const [dragOverId, setDragOverId] = useState(null); + const [hiddenSystemItemIds, setHiddenSystemItemIds] = useState(getHiddenSidebarSystemItemIds); + const [sidebarOrder, setSidebarOrder] = useState(() => 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(() => { + 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(); + + 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 currentIds = links.map((l) => l.id); - const draggedIndex = currentIds.indexOf(draggedId); - const targetIndex = currentIds.indexOf(targetId); + const currentOrder = layoutItems.map(item => item.id); + const draggedIndex = currentOrder.indexOf(draggedId); + const targetIndex = currentOrder.indexOf(targetId); - if (draggedIndex === -1 || targetIndex === -1) return; + if (draggedIndex === -1 || targetIndex === -1) { + setDraggedId(null); + setDragOverId(null); + return; + } - // Reorder - const newIds = [...currentIds]; - newIds.splice(draggedIndex, 1); - newIds.splice(targetIndex, 0, draggedId); + currentOrder.splice(draggedIndex, 1); + currentOrder.splice(targetIndex, 0, draggedId); + persistSidebarOrder(currentOrder); - reorderMutation.mutate(newIds); setDraggedId(null); + setDragOverId(null); + }; + + 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 ( <> -
+
-

Sidebar Links

+

{t('externalLinks.sidebarLayout')}

+
+
+ {canSetDefaultSidebarOrder && ( + + )} + +
-

- Add external links to the sidebar navigation. Drag to reorder. + {t('externalLinks.sidebarLayoutDescription')}

{isLoading ? (
- ) : links && links.length > 0 ? ( + ) : (
- {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 ( +
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' + : '' + }`} + > + +
+ +
+
+ {t(item.navItem.labelKey)} + + {isSettings + ? t('externalLinks.requiredInSidebar') + : isHidden + ? t('externalLinks.hiddenFromSidebar') + : t('externalLinks.visibleInSidebar')} + +
+ +
+ ); + } + + const link = item.link; const Icon = getIconByName(link.icon); return (
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' + : '' }`} >
- + {link.custom_icon ? ( + + ) : ( + + )}
@@ -151,17 +347,10 @@ export function ExternalLinksSettings() { ); })}
- ) : ( -
- -

{t('externalLinks.noLinksConfigured')}

-

Click "Add Link" to add one

-
)} - {/* Add/Edit Modal */} {(showAddModal || editingLink) && ( )} - {/* Delete Confirmation Modal */} {deletingLink && ( 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(getSidebarOrder); - const [draggedId, setDraggedId] = useState(null); - const [dragOverId, setDragOverId] = useState(null); + const defaultSidebarOrder = useMemo(() => defaultNavItems.map(i => i.id), []); + const [sidebarOrder, setSidebarOrder] = useState(() => getSidebarOrder(defaultNavItems.map(i => i.id))); + const [hiddenSystemItemIds, setHiddenSystemItemIds] = useState(getHiddenSidebarSystemItemIds); const hasRedirected = useRef(false); const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState(() => 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() {