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:
@@ -7,6 +7,11 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
### Neu
|
||||
|
||||
- Das Dashboard hat jetzt mehrere Reiter: Sie können beliebig viele Dashboards anlegen, jeder mit eigenen Kacheln und eigener Anordnung, per Ziehen umsortierbar, wobei der erste Reiter beim Öffnen geladen wird. Ihre vorhandenen Kacheln bleiben dabei unverändert auf dem ersten Reiter liegen.
|
||||
- Favoriten-Widget: eigenes Symbol je Link hochladen – PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB – beim Hinzufügen und im Bearbeitungsformular; ein hochgeladenes Symbol hat Vorrang vor der Logo-Adresse; „Hochgeladenes Symbol entfernen“ macht es rückgängig
|
||||
|
||||
### Behoben
|
||||
|
||||
- Favoriten-Widget: nach dem Ändern der Logo-Adresse erscheint das neue Symbol jetzt sofort, statt erst nach einem Tag; ist das Bild unter der Adresse nicht abrufbar – etwa wegen einer Cloudflare-Prüfung – meldet das Formular das jetzt beim Speichern, statt die Adresse still zu übernehmen
|
||||
|
||||
## 1.3.1 – 2026-09-23
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -82,7 +82,7 @@ Ihre Änderungen werden über **„Änderungen speichern"** übernommen. Verlass
|
||||
| Kalender | Monatsübersicht mit der Anzahl der Termine je Tag (die Termine eines Tages erscheinen, wenn Sie mit der Maus darüberfahren) und darunter die nächsten Termine aus Ihren verbundenen Kalenderquellen. Ob die Monatsansicht erscheint, wie viele Termine und welcher Zeitraum gezeigt werden, stellen Sie unter Einstellungen > Dashboard > Widgets ein |
|
||||
| Notizen | Freitext-Notizen mit Markdown-Formatierung; Listen zum Abhaken (`- [ ]`) lassen sich in der Ansicht direkt per Klick abhaken |
|
||||
| Taschenrechner | Grundrechenarten, auch per Tastatur bedienbar |
|
||||
| Favoriten | Schnellzugriff auf mehrere selbst gepflegte Links, als Liste oder Kachelansicht, optional mit eigener Überschrift. Im Bearbeitungsmodus des Dashboards bringen Sie die Links mit den Pfeilen „Nach oben"/„Nach unten" in die gewünschte Reihenfolge. Das Symbol einer Seite holt Tessera automatisch; bei internen Adressen versucht es zusätzlich Ihr Browser direkt |
|
||||
| Favoriten | Schnellzugriff auf mehrere selbst gepflegte Links, als Liste oder Kachelansicht, optional mit eigener Überschrift. Im Bearbeitungsmodus des Dashboards bringen Sie die Links mit den Pfeilen „Nach oben"/„Nach unten" in die gewünschte Reihenfolge. Das Symbol einer Seite holt Tessera automatisch; bei internen Adressen versucht es zusätzlich Ihr Browser direkt. Im Bearbeitungsformular tragen Sie stattdessen eine eigene Logo-Adresse ein oder laden ein eigenes Symbol hoch (PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB) — ein hochgeladenes Symbol hat Vorrang und lässt sich über „Hochgeladenes Symbol entfernen“ wieder zurücknehmen; blockiert eine Seite automatische Abrufe (z. B. durch eine Cloudflare-Prüfung), meldet Tessera das beim Speichern |
|
||||
| Stoppuhr | Zeitmessung mit Rundenzeiten |
|
||||
| Bilderrahmen | Diashow aus eigenen Bildern: Sie laden Bilder hoch (PNG, JPEG, GIF oder WebP, höchstens 5 MB je Bild, bis zu 30 Bilder) oder binden Bilder über eine https-Adresse ein. Ob das Bild ganz sichtbar oder formatfüllend erscheint, wie oft gewechselt wird (oder gar nicht), ob in Reihenfolge oder zufällig, und welche Bildunterschrift jedes Bild trägt, stellen Sie unter Einstellungen > Dashboard ein. Ein Klick auf das Bild zeigt es groß; Escape oder ein Klick daneben schließt die Großansicht wieder |
|
||||
| XFrame | Zeigt eine Webseite als Rahmen in der Kachel. Die https-Adresse, einen optionalen Titel und ob die Seite automatisch neu geladen wird (nie, 1 Minute bis 1 Stunde), stellen Sie unter Einstellungen > Dashboard ein. Die eingebettete Seite kann Tessera nicht verlassen; über „In neuem Tab öffnen“ erreichen Sie die Seite jederzeit direkt. Manche Webseiten erlauben das Einbetten nicht — der Rahmen bleibt dann leer, der Knopf funktioniert trotzdem. Wahlweise zeigen Sie nur einen Ausschnitt der Seite: den Rahmen in der Vorschau verschieben oder an den Ecken ziehen (oder Links, Oben, Breite und Höhe eintippen) – die Kachel zeigt dann genau diesen Ausschnitt, passend zu ihrer Größe; für die ganze Seite gibt es eine Vergrößerung (50 bis 150 %), und „Nur anzeigen“ sperrt Klicken und Scrollen im Rahmen. |
|
||||
|
||||
Reference in New Issue
Block a user