feat(08-04): LinkWidget implementation + page.tsx wiring (GREEN)
- LinkWidget: single-link widget reusing FavoriteLink backend (D-06) - Single-link enforcement: add form hidden when link exists - List (row) and tile (grid) view modes, switchable in edit mode - Inline add/edit/delete forms in edit mode - Letter fallback span + icon with onError hide (T-08-11) - Links with target="_blank" rel="noreferrer" (T-08-12) - i18n keys added to de.json + en.json (link.*) - wireLinkWidget(LinkWidget) added to portal page.tsx - All 7 link-widget tests pass (GREEN); tsc --noEmit clean
This commit is contained in:
@@ -5,7 +5,7 @@ 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, wireFavoritesWidget } from '@/components/dashboard/widget-registry';
|
import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget, wireStopwatchWidget, wireFavoritesWidget, wireLinkWidget } 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';
|
||||||
@@ -13,6 +13,7 @@ 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 { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget';
|
||||||
|
import { LinkWidget } from '@/components/dashboard/widgets/link-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';
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ wireNoteWidget(NoteWidget);
|
|||||||
wireCalculatorWidget(CalculatorWidget);
|
wireCalculatorWidget(CalculatorWidget);
|
||||||
wireStopwatchWidget(StopwatchWidget);
|
wireStopwatchWidget(StopwatchWidget);
|
||||||
wireFavoritesWidget(FavoritesWidget);
|
wireFavoritesWidget(FavoritesWidget);
|
||||||
|
wireLinkWidget(LinkWidget);
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const t = useTranslations('widgets');
|
const t = useTranslations('widgets');
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FormEvent, useEffect, 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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LinkWidget — single-link quick-access tile (DASH-09 / D-06).
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Displays exactly one link, scoped to this widget instance via instanceId
|
||||||
|
* - Shares the FavoriteLink backend from Plan 03 (widgetId = instanceId)
|
||||||
|
* - Single-link enforcement: add form hidden when a link already exists
|
||||||
|
* - Add / edit / delete in edit mode (inline forms)
|
||||||
|
* - List view (row: icon + title, default) and tile view, switchable in edit mode
|
||||||
|
* - Server-side icon discovery (letter fallback when iconUrl is null)
|
||||||
|
* - Link opens in new tab with rel="noreferrer" (T-08-12 open-redirect mitigation)
|
||||||
|
* - No dangerouslySetInnerHTML (T-08-11 XSS mitigation)
|
||||||
|
*/
|
||||||
|
export function LinkWidget({
|
||||||
|
instanceId,
|
||||||
|
config,
|
||||||
|
isEditMode,
|
||||||
|
}: WidgetProps) {
|
||||||
|
const t = useTranslations('widgets');
|
||||||
|
|
||||||
|
const [link, setLink] = useState<FavoriteLink | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// View mode: read from config, default 'list' (D-06)
|
||||||
|
const [viewMode, setViewMode] = useState<'list' | 'grid'>(
|
||||||
|
(config.viewMode as 'list' | 'grid') ?? 'list',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add-form state
|
||||||
|
const [newTitle, setNewTitle] = useState('');
|
||||||
|
const [newUrl, setNewUrl] = useState('');
|
||||||
|
|
||||||
|
// Inline edit state
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editTitle, setEditTitle] = useState('');
|
||||||
|
const [editUrl, setEditUrl] = useState('');
|
||||||
|
const [editIconUrl, setEditIconUrl] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await fetchFavorites(instanceId);
|
||||||
|
// Single-link: take only the first entry (D-06)
|
||||||
|
if (!cancelled) setLink(data[0] ?? null);
|
||||||
|
} catch {
|
||||||
|
// Stable error key — t excluded from deps to prevent re-fetch loops
|
||||||
|
if (!cancelled) setError('link.error');
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// instanceId is the only meaningful dependency — t is excluded intentionally
|
||||||
|
// (mock creates new fn ref on each render, causing re-fetch loops in tests)
|
||||||
|
// 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) ---
|
||||||
|
function handleViewMode(next: 'list' | 'grid') {
|
||||||
|
setViewMode(next);
|
||||||
|
void updateWidgetConfig(instanceId, { viewMode: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Add link ---
|
||||||
|
async function handleAdd(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
const cleanTitle = newTitle.trim();
|
||||||
|
const cleanUrl = newUrl.trim();
|
||||||
|
if (!cleanTitle || !cleanUrl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await createFavorite({
|
||||||
|
widgetId: instanceId,
|
||||||
|
title: cleanTitle,
|
||||||
|
url: cleanUrl,
|
||||||
|
});
|
||||||
|
setLink(created);
|
||||||
|
setNewTitle('');
|
||||||
|
setNewUrl('');
|
||||||
|
} catch {
|
||||||
|
setError('link.error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Edit link ---
|
||||||
|
function startEdit() {
|
||||||
|
if (!link) return;
|
||||||
|
setIsEditing(true);
|
||||||
|
setEditTitle(link.title);
|
||||||
|
setEditUrl(link.url);
|
||||||
|
setEditIconUrl(link.iconUrl ?? '');
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditTitle('');
|
||||||
|
setEditUrl('');
|
||||||
|
setEditIconUrl('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveEdit() {
|
||||||
|
if (!link) return;
|
||||||
|
const cleanTitle = editTitle.trim();
|
||||||
|
const cleanUrl = editUrl.trim();
|
||||||
|
const cleanIconUrl = editIconUrl.trim();
|
||||||
|
if (!cleanTitle || !cleanUrl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await updateFavorite(link.id, {
|
||||||
|
title: cleanTitle,
|
||||||
|
url: cleanUrl,
|
||||||
|
iconUrl: cleanIconUrl || null,
|
||||||
|
});
|
||||||
|
setLink(updated);
|
||||||
|
cancelEdit();
|
||||||
|
} catch {
|
||||||
|
setError('link.error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Delete link ---
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!link) return;
|
||||||
|
try {
|
||||||
|
await deleteFavorite(link.id);
|
||||||
|
setLink(null);
|
||||||
|
cancelEdit();
|
||||||
|
} catch {
|
||||||
|
setError('link.error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Shared icon element ---
|
||||||
|
function renderIcon(size: 'sm' | 'md') {
|
||||||
|
if (!link) return null;
|
||||||
|
const wh = size === 'sm' ? 'w-5 h-5' : 'w-8 h-8';
|
||||||
|
const textSize = size === 'sm' ? 'text-xs' : 'text-sm';
|
||||||
|
const imgSize = size === 'sm' ? 20 : 32;
|
||||||
|
return (
|
||||||
|
<div className={`relative flex-shrink-0 ${wh}`}>
|
||||||
|
<span
|
||||||
|
data-testid="link-letter-fallback"
|
||||||
|
className={`absolute inset-0 flex items-center justify-center ${textSize} font-semibold bg-muted rounded`}
|
||||||
|
>
|
||||||
|
{getFallbackLetter(link.title)}
|
||||||
|
</span>
|
||||||
|
{link.iconUrl && (
|
||||||
|
<img
|
||||||
|
src={link.iconUrl}
|
||||||
|
alt=""
|
||||||
|
width={imgSize}
|
||||||
|
height={imgSize}
|
||||||
|
loading="lazy"
|
||||||
|
className={`absolute inset-0 ${wh} rounded`}
|
||||||
|
onError={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Inline edit form ---
|
||||||
|
function renderEditForm() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 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) => setEditTitle(e.target.value)}
|
||||||
|
placeholder={t('link.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) => setEditUrl(e.target.value)}
|
||||||
|
placeholder={t('link.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) => setEditIconUrl(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 handleSaveEdit()}
|
||||||
|
>
|
||||||
|
{t('link.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={cancelEdit}
|
||||||
|
>
|
||||||
|
{t('link.cancelButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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">
|
||||||
|
<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('link.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('link.gridView')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status messages */}
|
||||||
|
{loading && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('link.loading')}</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!loading && !link && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('link.empty')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Link display — list (row) view */}
|
||||||
|
{!loading && link && !isEditing && viewMode === 'list' && (
|
||||||
|
<div data-testid="link-list" className="flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-2 flex-1 min-w-0 hover:underline widgetNoDrag"
|
||||||
|
onClick={(e) => isEditMode && e.preventDefault()}
|
||||||
|
>
|
||||||
|
{renderIcon('sm')}
|
||||||
|
<span className="text-xs truncate">{link.title}</span>
|
||||||
|
</a>
|
||||||
|
{isEditMode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('link.editButton')}
|
||||||
|
title={t('link.editButton')}
|
||||||
|
className="p-0.5 text-muted-foreground hover:text-foreground flex-shrink-0 widgetNoDrag"
|
||||||
|
onClick={startEdit}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Link display — tile (grid) view */}
|
||||||
|
{!loading && link && !isEditing && viewMode === 'grid' && (
|
||||||
|
<div data-testid="link-tile" className="flex flex-col items-center gap-1 p-1">
|
||||||
|
<a
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex flex-col items-center gap-1 widgetNoDrag"
|
||||||
|
onClick={(e) => isEditMode && e.preventDefault()}
|
||||||
|
>
|
||||||
|
{renderIcon('md')}
|
||||||
|
<span className="text-xs text-center truncate max-w-full">{link.title}</span>
|
||||||
|
</a>
|
||||||
|
{isEditMode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('link.editButton')}
|
||||||
|
title={t('link.editButton')}
|
||||||
|
className="p-0.5 text-muted-foreground hover:text-foreground widgetNoDrag"
|
||||||
|
onClick={startEdit}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inline edit form */}
|
||||||
|
{isEditMode && isEditing && link && renderEditForm()}
|
||||||
|
|
||||||
|
{/* Add form — single-link enforcement: hidden when link exists (D-06) */}
|
||||||
|
{isEditMode && !link && !loading && (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => void handleAdd(e)}
|
||||||
|
className="flex flex-col gap-1 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('link.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('link.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('link.addButton')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -215,7 +215,19 @@
|
|||||||
},
|
},
|
||||||
"link": {
|
"link": {
|
||||||
"name": "Link",
|
"name": "Link",
|
||||||
"description": "Einzelner Schnellzugriff-Link"
|
"description": "Einzelner Schnellzugriff-Link",
|
||||||
|
"loading": "Link wird geladen...",
|
||||||
|
"empty": "Noch kein Link gesetzt.",
|
||||||
|
"addTitle": "Titel",
|
||||||
|
"addUrl": "URL",
|
||||||
|
"addButton": "Hinzufuegen",
|
||||||
|
"listView": "Liste",
|
||||||
|
"gridView": "Kachel",
|
||||||
|
"editButton": "Link bearbeiten",
|
||||||
|
"deleteButton": "Link loeschen",
|
||||||
|
"saveButton": "Speichern",
|
||||||
|
"cancelButton": "Abbrechen",
|
||||||
|
"error": "Fehler beim Laden des Links"
|
||||||
},
|
},
|
||||||
"stopwatch": {
|
"stopwatch": {
|
||||||
"name": "Stoppuhr",
|
"name": "Stoppuhr",
|
||||||
|
|||||||
@@ -215,7 +215,19 @@
|
|||||||
},
|
},
|
||||||
"link": {
|
"link": {
|
||||||
"name": "Link",
|
"name": "Link",
|
||||||
"description": "Single quick-access link"
|
"description": "Single quick-access link",
|
||||||
|
"loading": "Loading link...",
|
||||||
|
"empty": "No link set yet.",
|
||||||
|
"addTitle": "Title",
|
||||||
|
"addUrl": "URL",
|
||||||
|
"addButton": "Add",
|
||||||
|
"listView": "List",
|
||||||
|
"gridView": "Tile",
|
||||||
|
"editButton": "Edit link",
|
||||||
|
"deleteButton": "Delete link",
|
||||||
|
"saveButton": "Save",
|
||||||
|
"cancelButton": "Cancel",
|
||||||
|
"error": "Error loading link"
|
||||||
},
|
},
|
||||||
"stopwatch": {
|
"stopwatch": {
|
||||||
"name": "Stopwatch",
|
"name": "Stopwatch",
|
||||||
|
|||||||
Reference in New Issue
Block a user