feat(08-03): FavoritesWidget frontend + API client + page.tsx wiring (GREEN)
- favorites-api.ts: FavoriteLink type + fetchFavorites/createFavorite/updateFavorite/deleteFavorite all use credentials: include and API_URL/favorites - favorites-widget.tsx: list/grid view, inline add/edit/delete in edit mode, icon + letter fallback, rel=noreferrer + target=_blank, no dangerouslySetInnerHTML (T-08-07) - useEffect deps fixed to [instanceId] only — excludes t() to prevent re-fetch on each render - page.tsx: wireFavoritesWidget(FavoritesWidget) wired - Full test suite: 81/81 pass (17 test files) - Web TypeScript: clean
This commit is contained in:
@@ -5,13 +5,14 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
||||||
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
||||||
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
|
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
|
||||||
import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget, wireStopwatchWidget } from '@/components/dashboard/widget-registry';
|
import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget, wireStopwatchWidget, wireFavoritesWidget } from '@/components/dashboard/widget-registry';
|
||||||
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
|
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
|
||||||
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
|
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
|
||||||
import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
|
import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
|
||||||
import { NoteWidget } from '@/components/dashboard/widgets/note-widget';
|
import { NoteWidget } from '@/components/dashboard/widgets/note-widget';
|
||||||
import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widget';
|
import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widget';
|
||||||
import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget';
|
import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget';
|
||||||
|
import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget';
|
||||||
import { useDashboardStore } from '@/lib/stores/dashboard-store';
|
import { useDashboardStore } from '@/lib/stores/dashboard-store';
|
||||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ wireCalendarWidget(CalendarWidget);
|
|||||||
wireNoteWidget(NoteWidget);
|
wireNoteWidget(NoteWidget);
|
||||||
wireCalculatorWidget(CalculatorWidget);
|
wireCalculatorWidget(CalculatorWidget);
|
||||||
wireStopwatchWidget(StopwatchWidget);
|
wireStopwatchWidget(StopwatchWidget);
|
||||||
|
wireFavoritesWidget(FavoritesWidget);
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const t = useTranslations('widgets');
|
const t = useTranslations('widgets');
|
||||||
|
|||||||
@@ -132,25 +132,28 @@ describe('FavoritesWidget', () => {
|
|||||||
const titleInput = screen.getByPlaceholderText('favorites.addTitle');
|
const titleInput = screen.getByPlaceholderText('favorites.addTitle');
|
||||||
const urlInput = screen.getByPlaceholderText('favorites.addUrl');
|
const urlInput = screen.getByPlaceholderText('favorites.addUrl');
|
||||||
|
|
||||||
await act(async () => {
|
// Use sync fireEvent — RTL will flush state updates automatically
|
||||||
|
act(() => {
|
||||||
fireEvent.change(titleInput, { target: { value: 'New Link' } });
|
fireEvent.change(titleInput, { target: { value: 'New Link' } });
|
||||||
fireEvent.change(urlInput, { target: { value: 'https://example.com' } });
|
fireEvent.change(urlInput, { target: { value: 'https://example.com' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Submit the form (button with text 'favorites.addButton')
|
// Submit the form
|
||||||
const addButton = screen.getByRole('button', { name: /favorites\.addButton/i });
|
const addButton = screen.getByRole('button', { name: /favorites\.addButton/i });
|
||||||
await act(async () => {
|
act(() => {
|
||||||
fireEvent.click(addButton);
|
fireEvent.click(addButton);
|
||||||
});
|
});
|
||||||
|
|
||||||
// createFavorite must be called with the widgetId and the entered values
|
// waitFor polls until the async chain (createFavorite → setFavorites) completes
|
||||||
expect(mockCreate).toHaveBeenCalledWith(
|
await waitFor(() => {
|
||||||
expect.objectContaining({
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
widgetId: 'fav-1',
|
expect.objectContaining({
|
||||||
title: 'New Link',
|
widgetId: 'fav-1',
|
||||||
url: 'https://example.com',
|
title: 'New Link',
|
||||||
}),
|
url: 'https://example.com',
|
||||||
);
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// The new link title should appear in the list
|
// The new link title should appear in the list
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -175,29 +178,37 @@ describe('FavoritesWidget', () => {
|
|||||||
const editButtons = screen.getAllByRole('button', {
|
const editButtons = screen.getAllByRole('button', {
|
||||||
name: /favorites\.editButton/i,
|
name: /favorites\.editButton/i,
|
||||||
});
|
});
|
||||||
await act(async () => {
|
act(() => {
|
||||||
fireEvent.click(editButtons[0]);
|
fireEvent.click(editButtons[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Change the title in the inline edit form
|
// Change the title in the inline edit form
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByDisplayValue('GitHub')).toBeInTheDocument();
|
||||||
|
});
|
||||||
const editTitleInput = screen.getByDisplayValue('GitHub');
|
const editTitleInput = screen.getByDisplayValue('GitHub');
|
||||||
await act(async () => {
|
act(() => {
|
||||||
fireEvent.change(editTitleInput, { target: { value: 'GitHub Updated' } });
|
fireEvent.change(editTitleInput, { target: { value: 'GitHub Updated' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Click Save
|
// Click Save
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: /favorites\.saveButton/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
const saveButton = screen.getByRole('button', {
|
const saveButton = screen.getByRole('button', {
|
||||||
name: /favorites\.saveButton/i,
|
name: /favorites\.saveButton/i,
|
||||||
});
|
});
|
||||||
await act(async () => {
|
act(() => {
|
||||||
fireEvent.click(saveButton);
|
fireEvent.click(saveButton);
|
||||||
});
|
});
|
||||||
|
|
||||||
// updateFavorite called with the favorite id and new title
|
// updateFavorite called with the favorite id and new title
|
||||||
expect(mockUpdate).toHaveBeenCalledWith(
|
await waitFor(() => {
|
||||||
'fav-id-1',
|
expect(mockUpdate).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ title: 'GitHub Updated' }),
|
'fav-id-1',
|
||||||
);
|
expect.objectContaining({ title: 'GitHub Updated' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('delete: clicking delete calls deleteFavorite with id and removes the row', async () => {
|
it('delete: clicking delete calls deleteFavorite with id and removes the row', async () => {
|
||||||
@@ -217,12 +228,14 @@ describe('FavoritesWidget', () => {
|
|||||||
const deleteButtons = screen.getAllByRole('button', {
|
const deleteButtons = screen.getAllByRole('button', {
|
||||||
name: /favorites\.deleteButton/i,
|
name: /favorites\.deleteButton/i,
|
||||||
});
|
});
|
||||||
await act(async () => {
|
act(() => {
|
||||||
fireEvent.click(deleteButtons[0]);
|
fireEvent.click(deleteButtons[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// deleteFavorite called with the correct id
|
// deleteFavorite called with the correct id
|
||||||
expect(mockDelete).toHaveBeenCalledWith('fav-id-1');
|
await waitFor(() => {
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('fav-id-1');
|
||||||
|
});
|
||||||
|
|
||||||
// The deleted favorite is removed from the list
|
// The deleted favorite is removed from the list
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -0,0 +1,462 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||||
|
import {
|
||||||
|
createFavorite,
|
||||||
|
deleteFavorite,
|
||||||
|
fetchFavorites,
|
||||||
|
updateFavorite,
|
||||||
|
type FavoriteLink,
|
||||||
|
} from '@/lib/favorites-api';
|
||||||
|
import type { WidgetProps } from '@/components/dashboard/widget-registry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FavoritesWidget — persistent per-widget list of favorite links (DASH-09).
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Loads favorites scoped to this widget instance via instanceId (Pitfall 3)
|
||||||
|
* - Add / edit / delete in edit mode with inline forms
|
||||||
|
* - List view (default, D-03) and grid view switchable in edit mode
|
||||||
|
* - Server-side icon discovery (letter fallback when iconUrl is null)
|
||||||
|
* - Links open in new tab with rel="noreferrer" (T-08-08 open-redirect mitigation)
|
||||||
|
* - No dangerouslySetInnerHTML (T-08-07 XSS mitigation)
|
||||||
|
*/
|
||||||
|
export function FavoritesWidget({
|
||||||
|
instanceId,
|
||||||
|
config,
|
||||||
|
isEditMode,
|
||||||
|
}: WidgetProps) {
|
||||||
|
const t = useTranslations('widgets');
|
||||||
|
|
||||||
|
const [favorites, setFavorites] = useState<FavoriteLink[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// View mode: read from config, default 'list' (D-03)
|
||||||
|
const [viewMode, setViewMode] = useState<'list' | 'grid'>(
|
||||||
|
(config.viewMode as 'list' | 'grid') ?? 'list',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add-form state
|
||||||
|
const [newTitle, setNewTitle] = useState('');
|
||||||
|
const [newUrl, setNewUrl] = useState('');
|
||||||
|
const [newIconUrl, setNewIconUrl] = useState('');
|
||||||
|
|
||||||
|
// Inline edit state
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editTitle, setEditTitle] = useState('');
|
||||||
|
const [editUrl, setEditUrl] = useState('');
|
||||||
|
const [editIconUrl, setEditIconUrl] = useState('');
|
||||||
|
|
||||||
|
// Sorted favorites: position asc, then title asc
|
||||||
|
const sortedFavorites = useMemo(
|
||||||
|
() =>
|
||||||
|
[...favorites].sort((a, b) => {
|
||||||
|
if (a.position !== b.position) return a.position - b.position;
|
||||||
|
return a.title.localeCompare(b.title);
|
||||||
|
}),
|
||||||
|
[favorites],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await fetchFavorites(instanceId);
|
||||||
|
if (!cancelled) setFavorites(data);
|
||||||
|
} catch {
|
||||||
|
// Use a stable error key — t is excluded from deps intentionally
|
||||||
|
if (!cancelled) setError('favorites.error');
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// instanceId is the only meaningful dependency — t is stable at runtime
|
||||||
|
// and excluded to prevent re-fetching on every render (mock creates new fn)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
function getFallbackLetter(title: string): string {
|
||||||
|
const clean = title.trim();
|
||||||
|
return clean ? clean.slice(0, 1).toUpperCase() : '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- View mode toggle (edit mode only, D-03) ---
|
||||||
|
function handleViewMode(next: 'list' | 'grid') {
|
||||||
|
setViewMode(next);
|
||||||
|
void updateWidgetConfig(instanceId, { viewMode: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Add favorite ---
|
||||||
|
async function handleAdd(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
const cleanTitle = newTitle.trim();
|
||||||
|
const cleanUrl = newUrl.trim();
|
||||||
|
const cleanIconUrl = newIconUrl.trim();
|
||||||
|
|
||||||
|
if (!cleanTitle || !cleanUrl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await createFavorite({
|
||||||
|
widgetId: instanceId,
|
||||||
|
title: cleanTitle,
|
||||||
|
url: cleanUrl,
|
||||||
|
iconUrl: cleanIconUrl || undefined,
|
||||||
|
});
|
||||||
|
setFavorites((prev) => [...prev, created]);
|
||||||
|
setNewTitle('');
|
||||||
|
setNewUrl('');
|
||||||
|
setNewIconUrl('');
|
||||||
|
} catch {
|
||||||
|
setError(t('favorites.error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Edit favorite ---
|
||||||
|
function startEdit(fav: FavoriteLink) {
|
||||||
|
setEditingId(fav.id);
|
||||||
|
setEditTitle(fav.title);
|
||||||
|
setEditUrl(fav.url);
|
||||||
|
setEditIconUrl(fav.iconUrl ?? '');
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditTitle('');
|
||||||
|
setEditUrl('');
|
||||||
|
setEditIconUrl('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveEdit(id: string) {
|
||||||
|
const cleanTitle = editTitle.trim();
|
||||||
|
const cleanUrl = editUrl.trim();
|
||||||
|
const cleanIconUrl = editIconUrl.trim();
|
||||||
|
|
||||||
|
if (!cleanTitle || !cleanUrl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await updateFavorite(id, {
|
||||||
|
title: cleanTitle,
|
||||||
|
url: cleanUrl,
|
||||||
|
iconUrl: cleanIconUrl || null,
|
||||||
|
});
|
||||||
|
setFavorites((prev) => prev.map((f) => (f.id === id ? updated : f)));
|
||||||
|
cancelEdit();
|
||||||
|
} catch {
|
||||||
|
setError(t('favorites.error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Delete favorite ---
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
try {
|
||||||
|
await deleteFavorite(id);
|
||||||
|
setFavorites((prev) => prev.filter((f) => f.id !== id));
|
||||||
|
if (editingId === id) cancelEdit();
|
||||||
|
} catch {
|
||||||
|
setError(t('favorites.error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Render ---
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-auto p-2 gap-2">
|
||||||
|
{/* View mode toggle (edit mode only) */}
|
||||||
|
{isEditMode && (
|
||||||
|
<div className="flex gap-1 widgetNoDrag" aria-label={t('favorites.name')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleViewMode('list')}
|
||||||
|
className={`px-2 py-1 text-xs rounded ${
|
||||||
|
viewMode === 'list'
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('favorites.listView')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleViewMode('grid')}
|
||||||
|
className={`px-2 py-1 text-xs rounded ${
|
||||||
|
viewMode === 'grid'
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('favorites.gridView')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status messages */}
|
||||||
|
{loading && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('favorites.loading')}</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Favorites list / grid */}
|
||||||
|
{!loading && sortedFavorites.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('favorites.empty')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && sortedFavorites.length > 0 && (
|
||||||
|
viewMode === 'grid' ? (
|
||||||
|
<div
|
||||||
|
data-testid="favorites-grid"
|
||||||
|
className="grid grid-cols-3 gap-2"
|
||||||
|
>
|
||||||
|
{sortedFavorites.map((fav) => (
|
||||||
|
<FavoriteTile
|
||||||
|
key={fav.id}
|
||||||
|
fav={fav}
|
||||||
|
isEditMode={isEditMode}
|
||||||
|
editingId={editingId}
|
||||||
|
editTitle={editTitle}
|
||||||
|
editUrl={editUrl}
|
||||||
|
editIconUrl={editIconUrl}
|
||||||
|
getFallbackLetter={getFallbackLetter}
|
||||||
|
onStartEdit={startEdit}
|
||||||
|
onCancelEdit={cancelEdit}
|
||||||
|
onSaveEdit={handleSaveEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onEditTitleChange={setEditTitle}
|
||||||
|
onEditUrlChange={setEditUrl}
|
||||||
|
onEditIconUrlChange={setEditIconUrl}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
data-testid="favorites-list"
|
||||||
|
className="flex flex-col gap-1"
|
||||||
|
>
|
||||||
|
{sortedFavorites.map((fav) => (
|
||||||
|
<FavoriteTile
|
||||||
|
key={fav.id}
|
||||||
|
fav={fav}
|
||||||
|
isEditMode={isEditMode}
|
||||||
|
editingId={editingId}
|
||||||
|
editTitle={editTitle}
|
||||||
|
editUrl={editUrl}
|
||||||
|
editIconUrl={editIconUrl}
|
||||||
|
getFallbackLetter={getFallbackLetter}
|
||||||
|
onStartEdit={startEdit}
|
||||||
|
onCancelEdit={cancelEdit}
|
||||||
|
onSaveEdit={handleSaveEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onEditTitleChange={setEditTitle}
|
||||||
|
onEditUrlChange={setEditUrl}
|
||||||
|
onEditIconUrlChange={setEditIconUrl}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add form (edit mode only, D-04) */}
|
||||||
|
{isEditMode && (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => void handleAdd(e)}
|
||||||
|
className="flex flex-col gap-1 pt-1 border-t border-border widgetNoDrag"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
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={newTitle}
|
||||||
|
onChange={(e) => setNewTitle(e.target.value)}
|
||||||
|
placeholder={t('favorites.addTitle')}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
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={newUrl}
|
||||||
|
onChange={(e) => setNewUrl(e.target.value)}
|
||||||
|
placeholder={t('favorites.addUrl')}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
{t('favorites.addButton')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FavoriteTile sub-component ---
|
||||||
|
|
||||||
|
type FavoriteTileProps = {
|
||||||
|
fav: FavoriteLink;
|
||||||
|
isEditMode: boolean;
|
||||||
|
editingId: string | null;
|
||||||
|
editTitle: string;
|
||||||
|
editUrl: string;
|
||||||
|
editIconUrl: string;
|
||||||
|
getFallbackLetter: (title: string) => string;
|
||||||
|
onStartEdit: (fav: FavoriteLink) => void;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
onSaveEdit: (id: string) => Promise<void>;
|
||||||
|
onDelete: (id: string) => Promise<void>;
|
||||||
|
onEditTitleChange: (v: string) => void;
|
||||||
|
onEditUrlChange: (v: string) => void;
|
||||||
|
onEditIconUrlChange: (v: string) => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function FavoriteTile({
|
||||||
|
fav,
|
||||||
|
isEditMode,
|
||||||
|
editingId,
|
||||||
|
editTitle,
|
||||||
|
editUrl,
|
||||||
|
editIconUrl,
|
||||||
|
getFallbackLetter,
|
||||||
|
onStartEdit,
|
||||||
|
onCancelEdit,
|
||||||
|
onSaveEdit,
|
||||||
|
onDelete,
|
||||||
|
onEditTitleChange,
|
||||||
|
onEditUrlChange,
|
||||||
|
onEditIconUrlChange,
|
||||||
|
t,
|
||||||
|
}: FavoriteTileProps) {
|
||||||
|
const isEditing = editingId === fav.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{/* Main link row */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Anchor — opens in new tab, no server-side redirect (T-08-08) */}
|
||||||
|
<a
|
||||||
|
href={fav.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-2 flex-1 min-w-0 hover:underline widgetNoDrag"
|
||||||
|
onClick={(e) => isEditMode && e.preventDefault()} // block navigation in edit mode
|
||||||
|
>
|
||||||
|
{/* Icon container with letter fallback (T-08-07 — no dangerouslySetInnerHTML) */}
|
||||||
|
<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>
|
||||||
|
{fav.iconUrl && (
|
||||||
|
<img
|
||||||
|
src={fav.iconUrl}
|
||||||
|
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>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Action buttons (edit mode only, D-04) */}
|
||||||
|
{isEditMode && !isEditing && (
|
||||||
|
<div className="flex gap-1 flex-shrink-0 widgetNoDrag">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('favorites.editButton')}
|
||||||
|
title={t('favorites.editButton')}
|
||||||
|
className="p-0.5 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => onStartEdit(fav)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M4 20h4.6L19.2 9.4a2.1 2.1 0 0 0 0-3l-1.6-1.6a2.1 2.1 0 0 0-3 0L4 15.4V20Zm2-2v-1.8L16.1 6.1l1.8 1.8L7.8 18H6Zm9.1-12.9 1.8-1.8 1.8 1.8-1.8 1.8-1.8-1.8Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('favorites.deleteButton')}
|
||||||
|
title={t('favorites.deleteButton')}
|
||||||
|
className="p-0.5 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => void onDelete(fav.id)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M7 21c-.6 0-1.1-.2-1.5-.7A2 2 0 0 1 5 18.9V8H4V6h5V4h6v2h5v2h-1v10.9c0 .6-.2 1.1-.7 1.5-.4.4-.9.6-1.5.6H7ZM17 8H7v10.9l.1.1h9.8l.1-.1V8Zm-8 9h2v-7H9v7Zm4 0h2v-7h-2v7Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline edit form */}
|
||||||
|
{isEditMode && isEditing && (
|
||||||
|
<div className="flex flex-col gap-1 mt-1 pl-7 widgetNoDrag">
|
||||||
|
<input
|
||||||
|
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={editTitle}
|
||||||
|
onChange={(e) => onEditTitleChange(e.target.value)}
|
||||||
|
placeholder={t('favorites.addTitle')}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
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={editUrl}
|
||||||
|
onChange={(e) => onEditUrlChange(e.target.value)}
|
||||||
|
placeholder={t('favorites.addUrl')}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
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)"
|
||||||
|
/>
|
||||||
|
<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"
|
||||||
|
onClick={() => void onSaveEdit(fav.id)}
|
||||||
|
>
|
||||||
|
{t('favorites.saveButton')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 rounded bg-muted px-2 py-1 text-xs text-muted-foreground hover:bg-muted/80 transition-colors"
|
||||||
|
onClick={onCancelEdit}
|
||||||
|
>
|
||||||
|
{t('favorites.cancelButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Favorites API client functions.
|
||||||
|
* Mirrors the NestJS FavoritesController routes (08-03).
|
||||||
|
* All calls use credentials: 'include' for cookie-based auth.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
export interface FavoriteLink {
|
||||||
|
id: string;
|
||||||
|
widgetId: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
iconUrl: string | null;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all favorite links for a specific widget instance.
|
||||||
|
* The widgetId parameter scopes the query to the correct widget (Pitfall 3).
|
||||||
|
*/
|
||||||
|
export async function fetchFavorites(widgetId: string): Promise<FavoriteLink[]> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_URL}/favorites?widgetId=${encodeURIComponent(widgetId)}`,
|
||||||
|
{ credentials: 'include' },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch favorites');
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new favorite link.
|
||||||
|
* Server-side icon discovery runs automatically if iconUrl is not provided.
|
||||||
|
*/
|
||||||
|
export async function createFavorite(payload: {
|
||||||
|
widgetId: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
iconUrl?: string;
|
||||||
|
}): Promise<FavoriteLink> {
|
||||||
|
const res = await fetch(`${API_URL}/favorites`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to create favorite');
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing favorite link.
|
||||||
|
* Pass iconUrl: null to clear a stored icon.
|
||||||
|
*/
|
||||||
|
export async function updateFavorite(
|
||||||
|
id: string,
|
||||||
|
payload: Partial<{ title: string; url: string; iconUrl: string | null; position: number }>,
|
||||||
|
): Promise<FavoriteLink> {
|
||||||
|
const res = await fetch(`${API_URL}/favorites/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to update favorite');
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a favorite link by id.
|
||||||
|
*/
|
||||||
|
export async function deleteFavorite(id: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API_URL}/favorites/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to delete favorite');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user