Files
tessera-ctl/apps/web/src/components/dashboard/widgets/favorites-widget.tsx
T
schalli 69fe706580 fix(quick-260921-jt4): vier <img onError>-Avatarbilder tragen aria-hidden (D-03)
header.tsx, account-settings-form.tsx, favorites-widget.tsx (zweimal):
alle vier tragen bereits alt="", sind also schon aus dem
Zugaenglichkeitsbaum genommen; aria-hidden sagt dasselbe nur
ausdruecklich. Ehrliche Einordnung: richtige Auszeichnung, verbessert
fuer keinen Menschen etwas — der Befund verschwindet, weil die Regel
ein verborgenes Element nicht mehr betrachtet. onError ist ein
Ladefehler, keine Bedienung: hier gab es nie einen Tastaturweg zu
schaffen. Keine Unterdrueckung, kein biome-ignore.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 14:57:01 +02:00

689 lines
23 KiB
TypeScript

'use client';
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { updateWidgetConfig } from '@/lib/dashboard-api';
import {
createFavorite,
deleteFavorite,
fetchFavorites,
reorderFavorites,
updateFavorite,
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;
/**
* 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<FavoriteLink[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<string>(
typeof config.title === 'string' ? config.title : '',
);
const titleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
useEffect(() => {
return () => clearTimeout(titleTimerRef.current);
}, []);
function handleTitleChange(e: React.ChangeEvent<HTMLInputElement>) {
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('');
// Inline edit state
const [editingId, setEditingId] = useState<string | null>(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],
);
// 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 ---
async function handleAdd(e: FormEvent<HTMLFormElement>) {
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 (
<div className="flex h-full flex-col overflow-hidden">
{/* Header: optional title (quick-260916-iex) — mirrors note-widget.tsx look */}
{showHeader && (
<div className="flex items-center gap-2 border-b border-border px-1.5 py-1.5">
{isEditMode ? (
<input
type="text"
className="flex-1 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground widgetNoDrag"
value={title}
onChange={handleTitleChange}
placeholder={t('favorites.titlePlaceholder')}
aria-label={t('favorites.titleLabel')}
/>
) : (
hasTitle && (
<h2 className="truncate text-sm font-semibold text-foreground">
{title.trim()}
</h2>
)
)}
</div>
)}
<div className="flex flex-1 flex-col gap-2 overflow-auto p-1">
{/* 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.
<div
className="flex gap-1 widgetNoDrag"
role="toolbar"
aria-label={t('favorites.viewModeLabel')}
>
<button
type="button"
onClick={() => handleViewMode('list')}
className={`px-2 py-1 text-xs rounded ${
viewMode === 'list'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
}`}
>
{t('favorites.listView')}
</button>
<button
type="button"
onClick={() => handleViewMode('grid')}
className={`px-2 py-1 text-xs rounded ${
viewMode === 'grid'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
}`}
>
{t('favorites.gridView')}
</button>
</div>
)}
{/* Status messages */}
{loading && (
<p className="text-xs text-muted-foreground">{t('favorites.loading')}</p>
)}
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
{/* Favorites list / grid */}
{!loading && sortedFavorites.length === 0 && (
<p className="text-xs text-muted-foreground">{t('favorites.empty')}</p>
)}
{!loading && sortedFavorites.length > 0 && (
viewMode === 'grid' ? (
<div
data-testid="favorites-grid"
className="grid grid-cols-3 gap-2"
>
{sortedFavorites.map((fav, index) => (
<FavoriteTile
key={fav.id}
fav={fav}
isEditMode={isEditMode}
editingId={editingId}
editTitle={editTitle}
editUrl={editUrl}
editIconUrl={editIconUrl}
getFallbackLetter={getFallbackLetter}
onStartEdit={startEdit}
onCancelEdit={cancelEdit}
onSaveEdit={handleSaveEdit}
onDelete={handleDelete}
onEditTitleChange={setEditTitle}
onEditUrlChange={setEditUrl}
onEditIconUrlChange={setEditIconUrl}
canMoveUp={index > 0}
canMoveDown={index < sortedFavorites.length - 1}
onMove={(fid, dir) => void handleMove(fid, dir)}
t={t}
/>
))}
</div>
) : (
<div
data-testid="favorites-list"
className="flex flex-col gap-1"
>
{sortedFavorites.map((fav, index) => (
<FavoriteTile
key={fav.id}
fav={fav}
isEditMode={isEditMode}
editingId={editingId}
editTitle={editTitle}
editUrl={editUrl}
editIconUrl={editIconUrl}
getFallbackLetter={getFallbackLetter}
onStartEdit={startEdit}
onCancelEdit={cancelEdit}
onSaveEdit={handleSaveEdit}
onDelete={handleDelete}
onEditTitleChange={setEditTitle}
onEditUrlChange={setEditUrl}
onEditIconUrlChange={setEditIconUrl}
canMoveUp={index > 0}
canMoveDown={index < sortedFavorites.length - 1}
onMove={(fid, dir) => void handleMove(fid, dir)}
t={t}
/>
))}
</div>
)
)}
{/* Add form (edit mode only, D-04) */}
{isEditMode && (
<form
onSubmit={(e) => void handleAdd(e)}
className="flex flex-col gap-1 pt-1 border-t border-border widgetNoDrag"
>
<input
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder={t('favorites.addTitle')}
/>
<input
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
placeholder={t('favorites.addUrl')}
/>
<button
type="submit"
className="w-full rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:opacity-90 transition-opacity"
>
{t('favorites.addButton')}
</button>
</form>
)}
</div>
</div>
);
}
// --- 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 (
<div className="relative flex-shrink-0 w-5 h-5">
<span
data-testid={`letter-fallback-${fav.id}`}
className="absolute inset-0 flex items-center justify-center text-xs font-semibold bg-muted rounded"
>
{getFallbackLetter(fav.title)}
</span>
{stage === 'proxy' && proxySrc && (
<img
data-testid={`icon-proxy-${fav.id}`}
src={proxySrc}
alt=""
aria-hidden="true"
width={20}
height={20}
loading="lazy"
className="absolute inset-0 w-5 h-5 rounded"
onError={() => setStage('direct')}
/>
)}
{stage === 'direct' && directSrc && (
<img
data-testid={`icon-direct-${fav.id}`}
src={directSrc}
alt=""
aria-hidden="true"
width={20}
height={20}
loading="lazy"
referrerPolicy="no-referrer"
className="absolute inset-0 w-5 h-5 rounded"
onError={() => setStage('none')}
/>
)}
</div>
);
}
// --- 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<void>;
onDelete: (id: string) => Promise<void>;
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;
};
function FavoriteTile({
fav,
isEditMode,
editingId,
editTitle,
editUrl,
editIconUrl,
getFallbackLetter,
onStartEdit,
onCancelEdit,
onSaveEdit,
onDelete,
onEditTitleChange,
onEditUrlChange,
onEditIconUrlChange,
canMoveUp,
canMoveDown,
onMove,
t,
}: FavoriteTileProps) {
const isEditing = editingId === fav.id;
return (
<div className="flex flex-col">
{/* Main link row */}
<div className="flex items-center gap-2">
{/* Anchor — opens in new tab, no server-side redirect (T-08-08) */}
<a
href={fav.url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 flex-1 min-w-0 hover:underline widgetNoDrag"
onClick={(e) => isEditMode && e.preventDefault()} // block navigation in edit mode
>
{/* Icon container with letter fallback (T-08-07 — no dangerouslySetInnerHTML) */}
<FavoriteIcon
key={`${fav.iconUrl ?? ''}|${fav.url}`}
fav={fav}
getFallbackLetter={getFallbackLetter}
/>
<span className="text-xs truncate">{fav.title}</span>
</a>
{/* Action buttons (edit mode only, D-04) */}
{isEditMode && !isEditing && (
<div className="flex gap-1 flex-shrink-0 widgetNoDrag">
{/* Sortierpfeile (260917-jdd) — kein Drag & Drop hier, das
kollidiert mit dem Ziehen der Kachel in react-grid-layout */}
<button
type="button"
aria-label={t('favorites.moveUpButton')}
title={t('favorites.moveUpButton')}
className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:hover:text-muted-foreground"
disabled={!canMoveUp}
onClick={() => onMove(fav.id, 'up')}
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="currentColor"
aria-hidden="true"
>
<path d="M12 8.6 5.4 15.2l1.4 1.4L12 11.4l5.2 5.2 1.4-1.4z" />
</svg>
</button>
<button
type="button"
aria-label={t('favorites.moveDownButton')}
title={t('favorites.moveDownButton')}
className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:hover:text-muted-foreground"
disabled={!canMoveDown}
onClick={() => onMove(fav.id, 'down')}
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="currentColor"
aria-hidden="true"
>
<path d="m12 15.4 6.6-6.6-1.4-1.4L12 12.6 6.8 7.4 5.4 8.8z" />
</svg>
</button>
<button
type="button"
aria-label={t('favorites.editButton')}
title={t('favorites.editButton')}
className="p-0.5 text-muted-foreground hover:text-foreground"
onClick={() => onStartEdit(fav)}
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="currentColor"
aria-hidden="true"
>
<path d="M4 20h4.6L19.2 9.4a2.1 2.1 0 0 0 0-3l-1.6-1.6a2.1 2.1 0 0 0-3 0L4 15.4V20Zm2-2v-1.8L16.1 6.1l1.8 1.8L7.8 18H6Zm9.1-12.9 1.8-1.8 1.8 1.8-1.8 1.8-1.8-1.8Z" />
</svg>
</button>
<button
type="button"
aria-label={t('favorites.deleteButton')}
title={t('favorites.deleteButton')}
className="p-0.5 text-muted-foreground hover:text-destructive"
onClick={() => void onDelete(fav.id)}
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="currentColor"
aria-hidden="true"
>
<path d="M7 21c-.6 0-1.1-.2-1.5-.7A2 2 0 0 1 5 18.9V8H4V6h5V4h6v2h5v2h-1v10.9c0 .6-.2 1.1-.7 1.5-.4.4-.9.6-1.5.6H7ZM17 8H7v10.9l.1.1h9.8l.1-.1V8Zm-8 9h2v-7H9v7Zm4 0h2v-7h-2v7Z" />
</svg>
</button>
</div>
)}
</div>
{/* Inline edit form */}
{isEditMode && isEditing && (
<div className="flex flex-col gap-1 mt-1 pl-7 widgetNoDrag">
<input
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
value={editTitle}
onChange={(e) => onEditTitleChange(e.target.value)}
placeholder={t('favorites.addTitle')}
/>
<input
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
value={editUrl}
onChange={(e) => onEditUrlChange(e.target.value)}
placeholder={t('favorites.addUrl')}
/>
<input
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
value={editIconUrl}
onChange={(e) => onEditIconUrlChange(e.target.value)}
placeholder="Logo-URL (optional)"
/>
<div className="flex gap-1">
<button
type="button"
className="flex-1 rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:opacity-90 transition-opacity"
onClick={() => void onSaveEdit(fav.id)}
>
{t('favorites.saveButton')}
</button>
<button
type="button"
className="flex-1 rounded bg-muted px-2 py-1 text-xs text-muted-foreground hover:bg-muted/80 transition-colors"
onClick={onCancelEdit}
>
{t('favorites.cancelButton')}
</button>
</div>
</div>
)}
</div>
);
}