feat(08-01): registry foundation for 4 new widget types + Calculator widget (GREEN)

- widget-registry.tsx: extend WidgetType union with calculator/favorites/link/stopwatch
- widget-registry.tsx: add WIDGET_CONSTRAINTS entries with per-widget grid constraints (DASH-11)
- widget-registry.tsx: add SVG icons (CalculatorIcon, FavoritesIcon, LinkIcon, StopwatchIcon)
- widget-registry.tsx: add WIDGET_REGISTRY entries and wire functions for all 4 new types
- calculator-widget.tsx: full arithmetic implementation ported from personal-dashboard
  (parseDisplay, formatNumber, calculate, keyboard handler with stopPropagation)
- widget-catalog-modal.tsx: extend WIDGET_TYPES to include all 8 types
- create-widget.dto.ts: extend @IsIn to accept 8 widget types (T-08-01 mitigated)
- page.tsx: import CalculatorWidget and call wireCalculatorWidget()
- de.json / en.json: add i18n keys for calculator, favorites, link, stopwatch
- All 16 tests passing (GREEN)
This commit is contained in:
2026-07-01 09:57:44 +02:00
parent b751ae7453
commit 63ec93bd35
7 changed files with 644 additions and 25 deletions
@@ -7,7 +7,7 @@ import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
*/
export class CreateWidgetDto {
@IsString()
@IsIn(['clock', 'search', 'calendar', 'note'])
@IsIn(['clock', 'search', 'calendar', 'note', 'calculator', 'favorites', 'link', 'stopwatch'])
widgetType!: string;
@IsOptional()
+3 -1
View File
@@ -5,11 +5,12 @@ import { useTranslations } from 'next-intl';
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget } from '@/components/dashboard/widget-registry';
import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget } from '@/components/dashboard/widget-registry';
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
import { NoteWidget } from '@/components/dashboard/widgets/note-widget';
import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widget';
import { useDashboardStore } from '@/lib/stores/dashboard-store';
import type { WidgetType } from '@/components/dashboard/widget-registry';
@@ -18,6 +19,7 @@ wireClockWidget(ClockWidget);
wireSearchWidget(SearchWidget);
wireCalendarWidget(CalendarWidget);
wireNoteWidget(NoteWidget);
wireCalculatorWidget(CalculatorWidget);
export default function DashboardPage() {
const t = useTranslations('widgets');
@@ -10,7 +10,16 @@ interface WidgetCatalogModalProps {
onAddWidget: (type: WidgetType) => void;
}
const WIDGET_TYPES: WidgetType[] = ['clock', 'search', 'calendar', 'note'];
const WIDGET_TYPES: WidgetType[] = [
'clock',
'search',
'calendar',
'note',
'calculator',
'favorites',
'link',
'stopwatch',
];
/**
* Modal dialog showing available widget types as selectable cards.
@@ -2,10 +2,18 @@ import type { ComponentType } from 'react';
/**
* Supported widget types for the dashboard.
* clock is fully implemented in this plan; search/calendar/note are
* declared here so the catalog shows all four types immediately.
* clock/search/calendar/note: implemented in previous plans.
* calculator/favorites/link/stopwatch: Phase 8 additions.
*/
export type WidgetType = 'clock' | 'search' | 'calendar' | 'note';
export type WidgetType =
| 'clock'
| 'search'
| 'calendar'
| 'note'
| 'calculator'
| 'favorites'
| 'link'
| 'stopwatch';
/**
* Props contract that every widget component must accept.
@@ -18,16 +26,22 @@ export interface WidgetProps {
/**
* Per-type size constraints for the react-grid-layout grid.
* Values from UI-SPEC size table (D-06).
* Values from UI-SPEC size table (D-06) and Phase-8 Grid-Constraints (D-01).
*/
export const WIDGET_CONSTRAINTS: Record<
WidgetType,
{ minW: number; minH: number; defaultW: number; defaultH: number }
> = {
// Existing widgets (values unchanged)
clock: { minW: 2, minH: 2, defaultW: 2, defaultH: 2 },
search: { minW: 3, minH: 2, defaultW: 6, defaultH: 2 },
calendar: { minW: 3, minH: 3, defaultW: 4, defaultH: 6 },
note: { minW: 2, minH: 3, defaultW: 3, defaultH: 4 },
// Phase 8 new widgets (D-01)
calculator: { minW: 2, minH: 4, defaultW: 3, defaultH: 5 },
favorites: { minW: 2, minH: 3, defaultW: 3, defaultH: 5 },
link: { minW: 2, minH: 2, defaultW: 2, defaultH: 2 },
stopwatch: { minW: 2, minH: 2, defaultW: 3, defaultH: 3 },
};
/**
@@ -147,12 +161,98 @@ function NoteIcon({ className }: { className?: string }) {
);
}
function CalculatorIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<rect x="4" y="2" width="16" height="20" rx="2" />
<line x1="8" y1="6" x2="16" y2="6" />
<line x1="8" y1="10" x2="8" y2="10" />
<line x1="12" y1="10" x2="12" y2="10" />
<line x1="16" y1="10" x2="16" y2="10" />
<line x1="8" y1="14" x2="8" y2="14" />
<line x1="12" y1="14" x2="12" y2="14" />
<line x1="16" y1="14" x2="16" y2="18" />
<line x1="8" y1="18" x2="8" y2="18" />
<line x1="12" y1="18" x2="12" y2="18" />
</svg>
);
}
function FavoritesIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
);
}
function LinkIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
);
}
function StopwatchIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<circle cx="12" cy="13" r="8" />
<polyline points="12 9 12 13 14 15" />
<line x1="9" y1="2" x2="15" y2="2" />
<line x1="12" y1="2" x2="12" y2="4" />
</svg>
);
}
/**
* Registry of all widget types. Used by the widget catalog modal
* and the grid renderer to look up components and constraints.
*
* clock: Fully implemented this plan.
* search, calendar, note: Placeholder components — implemented in later plans.
*/
export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
clock: {
@@ -161,8 +261,7 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'clock.description',
icon: ClockIcon,
...WIDGET_CONSTRAINTS.clock,
// Lazy import replaced at module level for clock
component: PlaceholderWidget, // Replaced below
component: PlaceholderWidget, // Replaced via wireClockWidget()
},
search: {
type: 'search',
@@ -178,7 +277,7 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'calendar.description',
icon: CalendarIcon,
...WIDGET_CONSTRAINTS.calendar,
component: PlaceholderWidget,
component: PlaceholderWidget, // Replaced via wireCalendarWidget()
},
note: {
type: 'note',
@@ -188,15 +287,46 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
...WIDGET_CONSTRAINTS.note,
component: PlaceholderWidget, // Replaced via wireNoteWidget()
},
calculator: {
type: 'calculator',
nameKey: 'calculator.name',
descriptionKey: 'calculator.description',
icon: CalculatorIcon,
...WIDGET_CONSTRAINTS.calculator,
component: PlaceholderWidget, // Replaced via wireCalculatorWidget()
},
favorites: {
type: 'favorites',
nameKey: 'favorites.name',
descriptionKey: 'favorites.description',
icon: FavoritesIcon,
...WIDGET_CONSTRAINTS.favorites,
component: PlaceholderWidget, // Replaced via wireFavoritesWidget()
},
link: {
type: 'link',
nameKey: 'link.name',
descriptionKey: 'link.description',
icon: LinkIcon,
...WIDGET_CONSTRAINTS.link,
component: PlaceholderWidget, // Replaced via wireLinkWidget()
},
stopwatch: {
type: 'stopwatch',
nameKey: 'stopwatch.name',
descriptionKey: 'stopwatch.description',
icon: StopwatchIcon,
...WIDGET_CONSTRAINTS.stopwatch,
component: PlaceholderWidget, // Replaced via wireStopwatchWidget()
},
};
// Wire actual widget components lazily to avoid circular deps
// (imports are deferred so widget-registry can be imported by tests without
// pulling in the entire React tree)
let clockWired = false;
export function wireClockWidget(
component: ComponentType<WidgetProps>,
) {
export function wireClockWidget(component: ComponentType<WidgetProps>) {
if (!clockWired) {
WIDGET_REGISTRY.clock.component = component;
clockWired = true;
@@ -204,9 +334,7 @@ export function wireClockWidget(
}
let searchWired = false;
export function wireSearchWidget(
component: ComponentType<WidgetProps>,
) {
export function wireSearchWidget(component: ComponentType<WidgetProps>) {
if (!searchWired) {
WIDGET_REGISTRY.search.component = component;
searchWired = true;
@@ -214,9 +342,7 @@ export function wireSearchWidget(
}
let calendarWired = false;
export function wireCalendarWidget(
component: ComponentType<WidgetProps>,
) {
export function wireCalendarWidget(component: ComponentType<WidgetProps>) {
if (!calendarWired) {
WIDGET_REGISTRY.calendar.component = component;
calendarWired = true;
@@ -224,11 +350,41 @@ export function wireCalendarWidget(
}
let noteWired = false;
export function wireNoteWidget(
component: ComponentType<WidgetProps>,
) {
export function wireNoteWidget(component: ComponentType<WidgetProps>) {
if (!noteWired) {
WIDGET_REGISTRY.note.component = component;
noteWired = true;
}
}
let calculatorWired = false;
export function wireCalculatorWidget(component: ComponentType<WidgetProps>) {
if (!calculatorWired) {
WIDGET_REGISTRY.calculator.component = component;
calculatorWired = true;
}
}
let favoritesWired = false;
export function wireFavoritesWidget(component: ComponentType<WidgetProps>) {
if (!favoritesWired) {
WIDGET_REGISTRY.favorites.component = component;
favoritesWired = true;
}
}
let linkWired = false;
export function wireLinkWidget(component: ComponentType<WidgetProps>) {
if (!linkWired) {
WIDGET_REGISTRY.link.component = component;
linkWired = true;
}
}
let stopwatchWired = false;
export function wireStopwatchWidget(component: ComponentType<WidgetProps>) {
if (!stopwatchWired) {
WIDGET_REGISTRY.stopwatch.component = component;
stopwatchWired = true;
}
}
@@ -0,0 +1,388 @@
'use client';
import type { KeyboardEvent } from 'react';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import type { WidgetProps } from '../widget-registry';
type Operator = 'add' | 'subtract' | 'multiply' | 'divide';
const MAX_DISPLAY_LENGTH = 16;
function parseDisplay(value: string): number {
const normalizedValue = value.replace(',', '.');
const numberValue = Number(normalizedValue);
if (!Number.isFinite(numberValue)) {
return 0;
}
return numberValue;
}
function formatNumber(value: number): string {
if (!Number.isFinite(value)) {
return 'Fehler';
}
if (Object.is(value, -0)) {
return '0';
}
const absoluteValue = Math.abs(value);
if (
absoluteValue !== 0 &&
(absoluteValue >= 1e15 || absoluteValue < 1e-9)
) {
return value.toExponential(8).replace('.', ',');
}
const roundedValue = Math.round(value * 1e10) / 1e10;
return roundedValue.toLocaleString('de-DE', {
useGrouping: false,
maximumFractionDigits: 10,
});
}
function calculate(
firstValue: number,
secondValue: number,
operator: Operator,
): number {
if (operator === 'add') return firstValue + secondValue;
if (operator === 'subtract') return firstValue - secondValue;
if (operator === 'multiply') return firstValue * secondValue;
if (operator === 'divide') {
if (secondValue === 0) return Number.NaN;
return firstValue / secondValue;
}
return secondValue;
}
function getOperatorLabel(operator: Operator | null): string {
if (operator === 'add') return '+';
if (operator === 'subtract') return '−';
if (operator === 'multiply') return '×';
if (operator === 'divide') return '÷';
return '';
}
interface ButtonProps {
label: string;
ariaLabel?: string;
className?: string;
onClick: () => void;
disabled?: boolean;
}
function CalcButton({ label, ariaLabel, className = '', onClick, disabled }: ButtonProps) {
return (
<button
type="button"
aria-label={ariaLabel ?? label}
onClick={onClick}
disabled={disabled}
className={`flex items-center justify-center rounded text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${className}`}
>
{label}
</button>
);
}
/**
* Calculator widget — arithmetic (+ − × ÷) with mouse and keyboard support.
* Ported from personal-dashboard/src/components/CalculatorWidget.tsx.
* All styling via Tailwind utility classes (Tessera convention).
* Keyboard handler uses stopPropagation() to prevent react-grid-layout interference (Pitfall 1).
*/
export function CalculatorWidget({ isEditMode }: WidgetProps) {
const t = useTranslations('widgets');
const [display, setDisplay] = useState('0');
const [storedValue, setStoredValue] = useState<number | null>(null);
const [pendingOperator, setPendingOperator] = useState<Operator | null>(null);
const [waitingForOperand, setWaitingForOperand] = useState(false);
const [memory, setMemory] = useState(0);
const displayIsError = display === 'Fehler';
function resetIfError(): boolean {
if (!displayIsError) return false;
setDisplay('0');
setStoredValue(null);
setPendingOperator(null);
setWaitingForOperand(false);
return true;
}
function inputDigit(digit: string) {
if (resetIfError()) {
setDisplay(digit);
return;
}
if (waitingForOperand) {
setDisplay(digit);
setWaitingForOperand(false);
return;
}
setDisplay((cur) => {
if (cur === '0') return digit;
if (cur.replace('-', '').replace(',', '').length >= MAX_DISPLAY_LENGTH)
return cur;
return `${cur}${digit}`;
});
}
function inputDecimal() {
if (resetIfError()) {
setDisplay('0,');
return;
}
if (waitingForOperand) {
setDisplay('0,');
setWaitingForOperand(false);
return;
}
setDisplay((cur) => {
if (cur.includes(',')) return cur;
return `${cur},`;
});
}
function clearEntry() {
setDisplay('0');
setWaitingForOperand(false);
}
function clearAll() {
setDisplay('0');
setStoredValue(null);
setPendingOperator(null);
setWaitingForOperand(false);
}
function backspace() {
if (resetIfError() || waitingForOperand) {
setDisplay('0');
setWaitingForOperand(false);
return;
}
setDisplay((cur) => {
if (
cur.length <= 1 ||
(cur.length === 2 && cur.startsWith('-'))
)
return '0';
return cur.slice(0, -1);
});
}
function toggleSign() {
if (resetIfError()) return;
setDisplay((cur) => {
if (cur === '0') return cur;
return cur.startsWith('-') ? cur.slice(1) : `-${cur}`;
});
}
function applyUnary(operation: 'percent' | 'reciprocal' | 'square' | 'sqrt') {
if (resetIfError()) return;
const cur = parseDisplay(display);
let next = cur;
if (operation === 'percent') {
next =
storedValue !== null && pendingOperator
? (storedValue * cur) / 100
: cur / 100;
} else if (operation === 'reciprocal') {
next = cur === 0 ? Number.NaN : 1 / cur;
} else if (operation === 'square') {
next = cur * cur;
} else if (operation === 'sqrt') {
next = cur < 0 ? Number.NaN : Math.sqrt(cur);
}
setDisplay(formatNumber(next));
setWaitingForOperand(true);
}
function chooseOperator(operator: Operator) {
if (resetIfError()) return;
const cur = parseDisplay(display);
if (storedValue === null) {
setStoredValue(cur);
} else if (pendingOperator && !waitingForOperand) {
const result = calculate(storedValue, cur, pendingOperator);
setDisplay(formatNumber(result));
setStoredValue(result);
}
setPendingOperator(operator);
setWaitingForOperand(true);
}
function applyEquals() {
if (resetIfError()) return;
if (storedValue === null || pendingOperator === null) return;
const cur = parseDisplay(display);
const result = calculate(storedValue, cur, pendingOperator);
setDisplay(formatNumber(result));
setStoredValue(null);
setPendingOperator(null);
setWaitingForOperand(true);
}
function memoryClear() {
setMemory(0);
}
function memoryRecall() {
setDisplay(formatNumber(memory));
setWaitingForOperand(true);
}
function memoryAdd() {
if (resetIfError()) return;
setMemory((m) => m + parseDisplay(display));
setWaitingForOperand(true);
}
function memorySubtract() {
if (resetIfError()) return;
setMemory((m) => m - parseDisplay(display));
setWaitingForOperand(true);
}
function memoryStore() {
if (resetIfError()) return;
setMemory(parseDisplay(display));
setWaitingForOperand(true);
}
function handleKeyboard(event: KeyboardEvent<HTMLDivElement>) {
// In edit mode, do not capture keyboard events so the grid can handle them
if (isEditMode) return;
const key = event.key;
const code = event.code;
let handled = true;
if (/^[0-9]$/.test(key)) {
inputDigit(key);
} else if (key === ',' || key === '.' || code === 'NumpadDecimal') {
inputDecimal();
} else if (key === '+' || code === 'NumpadAdd') {
chooseOperator('add');
} else if (key === '-' || code === 'NumpadSubtract') {
chooseOperator('subtract');
} else if (key === '*' || code === 'NumpadMultiply') {
chooseOperator('multiply');
} else if (key === '/' || code === 'NumpadDivide') {
chooseOperator('divide');
} else if (key === 'Enter' || key === '=' || code === 'NumpadEnter') {
applyEquals();
} else if (key === 'Backspace') {
backspace();
} else if (key === 'Escape') {
clearAll();
} else if (key === 'Delete') {
clearEntry();
} else if (key === '%') {
applyUnary('percent');
} else if (key === 'F9') {
toggleSign();
} else {
handled = false;
}
if (handled) {
event.preventDefault();
event.stopPropagation(); // Pitfall 1: prevent react-grid-layout interference
}
}
const baseBtn = 'h-9 cursor-pointer select-none active:scale-95';
const numBtn = `${baseBtn} bg-background hover:bg-muted border border-border text-foreground`;
const opBtn = `${baseBtn} bg-muted hover:bg-muted/80 border border-border text-primary font-semibold`;
const utilBtn = `${baseBtn} bg-muted/50 hover:bg-muted border border-border text-muted-foreground text-xs`;
const eqBtn = `${baseBtn} bg-primary hover:bg-primary/90 text-primary-foreground font-semibold`;
const memBtn = 'h-7 cursor-pointer select-none rounded text-xs font-medium transition-colors hover:bg-muted text-muted-foreground disabled:opacity-30 disabled:cursor-default px-1';
return (
<div
className="flex h-full flex-col gap-1 p-2 select-none"
tabIndex={0}
role="application"
aria-label={t('calculator.name')}
onKeyDown={handleKeyboard}
>
{/* Display */}
<output
className="flex min-h-[2.5rem] items-end justify-end rounded bg-muted px-3 py-1 font-mono text-xl font-semibold text-foreground overflow-hidden"
aria-label="Anzeige"
>
<span className="truncate">{display}</span>
</output>
{/* Memory row */}
<div className="flex gap-1" aria-label="Speicherfunktionen">
<button type="button" className={memBtn} onClick={memoryClear} disabled={memory === 0}>MC</button>
<button type="button" className={memBtn} onClick={memoryRecall} disabled={memory === 0}>MR</button>
<button type="button" className={memBtn} onClick={memoryAdd}>M+</button>
<button type="button" className={memBtn} onClick={memorySubtract}>M−</button>
<button type="button" className={memBtn} onClick={memoryStore}>MS</button>
<button type="button" className={memBtn} onClick={memoryRecall} disabled={memory === 0}>M</button>
</div>
{/* Keypad — 4 columns × 5 rows */}
<div className="grid flex-1 grid-cols-4 grid-rows-5 gap-1">
{/* Row 1: utility */}
<CalcButton label="%" className={utilBtn} onClick={() => applyUnary('percent')} />
<CalcButton label="CE" className={utilBtn} onClick={clearEntry} />
<CalcButton label="C" className={utilBtn} onClick={clearAll} />
<CalcButton label="⌫" ariaLabel="Rückschritt" className={utilBtn} onClick={backspace} />
{/* Row 2: unary + divide */}
<CalcButton label="1/x" className={utilBtn} onClick={() => applyUnary('reciprocal')} />
<CalcButton label="x²" className={utilBtn} onClick={() => applyUnary('square')} />
<CalcButton label="²√x" className={utilBtn} onClick={() => applyUnary('sqrt')} />
<CalcButton label="÷" className={opBtn} onClick={() => chooseOperator('divide')} />
{/* Row 3: 7 8 9 × */}
<CalcButton label="7" className={numBtn} onClick={() => inputDigit('7')} />
<CalcButton label="8" className={numBtn} onClick={() => inputDigit('8')} />
<CalcButton label="9" className={numBtn} onClick={() => inputDigit('9')} />
<CalcButton label="×" className={opBtn} onClick={() => chooseOperator('multiply')} />
{/* Row 4: 4 5 6 − */}
<CalcButton label="4" className={numBtn} onClick={() => inputDigit('4')} />
<CalcButton label="5" className={numBtn} onClick={() => inputDigit('5')} />
<CalcButton label="6" className={numBtn} onClick={() => inputDigit('6')} />
<CalcButton label="−" className={opBtn} onClick={() => chooseOperator('subtract')} />
{/* Row 5: 1 2 3 + */}
<CalcButton label="1" className={numBtn} onClick={() => inputDigit('1')} />
<CalcButton label="2" className={numBtn} onClick={() => inputDigit('2')} />
<CalcButton label="3" className={numBtn} onClick={() => inputDigit('3')} />
<CalcButton label="+" className={opBtn} onClick={() => chooseOperator('add')} />
{/* Row 6: +/- 0 , = */}
<CalcButton label="+/−" className={numBtn} onClick={toggleSign} />
<CalcButton label="0" className={numBtn} onClick={() => inputDigit('0')} />
<CalcButton label="," className={numBtn} onClick={inputDecimal} />
<CalcButton
label="="
ariaLabel={`Gleich ${getOperatorLabel(pendingOperator)}`}
className={eqBtn}
onClick={applyEquals}
/>
</div>
</div>
);
}
+32
View File
@@ -192,6 +192,38 @@
"description": "Freitext-Notizen mit Markdown",
"defaultTitle": "Notiz",
"autosaveError": "Speichern fehlgeschlagen"
},
"calculator": {
"name": "Taschenrechner",
"description": "Grundrechenarten mit Tastatureingabe"
},
"favorites": {
"name": "Favoriten",
"description": "Schnellzugriff auf Links",
"loading": "Favoriten werden geladen...",
"empty": "Noch keine Favoriten.",
"addTitle": "Titel",
"addUrl": "URL",
"addButton": "Hinzufuegen",
"listView": "Liste",
"gridView": "Kacheln",
"editButton": "Favorit bearbeiten",
"deleteButton": "Favorit loeschen",
"saveButton": "Speichern",
"cancelButton": "Abbrechen",
"error": "Fehler beim Laden der Favoriten"
},
"link": {
"name": "Link",
"description": "Einzelner Schnellzugriff-Link"
},
"stopwatch": {
"name": "Stoppuhr",
"description": "Zeitmessung mit Rundenzeiten",
"start": "Start",
"stop": "Stop",
"reset": "Reset",
"lap": "Runde"
}
},
"admin": {
+32
View File
@@ -192,6 +192,38 @@
"description": "Free-text notes with Markdown",
"defaultTitle": "Note",
"autosaveError": "Save failed"
},
"calculator": {
"name": "Calculator",
"description": "Basic arithmetic with keyboard support"
},
"favorites": {
"name": "Favorites",
"description": "Quick-access links",
"loading": "Loading favorites...",
"empty": "No favorites yet.",
"addTitle": "Title",
"addUrl": "URL",
"addButton": "Add",
"listView": "List",
"gridView": "Grid",
"editButton": "Edit favorite",
"deleteButton": "Delete favorite",
"saveButton": "Save",
"cancelButton": "Cancel",
"error": "Error loading favorites"
},
"link": {
"name": "Link",
"description": "Single quick-access link"
},
"stopwatch": {
"name": "Stopwatch",
"description": "Time measurement with lap times",
"start": "Start",
"stop": "Stop",
"reset": "Reset",
"lap": "Lap"
}
},
"admin": {