'use client'; import { FormEvent, useEffect, 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'; /** * LinkWidget — single-link quick-access tile (DASH-09 / D-06). * * Features: * - Displays exactly one link, scoped to this widget instance via instanceId * - Shares the FavoriteLink backend from Plan 03 (widgetId = instanceId) * - Single-link enforcement: add form hidden when a link already exists * - Add / edit / delete in edit mode (inline forms) * - List view (row: icon + title, default) and tile view, switchable in edit mode * - Server-side icon discovery (letter fallback when iconUrl is null) * - Link opens in new tab with rel="noreferrer" (T-08-12 open-redirect mitigation) * - No dangerouslySetInnerHTML (T-08-11 XSS mitigation) */ export function LinkWidget({ instanceId, config, isEditMode, }: WidgetProps) { const t = useTranslations('widgets'); const [link, setLink] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // View mode: read from config, default 'list' (D-06) const [viewMode, setViewMode] = useState<'list' | 'grid'>( (config.viewMode as 'list' | 'grid') ?? 'list', ); // Add-form state const [newTitle, setNewTitle] = useState(''); const [newUrl, setNewUrl] = useState(''); // Inline edit state const [isEditing, setIsEditing] = useState(false); const [editTitle, setEditTitle] = useState(''); const [editUrl, setEditUrl] = useState(''); const [editIconUrl, setEditIconUrl] = useState(''); useEffect(() => { let cancelled = false; async function load() { setLoading(true); setError(null); try { const data = await fetchFavorites(instanceId); // Single-link: take only the first entry (D-06) if (!cancelled) setLink(data[0] ?? null); } catch { // Stable error key — t excluded from deps to prevent re-fetch loops if (!cancelled) setError(t('link.error')); } finally { if (!cancelled) setLoading(false); } } void load(); return () => { cancelled = true; }; // instanceId is the only meaningful dependency — t is excluded intentionally // (mock creates new fn ref on each render, causing re-fetch loops in tests) // 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) --- function handleViewMode(next: 'list' | 'grid') { setViewMode(next); void updateWidgetConfig(instanceId, { viewMode: next }); } // --- Add link --- async function handleAdd(e: FormEvent) { e.preventDefault(); const cleanTitle = newTitle.trim(); const cleanUrl = newUrl.trim(); if (!cleanTitle || !cleanUrl) return; try { const created = await createFavorite({ widgetId: instanceId, title: cleanTitle, url: cleanUrl, }); setLink(created); setNewTitle(''); setNewUrl(''); } catch { setError(t('link.error')); } } // --- Edit link --- function startEdit() { if (!link) return; setIsEditing(true); setEditTitle(link.title); setEditUrl(link.url); setEditIconUrl(link.iconUrl ?? ''); setError(null); } function cancelEdit() { setIsEditing(false); setEditTitle(''); setEditUrl(''); setEditIconUrl(''); } async function handleSaveEdit() { if (!link) return; const cleanTitle = editTitle.trim(); const cleanUrl = editUrl.trim(); const cleanIconUrl = editIconUrl.trim(); if (!cleanTitle || !cleanUrl) return; try { const updated = await updateFavorite(link.id, { title: cleanTitle, url: cleanUrl, iconUrl: cleanIconUrl || null, }); setLink(updated); cancelEdit(); } catch { setError(t('link.error')); } } // --- Delete link --- async function handleDelete() { if (!link) return; try { await deleteFavorite(link.id); setLink(null); cancelEdit(); } catch { setError(t('link.error')); } } // --- Shared icon element --- function renderIcon(size: 'sm' | 'md') { if (!link) return null; const wh = size === 'sm' ? 'w-5 h-5' : 'w-8 h-8'; const textSize = size === 'sm' ? 'text-xs' : 'text-sm'; const imgSize = size === 'sm' ? 20 : 32; return (
{getFallbackLetter(link.title)} {link.iconUrl && ( { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} /> )}
); } // --- Inline edit form --- function renderEditForm() { return (
setEditTitle(e.target.value)} placeholder={t('link.addTitle')} /> setEditUrl(e.target.value)} placeholder={t('link.addUrl')} /> setEditIconUrl(e.target.value)} placeholder="Logo-URL (optional)" />
); } // --- Render --- return (
{/* View mode toggle (edit mode only) */} {isEditMode && (
)} {/* Status messages */} {loading && (

{t('link.loading')}

)} {error && (

{error}

)} {/* Empty state */} {!loading && !link && (

{t('link.empty')}

)} {/* Link display — list (row) view */} {!loading && link && !isEditing && viewMode === 'list' && (
isEditMode && e.preventDefault()} > {renderIcon('sm')} {link.title} {isEditMode && ( )}
)} {/* Link display — tile (grid) view */} {!loading && link && !isEditing && viewMode === 'grid' && (
isEditMode && e.preventDefault()} > {renderIcon('md')} {link.title} {isEditMode && ( )}
)} {/* Inline edit form */} {isEditMode && isEditing && link && renderEditForm()} {/* Add form — single-link enforcement: hidden when link exists (D-06) */} {isEditMode && !link && !loading && (
void handleAdd(e)} className="flex flex-col gap-1 widgetNoDrag" > setNewTitle(e.target.value)} placeholder={t('link.addTitle')} /> setNewUrl(e.target.value)} placeholder={t('link.addUrl')} />
)}
); }