Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12eea333ba | |||
| bdaa3c8db5 |
@@ -4,6 +4,22 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
|||||||
|
|
||||||
## Unveröffentlicht
|
## Unveröffentlicht
|
||||||
|
|
||||||
|
## 1.5.2 – 2026-09-28
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- Dashboard: Den Titel eines Widgets können Sie jetzt ausblenden. Im Bearbeitungsmodus sitzt dafür oben in der Mitte jedes Widgets mit Titel ein kleines „T“; ein Klick blendet den Titel aus, ein zweiter wieder ein. Die Einstellung gilt je Widget und bleibt gespeichert. Im Bearbeitungsmodus sehen Sie den Titel weiterhin, damit Sie ihn ändern können; Knöpfe wie der Stift der Notiz bleiben auch ohne Titel oben rechts erreichbar.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Seitenleiste: Die Kategorien (etwa „Fuhrpark“ oder „Infrastruktur“) sind etwas größer beschriftet, die Module darunter etwas kleiner – so ist die Gliederung auf einen Blick erkennbar.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -461,6 +474,26 @@ input[type="checkbox"], input[type="radio"] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Titel ausgeblendet (quick-260928-wtt, `hideTitle` in der Widget-
|
||||||
|
Konfiguration, nur in der Ansicht): die Titelzeile entfaellt. Traegt sie
|
||||||
|
Aktionen (Notiz-Stift, XFrame „In neuem Tab“), bleiben nur diese als
|
||||||
|
kleine Schwebeleiste oben rechts. */
|
||||||
|
[data-hide-title] .widget-head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
[data-hide-title] .widget-head:has(.widget-action) {
|
||||||
|
display: flex;
|
||||||
|
position: absolute;
|
||||||
|
top: 0.25rem;
|
||||||
|
right: 0.25rem;
|
||||||
|
z-index: 5;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
[data-hide-title] .widget-head:has(.widget-action) > :not(.widget-action) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Beim Laden blenden die Kacheln gestaffelt ein (je 80 ms, --enter-delay). */
|
/* Beim Laden blenden die Kacheln gestaffelt ein (je 80 ms, --enter-delay). */
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
.widget-card {
|
.widget-card {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
// quick-260916-iex: Link-Widget entfernt — unbekannte Widget-Typen (z. B.
|
// quick-260916-iex: Link-Widget entfernt — unbekannte Widget-Typen (z. B.
|
||||||
@@ -14,6 +14,12 @@ vi.mock('next-intl', () => ({
|
|||||||
: key,
|
: key,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const setWidgetConfig = vi.fn();
|
||||||
|
vi.mock('@/lib/stores/dashboard-store', () => ({
|
||||||
|
useDashboardStore: (selector: (state: { setWidgetConfig: typeof setWidgetConfig }) => unknown) =>
|
||||||
|
selector({ setWidgetConfig }),
|
||||||
|
}));
|
||||||
|
|
||||||
import { WidgetWrapper } from './widget-wrapper';
|
import { WidgetWrapper } from './widget-wrapper';
|
||||||
|
|
||||||
describe('WidgetWrapper', () => {
|
describe('WidgetWrapper', () => {
|
||||||
@@ -54,4 +60,53 @@ describe('WidgetWrapper', () => {
|
|||||||
),
|
),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// quick-260928-wtt: Titel je Widget ein-/ausblenden
|
||||||
|
it('Bearbeitungsmodus: Schalter blendet den Titel aus und speichert hideTitle', () => {
|
||||||
|
setWidgetConfig.mockClear();
|
||||||
|
render(
|
||||||
|
<WidgetWrapper
|
||||||
|
widget={{ id: 'w-uhr', widgetType: 'clock', config: {} }}
|
||||||
|
isEditMode={true}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'hideTitle' }));
|
||||||
|
expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: true });
|
||||||
|
// im Bearbeitungsmodus bleibt der Titel sichtbar
|
||||||
|
expect(screen.getByRole('article')).not.toHaveAttribute('data-hide-title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ausgeblendeter Titel: Schalter blendet wieder ein, Ansicht markiert die Karte', () => {
|
||||||
|
setWidgetConfig.mockClear();
|
||||||
|
const { rerender } = render(
|
||||||
|
<WidgetWrapper
|
||||||
|
widget={{ id: 'w-uhr', widgetType: 'clock', config: { hideTitle: true } }}
|
||||||
|
isEditMode={true}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'showTitle' }));
|
||||||
|
expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: false });
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<WidgetWrapper
|
||||||
|
widget={{ id: 'w-uhr', widgetType: 'clock', config: { hideTitle: true } }}
|
||||||
|
isEditMode={false}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('article')).toHaveAttribute('data-hide-title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Widgets ohne Titelzeile (Suche) bekommen keinen Schalter', () => {
|
||||||
|
render(
|
||||||
|
<WidgetWrapper
|
||||||
|
widget={{ id: 'w-suche', widgetType: 'search', config: {} }}
|
||||||
|
isEditMode={true}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole('button', { name: 'hideTitle' })).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
|
import { useDashboardStore } from '@/lib/stores/dashboard-store';
|
||||||
import { WIDGET_REGISTRY, type WidgetType } from '../widget-registry';
|
import { WIDGET_REGISTRY, type WidgetType } from '../widget-registry';
|
||||||
import { WidgetIcon } from './widget-icon';
|
import { WidgetIcon } from './widget-icon';
|
||||||
|
|
||||||
@@ -14,6 +15,14 @@ import { WidgetIcon } from './widget-icon';
|
|||||||
*/
|
*/
|
||||||
const FRAME_HEADER_TYPES = new Set(['calendar', 'clock', 'calculator', 'stopwatch']);
|
const FRAME_HEADER_TYPES = new Set(['calendar', 'clock', 'calculator', 'stopwatch']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kacheltypen mit Titelzeile — nur diese bekommen im Bearbeitungsmodus den
|
||||||
|
* Schalter „Titel ausblenden“ (quick-260928-wtt). Die Wahl steht als
|
||||||
|
* `hideTitle: true` in der Widget-Konfiguration und gilt nur in der Ansicht;
|
||||||
|
* im Bearbeitungsmodus bleibt der Titel sichtbar (und editierbar).
|
||||||
|
*/
|
||||||
|
const TITLE_TYPES = new Set([...FRAME_HEADER_TYPES, 'note', 'favorites', 'proxmox', 'xframe']);
|
||||||
|
|
||||||
interface WidgetWrapperProps {
|
interface WidgetWrapperProps {
|
||||||
widget: {
|
widget: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -42,6 +51,9 @@ export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }:
|
|||||||
const t = useTranslations('widgets');
|
const t = useTranslations('widgets');
|
||||||
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
|
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
|
||||||
const WidgetComponent = definition?.component;
|
const WidgetComponent = definition?.component;
|
||||||
|
const setWidgetConfig = useDashboardStore((state) => state.setWidgetConfig);
|
||||||
|
const hasTitle = TITLE_TYPES.has(widget.widgetType);
|
||||||
|
const titleHidden = widget.config?.hideTitle === true;
|
||||||
|
|
||||||
// Design „Mosaik“: in der Ansicht eine ruhige Fluent-Karte (hell Schatten,
|
// Design „Mosaik“: in der Ansicht eine ruhige Fluent-Karte (hell Schatten,
|
||||||
// dunkel Haarlinie), die Akzent-Umrandung nur im Bearbeitungsmodus.
|
// dunkel Haarlinie), die Akzent-Umrandung nur im Bearbeitungsmodus.
|
||||||
@@ -54,6 +66,7 @@ export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }:
|
|||||||
className={cardClassName}
|
className={cardClassName}
|
||||||
style={{ '--enter-delay': `${Math.min(enterIndex, 8) * 80}ms` } as CSSProperties}
|
style={{ '--enter-delay': `${Math.min(enterIndex, 8) * 80}ms` } as CSSProperties}
|
||||||
data-widget-id={widget.id}
|
data-widget-id={widget.id}
|
||||||
|
data-hide-title={!isEditMode && hasTitle && titleHidden ? '' : undefined}
|
||||||
aria-label={definition ? t(definition.nameKey) : widget.widgetType}
|
aria-label={definition ? t(definition.nameKey) : widget.widgetType}
|
||||||
>
|
>
|
||||||
{/* Edit mode: Overlay-Kopfleiste (Griff-Hinweis) mit Loesch-Knopf rechts.
|
{/* Edit mode: Overlay-Kopfleiste (Griff-Hinweis) mit Loesch-Knopf rechts.
|
||||||
@@ -81,6 +94,39 @@ export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }:
|
|||||||
<circle cx="12" cy="15" r="1.5" />
|
<circle cx="12" cy="15" r="1.5" />
|
||||||
<circle cx="19" cy="15" r="1.5" />
|
<circle cx="19" cy="15" r="1.5" />
|
||||||
</svg>
|
</svg>
|
||||||
|
{hasTitle && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void setWidgetConfig(widget.id, { hideTitle: !titleHidden });
|
||||||
|
}}
|
||||||
|
className={`ml-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-card transition-colors hover:bg-muted hover:text-foreground ${titleHidden ? 'text-muted-foreground/60' : 'text-muted-foreground'}`}
|
||||||
|
aria-label={titleHidden ? t('showTitle') : t('hideTitle')}
|
||||||
|
title={titleHidden ? t('showTitle') : t('hideTitle')}
|
||||||
|
aria-pressed={titleHidden}
|
||||||
|
data-no-drag=""
|
||||||
|
>
|
||||||
|
{/* „T“ mit Querstrich, solange der Titel ausgeblendet ist */}
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="4 7 4 4 20 4 20 7" />
|
||||||
|
<line x1="9" y1="20" x2="15" y2="20" />
|
||||||
|
<line x1="12" y1="4" x2="12" y2="20" />
|
||||||
|
{titleHidden && <line x1="3" y1="3" x2="21" y2="21" />}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ export function Sidebar() {
|
|||||||
|
|
||||||
const linkClass = (href: string) => itemClass(isActive(href));
|
const linkClass = (href: string) => itemClass(isActive(href));
|
||||||
|
|
||||||
|
// Moduleintraege unter einer Kategorie eine Stufe kleiner als die
|
||||||
|
// Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift;
|
||||||
|
// die 20-px-Modulkachel passt weiter hinein.
|
||||||
|
const moduleItemClass = (active: boolean) =>
|
||||||
|
itemClass(active).replace('h-9', 'h-8').replace('gap-3', 'gap-2.5').replace('text-sm', 'text-[13px]');
|
||||||
|
|
||||||
const fetchActiveModules = useCallback(async () => {
|
const fetchActiveModules = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/modules/active`, {
|
const res = await fetch(`${API_URL}/modules/active`, {
|
||||||
@@ -205,10 +211,10 @@ export function Sidebar() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleCategory(category)}
|
onClick={() => toggleCategory(category)}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className="group flex h-7 w-full items-center gap-1.5 rounded-md px-2.5 text-xs font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
className="group flex h-8 w-full items-center gap-1.5 rounded-md px-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<span className="truncate">{categoryLabel(category)}</span>
|
<span className="truncate">{categoryLabel(category)}</span>
|
||||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}>
|
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}>
|
||||||
<polyline points="6 9 12 15 18 9" />
|
<polyline points="6 9 12 15 18 9" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -219,7 +225,7 @@ export function Sidebar() {
|
|||||||
const active = isActive(href) || isActive(`/modules/${mod.slug}`);
|
const active = isActive(href) || isActive(`/modules/${mod.slug}`);
|
||||||
return (
|
return (
|
||||||
<li key={mod.id}>
|
<li key={mod.id}>
|
||||||
<Link href={href} className={itemClass(active)}>
|
<Link href={href} className={moduleItemClass(active)}>
|
||||||
{active && <SelectionPill />}
|
{active && <SelectionPill />}
|
||||||
<ModuleTile slug={mod.slug} size={20} tone={active ? 'accent' : 'neutral'} />
|
<ModuleTile slug={mod.slug} size={20} tone={active ? 'accent' : 'neutral'} />
|
||||||
<span className="truncate">{mod.name}</span>
|
<span className="truncate">{mod.name}</span>
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ interface DashboardState {
|
|||||||
updateLayouts: (layouts: Record<string, unknown>) => void;
|
updateLayouts: (layouts: Record<string, unknown>) => void;
|
||||||
addWidget: (widgetType: WidgetType) => Promise<void>;
|
addWidget: (widgetType: WidgetType) => Promise<void>;
|
||||||
removeWidget: (id: string) => Promise<void>;
|
removeWidget: (id: string) => Promise<void>;
|
||||||
|
/** Teil-Konfiguration eines Widgets setzen — sofort sichtbar, bei Fehler zurueck. */
|
||||||
|
setWidgetConfig: (id: string, partial: Record<string, unknown>) => Promise<void>;
|
||||||
loadDashboard: () => Promise<void>;
|
loadDashboard: () => Promise<void>;
|
||||||
saveLayout: () => Promise<void>;
|
saveLayout: () => Promise<void>;
|
||||||
selectDashboard: (id: string) => Promise<void>;
|
selectDashboard: (id: string) => Promise<void>;
|
||||||
@@ -159,6 +161,22 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setWidgetConfig: async (id: string, partial: Record<string, unknown>) => {
|
||||||
|
const before = get().widgets.find((w) => w.id === id)?.config;
|
||||||
|
if (!before) return;
|
||||||
|
const patch = (config: Record<string, unknown>) =>
|
||||||
|
set((state) => ({
|
||||||
|
widgets: state.widgets.map((w) => (w.id === id ? { ...w, config } : w)),
|
||||||
|
}));
|
||||||
|
patch({ ...before, ...partial });
|
||||||
|
try {
|
||||||
|
await api.updateWidgetConfig(id, partial);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update widget config:', err);
|
||||||
|
patch(before);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
loadDashboard: async () => {
|
loadDashboard: async () => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -236,6 +236,8 @@
|
|||||||
"catalogTitle": "Widget hinzufügen",
|
"catalogTitle": "Widget hinzufügen",
|
||||||
"catalogClose": "Dialog schließen",
|
"catalogClose": "Dialog schließen",
|
||||||
"deleteTooltip": "Widget entfernen",
|
"deleteTooltip": "Widget entfernen",
|
||||||
|
"hideTitle": "Titel ausblenden",
|
||||||
|
"showTitle": "Titel einblenden",
|
||||||
"dragHint": "Ziehen Sie die Kachel, um sie zu verschieben",
|
"dragHint": "Ziehen Sie die Kachel, um sie zu verschieben",
|
||||||
"editMode": "Dashboard bearbeiten",
|
"editMode": "Dashboard bearbeiten",
|
||||||
"saveChanges": "Änderungen speichern",
|
"saveChanges": "Änderungen speichern",
|
||||||
|
|||||||
@@ -236,6 +236,8 @@
|
|||||||
"catalogTitle": "Add widget",
|
"catalogTitle": "Add widget",
|
||||||
"catalogClose": "Close dialog",
|
"catalogClose": "Close dialog",
|
||||||
"deleteTooltip": "Remove widget",
|
"deleteTooltip": "Remove widget",
|
||||||
|
"hideTitle": "Hide title",
|
||||||
|
"showTitle": "Show title",
|
||||||
"dragHint": "Drag the tile to move it",
|
"dragHint": "Drag the tile to move it",
|
||||||
"editMode": "Edit dashboard",
|
"editMode": "Edit dashboard",
|
||||||
"saveChanges": "Save changes",
|
"saveChanges": "Save changes",
|
||||||
|
|||||||
Reference in New Issue
Block a user