feat(web): Widget-Titel je Widget ausblenden, Seitenleiste gegliedert

Neuer Schalter im Bearbeitungsmodus (neben dem Griff) setzt hideTitle in
der Widget-Konfiguration (setWidgetConfig im Dashboard-Store, optimistisch
mit Ruecksetzen); in der Ansicht entfaellt die Titelzeile per
data-hide-title, Aktionen der Titelzeile bleiben oben rechts. Kategorien der
Seitenleiste 14 px, Moduleintraege 13 px / 32 px hoch. Version 1.5.2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 23:21:26 +02:00
parent bdaa3c8db5
commit 5284472815
8 changed files with 163 additions and 4 deletions
+10
View File
@@ -4,6 +4,16 @@ 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 ## 1.5.1 – 2026-09-28
### Geändert ### Geändert
+20
View File
@@ -474,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 {
@@ -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) => {
+9 -3
View File
@@ -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 {
+2
View File
@@ -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",
+2
View File
@@ -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",