feat(260923-lrr): Web — versionierte Symbol-Adresse, Datei-Auswahl, Entfernen-Knopf

- favorites-api.ts: FavoriteRequestError (Grund iconUrlUnreachable/iconTooLarge/
  iconInvalidType/iconUploadFailed), uploadFavoriteIcon/removeFavoriteIcon,
  FAVORITE_ICON_MAX_BYTES, Felder uploadedIconMime/iconVersion
- favorites-widget.tsx: Proxy-Bild traegt ?v=<iconVersion> (Cache-Bust nach
  Aenderung), Remount-Key um iconVersion/uploadedIconMime erweitert, eigenes
  Symbol im Hinzufuegen- und Bearbeitungsformular waehlen, Entfernen-Knopf,
  Fehlermeldung (role=alert) im Formular, Platzhalter jetzt uebersetzbar
- de.json/en.json: neun neue Texte unter widgets.favorites
- CHANGELOG.md und Anwenderhandbuch ergaenzt

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 16:13:05 +02:00
parent 7704372c3c
commit 61f95c8c52
8 changed files with 749 additions and 22 deletions
@@ -6,17 +6,31 @@ vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
// Mock favorites-api — all 5 functions as controllable vi.fn() spies
// Mock favorites-api — all functions as controllable vi.fn() spies
// (reorderFavorites hinzugefuegt, 260917-jdd — ohne diesen Eintrag
// importiert das Widget undefined und der Klick auf einen Sortierpfeil
// wirft TypeError.)
vi.mock('@/lib/favorites-api', () => ({
fetchFavorites: vi.fn(),
createFavorite: vi.fn(),
updateFavorite: vi.fn(),
deleteFavorite: vi.fn(),
reorderFavorites: vi.fn(),
}));
//
// 260923-lrr: die Fabrik ist jetzt ASYNCHRON und uebernimmt per
// `vi.importActual` die ECHTE `FavoriteRequestError`-Klasse und
// `FAVORITE_ICON_MAX_BYTES` — ein `instanceof FavoriteRequestError` im
// Widget muss gegen dieselbe Klasse pruefen, die die Tests werfen, sonst
// faellt `favoriteErrorKey` immer auf `favorites.error` zurueck.
// `uploadFavoriteIcon`/`removeFavoriteIcon` kommen als `vi.fn()` dazu.
vi.mock('@/lib/favorites-api', async () => {
const actual = await vi.importActual<typeof import('@/lib/favorites-api')>('@/lib/favorites-api');
return {
FavoriteRequestError: actual.FavoriteRequestError,
FAVORITE_ICON_MAX_BYTES: actual.FAVORITE_ICON_MAX_BYTES,
fetchFavorites: vi.fn(),
createFavorite: vi.fn(),
updateFavorite: vi.fn(),
deleteFavorite: vi.fn(),
reorderFavorites: vi.fn(),
uploadFavoriteIcon: vi.fn(),
removeFavoriteIcon: vi.fn(),
};
});
// Mock dashboard-api for updateWidgetConfig (view mode persistence)
vi.mock('@/lib/dashboard-api', () => ({
@@ -24,11 +38,14 @@ vi.mock('@/lib/dashboard-api', () => ({
}));
import {
FavoriteRequestError,
fetchFavorites,
createFavorite,
updateFavorite,
deleteFavorite,
reorderFavorites,
uploadFavoriteIcon,
removeFavoriteIcon,
} from '@/lib/favorites-api';
import { updateWidgetConfig } from '@/lib/dashboard-api';
@@ -41,6 +58,8 @@ const mockCreate = createFavorite as ReturnType<typeof vi.fn>;
const mockUpdate = updateFavorite as ReturnType<typeof vi.fn>;
const mockDelete = deleteFavorite as ReturnType<typeof vi.fn>;
const mockReorder = reorderFavorites as ReturnType<typeof vi.fn>;
const mockUploadIcon = uploadFavoriteIcon as ReturnType<typeof vi.fn>;
const mockRemoveIcon = removeFavoriteIcon as ReturnType<typeof vi.fn>;
const mockUpdateWidgetConfig = updateWidgetConfig as ReturnType<typeof vi.fn>;
const BASE_FAVORITES = [
@@ -450,7 +469,8 @@ describe('FavoritesWidget', () => {
});
const proxyImg = screen.getByTestId('icon-proxy-fav-id-1') as HTMLImageElement;
expect(proxyImg.getAttribute('src')).toBe('/api-proxy/favorites/fav-id-1/icon');
// 260923-lrr: die Proxy-Adresse traegt jetzt ?v=<iconVersion> (Cache-Bust).
expect(proxyImg.getAttribute('src')).toBe('/api-proxy/favorites/fav-id-1/icon?v=0');
expect(screen.queryByTestId('icon-direct-fav-id-1')).not.toBeInTheDocument();
act(() => {
@@ -573,4 +593,288 @@ describe('FavoritesWidget', () => {
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
});
// 260923-lrr: eigenes Symbol hochladen, Zwischenspeicher nach Aenderung
// erneuern (versionierte Symbol-Adresse, Datei-Auswahl beim Bearbeiten
// und Hinzufuegen, Entfernen-Knopf, Meldungen im Formular).
describe('Eigenes Symbol (quick-260923-lrr)', () => {
function openEditForGitHub() {
const editButtons = screen.getAllByRole('button', { name: /favorites\.editButton/i });
act(() => {
fireEvent.click(editButtons[0]);
});
}
it('Proxy-Bild traegt ?v=<iconVersion>; Zeile ohne iconVersion -> ?v=0', async () => {
mockFetch.mockResolvedValue([{ ...BASE_FAVORITES[0], iconVersion: 3 }, BASE_FAVORITES[1]]);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
});
const proxyImg = screen.getByTestId('icon-proxy-fav-id-1') as HTMLImageElement;
expect(proxyImg.getAttribute('src')).toBe('/api-proxy/favorites/fav-id-1/icon?v=3');
});
it('Zeile nur mit uploadedIconMime (iconUrl null) -> Proxy-Bild statt Direktbild', async () => {
mockFetch.mockResolvedValue([
{
id: 'fav-id-4',
widgetId: 'fav-1',
title: 'Eigenes',
url: 'https://eigenes.invalid',
iconUrl: null,
uploadedIconMime: 'image/png',
iconVersion: 1,
position: 0,
},
]);
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
await waitFor(() => {
expect(screen.getByText('Eigenes')).toBeInTheDocument();
});
expect(screen.getByTestId('icon-proxy-fav-id-4')).toBeInTheDocument();
expect(screen.queryByTestId('icon-direct-fav-id-4')).not.toBeInTheDocument();
});
it('Logo-Adress-Feld zeigt den Platzhalter favorites.iconUrlPlaceholder (kein fest verdrahteter Text mehr)', async () => {
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
});
openEditForGitHub();
expect(screen.getByPlaceholderText('favorites.iconUrlPlaceholder')).toBeInTheDocument();
});
it('Speichern mit neuer Logo-Adresse, updateFavorite liefert iconVersion 1 -> src des Proxy-Bildes endet danach auf ?v=1', async () => {
mockUpdate.mockResolvedValue({
...BASE_FAVORITES[0],
iconUrl: 'https://neu.invalid/icon.png',
iconVersion: 1,
});
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
});
openEditForGitHub();
const iconUrlField = screen.getByPlaceholderText('favorites.iconUrlPlaceholder');
act(() => {
fireEvent.change(iconUrlField, { target: { value: 'https://neu.invalid/icon.png' } });
});
const saveButton = screen.getByRole('button', { name: /favorites\.saveButton/i });
act(() => {
fireEvent.click(saveButton);
});
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith(
'fav-id-1',
expect.objectContaining({ iconUrl: 'https://neu.invalid/icon.png' }),
);
});
await waitFor(() => {
const proxyImg = screen.getByTestId('icon-proxy-fav-id-1') as HTMLImageElement;
expect(proxyImg.getAttribute('src')).toBe('/api-proxy/favorites/fav-id-1/icon?v=1');
});
});
it('Datei im Bearbeitungsformular waehlen, Speichern -> updateFavorite, danach uploadFavoriteIcon(\'fav-id-1\', Datei); Formular schliesst', async () => {
mockUpdate.mockResolvedValue({ ...BASE_FAVORITES[0], iconVersion: 1 });
mockUploadIcon.mockResolvedValue({
...BASE_FAVORITES[0],
uploadedIconMime: 'image/png',
iconVersion: 2,
});
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
});
openEditForGitHub();
const fileField = screen.getByTestId('favorite-icon-upload-fav-id-1');
const file = new File([new Uint8Array([1, 2, 3])], 'icon.png', { type: 'image/png' });
act(() => {
fireEvent.change(fileField, { target: { files: [file] } });
});
const saveButton = screen.getByRole('button', { name: /favorites\.saveButton/i });
act(() => {
fireEvent.click(saveButton);
});
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith('fav-id-1', expect.objectContaining({ title: 'GitHub' }));
});
await waitFor(() => {
expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-1', file);
});
await waitFor(() => {
expect(screen.queryByTestId('favorite-icon-upload-fav-id-1')).not.toBeInTheDocument();
});
});
it('updateFavorite wirft FavoriteRequestError(iconUrlUnreachable) -> Meldung im Formular (role alert), Formular bleibt offen, uploadFavoriteIcon NICHT aufgerufen', async () => {
mockUpdate.mockRejectedValue(new FavoriteRequestError('iconUrlUnreachable'));
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
});
openEditForGitHub();
const fileField = screen.getByTestId('favorite-icon-upload-fav-id-1');
const file = new File([new Uint8Array([1])], 'icon.png', { type: 'image/png' });
act(() => {
fireEvent.change(fileField, { target: { files: [file] } });
});
const saveButton = screen.getByRole('button', { name: /favorites\.saveButton/i });
act(() => {
fireEvent.click(saveButton);
});
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('favorites.iconUrlUnreachable');
});
expect(mockUploadIcon).not.toHaveBeenCalled();
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
});
it('Upload wirft FavoriteRequestError(iconTooLarge) -> favorites.iconTooLarge im Formular, Formular bleibt offen', async () => {
mockUpdate.mockResolvedValue({ ...BASE_FAVORITES[0] });
mockUploadIcon.mockRejectedValue(new FavoriteRequestError('iconTooLarge'));
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
});
openEditForGitHub();
const fileField = screen.getByTestId('favorite-icon-upload-fav-id-1');
const file = new File([new Uint8Array([1])], 'icon.png', { type: 'image/png' });
act(() => {
fireEvent.change(fileField, { target: { files: [file] } });
});
const saveButton = screen.getByRole('button', { name: /favorites\.saveButton/i });
act(() => {
fireEvent.click(saveButton);
});
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('favorites.iconTooLarge');
});
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
});
it('Knopf favorites.iconRemoveButton nur bei gesetztem uploadedIconMime; Klick -> removeFavoriteIcon(\'fav-id-1\'), danach verschwindet der Knopf, Formular bleibt offen', async () => {
mockFetch.mockResolvedValue([
{ ...BASE_FAVORITES[0], uploadedIconMime: 'image/png', iconVersion: 1 },
BASE_FAVORITES[1],
]);
mockRemoveIcon.mockResolvedValue({ ...BASE_FAVORITES[0], uploadedIconMime: null, iconVersion: 2 });
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
});
openEditForGitHub();
const removeButton = screen.getByRole('button', { name: 'favorites.iconRemoveButton' });
act(() => {
fireEvent.click(removeButton);
});
await waitFor(() => {
expect(mockRemoveIcon).toHaveBeenCalledWith('fav-id-1');
});
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'favorites.iconRemoveButton' })).not.toBeInTheDocument();
});
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
});
it('kein Entfernen-Knopf ohne hochgeladenes Symbol', async () => {
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
});
openEditForGitHub();
expect(screen.queryByRole('button', { name: 'favorites.iconRemoveButton' })).not.toBeInTheDocument();
});
it('Hinzufuegen mit gewaehlter Datei -> createFavorite, danach uploadFavoriteIcon(created.id, Datei)', async () => {
mockFetch.mockResolvedValue([]);
mockUploadIcon.mockResolvedValue({
id: 'fav-id-new',
widgetId: 'fav-1',
title: 'New Link',
url: 'https://example.com',
iconUrl: null,
uploadedIconMime: 'image/png',
iconVersion: 1,
position: 2,
});
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
await waitFor(() => {
expect(screen.getByText('favorites.empty')).toBeInTheDocument();
});
const titleInput = screen.getByPlaceholderText('favorites.addTitle');
const urlInput = screen.getByPlaceholderText('favorites.addUrl');
const fileInput = screen.getByTestId('favorite-add-icon-upload');
const file = new File([new Uint8Array([1, 2, 3])], 'icon.png', { type: 'image/png' });
act(() => {
fireEvent.change(titleInput, { target: { value: 'New Link' } });
fireEvent.change(urlInput, { target: { value: 'https://example.com' } });
fireEvent.change(fileInput, { target: { files: [file] } });
});
const addButton = screen.getByRole('button', { name: /favorites\.addButton/i });
act(() => {
fireEvent.click(addButton);
});
await waitFor(() => {
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ widgetId: 'fav-1', title: 'New Link', url: 'https://example.com' }),
);
});
await waitFor(() => {
expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-new', file);
});
await waitFor(() => {
expect(screen.getByText('New Link')).toBeInTheDocument();
});
});
});
});
@@ -4,11 +4,14 @@ 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';
@@ -16,6 +19,18 @@ 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 (`iconUrlUnreachable`/`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).
*
@@ -79,12 +94,21 @@ export function FavoritesWidget({
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(
@@ -164,6 +188,10 @@ export function FavoritesWidget({
}
// --- 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();
@@ -179,12 +207,24 @@ export function FavoritesWidget({
url: cleanUrl,
iconUrl: cleanIconUrl || undefined,
});
setFavorites((prev) => [...prev, created]);
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('');
} catch {
setError(t('favorites.error'));
setNewIconFile(null);
if (newIconFileInputRef.current) newIconFileInputRef.current.value = '';
} catch (err) {
setError(t(favoriteErrorKey(err)));
}
}
@@ -194,6 +234,9 @@ export function FavoritesWidget({
setEditTitle(fav.title);
setEditUrl(fav.url);
setEditIconUrl(fav.iconUrl ?? '');
setEditIconFile(null);
setEditError(null);
setEditBusy(false);
setError(null);
}
@@ -202,8 +245,17 @@ export function FavoritesWidget({
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();
@@ -211,16 +263,45 @@ export function FavoritesWidget({
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 {
setError(t('favorites.error'));
} 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)));
}
}
@@ -326,6 +407,8 @@ export function FavoritesWidget({
editTitle={editTitle}
editUrl={editUrl}
editIconUrl={editIconUrl}
editError={editError}
editBusy={editBusy}
getFallbackLetter={getFallbackLetter}
onStartEdit={startEdit}
onCancelEdit={cancelEdit}
@@ -334,6 +417,8 @@ export function FavoritesWidget({
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)}
@@ -355,6 +440,8 @@ export function FavoritesWidget({
editTitle={editTitle}
editUrl={editUrl}
editIconUrl={editIconUrl}
editError={editError}
editBusy={editBusy}
getFallbackLetter={getFallbackLetter}
onStartEdit={startEdit}
onCancelEdit={cancelEdit}
@@ -363,6 +450,8 @@ export function FavoritesWidget({
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)}
@@ -391,6 +480,18 @@ export function FavoritesWidget({
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"
@@ -443,6 +544,16 @@ function getDirectFaviconSrc(url: string): string | null {
* 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,
@@ -451,8 +562,9 @@ function FavoriteIcon({
fav: FavoriteLink;
getFallbackLetter: (title: string) => string;
}) {
const proxySrc = fav.iconUrl
? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon`
const hasServerIcon = Boolean(fav.iconUrl) || Boolean(fav.uploadedIconMime);
const proxySrc = hasServerIcon
? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon?v=${fav.iconVersion ?? 0}`
: null;
const directSrc = getDirectFaviconSrc(fav.url);
const [stage, setStage] = useState<'proxy' | 'direct' | 'none'>(
@@ -507,6 +619,9 @@ type FavoriteTileProps = {
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;
@@ -515,6 +630,10 @@ type FavoriteTileProps = {
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;
@@ -528,6 +647,8 @@ function FavoriteTile({
editTitle,
editUrl,
editIconUrl,
editError,
editBusy,
getFallbackLetter,
onStartEdit,
onCancelEdit,
@@ -536,6 +657,8 @@ function FavoriteTile({
onEditTitleChange,
onEditUrlChange,
onEditIconUrlChange,
onEditIconFileChange,
onRemoveIcon,
canMoveUp,
canMoveDown,
onMove,
@@ -557,7 +680,7 @@ function FavoriteTile({
>
{/* Icon container with letter fallback (T-08-07 — no dangerouslySetInnerHTML) */}
<FavoriteIcon
key={`${fav.iconUrl ?? ''}|${fav.url}`}
key={`${fav.iconUrl ?? ''}|${fav.url}|${fav.iconVersion ?? 0}|${fav.uploadedIconMime ?? ''}`}
fav={fav}
getFallbackLetter={getFallbackLetter}
/>
@@ -663,12 +786,43 @@ function FavoriteTile({
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)"
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"
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')}