Initial commit: Personal Dashboard
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>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
type BrowserChromeSettings = {
|
||||
dashboardTitle?: string | null;
|
||||
faviconUrl?: string | null;
|
||||
};
|
||||
|
||||
const defaultTitle = "Personal Dashboard";
|
||||
const defaultFaviconUrl = "/favicon.ico";
|
||||
|
||||
function cleanText(value: string | null | undefined, fallback: string): string {
|
||||
const cleanValue = typeof value === "string" ? value.trim() : "";
|
||||
|
||||
return cleanValue || fallback;
|
||||
}
|
||||
|
||||
function applyBrowserChrome(settings: BrowserChromeSettings) {
|
||||
const title = cleanText(settings.dashboardTitle, defaultTitle);
|
||||
const faviconUrl = cleanText(settings.faviconUrl, defaultFaviconUrl);
|
||||
|
||||
document.title = title;
|
||||
|
||||
let faviconLink = document.querySelector<HTMLLinkElement>('link[rel="icon"][data-personal-dashboard="true"]');
|
||||
|
||||
if (!faviconLink) {
|
||||
faviconLink = document.createElement("link");
|
||||
faviconLink.rel = "icon";
|
||||
faviconLink.setAttribute("data-personal-dashboard", "true");
|
||||
document.head.appendChild(faviconLink);
|
||||
}
|
||||
|
||||
faviconLink.href = faviconUrl;
|
||||
}
|
||||
|
||||
export default function BrowserChrome() {
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const response = await fetch("/api/settings", {
|
||||
cache: "no-store"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await response.json().catch(() => null)) as { settings?: BrowserChromeSettings } | null;
|
||||
|
||||
if (active && data?.settings) {
|
||||
applyBrowserChrome(data.settings);
|
||||
}
|
||||
} catch {
|
||||
// Browser chrome is cosmetic; dashboard rendering must not fail because of it.
|
||||
}
|
||||
}
|
||||
|
||||
function handleSettingsUpdated(event: Event) {
|
||||
const customEvent = event as CustomEvent<BrowserChromeSettings>;
|
||||
|
||||
if (customEvent.detail) {
|
||||
applyBrowserChrome(customEvent.detail);
|
||||
}
|
||||
}
|
||||
|
||||
void loadSettings();
|
||||
|
||||
window.addEventListener("personal-dashboard-settings-updated", handleSettingsUpdated);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener("personal-dashboard-settings-updated", handleSettingsUpdated);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
.calculator {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
gap: clamp(4px, 1.4cqh, 8px);
|
||||
padding: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.calculator:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.display {
|
||||
min-height: clamp(34px, 15cqh, 64px);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
padding: clamp(5px, 1.4cqh, 10px) clamp(7px, 2cqw, 12px);
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: clamp(8px, 2cqw, 12px);
|
||||
font-size: clamp(20px, 10cqw, 48px);
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.memoryRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.memoryButton {
|
||||
min-width: 0;
|
||||
min-height: clamp(20px, 6cqh, 28px);
|
||||
padding: 0 4px;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: clamp(10px, 2.3cqw, 12px);
|
||||
}
|
||||
|
||||
.memoryButton:hover:not(:disabled) {
|
||||
background: var(--surface-strong);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.memoryButton:disabled {
|
||||
color: var(--muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.keypad {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: clamp(3px, 0.9cqh, 4px);
|
||||
}
|
||||
|
||||
.button {
|
||||
min-width: 0;
|
||||
min-height: clamp(24px, 8cqh, 38px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
background: var(--surface-strong);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: clamp(6px, 1.7cqw, 9px);
|
||||
cursor: pointer;
|
||||
font-size: clamp(12px, 4cqw, 20px);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
border-color: var(--accent);
|
||||
filter: brightness(0.98);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.numberButton {
|
||||
background: var(--surface);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.operatorButton {
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
|
||||
.equalsButton {
|
||||
color: var(--accent-text);
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.utilityButton {
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
|
||||
@container (max-height: 260px) {
|
||||
.calculator {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.memoryRow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.display {
|
||||
min-height: 38px;
|
||||
padding: 5px 8px;
|
||||
font-size: clamp(20px, 8cqw, 32px);
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 26px;
|
||||
font-size: clamp(11px, 3.4cqw, 15px);
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-height: 210px) {
|
||||
.display {
|
||||
min-height: 30px;
|
||||
padding: 3px 7px;
|
||||
font-size: clamp(18px, 7cqw, 26px);
|
||||
}
|
||||
|
||||
.keypad {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 22px;
|
||||
font-size: clamp(10px, 3cqw, 13px);
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-height: 160px) {
|
||||
.display {
|
||||
min-height: 26px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 18px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 230px) {
|
||||
.display {
|
||||
min-height: 34px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.memoryRow {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.memoryButton {
|
||||
min-height: 22px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 26px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
.clockWidget {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.clockCenter {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
align-content: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.clockTime {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
color: var(--text);
|
||||
font-weight: 900;
|
||||
line-height: 0.92;
|
||||
letter-spacing: -0.07em;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.clockDate {
|
||||
max-width: 100%;
|
||||
color: color-mix(in srgb, var(--text) 74%, transparent);
|
||||
font-weight: 750;
|
||||
line-height: 1.12;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.clockWidgetEditMode .clockCenter {
|
||||
gap: 4px;
|
||||
padding: 2px 8px 5px;
|
||||
}
|
||||
|
||||
.clockDateControl {
|
||||
width: min(100%, 210px);
|
||||
display: grid;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.clockDateControlLabel {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.clockDateSelect {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
min-height: 30px;
|
||||
padding: 0 28px 0 10px;
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--surface) 82%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.clockDateSelect:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type ClockWidgetProps = {
|
||||
widgetId: string;
|
||||
editMode: boolean;
|
||||
};
|
||||
|
||||
type DateDisplayMode = "weekday-date" | "date" | "compact" | "hidden";
|
||||
|
||||
type ClockBoxSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const dateDisplayOptions: Array<{
|
||||
value: DateDisplayMode;
|
||||
label: string;
|
||||
}> = [
|
||||
{
|
||||
value: "weekday-date",
|
||||
label: "Dienstag, 12. Mai"
|
||||
},
|
||||
{
|
||||
value: "date",
|
||||
label: "12. Mai 2026"
|
||||
},
|
||||
{
|
||||
value: "compact",
|
||||
label: "12.05.2026"
|
||||
},
|
||||
{
|
||||
value: "hidden",
|
||||
label: "Datum ausblenden"
|
||||
}
|
||||
];
|
||||
|
||||
function getStorageKey(widgetId: string): string {
|
||||
return `personal-dashboard-clock-date-mode-${widgetId}`;
|
||||
}
|
||||
|
||||
function isDateDisplayMode(value: string | null): value is DateDisplayMode {
|
||||
return value === "weekday-date" || value === "date" || value === "compact" || value === "hidden";
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function formatDate(date: Date, mode: DateDisplayMode): string {
|
||||
if (mode === "hidden") {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (mode === "compact") {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
if (mode === "date") {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "long"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getClockSizing(size: ClockBoxSize, editMode: boolean, dateDisplayMode: DateDisplayMode) {
|
||||
const width = size.width > 0 ? size.width : 220;
|
||||
const height = size.height > 0 ? size.height : 120;
|
||||
|
||||
const controlHeight = editMode ? 34 : 0;
|
||||
const availableHeight = Math.max(32, height - controlHeight);
|
||||
const showDate = dateDisplayMode !== "hidden" && availableHeight >= 42;
|
||||
|
||||
const timeFontSize = Math.floor(
|
||||
clamp(
|
||||
Math.min(
|
||||
width * 0.39,
|
||||
availableHeight * (showDate ? 0.62 : 0.78),
|
||||
editMode ? 62 : 82
|
||||
),
|
||||
availableHeight < 48 ? 18 : 24,
|
||||
editMode ? 62 : 82
|
||||
)
|
||||
);
|
||||
|
||||
const dateFontSize = Math.floor(clamp(Math.min(width * 0.06, availableHeight * 0.15), 9, 14));
|
||||
|
||||
return {
|
||||
timeFontSize,
|
||||
dateFontSize,
|
||||
showDate
|
||||
};
|
||||
}
|
||||
|
||||
export default function ClockWidget({ widgetId, editMode }: ClockWidgetProps) {
|
||||
const widgetRef = useRef<HTMLDivElement | null>(null);
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const [dateDisplayMode, setDateDisplayMode] = useState<DateDisplayMode>("weekday-date");
|
||||
const [boxSize, setBoxSize] = useState<ClockBoxSize>({
|
||||
width: 220,
|
||||
height: 120
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const savedMode = window.localStorage.getItem(getStorageKey(widgetId));
|
||||
|
||||
if (isDateDisplayMode(savedMode)) {
|
||||
setDateDisplayMode(savedMode);
|
||||
}
|
||||
}, [widgetId]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentElement = widgetRef.current;
|
||||
|
||||
if (currentElement === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observedElement: HTMLDivElement = currentElement;
|
||||
|
||||
function updateSize() {
|
||||
const rect = observedElement.getBoundingClientRect();
|
||||
|
||||
setBoxSize({
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
});
|
||||
}
|
||||
|
||||
updateSize();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateSize);
|
||||
resizeObserver.observe(observedElement);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(() => {
|
||||
setNow(new Date());
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const timeLabel = useMemo(() => formatTime(now), [now]);
|
||||
const dateLabel = useMemo(() => formatDate(now, dateDisplayMode), [now, dateDisplayMode]);
|
||||
const sizing = useMemo(() => getClockSizing(boxSize, editMode, dateDisplayMode), [boxSize, editMode, dateDisplayMode]);
|
||||
|
||||
function updateDateDisplayMode(nextMode: DateDisplayMode) {
|
||||
setDateDisplayMode(nextMode);
|
||||
window.localStorage.setItem(getStorageKey(widgetId), nextMode);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={widgetRef} className={editMode ? "pdClockWidget pdClockWidgetEditMode" : "pdClockWidget"}>
|
||||
<div className="pdClockCenter">
|
||||
<time
|
||||
className="pdClockTime"
|
||||
dateTime={now.toISOString()}
|
||||
style={{
|
||||
fontSize: `${sizing.timeFontSize}px`
|
||||
}}
|
||||
>
|
||||
{timeLabel}
|
||||
</time>
|
||||
|
||||
{dateLabel && sizing.showDate ? (
|
||||
<div
|
||||
className="pdClockDate"
|
||||
style={{
|
||||
fontSize: `${sizing.dateFontSize}px`
|
||||
}}
|
||||
>
|
||||
{dateLabel}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{editMode ? (
|
||||
<label className="pdClockDateControl widgetNoDrag">
|
||||
<span className="pdClockDateControlLabel">Datumsanzeige</span>
|
||||
|
||||
<select
|
||||
className="pdClockDateSelect"
|
||||
value={dateDisplayMode}
|
||||
onChange={(event) => updateDateDisplayMode(event.target.value as DateDisplayMode)}
|
||||
>
|
||||
{dateDisplayOptions.map((option) => (
|
||||
<option value={option.value} key={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import ReactGridLayoutBase, { WidthProvider } from "react-grid-layout/legacy";
|
||||
import type { DashboardGridWidget, DashboardLayoutItem } from "@/lib/dashboard-layout";
|
||||
|
||||
export type DashboardGridProps = {
|
||||
widgets: DashboardGridWidget[];
|
||||
editMode: boolean;
|
||||
activeMenuWidgetId?: string | null;
|
||||
renderWidget: (widget: DashboardGridWidget) => ReactNode;
|
||||
onLayoutChange: (layout: DashboardLayoutItem[]) => void;
|
||||
};
|
||||
|
||||
const WidthAwareGridLayout = WidthProvider(ReactGridLayoutBase) as ComponentType<any>;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function getWidgetMinimumSize(widget: DashboardGridWidget): { minW: number; minH: number } {
|
||||
if (widget.type === "search") {
|
||||
return {
|
||||
minW: 8,
|
||||
minH: 5
|
||||
};
|
||||
}
|
||||
|
||||
if (widget.type === "clock") {
|
||||
return {
|
||||
minW: 4,
|
||||
minH: 4
|
||||
};
|
||||
}
|
||||
|
||||
if (widget.type === "calendar") {
|
||||
return {
|
||||
minW: 8,
|
||||
minH: 6
|
||||
};
|
||||
}
|
||||
|
||||
if (widget.type === "calculator") {
|
||||
return {
|
||||
minW: 8,
|
||||
minH: 6
|
||||
};
|
||||
}
|
||||
|
||||
if (widget.type === "note") {
|
||||
return {
|
||||
minW: 4,
|
||||
minH: 5
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
minW: 4,
|
||||
minH: 4
|
||||
};
|
||||
}
|
||||
|
||||
function widgetsToLayout(widgets: DashboardGridWidget[]): DashboardLayoutItem[] {
|
||||
return widgets.map((widget) => {
|
||||
const minimumSize = getWidgetMinimumSize(widget);
|
||||
const width = clamp(widget.w, minimumSize.minW, 48);
|
||||
const height = clamp(widget.h, minimumSize.minH, 180);
|
||||
const x = clamp(widget.x, 0, 48 - width);
|
||||
const y = clamp(widget.y, 0, 10000);
|
||||
|
||||
return {
|
||||
i: widget.id,
|
||||
x,
|
||||
y,
|
||||
w: width,
|
||||
h: height,
|
||||
minW: minimumSize.minW,
|
||||
minH: minimumSize.minH,
|
||||
maxW: 48,
|
||||
maxH: 180
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeLayout(layout: readonly DashboardLayoutItem[]): DashboardLayoutItem[] {
|
||||
return layout.map((item) => ({
|
||||
i: String(item.i),
|
||||
x: Number.isFinite(item.x) ? item.x : 0,
|
||||
y: Number.isFinite(item.y) ? item.y : 0,
|
||||
w: Number.isFinite(item.w) ? item.w : 1,
|
||||
h: Number.isFinite(item.h) ? item.h : 1,
|
||||
minW: item.minW,
|
||||
minH: item.minH,
|
||||
maxW: item.maxW,
|
||||
maxH: item.maxH
|
||||
}));
|
||||
}
|
||||
|
||||
export default function DashboardGrid({
|
||||
widgets,
|
||||
editMode,
|
||||
activeMenuWidgetId = null,
|
||||
renderWidget,
|
||||
onLayoutChange
|
||||
}: DashboardGridProps) {
|
||||
const layout = useMemo(() => widgetsToLayout(widgets), [widgets]);
|
||||
|
||||
return (
|
||||
<div className="widgetGridShell">
|
||||
{widgets.length === 0 ? (
|
||||
<div className="emptyDashboard">
|
||||
<h2>Keine Widgets aktiv</h2>
|
||||
<p className="muted">Schalte den Bearbeitungsmodus ein und füge Widgets hinzu.</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{widgets.length > 0 ? (
|
||||
<WidthAwareGridLayout
|
||||
className="widgetGrid"
|
||||
layout={layout}
|
||||
cols={48}
|
||||
rowHeight={8}
|
||||
margin={[12, 12]}
|
||||
containerPadding={[0, 0]}
|
||||
compactType={null}
|
||||
preventCollision={true}
|
||||
isBounded={false}
|
||||
autoSize={true}
|
||||
isDraggable={editMode}
|
||||
isResizable={editMode}
|
||||
draggableHandle=".widgetDragHandle"
|
||||
draggableCancel=".widgetNoDrag"
|
||||
resizeHandles={["se"]}
|
||||
measureBeforeMount={true}
|
||||
onLayoutChange={(nextLayout: DashboardLayoutItem[]) => onLayoutChange(normalizeLayout(nextLayout))}
|
||||
>
|
||||
{widgets.map((widget) => (
|
||||
<div
|
||||
key={widget.id}
|
||||
className={activeMenuWidgetId === widget.id ? "gridItemMenuOpen" : ""}
|
||||
>
|
||||
{renderWidget(widget)}
|
||||
</div>
|
||||
))}
|
||||
</WidthAwareGridLayout>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
|
||||
type DomainCheckStatus = "idle" | "invalid" | "available" | "registered" | "unknown" | "loading";
|
||||
|
||||
type DomainCheckResult = {
|
||||
status: Exclude<DomainCheckStatus, "idle" | "loading">;
|
||||
domain?: string;
|
||||
asciiDomain?: string;
|
||||
message: string;
|
||||
registrar?: string | null;
|
||||
nameservers?: string[];
|
||||
rdapUrl?: string;
|
||||
whoisServer?: string | null;
|
||||
source?: string;
|
||||
checkedAt?: string;
|
||||
};
|
||||
|
||||
async function parseJsonResponse<T>(response: Response): Promise<T> {
|
||||
const data = (await response.json().catch(() => null)) as T | null;
|
||||
|
||||
if (!response.ok && data && typeof data === "object") {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error("Ungültige Server-Antwort.");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function normalizeDomainInput(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getStatusLabel(status: DomainCheckStatus): string {
|
||||
if (status === "available") {
|
||||
return "Frei";
|
||||
}
|
||||
|
||||
if (status === "registered") {
|
||||
return "Belegt";
|
||||
}
|
||||
|
||||
if (status === "invalid") {
|
||||
return "Ungültig";
|
||||
}
|
||||
|
||||
if (status === "unknown") {
|
||||
return "Unklar";
|
||||
}
|
||||
|
||||
if (status === "loading") {
|
||||
return "Prüft";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function getStatusTitle(status: DomainCheckStatus, result: DomainCheckResult | null): string {
|
||||
if (result?.message) {
|
||||
return result.message;
|
||||
}
|
||||
|
||||
if (status === "available") {
|
||||
return "Domain ist frei.";
|
||||
}
|
||||
|
||||
if (status === "registered") {
|
||||
return "Domain ist bereits registriert.";
|
||||
}
|
||||
|
||||
if (status === "invalid") {
|
||||
return "Domain ist ungültig.";
|
||||
}
|
||||
|
||||
if (status === "unknown") {
|
||||
return "Status konnte nicht eindeutig geprüft werden.";
|
||||
}
|
||||
|
||||
if (status === "loading") {
|
||||
return "Domain wird geprüft.";
|
||||
}
|
||||
|
||||
return "Noch nicht geprüft.";
|
||||
}
|
||||
|
||||
export default function DomainCheckWidget() {
|
||||
const [domain, setDomain] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<DomainCheckResult | null>(null);
|
||||
|
||||
async function checkDomain(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
const cleanDomain = normalizeDomainInput(domain);
|
||||
|
||||
if (!cleanDomain) {
|
||||
setResult({
|
||||
status: "invalid",
|
||||
message: "Bitte eine Domain eingeben."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/domain-check?domain=${encodeURIComponent(cleanDomain)}`, {
|
||||
cache: "no-store"
|
||||
});
|
||||
|
||||
const data = await parseJsonResponse<DomainCheckResult>(response);
|
||||
|
||||
setResult(data);
|
||||
} catch (error) {
|
||||
setResult({
|
||||
status: "unknown",
|
||||
message: error instanceof Error ? error.message : "Domainprüfung fehlgeschlagen."
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const status: DomainCheckStatus = loading ? "loading" : result?.status ?? "idle";
|
||||
const statusLabel = getStatusLabel(status);
|
||||
const statusTitle = getStatusTitle(status, result);
|
||||
|
||||
return (
|
||||
<form className="domainCheckWidget widgetNoDrag" onSubmit={checkDomain}>
|
||||
<input
|
||||
className="input domainCheckInput"
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
placeholder="example.com"
|
||||
inputMode="url"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
aria-label="Domain"
|
||||
/>
|
||||
|
||||
<span
|
||||
className={`domainCheckStatus domainCheckStatus-${status}`}
|
||||
title={statusTitle}
|
||||
aria-label={statusTitle}
|
||||
>
|
||||
<span className="domainCheckStatusDot" aria-hidden="true" />
|
||||
{statusLabel ? <span className="domainCheckStatusText">{statusLabel}</span> : null}
|
||||
</span>
|
||||
|
||||
<button type="submit" className="button domainCheckButton" disabled={loading}>
|
||||
{loading ? "..." : "Prüfen"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
|
||||
type FavoriteLink = {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
iconUrl: string | null;
|
||||
position: number;
|
||||
};
|
||||
|
||||
type FavoritesWidgetProps = {
|
||||
widgetId: string;
|
||||
editMode: boolean;
|
||||
viewMode?: "list" | "grid";
|
||||
onViewModeChange?: (viewMode: "list" | "grid") => void;
|
||||
};
|
||||
|
||||
async function parseJsonResponse<T>(response: Response): Promise<T> {
|
||||
const data = (await response.json().catch(() => null)) as T | null;
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage =
|
||||
data && typeof data === "object" && "error" in data && typeof data.error === "string"
|
||||
? data.error
|
||||
: "Anfrage fehlgeschlagen.";
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error("Ungültige Server-Antwort.");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function createClientId(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function getFavoriteInitial(title: string): string {
|
||||
const cleanTitle = title.trim();
|
||||
|
||||
if (!cleanTitle) {
|
||||
return "?";
|
||||
}
|
||||
|
||||
return cleanTitle.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
function getFallbackFaviconUrl(url: string): string | null {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
return new URL("/favicon.ico", parsedUrl.origin).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getFavoriteIconUrl(favorite: FavoriteLink): string | null {
|
||||
return favorite.iconUrl || getFallbackFaviconUrl(favorite.url);
|
||||
}
|
||||
|
||||
function PencilIcon() {
|
||||
return (
|
||||
<svg className="favoriteActionIcon" viewBox="0 0 24 24" 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"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TrashIcon() {
|
||||
return (
|
||||
<svg className="favoriteActionIcon" viewBox="0 0 24 24" 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"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FavoritesWidget({
|
||||
widgetId,
|
||||
editMode,
|
||||
viewMode = "list",
|
||||
onViewModeChange
|
||||
}: FavoritesWidgetProps) {
|
||||
const [favorites, setFavorites] = useState<FavoriteLink[]>([]);
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
const [newUrl, setNewUrl] = useState("");
|
||||
const [newIconUrl, setNewIconUrl] = useState("");
|
||||
const [favoriteError, setFavoriteError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [draggingFavoriteId, setDraggingFavoriteId] = useState<string | null>(null);
|
||||
|
||||
const [editingFavoriteId, setEditingFavoriteId] = useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const [editUrl, setEditUrl] = useState("");
|
||||
const [editIconUrl, setEditIconUrl] = useState("");
|
||||
|
||||
const sortedFavorites = useMemo(() => {
|
||||
return [...favorites].sort((a, b) => {
|
||||
if (a.position !== b.position) {
|
||||
return a.position - b.position;
|
||||
}
|
||||
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
}, [favorites]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFavorites();
|
||||
}, [widgetId]);
|
||||
|
||||
async function loadFavorites() {
|
||||
setLoading(true);
|
||||
setFavoriteError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/favorites?widgetId=${encodeURIComponent(widgetId)}`, {
|
||||
cache: "no-store"
|
||||
});
|
||||
|
||||
const data = await parseJsonResponse<{ favorites: FavoriteLink[] }>(response);
|
||||
|
||||
setFavorites(data.favorites);
|
||||
} catch (error) {
|
||||
setFavoriteError(error instanceof Error ? error.message : "Favoriten konnten nicht geladen werden.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function addFavorite(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setFavoriteError(null);
|
||||
|
||||
const cleanTitle = newTitle.trim();
|
||||
const cleanUrl = newUrl.trim();
|
||||
const cleanIconUrl = newIconUrl.trim();
|
||||
|
||||
if (!cleanTitle || !cleanUrl) {
|
||||
setFavoriteError("Titel und URL sind erforderlich.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/favorites", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
widgetId,
|
||||
title: cleanTitle,
|
||||
url: cleanUrl,
|
||||
iconUrl: cleanIconUrl || undefined
|
||||
})
|
||||
});
|
||||
|
||||
const data = await parseJsonResponse<{ favorite: FavoriteLink }>(response);
|
||||
|
||||
setFavorites((current) => [...current, data.favorite]);
|
||||
setNewTitle("");
|
||||
setNewUrl("");
|
||||
setNewIconUrl("");
|
||||
} catch (error) {
|
||||
setFavoriteError(error instanceof Error ? error.message : "Favorit konnte nicht gespeichert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFavorite(favoriteId: string) {
|
||||
setFavoriteError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/favorites/${encodeURIComponent(favoriteId)}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
|
||||
throw new Error(data?.error ?? "Favorit konnte nicht gelöscht werden.");
|
||||
}
|
||||
|
||||
setFavorites((current) => current.filter((favorite) => favorite.id !== favoriteId));
|
||||
|
||||
if (editingFavoriteId === favoriteId) {
|
||||
cancelEditingFavorite();
|
||||
}
|
||||
} catch (error) {
|
||||
setFavoriteError(error instanceof Error ? error.message : "Favorit konnte nicht gelöscht werden.");
|
||||
}
|
||||
}
|
||||
|
||||
function startEditingFavorite(favorite: FavoriteLink) {
|
||||
setEditingFavoriteId(favorite.id);
|
||||
setEditTitle(favorite.title);
|
||||
setEditUrl(favorite.url);
|
||||
setEditIconUrl(favorite.iconUrl ?? "");
|
||||
setFavoriteError(null);
|
||||
}
|
||||
|
||||
function cancelEditingFavorite() {
|
||||
setEditingFavoriteId(null);
|
||||
setEditTitle("");
|
||||
setEditUrl("");
|
||||
setEditIconUrl("");
|
||||
}
|
||||
|
||||
async function saveEditedFavorite(favoriteId: string) {
|
||||
const cleanTitle = editTitle.trim();
|
||||
const cleanUrl = editUrl.trim();
|
||||
const cleanIconUrl = editIconUrl.trim();
|
||||
|
||||
if (!cleanTitle || !cleanUrl) {
|
||||
setFavoriteError("Titel und URL sind erforderlich.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/favorites/${encodeURIComponent(favoriteId)}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: cleanTitle,
|
||||
url: cleanUrl,
|
||||
iconUrl: cleanIconUrl || null
|
||||
})
|
||||
});
|
||||
|
||||
const data = await parseJsonResponse<{ favorite: FavoriteLink }>(response);
|
||||
|
||||
setFavorites((current) => current.map((favorite) => (favorite.id === favoriteId ? data.favorite : favorite)));
|
||||
cancelEditingFavorite();
|
||||
} catch (error) {
|
||||
setFavoriteError(error instanceof Error ? error.message : "Favorit konnte nicht gespeichert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
async function persistFavoriteOrder(nextFavorites: FavoriteLink[]) {
|
||||
try {
|
||||
await Promise.all(
|
||||
nextFavorites.map((favorite, index) =>
|
||||
fetch(`/api/favorites/${encodeURIComponent(favorite.id)}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
position: index
|
||||
})
|
||||
}).then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Favoriten-Reihenfolge konnte nicht gespeichert werden.");
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
setFavoriteError(error instanceof Error ? error.message : "Favoriten-Reihenfolge konnte nicht gespeichert werden.");
|
||||
void loadFavorites();
|
||||
}
|
||||
}
|
||||
|
||||
function reorderFavorites(sourceId: string, targetId: string) {
|
||||
if (sourceId === targetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const orderedFavorites = [...sortedFavorites];
|
||||
const sourceIndex = orderedFavorites.findIndex((favorite) => favorite.id === sourceId);
|
||||
const targetIndex = orderedFavorites.findIndex((favorite) => favorite.id === targetId);
|
||||
|
||||
if (sourceIndex < 0 || targetIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [movedFavorite] = orderedFavorites.splice(sourceIndex, 1);
|
||||
orderedFavorites.splice(targetIndex, 0, movedFavorite);
|
||||
|
||||
const nextFavorites = orderedFavorites.map((favorite, index) => ({
|
||||
...favorite,
|
||||
position: index
|
||||
}));
|
||||
|
||||
setFavorites(nextFavorites);
|
||||
void persistFavoriteOrder(nextFavorites);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={viewMode === "grid" ? "favoritesWidget favoritesWidgetGridMode" : "favoritesWidget"}>
|
||||
{editMode ? (
|
||||
<div className="favoriteViewModeToggle widgetNoDrag" aria-label="Favoriten-Darstellung">
|
||||
<button
|
||||
type="button"
|
||||
className={viewMode === "list" ? "favoriteViewModeButton favoriteViewModeButtonActive" : "favoriteViewModeButton"}
|
||||
onClick={() => onViewModeChange?.("list")}
|
||||
>
|
||||
Liste
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={viewMode === "grid" ? "favoriteViewModeButton favoriteViewModeButtonActive" : "favoriteViewModeButton"}
|
||||
onClick={() => onViewModeChange?.("grid")}
|
||||
>
|
||||
Kacheln
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? <p className="muted">Favoriten werden geladen...</p> : null}
|
||||
{favoriteError ? <p className="errorText">{favoriteError}</p> : null}
|
||||
|
||||
<div className="favoriteTileList">
|
||||
{sortedFavorites.length === 0 && !loading ? <p className="muted">Noch keine Favoriten.</p> : null}
|
||||
|
||||
{sortedFavorites.map((favorite) => {
|
||||
const iconUrl = getFavoriteIconUrl(favorite);
|
||||
const isEditing = editingFavoriteId === favorite.id;
|
||||
const dragKey = createClientId();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"favoriteSortableItem",
|
||||
draggingFavoriteId === favorite.id ? "favoriteSortableItemDragging" : "",
|
||||
isEditing ? "favoriteSortableItemEditing" : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
key={favorite.id}
|
||||
draggable={editMode && !isEditing}
|
||||
onDragStart={(event) => {
|
||||
setDraggingFavoriteId(favorite.id);
|
||||
event.dataTransfer.setData("text/plain", favorite.id);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (!editMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (!editMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const sourceId = event.dataTransfer.getData("text/plain");
|
||||
|
||||
if (sourceId) {
|
||||
reorderFavorites(sourceId, favorite.id);
|
||||
}
|
||||
|
||||
setDraggingFavoriteId(null);
|
||||
}}
|
||||
onDragEnd={() => setDraggingFavoriteId(null)}
|
||||
data-drag-key={dragKey}
|
||||
>
|
||||
<a className="favoriteTile widgetNoDrag" href={favorite.url} target="_blank" rel="noreferrer">
|
||||
<div className="favoriteIcon">
|
||||
<span className="favoriteIconFallback">{getFavoriteInitial(favorite.title)}</span>
|
||||
|
||||
{iconUrl ? (
|
||||
<img
|
||||
className="favoriteIconImage"
|
||||
src={iconUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<span className="favoriteTitle">{favorite.title}</span>
|
||||
</a>
|
||||
|
||||
{editMode ? (
|
||||
<div className="favoriteItemActions widgetNoDrag">
|
||||
<button
|
||||
type="button"
|
||||
className="favoriteEditButton"
|
||||
onClick={() => startEditingFavorite(favorite)}
|
||||
aria-label="Favorit bearbeiten"
|
||||
title="Favorit bearbeiten"
|
||||
>
|
||||
<PencilIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="favoriteDeleteButton"
|
||||
onClick={() => void removeFavorite(favorite.id)}
|
||||
aria-label="Favorit löschen"
|
||||
title="Favorit löschen"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{editMode && isEditing ? (
|
||||
<div className="favoriteInlineEditForm widgetNoDrag">
|
||||
<input
|
||||
className="input"
|
||||
value={editTitle}
|
||||
onChange={(event) => setEditTitle(event.target.value)}
|
||||
placeholder="Titel"
|
||||
/>
|
||||
|
||||
<input
|
||||
className="input"
|
||||
value={editUrl}
|
||||
onChange={(event) => setEditUrl(event.target.value)}
|
||||
placeholder="URL"
|
||||
/>
|
||||
|
||||
<input
|
||||
className="input"
|
||||
value={editIconUrl}
|
||||
onChange={(event) => setEditIconUrl(event.target.value)}
|
||||
placeholder="Logo-Bild-URL optional"
|
||||
/>
|
||||
|
||||
<div className="favoriteInlineEditActions">
|
||||
<button type="button" className="button buttonSecondary" onClick={() => void saveEditedFavorite(favorite.id)}>
|
||||
Speichern
|
||||
</button>
|
||||
|
||||
<button type="button" className="button buttonSecondary" onClick={cancelEditingFavorite}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{editMode ? (
|
||||
<form className="favoriteAddForm widgetNoDrag" onSubmit={addFavorite}>
|
||||
<input className="input" value={newTitle} onChange={(event) => setNewTitle(event.target.value)} placeholder="Titel" />
|
||||
|
||||
<input className="input" value={newUrl} onChange={(event) => setNewUrl(event.target.value)} placeholder="URL" />
|
||||
|
||||
<input
|
||||
className="input"
|
||||
value={newIconUrl}
|
||||
onChange={(event) => setNewIconUrl(event.target.value)}
|
||||
placeholder="Logo-Bild-URL optional"
|
||||
/>
|
||||
|
||||
<button type="submit" className="button">
|
||||
Hinzufügen
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user