Merge branch 'phase/034-mobil'
@@ -17,6 +17,15 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
|
## [0.20.2] – 2026-10-03
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- **Das Seitenmenü ließ sich auf dem Handy nicht schließen.** Es gab weder eine Fläche zum Antippen noch ein Schließen beim Navigieren – das Markup stammt von AdminLTE, dessen JavaScript wir bewusst nicht einbinden (#034)
|
||||||
|
- Die Dashboard-Tabelle war auf dem Telefon rechts abgeschnitten; sie zeigt dort jetzt Host und Ablauf, den Kunden klein darunter (#034)
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Schließen-Knopf im Menükopf, Schließen per Escape, und der Inhalt hinter dem offenen Menü scrollt nicht mehr mit (#034)
|
||||||
|
|
||||||
## [0.20.1] – 2026-10-03
|
## [0.20.1] – 2026-10-03
|
||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt:** 16 h 58 min
|
**Gesamt:** 17 h 38 min
|
||||||
|
|
||||||
| Issue | Zeit |
|
| Issue | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -39,6 +39,7 @@
|
|||||||
| #031 Update für beide Varianten | 0 h 45 min |
|
| #031 Update für beide Varianten | 0 h 45 min |
|
||||||
| #032 LXC-Installation korrigiert | 0 h 20 min |
|
| #032 LXC-Installation korrigiert | 0 h 20 min |
|
||||||
| #033 Weiterleitung auf Lausch-Adresse | 0 h 30 min |
|
| #033 Weiterleitung auf Lausch-Adresse | 0 h 30 min |
|
||||||
|
| #034 Bedienung auf dem Handy | 0 h 40 min |
|
||||||
|
|
||||||
| Meilenstein | Zeit |
|
| Meilenstein | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -61,7 +62,7 @@
|
|||||||
|
|
||||||
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
||||||
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
||||||
**Version:** 0.20.1
|
**Version:** 0.20.2
|
||||||
|
|
||||||
### Erledigt
|
### Erledigt
|
||||||
- Auftrag gelesen, Rückfragen gestellt
|
- Auftrag gelesen, Rückfragen gestellt
|
||||||
@@ -179,6 +180,9 @@
|
|||||||
- #033 war hausgemacht: Mein `HOSTNAME=0.0.0.0` aus #031 hat den Healthcheck repariert und dafür alle Weiterleitungen aus Server Actions auf `0.0.0.0` gelenkt. Vorher stand dort der Containername – derselbe Fehler, nur unauffälliger
|
- #033 war hausgemacht: Mein `HOSTNAME=0.0.0.0` aus #031 hat den Healthcheck repariert und dafür alle Weiterleitungen aus Server Actions auf `0.0.0.0` gelenkt. Vorher stand dort der Containername – derselbe Fehler, nur unauffälliger
|
||||||
- Lehre: Weiterleitungen gehören aus der konfigurierten Adresse gebaut, nie aus dem, was der Server über sich selbst weiß
|
- Lehre: Weiterleitungen gehören aus der konfigurierten Adresse gebaut, nie aus dem, was der Server über sich selbst weiß
|
||||||
|
|
||||||
|
- Mobile Bedienung (#034): Das Menü ließ sich auf dem Telefon nicht schließen – AdminLTE-Markup ohne AdminLTE-JavaScript. Eigener Zustand, abdunkelnde Fläche, Schließen beim Navigieren und mit Escape
|
||||||
|
- Lehre: Die Mobilansicht war mit Screenshots geprüft, nicht mit den Fingern. Ein Bild zeigt nicht, dass etwas nicht wieder weggeht
|
||||||
|
|
||||||
### Nächster Schritt
|
### Nächster Schritt
|
||||||
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
|
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
|
||||||
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
|
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
|
||||||
|
|||||||
@@ -473,6 +473,11 @@ Server-ID stehen in der Detailansicht unter *Subscription-Historie*.
|
|||||||
|
|
||||||
## Anhang: dunkles Design und Mobilansicht
|
## Anhang: dunkles Design und Mobilansicht
|
||||||
|
|
||||||
|
Auf dem Telefon öffnet das Symbol links oben das Menü; es schließt sich durch Antippen der
|
||||||
|
abgedunkelten Fläche, über das Kreuz im Menükopf, mit Escape – und von selbst, sobald ein Eintrag
|
||||||
|
gewählt wurde. Die Tabellen zeigen auf schmalen Bildschirmen nur die wichtigsten Spalten; die
|
||||||
|
übrigen Angaben stehen auf der jeweiligen Detailseite.
|
||||||
|
|
||||||
Das Dashboard folgt der Einstellung des Betriebssystems und lässt sich über das Mondsymbol in der
|
Das Dashboard folgt der Einstellung des Betriebssystems und lässt sich über das Mondsymbol in der
|
||||||
Kopfleiste umschalten.
|
Kopfleiste umschalten.
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 297 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 297 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 252 KiB |
@@ -0,0 +1,28 @@
|
|||||||
|
# #034 Bedienung auf dem Handy
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.20.2
|
||||||
|
**Labels:** ui, bug
|
||||||
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
Florian im produktiven Einsatz auf dem Telefon: „Das Menü geht nicht weg.“ Dazu eine Tabelle, von der
|
||||||
|
rechts die Hälfte abgeschnitten war.
|
||||||
|
|
||||||
|
## Ursache
|
||||||
|
Das Markup stammt von AdminLTE, dessen JavaScript binden wir bewusst nicht ein. Die Klasse
|
||||||
|
`sidebar-open` ließ sich damit setzen – aber nichts nahm sie wieder weg: keine Fläche zum Antippen,
|
||||||
|
kein Schließen beim Navigieren, kein Escape. Auf dem Telefon blieb das Menü über dem Inhalt stehen.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
- Eigener, winziger Zustand (`menuZustand.ts`), auf den Kopfzeile und Menü gemeinsam hören
|
||||||
|
- Abdunkelnde Fläche hinter dem Menü – antippen schließt (ein `button`, damit auch per Tastatur erreichbar)
|
||||||
|
- Schließen-Knopf im Menükopf, nur auf schmalen Bildschirmen
|
||||||
|
- Schließt beim Navigieren und mit Escape
|
||||||
|
- Der Inhalt dahinter scrollt nicht mehr mit, solange das Menü offen ist
|
||||||
|
- Dashboard-Tabelle: auf dem Telefon nur Host und Ablauf, der Kunde steht klein unter dem Hostnamen.
|
||||||
|
Key und Rechnungsstände erscheinen ab Tablet-Breite – die Host-Tabelle machte das schon vorher so.
|
||||||
|
- Fünf Tests für die Zustandslogik
|
||||||
|
|
||||||
|
## Nicht geändert
|
||||||
|
Die abdunkelnde Fläche gilt nur unter 992 px. Auf breiten Bildschirmen bleibt es beim bisherigen
|
||||||
|
Verhalten: Das Menü klappt zu Symbolen zusammen, statt sich über den Inhalt zu legen.
|
||||||
@@ -37,4 +37,5 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
|||||||
| [#031](031-update-skript-beide-varianten.md) | Update- und Sicherungsskript für beide Varianten | erledigt | v0.20.0 | 2 h | 0 h 45 min |
|
| [#031](031-update-skript-beide-varianten.md) | Update- und Sicherungsskript für beide Varianten | erledigt | v0.20.0 | 2 h | 0 h 45 min |
|
||||||
| [#032](032-lxc-installation-korrekturen.md) | LXC-Installation: Herkunft der Skripte und Speicher | erledigt | v0.20.1 | 1 h | 0 h 20 min |
|
| [#032](032-lxc-installation-korrekturen.md) | LXC-Installation: Herkunft der Skripte und Speicher | erledigt | v0.20.1 | 1 h | 0 h 20 min |
|
||||||
| [#033](033-weiterleitung-lauschadresse.md) | Abmelden führte auf die Lausch-Adresse | erledigt | v0.20.1 | 1 h | 0 h 30 min |
|
| [#033](033-weiterleitung-lauschadresse.md) | Abmelden führte auf die Lausch-Adresse | erledigt | v0.20.1 | 1 h | 0 h 30 min |
|
||||||
| | **Summe** | | | **151 h** | **16 h 58 min** |
|
| [#034](034-mobile-bedienung.md) | Bedienung auf dem Handy | erledigt | v0.20.2 | 2 h | 0 h 40 min |
|
||||||
|
| | **Summe** | | | **153 h** | **17 h 38 min** |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxledger",
|
"name": "proxledger",
|
||||||
"version": "0.20.1",
|
"version": "0.20.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -121,12 +121,22 @@ export default async function DashboardSeite() {
|
|||||||
<table className="table table-striped align-middle">
|
<table className="table table-striped align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* Auf dem Handy bleiben Host und Ablauf; der Rest käme ohnehin nur
|
||||||
|
per Seitwärtsscrollen ins Bild (#034). */}
|
||||||
<th scope="col">Host</th>
|
<th scope="col">Host</th>
|
||||||
<th scope="col">Kunde</th>
|
<th scope="col" className="d-none d-md-table-cell">
|
||||||
<th scope="col">Key</th>
|
Kunde
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="d-none d-lg-table-cell">
|
||||||
|
Key
|
||||||
|
</th>
|
||||||
<th scope="col">Ablauf</th>
|
<th scope="col">Ablauf</th>
|
||||||
<th scope="col">Proxmox</th>
|
<th scope="col" className="d-none d-sm-table-cell">
|
||||||
<th scope="col">Kundenrechnung</th>
|
Proxmox
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="d-none d-sm-table-cell">
|
||||||
|
Kundenrechnung
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -137,8 +147,12 @@ export default async function DashboardSeite() {
|
|||||||
{host.hostname}
|
{host.hostname}
|
||||||
</Link>
|
</Link>
|
||||||
{host.meldetNicht ? <KeineMeldungBadge /> : null}
|
{host.meldetNicht ? <KeineMeldungBadge /> : null}
|
||||||
|
{/* Auf schmalen Bildschirmen steht der Kunde hier mit darunter. */}
|
||||||
|
<div className="small text-body-secondary d-md-none">
|
||||||
|
{host.kunde ? host.kunde.name : "Nicht zugeordnet"}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="d-none d-md-table-cell">
|
||||||
{host.kunde ? (
|
{host.kunde ? (
|
||||||
<Link href={`/kunden/${host.kunde.id}`} className="text-decoration-none">
|
<Link href={`/kunden/${host.kunde.id}`} className="text-decoration-none">
|
||||||
{host.kunde.name}
|
{host.kunde.name}
|
||||||
@@ -152,16 +166,16 @@ export default async function DashboardSeite() {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="d-none d-lg-table-cell">
|
||||||
<CopyableCode wert={host.subKey} />
|
<CopyableCode wert={host.subKey} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<AblaufBadge datum={host.nextduedate?.toISOString() ?? null} heute={heute} />
|
<AblaufBadge datum={host.nextduedate?.toISOString() ?? null} heute={heute} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="d-none d-sm-table-cell">
|
||||||
<EinkaufBadge bezahlt={host.einkaufBezahlt} />
|
<EinkaufBadge bezahlt={host.einkaufBezahlt} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="d-none d-sm-table-cell">
|
||||||
<VerkaufBadge erstellt={host.verkaufErstellt} bezahlt={host.verkaufBezahlt} />
|
<VerkaufBadge erstellt={host.verkaufErstellt} bezahlt={host.verkaufBezahlt} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -186,6 +186,19 @@ body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Fläche hinter dem geöffneten Menü auf schmalen Bildschirmen. Ein Knopf statt eines div,
|
||||||
|
* damit sie auch per Tastatur erreichbar ist.
|
||||||
|
*/
|
||||||
|
.pl-menu-hintergrund {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1037; /* unter dem Menü (1038), über allem anderen */
|
||||||
|
padding: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Inhaltsbereich ---------- */
|
/* ---------- Inhaltsbereich ---------- */
|
||||||
|
|
||||||
.app-content-header {
|
.app-content-header {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
|||||||
import { SearchDialog, type SuchEintrag } from "./SearchDialog";
|
import { SearchDialog, type SuchEintrag } from "./SearchDialog";
|
||||||
import type { Rolle } from "@/generated/prisma/enums";
|
import type { Rolle } from "@/generated/prisma/enums";
|
||||||
import { ROLLEN_TEXT } from "@/server/auth/rollen";
|
import { ROLLEN_TEXT } from "@/server/auth/rollen";
|
||||||
|
import { menuUmschalten } from "./menuZustand";
|
||||||
|
|
||||||
export interface KopfleistenAlert {
|
export interface KopfleistenAlert {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -58,16 +59,9 @@ export function Header({
|
|||||||
return () => window.removeEventListener("keydown", tasten);
|
return () => window.removeEventListener("keydown", tasten);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** Sidebar umschalten: auf schmalen Bildschirmen als Overlay, sonst eingeklappt. */
|
/** Seitenmenü umschalten – der Zustand liegt in menuZustand.ts, damit das Menü selbst ihn kennt. */
|
||||||
const sidebarUmschalten = useCallback(() => {
|
const sidebarUmschalten = useCallback(() => {
|
||||||
const body = document.body;
|
menuUmschalten();
|
||||||
if (window.innerWidth < 992) {
|
|
||||||
body.classList.toggle("sidebar-open");
|
|
||||||
body.classList.remove("sidebar-collapse");
|
|
||||||
} else {
|
|
||||||
body.classList.toggle("sidebar-collapse");
|
|
||||||
body.classList.remove("sidebar-open");
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function themaUmschalten() {
|
function themaUmschalten() {
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LogoMitSchriftzug } from "@/components/Logo";
|
import { LogoMitSchriftzug } from "@/components/Logo";
|
||||||
import type { Rolle } from "@/generated/prisma/enums";
|
import type { Rolle } from "@/generated/prisma/enums";
|
||||||
import { darf } from "@/server/auth/rollen";
|
import { darf } from "@/server/auth/rollen";
|
||||||
|
import { menuSchliessen, useMenuOffen } from "./menuZustand";
|
||||||
|
|
||||||
interface MenuEintrag {
|
interface MenuEintrag {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -30,6 +31,21 @@ export function Sidebar({
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [suche, setSuche] = useState("");
|
const [suche, setSuche] = useState("");
|
||||||
|
const menuOffen = useMenuOffen();
|
||||||
|
|
||||||
|
// Nach dem Navigieren hat das Menü seinen Zweck erfüllt – auf dem Handy verdeckt es sonst alles.
|
||||||
|
useEffect(() => {
|
||||||
|
menuSchliessen();
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
// Escape schließt, wie bei jedem anderen Overlay auch.
|
||||||
|
useEffect(() => {
|
||||||
|
function taste(ereignis: KeyboardEvent) {
|
||||||
|
if (ereignis.key === "Escape") menuSchliessen();
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", taste);
|
||||||
|
return () => window.removeEventListener("keydown", taste);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const haupt: MenuEintrag[] = [
|
const haupt: MenuEintrag[] = [
|
||||||
{ href: "/", titel: "Dashboard", icon: "bi-speedometer2" },
|
{ href: "/", titel: "Dashboard", icon: "bi-speedometer2" },
|
||||||
@@ -105,11 +121,30 @@ export function Sidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{/* Fläche hinter dem Menü: antippen schließt. Ohne sie bleibt das Menü auf dem Handy stehen. */}
|
||||||
|
{menuOffen ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pl-menu-hintergrund d-lg-none"
|
||||||
|
aria-label="Menü schließen"
|
||||||
|
onClick={menuSchliessen}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<aside className="app-sidebar shadow" data-bs-theme="dark">
|
<aside className="app-sidebar shadow" data-bs-theme="dark">
|
||||||
<div className="sidebar-brand">
|
<div className="sidebar-brand d-flex align-items-center justify-content-between">
|
||||||
<Link href="/" className="brand-link">
|
<Link href="/" className="brand-link">
|
||||||
<LogoMitSchriftzug variante="sidebar" groesse={34} />
|
<LogoMitSchriftzug variante="sidebar" groesse={34} />
|
||||||
</Link>
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-link text-white d-lg-none pl-touch-target me-2"
|
||||||
|
aria-label="Menü schließen"
|
||||||
|
onClick={menuSchliessen}
|
||||||
|
>
|
||||||
|
<i className="bi bi-x-lg" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pl-sidebar-user">
|
<div className="pl-sidebar-user">
|
||||||
@@ -148,5 +183,6 @@ export function Sidebar({
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zustand des Seitenmenüs auf schmalen Bildschirmen (#034).
|
||||||
|
*
|
||||||
|
* AdminLTE bringt dafür eigenes JavaScript mit, das wir bewusst nicht einbinden – die Klasse
|
||||||
|
* `sidebar-open` am `body` allein lässt sich aber nicht wieder loswerden: Kein Hintergrund zum
|
||||||
|
* Antippen, kein Schließen beim Navigieren. Deshalb ein eigener, winziger Zustand, auf den
|
||||||
|
* Kopfzeile und Menü gemeinsam hören.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SCHMAL = 992;
|
||||||
|
|
||||||
|
let offen = false;
|
||||||
|
const zuhoerer = new Set<() => void>();
|
||||||
|
|
||||||
|
function melden() {
|
||||||
|
for (const ruf of zuhoerer) ruf();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Setzt die Klassen, die das AdminLTE-Stylesheet auswertet. */
|
||||||
|
function klassenSetzen() {
|
||||||
|
const body = document.body;
|
||||||
|
body.classList.toggle("sidebar-open", offen);
|
||||||
|
// Auf schmalen Bildschirmen liegt das Menü über dem Inhalt; dahinter soll nichts mitscrollen.
|
||||||
|
body.style.overflow = offen && window.innerWidth < SCHMAL ? "hidden" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function menuUmschalten() {
|
||||||
|
if (window.innerWidth >= SCHMAL) {
|
||||||
|
// Breiter Bildschirm: Das Menü bleibt stehen und wird nur schmaler.
|
||||||
|
document.body.classList.toggle("sidebar-collapse");
|
||||||
|
document.body.classList.remove("sidebar-open");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
offen = !offen;
|
||||||
|
document.body.classList.remove("sidebar-collapse");
|
||||||
|
klassenSetzen();
|
||||||
|
melden();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function menuSchliessen() {
|
||||||
|
if (!offen) return;
|
||||||
|
offen = false;
|
||||||
|
klassenSetzen();
|
||||||
|
melden();
|
||||||
|
}
|
||||||
|
|
||||||
|
function abonnieren(ruf: () => void) {
|
||||||
|
zuhoerer.add(ruf);
|
||||||
|
return () => {
|
||||||
|
zuhoerer.delete(ruf);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMenuOffen(): boolean {
|
||||||
|
// Auf dem Server ist das Menü nie offen – sonst käme es beim ersten Rendern zu einem Unterschied.
|
||||||
|
return useSyncExternalStore(
|
||||||
|
abonnieren,
|
||||||
|
() => offen,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft das Öffnen und Schließen des Seitenmenüs. Statt eines echten Browsers genügen hier
|
||||||
|
* Attrappen für `document.body` und `window` – geprüft wird, welche Klassen gesetzt werden.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const klassen = new Set<string>();
|
||||||
|
let breite = 390;
|
||||||
|
|
||||||
|
function aufraeumen() {
|
||||||
|
klassen.clear();
|
||||||
|
breite = 390;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
aufraeumen();
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
classList: {
|
||||||
|
toggle(name: string, an?: boolean) {
|
||||||
|
const soll = an ?? !klassen.has(name);
|
||||||
|
if (soll) klassen.add(name);
|
||||||
|
else klassen.delete(name);
|
||||||
|
},
|
||||||
|
remove(name: string) {
|
||||||
|
klassen.delete(name);
|
||||||
|
},
|
||||||
|
contains: (name: string) => klassen.has(name),
|
||||||
|
},
|
||||||
|
style: { overflow: "" },
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.stubGlobal("document", { body });
|
||||||
|
vi.stubGlobal("window", {
|
||||||
|
get innerWidth() {
|
||||||
|
return breite;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Seitenmenü auf schmalen Bildschirmen", () => {
|
||||||
|
it("öffnet und schließt wieder", async () => {
|
||||||
|
const { menuUmschalten, menuSchliessen } = await import("@/components/layout/menuZustand");
|
||||||
|
|
||||||
|
menuUmschalten();
|
||||||
|
expect(klassen.has("sidebar-open")).toBe(true);
|
||||||
|
|
||||||
|
menuSchliessen();
|
||||||
|
expect(klassen.has("sidebar-open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sperrt den Inhalt hinter dem offenen Menü gegen Scrollen", async () => {
|
||||||
|
const { menuUmschalten, menuSchliessen } = await import("@/components/layout/menuZustand");
|
||||||
|
|
||||||
|
menuUmschalten();
|
||||||
|
expect((document.body as unknown as { style: { overflow: string } }).style.overflow).toBe("hidden");
|
||||||
|
|
||||||
|
menuSchliessen();
|
||||||
|
expect((document.body as unknown as { style: { overflow: string } }).style.overflow).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schließt auch beim zweiten Antippen des Knopfes", async () => {
|
||||||
|
const { menuUmschalten } = await import("@/components/layout/menuZustand");
|
||||||
|
|
||||||
|
menuUmschalten();
|
||||||
|
menuUmschalten();
|
||||||
|
expect(klassen.has("sidebar-open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("macht ein zweites Schließen zum Nichts", async () => {
|
||||||
|
const { menuSchliessen } = await import("@/components/layout/menuZustand");
|
||||||
|
|
||||||
|
menuSchliessen();
|
||||||
|
expect(klassen.has("sidebar-open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("klappt auf breiten Bildschirmen nur ein, statt zu überlagern", async () => {
|
||||||
|
breite = 1400;
|
||||||
|
const { menuUmschalten } = await import("@/components/layout/menuZustand");
|
||||||
|
|
||||||
|
menuUmschalten();
|
||||||
|
expect(klassen.has("sidebar-collapse")).toBe(true);
|
||||||
|
expect(klassen.has("sidebar-open")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||