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:
@@ -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