Design: Geruest mit Textmenue, Schublade fuers Handy, Logo; Footer und Einklappen entfallen
Weisses Geruest (.shell, feste Seitenleiste, Dokument-Scroll), Menue als Textliste mit Zaehlern und Serverpuls-Punkt, Schublade mit Scrim und Esc auf dem Handy, Logo-Komponente und Favicon. Footer, Einklappen (lib/sidebar.js) und das alte Menue-CSS (.side*, .app-*) sind entfernt. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c19e19968d
commit
5a0307c9d6
14 files changed
+102
-234
No files matched your search
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<title>Webklar WOMS 2.0</title>
|
<title>Webklar WOMS 2.0</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<rect x="48" y="48" width="416" height="416" rx="92" fill="#FFFFFF"/>
|
||||||
|
<ellipse cx="256" cy="256" rx="210" ry="150" fill="#111111" transform="rotate(-28 256 256)"/>
|
||||||
|
<circle cx="356" cy="172" r="18" fill="#FFFFFF"/>
|
||||||
|
<g transform="translate(256 256) rotate(-28)">
|
||||||
|
<path d="M 0,-170 C 22,-105 60,-72 120,-56 C 60,-40 22,-8 0,64 C -22,-8 -60,-40 -120,-56 C -60,-72 -22,-105 0,-170 Z" fill="#FFFFFF"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 510 B |
+31
-14
@@ -1,7 +1,8 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate, Link, useLocation } from 'react-router-dom'
|
||||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||||
import Sidebar from './components/Navbar'
|
import Sidebar from './components/Navbar'
|
||||||
import Footer from './components/Footer'
|
import Logo from './components/Logo'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import TicketsPage from './pages/TicketsPage'
|
import TicketsPage from './pages/TicketsPage'
|
||||||
import PlanboardPage from './pages/PlanboardPage'
|
import PlanboardPage from './pages/PlanboardPage'
|
||||||
@@ -28,12 +29,7 @@ function ProtectedRoute({ children }) {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <div className="loading">Wird geladen …</div>
|
||||||
<div className="text-center p-4" style={{ marginTop: 80 }}>
|
|
||||||
<div className="spinner"></div>
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -79,19 +75,40 @@ function AppRoutes() {
|
|||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
// Schublade des Menues auf dem Handy; auf dem Desktop steht das Menue fest und menuOpen bleibt ohne Wirkung
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const open = useCallback(() => setMenuOpen(true), [])
|
||||||
|
const close = useCallback(() => setMenuOpen(false), [])
|
||||||
|
|
||||||
|
// Seitenwechsel (nur der Pfad, nicht ?woid= oder ?tab=): Schublade zu und wieder nach oben
|
||||||
|
useEffect(() => {
|
||||||
|
setMenuOpen(false)
|
||||||
|
window.scrollTo(0, 0)
|
||||||
|
}, [location.pathname])
|
||||||
|
|
||||||
|
// Esc schliesst die Schublade, nur solange sie offen ist
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuOpen) return undefined
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') setMenuOpen(false) }
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
|
}, [menuOpen])
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <AppRoutes />
|
return <AppRoutes />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className={`shell${menuOpen ? ' menu-open' : ''}`}>
|
||||||
<Sidebar />
|
<Sidebar onNavigate={close} />
|
||||||
<div className="app-main">
|
<div className="scrim" onClick={close} />
|
||||||
<div className="app-scroll">
|
<div className="main">
|
||||||
<AppRoutes />
|
<div className="mobile-bar">
|
||||||
|
<Link className="brand" to="/tickets"><Logo />WEBklar</Link>
|
||||||
|
<button type="button" className="btn btn-quiet" onClick={open} aria-expanded={menuOpen} aria-controls="menue">Menü</button>
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<main id="inhalt"><AppRoutes /></main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
export default function Footer() {
|
|
||||||
return (
|
|
||||||
<footer className="footer">
|
|
||||||
<p className="faint" style={{ fontSize: 13, margin: 0 }}>
|
|
||||||
Webklar Ticket-Plattform · © {new Date().getFullYear()}
|
|
||||||
</p>
|
|
||||||
</footer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// WEBklar-Zeichen (Ellipse mit Stern); Pfade aus dem Prototyp. Die Groesse setzen im Menue und auf der Anmeldung die Stile
|
||||||
|
// (.brand svg, .login-box svg), size gilt, wo keine Regel greift.
|
||||||
|
export default function Logo({ size = 24 }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 512 512" width={size} height={size} aria-hidden="true">
|
||||||
|
<g transform="translate(256 256) scale(0.98) translate(-256 -256)">
|
||||||
|
<ellipse cx="256" cy="256" rx="210" ry="150" fill="#111" transform="rotate(-28 256 256)" />
|
||||||
|
<circle cx="356" cy="172" r="18" fill="#fff" />
|
||||||
|
<path
|
||||||
|
transform="translate(256 256) rotate(-28)"
|
||||||
|
d="M 0,-170 C 22,-105 60,-72 120,-56 C 60,-40 22,-8 0,64 C -22,-8 -60,-40 -120,-56 C -60,-72 -22,-105 0,-170 Z"
|
||||||
|
fill="#fff"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
+33
-97
@@ -1,52 +1,16 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import {
|
import Logo from './Logo'
|
||||||
IconTicket,
|
|
||||||
IconUsers,
|
|
||||||
IconTargetArrow,
|
|
||||||
IconReportMoney,
|
|
||||||
IconScale,
|
|
||||||
IconActivityHeartbeat,
|
|
||||||
IconFolders,
|
|
||||||
IconLayoutGrid,
|
|
||||||
IconCalendar,
|
|
||||||
IconSettings,
|
|
||||||
IconLogout,
|
|
||||||
IconLayoutSidebarLeftCollapse,
|
|
||||||
IconLayoutSidebarLeftExpand,
|
|
||||||
IconMessages,
|
|
||||||
IconReceiptRefund,
|
|
||||||
IconDiscount,
|
|
||||||
IconMailCog,
|
|
||||||
} from '@tabler/icons-react'
|
|
||||||
import { serverpulsApi } from '../lib/serverpulsApi'
|
import { serverpulsApi } from '../lib/serverpulsApi'
|
||||||
import { startsCollapsed } from '../lib/sidebar'
|
|
||||||
import { buildNavLinks } from '../lib/navLinks'
|
import { buildNavLinks } from '../lib/navLinks'
|
||||||
import { useSidebarCounts } from '../hooks/useSidebarCounts'
|
import { useSidebarCounts } from '../hooks/useSidebarCounts'
|
||||||
|
|
||||||
const ICONS = {
|
// Seitenmenue als reine Textliste. onNavigate schliesst die Schublade auf dem Handy (App.jsx).
|
||||||
tickets: <IconTicket />,
|
export default function Sidebar({ onNavigate }) {
|
||||||
planboard: <IconCalendar />,
|
|
||||||
projects: <IconFolders />,
|
|
||||||
services: <IconLayoutGrid />,
|
|
||||||
customers: <IconUsers />,
|
|
||||||
chat: <IconMessages />,
|
|
||||||
gutschriften: <IconReceiptRefund />,
|
|
||||||
angebote: <IconDiscount />,
|
|
||||||
speicher: <IconMailCog />,
|
|
||||||
leads: <IconTargetArrow />,
|
|
||||||
finance: <IconReportMoney />,
|
|
||||||
legal: <IconScale />,
|
|
||||||
serverpuls: <IconActivityHeartbeat />,
|
|
||||||
admin: <IconSettings />,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Sidebar() {
|
|
||||||
const { user, logout, isAdmin } = useAuth()
|
const { user, logout, isAdmin } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [collapsed, setCollapsed] = useState(() => startsCollapsed())
|
|
||||||
const [pulseLevel, setPulseLevel] = useState('ok')
|
const [pulseLevel, setPulseLevel] = useState('ok')
|
||||||
const counts = useSidebarCounts()
|
const counts = useSidebarCounts()
|
||||||
|
|
||||||
@@ -69,7 +33,7 @@ export default function Sidebar() {
|
|||||||
navigate('/login')
|
navigate('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eintraege und Unterpunkte (Kunden -> Chat) aus lib/navLinks.js, Icons ueber key
|
// Eintraege und Unterpunkte (Kunden -> Chat) aus lib/navLinks.js
|
||||||
const links = buildNavLinks({ isAdmin, pulseLevel, counts })
|
const links = buildNavLinks({ isAdmin, pulseLevel, counts })
|
||||||
|
|
||||||
const isActive = (href) =>
|
const isActive = (href) =>
|
||||||
@@ -78,79 +42,51 @@ export default function Sidebar() {
|
|||||||
const isLinkActive = (link) => isActive(link.href) || (link.also ? isActive(link.also) : false)
|
const isLinkActive = (link) => isActive(link.href) || (link.also ? isActive(link.also) : false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`side ${collapsed ? 'collapsed' : ''}`}>
|
<aside className="sidebar" id="menue">
|
||||||
<div className="side-head">
|
<Link className="brand" to="/tickets" onClick={onNavigate}><Logo />WEBklar</Link>
|
||||||
<Link to="/" className="side-logo" title="Webklar">W</Link>
|
|
||||||
<div className="side-brand">
|
|
||||||
Webklar
|
|
||||||
<small>Ticket-Plattform</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="side-nav">
|
<nav className="nav">
|
||||||
<div className="side-group">
|
{links.flatMap((link) => [
|
||||||
{links.map((link) => link.paused ? (
|
link.paused ? (
|
||||||
<span
|
<span key={link.href} className="nav-item paused" aria-disabled="true">
|
||||||
key={link.href}
|
{link.label}
|
||||||
className="side-link side-link-paused"
|
<span className="nav-count">pausiert</span>
|
||||||
title={`${link.label} (pausiert)`}
|
|
||||||
aria-disabled="true"
|
|
||||||
>
|
|
||||||
{ICONS[link.key]}
|
|
||||||
<span className="side-link-label">{link.label}</span>
|
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
key={link.href}
|
key={link.href}
|
||||||
to={link.href}
|
to={link.href}
|
||||||
className={`side-link ${isLinkActive(link) ? 'active' : ''}`}
|
className={`nav-item${isLinkActive(link) ? ' active' : ''}`}
|
||||||
title={link.label}
|
onClick={onNavigate}
|
||||||
>
|
>
|
||||||
{ICONS[link.key]}
|
{link.label}
|
||||||
<span className="side-link-label">{link.label}</span>
|
|
||||||
{link.dot && link.dot !== 'ok' && (
|
{link.dot && link.dot !== 'ok' && (
|
||||||
<span
|
<span
|
||||||
className={`side-dot side-dot-${link.dot}`}
|
className={`nav-dot${link.dot === 'critical' ? ' critical' : ''}`}
|
||||||
data-testid="sp-nav-dot"
|
data-testid="sp-nav-dot"
|
||||||
title={link.dot === 'critical' ? 'Critical-Warnung aktiv' : 'Warnung aktiv'}
|
title={link.dot === 'critical' ? 'Critical-Warnung aktiv' : 'Warnung aktiv'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
)).flatMap((item, i) => [
|
),
|
||||||
item,
|
...(link.children || []).map((child) => (
|
||||||
...(links[i].children || []).map((child) => (
|
<Link
|
||||||
<Link
|
key={child.href}
|
||||||
key={child.href}
|
to={child.href}
|
||||||
to={child.href}
|
className={`nav-item sub${isActive(child.href) ? ' active' : ''}`}
|
||||||
className={`side-link side-sublink ${isActive(child.href) ? 'active' : ''}`}
|
title={child.count > 0 ? `${child.label} (${child.count} ${child.key === 'chat' ? 'ungelesen' : 'offen'})` : undefined}
|
||||||
title={child.count > 0 ? `${child.label} (${child.count} ${child.key === 'chat' ? 'ungelesen' : 'offen'})` : child.label}
|
onClick={onNavigate}
|
||||||
>
|
>
|
||||||
{ICONS[child.key]}
|
{child.label}
|
||||||
<span className="side-link-label">{child.label}</span>
|
{child.count > 0 && <span className={`nav-count${child.hot ? ' hot' : ''}`}>{child.count}</span>}
|
||||||
{child.count > 0 && <span className="side-count">{child.count}</span>}
|
</Link>
|
||||||
</Link>
|
)),
|
||||||
)),
|
])}
|
||||||
])}
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="side-foot">
|
<div className="sidebar-foot">
|
||||||
{user && (
|
{user && <span className="who text-ellipsis">{user.name || user.email}</span>}
|
||||||
<div className="side-link" title={user.name || user.email} style={{ cursor: 'default' }}>
|
<button type="button" className="link" onClick={handleLogout}>Abmelden</button>
|
||||||
<div className="side-logo" style={{ width: 28, height: 28, fontSize: 12, background: 'linear-gradient(135deg,#667eea,#764ba2)' }}>
|
|
||||||
{(user.name || user.email || '?').charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<span className="side-link-label text-ellipsis">{user.name || user.email}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button className="side-toggle" onClick={handleLogout} title="Logout">
|
|
||||||
<IconLogout size={20} />
|
|
||||||
<span className="side-link-label">Logout</span>
|
|
||||||
</button>
|
|
||||||
<button className="side-toggle" onClick={() => setCollapsed((c) => !c)} title="Menue ein-/ausklappen">
|
|
||||||
{collapsed ? <IconLayoutSidebarLeftExpand size={20} /> : <IconLayoutSidebarLeftCollapse size={20} />}
|
|
||||||
<span className="side-link-label">Einklappen</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
// Eintraege der Sidebar als reine Daten (unter Node testbar); Navbar.jsx ordnet die Icons ueber key zu.
|
// Eintraege der Sidebar als reine Daten (unter Node testbar).
|
||||||
// Unterpunkte (children) erscheinen eingerueckt unter ihrem Eintrag, count zeigt eine Zahl (nur > 0).
|
// Unterpunkte (children) erscheinen eingerueckt unter ihrem Eintrag, count zeigt eine Zahl (nur > 0), hot hebt die Zahl farbig hervor.
|
||||||
|
|
||||||
export function buildNavLinks({ isAdmin, pulseLevel, counts = {} }) {
|
export function buildNavLinks({ isAdmin, pulseLevel, counts = {} }) {
|
||||||
return [
|
return [
|
||||||
@@ -13,7 +13,7 @@ export function buildNavLinks({ isAdmin, pulseLevel, counts = {} }) {
|
|||||||
href: '/customers',
|
href: '/customers',
|
||||||
// Bereiche aus dem frueheren Admin-Panel; Zahlen: ungelesene Chats, offene Gutschriften, offene Speicher-Aufgaben
|
// Bereiche aus dem frueheren Admin-Panel; Zahlen: ungelesene Chats, offene Gutschriften, offene Speicher-Aufgaben
|
||||||
children: [
|
children: [
|
||||||
{ key: 'chat', label: 'Chat', href: '/customers/chat', count: counts.chat ?? 0 },
|
{ key: 'chat', label: 'Chat', href: '/customers/chat', count: counts.chat ?? 0, hot: true },
|
||||||
{ key: 'gutschriften', label: 'Gutschriften', href: '/customers/gutschriften', count: counts.credits ?? 0 },
|
{ key: 'gutschriften', label: 'Gutschriften', href: '/customers/gutschriften', count: counts.credits ?? 0 },
|
||||||
{ key: 'angebote', label: 'Abo-Angebote', href: '/customers/angebote' },
|
{ key: 'angebote', label: 'Abo-Angebote', href: '/customers/angebote' },
|
||||||
{ key: 'speicher', label: 'E-Mail-Speicher', href: '/customers/email-speicher', count: counts.storage ?? 0 },
|
{ key: 'speicher', label: 'E-Mail-Speicher', href: '/customers/email-speicher', count: counts.storage ?? 0 },
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
// Seitenmenue: auf schmalen Bildschirmen (Handy) startet es eingeklappt, sonst bleiben vom Inhalt bei 390 px nur
|
|
||||||
// etwa 140 px und jede Seite scrollt waagerecht. Ausklappen geht weiterhin per Knopf.
|
|
||||||
export const AUTO_COLLAPSE_MAX_WIDTH = 720
|
|
||||||
|
|
||||||
/** true, wenn das Menue eingeklappt starten soll (Fensterbreite bis 720 px); ohne window (Test, Server) false. */
|
|
||||||
export function startsCollapsed(win = typeof window !== 'undefined' ? window : null) {
|
|
||||||
try {
|
|
||||||
return Boolean(win?.matchMedia?.(`(max-width: ${AUTO_COLLAPSE_MAX_WIDTH}px)`)?.matches)
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -51,13 +51,13 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
<main className="page admin-page">
|
<div className="page admin-page">
|
||||||
<section className="admin-access-denied">
|
<section className="admin-access-denied">
|
||||||
<span>403</span>
|
<span>403</span>
|
||||||
<h1>Zugriff verweigert</h1>
|
<h1>Zugriff verweigert</h1>
|
||||||
<p>Für diesen Bereich ist ein Admin-Account erforderlich.</p>
|
<p>Für diesen Bereich ist ein Admin-Account erforderlich.</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page admin-page">
|
<div className="page admin-page">
|
||||||
<header className="admin-header">
|
<header className="admin-header">
|
||||||
<div>
|
<div>
|
||||||
<span className="admin-eyebrow">Systemverwaltung</span>
|
<span className="admin-eyebrow">Systemverwaltung</span>
|
||||||
@@ -222,6 +222,6 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</main>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -369,7 +369,7 @@ export default function ProjectsPage() {
|
|||||||
const typeOptions = Object.entries(TYPES).filter(([, meta]) => isAdmin || !meta.adminOnly)
|
const typeOptions = Object.entries(TYPES).filter(([, meta]) => isAdmin || !meta.adminOnly)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="main-content">
|
<div className="page">
|
||||||
<header className="text-center mb-2">
|
<header className="text-center mb-2">
|
||||||
<h2>Website-Projekte</h2>
|
<h2>Website-Projekte</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
+3
-61
@@ -131,6 +131,7 @@ fieldset:disabled { opacity: 1; }
|
|||||||
.nav-item.paused:hover { background: none; color: var(--text-3); }
|
.nav-item.paused:hover { background: none; color: var(--text-3); }
|
||||||
.nav-count { font-size: 12px; color: var(--text-3); font-variant-numeric: tabular-nums; }
|
.nav-count { font-size: 12px; color: var(--text-3); font-variant-numeric: tabular-nums; }
|
||||||
.nav-count.hot { color: var(--accent-ink); font-weight: 600; }
|
.nav-count.hot { color: var(--accent-ink); font-weight: 600; }
|
||||||
|
.nav-item.active .nav-count:not(.hot) { color: var(--text-2); } /* auf der grauen Markierung reicht --text-3 nicht (2,7:1) */
|
||||||
.nav-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--nav-dot); }
|
.nav-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--nav-dot); }
|
||||||
.nav-dot.critical { background: var(--danger); }
|
.nav-dot.critical { background: var(--danger); }
|
||||||
|
|
||||||
@@ -143,6 +144,7 @@ fieldset:disabled { opacity: 1; }
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
.sidebar-foot .who { font-weight: 500; }
|
.sidebar-foot .who { font-weight: 500; }
|
||||||
|
.sidebar-foot .link { align-self: flex-start; } /* Knopf statt Link: sonst steht "Abmelden" mittig in der vollen Breite */
|
||||||
|
|
||||||
.main { flex: 1; margin-left: var(--sidebar-w); min-width: 0; }
|
.main { flex: 1; margin-left: var(--sidebar-w); min-width: 0; }
|
||||||
|
|
||||||
@@ -482,14 +484,6 @@ a.list-row:hover .cell-title { color: var(--accent-ink); }
|
|||||||
}
|
}
|
||||||
/* NEU-ENDE */
|
/* NEU-ENDE */
|
||||||
|
|
||||||
/* Layout */
|
|
||||||
.main-content {
|
|
||||||
padding: var(--sp-5);
|
|
||||||
max-width: 1320px;
|
|
||||||
margin: 0 auto;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Utilities */
|
/* Utilities */
|
||||||
.text-center { text-align: center; }
|
.text-center { text-align: center; }
|
||||||
|
|
||||||
@@ -515,11 +509,6 @@ a.list-row:hover .cell-title { color: var(--accent-ink); }
|
|||||||
/* =================== REDESIGN (Designsystem) =============== */
|
/* =================== REDESIGN (Designsystem) =============== */
|
||||||
/* ============================================================= */
|
/* ============================================================= */
|
||||||
|
|
||||||
/* --- App-Shell --- */
|
|
||||||
.app-shell { display: flex; height: 100vh; overflow: hidden; }
|
|
||||||
.app-main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
|
|
||||||
.app-scroll { flex: 1; overflow-y: auto; }
|
|
||||||
|
|
||||||
/* --- Page header + container --- */
|
/* --- Page header + container --- */
|
||||||
.page-header {
|
.page-header {
|
||||||
display: flex; align-items: flex-start; justify-content: space-between;
|
display: flex; align-items: flex-start; justify-content: space-between;
|
||||||
@@ -1110,53 +1099,6 @@ select.form-control {
|
|||||||
border-radius: var(--radius); background: var(--grey-bg); color: var(--grey-fg); font-weight: 600;
|
border-radius: var(--radius); background: var(--grey-bg); color: var(--grey-fg); font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Altes Menue (Navbar.jsx) unveraendert aus dem Altbestand: Task 5 ersetzt das Menue und loescht diesen Teil. Ohne ihn faellt das */
|
|
||||||
/* Alt-Geruest (.app-shell) zusammen; .side.collapsed gilt auf dem Handy (lib/sidebar.js). */
|
|
||||||
.side {
|
|
||||||
width: var(--sidebar-w);
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: linear-gradient(180deg, var(--surface-1), var(--bg));
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
transition: width 0.2s ease;
|
|
||||||
}
|
|
||||||
.side.collapsed { width: 72px; }
|
|
||||||
.side-head {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-3);
|
|
||||||
padding: var(--sp-4); border-bottom: 1px solid var(--border);
|
|
||||||
min-height: 64px;
|
|
||||||
}
|
|
||||||
.side-brand { font-weight: 800; font-size: 16px; letter-spacing: 0.2px; white-space: nowrap; }
|
|
||||||
.side-brand small { display: block; font-weight: 500; font-size: 11px; color: var(--text-muted); }
|
|
||||||
.side-nav { flex: 1; overflow-y: auto; padding: var(--sp-3) var(--sp-2); }
|
|
||||||
.side-group { margin-bottom: var(--sp-4); }
|
|
||||||
.side.collapsed .side-link-label,
|
|
||||||
.side.collapsed .side-brand { display: none; }
|
|
||||||
.side-link {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-3);
|
|
||||||
padding: 10px var(--sp-3); margin: 2px var(--sp-1);
|
|
||||||
border-radius: var(--r-sm); color: var(--text-muted);
|
|
||||||
text-decoration: none; transition: all 0.15s ease; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.side-link svg { width: 20px; height: 20px; flex-shrink: 0; }
|
|
||||||
.side-link:hover { background: var(--surface-2); color: var(--text); }
|
|
||||||
.side-link-paused, .side-link-paused:hover { opacity: 0.45; cursor: not-allowed; background: transparent; color: var(--text-muted); }
|
|
||||||
.side-link.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
|
||||||
.side-sublink { padding-left: calc(var(--sp-3) + 18px); font-size: 13.5px; }
|
|
||||||
.side-sublink svg { width: 17px; height: 17px; }
|
|
||||||
.side.collapsed .side-sublink { padding-left: var(--sp-3); }
|
|
||||||
.side.collapsed .side-count { position: absolute; margin: -18px 0 0 14px; min-width: 16px; padding: 0 4px; font-size: 10px; line-height: 16px; }
|
|
||||||
.side.collapsed .side-sublink { position: relative; }
|
|
||||||
.side-link.active svg { color: var(--accent); }
|
|
||||||
.side-foot { border-top: 1px solid var(--border); padding: var(--sp-3); }
|
|
||||||
.side-toggle {
|
|
||||||
background: transparent; border: none; color: var(--text-muted);
|
|
||||||
cursor: pointer; padding: var(--sp-2); border-radius: var(--r-sm);
|
|
||||||
display: flex; align-items: center; gap: var(--sp-2); width: 100%;
|
|
||||||
}
|
|
||||||
.side-toggle:hover { background: var(--surface-2); color: var(--text); }
|
|
||||||
|
|
||||||
/* Neutral: Karte, Raster, Aufklappmenue */
|
/* Neutral: Karte, Raster, Aufklappmenue */
|
||||||
.card { margin-bottom: 16px; background: none; border: 0; border-radius: 0; color: var(--text); }
|
.card { margin-bottom: 16px; background: none; border: 0; border-radius: 0; color: var(--text); }
|
||||||
.card-header { padding: 0 0 8px; background: none; border-bottom: 1px solid var(--line); color: var(--text); font-weight: 600; }
|
.card-header { padding: 0 0 8px; background: none; border-bottom: 1px solid var(--line); color: var(--text); font-weight: 600; }
|
||||||
@@ -1179,7 +1121,7 @@ div.row { display: flex; flex-wrap: wrap; margin: 0 -8px; }
|
|||||||
.spinner { width: 40px; height: 40px; margin: 0 auto; border: 4px solid var(--line); border-top-color: var(--text-2); border-radius: 50%; animation: spin 1s linear infinite; }
|
.spinner { width: 40px; height: 40px; margin: 0 auto; border: 4px solid var(--line); border-top-color: var(--text-2); border-radius: 50%; animation: spin 1s linear infinite; }
|
||||||
|
|
||||||
/* Verschachtelte Seiten: Abstand und Breite des inneren Rahmens entfallen */
|
/* Verschachtelte Seiten: Abstand und Breite des inneren Rahmens entfallen */
|
||||||
.page .page, .page .main-content { margin: 0; padding: 0; max-width: none; }
|
.page .page { margin: 0; padding: 0; max-width: none; }
|
||||||
|
|
||||||
/* Links in Bestandsklassen, die bisher --accent als Farbe nutzten: feiner Unterstrich */
|
/* Links in Bestandsklassen, die bisher --accent als Farbe nutzten: feiner Unterstrich */
|
||||||
.fin-link, .legal-files a, .credit-cols a, .area-rows a {
|
.fin-link, .legal-files a, .credit-cols a, .area-rows a {
|
||||||
|
|||||||
@@ -66,9 +66,3 @@
|
|||||||
.sp-cleanup-grid strong { font-size: 20px; }
|
.sp-cleanup-grid strong { font-size: 20px; }
|
||||||
.sp-cleanup-list { list-style: none; margin: var(--sp-3) 0; padding: 0; }
|
.sp-cleanup-list { list-style: none; margin: var(--sp-3) 0; padding: 0; }
|
||||||
.sp-cleanup-list li { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
|
.sp-cleanup-list li { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||||
|
|
||||||
.side-dot { width: 8px; height: 8px; border-radius: 50%; margin-left: auto; flex-shrink: 0; }
|
|
||||||
.side-dot-medium { background: #f59e0b; }
|
|
||||||
.side-dot-critical { background: #ef4444; }
|
|
||||||
.side.collapsed .side-dot { position: absolute; margin: 0; transform: translate(12px, -10px); }
|
|
||||||
.side-link { position: relative; }
|
|
||||||
@@ -17,6 +17,7 @@ test('buildNavLinks: vier Unterpunkte unter Kunden mit Zahlen, Admin-Eintraege n
|
|||||||
])
|
])
|
||||||
assert.deepEqual(links.map((l) => l.key), ['tickets', 'planboard', 'projects', 'services', 'customers', 'leads', 'legal'])
|
assert.deepEqual(links.map((l) => l.key), ['tickets', 'planboard', 'projects', 'services', 'customers', 'leads', 'legal'])
|
||||||
assert.equal(links.find((l) => l.key === 'leads').paused, true)
|
assert.equal(links.find((l) => l.key === 'leads').paused, true)
|
||||||
|
assert.equal(kunden.children.find((c) => c.key === 'chat').hot, true)
|
||||||
const admin = buildNavLinks({ isAdmin: true, pulseLevel: 'warn', counts: {} })
|
const admin = buildNavLinks({ isAdmin: true, pulseLevel: 'warn', counts: {} })
|
||||||
assert.deepEqual(admin.map((l) => l.key), ['tickets', 'planboard', 'projects', 'services', 'customers', 'leads', 'finance', 'legal', 'serverpuls', 'admin'])
|
assert.deepEqual(admin.map((l) => l.key), ['tickets', 'planboard', 'projects', 'services', 'customers', 'leads', 'finance', 'legal', 'serverpuls', 'admin'])
|
||||||
assert.equal(admin.find((l) => l.key === 'serverpuls').dot, 'warn')
|
assert.equal(admin.find((l) => l.key === 'serverpuls').dot, 'warn')
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import test from 'node:test'
|
|
||||||
import assert from 'node:assert/strict'
|
|
||||||
import { loadModule } from './load.mjs'
|
|
||||||
|
|
||||||
const { startsCollapsed, AUTO_COLLAPSE_MAX_WIDTH } = await loadModule('src/lib/sidebar.js')
|
|
||||||
|
|
||||||
// Fenster mit matchMedia, das "(max-width: Npx)" gegen eine feste Breite prueft
|
|
||||||
const windowOf = (width) => ({
|
|
||||||
matchMedia: (query) => ({ matches: width <= Number(/max-width:\s*(\d+)px/.exec(query)?.[1]) }),
|
|
||||||
})
|
|
||||||
|
|
||||||
test('startsCollapsed: Handy (390 px) und Grenze (720 px) starten eingeklappt', () => {
|
|
||||||
assert.equal(AUTO_COLLAPSE_MAX_WIDTH, 720)
|
|
||||||
assert.equal(startsCollapsed(windowOf(390)), true)
|
|
||||||
assert.equal(startsCollapsed(windowOf(720)), true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('startsCollapsed: breitere Fenster starten ausgeklappt', () => {
|
|
||||||
assert.equal(startsCollapsed(windowOf(721)), false)
|
|
||||||
assert.equal(startsCollapsed(windowOf(1280)), false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('startsCollapsed: ohne window oder matchMedia (oder bei Fehler) ausgeklappt', () => {
|
|
||||||
assert.equal(startsCollapsed(null), false)
|
|
||||||
assert.equal(startsCollapsed({}), false)
|
|
||||||
assert.equal(startsCollapsed({ matchMedia: () => { throw new Error('kaputt') } }), false)
|
|
||||||
})
|
|
||||||
Reference in new issue
Block a user