'use client'; import { FormEvent, useEffect, useMemo, useState } from 'react'; import { useTranslations } from 'next-intl'; import { updateWidgetConfig } from '@/lib/dashboard-api'; import { createFavorite, deleteFavorite, fetchFavorites, updateFavorite, type FavoriteLink, } from '@/lib/favorites-api'; import type { WidgetProps } from '@/components/dashboard/widget-registry'; /** * 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) */ 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', ); // Add-form state const [newTitle, setNewTitle] = useState(''); const [newUrl, setNewUrl] = useState(''); const [newIconUrl, setNewIconUrl] = useState(''); // Inline edit state const [editingId, setEditingId] = useState(null); const [editTitle, setEditTitle] = useState(''); const [editUrl, setEditUrl] = useState(''); const [editIconUrl, setEditIconUrl] = useState(''); // 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], ); 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(t('favorites.error')); } finally { if (!cancelled) setLoading(false); } } void load(); return () => { cancelled = true; }; // instanceId is the only meaningful dependency — t is stable at runtime // and excluded to prevent re-fetching on every render (mock creates new fn) // eslint-disable-next-line react-hooks/exhaustive-deps }, [instanceId]); 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 }); } // --- Add favorite --- 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, }); setFavorites((prev) => [...prev, created]); setNewTitle(''); setNewUrl(''); setNewIconUrl(''); } catch { setError(t('favorites.error')); } } // --- Edit favorite --- function startEdit(fav: FavoriteLink) { setEditingId(fav.id); setEditTitle(fav.title); setEditUrl(fav.url); setEditIconUrl(fav.iconUrl ?? ''); setError(null); } function cancelEdit() { setEditingId(null); setEditTitle(''); setEditUrl(''); setEditIconUrl(''); } async function handleSaveEdit(id: string) { const cleanTitle = editTitle.trim(); const cleanUrl = editUrl.trim(); const cleanIconUrl = editIconUrl.trim(); if (!cleanTitle || !cleanUrl) return; try { const updated = await updateFavorite(id, { title: cleanTitle, url: cleanUrl, iconUrl: cleanIconUrl || null, }); setFavorites((prev) => prev.map((f) => (f.id === id ? updated : f))); cancelEdit(); } catch { setError(t('favorites.error')); } } // --- 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 (
{/* View mode toggle (edit mode only) */} {isEditMode && (
)} {/* 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) => ( ))}
) : (
{sortedFavorites.map((fav) => ( ))}
) )} {/* 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')} />
)}
); } // --- FavoriteTile sub-component --- type FavoriteTileProps = { fav: FavoriteLink; isEditMode: boolean; editingId: string | null; editTitle: string; editUrl: string; editIconUrl: string; 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; t: (key: string) => string; }; function FavoriteTile({ fav, isEditMode, editingId, editTitle, editUrl, editIconUrl, getFallbackLetter, onStartEdit, onCancelEdit, onSaveEdit, onDelete, onEditTitleChange, onEditUrlChange, onEditIconUrlChange, t, }: 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) */}
{getFallbackLetter(fav.title)} {fav.iconUrl && ( { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} /> )}
{fav.title}
{/* Action buttons (edit mode only, D-04) */} {isEditMode && !isEditing && (
)}
{/* 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="Logo-URL (optional)" />
)}
); }