diff --git a/CHANGELOG.md b/CHANGELOG.md index c501e3d..c3d3d75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T ### Neu - Das Dashboard hat jetzt mehrere Reiter: Sie können beliebig viele Dashboards anlegen, jeder mit eigenen Kacheln und eigener Anordnung, per Ziehen umsortierbar, wobei der erste Reiter beim Öffnen geladen wird. Ihre vorhandenen Kacheln bleiben dabei unverändert auf dem ersten Reiter liegen. +- Favoriten-Widget: eigenes Symbol je Link hochladen – PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB – beim Hinzufügen und im Bearbeitungsformular; ein hochgeladenes Symbol hat Vorrang vor der Logo-Adresse; „Hochgeladenes Symbol entfernen“ macht es rückgängig + +### Behoben + +- Favoriten-Widget: nach dem Ändern der Logo-Adresse erscheint das neue Symbol jetzt sofort, statt erst nach einem Tag; ist das Bild unter der Adresse nicht abrufbar – etwa wegen einer Cloudflare-Prüfung – meldet das Formular das jetzt beim Speichern, statt die Adresse still zu übernehmen ## 1.3.1 – 2026-09-23 diff --git a/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx b/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx index de2933f..66c3f7a 100644 --- a/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx +++ b/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx @@ -6,17 +6,31 @@ vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key, })); -// Mock favorites-api — all 5 functions as controllable vi.fn() spies +// Mock favorites-api — all functions as controllable vi.fn() spies // (reorderFavorites hinzugefuegt, 260917-jdd — ohne diesen Eintrag // importiert das Widget undefined und der Klick auf einen Sortierpfeil // wirft TypeError.) -vi.mock('@/lib/favorites-api', () => ({ - fetchFavorites: vi.fn(), - createFavorite: vi.fn(), - updateFavorite: vi.fn(), - deleteFavorite: vi.fn(), - reorderFavorites: vi.fn(), -})); +// +// 260923-lrr: die Fabrik ist jetzt ASYNCHRON und uebernimmt per +// `vi.importActual` die ECHTE `FavoriteRequestError`-Klasse und +// `FAVORITE_ICON_MAX_BYTES` — ein `instanceof FavoriteRequestError` im +// Widget muss gegen dieselbe Klasse pruefen, die die Tests werfen, sonst +// faellt `favoriteErrorKey` immer auf `favorites.error` zurueck. +// `uploadFavoriteIcon`/`removeFavoriteIcon` kommen als `vi.fn()` dazu. +vi.mock('@/lib/favorites-api', async () => { + const actual = await vi.importActual('@/lib/favorites-api'); + return { + FavoriteRequestError: actual.FavoriteRequestError, + FAVORITE_ICON_MAX_BYTES: actual.FAVORITE_ICON_MAX_BYTES, + fetchFavorites: vi.fn(), + createFavorite: vi.fn(), + updateFavorite: vi.fn(), + deleteFavorite: vi.fn(), + reorderFavorites: vi.fn(), + uploadFavoriteIcon: vi.fn(), + removeFavoriteIcon: vi.fn(), + }; +}); // Mock dashboard-api for updateWidgetConfig (view mode persistence) vi.mock('@/lib/dashboard-api', () => ({ @@ -24,11 +38,14 @@ vi.mock('@/lib/dashboard-api', () => ({ })); import { + FavoriteRequestError, fetchFavorites, createFavorite, updateFavorite, deleteFavorite, reorderFavorites, + uploadFavoriteIcon, + removeFavoriteIcon, } from '@/lib/favorites-api'; import { updateWidgetConfig } from '@/lib/dashboard-api'; @@ -41,6 +58,8 @@ const mockCreate = createFavorite as ReturnType; const mockUpdate = updateFavorite as ReturnType; const mockDelete = deleteFavorite as ReturnType; const mockReorder = reorderFavorites as ReturnType; +const mockUploadIcon = uploadFavoriteIcon as ReturnType; +const mockRemoveIcon = removeFavoriteIcon as ReturnType; const mockUpdateWidgetConfig = updateWidgetConfig as ReturnType; const BASE_FAVORITES = [ @@ -450,7 +469,8 @@ describe('FavoritesWidget', () => { }); const proxyImg = screen.getByTestId('icon-proxy-fav-id-1') as HTMLImageElement; - expect(proxyImg.getAttribute('src')).toBe('/api-proxy/favorites/fav-id-1/icon'); + // 260923-lrr: die Proxy-Adresse traegt jetzt ?v= (Cache-Bust). + expect(proxyImg.getAttribute('src')).toBe('/api-proxy/favorites/fav-id-1/icon?v=0'); expect(screen.queryByTestId('icon-direct-fav-id-1')).not.toBeInTheDocument(); act(() => { @@ -573,4 +593,288 @@ describe('FavoritesWidget', () => { await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); }); + + // 260923-lrr: eigenes Symbol hochladen, Zwischenspeicher nach Aenderung + // erneuern (versionierte Symbol-Adresse, Datei-Auswahl beim Bearbeiten + // und Hinzufuegen, Entfernen-Knopf, Meldungen im Formular). + describe('Eigenes Symbol (quick-260923-lrr)', () => { + function openEditForGitHub() { + const editButtons = screen.getAllByRole('button', { name: /favorites\.editButton/i }); + act(() => { + fireEvent.click(editButtons[0]); + }); + } + + it('Proxy-Bild traegt ?v=; Zeile ohne iconVersion -> ?v=0', async () => { + mockFetch.mockResolvedValue([{ ...BASE_FAVORITES[0], iconVersion: 3 }, BASE_FAVORITES[1]]); + + render(); + + await waitFor(() => { + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + + const proxyImg = screen.getByTestId('icon-proxy-fav-id-1') as HTMLImageElement; + expect(proxyImg.getAttribute('src')).toBe('/api-proxy/favorites/fav-id-1/icon?v=3'); + }); + + it('Zeile nur mit uploadedIconMime (iconUrl null) -> Proxy-Bild statt Direktbild', async () => { + mockFetch.mockResolvedValue([ + { + id: 'fav-id-4', + widgetId: 'fav-1', + title: 'Eigenes', + url: 'https://eigenes.invalid', + iconUrl: null, + uploadedIconMime: 'image/png', + iconVersion: 1, + position: 0, + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Eigenes')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('icon-proxy-fav-id-4')).toBeInTheDocument(); + expect(screen.queryByTestId('icon-direct-fav-id-4')).not.toBeInTheDocument(); + }); + + it('Logo-Adress-Feld zeigt den Platzhalter favorites.iconUrlPlaceholder (kein fest verdrahteter Text mehr)', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + + openEditForGitHub(); + + expect(screen.getByPlaceholderText('favorites.iconUrlPlaceholder')).toBeInTheDocument(); + }); + + it('Speichern mit neuer Logo-Adresse, updateFavorite liefert iconVersion 1 -> src des Proxy-Bildes endet danach auf ?v=1', async () => { + mockUpdate.mockResolvedValue({ + ...BASE_FAVORITES[0], + iconUrl: 'https://neu.invalid/icon.png', + iconVersion: 1, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + + openEditForGitHub(); + + const iconUrlField = screen.getByPlaceholderText('favorites.iconUrlPlaceholder'); + act(() => { + fireEvent.change(iconUrlField, { target: { value: 'https://neu.invalid/icon.png' } }); + }); + + const saveButton = screen.getByRole('button', { name: /favorites\.saveButton/i }); + act(() => { + fireEvent.click(saveButton); + }); + + await waitFor(() => { + expect(mockUpdate).toHaveBeenCalledWith( + 'fav-id-1', + expect.objectContaining({ iconUrl: 'https://neu.invalid/icon.png' }), + ); + }); + + await waitFor(() => { + const proxyImg = screen.getByTestId('icon-proxy-fav-id-1') as HTMLImageElement; + expect(proxyImg.getAttribute('src')).toBe('/api-proxy/favorites/fav-id-1/icon?v=1'); + }); + }); + + 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], + uploadedIconMime: 'image/png', + iconVersion: 2, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + + openEditForGitHub(); + + const fileField = screen.getByTestId('favorite-icon-upload-fav-id-1'); + const file = new File([new Uint8Array([1, 2, 3])], 'icon.png', { type: 'image/png' }); + act(() => { + fireEvent.change(fileField, { target: { files: [file] } }); + }); + + const saveButton = screen.getByRole('button', { name: /favorites\.saveButton/i }); + act(() => { + fireEvent.click(saveButton); + }); + + await waitFor(() => { + expect(mockUpdate).toHaveBeenCalledWith('fav-id-1', expect.objectContaining({ title: 'GitHub' })); + }); + await waitFor(() => { + expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-1', file); + }); + await waitFor(() => { + expect(screen.queryByTestId('favorite-icon-upload-fav-id-1')).not.toBeInTheDocument(); + }); + }); + + it('updateFavorite wirft FavoriteRequestError(iconUrlUnreachable) -> Meldung im Formular (role alert), Formular bleibt offen, uploadFavoriteIcon NICHT aufgerufen', async () => { + mockUpdate.mockRejectedValue(new FavoriteRequestError('iconUrlUnreachable')); + + render(); + + await waitFor(() => { + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + + openEditForGitHub(); + + const fileField = screen.getByTestId('favorite-icon-upload-fav-id-1'); + const file = new File([new Uint8Array([1])], 'icon.png', { type: 'image/png' }); + act(() => { + fireEvent.change(fileField, { target: { files: [file] } }); + }); + + const saveButton = screen.getByRole('button', { name: /favorites\.saveButton/i }); + act(() => { + fireEvent.click(saveButton); + }); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('favorites.iconUrlUnreachable'); + }); + expect(mockUploadIcon).not.toHaveBeenCalled(); + expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument(); + }); + + it('Upload wirft FavoriteRequestError(iconTooLarge) -> favorites.iconTooLarge im Formular, Formular bleibt offen', async () => { + mockUpdate.mockResolvedValue({ ...BASE_FAVORITES[0] }); + mockUploadIcon.mockRejectedValue(new FavoriteRequestError('iconTooLarge')); + + render(); + + await waitFor(() => { + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + + openEditForGitHub(); + + const fileField = screen.getByTestId('favorite-icon-upload-fav-id-1'); + const file = new File([new Uint8Array([1])], 'icon.png', { type: 'image/png' }); + act(() => { + fireEvent.change(fileField, { target: { files: [file] } }); + }); + + const saveButton = screen.getByRole('button', { name: /favorites\.saveButton/i }); + act(() => { + fireEvent.click(saveButton); + }); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('favorites.iconTooLarge'); + }); + 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 () => { + mockFetch.mockResolvedValue([ + { ...BASE_FAVORITES[0], uploadedIconMime: 'image/png', iconVersion: 1 }, + BASE_FAVORITES[1], + ]); + mockRemoveIcon.mockResolvedValue({ ...BASE_FAVORITES[0], uploadedIconMime: null, iconVersion: 2 }); + + render(); + + await waitFor(() => { + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + + openEditForGitHub(); + + const removeButton = screen.getByRole('button', { name: 'favorites.iconRemoveButton' }); + act(() => { + fireEvent.click(removeButton); + }); + + await waitFor(() => { + expect(mockRemoveIcon).toHaveBeenCalledWith('fav-id-1'); + }); + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'favorites.iconRemoveButton' })).not.toBeInTheDocument(); + }); + expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument(); + }); + + it('kein Entfernen-Knopf ohne hochgeladenes Symbol', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + + openEditForGitHub(); + + expect(screen.queryByRole('button', { name: 'favorites.iconRemoveButton' })).not.toBeInTheDocument(); + }); + + it('Hinzufuegen mit gewaehlter Datei -> createFavorite, danach uploadFavoriteIcon(created.id, Datei)', async () => { + mockFetch.mockResolvedValue([]); + mockUploadIcon.mockResolvedValue({ + id: 'fav-id-new', + widgetId: 'fav-1', + title: 'New Link', + url: 'https://example.com', + iconUrl: null, + uploadedIconMime: 'image/png', + iconVersion: 1, + position: 2, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('favorites.empty')).toBeInTheDocument(); + }); + + const titleInput = screen.getByPlaceholderText('favorites.addTitle'); + const urlInput = screen.getByPlaceholderText('favorites.addUrl'); + const fileInput = screen.getByTestId('favorite-add-icon-upload'); + const file = new File([new Uint8Array([1, 2, 3])], 'icon.png', { type: 'image/png' }); + + act(() => { + fireEvent.change(titleInput, { target: { value: 'New Link' } }); + fireEvent.change(urlInput, { target: { value: 'https://example.com' } }); + fireEvent.change(fileInput, { target: { files: [file] } }); + }); + + const addButton = screen.getByRole('button', { name: /favorites\.addButton/i }); + act(() => { + fireEvent.click(addButton); + }); + + await waitFor(() => { + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ widgetId: 'fav-1', title: 'New Link', url: 'https://example.com' }), + ); + }); + await waitFor(() => { + expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-new', file); + }); + await waitFor(() => { + expect(screen.getByText('New Link')).toBeInTheDocument(); + }); + }); + }); }); diff --git a/apps/web/src/components/dashboard/widgets/favorites-widget.tsx b/apps/web/src/components/dashboard/widgets/favorites-widget.tsx index 881b408..786f519 100644 --- a/apps/web/src/components/dashboard/widgets/favorites-widget.tsx +++ b/apps/web/src/components/dashboard/widgets/favorites-widget.tsx @@ -4,11 +4,14 @@ import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; import { updateWidgetConfig } from '@/lib/dashboard-api'; import { + FavoriteRequestError, createFavorite, deleteFavorite, fetchFavorites, + removeFavoriteIcon, reorderFavorites, updateFavorite, + uploadFavoriteIcon, type FavoriteLink, } from '@/lib/favorites-api'; import type { WidgetProps } from '@/components/dashboard/widget-registry'; @@ -16,6 +19,18 @@ import type { WidgetProps } from '@/components/dashboard/widget-registry'; // quick-260916-iex: wie DEBOUNCE_MS im Notiz-Widget const TITLE_DEBOUNCE_MS = 1500; +/** + * Bildet einen Fehler aus dem Favoriten-Klienten auf einen Uebersetzungs- + * schluessel ab (260923-lrr): `FavoriteRequestError` traegt den passenden + * Grund (`iconUrlUnreachable`/`iconTooLarge`/`iconInvalidType`/ + * `iconUploadFailed`) bereits als `reason`, jeder andere Fehler faellt auf + * die bisherige allgemeine Meldung zurueck. + */ +function favoriteErrorKey(err: unknown): string { + if (err instanceof FavoriteRequestError) return `favorites.${err.reason}`; + return 'favorites.error'; +} + /** * FavoritesWidget — persistent per-widget list of favorite links (DASH-09). * @@ -79,12 +94,21 @@ export function FavoritesWidget({ const [newTitle, setNewTitle] = useState(''); const [newUrl, setNewUrl] = useState(''); const [newIconUrl, setNewIconUrl] = useState(''); + // 260923-lrr: gewaehlte Datei fuer das Hinzufuegen-Formular; der Ref setzt + // das native Datei-Feld nach dem Anlegen zurueck (unkontrolliertes Feld). + const [newIconFile, setNewIconFile] = useState(null); + const newIconFileInputRef = useRef(null); // Inline edit state const [editingId, setEditingId] = useState(null); const [editTitle, setEditTitle] = useState(''); const [editUrl, setEditUrl] = useState(''); const [editIconUrl, setEditIconUrl] = useState(''); + // 260923-lrr: gewaehlte Datei, Fehlermeldung und Sperre des Bearbeitungs- + // formulars — eigenes Symbol hochladen/entfernen. + const [editIconFile, setEditIconFile] = useState(null); + const [editError, setEditError] = useState(null); + const [editBusy, setEditBusy] = useState(false); // Sorted favorites: position asc, then title asc const sortedFavorites = useMemo( @@ -164,6 +188,10 @@ export function FavoritesWidget({ } // --- Add favorite --- + // 260923-lrr: nach createFavorite wird — falls im Hinzufuegen-Formular + // eine Datei gewaehlt wurde — diese fuer die neue Kennung hochgeladen; + // scheitert nur der Upload, bleibt der Favorit angelegt (mit der Zeile aus + // createFavorite) und die Meldung erscheint ueber dem Formular. async function handleAdd(e: FormEvent) { e.preventDefault(); const cleanTitle = newTitle.trim(); @@ -179,12 +207,24 @@ export function FavoritesWidget({ url: cleanUrl, iconUrl: cleanIconUrl || undefined, }); - setFavorites((prev) => [...prev, created]); + + let finalFavorite = created; + if (newIconFile) { + try { + finalFavorite = await uploadFavoriteIcon(created.id, newIconFile); + } catch (err) { + setError(t(favoriteErrorKey(err))); + } + } + + setFavorites((prev) => [...prev, finalFavorite]); setNewTitle(''); setNewUrl(''); setNewIconUrl(''); - } catch { - setError(t('favorites.error')); + setNewIconFile(null); + if (newIconFileInputRef.current) newIconFileInputRef.current.value = ''; + } catch (err) { + setError(t(favoriteErrorKey(err))); } } @@ -194,6 +234,9 @@ export function FavoritesWidget({ setEditTitle(fav.title); setEditUrl(fav.url); setEditIconUrl(fav.iconUrl ?? ''); + setEditIconFile(null); + setEditError(null); + setEditBusy(false); setError(null); } @@ -202,8 +245,17 @@ export function FavoritesWidget({ setEditTitle(''); setEditUrl(''); setEditIconUrl(''); + setEditIconFile(null); + setEditError(null); + setEditBusy(false); } + // 260923-lrr: erst PATCH (Titel/URL/Logo-Adresse), danach — falls eine + // Datei gewaehlt wurde — der Upload. Scheitert `updateFavorite`, bleibt + // die Zeile unveraendert und das Formular offen (z. B. 422 bei einer + // nicht abrufbaren Logo-Adresse). Scheitert NUR der Upload, wird die + // bereits aktualisierte Zeile trotzdem uebernommen, das Formular bleibt + // ebenfalls offen, damit der Hinweis sichtbar ist. async function handleSaveEdit(id: string) { const cleanTitle = editTitle.trim(); const cleanUrl = editUrl.trim(); @@ -211,16 +263,45 @@ export function FavoritesWidget({ if (!cleanTitle || !cleanUrl) return; + setEditBusy(true); + setEditError(null); try { const updated = await updateFavorite(id, { title: cleanTitle, url: cleanUrl, iconUrl: cleanIconUrl || null, }); + + if (editIconFile) { + try { + const withIcon = await uploadFavoriteIcon(id, editIconFile); + setFavorites((prev) => prev.map((f) => (f.id === id ? withIcon : f))); + cancelEdit(); + return; + } catch (err) { + setFavorites((prev) => prev.map((f) => (f.id === id ? updated : f))); + setEditError(t(favoriteErrorKey(err))); + return; + } + } + setFavorites((prev) => prev.map((f) => (f.id === id ? updated : f))); cancelEdit(); - } catch { - setError(t('favorites.error')); + } catch (err) { + setEditError(t(favoriteErrorKey(err))); + } finally { + setEditBusy(false); + } + } + + // --- Eigenes Symbol entfernen (260923-lrr) --- + // Wirkt sofort (wie Loeschen einer Zeile), das Formular bleibt offen. + async function handleRemoveIcon(id: string) { + try { + const updated = await removeFavoriteIcon(id); + setFavorites((prev) => prev.map((f) => (f.id === id ? updated : f))); + } catch (err) { + setEditError(t(favoriteErrorKey(err))); } } @@ -326,6 +407,8 @@ export function FavoritesWidget({ editTitle={editTitle} editUrl={editUrl} editIconUrl={editIconUrl} + editError={editError} + editBusy={editBusy} getFallbackLetter={getFallbackLetter} onStartEdit={startEdit} onCancelEdit={cancelEdit} @@ -334,6 +417,8 @@ export function FavoritesWidget({ 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)} @@ -355,6 +440,8 @@ export function FavoritesWidget({ editTitle={editTitle} editUrl={editUrl} editIconUrl={editIconUrl} + editError={editError} + editBusy={editBusy} getFallbackLetter={getFallbackLetter} onStartEdit={startEdit} onCancelEdit={cancelEdit} @@ -363,6 +450,8 @@ export function FavoritesWidget({ 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)} @@ -391,6 +480,18 @@ export function FavoritesWidget({ onChange={(e) => setNewUrl(e.target.value)} placeholder={t('favorites.addUrl')} /> + {/* 260923-lrr: eigenes Symbol beim Hinzufuegen */} + + + )} + {editError && ( +

+ {editError} +

+ )}