From e9914f61cb711772c22fa0715944972766af9c5b Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 1 Jul 2026 10:48:58 +0200 Subject: [PATCH] feat(08-04): LinkWidget implementation + page.tsx wiring (GREEN) - LinkWidget: single-link widget reusing FavoriteLink backend (D-06) - Single-link enforcement: add form hidden when link exists - List (row) and tile (grid) view modes, switchable in edit mode - Inline add/edit/delete forms in edit mode - Letter fallback span + icon with onError hide (T-08-11) - Links with target="_blank" rel="noreferrer" (T-08-12) - i18n keys added to de.json + en.json (link.*) - wireLinkWidget(LinkWidget) added to portal page.tsx - All 7 link-widget tests pass (GREEN); tsc --noEmit clean --- apps/web/src/app/(portal)/page.tsx | 4 +- .../dashboard/widgets/link-widget.tsx | 381 ++++++++++++++++++ apps/web/src/messages/de.json | 14 +- apps/web/src/messages/en.json | 14 +- 4 files changed, 410 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/dashboard/widgets/link-widget.tsx diff --git a/apps/web/src/app/(portal)/page.tsx b/apps/web/src/app/(portal)/page.tsx index 14a51cd..1d72c56 100644 --- a/apps/web/src/app/(portal)/page.tsx +++ b/apps/web/src/app/(portal)/page.tsx @@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl'; import { DashboardGrid } from '@/components/dashboard/dashboard-grid'; import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle'; import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal'; -import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget, wireStopwatchWidget, wireFavoritesWidget } from '@/components/dashboard/widget-registry'; +import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget, wireStopwatchWidget, wireFavoritesWidget, wireLinkWidget } from '@/components/dashboard/widget-registry'; import { ClockWidget } from '@/components/dashboard/widgets/clock-widget'; import { SearchWidget } from '@/components/dashboard/widgets/search-widget'; import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget'; @@ -13,6 +13,7 @@ import { NoteWidget } from '@/components/dashboard/widgets/note-widget'; import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widget'; import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget'; import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget'; +import { LinkWidget } from '@/components/dashboard/widgets/link-widget'; import { useDashboardStore } from '@/lib/stores/dashboard-store'; import type { WidgetType } from '@/components/dashboard/widget-registry'; @@ -24,6 +25,7 @@ wireNoteWidget(NoteWidget); wireCalculatorWidget(CalculatorWidget); wireStopwatchWidget(StopwatchWidget); wireFavoritesWidget(FavoritesWidget); +wireLinkWidget(LinkWidget); export default function DashboardPage() { const t = useTranslations('widgets'); diff --git a/apps/web/src/components/dashboard/widgets/link-widget.tsx b/apps/web/src/components/dashboard/widgets/link-widget.tsx new file mode 100644 index 0000000..412b64b --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/link-widget.tsx @@ -0,0 +1,381 @@ +'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('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('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('link.error'); + } + } + + // --- Delete link --- + async function handleDelete() { + if (!link) return; + try { + await deleteFavorite(link.id); + setLink(null); + cancelEdit(); + } catch { + setError('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' && ( + + )} + + {/* Link display — tile (grid) view */} + {!loading && link && !isEditing && viewMode === 'grid' && ( + + )} + + {/* 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')} + /> + +
+ )} +
+ ); +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 374f008..337b8aa 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -215,7 +215,19 @@ }, "link": { "name": "Link", - "description": "Einzelner Schnellzugriff-Link" + "description": "Einzelner Schnellzugriff-Link", + "loading": "Link wird geladen...", + "empty": "Noch kein Link gesetzt.", + "addTitle": "Titel", + "addUrl": "URL", + "addButton": "Hinzufuegen", + "listView": "Liste", + "gridView": "Kachel", + "editButton": "Link bearbeiten", + "deleteButton": "Link loeschen", + "saveButton": "Speichern", + "cancelButton": "Abbrechen", + "error": "Fehler beim Laden des Links" }, "stopwatch": { "name": "Stoppuhr", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 7a4d63f..afb9163 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -215,7 +215,19 @@ }, "link": { "name": "Link", - "description": "Single quick-access link" + "description": "Single quick-access link", + "loading": "Loading link...", + "empty": "No link set yet.", + "addTitle": "Title", + "addUrl": "URL", + "addButton": "Add", + "listView": "List", + "gridView": "Tile", + "editButton": "Edit link", + "deleteButton": "Delete link", + "saveButton": "Save", + "cancelButton": "Cancel", + "error": "Error loading link" }, "stopwatch": { "name": "Stopwatch",