feat(web): Dashboard-Aktionen in die App-Leiste, Bearbeiten als Stift

Der Knopf Bearbeiten belegte eine eigene Zeile ueber den Widgets. Die
Aktionen rendern jetzt per Portal in einen Einhaengepunkt rechts in der
Kopfzeile (HEADER_ACTIONS_SLOT_ID); in der Ansicht nur der Stift, im
Bearbeitungsmodus Hintergrund, Widget hinzufuegen und Fertig. Version 1.5.1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 23:12:38 +02:00
parent 03026d616f
commit 7d37090306
7 changed files with 87 additions and 54 deletions
+6
View File
@@ -4,6 +4,12 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
## Unveröffentlicht ## Unveröffentlicht
## 1.5.1 – 2026-09-28
### Geändert
- Dashboard: Der Knopf „Bearbeiten“ sitzt jetzt als Stift-Symbol rechts in der dunklen Leiste oben und nimmt über den Widgets keinen Platz mehr weg. Im Bearbeitungsmodus erscheinen dort auch „Hintergrund“, „Widget hinzufügen“ und „Fertig“.
## 1.5.0 – 2026-09-28 ## 1.5.0 – 2026-09-28
### Neu ### Neu
+44 -46
View File
@@ -1,7 +1,9 @@
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
import { DashboardGrid } from '@/components/dashboard/dashboard-grid'; import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs'; import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle'; import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
@@ -48,7 +50,11 @@ export default function DashboardPage() {
const [catalogOpen, setCatalogOpen] = useState(false); const [catalogOpen, setCatalogOpen] = useState(false);
// Seitlicher Versatz der zentrierten Kacheln — die Zeile mit Begruessung // Seitlicher Versatz der zentrierten Kacheln — die Zeile mit Begruessung
// und Befehlsleiste rueckt mit, damit sie buendig ueber den Kacheln steht. // und Befehlsleiste rueckt mit, damit sie buendig ueber den Kacheln steht.
const [gridInset, setGridInset] = useState(0); // Einhaengepunkt der Kopfzeile fuer die Dashboard-Aktionen (quick-260928-vxe)
const [actionsSlot, setActionsSlot] = useState<HTMLElement | null>(null);
useEffect(() => {
setActionsSlot(document.getElementById(HEADER_ACTIONS_SLOT_ID));
}, []);
// Hintergrund je Benutzer (Design „Mosaik“), seit quick-260928-ujj in der // Hintergrund je Benutzer (Design „Mosaik“), seit quick-260928-ujj in der
// Datenbank gespeichert und mit der Sitzung geladen. // Datenbank gespeichert und mit der Sitzung geladen.
const { background, choose: chooseBackground } = useDashboardBackground(); const { background, choose: chooseBackground } = useDashboardBackground();
@@ -122,6 +128,39 @@ export default function DashboardPage() {
); );
} }
const actions = (
<div role="toolbar" aria-label={t('toolbarLabel')} className="flex items-center gap-1">
{isEditMode && <BackgroundPicker value={background} onChange={chooseBackground} />}
{isEditMode && (
<button
type="button"
onClick={() => setCatalogOpen(true)}
aria-label={t('addWidget')}
title={t('addWidget')}
className="btn btn-appbar"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
<span className="hidden lg:inline">{t('addWidget')}</span>
</button>
)}
<EditModeToggle isEditMode={isEditMode} onToggle={() => setEditMode(!isEditMode)} />
</div>
);
return ( return (
<div className={`relative p-2 ${hasBackground ? 'dashboard-has-bg' : ''}`}> <div className={`relative p-2 ${hasBackground ? 'dashboard-has-bg' : ''}`}>
<DashboardBackdrop background={background} /> <DashboardBackdrop background={background} />
@@ -140,50 +179,10 @@ export default function DashboardPage() {
onReorder={reorderDashboards} onReorder={reorderDashboards}
/> />
{/* Befehlsleiste rechts ueber dem Raster (Design „Mosaik“, Fluent {/* Dashboard-Aktionen (quick-260928-vxe): per Portal rechts in der
„command bar“). Die Begruessung sitzt seit Runde 3 unten in der App-Leiste statt als eigene Zeile ueber dem Raster. Fehlt der
Seitenleiste; hier bleibt nur die Leiste. Mit Hintergrund liegt sie Einhaengepunkt (etwa in Tests ohne Kopfzeile), stehen sie hier. */}
auf der durchscheinenden Kachelflaeche (.dashboard-chrome). */} {actionsSlot ? createPortal(actions, actionsSlot) : actions}
<div
className="flex justify-end px-3 pt-1 transition-[padding] duration-200 ease-out motion-reduce:transition-none"
style={gridInset > 0 ? { paddingInline: 12 + gridInset } : undefined}
>
<div
role="toolbar"
aria-label={t('toolbarLabel')}
className="dashboard-chrome flex items-center gap-1 rounded-lg"
>
{isEditMode && <BackgroundPicker value={background} onChange={chooseBackground} />}
{isEditMode && (
<button
type="button"
onClick={() => setCatalogOpen(true)}
className="btn btn-subtle"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{t('addWidget')}
</button>
)}
<EditModeToggle
isEditMode={isEditMode}
onToggle={() => setEditMode(!isEditMode)}
/>
</div>
</div>
{isSwitchingDashboard ? ( {isSwitchingDashboard ? (
<div className="flex min-h-[40vh] items-center justify-center"> <div className="flex min-h-[40vh] items-center justify-center">
@@ -199,7 +198,6 @@ export default function DashboardPage() {
onRemoveWidget={removeWidget} onRemoveWidget={removeWidget}
onOpenCatalog={() => setCatalogOpen(true)} onOpenCatalog={() => setCatalogOpen(true)}
onQuickAdd={(type) => addWidget(type)} onQuickAdd={(type) => addWidget(type)}
onInsetChange={setGridInset}
/> />
)} )}
+13
View File
@@ -357,6 +357,19 @@ body {
width: 2rem; width: 2rem;
padding-inline: 0; padding-inline: 0;
} }
/* Knopf auf der dunklen App-Leiste (Dashboard-Aktionen, quick-260928-vxe). */
.btn-appbar {
background-color: transparent;
color: var(--appbar-muted);
}
.btn-appbar:hover:not(:disabled) {
background-color: var(--appbar-hover);
color: var(--appbar-foreground);
}
.btn-appbar:focus-visible {
outline-color: var(--appbar-foreground);
outline-offset: -2px;
}
/* Verweis: dunkle bzw. blaue Schrift, nie gelb auf Weiss. */ /* Verweis: dunkle bzw. blaue Schrift, nie gelb auf Weiss. */
.link { .link {
@@ -227,7 +227,9 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
aria-expanded={open} aria-expanded={open}
aria-haspopup="dialog" aria-haspopup="dialog"
className="btn btn-subtle" aria-label={t('button')}
title={t('button')}
className="btn btn-appbar"
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
@@ -245,7 +247,8 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
<circle cx="9" cy="9" r="2" /> <circle cx="9" cy="9" r="2" />
<path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21" /> <path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21" />
</svg> </svg>
{t('button')} {/* In der App-Leiste ist der Platz knapp: Beschriftung erst ab lg. */}
<span className="hidden lg:inline">{t('button')}</span>
</button> </button>
{open && ( {open && (
@@ -10,9 +10,9 @@ interface EditModeToggleProps {
/** /**
* Pencil / Checkmark icon button to toggle dashboard edit mode (D-01). * Pencil / Checkmark icon button to toggle dashboard edit mode (D-01).
* Active state (edit mode) uses primary background. * Active state (edit mode) uses primary background.
* Schwebt in der festen Aktionsleiste unten rechts (quick-260916-dyv): * Sitzt seit quick-260928-vxe rechts in der dunklen App-Leiste: in der
* inaktiv mit Karten-Hintergrund, Rahmen und Schatten, damit der Stift * Ansicht nur der Stift (Name per aria-label/Tooltip), im Bearbeitungsmodus
* ueber Widgets sichtbar bleibt. * der gelbe Knopf „Fertig“.
*/ */
export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) { export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
@@ -21,8 +21,9 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
<button <button
type="button" type="button"
onClick={onToggle} onClick={onToggle}
className={`btn ${isEditMode ? 'btn-primary' : 'btn-subtle'}`} className={`btn ${isEditMode ? 'btn-primary' : 'btn-appbar btn-icon'}`}
aria-pressed={isEditMode} aria-pressed={isEditMode}
aria-label={isEditMode ? undefined : t('editShort')}
title={isEditMode ? t('saveChanges') : t('editMode')} title={isEditMode ? t('saveChanges') : t('editMode')}
> >
{isEditMode ? ( {isEditMode ? (
@@ -60,7 +61,7 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /> <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg> </svg>
)} )}
{isEditMode ? t('done') : t('editShort')} {isEditMode ? t('done') : null}
</button> </button>
); );
} }
@@ -4,3 +4,11 @@
* zeichnen per `createPortal` hinein (heute: die Dashboard-Reiter). * zeichnen per `createPortal` hinein (heute: die Dashboard-Reiter).
*/ */
export const HEADER_CENTER_SLOT_ID = 'header-center-slot'; export const HEADER_CENTER_SLOT_ID = 'header-center-slot';
/**
* Einhaengepunkt rechts in der Kopfzeile, vor „Fehler melden“ (quick-260928-vxe).
* Das Dashboard zeichnet dort seine Aktionen hinein (Bearbeiten, im
* Bearbeitungsmodus Hintergrund, Widget hinzufuegen, Fertig) — so belegen sie
* keine eigene Zeile ueber den Widgets.
*/
export const HEADER_ACTIONS_SLOT_ID = 'header-actions-slot';
+5 -1
View File
@@ -10,7 +10,7 @@ import { fetchSessionState, logout } from '@/lib/auth-actions';
import { buildNextParam } from '@/lib/safe-next'; import { buildNextParam } from '@/lib/safe-next';
import { ThemeToggle } from '@/components/theme-toggle'; import { ThemeToggle } from '@/components/theme-toggle';
import { BugReportButton } from '@/components/bug-report/bug-report-button'; import { BugReportButton } from '@/components/bug-report/bug-report-button';
import { HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot'; import { HEADER_ACTIONS_SLOT_ID, HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { resolvePageTitle, useNavStore } from '@/lib/stores/nav-store'; import { resolvePageTitle, useNavStore } from '@/lib/stores/nav-store';
@@ -169,6 +169,10 @@ export function Header() {
{/* Right: Actions */} {/* Right: Actions */}
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{/* Einhaengepunkt fuer die Dashboard-Aktionen (quick-260928-vxe);
bleibt im React-Baum der Kopfzeile leer, `empty:hidden` nimmt ihm
auf anderen Seiten den Abstand. */}
<div id={HEADER_ACTIONS_SLOT_ID} className="flex items-center gap-1 empty:hidden" />
<BugReportButton /> <BugReportButton />
<ThemeToggle /> <ThemeToggle />