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:
@@ -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')}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
FAVORITE_ICON_MAX_BYTES,
|
||||
FavoriteRequestError,
|
||||
createFavorite,
|
||||
removeFavoriteIcon,
|
||||
updateFavorite,
|
||||
uploadFavoriteIcon,
|
||||
} from './favorites-api';
|
||||
|
||||
/**
|
||||
* favorites-api.test — NEU (quick-260923-lrr).
|
||||
*
|
||||
* Die vier neuen/geaenderten Aufrufe: `uploadFavoriteIcon` (FormData-Feld
|
||||
* `icon`, Groessenpruefung vor dem Netzwerkaufruf, 413/400/sonst ->
|
||||
* `FavoriteRequestError`), `removeFavoriteIcon` (DELETE), sowie
|
||||
* `createFavorite`/`updateFavorite` (422 -> `FavoriteRequestError` mit
|
||||
* `reason: 'iconUrlUnreachable'`, jeder andere Fehler bleibt eine generische
|
||||
* `Error` wie bisher). Muster `dashboard-images-api.test.ts`.
|
||||
*/
|
||||
const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() }));
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('favorites-api (quick-260923-lrr)', () => {
|
||||
describe('uploadFavoriteIcon', () => {
|
||||
it('schickt POST an /favorites/<id>/icon mit credentials include, FormData-Feld icon, OHNE eigenen Content-Type; 200 -> Zeile', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ id: 'fav-1', uploadedIconMime: 'image/png', iconVersion: 1 }),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'x.png', { type: 'image/png' });
|
||||
|
||||
const result = await uploadFavoriteIcon('fav-1', file);
|
||||
|
||||
expect(result.id).toBe('fav-1');
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(String(url).endsWith('/favorites/fav-1/icon')).toBe(true);
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.credentials).toBe('include');
|
||||
expect(init.headers).toBeUndefined();
|
||||
const body = init.body as FormData;
|
||||
expect([...body.keys()]).toEqual(['icon']);
|
||||
expect(body.get('icon')).toBeInstanceOf(File);
|
||||
});
|
||||
|
||||
it('Datei groesser 512 KB -> FavoriteRequestError iconTooLarge, fetch NICHT aufgerufen', async () => {
|
||||
const big = new File([new Uint8Array(FAVORITE_ICON_MAX_BYTES + 1)], 'gross.png', {
|
||||
type: 'image/png',
|
||||
});
|
||||
|
||||
const err = await uploadFavoriteIcon('fav-1', big).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconTooLarge');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('413 -> iconTooLarge', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 413 }));
|
||||
const file = new File([new Uint8Array([1])], 'x.png', { type: 'image/png' });
|
||||
|
||||
const err = await uploadFavoriteIcon('fav-1', file).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconTooLarge');
|
||||
});
|
||||
|
||||
it('400 -> iconInvalidType', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 400 }));
|
||||
const file = new File([new Uint8Array([1])], 'x.txt', { type: 'text/plain' });
|
||||
|
||||
const err = await uploadFavoriteIcon('fav-1', file).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconInvalidType');
|
||||
});
|
||||
|
||||
it('500 -> iconUploadFailed', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('kaputt', { status: 500 }));
|
||||
const file = new File([new Uint8Array([1])], 'x.png', { type: 'image/png' });
|
||||
|
||||
const err = await uploadFavoriteIcon('fav-1', file).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconUploadFailed');
|
||||
});
|
||||
});
|
||||
|
||||
it('removeFavoriteIcon schickt DELETE an /favorites/<id>/icon mit credentials include, liefert die Zeile', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: 'fav-1', uploadedIconMime: null, iconVersion: 2 }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await removeFavoriteIcon('fav-1');
|
||||
|
||||
expect(result.uploadedIconMime).toBeNull();
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(String(url).endsWith('/favorites/fav-1/icon')).toBe(true);
|
||||
expect(init.method).toBe('DELETE');
|
||||
expect(init.credentials).toBe('include');
|
||||
});
|
||||
|
||||
describe('createFavorite / updateFavorite — Abrufprobe (260923-lrr)', () => {
|
||||
it('createFavorite: 422 -> FavoriteRequestError reason iconUrlUnreachable', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 422 }));
|
||||
|
||||
const err = await createFavorite({
|
||||
widgetId: 'w1',
|
||||
title: 'X',
|
||||
url: 'https://x.invalid',
|
||||
iconUrl: 'https://x.invalid/logo.png',
|
||||
}).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconUrlUnreachable');
|
||||
});
|
||||
|
||||
it('createFavorite: anderer Fehler -> wie bisher eine generische Error', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 500 }));
|
||||
|
||||
const err = await createFavorite({ widgetId: 'w1', title: 'X', url: 'https://x.invalid' }).catch(
|
||||
(e) => e,
|
||||
);
|
||||
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).not.toBeInstanceOf(FavoriteRequestError);
|
||||
});
|
||||
|
||||
it('updateFavorite: 422 -> FavoriteRequestError reason iconUrlUnreachable', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 422 }));
|
||||
|
||||
const err = await updateFavorite('fav-1', { iconUrl: 'https://x.invalid/logo.png' }).catch(
|
||||
(e) => e,
|
||||
);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconUrlUnreachable');
|
||||
});
|
||||
|
||||
it('updateFavorite: anderer Fehler -> wie bisher eine generische Error', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 500 }));
|
||||
|
||||
const err = await updateFavorite('fav-1', { title: 'Neu' }).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).not.toBeInstanceOf(FavoriteRequestError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -336,7 +336,16 @@
|
||||
"error": "Fehler beim Laden der Favoriten",
|
||||
"titleLabel": "Titel",
|
||||
"titlePlaceholder": "Titel (optional)",
|
||||
"viewModeLabel": "Ansicht wechseln"
|
||||
"viewModeLabel": "Ansicht wechseln",
|
||||
"iconUrlPlaceholder": "Logo-Adresse (optional)",
|
||||
"iconUploadLabel": "Eigenes Symbol hochladen",
|
||||
"iconUploadHint": "PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB. Ein hochgeladenes Symbol hat Vorrang vor der Logo-Adresse.",
|
||||
"iconUploadedHint": "Für diesen Favoriten ist ein eigenes Symbol hochgeladen.",
|
||||
"iconRemoveButton": "Hochgeladenes Symbol entfernen",
|
||||
"iconUrlUnreachable": "Das Bild unter dieser Adresse konnte nicht geladen werden. Die Seite blockiert vermutlich automatische Abrufe (zum Beispiel durch eine Cloudflare-Prüfung) oder ist nicht erreichbar. Bitte laden Sie das Symbol stattdessen hoch.",
|
||||
"iconTooLarge": "Die Datei ist zu groß – erlaubt sind höchstens 512 KB.",
|
||||
"iconInvalidType": "Nur Bilder im Format PNG, JPEG, GIF, WebP, ICO oder SVG sind erlaubt.",
|
||||
"iconUploadFailed": "Das Symbol konnte nicht hochgeladen werden."
|
||||
},
|
||||
"stopwatch": {
|
||||
"name": "Stoppuhr",
|
||||
|
||||
@@ -336,7 +336,16 @@
|
||||
"error": "Error loading favorites",
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "Title (optional)",
|
||||
"viewModeLabel": "Switch view"
|
||||
"viewModeLabel": "Switch view",
|
||||
"iconUrlPlaceholder": "Logo URL (optional)",
|
||||
"iconUploadLabel": "Upload custom icon",
|
||||
"iconUploadHint": "PNG, JPEG, GIF, WebP, ICO or SVG, at most 512 KB. An uploaded icon takes precedence over the logo URL.",
|
||||
"iconUploadedHint": "A custom icon has been uploaded for this favorite.",
|
||||
"iconRemoveButton": "Remove uploaded icon",
|
||||
"iconUrlUnreachable": "The image at this address could not be loaded. The site likely blocks automated requests (for example via a Cloudflare check) or is unreachable. Please upload the icon instead.",
|
||||
"iconTooLarge": "The file is too large – at most 512 KB is allowed.",
|
||||
"iconInvalidType": "Only PNG, JPEG, GIF, WebP, ICO or SVG images are allowed.",
|
||||
"iconUploadFailed": "The icon could not be uploaded."
|
||||
},
|
||||
"stopwatch": {
|
||||
"name": "Stopwatch",
|
||||
|
||||
Reference in New Issue
Block a user