feat(web): Dashboard mit Begruessung, Befehlsleiste und ruhigeren Kacheln
Begruessung nach Tageszeit mit Datum, Befehlsleiste oben rechts statt schwebendem Knopf, einheitliche Kachel-Kopfzeilen mit Symbol, Kalender mit Pfeilknoepfen, runden Tagen und Terminpunkten, Favoriten als 36-px-Zeilen bzw. App-Starter-Kacheln, Notiz-Kaestchen im Akzent, leerer Zustand mit drei Vorschlaegen. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,12 +11,16 @@ const messages: Record<string, Record<string, string>> = {
|
||||
editMode: 'Dashboard bearbeiten',
|
||||
saveChanges: 'Änderungen speichern',
|
||||
addWidget: 'Widget hinzufügen',
|
||||
editShort: 'Bearbeiten',
|
||||
done: 'Fertig',
|
||||
toolbarLabel: 'Dashboard-Aktionen',
|
||||
layoutLoadError: 'Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.',
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (ns: string) => (key: string) => messages[ns]?.[key] ?? key,
|
||||
useLocale: () => 'de',
|
||||
}));
|
||||
|
||||
const mockStore = {
|
||||
@@ -106,45 +110,46 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('DashboardPage (quick-260916-dyv)', () => {
|
||||
it('Test 1: Ansichtsmodus — Stift in fester Leiste unten rechts, kein "Widget hinzufuegen", kein mt-8/top-2, Grid direkt im Container p-2', async () => {
|
||||
it('Test 1: Ansichtsmodus — Befehlsleiste rechts oben mit „Bearbeiten“, kein "Widget hinzufuegen", Begruessung, Grid direkt im Container p-2 (Design „Mosaik“)', async () => {
|
||||
const { default: DashboardPage } = await import('./page');
|
||||
render(<DashboardPage />);
|
||||
|
||||
const toggle = screen.getByRole('button', { name: 'Dashboard bearbeiten' });
|
||||
const bar = toggle.parentElement as HTMLElement;
|
||||
expect(bar.classList.contains('fixed')).toBe(true);
|
||||
expect(bar.classList.contains('bottom-6')).toBe(true);
|
||||
expect(bar.classList.contains('right-6')).toBe(true);
|
||||
expect(bar.classList.contains('z-20')).toBe(true);
|
||||
const toolbar = screen.getByRole('toolbar', { name: 'Dashboard-Aktionen' });
|
||||
const toggle = screen.getByRole('button', { name: 'Bearbeiten' });
|
||||
expect(toggle.parentElement).toBe(toolbar);
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
// kein schwebender Knopf mehr unten rechts
|
||||
expect(toolbar.classList.contains('fixed')).toBe(false);
|
||||
expect(document.querySelector('.bottom-6')).toBeNull();
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Widget hinzufügen' })).toBeNull();
|
||||
expect(document.querySelector('.mt-8')).toBeNull();
|
||||
expect(document.querySelector('.top-2')).toBeNull();
|
||||
await waitFor(() => expect(screen.getByTestId('dashboard-greeting')).toBeInTheDocument());
|
||||
|
||||
const grid = screen.getByTestId('dashboard-grid');
|
||||
expect((grid.parentElement as HTMLElement).classList.contains('p-2')).toBe(true);
|
||||
expect(grid.getAttribute('data-edit')).toBe('false');
|
||||
});
|
||||
|
||||
it('Test 2: Bearbeitungsmodus — "Widget hinzufuegen" links neben dem Haekchen in derselben Leiste, Grid im Bearbeitungsmodus', async () => {
|
||||
it('Test 2: Bearbeitungsmodus — "Widget hinzufuegen" links neben „Fertig“ in derselben Leiste, Grid im Bearbeitungsmodus', async () => {
|
||||
mockStore.isEditMode = true;
|
||||
const { default: DashboardPage } = await import('./page');
|
||||
render(<DashboardPage />);
|
||||
|
||||
const toggle = screen.getByRole('button', { name: 'Änderungen speichern' });
|
||||
const toggle = screen.getByRole('button', { name: 'Fertig' });
|
||||
const add = screen.getByRole('button', { name: 'Widget hinzufügen' });
|
||||
expect(add.parentElement).toBe(toggle.parentElement);
|
||||
// "Widget hinzufuegen" steht im DOM VOR dem Umschalter (links daneben).
|
||||
expect(add.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
|
||||
expect(screen.getByTestId('dashboard-grid').getAttribute('data-edit')).toBe('true');
|
||||
});
|
||||
|
||||
it('Test 3: Umschalten — Klick auf "Dashboard bearbeiten" ruft setEditMode(true) genau einmal', async () => {
|
||||
it('Test 3: Umschalten — Klick auf "Bearbeiten" ruft setEditMode(true) genau einmal', async () => {
|
||||
const { default: DashboardPage } = await import('./page');
|
||||
render(<DashboardPage />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dashboard bearbeiten' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Bearbeiten' }));
|
||||
expect(mockStore.setEditMode).toHaveBeenCalledTimes(1);
|
||||
expect(mockStore.setEditMode).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
|
||||
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
||||
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
|
||||
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
||||
import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting';
|
||||
import { BackgroundPicker, DashboardBackdrop, useDashboardBackground } from '@/components/dashboard/dashboard-background';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
|
||||
@@ -138,32 +139,25 @@ export default function DashboardPage() {
|
||||
onReorder={reorderDashboards}
|
||||
/>
|
||||
|
||||
{isSwitchingDashboard ? (
|
||||
<div className="flex min-h-[40vh] items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">{t('tabs.switching')}</p>
|
||||
{/* Kopfzeile des Dashboards (Design „Mosaik“, Runde 2): Begruessung
|
||||
links, Befehlsleiste rechts (Fluent „command bar“) statt des
|
||||
frueheren schwebenden Knopfs unten rechts. Mit Hintergrund liegen
|
||||
beide auf der durchscheinenden Kachelflaeche (.dashboard-chrome). */}
|
||||
<div className="flex flex-wrap items-end justify-between gap-x-4 gap-y-2 px-3 pb-1 pt-2">
|
||||
<div className="dashboard-chrome min-w-0 rounded-lg">
|
||||
<DashboardGreeting />
|
||||
</div>
|
||||
) : (
|
||||
/* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */
|
||||
<DashboardGrid
|
||||
layouts={layouts}
|
||||
widgets={widgets}
|
||||
isEditMode={isEditMode}
|
||||
onLayoutChange={updateLayouts}
|
||||
onRemoveWidget={removeWidget}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Feste Aktionsleiste unten rechts.
|
||||
quick-260916-dyv: Umschalter unten rechts statt oben rechts, damit das
|
||||
Grid direkt unter der Kopfzeile beginnt (12 + 8 + 8 = 28 px statt 60 px).
|
||||
Im Bearbeitungsmodus steht "Widget hinzufuegen" links neben dem Haekchen. */}
|
||||
<div className="fixed bottom-6 right-6 z-20 flex items-center gap-2">
|
||||
<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-primary shadow-lg"
|
||||
className="btn btn-subtle"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
@@ -188,6 +182,24 @@ export default function DashboardPage() {
|
||||
onToggle={() => setEditMode(!isEditMode)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSwitchingDashboard ? (
|
||||
<div className="flex min-h-[40vh] items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">{t('tabs.switching')}</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */
|
||||
<DashboardGrid
|
||||
layouts={layouts}
|
||||
widgets={widgets}
|
||||
isEditMode={isEditMode}
|
||||
onLayoutChange={updateLayouts}
|
||||
onRemoveWidget={removeWidget}
|
||||
onOpenCatalog={() => setCatalogOpen(true)}
|
||||
onQuickAdd={(type) => addWidget(type)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Widget catalog modal */}
|
||||
<WidgetCatalogModal
|
||||
|
||||
@@ -58,6 +58,8 @@
|
||||
--color-status-orphan-fg: var(--status-orphan-fg);
|
||||
--color-well: var(--well);
|
||||
--color-link: var(--link);
|
||||
--color-tile: var(--tile);
|
||||
--color-tile-foreground: var(--tile-foreground);
|
||||
--color-primary-strong: var(--primary-strong);
|
||||
--color-input-strong: var(--input-strong);
|
||||
--color-appbar: var(--appbar);
|
||||
@@ -118,6 +120,9 @@
|
||||
--ring: oklch(0.3 0.01 260);
|
||||
/* Verweise: nie gelbe Schrift auf Weiss. 5,9:1 auf --card. */
|
||||
--link: oklch(0.50 0.15 255);
|
||||
/* Neutrale Modul-Kachel (Runde 2: einfarbig statt bunt). */
|
||||
--tile: oklch(0.93 0.004 260);
|
||||
--tile-foreground: oklch(0.35 0.01 260);
|
||||
/* Akzent fuer duenne Markierungen (Auswahlpille, Reiter-Unterstrich) auf
|
||||
hellem Grund: die Akzentfarbe um knapp ein Drittel abgedunkelt, damit
|
||||
auch Gelb neben Hellgrau noch erkennbar bleibt. */
|
||||
@@ -177,6 +182,8 @@
|
||||
--input-strong: oklch(1 0 0 / 0.42);
|
||||
--ring: oklch(0.92 0.005 260);
|
||||
--link: oklch(0.78 0.11 250);
|
||||
--tile: oklch(0.32 0.01 260);
|
||||
--tile-foreground: oklch(0.88 0.005 260);
|
||||
--primary-strong: var(--primary);
|
||||
|
||||
--appbar: oklch(0.18 0.01 260);
|
||||
@@ -502,3 +509,41 @@ input[type="checkbox"], input[type="radio"] {
|
||||
.dashboard-has-bg [data-widget-id] {
|
||||
box-shadow: 0 0 2px oklch(0 0 0 / 0.14), 0 2px 6px oklch(0 0 0 / 0.16);
|
||||
}
|
||||
|
||||
/* Begruessung, Befehlsleiste und leerer Zustand: ohne Hintergrund frei auf
|
||||
der Arbeitsflaeche, mit Hintergrund auf derselben Kachelflaeche wie die
|
||||
Widgets (deckend als Rueckfall, durchscheinend wo moeglich). */
|
||||
.dashboard-has-bg .dashboard-chrome {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background-color: var(--card);
|
||||
box-shadow: 0 0 2px oklch(0 0 0 / 0.14), 0 2px 6px oklch(0 0 0 / 0.16);
|
||||
}
|
||||
.dashboard-has-bg [role="toolbar"].dashboard-chrome {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
@media not (prefers-reduced-transparency: reduce) {
|
||||
.dashboard-has-bg .dashboard-chrome {
|
||||
background-color: oklch(1 0 0 / 0.86);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.2);
|
||||
backdrop-filter: blur(20px) saturate(1.2);
|
||||
}
|
||||
.dark .dashboard-has-bg .dashboard-chrome {
|
||||
background-color: oklch(0.24 0.01 260 / 0.88);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Notiz-Checklisten: Kaestchen in der Akzentfarbe; der Browser waehlt den
|
||||
Haken selbst hell oder dunkel passend zur Akzentfarbe. */
|
||||
:root .widget-note-body input[type="checkbox"] {
|
||||
accent-color: var(--primary);
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin: 0 0.5rem 0 0;
|
||||
vertical-align: -0.15em;
|
||||
}
|
||||
:root .widget-note-body .task-list-item {
|
||||
padding-block: 0.125rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
className="btn btn-secondary shadow-lg"
|
||||
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">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
@@ -199,7 +199,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={t('title')}
|
||||
className="absolute bottom-full right-0 z-30 mb-2 w-[min(22rem,calc(100vw-2rem))] rounded-lg bg-card p-4 text-card-foreground shadow-xl dark:border dark:border-border"
|
||||
className="absolute right-0 top-full z-30 mt-2 w-[min(22rem,calc(100vw-2rem))] rounded-lg bg-card p-4 text-card-foreground shadow-xl dark:border dark:border-border"
|
||||
>
|
||||
<h2 className="text-sm font-semibold">{t('title')}</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{t('hint')}</p>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
|
||||
/** Tageszeit fuer die Begruessung: 5–10 Uhr Morgen, 11–17 Uhr Tag, sonst Abend. */
|
||||
export function greetingPeriod(date: Date): 'morning' | 'day' | 'evening' {
|
||||
const hour = date.getHours();
|
||||
if (hour >= 5 && hour < 11) return 'morning';
|
||||
if (hour >= 11 && hour < 18) return 'day';
|
||||
return 'evening';
|
||||
}
|
||||
|
||||
/** Vorname aus dem Anzeigenamen (erstes Wort), sonst der Benutzername. */
|
||||
export function greetingName(displayName: string | null | undefined, username: string | undefined): string {
|
||||
const first = displayName?.trim().split(/\s+/)[0];
|
||||
return first || username || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruhige Begruessung ueber dem Raster (Design „Mosaik“, Runde 2): „Guten
|
||||
* Morgen, Max“ plus Wochentag und Datum. Keine Kennzahlen. Die Uhrzeit wird
|
||||
* erst im Browser gelesen (kein Unterschied zwischen Server und Browser) und
|
||||
* jede Minute aufgefrischt.
|
||||
*/
|
||||
export function DashboardGreeting() {
|
||||
const t = useTranslations('dashboard.greeting');
|
||||
const locale = useLocale();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [now, setNow] = useState<Date | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setNow(new Date());
|
||||
const id = window.setInterval(() => setNow(new Date()), 60_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
if (!now) return <div className="h-14" aria-hidden="true" />;
|
||||
|
||||
const name = greetingName(user?.displayName, user?.username);
|
||||
const period = greetingPeriod(now);
|
||||
const dateLine = new Intl.DateTimeFormat(locale === 'en' ? 'en-GB' : 'de-DE', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
}).format(now);
|
||||
|
||||
return (
|
||||
<div data-testid="dashboard-greeting" className="min-w-0">
|
||||
<h1 className="truncate text-2xl font-semibold text-foreground">
|
||||
{name ? t(`${period}Named`, { name }) : t(period)}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">{dateLine}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Children, isValidElement } from 'react';
|
||||
import { act, cleanup, render, screen } from '@testing-library/react';
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { stubResizeObserver } from '@/test/fake-resize-observer';
|
||||
|
||||
@@ -13,6 +13,8 @@ vi.mock('next-intl', () => ({
|
||||
const map: Record<string, Record<string, string>> = {
|
||||
widgets: {
|
||||
emptyHeading: 'No active widgets',
|
||||
'emptyState.title': 'Your dashboard is still empty',
|
||||
'emptyState.body': 'Build your start page from tiles.',
|
||||
emptyBody: 'Click edit to add widgets to your dashboard.',
|
||||
addWidget: 'Add widget',
|
||||
catalogTitle: 'Add widget',
|
||||
@@ -104,7 +106,33 @@ describe('DashboardGrid', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('No active widgets')).toBeInTheDocument();
|
||||
expect(screen.getByText('Your dashboard is still empty')).toBeInTheDocument();
|
||||
// Ohne Rueckrufe keine Knoepfe.
|
||||
expect(screen.queryByRole('button', { name: 'Add widget' })).toBeNull();
|
||||
});
|
||||
|
||||
it('Design „Mosaik“: leerer Zustand bietet drei Vorschlaege und den Katalog an', async () => {
|
||||
const { DashboardGrid } = await import('./dashboard-grid');
|
||||
const onQuickAdd = vi.fn();
|
||||
const onOpenCatalog = vi.fn();
|
||||
render(
|
||||
<DashboardGrid
|
||||
layouts={{ lg: [], md: [], sm: [], xs: [], xxs: [] }}
|
||||
widgets={[]}
|
||||
isEditMode={false}
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
onQuickAdd={onQuickAdd}
|
||||
onOpenCatalog={onOpenCatalog}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Calendar' }));
|
||||
expect(onQuickAdd).toHaveBeenCalledWith('calendar');
|
||||
expect(screen.getByRole('button', { name: 'Clock' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Notes' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add widget' }));
|
||||
expect(onOpenCatalog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders widget instance when widgets are provided', async () => {
|
||||
@@ -409,7 +437,7 @@ describe('DashboardGrid', () => {
|
||||
);
|
||||
|
||||
// Leerzustand: der gemessene Knoten ist gar nicht eingehaengt.
|
||||
expect(screen.getByText('No active widgets')).toBeInTheDocument();
|
||||
expect(screen.getByText('Your dashboard is still empty')).toBeInTheDocument();
|
||||
expect(captured.props).toBeNull();
|
||||
|
||||
// Erste Kachel erscheint -> der Raster-<div> wird eingehaengt -> measureRef
|
||||
|
||||
@@ -6,9 +6,9 @@ import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { WIDGET_CONSTRAINTS, type WidgetType } from './widget-registry';
|
||||
import { WIDGET_CONSTRAINTS, WIDGET_REGISTRY, type WidgetType } from './widget-registry';
|
||||
import { WidgetIcon } from './widgets/widget-icon';
|
||||
import { WidgetWrapper } from './widgets/widget-wrapper';
|
||||
import { TesseraLogo } from '@/components/brand/tessera-logo';
|
||||
|
||||
// quick-260916-bwo: Raster doppelt so fein wie zuvor (24 statt 12 Spalten,
|
||||
// 20 statt 40 px Zeilenhoehe, 8 statt 16 px Abstand). Gespeicherte Anordnungen
|
||||
@@ -60,8 +60,15 @@ interface DashboardGridProps {
|
||||
isEditMode: boolean;
|
||||
onLayoutChange: (allLayouts: ResponsiveLayouts) => void;
|
||||
onRemoveWidget: (id: string) => void;
|
||||
/** Leerer Zustand: oeffnet den Widget-Katalog. */
|
||||
onOpenCatalog?: () => void;
|
||||
/** Leerer Zustand: fuegt eine vorgeschlagene Kachel direkt hinzu. */
|
||||
onQuickAdd?: (type: WidgetType) => void;
|
||||
}
|
||||
|
||||
/** Vorschlaege im leeren Zustand — Plattform-Kacheln, immer verfuegbar. */
|
||||
const QUICK_ADD_TYPES: WidgetType[] = ['clock', 'calendar', 'note'];
|
||||
|
||||
/**
|
||||
* quick-260916-dyv: Gespeicherte minW/minH aus den Konstanten ueberschreiben.
|
||||
*
|
||||
@@ -123,6 +130,8 @@ export function DashboardGrid({
|
||||
isEditMode,
|
||||
onLayoutChange,
|
||||
onRemoveWidget,
|
||||
onOpenCatalog,
|
||||
onQuickAdd,
|
||||
}: DashboardGridProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const [width, setWidth] = useState(1200);
|
||||
@@ -203,20 +212,46 @@ export function DashboardGrid({
|
||||
// stabil bleibt.
|
||||
const effectiveLayouts = useMemo(() => applyConstraintMinima(layouts, widgets), [layouts, widgets]);
|
||||
|
||||
// Empty state (D-02)
|
||||
// Leerer Zustand (D-02, Design „Mosaik“ Runde 2): freundlich ohne
|
||||
// Illustration — Titel, ein Satz, drei Vorschlaege zum direkten
|
||||
// Hinzufuegen und der Hauptknopf zum Katalog.
|
||||
if (widgets.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
|
||||
{/* Bildmarke statt des frueheren handgezeichneten Vier-Quadrate-Symbols.
|
||||
Ohne Rahmen und ohne Kontur: die Marke steht hier frei auf dem
|
||||
Seitenhintergrund, nicht auf einer eigenen Flaeche. */}
|
||||
<TesseraLogo variant="mark" size={64} plateOutline={false} />
|
||||
<h2 className="text-lg font-semibold text-muted-foreground">
|
||||
{t('emptyHeading')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('emptyBody')}
|
||||
</p>
|
||||
<div className="flex min-h-[55vh] flex-col items-center justify-center px-4 text-center">
|
||||
<div className="dashboard-chrome flex max-w-md flex-col items-center rounded-xl px-6 py-8">
|
||||
<span className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="9" rx="1.5" />
|
||||
<rect x="14" y="3" width="7" height="5" rx="1.5" />
|
||||
<rect x="14" y="12" width="7" height="9" rx="1.5" />
|
||||
<rect x="3" y="16" width="7" height="5" rx="1.5" />
|
||||
</svg>
|
||||
</span>
|
||||
<h2 className="text-base font-semibold text-foreground">{t('emptyState.title')}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('emptyState.body')}</p>
|
||||
{onQuickAdd && (
|
||||
<ul className="mt-4 flex flex-wrap justify-center gap-2">
|
||||
{QUICK_ADD_TYPES.map((type) => (
|
||||
<li key={type}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onQuickAdd(type)}
|
||||
className="inline-flex h-8 items-center gap-2 rounded-full border border-border bg-card px-3 text-sm text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<WidgetIcon type={type} />
|
||||
{t(WIDGET_REGISTRY[type].nameKey)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{onOpenCatalog && (
|
||||
<button type="button" onClick={onOpenCatalog} className="btn btn-primary mt-5">
|
||||
<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"><path d="M12 5v14M5 12h14" /></svg>
|
||||
{t('addWidget')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,9 +21,8 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={`btn btn-icon h-10 w-10 shadow-lg ${isEditMode ? 'btn-primary' : 'btn-secondary text-muted-foreground'}`}
|
||||
className={`btn ${isEditMode ? 'btn-primary' : 'btn-subtle'}`}
|
||||
aria-pressed={isEditMode}
|
||||
aria-label={isEditMode ? t('saveChanges') : t('editMode')}
|
||||
title={isEditMode ? t('saveChanges') : t('editMode')}
|
||||
>
|
||||
{isEditMode ? (
|
||||
@@ -31,8 +30,8 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -42,13 +41,14 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : (
|
||||
) : null}
|
||||
{isEditMode ? null : (
|
||||
// Pencil icon
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -60,6 +60,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" />
|
||||
</svg>
|
||||
)}
|
||||
{isEditMode ? t('done') : t('editShort')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ describe('CalendarWidget', () => {
|
||||
const badge = within(day20).getByTestId('calendar-day-count');
|
||||
expect(badge).toHaveTextContent('2');
|
||||
expect(badge).toHaveStyle({ backgroundColor: '#c44040' });
|
||||
expect(badge).toHaveClass('text-white');
|
||||
expect(badge).toHaveAttribute('data-count', '2');
|
||||
expect(badge).not.toHaveClass('bg-primary');
|
||||
|
||||
fireEvent.mouseEnter(day20);
|
||||
@@ -183,7 +183,7 @@ describe('CalendarWidget', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 3c: Plakette ohne Kalenderfarbe behaelt bg-primary', async () => {
|
||||
it('Test 3c: Terminpunkt ohne Kalenderfarbe traegt den Akzentton', async () => {
|
||||
mockFetchEvents.mockResolvedValue([
|
||||
ev('Ohne Farbe', new Date(2026, 6, 21, 9, 0), new Date(2026, 6, 21, 10, 0), {
|
||||
color: undefined,
|
||||
@@ -199,9 +199,9 @@ describe('CalendarWidget', () => {
|
||||
|
||||
const day21 = document.querySelector('[data-date="2026-07-21"]') as HTMLElement;
|
||||
const badge = within(day21).getByTestId('calendar-day-count');
|
||||
expect(badge).toHaveClass('bg-primary');
|
||||
expect(badge).toHaveClass('text-primary-foreground');
|
||||
expect(badge).not.toHaveClass('text-white');
|
||||
// Design „Mosaik“: Terminpunkt ohne Kalenderfarbe im abgedunkelten Akzent.
|
||||
expect(badge).toHaveClass('bg-primary-strong');
|
||||
expect(badge).toHaveAttribute('data-count', '1');
|
||||
expect((badge as HTMLElement).style.backgroundColor).toBe('');
|
||||
|
||||
fireEvent.mouseEnter(day21);
|
||||
@@ -240,9 +240,9 @@ describe('CalendarWidget', () => {
|
||||
expect(badges[1]).toHaveTextContent('2');
|
||||
expect(badges[1]).toHaveStyle({ backgroundColor: '#4060c4' });
|
||||
for (const badge of badges) {
|
||||
expect(badge).toHaveClass('text-white');
|
||||
expect(badge).toHaveClass('rounded-full');
|
||||
expect(badge).not.toHaveClass('bg-primary');
|
||||
expect(badge).toHaveClass('h-[clamp(8px,2.4cqw,12px)]');
|
||||
expect(badge).toHaveClass('h-1');
|
||||
}
|
||||
expect(within(day20).queryByTestId('calendar-day-count-rest')).toBeNull();
|
||||
expect(within(day20).getByTestId('calendar-day-badges').childElementCount).toBe(2);
|
||||
@@ -300,7 +300,7 @@ describe('CalendarWidget', () => {
|
||||
const restBadge = within(day21).getByTestId('calendar-day-count-rest');
|
||||
expect(restBadge).toHaveTextContent('4');
|
||||
expect(restBadge).toHaveClass('bg-muted-foreground');
|
||||
expect(restBadge).toHaveClass('text-background');
|
||||
expect(restBadge).toHaveAttribute('data-count', '4');
|
||||
expect((restBadge as HTMLElement).style.backgroundColor).toBe('');
|
||||
expect(restBadge).not.toHaveClass('text-white');
|
||||
});
|
||||
|
||||
@@ -243,21 +243,23 @@ export function CalendarWidget({ config }: WidgetProps) {
|
||||
const hoverEvents = hover ? (eventsByDate.get(hover.key) ?? []) : [];
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2 overflow-hidden p-3">
|
||||
<div className="flex h-full flex-col gap-2 overflow-hidden px-3 pb-3 pt-1">
|
||||
{showMonth && (
|
||||
<div data-testid="calendar-month" className="flex shrink-0 flex-col gap-1">
|
||||
{/* Nav-Zeile */}
|
||||
<div className="grid grid-cols-[1fr_1.4fr_1fr] gap-1">
|
||||
{/* Nav-Zeile: Pfeilknoepfe links/rechts, Monatsname mittig */}
|
||||
<div className="grid grid-cols-[auto_1fr_auto] items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="widgetNoDrag rounded-md px-1 py-[clamp(3px,0.9cqh,6px)] text-[clamp(10px,2.6cqw,13px)] leading-none text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label={t('calendar.monthPrev')}
|
||||
title={t('calendar.monthPrev')}
|
||||
className="widgetNoDrag flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={showPrev}
|
||||
>
|
||||
{t('calendar.monthPrev')}
|
||||
<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"><path d="m15 18-6-6 6-6" /></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="widgetNoDrag truncate rounded-md px-1 py-[clamp(3px,0.9cqh,6px)] text-[clamp(11px,2.8cqw,14px)] font-semibold leading-none text-foreground transition-colors hover:bg-muted"
|
||||
className="widgetNoDrag mx-auto truncate rounded-md px-2 py-1 text-sm font-semibold leading-5 text-foreground transition-colors hover:bg-muted"
|
||||
title={t('calendar.monthToday')}
|
||||
onClick={showToday}
|
||||
>
|
||||
@@ -265,31 +267,35 @@ export function CalendarWidget({ config }: WidgetProps) {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="widgetNoDrag rounded-md px-1 py-[clamp(3px,0.9cqh,6px)] text-[clamp(10px,2.6cqw,13px)] leading-none text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label={t('calendar.monthNext')}
|
||||
title={t('calendar.monthNext')}
|
||||
className="widgetNoDrag flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={showNext}
|
||||
>
|
||||
{t('calendar.monthNext')}
|
||||
<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"><path d="m9 18 6-6-6-6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Wochentagskopf */}
|
||||
<div className="grid grid-cols-7 gap-px text-center text-[clamp(9px,2.2cqw,12px)] font-medium text-muted-foreground">
|
||||
<div className="grid grid-cols-7 text-center text-[11px] font-medium leading-4 text-muted-foreground">
|
||||
{WEEKDAY_LABELS.map((label) => (
|
||||
<div key={label}>{label}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Raster */}
|
||||
<div className="grid grid-cols-7 gap-px">
|
||||
<div className="grid grid-cols-7 gap-y-0.5">
|
||||
{days.map((day) => {
|
||||
const hasEvents = day.events.length > 0;
|
||||
const badges = buildDayBadges(day.events);
|
||||
const single = badges.length === 1;
|
||||
// Tag als Kreis (Design „Mosaik“): heute gelb gefuellt, Termine
|
||||
// als kleine Punkte unter der Zahl — je Kalenderquelle einer,
|
||||
// in deren Farbe; ohne Farbe im Akzentton, Rest grau.
|
||||
const cellClass = [
|
||||
'widgetNoDrag relative flex min-h-[clamp(16px,5.5cqh,40px)] items-start rounded-md px-1 py-0.5 text-[clamp(9px,2.4cqw,13px)] leading-none',
|
||||
'widgetNoDrag relative mx-auto flex h-[clamp(20px,7cqw,28px)] w-[clamp(20px,7cqw,28px)] items-center justify-center rounded-full text-[clamp(11px,2.9cqw,13px)] leading-none tabular-nums',
|
||||
day.inCurrentMonth ? 'text-foreground' : 'text-muted-foreground/70',
|
||||
day.isToday ? 'bg-primary font-semibold text-primary-foreground' : '',
|
||||
hasEvents ? 'cursor-default hover:bg-muted' : '',
|
||||
hasEvents && !day.isToday ? 'cursor-default font-medium hover:bg-muted' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
@@ -298,23 +304,20 @@ export function CalendarWidget({ config }: WidgetProps) {
|
||||
<>
|
||||
<span>{day.date.getDate()}</span>
|
||||
{hasEvents && (
|
||||
// Wrapper traegt die Positionierung, die einzelnen Kreise nicht
|
||||
// mehr (mehrere Kreise stehen sonst uebereinander).
|
||||
<span
|
||||
data-testid="calendar-day-badges"
|
||||
className="absolute bottom-px right-px flex items-end gap-px"
|
||||
className="absolute bottom-[3px] left-1/2 flex -translate-x-1/2 items-center gap-[2px]"
|
||||
>
|
||||
{badges.map((badge) => {
|
||||
const badgeClass = [
|
||||
'flex items-center justify-center rounded-full px-0.5 font-semibold leading-none',
|
||||
single
|
||||
? 'h-[clamp(10px,3cqw,16px)] min-w-[clamp(10px,3cqw,16px)] text-[clamp(7px,1.8cqw,10px)]'
|
||||
: 'h-[clamp(8px,2.4cqw,12px)] min-w-[clamp(8px,2.4cqw,12px)] text-[clamp(6px,1.5cqw,8px)]',
|
||||
'block h-1 w-1 rounded-full',
|
||||
badge.rest
|
||||
? 'bg-muted-foreground text-background'
|
||||
? 'bg-muted-foreground'
|
||||
: badge.color
|
||||
? 'text-white'
|
||||
: 'bg-primary text-primary-foreground',
|
||||
? ''
|
||||
: day.isToday
|
||||
? 'bg-primary-foreground'
|
||||
: 'bg-primary-strong',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
@@ -322,10 +325,11 @@ export function CalendarWidget({ config }: WidgetProps) {
|
||||
<span
|
||||
key={badge.key}
|
||||
data-testid={badge.rest ? 'calendar-day-count-rest' : 'calendar-day-count'}
|
||||
data-count={badge.count}
|
||||
className={badgeClass}
|
||||
style={badge.color && !badge.rest ? { backgroundColor: badge.color } : undefined}
|
||||
>
|
||||
{badge.count}
|
||||
<span className="sr-only">{badge.count}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
@@ -360,7 +364,7 @@ export function CalendarWidget({ config }: WidgetProps) {
|
||||
data-date={day.key}
|
||||
data-today={day.isToday || undefined}
|
||||
data-outside={!day.inCurrentMonth || undefined}
|
||||
className={`${cellClass} w-full text-left`}
|
||||
className={cellClass}
|
||||
aria-label={ariaLabel}
|
||||
onMouseEnter={(e) =>
|
||||
setHover({ key: day.key, rect: e.currentTarget.getBoundingClientRect() })
|
||||
@@ -395,7 +399,7 @@ export function CalendarWidget({ config }: WidgetProps) {
|
||||
|
||||
{maxEvents > 0 && (
|
||||
<section className="flex min-h-0 flex-1 flex-col gap-1">
|
||||
<h3 className="shrink-0 text-[clamp(10px,2.6cqw,13px)] font-semibold text-foreground">
|
||||
<h3 className="shrink-0 text-xs font-semibold text-muted-foreground">
|
||||
{t('calendar.upcomingTitle')}
|
||||
</h3>
|
||||
{upcoming.length === 0 ? (
|
||||
@@ -403,24 +407,24 @@ export function CalendarWidget({ config }: WidgetProps) {
|
||||
{t('calendar.emptyNoEvents')}
|
||||
</p>
|
||||
) : (
|
||||
<ul data-testid="calendar-upcoming" className="min-h-0 flex-1 space-y-1 overflow-y-auto">
|
||||
<ul data-testid="calendar-upcoming" className="min-h-0 flex-1 space-y-0.5 overflow-y-auto">
|
||||
{upcoming.map((event) => (
|
||||
<li key={event.id} className="flex items-start gap-2 rounded-md px-2 py-1 hover:bg-muted">
|
||||
<li key={event.id} className="flex items-stretch gap-2.5 rounded-md px-1.5 py-1 hover:bg-muted">
|
||||
<span
|
||||
data-testid="event-color-dot"
|
||||
className="mt-1 h-2 w-2 shrink-0 rounded-full"
|
||||
className="w-[3px] shrink-0 rounded-full"
|
||||
style={{ backgroundColor: event.color || 'var(--muted-foreground)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[clamp(9px,2.2cqw,12px)] text-muted-foreground">
|
||||
<p className="truncate text-[11px] leading-4 text-muted-foreground">
|
||||
{formatEventDate(event)}
|
||||
</p>
|
||||
<p className="truncate text-[clamp(10px,2.5cqw,14px)] font-medium text-foreground">
|
||||
<p className="truncate text-[13px] font-medium leading-[18px] text-foreground">
|
||||
{event.title}
|
||||
</p>
|
||||
{event.location && (
|
||||
<p className="truncate text-[clamp(9px,2.1cqw,12px)] text-muted-foreground">
|
||||
<p className="truncate text-[11px] leading-4 text-muted-foreground">
|
||||
{event.location}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { WidgetIcon } from './widget-icon';
|
||||
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||
@@ -322,6 +323,7 @@ export function FavoritesWidget({
|
||||
{/* Header: optional title (quick-260916-iex) — mirrors note-widget.tsx look */}
|
||||
{showHeader && (
|
||||
<div className="widget-head">
|
||||
<WidgetIcon type="favorites" />
|
||||
{isEditMode ? (
|
||||
<input
|
||||
type="text"
|
||||
@@ -396,10 +398,11 @@ export function FavoritesWidget({
|
||||
viewMode === 'grid' ? (
|
||||
<div
|
||||
data-testid="favorites-grid"
|
||||
className="grid grid-cols-3 gap-2"
|
||||
className="grid grid-cols-[repeat(auto-fill,minmax(76px,1fr))] gap-1"
|
||||
>
|
||||
{sortedFavorites.map((fav, index) => (
|
||||
<FavoriteTile
|
||||
launcher
|
||||
key={fav.id}
|
||||
fav={fav}
|
||||
isEditMode={isEditMode}
|
||||
@@ -429,7 +432,7 @@ export function FavoritesWidget({
|
||||
) : (
|
||||
<div
|
||||
data-testid="favorites-list"
|
||||
className="flex flex-col gap-1"
|
||||
className="flex flex-col gap-0.5"
|
||||
>
|
||||
{sortedFavorites.map((fav, index) => (
|
||||
<FavoriteTile
|
||||
@@ -558,10 +561,16 @@ function getDirectFaviconSrc(url: string): string | null {
|
||||
function FavoriteIcon({
|
||||
fav,
|
||||
getFallbackLetter,
|
||||
large = false,
|
||||
}: {
|
||||
fav: FavoriteLink;
|
||||
getFallbackLetter: (title: string) => string;
|
||||
/** Kachelansicht: 40-px-Feld mit 24-px-Symbol, sonst 24-px-Feld mit 16-px-Symbol. */
|
||||
large?: boolean;
|
||||
}) {
|
||||
const box = large ? 'h-10 w-10 rounded-lg' : 'h-6 w-6 rounded-md';
|
||||
const img = large ? 'h-6 w-6' : 'h-4 w-4';
|
||||
const px = large ? 24 : 16;
|
||||
const hasServerIcon = Boolean(fav.iconUrl) || Boolean(fav.uploadedIconMime);
|
||||
const proxySrc = hasServerIcon
|
||||
? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon?v=${fav.iconVersion ?? 0}`
|
||||
@@ -572,10 +581,10 @@ function FavoriteIcon({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex-shrink-0 w-5 h-5">
|
||||
<div className={`relative flex shrink-0 items-center justify-center bg-muted ${box}`}>
|
||||
<span
|
||||
data-testid={`letter-fallback-${fav.id}`}
|
||||
className="absolute inset-0 flex items-center justify-center text-xs font-semibold bg-muted rounded"
|
||||
className={`absolute inset-0 flex items-center justify-center font-semibold text-muted-foreground ${large ? 'text-base' : 'text-xs'}`}
|
||||
>
|
||||
{getFallbackLetter(fav.title)}
|
||||
</span>
|
||||
@@ -585,10 +594,10 @@ function FavoriteIcon({
|
||||
src={proxySrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={20}
|
||||
height={20}
|
||||
width={px}
|
||||
height={px}
|
||||
loading="lazy"
|
||||
className="absolute inset-0 w-5 h-5 rounded"
|
||||
className={`relative rounded-sm ${img}`}
|
||||
onError={() => setStage('direct')}
|
||||
/>
|
||||
)}
|
||||
@@ -598,11 +607,11 @@ function FavoriteIcon({
|
||||
src={directSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={20}
|
||||
height={20}
|
||||
width={px}
|
||||
height={px}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="absolute inset-0 w-5 h-5 rounded"
|
||||
className={`relative rounded-sm ${img}`}
|
||||
onError={() => setStage('none')}
|
||||
/>
|
||||
)}
|
||||
@@ -638,6 +647,8 @@ type FavoriteTileProps = {
|
||||
canMoveDown: boolean;
|
||||
onMove: (id: string, direction: 'up' | 'down') => void;
|
||||
t: (key: string) => string;
|
||||
/** Kachelansicht wie ein App-Starter (Symbol oben, Name darunter). */
|
||||
launcher?: boolean;
|
||||
};
|
||||
|
||||
function FavoriteTile({
|
||||
@@ -663,19 +674,24 @@ function FavoriteTile({
|
||||
canMoveDown,
|
||||
onMove,
|
||||
t,
|
||||
launcher = false,
|
||||
}: FavoriteTileProps) {
|
||||
const isEditing = editingId === fav.id;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* Main link row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={launcher ? 'flex flex-col items-stretch gap-1' : 'flex items-center gap-2'}>
|
||||
{/* Anchor — opens in new tab, no server-side redirect (T-08-08) */}
|
||||
<a
|
||||
href={fav.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex h-8 min-w-0 flex-1 items-center gap-2.5 rounded-md px-1.5 transition-colors hover:bg-muted widgetNoDrag"
|
||||
className={
|
||||
launcher
|
||||
? 'flex min-w-0 flex-col items-center gap-1.5 rounded-lg px-1 py-2 text-center transition-colors hover:bg-muted widgetNoDrag'
|
||||
: 'flex h-9 min-w-0 flex-1 items-center gap-2.5 rounded-md px-1.5 transition-colors hover:bg-muted widgetNoDrag'
|
||||
}
|
||||
onClick={(e) => isEditMode && e.preventDefault()} // block navigation in edit mode
|
||||
>
|
||||
{/* Icon container with letter fallback (T-08-07 — no dangerouslySetInnerHTML) */}
|
||||
@@ -683,9 +699,12 @@ function FavoriteTile({
|
||||
key={`${fav.iconUrl ?? ''}|${fav.url}|${fav.iconVersion ?? 0}|${fav.uploadedIconMime ?? ''}`}
|
||||
fav={fav}
|
||||
getFallbackLetter={getFallbackLetter}
|
||||
large={launcher}
|
||||
/>
|
||||
|
||||
<span className="truncate text-sm">{fav.title}</span>
|
||||
<span className={launcher ? 'line-clamp-2 w-full break-words text-xs leading-4' : 'truncate text-sm'}>
|
||||
{fav.title}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{/* Action buttons (edit mode only, D-04) */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { WidgetIcon } from './widget-icon';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTheme } from 'next-themes';
|
||||
@@ -164,6 +165,7 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
|
||||
<div className="flex h-full flex-col overflow-hidden" data-color-mode={colorMode}>
|
||||
{/* Header: title + pencil toggle */}
|
||||
<div className="widget-head relative">
|
||||
<WidgetIcon type="note" />
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { WidgetIcon } from './widget-icon';
|
||||
import Link from 'next/link';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { type ChangeEvent, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -163,6 +164,7 @@ export function ProxmoxWidget({ instanceId, config, isEditMode }: WidgetProps) {
|
||||
if (isEditMode) {
|
||||
header = (
|
||||
<div className="widget-head">
|
||||
<WidgetIcon type="proxmox" />
|
||||
<input
|
||||
type="text"
|
||||
className="widgetNoDrag min-w-0 flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground"
|
||||
@@ -187,6 +189,7 @@ export function ProxmoxWidget({ instanceId, config, isEditMode }: WidgetProps) {
|
||||
} else if (trimmedTitle !== '') {
|
||||
header = (
|
||||
<div className="widget-head">
|
||||
<WidgetIcon type="proxmox" />
|
||||
<h2 className="truncate text-sm font-semibold text-foreground">{trimmedTitle}</h2>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Kleines einfarbiges Symbol je Kacheltyp fuer die Kachel-Kopfzeile
|
||||
* (Design „Mosaik“, Runde 2): 16 px, gedaempftes Grau, rein dekorativ.
|
||||
*/
|
||||
const PATHS: Record<string, ReactNode> = {
|
||||
calendar: (
|
||||
<>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" />
|
||||
</>
|
||||
),
|
||||
note: (
|
||||
<>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4M10 13h6M10 17h6M8 13h.01M8 17h.01" />
|
||||
</>
|
||||
),
|
||||
favorites: <path d="m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />,
|
||||
clock: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" />
|
||||
</>
|
||||
),
|
||||
search: (
|
||||
<>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.5-3.5" />
|
||||
</>
|
||||
),
|
||||
calculator: (
|
||||
<>
|
||||
<rect x="4" y="2" width="16" height="20" rx="2" />
|
||||
<path d="M8 6h8M16 14v4M8 10h.01M12 10h.01M16 10h.01M8 14h.01M12 14h.01M8 18h.01M12 18h.01" />
|
||||
</>
|
||||
),
|
||||
stopwatch: (
|
||||
<>
|
||||
<circle cx="12" cy="14" r="8" />
|
||||
<path d="M10 2h4M12 14l3-3M12 2v4" />
|
||||
</>
|
||||
),
|
||||
'picture-frame': (
|
||||
<>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21" />
|
||||
</>
|
||||
),
|
||||
xframe: (
|
||||
<>
|
||||
<rect x="2" y="3" width="20" height="18" rx="2" />
|
||||
<path d="M2 8h20M6 5.5h.01M9 5.5h.01" />
|
||||
</>
|
||||
),
|
||||
proxmox: (
|
||||
<>
|
||||
<rect x="2" y="2" width="20" height="8" rx="2" />
|
||||
<rect x="2" y="14" width="20" height="8" rx="2" />
|
||||
<path d="M6 6h.01M6 18h.01" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
export function WidgetIcon({ type }: { type: string }) {
|
||||
const paths = PATHS[type];
|
||||
if (!paths) return null;
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
data-widget-icon={type}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
>
|
||||
{paths}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { WIDGET_REGISTRY, type WidgetType } from '../widget-registry';
|
||||
import { WidgetIcon } from './widget-icon';
|
||||
|
||||
/**
|
||||
* Kacheltypen ohne eigene Kopfzeile, denen der Rahmen eine einheitliche
|
||||
* Kopfzeile (Symbol + Name) gibt (Design „Mosaik“, Runde 2). Notiz,
|
||||
* Favoriten, Proxmox und XFrame bringen ihre eigene (mit editierbarem Titel)
|
||||
* mit; Suche und Bilderrahmen bleiben bewusst randlos — ein Suchfeld bzw.
|
||||
* ein Foto braucht keine Ueberschrift.
|
||||
*/
|
||||
const FRAME_HEADER_TYPES = new Set(['calendar', 'clock', 'calculator', 'stopwatch']);
|
||||
|
||||
interface WidgetWrapperProps {
|
||||
widget: {
|
||||
@@ -47,7 +57,7 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
|
||||
Hoehenkette Karte -> Rumpf bleibt definit (cqh loest weiter auf). */}
|
||||
{isEditMode && (
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 z-10 flex h-5 items-center justify-center bg-muted/80"
|
||||
className="absolute inset-x-0 top-0 z-10 flex h-5 items-center justify-center"
|
||||
title={t('dragHint')}
|
||||
data-testid="widget-drag-head"
|
||||
>
|
||||
@@ -105,7 +115,14 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
|
||||
im Bearbeitungsmodus zusaetzlich den Griff traegt.
|
||||
quick-260916-dyv: Die Kopfleiste liegt als Overlay ueber dem Rumpf und
|
||||
aendert die Hoehenkette nicht. */}
|
||||
<div className="@container-size h-full">
|
||||
<div className="flex h-full flex-col">
|
||||
{WidgetComponent && definition && FRAME_HEADER_TYPES.has(widget.widgetType) && (
|
||||
<div className="widget-head" data-testid="widget-frame-head">
|
||||
<WidgetIcon type={widget.widgetType} />
|
||||
<h2 className="truncate font-semibold text-foreground">{t(definition.nameKey)}</h2>
|
||||
</div>
|
||||
)}
|
||||
<div className="@container-size min-h-0 flex-1">
|
||||
{WidgetComponent ? (
|
||||
<WidgetComponent
|
||||
instanceId={widget.id}
|
||||
@@ -124,6 +141,7 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { WidgetIcon } from './widget-icon';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { WidgetProps } from '@/components/dashboard/widget-registry';
|
||||
@@ -120,6 +121,7 @@ export function XframeWidget({ config, isEditMode }: WidgetProps) {
|
||||
// Bearbeitungsmodus ueberdeckt die 20-px-Griffleiste der Karte den
|
||||
// oberen Teil der Kopfleiste — Bearbeiten ist Anordnen, nicht Lesen.
|
||||
<div className="widget-head">
|
||||
<WidgetIcon type="xframe" />
|
||||
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{title}</h2>
|
||||
<NewTabLink
|
||||
url={url}
|
||||
|
||||
@@ -136,11 +136,19 @@
|
||||
"uploadError": "Das Bild konnte nicht hochgeladen werden.",
|
||||
"presets": {
|
||||
"mist": "Nebel",
|
||||
"sage": "Salbei",
|
||||
"bloom": "Blüte",
|
||||
"dunes": "Dünen",
|
||||
"mosaic": "Mosaik"
|
||||
"mosaic": "Mosaik",
|
||||
"pebble": "Kiesel"
|
||||
}
|
||||
},
|
||||
"greeting": {
|
||||
"morning": "Guten Morgen",
|
||||
"day": "Guten Tag",
|
||||
"evening": "Guten Abend",
|
||||
"morningNamed": "Guten Morgen, {name}",
|
||||
"dayNamed": "Guten Tag, {name}",
|
||||
"eveningNamed": "Guten Abend, {name}"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -270,8 +278,8 @@
|
||||
"emptyNoSources": "Keine Kalenderquellen konfiguriert",
|
||||
"emptyNoEvents": "Keine anstehenden Termine",
|
||||
"nothingSelected": "Nichts zum Anzeigen ausgewählt",
|
||||
"monthPrev": "Zurück",
|
||||
"monthNext": "Weiter",
|
||||
"monthPrev": "Vorheriger Monat",
|
||||
"monthNext": "Nächster Monat",
|
||||
"monthToday": "Zurück zum heutigen Monat",
|
||||
"upcomingTitle": "Nächste Termine",
|
||||
"tooltipMore": "Weitere Termine vorhanden",
|
||||
@@ -454,6 +462,13 @@
|
||||
"serversLabel": "Angezeigte Server",
|
||||
"serversHint": "Ohne Auswahl zeigt die Kachel alle Server.",
|
||||
"chooseServers": "Server auswählen"
|
||||
},
|
||||
"editShort": "Bearbeiten",
|
||||
"done": "Fertig",
|
||||
"toolbarLabel": "Dashboard-Aktionen",
|
||||
"emptyState": {
|
||||
"title": "Ihr Dashboard ist noch leer",
|
||||
"body": "Stellen Sie sich Ihre Startseite aus Kacheln zusammen. Zum Beispiel:"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -136,11 +136,19 @@
|
||||
"uploadError": "The image could not be uploaded.",
|
||||
"presets": {
|
||||
"mist": "Mist",
|
||||
"sage": "Sage",
|
||||
"bloom": "Bloom",
|
||||
"dunes": "Dunes",
|
||||
"mosaic": "Mosaic"
|
||||
"mosaic": "Mosaic",
|
||||
"pebble": "Pebble"
|
||||
}
|
||||
},
|
||||
"greeting": {
|
||||
"morning": "Good morning",
|
||||
"day": "Good afternoon",
|
||||
"evening": "Good evening",
|
||||
"morningNamed": "Good morning, {name}",
|
||||
"dayNamed": "Good afternoon, {name}",
|
||||
"eveningNamed": "Good evening, {name}"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -270,8 +278,8 @@
|
||||
"emptyNoSources": "No calendar sources configured",
|
||||
"emptyNoEvents": "No upcoming events",
|
||||
"nothingSelected": "Nothing selected to display",
|
||||
"monthPrev": "Back",
|
||||
"monthNext": "Next",
|
||||
"monthPrev": "Previous month",
|
||||
"monthNext": "Next month",
|
||||
"monthToday": "Back to the current month",
|
||||
"upcomingTitle": "Upcoming events",
|
||||
"tooltipMore": "More events available",
|
||||
@@ -454,6 +462,13 @@
|
||||
"serversLabel": "Servers shown",
|
||||
"serversHint": "With nothing selected, the tile shows all servers.",
|
||||
"chooseServers": "Choose servers"
|
||||
},
|
||||
"editShort": "Edit",
|
||||
"done": "Done",
|
||||
"toolbarLabel": "Dashboard actions",
|
||||
"emptyState": {
|
||||
"title": "Your dashboard is still empty",
|
||||
"body": "Build your start page from tiles. For example:"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
Reference in New Issue
Block a user