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';
|
'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 { useTranslations } from 'next-intl';
|
||||||
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
||||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||||
|
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||||
import { SidebarFooter } from '@/components/layout/sidebar-footer';
|
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';
|
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 { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore();
|
||||||
const [categoriesOpen, setCategoriesOpen] = useState(true);
|
const [categoriesOpen, setCategoriesOpen] = useState(true);
|
||||||
const [activeModules, setActiveModules] = useState<SidebarModule[]>([]);
|
const [activeModules, setActiveModules] = useState<SidebarModule[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const user = useAuthStore((s) => s.user);
|
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 () => {
|
const fetchActiveModules = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -38,91 +57,74 @@ export function Sidebar() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchActiveModules();
|
fetchActiveModules();
|
||||||
}, [fetchActiveModules]);
|
}, [fetchActiveModules, sidebarRefreshKey]);
|
||||||
|
|
||||||
// Admin section visibility based on role
|
|
||||||
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
|
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
|
||||||
const isSuperAdmin = 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 = (
|
const sidebarContent = (
|
||||||
<div className="flex h-full flex-col bg-sidebar">
|
<div className="flex h-full flex-col bg-sidebar">
|
||||||
{/* Navigation items */}
|
|
||||||
<nav className="flex-1 overflow-y-auto p-3">
|
<nav className="flex-1 overflow-y-auto p-3">
|
||||||
<ul className="flex flex-col gap-1">
|
<ul className="flex flex-col gap-1">
|
||||||
{/* Dashboard - active by default (D-15) */}
|
|
||||||
<li>
|
<li>
|
||||||
<a
|
<Link href="/" className={linkClass('/')}>
|
||||||
href="/"
|
<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">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<rect x="3" y="3" width="7" height="7" />
|
<rect x="3" y="3" width="7" height="7" />
|
||||||
<rect x="14" 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="14" y="14" width="7" height="7" />
|
||||||
<rect x="3" y="14" width="7" height="7" />
|
<rect x="3" y="14" width="7" height="7" />
|
||||||
</svg>
|
</svg>
|
||||||
{!isCollapsed && <span className="truncate">{t('dashboard')}</span>}
|
{!isCollapsed && <span className="truncate">{t('dashboard')}</span>}
|
||||||
</a>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
{/* Marketplace */}
|
|
||||||
<li>
|
<li>
|
||||||
<a
|
<Link href="/marketplace" className={linkClass('/marketplace')}>
|
||||||
href="/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">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<circle cx="9" cy="21" r="1" />
|
<circle cx="9" cy="21" r="1" />
|
||||||
<circle cx="20" 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" />
|
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
|
||||||
</svg>
|
</svg>
|
||||||
{!isCollapsed && <span className="truncate">{t('marketplace')}</span>}
|
{!isCollapsed && <span className="truncate">{t('marketplace')}</span>}
|
||||||
</a>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{/* Categories accordion with active modules (D-05) */}
|
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="mt-6">
|
<>
|
||||||
|
<div className="mt-4">
|
||||||
|
<SidebarSearch value={searchQuery} onChange={setSearchQuery} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setCategoriesOpen(!categoriesOpen)}
|
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"
|
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>
|
<span>{t('categories.label')}</span>
|
||||||
<svg
|
<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' : ''}`}>
|
||||||
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" />
|
<polyline points="6 9 12 15 18 9" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -132,50 +134,54 @@ export function Sidebar() {
|
|||||||
<div className="px-2 py-2 text-sm text-muted-foreground">
|
<div className="px-2 py-2 text-sm text-muted-foreground">
|
||||||
{t('categories.empty')}
|
{t('categories.empty')}
|
||||||
</div>
|
</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">
|
<ul className="flex flex-col gap-1">
|
||||||
{(() => {
|
{filteredCategories.map(([category, mods]) => (
|
||||||
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]) => (
|
|
||||||
<li key={category}>
|
<li key={category}>
|
||||||
<a
|
<Link
|
||||||
href={`/modules/${category}`}
|
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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span className="capitalize">{category.replace(/-/g, ' ')}</span>
|
<span className="capitalize">{category.replace(/-/g, ' ')}</span>
|
||||||
<span className="ml-auto text-[10px] text-muted-foreground/60">{mods.length}</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">
|
<ul className="ml-5 flex flex-col gap-0.5">
|
||||||
{mods.map((mod) => (
|
{mods.map((mod) => (
|
||||||
<li key={mod.id}>
|
<li key={mod.id}>
|
||||||
<a
|
<Link
|
||||||
href={`/modules/${category}/${mod.slug}`}
|
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}
|
{mod.name}
|
||||||
</a>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
));
|
))}
|
||||||
})()}
|
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Admin section -- Verwaltung (D-12) */}
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
@@ -184,157 +190,72 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ul className="flex flex-col gap-1">
|
<ul className="flex flex-col gap-1">
|
||||||
{/* Users link -- ADMIN and SUPER_ADMIN */}
|
|
||||||
<li>
|
<li>
|
||||||
<a
|
<Link href="/admin/users" className={linkClass('/admin/users')}>
|
||||||
href="/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">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||||
<circle cx="9" cy="7" r="4" />
|
<circle cx="9" cy="7" r="4" />
|
||||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
</svg>
|
</svg>
|
||||||
{!isCollapsed && <span className="truncate">{t('users')}</span>}
|
{!isCollapsed && <span className="truncate">{t('users')}</span>}
|
||||||
</a>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
{/* Tenants link -- SUPER_ADMIN only (D-10) */}
|
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<li>
|
<li>
|
||||||
<a
|
<Link href="/admin/tenants" className={linkClass('/admin/tenants')}>
|
||||||
href="/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">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
<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" />
|
<polyline points="9 22 9 12 15 12 15 22" />
|
||||||
</svg>
|
</svg>
|
||||||
{!isCollapsed && (
|
{!isCollapsed && <span className="truncate">{t('tenants')}</span>}
|
||||||
<span className="truncate">{t('tenants')}</span>
|
</Link>
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modules link -- ADMIN and SUPER_ADMIN */}
|
|
||||||
<li>
|
<li>
|
||||||
<a
|
<Link href="/admin/modules" className={linkClass('/admin/modules')}>
|
||||||
href="/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">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<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" />
|
<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" />
|
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||||
<line x1="12" y1="22.08" x2="12" y2="12" />
|
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||||
</svg>
|
</svg>
|
||||||
{!isCollapsed && (
|
{!isCollapsed && <span className="truncate">{t('modules')}</span>}
|
||||||
<span className="truncate">{t('modules')}</span>
|
</Link>
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
{/* LDAP link -- ADMIN and SUPER_ADMIN (D-18) */}
|
|
||||||
<li>
|
<li>
|
||||||
<a
|
<Link href="/admin/ldap" className={linkClass('/admin/ldap')}>
|
||||||
href="/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">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||||
<path d="M2 17l10 5 10-5" />
|
<path d="M2 17l10 5 10-5" />
|
||||||
<path d="M2 12l10 5 10-5" />
|
<path d="M2 12l10 5 10-5" />
|
||||||
</svg>
|
</svg>
|
||||||
{!isCollapsed && (
|
{!isCollapsed && <span className="truncate">{t('ldap')}</span>}
|
||||||
<span className="truncate">{t('ldap')}</span>
|
</Link>
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Collapse/Expand toggle */}
|
|
||||||
<div className="hidden md:block border-t border-sidebar-border p-2">
|
<div className="hidden md:block border-t border-sidebar-border p-2">
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
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"
|
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')}
|
aria-label={isCollapsed ? t('expand') : t('collapse')}
|
||||||
>
|
>
|
||||||
<svg
|
<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' : ''}`}>
|
||||||
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" />
|
<polyline points="15 18 9 12 15 6" />
|
||||||
</svg>
|
</svg>
|
||||||
{!isCollapsed && (
|
{!isCollapsed && <span className="truncate">{t('collapse')}</span>}
|
||||||
<span className="truncate">{t('collapse')}</span>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar footer (D-06) */}
|
|
||||||
<SidebarFooter />
|
<SidebarFooter />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Desktop sidebar */}
|
|
||||||
<aside
|
<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"
|
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={{
|
style={{
|
||||||
@@ -346,18 +267,14 @@ export function Sidebar() {
|
|||||||
{sidebarContent}
|
{sidebarContent}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Mobile sidebar overlay (D-03) */}
|
|
||||||
{isMobileOpen && (
|
{isMobileOpen && (
|
||||||
<div className="fixed inset-0 z-40 md:hidden">
|
<div className="fixed inset-0 z-40 md:hidden">
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50"
|
className="fixed inset-0 bg-black/50"
|
||||||
onClick={() => setMobileOpen(false)}
|
onClick={() => setMobileOpen(false)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{/* Sidebar panel */}
|
|
||||||
<aside className="fixed left-0 top-0 z-50 h-full w-[var(--sidebar-width)] bg-sidebar shadow-lg">
|
<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">
|
<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">
|
<span className="text-lg font-bold text-primary">
|
||||||
{tCommon('appName')}
|
{tCommon('appName')}
|
||||||
@@ -366,17 +283,7 @@ export function Sidebar() {
|
|||||||
onClick={() => setMobileOpen(false)}
|
onClick={() => setMobileOpen(false)}
|
||||||
className="rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
className="rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<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">
|
||||||
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="18" y1="6" x2="6" y2="18" />
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
Reference in New Issue
Block a user