feat(04-03): migrate sidebar to Link/usePathname, add search and refresh signal
Replace all raw <a> with Next.js Link. Add usePathname-based active highlighting, SidebarSearch with category/module filtering, and sidebarRefreshKey subscription for live activation updates. 26 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface SidebarSearchProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SidebarSearch({ value, onChange }: SidebarSearchProps) {
|
||||
const t = useTranslations('sidebar');
|
||||
|
||||
return (
|
||||
<div className="px-3 pb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={t('search')}
|
||||
aria-label={t('search')}
|
||||
className="flex h-9 w-full rounded-md border border-border bg-background px-3 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
let mockPathname = '/';
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => mockPathname,
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => (
|
||||
<a href={href} className={className}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (ns: string) => (key: string) => {
|
||||
const map: Record<string, Record<string, string>> = {
|
||||
sidebar: {
|
||||
dashboard: 'Dashboard',
|
||||
marketplace: 'Marktplatz',
|
||||
collapse: 'Einklappen',
|
||||
expand: 'Ausklappen',
|
||||
search: 'Module suchen...',
|
||||
noResults: 'Keine Module gefunden',
|
||||
'categories.label': 'Kategorien',
|
||||
'categories.empty': 'Keine Module',
|
||||
admin: 'Verwaltung',
|
||||
users: 'Benutzer',
|
||||
tenants: 'Mandanten',
|
||||
modules: 'Module',
|
||||
ldap: 'LDAP',
|
||||
},
|
||||
common: {
|
||||
appName: 'Tessera',
|
||||
},
|
||||
};
|
||||
return map[ns]?.[key] ?? key;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/stores/sidebar-store', () => ({
|
||||
useSidebarStore: () => ({
|
||||
isCollapsed: false,
|
||||
isMobileOpen: false,
|
||||
toggle: vi.fn(),
|
||||
setMobileOpen: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockAuthStore = vi.fn();
|
||||
vi.mock('@/lib/stores/auth-store', () => ({
|
||||
useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector),
|
||||
}));
|
||||
|
||||
let mockRefreshKey = 0;
|
||||
vi.mock('@/lib/stores/marketplace-store', () => ({
|
||||
useMarketplaceStore: (selector: (state: { sidebarRefreshKey: number }) => unknown) =>
|
||||
selector({ sidebarRefreshKey: mockRefreshKey }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/layout/sidebar-footer', () => ({
|
||||
SidebarFooter: () => <div data-testid="sidebar-footer" />,
|
||||
}));
|
||||
|
||||
const mockActiveModules = [
|
||||
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'Domain-Tools' },
|
||||
{ id: 'm2', slug: 'converter', name: 'Converter', category: 'Utilities' },
|
||||
];
|
||||
|
||||
function stubFetch() {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
mockPathname = '/';
|
||||
mockRefreshKey = 0;
|
||||
});
|
||||
|
||||
async function importSidebar() {
|
||||
const mod = await import('./sidebar');
|
||||
return mod.Sidebar;
|
||||
}
|
||||
|
||||
describe('Sidebar', () => {
|
||||
beforeEach(() => {
|
||||
mockAuthStore.mockImplementation(
|
||||
(selector: (state: { user: { id: string; role: string; tenantId: string } }) => unknown) =>
|
||||
selector({ user: { id: 'u1', role: 'ADMIN', tenantId: 't1' } }),
|
||||
);
|
||||
stubFetch();
|
||||
});
|
||||
|
||||
it('renders Dashboard and Marketplace as Link elements with correct hrefs', async () => {
|
||||
const Sidebar = await importSidebar();
|
||||
render(<Sidebar />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const dashLink = screen.getByText('Dashboard').closest('a');
|
||||
const marketLink = screen.getByText('Marktplatz').closest('a');
|
||||
expect(dashLink).toHaveAttribute('href', '/');
|
||||
expect(marketLink).toHaveAttribute('href', '/marketplace');
|
||||
});
|
||||
|
||||
it('highlights Marketplace link when pathname is /marketplace', async () => {
|
||||
mockPathname = '/marketplace';
|
||||
const Sidebar = await importSidebar();
|
||||
render(<Sidebar />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const dashLink = screen.getByText('Dashboard').closest('a');
|
||||
const marketLink = screen.getByText('Marktplatz').closest('a');
|
||||
expect(marketLink?.className).toContain('bg-sidebar-accent');
|
||||
expect(dashLink?.className).not.toContain('bg-sidebar-accent');
|
||||
});
|
||||
|
||||
it('renders search input with correct placeholder', async () => {
|
||||
const Sidebar = await importSidebar();
|
||||
render(<Sidebar />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Module suchen...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows noResults text when search matches no modules', async () => {
|
||||
const Sidebar = await importSidebar();
|
||||
render(<Sidebar />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Module suchen...');
|
||||
await userEvent.type(searchInput, 'zzz-nothing');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Keine Module gefunden')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('re-fetches modules when sidebarRefreshKey changes', async () => {
|
||||
const Sidebar = await importSidebar();
|
||||
const { rerender } = render(<Sidebar />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCallCount = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||
|
||||
mockRefreshKey = 1;
|
||||
rerender(<Sidebar />);
|
||||
|
||||
await waitFor(() => {
|
||||
const newCallCount = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||
expect(newCallCount).toBeGreaterThan(initialCallCount);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||
import { SidebarFooter } from '@/components/layout/sidebar-footer';
|
||||
import { SidebarSearch } from '@/components/layout/sidebar-search';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
@@ -21,7 +25,22 @@ export function Sidebar() {
|
||||
const { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore();
|
||||
const [categoriesOpen, setCategoriesOpen] = useState(true);
|
||||
const [activeModules, setActiveModules] = useState<SidebarModule[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const pathname = usePathname();
|
||||
const sidebarRefreshKey = useMarketplaceStore((s) => s.sidebarRefreshKey);
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === '/') return pathname === '/';
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
|
||||
const linkClass = (href: string) =>
|
||||
`flex items-center gap-3 rounded-md px-2 py-2 text-sm transition-colors ${
|
||||
isActive(href)
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
|
||||
: 'text-sidebar-foreground hover:bg-muted'
|
||||
}`;
|
||||
|
||||
const fetchActiveModules = useCallback(async () => {
|
||||
try {
|
||||
@@ -38,144 +57,131 @@ export function Sidebar() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchActiveModules();
|
||||
}, [fetchActiveModules]);
|
||||
}, [fetchActiveModules, sidebarRefreshKey]);
|
||||
|
||||
// Admin section visibility based on role
|
||||
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
|
||||
const isSuperAdmin = user?.role === 'SUPER_ADMIN';
|
||||
|
||||
const filteredCategories = useMemo(() => {
|
||||
const categories = new Map<string, SidebarModule[]>();
|
||||
for (const mod of activeModules) {
|
||||
const cat = mod.category || 'other';
|
||||
if (!categories.has(cat)) categories.set(cat, []);
|
||||
categories.get(cat)!.push(mod);
|
||||
}
|
||||
|
||||
if (!searchQuery) return Array.from(categories.entries());
|
||||
|
||||
const q = searchQuery.toLowerCase();
|
||||
const result: [string, SidebarModule[]][] = [];
|
||||
for (const [cat, mods] of categories.entries()) {
|
||||
if (cat.toLowerCase().includes(q)) {
|
||||
result.push([cat, mods]);
|
||||
} else {
|
||||
const filtered = mods.filter((m) => m.name.toLowerCase().includes(q));
|
||||
if (filtered.length > 0) result.push([cat, filtered]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [activeModules, searchQuery]);
|
||||
|
||||
const sidebarContent = (
|
||||
<div className="flex h-full flex-col bg-sidebar">
|
||||
{/* Navigation items */}
|
||||
<nav className="flex-1 overflow-y-auto p-3">
|
||||
<ul className="flex flex-col gap-1">
|
||||
{/* Dashboard - active by default (D-15) */}
|
||||
<li>
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm font-medium bg-sidebar-accent text-sidebar-accent-foreground transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Link href="/" className={linkClass('/')}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
{!isCollapsed && <span className="truncate">{t('dashboard')}</span>}
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
{/* Marketplace */}
|
||||
<li>
|
||||
<a
|
||||
href="/marketplace"
|
||||
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm text-sidebar-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Link href="/marketplace" className={linkClass('/marketplace')}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<circle cx="9" cy="21" r="1" />
|
||||
<circle cx="20" cy="21" r="1" />
|
||||
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
|
||||
</svg>
|
||||
{!isCollapsed && <span className="truncate">{t('marketplace')}</span>}
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* Categories accordion with active modules (D-05) */}
|
||||
{!isCollapsed && (
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={() => setCategoriesOpen(!categoriesOpen)}
|
||||
className="flex w-full items-center justify-between rounded-md px-2 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>{t('categories.label')}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`transition-transform duration-200 ${categoriesOpen ? 'rotate-180' : ''}`}
|
||||
<>
|
||||
<div className="mt-4">
|
||||
<SidebarSearch value={searchQuery} onChange={setSearchQuery} />
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => setCategoriesOpen(!categoriesOpen)}
|
||||
className="flex w-full items-center justify-between rounded-md px-2 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
{categoriesOpen && (
|
||||
<div className="mt-1">
|
||||
{activeModules.length === 0 ? (
|
||||
<div className="px-2 py-2 text-sm text-muted-foreground">
|
||||
{t('categories.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{(() => {
|
||||
const categories = new Map<string, SidebarModule[]>();
|
||||
for (const mod of activeModules) {
|
||||
const cat = mod.category || 'other';
|
||||
if (!categories.has(cat)) categories.set(cat, []);
|
||||
categories.get(cat)!.push(mod);
|
||||
}
|
||||
return Array.from(categories.entries()).map(([category, mods]) => (
|
||||
<span>{t('categories.label')}</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`transition-transform duration-200 ${categoriesOpen ? 'rotate-180' : ''}`}>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
{categoriesOpen && (
|
||||
<div className="mt-1">
|
||||
{activeModules.length === 0 ? (
|
||||
<div className="px-2 py-2 text-sm text-muted-foreground">
|
||||
{t('categories.empty')}
|
||||
</div>
|
||||
) : filteredCategories.length === 0 ? (
|
||||
<div className="px-2 py-2 text-sm text-muted-foreground">
|
||||
{t('noResults')}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{filteredCategories.map(([category, mods]) => (
|
||||
<li key={category}>
|
||||
<a
|
||||
<Link
|
||||
href={`/modules/${category}`}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-xs font-medium transition-colors ${
|
||||
isActive(`/modules/${category}`)
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
<span className="capitalize">{category.replace(/-/g, ' ')}</span>
|
||||
<span className="ml-auto text-[10px] text-muted-foreground/60">{mods.length}</span>
|
||||
</a>
|
||||
</Link>
|
||||
<ul className="ml-5 flex flex-col gap-0.5">
|
||||
{mods.map((mod) => (
|
||||
<li key={mod.id}>
|
||||
<a
|
||||
<Link
|
||||
href={`/modules/${category}/${mod.slug}`}
|
||||
className="block rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
className={`block rounded-md px-2 py-1 text-xs transition-colors ${
|
||||
isActive(`/modules/${category}/${mod.slug}`)
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{mod.name}
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
));
|
||||
})()}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Admin section -- Verwaltung (D-12) */}
|
||||
{isAdmin && (
|
||||
<div className="mt-6">
|
||||
{!isCollapsed && (
|
||||
@@ -184,157 +190,72 @@ export function Sidebar() {
|
||||
</div>
|
||||
)}
|
||||
<ul className="flex flex-col gap-1">
|
||||
{/* Users link -- ADMIN and SUPER_ADMIN */}
|
||||
<li>
|
||||
<a
|
||||
href="/admin/users"
|
||||
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm text-sidebar-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Link href="/admin/users" className={linkClass('/admin/users')}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
{!isCollapsed && <span className="truncate">{t('users')}</span>}
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
{/* Tenants link -- SUPER_ADMIN only (D-10) */}
|
||||
{isSuperAdmin && (
|
||||
<li>
|
||||
<a
|
||||
href="/admin/tenants"
|
||||
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm text-sidebar-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Link href="/admin/tenants" className={linkClass('/admin/tenants')}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
{!isCollapsed && (
|
||||
<span className="truncate">{t('tenants')}</span>
|
||||
)}
|
||||
</a>
|
||||
{!isCollapsed && <span className="truncate">{t('tenants')}</span>}
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{/* Modules link -- ADMIN and SUPER_ADMIN */}
|
||||
<li>
|
||||
<a
|
||||
href="/admin/modules"
|
||||
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm text-sidebar-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Link href="/admin/modules" className={linkClass('/admin/modules')}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||
</svg>
|
||||
{!isCollapsed && (
|
||||
<span className="truncate">{t('modules')}</span>
|
||||
)}
|
||||
</a>
|
||||
{!isCollapsed && <span className="truncate">{t('modules')}</span>}
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
{/* LDAP link -- ADMIN and SUPER_ADMIN (D-18) */}
|
||||
<li>
|
||||
<a
|
||||
href="/admin/ldap"
|
||||
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm text-sidebar-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Link href="/admin/ldap" className={linkClass('/admin/ldap')}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
{!isCollapsed && (
|
||||
<span className="truncate">{t('ldap')}</span>
|
||||
)}
|
||||
</a>
|
||||
{!isCollapsed && <span className="truncate">{t('ldap')}</span>}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Collapse/Expand toggle */}
|
||||
<div className="hidden md:block border-t border-sidebar-border p-2">
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-sm text-sidebar-foreground hover:bg-muted transition-colors"
|
||||
aria-label={isCollapsed ? t('expand') : t('collapse')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
{!isCollapsed && (
|
||||
<span className="truncate">{t('collapse')}</span>
|
||||
)}
|
||||
{!isCollapsed && <span className="truncate">{t('collapse')}</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sidebar footer (D-06) */}
|
||||
<SidebarFooter />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop sidebar */}
|
||||
<aside
|
||||
className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-all duration-200 md:block"
|
||||
style={{
|
||||
@@ -346,18 +267,14 @@ export function Sidebar() {
|
||||
{sidebarContent}
|
||||
</aside>
|
||||
|
||||
{/* Mobile sidebar overlay (D-03) */}
|
||||
{isMobileOpen && (
|
||||
<div className="fixed inset-0 z-40 md:hidden">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Sidebar panel */}
|
||||
<aside className="fixed left-0 top-0 z-50 h-full w-[var(--sidebar-width)] bg-sidebar shadow-lg">
|
||||
{/* Mobile close button */}
|
||||
<div className="flex h-[var(--header-height)] items-center justify-between border-b border-sidebar-border px-4">
|
||||
<span className="text-lg font-bold text-primary">
|
||||
{tCommon('appName')}
|
||||
@@ -366,17 +283,7 @@ export function Sidebar() {
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
|
||||
Reference in New Issue
Block a user