a4051ae132
Next.js 16 dashboard with configurable widgets (favorites, notes, calendar, clock, calculator, search, domain-check), multi-tab support, user auth, dark mode, and Docker deployment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
435 lines
12 KiB
TypeScript
435 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import type { KeyboardEvent } from "react";
|
||
import { useState } from "react";
|
||
import styles from "./CalculatorWidget.module.css";
|
||
|
||
type Operator = "add" | "subtract" | "multiply" | "divide";
|
||
|
||
type CalculatorButtonProps = {
|
||
label: string;
|
||
ariaLabel?: string;
|
||
className?: string;
|
||
onClick: () => void;
|
||
};
|
||
|
||
const maxDisplayLength = 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 "";
|
||
}
|
||
|
||
function CalculatorButton({ label, ariaLabel, className, onClick }: CalculatorButtonProps) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={[styles.button, className].filter(Boolean).join(" ")}
|
||
aria-label={ariaLabel ?? label}
|
||
onClick={onClick}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export default function CalculatorWidget() {
|
||
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((currentDisplay) => {
|
||
if (currentDisplay === "0") {
|
||
return digit;
|
||
}
|
||
|
||
if (currentDisplay.replace("-", "").replace(",", "").length >= maxDisplayLength) {
|
||
return currentDisplay;
|
||
}
|
||
|
||
return `${currentDisplay}${digit}`;
|
||
});
|
||
}
|
||
|
||
function inputDecimal() {
|
||
if (resetIfError()) {
|
||
setDisplay("0,");
|
||
return;
|
||
}
|
||
|
||
if (waitingForOperand) {
|
||
setDisplay("0,");
|
||
setWaitingForOperand(false);
|
||
return;
|
||
}
|
||
|
||
setDisplay((currentDisplay) => {
|
||
if (currentDisplay.includes(",")) {
|
||
return currentDisplay;
|
||
}
|
||
|
||
return `${currentDisplay},`;
|
||
});
|
||
}
|
||
|
||
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((currentDisplay) => {
|
||
if (currentDisplay.length <= 1 || (currentDisplay.length === 2 && currentDisplay.startsWith("-"))) {
|
||
return "0";
|
||
}
|
||
|
||
return currentDisplay.slice(0, -1);
|
||
});
|
||
}
|
||
|
||
function toggleSign() {
|
||
if (resetIfError()) {
|
||
return;
|
||
}
|
||
|
||
setDisplay((currentDisplay) => {
|
||
if (currentDisplay === "0") {
|
||
return currentDisplay;
|
||
}
|
||
|
||
return currentDisplay.startsWith("-") ? currentDisplay.slice(1) : `-${currentDisplay}`;
|
||
});
|
||
}
|
||
|
||
function applyUnary(operation: "percent" | "reciprocal" | "square" | "sqrt") {
|
||
if (resetIfError()) {
|
||
return;
|
||
}
|
||
|
||
const currentValue = parseDisplay(display);
|
||
let nextValue = currentValue;
|
||
|
||
if (operation === "percent") {
|
||
if (storedValue !== null && pendingOperator) {
|
||
nextValue = (storedValue * currentValue) / 100;
|
||
} else {
|
||
nextValue = currentValue / 100;
|
||
}
|
||
}
|
||
|
||
if (operation === "reciprocal") {
|
||
nextValue = currentValue === 0 ? Number.NaN : 1 / currentValue;
|
||
}
|
||
|
||
if (operation === "square") {
|
||
nextValue = currentValue * currentValue;
|
||
}
|
||
|
||
if (operation === "sqrt") {
|
||
nextValue = currentValue < 0 ? Number.NaN : Math.sqrt(currentValue);
|
||
}
|
||
|
||
setDisplay(formatNumber(nextValue));
|
||
setWaitingForOperand(true);
|
||
}
|
||
|
||
function chooseOperator(operator: Operator) {
|
||
if (resetIfError()) {
|
||
return;
|
||
}
|
||
|
||
const currentValue = parseDisplay(display);
|
||
|
||
if (storedValue === null) {
|
||
setStoredValue(currentValue);
|
||
} else if (pendingOperator && !waitingForOperand) {
|
||
const result = calculate(storedValue, currentValue, pendingOperator);
|
||
setDisplay(formatNumber(result));
|
||
setStoredValue(result);
|
||
}
|
||
|
||
setPendingOperator(operator);
|
||
setWaitingForOperand(true);
|
||
}
|
||
|
||
function applyEquals() {
|
||
if (resetIfError()) {
|
||
return;
|
||
}
|
||
|
||
if (storedValue === null || pendingOperator === null) {
|
||
return;
|
||
}
|
||
|
||
const currentValue = parseDisplay(display);
|
||
const result = calculate(storedValue, currentValue, 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((currentMemory) => currentMemory + parseDisplay(display));
|
||
setWaitingForOperand(true);
|
||
}
|
||
|
||
function memorySubtract() {
|
||
if (resetIfError()) {
|
||
return;
|
||
}
|
||
|
||
setMemory((currentMemory) => currentMemory - parseDisplay(display));
|
||
setWaitingForOperand(true);
|
||
}
|
||
|
||
function memoryStore() {
|
||
if (resetIfError()) {
|
||
return;
|
||
}
|
||
|
||
setMemory(parseDisplay(display));
|
||
setWaitingForOperand(true);
|
||
}
|
||
|
||
function handleKeyboard(event: KeyboardEvent<HTMLDivElement>) {
|
||
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();
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={`${styles.calculator} widgetNoDrag`}
|
||
tabIndex={0}
|
||
role="application"
|
||
aria-label="Taschenrechner"
|
||
onKeyDown={handleKeyboard}
|
||
>
|
||
<output className={styles.display} aria-label="Anzeige">
|
||
{display}
|
||
</output>
|
||
|
||
<div className={styles.memoryRow} aria-label="Speicherfunktionen">
|
||
<button type="button" className={styles.memoryButton} onClick={memoryClear} disabled={memory === 0}>
|
||
MC
|
||
</button>
|
||
<button type="button" className={styles.memoryButton} onClick={memoryRecall} disabled={memory === 0}>
|
||
MR
|
||
</button>
|
||
<button type="button" className={styles.memoryButton} onClick={memoryAdd}>
|
||
M+
|
||
</button>
|
||
<button type="button" className={styles.memoryButton} onClick={memorySubtract}>
|
||
M−
|
||
</button>
|
||
<button type="button" className={styles.memoryButton} onClick={memoryStore}>
|
||
MS
|
||
</button>
|
||
<button type="button" className={styles.memoryButton} onClick={memoryRecall} disabled={memory === 0}>
|
||
M
|
||
</button>
|
||
</div>
|
||
|
||
<div className={styles.keypad}>
|
||
<CalculatorButton label="%" className={styles.utilityButton} onClick={() => applyUnary("percent")} />
|
||
<CalculatorButton label="CE" className={styles.utilityButton} onClick={clearEntry} />
|
||
<CalculatorButton label="C" className={styles.utilityButton} onClick={clearAll} />
|
||
<CalculatorButton label="⌫" ariaLabel="Rückschritt" className={styles.utilityButton} onClick={backspace} />
|
||
|
||
<CalculatorButton label="1/x" className={styles.utilityButton} onClick={() => applyUnary("reciprocal")} />
|
||
<CalculatorButton label="x²" className={styles.utilityButton} onClick={() => applyUnary("square")} />
|
||
<CalculatorButton label="²√x" className={styles.utilityButton} onClick={() => applyUnary("sqrt")} />
|
||
<CalculatorButton label="÷" className={styles.operatorButton} onClick={() => chooseOperator("divide")} />
|
||
|
||
<CalculatorButton label="7" className={styles.numberButton} onClick={() => inputDigit("7")} />
|
||
<CalculatorButton label="8" className={styles.numberButton} onClick={() => inputDigit("8")} />
|
||
<CalculatorButton label="9" className={styles.numberButton} onClick={() => inputDigit("9")} />
|
||
<CalculatorButton label="×" className={styles.operatorButton} onClick={() => chooseOperator("multiply")} />
|
||
|
||
<CalculatorButton label="4" className={styles.numberButton} onClick={() => inputDigit("4")} />
|
||
<CalculatorButton label="5" className={styles.numberButton} onClick={() => inputDigit("5")} />
|
||
<CalculatorButton label="6" className={styles.numberButton} onClick={() => inputDigit("6")} />
|
||
<CalculatorButton label="−" className={styles.operatorButton} onClick={() => chooseOperator("subtract")} />
|
||
|
||
<CalculatorButton label="1" className={styles.numberButton} onClick={() => inputDigit("1")} />
|
||
<CalculatorButton label="2" className={styles.numberButton} onClick={() => inputDigit("2")} />
|
||
<CalculatorButton label="3" className={styles.numberButton} onClick={() => inputDigit("3")} />
|
||
<CalculatorButton label="+" className={styles.operatorButton} onClick={() => chooseOperator("add")} />
|
||
|
||
<CalculatorButton label="+/−" className={styles.numberButton} onClick={toggleSign} />
|
||
<CalculatorButton label="0" className={styles.numberButton} onClick={() => inputDigit("0")} />
|
||
<CalculatorButton label="," className={styles.numberButton} onClick={inputDecimal} />
|
||
<CalculatorButton
|
||
label="="
|
||
ariaLabel={`Gleich ${getOperatorLabel(pendingOperator)}`}
|
||
className={styles.equalsButton}
|
||
onClick={applyEquals}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|