'use client'; import { WidgetIcon } from './widget-icon'; 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'; // 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 (`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). * * Features: * - Loads favorites scoped to this widget instance via instanceId (Pitfall 3) * - Add / edit / delete in edit mode with inline forms * - List view (default, D-03) and grid view switchable in edit mode * - Server-side icon discovery (letter fallback when iconUrl is null) * - Links open in new tab with rel="noreferrer" (T-08-08 open-redirect mitigation) * - No dangerouslySetInnerHTML (T-08-07 XSS mitigation) * - 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, config, isEditMode, }: WidgetProps) { const t = useTranslations('widgets'); const [favorites, setFavorites] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // View mode: read from config, default 'list' (D-03) const [viewMode, setViewMode] = useState<'list' | 'grid'>( (config.viewMode as 'list' | 'grid') ?? 'list', ); // Optionaler Titel (quick-260916-iex) const [title, setTitle] = useState( typeof config.title === 'string' ? config.title : '', ); const titleTimerRef = useRef | undefined>( undefined, ); useEffect(() => { return () => clearTimeout(titleTimerRef.current); }, []); function handleTitleChange(e: React.ChangeEvent) { const value = e.target.value; setTitle(value); clearTimeout(titleTimerRef.current); titleTimerRef.current = setTimeout(() => { void updateWidgetConfig(instanceId, { title: value }); }, TITLE_DEBOUNCE_MS); } const hasTitle = title.trim() !== ''; const showHeader = isEditMode || hasTitle; // Add-form state 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( () => [...favorites].sort((a, b) => { if (a.position !== b.position) return a.position - b.position; return a.title.localeCompare(b.title); }), [favorites], ); // Befund 14 (quick-260921-gof): Ersatztext vor dem Effekt bestimmt, `t` // selbst bleibt aussen vor — die Testattrappe liefert bei jedem Durchlauf // eine frische Funktion, das wuerde den Mount-Abruf zur Schleife machen. const favoritesErrorText = t('favorites.error'); useEffect(() => { let cancelled = false; async function load() { setLoading(true); setError(null); try { const data = await fetchFavorites(instanceId); if (!cancelled) setFavorites(data); } catch { if (!cancelled) setError(favoritesErrorText); } finally { if (!cancelled) setLoading(false); } } void load(); return () => { cancelled = true; }; }, [instanceId, favoritesErrorText]); function getFallbackLetter(title: string): string { const clean = title.trim(); return clean ? clean.slice(0, 1).toUpperCase() : '?'; } // --- View mode toggle (edit mode only, D-03) --- function handleViewMode(next: 'list' | 'grid') { setViewMode(next); 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 --- // 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(); const cleanUrl = newUrl.trim(); const cleanIconUrl = newIconUrl.trim(); if (!cleanTitle || !cleanUrl) return; try { const created = await createFavorite({ widgetId: instanceId, title: cleanTitle, url: cleanUrl, iconUrl: cleanIconUrl || undefined, }); 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(''); setNewIconFile(null); if (newIconFileInputRef.current) newIconFileInputRef.current.value = ''; } catch (err) { setError(t(favoriteErrorKey(err))); } } // --- Edit favorite --- function startEdit(fav: FavoriteLink) { setEditingId(fav.id); setEditTitle(fav.title); setEditUrl(fav.url); setEditIconUrl(fav.iconUrl ?? ''); setEditIconFile(null); setEditError(null); setEditBusy(false); setError(null); } function cancelEdit() { setEditingId(null); 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(); const cleanIconUrl = editIconUrl.trim(); 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 (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))); } } // --- Delete favorite --- async function handleDelete(id: string) { try { await deleteFavorite(id); setFavorites((prev) => prev.filter((f) => f.id !== id)); if (editingId === id) cancelEdit(); } catch { setError(t('favorites.error')); } } // --- Render --- return (
{/* Header: optional title (quick-260916-iex) — mirrors note-widget.tsx look */} {showHeader && (
{isEditMode ? ( ) : ( hasTitle && (

{title.trim()}

) )}
)}
{/* 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.
)} {/* Status messages */} {loading && (

{t('favorites.loading')}

)} {error && (

{error}

)} {/* Favorites list / grid */} {!loading && sortedFavorites.length === 0 && (

{t('favorites.empty')}

)} {!loading && sortedFavorites.length > 0 && ( viewMode === 'grid' ? (
{sortedFavorites.map((fav, index) => ( 0} canMoveDown={index < sortedFavorites.length - 1} onMove={(fid, dir) => void handleMove(fid, dir)} t={t} /> ))}
) : (
{sortedFavorites.map((fav, index) => ( 0} canMoveDown={index < sortedFavorites.length - 1} onMove={(fid, dir) => void handleMove(fid, dir)} t={t} /> ))}
) )} {/* Add form (edit mode only, D-04) */} {isEditMode && (
void handleAdd(e)} className="flex flex-col gap-1 pt-1 border-t border-border widgetNoDrag" > setNewTitle(e.target.value)} placeholder={t('favorites.addTitle')} /> setNewUrl(e.target.value)} placeholder={t('favorites.addUrl')} /> {/* 260923-lrr: eigenes Symbol beim Hinzufuegen */}
)}
); } // --- 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; } } /** * Direkt-ladbare Adressen fuer Stufe 2 des Ersatzwegs (260929-lh3): erst die * gespeicherte `iconUrl`, dann das Origin-Favicon der Favoriten-URL; nur * http:/https: (kein javascript:/data:, T-JDD-04), ohne Doppelte. */ function getDirectCandidates(iconUrl: string | null, url: string): string[] { const candidates: string[] = []; if (iconUrl) { try { const u = new URL(iconUrl); if (u.protocol === 'http:' || u.protocol === 'https:') candidates.push(u.toString()); } catch { // ungueltige Adresse: uebersprungen } } const origin = getDirectFaviconSrc(url); if (origin && !candidates.includes(origin)) candidates.push(origin); return candidates; } /** * FavoriteIcon — Symbol-Ersatzweg (260917-jdd, erweitert 260929-lh3): * * 1. `proxy` — Server-Proxy (GET /favorites/:id/icon), der seit diesem Plan * auch bei Zertifikatsfehlern des Zielhosts liefert (undici-Dispatcher). * 2. `direct` — Direktbilder aus dem Browser des Nutzers * (`referrerPolicy="no-referrer"`, nur http/https), nacheinander: * zuerst die gespeicherte `iconUrl` (ausdrueckliche Symbol-Adresse oder * erkannter Verweis — Server wie docuvita geben dem Server 404/HTML, dem * Browser aber das Bild), dann `{origin}/favicon.ico` (entfaellt, wenn * identisch). Erreicht auch 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. * * 260923-lrr: die Proxy-Adresse traegt jetzt `?v=` — der * bisherige 24-Stunden-Zwischenspeicher des Browsers (`Cache-Control` am * Controller) aenderte sich bei einer neuen Logo-Adresse oder einem neuen * hochgeladenen Symbol NIE, weil die Adresse selbst gleich blieb. Der * Remount-Key an der Aufrufstelle (`FavoriteTile`) enthaelt `iconVersion` * und `uploadedIconMime` zusaetzlich zu `iconUrl`/`url`, damit nach einer * Aenderung wieder mit Stufe `proxy` begonnen wird — ein hochgeladenes * Symbol hat serverseitig bereits Vorrang (`getIconBytes`), die Kachel * zeigt es also auch ohne gesetzte `iconUrl`. */ function FavoriteIcon({ fav, getFallbackLetter, large = false, }: { fav: FavoriteLink; getFallbackLetter: (title: string) => string; /** Kachelansicht: 40-px-Feld mit 24-px-Symbol, sonst 24-px-Feld mit 16-px-Symbol. */ large?: boolean; }) { const box = large ? 'h-10 w-10 rounded-lg' : 'h-6 w-6 rounded-md'; const img = large ? 'h-6 w-6' : 'h-4 w-4'; const px = large ? 24 : 16; const hasServerIcon = Boolean(fav.iconUrl) || Boolean(fav.uploadedIconMime); const proxySrc = hasServerIcon ? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon?v=${fav.iconVersion ?? 0}` : null; // 260929-lh3: Direkt-Kandidaten in Reihenfolge — gespeicherte Adresse zuerst, // dann das Origin-Favicon; nur http/https, ohne Doppelte. const directSrcs = getDirectCandidates(fav.iconUrl, fav.url); // 'proxy' | Index in directSrcs | Ende der Kette (Buchstabe). const [stage, setStage] = useState<'proxy' | number>(proxySrc ? 'proxy' : 0); const directSrc = typeof stage === 'number' ? (directSrcs[stage] ?? null) : null; return (
{getFallbackLetter(fav.title)} {stage === 'proxy' && proxySrc && ( setStage(0)} /> )} {directSrc && ( setStage((prev) => (typeof prev === 'number' ? prev + 1 : 0))} /> )}
); } // --- FavoriteTile sub-component --- type FavoriteTileProps = { fav: FavoriteLink; isEditMode: boolean; editingId: string | null; editTitle: string; editUrl: string; editIconUrl: string; /** 260923-lrr — Fehlermeldung/Sperre des Bearbeitungsformulars (eigenes Symbol). */ editError: string | null; editBusy: boolean; getFallbackLetter: (title: string) => string; onStartEdit: (fav: FavoriteLink) => void; onCancelEdit: () => void; onSaveEdit: (id: string) => Promise; onDelete: (id: string) => Promise; onEditTitleChange: (v: string) => void; onEditUrlChange: (v: string) => void; onEditIconUrlChange: (v: string) => void; /** 260923-lrr — gewaehlte Datei im Bearbeitungsformular. */ onEditIconFileChange: (file: File | null) => void; /** 260923-lrr — „Hochgeladenes Symbol entfernen“. */ onRemoveIcon: (id: string) => Promise; canMoveUp: boolean; canMoveDown: boolean; onMove: (id: string, direction: 'up' | 'down') => void; t: (key: string) => string; /** Kachelansicht wie ein App-Starter (Symbol oben, Name darunter). */ launcher?: boolean; }; function FavoriteTile({ fav, isEditMode, editingId, editTitle, editUrl, editIconUrl, editError, editBusy, getFallbackLetter, onStartEdit, onCancelEdit, onSaveEdit, onDelete, onEditTitleChange, onEditUrlChange, onEditIconUrlChange, onEditIconFileChange, onRemoveIcon, canMoveUp, canMoveDown, onMove, t, launcher = false, }: FavoriteTileProps) { const isEditing = editingId === fav.id; return (
{/* Main link row */}
{/* Anchor — opens in new tab, no server-side redirect (T-08-08) */} isEditMode && e.preventDefault()} // block navigation in edit mode > {/* Icon container with letter fallback (T-08-07 — no dangerouslySetInnerHTML) */} {fav.title} {/* 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 */}
)}
{/* Inline edit form */} {isEditMode && isEditing && (
onEditTitleChange(e.target.value)} placeholder={t('favorites.addTitle')} /> onEditUrlChange(e.target.value)} placeholder={t('favorites.addUrl')} /> onEditIconUrlChange(e.target.value)} placeholder={t('favorites.iconUrlPlaceholder')} aria-label={t('favorites.iconUrlPlaceholder')} /> {/* 260923-lrr: eigenes Symbol — Datei-Feld, Hinweis, ggf. Entfernen-Knopf */}

{t('favorites.iconUploadHint')}

{fav.uploadedIconMime != null && ( <>

{t('favorites.iconUploadedHint')}

)} {editError && (

{editError}

)}
)}
); }