style(favorites): Kachelansicht enger, lange Namen kleiner und zweizeilig
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:
@@ -10,6 +10,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
|
||||
### 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.
|
||||
- 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.
|
||||
|
||||
@@ -37,20 +37,20 @@ vi.mock('@/lib/dashboard-api', () => ({
|
||||
updateWidgetConfig: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||
import {
|
||||
createFavorite,
|
||||
deleteFavorite,
|
||||
FavoriteRequestError,
|
||||
fetchFavorites,
|
||||
createFavorite,
|
||||
updateFavorite,
|
||||
deleteFavorite,
|
||||
reorderFavorites,
|
||||
uploadFavoriteIcon,
|
||||
removeFavoriteIcon,
|
||||
reorderFavorites,
|
||||
updateFavorite,
|
||||
uploadFavoriteIcon,
|
||||
} from '@/lib/favorites-api';
|
||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||
|
||||
// Must import AFTER mocks
|
||||
import { FavoritesWidget } from './favorites-widget';
|
||||
import { FavoritesWidget, LauncherLabel } from './favorites-widget';
|
||||
|
||||
// Typed mock helpers
|
||||
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 () => {
|
||||
render(
|
||||
<FavoritesWidget
|
||||
instanceId="fav-1"
|
||||
config={{}}
|
||||
isEditMode={false}
|
||||
/>,
|
||||
);
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||
|
||||
// fetchFavorites must be called with the instanceId (widgetId scope — Pitfall 3)
|
||||
expect(mockFetch).toHaveBeenCalledWith('fav-1');
|
||||
@@ -127,13 +121,7 @@ describe('FavoritesWidget', () => {
|
||||
it('shows empty message when fetchFavorites resolves to empty array', async () => {
|
||||
mockFetch.mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<FavoritesWidget
|
||||
instanceId="fav-1"
|
||||
config={{}}
|
||||
isEditMode={false}
|
||||
/>,
|
||||
);
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
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 () => {
|
||||
mockFetch.mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<FavoritesWidget
|
||||
instanceId="fav-1"
|
||||
config={{}}
|
||||
isEditMode={true}
|
||||
/>,
|
||||
);
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
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 () => {
|
||||
render(
|
||||
<FavoritesWidget
|
||||
instanceId="fav-1"
|
||||
config={{}}
|
||||
isEditMode={true}
|
||||
/>,
|
||||
);
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
@@ -239,13 +215,7 @@ describe('FavoritesWidget', () => {
|
||||
});
|
||||
|
||||
it('delete: clicking delete calls deleteFavorite with id and removes the row', async () => {
|
||||
render(
|
||||
<FavoritesWidget
|
||||
instanceId="fav-1"
|
||||
config={{}}
|
||||
isEditMode={true}
|
||||
/>,
|
||||
);
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
@@ -271,13 +241,7 @@ describe('FavoritesWidget', () => {
|
||||
});
|
||||
|
||||
it('view toggle: switching to grid in edit mode renders grid container', async () => {
|
||||
render(
|
||||
<FavoritesWidget
|
||||
instanceId="fav-1"
|
||||
config={{ viewMode: 'list' }}
|
||||
isEditMode={true}
|
||||
/>,
|
||||
);
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{ viewMode: 'list' }} isEditMode={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
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 () => {
|
||||
render(
|
||||
<FavoritesWidget
|
||||
instanceId="fav-1"
|
||||
config={{}}
|
||||
isEditMode={false}
|
||||
/>,
|
||||
);
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Notion')).toBeInTheDocument();
|
||||
@@ -318,24 +276,16 @@ describe('FavoritesWidget', () => {
|
||||
// quick-260916-iex: optionaler Titel
|
||||
describe('optionaler Titel (quick-260916-iex)', () => {
|
||||
it('Ansicht ohne (leeren/ungueltigen) Titel zeigt keine Kopfzeile', async () => {
|
||||
const r1 = render(
|
||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
||||
);
|
||||
const r1 = render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByPlaceholderText('favorites.titlePlaceholder'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('favorites.titlePlaceholder')).not.toBeInTheDocument();
|
||||
r1.unmount();
|
||||
|
||||
const r2 = render(
|
||||
<FavoritesWidget
|
||||
instanceId="fav-1"
|
||||
config={{ title: ' ' }}
|
||||
isEditMode={false}
|
||||
/>,
|
||||
<FavoritesWidget instanceId="fav-1" config={{ title: ' ' }} isEditMode={false} />,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
@@ -343,13 +293,7 @@ describe('FavoritesWidget', () => {
|
||||
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
||||
r2.unmount();
|
||||
|
||||
render(
|
||||
<FavoritesWidget
|
||||
instanceId="fav-1"
|
||||
config={{ title: 42 }}
|
||||
isEditMode={false}
|
||||
/>,
|
||||
);
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{ title: 42 }} isEditMode={false} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
@@ -358,11 +302,7 @@ describe('FavoritesWidget', () => {
|
||||
|
||||
it('Ansicht mit Titel zeigt eine H2-Kopfzeile', async () => {
|
||||
render(
|
||||
<FavoritesWidget
|
||||
instanceId="fav-1"
|
||||
config={{ title: 'Werkzeuge' }}
|
||||
isEditMode={false}
|
||||
/>,
|
||||
<FavoritesWidget instanceId="fav-1" config={{ title: 'Werkzeuge' }} isEditMode={false} />,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
@@ -371,15 +311,11 @@ describe('FavoritesWidget', () => {
|
||||
expect(heading.tagName).toBe('H2');
|
||||
expect(heading.className).toContain('text-sm');
|
||||
expect(heading.className).toContain('font-semibold');
|
||||
expect(
|
||||
screen.queryByPlaceholderText('favorites.titlePlaceholder'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('favorites.titlePlaceholder')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Bearbeitungsmodus ohne Titel zeigt ein Titelfeld statt einer Kopfzeile', async () => {
|
||||
render(
|
||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
|
||||
);
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
@@ -391,25 +327,17 @@ describe('FavoritesWidget', () => {
|
||||
expect(titleField.className).toContain('widgetNoDrag');
|
||||
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /favorites\.listView/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /favorites\.gridView/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /favorites\.listView/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /favorites\.gridView/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('entprellt das Speichern des Titels um 1500 ms', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
||||
try {
|
||||
render(
|
||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
|
||||
);
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
await act(async () => {});
|
||||
|
||||
const titleField = screen.getByPlaceholderText(
|
||||
'favorites.titlePlaceholder',
|
||||
);
|
||||
const titleField = screen.getByPlaceholderText('favorites.titlePlaceholder');
|
||||
|
||||
for (const value of ['W', 'We', 'Werkzeuge']) {
|
||||
await act(async () => {
|
||||
@@ -444,9 +372,7 @@ describe('FavoritesWidget', () => {
|
||||
// Sortierpfeile im Bearbeitungsmodus.
|
||||
describe('Ersatzbild und Sortierung (quick-260917-jdd)', () => {
|
||||
it('Ersatzbild bei iconUrl null: Direktbild statt Proxy-Bild, referrerpolicy no-referrer', async () => {
|
||||
render(
|
||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
||||
);
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Notion')).toBeInTheDocument();
|
||||
@@ -460,9 +386,7 @@ describe('FavoritesWidget', () => {
|
||||
});
|
||||
|
||||
it('Kette Proxy -> direkt -> Buchstabe', async () => {
|
||||
render(
|
||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
||||
);
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
@@ -561,12 +485,17 @@ describe('FavoritesWidget', () => {
|
||||
|
||||
it('kein Direktbild bei Nicht-http-URL', async () => {
|
||||
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(
|
||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
||||
);
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Ablage')).toBeInTheDocument();
|
||||
@@ -583,9 +512,7 @@ describe('FavoritesWidget', () => {
|
||||
{ ...BASE_FAVORITES[0], position: 1 },
|
||||
]);
|
||||
|
||||
render(
|
||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
|
||||
);
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
@@ -618,9 +545,7 @@ describe('FavoritesWidget', () => {
|
||||
it('Fehler beim Sortieren -> Neuladen, Fehlermeldung, alte Reihenfolge', async () => {
|
||||
mockReorder.mockRejectedValue(new Error('boom'));
|
||||
|
||||
render(
|
||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
|
||||
);
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
@@ -656,9 +581,7 @@ describe('FavoritesWidget', () => {
|
||||
|
||||
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(
|
||||
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
||||
);
|
||||
rerender(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||
|
||||
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 });
|
||||
mockUploadIcon.mockResolvedValue({
|
||||
...BASE_FAVORITES[0],
|
||||
@@ -789,7 +712,10 @@ describe('FavoritesWidget', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith('fav-id-1', expect.objectContaining({ title: 'GitHub' }));
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
'fav-id-1',
|
||||
expect.objectContaining({ title: 'GitHub' }),
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-1', file);
|
||||
@@ -857,12 +783,16 @@ describe('FavoritesWidget', () => {
|
||||
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([
|
||||
{ ...BASE_FAVORITES[0], uploadedIconMime: 'image/png', iconVersion: 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} />);
|
||||
|
||||
@@ -881,7 +811,9 @@ describe('FavoritesWidget', () => {
|
||||
expect(mockRemoveIcon).toHaveBeenCalledWith('fav-id-1');
|
||||
});
|
||||
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();
|
||||
});
|
||||
@@ -895,7 +827,9 @@ describe('FavoritesWidget', () => {
|
||||
|
||||
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 () => {
|
||||
@@ -935,7 +869,11 @@ describe('FavoritesWidget', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
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(() => {
|
||||
@@ -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';
|
||||
|
||||
import { WidgetIcon } from './widget-icon';
|
||||
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
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 {
|
||||
FavoriteRequestError,
|
||||
createFavorite,
|
||||
deleteFavorite,
|
||||
type FavoriteLink,
|
||||
FavoriteRequestError,
|
||||
fetchFavorites,
|
||||
removeFavoriteIcon,
|
||||
reorderFavorites,
|
||||
updateFavorite,
|
||||
uploadFavoriteIcon,
|
||||
type FavoriteLink,
|
||||
} 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
|
||||
const TITLE_DEBOUNCE_MS = 1500;
|
||||
@@ -51,11 +51,7 @@ function favoriteErrorKey(err: unknown): string {
|
||||
* - Sortierung per Pfeilen im Bearbeitungsmodus (260917-jdd): optimistisch,
|
||||
* persistiert ueber PUT /favorites/order
|
||||
*/
|
||||
export function FavoritesWidget({
|
||||
instanceId,
|
||||
config,
|
||||
isEditMode,
|
||||
}: WidgetProps) {
|
||||
export function FavoritesWidget({ instanceId, config, isEditMode }: WidgetProps) {
|
||||
const t = useTranslations('widgets');
|
||||
|
||||
const [favorites, setFavorites] = useState<FavoriteLink[]>([]);
|
||||
@@ -68,12 +64,8 @@ export function FavoritesWidget({
|
||||
);
|
||||
|
||||
// Optionaler Titel (quick-260916-iex)
|
||||
const [title, setTitle] = useState<string>(
|
||||
typeof config.title === 'string' ? config.title : '',
|
||||
);
|
||||
const titleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [title, setTitle] = useState<string>(typeof config.title === 'string' ? config.title : '');
|
||||
const titleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(titleTimerRef.current);
|
||||
@@ -335,174 +327,167 @@ export function FavoritesWidget({
|
||||
/>
|
||||
) : (
|
||||
hasTitle && (
|
||||
<h2 className="truncate text-sm font-semibold text-foreground">
|
||||
{title.trim()}
|
||||
</h2>
|
||||
<h2 className="truncate text-sm font-semibold text-foreground">{title.trim()}</h2>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex flex-1 flex-col gap-2 overflow-auto px-2 pb-2 ${showHeader ? '' : 'pt-2'}`}>
|
||||
{/* View mode toggle (edit mode only) */}
|
||||
{isEditMode && (
|
||||
// role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten")
|
||||
// war sachlich falsch fuer einen Umschalter zwischen Listen- und
|
||||
// Kachelansicht — hier greift D-03 im Sinn des Streichens (die
|
||||
// Beschriftung trug keine echte Bedeutung), eine richtige
|
||||
// Beschriftung ist trotzdem besser als gar keine.
|
||||
<div
|
||||
className="flex gap-1 widgetNoDrag"
|
||||
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
|
||||
className={`flex flex-1 flex-col gap-2 overflow-auto px-2 pb-2 ${showHeader ? '' : 'pt-2'}`}
|
||||
>
|
||||
{/* View mode toggle (edit mode only) */}
|
||||
{isEditMode && (
|
||||
// role="toolbar" (D-03): die bisherige Beschriftung ("Favoriten")
|
||||
// war sachlich falsch fuer einen Umschalter zwischen Listen- und
|
||||
// Kachelansicht — hier greift D-03 im Sinn des Streichens (die
|
||||
// Beschriftung trug keine echte Bedeutung), eine richtige
|
||||
// Beschriftung ist trotzdem besser als gar keine.
|
||||
<div
|
||||
data-testid="favorites-grid"
|
||||
className="grid grid-cols-[repeat(auto-fill,minmax(76px,1fr))] gap-1"
|
||||
className="flex gap-1 widgetNoDrag"
|
||||
role="toolbar"
|
||||
aria-label={t('favorites.viewModeLabel')}
|
||||
>
|
||||
{sortedFavorites.map((fav, index) => (
|
||||
<FavoriteTile
|
||||
launcher
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
<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>
|
||||
) : (
|
||||
<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
|
||||
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)}
|
||||
placeholder={t('favorites.addTitle')}
|
||||
/>
|
||||
<input
|
||||
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={newUrl}
|
||||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
placeholder={t('favorites.addUrl')}
|
||||
/>
|
||||
{/* 260923-lrr: eigenes Symbol beim Hinzufuegen */}
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t('favorites.iconUploadLabel')}
|
||||
{/* 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
|
||||
data-testid="favorites-grid"
|
||||
className="grid grid-cols-[repeat(auto-fill,minmax(52px,1fr))] gap-1"
|
||||
>
|
||||
{sortedFavorites.map((fav, index) => (
|
||||
<FavoriteTile
|
||||
launcher
|
||||
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>
|
||||
) : (
|
||||
<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
|
||||
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)}
|
||||
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)}
|
||||
placeholder={t('favorites.addTitle')}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
<input
|
||||
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={newUrl}
|
||||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
placeholder={t('favorites.addUrl')}
|
||||
/>
|
||||
{/* 260923-lrr: eigenes Symbol beim Hinzufuegen */}
|
||||
<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>
|
||||
);
|
||||
@@ -677,6 +662,60 @@ type FavoriteTileProps = {
|
||||
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({
|
||||
fav,
|
||||
isEditMode,
|
||||
@@ -728,9 +767,11 @@ function FavoriteTile({
|
||||
large={launcher}
|
||||
/>
|
||||
|
||||
<span className={launcher ? 'line-clamp-2 w-full break-words text-xs leading-4' : 'truncate text-sm'}>
|
||||
{fav.title}
|
||||
</span>
|
||||
{launcher ? (
|
||||
<LauncherLabel text={fav.title} />
|
||||
) : (
|
||||
<span className="truncate text-sm">{fav.title}</span>
|
||||
)}
|
||||
</a>
|
||||
|
||||
{/* Action buttons (edit mode only, D-04) */}
|
||||
|
||||
Reference in New Issue
Block a user