From b18ac25ccc9be859acb98516fbccc17efde2410a Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 17 Sep 2026 14:40:09 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20Favoriten-Widget=20=E2=80=94=20Sym?= =?UTF-8?q?bol-Ersatzweg=20aus=20dem=20Browser,=20Sortierpfeile=20im=20Bea?= =?UTF-8?q?rbeitungsmodus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FavoriteIcon (favorites-widget.tsx): dreistufiger Ersatzweg proxy -> direct -> none; Buchstaben-Platzhalter liegt immer darunter. Direktbild nur bei http/https-URL (getDirectFaviconSrc), referrerPolicy no-referrer, kein Drittanbieter-Favicon-Dienst. key={iconUrl|url} setzt die Stufe bei Aenderung zurueck; kein style.display-Hack mehr - handleMove + Sortierpfeile im Bearbeitungsmodus (nur bei nicht-inline- Bearbeitung): optimistische Neuberechnung, PUT /favorites/order ueber reorderFavorites; erster/letzter Eintrag deaktiviert; Fehler -> Neuladen mit Fehlermeldung - favorites-api.ts: reorderFavorites(widgetId, ids) - de.json/en.json: widgets.favorites.moveUpButton/moveDownButton - 5 neue Widget-Tests (Ersatzbild-Kette, Nicht-http-URL, Pfeilzustand, Klick, Fehlerpfad); 11 bestehende unveraendert gruen; volle Web-Suite 64 Dateien/429 Tests und type-check gruen Co-Authored-By: Claude Opus 5 (1M context) --- .../widgets/favorites-widget.test.tsx | 149 ++++++++++++- .../dashboard/widgets/favorites-widget.tsx | 206 ++++++++++++++++-- apps/web/src/lib/favorites-api.ts | 21 ++ apps/web/src/messages/de.json | 2 + apps/web/src/messages/en.json | 2 + 5 files changed, 355 insertions(+), 25 deletions(-) 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 b2c8888..89a6f20 100644 --- a/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx +++ b/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Mock next-intl — passthrough t(key) => key (Pitfall: must be before imports) @@ -6,12 +6,16 @@ vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key, })); -// Mock favorites-api — all 4 functions as controllable vi.fn() spies +// Mock favorites-api — all 5 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(), })); // Mock dashboard-api for updateWidgetConfig (view mode persistence) @@ -24,6 +28,7 @@ import { createFavorite, updateFavorite, deleteFavorite, + reorderFavorites, } from '@/lib/favorites-api'; import { updateWidgetConfig } from '@/lib/dashboard-api'; @@ -35,6 +40,7 @@ const mockFetch = fetchFavorites as ReturnType; const mockCreate = createFavorite as ReturnType; const mockUpdate = updateFavorite as ReturnType; const mockDelete = deleteFavorite as ReturnType; +const mockReorder = reorderFavorites as ReturnType; const mockUpdateWidgetConfig = updateWidgetConfig as ReturnType; const BASE_FAVORITES = [ @@ -414,4 +420,143 @@ describe('FavoritesWidget', () => { } }); }); + + // 260917-jdd: Browser-Ersatzweg (Proxy -> Direktbild -> Buchstabe) und + // Sortierpfeile im Bearbeitungsmodus. + describe('Ersatzbild und Sortierung (quick-260917-jdd)', () => { + it('Ersatzbild bei iconUrl null: Direktbild statt Proxy-Bild, referrerpolicy no-referrer', async () => { + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('Notion')).toBeInTheDocument(); + }); + + const directImg = screen.getByTestId('icon-direct-fav-id-2') as HTMLImageElement; + expect(directImg.src).toBe('https://notion.so/favicon.ico'); + expect(directImg.getAttribute('referrerpolicy')).toBe('no-referrer'); + expect(screen.queryByTestId('icon-proxy-fav-id-2')).not.toBeInTheDocument(); + expect(screen.getByTestId('letter-fallback-fav-id-2')).toHaveTextContent('N'); + }); + + it('Kette Proxy -> direkt -> Buchstabe', async () => { + 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'); + expect(screen.queryByTestId('icon-direct-fav-id-1')).not.toBeInTheDocument(); + + act(() => { + fireEvent.error(proxyImg); + }); + + expect(screen.queryByTestId('icon-proxy-fav-id-1')).not.toBeInTheDocument(); + const directImg = screen.getByTestId('icon-direct-fav-id-1') as HTMLImageElement; + expect(directImg.src).toBe('https://github.com/favicon.ico'); + + act(() => { + fireEvent.error(directImg); + }); + + expect(screen.queryByTestId('icon-proxy-fav-id-1')).not.toBeInTheDocument(); + expect(screen.queryByTestId('icon-direct-fav-id-1')).not.toBeInTheDocument(); + expect(screen.getByTestId('letter-fallback-fav-id-1')).toHaveTextContent('G'); + }); + + 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 }, + ]); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('Ablage')).toBeInTheDocument(); + }); + + expect(screen.queryByTestId('icon-direct-fav-id-3')).not.toBeInTheDocument(); + expect(screen.queryByTestId('icon-proxy-fav-id-3')).not.toBeInTheDocument(); + expect(screen.getByTestId('letter-fallback-fav-id-3')).toHaveTextContent('A'); + }); + + it('Pfeile: Zustand (erster/letzter deaktiviert) und Klick loest reorderFavorites aus', async () => { + mockReorder.mockResolvedValue([ + { ...BASE_FAVORITES[1], position: 0 }, + { ...BASE_FAVORITES[0], position: 1 }, + ]); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + + const up = screen.getAllByRole('button', { name: 'favorites.moveUpButton' }); + const down = screen.getAllByRole('button', { name: 'favorites.moveDownButton' }); + + expect(up[0]).toBeDisabled(); + expect(down[0]).not.toBeDisabled(); + expect(up[1]).not.toBeDisabled(); + expect(down[1]).toBeDisabled(); + + act(() => { + fireEvent.click(down[0]); + }); + + await waitFor(() => { + expect(mockReorder).toHaveBeenCalledWith('fav-1', ['fav-id-2', 'fav-id-1']); + }); + + await waitFor(() => { + const titles = within(screen.getByTestId('favorites-list')) + .getAllByRole('link') + .map((a) => a.querySelector('.truncate')?.textContent); + expect(titles).toEqual(['Notion', 'GitHub']); + }); + }); + + it('Fehler beim Sortieren -> Neuladen, Fehlermeldung, alte Reihenfolge', async () => { + mockReorder.mockRejectedValue(new Error('boom')); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + + const down = screen.getAllByRole('button', { name: 'favorites.moveDownButton' }); + + act(() => { + fireEvent.click(down[0]); + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + await waitFor(() => { + expect(screen.getByText('favorites.error')).toBeInTheDocument(); + }); + + await waitFor(() => { + const titles = within(screen.getByTestId('favorites-list')) + .getAllByRole('link') + .map((a) => a.querySelector('.truncate')?.textContent); + expect(titles).toEqual(['GitHub', 'Notion']); + }); + }); + }); }); diff --git a/apps/web/src/components/dashboard/widgets/favorites-widget.tsx b/apps/web/src/components/dashboard/widgets/favorites-widget.tsx index 19b179d..b455276 100644 --- a/apps/web/src/components/dashboard/widgets/favorites-widget.tsx +++ b/apps/web/src/components/dashboard/widgets/favorites-widget.tsx @@ -7,6 +7,7 @@ import { createFavorite, deleteFavorite, fetchFavorites, + reorderFavorites, updateFavorite, type FavoriteLink, } from '@/lib/favorites-api'; @@ -28,6 +29,11 @@ const TITLE_DEBOUNCE_MS = 1500; * - Optionaler Titel (quick-260916-iex): leer -> keine Kopfzeile, sonst * Kopfzeile im Notiz-Look; im Bearbeitungsmodus immer ein Titelfeld, * entprellt gespeichert (Muster note-widget.tsx) + * - Symbol-Ersatzweg (260917-jdd): FavoriteIcon versucht zuerst den Server- + * Proxy, bei Fehler den Browser direkt (`onError`-Kette), zuletzt den + * Buchstaben-Platzhalter + * - Sortierung per Pfeilen im Bearbeitungsmodus (260917-jdd): optimistisch, + * persistiert ueber PUT /favorites/order */ export function FavoritesWidget({ instanceId, @@ -127,6 +133,34 @@ export function FavoritesWidget({ void updateWidgetConfig(instanceId, { viewMode: next }); } + // --- Sortierung per Pfeilen (260917-jdd) --- + async function handleMove(id: string, direction: 'up' | 'down') { + const order = sortedFavorites.map((f) => f.id); + const index = order.indexOf(id); + const target = direction === 'up' ? index - 1 : index + 1; + + if (index < 0 || target < 0 || target >= order.length) return; + + [order[index], order[target]] = [order[target], order[index]]; + + const byId = new Map(favorites.map((f) => [f.id, f])); + const reindexed = order.map((fid, i) => ({ ...byId.get(fid)!, position: i })); + + setFavorites(reindexed); + setError(null); + + try { + setFavorites(await reorderFavorites(instanceId, order)); + } catch { + setError(t('favorites.error')); + try { + setFavorites(await fetchFavorites(instanceId)); + } catch { + // Fehlermeldung steht bereits + } + } + } + // --- Add favorite --- async function handleAdd(e: FormEvent) { e.preventDefault(); @@ -272,7 +306,7 @@ export function FavoritesWidget({ data-testid="favorites-grid" className="grid grid-cols-3 gap-2" > - {sortedFavorites.map((fav) => ( + {sortedFavorites.map((fav, index) => ( 0} + canMoveDown={index < sortedFavorites.length - 1} + onMove={(fid, dir) => void handleMove(fid, dir)} t={t} /> ))} @@ -298,7 +335,7 @@ export function FavoritesWidget({ data-testid="favorites-list" className="flex flex-col gap-1" > - {sortedFavorites.map((fav) => ( + {sortedFavorites.map((fav, index) => ( 0} + canMoveDown={index < sortedFavorites.length - 1} + onMove={(fid, dir) => void handleMove(fid, dir)} t={t} /> ))} @@ -353,6 +393,98 @@ export function FavoritesWidget({ ); } +// --- Symbol-Ersatzweg (260917-jdd) --- + +/** + * Leitet aus einer Favoriten-URL das direkte Browser-Favicon ab + * (`{origin}/favicon.ico`) — nur fuer http:/https:, sonst null (kein + * javascript:/data:, T-JDD-04). Wird ausschliesslich als Stufe 2 des + * Ersatzwegs genutzt (siehe FavoriteIcon). + */ +function getDirectFaviconSrc(url: string): string | null { + try { + const u = new URL(url); + if (u.protocol !== 'http:' && u.protocol !== 'https:') return null; + return `${u.origin}/favicon.ico`; + } catch { + return null; + } +} + +/** + * FavoriteIcon — dreistufiger Symbol-Ersatzweg (260917-jdd): + * + * 1. `proxy` — Server-Proxy (GET /favorites/:id/icon), der seit diesem Plan + * auch bei Zertifikatsfehlern des Zielhosts liefert (undici-Dispatcher). + * 2. `direct` — Direktbild aus dem Browser des Nutzers + * (`referrerPolicy="no-referrer"`, Origin nur aus http/https); erreicht + * interne Hosts, die der SSRF-Schutz des Servers absichtlich ablehnt. + * 3. `none` — der Buchstaben-Platzhalter liegt IMMER darunter. + * + * Bewusst KEIN Drittanbieter-Favicon-Dienst: der wuerde Hostnamen nach + * aussen geben und interne Hosts ohnehin nicht kennen. + * + * Befund am Code: `discoverFavoriteIconUrl` liefert NIE null, sondern bei + * jedem Fehler den Origin-Rueckfall — die Stufe `direct` haengt deshalb an + * `onError` des Proxy-Bildes UND an `iconUrl === null`, nicht nur an + * letzterem. + * + * Grenzen: ein http-Favorit auf einem https-Tessera ist Mischinhalt + * (Browser blockiert ihn); ein selbstsigniertes Zertifikat ohne Vertrauen + * im Browser des Nutzers klappt auch ueber Stufe 2 nicht. + */ +function FavoriteIcon({ + fav, + getFallbackLetter, +}: { + fav: FavoriteLink; + getFallbackLetter: (title: string) => string; +}) { + const proxySrc = fav.iconUrl + ? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon` + : null; + const directSrc = getDirectFaviconSrc(fav.url); + const [stage, setStage] = useState<'proxy' | 'direct' | 'none'>( + proxySrc ? 'proxy' : 'direct', + ); + + return ( +
+ + {getFallbackLetter(fav.title)} + + {stage === 'proxy' && proxySrc && ( + setStage('direct')} + /> + )} + {stage === 'direct' && directSrc && ( + setStage('none')} + /> + )} +
+ ); +} + // --- FavoriteTile sub-component --- type FavoriteTileProps = { @@ -370,6 +502,9 @@ type FavoriteTileProps = { onEditTitleChange: (v: string) => void; onEditUrlChange: (v: string) => void; onEditIconUrlChange: (v: string) => void; + canMoveUp: boolean; + canMoveDown: boolean; + onMove: (id: string, direction: 'up' | 'down') => void; t: (key: string) => string; }; @@ -388,6 +523,9 @@ function FavoriteTile({ onEditTitleChange, onEditUrlChange, onEditIconUrlChange, + canMoveUp, + canMoveDown, + onMove, t, }: FavoriteTileProps) { const isEditing = editingId === fav.id; @@ -405,27 +543,11 @@ function FavoriteTile({ onClick={(e) => isEditMode && e.preventDefault()} // block navigation in edit mode > {/* Icon container with letter fallback (T-08-07 — no dangerouslySetInnerHTML) */} -
- - {getFallbackLetter(fav.title)} - - {fav.iconUrl && ( - { - (e.currentTarget as HTMLImageElement).style.display = 'none'; - }} - /> - )} -
+ {fav.title} @@ -433,6 +555,44 @@ function FavoriteTile({ {/* Action buttons (edit mode only, D-04) */} {isEditMode && !isEditing && (
+ {/* Sortierpfeile (260917-jdd) — kein Drag & Drop hier, das + kollidiert mit dem Ziehen der Kachel in react-grid-layout */} + +