feat(web): Favoriten-Widget — Symbol-Ersatzweg aus dem Browser, Sortierpfeile im Bearbeitungsmodus
- FavoriteIcon (favorites-widget.tsx): dreistufiger Ersatzweg proxy ->
direct -> none; Buchstaben-Platzhalter liegt immer darunter. Direktbild
nur bei http/https-URL (getDirectFaviconSrc), referrerPolicy
no-referrer, kein Drittanbieter-Favicon-Dienst. key={iconUrl|url}
setzt die Stufe bei Aenderung zurueck; kein style.display-Hack mehr
- handleMove + Sortierpfeile im Bearbeitungsmodus (nur bei nicht-inline-
Bearbeitung): optimistische Neuberechnung, PUT /favorites/order ueber
reorderFavorites; erster/letzter Eintrag deaktiviert; Fehler ->
Neuladen mit Fehlermeldung
- favorites-api.ts: reorderFavorites(widgetId, ids)
- de.json/en.json: widgets.favorites.moveUpButton/moveDownButton
- 5 neue Widget-Tests (Ersatzbild-Kette, Nicht-http-URL, Pfeilzustand,
Klick, Fehlerpfad); 11 bestehende unveraendert gruen; volle Web-Suite
64 Dateien/429 Tests und type-check gruen
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
// Mock next-intl — passthrough t(key) => key (Pitfall: must be before imports)
|
// Mock next-intl — passthrough t(key) => key (Pitfall: must be before imports)
|
||||||
@@ -6,12 +6,16 @@ vi.mock('next-intl', () => ({
|
|||||||
useTranslations: () => (key: string) => key,
|
useTranslations: () => (key: string) => key,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock favorites-api — all 4 functions as controllable vi.fn() spies
|
// Mock favorites-api — all 5 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', () => ({
|
vi.mock('@/lib/favorites-api', () => ({
|
||||||
fetchFavorites: vi.fn(),
|
fetchFavorites: vi.fn(),
|
||||||
createFavorite: vi.fn(),
|
createFavorite: vi.fn(),
|
||||||
updateFavorite: vi.fn(),
|
updateFavorite: vi.fn(),
|
||||||
deleteFavorite: vi.fn(),
|
deleteFavorite: vi.fn(),
|
||||||
|
reorderFavorites: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock dashboard-api for updateWidgetConfig (view mode persistence)
|
// Mock dashboard-api for updateWidgetConfig (view mode persistence)
|
||||||
@@ -24,6 +28,7 @@ import {
|
|||||||
createFavorite,
|
createFavorite,
|
||||||
updateFavorite,
|
updateFavorite,
|
||||||
deleteFavorite,
|
deleteFavorite,
|
||||||
|
reorderFavorites,
|
||||||
} from '@/lib/favorites-api';
|
} from '@/lib/favorites-api';
|
||||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||||
|
|
||||||
@@ -35,6 +40,7 @@ const mockFetch = fetchFavorites as ReturnType<typeof vi.fn>;
|
|||||||
const mockCreate = createFavorite as ReturnType<typeof vi.fn>;
|
const mockCreate = createFavorite as ReturnType<typeof vi.fn>;
|
||||||
const mockUpdate = updateFavorite as ReturnType<typeof vi.fn>;
|
const mockUpdate = updateFavorite as ReturnType<typeof vi.fn>;
|
||||||
const mockDelete = deleteFavorite as ReturnType<typeof vi.fn>;
|
const mockDelete = deleteFavorite as ReturnType<typeof vi.fn>;
|
||||||
|
const mockReorder = reorderFavorites as ReturnType<typeof vi.fn>;
|
||||||
const mockUpdateWidgetConfig = updateWidgetConfig as ReturnType<typeof vi.fn>;
|
const mockUpdateWidgetConfig = updateWidgetConfig as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
const BASE_FAVORITES = [
|
const BASE_FAVORITES = [
|
||||||
@@ -414,4 +420,143 @@ describe('FavoritesWidget', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 260917-jdd: Browser-Ersatzweg (Proxy -> Direktbild -> Buchstabe) und
|
||||||
|
// Sortierpfeile im Bearbeitungsmodus.
|
||||||
|
describe('Ersatzbild und Sortierung (quick-260917-jdd)', () => {
|
||||||
|
it('Ersatzbild bei iconUrl null: Direktbild statt Proxy-Bild, referrerpolicy no-referrer', async () => {
|
||||||
|
render(
|
||||||
|
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Notion')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const directImg = screen.getByTestId('icon-direct-fav-id-2') as HTMLImageElement;
|
||||||
|
expect(directImg.src).toBe('https://notion.so/favicon.ico');
|
||||||
|
expect(directImg.getAttribute('referrerpolicy')).toBe('no-referrer');
|
||||||
|
expect(screen.queryByTestId('icon-proxy-fav-id-2')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('letter-fallback-fav-id-2')).toHaveTextContent('N');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Kette Proxy -> direkt -> Buchstabe', async () => {
|
||||||
|
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');
|
||||||
|
expect(screen.queryByTestId('icon-direct-fav-id-1')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.error(proxyImg);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('icon-proxy-fav-id-1')).not.toBeInTheDocument();
|
||||||
|
const directImg = screen.getByTestId('icon-direct-fav-id-1') as HTMLImageElement;
|
||||||
|
expect(directImg.src).toBe('https://github.com/favicon.ico');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.error(directImg);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('icon-proxy-fav-id-1')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('icon-direct-fav-id-1')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('letter-fallback-fav-id-1')).toHaveTextContent('G');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kein Direktbild bei Nicht-http-URL', async () => {
|
||||||
|
mockFetch.mockResolvedValue([
|
||||||
|
{ id: 'fav-id-3', widgetId: 'fav-1', title: 'Ablage', url: 'ftp://files.example', iconUrl: null, position: 0 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Ablage')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('icon-direct-fav-id-3')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('icon-proxy-fav-id-3')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('letter-fallback-fav-id-3')).toHaveTextContent('A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Pfeile: Zustand (erster/letzter deaktiviert) und Klick loest reorderFavorites aus', async () => {
|
||||||
|
mockReorder.mockResolvedValue([
|
||||||
|
{ ...BASE_FAVORITES[1], position: 0 },
|
||||||
|
{ ...BASE_FAVORITES[0], position: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const up = screen.getAllByRole('button', { name: 'favorites.moveUpButton' });
|
||||||
|
const down = screen.getAllByRole('button', { name: 'favorites.moveDownButton' });
|
||||||
|
|
||||||
|
expect(up[0]).toBeDisabled();
|
||||||
|
expect(down[0]).not.toBeDisabled();
|
||||||
|
expect(up[1]).not.toBeDisabled();
|
||||||
|
expect(down[1]).toBeDisabled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.click(down[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockReorder).toHaveBeenCalledWith('fav-1', ['fav-id-2', 'fav-id-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const titles = within(screen.getByTestId('favorites-list'))
|
||||||
|
.getAllByRole('link')
|
||||||
|
.map((a) => a.querySelector('.truncate')?.textContent);
|
||||||
|
expect(titles).toEqual(['Notion', 'GitHub']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehler beim Sortieren -> Neuladen, Fehlermeldung, alte Reihenfolge', async () => {
|
||||||
|
mockReorder.mockRejectedValue(new Error('boom'));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const down = screen.getAllByRole('button', { name: 'favorites.moveDownButton' });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.click(down[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('favorites.error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const titles = within(screen.getByTestId('favorites-list'))
|
||||||
|
.getAllByRole('link')
|
||||||
|
.map((a) => a.querySelector('.truncate')?.textContent);
|
||||||
|
expect(titles).toEqual(['GitHub', 'Notion']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
createFavorite,
|
createFavorite,
|
||||||
deleteFavorite,
|
deleteFavorite,
|
||||||
fetchFavorites,
|
fetchFavorites,
|
||||||
|
reorderFavorites,
|
||||||
updateFavorite,
|
updateFavorite,
|
||||||
type FavoriteLink,
|
type FavoriteLink,
|
||||||
} from '@/lib/favorites-api';
|
} from '@/lib/favorites-api';
|
||||||
@@ -28,6 +29,11 @@ const TITLE_DEBOUNCE_MS = 1500;
|
|||||||
* - Optionaler Titel (quick-260916-iex): leer -> keine Kopfzeile, sonst
|
* - Optionaler Titel (quick-260916-iex): leer -> keine Kopfzeile, sonst
|
||||||
* Kopfzeile im Notiz-Look; im Bearbeitungsmodus immer ein Titelfeld,
|
* Kopfzeile im Notiz-Look; im Bearbeitungsmodus immer ein Titelfeld,
|
||||||
* entprellt gespeichert (Muster note-widget.tsx)
|
* entprellt gespeichert (Muster note-widget.tsx)
|
||||||
|
* - Symbol-Ersatzweg (260917-jdd): FavoriteIcon versucht zuerst den Server-
|
||||||
|
* Proxy, bei Fehler den Browser direkt (`onError`-Kette), zuletzt den
|
||||||
|
* Buchstaben-Platzhalter
|
||||||
|
* - Sortierung per Pfeilen im Bearbeitungsmodus (260917-jdd): optimistisch,
|
||||||
|
* persistiert ueber PUT /favorites/order
|
||||||
*/
|
*/
|
||||||
export function FavoritesWidget({
|
export function FavoritesWidget({
|
||||||
instanceId,
|
instanceId,
|
||||||
@@ -127,6 +133,34 @@ export function FavoritesWidget({
|
|||||||
void updateWidgetConfig(instanceId, { viewMode: next });
|
void updateWidgetConfig(instanceId, { viewMode: next });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Sortierung per Pfeilen (260917-jdd) ---
|
||||||
|
async function handleMove(id: string, direction: 'up' | 'down') {
|
||||||
|
const order = sortedFavorites.map((f) => f.id);
|
||||||
|
const index = order.indexOf(id);
|
||||||
|
const target = direction === 'up' ? index - 1 : index + 1;
|
||||||
|
|
||||||
|
if (index < 0 || target < 0 || target >= order.length) return;
|
||||||
|
|
||||||
|
[order[index], order[target]] = [order[target], order[index]];
|
||||||
|
|
||||||
|
const byId = new Map(favorites.map((f) => [f.id, f]));
|
||||||
|
const reindexed = order.map((fid, i) => ({ ...byId.get(fid)!, position: i }));
|
||||||
|
|
||||||
|
setFavorites(reindexed);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setFavorites(await reorderFavorites(instanceId, order));
|
||||||
|
} catch {
|
||||||
|
setError(t('favorites.error'));
|
||||||
|
try {
|
||||||
|
setFavorites(await fetchFavorites(instanceId));
|
||||||
|
} catch {
|
||||||
|
// Fehlermeldung steht bereits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Add favorite ---
|
// --- Add favorite ---
|
||||||
async function handleAdd(e: FormEvent<HTMLFormElement>) {
|
async function handleAdd(e: FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -272,7 +306,7 @@ export function FavoritesWidget({
|
|||||||
data-testid="favorites-grid"
|
data-testid="favorites-grid"
|
||||||
className="grid grid-cols-3 gap-2"
|
className="grid grid-cols-3 gap-2"
|
||||||
>
|
>
|
||||||
{sortedFavorites.map((fav) => (
|
{sortedFavorites.map((fav, index) => (
|
||||||
<FavoriteTile
|
<FavoriteTile
|
||||||
key={fav.id}
|
key={fav.id}
|
||||||
fav={fav}
|
fav={fav}
|
||||||
@@ -289,6 +323,9 @@ export function FavoritesWidget({
|
|||||||
onEditTitleChange={setEditTitle}
|
onEditTitleChange={setEditTitle}
|
||||||
onEditUrlChange={setEditUrl}
|
onEditUrlChange={setEditUrl}
|
||||||
onEditIconUrlChange={setEditIconUrl}
|
onEditIconUrlChange={setEditIconUrl}
|
||||||
|
canMoveUp={index > 0}
|
||||||
|
canMoveDown={index < sortedFavorites.length - 1}
|
||||||
|
onMove={(fid, dir) => void handleMove(fid, dir)}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -298,7 +335,7 @@ export function FavoritesWidget({
|
|||||||
data-testid="favorites-list"
|
data-testid="favorites-list"
|
||||||
className="flex flex-col gap-1"
|
className="flex flex-col gap-1"
|
||||||
>
|
>
|
||||||
{sortedFavorites.map((fav) => (
|
{sortedFavorites.map((fav, index) => (
|
||||||
<FavoriteTile
|
<FavoriteTile
|
||||||
key={fav.id}
|
key={fav.id}
|
||||||
fav={fav}
|
fav={fav}
|
||||||
@@ -315,6 +352,9 @@ export function FavoritesWidget({
|
|||||||
onEditTitleChange={setEditTitle}
|
onEditTitleChange={setEditTitle}
|
||||||
onEditUrlChange={setEditUrl}
|
onEditUrlChange={setEditUrl}
|
||||||
onEditIconUrlChange={setEditIconUrl}
|
onEditIconUrlChange={setEditIconUrl}
|
||||||
|
canMoveUp={index > 0}
|
||||||
|
canMoveDown={index < sortedFavorites.length - 1}
|
||||||
|
onMove={(fid, dir) => void handleMove(fid, dir)}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -353,6 +393,98 @@ export function FavoritesWidget({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Symbol-Ersatzweg (260917-jdd) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leitet aus einer Favoriten-URL das direkte Browser-Favicon ab
|
||||||
|
* (`{origin}/favicon.ico`) — nur fuer http:/https:, sonst null (kein
|
||||||
|
* javascript:/data:, T-JDD-04). Wird ausschliesslich als Stufe 2 des
|
||||||
|
* Ersatzwegs genutzt (siehe FavoriteIcon).
|
||||||
|
*/
|
||||||
|
function getDirectFaviconSrc(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
||||||
|
return `${u.origin}/favicon.ico`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FavoriteIcon — dreistufiger Symbol-Ersatzweg (260917-jdd):
|
||||||
|
*
|
||||||
|
* 1. `proxy` — Server-Proxy (GET /favorites/:id/icon), der seit diesem Plan
|
||||||
|
* auch bei Zertifikatsfehlern des Zielhosts liefert (undici-Dispatcher).
|
||||||
|
* 2. `direct` — Direktbild aus dem Browser des Nutzers
|
||||||
|
* (`referrerPolicy="no-referrer"`, Origin nur aus http/https); erreicht
|
||||||
|
* interne Hosts, die der SSRF-Schutz des Servers absichtlich ablehnt.
|
||||||
|
* 3. `none` — der Buchstaben-Platzhalter liegt IMMER darunter.
|
||||||
|
*
|
||||||
|
* Bewusst KEIN Drittanbieter-Favicon-Dienst: der wuerde Hostnamen nach
|
||||||
|
* aussen geben und interne Hosts ohnehin nicht kennen.
|
||||||
|
*
|
||||||
|
* Befund am Code: `discoverFavoriteIconUrl` liefert NIE null, sondern bei
|
||||||
|
* jedem Fehler den Origin-Rueckfall — die Stufe `direct` haengt deshalb an
|
||||||
|
* `onError` des Proxy-Bildes UND an `iconUrl === null`, nicht nur an
|
||||||
|
* letzterem.
|
||||||
|
*
|
||||||
|
* Grenzen: ein http-Favorit auf einem https-Tessera ist Mischinhalt
|
||||||
|
* (Browser blockiert ihn); ein selbstsigniertes Zertifikat ohne Vertrauen
|
||||||
|
* im Browser des Nutzers klappt auch ueber Stufe 2 nicht.
|
||||||
|
*/
|
||||||
|
function FavoriteIcon({
|
||||||
|
fav,
|
||||||
|
getFallbackLetter,
|
||||||
|
}: {
|
||||||
|
fav: FavoriteLink;
|
||||||
|
getFallbackLetter: (title: string) => string;
|
||||||
|
}) {
|
||||||
|
const proxySrc = fav.iconUrl
|
||||||
|
? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon`
|
||||||
|
: null;
|
||||||
|
const directSrc = getDirectFaviconSrc(fav.url);
|
||||||
|
const [stage, setStage] = useState<'proxy' | 'direct' | 'none'>(
|
||||||
|
proxySrc ? 'proxy' : 'direct',
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex-shrink-0 w-5 h-5">
|
||||||
|
<span
|
||||||
|
data-testid={`letter-fallback-${fav.id}`}
|
||||||
|
className="absolute inset-0 flex items-center justify-center text-xs font-semibold bg-muted rounded"
|
||||||
|
>
|
||||||
|
{getFallbackLetter(fav.title)}
|
||||||
|
</span>
|
||||||
|
{stage === 'proxy' && proxySrc && (
|
||||||
|
<img
|
||||||
|
data-testid={`icon-proxy-${fav.id}`}
|
||||||
|
src={proxySrc}
|
||||||
|
alt=""
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
loading="lazy"
|
||||||
|
className="absolute inset-0 w-5 h-5 rounded"
|
||||||
|
onError={() => setStage('direct')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{stage === 'direct' && directSrc && (
|
||||||
|
<img
|
||||||
|
data-testid={`icon-direct-${fav.id}`}
|
||||||
|
src={directSrc}
|
||||||
|
alt=""
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
className="absolute inset-0 w-5 h-5 rounded"
|
||||||
|
onError={() => setStage('none')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- FavoriteTile sub-component ---
|
// --- FavoriteTile sub-component ---
|
||||||
|
|
||||||
type FavoriteTileProps = {
|
type FavoriteTileProps = {
|
||||||
@@ -370,6 +502,9 @@ type FavoriteTileProps = {
|
|||||||
onEditTitleChange: (v: string) => void;
|
onEditTitleChange: (v: string) => void;
|
||||||
onEditUrlChange: (v: string) => void;
|
onEditUrlChange: (v: string) => void;
|
||||||
onEditIconUrlChange: (v: string) => void;
|
onEditIconUrlChange: (v: string) => void;
|
||||||
|
canMoveUp: boolean;
|
||||||
|
canMoveDown: boolean;
|
||||||
|
onMove: (id: string, direction: 'up' | 'down') => void;
|
||||||
t: (key: string) => string;
|
t: (key: string) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -388,6 +523,9 @@ function FavoriteTile({
|
|||||||
onEditTitleChange,
|
onEditTitleChange,
|
||||||
onEditUrlChange,
|
onEditUrlChange,
|
||||||
onEditIconUrlChange,
|
onEditIconUrlChange,
|
||||||
|
canMoveUp,
|
||||||
|
canMoveDown,
|
||||||
|
onMove,
|
||||||
t,
|
t,
|
||||||
}: FavoriteTileProps) {
|
}: FavoriteTileProps) {
|
||||||
const isEditing = editingId === fav.id;
|
const isEditing = editingId === fav.id;
|
||||||
@@ -405,27 +543,11 @@ function FavoriteTile({
|
|||||||
onClick={(e) => isEditMode && e.preventDefault()} // block navigation in edit mode
|
onClick={(e) => isEditMode && e.preventDefault()} // block navigation in edit mode
|
||||||
>
|
>
|
||||||
{/* Icon container with letter fallback (T-08-07 — no dangerouslySetInnerHTML) */}
|
{/* Icon container with letter fallback (T-08-07 — no dangerouslySetInnerHTML) */}
|
||||||
<div className="relative flex-shrink-0 w-5 h-5">
|
<FavoriteIcon
|
||||||
<span
|
key={`${fav.iconUrl ?? ''}|${fav.url}`}
|
||||||
data-testid={`letter-fallback-${fav.id}`}
|
fav={fav}
|
||||||
className="absolute inset-0 flex items-center justify-center text-xs font-semibold bg-muted rounded"
|
getFallbackLetter={getFallbackLetter}
|
||||||
>
|
/>
|
||||||
{getFallbackLetter(fav.title)}
|
|
||||||
</span>
|
|
||||||
{fav.iconUrl && (
|
|
||||||
<img
|
|
||||||
src={`/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon`}
|
|
||||||
alt=""
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
loading="lazy"
|
|
||||||
className="absolute inset-0 w-5 h-5 rounded"
|
|
||||||
onError={(e) => {
|
|
||||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="text-xs truncate">{fav.title}</span>
|
<span className="text-xs truncate">{fav.title}</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -433,6 +555,44 @@ function FavoriteTile({
|
|||||||
{/* Action buttons (edit mode only, D-04) */}
|
{/* Action buttons (edit mode only, D-04) */}
|
||||||
{isEditMode && !isEditing && (
|
{isEditMode && !isEditing && (
|
||||||
<div className="flex gap-1 flex-shrink-0 widgetNoDrag">
|
<div className="flex gap-1 flex-shrink-0 widgetNoDrag">
|
||||||
|
{/* Sortierpfeile (260917-jdd) — kein Drag & Drop hier, das
|
||||||
|
kollidiert mit dem Ziehen der Kachel in react-grid-layout */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('favorites.moveUpButton')}
|
||||||
|
title={t('favorites.moveUpButton')}
|
||||||
|
className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:hover:text-muted-foreground"
|
||||||
|
disabled={!canMoveUp}
|
||||||
|
onClick={() => onMove(fav.id, 'up')}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M12 8.6 5.4 15.2l1.4 1.4L12 11.4l5.2 5.2 1.4-1.4z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('favorites.moveDownButton')}
|
||||||
|
title={t('favorites.moveDownButton')}
|
||||||
|
className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:hover:text-muted-foreground"
|
||||||
|
disabled={!canMoveDown}
|
||||||
|
onClick={() => onMove(fav.id, 'down')}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="m12 15.4 6.6-6.6-1.4-1.4L12 12.6 6.8 7.4 5.4 8.8z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t('favorites.editButton')}
|
aria-label={t('favorites.editButton')}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* Favorites API client functions.
|
* Favorites API client functions.
|
||||||
* Mirrors the NestJS FavoritesController routes (08-03).
|
* Mirrors the NestJS FavoritesController routes (08-03).
|
||||||
* All calls use credentials: 'include' for cookie-based auth.
|
* All calls use credentials: 'include' for cookie-based auth.
|
||||||
|
* PUT /favorites/order (reorderFavorites) added 260917-jdd.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
@@ -69,6 +70,26 @@ export async function updateFavorite(
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the display order for a widget's favorites (260917-jdd).
|
||||||
|
* `ids` is the full id list in the desired order; the server responds with
|
||||||
|
* the favorites of this widget in the new order.
|
||||||
|
*/
|
||||||
|
export async function reorderFavorites(
|
||||||
|
widgetId: string,
|
||||||
|
ids: string[],
|
||||||
|
): Promise<FavoriteLink[]> {
|
||||||
|
const res = await fetch(`${API_URL}/favorites/order`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ widgetId, ids }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to reorder favorites');
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a favorite link by id.
|
* Delete a favorite link by id.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -304,6 +304,8 @@
|
|||||||
"gridView": "Kacheln",
|
"gridView": "Kacheln",
|
||||||
"editButton": "Favorit bearbeiten",
|
"editButton": "Favorit bearbeiten",
|
||||||
"deleteButton": "Favorit löschen",
|
"deleteButton": "Favorit löschen",
|
||||||
|
"moveUpButton": "Nach oben",
|
||||||
|
"moveDownButton": "Nach unten",
|
||||||
"saveButton": "Speichern",
|
"saveButton": "Speichern",
|
||||||
"cancelButton": "Abbrechen",
|
"cancelButton": "Abbrechen",
|
||||||
"error": "Fehler beim Laden der Favoriten",
|
"error": "Fehler beim Laden der Favoriten",
|
||||||
|
|||||||
@@ -304,6 +304,8 @@
|
|||||||
"gridView": "Grid",
|
"gridView": "Grid",
|
||||||
"editButton": "Edit favorite",
|
"editButton": "Edit favorite",
|
||||||
"deleteButton": "Delete favorite",
|
"deleteButton": "Delete favorite",
|
||||||
|
"moveUpButton": "Move up",
|
||||||
|
"moveDownButton": "Move down",
|
||||||
"saveButton": "Save",
|
"saveButton": "Save",
|
||||||
"cancelButton": "Cancel",
|
"cancelButton": "Cancel",
|
||||||
"error": "Error loading favorites",
|
"error": "Error loading favorites",
|
||||||
|
|||||||
Reference in New Issue
Block a user