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:
Claude
2026-06-18 10:02:05 +02:00
commit a4051ae132
74 changed files with 18317 additions and 0 deletions
+80
View File
@@ -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;
}
+186
View File
@@ -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;
}
}
+434
View File
@@ -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>
);
}
+87
View File
@@ -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);
}
+219
View File
@@ -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>
);
}
+150
View File
@@ -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>
);
}
+161
View File
@@ -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>
);
}
+482
View File
@@ -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>
);
}