style(favorites): Kachelansicht enger, lange Namen kleiner und zweizeilig
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m20s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m58s

Spaltenmindestbreite 76 -> 52 px (Abstand zwischen den Symbolen ~59 -> ~27 px
bei einer 373 px breiten Kachel). LauncherLabel misst den Titel einzeilig in
12 px; passt er nicht, 10 px, zweizeilig mit Silbentrennung und Auslassung.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 11:33:20 +02:00
parent af87c2c112
commit c846eb49cb
3 changed files with 307 additions and 299 deletions
+1
View File
@@ -10,6 +10,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
### Geändert ### Geändert
- Dashboard, Favoriten: In der Kachelansicht stehen die Symbole enger beieinander; der Abstand zwischen ihnen ist etwa halb so groß, in eine Zeile passen mehr Favoriten. Passt ein Name nicht in eine Zeile, wird er kleiner geschrieben und auf zwei Zeilen umbrochen.
- Dashboard: Es sieht jetzt auf jedem Bildschirm gleich aus. Tessera merkt sich die Fläche des Bildschirms, an dem Sie ein Dashboard zuerst öffnen, und zeigt es auf anderen Bildschirmen maßstäblich verkleinert oder vergrößert – samt Schrift und ohne Scrollen. Auf dem Handy bleibt es bei der bisherigen Anordnung untereinander. - Dashboard: Es sieht jetzt auf jedem Bildschirm gleich aus. Tessera merkt sich die Fläche des Bildschirms, an dem Sie ein Dashboard zuerst öffnen, und zeigt es auf anderen Bildschirmen maßstäblich verkleinert oder vergrößert – samt Schrift und ohne Scrollen. Auf dem Handy bleibt es bei der bisherigen Anordnung untereinander.
- Eigene Module: Neue Einträge sind mit der Kategorie „Eigene Module“ vorbelegt. - Eigene Module: Neue Einträge sind mit der Kategorie „Eigene Module“ vorbelegt.
- Dashboard: Der Kalender lässt sich nicht mehr so schmal ziehen, dass seine Überschrift abgeschnitten wird. - Dashboard: Der Kalender lässt sich nicht mehr so schmal ziehen, dass seine Überschrift abgeschnitten wird.
@@ -37,20 +37,20 @@ vi.mock('@/lib/dashboard-api', () => ({
updateWidgetConfig: vi.fn().mockResolvedValue(undefined), updateWidgetConfig: vi.fn().mockResolvedValue(undefined),
})); }));
import { updateWidgetConfig } from '@/lib/dashboard-api';
import { import {
createFavorite,
deleteFavorite,
FavoriteRequestError, FavoriteRequestError,
fetchFavorites, fetchFavorites,
createFavorite,
updateFavorite,
deleteFavorite,
reorderFavorites,
uploadFavoriteIcon,
removeFavoriteIcon, removeFavoriteIcon,
reorderFavorites,
updateFavorite,
uploadFavoriteIcon,
} from '@/lib/favorites-api'; } from '@/lib/favorites-api';
import { updateWidgetConfig } from '@/lib/dashboard-api';
// Must import AFTER mocks // Must import AFTER mocks
import { FavoritesWidget } from './favorites-widget'; import { FavoritesWidget, LauncherLabel } from './favorites-widget';
// Typed mock helpers // Typed mock helpers
const mockFetch = fetchFavorites as ReturnType<typeof vi.fn>; const mockFetch = fetchFavorites as ReturnType<typeof vi.fn>;
@@ -106,13 +106,7 @@ describe('FavoritesWidget', () => {
}); });
it('calls fetchFavorites with instanceId on mount and renders link titles', async () => { it('calls fetchFavorites with instanceId on mount and renders link titles', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={false}
/>,
);
// fetchFavorites must be called with the instanceId (widgetId scope — Pitfall 3) // fetchFavorites must be called with the instanceId (widgetId scope — Pitfall 3)
expect(mockFetch).toHaveBeenCalledWith('fav-1'); expect(mockFetch).toHaveBeenCalledWith('fav-1');
@@ -127,13 +121,7 @@ describe('FavoritesWidget', () => {
it('shows empty message when fetchFavorites resolves to empty array', async () => { it('shows empty message when fetchFavorites resolves to empty array', async () => {
mockFetch.mockResolvedValue([]); mockFetch.mockResolvedValue([]);
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={false}
/>,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('favorites.empty')).toBeInTheDocument(); expect(screen.getByText('favorites.empty')).toBeInTheDocument();
@@ -143,13 +131,7 @@ describe('FavoritesWidget', () => {
it('add: submit form in edit mode calls createFavorite with widgetId, title, url', async () => { it('add: submit form in edit mode calls createFavorite with widgetId, title, url', async () => {
mockFetch.mockResolvedValue([]); mockFetch.mockResolvedValue([]);
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={true}
/>,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('favorites.empty')).toBeInTheDocument(); expect(screen.getByText('favorites.empty')).toBeInTheDocument();
@@ -189,13 +171,7 @@ describe('FavoritesWidget', () => {
}); });
it('edit: clicking edit, changing title, saving calls updateFavorite with id and new title', async () => { it('edit: clicking edit, changing title, saving calls updateFavorite with id and new title', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={true}
/>,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -239,13 +215,7 @@ describe('FavoritesWidget', () => {
}); });
it('delete: clicking delete calls deleteFavorite with id and removes the row', async () => { it('delete: clicking delete calls deleteFavorite with id and removes the row', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={true}
/>,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -271,13 +241,7 @@ describe('FavoritesWidget', () => {
}); });
it('view toggle: switching to grid in edit mode renders grid container', async () => { it('view toggle: switching to grid in edit mode renders grid container', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{ viewMode: 'list' }} isEditMode={true} />);
<FavoritesWidget
instanceId="fav-1"
config={{ viewMode: 'list' }}
isEditMode={true}
/>,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -299,13 +263,7 @@ describe('FavoritesWidget', () => {
}); });
it('letter fallback: link with iconUrl null renders first uppercase letter of title', async () => { it('letter fallback: link with iconUrl null renders first uppercase letter of title', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={false}
/>,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Notion')).toBeInTheDocument(); expect(screen.getByText('Notion')).toBeInTheDocument();
@@ -318,24 +276,16 @@ describe('FavoritesWidget', () => {
// quick-260916-iex: optionaler Titel // quick-260916-iex: optionaler Titel
describe('optionaler Titel (quick-260916-iex)', () => { describe('optionaler Titel (quick-260916-iex)', () => {
it('Ansicht ohne (leeren/ungueltigen) Titel zeigt keine Kopfzeile', async () => { it('Ansicht ohne (leeren/ungueltigen) Titel zeigt keine Kopfzeile', async () => {
const r1 = render( const r1 = render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
}); });
expect(screen.queryByRole('heading')).not.toBeInTheDocument(); expect(screen.queryByRole('heading')).not.toBeInTheDocument();
expect( expect(screen.queryByPlaceholderText('favorites.titlePlaceholder')).not.toBeInTheDocument();
screen.queryByPlaceholderText('favorites.titlePlaceholder'),
).not.toBeInTheDocument();
r1.unmount(); r1.unmount();
const r2 = render( const r2 = render(
<FavoritesWidget <FavoritesWidget instanceId="fav-1" config={{ title: ' ' }} isEditMode={false} />,
instanceId="fav-1"
config={{ title: ' ' }}
isEditMode={false}
/>,
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -343,13 +293,7 @@ describe('FavoritesWidget', () => {
expect(screen.queryByRole('heading')).not.toBeInTheDocument(); expect(screen.queryByRole('heading')).not.toBeInTheDocument();
r2.unmount(); r2.unmount();
render( render(<FavoritesWidget instanceId="fav-1" config={{ title: 42 }} isEditMode={false} />);
<FavoritesWidget
instanceId="fav-1"
config={{ title: 42 }}
isEditMode={false}
/>,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
}); });
@@ -358,11 +302,7 @@ describe('FavoritesWidget', () => {
it('Ansicht mit Titel zeigt eine H2-Kopfzeile', async () => { it('Ansicht mit Titel zeigt eine H2-Kopfzeile', async () => {
render( render(
<FavoritesWidget <FavoritesWidget instanceId="fav-1" config={{ title: 'Werkzeuge' }} isEditMode={false} />,
instanceId="fav-1"
config={{ title: 'Werkzeuge' }}
isEditMode={false}
/>,
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -371,15 +311,11 @@ describe('FavoritesWidget', () => {
expect(heading.tagName).toBe('H2'); expect(heading.tagName).toBe('H2');
expect(heading.className).toContain('text-sm'); expect(heading.className).toContain('text-sm');
expect(heading.className).toContain('font-semibold'); expect(heading.className).toContain('font-semibold');
expect( expect(screen.queryByPlaceholderText('favorites.titlePlaceholder')).not.toBeInTheDocument();
screen.queryByPlaceholderText('favorites.titlePlaceholder'),
).not.toBeInTheDocument();
}); });
it('Bearbeitungsmodus ohne Titel zeigt ein Titelfeld statt einer Kopfzeile', async () => { it('Bearbeitungsmodus ohne Titel zeigt ein Titelfeld statt einer Kopfzeile', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
}); });
@@ -391,25 +327,17 @@ describe('FavoritesWidget', () => {
expect(titleField.className).toContain('widgetNoDrag'); expect(titleField.className).toContain('widgetNoDrag');
expect(screen.queryByRole('heading')).not.toBeInTheDocument(); expect(screen.queryByRole('heading')).not.toBeInTheDocument();
expect( expect(screen.getByRole('button', { name: /favorites\.listView/i })).toBeInTheDocument();
screen.getByRole('button', { name: /favorites\.listView/i }), expect(screen.getByRole('button', { name: /favorites\.gridView/i })).toBeInTheDocument();
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /favorites\.gridView/i }),
).toBeInTheDocument();
}); });
it('entprellt das Speichern des Titels um 1500 ms', async () => { it('entprellt das Speichern des Titels um 1500 ms', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
try { try {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
);
await act(async () => {}); await act(async () => {});
const titleField = screen.getByPlaceholderText( const titleField = screen.getByPlaceholderText('favorites.titlePlaceholder');
'favorites.titlePlaceholder',
);
for (const value of ['W', 'We', 'Werkzeuge']) { for (const value of ['W', 'We', 'Werkzeuge']) {
await act(async () => { await act(async () => {
@@ -444,9 +372,7 @@ describe('FavoritesWidget', () => {
// Sortierpfeile im Bearbeitungsmodus. // Sortierpfeile im Bearbeitungsmodus.
describe('Ersatzbild und Sortierung (quick-260917-jdd)', () => { describe('Ersatzbild und Sortierung (quick-260917-jdd)', () => {
it('Ersatzbild bei iconUrl null: Direktbild statt Proxy-Bild, referrerpolicy no-referrer', async () => { it('Ersatzbild bei iconUrl null: Direktbild statt Proxy-Bild, referrerpolicy no-referrer', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Notion')).toBeInTheDocument(); expect(screen.getByText('Notion')).toBeInTheDocument();
@@ -460,9 +386,7 @@ describe('FavoritesWidget', () => {
}); });
it('Kette Proxy -> direkt -> Buchstabe', async () => { it('Kette Proxy -> direkt -> Buchstabe', async () => {
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -561,12 +485,17 @@ describe('FavoritesWidget', () => {
it('kein Direktbild bei Nicht-http-URL', async () => { it('kein Direktbild bei Nicht-http-URL', async () => {
mockFetch.mockResolvedValue([ mockFetch.mockResolvedValue([
{ id: 'fav-id-3', widgetId: 'fav-1', title: 'Ablage', url: 'ftp://files.example', iconUrl: null, position: 0 }, {
id: 'fav-id-3',
widgetId: 'fav-1',
title: 'Ablage',
url: 'ftp://files.example',
iconUrl: null,
position: 0,
},
]); ]);
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Ablage')).toBeInTheDocument(); expect(screen.getByText('Ablage')).toBeInTheDocument();
@@ -583,9 +512,7 @@ describe('FavoritesWidget', () => {
{ ...BASE_FAVORITES[0], position: 1 }, { ...BASE_FAVORITES[0], position: 1 },
]); ]);
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -618,9 +545,7 @@ describe('FavoritesWidget', () => {
it('Fehler beim Sortieren -> Neuladen, Fehlermeldung, alte Reihenfolge', async () => { it('Fehler beim Sortieren -> Neuladen, Fehlermeldung, alte Reihenfolge', async () => {
mockReorder.mockRejectedValue(new Error('boom')); mockReorder.mockRejectedValue(new Error('boom'));
render( render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('GitHub')).toBeInTheDocument();
@@ -656,9 +581,7 @@ describe('FavoritesWidget', () => {
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
rerender( rerender(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
);
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
}); });
@@ -761,7 +684,7 @@ describe('FavoritesWidget', () => {
}); });
}); });
it('Datei im Bearbeitungsformular waehlen, Speichern -> updateFavorite, danach uploadFavoriteIcon(\'fav-id-1\', Datei); Formular schliesst', async () => { it("Datei im Bearbeitungsformular waehlen, Speichern -> updateFavorite, danach uploadFavoriteIcon('fav-id-1', Datei); Formular schliesst", async () => {
mockUpdate.mockResolvedValue({ ...BASE_FAVORITES[0], iconVersion: 1 }); mockUpdate.mockResolvedValue({ ...BASE_FAVORITES[0], iconVersion: 1 });
mockUploadIcon.mockResolvedValue({ mockUploadIcon.mockResolvedValue({
...BASE_FAVORITES[0], ...BASE_FAVORITES[0],
@@ -789,7 +712,10 @@ describe('FavoritesWidget', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith('fav-id-1', expect.objectContaining({ title: 'GitHub' })); expect(mockUpdate).toHaveBeenCalledWith(
'fav-id-1',
expect.objectContaining({ title: 'GitHub' }),
);
}); });
await waitFor(() => { await waitFor(() => {
expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-1', file); expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-1', file);
@@ -857,12 +783,16 @@ describe('FavoritesWidget', () => {
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument(); expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
}); });
it('Knopf favorites.iconRemoveButton nur bei gesetztem uploadedIconMime; Klick -> removeFavoriteIcon(\'fav-id-1\'), danach verschwindet der Knopf, Formular bleibt offen', async () => { it("Knopf favorites.iconRemoveButton nur bei gesetztem uploadedIconMime; Klick -> removeFavoriteIcon('fav-id-1'), danach verschwindet der Knopf, Formular bleibt offen", async () => {
mockFetch.mockResolvedValue([ mockFetch.mockResolvedValue([
{ ...BASE_FAVORITES[0], uploadedIconMime: 'image/png', iconVersion: 1 }, { ...BASE_FAVORITES[0], uploadedIconMime: 'image/png', iconVersion: 1 },
BASE_FAVORITES[1], BASE_FAVORITES[1],
]); ]);
mockRemoveIcon.mockResolvedValue({ ...BASE_FAVORITES[0], uploadedIconMime: null, iconVersion: 2 }); mockRemoveIcon.mockResolvedValue({
...BASE_FAVORITES[0],
uploadedIconMime: null,
iconVersion: 2,
});
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />); render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
@@ -881,7 +811,9 @@ describe('FavoritesWidget', () => {
expect(mockRemoveIcon).toHaveBeenCalledWith('fav-id-1'); expect(mockRemoveIcon).toHaveBeenCalledWith('fav-id-1');
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.queryByRole('button', { name: 'favorites.iconRemoveButton' })).not.toBeInTheDocument(); expect(
screen.queryByRole('button', { name: 'favorites.iconRemoveButton' }),
).not.toBeInTheDocument();
}); });
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument(); expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
}); });
@@ -895,7 +827,9 @@ describe('FavoritesWidget', () => {
openEditForGitHub(); openEditForGitHub();
expect(screen.queryByRole('button', { name: 'favorites.iconRemoveButton' })).not.toBeInTheDocument(); expect(
screen.queryByRole('button', { name: 'favorites.iconRemoveButton' }),
).not.toBeInTheDocument();
}); });
it('Hinzufuegen mit gewaehlter Datei -> createFavorite, danach uploadFavoriteIcon(created.id, Datei)', async () => { it('Hinzufuegen mit gewaehlter Datei -> createFavorite, danach uploadFavoriteIcon(created.id, Datei)', async () => {
@@ -935,7 +869,11 @@ describe('FavoritesWidget', () => {
await waitFor(() => { await waitFor(() => {
expect(mockCreate).toHaveBeenCalledWith( expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ widgetId: 'fav-1', title: 'New Link', url: 'https://example.com' }), expect.objectContaining({
widgetId: 'fav-1',
title: 'New Link',
url: 'https://example.com',
}),
); );
}); });
await waitFor(() => { await waitFor(() => {
@@ -947,3 +885,31 @@ describe('FavoritesWidget', () => {
}); });
}); });
}); });
describe('LauncherLabel — Beschriftung in der Kachelansicht', () => {
afterEach(() => {
vi.restoreAllMocks();
});
function mockWidths(scroll: number, client: number) {
vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(scroll);
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(client);
}
it('bleibt in normaler Groesse, wenn der Titel in eine Zeile passt', () => {
mockWidths(40, 60);
render(<LauncherLabel text="Wiki" />);
const label = screen.getByText('Wiki');
expect(label).not.toHaveAttribute('data-small');
expect(label.className).toContain('text-xs');
});
it('wird kleiner und zweizeilig, wenn der Titel nicht in eine Zeile passt', () => {
mockWidths(120, 60);
render(<LauncherLabel text="Reisekostenabrechnung" />);
const label = screen.getByText('Reisekostenabrechnung');
expect(label).toHaveAttribute('data-small', 'true');
expect(label.className).toContain('text-[10px]');
expect(label.className).toContain('line-clamp-2');
});
});
@@ -1,21 +1,21 @@
'use client'; 'use client';
import { WidgetIcon } from './widget-icon';
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { type FormEvent, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { WidgetProps } from '@/components/dashboard/widget-registry';
import { updateWidgetConfig } from '@/lib/dashboard-api'; import { updateWidgetConfig } from '@/lib/dashboard-api';
import { import {
FavoriteRequestError,
createFavorite, createFavorite,
deleteFavorite, deleteFavorite,
type FavoriteLink,
FavoriteRequestError,
fetchFavorites, fetchFavorites,
removeFavoriteIcon, removeFavoriteIcon,
reorderFavorites, reorderFavorites,
updateFavorite, updateFavorite,
uploadFavoriteIcon, uploadFavoriteIcon,
type FavoriteLink,
} from '@/lib/favorites-api'; } from '@/lib/favorites-api';
import type { WidgetProps } from '@/components/dashboard/widget-registry'; import { WidgetIcon } from './widget-icon';
// quick-260916-iex: wie DEBOUNCE_MS im Notiz-Widget // quick-260916-iex: wie DEBOUNCE_MS im Notiz-Widget
const TITLE_DEBOUNCE_MS = 1500; const TITLE_DEBOUNCE_MS = 1500;
@@ -51,11 +51,7 @@ function favoriteErrorKey(err: unknown): string {
* - Sortierung per Pfeilen im Bearbeitungsmodus (260917-jdd): optimistisch, * - Sortierung per Pfeilen im Bearbeitungsmodus (260917-jdd): optimistisch,
* persistiert ueber PUT /favorites/order * persistiert ueber PUT /favorites/order
*/ */
export function FavoritesWidget({ export function FavoritesWidget({ instanceId, config, isEditMode }: WidgetProps) {
instanceId,
config,
isEditMode,
}: WidgetProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const [favorites, setFavorites] = useState<FavoriteLink[]>([]); const [favorites, setFavorites] = useState<FavoriteLink[]>([]);
@@ -68,12 +64,8 @@ export function FavoritesWidget({
); );
// Optionaler Titel (quick-260916-iex) // Optionaler Titel (quick-260916-iex)
const [title, setTitle] = useState<string>( const [title, setTitle] = useState<string>(typeof config.title === 'string' ? config.title : '');
typeof config.title === 'string' ? config.title : '', const titleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
);
const titleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
useEffect(() => { useEffect(() => {
return () => clearTimeout(titleTimerRef.current); return () => clearTimeout(titleTimerRef.current);
@@ -335,174 +327,167 @@ export function FavoritesWidget({
/> />
) : ( ) : (
hasTitle && ( hasTitle && (
<h2 className="truncate text-sm font-semibold text-foreground"> <h2 className="truncate text-sm font-semibold text-foreground">{title.trim()}</h2>
{title.trim()}
</h2>
) )
)} )}
</div> </div>
)} )}
<div className={`flex flex-1 flex-col gap-2 overflow-auto px-2 pb-2 ${showHeader ? '' : 'pt-2'}`}> <div
{/* View mode toggle (edit mode only) */} className={`flex flex-1 flex-col gap-2 overflow-auto px-2 pb-2 ${showHeader ? '' : 'pt-2'}`}
{isEditMode && ( >
// role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten") {/* View mode toggle (edit mode only) */}
// war sachlich falsch fuer einen Umschalter zwischen Listen- und {isEditMode && (
// Kachelansicht — hier greift D-03 im Sinn des Streichens (die // role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten")
// Beschriftung trug keine echte Bedeutung), eine richtige // war sachlich falsch fuer einen Umschalter zwischen Listen- und
// Beschriftung ist trotzdem besser als gar keine. // Kachelansicht — hier greift D-03 im Sinn des Streichens (die
<div // Beschriftung trug keine echte Bedeutung), eine richtige
className="flex gap-1 widgetNoDrag" // Beschriftung ist trotzdem besser als gar keine.
role="toolbar"
aria-label={t('favorites.viewModeLabel')}
>
<button
type="button"
onClick={() => handleViewMode('list')}
className={`px-2 py-1 text-xs rounded ${
viewMode === 'list'
? 'bg-foreground text-background'
: 'bg-muted text-muted-foreground hover:text-foreground'
}`}
>
{t('favorites.listView')}
</button>
<button
type="button"
onClick={() => handleViewMode('grid')}
className={`px-2 py-1 text-xs rounded ${
viewMode === 'grid'
? 'bg-foreground text-background'
: 'bg-muted text-muted-foreground hover:text-foreground'
}`}
>
{t('favorites.gridView')}
</button>
</div>
)}
{/* Status messages */}
{loading && (
<p className="text-xs text-muted-foreground">{t('favorites.loading')}</p>
)}
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
{/* Favorites list / grid */}
{!loading && sortedFavorites.length === 0 && (
<p className="text-xs text-muted-foreground">{t('favorites.empty')}</p>
)}
{!loading && sortedFavorites.length > 0 && (
viewMode === 'grid' ? (
<div <div
data-testid="favorites-grid" className="flex gap-1 widgetNoDrag"
className="grid grid-cols-[repeat(auto-fill,minmax(76px,1fr))] gap-1" role="toolbar"
aria-label={t('favorites.viewModeLabel')}
> >
{sortedFavorites.map((fav, index) => ( <button
<FavoriteTile type="button"
launcher onClick={() => handleViewMode('list')}
key={fav.id} className={`px-2 py-1 text-xs rounded ${
fav={fav} viewMode === 'list'
isEditMode={isEditMode} ? 'bg-foreground text-background'
editingId={editingId} : 'bg-muted text-muted-foreground hover:text-foreground'
editTitle={editTitle} }`}
editUrl={editUrl} >
editIconUrl={editIconUrl} {t('favorites.listView')}
editError={editError} </button>
editBusy={editBusy} <button
getFallbackLetter={getFallbackLetter} type="button"
onStartEdit={startEdit} onClick={() => handleViewMode('grid')}
onCancelEdit={cancelEdit} className={`px-2 py-1 text-xs rounded ${
onSaveEdit={handleSaveEdit} viewMode === 'grid'
onDelete={handleDelete} ? 'bg-foreground text-background'
onEditTitleChange={setEditTitle} : 'bg-muted text-muted-foreground hover:text-foreground'
onEditUrlChange={setEditUrl} }`}
onEditIconUrlChange={setEditIconUrl} >
onEditIconFileChange={setEditIconFile} {t('favorites.gridView')}
onRemoveIcon={handleRemoveIcon} </button>
canMoveUp={index > 0}
canMoveDown={index < sortedFavorites.length - 1}
onMove={(fid, dir) => void handleMove(fid, dir)}
t={t}
/>
))}
</div> </div>
) : ( )}
<div
data-testid="favorites-list"
className="flex flex-col gap-0.5"
>
{sortedFavorites.map((fav, index) => (
<FavoriteTile
key={fav.id}
fav={fav}
isEditMode={isEditMode}
editingId={editingId}
editTitle={editTitle}
editUrl={editUrl}
editIconUrl={editIconUrl}
editError={editError}
editBusy={editBusy}
getFallbackLetter={getFallbackLetter}
onStartEdit={startEdit}
onCancelEdit={cancelEdit}
onSaveEdit={handleSaveEdit}
onDelete={handleDelete}
onEditTitleChange={setEditTitle}
onEditUrlChange={setEditUrl}
onEditIconUrlChange={setEditIconUrl}
onEditIconFileChange={setEditIconFile}
onRemoveIcon={handleRemoveIcon}
canMoveUp={index > 0}
canMoveDown={index < sortedFavorites.length - 1}
onMove={(fid, dir) => void handleMove(fid, dir)}
t={t}
/>
))}
</div>
)
)}
{/* Add form (edit mode only, D-04) */} {/* Status messages */}
{isEditMode && ( {loading && <p className="text-xs text-muted-foreground">{t('favorites.loading')}</p>}
<form {error && <p className="text-xs text-destructive">{error}</p>}
onSubmit={(e) => void handleAdd(e)}
className="flex flex-col gap-1 pt-1 border-t border-border widgetNoDrag" {/* Favorites list / grid */}
> {!loading && sortedFavorites.length === 0 && (
<input <p className="text-xs text-muted-foreground">{t('favorites.empty')}</p>
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring" )}
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)} {!loading &&
placeholder={t('favorites.addTitle')} sortedFavorites.length > 0 &&
/> (viewMode === 'grid' ? (
<input <div
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring" data-testid="favorites-grid"
value={newUrl} className="grid grid-cols-[repeat(auto-fill,minmax(52px,1fr))] gap-1"
onChange={(e) => setNewUrl(e.target.value)} >
placeholder={t('favorites.addUrl')} {sortedFavorites.map((fav, index) => (
/> <FavoriteTile
{/* 260923-lrr: eigenes Symbol beim Hinzufuegen */} launcher
<label className="text-xs text-muted-foreground"> key={fav.id}
{t('favorites.iconUploadLabel')} fav={fav}
isEditMode={isEditMode}
editingId={editingId}
editTitle={editTitle}
editUrl={editUrl}
editIconUrl={editIconUrl}
editError={editError}
editBusy={editBusy}
getFallbackLetter={getFallbackLetter}
onStartEdit={startEdit}
onCancelEdit={cancelEdit}
onSaveEdit={handleSaveEdit}
onDelete={handleDelete}
onEditTitleChange={setEditTitle}
onEditUrlChange={setEditUrl}
onEditIconUrlChange={setEditIconUrl}
onEditIconFileChange={setEditIconFile}
onRemoveIcon={handleRemoveIcon}
canMoveUp={index > 0}
canMoveDown={index < sortedFavorites.length - 1}
onMove={(fid, dir) => void handleMove(fid, dir)}
t={t}
/>
))}
</div>
) : (
<div data-testid="favorites-list" className="flex flex-col gap-0.5">
{sortedFavorites.map((fav, index) => (
<FavoriteTile
key={fav.id}
fav={fav}
isEditMode={isEditMode}
editingId={editingId}
editTitle={editTitle}
editUrl={editUrl}
editIconUrl={editIconUrl}
editError={editError}
editBusy={editBusy}
getFallbackLetter={getFallbackLetter}
onStartEdit={startEdit}
onCancelEdit={cancelEdit}
onSaveEdit={handleSaveEdit}
onDelete={handleDelete}
onEditTitleChange={setEditTitle}
onEditUrlChange={setEditUrl}
onEditIconUrlChange={setEditIconUrl}
onEditIconFileChange={setEditIconFile}
onRemoveIcon={handleRemoveIcon}
canMoveUp={index > 0}
canMoveDown={index < sortedFavorites.length - 1}
onMove={(fid, dir) => void handleMove(fid, dir)}
t={t}
/>
))}
</div>
))}
{/* Add form (edit mode only, D-04) */}
{isEditMode && (
<form
onSubmit={(e) => void handleAdd(e)}
className="flex flex-col gap-1 pt-1 border-t border-border widgetNoDrag"
>
<input <input
type="file" className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
data-testid="favorite-add-icon-upload" value={newTitle}
ref={newIconFileInputRef} onChange={(e) => setNewTitle(e.target.value)}
accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon,image/svg+xml,.ico,.svg" placeholder={t('favorites.addTitle')}
className="mt-0.5 block w-full text-xs"
onChange={(e) => setNewIconFile(e.target.files?.[0] ?? null)}
/> />
</label> <input
<button className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
type="submit" value={newUrl}
className="w-full rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:opacity-90 transition-opacity" onChange={(e) => setNewUrl(e.target.value)}
> placeholder={t('favorites.addUrl')}
{t('favorites.addButton')} />
</button> {/* 260923-lrr: eigenes Symbol beim Hinzufuegen */}
</form> <label className="text-xs text-muted-foreground">
)} {t('favorites.iconUploadLabel')}
<input
type="file"
data-testid="favorite-add-icon-upload"
ref={newIconFileInputRef}
accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon,image/svg+xml,.ico,.svg"
className="mt-0.5 block w-full text-xs"
onChange={(e) => setNewIconFile(e.target.files?.[0] ?? null)}
/>
</label>
<button
type="submit"
className="w-full rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:opacity-90 transition-opacity"
>
{t('favorites.addButton')}
</button>
</form>
)}
</div> </div>
</div> </div>
); );
@@ -677,6 +662,60 @@ type FavoriteTileProps = {
launcher?: boolean; launcher?: boolean;
}; };
/**
* Beschriftung einer Favoriten-Kachel in der Kachelansicht. Passt der Titel
* in normaler Groesse (12 px) nicht in eine Zeile, wuerde er an die
* Nachbarkachel stossen bzw. mitten im Wort umbrechen — dann wird die Schrift
* kleiner (10 px) und darf zweizeilig umbrechen (Silbentrennung, danach "…").
* Gemessen wird immer in normaler Groesse und einzeilig, damit das Ergebnis
* nicht von der eigenen Umschaltung abhaengt (keine Rueckkopplung). Der
* ResizeObserver misst neu, wenn die Kachel breiter oder schmaler wird;
* scrollWidth/clientWidth sind Layoutmasse und von der Dashboard-Skalierung
* (transform) unberuehrt.
*/
export function LauncherLabel({ text }: { text: string }) {
const ref = useRef<HTMLSpanElement>(null);
const [small, setSmall] = useState(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: neuer Titel = neu messen, der Wert selbst steht im DOM
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const measure = () => {
const prev = {
whiteSpace: el.style.whiteSpace,
fontSize: el.style.fontSize,
display: el.style.display,
};
el.style.whiteSpace = 'nowrap';
el.style.fontSize = '12px';
el.style.display = 'block';
const overflow = el.scrollWidth > el.clientWidth + 1;
el.style.whiteSpace = prev.whiteSpace;
el.style.fontSize = prev.fontSize;
el.style.display = prev.display;
setSmall(overflow);
};
measure();
if (typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [text]);
return (
<span
ref={ref}
data-small={small ? 'true' : undefined}
className={`line-clamp-2 w-full break-words hyphens-auto ${
small ? 'text-[10px] leading-3' : 'text-xs leading-4'
}`}
>
{text}
</span>
);
}
function FavoriteTile({ function FavoriteTile({
fav, fav,
isEditMode, isEditMode,
@@ -728,9 +767,11 @@ function FavoriteTile({
large={launcher} large={launcher}
/> />
<span className={launcher ? 'line-clamp-2 w-full break-words text-xs leading-4' : 'truncate text-sm'}> {launcher ? (
{fav.title} <LauncherLabel text={fav.title} />
</span> ) : (
<span className="truncate text-sm">{fav.title}</span>
)}
</a> </a>
{/* Action buttons (edit mode only, D-04) */} {/* Action buttons (edit mode only, D-04) */}