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
+85
View File
@@ -3,10 +3,22 @@
* Mirrors the NestJS FavoritesController routes (08-03).
* All calls use credentials: 'include' for cookie-based auth.
* PUT /favorites/order (reorderFavorites) added 260917-jdd.
*
* 260923-lrr — eigenes Symbol hochladen, Zwischenspeicher nach Aenderung
* erneuern: `FavoriteLink` traegt jetzt `uploadedIconMime`/`iconVersion`
* (optional, weil Testdaten und aeltere Antworten sie nicht tragen).
* `createFavorite`/`updateFavorite` uebersetzen ein 422 (Abrufprobe der API
* fuer eine nicht abrufbare Logo-Adresse) in `FavoriteRequestError`, damit
* das Widget eine sprachrichtige Meldung zeigen kann, statt der bisherigen
* generischen `Error`. `uploadFavoriteIcon`/`removeFavoriteIcon` sind neu,
* Muster `uploadDashboardImage`/`deleteDashboardImage` (dashboard-images-api.ts).
*/
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
/** Hoechstgroesse eines hochgeladenen Symbols — muss der API-Konstante entsprechen. */
export const FAVORITE_ICON_MAX_BYTES = 512 * 1024;
export interface FavoriteLink {
id: string;
widgetId: string;
@@ -14,6 +26,33 @@ export interface FavoriteLink {
url: string;
iconUrl: string | null;
position: number;
/** 260923-lrr — Typ eines hochgeladenen eigenen Symbols, null = keins. */
uploadedIconMime?: string | null;
/** 260923-lrr — Zaehler fuer die Symbol-Adresse (`?v=`), macht den 24h-Zwischenspeicher nach einer Aenderung sofort ungueltig. */
iconVersion?: number;
}
/** Grund einer abgewiesenen Favoriten-Anfrage (260923-lrr). */
export type FavoriteErrorReason =
| 'iconUrlUnreachable'
| 'iconTooLarge'
| 'iconInvalidType'
| 'iconUploadFailed';
/**
* Ein abgewiesener Favoriten-Aufruf mit einem uebersetzbaren Grund
* (260923-lrr) — das Widget bildet `reason` auf `t('favorites.' + reason)`
* ab, damit die Meldung sprachrichtig ist statt einer festen deutschen
* Zeichenkette aus dem Klienten.
*/
export class FavoriteRequestError extends Error {
public readonly reason: FavoriteErrorReason;
constructor(reason: FavoriteErrorReason) {
super(reason);
this.name = 'FavoriteRequestError';
this.reason = reason;
}
}
/**
@@ -33,6 +72,8 @@ export async function fetchFavorites(widgetId: string): Promise<FavoriteLink[]>
/**
* Create a new favorite link.
* Server-side icon discovery runs automatically if iconUrl is not provided.
* 260923-lrr: eine explizite `iconUrl`, die sich serverseitig nicht laden
* laesst, ergibt 422 -> `FavoriteRequestError('iconUrlUnreachable')`.
*/
export async function createFavorite(payload: {
widgetId: string;
@@ -46,6 +87,7 @@ export async function createFavorite(payload: {
credentials: 'include',
body: JSON.stringify(payload),
});
if (res.status === 422) throw new FavoriteRequestError('iconUrlUnreachable');
if (!res.ok) throw new Error('Failed to create favorite');
return res.json();
@@ -54,6 +96,8 @@ export async function createFavorite(payload: {
/**
* Update an existing favorite link.
* Pass iconUrl: null to clear a stored icon.
* 260923-lrr: eine neue, nicht abrufbare `iconUrl` ergibt ebenso 422 ->
* `FavoriteRequestError('iconUrlUnreachable')`.
*/
export async function updateFavorite(
id: string,
@@ -65,6 +109,7 @@ export async function updateFavorite(
credentials: 'include',
body: JSON.stringify(payload),
});
if (res.status === 422) throw new FavoriteRequestError('iconUrlUnreachable');
if (!res.ok) throw new Error('Failed to update favorite');
return res.json();
@@ -100,3 +145,43 @@ export async function deleteFavorite(id: string): Promise<void> {
});
if (!res.ok) throw new Error('Failed to delete favorite');
}
/**
* Laedt ein eigenes Symbol fuer einen Favoriten hoch (260923-lrr). Muster
* `uploadDashboardImage` (dashboard-images-api.ts): FormData ohne eigenen
* Content-Type-Header — die Multipart-Grenze setzt der Browser. Die
* Groessenpruefung laeuft VOR dem Netzwerkaufruf (kein Aufruf fuer eine
* offensichtlich zu grosse Datei).
*/
export async function uploadFavoriteIcon(id: string, file: File): Promise<FavoriteLink> {
if (file.size > FAVORITE_ICON_MAX_BYTES) {
throw new FavoriteRequestError('iconTooLarge');
}
const body = new FormData();
body.append('icon', file, file.name);
const res = await fetch(`${API_URL}/favorites/${encodeURIComponent(id)}/icon`, {
method: 'POST',
credentials: 'include',
body,
});
if (res.ok) return res.json();
if (res.status === 413) throw new FavoriteRequestError('iconTooLarge');
if (res.status === 400) throw new FavoriteRequestError('iconInvalidType');
throw new FavoriteRequestError('iconUploadFailed');
}
/**
* Entfernt ein zuvor hochgeladenes Symbol wieder (260923-lrr); die Kachel
* faellt danach auf `iconUrl` bzw. automatische Erkennung zurueck.
*/
export async function removeFavoriteIcon(id: string): Promise<FavoriteLink> {
const res = await fetch(`${API_URL}/favorites/${encodeURIComponent(id)}/icon`, {
method: 'DELETE',
credentials: 'include',
});
if (!res.ok) throw new Error('Failed to remove favorite icon');
return res.json();
}