b15c74632b
- API: ausdrueckliche iconUrl wird nur auf Form (http/https, <= 2048) geprueft und auch gespeichert, wenn der Server sie nicht abrufen kann; keine 422 mehr - Erkennung: Seite mit Fehlerstatus, aber HTML mit <link rel=icon>, liefert diesen Verweis (docuvita); og:image einer Fehlerseite zaehlt nicht - Kachel: Proxy -> iconUrl direkt im Browser (no-referrer, nur http/https) -> Origin-Favicon -> Buchstabe - Meldung iconUrlUnreachable (de/en) entfernt, Hinweis zum Vorrang angepasst Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
888 lines
32 KiB
TypeScript
888 lines
32 KiB
TypeScript
'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<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('');
|
|
// 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<File | null>(null);
|
|
const newIconFileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Inline edit state
|
|
const [editingId, setEditingId] = useState<string | null>(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<File | null>(null);
|
|
const [editError, setEditError] = useState<string | null>(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<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,
|
|
});
|
|
|
|
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 (
|
|
<div className="flex h-full flex-col overflow-hidden">
|
|
{/* Header: optional title (quick-260916-iex) — mirrors note-widget.tsx look */}
|
|
{showHeader && (
|
|
<div className="widget-head">
|
|
<WidgetIcon type="favorites" />
|
|
{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 px-2 pb-2 ${showHeader ? '' : 'pt-2'}`}>
|
|
{/* 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-foreground text-background'
|
|
: 'bg-muted text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
>
|
|
{t('favorites.listView')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleViewMode('grid')}
|
|
className={`px-2 py-1 text-xs rounded ${
|
|
viewMode === 'grid'
|
|
? 'bg-foreground text-background'
|
|
: 'bg-muted text-muted-foreground hover:text-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-[repeat(auto-fill,minmax(76px,1fr))] gap-1"
|
|
>
|
|
{sortedFavorites.map((fav, index) => (
|
|
<FavoriteTile
|
|
launcher
|
|
key={fav.id}
|
|
fav={fav}
|
|
isEditMode={isEditMode}
|
|
editingId={editingId}
|
|
editTitle={editTitle}
|
|
editUrl={editUrl}
|
|
editIconUrl={editIconUrl}
|
|
editError={editError}
|
|
editBusy={editBusy}
|
|
getFallbackLetter={getFallbackLetter}
|
|
onStartEdit={startEdit}
|
|
onCancelEdit={cancelEdit}
|
|
onSaveEdit={handleSaveEdit}
|
|
onDelete={handleDelete}
|
|
onEditTitleChange={setEditTitle}
|
|
onEditUrlChange={setEditUrl}
|
|
onEditIconUrlChange={setEditIconUrl}
|
|
onEditIconFileChange={setEditIconFile}
|
|
onRemoveIcon={handleRemoveIcon}
|
|
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-0.5"
|
|
>
|
|
{sortedFavorites.map((fav, index) => (
|
|
<FavoriteTile
|
|
key={fav.id}
|
|
fav={fav}
|
|
isEditMode={isEditMode}
|
|
editingId={editingId}
|
|
editTitle={editTitle}
|
|
editUrl={editUrl}
|
|
editIconUrl={editIconUrl}
|
|
editError={editError}
|
|
editBusy={editBusy}
|
|
getFallbackLetter={getFallbackLetter}
|
|
onStartEdit={startEdit}
|
|
onCancelEdit={cancelEdit}
|
|
onSaveEdit={handleSaveEdit}
|
|
onDelete={handleDelete}
|
|
onEditTitleChange={setEditTitle}
|
|
onEditUrlChange={setEditUrl}
|
|
onEditIconUrlChange={setEditIconUrl}
|
|
onEditIconFileChange={setEditIconFile}
|
|
onRemoveIcon={handleRemoveIcon}
|
|
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')}
|
|
/>
|
|
{/* 260923-lrr: eigenes Symbol beim Hinzufuegen */}
|
|
<label className="text-xs text-muted-foreground">
|
|
{t('favorites.iconUploadLabel')}
|
|
<input
|
|
type="file"
|
|
data-testid="favorite-add-icon-upload"
|
|
ref={newIconFileInputRef}
|
|
accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon,image/svg+xml,.ico,.svg"
|
|
className="mt-0.5 block w-full text-xs"
|
|
onChange={(e) => setNewIconFile(e.target.files?.[0] ?? null)}
|
|
/>
|
|
</label>
|
|
<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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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=<iconVersion>` — 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 (
|
|
<div className={`relative flex shrink-0 items-center justify-center bg-muted ${box}`}>
|
|
<span
|
|
data-testid={`letter-fallback-${fav.id}`}
|
|
className={`absolute inset-0 flex items-center justify-center font-semibold text-muted-foreground ${large ? 'text-base' : 'text-xs'}`}
|
|
>
|
|
{getFallbackLetter(fav.title)}
|
|
</span>
|
|
{stage === 'proxy' && proxySrc && (
|
|
<img
|
|
data-testid={`icon-proxy-${fav.id}`}
|
|
src={proxySrc}
|
|
alt=""
|
|
aria-hidden="true"
|
|
width={px}
|
|
height={px}
|
|
loading="lazy"
|
|
className={`relative rounded-sm ${img}`}
|
|
onError={() => setStage(0)}
|
|
/>
|
|
)}
|
|
{directSrc && (
|
|
<img
|
|
data-testid={`icon-direct-${fav.id}`}
|
|
src={directSrc}
|
|
alt=""
|
|
aria-hidden="true"
|
|
width={px}
|
|
height={px}
|
|
loading="lazy"
|
|
referrerPolicy="no-referrer"
|
|
className={`relative rounded-sm ${img}`}
|
|
onError={() => setStage((prev) => (typeof prev === 'number' ? prev + 1 : 0))}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- 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<void>;
|
|
onDelete: (id: string) => Promise<void>;
|
|
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<void>;
|
|
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 (
|
|
<div className="flex flex-col">
|
|
{/* Main link row */}
|
|
<div className={launcher ? 'flex flex-col items-stretch gap-1' : '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={
|
|
launcher
|
|
? 'flex min-w-0 flex-col items-center gap-1.5 rounded-lg px-1 py-2 text-center transition-colors hover:bg-muted widgetNoDrag'
|
|
: 'flex h-9 min-w-0 flex-1 items-center gap-2.5 rounded-md px-1.5 transition-colors hover:bg-muted 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.iconVersion ?? 0}|${fav.uploadedIconMime ?? ''}`}
|
|
fav={fav}
|
|
getFallbackLetter={getFallbackLetter}
|
|
large={launcher}
|
|
/>
|
|
|
|
<span className={launcher ? 'line-clamp-2 w-full break-words text-xs leading-4' : 'truncate text-sm'}>
|
|
{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={t('favorites.iconUrlPlaceholder')}
|
|
aria-label={t('favorites.iconUrlPlaceholder')}
|
|
/>
|
|
{/* 260923-lrr: eigenes Symbol — Datei-Feld, Hinweis, ggf. Entfernen-Knopf */}
|
|
<label className="text-xs text-muted-foreground">
|
|
{t('favorites.iconUploadLabel')}
|
|
<input
|
|
type="file"
|
|
data-testid={`favorite-icon-upload-${fav.id}`}
|
|
accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon,image/svg+xml,.ico,.svg"
|
|
className="mt-0.5 block w-full text-xs"
|
|
onChange={(e) => onEditIconFileChange(e.target.files?.[0] ?? null)}
|
|
/>
|
|
</label>
|
|
<p className="text-xs text-muted-foreground">{t('favorites.iconUploadHint')}</p>
|
|
{fav.uploadedIconMime != null && (
|
|
<>
|
|
<p className="text-xs text-muted-foreground">{t('favorites.iconUploadedHint')}</p>
|
|
<button
|
|
type="button"
|
|
className="rounded bg-muted px-2 py-1 text-xs text-muted-foreground hover:bg-muted/80 transition-colors"
|
|
onClick={() => void onRemoveIcon(fav.id)}
|
|
>
|
|
{t('favorites.iconRemoveButton')}
|
|
</button>
|
|
</>
|
|
)}
|
|
{editError && (
|
|
<p role="alert" className="text-xs text-destructive">
|
|
{editError}
|
|
</p>
|
|
)}
|
|
<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 disabled:opacity-50"
|
|
disabled={editBusy}
|
|
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>
|
|
);
|
|
}
|