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
@@ -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) */}